codegraph-nav 0.1.0__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.
Files changed (41) hide show
  1. codegraph_nav/__init__.py +194 -0
  2. codegraph_nav/ast_grep_analyzer.py +448 -0
  3. codegraph_nav/cli.py +223 -0
  4. codegraph_nav/code_navigator.py +1328 -0
  5. codegraph_nav/code_search.py +1009 -0
  6. codegraph_nav/colors.py +209 -0
  7. codegraph_nav/completions.py +354 -0
  8. codegraph_nav/dart_analyzer.py +301 -0
  9. codegraph_nav/dependency_graph.py +814 -0
  10. codegraph_nav/domain/__init__.py +20 -0
  11. codegraph_nav/domain/routes.py +337 -0
  12. codegraph_nav/domain/schemas.py +229 -0
  13. codegraph_nav/domain/tags.py +87 -0
  14. codegraph_nav/exporters.py +563 -0
  15. codegraph_nav/go_analyzer.py +273 -0
  16. codegraph_nav/graph/__init__.py +72 -0
  17. codegraph_nav/graph/builder.py +409 -0
  18. codegraph_nav/graph/communities.py +402 -0
  19. codegraph_nav/graph/flows.py +311 -0
  20. codegraph_nav/graph/query.py +380 -0
  21. codegraph_nav/graph/schema.py +266 -0
  22. codegraph_nav/graph/search.py +257 -0
  23. codegraph_nav/graph/store.py +517 -0
  24. codegraph_nav/hints.py +195 -0
  25. codegraph_nav/import_resolver.py +891 -0
  26. codegraph_nav/js_ts_analyzer.py +564 -0
  27. codegraph_nav/line_reader.py +664 -0
  28. codegraph_nav/mcp/__init__.py +39 -0
  29. codegraph_nav/mcp/__main__.py +5 -0
  30. codegraph_nav/mcp/server.py +2228 -0
  31. codegraph_nav/py.typed +2 -0
  32. codegraph_nav/ruby_analyzer.py +259 -0
  33. codegraph_nav/rust_analyzer.py +379 -0
  34. codegraph_nav/token_efficient_renderer.py +743 -0
  35. codegraph_nav/watcher.py +382 -0
  36. codegraph_nav-0.1.0.dist-info/METADATA +487 -0
  37. codegraph_nav-0.1.0.dist-info/RECORD +41 -0
  38. codegraph_nav-0.1.0.dist-info/WHEEL +5 -0
  39. codegraph_nav-0.1.0.dist-info/entry_points.txt +4 -0
  40. codegraph_nav-0.1.0.dist-info/licenses/LICENSE +21 -0
  41. codegraph_nav-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1009 @@
1
+ #!/usr/bin/env python3
2
+ """Code Search - Search through the code map to find symbols, files, and locations.
3
+
4
+ This module provides search functionality over a pre-built code map, enabling
5
+ token-efficient navigation by returning only the locations of relevant code
6
+ without reading file contents.
7
+
8
+ Example:
9
+ Command line usage:
10
+ $ code-search "process_payment" --type function
11
+ $ code-search --structure src/api.py
12
+ $ code-search --deps "calculate_total"
13
+
14
+ Python API usage:
15
+ >>> searcher = CodeSearcher('.codegraph.json')
16
+ >>> results = searcher.search_symbol('payment', symbol_type='function')
17
+ >>> for r in results:
18
+ ... print(f"{r.name} in {r.file}:{r.lines}")
19
+ """
20
+
21
+ import argparse
22
+ import json
23
+ import os
24
+ import re
25
+ import sys
26
+ from dataclasses import dataclass
27
+ from difflib import SequenceMatcher
28
+ from pathlib import Path
29
+
30
+ from .colors import get_colors
31
+
32
+ __version__ = "0.1.0"
33
+
34
+ # Pattern to detect catastrophic regex constructs (nested quantifiers)
35
+ _CATASTROPHIC_RE = re.compile(r"\([^)]*[+*]\)[+*]")
36
+
37
+
38
+ def _safe_regex_compile(pattern: str) -> re.Pattern:
39
+ """Compile a regex pattern with validation against ReDoS.
40
+
41
+ Raises:
42
+ ValueError: If the pattern is invalid or contains catastrophic constructs.
43
+ """
44
+ if _CATASTROPHIC_RE.search(pattern):
45
+ raise ValueError(
46
+ f"Regex pattern rejected: contains nested quantifiers "
47
+ f"that could cause ReDoS: {pattern!r}"
48
+ )
49
+ try:
50
+ return re.compile(pattern, re.IGNORECASE)
51
+ except re.error as e:
52
+ raise ValueError(f"Invalid regex pattern: {e}") from None
53
+
54
+
55
+ @dataclass
56
+ class SearchResult:
57
+ """Represents a search result from the code map.
58
+
59
+ Attributes:
60
+ name: Symbol name (e.g., 'process_payment').
61
+ type: Symbol type ('function', 'class', 'method', etc.).
62
+ file: File path relative to project root.
63
+ lines: [start_line, end_line] tuple.
64
+ signature: Function/class signature if available.
65
+ docstring: Truncated docstring if available.
66
+ parent: Parent class name for methods.
67
+ score: Relevance score (0.0 to 1.0).
68
+
69
+ Example:
70
+ >>> result = SearchResult(
71
+ ... name='process_payment',
72
+ ... type='function',
73
+ ... file='src/billing.py',
74
+ ... lines=[45, 89],
75
+ ... score=1.0
76
+ ... )
77
+ >>> print(result.to_dict())
78
+ """
79
+
80
+ name: str
81
+ type: str
82
+ file: str
83
+ lines: list[int]
84
+ signature: str | None = None
85
+ docstring: str | None = None
86
+ parent: str | None = None
87
+ score: float = 0.0
88
+
89
+ def to_dict(self) -> dict:
90
+ """Convert the search result to a dictionary.
91
+
92
+ Returns:
93
+ Dict representation suitable for JSON serialization.
94
+ """
95
+ result = {
96
+ "name": self.name,
97
+ "type": self.type,
98
+ "file": self.file,
99
+ "lines": self.lines,
100
+ "score": round(self.score, 2),
101
+ }
102
+ if self.signature:
103
+ result["signature"] = self.signature
104
+ if self.docstring:
105
+ result["docstring"] = self.docstring
106
+ if self.parent:
107
+ result["parent"] = self.parent
108
+ return result
109
+
110
+
111
+ class CodeSearcher:
112
+ """Search through a code map for symbols and files.
113
+
114
+ Provides various search methods including fuzzy symbol search,
115
+ file pattern matching, dependency analysis, and structure queries.
116
+
117
+ Attributes:
118
+ map_path: Path to the code map JSON file.
119
+ code_map: Loaded code map dictionary.
120
+
121
+ Example:
122
+ >>> searcher = CodeSearcher('.codegraph.json')
123
+ >>> results = searcher.search_symbol('user', symbol_type='class')
124
+ >>> print(f"Found {len(results)} classes matching 'user'")
125
+
126
+ >>> # Get file structure
127
+ >>> structure = searcher.get_file_structure('src/models/user.py')
128
+ >>> print(structure['classes'].keys())
129
+
130
+ >>> # Find dependencies
131
+ >>> deps = searcher.find_dependencies('process_payment')
132
+ >>> print(deps['called_by'])
133
+ """
134
+
135
+ def __init__(self, map_path: str):
136
+ """Initialize the code searcher.
137
+
138
+ Args:
139
+ map_path: Path to the .codegraph.json file.
140
+
141
+ Raises:
142
+ FileNotFoundError: If the code map file doesn't exist.
143
+ """
144
+ self.map_path = map_path
145
+ self.code_map = self._load_map()
146
+
147
+ def _load_map(self) -> dict:
148
+ """Load the code map from file.
149
+
150
+ Returns:
151
+ Parsed code map dictionary.
152
+ """
153
+ with open(self.map_path, encoding="utf-8") as f:
154
+ data: dict = json.load(f)
155
+ return data
156
+
157
+ def _similarity(self, a: str, b: str) -> float:
158
+ """Calculate string similarity ratio.
159
+
160
+ Args:
161
+ a: First string.
162
+ b: Second string.
163
+
164
+ Returns:
165
+ Similarity ratio between 0.0 and 1.0.
166
+ """
167
+ return SequenceMatcher(None, a.lower(), b.lower()).ratio()
168
+
169
+ def search_symbol(
170
+ self,
171
+ query: str,
172
+ symbol_type: str | None = None,
173
+ file_pattern: str | None = None,
174
+ limit: int = 10,
175
+ fuzzy: bool = True,
176
+ ) -> list[SearchResult]:
177
+ """Search for symbols by name.
178
+
179
+ Performs fuzzy matching against the code map index to find functions,
180
+ classes, methods, and other symbols.
181
+
182
+ Args:
183
+ query: Symbol name or pattern to search for.
184
+ symbol_type: Filter by type ('function', 'class', 'method', etc.).
185
+ file_pattern: Regex pattern to filter by file path.
186
+ limit: Maximum results to return.
187
+ fuzzy: Enable fuzzy matching (default: True).
188
+
189
+ Returns:
190
+ List of SearchResult objects sorted by relevance score.
191
+
192
+ Example:
193
+ >>> results = searcher.search_symbol('payment', symbol_type='function')
194
+ >>> for r in results:
195
+ ... print(f"{r.name}: {r.file}:{r.lines[0]}-{r.lines[1]}")
196
+ """
197
+ results = []
198
+ query_lower = query.lower()
199
+
200
+ # Pre-compile file pattern for safety and performance
201
+ file_regex = _safe_regex_compile(file_pattern) if file_pattern else None
202
+
203
+ index = self.code_map.get("index", {})
204
+
205
+ # Direct lookup for exact matches
206
+ if query_lower in index:
207
+ for entry in index[query_lower]:
208
+ if symbol_type and entry["type"] != symbol_type:
209
+ continue
210
+ if file_regex and not file_regex.search(entry["file"]):
211
+ continue
212
+
213
+ file_info = self.code_map["files"].get(entry["file"], {})
214
+ for sym in file_info.get("symbols", []):
215
+ if sym["name"].lower() == query_lower and sym["lines"] == entry["lines"]:
216
+ results.append(
217
+ SearchResult(
218
+ name=sym["name"],
219
+ type=sym["type"],
220
+ file=entry["file"],
221
+ lines=sym["lines"],
222
+ signature=sym.get("signature"),
223
+ docstring=sym.get("docstring"),
224
+ parent=sym.get("parent"),
225
+ score=1.0,
226
+ )
227
+ )
228
+
229
+ # Fuzzy search if enabled and more results needed
230
+ if (not results or fuzzy) and len(results) < limit:
231
+ for file_path, file_info in self.code_map.get("files", {}).items():
232
+ if file_regex and not file_regex.search(file_path):
233
+ continue
234
+
235
+ for sym in file_info.get("symbols", []):
236
+ if symbol_type and sym["type"] != symbol_type:
237
+ continue
238
+
239
+ name_lower = sym["name"].lower()
240
+
241
+ # Skip if already found
242
+ if any(r.name.lower() == name_lower and r.file == file_path for r in results):
243
+ continue
244
+
245
+ # Calculate relevance score
246
+ score = 0.0
247
+
248
+ if name_lower == query_lower:
249
+ score = 1.0
250
+ elif query_lower in name_lower:
251
+ score = 0.7 + (len(query) / len(sym["name"])) * 0.2
252
+ elif name_lower in query_lower:
253
+ score = 0.5
254
+ elif fuzzy:
255
+ sim = self._similarity(query, sym["name"])
256
+ if sim > 0.5:
257
+ score = sim * 0.6
258
+
259
+ # Boost for signature match
260
+ if score > 0 and sym.get("signature"):
261
+ if query_lower in sym["signature"].lower():
262
+ score = min(1.0, score + 0.1)
263
+
264
+ if score > 0.3:
265
+ results.append(
266
+ SearchResult(
267
+ name=sym["name"],
268
+ type=sym["type"],
269
+ file=file_path,
270
+ lines=sym["lines"],
271
+ signature=sym.get("signature"),
272
+ docstring=sym.get("docstring"),
273
+ parent=sym.get("parent"),
274
+ score=score,
275
+ )
276
+ )
277
+
278
+ results.sort(key=lambda x: (-x.score, x.name))
279
+ return results[:limit]
280
+
281
+ def search_file(self, pattern: str, limit: int = 20) -> list[dict]:
282
+ """Search for files by path pattern.
283
+
284
+ Args:
285
+ pattern: Regex pattern or substring to match against file paths.
286
+ limit: Maximum results to return.
287
+
288
+ Returns:
289
+ List of dicts with file info (path, hash, symbol counts).
290
+
291
+ Example:
292
+ >>> files = searcher.search_file('models/')
293
+ >>> for f in files:
294
+ ... print(f"{f['file']}: {f['total_symbols']} symbols")
295
+ """
296
+ results = []
297
+ compiled_pattern = _safe_regex_compile(pattern)
298
+
299
+ for file_path, file_info in self.code_map.get("files", {}).items():
300
+ if compiled_pattern.search(file_path):
301
+ symbols_summary: dict[str, int] = {}
302
+ for sym in file_info.get("symbols", []):
303
+ sym_type = sym["type"]
304
+ symbols_summary[sym_type] = symbols_summary.get(sym_type, 0) + 1
305
+
306
+ results.append(
307
+ {
308
+ "file": file_path,
309
+ "hash": file_info.get("hash", ""),
310
+ "symbols": symbols_summary,
311
+ "total_symbols": len(file_info.get("symbols", [])),
312
+ }
313
+ )
314
+
315
+ results.sort(key=lambda x: x["file"])
316
+ return results[:limit]
317
+
318
+ def get_file_structure(self, file_path: str) -> dict | None:
319
+ """Get the structure of a specific file.
320
+
321
+ Returns all symbols in the file organized hierarchically by type.
322
+
323
+ Args:
324
+ file_path: Path to the file (can be partial).
325
+
326
+ Returns:
327
+ Dict with classes, functions, and other symbols, or None if not found.
328
+
329
+ Example:
330
+ >>> structure = searcher.get_file_structure('src/models/user.py')
331
+ >>> print(list(structure['classes'].keys()))
332
+ ['User', 'UserProfile']
333
+ """
334
+ file_info = self.code_map.get("files", {}).get(file_path)
335
+ if not file_info:
336
+ # Try partial match
337
+ for path, info in self.code_map.get("files", {}).items():
338
+ if file_path in path:
339
+ file_info = info
340
+ file_path = path
341
+ break
342
+
343
+ if not file_info:
344
+ return None
345
+
346
+ classes = {}
347
+ functions = []
348
+ other = []
349
+
350
+ for sym in file_info.get("symbols", []):
351
+ if sym["type"] == "class":
352
+ classes[sym["name"]] = {
353
+ "lines": sym["lines"],
354
+ "signature": sym.get("signature"),
355
+ "docstring": sym.get("docstring"),
356
+ "methods": [],
357
+ }
358
+ elif sym["type"] == "method" and sym.get("parent"):
359
+ if sym["parent"] in classes:
360
+ classes[sym["parent"]]["methods"].append(
361
+ {
362
+ "name": sym["name"],
363
+ "lines": sym["lines"],
364
+ "signature": sym.get("signature"),
365
+ }
366
+ )
367
+ elif sym["type"] == "function":
368
+ functions.append(
369
+ {
370
+ "name": sym["name"],
371
+ "lines": sym["lines"],
372
+ "signature": sym.get("signature"),
373
+ "docstring": sym.get("docstring"),
374
+ }
375
+ )
376
+ else:
377
+ other.append({"name": sym["name"], "type": sym["type"], "lines": sym["lines"]})
378
+
379
+ return {
380
+ "file": file_path,
381
+ "hash": file_info.get("hash", ""),
382
+ "classes": classes,
383
+ "functions": functions,
384
+ "other": other if other else None,
385
+ }
386
+
387
+ def find_dependencies(self, symbol_name: str, file_path: str | None = None) -> dict:
388
+ """Find what a symbol depends on and what depends on it.
389
+
390
+ Args:
391
+ symbol_name: Name of the symbol to analyze.
392
+ file_path: Optional file path filter.
393
+
394
+ Returns:
395
+ Dict with:
396
+ - found: Boolean indicating if the symbol was found
397
+ - symbol: The searched symbol name
398
+ - file: File path where symbol was found (None if not found)
399
+ - lines: Line range [start, end] (None if not found)
400
+ - calls: List of symbols this symbol depends on
401
+ - called_by: List of dicts with symbols that depend on this one
402
+
403
+ Example:
404
+ >>> deps = searcher.find_dependencies('process_payment')
405
+ >>> if deps['found']:
406
+ ... print(f"Calls: {deps['calls']}")
407
+ ... print(f"Called by: {len(deps['called_by'])} functions")
408
+ ... else:
409
+ ... print("Symbol not found")
410
+ """
411
+ deps_of = []
412
+ depended_by = []
413
+
414
+ target_file = None
415
+ target_lines = None
416
+ found = False
417
+
418
+ for fpath, file_info in self.code_map.get("files", {}).items():
419
+ if file_path and file_path not in fpath:
420
+ continue
421
+
422
+ for sym in file_info.get("symbols", []):
423
+ if sym["name"].lower() == symbol_name.lower():
424
+ if not found: # Only use first match for target info
425
+ target_file = fpath
426
+ target_lines = sym["lines"]
427
+ found = True
428
+ if sym.get("deps"):
429
+ deps_of = sym["deps"]
430
+ break
431
+
432
+ for sym in file_info.get("symbols", []):
433
+ if sym.get("deps") and symbol_name in sym["deps"]:
434
+ depended_by.append({"name": sym["name"], "file": fpath, "lines": sym["lines"]})
435
+
436
+ return {
437
+ "found": found,
438
+ "symbol": symbol_name,
439
+ "file": target_file,
440
+ "lines": target_lines,
441
+ "calls": deps_of,
442
+ "called_by": depended_by,
443
+ }
444
+
445
+ def get_stats(self) -> dict:
446
+ """Get statistics about the codebase.
447
+
448
+ Returns:
449
+ Dict with root path, generation time, file count, symbol count,
450
+ and breakdown by symbol type.
451
+
452
+ Example:
453
+ >>> stats = searcher.get_stats()
454
+ >>> print(f"Total: {stats['total_symbols']} symbols in {stats['files']} files")
455
+ """
456
+ stats = self.code_map.get("stats", {})
457
+
458
+ type_counts: dict[str, int] = {}
459
+ for file_info in self.code_map.get("files", {}).values():
460
+ for sym in file_info.get("symbols", []):
461
+ sym_type = sym["type"]
462
+ type_counts[sym_type] = type_counts.get(sym_type, 0) + 1
463
+
464
+ return {
465
+ "root": self.code_map.get("root"),
466
+ "generated_at": self.code_map.get("generated_at"),
467
+ "files": stats.get("files_processed", len(self.code_map.get("files", {}))),
468
+ "total_symbols": stats.get("symbols_found", 0),
469
+ "by_type": type_counts,
470
+ }
471
+
472
+ def list_by_type(
473
+ self, symbol_type: str, file_pattern: str | None = None, limit: int = 100
474
+ ) -> list[SearchResult]:
475
+ """List all symbols of a specific type.
476
+
477
+ Args:
478
+ symbol_type: Type to filter by ('function', 'class', 'method', etc.).
479
+ file_pattern: Optional regex pattern to filter by file path.
480
+ limit: Maximum results to return.
481
+
482
+ Returns:
483
+ List of SearchResult objects matching the type.
484
+
485
+ Example:
486
+ >>> classes = searcher.list_by_type('class')
487
+ >>> for c in classes:
488
+ ... print(f"{c.name} in {c.file}:{c.lines[0]}")
489
+ """
490
+ results = []
491
+ file_regex = _safe_regex_compile(file_pattern) if file_pattern else None
492
+
493
+ for file_path, file_info in self.code_map.get("files", {}).items():
494
+ if file_regex and not file_regex.search(file_path):
495
+ continue
496
+
497
+ for sym in file_info.get("symbols", []):
498
+ if sym["type"] != symbol_type:
499
+ continue
500
+
501
+ results.append(
502
+ SearchResult(
503
+ name=sym["name"],
504
+ type=sym["type"],
505
+ file=file_path,
506
+ lines=sym["lines"],
507
+ signature=sym.get("signature"),
508
+ docstring=sym.get("docstring"),
509
+ parent=sym.get("parent"),
510
+ score=1.0,
511
+ )
512
+ )
513
+
514
+ if len(results) >= limit:
515
+ break
516
+
517
+ if len(results) >= limit:
518
+ break
519
+
520
+ # Sort by file path and name for consistent output
521
+ results.sort(key=lambda x: (x.file, x.name))
522
+ return results[:limit]
523
+
524
+ def check_stale_files(self, root_path: str | None = None) -> dict:
525
+ """Check for files that have changed since the map was generated.
526
+
527
+ Compares current file hashes with stored hashes to detect modifications.
528
+
529
+ Args:
530
+ root_path: Root path of the codebase. If None, uses the root from the map.
531
+
532
+ Returns:
533
+ Dict with 'stale' (modified files), 'missing' (deleted files),
534
+ 'new' (untracked files in map), and 'is_stale' boolean.
535
+
536
+ Example:
537
+ >>> result = searcher.check_stale_files()
538
+ >>> if result['is_stale']:
539
+ ... print(f"Warning: {len(result['stale'])} files changed")
540
+ """
541
+ root = root_path or self.code_map.get("root", "")
542
+ if not root or not os.path.isdir(root):
543
+ return {
544
+ "error": f"Root path not found: {root}",
545
+ "is_stale": False,
546
+ "stale": [],
547
+ "missing": [],
548
+ }
549
+
550
+ root_path_obj = Path(root)
551
+ stale_files = []
552
+ missing_files = []
553
+
554
+ for file_path, file_info in self.code_map.get("files", {}).items():
555
+ full_path = root_path_obj / file_path
556
+ stored_hash = file_info.get("hash", "")
557
+
558
+ if not full_path.exists():
559
+ missing_files.append(file_path)
560
+ else:
561
+ try:
562
+ from . import compute_content_hash
563
+
564
+ content = full_path.read_text(encoding="utf-8", errors="ignore")
565
+ current_hash = compute_content_hash(content)
566
+ if current_hash != stored_hash:
567
+ stale_files.append(file_path)
568
+ except OSError:
569
+ stale_files.append(file_path)
570
+
571
+ return {
572
+ "is_stale": len(stale_files) > 0 or len(missing_files) > 0,
573
+ "stale": stale_files,
574
+ "missing": missing_files,
575
+ "total_checked": len(self.code_map.get("files", {})),
576
+ "generated_at": self.code_map.get("generated_at"),
577
+ }
578
+
579
+ def get_changes_since_commit(self, commit: str, root_path: str | None = None) -> dict:
580
+ """Get symbols in files that changed since a specific git commit.
581
+
582
+ Args:
583
+ commit: Git commit reference (hash, branch, tag, HEAD~N, etc.)
584
+ root_path: Root path of the codebase. If None, uses the root from the map.
585
+
586
+ Returns:
587
+ Dict with changed files and their symbols.
588
+
589
+ Example:
590
+ >>> result = searcher.get_changes_since_commit('HEAD~5')
591
+ >>> for f in result['changed_files']:
592
+ ... print(f"{f['file']}: {len(f['symbols'])} symbols")
593
+ """
594
+ import subprocess
595
+
596
+ root = root_path or self.code_map.get("root", "")
597
+ if not root or not os.path.isdir(root):
598
+ return {"error": f"Root path not found: {root}", "changed_files": []}
599
+
600
+ # Validate commit reference to prevent command injection
601
+ if not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9_.~^/@{}\-]*", commit):
602
+ raise ValueError(f"Invalid git reference: {commit}")
603
+
604
+ # Get changed files from git
605
+ try:
606
+ result = subprocess.run(
607
+ ["git", "diff", "--name-only", commit, "HEAD"],
608
+ cwd=root,
609
+ capture_output=True,
610
+ text=True,
611
+ timeout=30,
612
+ )
613
+ if result.returncode != 0:
614
+ return {"error": f"Git error: {result.stderr.strip()}", "changed_files": []}
615
+
616
+ changed_files = (
617
+ set(result.stdout.strip().split("\n")) if result.stdout.strip() else set()
618
+ )
619
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
620
+ return {"error": f"Git not available: {e}", "changed_files": []}
621
+
622
+ # Find symbols in changed files
623
+ files_with_symbols = []
624
+ for file_path, file_info in self.code_map.get("files", {}).items():
625
+ if file_path in changed_files:
626
+ files_with_symbols.append(
627
+ {
628
+ "file": file_path,
629
+ "symbols": file_info.get("symbols", []),
630
+ }
631
+ )
632
+
633
+ return {
634
+ "commit": commit,
635
+ "total_changed": len(changed_files),
636
+ "tracked_changed": len(files_with_symbols),
637
+ "changed_files": files_with_symbols,
638
+ }
639
+
640
+
641
+ def format_search_output(
642
+ result: dict | list,
643
+ style: str = "json",
644
+ compact: bool = False,
645
+ no_color: bool = False,
646
+ ) -> str:
647
+ """Format search results for display.
648
+
649
+ Args:
650
+ result: Search results (dict or list of dicts).
651
+ style: Output style ('json' or 'table').
652
+ compact: If True, output compact JSON.
653
+ no_color: If True, disable colored output.
654
+
655
+ Returns:
656
+ Formatted string representation.
657
+ """
658
+ if style == "json":
659
+ if compact:
660
+ return json.dumps(result, separators=(",", ":"))
661
+ return json.dumps(result, indent=2)
662
+
663
+ # Table format with colors
664
+ c = get_colors(no_color=no_color)
665
+
666
+ if isinstance(result, dict):
667
+ # Handle error
668
+ if "error" in result:
669
+ return c.error(f"Error: {result['error']}")
670
+
671
+ # Handle --since-commit output
672
+ if "changed_files" in result and "commit" in result:
673
+ if result.get("error"):
674
+ return c.error(f"Error: {result['error']}")
675
+
676
+ output = [c.bold(f"Changes since {c.cyan(result.get('commit', 'Unknown'))}")]
677
+ output.append(f" Total changed files: {c.yellow(str(result.get('total_changed', 0)))}")
678
+ output.append(f" Tracked in map: {c.green(str(result.get('tracked_changed', 0)))}")
679
+
680
+ changed_files = result.get("changed_files", [])
681
+ if changed_files:
682
+ output.append("")
683
+ for file_info in changed_files[:20]:
684
+ file_path = file_info.get("file", "?")
685
+ symbols = file_info.get("symbols", [])
686
+ output.append(f" {c.cyan(file_path)}")
687
+ for sym in symbols[:5]:
688
+ sym_type = c.magenta(f"[{sym.get('type', '?')}]")
689
+ sym_name = c.green(sym.get("name", "?"))
690
+ lines = sym.get("lines", [0, 0])
691
+ output.append(f" {sym_type} {sym_name} :{lines[0]}-{lines[1]}")
692
+ if len(symbols) > 5:
693
+ output.append(f" {c.dim(f'... and {len(symbols) - 5} more symbols')}")
694
+
695
+ if len(changed_files) > 20:
696
+ output.append(f"\n {c.dim(f'... and {len(changed_files) - 20} more files')}")
697
+ else:
698
+ output.append(f"\n {c.dim('No tracked files changed')}")
699
+
700
+ return "\n".join(output)
701
+
702
+ # Handle stale check
703
+ if "is_stale" in result:
704
+ if result.get("error"):
705
+ return c.error(f"Error: {result['error']}")
706
+
707
+ output = [c.bold("Stale File Check")]
708
+ output.append(f" Generated: {c.dim(result.get('generated_at', 'Unknown'))}")
709
+ output.append(f" Files checked: {c.cyan(str(result.get('total_checked', 0)))}")
710
+
711
+ stale = result.get("stale", [])
712
+ missing = result.get("missing", [])
713
+
714
+ if result.get("is_stale"):
715
+ if stale:
716
+ output.append(f" {c.yellow(f'Modified ({len(stale)}):')}")
717
+ for f in stale[:10]:
718
+ output.append(f" {c.yellow(f)}")
719
+ if len(stale) > 10:
720
+ output.append(f" {c.dim(f'... and {len(stale) - 10} more')}")
721
+
722
+ if missing:
723
+ output.append(f" {c.magenta(f'Deleted ({len(missing)}):')}")
724
+ for f in missing[:10]:
725
+ output.append(f" {c.magenta(f)}")
726
+ if len(missing) > 10:
727
+ output.append(f" {c.dim(f'... and {len(missing) - 10} more')}")
728
+
729
+ output.append("")
730
+ output.append(c.warning("Run 'codegraph-nav map --incremental' to update the map."))
731
+ else:
732
+ output.append(f" Status: {c.success('Up to date')}")
733
+
734
+ return "\n".join(output)
735
+
736
+ # Handle stats
737
+ if "total_symbols" in result:
738
+ output = [c.bold("Codebase Statistics")]
739
+ output.append(f" Root: {c.cyan(result.get('root', 'N/A'))}")
740
+ output.append(f" Files: {c.green(str(result.get('files', 0)))}")
741
+ output.append(f" Symbols: {c.green(str(result.get('total_symbols', 0)))}")
742
+ if "by_type" in result:
743
+ output.append(" By type:")
744
+ for type_name, count in result["by_type"].items():
745
+ output.append(f" {c.magenta(type_name)}: {count}")
746
+ return "\n".join(output)
747
+
748
+ # Handle file structure
749
+ if "symbols" in result:
750
+ output = [c.bold(f"Structure: {c.cyan(result.get('file', 'Unknown'))}")]
751
+ for sym in result.get("symbols", []):
752
+ sym_type = c.magenta(f"[{sym.get('type', '?')}]")
753
+ sym_name = c.green(sym.get("name", "?"))
754
+ lines = sym.get("lines", [0, 0])
755
+ line_range = c.cyan(f":{lines[0]}-{lines[1]}")
756
+ output.append(f" {sym_type} {sym_name}{line_range}")
757
+ return "\n".join(output)
758
+
759
+ # Handle dependencies
760
+ if "calls" in result or "called_by" in result:
761
+ output = [c.bold(f"Dependencies: {c.green(result.get('symbol', 'Unknown'))}")]
762
+ if result.get("calls"):
763
+ output.append(" Calls:")
764
+ for call in result["calls"]:
765
+ output.append(f" {c.cyan(call)}")
766
+ if result.get("called_by"):
767
+ output.append(" Called by:")
768
+ for caller in result["called_by"]:
769
+ output.append(f" {c.cyan(caller)}")
770
+ if not result.get("calls") and not result.get("called_by"):
771
+ output.append(c.dim(" No dependencies found"))
772
+ return "\n".join(output)
773
+
774
+ # Fallback to JSON for unknown dict structures
775
+ return json.dumps(result, indent=2)
776
+
777
+ # Handle list of search results
778
+ if isinstance(result, list):
779
+ if not result:
780
+ return c.dim("No results found")
781
+
782
+ # Detect file search results (have 'total_symbols' key, no 'type' key)
783
+ if result[0].get("total_symbols") is not None and "type" not in result[0]:
784
+ output = []
785
+ for item in result:
786
+ file_path = c.cyan(item.get("file", "?"))
787
+ symbols = item.get("symbols", {})
788
+ if symbols:
789
+ breakdown = ", ".join(
790
+ f"{c.magenta(t)}: {n}" for t, n in sorted(symbols.items())
791
+ )
792
+ output.append(f"{file_path} {c.dim(f'({breakdown})')}")
793
+ else:
794
+ output.append(f"{file_path} {c.dim('(empty)')}")
795
+ return "\n".join(output)
796
+
797
+ output = []
798
+ for item in result:
799
+ sym_type = c.magenta(f"[{item.get('type', '?')}]")
800
+ sym_name = c.green(item.get("name", "?"))
801
+ file_path = c.cyan(item.get("file", "?"))
802
+ lines = item.get("lines", [0, 0])
803
+ line_range = f"{lines[0]}-{lines[1]}"
804
+
805
+ output.append(f"{sym_type} {sym_name}")
806
+ output.append(f" {file_path}:{c.yellow(line_range)}")
807
+
808
+ if item.get("signature"):
809
+ sig = item["signature"]
810
+ if len(sig) > 60:
811
+ sig = sig[:57] + "..."
812
+ output.append(f" {c.dim(sig)}")
813
+
814
+ return "\n".join(output)
815
+
816
+ return str(result)
817
+
818
+
819
+ def add_search_arguments(parser: argparse.ArgumentParser) -> None:
820
+ """Add search command arguments to a parser.
821
+
822
+ Args:
823
+ parser: The argument parser to add arguments to.
824
+ """
825
+ parser.add_argument("query", nargs="?", help="Search query (symbol name, file pattern, etc.)")
826
+ parser.add_argument(
827
+ "-m",
828
+ "--map",
829
+ default=".codegraph.json",
830
+ help="Path to code map file (default: .codegraph.json)",
831
+ )
832
+ parser.add_argument(
833
+ "-t",
834
+ "--type",
835
+ choices=["function", "class", "method", "interface", "struct", "trait", "enum"],
836
+ help="Filter by symbol type",
837
+ )
838
+ parser.add_argument("-f", "--file", help="Filter by file path pattern")
839
+ parser.add_argument("--files", action="store_true", help="Search for files instead of symbols")
840
+ parser.add_argument("--structure", help="Show structure of a specific file")
841
+ parser.add_argument("--deps", help="Show dependencies of a symbol")
842
+ parser.add_argument("--stats", action="store_true", help="Show codebase statistics")
843
+ parser.add_argument(
844
+ "--check-stale",
845
+ action="store_true",
846
+ help="Check if any files have changed since map generation",
847
+ )
848
+ parser.add_argument(
849
+ "--warn-stale",
850
+ action="store_true",
851
+ help="Warn if files are stale before showing results",
852
+ )
853
+ parser.add_argument(
854
+ "--since-commit",
855
+ metavar="COMMIT",
856
+ help="Show symbols in files changed since COMMIT (git ref: hash, branch, HEAD~N)",
857
+ )
858
+ parser.add_argument("-l", "--limit", type=int, default=10, help="Maximum results (default: 10)")
859
+ parser.add_argument("--no-fuzzy", action="store_true", help="Disable fuzzy matching")
860
+ parser.add_argument(
861
+ "--compact", action="store_true", help="Output compact JSON (default: pretty-printed)"
862
+ )
863
+ parser.add_argument(
864
+ "-o",
865
+ "--output",
866
+ choices=["json", "table"],
867
+ default="json",
868
+ help="Output format (default: json)",
869
+ )
870
+ parser.add_argument("--no-color", action="store_true", help="Disable colored output")
871
+
872
+
873
+ def run_search(args: argparse.Namespace) -> None:
874
+ """Execute the search command with parsed arguments.
875
+
876
+ Args:
877
+ args: Parsed command-line arguments.
878
+ """
879
+ # Find map file
880
+ map_path = args.map
881
+ if not os.path.isabs(map_path) and not os.path.exists(map_path):
882
+ cwd_map = os.path.join(os.getcwd(), ".codegraph.json")
883
+ if os.path.exists(cwd_map):
884
+ map_path = cwd_map
885
+
886
+ if not os.path.exists(map_path):
887
+ print(json.dumps({"error": f"Code map not found: {map_path}"}))
888
+ sys.exit(1)
889
+
890
+ searcher = CodeSearcher(map_path)
891
+ c = get_colors(no_color=args.no_color)
892
+
893
+ # Check for stale files if requested
894
+ result: dict | list
895
+ if args.check_stale:
896
+ result = searcher.check_stale_files()
897
+ print(
898
+ format_search_output(
899
+ result,
900
+ style=args.output,
901
+ compact=args.compact,
902
+ no_color=args.no_color,
903
+ )
904
+ )
905
+ return
906
+
907
+ # Warn about stale files if requested
908
+ if getattr(args, "warn_stale", False):
909
+ stale_result = searcher.check_stale_files()
910
+ if stale_result.get("is_stale"):
911
+ stale_count = len(stale_result.get("stale", []))
912
+ missing_count = len(stale_result.get("missing", []))
913
+ warnings = []
914
+ if stale_count > 0:
915
+ warnings.append(f"{stale_count} modified")
916
+ if missing_count > 0:
917
+ warnings.append(f"{missing_count} deleted")
918
+ print(
919
+ c.warning(
920
+ f"Warning: {', '.join(warnings)} files since map generation. "
921
+ "Run 'codegraph-nav map --incremental' to update."
922
+ ),
923
+ file=sys.stderr,
924
+ )
925
+
926
+ # Handle --since-commit
927
+ if getattr(args, "since_commit", None):
928
+ result = searcher.get_changes_since_commit(args.since_commit)
929
+ print(
930
+ format_search_output(
931
+ result,
932
+ style=args.output,
933
+ compact=args.compact,
934
+ no_color=args.no_color,
935
+ )
936
+ )
937
+ return
938
+
939
+ # Determine operation
940
+ if args.stats:
941
+ result = searcher.get_stats()
942
+ elif args.structure:
943
+ structure = searcher.get_file_structure(args.structure)
944
+ if not structure:
945
+ result = {"error": f"File not found: {args.structure}"}
946
+ else:
947
+ result = structure
948
+ elif args.deps:
949
+ result = searcher.find_dependencies(args.deps, args.file)
950
+ elif args.files:
951
+ if not args.query:
952
+ result = {"error": "Query required for file search"}
953
+ else:
954
+ result = searcher.search_file(args.query, args.limit)
955
+ elif args.query:
956
+ results = searcher.search_symbol(
957
+ args.query,
958
+ symbol_type=args.type,
959
+ file_pattern=args.file,
960
+ limit=args.limit,
961
+ fuzzy=not args.no_fuzzy,
962
+ )
963
+ result = [r.to_dict() for r in results]
964
+ elif args.type:
965
+ # List all symbols of specified type (no query needed)
966
+ results = searcher.list_by_type(args.type, file_pattern=args.file, limit=args.limit)
967
+ result = [r.to_dict() for r in results]
968
+ else:
969
+ result = {
970
+ "error": "No query provided. Use --help for usage or --type to list all symbols of a type."
971
+ }
972
+
973
+ # Output
974
+ print(
975
+ format_search_output(
976
+ result,
977
+ style=args.output,
978
+ compact=args.compact,
979
+ no_color=args.no_color,
980
+ )
981
+ )
982
+
983
+
984
+ def main():
985
+ """Command-line interface for code search.
986
+
987
+ Usage:
988
+ code-search QUERY [--type TYPE] [--file PATTERN] [--limit N]
989
+ code-search --structure FILE
990
+ code-search --deps SYMBOL
991
+ code-search --stats
992
+
993
+ Example:
994
+ $ code-search "payment" --type function --limit 5
995
+ $ code-search --structure src/api.py --pretty
996
+ """
997
+ parser = argparse.ArgumentParser(
998
+ description="Search through a code map for symbols and files",
999
+ epilog='Example: code-search "payment" --type function',
1000
+ )
1001
+ add_search_arguments(parser)
1002
+ parser.add_argument("-v", "--version", action="version", version=f"%(prog)s {__version__}")
1003
+
1004
+ args = parser.parse_args()
1005
+ run_search(args)
1006
+
1007
+
1008
+ if __name__ == "__main__":
1009
+ main()