Skip to content

Commit fdf6dcc

Browse files
author
he-yufeng
committed
feat(symbols): outline one file's structure, top to bottom
Add file_outline() plus a /project/{id}/outline endpoint: where the definition index spans the whole project alphabetically, this lists a single file's functions and classes in the order they are written, with each class's methods nested underneath. It is the view a reader wants on opening an unfamiliar file — its shape before its detail. Deterministic, regex-based, no LLM. Covers Python (methods and nested classes nest) and JS/TS (top-level, flat). 9 tests.
1 parent 471a300 commit fdf6dcc

4 files changed

Lines changed: 233 additions & 0 deletions

File tree

backend/models.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -453,6 +453,27 @@ class ReferenceMatches(BaseModel):
453453
notes: list[str] = []
454454

455455

456+
class OutlineNode(BaseModel):
457+
name: str
458+
qualname: str = "" # "Class.method" for methods, plain name otherwise
459+
kind: str # function | class | method
460+
parent: str | None = None # enclosing class for a method
461+
lang: str = "" # python | js
462+
file: str
463+
line: int
464+
children: list["OutlineNode"] = [] # a class's methods (and nested classes)
465+
466+
467+
class FileOutline(BaseModel):
468+
"""One file's structure, in source order — the file's table of contents."""
469+
470+
file: str
471+
lang: str = "" # python | js
472+
total: int = 0 # number of definitions, nested ones included
473+
outline: list[OutlineNode] = []
474+
notes: list[str] = []
475+
476+
456477
class GitHubRequest(BaseModel):
457478
url: str = Field(..., pattern=r"^https?://github\.com/.+/.+")
458479

backend/routers/project.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
FileDependencies,
3737
FileGlossary,
3838
FileInfo,
39+
FileOutline,
3940
GitHubRequest,
4041
GlossaryTerm,
4142
HealthScore,
@@ -648,6 +649,23 @@ async def get_references(project_id: str, name: str):
648649
)
649650

650651

652+
@router.get("/project/{project_id}/outline", response_model=FileOutline)
653+
async def get_file_outline(project_id: str, path: str):
654+
"""Return one file's structure, top to bottom — its table of contents.
655+
656+
Deterministic (no LLM): where the definition index spans the whole project
657+
alphabetically, this lists just the chosen file's functions and classes in
658+
the order they are written, with each class's methods nested underneath, so
659+
a reader can see the shape of an unfamiliar file before its detail.
660+
"""
661+
proj = await _resolve_project(project_id)
662+
if not proj:
663+
raise HTTPException(404, "Project not found")
664+
665+
result = symbols.file_outline(proj.get("file_contents", {}), path)
666+
return FileOutline(**result)
667+
668+
651669
@router.get("/project/{project_id}/codemap.md")
652670
async def get_codemap_markdown(project_id: str):
653671
"""Export the deterministic code map (import-graph analyses) as Markdown."""

backend/services/symbols.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,15 @@ def _indent_width(line: str) -> int:
5757
return len(expanded) - len(expanded.lstrip())
5858

5959

60+
def _count_phrase(n: int, kind: str) -> str:
61+
"""'1 class', '2 classes', '1 function' — grammatical for a plain-language note."""
62+
if kind == "class":
63+
word = "class" if n == 1 else "classes"
64+
else:
65+
word = kind if n == 1 else f"{kind}s"
66+
return f"{n} {word}"
67+
68+
6069
# ---------------------------------------------------------------------------
6170
# Python
6271
# ---------------------------------------------------------------------------
@@ -342,3 +351,94 @@ def _reference_notes(
342351
"missed, and a same-named field on an unrelated object may be counted."
343352
)
344353
return notes
354+
355+
356+
# ---------------------------------------------------------------------------
357+
# File outline — one file's structure, in the order it is written
358+
# ---------------------------------------------------------------------------
359+
360+
361+
def file_outline(file_contents: dict[str, str], path: str, *, limit: int = 1000) -> dict:
362+
"""Return one file's table of contents, in source order.
363+
364+
Where :func:`build_definition_index` flattens the whole project
365+
alphabetically, this answers the question a reader has the moment they open
366+
an unfamiliar file: "what is in here, top to bottom?". Top-level functions
367+
and classes are listed in the order they appear, and a class carries its
368+
methods nested underneath (also in source order), so the shape of the file
369+
is visible before any of its detail.
370+
371+
Returns ``{"file", "lang", "total", "outline", "notes"}``. ``outline`` is a
372+
list of entries (see :func:`_entry`) in line order; an entry that can hold
373+
nested definitions — a class — gains a ``children`` list with its methods
374+
(and any nested classes). ``total`` counts every definition, nested ones
375+
included, before the ``limit`` truncation.
376+
"""
377+
content = file_contents.get(path)
378+
if content is None:
379+
return {
380+
"file": path,
381+
"lang": _lang(path),
382+
"total": 0,
383+
"outline": [],
384+
"notes": [f"'{path}' is not among the analyzed files."],
385+
}
386+
387+
lang = _lang(path)
388+
if lang == "python":
389+
defs = _py_definitions(path, content)
390+
elif lang == "js":
391+
defs = _js_definitions(path, content)
392+
else:
393+
return {
394+
"file": path,
395+
"lang": lang,
396+
"total": 0,
397+
"outline": [],
398+
"notes": ["The outline covers Python and JS/TS files; this file is neither."],
399+
}
400+
401+
total = len(defs)
402+
classes_by_name: dict[str, dict] = {}
403+
outline: list[dict] = []
404+
# Walk in source order so a class is always seen before its own members.
405+
for d in sorted(defs, key=lambda e: e["line"]):
406+
node = dict(d)
407+
parent_node = classes_by_name.get(d["parent"]) if d["parent"] else None
408+
if parent_node is not None:
409+
parent_node["children"].append(node)
410+
else:
411+
# A method whose class is not itself indexed (an orphan) still gets
412+
# surfaced at the top level rather than silently dropped.
413+
outline.append(node)
414+
if d["kind"] == "class":
415+
node["children"] = []
416+
classes_by_name[d["name"]] = node
417+
418+
notes = _outline_notes(path, lang, defs, total)
419+
return {
420+
"file": path,
421+
"lang": lang,
422+
"total": total,
423+
"outline": outline[:limit],
424+
"notes": notes,
425+
}
426+
427+
428+
def _outline_notes(path: str, lang: str, defs: list[dict], total: int) -> list[str]:
429+
name = path.rsplit("/", 1)[-1]
430+
if total == 0:
431+
return [f"No top-level functions or classes were found in {name}."]
432+
433+
by_kind: dict[str, int] = {}
434+
for d in defs:
435+
by_kind[d["kind"]] = by_kind.get(d["kind"], 0) + 1
436+
parts = [_count_phrase(by_kind[k], k) for k in sorted(by_kind)]
437+
notes = [f"{name} defines {', '.join(parts)}, listed in the order they appear."]
438+
if lang == "js":
439+
notes.append(
440+
"For JS/TS, methods inside a class are not broken out yet; the class "
441+
"is listed as a whole."
442+
)
443+
notes.append("Regex-based outline: runtime-built or re-exported names may be missed.")
444+
return notes

tests/test_symbols.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from backend.services.symbols import (
66
build_definition_index,
7+
file_outline,
78
find_definition,
89
find_references,
910
)
@@ -250,3 +251,96 @@ def test_blank_query(self):
250251
result = find_references({"a.py": "x = 1\n"}, " ")
251252
assert result["total"] == 0
252253
assert result["references"] == []
254+
255+
256+
# ---------------------------------------------------------------------------
257+
# File outline — one file's structure, in source order
258+
# ---------------------------------------------------------------------------
259+
260+
261+
class TestFileOutline:
262+
def test_methods_nest_under_their_class(self):
263+
code = """\
264+
class Scanner:
265+
def __init__(self, root):
266+
self.root = root
267+
268+
def scan(self):
269+
return []
270+
271+
272+
def helper(x):
273+
return x
274+
"""
275+
result = file_outline({"scanner.py": code}, "scanner.py")
276+
assert result["lang"] == "python"
277+
assert result["total"] == 4 # class + 2 methods + 1 function
278+
279+
top = result["outline"]
280+
assert [n["name"] for n in top] == ["Scanner", "helper"]
281+
282+
scanner = top[0]
283+
assert scanner["kind"] == "class"
284+
assert [c["name"] for c in scanner["children"]] == ["__init__", "scan"]
285+
assert all(c["kind"] == "method" for c in scanner["children"])
286+
assert top[1]["kind"] == "function"
287+
assert top[1].get("children", []) == [] # only classes carry children
288+
289+
def test_source_order_is_preserved(self):
290+
code = "def a():\n pass\n\n\nclass B:\n pass\n\n\ndef c():\n pass\n"
291+
result = file_outline({"m.py": code}, "m.py")
292+
assert [n["name"] for n in result["outline"]] == ["a", "B", "c"]
293+
assert [n["line"] for n in result["outline"]] == [1, 5, 9]
294+
295+
def test_nested_class_nests_under_its_parent(self):
296+
code = """\
297+
class Outer:
298+
def m(self):
299+
pass
300+
301+
class Inner:
302+
def n(self):
303+
pass
304+
"""
305+
result = file_outline({"m.py": code}, "m.py")
306+
outer = result["outline"][0]
307+
names = [c["name"] for c in outer["children"]]
308+
assert names == ["m", "Inner"]
309+
inner = next(c for c in outer["children"] if c["name"] == "Inner")
310+
assert [c["name"] for c in inner["children"]] == ["n"]
311+
312+
def test_javascript_top_level_in_order(self):
313+
code = "export function load(){}\nconst save = () => {};\nclass Store {}\n"
314+
result = file_outline({"app.ts": code}, "app.ts")
315+
assert result["lang"] == "js"
316+
assert [n["name"] for n in result["outline"]] == ["load", "save", "Store"]
317+
318+
def test_singular_counts_read_naturally(self):
319+
code = "class A:\n def m(self):\n pass\n"
320+
result = file_outline({"a.py": code}, "a.py")
321+
assert "1 class" in result["notes"][0]
322+
assert "1 method" in result["notes"][0]
323+
assert "1 classes" not in result["notes"][0]
324+
325+
def test_missing_path_is_reported(self):
326+
result = file_outline({"a.py": "x = 1\n"}, "b.py")
327+
assert result["total"] == 0
328+
assert result["outline"] == []
329+
assert any("not among the analyzed files" in n for n in result["notes"])
330+
331+
def test_unsupported_language_is_reported(self):
332+
result = file_outline({"notes.md": "# title\n"}, "notes.md")
333+
assert result["total"] == 0
334+
assert any("Python and JS/TS" in n for n in result["notes"])
335+
336+
def test_empty_file_has_no_definitions(self):
337+
result = file_outline({"a.py": "x = 1\ny = 2\n"}, "a.py")
338+
assert result["total"] == 0
339+
assert result["outline"] == []
340+
assert any("No top-level functions or classes" in n for n in result["notes"])
341+
342+
def test_limit_truncates_top_level_but_total_counts_all(self):
343+
code = "".join(f"def f{i}():\n pass\n" for i in range(10))
344+
result = file_outline({"a.py": code}, "a.py", limit=3)
345+
assert result["total"] == 10
346+
assert len(result["outline"]) == 3

0 commit comments

Comments
 (0)