File size: 4,165 Bytes
71d239c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 | """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: # prebuilt grammars; no compiler needed on the Space
from tree_sitter_languages import get_parser
_HAVE_TS = True
except Exception: # pragma: no cover - exercised only when dep missing
_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)
# --- tree-sitter path -------------------------------------------------------
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":
# const Foo = () => {...} / const Foo = function...
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)
# require('x') and dynamic import('x') aren't import_statements — sweep them too.
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
# --- regex fallback ---------------------------------------------------------
_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
|