Skip to content

Commit 9337408

Browse files
committed
feat(symbols): break out JS/TS class methods in the definition index
The Python pass already records methods one indent inside a class, but the JS/TS pass only captured top-level functions, classes and arrow assignments, so a TypeScript class showed up as a single opaque entry. Track an indent frame stack the same way the Python side does and read a method declaration (name + params + opening brace, with TS modifiers/get/set/return types) when it sits directly in a class body. Control-flow heads that also read as `word (...) {` are excluded, and a method's own body is not re-scanned, so the outline now nests JS methods under their class just like Python.
1 parent 2cbef0a commit 9337408

2 files changed

Lines changed: 98 additions & 7 deletions

File tree

backend/services/symbols.py

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@
1313
Python ``def`` / ``async def`` / ``class`` at module level, plus methods
1414
one indent inside a class (recorded with their enclosing class).
1515
JS / TS ``function`` / ``async function`` / ``export function`` / ``class``,
16-
and ``const name = (...) =>`` / ``const name = function`` style
17-
assignments.
16+
``const name = (...) =>`` / ``const name = function`` assignments, and
17+
methods one indent inside a class (recorded with their class).
1818
1919
Each entry carries the name, its kind (function / class / method), the
2020
enclosing class for a method, the file, and a 1-based line number.
@@ -140,28 +140,67 @@ class is a method; a ``def`` nested inside another ``def`` is a local helper
140140
r"^\s*(?:export\s+)?(?:default\s+)?(?:const|let|var)\s+(" + _IDENT + r")\s*=\s*"
141141
r"(?:async\s+)?(?:function\b|\*?\s*\([^)]*\)\s*=>|" + _IDENT + r"\s*=>)"
142142
)
143+
# A method *declaration* inside a class body: optional TS modifiers, an optional
144+
# get/set or generator marker, the name, a parameter list, an optional TS return
145+
# annotation, and the opening brace on the same line. The trailing ``{`` is what
146+
# separates a declaration (``render() {``) from a call (``render();``).
147+
_JS_METHOD_RE = re.compile(
148+
r"^\s*(?:(?:public|private|protected|readonly|static|async|override|abstract)\s+)*"
149+
r"(?:(?:get|set)\s+)?\*?\s*"
150+
r"(?P<name>" + _IDENT + r")\s*\([^;]*\)\s*(?::[^={};]+)?\{"
151+
)
152+
# Control-flow heads also read as ``word (...) {``; never treat them as methods.
153+
_JS_NON_METHODS = frozenset(
154+
{"if", "for", "while", "switch", "catch", "do", "with", "return", "function", "else"}
155+
)
143156

144157

145158
def _js_definitions(path: str, content: str) -> list[dict]:
146159
out: list[dict] = []
160+
# frames: list of (indent, kind, name) for currently open class/block scopes,
161+
# mirroring the Python pass so a method is only read inside a class body and a
162+
# method's own body is not re-scanned for nested "methods".
163+
frames: list[tuple[int, str, str]] = []
164+
147165
for lineno, raw in enumerate(content.splitlines(), start=1):
148166
stripped = raw.strip()
149167
if not stripped or stripped.startswith("//"):
150168
continue
151169

170+
indent = _indent_width(raw)
171+
while frames and frames[-1][0] >= indent:
172+
frames.pop()
173+
enclosing = frames[-1] if frames else None
174+
152175
class_match = _JS_CLASS_RE.match(raw)
153176
if class_match:
154-
out.append(_entry(path, "js", class_match.group(1), "class", None, lineno))
177+
name = class_match.group(1)
178+
parent = enclosing[2] if enclosing and enclosing[1] == "class" else None
179+
out.append(_entry(path, "js", name, "class", parent, lineno))
180+
frames.append((indent, "class", name))
155181
continue
156182

157183
func_match = _JS_FUNC_RE.match(raw)
158184
if func_match:
159-
out.append(_entry(path, "js", func_match.group(1), "function", None, lineno))
185+
name = func_match.group(1)
186+
out.append(_entry(path, "js", name, "function", None, lineno))
187+
frames.append((indent, "block", name))
160188
continue
161189

162190
assign_match = _JS_ASSIGN_RE.match(raw)
163191
if assign_match:
164-
out.append(_entry(path, "js", assign_match.group(1), "function", None, lineno))
192+
name = assign_match.group(1)
193+
out.append(_entry(path, "js", name, "function", None, lineno))
194+
frames.append((indent, "block", name))
195+
continue
196+
197+
if enclosing is not None and enclosing[1] == "class":
198+
method_match = _JS_METHOD_RE.match(raw)
199+
if method_match:
200+
name = method_match.group("name")
201+
if name not in _JS_NON_METHODS:
202+
out.append(_entry(path, "js", name, "method", enclosing[2], lineno))
203+
frames.append((indent, "block", name))
165204

166205
return out
167206

@@ -437,8 +476,9 @@ def _outline_notes(path: str, lang: str, defs: list[dict], total: int) -> list[s
437476
notes = [f"{name} defines {', '.join(parts)}, listed in the order they appear."]
438477
if lang == "js":
439478
notes.append(
440-
"For JS/TS, methods inside a class are not broken out yet; the class "
441-
"is listed as a whole."
479+
"For JS/TS, class methods are broken out when the class body is "
480+
"conventionally indented; arrow-function fields and computed names "
481+
"are still listed under the class as a whole."
442482
)
443483
notes.append("Regex-based outline: runtime-built or re-exported names may be missed.")
444484
return notes

tests/test_symbols.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,57 @@ def test_class_declaration(self):
114114
entry = next(d for d in index["definitions"] if d["name"] == "Widget")
115115
assert entry["kind"] == "class"
116116

117+
def test_class_methods_are_broken_out(self):
118+
code = (
119+
"export class Store {\n"
120+
" constructor(state) {\n"
121+
" this.state = state;\n"
122+
" }\n"
123+
" async load(id) {\n"
124+
" return this.fetch(id);\n"
125+
" }\n"
126+
" get size() {\n"
127+
" return this.state.length;\n"
128+
" }\n"
129+
"}\n"
130+
)
131+
index = build_definition_index({"store.ts": code})
132+
by_name = {d["name"]: d for d in index["definitions"]}
133+
for name in ("constructor", "load", "size"):
134+
assert by_name[name]["kind"] == "method"
135+
assert by_name[name]["parent"] == "Store"
136+
assert by_name[name]["qualname"] == f"Store.{name}"
137+
138+
def test_typescript_return_type_method(self):
139+
code = "class View {\n render(): JSX.Element {\n return null;\n }\n}\n"
140+
index = build_definition_index({"view.tsx": code})
141+
render = next(d for d in index["definitions"] if d["name"] == "render")
142+
assert render["kind"] == "method"
143+
assert render["parent"] == "View"
144+
145+
def test_control_flow_in_method_body_is_not_a_method(self):
146+
# `if (...) {` and a method call inside the body read like `name(...) {`
147+
# / `name();` — neither should be mistaken for a class member.
148+
code = (
149+
"class Engine {\n"
150+
" run(items) {\n"
151+
" if (items.length) {\n"
152+
" this.start();\n"
153+
" }\n"
154+
" }\n"
155+
"}\n"
156+
)
157+
index = build_definition_index({"engine.ts": code})
158+
names = {d["name"] for d in index["definitions"]}
159+
assert names == {"Engine", "run"}
160+
161+
def test_outline_nests_js_methods_under_class(self):
162+
code = "class Box {\n open() {}\n close() {}\n}\n"
163+
result = file_outline({"box.ts": code}, "box.ts")
164+
box = next(n for n in result["outline"] if n["name"] == "Box")
165+
child_names = {c["name"] for c in box["children"]}
166+
assert child_names == {"open", "close"}
167+
117168

118169
# ---------------------------------------------------------------------------
119170
# Index assembly + lookup

0 commit comments

Comments
 (0)