@@ -111,8 +111,23 @@ def find_code(
111
111
Internally calls: ast-grep run --pattern <pattern> [--json] <project_folder>
112
112
113
113
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)".
116
131
117
132
Example usage:
118
133
find_code(pattern="class $NAME", max_results=20) # Returns text format
@@ -125,33 +140,24 @@ def find_code(
125
140
if language :
126
141
args .extend (["--lang" , language ])
127
142
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]
155
161
156
162
@mcp .tool ()
157
163
def find_code_by_rule (
@@ -170,8 +176,23 @@ def find_code_by_rule(
170
176
Internally calls: ast-grep scan --inline-rules <yaml> [--json] <project_folder>
171
177
172
178
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)".
175
196
176
197
Example usage:
177
198
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(
182
203
183
204
args = ["--inline-rules" , yaml ]
184
205
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 } "
199
244
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 )
212
250
213
251
def run_command (args : List [str ], input_text : Optional [str ] = None ) -> subprocess .CompletedProcess :
214
252
try :
0 commit comments