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.
- codegraph_nav/__init__.py +194 -0
- codegraph_nav/ast_grep_analyzer.py +448 -0
- codegraph_nav/cli.py +223 -0
- codegraph_nav/code_navigator.py +1328 -0
- codegraph_nav/code_search.py +1009 -0
- codegraph_nav/colors.py +209 -0
- codegraph_nav/completions.py +354 -0
- codegraph_nav/dart_analyzer.py +301 -0
- codegraph_nav/dependency_graph.py +814 -0
- codegraph_nav/domain/__init__.py +20 -0
- codegraph_nav/domain/routes.py +337 -0
- codegraph_nav/domain/schemas.py +229 -0
- codegraph_nav/domain/tags.py +87 -0
- codegraph_nav/exporters.py +563 -0
- codegraph_nav/go_analyzer.py +273 -0
- codegraph_nav/graph/__init__.py +72 -0
- codegraph_nav/graph/builder.py +409 -0
- codegraph_nav/graph/communities.py +402 -0
- codegraph_nav/graph/flows.py +311 -0
- codegraph_nav/graph/query.py +380 -0
- codegraph_nav/graph/schema.py +266 -0
- codegraph_nav/graph/search.py +257 -0
- codegraph_nav/graph/store.py +517 -0
- codegraph_nav/hints.py +195 -0
- codegraph_nav/import_resolver.py +891 -0
- codegraph_nav/js_ts_analyzer.py +564 -0
- codegraph_nav/line_reader.py +664 -0
- codegraph_nav/mcp/__init__.py +39 -0
- codegraph_nav/mcp/__main__.py +5 -0
- codegraph_nav/mcp/server.py +2228 -0
- codegraph_nav/py.typed +2 -0
- codegraph_nav/ruby_analyzer.py +259 -0
- codegraph_nav/rust_analyzer.py +379 -0
- codegraph_nav/token_efficient_renderer.py +743 -0
- codegraph_nav/watcher.py +382 -0
- codegraph_nav-0.1.0.dist-info/METADATA +487 -0
- codegraph_nav-0.1.0.dist-info/RECORD +41 -0
- codegraph_nav-0.1.0.dist-info/WHEEL +5 -0
- codegraph_nav-0.1.0.dist-info/entry_points.txt +4 -0
- codegraph_nav-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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()
|