Skip to content

Commit 7b2721b

Browse files
committed
fix: max_results can result in returning incomplete matches
1 parent 51e5f61 commit 7b2721b

File tree

5 files changed

+210
-76
lines changed

5 files changed

+210
-76
lines changed

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ jobs:
6363
6464
- name: Run unit tests
6565
run: |
66-
uv run pytest tests/test_main.py -v --cov=main --cov-report=term-missing
66+
uv run pytest tests/test_unit.py -v --cov=main --cov-report=term-missing
6767
6868
- name: Run integration tests
6969
run: |

README.md

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,9 +132,23 @@ Test ast-grep YAML rules against code snippets before applying them to larger co
132132
Search codebases using simple ast-grep patterns for straightforward structural matches.
133133

134134
**Parameters:**
135-
- `max_results`: Limit number of results (default: unlimited)
135+
- `max_results`: Limit number of complete matches returned (default: unlimited)
136136
- `output_format`: Choose between `"text"` (default, ~75% fewer tokens) or `"json"` (full metadata)
137137

138+
**Text Output Format:**
139+
```
140+
Found 2 matches:
141+
142+
path/to/file.py:10-15
143+
def example_function():
144+
# function body
145+
return result
146+
147+
path/to/file.py:20-22
148+
def another_function():
149+
pass
150+
```
151+
138152
**Use cases:**
139153
- Find function calls with specific patterns
140154
- Locate variable declarations
@@ -144,7 +158,7 @@ Search codebases using simple ast-grep patterns for straightforward structural m
144158
Advanced codebase search using complex YAML rules that can express sophisticated matching criteria.
145159

146160
**Parameters:**
147-
- `max_results`: Limit number of results (default: unlimited)
161+
- `max_results`: Limit number of complete matches returned (default: unlimited)
148162
- `output_format`: Choose between `"text"` (default, ~75% fewer tokens) or `"json"` (full metadata)
149163

150164
**Use cases:**

main.py

Lines changed: 95 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -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()
157163
def 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

213251
def run_command(args: List[str], input_text: Optional[str] = None) -> subprocess.CompletedProcess:
214252
try:

tests/test_integration.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Integration tests for ast-grep MCP server"""
22

3+
import json
34
import os
45
import sys
56
from unittest.mock import Mock, patch
@@ -79,9 +80,14 @@ def test_find_code_json_format(self, fixtures_dir):
7980
@patch("main.run_ast_grep")
8081
def test_find_code_by_rule(self, mock_run, fixtures_dir):
8182
"""Test find_code_by_rule with mocked ast-grep"""
82-
# Mock the response
83+
# Mock the response with JSON format (since we always use JSON internally)
8384
mock_result = Mock()
84-
mock_result.stdout = "fixtures/example.py:7:class Calculator:"
85+
mock_matches = [{
86+
"text": "class Calculator:\n pass",
87+
"file": "fixtures/example.py",
88+
"range": {"start": {"line": 6}, "end": {"line": 7}}
89+
}]
90+
mock_result.stdout = json.dumps(mock_matches)
8591
mock_run.return_value = mock_result
8692

8793
yaml_rule = """id: test
@@ -95,10 +101,11 @@ def test_find_code_by_rule(self, mock_run, fixtures_dir):
95101

96102
assert "Calculator" in result
97103
assert "Found 1 match" in result
104+
assert "fixtures/example.py:7-8" in result
98105

99106
# Verify the command was called correctly
100107
mock_run.assert_called_once_with(
101-
"scan", ["--inline-rules", yaml_rule, fixtures_dir]
108+
"scan", ["--inline-rules", yaml_rule, "--json", fixtures_dir]
102109
)
103110

104111
def test_find_code_with_max_results(self, fixtures_dir):
@@ -111,7 +118,8 @@ def test_find_code_with_max_results(self, fixtures_dir):
111118
output_format="text",
112119
)
113120

114-
assert "limited to 1" in result
121+
# The new format says "showing first X of Y" instead of "limited to X"
122+
assert "showing first 1 of" in result or "Found 1 match" in result
115123
# Should only have one match in the output
116124
assert result.count("def ") == 1
117125

0 commit comments

Comments
 (0)