tree-sitter-analyzer 1.2.3__py3-none-any.whl → 1.2.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of tree-sitter-analyzer might be problematic. Click here for more details.

@@ -185,16 +185,18 @@ class JavaElementExtractor(ElementExtractor):
185
185
  """Fallback import extraction using regex when tree-sitter fails"""
186
186
  imports = []
187
187
  lines = source_code.split("\n")
188
-
188
+
189
189
  for line_num, line in enumerate(lines, 1):
190
190
  line = line.strip()
191
191
  if line.startswith("import ") and line.endswith(";"):
192
192
  # Extract import statement
193
193
  import_content = line[:-1] # Remove semicolon
194
-
194
+
195
195
  if "static" in import_content:
196
196
  # Static import
197
- static_match = re.search(r"import\s+static\s+([\w.]+)", import_content)
197
+ static_match = re.search(
198
+ r"import\s+static\s+([\w.]+)", import_content
199
+ )
198
200
  if static_match:
199
201
  import_name = static_match.group(1)
200
202
  if import_content.endswith(".*"):
@@ -202,18 +204,20 @@ class JavaElementExtractor(ElementExtractor):
202
204
  parts = import_name.split(".")
203
205
  if len(parts) > 1:
204
206
  import_name = ".".join(parts[:-1])
205
-
206
- imports.append(Import(
207
- name=import_name,
208
- start_line=line_num,
209
- end_line=line_num,
210
- raw_text=line,
211
- language="java",
212
- module_name=import_name,
213
- is_static=True,
214
- is_wildcard=import_content.endswith(".*"),
215
- import_statement=import_content,
216
- ))
207
+
208
+ imports.append(
209
+ Import(
210
+ name=import_name,
211
+ start_line=line_num,
212
+ end_line=line_num,
213
+ raw_text=line,
214
+ language="java",
215
+ module_name=import_name,
216
+ is_static=True,
217
+ is_wildcard=import_content.endswith(".*"),
218
+ import_statement=import_content,
219
+ )
220
+ )
217
221
  else:
218
222
  # Normal import
219
223
  normal_match = re.search(r"import\s+([\w.]+)", import_content)
@@ -224,19 +228,21 @@ class JavaElementExtractor(ElementExtractor):
224
228
  import_name = import_name[:-2]
225
229
  elif import_name.endswith("."):
226
230
  import_name = import_name[:-1]
227
-
228
- imports.append(Import(
229
- name=import_name,
230
- start_line=line_num,
231
- end_line=line_num,
232
- raw_text=line,
233
- language="java",
234
- module_name=import_name,
235
- is_static=False,
236
- is_wildcard=import_content.endswith(".*"),
237
- import_statement=import_content,
238
- ))
239
-
231
+
232
+ imports.append(
233
+ Import(
234
+ name=import_name,
235
+ start_line=line_num,
236
+ end_line=line_num,
237
+ raw_text=line,
238
+ language="java",
239
+ module_name=import_name,
240
+ is_static=False,
241
+ is_wildcard=import_content.endswith(".*"),
242
+ import_statement=import_content,
243
+ )
244
+ )
245
+
240
246
  return imports
241
247
 
242
248
  def extract_packages(
@@ -60,8 +60,11 @@ from ..utils import setup_logger
60
60
  from . import MCP_INFO
61
61
  from .resources import CodeFileResource, ProjectStatsResource
62
62
  from .tools.analyze_scale_tool import AnalyzeScaleTool
63
+ from .tools.find_and_grep_tool import FindAndGrepTool
64
+ from .tools.list_files_tool import ListFilesTool
63
65
  from .tools.query_tool import QueryTool
64
66
  from .tools.read_partial_tool import ReadPartialTool
67
+ from .tools.search_content_tool import SearchContentTool
65
68
  from .tools.table_format_tool import TableFormatTool
66
69
 
67
70
  # Set up logging
@@ -87,11 +90,15 @@ class TreeSitterAnalyzerMCPServer:
87
90
  self.security_validator = SecurityValidator(project_root)
88
91
  # Use unified analysis engine instead of deprecated AdvancedAnalyzer
89
92
 
90
- # Initialize MCP tools with security validation (four core tools)
93
+ # Initialize MCP tools with security validation (core tools + fd/rg tools)
91
94
  self.query_tool = QueryTool(project_root) # query_code
92
95
  self.read_partial_tool = ReadPartialTool(project_root) # extract_code_section
93
96
  self.table_format_tool = TableFormatTool(project_root) # analyze_code_structure
94
97
  self.analyze_scale_tool = AnalyzeScaleTool(project_root) # check_code_scale
98
+ # New fd/rg tools
99
+ self.list_files_tool = ListFilesTool(project_root) # list_files
100
+ self.search_content_tool = SearchContentTool(project_root) # search_content
101
+ self.find_and_grep_tool = FindAndGrepTool(project_root) # find_and_grep
95
102
 
96
103
  # Optional universal tool to satisfy initialization tests
97
104
  try:
@@ -466,6 +473,9 @@ class TreeSitterAnalyzerMCPServer:
466
473
  },
467
474
  ),
468
475
  Tool(**self.query_tool.get_tool_definition()),
476
+ Tool(**self.list_files_tool.get_tool_definition()),
477
+ Tool(**self.search_content_tool.get_tool_definition()),
478
+ Tool(**self.find_and_grep_tool.get_tool_definition()),
469
479
  ]
470
480
 
471
481
  logger.info(f"Returning {len(tools)} tools: {[t.name for t in tools]}")
@@ -545,6 +555,15 @@ class TreeSitterAnalyzerMCPServer:
545
555
  elif name == "query_code":
546
556
  result = await self.query_tool.execute(arguments)
547
557
 
558
+ elif name == "list_files":
559
+ result = await self.list_files_tool.execute(arguments)
560
+
561
+ elif name == "search_content":
562
+ result = await self.search_content_tool.execute(arguments)
563
+
564
+ elif name == "find_and_grep":
565
+ result = await self.find_and_grep_tool.execute(arguments)
566
+
548
567
  else:
549
568
  raise ValueError(f"Unknown tool: {name}")
550
569
 
@@ -653,6 +672,9 @@ class TreeSitterAnalyzerMCPServer:
653
672
  self.read_partial_tool.set_project_path(project_path)
654
673
  self.table_format_tool.set_project_path(project_path)
655
674
  self.analyze_scale_tool.set_project_path(project_path)
675
+ self.list_files_tool.set_project_path(project_path)
676
+ self.search_content_tool.set_project_path(project_path)
677
+ self.find_and_grep_tool.set_project_path(project_path)
656
678
 
657
679
  # Update universal tool if available
658
680
  if hasattr(self, "universal_analyze_tool") and self.universal_analyze_tool:
@@ -0,0 +1,549 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Shared utilities for fd/ripgrep based MCP tools.
4
+
5
+ This module centralizes subprocess execution, command building, result caps,
6
+ and JSON line parsing for ripgrep.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import json
13
+ import os
14
+ import tempfile
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ # Safety caps (hard limits)
20
+ MAX_RESULTS_HARD_CAP = 10000
21
+ DEFAULT_RESULTS_LIMIT = 2000
22
+
23
+ DEFAULT_RG_MAX_FILESIZE = "10M"
24
+ RG_MAX_FILESIZE_HARD_CAP_BYTES = 200 * 1024 * 1024 # 200M
25
+
26
+ DEFAULT_RG_TIMEOUT_MS = 4000
27
+ RG_TIMEOUT_HARD_CAP_MS = 30000
28
+
29
+
30
+ def clamp_int(value: int | None, default_value: int, hard_cap: int) -> int:
31
+ if value is None:
32
+ return default_value
33
+ try:
34
+ v = int(value)
35
+ except (TypeError, ValueError):
36
+ return default_value
37
+ return max(0, min(v, hard_cap))
38
+
39
+
40
+ def parse_size_to_bytes(size_str: str) -> int | None:
41
+ """Parse ripgrep --max-filesize strings like '10M', '200K' to bytes."""
42
+ if not size_str:
43
+ return None
44
+ s = size_str.strip().upper()
45
+ try:
46
+ if s.endswith("K"):
47
+ return int(float(s[:-1]) * 1024)
48
+ if s.endswith("M"):
49
+ return int(float(s[:-1]) * 1024 * 1024)
50
+ if s.endswith("G"):
51
+ return int(float(s[:-1]) * 1024 * 1024 * 1024)
52
+ return int(s)
53
+ except ValueError:
54
+ return None
55
+
56
+
57
+ async def run_command_capture(
58
+ cmd: list[str],
59
+ input_data: bytes | None = None,
60
+ timeout_ms: int | None = None,
61
+ ) -> tuple[int, bytes, bytes]:
62
+ """Run a subprocess and capture output.
63
+
64
+ Returns (returncode, stdout, stderr). On timeout, kills process and returns 124.
65
+ Separated into a util for easy monkeypatching in tests.
66
+ """
67
+ # Create process
68
+ proc = await asyncio.create_subprocess_exec(
69
+ *cmd,
70
+ stdin=asyncio.subprocess.PIPE if input_data is not None else None,
71
+ stdout=asyncio.subprocess.PIPE,
72
+ stderr=asyncio.subprocess.PIPE,
73
+ )
74
+
75
+ # Compute timeout seconds
76
+ timeout_s: float | None = None
77
+ if timeout_ms and timeout_ms > 0:
78
+ timeout_s = timeout_ms / 1000.0
79
+
80
+ try:
81
+ stdout, stderr = await asyncio.wait_for(
82
+ proc.communicate(input=input_data), timeout=timeout_s
83
+ )
84
+ return proc.returncode, stdout, stderr
85
+ except asyncio.TimeoutError:
86
+ try:
87
+ proc.kill()
88
+ finally:
89
+ with contextlib.suppress(Exception):
90
+ await proc.wait()
91
+ return 124, b"", f"Timeout after {timeout_ms} ms".encode()
92
+
93
+
94
+ def build_fd_command(
95
+ *,
96
+ pattern: str | None,
97
+ glob: bool,
98
+ types: list[str] | None,
99
+ extensions: list[str] | None,
100
+ exclude: list[str] | None,
101
+ depth: int | None,
102
+ follow_symlinks: bool,
103
+ hidden: bool,
104
+ no_ignore: bool,
105
+ size: list[str] | None,
106
+ changed_within: str | None,
107
+ changed_before: str | None,
108
+ full_path_match: bool,
109
+ absolute: bool,
110
+ limit: int | None,
111
+ roots: list[str],
112
+ ) -> list[str]:
113
+ """Build an fd command with appropriate flags."""
114
+ cmd: list[str] = ["fd", "--color", "never"]
115
+ if glob:
116
+ cmd.append("--glob")
117
+ if full_path_match:
118
+ cmd.append("-p")
119
+ if absolute:
120
+ cmd.append("-a")
121
+ if follow_symlinks:
122
+ cmd.append("-L")
123
+ if hidden:
124
+ cmd.append("-H")
125
+ if no_ignore:
126
+ cmd.append("-I")
127
+ if depth is not None:
128
+ cmd += ["-d", str(depth)]
129
+ if types:
130
+ for t in types:
131
+ cmd += ["-t", str(t)]
132
+ if extensions:
133
+ for ext in extensions:
134
+ if ext.startswith("."):
135
+ ext = ext[1:]
136
+ cmd += ["-e", ext]
137
+ if exclude:
138
+ for ex in exclude:
139
+ cmd += ["-E", ex]
140
+ if size:
141
+ for s in size:
142
+ cmd += ["-S", s]
143
+ if changed_within:
144
+ cmd += ["--changed-within", str(changed_within)]
145
+ if changed_before:
146
+ cmd += ["--changed-before", str(changed_before)]
147
+ if limit is not None:
148
+ cmd += ["--max-results", str(limit)]
149
+
150
+ # Add search paths using --search-path option for better reliability
151
+ # This avoids conflicts between pattern and path arguments
152
+ if roots:
153
+ for root in roots:
154
+ cmd += ["--search-path", root]
155
+
156
+ # Pattern goes last if specified
157
+ if pattern:
158
+ cmd.append(pattern)
159
+
160
+ return cmd
161
+
162
+
163
+ def normalize_max_filesize(user_value: str | None) -> str:
164
+ if not user_value:
165
+ return DEFAULT_RG_MAX_FILESIZE
166
+ bytes_val = parse_size_to_bytes(user_value)
167
+ if bytes_val is None:
168
+ return DEFAULT_RG_MAX_FILESIZE
169
+ if bytes_val > RG_MAX_FILESIZE_HARD_CAP_BYTES:
170
+ return "200M"
171
+ return user_value
172
+
173
+
174
+ def build_rg_command(
175
+ *,
176
+ query: str,
177
+ case: str | None,
178
+ fixed_strings: bool,
179
+ word: bool,
180
+ multiline: bool,
181
+ include_globs: list[str] | None,
182
+ exclude_globs: list[str] | None,
183
+ follow_symlinks: bool,
184
+ hidden: bool,
185
+ no_ignore: bool,
186
+ max_filesize: str | None,
187
+ context_before: int | None,
188
+ context_after: int | None,
189
+ encoding: str | None,
190
+ max_count: int | None,
191
+ timeout_ms: int | None,
192
+ roots: list[str] | None,
193
+ files_from: str | None,
194
+ count_only_matches: bool = False,
195
+ ) -> list[str]:
196
+ """Build ripgrep command with JSON output and options."""
197
+ if count_only_matches:
198
+ # Use --count-matches for count-only mode (no JSON output)
199
+ cmd: list[str] = [
200
+ "rg",
201
+ "--count-matches",
202
+ "--no-heading",
203
+ "--color",
204
+ "never",
205
+ ]
206
+ else:
207
+ # Use --json for full match details
208
+ cmd: list[str] = [
209
+ "rg",
210
+ "--json",
211
+ "--no-heading",
212
+ "--color",
213
+ "never",
214
+ ]
215
+
216
+ # Case sensitivity
217
+ if case == "smart":
218
+ cmd.append("-S")
219
+ elif case == "insensitive":
220
+ cmd.append("-i")
221
+ elif case == "sensitive":
222
+ cmd.append("-s")
223
+
224
+ if fixed_strings:
225
+ cmd.append("-F")
226
+ if word:
227
+ cmd.append("-w")
228
+ if multiline:
229
+ # Prefer --multiline (does not imply binary)
230
+ cmd.append("--multiline")
231
+
232
+ if follow_symlinks:
233
+ cmd.append("-L")
234
+ if hidden:
235
+ cmd.append("-H")
236
+ if no_ignore:
237
+ # Use -u (respect ignore but include hidden); do not escalate to -uu automatically
238
+ cmd.append("-u")
239
+
240
+ if include_globs:
241
+ for g in include_globs:
242
+ cmd += ["-g", g]
243
+ if exclude_globs:
244
+ for g in exclude_globs:
245
+ # ripgrep exclusion via !pattern
246
+ if not g.startswith("!"):
247
+ cmd += ["-g", f"!{g}"]
248
+ else:
249
+ cmd += ["-g", g]
250
+
251
+ if context_before is not None:
252
+ cmd += ["-B", str(context_before)]
253
+ if context_after is not None:
254
+ cmd += ["-A", str(context_after)]
255
+ if encoding:
256
+ cmd += ["--encoding", encoding]
257
+ if max_count is not None:
258
+ cmd += ["-m", str(max_count)]
259
+
260
+ # Normalize filesize
261
+ cmd += ["--max-filesize", normalize_max_filesize(max_filesize)]
262
+
263
+ # Only add timeout if supported (check if timeout_ms is provided and > 0)
264
+ # Note: --timeout flag may not be available in all ripgrep versions
265
+ # For now, we'll skip the timeout flag to ensure compatibility
266
+ # effective_timeout = clamp_int(timeout_ms, DEFAULT_RG_TIMEOUT_MS, RG_TIMEOUT_HARD_CAP_MS)
267
+ # cmd += ["--timeout", str(effective_timeout)]
268
+
269
+ # Query must be last before roots/files
270
+ cmd.append(query)
271
+
272
+ # Skip --files-from flag as it's not supported in this ripgrep version
273
+ # Use roots instead for compatibility
274
+ if roots:
275
+ cmd += roots
276
+ # Note: files_from functionality is disabled for compatibility
277
+
278
+ return cmd
279
+
280
+
281
+ def parse_rg_json_lines_to_matches(stdout_bytes: bytes) -> list[dict[str, Any]]:
282
+ """Parse ripgrep JSON event stream and keep only match events."""
283
+ results: list[dict[str, Any]] = []
284
+ for raw_line in stdout_bytes.splitlines():
285
+ if not raw_line.strip():
286
+ continue
287
+ try:
288
+ evt = json.loads(raw_line.decode("utf-8", errors="replace"))
289
+ except (json.JSONDecodeError, UnicodeDecodeError): # nosec B112
290
+ continue
291
+ if evt.get("type") != "match":
292
+ continue
293
+ data = evt.get("data", {})
294
+ path_text = (data.get("path", {}) or {}).get("text")
295
+ line_number = data.get("line_number")
296
+ line_text = (data.get("lines", {}) or {}).get("text")
297
+ submatches_raw = data.get("submatches", []) or []
298
+ # Normalize line content to reduce token usage
299
+ normalized_line = " ".join(line_text.split()) if line_text else ""
300
+
301
+ # Simplify submatches - remove redundant match text, keep only positions
302
+ simplified_matches = []
303
+ for sm in submatches_raw:
304
+ start = sm.get("start")
305
+ end = sm.get("end")
306
+ if start is not None and end is not None:
307
+ simplified_matches.append([start, end])
308
+
309
+ results.append(
310
+ {
311
+ "file": path_text,
312
+ "line": line_number, # Shortened field name
313
+ "text": normalized_line, # Normalized content
314
+ "matches": simplified_matches, # Simplified match positions
315
+ }
316
+ )
317
+ return results
318
+
319
+
320
+ def group_matches_by_file(matches: list[dict[str, Any]]) -> dict[str, Any]:
321
+ """Group matches by file to eliminate file path duplication."""
322
+ if not matches:
323
+ return {"success": True, "count": 0, "files": []}
324
+
325
+ # Group matches by file
326
+ file_groups: dict[str, list[dict[str, Any]]] = {}
327
+ total_matches = 0
328
+
329
+ for match in matches:
330
+ file_path = match.get("file", "unknown")
331
+ if file_path not in file_groups:
332
+ file_groups[file_path] = []
333
+
334
+ # Create match entry without file path
335
+ match_entry = {
336
+ "line": match.get("line", match.get("line_number", "?")),
337
+ "text": match.get("text", match.get("line", "")),
338
+ "positions": match.get("matches", match.get("submatches", [])),
339
+ }
340
+ file_groups[file_path].append(match_entry)
341
+ total_matches += 1
342
+
343
+ # Convert to grouped structure
344
+ files = []
345
+ for file_path, file_matches in file_groups.items():
346
+ files.append({"file": file_path, "matches": file_matches})
347
+
348
+ return {"success": True, "count": total_matches, "files": files}
349
+
350
+
351
+ def optimize_match_paths(matches: list[dict[str, Any]]) -> list[dict[str, Any]]:
352
+ """Optimize file paths in match results to reduce token consumption."""
353
+ if not matches:
354
+ return matches
355
+
356
+ # Find common prefix among all file paths
357
+ file_paths = [match.get("file", "") for match in matches if match.get("file")]
358
+ common_prefix = ""
359
+ if len(file_paths) > 1:
360
+ import os
361
+
362
+ try:
363
+ common_prefix = os.path.commonpath(file_paths)
364
+ except (ValueError, TypeError):
365
+ common_prefix = ""
366
+
367
+ # Optimize each match
368
+ optimized_matches = []
369
+ for match in matches:
370
+ optimized_match = match.copy()
371
+ file_path = match.get("file")
372
+ if file_path:
373
+ optimized_match["file"] = _optimize_file_path(file_path, common_prefix)
374
+ optimized_matches.append(optimized_match)
375
+
376
+ return optimized_matches
377
+
378
+
379
+ def _optimize_file_path(file_path: str, common_prefix: str = "") -> str:
380
+ """Optimize file path for token efficiency by removing common prefixes and shortening."""
381
+ if not file_path:
382
+ return file_path
383
+
384
+ # Remove common prefix if provided
385
+ if common_prefix and file_path.startswith(common_prefix):
386
+ optimized = file_path[len(common_prefix) :].lstrip("/\\")
387
+ if optimized:
388
+ return optimized
389
+
390
+ # For very long paths, show only the last few components
391
+ from pathlib import Path
392
+
393
+ path_obj = Path(file_path)
394
+ parts = path_obj.parts
395
+
396
+ if len(parts) > 4:
397
+ # Show first part + ... + last 3 parts
398
+ return str(Path(parts[0]) / "..." / Path(*parts[-3:]))
399
+
400
+ return file_path
401
+
402
+
403
+ def summarize_search_results(
404
+ matches: list[dict[str, Any]], max_files: int = 10, max_total_lines: int = 50
405
+ ) -> dict[str, Any]:
406
+ """Summarize search results to reduce context size while preserving key information."""
407
+ if not matches:
408
+ return {
409
+ "total_matches": 0,
410
+ "total_files": 0,
411
+ "summary": "No matches found",
412
+ "top_files": [],
413
+ }
414
+
415
+ # Group matches by file and find common prefix for optimization
416
+ file_groups: dict[str, list[dict[str, Any]]] = {}
417
+ all_file_paths = []
418
+ for match in matches:
419
+ file_path = match.get("file", "unknown")
420
+ all_file_paths.append(file_path)
421
+ if file_path not in file_groups:
422
+ file_groups[file_path] = []
423
+ file_groups[file_path].append(match)
424
+
425
+ # Find common prefix to optimize paths
426
+ common_prefix = ""
427
+ if len(all_file_paths) > 1:
428
+ import os
429
+
430
+ common_prefix = os.path.commonpath(all_file_paths) if all_file_paths else ""
431
+
432
+ # Sort files by match count (descending)
433
+ sorted_files = sorted(file_groups.items(), key=lambda x: len(x[1]), reverse=True)
434
+
435
+ # Create summary
436
+ total_matches = len(matches)
437
+ total_files = len(file_groups)
438
+
439
+ # Top files with match counts
440
+ top_files = []
441
+ remaining_lines = max_total_lines
442
+
443
+ for file_path, file_matches in sorted_files[:max_files]:
444
+ match_count = len(file_matches)
445
+
446
+ # Include a few sample lines from this file
447
+ sample_lines = []
448
+ lines_to_include = min(3, remaining_lines, len(file_matches))
449
+
450
+ for _i, match in enumerate(file_matches[:lines_to_include]):
451
+ line_num = match.get(
452
+ "line", match.get("line_number", "?")
453
+ ) # Support both old and new format
454
+ line_text = match.get(
455
+ "text", match.get("line", "")
456
+ ).strip() # Support both old and new format
457
+ if line_text:
458
+ # Truncate long lines and remove extra whitespace to save tokens
459
+ truncated_line = " ".join(line_text.split())[:60]
460
+ if len(line_text) > 60:
461
+ truncated_line += "..."
462
+ sample_lines.append(f"L{line_num}: {truncated_line}")
463
+ remaining_lines -= 1
464
+
465
+ # Optimize file path for token efficiency
466
+ optimized_path = _optimize_file_path(file_path, common_prefix)
467
+
468
+ top_files.append(
469
+ {
470
+ "file": optimized_path,
471
+ "match_count": match_count,
472
+ "sample_lines": sample_lines,
473
+ }
474
+ )
475
+
476
+ if remaining_lines <= 0:
477
+ break
478
+
479
+ # Create summary text
480
+ if total_files <= max_files:
481
+ summary = f"Found {total_matches} matches in {total_files} files"
482
+ else:
483
+ summary = f"Found {total_matches} matches in {total_files} files (showing top {len(top_files)})"
484
+
485
+ return {
486
+ "total_matches": total_matches,
487
+ "total_files": total_files,
488
+ "summary": summary,
489
+ "top_files": top_files,
490
+ "truncated": total_files > max_files,
491
+ }
492
+
493
+
494
+ def parse_rg_count_output(stdout_bytes: bytes) -> dict[str, int]:
495
+ """Parse ripgrep --count-matches output and return file->count mapping."""
496
+ results: dict[str, int] = {}
497
+ total_matches = 0
498
+
499
+ for line in stdout_bytes.decode("utf-8", errors="replace").splitlines():
500
+ line = line.strip()
501
+ if not line:
502
+ continue
503
+
504
+ # Format: "file_path:count"
505
+ if ":" in line:
506
+ file_path, count_str = line.rsplit(":", 1)
507
+ try:
508
+ count = int(count_str)
509
+ results[file_path] = count
510
+ total_matches += count
511
+ except ValueError:
512
+ # Skip lines that don't have valid count format
513
+ continue
514
+
515
+ # Add total count as special key
516
+ results["__total__"] = total_matches
517
+ return results
518
+
519
+
520
+ @dataclass
521
+ class TempFileList:
522
+ path: str
523
+
524
+ def __enter__(self) -> TempFileList:
525
+ return self
526
+
527
+ def __exit__(self, exc_type, exc, tb) -> None:
528
+ with contextlib.suppress(Exception):
529
+ Path(self.path).unlink(missing_ok=True)
530
+
531
+
532
+ class contextlib: # minimal shim for suppress without importing globally
533
+ class suppress:
534
+ def __init__(self, *exceptions: type[BaseException]) -> None:
535
+ self.exceptions = exceptions
536
+
537
+ def __enter__(self) -> None: # noqa: D401
538
+ return None
539
+
540
+ def __exit__(self, exc_type, exc, tb) -> bool:
541
+ return exc_type is not None and issubclass(exc_type, self.exceptions)
542
+
543
+
544
+ def write_files_to_temp(files: list[str]) -> TempFileList:
545
+ fd, temp_path = tempfile.mkstemp(prefix="rg-files-", suffix=".lst")
546
+ os.close(fd)
547
+ content = "\n".join(files)
548
+ Path(temp_path).write_text(content, encoding="utf-8")
549
+ return TempFileList(path=temp_path)