22import difflib
33import sys
44from pathlib import Path
5- from typing import List , Optional , Tuple
5+ from typing import List , Optional , Tuple , Any
66
7- from ast_grep_py .ast_grep_py import SgRoot
7+ from ast_grep_py import SgRoot
8+ from ast_grep_py .ast_grep_py import SgRoot , SgNode
89
910from awsclilinter import linter
1011from awsclilinter .linter import parse
2728CONTEXT_SIZE = 3
2829
2930
30- def color_text (text , color_code ):
31+ def _color_text (text , color_code ):
3132 """Colorize text for terminal output only if a TTY is available."""
3233 if sys .stdout .isatty ():
3334 return f"{ color_code } { text } { RESET } "
3435 return text
3536
3637
37- def prompt_user_choice_interactive_mode (auto_fixable : bool = True ) -> str :
38+ def _prompt_user_choice_interactive_mode (auto_fixable : bool = True ) -> str :
3839 """Get user input for interactive mode."""
3940 while True :
4041 if auto_fixable :
@@ -56,7 +57,7 @@ def prompt_user_choice_interactive_mode(auto_fixable: bool = True) -> str:
5657 print ("Invalid choice. Please enter n, s, or q." )
5758
5859
59- def display_finding (finding : LintFinding , index : int , script_content : str ):
60+ def _display_finding (finding : LintFinding , index : int , script_content : str ):
6061 """Display a finding to the user with context."""
6162 if finding .auto_fixable :
6263 # Apply the edit to get the fixed content
@@ -79,13 +80,13 @@ def display_finding(finding: LintFinding, index: int, script_content: str):
7980 elif line_num == 2 :
8081 # The 3rd line is the context control line.
8182 print ("\n " )
82- print (color_text (line , CYAN ))
83+ print (_color_text (line , CYAN ))
8384 elif line .startswith ("-" ):
8485 # Removed line
85- print (color_text (line , RED ))
86+ print (_color_text (line , RED ))
8687 elif line .startswith ("+" ):
8788 # Added line
88- print (color_text (line , GREEN ))
89+ print (_color_text (line , GREEN ))
8990 else :
9091 # Context (unchanged) lines always start with whitespace.
9192 print (line )
@@ -97,42 +98,31 @@ def display_finding(finding: LintFinding, index: int, script_content: str):
9798 context_start = max (0 , start_line - CONTEXT_SIZE )
9899 context_end = min (len (src_lines ), end_line + CONTEXT_SIZE + 1 )
99100
100- manual_tag = color_text ("[MANUAL REVIEW REQUIRED]" , YELLOW )
101+ manual_tag = _color_text ("[MANUAL REVIEW REQUIRED]" , YELLOW )
101102 print (f"\n [{ index } ] { finding .rule_name } { manual_tag } " )
102103 print (f"{ finding .description } \n " )
103104
104- print (color_text (f"Lines { context_start + 1 } -{ context_end + 1 } " , CYAN ))
105+ print (_color_text (f"Lines { context_start + 1 } -{ context_end + 1 } " , CYAN ))
105106 for i in range (context_start , context_end ):
106107 line = src_lines [i ]
107108 if start_line <= i <= end_line :
108- print (f"{ color_text (line , YELLOW )} " )
109+ print (f"{ _color_text (line , YELLOW )} " )
109110 else :
110111 print (f"{ line } " )
111112
112- warning_msg = color_text (
113+ warning_msg = _color_text (
113114 f"⚠️ This issue requires manual intervention. "
114115 f"Suggested action: { finding .suggested_manual_fix } " ,
115116 YELLOW ,
116117 )
117118 print (f"\n { warning_msg } " )
118119
119120
120- def auto_fix_mode (
121+ def _lint_and_fix_script (
121122 rules : List [LintRule ],
122- script_content : str ,
123- output_path : Path ,
124- ):
125- """Handler for auto-fix mode. Lints the input script based on the input rules list. If
126- any findings were detected that can be automatically fixed, applies the automatic
127- fixes to the script and writes it to the output path.
128-
129- Args:
130- rules: List of findings and their rules.
131- script_content: Current script represented as an AST.
132- output_path: The path to write the updated script if any findings were detected.
133- """
134- current_ast = parse (script_content )
135-
123+ script_ast : SgRoot ,
124+ ) -> tuple [SgRoot , int , list [tuple [LintFinding , LintRule ]]]:
125+ current_ast = script_ast
136126 findings_found = 0
137127 num_auto_fixable_findings = 0
138128 non_auto_fixable = []
@@ -154,6 +144,27 @@ def auto_fix_mode(
154144 continue
155145
156146 current_ast = parse (linter .apply_fixes (current_ast , auto_fixable_findings ))
147+ return current_ast , num_auto_fixable_findings , non_auto_fixable
148+
149+
150+ def auto_fix_mode (
151+ rules : List [LintRule ],
152+ script_content : str ,
153+ output_path : Path ,
154+ ):
155+ """Handler for auto-fix mode. Lints the input script based on the input rules list. If
156+ any findings were detected that can be automatically fixed, applies the automatic
157+ fixes to the script and writes it to the output path.
158+
159+ Args:
160+ rules: List of linting rules to run against the input script.
161+ script_content: Current script represented as an AST.
162+ output_path: The path to write the updated script if any findings were detected.
163+ """
164+ current_ast , num_auto_fixable_findings , non_auto_fixable = _lint_and_fix_script (
165+ rules ,
166+ parse (script_content )
167+ )
157168
158169 if num_auto_fixable_findings :
159170 output_path .write_text (current_ast .root ().text ())
@@ -164,12 +175,83 @@ def auto_fix_mode(
164175
165176 # If there were findings that need manual review, display them last.
166177 if non_auto_fixable :
167- warning_header = color_text (
178+ warning_header = _color_text (
168179 f"⚠️ { len (non_auto_fixable )} issue(s) require manual review:" , YELLOW
169180 )
170181 print (f"\n { warning_header } \n " )
171182 for i , (finding , _ ) in enumerate (non_auto_fixable , 1 ):
172- display_finding (finding , i , script_content )
183+ _display_finding (finding , i , script_content )
184+
185+
186+ def dry_run_mode (
187+ rules : List [LintRule ],
188+ script_content : str ,
189+ script_path : Path ,
190+ ):
191+ """Handler for dry-run mode. Lints the input script based on the input rules list and
192+ prints the diff to stdout.
193+
194+ Args:
195+ rules: List of linting rules to run against the input script.
196+ script_content: The input script.
197+ script_path: Path to the script being linted.
198+ """
199+ current_ast , num_auto_fixable_findings , non_auto_fixable = _lint_and_fix_script (
200+ rules ,
201+ parse (script_content )
202+ )
203+
204+ if not num_auto_fixable_findings and not non_auto_fixable :
205+ print ("No issues found." )
206+ return
207+
208+ print (f"\n Found { num_auto_fixable_findings + len (non_auto_fixable )} issue(s):" )
209+ if num_auto_fixable_findings and non_auto_fixable :
210+ print (f" - { num_auto_fixable_findings } can be automatically fixed" )
211+ print (f" - { len (non_auto_fixable )} require manual review" )
212+ print ()
213+
214+ diff = difflib .unified_diff (
215+ script_content .splitlines (),
216+ current_ast .root ().text ().splitlines (),
217+ n = CONTEXT_SIZE ,
218+ lineterm = "" ,
219+ )
220+ for line_num , line in enumerate (diff ):
221+ if line_num == 0 :
222+ # First line is always '--- '
223+ print (f"{ line } a/{ script_path } " )
224+ elif line_num == 1 :
225+ # Second line is always '+++ '
226+ print (f"{ line } b/{ script_path } " )
227+ elif line .startswith ("@" ):
228+ # Context control line.
229+ print (f"\n { _color_text (line , CYAN )} " )
230+ elif line .startswith ("-" ):
231+ # Removed line
232+ print (_color_text (line , RED ))
233+ elif line .startswith ("+" ):
234+ # Added line
235+ print (_color_text (line , GREEN ))
236+ else :
237+ # Context (unchanged) lines always start with whitespace.
238+ print (line )
239+
240+ if non_auto_fixable :
241+ warning_header = _color_text (
242+ f"⚠️ { len (non_auto_fixable )} issue(s) require manual review:" , YELLOW
243+ )
244+ print (f"\n { warning_header } \n " )
245+ for i , (finding , _ ) in enumerate (non_auto_fixable , 1 ):
246+ _display_finding (finding , i , script_content )
247+
248+ if num_auto_fixable_findings :
249+ print (
250+ "\n \n Run with `--fix` to apply automatic fixes to the script, "
251+ "or `--output PATH` to write the modified script to a specific path."
252+ )
253+ else :
254+ print ("\n \n All issues require manual review." )
173255
174256
175257def interactive_mode_for_rule (
@@ -194,11 +276,11 @@ def interactive_mode_for_rule(
194276 last_choice : Optional [str ] = None
195277
196278 for i , finding in enumerate (findings ):
197- display_finding (finding , finding_offset + i + 1 , ast .root ().text ())
279+ _display_finding (finding , finding_offset + i + 1 , ast .root ().text ())
198280
199281 if not finding .auto_fixable :
200282 # Non-fixable finding - only allow next, save, or quit
201- last_choice = prompt_user_choice_interactive_mode (auto_fixable = False )
283+ last_choice = _prompt_user_choice_interactive_mode (auto_fixable = False )
202284 if last_choice == "q" :
203285 print ("Quit without saving." )
204286 sys .exit (0 )
@@ -210,7 +292,7 @@ def interactive_mode_for_rule(
210292 # 'n' means continue to next finding
211293 continue
212294
213- last_choice = prompt_user_choice_interactive_mode (auto_fixable = True )
295+ last_choice = _prompt_user_choice_interactive_mode (auto_fixable = True )
214296
215297 if last_choice == "y" :
216298 accepted_findings .append (finding )
@@ -341,31 +423,7 @@ def main():
341423 elif args .fix or args .output :
342424 auto_fix_mode (rules , script_content , Path (args .output ) if args .output else script_path )
343425 else :
344- current_ast = parse (script_content )
345- findings_with_rules = linter .lint (current_ast , rules )
346-
347- if not findings_with_rules :
348- print ("No issues found." )
349- return
350-
351- fixable = [(f , r ) for f , r in findings_with_rules if f .auto_fixable ]
352- non_fixable = [(f , r ) for f , r in findings_with_rules if not f .auto_fixable ]
353-
354- print (f"\n Found { len (findings_with_rules )} issue(s):" )
355- if fixable and non_fixable :
356- print (f" - { len (fixable )} can be automatically fixed" )
357- print (f" - { len (non_fixable )} require manual review" )
358- print ()
359-
360- for i , (finding , _ ) in enumerate (findings_with_rules , 1 ):
361- display_finding (finding , i , script_content )
362-
363- if fixable :
364- print ("\n \n Run with --fix to apply automatic fixes" )
365- if non_fixable :
366- print ("Non-fixable issues will be shown for manual review" )
367- else :
368- print ("\n \n All issues require manual review" )
426+ dry_run_mode (rules , script_content , script_path )
369427
370428
371429if __name__ == "__main__" :
0 commit comments