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,664 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Line Reader - Read specific lines or ranges from files.
|
|
3
|
+
|
|
4
|
+
This module provides surgical precision for reading code - load only the exact
|
|
5
|
+
lines you need instead of entire files, dramatically reducing token usage.
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
Command line usage:
|
|
9
|
+
$ code-read src/api.py 45-60
|
|
10
|
+
$ code-read src/api.py "10-20,45-60" -c 3
|
|
11
|
+
$ code-read src/api.py --search "def process"
|
|
12
|
+
|
|
13
|
+
Python API usage:
|
|
14
|
+
>>> reader = LineReader('/path/to/project')
|
|
15
|
+
>>> result = reader.read_lines('src/api.py', 45, 60)
|
|
16
|
+
>>> for line in result['lines']:
|
|
17
|
+
... print(f"{line['num']}: {line['content']}")
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import json
|
|
22
|
+
import re
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from .colors import get_colors
|
|
26
|
+
|
|
27
|
+
__version__ = "0.1.0"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class LineReader:
|
|
31
|
+
"""Read specific lines from files efficiently.
|
|
32
|
+
|
|
33
|
+
Provides methods to read single ranges, multiple ranges, and search
|
|
34
|
+
for patterns within files, all with minimal overhead.
|
|
35
|
+
|
|
36
|
+
Attributes:
|
|
37
|
+
root_path: Base path for resolving relative file paths.
|
|
38
|
+
|
|
39
|
+
Example:
|
|
40
|
+
>>> reader = LineReader('/my/project')
|
|
41
|
+
>>> result = reader.read_lines('src/api.py', 45, 60, context=2)
|
|
42
|
+
>>> print(f"Read lines {result['actual'][0]}-{result['actual'][1]}")
|
|
43
|
+
|
|
44
|
+
>>> # Read a function with smart truncation
|
|
45
|
+
>>> symbol = reader.read_symbol('src/api.py', 45, 150, max_lines=50)
|
|
46
|
+
>>> print(f"Truncated: {symbol['truncated']}")
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, root_path: str | None = None):
|
|
50
|
+
"""Initialize the line reader.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
root_path: Base directory for resolving relative paths.
|
|
54
|
+
Defaults to current working directory.
|
|
55
|
+
"""
|
|
56
|
+
self.root_path = Path(root_path) if root_path else Path.cwd()
|
|
57
|
+
|
|
58
|
+
def _resolve_path(self, file_path: str) -> Path:
|
|
59
|
+
"""Resolve a file path relative to root with security validation.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
file_path: Relative or absolute file path.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Resolved absolute Path object.
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
ValueError: If the resolved path escapes the root directory
|
|
69
|
+
(path traversal attempt).
|
|
70
|
+
"""
|
|
71
|
+
# Security check: reject symlinked root paths to prevent traversal attacks
|
|
72
|
+
if self.root_path.is_symlink():
|
|
73
|
+
raise PermissionError(
|
|
74
|
+
f"Security error: root path '{self.root_path}' is a symlink. "
|
|
75
|
+
"Symlinked root paths are not allowed."
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
path = Path(file_path)
|
|
79
|
+
if not path.is_absolute():
|
|
80
|
+
path = self.root_path / path
|
|
81
|
+
|
|
82
|
+
resolved = path.resolve()
|
|
83
|
+
root_resolved = self.root_path.resolve()
|
|
84
|
+
|
|
85
|
+
# Security check: ensure path doesn't escape root directory
|
|
86
|
+
try:
|
|
87
|
+
resolved.relative_to(root_resolved)
|
|
88
|
+
except ValueError:
|
|
89
|
+
raise ValueError(
|
|
90
|
+
f"Security error: path '{file_path}' escapes root directory. "
|
|
91
|
+
f"Resolved to '{resolved}' which is outside '{root_resolved}'"
|
|
92
|
+
) from None
|
|
93
|
+
|
|
94
|
+
return resolved
|
|
95
|
+
|
|
96
|
+
def read_lines(
|
|
97
|
+
self, file_path: str, start: int, end: int | None = None, context: int = 0
|
|
98
|
+
) -> dict:
|
|
99
|
+
"""Read specific lines from a file.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
file_path: Path to the file to read.
|
|
103
|
+
start: Starting line number (1-indexed).
|
|
104
|
+
end: Ending line number (inclusive). Defaults to start.
|
|
105
|
+
context: Number of context lines before and after.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
Dict with:
|
|
109
|
+
- file: The file path
|
|
110
|
+
- requested: [start, end] as requested
|
|
111
|
+
- actual: [start, end] after applying context
|
|
112
|
+
- total_lines: Total lines in file
|
|
113
|
+
- lines: List of {num, content, in_range} dicts
|
|
114
|
+
|
|
115
|
+
Example:
|
|
116
|
+
>>> result = reader.read_lines('api.py', 45, 60, context=2)
|
|
117
|
+
>>> print(result['lines'][0]['content'])
|
|
118
|
+
"""
|
|
119
|
+
try:
|
|
120
|
+
path = self._resolve_path(file_path)
|
|
121
|
+
except ValueError as e:
|
|
122
|
+
return {"error": str(e)}
|
|
123
|
+
|
|
124
|
+
if not path.exists():
|
|
125
|
+
return {"error": f"File not found: {file_path}"}
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
with open(path, encoding="utf-8", errors="replace") as f:
|
|
129
|
+
all_lines = f.readlines()
|
|
130
|
+
except Exception as e:
|
|
131
|
+
return {"error": f"Failed to read file: {e}"}
|
|
132
|
+
|
|
133
|
+
total_lines = len(all_lines)
|
|
134
|
+
end = end or start
|
|
135
|
+
|
|
136
|
+
actual_start = max(1, start - context)
|
|
137
|
+
actual_end = min(total_lines, end + context)
|
|
138
|
+
|
|
139
|
+
extracted = all_lines[actual_start - 1 : actual_end]
|
|
140
|
+
|
|
141
|
+
lines_with_numbers = []
|
|
142
|
+
for i, line in enumerate(extracted, start=actual_start):
|
|
143
|
+
lines_with_numbers.append(
|
|
144
|
+
{"num": i, "content": line.rstrip("\n\r"), "in_range": start <= i <= end}
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
"file": file_path,
|
|
149
|
+
"requested": [start, end],
|
|
150
|
+
"actual": [actual_start, actual_end],
|
|
151
|
+
"total_lines": total_lines,
|
|
152
|
+
"lines": lines_with_numbers,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
def read_ranges(
|
|
156
|
+
self, file_path: str, ranges: list[tuple[int, int]], context: int = 0, collapse_gap: int = 5
|
|
157
|
+
) -> dict:
|
|
158
|
+
"""Read multiple line ranges from a file efficiently.
|
|
159
|
+
|
|
160
|
+
Intelligently merges overlapping or close ranges to minimize
|
|
161
|
+
redundant reads while preserving the requested range markers.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
file_path: Path to the file to read.
|
|
165
|
+
ranges: List of (start, end) tuples (1-indexed).
|
|
166
|
+
context: Context lines for each range.
|
|
167
|
+
collapse_gap: Merge ranges if gap is smaller than this.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
Dict with:
|
|
171
|
+
- file: The file path
|
|
172
|
+
- total_lines: Total lines in file
|
|
173
|
+
- sections: List of merged sections with lines
|
|
174
|
+
|
|
175
|
+
Example:
|
|
176
|
+
>>> ranges = [(10, 20), (25, 35), (100, 110)]
|
|
177
|
+
>>> result = reader.read_ranges('api.py', ranges, context=2)
|
|
178
|
+
>>> print(f"Got {len(result['sections'])} sections")
|
|
179
|
+
"""
|
|
180
|
+
try:
|
|
181
|
+
path = self._resolve_path(file_path)
|
|
182
|
+
except ValueError as e:
|
|
183
|
+
return {"error": str(e)}
|
|
184
|
+
|
|
185
|
+
if not path.exists():
|
|
186
|
+
return {"error": f"File not found: {file_path}"}
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
with open(path, encoding="utf-8", errors="replace") as f:
|
|
190
|
+
all_lines = f.readlines()
|
|
191
|
+
except Exception as e:
|
|
192
|
+
return {"error": f"Failed to read file: {e}"}
|
|
193
|
+
|
|
194
|
+
total_lines = len(all_lines)
|
|
195
|
+
|
|
196
|
+
# Normalize and sort ranges
|
|
197
|
+
normalized: list[tuple[int, int, int, int]] = []
|
|
198
|
+
for start, end in ranges:
|
|
199
|
+
s = max(1, start - context)
|
|
200
|
+
ctx_end = min(total_lines, end + context)
|
|
201
|
+
normalized.append((s, ctx_end, start, end))
|
|
202
|
+
|
|
203
|
+
normalized.sort(key=lambda x: x[0])
|
|
204
|
+
|
|
205
|
+
# Merge overlapping or close ranges
|
|
206
|
+
merged: list[tuple[int, int, list[tuple[int, int]]]] = []
|
|
207
|
+
for s, ctx_end, os, oe in normalized:
|
|
208
|
+
if merged and s <= merged[-1][1] + collapse_gap:
|
|
209
|
+
prev = merged[-1]
|
|
210
|
+
merged[-1] = (prev[0], max(prev[1], ctx_end), prev[2])
|
|
211
|
+
merged[-1][2].append((os, oe))
|
|
212
|
+
else:
|
|
213
|
+
merged.append((s, ctx_end, [(os, oe)]))
|
|
214
|
+
|
|
215
|
+
# Extract lines for each merged range
|
|
216
|
+
sections = []
|
|
217
|
+
for actual_start, actual_end, original_ranges in merged:
|
|
218
|
+
lines_with_numbers = []
|
|
219
|
+
for i in range(actual_start - 1, actual_end):
|
|
220
|
+
if i < len(all_lines):
|
|
221
|
+
line_num = i + 1
|
|
222
|
+
in_range = any(os <= line_num <= oe for os, oe in original_ranges)
|
|
223
|
+
lines_with_numbers.append(
|
|
224
|
+
{
|
|
225
|
+
"num": line_num,
|
|
226
|
+
"content": all_lines[i].rstrip("\n\r"),
|
|
227
|
+
"in_range": in_range,
|
|
228
|
+
}
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
sections.append(
|
|
232
|
+
{
|
|
233
|
+
"range": [actual_start, actual_end],
|
|
234
|
+
"original_ranges": original_ranges,
|
|
235
|
+
"lines": lines_with_numbers,
|
|
236
|
+
}
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
return {"file": file_path, "total_lines": total_lines, "sections": sections}
|
|
240
|
+
|
|
241
|
+
def read_symbol(
|
|
242
|
+
self,
|
|
243
|
+
file_path: str,
|
|
244
|
+
start: int,
|
|
245
|
+
end: int,
|
|
246
|
+
include_context: bool = True,
|
|
247
|
+
max_lines: int = 100,
|
|
248
|
+
) -> dict:
|
|
249
|
+
"""Read a symbol (function, class, etc.) with smart truncation.
|
|
250
|
+
|
|
251
|
+
For large symbols, shows signature + beginning + ... + end.
|
|
252
|
+
This prevents large functions from consuming excessive tokens.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
file_path: Path to the file.
|
|
256
|
+
start: Symbol start line (1-indexed).
|
|
257
|
+
end: Symbol end line (1-indexed).
|
|
258
|
+
include_context: Add 2 lines before and 1 after.
|
|
259
|
+
max_lines: Maximum lines before truncation kicks in.
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
Dict with:
|
|
263
|
+
- file: The file path
|
|
264
|
+
- range: [start, end]
|
|
265
|
+
- truncated: Boolean indicating if truncation occurred
|
|
266
|
+
- skipped_lines: Number of lines omitted (if truncated)
|
|
267
|
+
- lines: List of line dicts
|
|
268
|
+
|
|
269
|
+
Example:
|
|
270
|
+
>>> # A 200-line function will be truncated
|
|
271
|
+
>>> result = reader.read_symbol('api.py', 100, 300, max_lines=50)
|
|
272
|
+
>>> print(result['truncated']) # True
|
|
273
|
+
>>> print(result['skipped_lines']) # ~150
|
|
274
|
+
"""
|
|
275
|
+
try:
|
|
276
|
+
path = self._resolve_path(file_path)
|
|
277
|
+
except ValueError as e:
|
|
278
|
+
return {"error": str(e)}
|
|
279
|
+
|
|
280
|
+
if not path.exists():
|
|
281
|
+
return {"error": f"File not found: {file_path}"}
|
|
282
|
+
|
|
283
|
+
try:
|
|
284
|
+
with open(path, encoding="utf-8", errors="replace") as f:
|
|
285
|
+
all_lines = f.readlines()
|
|
286
|
+
except Exception as e:
|
|
287
|
+
return {"error": f"Failed to read file: {e}"}
|
|
288
|
+
|
|
289
|
+
total_lines = len(all_lines)
|
|
290
|
+
start = max(1, start)
|
|
291
|
+
end = min(total_lines, end)
|
|
292
|
+
symbol_length = end - start + 1
|
|
293
|
+
|
|
294
|
+
context_start = max(1, start - 2) if include_context else start
|
|
295
|
+
context_end = min(total_lines, end + 1) if include_context else end
|
|
296
|
+
|
|
297
|
+
if symbol_length <= max_lines:
|
|
298
|
+
# Return full symbol
|
|
299
|
+
lines = []
|
|
300
|
+
for i in range(context_start - 1, context_end):
|
|
301
|
+
if i < len(all_lines):
|
|
302
|
+
lines.append(
|
|
303
|
+
{
|
|
304
|
+
"num": i + 1,
|
|
305
|
+
"content": all_lines[i].rstrip("\n\r"),
|
|
306
|
+
"in_range": start <= (i + 1) <= end,
|
|
307
|
+
}
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
return {"file": file_path, "range": [start, end], "truncated": False, "lines": lines}
|
|
311
|
+
else:
|
|
312
|
+
# Truncate: show beginning and end
|
|
313
|
+
head_lines = max_lines // 2
|
|
314
|
+
tail_lines = max_lines - head_lines - 1
|
|
315
|
+
|
|
316
|
+
lines = []
|
|
317
|
+
|
|
318
|
+
# Context before
|
|
319
|
+
for i in range(context_start - 1, start - 1):
|
|
320
|
+
if i < len(all_lines):
|
|
321
|
+
lines.append(
|
|
322
|
+
{"num": i + 1, "content": all_lines[i].rstrip("\n\r"), "in_range": False}
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
# Head of symbol
|
|
326
|
+
for i in range(start - 1, start - 1 + head_lines):
|
|
327
|
+
if i < len(all_lines):
|
|
328
|
+
lines.append(
|
|
329
|
+
{"num": i + 1, "content": all_lines[i].rstrip("\n\r"), "in_range": True}
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
# Ellipsis marker
|
|
333
|
+
skipped = symbol_length - head_lines - tail_lines
|
|
334
|
+
lines.append(
|
|
335
|
+
{"num": None, "content": f"... ({skipped} lines omitted) ...", "in_range": True}
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
# Tail of symbol
|
|
339
|
+
for i in range(end - tail_lines, end):
|
|
340
|
+
if i < len(all_lines) and i >= 0:
|
|
341
|
+
lines.append(
|
|
342
|
+
{"num": i + 1, "content": all_lines[i].rstrip("\n\r"), "in_range": True}
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
# Context after
|
|
346
|
+
for i in range(end, context_end):
|
|
347
|
+
if i < len(all_lines):
|
|
348
|
+
lines.append(
|
|
349
|
+
{"num": i + 1, "content": all_lines[i].rstrip("\n\r"), "in_range": False}
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
return {
|
|
353
|
+
"file": file_path,
|
|
354
|
+
"range": [start, end],
|
|
355
|
+
"truncated": True,
|
|
356
|
+
"skipped_lines": skipped,
|
|
357
|
+
"lines": lines,
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
def search_in_file(
|
|
361
|
+
self, file_path: str, pattern: str, context: int = 2, max_matches: int = 10
|
|
362
|
+
) -> dict:
|
|
363
|
+
"""Search for a pattern in a file and return matching lines with context.
|
|
364
|
+
|
|
365
|
+
Args:
|
|
366
|
+
file_path: Path to the file.
|
|
367
|
+
pattern: Regex pattern or literal string to search.
|
|
368
|
+
context: Context lines around each match.
|
|
369
|
+
max_matches: Maximum matches to return.
|
|
370
|
+
|
|
371
|
+
Returns:
|
|
372
|
+
Dict with:
|
|
373
|
+
- file: The file path
|
|
374
|
+
- pattern: The search pattern
|
|
375
|
+
- matches: Number of matches found
|
|
376
|
+
- sections: List of matching sections with context
|
|
377
|
+
|
|
378
|
+
Example:
|
|
379
|
+
>>> result = reader.search_in_file('api.py', 'def process')
|
|
380
|
+
>>> print(f"Found {result['matches']} matches")
|
|
381
|
+
"""
|
|
382
|
+
try:
|
|
383
|
+
path = self._resolve_path(file_path)
|
|
384
|
+
except ValueError as e:
|
|
385
|
+
return {"error": str(e)}
|
|
386
|
+
|
|
387
|
+
if not path.exists():
|
|
388
|
+
return {"error": f"File not found: {file_path}"}
|
|
389
|
+
|
|
390
|
+
try:
|
|
391
|
+
with open(path, encoding="utf-8", errors="replace") as f:
|
|
392
|
+
all_lines = f.readlines()
|
|
393
|
+
except Exception as e:
|
|
394
|
+
return {"error": f"Failed to read file: {e}"}
|
|
395
|
+
|
|
396
|
+
# Find matches
|
|
397
|
+
matches = []
|
|
398
|
+
try:
|
|
399
|
+
regex = re.compile(pattern, re.IGNORECASE)
|
|
400
|
+
except re.error as e:
|
|
401
|
+
return {
|
|
402
|
+
"error": f"Invalid regex pattern: {e}",
|
|
403
|
+
"file": file_path,
|
|
404
|
+
"pattern": pattern,
|
|
405
|
+
"matches": 0,
|
|
406
|
+
"sections": [],
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
for i, line in enumerate(all_lines):
|
|
410
|
+
if regex.search(line):
|
|
411
|
+
matches.append(i + 1)
|
|
412
|
+
if len(matches) >= max_matches:
|
|
413
|
+
break
|
|
414
|
+
|
|
415
|
+
if not matches:
|
|
416
|
+
return {"file": file_path, "pattern": pattern, "matches": 0, "sections": []}
|
|
417
|
+
|
|
418
|
+
# Convert matches to ranges with context
|
|
419
|
+
ranges = [(m, m) for m in matches]
|
|
420
|
+
result = self.read_ranges(file_path, ranges, context=context)
|
|
421
|
+
result["pattern"] = pattern
|
|
422
|
+
result["matches"] = len(matches)
|
|
423
|
+
|
|
424
|
+
return result
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def format_output(
|
|
428
|
+
result: dict, style: str = "json", compact: bool = False, no_color: bool = False
|
|
429
|
+
) -> str:
|
|
430
|
+
"""Format the output for display.
|
|
431
|
+
|
|
432
|
+
Args:
|
|
433
|
+
result: The result dict to format.
|
|
434
|
+
style: Output style ('json' or 'code').
|
|
435
|
+
compact: If True, output compact JSON without indentation.
|
|
436
|
+
no_color: If True, disable colored output.
|
|
437
|
+
|
|
438
|
+
Returns:
|
|
439
|
+
Formatted string representation.
|
|
440
|
+
"""
|
|
441
|
+
if style == "json":
|
|
442
|
+
if compact:
|
|
443
|
+
return json.dumps(result, separators=(",", ":"))
|
|
444
|
+
return json.dumps(result, indent=2)
|
|
445
|
+
|
|
446
|
+
elif style == "code":
|
|
447
|
+
c = get_colors(no_color=no_color)
|
|
448
|
+
|
|
449
|
+
if "error" in result:
|
|
450
|
+
return c.error(f"Error: {result['error']}")
|
|
451
|
+
|
|
452
|
+
output = []
|
|
453
|
+
output.append(c.cyan(f"# {result.get('file', 'Unknown file')}"))
|
|
454
|
+
|
|
455
|
+
if "lines" in result:
|
|
456
|
+
lines = result["lines"]
|
|
457
|
+
for line in lines:
|
|
458
|
+
num = line.get("num")
|
|
459
|
+
content = line.get("content", "")
|
|
460
|
+
if num is None:
|
|
461
|
+
# Ellipsis/omitted lines
|
|
462
|
+
output.append(c.dim(f" {content}"))
|
|
463
|
+
else:
|
|
464
|
+
in_range = line.get("in_range")
|
|
465
|
+
marker = c.green(">") if in_range else " "
|
|
466
|
+
line_num = c.cyan(f"{num:4d}")
|
|
467
|
+
if in_range:
|
|
468
|
+
output.append(f"{marker}{line_num} | {content}")
|
|
469
|
+
else:
|
|
470
|
+
# Context lines (dimmed)
|
|
471
|
+
output.append(f"{marker}{line_num} | {c.dim(content)}")
|
|
472
|
+
|
|
473
|
+
elif "sections" in result:
|
|
474
|
+
for i, section in enumerate(result["sections"]):
|
|
475
|
+
if i > 0:
|
|
476
|
+
output.append(c.dim("..."))
|
|
477
|
+
for line in section.get("lines", []):
|
|
478
|
+
num = line.get("num")
|
|
479
|
+
content = line.get("content", "")
|
|
480
|
+
if num is None:
|
|
481
|
+
output.append(c.dim(f" {content}"))
|
|
482
|
+
else:
|
|
483
|
+
in_range = line.get("in_range")
|
|
484
|
+
marker = c.green(">") if in_range else " "
|
|
485
|
+
line_num = c.cyan(f"{num:4d}")
|
|
486
|
+
if in_range:
|
|
487
|
+
output.append(f"{marker}{line_num} | {content}")
|
|
488
|
+
else:
|
|
489
|
+
output.append(f"{marker}{line_num} | {c.dim(content)}")
|
|
490
|
+
|
|
491
|
+
return "\n".join(output)
|
|
492
|
+
|
|
493
|
+
return json.dumps(result)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def add_read_arguments(parser: argparse.ArgumentParser) -> None:
|
|
497
|
+
"""Add read command arguments to a parser.
|
|
498
|
+
|
|
499
|
+
Args:
|
|
500
|
+
parser: The argument parser to add arguments to.
|
|
501
|
+
"""
|
|
502
|
+
parser.add_argument("file", help="Path to the file to read")
|
|
503
|
+
parser.add_argument("lines", nargs="?", help='Line range (e.g., "10", "10-20", "10,20,30-40")')
|
|
504
|
+
parser.add_argument("-r", "--root", help="Root directory for relative paths")
|
|
505
|
+
parser.add_argument(
|
|
506
|
+
"-c", "--context", type=int, default=0, help="Number of context lines (default: 0)"
|
|
507
|
+
)
|
|
508
|
+
parser.add_argument("-s", "--search", help="Search for pattern instead of line numbers")
|
|
509
|
+
parser.add_argument(
|
|
510
|
+
"--symbol", action="store_true", help="Read as symbol with smart truncation"
|
|
511
|
+
)
|
|
512
|
+
parser.add_argument(
|
|
513
|
+
"--max-lines", type=int, default=100, help="Maximum lines before truncation (default: 100)"
|
|
514
|
+
)
|
|
515
|
+
parser.add_argument(
|
|
516
|
+
"-o",
|
|
517
|
+
"--output",
|
|
518
|
+
choices=["json", "code"],
|
|
519
|
+
default="json",
|
|
520
|
+
help="Output format (default: json)",
|
|
521
|
+
)
|
|
522
|
+
parser.add_argument(
|
|
523
|
+
"--compact", action="store_true", help="Output compact JSON (default: pretty-printed)"
|
|
524
|
+
)
|
|
525
|
+
parser.add_argument("--no-color", action="store_true", help="Disable colored output")
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def run_read(args: argparse.Namespace) -> None:
|
|
529
|
+
"""Execute the read command with parsed arguments.
|
|
530
|
+
|
|
531
|
+
Args:
|
|
532
|
+
args: Parsed command-line arguments.
|
|
533
|
+
"""
|
|
534
|
+
reader = LineReader(args.root)
|
|
535
|
+
|
|
536
|
+
if args.search:
|
|
537
|
+
result = reader.search_in_file(args.file, args.search, context=args.context)
|
|
538
|
+
elif args.lines:
|
|
539
|
+
# Parse line specification with validation
|
|
540
|
+
ranges = []
|
|
541
|
+
for part in args.lines.split(","):
|
|
542
|
+
part = part.strip()
|
|
543
|
+
if not part:
|
|
544
|
+
# Skip empty parts (e.g., "10,,20" or trailing comma)
|
|
545
|
+
continue
|
|
546
|
+
try:
|
|
547
|
+
if "-" in part:
|
|
548
|
+
start_str, end_str = part.split("-", 1)
|
|
549
|
+
start = int(start_str.strip())
|
|
550
|
+
end = int(end_str.strip())
|
|
551
|
+
if start < 1:
|
|
552
|
+
result = {
|
|
553
|
+
"error": f"Invalid line number: {start}. Line numbers must be >= 1"
|
|
554
|
+
}
|
|
555
|
+
print(
|
|
556
|
+
format_output(
|
|
557
|
+
result, args.output, compact=args.compact, no_color=args.no_color
|
|
558
|
+
)
|
|
559
|
+
)
|
|
560
|
+
return
|
|
561
|
+
if end < 1:
|
|
562
|
+
result = {"error": f"Invalid line number: {end}. Line numbers must be >= 1"}
|
|
563
|
+
print(
|
|
564
|
+
format_output(
|
|
565
|
+
result, args.output, compact=args.compact, no_color=args.no_color
|
|
566
|
+
)
|
|
567
|
+
)
|
|
568
|
+
return
|
|
569
|
+
if start > end:
|
|
570
|
+
result = {"error": f"Invalid range: {start}-{end}. Start must be <= end"}
|
|
571
|
+
print(
|
|
572
|
+
format_output(
|
|
573
|
+
result, args.output, compact=args.compact, no_color=args.no_color
|
|
574
|
+
)
|
|
575
|
+
)
|
|
576
|
+
return
|
|
577
|
+
ranges.append((start, end))
|
|
578
|
+
else:
|
|
579
|
+
line = int(part)
|
|
580
|
+
if line < 1:
|
|
581
|
+
result = {
|
|
582
|
+
"error": f"Invalid line number: {line}. Line numbers must be >= 1"
|
|
583
|
+
}
|
|
584
|
+
print(
|
|
585
|
+
format_output(
|
|
586
|
+
result, args.output, compact=args.compact, no_color=args.no_color
|
|
587
|
+
)
|
|
588
|
+
)
|
|
589
|
+
return
|
|
590
|
+
ranges.append((line, line))
|
|
591
|
+
except ValueError:
|
|
592
|
+
result = {
|
|
593
|
+
"error": f"Invalid line specification: '{part}'. Expected number or range (e.g., '10' or '10-20')"
|
|
594
|
+
}
|
|
595
|
+
print(
|
|
596
|
+
format_output(result, args.output, compact=args.compact, no_color=args.no_color)
|
|
597
|
+
)
|
|
598
|
+
return
|
|
599
|
+
|
|
600
|
+
if not ranges:
|
|
601
|
+
result = {"error": "No valid line ranges specified"}
|
|
602
|
+
print(format_output(result, args.output, compact=args.compact, no_color=args.no_color))
|
|
603
|
+
return
|
|
604
|
+
|
|
605
|
+
if len(ranges) == 1 and args.symbol:
|
|
606
|
+
result = reader.read_symbol(
|
|
607
|
+
args.file,
|
|
608
|
+
ranges[0][0],
|
|
609
|
+
ranges[0][1],
|
|
610
|
+
include_context=args.context > 0,
|
|
611
|
+
max_lines=args.max_lines,
|
|
612
|
+
)
|
|
613
|
+
elif len(ranges) == 1:
|
|
614
|
+
result = reader.read_lines(args.file, ranges[0][0], ranges[0][1], context=args.context)
|
|
615
|
+
else:
|
|
616
|
+
result = reader.read_ranges(args.file, ranges, context=args.context)
|
|
617
|
+
else:
|
|
618
|
+
# Default: show file info
|
|
619
|
+
try:
|
|
620
|
+
path = reader._resolve_path(args.file)
|
|
621
|
+
except ValueError as e:
|
|
622
|
+
result = {"error": str(e)}
|
|
623
|
+
print(format_output(result, args.output, compact=args.compact, no_color=args.no_color))
|
|
624
|
+
return
|
|
625
|
+
|
|
626
|
+
if path.exists():
|
|
627
|
+
with open(path, encoding="utf-8", errors="replace") as f:
|
|
628
|
+
lines = f.readlines()
|
|
629
|
+
result = {
|
|
630
|
+
"file": args.file,
|
|
631
|
+
"total_lines": len(lines),
|
|
632
|
+
"hint": 'Specify lines to read (e.g., "10-20") or use --search',
|
|
633
|
+
}
|
|
634
|
+
else:
|
|
635
|
+
result = {"error": f"File not found: {args.file}"}
|
|
636
|
+
|
|
637
|
+
print(format_output(result, args.output, compact=args.compact, no_color=args.no_color))
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def main():
|
|
641
|
+
"""Command-line interface for the line reader.
|
|
642
|
+
|
|
643
|
+
Usage:
|
|
644
|
+
code-read FILE LINES [-c CONTEXT] [--symbol] [-o FORMAT]
|
|
645
|
+
code-read FILE --search PATTERN
|
|
646
|
+
|
|
647
|
+
Examples:
|
|
648
|
+
$ code-read src/api.py 45-60 -c 2
|
|
649
|
+
$ code-read src/api.py "10,20-30,50" --symbol
|
|
650
|
+
$ code-read src/api.py --search "def process" -o code
|
|
651
|
+
"""
|
|
652
|
+
parser = argparse.ArgumentParser(
|
|
653
|
+
description="Read specific lines from files for token-efficient code viewing",
|
|
654
|
+
epilog="Example: code-read src/api.py 45-60 -c 2 -o code",
|
|
655
|
+
)
|
|
656
|
+
add_read_arguments(parser)
|
|
657
|
+
parser.add_argument("-v", "--version", action="version", version=f"%(prog)s {__version__}")
|
|
658
|
+
|
|
659
|
+
args = parser.parse_args()
|
|
660
|
+
run_read(args)
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
if __name__ == "__main__":
|
|
664
|
+
main()
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Codenav MCP Server - Model Context Protocol integration.
|
|
2
|
+
|
|
3
|
+
This module exposes Codenav's functionality as MCP tools and resources,
|
|
4
|
+
enabling seamless integration with Claude Code (CLI and VS Code),
|
|
5
|
+
Claude Desktop, and other MCP-compatible AI assistants.
|
|
6
|
+
|
|
7
|
+
Requires the ``mcp`` extra: ``pip install codegraph-nav[mcp]``
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
# Entry point (recommended)
|
|
11
|
+
codegraph-nav-mcp
|
|
12
|
+
|
|
13
|
+
# Or as a Python module
|
|
14
|
+
python -m codegraph_nav.mcp
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
from .server import create_server, main, mcp, run_server
|
|
19
|
+
|
|
20
|
+
MCP_AVAILABLE = True
|
|
21
|
+
except ImportError:
|
|
22
|
+
MCP_AVAILABLE = False
|
|
23
|
+
mcp = None # type: ignore
|
|
24
|
+
create_server = None # type: ignore
|
|
25
|
+
run_server = None # type: ignore
|
|
26
|
+
|
|
27
|
+
def main(): # type: ignore
|
|
28
|
+
raise SystemExit(
|
|
29
|
+
"MCP dependencies not installed. Install with: pip install codegraph-nav[mcp]"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"MCP_AVAILABLE",
|
|
35
|
+
"mcp",
|
|
36
|
+
"create_server",
|
|
37
|
+
"run_server",
|
|
38
|
+
"main",
|
|
39
|
+
]
|