| """Extract structure from JS / TS files using tree-sitter. |
| |
| We use `tree-sitter-languages`, which ships prebuilt grammars so the Space needs |
| no compiler. If tree-sitter is unavailable we fall back to a small regex pass so |
| the app still works (degraded, but never crashes). |
| """ |
| from __future__ import annotations |
|
|
| import re |
|
|
| from schema import Symbol |
|
|
| try: |
| from tree_sitter_languages import get_parser |
| _HAVE_TS = True |
| except Exception: |
| _HAVE_TS = False |
|
|
| _LANG_NAME = {"javascript": "javascript", "typescript": "typescript"} |
|
|
|
|
| def parse(text: str, lang: str) -> tuple[list[Symbol], list[str]]: |
| """Return (symbols, import_targets). Best-effort; never raises.""" |
| if _HAVE_TS: |
| try: |
| return _parse_ts(text, lang) |
| except Exception: |
| pass |
| return _parse_regex(text) |
|
|
|
|
| |
| def _parse_ts(text: str, lang: str) -> tuple[list[Symbol], list[str]]: |
| parser = get_parser(_LANG_NAME.get(lang, "javascript")) |
| blob = text.encode("utf-8", "ignore") |
| root = parser.parse(blob).root_node |
| symbols: list[Symbol] = [] |
| imports: list[str] = [] |
|
|
| def txt(node) -> str: |
| return blob[node.start_byte:node.end_byte].decode("utf-8", "ignore") |
|
|
| def name_of(node) -> str | None: |
| n = node.child_by_field_name("name") |
| return txt(n) if n is not None else None |
|
|
| def visit(node, depth=0): |
| t = node.type |
| if t in ("function_declaration", "generator_function_declaration"): |
| nm = name_of(node) |
| if nm: |
| symbols.append(Symbol(kind="function", name=nm, lineno=node.start_point[0] + 1)) |
| elif t in ("class_declaration", "abstract_class_declaration"): |
| nm = name_of(node) |
| if nm: |
| symbols.append(Symbol(kind="class", name=nm, lineno=node.start_point[0] + 1)) |
| elif t == "lexical_declaration": |
| |
| for child in node.named_children: |
| if child.type == "variable_declarator": |
| val = child.child_by_field_name("value") |
| nm = name_of(child) |
| if nm and val is not None and val.type in ( |
| "arrow_function", "function", "function_expression"): |
| symbols.append(Symbol(kind="function", name=nm, |
| lineno=node.start_point[0] + 1)) |
| elif t == "import_statement": |
| src = node.child_by_field_name("source") |
| if src is not None: |
| imports.append(txt(src).strip("'\"`")) |
| for child in node.named_children: |
| visit(child, depth + 1) |
|
|
| visit(root) |
| |
| imports.extend(_REQUIRE_RE.findall(text)) |
| seen: set[str] = set() |
| uniq = [i for i in imports if i and not (i in seen or seen.add(i))] |
| return symbols, uniq |
|
|
|
|
| |
| _FUNC_RE = re.compile(r"\bfunction\s+([A-Za-z_$][\w$]*)") |
| _ARROW_RE = re.compile(r"\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?\([^)]*\)\s*=>") |
| _CLASS_RE = re.compile(r"\bclass\s+([A-Za-z_$][\w$]*)") |
| _IMPORT_RE = re.compile(r"""\bimport\b[^'"]*['"]([^'"]+)['"]""") |
| _REQUIRE_RE = re.compile(r"""\brequire\(\s*['"]([^'"]+)['"]\s*\)""") |
|
|
|
|
| def _parse_regex(text: str) -> tuple[list[Symbol], list[str]]: |
| symbols: list[Symbol] = [] |
| for m in _FUNC_RE.finditer(text): |
| symbols.append(Symbol(kind="function", name=m.group(1))) |
| for m in _ARROW_RE.finditer(text): |
| symbols.append(Symbol(kind="function", name=m.group(1))) |
| for m in _CLASS_RE.finditer(text): |
| symbols.append(Symbol(kind="class", name=m.group(1))) |
| imports = _IMPORT_RE.findall(text) + _REQUIRE_RE.findall(text) |
| seen: set[str] = set() |
| uniq = [i for i in imports if not (i in seen or seen.add(i))] |
| return symbols, uniq |
|
|