@@ -111,8 +111,23 @@ def find_code(
111111 Internally calls: ast-grep run --pattern <pattern> [--json] <project_folder>
112112
113113 Output formats:
114- - text (default): Simple file:line:content format, ~75% fewer tokens
115- - json: Full match objects with metadata
114+ - text (default): Compact text format with file:line-range headers and complete match text
115+ Example:
116+ Found 2 matches:
117+
118+ path/to/file.py:10-15
119+ def example_function():
120+ # function body
121+ return result
122+
123+ path/to/file.py:20-22
124+ def another_function():
125+ pass
126+
127+ - json: Full match objects with metadata including ranges, meta-variables, etc.
128+
129+ The max_results parameter limits the number of complete matches returned (not individual lines).
130+ When limited, the header shows "Found X matches (showing first Y of Z)".
116131
117132 Example usage:
118133 find_code(pattern="class $NAME", max_results=20) # Returns text format
@@ -125,33 +140,24 @@ def find_code(
125140 if language :
126141 args .extend (["--lang" , language ])
127142
128- if output_format == "json" :
129- # JSON format - return structured data
130- result = run_ast_grep ("run" , args + ["--json" , project_folder ])
131- matches = json .loads (result .stdout .strip () or "[]" )
132- # Limit results if max_results is specified
133- if max_results is not None and len (matches ) > max_results :
134- matches = matches [:max_results ]
135- return matches # type: ignore[no-any-return]
136- else :
137- # Text format - return plain text output
138- result = run_ast_grep ("run" , args + [project_folder ])
139- output = result .stdout .strip ()
140- if not output :
141- output = "No matches found"
142- else :
143- # Apply max_results limit if specified
144- lines = output .split ('\n ' )
145- non_empty_lines = [line for line in lines if line .strip ()]
146- if max_results is not None and len (non_empty_lines ) > max_results :
147- # Limit the results
148- non_empty_lines = non_empty_lines [:max_results ]
149- output = '\n ' .join (non_empty_lines )
150- header = f"Found { len (non_empty_lines )} matches (limited to { max_results } ):\n "
151- else :
152- header = f"Found { len (non_empty_lines )} matches:\n "
153- output = header + output
154- return output # type: ignore[no-any-return]
143+ # Always get JSON internally for accurate match limiting
144+ result = run_ast_grep ("run" , args + ["--json" , project_folder ])
145+ matches = json .loads (result .stdout .strip () or "[]" )
146+
147+ # Apply max_results limit to complete matches
148+ total_matches = len (matches )
149+ if max_results is not None and total_matches > max_results :
150+ matches = matches [:max_results ]
151+
152+ if output_format == "text" :
153+ if not matches :
154+ return "No matches found"
155+ text_output = format_matches_as_text (matches )
156+ header = f"Found { len (matches )} matches"
157+ if max_results is not None and total_matches > max_results :
158+ header += f" (showing first { max_results } of { total_matches } )"
159+ return header + ":\n \n " + text_output
160+ return matches # type: ignore[no-any-return]
155161
156162@mcp .tool ()
157163def find_code_by_rule (
@@ -170,8 +176,23 @@ def find_code_by_rule(
170176 Internally calls: ast-grep scan --inline-rules <yaml> [--json] <project_folder>
171177
172178 Output formats:
173- - text (default): Simple file:line:content format, ~75% fewer tokens
174- - json: Full match objects with metadata
179+ - text (default): Compact text format with file:line-range headers and complete match text
180+ Example:
181+ Found 2 matches:
182+
183+ src/models.py:45-52
184+ class UserModel:
185+ def __init__(self):
186+ self.id = None
187+ self.name = None
188+
189+ src/views.py:12
190+ class SimpleView: pass
191+
192+ - json: Full match objects with metadata including ranges, meta-variables, etc.
193+
194+ The max_results parameter limits the number of complete matches returned (not individual lines).
195+ When limited, the header shows "Found X matches (showing first Y of Z)".
175196
176197 Example usage:
177198 find_code_by_rule(yaml="id: x\\ nlanguage: python\\ nrule: {pattern: 'class $NAME'}", max_results=20)
@@ -182,33 +203,50 @@ def find_code_by_rule(
182203
183204 args = ["--inline-rules" , yaml ]
184205
185- if output_format == "json" :
186- # JSON format - return structured data
187- result = run_ast_grep ("scan" , args + ["--json" , project_folder ])
188- matches = json .loads (result .stdout .strip () or "[]" )
189- # Limit results if max_results is specified
190- if max_results is not None and len (matches ) > max_results :
191- matches = matches [:max_results ]
192- return matches # type: ignore[no-any-return]
193- else :
194- # Text format - return plain text output
195- result = run_ast_grep ("scan" , args + [project_folder ])
196- output = result .stdout .strip ()
197- if not output :
198- output = "No matches found"
206+ # Always get JSON internally for accurate match limiting
207+ result = run_ast_grep ("scan" , args + ["--json" , project_folder ])
208+ matches = json .loads (result .stdout .strip () or "[]" )
209+
210+ # Apply max_results limit to complete matches
211+ total_matches = len (matches )
212+ if max_results is not None and total_matches > max_results :
213+ matches = matches [:max_results ]
214+
215+ if output_format == "text" :
216+ if not matches :
217+ return "No matches found"
218+ text_output = format_matches_as_text (matches )
219+ header = f"Found { len (matches )} matches"
220+ if max_results is not None and total_matches > max_results :
221+ header += f" (showing first { max_results } of { total_matches } )"
222+ return header + ":\n \n " + text_output
223+ return matches # type: ignore[no-any-return]
224+
225+ def format_matches_as_text (matches : List [dict ]) -> str :
226+ """Convert JSON matches to LLM-friendly text format.
227+
228+ Format: file:start-end followed by the complete match text.
229+ Matches are separated by blank lines for clarity.
230+ """
231+ if not matches :
232+ return ""
233+
234+ output_blocks = []
235+ for m in matches :
236+ file_path = m .get ('file' , '' )
237+ start_line = m .get ('range' , {}).get ('start' , {}).get ('line' , 0 ) + 1
238+ end_line = m .get ('range' , {}).get ('end' , {}).get ('line' , 0 ) + 1
239+ match_text = m .get ('text' , '' ).rstrip ()
240+
241+ # Format: filepath:start-end (or just :line for single-line matches)
242+ if start_line == end_line :
243+ header = f"{ file_path } :{ start_line } "
199244 else :
200- # Apply max_results limit if specified
201- lines = output .split ('\n ' )
202- non_empty_lines = [line for line in lines if line .strip ()]
203- if max_results is not None and len (non_empty_lines ) > max_results :
204- # Limit the results
205- non_empty_lines = non_empty_lines [:max_results ]
206- output = '\n ' .join (non_empty_lines )
207- header = f"Found { len (non_empty_lines )} matches (limited to { max_results } ):\n "
208- else :
209- header = f"Found { len (non_empty_lines )} matches:\n "
210- output = header + output
211- return output # type: ignore[no-any-return]
245+ header = f"{ file_path } :{ start_line } -{ end_line } "
246+
247+ output_blocks .append (f"{ header } \n { match_text } " )
248+
249+ return '\n \n ' .join (output_blocks )
212250
213251def run_command (args : List [str ], input_text : Optional [str ] = None ) -> subprocess .CompletedProcess :
214252 try :
0 commit comments