Skip to content

Commit 57d6a29

Browse files
authored
Merge pull request #7 from Codeturion/feature/line-numbers
Add line numbers to all records for targeted source reads
2 parents a1e5f4f + 9365ed3 commit 57d6a29

8 files changed

Lines changed: 193 additions & 33 deletions

File tree

README.md

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -42,25 +42,24 @@ Restart your AI tool and ask: *"What methods does MyService have?"*
4242

4343
Add this to your project's `CLAUDE.md` (or equivalent instructions file). **This step is important.** Without it, the AI has the tools but won't know when to reach for them.
4444

45-
```markdown
45+
````markdown
4646
## Codebase API Lookup (codesurface MCP)
4747

48-
When you need to find a class, method, property, or field — use the codesurface MCP tools BEFORE Grep, Glob, or Read. They return compact, ranked results and save tokens.
49-
50-
| When | Tool | Example |
51-
|------|------|---------|
52-
| Searching for an API by keyword | `search` | `search("MergeService")` |
53-
| Need exact method signature | `get_signature` | `get_signature("TryMerge")` |
54-
| Want all members on a class | `get_class` | `get_class("BlastBoardModel")` |
55-
| Overview of indexed codebase | `get_stats` | `get_stats()` |
56-
| Force refresh after bulk file changes | `reindex` | `reindex()` (auto-refreshes on query misses) |
57-
58-
**Rules:**
59-
- Before looking up a class or method, use `search` or `get_signature` instead of Grep/Glob/Read
60-
- Use `get_class` to see all members on a class instead of reading the source file
61-
- The index auto-refreshes on query misses — no need to manually reindex after editing files
62-
- Only fall back to Grep/Read when you need implementation details (method bodies, control flow) that the API index doesn't cover
63-
```
48+
Use codesurface MCP tools BEFORE Grep, Glob, Read, or Task (subagents) for any class/method/field lookup. This applies to you AND any subagents you spawn.
49+
50+
| Tool | Use when | Example |
51+
|------|----------|---------|
52+
| `search` | Find APIs by keyword | `search("MergeService")` |
53+
| `get_signature` | Need exact signature | `get_signature("TryMerge")` |
54+
| `get_class` | See all members on a class | `get_class("BlastBoardModel")` |
55+
| `get_stats` | Codebase overview | `get_stats()` |
56+
57+
Every result includes file path + line numbers. Use them for targeted reads:
58+
- `File: Service.cs:32``Read("Service.cs", offset=32, limit=15)`
59+
- `File: Converter.java:504-506``Read("Converter.java", offset=504, limit=10)`
60+
61+
Never read a full file when you have a line number. Only fall back to Grep/Read for implementation details (method bodies, control flow).
62+
````
6463

6564
## Tools
6665

@@ -92,6 +91,30 @@ When you need to find a class, method, property, or field — use the codesurfac
9291
| [gin](https://github.com/gin-gonic/gin) | Go | 41 | 574 | <0.1s |
9392
| Unity game (private) | C# | 129 | 1,018 | 0.1s |
9493

94+
## Line Numbers for Targeted Reads
95+
96+
Every record includes `line_start` and `line_end` (1-indexed). Multi-line declarations span the full signature:
97+
98+
```
99+
[METHOD] com.google.common.base.Converter.from
100+
Signature: static Converter<A, B> from(Function<...> forward, Function<...> backward)
101+
File: Converter.java:504-506 ← multi-line signature
102+
103+
[METHOD] server.AlbumController.createAlbum
104+
Signature: createAlbum(@Auth() auth: AuthDto, @Body() dto: CreateAlbumDto)
105+
File: album.controller.ts:46 ← single-line
106+
```
107+
108+
This lets AI agents do **targeted reads** instead of reading full files:
109+
110+
```python
111+
# Instead of reading the entire 600-line file:
112+
Read("Converter.java") # 600 lines, ~12k tokens
113+
114+
# Read just the method + context:
115+
Read("Converter.java", offset=504, limit=10) # 10 lines, ~200 tokens
116+
```
117+
95118
## Benchmarks
96119

97120
Measured against a real Unity game project (129 files, 1,018 API records) across a 10-step cross-cutting research workflow.

src/codesurface/db.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ def _build_search_text(record: dict) -> str:
5858
params_json TEXT NOT NULL DEFAULT '[]',
5959
returns_text TEXT NOT NULL DEFAULT '',
6060
file_path TEXT NOT NULL DEFAULT '',
61+
line_start INTEGER NOT NULL DEFAULT 0,
62+
line_end INTEGER NOT NULL DEFAULT 0,
6163
search_text TEXT NOT NULL DEFAULT ''
6264
);
6365
@@ -99,8 +101,9 @@ def insert_records(conn: sqlite3.Connection, records: list[dict]) -> int:
99101
sql = """
100102
INSERT OR REPLACE INTO api_records
101103
(fqn, namespace, class_name, member_name, member_type,
102-
signature, summary, params_json, returns_text, file_path, search_text)
103-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
104+
signature, summary, params_json, returns_text, file_path,
105+
line_start, line_end, search_text)
106+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
104107
"""
105108
rows = [
106109
(
@@ -114,6 +117,8 @@ def insert_records(conn: sqlite3.Connection, records: list[dict]) -> int:
114117
json.dumps(r.get("params_json", [])),
115118
r.get("returns_text", ""),
116119
r.get("file_path", ""),
120+
r.get("line_start", 0),
121+
r.get("line_end", 0),
117122
_build_search_text(r),
118123
)
119124
for r in records

src/codesurface/parsers/csharp.py

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,8 @@ def _parse_cs_file(path: Path, base_dir: Path) -> list[dict]:
200200
params_json=doc.get("params", []),
201201
returns_text="",
202202
file_path=rel_path,
203+
line_start=i + 1,
204+
line_end=i + 1,
203205
))
204206

205207
# For enums, extract members
@@ -248,6 +250,8 @@ def _parse_cs_file(path: Path, base_dir: Path) -> list[dict]:
248250
params_json=[],
249251
returns_text="",
250252
file_path=rel_path,
253+
line_start=i + 1,
254+
line_end=i + 1,
251255
))
252256
brace_depth = new_depth
253257
i += 1
@@ -258,7 +262,7 @@ def _parse_cs_file(path: Path, base_dir: Path) -> list[dict]:
258262
# Try constructor first
259263
ctor_match = _CTOR_RE.match(line)
260264
if ctor_match and ctor_match.group(1) == current_class:
261-
params_str = _collect_params(lines, i, ctor_match.group(2))
265+
params_str, end_i = _collect_params(lines, i, ctor_match.group(2))
262266
doc = _look_back_for_doc(lines, i)
263267
sig = f"{current_class}({params_str})"
264268
records.append(_build_record(
@@ -272,6 +276,8 @@ def _parse_cs_file(path: Path, base_dir: Path) -> list[dict]:
272276
params_json=doc.get("params", []),
273277
returns_text="",
274278
file_path=rel_path,
279+
line_start=i + 1,
280+
line_end=end_i + 1,
275281
))
276282
brace_depth = new_depth
277283
i += 1
@@ -281,7 +287,7 @@ def _parse_cs_file(path: Path, base_dir: Path) -> list[dict]:
281287
if method_match:
282288
ret_type = method_match.group(1).strip()
283289
meth_name = method_match.group(2)
284-
params_str = _collect_params(lines, i, method_match.group(3))
290+
params_str, end_i = _collect_params(lines, i, method_match.group(3))
285291
if meth_name not in _SKIP_NAMES:
286292
doc = _look_back_for_doc(lines, i)
287293
sig = f"{ret_type} {meth_name}({params_str})"
@@ -296,6 +302,8 @@ def _parse_cs_file(path: Path, base_dir: Path) -> list[dict]:
296302
params_json=doc.get("params", []),
297303
returns_text=doc.get("returns", ""),
298304
file_path=rel_path,
305+
line_start=i + 1,
306+
line_end=end_i + 1,
299307
))
300308
brace_depth = new_depth
301309
i += 1
@@ -323,6 +331,8 @@ def _parse_cs_file(path: Path, base_dir: Path) -> list[dict]:
323331
params_json=[],
324332
returns_text="",
325333
file_path=rel_path,
334+
line_start=i + 1,
335+
line_end=i + 1,
326336
))
327337
brace_depth = new_depth
328338
i += 1
@@ -351,6 +361,8 @@ def _parse_cs_file(path: Path, base_dir: Path) -> list[dict]:
351361
params_json=[],
352362
returns_text="",
353363
file_path=rel_path,
364+
line_start=i + 1,
365+
line_end=i + 1,
354366
))
355367
brace_depth = new_depth
356368
i += 1
@@ -402,6 +414,8 @@ def _try_parse_interface_member(
402414
params_json=doc.get("params", []),
403415
returns_text=doc.get("returns", ""),
404416
file_path=file_path,
417+
line_start=idx + 1,
418+
line_end=idx + 1,
405419
)
406420

407421
# Interface property
@@ -424,6 +438,8 @@ def _try_parse_interface_member(
424438
params_json=[],
425439
returns_text="",
426440
file_path=file_path,
441+
line_start=idx + 1,
442+
line_end=idx + 1,
427443
)
428444

429445
return None
@@ -467,6 +483,8 @@ def _parse_enum_members(
467483
params_json=[],
468484
returns_text="",
469485
file_path=file_path,
486+
line_start=i + 1,
487+
line_end=i + 1,
470488
))
471489
return records
472490

@@ -518,23 +536,29 @@ def _look_back_for_doc(lines: list[str], decl_idx: int) -> dict:
518536
return result
519537

520538

521-
def _collect_params(lines: list[str], line_idx: int, initial: str) -> str:
522-
"""Collect parameters that may span multiple lines."""
539+
def _collect_params(lines: list[str], line_idx: int, initial: str) -> tuple[str, int]:
540+
"""Collect parameters that may span multiple lines.
541+
542+
Returns (params_str, end_line_idx) where end_line_idx is the 0-based
543+
index of the last line consumed by the parameter list.
544+
"""
523545
params = initial.strip()
524546
if ")" in lines[line_idx]:
525-
return params
547+
return re.sub(r"\s+", " ", params).strip(), line_idx
526548

527549
# Multi-line params -- collect until closing paren
550+
end = line_idx
528551
for j in range(line_idx + 1, min(line_idx + 50, len(lines))):
529552
part = lines[j].strip()
530553
params += " " + part
554+
end = j
531555
if ")" in part:
532556
# Trim at closing paren
533557
paren_idx = params.index(")")
534558
params = params[:paren_idx]
535559
break
536560

537-
return re.sub(r"\s+", " ", params).strip()
561+
return re.sub(r"\s+", " ", params).strip(), end
538562

539563

540564
def _extract_accessors(line: str) -> str:

src/codesurface/parsers/go.py

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -429,7 +429,7 @@ def _parse_go_file(path: Path, base_dir: Path) -> list[dict]:
429429
method_name = method_m.group(3)
430430

431431
if _is_exported(method_name) and _is_exported(receiver_type):
432-
full_sig, _ = _collect_signature(lines, i)
432+
full_sig, end_i = _collect_signature(lines, i)
433433
params_str, returns_str = _extract_func_parts(full_sig, method_name)
434434
doc = _look_back_for_doc_comment(lines, i)
435435

@@ -446,6 +446,8 @@ def _parse_go_file(path: Path, base_dir: Path) -> list[dict]:
446446
signature=sig,
447447
summary=doc,
448448
file_path=rel_path,
449+
line_start=i + 1,
450+
line_end=end_i + 1,
449451
))
450452

451453
brace_depth = new_depth
@@ -458,7 +460,7 @@ def _parse_go_file(path: Path, base_dir: Path) -> list[dict]:
458460
func_name = func_m.group(1)
459461

460462
if _is_exported(func_name):
461-
full_sig, _ = _collect_signature(lines, i)
463+
full_sig, end_i = _collect_signature(lines, i)
462464
params_str, returns_str = _extract_func_parts(full_sig, func_name)
463465
doc = _look_back_for_doc_comment(lines, i)
464466

@@ -475,6 +477,8 @@ def _parse_go_file(path: Path, base_dir: Path) -> list[dict]:
475477
signature=sig,
476478
summary=doc,
477479
file_path=rel_path,
480+
line_start=i + 1,
481+
line_end=end_i + 1,
478482
))
479483

480484
brace_depth = new_depth
@@ -500,6 +504,8 @@ def _parse_go_file(path: Path, base_dir: Path) -> list[dict]:
500504
signature=sig,
501505
summary=doc,
502506
file_path=rel_path,
507+
line_start=i + 1,
508+
line_end=i + 1,
503509
))
504510

505511
brace_depth = new_depth
@@ -527,6 +533,8 @@ def _parse_go_file(path: Path, base_dir: Path) -> list[dict]:
527533
signature=sig,
528534
summary=doc,
529535
file_path=rel_path,
536+
line_start=i + 1,
537+
line_end=i + 1,
530538
))
531539
if "{" in line:
532540
current_type = type_name
@@ -545,6 +553,8 @@ def _parse_go_file(path: Path, base_dir: Path) -> list[dict]:
545553
signature=sig,
546554
summary=doc,
547555
file_path=rel_path,
556+
line_start=i + 1,
557+
line_end=i + 1,
548558
))
549559
if "{" in line:
550560
current_type = type_name
@@ -565,6 +575,8 @@ def _parse_go_file(path: Path, base_dir: Path) -> list[dict]:
565575
signature=sig,
566576
summary=doc,
567577
file_path=rel_path,
578+
line_start=i + 1,
579+
line_end=i + 1,
568580
))
569581

570582
brace_depth = new_depth
@@ -590,6 +602,8 @@ def _parse_go_file(path: Path, base_dir: Path) -> list[dict]:
590602
signature=sig,
591603
summary=doc,
592604
file_path=rel_path,
605+
line_start=i + 1,
606+
line_end=i + 1,
593607
))
594608

595609
brace_depth = new_depth
@@ -622,6 +636,8 @@ def _parse_go_file(path: Path, base_dir: Path) -> list[dict]:
622636
signature=sig,
623637
summary=doc,
624638
file_path=rel_path,
639+
line_start=i + 1,
640+
line_end=i + 1,
625641
))
626642

627643
brace_depth = new_depth
@@ -692,6 +708,8 @@ def _try_parse_struct_field(
692708
signature=sig,
693709
summary=doc,
694710
file_path=file_path,
711+
line_start=idx + 1,
712+
line_end=idx + 1,
695713
))
696714

697715

@@ -721,7 +739,7 @@ def _try_parse_interface_method(
721739
if not _is_exported(method_name):
722740
return
723741

724-
full_sig, _ = _collect_signature(lines, idx)
742+
full_sig, end_i = _collect_signature(lines, idx)
725743
params_str, returns_str = _extract_iface_method_parts(full_sig, method_name)
726744
doc = _look_back_for_doc_comment(lines, idx)
727745

@@ -738,6 +756,8 @@ def _try_parse_interface_method(
738756
signature=sig,
739757
summary=doc,
740758
file_path=file_path,
759+
line_start=idx + 1,
760+
line_end=end_i + 1,
741761
))
742762

743763

@@ -771,6 +791,8 @@ def _parse_group_type_entry(
771791
signature=sig,
772792
summary=doc,
773793
file_path=file_path,
794+
line_start=idx + 1,
795+
line_end=idx + 1,
774796
))
775797
return
776798

@@ -799,6 +821,8 @@ def _parse_group_type_entry(
799821
signature=sig,
800822
summary=doc,
801823
file_path=file_path,
824+
line_start=idx + 1,
825+
line_end=idx + 1,
802826
))
803827
elif rest.startswith("interface"):
804828
sig = f"type {name} interface"
@@ -812,6 +836,8 @@ def _parse_group_type_entry(
812836
signature=sig,
813837
summary=doc,
814838
file_path=file_path,
839+
line_start=idx + 1,
840+
line_end=idx + 1,
815841
))
816842
else:
817843
underlying = rest.rstrip("{").strip()
@@ -826,6 +852,8 @@ def _parse_group_type_entry(
826852
signature=sig,
827853
summary=doc,
828854
file_path=file_path,
855+
line_start=idx + 1,
856+
line_end=idx + 1,
829857
))
830858

831859

@@ -884,6 +912,8 @@ def _parse_group_var_const_entry(
884912
signature=sig,
885913
summary=doc,
886914
file_path=file_path,
915+
line_start=idx + 1,
916+
line_end=idx + 1,
887917
))
888918

889919

0 commit comments

Comments
 (0)