diff --git a/app/db/models.py b/app/db/models.py index 4364430..45c7e1e 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -26,7 +26,7 @@ from sqlalchemy.dialects.postgresql import ARRAY from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship -INDEX_SEMANTICS_VERSION = 3 +INDEX_SEMANTICS_VERSION = 4 """Version of the indexing semantics the current code produces. 2: semantic search default-on -- every already-indexed branch must re-index once so @@ -37,6 +37,10 @@ ``reference_edges`` backfills; without a bump a branch at HEAD would skip forever and never get edges. +4: reference edges for JS/TS/TSX/Go/Java/Rust -- every already-indexed branch +must re-index once so multi-language ``reference_edges`` backfill; without a +bump a branch at HEAD would skip forever and never get non-Python edges. + Bump this whenever the *meaning* of what gets written changes: any change to ``indexer/symbols.py``, to ``indexer/parse.py``'s chunking, or to ``indexer/languages.py``'s extraction contract. A bump forces every repo to diff --git a/indexer/languages.py b/indexer/languages.py index 49fbc7d..5eca56a 100644 --- a/indexer/languages.py +++ b/indexer/languages.py @@ -5,8 +5,7 @@ never disagree on language names. Language values MUST be valid ``tree_sitter_language_pack`` parser names. ``EDGE_NODE_KINDS`` maps, per language, tree-sitter node ``.type`` -> reference-edge kind (``call``/``import``); -a language absent from the map yields zero edges (Python only for #84; #85 -adds the rest). +a language absent from the map yields zero edges. """ from __future__ import annotations @@ -79,13 +78,41 @@ # Per language: tree-sitter node ``.type`` -> reference-edge kind stored in # ``reference_edges``. Every value MUST be within the DB CHECK set # (``ReferenceEdge.__table__``'s ``ck_reference_edges_edge_kind``), enforced by -# a unit test. Python only for #84 -- #85 adds the other six languages. +# a unit test. EDGE_NODE_KINDS: dict[str, dict[str, str]] = { "python": { "call": "call", "import_statement": "import", "import_from_statement": "import", }, + "javascript": { + "call_expression": "call", # f(x), a.b.f(), obj?.m() + "new_expression": "call", # new Foo() -> constructor reference + "import_statement": "import", # ES imports (all specifier shapes) + }, + "typescript": { + "call_expression": "call", # incl. f(x) + "new_expression": "call", + "import_statement": "import", # incl. import type / import x = require(...) + }, + "tsx": { + "call_expression": "call", + "new_expression": "call", + "import_statement": "import", + }, + "go": { + "call_expression": "call", # f(), pkg.F(), obj.Method(); go/defer wrap this + "import_spec": "import", # per-spec node -> per-import anchors, single & grouped + }, + "java": { + "method_invocation": "call", # f(), obj.m(), C.stat(), this.n(), super.s(), obj.m() + "object_creation_expression": "call", # new Foo(), new a.b.Foo(), new Foo() + "import_declaration": "import", # import a.b.C; static; a.b.* + }, + "rust": { + "call_expression": "call", # f(), a::b::g(), Foo::new(), x.method() + "use_declaration": "import", # use trees: scoped, grouped, nested, self, wildcard, as + }, } diff --git a/indexer/symbols.py b/indexer/symbols.py index a50d466..ae9efe0 100644 --- a/indexer/symbols.py +++ b/indexer/symbols.py @@ -16,6 +16,7 @@ from __future__ import annotations import threading +from collections.abc import Callable from typing import Any from tree_sitter_language_pack import get_parser @@ -81,6 +82,10 @@ def extract_file(pf: ParsedFile) -> FileExtraction: pushing a same-enclosing child run via ``[enclosing] * len(children)`` is a single C-level list replication instead of N per-child tuple allocations, measurably cheaper for the common case (most nodes don't change the enclosing). + + Call/import extraction is dispatched per-language via ``_EDGE_EXTRACTORS``, + resolved once per file (not per node) immediately after the combined map is + found, since that lookup already guarantees ``lang in SYMBOL_KINDS``. """ lang = pf.lang if lang is None: @@ -88,6 +93,7 @@ def extract_file(pf: ParsedFile) -> FileExtraction: combined = _combined_kinds(lang) if combined is None: return FileExtraction(symbols=[], edges=[]) + call_edge, import_edges = _EDGE_EXTRACTORS[lang] tree = _parser_for(lang).parse(pf.content.encode("utf-8")) symbols: list[ExtractedSymbol] = [] @@ -115,11 +121,11 @@ def extract_file(pf: ParsedFile) -> FileExtraction: symbols.append(symbol) child_enclosing = symbol elif kind == "call": - edge = _python_call_edge(node, enclosing) + edge = call_edge(node, enclosing) if edge is not None: edges.append(edge) else: # kind == "import" - edges.extend(_python_import_edges(node, enclosing)) + edges.extend(import_edges(node, enclosing)) children = node.children if children: @@ -258,3 +264,372 @@ def _python_import_edges(node: Any, enclosing: ExtractedSymbol | None) -> list[E ) ) return edges + + +def _js_string_fragment_text(string_node: Any) -> str: + """Unquoted text of a JS/TS ``string`` node (its ``string_fragment`` child).""" + frag = next((c for c in string_node.children if c.type == "string_fragment"), None) + return frag.text.decode("utf-8") if frag is not None and frag.text is not None else "" + + +def _js_call_edge(node: Any, enclosing: ExtractedSymbol | None) -> ExtractedEdge | None: + """Rightmost-name target for a JS/TS/TSX ``call_expression``/``new_expression`` (#85). + + Callee field is ``function`` for calls, ``constructor`` for ``new``. A bare + ``identifier`` callee is its own target (``f()``, ``require(...)``); a + ``member_expression`` callee targets its ``property`` field, unaffected by an + optional chain (``a.b.f()``/``obj?.m()`` -> ``f``/``m``; ``new a.b.Foo()`` -> + ``Foo``). Any other callee shape -- subscript, an outer call-of-call, or the + ``import`` keyword node of a dynamic ``import(...)`` -- is skipped. + """ + field = "constructor" if node.type == "new_expression" else "function" + func = node.child_by_field_name(field) + if func is None: + return None + target: str | None = None + if func.type == "identifier": + target = func.text.decode("utf-8") if func.text is not None else None + elif func.type == "member_expression": + prop = func.child_by_field_name("property") + if prop is not None and prop.text is not None: + target = prop.text.decode("utf-8") + if target is None: + return None + return ExtractedEdge( + kind="call", target=target, line=node.start_point[0] + 1, enclosing=enclosing + ) + + +def _js_import_edges(node: Any, enclosing: ExtractedSymbol | None) -> list[ExtractedEdge]: + """Edges for one JS/TS/TSX ``import_statement`` (#85), per the A8 anchoring rule. + + TS ``import x = require('legacy')`` is handled first via its + ``import_require_clause`` child (its own ``source`` field), anchored at the + statement line. Otherwise the statement's own ``source`` field gives the + module string; an empty source or missing ``import_clause`` (side-effect + import) yields a single statement-anchored edge for the bare module (or none, + for an empty source). Within a clause: a bare ``identifier`` (default import) + or a ``namespace_import`` each yield one statement-anchored edge targeting the + module; each ``import_specifier`` in a ``named_imports`` block yields one + specifier-anchored edge (alias ignored -- D5) targeting ``module.name``. + """ + stmt_line = node.start_point[0] + 1 + require_clause = next((c for c in node.children if c.type == "import_require_clause"), None) + if require_clause is not None: + req_source = require_clause.child_by_field_name("source") + if req_source is None: + return [] + target = _js_string_fragment_text(req_source) + if not target: + return [] + return [ExtractedEdge(kind="import", target=target, line=stmt_line, enclosing=enclosing)] + + source_node = node.child_by_field_name("source") + if source_node is None: + return [] + source = _js_string_fragment_text(source_node) + if not source: + return [] + + clause = next((c for c in node.children if c.type == "import_clause"), None) + if clause is None: + return [ExtractedEdge(kind="import", target=source, line=stmt_line, enclosing=enclosing)] + + edges: list[ExtractedEdge] = [] + for child in clause.children: + if child.type in ("identifier", "namespace_import"): + edges.append( + ExtractedEdge(kind="import", target=source, line=stmt_line, enclosing=enclosing) + ) + elif child.type == "named_imports": + for spec in child.children: + if spec.type != "import_specifier": + continue + name_node = spec.child_by_field_name("name") + if name_node is None or name_node.text is None: + continue + edges.append( + ExtractedEdge( + kind="import", + target=f"{source}.{name_node.text.decode('utf-8')}", + line=spec.start_point[0] + 1, + enclosing=enclosing, + ) + ) + return edges + + +def _go_call_edge(node: Any, enclosing: ExtractedSymbol | None) -> ExtractedEdge | None: + """Rightmost-name target for a Go ``call_expression`` (#85). + + ``function`` field ``identifier`` -> its own text (``f()``); ``selector_expression`` + -> its ``field`` field text (``pkg.F()`` -> ``F``, ``obj.Method()`` -> ``Method``). + ``go``/``defer`` wrap an ordinary inner ``call_expression``, so they need no + special-casing here -- the walk visits the inner node directly. + """ + func = node.child_by_field_name("function") + if func is None: + return None + target: str | None = None + if func.type == "identifier": + target = func.text.decode("utf-8") if func.text is not None else None + elif func.type == "selector_expression": + field = func.child_by_field_name("field") + if field is not None and field.text is not None: + target = field.text.decode("utf-8") + if target is None: + return None + return ExtractedEdge( + kind="call", target=target, line=node.start_point[0] + 1, enclosing=enclosing + ) + + +def _go_import_edges(node: Any, enclosing: ExtractedSymbol | None) -> list[ExtractedEdge]: + """One edge per Go ``import_spec`` (#85), mapped instead of ``import_declaration``. + + Per-spec node -> per-import anchors for both single and grouped + (``import ( ... )``) forms; an empty group yields zero ``import_spec`` nodes and + needs no special-casing. Target is the *interior* text of the ``path`` field's + string-literal-content child (A7, binding) -- not ``node.text``, which includes + the quotes/backticks. The optional ``name`` field (alias, ``.``, ``_``) is + ignored (D5): dot/blank imports still target the package path. + """ + path_node = node.child_by_field_name("path") + if path_node is None: + return [] + content = next( + ( + c + for c in path_node.children + if c.type in ("interpreted_string_literal_content", "raw_string_literal_content") + ), + None, + ) + if content is not None and content.text is not None: + target = content.text.decode("utf-8") + elif path_node.text is not None: + target = path_node.text.decode("utf-8").strip('"`') + else: + target = "" + if not target: + return [] + return [ + ExtractedEdge( + kind="import", target=target, line=node.start_point[0] + 1, enclosing=enclosing + ) + ] + + +def _java_type_name(type_node: Any) -> str | None: + """Rightmost simple type name for a Java ``object_creation_expression`` target (A1). + + A ``generic_type`` first descends to its underlying type node (its first named + child, dropping ``type_arguments``). A ``type_identifier`` is its own text + (``Foo``). A ``scoped_type_identifier`` has no ``name`` field -- the grammar + exposes its segments as *unnamed* ``type_identifier`` children -- so the target + is the text of the **last** such child (``a.b.Foo`` -> ``Foo``). Any other shape + is skipped. + """ + if type_node.type == "generic_type": + inner = type_node.named_children[0] if type_node.named_children else None + if inner is None: + return None + type_node = inner + if type_node.type == "type_identifier": + return type_node.text.decode("utf-8") if type_node.text is not None else None + if type_node.type == "scoped_type_identifier": + last = None + for child in type_node.children: + if child.type == "type_identifier": + last = child + return last.text.decode("utf-8") if last is not None and last.text is not None else None + return None + + +def _java_call_edge(node: Any, enclosing: ExtractedSymbol | None) -> ExtractedEdge | None: + """Target for a Java ``method_invocation``/``object_creation_expression`` (#85). + + ``method_invocation`` -> its ``name`` field text, regardless of the optional + ``object`` field or generic ``type_arguments`` (``obj.m()`` -> ``m``). + ``object_creation_expression`` -> :func:`_java_type_name` of its ``type`` field + (A1). + """ + if node.type == "method_invocation": + name = node.child_by_field_name("name") + if name is None or name.text is None: + return None + target: str | None = name.text.decode("utf-8") + else: # object_creation_expression + type_node = node.child_by_field_name("type") + target = _java_type_name(type_node) if type_node is not None else None + if target is None: + return None + return ExtractedEdge( + kind="call", target=target, line=node.start_point[0] + 1, enclosing=enclosing + ) + + +def _java_import_edges(node: Any, enclosing: ExtractedSymbol | None) -> list[ExtractedEdge]: + """One edge per Java ``import_declaration`` (#85) -- no grouping in this grammar. + + Target is the text of the statement's ``scoped_identifier`` (or bare + ``identifier``) child, as written: plain (``a.b.C``), static (``a.b.C.m`` -- + the full text already includes the member), and wildcard (``a.b`` -- the + package; the sibling ``asterisk`` node carries no field and is ignored). + """ + ident = next((c for c in node.children if c.type in ("scoped_identifier", "identifier")), None) + if ident is None or ident.text is None: + return [] + return [ + ExtractedEdge( + kind="import", + target=ident.text.decode("utf-8"), + line=node.start_point[0] + 1, + enclosing=enclosing, + ) + ] + + +def _rust_call_edge(node: Any, enclosing: ExtractedSymbol | None) -> ExtractedEdge | None: + """Rightmost-name target for a Rust ``call_expression`` (#85). + + ``function`` field ``identifier`` -> its own text (``f()``); ``scoped_identifier`` + -> its ``name`` field, rightmost (``a::b::g()`` -> ``g``, ``Foo::new()`` -> ``new``); + ``field_expression`` -> its ``field`` field (``x.method()`` -> ``method``). Any + other callee -- notably ``macro_invocation`` (``println!(...)``), which is + unmapped and so never even reaches here -- is skipped. + """ + func = node.child_by_field_name("function") + if func is None: + return None + target: str | None = None + if func.type == "identifier": + target = func.text.decode("utf-8") if func.text is not None else None + elif func.type == "scoped_identifier": + name = func.child_by_field_name("name") + if name is not None and name.text is not None: + target = name.text.decode("utf-8") + elif func.type == "field_expression": + field = func.child_by_field_name("field") + if field is not None and field.text is not None: + target = field.text.decode("utf-8") + if target is None: + return None + return ExtractedEdge( + kind="call", target=target, line=node.start_point[0] + 1, enclosing=enclosing + ) + + +def _rust_join_path(prefix: str, segment: str) -> str: + """Join a Rust use-tree ``prefix`` accumulator to one more path ``segment``.""" + return segment if not prefix else f"{prefix}::{segment}" + + +def _rust_use_tree_edges( + node: Any, prefix: str, enclosing: ExtractedSymbol | None +) -> list[ExtractedEdge]: + """Recursive use-tree descent for one node of a Rust ``use_declaration`` (A2/A3). + + ``prefix`` is the accumulated path text from enclosing ``scoped_use_list`` + levels (``""`` at the top). A leaf ``identifier``/``scoped_identifier`` emits + ``join(prefix, its text)``; a ``use_as_clause`` emits ``join(prefix, path-field + text)`` (alias ignored -- D5); a ``use_wildcard`` emits ``join(prefix, inner + path text)`` if it has an inner path child, else ``prefix`` unchanged; a bare + ``self`` node (only reachable as a ``use_list`` item) emits ``prefix`` + unchanged; a ``scoped_use_list`` extends the prefix with its own ``path`` field + and recurses into each named child of its ``list``. Every edge anchors at its + own leaf node's start line. + """ + if node.type == "self": + return ( + [ + ExtractedEdge( + kind="import", target=prefix, line=node.start_point[0] + 1, enclosing=enclosing + ) + ] + if prefix + else [] + ) + if node.type in ("identifier", "scoped_identifier"): + if node.text is None: + return [] + return [ + ExtractedEdge( + kind="import", + target=_rust_join_path(prefix, node.text.decode("utf-8")), + line=node.start_point[0] + 1, + enclosing=enclosing, + ) + ] + if node.type == "use_as_clause": + path_node = node.child_by_field_name("path") + if path_node is None or path_node.text is None: + return [] + return [ + ExtractedEdge( + kind="import", + target=_rust_join_path(prefix, path_node.text.decode("utf-8")), + line=node.start_point[0] + 1, + enclosing=enclosing, + ) + ] + if node.type == "use_wildcard": + inner = next( + (c for c in node.children if c.type in ("identifier", "scoped_identifier")), None + ) + target = ( + _rust_join_path(prefix, inner.text.decode("utf-8")) + if inner is not None and inner.text is not None + else prefix + ) + if not target: + return [] + return [ + ExtractedEdge( + kind="import", target=target, line=node.start_point[0] + 1, enclosing=enclosing + ) + ] + if node.type == "scoped_use_list": + path_node = node.child_by_field_name("path") + new_prefix = ( + _rust_join_path(prefix, path_node.text.decode("utf-8")) + if path_node is not None and path_node.text is not None + else prefix + ) + list_node = node.child_by_field_name("list") + edges: list[ExtractedEdge] = [] + if list_node is not None: + for child in list_node.named_children: + edges.extend(_rust_use_tree_edges(child, new_prefix, enclosing)) + return edges + if node.type == "use_list": + # A prefix-less group -- ``use {std::io, std::fmt};`` -- is a bare + # ``use_list`` with no enclosing ``scoped_use_list``; each item keeps the + # current (possibly empty) prefix unchanged. + edges = [] + for child in node.named_children: + edges.extend(_rust_use_tree_edges(child, prefix, enclosing)) + return edges + return [] + + +def _rust_import_edges(node: Any, enclosing: ExtractedSymbol | None) -> list[ExtractedEdge]: + """Edges for one Rust ``use_declaration`` (#85): recurse its ``argument`` use-tree.""" + argument = node.child_by_field_name("argument") + if argument is None: + return [] + return _rust_use_tree_edges(argument, "", enclosing) + + +CallEdgeFn = Callable[[Any, "ExtractedSymbol | None"], "ExtractedEdge | None"] +ImportEdgesFn = Callable[[Any, "ExtractedSymbol | None"], list["ExtractedEdge"]] + +_EDGE_EXTRACTORS: dict[str, tuple[CallEdgeFn, ImportEdgesFn]] = { + "python": (_python_call_edge, _python_import_edges), + "javascript": (_js_call_edge, _js_import_edges), + "typescript": (_js_call_edge, _js_import_edges), + "tsx": (_js_call_edge, _js_import_edges), + "go": (_go_call_edge, _go_import_edges), + "java": (_java_call_edge, _java_import_edges), + "rust": (_rust_call_edge, _rust_import_edges), +} diff --git a/tests/unit/test_edges.py b/tests/unit/test_edges.py index ff769e0..6be8723 100644 --- a/tests/unit/test_edges.py +++ b/tests/unit/test_edges.py @@ -16,8 +16,8 @@ def _pf(content: str, lang: str | None = "python") -> ParsedFile: return ParsedFile(path="x.py", lang=lang, size=len(content), content=content) -def _edges(content: str) -> list[ExtractedEdge]: - return extract_file(_pf(content)).edges +def _edges(content: str, lang: str | None = "python") -> list[ExtractedEdge]: + return extract_file(_pf(content, lang=lang)).edges @pytest.mark.unit @@ -161,12 +161,20 @@ def test_function_local_import_attributes_to_enclosing_function() -> None: @pytest.mark.unit -def test_non_python_languages_and_none_lang_yield_no_edges() -> None: - js_content = "f(x);\nimport { a } from 'b';\n" - assert extract_file(_pf(js_content, lang="javascript")).edges == [] +def test_none_lang_yields_no_edges() -> None: assert extract_file(_pf("f(x)\n", lang=None)).edges == [] +@pytest.mark.unit +def test_javascript_call_and_import_edges_on_separate_lines() -> None: + content = "f(x);\nimport { a } from 'b';\n" + edges = _edges(content, lang="javascript") + assert [(e.kind, e.target, e.line) for e in edges] == [ + ("call", "f", 1), + ("import", "b.a", 2), + ] + + @pytest.mark.unit def test_extract_symbols_is_a_thin_wrapper_over_extract_file() -> None: content = "class C:\n def m(self):\n helper()\n" @@ -181,3 +189,390 @@ def test_extraction_is_deterministic() -> None: first = extract_file(pf).edges for _ in range(5): assert extract_file(pf).edges == first + + +@pytest.mark.unit +def test_extraction_is_deterministic_for_a_non_python_language() -> None: + content = "use a::b::{c, d};\nfn f() { a::b::c(); }\n" + pf = _pf(content, lang="rust") + first = extract_file(pf).edges + for _ in range(5): + assert extract_file(pf).edges == first + + +# --- #85: JavaScript / TypeScript / TSX -------------------------------------- + + +@pytest.mark.unit +@pytest.mark.parametrize( + ("content", "expected"), + [ + ("f(x);", [("call", "f", 1)]), + ("a.b.f();", [("call", "f", 1)]), + ("obj?.m();", [("call", "m", 1)]), + ("new Foo();", [("call", "Foo", 1)]), + ("new a.b.Foo();", [("call", "Foo", 1)]), + ("require('y');", [("call", "require", 1)]), + ("import d from 'm';", [("import", "m", 1)]), + ( + "import { a, b as c } from 'mod';", + [("import", "mod.a", 1), ("import", "mod.b", 1)], + ), + ("import * as ns from 'ns';", [("import", "ns", 1)]), + ("import 'side-effect';", [("import", "side-effect", 1)]), + ( + "import d, { a } from 'm';", + [("import", "m", 1), ("import", "m.a", 1)], + ), + ("export { z } from 'w';", []), + ("import('m').then(f);", [("call", "then", 1)]), + ("import('dyn');", []), + ], +) +def test_javascript_shape_fixtures(content: str, expected: list[tuple[str, str, int]]) -> None: + edges = _edges(content, lang="javascript") + assert [(e.kind, e.target, e.line) for e in edges] == expected + + +@pytest.mark.unit +def test_javascript_multiline_named_import_per_specifier_lines() -> None: + content = "import {\n a,\n b,\n} from 'mod';\n" + edges = _edges(content, lang="javascript") + assert [(e.kind, e.target, e.line) for e in edges] == [ + ("import", "mod.a", 2), + ("import", "mod.b", 3), + ] + + +@pytest.mark.unit +def test_typescript_generic_call_and_import_type_and_require() -> None: + assert [(e.kind, e.target, e.line) for e in _edges("f(x);", lang="typescript")] == [ + ("call", "f", 1) + ] + assert [ + (e.kind, e.target, e.line) for e in _edges("import type { T } from 'm';", lang="typescript") + ] == [("import", "m.T", 1)] + assert [ + (e.kind, e.target, e.line) + for e in _edges("import x = require('legacy');", lang="typescript") + ] == [("import", "legacy", 1)] + + +@pytest.mark.unit +def test_tsx_jsx_component_ignored_inner_call_captured() -> None: + content = "const e = ;\n" + edges = _edges(content, lang="tsx") + assert [(e.kind, e.target, e.line) for e in edges] == [("call", "g", 1)] + + +@pytest.mark.unit +def test_tsx_plain_named_import() -> None: + content = "import { Comp } from './comp';\n" + edges = _edges(content, lang="tsx") + assert [(e.kind, e.target, e.line) for e in edges] == [("import", "./comp.Comp", 1)] + + +@pytest.mark.unit +def test_javascript_and_typescript_share_the_same_call_and_import_extractors() -> None: + content = "f(x);\nimport { a } from 'm';\n" + assert _edges(content, lang="javascript") == _edges(content, lang="typescript") + + +# --- #85: Go ------------------------------------------------------------------ + + +def _go(body: str) -> str: + return f"package main\nfunc f() {{\n{body}}}\n" + + +@pytest.mark.unit +@pytest.mark.parametrize( + ("body", "expected_target"), + [ + (" f(x)\n", "f"), + (" pkg.F()\n", "F"), + (" obj.Method()\n", "Method"), + (" go h()\n", "h"), + (" defer cleanup()\n", "cleanup"), + ], +) +def test_go_call_shape_fixtures(body: str, expected_target: str) -> None: + edges = _edges(_go(body), lang="go") + assert [e.target for e in edges if e.kind == "call"] == [expected_target] + + +@pytest.mark.unit +def test_go_single_imports() -> None: + assert [(e.kind, e.target) for e in _edges('package main\nimport "fmt"\n', lang="go")] == [ + ("import", "fmt") + ] + assert [ + (e.kind, e.target) for e in _edges('package main\nimport "github.com/x/y"\n', lang="go") + ] == [("import", "github.com/x/y")] + + +@pytest.mark.unit +def test_go_grouped_import_per_spec_lines_and_aliases_ignored() -> None: + content = 'package main\nimport (\n "fmt"\n m "math"\n . "strings"\n _ "driver"\n)\n' + edges = _edges(content, lang="go") + assert [(e.kind, e.target, e.line) for e in edges] == [ + ("import", "fmt", 3), + ("import", "math", 4), + ("import", "strings", 5), + ("import", "driver", 6), + ] + + +@pytest.mark.unit +def test_go_empty_import_group_yields_no_edges() -> None: + content = "package main\nimport (\n)\n" + assert _edges(content, lang="go") == [] + + +# --- #85: Java ------------------------------------------------------------------ + + +def _java(body: str) -> str: + return f"class C {{\n void f() {{\n{body} }}\n}}\n" + + +@pytest.mark.unit +@pytest.mark.parametrize( + ("body", "expected_target"), + [ + (" f();\n", "f"), + (" obj.m();\n", "m"), + (" C.stat();\n", "stat"), + (" this.n();\n", "n"), + (" super.s();\n", "s"), + (" obj.m();\n", "m"), + (" new Foo();\n", "Foo"), + (" new a.b.Foo();\n", "Foo"), + (" new Foo();\n", "Foo"), + (" new java.util.ArrayList();\n", "ArrayList"), + ], +) +def test_java_call_shape_fixtures(body: str, expected_target: str) -> None: + edges = _edges(_java(body), lang="java") + assert [e.target for e in edges if e.kind == "call"] == [expected_target] + + +@pytest.mark.unit +def test_java_import_shapes() -> None: + assert [(e.kind, e.target) for e in _edges("import a.b.C;", lang="java")] == [ + ("import", "a.b.C") + ] + assert [(e.kind, e.target) for e in _edges("import static a.b.C.m;", lang="java")] == [ + ("import", "a.b.C.m") + ] + assert [(e.kind, e.target) for e in _edges("import a.b.*;", lang="java")] == [("import", "a.b")] + + +# --- #85: Rust ------------------------------------------------------------------ + + +def _rust(body: str) -> str: + return f"fn f() {{\n{body}}}\n" + + +@pytest.mark.unit +@pytest.mark.parametrize( + ("body", "expected_target"), + [ + (" f();\n", "f"), + (" a::b::g();\n", "g"), + (" Foo::new();\n", "new"), + (" x.method();\n", "method"), + ], +) +def test_rust_call_shape_fixtures(body: str, expected_target: str) -> None: + edges = _edges(_rust(body), lang="rust") + assert [e.target for e in edges if e.kind == "call"] == [expected_target] + + +@pytest.mark.unit +def test_rust_macro_invocation_is_not_an_edge() -> None: + edges = _edges(_rust(' println!("hi");\n'), lang="rust") + assert edges == [] + + +@pytest.mark.unit +@pytest.mark.parametrize( + ("content", "expected"), + [ + ("use a::b::c;", [("import", "a::b::c")]), + ("use g as h;", [("import", "g")]), + ("use a::*;", [("import", "a")]), + ("use crate::x;", [("import", "crate::x")]), + ("use super::y;", [("import", "super::y")]), + ("use self::z;", [("import", "self::z")]), + ], +) +def test_rust_use_shape_fixtures(content: str, expected: list[tuple[str, str]]) -> None: + edges = _edges(content, lang="rust") + assert [(e.kind, e.target) for e in edges] == expected + + +@pytest.mark.unit +def test_rust_use_grouped_renamed_per_item() -> None: + edges = _edges("use a::b::{d, e as f};", lang="rust") + assert [(e.kind, e.target) for e in edges] == [ + ("import", "a::b::d"), + ("import", "a::b::e"), + ] + + +@pytest.mark.unit +def test_rust_use_self_in_group_and_sibling() -> None: + edges = _edges("use a::b::{self, d};", lang="rust") + assert [(e.kind, e.target) for e in edges] == [ + ("import", "a::b"), + ("import", "a::b::d"), + ] + + +@pytest.mark.unit +def test_rust_use_nested_scoped_lists() -> None: + edges = _edges("use a::{b::{c, d}};", lang="rust") + assert [(e.kind, e.target) for e in edges] == [ + ("import", "a::b::c"), + ("import", "a::b::d"), + ] + + +@pytest.mark.unit +def test_rust_use_bare_prefix_less_group() -> None: + """``use {a::b, c::d};`` (no leading path) is a bare ``use_list`` argument -- + distinct from ``scoped_use_list``, which always has a ``path`` field.""" + edges = _edges("use {a::b, c::d};", lang="rust") + assert [(e.kind, e.target) for e in edges] == [ + ("import", "a::b"), + ("import", "c::d"), + ] + + +@pytest.mark.unit +def test_rust_import_attributes_to_enclosing_function() -> None: + content = "fn outer() {\n use a::b;\n c();\n}\n" + fx = extract_file(_pf(content, lang="rust")) + import_edge = next(e for e in fx.edges if e.kind == "import") + assert import_edge.target == "a::b" + assert import_edge.enclosing is not None + assert import_edge.enclosing.name == "outer" + + +@pytest.mark.unit +def test_javascript_default_and_namespace_import_both_target_the_module() -> None: + """Documents current behavior: a default+namespace combo binds two local + names to the same module, so both clauses independently emit a + statement-anchored edge targeting that module (duplicate kind/target/line is + expected here, not a bug -- there is no name-class target to disambiguate + module-class default/namespace imports).""" + edges = _edges("import d, * as ns from 'm';", lang="javascript") + assert [(e.kind, e.target, e.line) for e in edges] == [ + ("import", "m", 1), + ("import", "m", 1), + ] + + +# --- #85: enclosing attribution per language ----------------------------------- + + +@pytest.mark.unit +def test_javascript_enclosing_attribution_function_method_and_module() -> None: + content = ( + "function top() {\n" + " callInFn();\n" + "}\n" + "class C {\n" + " m() {\n" + " callInMethod();\n" + " }\n" + "}\n" + "callAtModuleScope();\n" + ) + fx = extract_file(_pf(content, lang="javascript")) + by_target = {e.target: e for e in fx.edges} + + fn_edge = by_target["callInFn"] + assert fn_edge.enclosing is not None + assert fn_edge.enclosing.name == "top" + assert fn_edge.enclosing.kind == "function" + + method_edge = by_target["callInMethod"] + assert method_edge.enclosing is not None + assert method_edge.enclosing.name == "m" + assert method_edge.enclosing.kind == "method" + + assert by_target["callAtModuleScope"].enclosing is None + + +@pytest.mark.unit +def test_go_enclosing_attribution_function_and_method() -> None: + content = ( + "package main\nfunc top() {\n callInFn()\n}\nfunc (r *R) m() {\n callInMethod()\n}\n" + ) + fx = extract_file(_pf(content, lang="go")) + by_target = {e.target: e for e in fx.edges} + + fn_edge = by_target["callInFn"] + assert fn_edge.enclosing is not None + assert fn_edge.enclosing.name == "top" + assert fn_edge.enclosing.kind == "function" + + method_edge = by_target["callInMethod"] + assert method_edge.enclosing is not None + assert method_edge.enclosing.name == "m" + assert method_edge.enclosing.kind == "method" + + +@pytest.mark.unit +def test_java_enclosing_attribution_method_inside_class() -> None: + content = "class C {\n void m() {\n callInMethod();\n }\n}\n" + fx = extract_file(_pf(content, lang="java")) + edge = fx.edges[0] + assert edge.target == "callInMethod" + assert edge.enclosing is not None + assert edge.enclosing.name == "m" + assert edge.enclosing.kind == "method" + + +@pytest.mark.unit +def test_java_enclosing_attribution_interface_default_method() -> None: + content = "interface I {\n default void m() {\n callInMethod();\n }\n}\n" + fx = extract_file(_pf(content, lang="java")) + edge = fx.edges[0] + assert edge.target == "callInMethod" + assert edge.enclosing is not None + assert edge.enclosing.name == "m" + assert edge.enclosing.kind == "method" + + +@pytest.mark.unit +def test_rust_enclosing_attribution_function_and_impl_method() -> None: + content = ( + "fn top() {\n" + " callInFn();\n" + "}\n" + "struct S;\n" + "impl S {\n" + " fn m(&self) {\n" + " callInMethod();\n" + " }\n" + "}\n" + "const X: i32 = { callAtModuleScope(); 1 };\n" + ) + fx = extract_file(_pf(content, lang="rust")) + by_target = {e.target: e for e in fx.edges} + + fn_edge = by_target["callInFn"] + assert fn_edge.enclosing is not None + assert fn_edge.enclosing.name == "top" + assert fn_edge.enclosing.kind == "function" + + method_edge = by_target["callInMethod"] + assert method_edge.enclosing is not None + assert method_edge.enclosing.name == "m" + assert method_edge.enclosing.kind == "function" + + assert by_target["callAtModuleScope"].enclosing is None diff --git a/tests/unit/test_languages.py b/tests/unit/test_languages.py index 1786c1a..8476a01 100644 --- a/tests/unit/test_languages.py +++ b/tests/unit/test_languages.py @@ -8,6 +8,7 @@ from app.db.models import ReferenceEdge from indexer.languages import EDGE_NODE_KINDS, EXT_TO_LANG, MAX_FILE_BYTES, SYMBOL_KINDS +from indexer.symbols import _EDGE_EXTRACTORS @pytest.mark.unit @@ -46,6 +47,38 @@ def test_edge_node_kinds_are_within_the_db_check_set() -> None: assert mapped_kinds <= allowed, f"EDGE_NODE_KINDS has a kind outside the DB CHECK set: {sql!r}" +@pytest.mark.unit +def test_every_symbol_language_has_an_edge_map() -> None: + """Coverage guard (issue #85): any language that extracts symbols must also + declare an edge node-map, so a newly-added language can't silently ship + symbols with no reference edges.""" + missing = set(SYMBOL_KINDS) - set(EDGE_NODE_KINDS) + assert not missing, ( + f"languages in SYMBOL_KINDS with no EDGE_NODE_KINDS entry: {sorted(missing)}" + ) + + +@pytest.mark.unit +def test_symbol_and_edge_node_types_are_disjoint_per_language() -> None: + """_combined_kinds merges the two maps with dict.update, which silently + clobbers a symbol entry on collision; the merge's losslessness is enforced + here, not assumed.""" + for lang, kinds in SYMBOL_KINDS.items(): + overlap = set(kinds) & set(EDGE_NODE_KINDS.get(lang, {})) + assert not overlap, ( + f"{lang}: node types in both SYMBOL_KINDS and EDGE_NODE_KINDS: {sorted(overlap)}" + ) + + +@pytest.mark.unit +def test_every_symbol_language_has_an_edge_extractor() -> None: + """extract_file indexes _EDGE_EXTRACTORS[lang] unconditionally for any + parsed language; a missing entry is a runtime KeyError for every file of + that language, so this guard is mandatory.""" + missing = set(SYMBOL_KINDS) - set(_EDGE_EXTRACTORS) + assert not missing, f"languages with symbols but no edge extractor: {sorted(missing)}" + + @pytest.mark.unit @pytest.mark.parametrize("lang", sorted(set(EXT_TO_LANG.values()))) def test_get_parser_succeeds_for_each_language(lang: str) -> None: