Skip to content

Commit 7a35f38

Browse files
Fix iterator macro parameter variable interpretation (#47)
* Fix: Function parameters with iterator macros are not CLI args Co-authored-by: blakeinvictoria <blakeinvictoria@gmail.com> * Bump version to 0.5.3 and fix CLI argument parsing Co-authored-by: blakeinvictoria <blakeinvictoria@gmail.com> * Fix: Remove unused CLI args from macro test Co-authored-by: blakeinvictoria <blakeinvictoria@gmail.com> * Refactor: Improve positional parameter analysis and macro handling Add exclude_function_params to parse_positional_usages to prevent false positives. Update analyzer orders and add variable type extraction for macro processing. Co-authored-by: blakeinvictoria <blakeinvictoria@gmail.com> * Refactor: Adjust analyzer order for environment and positional variables Co-authored-by: blakeinvictoria <blakeinvictoria@gmail.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent f50e733 commit 7a35f38

7 files changed

Lines changed: 206 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313

1414
## [Unreleased]
1515

16+
## [0.5.3] - 2025-01-28
17+
1618
### Fixed
19+
- Fixed function parameters being incorrectly treated as CLI arguments when used with iterator macros
20+
- Function parameters (`$1`, `$2`, etc.) within functions that have iterator macros are now properly excluded from CLI argument generation
21+
- The macro-generated loop provides these parameters instead of requiring them as CLI arguments
22+
- This fixes the bug where scripts with iterator macros would incorrectly require `--1`, `--2`, etc. arguments
1723
- Fixed default value parsing in Google-style annotations
1824
- Empty default values (e.g., `Default:` or `Default: `) are now properly handled as no default
1925
- Descriptions ending with periods (e.g., `# VAR (str): Description.`) are now correctly parsed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "argorator"
7-
version = "0.5.2"
7+
version = "0.5.3"
88
description = "CLI to wrap shell scripts and expose variables/positionals as argparse options"
99
readme = "README.md"
1010
requires-python = ">=3.9"

src/argorator/analyzers.py

Lines changed: 64 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -109,9 +109,13 @@ def parse_variable_usages(script_text: str) -> Set[str]:
109109
return {name for name in candidates if name and name not in SPECIAL_VARS}
110110

111111

112-
def parse_positional_usages(script_text: str) -> Tuple[Set[int], bool]:
112+
def parse_positional_usages(script_text: str, exclude_function_params: Optional[Set[str]] = None) -> Tuple[Set[int], bool]:
113113
"""Extract positional parameter indices and varargs usage from script.
114114
115+
Args:
116+
script_text: The script content
117+
exclude_function_params: Set of function parameter indices to exclude (e.g., {'1', '2'})
118+
115119
Returns:
116120
Tuple of (positional_indices, varargs_present)
117121
"""
@@ -121,9 +125,34 @@ def parse_positional_usages(script_text: str) -> Tuple[Set[int], bool]:
121125
indices = {int(m) for m in digit_pattern.findall(script_text)}
122126
varargs = bool(varargs_pattern.search(script_text))
123127

128+
# Exclude function parameters that are used with iterator macros
129+
if exclude_function_params:
130+
indices = {idx for idx in indices if str(idx) not in exclude_function_params}
131+
124132
return indices, varargs
125133

126134

135+
def _extract_function_parameters(script_text: str, function_target) -> Set[str]:
136+
"""Extract function parameter variables used within a function that has an iteration macro.
137+
138+
Args:
139+
script_text: The full script content
140+
function_target: The MacroTarget representing the function
141+
142+
Returns:
143+
Set of function parameter variable names (e.g., {'1', '2', '3'})
144+
"""
145+
lines = script_text.split('\n')
146+
function_lines = lines[function_target.start_line:function_target.end_line + 1]
147+
function_content = '\n'.join(function_lines)
148+
149+
# Find all positional parameter usages within the function
150+
digit_pattern = re.compile(r"\$([1-9][0-9]*)")
151+
param_indices = {m for m in digit_pattern.findall(function_content)}
152+
153+
return param_indices
154+
155+
127156
@analyzer(order=20)
128157
def analyze_variable_usages(context: AnalysisContext) -> None:
129158
"""Find all variables referenced in the script."""
@@ -142,15 +171,28 @@ def analyze_loop_variables(context: AnalysisContext) -> None:
142171
context.loop_vars = parse_loop_variables(context.script_text)
143172

144173

145-
@analyzer(order=21.5)
174+
@analyzer(order=45)
146175
def identify_macro_iterator_variables(context: AnalysisContext) -> None:
147176
"""Identify iterator variables from iteration macros to exclude from undefined variables."""
148177
try:
149178
from .macros.parser import macro_parser
179+
from .macros.processor import macro_processor
180+
181+
# Extract variable types from annotations and pass to macro processor
182+
variable_types = {}
183+
184+
# Get types from argument annotations (if available)
185+
if context.annotations:
186+
for var_name, annotation in context.annotations.items():
187+
variable_types[var_name] = annotation.type
188+
189+
# Set variable types in macro processor
190+
macro_processor.set_variable_types(variable_types)
150191

151192
# Find all iteration macro comments
152193
macro_comments = macro_parser.find_macro_comments(context.script_text)
153194
iterator_vars = set()
195+
function_param_vars = set()
154196

155197
for comment in macro_comments:
156198
if comment.macro_type == 'iteration':
@@ -161,30 +203,39 @@ def identify_macro_iterator_variables(context: AnalysisContext) -> None:
161203
if target:
162204
iteration_macro = macro_parser.parse_iteration_macro(comment, target)
163205
iterator_vars.add(iteration_macro.iterator_var)
206+
207+
# If this macro targets a function, identify function parameters used within that function
208+
if target.target_type == 'function':
209+
function_params = _extract_function_parameters(context.script_text, target)
210+
function_param_vars.update(function_params)
211+
164212
except Exception:
165213
# If parsing fails, continue with other macros
166214
pass
167215

168-
# Store iterator variables in temp_data for use in next analyzer
216+
# Store iterator variables and function parameter variables in temp_data for use in next analyzer
169217
context.temp_data['macro_iterator_vars'] = iterator_vars
218+
context.temp_data['macro_function_param_vars'] = function_param_vars
170219

171220
except ImportError:
172221
# If macro modules aren't available, skip this step
173222
context.temp_data['macro_iterator_vars'] = set()
223+
context.temp_data['macro_function_param_vars'] = set()
174224

175225

176-
@analyzer(order=22)
226+
@analyzer(order=46)
177227
def analyze_undefined_variables(context: AnalysisContext) -> None:
178228
"""Identify variables that are used but not defined in the script."""
179-
# Get iterator variables identified by macro analysis
229+
# Get iterator variables and function parameter variables identified by macro analysis
180230
macro_iterator_vars = context.temp_data.get('macro_iterator_vars', set())
231+
macro_function_param_vars = context.temp_data.get('macro_function_param_vars', set())
181232

182-
# Exclude iterator variables and loop variables from undefined variables
183-
undefined_vars = context.all_used_vars - context.defined_vars - macro_iterator_vars - context.loop_vars
233+
# Exclude iterator variables, function parameter variables, and loop variables from undefined variables
234+
undefined_vars = context.all_used_vars - context.defined_vars - macro_iterator_vars - macro_function_param_vars - context.loop_vars
184235
context.undefined_vars = {name: None for name in sorted(undefined_vars)}
185236

186237

187-
@analyzer(order=23)
238+
@analyzer(order=47)
188239
def analyze_environment_variables(context: AnalysisContext) -> None:
189240
"""Separate undefined variables into those with environment defaults and truly undefined."""
190241
env_vars: Dict[str, str] = {}
@@ -200,10 +251,13 @@ def analyze_environment_variables(context: AnalysisContext) -> None:
200251
context.undefined_vars = remaining_undefined
201252

202253

203-
@analyzer(order=30)
254+
@analyzer(order=49)
204255
def analyze_positional_parameters(context: AnalysisContext) -> None:
205256
"""Detect positional parameter usage and varargs references in the script."""
206-
indices, varargs = parse_positional_usages(context.script_text)
257+
# Get function parameter variables that are used with iterator macros
258+
macro_function_param_vars = context.temp_data.get('macro_function_param_vars', set())
259+
260+
indices, varargs = parse_positional_usages(context.script_text, exclude_function_params=macro_function_param_vars)
207261
context.positional_indices = indices
208262
context.varargs = varargs
209263

src/argorator/contexts.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,8 @@ def create_transform_context(analysis: AnalysisContext) -> TransformContext:
150150
varargs=analysis.varargs,
151151
annotations=analysis.annotations,
152152
script_metadata=analysis.script_metadata,
153-
script_path=analysis.script_path
153+
script_path=analysis.script_path,
154+
temp_data=analysis.temp_data.copy()
154155
)
155156

156157

src/argorator/transformers.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,13 @@ def add_positional_arguments(context: TransformContext) -> None:
9898
if not context.argument_parser:
9999
raise ValueError("Base parser must be created first")
100100

101-
# Add numbered positional arguments
101+
# Get function parameter variables that are used with iterator macros
102+
macro_function_param_vars = context.temp_data.get('macro_function_param_vars', set())
103+
104+
# Add numbered positional arguments, excluding those used with iterator macros
102105
for index in sorted(context.positional_indices):
103-
context.argument_parser.add_argument(f"ARG{index}")
106+
if str(index) not in macro_function_param_vars:
107+
context.argument_parser.add_argument(f"ARG{index}")
104108

105109
# Add varargs if needed
106110
if context.varargs:
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
"""Tests for the bug fix: function parameters with iterator macros should not require CLI arguments."""
2+
3+
import pytest
4+
from argorator.macros.processor import macro_processor
5+
from argorator.macros.parser import macro_parser
6+
7+
8+
class TestFunctionParameterBugFix:
9+
"""Test that function parameters used with iterator macros don't require CLI arguments."""
10+
11+
def test_function_parameters_with_iterator_macro_excluded_from_cli(self):
12+
"""Test that function parameters are excluded from CLI arguments when used with iterator macros."""
13+
script = '''#!/usr/bin/env argorator
14+
# DATA (str): Input data for processing
15+
16+
# for item in $DATA sep ,
17+
process_item() {
18+
echo "Processing item: $1"
19+
echo "Additional info: $2"
20+
}
21+
'''
22+
23+
# This should work without requiring --1 or --2 arguments
24+
# The macro will provide the parameters to the function
25+
result = macro_processor.process_macros(script)
26+
27+
# Verify that the macro generates the correct loop
28+
assert 'for item in' in result
29+
assert 'process_item "$item"' in result
30+
assert 'done' in result
31+
32+
def test_function_parameters_without_iterator_macro_require_cli(self):
33+
"""Test that function parameters still require CLI arguments when NOT used with iterator macros."""
34+
script = '''#!/usr/bin/env argorator
35+
# DATA (str): Input data for processing
36+
37+
process_item() {
38+
echo "Processing item: $1"
39+
echo "Additional info: $2"
40+
}
41+
42+
# Call the function manually - this should require CLI arguments
43+
process_item "test" "info"
44+
'''
45+
46+
# This should NOT work without providing --1 and --2 arguments
47+
# because there's no iterator macro to provide the parameters
48+
result = macro_processor.process_macros(script)
49+
50+
# The script should remain unchanged (no macros to process)
51+
assert result.strip() == script.strip()
52+
53+
def test_mixed_scenario_function_with_and_without_macro(self):
54+
"""Test a scenario with both functions that have macros and functions that don't."""
55+
script = '''#!/usr/bin/env argorator
56+
# DATA (str): Input data for processing
57+
# OUTPUT_DIR (str): Output directory
58+
59+
# for item in $DATA sep ,
60+
process_item() {
61+
echo "Processing item: $1"
62+
echo "Additional info: $2"
63+
}
64+
65+
# This function has no macro, so it should require CLI arguments
66+
process_other() {
67+
echo "Processing other: $1"
68+
echo "More info: $2"
69+
}
70+
71+
# Call the function without macro manually
72+
process_other "test" "info"
73+
'''
74+
75+
result = macro_processor.process_macros(script)
76+
77+
# Verify that only the function with the macro gets processed
78+
assert 'for item in' in result
79+
assert 'process_item "$item"' in result
80+
assert 'process_other "test" "info"' in result # This should remain unchanged
81+
82+
def test_function_with_additional_parameters_and_macro(self):
83+
"""Test function with additional parameters passed by the macro."""
84+
script = '''#!/usr/bin/env argorator
85+
# DATA (str): Input data for processing
86+
# OUTPUT_DIR (str): Output directory
87+
88+
# for item in $DATA sep , | with $OUTPUT_DIR
89+
process_item() {
90+
local input="$1"
91+
local output_dir="$2"
92+
echo "Processing $input to $output_dir"
93+
}
94+
'''
95+
96+
result = macro_processor.process_macros(script)
97+
98+
# Verify that the macro generates the correct loop with additional parameters
99+
assert 'for item in' in result
100+
assert 'process_item "$item" "$OUTPUT_DIR"' in result
101+
assert 'done' in result
102+
103+
def test_nested_function_parameters_with_macro(self):
104+
"""Test that nested function calls with parameters work correctly with macros."""
105+
script = '''#!/usr/bin/env argorator
106+
# DATA (str): Input data for processing
107+
108+
# for item in $DATA sep ,
109+
process_item() {
110+
echo "Processing item: $1"
111+
helper_function "$1" "extra"
112+
}
113+
114+
helper_function() {
115+
echo "Helper processing: $1 with $2"
116+
}
117+
'''
118+
119+
result = macro_processor.process_macros(script)
120+
121+
# Verify that the macro generates the correct loop
122+
assert 'for item in' in result
123+
assert 'process_item "$item"' in result
124+
assert 'helper_function "$1" "extra"' in result # This should remain unchanged
125+
assert 'done' in result

tests/test_integration_macros.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,8 @@ def test_function_macros_with_separators(self):
188188
}
189189
'''
190190

191-
output = self._run_argorator(script, ['--data', "a'b'c", 'dummy1', 'dummy2'])
191+
# Function parameters $1 and $2 are provided by the macro, not CLI arguments
192+
output = self._run_argorator(script, ['--data', "a'b'c"])
192193
assert 'Processing: a with prefix' in output
193194
assert 'Processing: b with prefix' in output
194195
assert 'Processing: c with prefix' in output

0 commit comments

Comments
 (0)