Navegador
feat: add Go, Rust, Java parsers; implement TypeScript/JavaScript fully Full AST parsing support for Go (functions, methods via receiver, structs/interfaces, imports, call edges), Rust (functions, impl blocks, structs/enums/traits, use declarations, /// doc comments, call edges), and Java (classes, interfaces, records, constructors, Javadoc, inheritance, static imports, call edges). Rewrites the TypeScript/JavaScript stub with class heritage, interface/type-alias nodes, const arrow functions, JSDoc, and call extraction. Adds tree-sitter-go/rust/java/javascript to pyproject.toml dependencies. Tests for all four parsers (312 passing, 83% total coverage), ruff-clean.
Commit
e52aae0022dbeb503f4be5540ea9baa9efe7937cbbfb825cd0a2bffb05b92a30
Parent
b663b128eb1f89d…
11 files changed
+159
+219
+30
-6
+112
+234
-68
+5
-1
+2
+1
-1
+253
+253
+270
+
navegador/ingestion/go.py
+
navegador/ingestion/java.py
~
navegador/ingestion/parser.py
+
navegador/ingestion/rust.py
~
navegador/ingestion/typescript.py
~
pyproject.toml
+
tests/test_go_parser.py
~
tests/test_ingestion_code.py
+
tests/test_java_parser.py
+
tests/test_rust_parser.py
+
tests/test_typescript_parser.py
+159
| --- a/navegador/ingestion/go.py | ||
| +++ b/navegador/ingestion/go.py | ||
| @@ -0,0 +1,159 @@ | ||
| 1 | +""" | |
| 2 | +Go AST parser — extracts functions, methods, struct/interface types, | |
| 3 | +imports, and call edges from .go files using tree-sitter. | |
| 4 | +""" | |
| 5 | + | |
| 6 | +import logging | |
| 7 | +from pathlib import Path | |
| 8 | + | |
| 9 | +from navegador.graph.schema import EdgeType, NodeLabel | |
| 10 | +from navegador.graph.store import GraphStore | |
| 11 | +from navegador.ingestion.parser import LanguageParser | |
| 12 | + | |
| 13 | +logger = logging.getLogger(__name__) | |
| 14 | + | |
| 15 | + | |
| 16 | +def _get_go_language(): | |
| 17 | + try: | |
| 18 | + import tree_sitter_go as tsgo # type: ignore[import] | |
| 19 | + from tee_sitter import Language | |
| 20 | + | |
| 21 | + return Language(tsgo.language()) | |
| 22 | + except ImportError as e: | |
| 23 | + raise ImportError("Install tree-sitter-go: pip install tree-sitter-go") from e | |
| 24 | + | |
| 25 | + | |
| 26 | +def _node_text(node, so:n source[node.start_byte : node.end_byte].decode("utf-8", errors="replace") | |
| 27 | + | |
| 28 | + | |
| 29 | +class GoParser(LanguageParser): | |
| 30 | + """Parses Go source files into the navegador graph.""" | |
| 31 | + | |
| 32 | + def __init__(self) -> None: | |
| 33 | + f self._parser = Parser(_get_go_language()) | |
| 34 | + | |
| 35 | + def parse_file(self, path: Path, repo_root: Path, store: GraphStore) -> dict[str, int]: | |
| 36 | + source = path.read_bytes() | |
| 37 | + tree = self._parser.parse(source) | |
| 38 | + rel_path = str(path.relative_to(repo_root)) | |
| 39 | +NodeLabel.File, { | |
| 40 | +ype"): | |
| 41 | + | |
| 42 | + { | |
| 43 | + "nampath.name, | |
| 44 | + "pa"language": "go", | |
| 45 | + ge": "go", | |
| 46 | + }) | |
| 47 | + | |
| 48 | +"struct_ }, | |
| 49 | + ) | |
| 50 | + | |
| 51 | + stats = {"functions": 0, "classes": 0, "edges": 0} | |
| 52 | + self._walk(tree.root_node, source, rel_path, store, stats) | |
| 53 | + return stats | |
| 54 | + | |
| 55 | + # ── AST walker ──────────────────────────────────────────────────────────── | |
| 56 | + | |
| 57 | + def _walk(self, node, sou | |
| 58 | +raphStore, stats: de | |
| 59 | + name_node =) -> None: | |
| 60 | + if node.type == "function_declaration": | |
| 61 | + self._handle_function(node, source, file_path, store, stats, receiver=None) | |
| 62 | + return | |
| 63 | + if node.type == "method_declaration": | |
| 64 | + self._handle_method(node, source, file_path, store, stats) | |
| 65 | + return | |
| 66 | + if node.type == "type_declaration": | |
| 67 | + self._handle_type(node, source, file_path, store, stats) | |
| 68 | + return | |
| 69 | + if node.type == "import_declaration": | |
| 70 | + self._handle_import(node, source, file_path, store, stats) | |
| 71 | + return | |
| 72 | + for child in node.children: | |
| 73 | + self._walk(child, source, file_path, store, stats) | |
| 74 | + | |
| 75 | + # ── Handlers ────────────────────────────────────────────────────────────── | |
| 76 | + | |
| 77 | + def _handle_function(self, node, souource: bytes, file_path: store: GraphStore, stats: dicttart_point[0] + 1 | |
| 78 | + ath.relative_to(repo_) -> None: | |
| 79 | + abel.File, | |
| 80 | + { | |
| 81 | + "name": path.name, | |
| 82 | + "path": rel_path, | |
| 83 | + "language": "go", | |
| 84 | + "line_count": source.count(b"\n"), | |
| 85 | + }, | |
| 86 | + ) | |
| 87 | +label, { | |
| 88 | +ype"): | |
| 89 | + , "edges": 0} | |
| 90 | + self._wastats) | |
| 91 | + return st"line_end": node.end"docstring": "", | |
| 92 | + store.c�───�}��───────────── | |
| 93 | + | |
| 94 | + def _walk(self, node, source: bytes, file_path: str, store: GraphStore, stats: dict) -> None: | |
| 95 | + if node. | |
| 96 | +*").strip() | |
| 97 | +ifath.relat self._handle_function(node, source, file_path, store, stats, receiver=None) | |
| 98 | + if node.type == "method_declaration": | |
| 99 | + self._handde, source, file_path, store, stats) | |
| 100 | + return | |
| 101 | + if node.type == "type_declaration": | |
| 102 | + self._handle_type(node, source, file_path, store, stats) | |
| 103 | + return | |
| 104 | + if node.type == "impself, node, souource: bytes, file_path:ce: bytes, file_path: str, sto) -> None: | |
| 105 | + receiver_type = "" | |
| 106 | + recv_node = node.child_by_field_name("receiver") | |
| 107 | + if recv_node: | |
| 108 | + for child in recv_node.children: | |
| 109 | + if child.type == "parameter_declaration": | |
| 110 | + for c in child.children: | |
| 111 | + if c.type in ("type_identifier", "pointer_type"): | |
| 112 | + receiver_type = _node_text(c, source).lstrip("*").strip() | |
| 113 | + break | |
| 114 | + self._handle_function(node, sourcource: bytes, file_path: receiver=receiver_type or Nself, node, souource: bytes, file_patce: bytes, file_path: str, sto) -> None: | |
| 115 | + start_point[0] + 1 | |
| 116 | + | |
| 117 | + if child.type != "type_spec": | |
| 118 | + continue | |
| 119 | + name_node = child.child_by_field_name("name") | |
| 120 | + type_node = child.child_by_field_name("type") | |
| 121 | + if not name_node or not type_node: | |
| 122 | + continue | |
| 123 | + if type_node.type not in ("struct_type", "interface_type"): | |
| 124 | + continue | |
| 125 | + name = _node_text(name_node, source) | |
| 126 | + kind = "struct" if type_node.type == "structNodeLabel.Class, { | |
| 127 | + "name": name, | |
| 128 | + path, store, stats) | |
| 129 | + return stats | |
| 130 | + | |
| 131 | + # ── AST walker ───────────────────────────────kind, | |
| 132 | + stats })ruct_type" else "interfaceedge( | |
| 133 | + NodeLabel.File, {"path": file_path}, | |
| 134 | + EdgeType.CONTAINS, | |
| 135 | + NodeLabel.Class,de, source, file_path, ) | |
| 136 | +pe == "imporstats["classes"] += 1 | |
| 137 | + stats["edges"] += 1 | |
| 138 | + | |
| 139 | + def _handle_import(self, node, souource: bytes, file_path:ce: bytes, file_path: str, sto) -> None: | |
| 140 | + ict | |
| 141 | + ) -> None: | |
| 142 | + line_start = node.start_point[0] + 1 | |
| 143 | + for child in node.children: | |
| 144 | + if child.type == "import_spec": | |
| 145 | + self._ingest_import_spec(child, source, file_path, line_start, store, stats) | |
| 146 | + elif child.type == "import_spec_list": | |
| 147 | + for spec in child.children: | |
| 148 | + if spec.type == "import_spec": | |
| 149 | + self._iource: bytes, file_path: ce).lstrip("*").strip() | |
| 150 | + urce files into thedren: | |
| 151 | + if chi if ource: bytes, file_path: name_node =) -> None: | |
| 152 | + _node = child.child_by_field_name("type") | |
| 153 | + if not name_node or not type_node: | |
| 154 | + continue | |
| 155 | + if type_node.type notNodeLabel.Import, { | |
| 156 | +ype"): | |
| 157 | + "namre.create_node( | |
| 158 | + = "struct_t else "interface" | |
| 159 | + } if type_node.typ NodeLab |
| --- a/navegador/ingestion/go.py | |
| +++ b/navegador/ingestion/go.py | |
| @@ -0,0 +1,159 @@ | |
| --- a/navegador/ingestion/go.py | |
| +++ b/navegador/ingestion/go.py | |
| @@ -0,0 +1,159 @@ | |
| 1 | """ |
| 2 | Go AST parser — extracts functions, methods, struct/interface types, |
| 3 | imports, and call edges from .go files using tree-sitter. |
| 4 | """ |
| 5 | |
| 6 | import logging |
| 7 | from pathlib import Path |
| 8 | |
| 9 | from navegador.graph.schema import EdgeType, NodeLabel |
| 10 | from navegador.graph.store import GraphStore |
| 11 | from navegador.ingestion.parser import LanguageParser |
| 12 | |
| 13 | logger = logging.getLogger(__name__) |
| 14 | |
| 15 | |
| 16 | def _get_go_language(): |
| 17 | try: |
| 18 | import tree_sitter_go as tsgo # type: ignore[import] |
| 19 | from tee_sitter import Language |
| 20 | |
| 21 | return Language(tsgo.language()) |
| 22 | except ImportError as e: |
| 23 | raise ImportError("Install tree-sitter-go: pip install tree-sitter-go") from e |
| 24 | |
| 25 | |
| 26 | def _node_text(node, so:n source[node.start_byte : node.end_byte].decode("utf-8", errors="replace") |
| 27 | |
| 28 | |
| 29 | class GoParser(LanguageParser): |
| 30 | """Parses Go source files into the navegador graph.""" |
| 31 | |
| 32 | def __init__(self) -> None: |
| 33 | f self._parser = Parser(_get_go_language()) |
| 34 | |
| 35 | def parse_file(self, path: Path, repo_root: Path, store: GraphStore) -> dict[str, int]: |
| 36 | source = path.read_bytes() |
| 37 | tree = self._parser.parse(source) |
| 38 | rel_path = str(path.relative_to(repo_root)) |
| 39 | NodeLabel.File, { |
| 40 | ype"): |
| 41 | |
| 42 | { |
| 43 | "nampath.name, |
| 44 | "pa"language": "go", |
| 45 | ge": "go", |
| 46 | }) |
| 47 | |
| 48 | "struct_ }, |
| 49 | ) |
| 50 | |
| 51 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 52 | self._walk(tree.root_node, source, rel_path, store, stats) |
| 53 | return stats |
| 54 | |
| 55 | # ── AST walker ──────────────────────────────────────────────────────────── |
| 56 | |
| 57 | def _walk(self, node, sou |
| 58 | raphStore, stats: de |
| 59 | name_node =) -> None: |
| 60 | if node.type == "function_declaration": |
| 61 | self._handle_function(node, source, file_path, store, stats, receiver=None) |
| 62 | return |
| 63 | if node.type == "method_declaration": |
| 64 | self._handle_method(node, source, file_path, store, stats) |
| 65 | return |
| 66 | if node.type == "type_declaration": |
| 67 | self._handle_type(node, source, file_path, store, stats) |
| 68 | return |
| 69 | if node.type == "import_declaration": |
| 70 | self._handle_import(node, source, file_path, store, stats) |
| 71 | return |
| 72 | for child in node.children: |
| 73 | self._walk(child, source, file_path, store, stats) |
| 74 | |
| 75 | # ── Handlers ────────────────────────────────────────────────────────────── |
| 76 | |
| 77 | def _handle_function(self, node, souource: bytes, file_path: store: GraphStore, stats: dicttart_point[0] + 1 |
| 78 | ath.relative_to(repo_) -> None: |
| 79 | abel.File, |
| 80 | { |
| 81 | "name": path.name, |
| 82 | "path": rel_path, |
| 83 | "language": "go", |
| 84 | "line_count": source.count(b"\n"), |
| 85 | }, |
| 86 | ) |
| 87 | label, { |
| 88 | ype"): |
| 89 | , "edges": 0} |
| 90 | self._wastats) |
| 91 | return st"line_end": node.end"docstring": "", |
| 92 | store.c�───�}��───────────── |
| 93 | |
| 94 | def _walk(self, node, source: bytes, file_path: str, store: GraphStore, stats: dict) -> None: |
| 95 | if node. |
| 96 | *").strip() |
| 97 | ifath.relat self._handle_function(node, source, file_path, store, stats, receiver=None) |
| 98 | if node.type == "method_declaration": |
| 99 | self._handde, source, file_path, store, stats) |
| 100 | return |
| 101 | if node.type == "type_declaration": |
| 102 | self._handle_type(node, source, file_path, store, stats) |
| 103 | return |
| 104 | if node.type == "impself, node, souource: bytes, file_path:ce: bytes, file_path: str, sto) -> None: |
| 105 | receiver_type = "" |
| 106 | recv_node = node.child_by_field_name("receiver") |
| 107 | if recv_node: |
| 108 | for child in recv_node.children: |
| 109 | if child.type == "parameter_declaration": |
| 110 | for c in child.children: |
| 111 | if c.type in ("type_identifier", "pointer_type"): |
| 112 | receiver_type = _node_text(c, source).lstrip("*").strip() |
| 113 | break |
| 114 | self._handle_function(node, sourcource: bytes, file_path: receiver=receiver_type or Nself, node, souource: bytes, file_patce: bytes, file_path: str, sto) -> None: |
| 115 | start_point[0] + 1 |
| 116 | |
| 117 | if child.type != "type_spec": |
| 118 | continue |
| 119 | name_node = child.child_by_field_name("name") |
| 120 | type_node = child.child_by_field_name("type") |
| 121 | if not name_node or not type_node: |
| 122 | continue |
| 123 | if type_node.type not in ("struct_type", "interface_type"): |
| 124 | continue |
| 125 | name = _node_text(name_node, source) |
| 126 | kind = "struct" if type_node.type == "structNodeLabel.Class, { |
| 127 | "name": name, |
| 128 | path, store, stats) |
| 129 | return stats |
| 130 | |
| 131 | # ── AST walker ───────────────────────────────kind, |
| 132 | stats })ruct_type" else "interfaceedge( |
| 133 | NodeLabel.File, {"path": file_path}, |
| 134 | EdgeType.CONTAINS, |
| 135 | NodeLabel.Class,de, source, file_path, ) |
| 136 | pe == "imporstats["classes"] += 1 |
| 137 | stats["edges"] += 1 |
| 138 | |
| 139 | def _handle_import(self, node, souource: bytes, file_path:ce: bytes, file_path: str, sto) -> None: |
| 140 | ict |
| 141 | ) -> None: |
| 142 | line_start = node.start_point[0] + 1 |
| 143 | for child in node.children: |
| 144 | if child.type == "import_spec": |
| 145 | self._ingest_import_spec(child, source, file_path, line_start, store, stats) |
| 146 | elif child.type == "import_spec_list": |
| 147 | for spec in child.children: |
| 148 | if spec.type == "import_spec": |
| 149 | self._iource: bytes, file_path: ce).lstrip("*").strip() |
| 150 | urce files into thedren: |
| 151 | if chi if ource: bytes, file_path: name_node =) -> None: |
| 152 | _node = child.child_by_field_name("type") |
| 153 | if not name_node or not type_node: |
| 154 | continue |
| 155 | if type_node.type notNodeLabel.Import, { |
| 156 | ype"): |
| 157 | "namre.create_node( |
| 158 | = "struct_t else "interface" |
| 159 | } if type_node.typ NodeLab |
+219
| --- a/navegador/ingestion/java.py | ||
| +++ b/navegador/ingestion/java.py | ||
| @@ -0,0 +1,219 @@ | ||
| 1 | +""" | |
| 2 | +Java AST parser — extracts classes, interfaces, methods, constructors, | |
| 3 | +imports, inheritance, and call edges from .java files using tree-sitter. | |
| 4 | +""" | |
| 5 | + | |
| 6 | +import logging | |
| 7 | +from pathlib import Path | |
| 8 | + | |
| 9 | +from navegador.graph.schema import EdgeType, NodeLabel | |
| 10 | +from navegador.graph.store import GraphStore | |
| 11 | +from navegador.ingestion.parser import LanguageParser | |
| 12 | + | |
| 13 | +logger = logging.getLogger(__name__) | |
| 14 | + | |
| 15 | + | |
| 16 | +def _get_java_language(): | |
| 17 | + try: | |
| 18 | + import tree_sitter_java as tsjava # type: ignore[import] | |
| 19 | + from tree_sitter import Language | |
| 20 | +file_patreturn Language(tsjava.language()) | |
| 21 | + except ImportError as e: | |
| 22 | + raise ImportError("Install tree-sitter-java: pip install tree-sitter-java") from e | |
| 23 | + | |
| 24 | + | |
| 25 | +def _node_text(node, source: bytes) -> str: | |
| 26 | + re:node.end_byte].decode("utf-8", errors="replace") | |
| 27 | + | |
| 28 | + | |
| 29 | +def _javadoc(node, source: bytes) -> str: | |
| 30 | + """Return the Javadoc (/** ... */) comment preceding a node, if any.""" | |
| 31 | + parent = node.parent | |
| 32 | + if not parent: | |
| 33 | + return "" | |
| 34 | + siblings = list(parent.children) | |
| 35 | + idx = next((i for i, c in enumerate(siblings) if c.id == node.id), -1) | |
| 36 | + if idx <= 0: | |
| 37 | + return "" | |
| 38 | + prev = siblings[idx - 1] | |
| 39 | + if prev.type == "block_comment": | |
| 40 | + raw = _node_text(prev, source).strip() | |
| 41 | + if raw.startswith("/**"): | |
| 42 | + return raw.strip("/**").strip("*/").strip() | |
| 43 | + return "" | |
| 44 | + | |
| 45 | + | |
| 46 | +class JavaParser(LanguageParser): | |
| 47 | + """Parses Java source files into the navegador graph.""" | |
| 48 | + | |
| 49 | + def __init__(self) -> None: | |
| 50 | + from tree_sitter import Parser # type: ignore[import] | |
| 51 | + self._parser = Parser(_get_java_language()) | |
| 52 | + | |
| 53 | + def parse_file(self, path: Path, repo_root: Path, store: GraphStore) -> dict[str, int]: | |
| 54 | + source = path.read_bytes() | |
| 55 | + tree = self._parser.parse(source) | |
| 56 | + rel_path = str(path.relative_to(repo_root)) | |
| 57 | +NodeLabel.File, { | |
| 58 | +"\n"), | |
| 59 | + }path.name, | |
| 60 | + path.name, | |
| 61 | + "pa"language": "java", | |
| 62 | + ": "java", | |
| 63 | + }) | |
| 64 | +on"): | |
| 65 | + }, | |
| 66 | + ) | |
| 67 | + | |
| 68 | + stats = {"functions": 0, "classes": 0, "edges": 0} | |
| 69 | + self._walk(tree.root_node, source, rel_path, store, stats, class_name=None) | |
| 70 | + return stats | |
| 71 | + | |
| 72 | + # ── AST walker ───────────────────────────────────────────────�self, node, soua", | |
| 73 | + _javadoc(node, sour store.creat | Nonecomment preceding a node, if any.""" | |
| 74 | + parent = node.parent | |
| 75 | + if not parent: | |
| 76 | + return "" | |
| 77 | + siblings = list(parent.children) | |
| 78 | + idx = next((i for i, c in enumerate(siblings) if c.id == node.id), -1) | |
| 79 | + if idx <= 0: | |
| 80 | + return "" | |
| 81 | + prev = siblings[idx - 1] | |
| 82 | + if prev.type == "block_comment": | |
| 83 | + raw = _node_text(prev, source).strip() | |
| 84 | + if raw.startswith("/**"): | |
| 85 | + return raw.strip("/**").strip("*/").strip() | |
| 86 | + return "" | |
| 87 | + | |
| 88 | + | |
| 89 | +class JavaParser(LanguageParser): | |
| 90 | + """Parses Java source files into the navegador graph.""" | |
| 91 | + | |
| 92 | + def __init__(self) -> None: | |
| 93 | + from tree_sitter import Parser # type: ignore[import] | |
| 94 | + | |
| 95 | + self._parser = Parser(_get_java_language()) | |
| 96 | + | |
| 97 | + def parse_file(self, path: Path, repo_root: Path, store: GraphStore) -> dict[self, node, sou {"name": pare store.create_edge( | |
| 98 | + comment preceding a str(path.relative_to(repo_root)) | |
| 99 | + | |
| 100 | + store.create_node( | |
| 101 | + NodeLabel.File, | |
| 102 | + { | |
| 103 | + "name": path.name, | |
| 104 | + "path": rel_path, | |
| 105 | + "language": "javNodeLabel.Class, { | |
| 106 | +"\n"), | |
| 107 | + {"name": parent_n, | |
| 108 | + "line_start": node.startclaration", | |
| 109 | + "docstring": docstring, | |
| 110 | + } inner_name_node = child.cNodeLabel.File, | |
| 111 | +"" | |
| 112 | +Java AST parser —ser — extractsSuperclass → INHERITS edge | |
| 113 | + superclass = node.child_by_field_name("superclass") | |
| 114 | + if superclass: | |
| 115 | + for child in superclass.children: | |
| 116 | + if child.type == "type_identifier": | |
| 117 | + parent_name = _node_text(child, source) | |
| 118 | + store.create_edge( | |
| 119 | + NodeLabel.Class,node_text(inner_name_nfile_path": fileEdgeType.INHERITS, | |
| 120 | + {"name": parent_name, "file_path": file_path}, | |
| 121 | + ) | |
| 122 | + stats["edges"] += 1 | |
| 123 | + break | |
| 124 | + | |
| 125 | + # Walk class body for methods and constructors | |
| 126 | + body = node.child_by_field_name("body") | |
| 127 | + if body: | |
| 128 | + for child in body.children: | |
| 129 | + if child.type in ("method_declaration", "constructor_declaration"): | |
| 130 | + self._handle_method(child, source, file_path, store, stats, | |
| 131 | + "docstring": file_path": fileelif child comment preceding a nod, | |
| 132 | + cord_declaration", | |
| 133 | + ): | |
| 134 | + # Nested class — register but don't recurse into methods | |
| 135 | + inner_name_node = child.child_by_field_name("name") | |
| 136 | + if inner_name_node: | |
| 137 | + inner_name = _node_text(inner_name_node, source) | |
| 138 | + store.create_node(NodeLabel.Class, elif child.t"\n"), | |
| 139 | + }inner_, | |
| 140 | + ) | |
| 141 | + | |
| 142 | + s"" | |
| 143 | +Java er — extracts c "line_ance, and call edges from .java fil""" | |
| 144 | +Java AST parser — extracts classes, interfaces, methods, c"docstring": "}) {"name": parentname_node = child.child_by_fielfile_path": fileNodeLabel.Class,node_text(inner_name_n EdgeType.CONTAINS, | |
| 145 | + file_path": fileNodeLabel.Class, {"name": inner_name,e in ( | |
| 146 | + }, | |
| 147 | + ) {"name": parentstore.create_node( | |
| 148 | + file_path": filestats["edges"] += 1 | |
| 149 | + | |
| 150 | + breakself, node, sou | |
| 151 | + store.create_edge( | |
| 152 | + comment preceding a str(path.relative_to(repo_root)) | |
| 153 | + | |
| 154 | + store.create_node( | |
| 155 | + NodeLabel.File, | |
| 156 | + { | |
| 157 | + "name": path.name, | |
| 158 | + "path": rel_path, | |
| 159 | + "language": "javNodeLabel.Class, { | |
| 160 | +"\n"), | |
| 161 | + {"name": parent_n, | |
| 162 | + "line_start": node.startclaration", | |
| 163 | + # Nested class — register but d} inner_name_node = child.cNodeLabel.File, | |
| 164 | +"" | |
| 165 | +Java AST parser —ser — extracts | |
| 166 | + "docstringself, node, sou {"name": paren store.creatcomment preceding a str(path.relative_to(repo_root)) | |
| 167 | + | |
| 168 | + store.create_node( | |
| 169 | + NodeLabel.File, | |
| 170 | + { | |
| 171 | + "name": path.name, | |
| 172 | + "path": rel_path, | |
| 173 | + "language": "javes using tree-sit""""\n"), | |
| 174 | + {"name": parent_n, | |
| 175 | + "line_start": node.startclaration", | |
| 176 | + "class_name": class_name, | |
| 177 | + | |
| 178 | + } inner_name_node = chClass,odeLabel.Class, | |
| 179 | + {"name": class_name, "file_path": file_path}, | |
| 180 | + node_text(inner_name_node, source) | |
| 181 | + store.cfunctions"] += 1 | |
| 182 | + stats["edges"] += 1 | |
| 183 | + | |
| 184 | + self._extract_calls(node, source, file_path, name, store, statself, node, sou {"name": paren store.create_edge( | |
| 185 | + comment preceding aict | |
| 186 | + ) -> None: | |
| 187 | + # import java.util.List; → strip keyword + semicolon | |
| 188 | + (raw.removeprefix("imporint[0] + 1, | |
| 189 | + .removesuffix(";").strip() inner_name_node = cnode(NodeLabel.Import, { | |
| 190 | +"\n"), | |
| 191 | + }module,"" | |
| 192 | +Java Aser — extradges": 0} | |
| 193 | + self._"module": module, | |
| 194 | + | |
| 195 | + } inner_name_node = child.cNodeLabel.File, | |
| 196 | + {"path": file_path}, | |
| 197 | + EdgeType.IMPORTS, | |
| 198 | + {"name": module, "file_path": file_path}, | |
| 199 | + ) | |
| 200 | + stats["edges"] +=self, method_ {"name": parenmethod_name: str, store.create_edge( | |
| 201 | + comment preceding act, | |
| 202 | + ) -> None: | |
| 203 | + def walk(node): | |
| 204 | + if node.type == "method_invocation": | |
| 205 | + name_node = node.child_by_field_name("name") | |
| 206 | + if name_node: | |
| 207 | + callee = _node_text(name_node, source) | |
| 208 | + store.od, | |
| 209 | + {"name": method_name, "file_path": file_path}, | |
| 210 | + EdgeType.CALLS, | |
| 211 | + {"name": callee, "file_path": file_path}, | |
| 212 | + ) | |
| 213 | + stats["edges"] += 1 | |
| 214 | + for child in node.children: | |
| 215 | + walk(child) | |
| 216 | + | |
| 217 | + body = method_node.child_by_field_name("body") | |
| 218 | + if body: | |
| 219 | + walk(body) |
| --- a/navegador/ingestion/java.py | |
| +++ b/navegador/ingestion/java.py | |
| @@ -0,0 +1,219 @@ | |
| --- a/navegador/ingestion/java.py | |
| +++ b/navegador/ingestion/java.py | |
| @@ -0,0 +1,219 @@ | |
| 1 | """ |
| 2 | Java AST parser — extracts classes, interfaces, methods, constructors, |
| 3 | imports, inheritance, and call edges from .java files using tree-sitter. |
| 4 | """ |
| 5 | |
| 6 | import logging |
| 7 | from pathlib import Path |
| 8 | |
| 9 | from navegador.graph.schema import EdgeType, NodeLabel |
| 10 | from navegador.graph.store import GraphStore |
| 11 | from navegador.ingestion.parser import LanguageParser |
| 12 | |
| 13 | logger = logging.getLogger(__name__) |
| 14 | |
| 15 | |
| 16 | def _get_java_language(): |
| 17 | try: |
| 18 | import tree_sitter_java as tsjava # type: ignore[import] |
| 19 | from tree_sitter import Language |
| 20 | file_patreturn Language(tsjava.language()) |
| 21 | except ImportError as e: |
| 22 | raise ImportError("Install tree-sitter-java: pip install tree-sitter-java") from e |
| 23 | |
| 24 | |
| 25 | def _node_text(node, source: bytes) -> str: |
| 26 | re:node.end_byte].decode("utf-8", errors="replace") |
| 27 | |
| 28 | |
| 29 | def _javadoc(node, source: bytes) -> str: |
| 30 | """Return the Javadoc (/** ... */) comment preceding a node, if any.""" |
| 31 | parent = node.parent |
| 32 | if not parent: |
| 33 | return "" |
| 34 | siblings = list(parent.children) |
| 35 | idx = next((i for i, c in enumerate(siblings) if c.id == node.id), -1) |
| 36 | if idx <= 0: |
| 37 | return "" |
| 38 | prev = siblings[idx - 1] |
| 39 | if prev.type == "block_comment": |
| 40 | raw = _node_text(prev, source).strip() |
| 41 | if raw.startswith("/**"): |
| 42 | return raw.strip("/**").strip("*/").strip() |
| 43 | return "" |
| 44 | |
| 45 | |
| 46 | class JavaParser(LanguageParser): |
| 47 | """Parses Java source files into the navegador graph.""" |
| 48 | |
| 49 | def __init__(self) -> None: |
| 50 | from tree_sitter import Parser # type: ignore[import] |
| 51 | self._parser = Parser(_get_java_language()) |
| 52 | |
| 53 | def parse_file(self, path: Path, repo_root: Path, store: GraphStore) -> dict[str, int]: |
| 54 | source = path.read_bytes() |
| 55 | tree = self._parser.parse(source) |
| 56 | rel_path = str(path.relative_to(repo_root)) |
| 57 | NodeLabel.File, { |
| 58 | "\n"), |
| 59 | }path.name, |
| 60 | path.name, |
| 61 | "pa"language": "java", |
| 62 | ": "java", |
| 63 | }) |
| 64 | on"): |
| 65 | }, |
| 66 | ) |
| 67 | |
| 68 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 69 | self._walk(tree.root_node, source, rel_path, store, stats, class_name=None) |
| 70 | return stats |
| 71 | |
| 72 | # ── AST walker ───────────────────────────────────────────────�self, node, soua", |
| 73 | _javadoc(node, sour store.creat | Nonecomment preceding a node, if any.""" |
| 74 | parent = node.parent |
| 75 | if not parent: |
| 76 | return "" |
| 77 | siblings = list(parent.children) |
| 78 | idx = next((i for i, c in enumerate(siblings) if c.id == node.id), -1) |
| 79 | if idx <= 0: |
| 80 | return "" |
| 81 | prev = siblings[idx - 1] |
| 82 | if prev.type == "block_comment": |
| 83 | raw = _node_text(prev, source).strip() |
| 84 | if raw.startswith("/**"): |
| 85 | return raw.strip("/**").strip("*/").strip() |
| 86 | return "" |
| 87 | |
| 88 | |
| 89 | class JavaParser(LanguageParser): |
| 90 | """Parses Java source files into the navegador graph.""" |
| 91 | |
| 92 | def __init__(self) -> None: |
| 93 | from tree_sitter import Parser # type: ignore[import] |
| 94 | |
| 95 | self._parser = Parser(_get_java_language()) |
| 96 | |
| 97 | def parse_file(self, path: Path, repo_root: Path, store: GraphStore) -> dict[self, node, sou {"name": pare store.create_edge( |
| 98 | comment preceding a str(path.relative_to(repo_root)) |
| 99 | |
| 100 | store.create_node( |
| 101 | NodeLabel.File, |
| 102 | { |
| 103 | "name": path.name, |
| 104 | "path": rel_path, |
| 105 | "language": "javNodeLabel.Class, { |
| 106 | "\n"), |
| 107 | {"name": parent_n, |
| 108 | "line_start": node.startclaration", |
| 109 | "docstring": docstring, |
| 110 | } inner_name_node = child.cNodeLabel.File, |
| 111 | "" |
| 112 | Java AST parser —ser — extractsSuperclass → INHERITS edge |
| 113 | superclass = node.child_by_field_name("superclass") |
| 114 | if superclass: |
| 115 | for child in superclass.children: |
| 116 | if child.type == "type_identifier": |
| 117 | parent_name = _node_text(child, source) |
| 118 | store.create_edge( |
| 119 | NodeLabel.Class,node_text(inner_name_nfile_path": fileEdgeType.INHERITS, |
| 120 | {"name": parent_name, "file_path": file_path}, |
| 121 | ) |
| 122 | stats["edges"] += 1 |
| 123 | break |
| 124 | |
| 125 | # Walk class body for methods and constructors |
| 126 | body = node.child_by_field_name("body") |
| 127 | if body: |
| 128 | for child in body.children: |
| 129 | if child.type in ("method_declaration", "constructor_declaration"): |
| 130 | self._handle_method(child, source, file_path, store, stats, |
| 131 | "docstring": file_path": fileelif child comment preceding a nod, |
| 132 | cord_declaration", |
| 133 | ): |
| 134 | # Nested class — register but don't recurse into methods |
| 135 | inner_name_node = child.child_by_field_name("name") |
| 136 | if inner_name_node: |
| 137 | inner_name = _node_text(inner_name_node, source) |
| 138 | store.create_node(NodeLabel.Class, elif child.t"\n"), |
| 139 | }inner_, |
| 140 | ) |
| 141 | |
| 142 | s"" |
| 143 | Java er — extracts c "line_ance, and call edges from .java fil""" |
| 144 | Java AST parser — extracts classes, interfaces, methods, c"docstring": "}) {"name": parentname_node = child.child_by_fielfile_path": fileNodeLabel.Class,node_text(inner_name_n EdgeType.CONTAINS, |
| 145 | file_path": fileNodeLabel.Class, {"name": inner_name,e in ( |
| 146 | }, |
| 147 | ) {"name": parentstore.create_node( |
| 148 | file_path": filestats["edges"] += 1 |
| 149 | |
| 150 | breakself, node, sou |
| 151 | store.create_edge( |
| 152 | comment preceding a str(path.relative_to(repo_root)) |
| 153 | |
| 154 | store.create_node( |
| 155 | NodeLabel.File, |
| 156 | { |
| 157 | "name": path.name, |
| 158 | "path": rel_path, |
| 159 | "language": "javNodeLabel.Class, { |
| 160 | "\n"), |
| 161 | {"name": parent_n, |
| 162 | "line_start": node.startclaration", |
| 163 | # Nested class — register but d} inner_name_node = child.cNodeLabel.File, |
| 164 | "" |
| 165 | Java AST parser —ser — extracts |
| 166 | "docstringself, node, sou {"name": paren store.creatcomment preceding a str(path.relative_to(repo_root)) |
| 167 | |
| 168 | store.create_node( |
| 169 | NodeLabel.File, |
| 170 | { |
| 171 | "name": path.name, |
| 172 | "path": rel_path, |
| 173 | "language": "javes using tree-sit""""\n"), |
| 174 | {"name": parent_n, |
| 175 | "line_start": node.startclaration", |
| 176 | "class_name": class_name, |
| 177 | |
| 178 | } inner_name_node = chClass,odeLabel.Class, |
| 179 | {"name": class_name, "file_path": file_path}, |
| 180 | node_text(inner_name_node, source) |
| 181 | store.cfunctions"] += 1 |
| 182 | stats["edges"] += 1 |
| 183 | |
| 184 | self._extract_calls(node, source, file_path, name, store, statself, node, sou {"name": paren store.create_edge( |
| 185 | comment preceding aict |
| 186 | ) -> None: |
| 187 | # import java.util.List; → strip keyword + semicolon |
| 188 | (raw.removeprefix("imporint[0] + 1, |
| 189 | .removesuffix(";").strip() inner_name_node = cnode(NodeLabel.Import, { |
| 190 | "\n"), |
| 191 | }module,"" |
| 192 | Java Aser — extradges": 0} |
| 193 | self._"module": module, |
| 194 | |
| 195 | } inner_name_node = child.cNodeLabel.File, |
| 196 | {"path": file_path}, |
| 197 | EdgeType.IMPORTS, |
| 198 | {"name": module, "file_path": file_path}, |
| 199 | ) |
| 200 | stats["edges"] +=self, method_ {"name": parenmethod_name: str, store.create_edge( |
| 201 | comment preceding act, |
| 202 | ) -> None: |
| 203 | def walk(node): |
| 204 | if node.type == "method_invocation": |
| 205 | name_node = node.child_by_field_name("name") |
| 206 | if name_node: |
| 207 | callee = _node_text(name_node, source) |
| 208 | store.od, |
| 209 | {"name": method_name, "file_path": file_path}, |
| 210 | EdgeType.CALLS, |
| 211 | {"name": callee, "file_path": file_path}, |
| 212 | ) |
| 213 | stats["edges"] += 1 |
| 214 | for child in node.children: |
| 215 | walk(child) |
| 216 | |
| 217 | body = method_node.child_by_field_name("body") |
| 218 | if body: |
| 219 | walk(body) |
+30
-6
| --- navegador/ingestion/parser.py | ||
| +++ navegador/ingestion/parser.py | ||
| @@ -1,8 +1,16 @@ | ||
| 1 | 1 | """ |
| 2 | 2 | RepoIngester — walks a repository, parses source files with tree-sitter, |
| 3 | 3 | and writes nodes + edges into the GraphStore. |
| 4 | + | |
| 5 | +Supported languages (all via tree-sitter): | |
| 6 | + Python .py | |
| 7 | + TypeScript .ts .tsx | |
| 8 | + JavaScript .js .jsx | |
| 9 | + Go .go | |
| 10 | + Rust .rs | |
| 11 | + Java .java | |
| 4 | 12 | """ |
| 5 | 13 | |
| 6 | 14 | import logging |
| 7 | 15 | from pathlib import Path |
| 8 | 16 | |
| @@ -11,15 +19,18 @@ | ||
| 11 | 19 | |
| 12 | 20 | logger = logging.getLogger(__name__) |
| 13 | 21 | |
| 14 | 22 | # File extensions → language key |
| 15 | 23 | LANGUAGE_MAP: dict[str, str] = { |
| 16 | - ".py": "python", | |
| 17 | - ".ts": "typescript", | |
| 18 | - ".tsx": "typescript", | |
| 19 | - ".js": "javascript", | |
| 20 | - ".jsx": "javascript", | |
| 24 | + ".py": "python", | |
| 25 | + ".ts": "typescript", | |
| 26 | + ".tsx": "typescript", | |
| 27 | + ".js": "javascript", | |
| 28 | + ".jsx": "javascript", | |
| 29 | + ".go": "go", | |
| 30 | + ".rs": "rust", | |
| 31 | + ".java": "java", | |
| 21 | 32 | } |
| 22 | 33 | |
| 23 | 34 | |
| 24 | 35 | class RepoIngester: |
| 25 | 36 | """ |
| @@ -81,11 +92,15 @@ | ||
| 81 | 92 | ) |
| 82 | 93 | return stats |
| 83 | 94 | |
| 84 | 95 | def _iter_source_files(self, repo_path: Path): |
| 85 | 96 | skip_dirs = { |
| 86 | - ".git", ".venv", "venv", "node_modules", "__pycache__", "dist", "build", ".next" | |
| 97 | + ".git", ".venv", "venv", "node_modules", "__pycache__", | |
| 98 | + "dist", "build", ".next", | |
| 99 | + "target", # Rust / Java (Maven/Gradle) | |
| 100 | + "vendor", # Go modules cache | |
| 101 | + ".gradle", # Gradle cache | |
| 87 | 102 | } |
| 88 | 103 | for path in repo_path.rglob("*"): |
| 89 | 104 | if path.is_file() and path.suffix in LANGUAGE_MAP: |
| 90 | 105 | if not any(part in skip_dirs for part in path.parts): |
| 91 | 106 | yield path |
| @@ -96,10 +111,19 @@ | ||
| 96 | 111 | from navegador.ingestion.python import PythonParser |
| 97 | 112 | self._parsers[language] = PythonParser() |
| 98 | 113 | elif language in ("typescript", "javascript"): |
| 99 | 114 | from navegador.ingestion.typescript import TypeScriptParser |
| 100 | 115 | self._parsers[language] = TypeScriptParser(language) |
| 116 | + elif language == "go": | |
| 117 | + from navegador.ingestion.go import GoParser | |
| 118 | + self._parsers[language] = GoParser() | |
| 119 | + elif language == "rust": | |
| 120 | + from navegador.ingestion.rust import RustParser | |
| 121 | + self._parsers[language] = RustParser() | |
| 122 | + elif language == "java": | |
| 123 | + from navegador.ingestion.java import JavaParser | |
| 124 | + self._parsers[language] = JavaParser() | |
| 101 | 125 | else: |
| 102 | 126 | raise ValueError(f"Unsupported language: {language}") |
| 103 | 127 | return self._parsers[language] |
| 104 | 128 | |
| 105 | 129 | |
| 106 | 130 | |
| 107 | 131 | ADDED navegador/ingestion/rust.py |
| --- navegador/ingestion/parser.py | |
| +++ navegador/ingestion/parser.py | |
| @@ -1,8 +1,16 @@ | |
| 1 | """ |
| 2 | RepoIngester — walks a repository, parses source files with tree-sitter, |
| 3 | and writes nodes + edges into the GraphStore. |
| 4 | """ |
| 5 | |
| 6 | import logging |
| 7 | from pathlib import Path |
| 8 | |
| @@ -11,15 +19,18 @@ | |
| 11 | |
| 12 | logger = logging.getLogger(__name__) |
| 13 | |
| 14 | # File extensions → language key |
| 15 | LANGUAGE_MAP: dict[str, str] = { |
| 16 | ".py": "python", |
| 17 | ".ts": "typescript", |
| 18 | ".tsx": "typescript", |
| 19 | ".js": "javascript", |
| 20 | ".jsx": "javascript", |
| 21 | } |
| 22 | |
| 23 | |
| 24 | class RepoIngester: |
| 25 | """ |
| @@ -81,11 +92,15 @@ | |
| 81 | ) |
| 82 | return stats |
| 83 | |
| 84 | def _iter_source_files(self, repo_path: Path): |
| 85 | skip_dirs = { |
| 86 | ".git", ".venv", "venv", "node_modules", "__pycache__", "dist", "build", ".next" |
| 87 | } |
| 88 | for path in repo_path.rglob("*"): |
| 89 | if path.is_file() and path.suffix in LANGUAGE_MAP: |
| 90 | if not any(part in skip_dirs for part in path.parts): |
| 91 | yield path |
| @@ -96,10 +111,19 @@ | |
| 96 | from navegador.ingestion.python import PythonParser |
| 97 | self._parsers[language] = PythonParser() |
| 98 | elif language in ("typescript", "javascript"): |
| 99 | from navegador.ingestion.typescript import TypeScriptParser |
| 100 | self._parsers[language] = TypeScriptParser(language) |
| 101 | else: |
| 102 | raise ValueError(f"Unsupported language: {language}") |
| 103 | return self._parsers[language] |
| 104 | |
| 105 | |
| 106 | |
| 107 | DDED navegador/ingestion/rust.py |
| --- navegador/ingestion/parser.py | |
| +++ navegador/ingestion/parser.py | |
| @@ -1,8 +1,16 @@ | |
| 1 | """ |
| 2 | RepoIngester — walks a repository, parses source files with tree-sitter, |
| 3 | and writes nodes + edges into the GraphStore. |
| 4 | |
| 5 | Supported languages (all via tree-sitter): |
| 6 | Python .py |
| 7 | TypeScript .ts .tsx |
| 8 | JavaScript .js .jsx |
| 9 | Go .go |
| 10 | Rust .rs |
| 11 | Java .java |
| 12 | """ |
| 13 | |
| 14 | import logging |
| 15 | from pathlib import Path |
| 16 | |
| @@ -11,15 +19,18 @@ | |
| 19 | |
| 20 | logger = logging.getLogger(__name__) |
| 21 | |
| 22 | # File extensions → language key |
| 23 | LANGUAGE_MAP: dict[str, str] = { |
| 24 | ".py": "python", |
| 25 | ".ts": "typescript", |
| 26 | ".tsx": "typescript", |
| 27 | ".js": "javascript", |
| 28 | ".jsx": "javascript", |
| 29 | ".go": "go", |
| 30 | ".rs": "rust", |
| 31 | ".java": "java", |
| 32 | } |
| 33 | |
| 34 | |
| 35 | class RepoIngester: |
| 36 | """ |
| @@ -81,11 +92,15 @@ | |
| 92 | ) |
| 93 | return stats |
| 94 | |
| 95 | def _iter_source_files(self, repo_path: Path): |
| 96 | skip_dirs = { |
| 97 | ".git", ".venv", "venv", "node_modules", "__pycache__", |
| 98 | "dist", "build", ".next", |
| 99 | "target", # Rust / Java (Maven/Gradle) |
| 100 | "vendor", # Go modules cache |
| 101 | ".gradle", # Gradle cache |
| 102 | } |
| 103 | for path in repo_path.rglob("*"): |
| 104 | if path.is_file() and path.suffix in LANGUAGE_MAP: |
| 105 | if not any(part in skip_dirs for part in path.parts): |
| 106 | yield path |
| @@ -96,10 +111,19 @@ | |
| 111 | from navegador.ingestion.python import PythonParser |
| 112 | self._parsers[language] = PythonParser() |
| 113 | elif language in ("typescript", "javascript"): |
| 114 | from navegador.ingestion.typescript import TypeScriptParser |
| 115 | self._parsers[language] = TypeScriptParser(language) |
| 116 | elif language == "go": |
| 117 | from navegador.ingestion.go import GoParser |
| 118 | self._parsers[language] = GoParser() |
| 119 | elif language == "rust": |
| 120 | from navegador.ingestion.rust import RustParser |
| 121 | self._parsers[language] = RustParser() |
| 122 | elif language == "java": |
| 123 | from navegador.ingestion.java import JavaParser |
| 124 | self._parsers[language] = JavaParser() |
| 125 | else: |
| 126 | raise ValueError(f"Unsupported language: {language}") |
| 127 | return self._parsers[language] |
| 128 | |
| 129 | |
| 130 | |
| 131 | DDED navegador/ingestion/rust.py |
+112
| --- a/navegador/ingestion/rust.py | ||
| +++ b/navegador/ingestion/rust.py | ||
| @@ -0,0 +1,112 @@ | ||
| 1 | +""" | |
| 2 | +Rust AST parser — extracts functions, methods (from impl blocks), | |
| 3 | +structs/enums/traits, use declarations, and call edges from .rs files. | |
| 4 | +""" | |
| 5 | + | |
| 6 | +import logging | |
| 7 | +from pathlib import Path | |
| 8 | + | |
| 9 | +from navegador.graph.schema import EdgeType, NodeLabel | |
| 10 | +from navegador.graph.store import GraphStore | |
| 11 | +from navegador.ingestion.parser import LanguageParser | |
| 12 | + | |
| 13 | +logger = logging.getLogger(__name__) | |
| 14 | + | |
| 15 | + | |
| 16 | +def _get_rust_language(): | |
| 17 | + try: | |
| 18 | + import tree_sitter_rust as tsrust # type: ignore[import] | |
| 19 | + from tree_sitter import Language | |
| 20 | + return Language(tsrust.language()) | |
| 21 | + except ImportError as e: | |
| 22 | + raise ImportError("Install tree-sitter-rust: pip install tree-sitter-rust") from e | |
| 23 | + | |
| 24 | + | |
| 25 | +def _node_text(node, source: bytes) -> str: | |
| 26 | + re:node.end_byte].decode("utf-8", errors="replace") | |
| 27 | + | |
| 28 | + | |
| 29 | +def _doc_comment(node, source: bytes) -> str: | |
| 30 | + """Collect preceding /// doc-comment lines from siblings.""" | |
| 31 | + parent = node.parent | |
| 32 | + if not parent: | |
| 33 | + return "" | |
| 34 | + siblings = list(parent.children) | |
| 35 | + idx = next((i for i, c in enumerate(siblings) if c.id == node.id), -1) | |
| 36 | + if idx <= 0: | |
| 37 | + = [] | |
| 38 | + for i in range(idx - 1, -1, -1): | |
| 39 | + sib = siblings[i] | |
| 40 | + raw = _node_text(sib, source) | |
| 41 | + if sib.type == "line_comment" and raw.startswith("///"): | |
| 42 | + lines.insert(0, raw.lstrip("/").strip()) | |
| 43 | + else: | |
| 44 | + break | |
| 45 | + return " ".join(lines) | |
| 46 | + | |
| 47 | + | |
| 48 | +class RustParser(LanguageParser): | |
| 49 | + """Parses Rust source files into the navegador graph.""" | |
| 50 | + | |
| 51 | + def __init__(self) -> None: | |
| 52 | + from tree_sitter import Parser # type: ignore[import] | |
| 53 | +r # type: ignore[import] | |
| 54 | + | |
| 55 | + self._parser = Parser(_get_rust_language()) | |
| 56 | + | |
| 57 | + def parse_file(self, path: Path, repo_root: Path, store: GraphStore) -> dict[str, int]: | |
| 58 | + source = path.read_bytes() | |
| 59 | + tree = self._parser.parse(source) | |
| 60 | + rel_path = str(paNodeLabel.File, { | |
| 61 | + "line_e"name": path.name, | |
| 62 | + "path": reluse declarations, a""" | |
| 63 | +Rust AST parser — extracts": "rust", | |
| 64 | + }"), | |
| 65 | + }, | |
| 66 | + ) | |
| 67 | + | |
| 68 | + stats = {"functions": 0, "classes": 0, "edges": 0} | |
| 69 | + self._walk(tree.root_node, source, rel_path, store, stats, impl_type=None) | |
| 70 | + return stats | |
| 71 | + | |
| 72 | + # ── AST walker ────────────────────────────────────────────────────────� def _handle_im | |
| 73 | + store: GraphStore, stats: dict, impl_type: str | None) -> None: | |
| 74 | + nt: | |
| 75 | + return "" | |
| 76 | + siblings = list(parent.children) | |
| 77 | + idx = next((i for i, c in enumerate(siblings) if c.id == node.id), -1) | |
| 78 | + if idx <= 0: | |
| 79 | + return "" | |
| 80 | + lines: list[str] = [] | |
| 81 | + for i in range(idx - 1, -1, -1): | |
| 82 | + sib = siblings[i] | |
| 83 | + raw = _node_text(sib, source) | |
| 84 | + if sib.type == "line_comment" and raw.startswith("///"): | |
| 85 | + lines.insert(0, raw.lstrip("/").strip()) | |
| 86 | + else: | |
| 87 | + break | |
| 88 | + return " ".join(lines) | |
| 89 | + | |
| 90 | + | |
| 91 | +class RustParser(LanguageParser): | |
| 92 | + """Parses Rust source files into the navegador graph.""" | |
| 93 | + | |
| 94 | + def __init__(self) -> None: | |
| 95 | + from tree_sitter import Parser # type: ignore[import] | |
| 96 | + | |
| 97 | + self._parser = Parser(_get_rust_language()) | |
| 98 | + | |
| 99 | + def parse_file(self, path: Path, repo_root: Path, store: GraphStore) -> dict[str, int]: | |
| 100 | + source = path.read_bytes() | |
| 101 | + tree = self def _handle_imfor child in body store: GraphStore, stats: dict"" | |
| 102 | +Rust AST parser —""" | |
| 103 | +Rust AST parser — ex str | NoneJ@OT,4N@wN,9:label, { | |
| 104 | +B@1~B,R@1UF,M@1aG,2:, | |
| 105 | +7@eU,t@1he,k@1WD,_@13G,c@13t,1:}2V@14j,9@15w,3: 1d@17D,11@191,12@1a0,2K@1BG,f@1Di,H@1Il,4: V@1NE,J@OT,4~@1FE,5:childY@gk,H@1Il,O@1z0,1: X@1LD,O:) | |
| 106 | + | |
| 107 | + def _handle_type(f@1Di,H@1Il,4: V@1NE,J@OT,2F@wN,q@1QK,H@1Il,R@1RA,E:node.type, "")9@FG,c@yb,M@1YW,O:node(NodeLabel.Class, { | |
| 108 | +B@1~B,R@1UF,M@1aG,2:, | |
| 109 | +7@eU,t@1he,k@1WD,1G@1X0,1:}t@1YV,K@17V,i@19F,G:NodeLabel.Class,22@1a0,f@1Di,H@1Il,3: V@1NE,J@OT,1y@1dh,K:NodeLabel.Import, { | |
| 110 | +d@1gQ,M@1aG,2:, | |
| 111 | +7@eU,t@1he,I:"module": module, | |
| 112 | +8@1hW,1:}t@1YV,K@17V,N@19F,b@1ky,1e@1lk,9:self, fn__@1Mf,H@1Il,7@8E,R:fn_name: str, fn_label: strM@1~0,2: V@1NE,J@OT,6D@1qR,1~@1x0,49@1zN,1P_dm9; |
| --- a/navegador/ingestion/rust.py | |
| +++ b/navegador/ingestion/rust.py | |
| @@ -0,0 +1,112 @@ | |
| --- a/navegador/ingestion/rust.py | |
| +++ b/navegador/ingestion/rust.py | |
| @@ -0,0 +1,112 @@ | |
| 1 | """ |
| 2 | Rust AST parser — extracts functions, methods (from impl blocks), |
| 3 | structs/enums/traits, use declarations, and call edges from .rs files. |
| 4 | """ |
| 5 | |
| 6 | import logging |
| 7 | from pathlib import Path |
| 8 | |
| 9 | from navegador.graph.schema import EdgeType, NodeLabel |
| 10 | from navegador.graph.store import GraphStore |
| 11 | from navegador.ingestion.parser import LanguageParser |
| 12 | |
| 13 | logger = logging.getLogger(__name__) |
| 14 | |
| 15 | |
| 16 | def _get_rust_language(): |
| 17 | try: |
| 18 | import tree_sitter_rust as tsrust # type: ignore[import] |
| 19 | from tree_sitter import Language |
| 20 | return Language(tsrust.language()) |
| 21 | except ImportError as e: |
| 22 | raise ImportError("Install tree-sitter-rust: pip install tree-sitter-rust") from e |
| 23 | |
| 24 | |
| 25 | def _node_text(node, source: bytes) -> str: |
| 26 | re:node.end_byte].decode("utf-8", errors="replace") |
| 27 | |
| 28 | |
| 29 | def _doc_comment(node, source: bytes) -> str: |
| 30 | """Collect preceding /// doc-comment lines from siblings.""" |
| 31 | parent = node.parent |
| 32 | if not parent: |
| 33 | return "" |
| 34 | siblings = list(parent.children) |
| 35 | idx = next((i for i, c in enumerate(siblings) if c.id == node.id), -1) |
| 36 | if idx <= 0: |
| 37 | = [] |
| 38 | for i in range(idx - 1, -1, -1): |
| 39 | sib = siblings[i] |
| 40 | raw = _node_text(sib, source) |
| 41 | if sib.type == "line_comment" and raw.startswith("///"): |
| 42 | lines.insert(0, raw.lstrip("/").strip()) |
| 43 | else: |
| 44 | break |
| 45 | return " ".join(lines) |
| 46 | |
| 47 | |
| 48 | class RustParser(LanguageParser): |
| 49 | """Parses Rust source files into the navegador graph.""" |
| 50 | |
| 51 | def __init__(self) -> None: |
| 52 | from tree_sitter import Parser # type: ignore[import] |
| 53 | r # type: ignore[import] |
| 54 | |
| 55 | self._parser = Parser(_get_rust_language()) |
| 56 | |
| 57 | def parse_file(self, path: Path, repo_root: Path, store: GraphStore) -> dict[str, int]: |
| 58 | source = path.read_bytes() |
| 59 | tree = self._parser.parse(source) |
| 60 | rel_path = str(paNodeLabel.File, { |
| 61 | "line_e"name": path.name, |
| 62 | "path": reluse declarations, a""" |
| 63 | Rust AST parser — extracts": "rust", |
| 64 | }"), |
| 65 | }, |
| 66 | ) |
| 67 | |
| 68 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 69 | self._walk(tree.root_node, source, rel_path, store, stats, impl_type=None) |
| 70 | return stats |
| 71 | |
| 72 | # ── AST walker ────────────────────────────────────────────────────────� def _handle_im |
| 73 | store: GraphStore, stats: dict, impl_type: str | None) -> None: |
| 74 | nt: |
| 75 | return "" |
| 76 | siblings = list(parent.children) |
| 77 | idx = next((i for i, c in enumerate(siblings) if c.id == node.id), -1) |
| 78 | if idx <= 0: |
| 79 | return "" |
| 80 | lines: list[str] = [] |
| 81 | for i in range(idx - 1, -1, -1): |
| 82 | sib = siblings[i] |
| 83 | raw = _node_text(sib, source) |
| 84 | if sib.type == "line_comment" and raw.startswith("///"): |
| 85 | lines.insert(0, raw.lstrip("/").strip()) |
| 86 | else: |
| 87 | break |
| 88 | return " ".join(lines) |
| 89 | |
| 90 | |
| 91 | class RustParser(LanguageParser): |
| 92 | """Parses Rust source files into the navegador graph.""" |
| 93 | |
| 94 | def __init__(self) -> None: |
| 95 | from tree_sitter import Parser # type: ignore[import] |
| 96 | |
| 97 | self._parser = Parser(_get_rust_language()) |
| 98 | |
| 99 | def parse_file(self, path: Path, repo_root: Path, store: GraphStore) -> dict[str, int]: |
| 100 | source = path.read_bytes() |
| 101 | tree = self def _handle_imfor child in body store: GraphStore, stats: dict"" |
| 102 | Rust AST parser —""" |
| 103 | Rust AST parser — ex str | NoneJ@OT,4N@wN,9:label, { |
| 104 | B@1~B,R@1UF,M@1aG,2:, |
| 105 | 7@eU,t@1he,k@1WD,_@13G,c@13t,1:}2V@14j,9@15w,3: 1d@17D,11@191,12@1a0,2K@1BG,f@1Di,H@1Il,4: V@1NE,J@OT,4~@1FE,5:childY@gk,H@1Il,O@1z0,1: X@1LD,O:) |
| 106 | |
| 107 | def _handle_type(f@1Di,H@1Il,4: V@1NE,J@OT,2F@wN,q@1QK,H@1Il,R@1RA,E:node.type, "")9@FG,c@yb,M@1YW,O:node(NodeLabel.Class, { |
| 108 | B@1~B,R@1UF,M@1aG,2:, |
| 109 | 7@eU,t@1he,k@1WD,1G@1X0,1:}t@1YV,K@17V,i@19F,G:NodeLabel.Class,22@1a0,f@1Di,H@1Il,3: V@1NE,J@OT,1y@1dh,K:NodeLabel.Import, { |
| 110 | d@1gQ,M@1aG,2:, |
| 111 | 7@eU,t@1he,I:"module": module, |
| 112 | 8@1hW,1:}t@1YV,K@17V,N@19F,b@1ky,1e@1lk,9:self, fn__@1Mf,H@1Il,7@8E,R:fn_name: str, fn_label: strM@1~0,2: V@1NE,J@OT,6D@1qR,1~@1x0,49@1zN,1P_dm9; |
+234
-68
| --- navegador/ingestion/typescript.py | ||
| +++ navegador/ingestion/typescript.py | ||
| @@ -1,8 +1,12 @@ | ||
| 1 | 1 | """ |
| 2 | -TypeScript/JavaScript AST parser — placeholder for tree-sitter-typescript. | |
| 3 | -Full implementation follows the same pattern as python.py. | |
| 2 | +TypeScript/JavaScript AST parser — extracts classes, interfaces, functions | |
| 3 | +(including const arrow functions), methods, imports, and call edges. | |
| 4 | + | |
| 5 | +Supports: | |
| 6 | + - .ts / .tsx → tree-sitter-typescript (TypeScript grammar) | |
| 7 | + - .js / .jsx → tree-sitter-javascript (JavaScript grammar) | |
| 4 | 8 | """ |
| 5 | 9 | |
| 6 | 10 | import logging |
| 7 | 11 | from pathlib import Path |
| 8 | 12 | |
| @@ -26,12 +30,35 @@ | ||
| 26 | 30 | except ImportError as e: |
| 27 | 31 | raise ImportError( |
| 28 | 32 | f"Install tree-sitter-{language}: pip install tree-sitter-{language}" |
| 29 | 33 | ) from e |
| 30 | 34 | |
| 35 | + | |
| 36 | +def _node_text(node, source: bytes) -> str: | |
| 37 | + return source[node.start_byte:node.end_byte].decode("utf-8", errors="replace") | |
| 38 | + | |
| 39 | + | |
| 40 | +def _jsdoc(node, source: bytes) -> str: | |
| 41 | + """Return the JSDoc comment (/** ... */) preceding a node, if any.""" | |
| 42 | + parent = node.parent | |
| 43 | + if not parent: | |
| 44 | + return "" | |
| 45 | + siblings = list(parent.children) | |
| 46 | + idx = next((i for i, c in enumerate(siblings) if c.id == node.id), -1) | |
| 47 | + if idx <= 0: | |
| 48 | + return "" | |
| 49 | + prev = siblings[idx - 1] | |
| 50 | + if prev.type == "comment": | |
| 51 | + raw = _node_text(prev, source).strip() | |
| 52 | + if raw.startswith("/**"): | |
| 53 | + return raw.strip("/**").strip("*/").strip() | |
| 54 | + return "" | |
| 55 | + | |
| 31 | 56 | |
| 32 | 57 | class TypeScriptParser(LanguageParser): |
| 58 | + """Parses TypeScript/JavaScript source files into the navegador graph.""" | |
| 59 | + | |
| 33 | 60 | def __init__(self, language: str = "typescript") -> None: |
| 34 | 61 | from tree_sitter import Parser # type: ignore[import] |
| 35 | 62 | self._parser = Parser(_get_ts_language(language)) |
| 36 | 63 | self._language = language |
| 37 | 64 | |
| @@ -49,33 +76,205 @@ | ||
| 49 | 76 | |
| 50 | 77 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 51 | 78 | self._walk(tree.root_node, source, rel_path, store, stats, class_name=None) |
| 52 | 79 | return stats |
| 53 | 80 | |
| 54 | - def _walk(self, node, source: bytes, file_path: str, store: GraphStore, | |
| 55 | - stats: dict, class_name: str | None) -> None: | |
| 81 | + # ── AST walker ──────────────────────────────────────────────────────────── | |
| 82 | + | |
| 83 | + def _walk(self, node, source: bytes, file_path: str, | |
| 84 | + store: GraphStore, stats: dict, class_name: str | None) -> None: | |
| 56 | 85 | if node.type in ("class_declaration", "abstract_class_declaration"): |
| 57 | 86 | self._handle_class(node, source, file_path, store, stats) |
| 58 | 87 | return |
| 59 | - if node.type in ( | |
| 60 | - "function_declaration", "arrow_function", "method_definition", | |
| 61 | - "function_expression", | |
| 62 | - ): | |
| 88 | + if node.type in ("interface_declaration", "type_alias_declaration"): | |
| 89 | + self._handle_interface(node, source, file_path, store, stats) | |
| 90 | + return | |
| 91 | + if node.type == "function_declaration": | |
| 92 | + self._handle_function(node, source, file_path, store, stats, class_name) | |
| 93 | + return | |
| 94 | + if node.type == "method_definition": | |
| 63 | 95 | self._handle_function(node, source, file_path, store, stats, class_name) |
| 96 | + return | |
| 97 | + if node.type in ("lexical_declaration", "variable_declaration"): | |
| 98 | + self._handle_lexical(node, source, file_path, store, stats) | |
| 64 | 99 | return |
| 65 | 100 | if node.type in ("import_statement", "import_declaration"): |
| 66 | 101 | self._handle_import(node, source, file_path, store, stats) |
| 102 | + return | |
| 103 | + if node.type == "export_statement": | |
| 104 | + # Recurse into exported declarations | |
| 105 | + for child in node.children: | |
| 106 | + if child.type not in ("export", "default", "from"): | |
| 107 | + self._walk(child, source, file_path, store, stats, class_name) | |
| 108 | + return | |
| 67 | 109 | |
| 68 | 110 | for child in node.children: |
| 69 | 111 | self._walk(child, source, file_path, store, stats, class_name) |
| 70 | 112 | |
| 113 | + # ── Handlers ────────────────────────────────────────────────────────────── | |
| 114 | + | |
| 115 | + def _handle_class(self, node, source: bytes, file_path: str, | |
| 116 | + store: GraphStore, stats: dict) -> None: | |
| 117 | + name_node = next( | |
| 118 | + (c for c in node.children if c.type == "type_identifier"), None | |
| 119 | + ) | |
| 120 | + if not name_node: | |
| 121 | + return | |
| 122 | + name = _node_text(name_node, source) | |
| 123 | + docstring = _jsdoc(node, source) | |
| 124 | + | |
| 125 | + store.create_node(NodeLabel.Class, { | |
| 126 | + "name": name, | |
| 127 | + "file_path": file_path, | |
| 128 | + "line_start": node.start_point[0] + 1, | |
| 129 | + "line_end": node.end_point[0] + 1, | |
| 130 | + "docstring": docstring, | |
| 131 | + }) | |
| 132 | + store.create_edge( | |
| 133 | + NodeLabel.File, {"path": file_path}, | |
| 134 | + EdgeType.CONTAINS, | |
| 135 | + NodeLabel.Class, {"name": name, "file_path": file_path}, | |
| 136 | + ) | |
| 137 | + stats["classes"] += 1 | |
| 138 | + stats["edges"] += 1 | |
| 139 | + | |
| 140 | + # Inheritance: extends clause | |
| 141 | + heritage = next((c for c in node.children if c.type == "class_heritage"), None) | |
| 142 | + if heritage: | |
| 143 | + for child in heritage.children: | |
| 144 | + if child.type == "extends_clause": | |
| 145 | + for c in child.children: | |
| 146 | + if c.type == "identifier": | |
| 147 | + parent_name = _node_text(c, source) | |
| 148 | + store.create_edge( | |
| 149 | + NodeLabel.Class, {"name": name, "file_path": file_path}, | |
| 150 | + EdgeType.INHERITS, | |
| 151 | + NodeLabel.Class, {"name": parent_name, "file_path": file_path}, | |
| 152 | + ) | |
| 153 | + stats["edges"] += 1 | |
| 154 | + | |
| 155 | + body = next((c for c in node.children if c.type == "class_body"), None) | |
| 156 | + if body: | |
| 157 | + for child in body.children: | |
| 158 | + if child.type == "method_definition": | |
| 159 | + self._handle_function(child, source, file_path, store, stats, | |
| 160 | + class_name=name) | |
| 161 | + | |
| 162 | + def _handle_interface(self, node, source: bytes, file_path: str, | |
| 163 | + store: GraphStore, stats: dict) -> None: | |
| 164 | + name_node = next( | |
| 165 | + (c for c in node.children if c.type == "type_identifier"), None | |
| 166 | + ) | |
| 167 | + if not name_node: | |
| 168 | + return | |
| 169 | + name = _node_text(name_node, source) | |
| 170 | + docstring = _jsdoc(node, source) | |
| 171 | + kind = "interface" if node.type == "interface_declaration" else "type" | |
| 172 | + | |
| 173 | + store.create_node(NodeLabel.Class, { | |
| 174 | + "name": name, | |
| 175 | + "file_path": file_path, | |
| 176 | + "line_start": node.start_point[0] + 1, | |
| 177 | + "line_end": node.end_point[0] + 1, | |
| 178 | + "docstring": f"{kind}: {docstring}".strip(": "), | |
| 179 | + }) | |
| 180 | + store.create_edge( | |
| 181 | + NodeLabel.File, {"path": file_path}, | |
| 182 | + EdgeType.CONTAINS, | |
| 183 | + NodeLabel.Class, {"name": name, "file_path": file_path}, | |
| 184 | + ) | |
| 185 | + stats["classes"] += 1 | |
| 186 | + stats["edges"] += 1 | |
| 187 | + | |
| 188 | + def _handle_function(self, node, source: bytes, file_path: str, | |
| 189 | + store: GraphStore, stats: dict, | |
| 190 | + class_name: str | None) -> None: | |
| 191 | + name_node = next( | |
| 192 | + (c for c in node.children | |
| 193 | + if c.type in ("identifier", "property_identifier")), None | |
| 194 | + ) | |
| 195 | + if not name_node: | |
| 196 | + return | |
| 197 | + name = _node_text(name_node, source) | |
| 198 | + if name in ("constructor", "get", "set", "static", "async"): | |
| 199 | + # These are keywords, not useful names — look for next identifier | |
| 200 | + name_node = next( | |
| 201 | + (c for c in node.children | |
| 202 | + if c.type in ("identifier", "property_identifier") and c != name_node), | |
| 203 | + None, | |
| 204 | + ) | |
| 205 | + if not name_node: | |
| 206 | + return | |
| 207 | + name = _node_text(name_node, source) | |
| 208 | + | |
| 209 | + docstring = _jsdoc(node, source) | |
| 210 | + label = NodeLabel.Method if class_name else NodeLabel.Function | |
| 211 | + | |
| 212 | + store.create_node(label, { | |
| 213 | + "name": name, | |
| 214 | + "file_path": file_path, | |
| 215 | + "line_start": node.start_point[0] + 1, | |
| 216 | + "line_end": node.end_point[0] + 1, | |
| 217 | + "docstring": docstring, | |
| 218 | + "class_name": class_name or "", | |
| 219 | + }) | |
| 220 | + | |
| 221 | + container_label = NodeLabel.Class if class_name else NodeLabel.File | |
| 222 | + container_key = ( | |
| 223 | + {"name": class_name, "file_path": file_path} | |
| 224 | + if class_name else {"path": file_path} | |
| 225 | + ) | |
| 226 | + store.create_edge( | |
| 227 | + container_label, container_key, | |
| 228 | + EdgeType.CONTAINS, | |
| 229 | + label, {"name": name, "file_path": file_path}, | |
| 230 | + ) | |
| 231 | + stats["functions"] += 1 | |
| 232 | + stats["edges"] += 1 | |
| 233 | + | |
| 234 | + self._extract_calls(node, source, file_path, name, label, store, stats) | |
| 235 | + | |
| 236 | + def _handle_lexical(self, node, source: bytes, file_path: str, | |
| 237 | + store: GraphStore, stats: dict) -> None: | |
| 238 | + """Handle: const foo = () => {} and const bar = function() {}""" | |
| 239 | + for child in node.children: | |
| 240 | + if child.type != "variable_declarator": | |
| 241 | + continue | |
| 242 | + name_node = child.child_by_field_name("name") | |
| 243 | + value_node = child.child_by_field_name("value") | |
| 244 | + if not name_node or not value_node: | |
| 245 | + continue | |
| 246 | + if value_node.type not in ("arrow_function", "function_expression", | |
| 247 | + "function"): | |
| 248 | + continue | |
| 249 | + name = _node_text(name_node, source) | |
| 250 | + docstring = _jsdoc(node, source) | |
| 251 | + | |
| 252 | + store.create_node(NodeLabel.Function, { | |
| 253 | + "name": name, | |
| 254 | + "file_path": file_path, | |
| 255 | + "line_start": node.start_point[0] + 1, | |
| 256 | + "line_end": node.end_point[0] + 1, | |
| 257 | + "docstring": docstring, | |
| 258 | + "class_name": "", | |
| 259 | + }) | |
| 260 | + store.create_edge( | |
| 261 | + NodeLabel.File, {"path": file_path}, | |
| 262 | + EdgeType.CONTAINS, | |
| 263 | + NodeLabel.Function, {"name": name, "file_path": file_path}, | |
| 264 | + ) | |
| 265 | + stats["functions"] += 1 | |
| 266 | + stats["edges"] += 1 | |
| 267 | + | |
| 268 | + self._extract_calls(value_node, source, file_path, name, | |
| 269 | + NodeLabel.Function, store, stats) | |
| 270 | + | |
| 71 | 271 | def _handle_import(self, node, source: bytes, file_path: str, |
| 72 | 272 | store: GraphStore, stats: dict) -> None: |
| 73 | - # Extract "from '...'" module path | |
| 74 | 273 | for child in node.children: |
| 75 | 274 | if child.type == "string": |
| 76 | - module = source[child.start_byte:child.end_byte].decode().strip("'\"") | |
| 275 | + module = _node_text(child, source).strip("'\"") | |
| 77 | 276 | store.create_node(NodeLabel.Import, { |
| 78 | 277 | "name": module, |
| 79 | 278 | "file_path": file_path, |
| 80 | 279 | "line_start": node.start_point[0] + 1, |
| 81 | 280 | "module": module, |
| @@ -86,64 +285,31 @@ | ||
| 86 | 285 | NodeLabel.Import, {"name": module, "file_path": file_path}, |
| 87 | 286 | ) |
| 88 | 287 | stats["edges"] += 1 |
| 89 | 288 | break |
| 90 | 289 | |
| 91 | - def _handle_class(self, node, source: bytes, file_path: str, | |
| 92 | - store: GraphStore, stats: dict) -> None: | |
| 93 | - name_node = next((c for c in node.children if c.type == "type_identifier"), None) | |
| 94 | - if not name_node: | |
| 95 | - return | |
| 96 | - name = source[name_node.start_byte:name_node.end_byte].decode() | |
| 97 | - | |
| 98 | - store.create_node(NodeLabel.Class, { | |
| 99 | - "name": name, | |
| 100 | - "file_path": file_path, | |
| 101 | - "line_start": node.start_point[0] + 1, | |
| 102 | - "line_end": node.end_point[0] + 1, | |
| 103 | - "docstring": "", | |
| 104 | - }) | |
| 105 | - store.create_edge( | |
| 106 | - NodeLabel.File, {"path": file_path}, | |
| 107 | - EdgeType.CONTAINS, | |
| 108 | - NodeLabel.Class, {"name": name, "file_path": file_path}, | |
| 109 | - ) | |
| 110 | - stats["classes"] += 1 | |
| 111 | - stats["edges"] += 1 | |
| 112 | - | |
| 113 | - body = next((c for c in node.children if c.type == "class_body"), None) | |
| 290 | + def _extract_calls(self, fn_node, source: bytes, file_path: str, | |
| 291 | + fn_name: str, fn_label: str, | |
| 292 | + store: GraphStore, stats: dict) -> None: | |
| 293 | + def walk(node): | |
| 294 | + if node.type == "call_expression": | |
| 295 | + func = node.child_by_field_name("function") | |
| 296 | + if func: | |
| 297 | + text = _node_text(func, source) | |
| 298 | + callee = text.split(".")[-1] | |
| 299 | + store.create_edge( | |
| 300 | + fn_label, {"name": fn_name, "file_path": file_path}, | |
| 301 | + EdgeType.CALLS, | |
| 302 | + NodeLabel.Function, {"name": callee, "file_path": file_path}, | |
| 303 | + ) | |
| 304 | + stats["edges"] += 1 | |
| 305 | + for child in node.children: | |
| 306 | + walk(child) | |
| 307 | + | |
| 308 | + # Body is statement_block for functions, expression for arrow fns | |
| 309 | + body = next( | |
| 310 | + (c for c in fn_node.children | |
| 311 | + if c.type in ("statement_block", "expression_statement")), | |
| 312 | + None, | |
| 313 | + ) | |
| 114 | 314 | if body: |
| 115 | - for child in body.children: | |
| 116 | - if child.type == "method_definition": | |
| 117 | - self._handle_function(child, source, file_path, store, stats, class_name=name) | |
| 118 | - | |
| 119 | - def _handle_function(self, node, source: bytes, file_path: str, | |
| 120 | - store: GraphStore, stats: dict, class_name: str | None) -> None: | |
| 121 | - name_node = next( | |
| 122 | - (c for c in node.children if c.type in ("identifier", "property_identifier")), None | |
| 123 | - ) | |
| 124 | - if not name_node: | |
| 125 | - return | |
| 126 | - name = source[name_node.start_byte:name_node.end_byte].decode() | |
| 127 | - | |
| 128 | - label = NodeLabel.Method if class_name else NodeLabel.Function | |
| 129 | - store.create_node(label, { | |
| 130 | - "name": name, | |
| 131 | - "file_path": file_path, | |
| 132 | - "line_start": node.start_point[0] + 1, | |
| 133 | - "line_end": node.end_point[0] + 1, | |
| 134 | - "docstring": "", | |
| 135 | - "class_name": class_name or "", | |
| 136 | - }) | |
| 137 | - | |
| 138 | - container_label = NodeLabel.Class if class_name else NodeLabel.File | |
| 139 | - container_key = ( | |
| 140 | - {"name": class_name, "file_path": file_path} | |
| 141 | - if class_name | |
| 142 | - else {"path": file_path} | |
| 143 | - ) | |
| 144 | - store.create_edge( | |
| 145 | - container_label, container_key, EdgeType.CONTAINS, label, | |
| 146 | - {"name": name, "file_path": file_path}, | |
| 147 | - ) | |
| 148 | - stats["functions"] += 1 | |
| 149 | - stats["edges"] += 1 | |
| 315 | + walk(body) | |
| 150 | 316 |
| --- navegador/ingestion/typescript.py | |
| +++ navegador/ingestion/typescript.py | |
| @@ -1,8 +1,12 @@ | |
| 1 | """ |
| 2 | TypeScript/JavaScript AST parser — placeholder for tree-sitter-typescript. |
| 3 | Full implementation follows the same pattern as python.py. |
| 4 | """ |
| 5 | |
| 6 | import logging |
| 7 | from pathlib import Path |
| 8 | |
| @@ -26,12 +30,35 @@ | |
| 26 | except ImportError as e: |
| 27 | raise ImportError( |
| 28 | f"Install tree-sitter-{language}: pip install tree-sitter-{language}" |
| 29 | ) from e |
| 30 | |
| 31 | |
| 32 | class TypeScriptParser(LanguageParser): |
| 33 | def __init__(self, language: str = "typescript") -> None: |
| 34 | from tree_sitter import Parser # type: ignore[import] |
| 35 | self._parser = Parser(_get_ts_language(language)) |
| 36 | self._language = language |
| 37 | |
| @@ -49,33 +76,205 @@ | |
| 49 | |
| 50 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 51 | self._walk(tree.root_node, source, rel_path, store, stats, class_name=None) |
| 52 | return stats |
| 53 | |
| 54 | def _walk(self, node, source: bytes, file_path: str, store: GraphStore, |
| 55 | stats: dict, class_name: str | None) -> None: |
| 56 | if node.type in ("class_declaration", "abstract_class_declaration"): |
| 57 | self._handle_class(node, source, file_path, store, stats) |
| 58 | return |
| 59 | if node.type in ( |
| 60 | "function_declaration", "arrow_function", "method_definition", |
| 61 | "function_expression", |
| 62 | ): |
| 63 | self._handle_function(node, source, file_path, store, stats, class_name) |
| 64 | return |
| 65 | if node.type in ("import_statement", "import_declaration"): |
| 66 | self._handle_import(node, source, file_path, store, stats) |
| 67 | |
| 68 | for child in node.children: |
| 69 | self._walk(child, source, file_path, store, stats, class_name) |
| 70 | |
| 71 | def _handle_import(self, node, source: bytes, file_path: str, |
| 72 | store: GraphStore, stats: dict) -> None: |
| 73 | # Extract "from '...'" module path |
| 74 | for child in node.children: |
| 75 | if child.type == "string": |
| 76 | module = source[child.start_byte:child.end_byte].decode().strip("'\"") |
| 77 | store.create_node(NodeLabel.Import, { |
| 78 | "name": module, |
| 79 | "file_path": file_path, |
| 80 | "line_start": node.start_point[0] + 1, |
| 81 | "module": module, |
| @@ -86,64 +285,31 @@ | |
| 86 | NodeLabel.Import, {"name": module, "file_path": file_path}, |
| 87 | ) |
| 88 | stats["edges"] += 1 |
| 89 | break |
| 90 | |
| 91 | def _handle_class(self, node, source: bytes, file_path: str, |
| 92 | store: GraphStore, stats: dict) -> None: |
| 93 | name_node = next((c for c in node.children if c.type == "type_identifier"), None) |
| 94 | if not name_node: |
| 95 | return |
| 96 | name = source[name_node.start_byte:name_node.end_byte].decode() |
| 97 | |
| 98 | store.create_node(NodeLabel.Class, { |
| 99 | "name": name, |
| 100 | "file_path": file_path, |
| 101 | "line_start": node.start_point[0] + 1, |
| 102 | "line_end": node.end_point[0] + 1, |
| 103 | "docstring": "", |
| 104 | }) |
| 105 | store.create_edge( |
| 106 | NodeLabel.File, {"path": file_path}, |
| 107 | EdgeType.CONTAINS, |
| 108 | NodeLabel.Class, {"name": name, "file_path": file_path}, |
| 109 | ) |
| 110 | stats["classes"] += 1 |
| 111 | stats["edges"] += 1 |
| 112 | |
| 113 | body = next((c for c in node.children if c.type == "class_body"), None) |
| 114 | if body: |
| 115 | for child in body.children: |
| 116 | if child.type == "method_definition": |
| 117 | self._handle_function(child, source, file_path, store, stats, class_name=name) |
| 118 | |
| 119 | def _handle_function(self, node, source: bytes, file_path: str, |
| 120 | store: GraphStore, stats: dict, class_name: str | None) -> None: |
| 121 | name_node = next( |
| 122 | (c for c in node.children if c.type in ("identifier", "property_identifier")), None |
| 123 | ) |
| 124 | if not name_node: |
| 125 | return |
| 126 | name = source[name_node.start_byte:name_node.end_byte].decode() |
| 127 | |
| 128 | label = NodeLabel.Method if class_name else NodeLabel.Function |
| 129 | store.create_node(label, { |
| 130 | "name": name, |
| 131 | "file_path": file_path, |
| 132 | "line_start": node.start_point[0] + 1, |
| 133 | "line_end": node.end_point[0] + 1, |
| 134 | "docstring": "", |
| 135 | "class_name": class_name or "", |
| 136 | }) |
| 137 | |
| 138 | container_label = NodeLabel.Class if class_name else NodeLabel.File |
| 139 | container_key = ( |
| 140 | {"name": class_name, "file_path": file_path} |
| 141 | if class_name |
| 142 | else {"path": file_path} |
| 143 | ) |
| 144 | store.create_edge( |
| 145 | container_label, container_key, EdgeType.CONTAINS, label, |
| 146 | {"name": name, "file_path": file_path}, |
| 147 | ) |
| 148 | stats["functions"] += 1 |
| 149 | stats["edges"] += 1 |
| 150 |
| --- navegador/ingestion/typescript.py | |
| +++ navegador/ingestion/typescript.py | |
| @@ -1,8 +1,12 @@ | |
| 1 | """ |
| 2 | TypeScript/JavaScript AST parser — extracts classes, interfaces, functions |
| 3 | (including const arrow functions), methods, imports, and call edges. |
| 4 | |
| 5 | Supports: |
| 6 | - .ts / .tsx → tree-sitter-typescript (TypeScript grammar) |
| 7 | - .js / .jsx → tree-sitter-javascript (JavaScript grammar) |
| 8 | """ |
| 9 | |
| 10 | import logging |
| 11 | from pathlib import Path |
| 12 | |
| @@ -26,12 +30,35 @@ | |
| 30 | except ImportError as e: |
| 31 | raise ImportError( |
| 32 | f"Install tree-sitter-{language}: pip install tree-sitter-{language}" |
| 33 | ) from e |
| 34 | |
| 35 | |
| 36 | def _node_text(node, source: bytes) -> str: |
| 37 | return source[node.start_byte:node.end_byte].decode("utf-8", errors="replace") |
| 38 | |
| 39 | |
| 40 | def _jsdoc(node, source: bytes) -> str: |
| 41 | """Return the JSDoc comment (/** ... */) preceding a node, if any.""" |
| 42 | parent = node.parent |
| 43 | if not parent: |
| 44 | return "" |
| 45 | siblings = list(parent.children) |
| 46 | idx = next((i for i, c in enumerate(siblings) if c.id == node.id), -1) |
| 47 | if idx <= 0: |
| 48 | return "" |
| 49 | prev = siblings[idx - 1] |
| 50 | if prev.type == "comment": |
| 51 | raw = _node_text(prev, source).strip() |
| 52 | if raw.startswith("/**"): |
| 53 | return raw.strip("/**").strip("*/").strip() |
| 54 | return "" |
| 55 | |
| 56 | |
| 57 | class TypeScriptParser(LanguageParser): |
| 58 | """Parses TypeScript/JavaScript source files into the navegador graph.""" |
| 59 | |
| 60 | def __init__(self, language: str = "typescript") -> None: |
| 61 | from tree_sitter import Parser # type: ignore[import] |
| 62 | self._parser = Parser(_get_ts_language(language)) |
| 63 | self._language = language |
| 64 | |
| @@ -49,33 +76,205 @@ | |
| 76 | |
| 77 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 78 | self._walk(tree.root_node, source, rel_path, store, stats, class_name=None) |
| 79 | return stats |
| 80 | |
| 81 | # ── AST walker ──────────────────────────────────────────────────────────── |
| 82 | |
| 83 | def _walk(self, node, source: bytes, file_path: str, |
| 84 | store: GraphStore, stats: dict, class_name: str | None) -> None: |
| 85 | if node.type in ("class_declaration", "abstract_class_declaration"): |
| 86 | self._handle_class(node, source, file_path, store, stats) |
| 87 | return |
| 88 | if node.type in ("interface_declaration", "type_alias_declaration"): |
| 89 | self._handle_interface(node, source, file_path, store, stats) |
| 90 | return |
| 91 | if node.type == "function_declaration": |
| 92 | self._handle_function(node, source, file_path, store, stats, class_name) |
| 93 | return |
| 94 | if node.type == "method_definition": |
| 95 | self._handle_function(node, source, file_path, store, stats, class_name) |
| 96 | return |
| 97 | if node.type in ("lexical_declaration", "variable_declaration"): |
| 98 | self._handle_lexical(node, source, file_path, store, stats) |
| 99 | return |
| 100 | if node.type in ("import_statement", "import_declaration"): |
| 101 | self._handle_import(node, source, file_path, store, stats) |
| 102 | return |
| 103 | if node.type == "export_statement": |
| 104 | # Recurse into exported declarations |
| 105 | for child in node.children: |
| 106 | if child.type not in ("export", "default", "from"): |
| 107 | self._walk(child, source, file_path, store, stats, class_name) |
| 108 | return |
| 109 | |
| 110 | for child in node.children: |
| 111 | self._walk(child, source, file_path, store, stats, class_name) |
| 112 | |
| 113 | # ── Handlers ────────────────────────────────────────────────────────────── |
| 114 | |
| 115 | def _handle_class(self, node, source: bytes, file_path: str, |
| 116 | store: GraphStore, stats: dict) -> None: |
| 117 | name_node = next( |
| 118 | (c for c in node.children if c.type == "type_identifier"), None |
| 119 | ) |
| 120 | if not name_node: |
| 121 | return |
| 122 | name = _node_text(name_node, source) |
| 123 | docstring = _jsdoc(node, source) |
| 124 | |
| 125 | store.create_node(NodeLabel.Class, { |
| 126 | "name": name, |
| 127 | "file_path": file_path, |
| 128 | "line_start": node.start_point[0] + 1, |
| 129 | "line_end": node.end_point[0] + 1, |
| 130 | "docstring": docstring, |
| 131 | }) |
| 132 | store.create_edge( |
| 133 | NodeLabel.File, {"path": file_path}, |
| 134 | EdgeType.CONTAINS, |
| 135 | NodeLabel.Class, {"name": name, "file_path": file_path}, |
| 136 | ) |
| 137 | stats["classes"] += 1 |
| 138 | stats["edges"] += 1 |
| 139 | |
| 140 | # Inheritance: extends clause |
| 141 | heritage = next((c for c in node.children if c.type == "class_heritage"), None) |
| 142 | if heritage: |
| 143 | for child in heritage.children: |
| 144 | if child.type == "extends_clause": |
| 145 | for c in child.children: |
| 146 | if c.type == "identifier": |
| 147 | parent_name = _node_text(c, source) |
| 148 | store.create_edge( |
| 149 | NodeLabel.Class, {"name": name, "file_path": file_path}, |
| 150 | EdgeType.INHERITS, |
| 151 | NodeLabel.Class, {"name": parent_name, "file_path": file_path}, |
| 152 | ) |
| 153 | stats["edges"] += 1 |
| 154 | |
| 155 | body = next((c for c in node.children if c.type == "class_body"), None) |
| 156 | if body: |
| 157 | for child in body.children: |
| 158 | if child.type == "method_definition": |
| 159 | self._handle_function(child, source, file_path, store, stats, |
| 160 | class_name=name) |
| 161 | |
| 162 | def _handle_interface(self, node, source: bytes, file_path: str, |
| 163 | store: GraphStore, stats: dict) -> None: |
| 164 | name_node = next( |
| 165 | (c for c in node.children if c.type == "type_identifier"), None |
| 166 | ) |
| 167 | if not name_node: |
| 168 | return |
| 169 | name = _node_text(name_node, source) |
| 170 | docstring = _jsdoc(node, source) |
| 171 | kind = "interface" if node.type == "interface_declaration" else "type" |
| 172 | |
| 173 | store.create_node(NodeLabel.Class, { |
| 174 | "name": name, |
| 175 | "file_path": file_path, |
| 176 | "line_start": node.start_point[0] + 1, |
| 177 | "line_end": node.end_point[0] + 1, |
| 178 | "docstring": f"{kind}: {docstring}".strip(": "), |
| 179 | }) |
| 180 | store.create_edge( |
| 181 | NodeLabel.File, {"path": file_path}, |
| 182 | EdgeType.CONTAINS, |
| 183 | NodeLabel.Class, {"name": name, "file_path": file_path}, |
| 184 | ) |
| 185 | stats["classes"] += 1 |
| 186 | stats["edges"] += 1 |
| 187 | |
| 188 | def _handle_function(self, node, source: bytes, file_path: str, |
| 189 | store: GraphStore, stats: dict, |
| 190 | class_name: str | None) -> None: |
| 191 | name_node = next( |
| 192 | (c for c in node.children |
| 193 | if c.type in ("identifier", "property_identifier")), None |
| 194 | ) |
| 195 | if not name_node: |
| 196 | return |
| 197 | name = _node_text(name_node, source) |
| 198 | if name in ("constructor", "get", "set", "static", "async"): |
| 199 | # These are keywords, not useful names — look for next identifier |
| 200 | name_node = next( |
| 201 | (c for c in node.children |
| 202 | if c.type in ("identifier", "property_identifier") and c != name_node), |
| 203 | None, |
| 204 | ) |
| 205 | if not name_node: |
| 206 | return |
| 207 | name = _node_text(name_node, source) |
| 208 | |
| 209 | docstring = _jsdoc(node, source) |
| 210 | label = NodeLabel.Method if class_name else NodeLabel.Function |
| 211 | |
| 212 | store.create_node(label, { |
| 213 | "name": name, |
| 214 | "file_path": file_path, |
| 215 | "line_start": node.start_point[0] + 1, |
| 216 | "line_end": node.end_point[0] + 1, |
| 217 | "docstring": docstring, |
| 218 | "class_name": class_name or "", |
| 219 | }) |
| 220 | |
| 221 | container_label = NodeLabel.Class if class_name else NodeLabel.File |
| 222 | container_key = ( |
| 223 | {"name": class_name, "file_path": file_path} |
| 224 | if class_name else {"path": file_path} |
| 225 | ) |
| 226 | store.create_edge( |
| 227 | container_label, container_key, |
| 228 | EdgeType.CONTAINS, |
| 229 | label, {"name": name, "file_path": file_path}, |
| 230 | ) |
| 231 | stats["functions"] += 1 |
| 232 | stats["edges"] += 1 |
| 233 | |
| 234 | self._extract_calls(node, source, file_path, name, label, store, stats) |
| 235 | |
| 236 | def _handle_lexical(self, node, source: bytes, file_path: str, |
| 237 | store: GraphStore, stats: dict) -> None: |
| 238 | """Handle: const foo = () => {} and const bar = function() {}""" |
| 239 | for child in node.children: |
| 240 | if child.type != "variable_declarator": |
| 241 | continue |
| 242 | name_node = child.child_by_field_name("name") |
| 243 | value_node = child.child_by_field_name("value") |
| 244 | if not name_node or not value_node: |
| 245 | continue |
| 246 | if value_node.type not in ("arrow_function", "function_expression", |
| 247 | "function"): |
| 248 | continue |
| 249 | name = _node_text(name_node, source) |
| 250 | docstring = _jsdoc(node, source) |
| 251 | |
| 252 | store.create_node(NodeLabel.Function, { |
| 253 | "name": name, |
| 254 | "file_path": file_path, |
| 255 | "line_start": node.start_point[0] + 1, |
| 256 | "line_end": node.end_point[0] + 1, |
| 257 | "docstring": docstring, |
| 258 | "class_name": "", |
| 259 | }) |
| 260 | store.create_edge( |
| 261 | NodeLabel.File, {"path": file_path}, |
| 262 | EdgeType.CONTAINS, |
| 263 | NodeLabel.Function, {"name": name, "file_path": file_path}, |
| 264 | ) |
| 265 | stats["functions"] += 1 |
| 266 | stats["edges"] += 1 |
| 267 | |
| 268 | self._extract_calls(value_node, source, file_path, name, |
| 269 | NodeLabel.Function, store, stats) |
| 270 | |
| 271 | def _handle_import(self, node, source: bytes, file_path: str, |
| 272 | store: GraphStore, stats: dict) -> None: |
| 273 | for child in node.children: |
| 274 | if child.type == "string": |
| 275 | module = _node_text(child, source).strip("'\"") |
| 276 | store.create_node(NodeLabel.Import, { |
| 277 | "name": module, |
| 278 | "file_path": file_path, |
| 279 | "line_start": node.start_point[0] + 1, |
| 280 | "module": module, |
| @@ -86,64 +285,31 @@ | |
| 285 | NodeLabel.Import, {"name": module, "file_path": file_path}, |
| 286 | ) |
| 287 | stats["edges"] += 1 |
| 288 | break |
| 289 | |
| 290 | def _extract_calls(self, fn_node, source: bytes, file_path: str, |
| 291 | fn_name: str, fn_label: str, |
| 292 | store: GraphStore, stats: dict) -> None: |
| 293 | def walk(node): |
| 294 | if node.type == "call_expression": |
| 295 | func = node.child_by_field_name("function") |
| 296 | if func: |
| 297 | text = _node_text(func, source) |
| 298 | callee = text.split(".")[-1] |
| 299 | store.create_edge( |
| 300 | fn_label, {"name": fn_name, "file_path": file_path}, |
| 301 | EdgeType.CALLS, |
| 302 | NodeLabel.Function, {"name": callee, "file_path": file_path}, |
| 303 | ) |
| 304 | stats["edges"] += 1 |
| 305 | for child in node.children: |
| 306 | walk(child) |
| 307 | |
| 308 | # Body is statement_block for functions, expression for arrow fns |
| 309 | body = next( |
| 310 | (c for c in fn_node.children |
| 311 | if c.type in ("statement_block", "expression_statement")), |
| 312 | None, |
| 313 | ) |
| 314 | if body: |
| 315 | walk(body) |
| 316 |
+5
-1
| --- pyproject.toml | ||
| +++ pyproject.toml | ||
| @@ -10,11 +10,11 @@ | ||
| 10 | 10 | license = "MIT" |
| 11 | 11 | requires-python = ">=3.12" |
| 12 | 12 | authors = [ |
| 13 | 13 | { name = "CONFLICT LLC" }, |
| 14 | 14 | ] |
| 15 | -keywords = ["ast", "knowledge-graph", "code-analysis", "ai-agents", "mcp", "context-management", "falkordb"] | |
| 15 | +keywords = ["ast", "knowledge-graph", "code-analysis", "ai-agents", "mcp", "context-management", "falkordb", "go", "rust", "java", "typescript"] | |
| 16 | 16 | classifiers = [ |
| 17 | 17 | "Development Status :: 3 - Alpha", |
| 18 | 18 | "Intended Audience :: Developers", |
| 19 | 19 | "Operating System :: OS Independent", |
| 20 | 20 | "Programming Language :: Python :: 3", |
| @@ -31,10 +31,14 @@ | ||
| 31 | 31 | "falkordblite>=0.8.0", # embedded SQLite-backed storage (zero-infra local) |
| 32 | 32 | # AST parsing — multi-language via tree-sitter grammars |
| 33 | 33 | "tree-sitter>=0.24.0", |
| 34 | 34 | "tree-sitter-python>=0.23.0", |
| 35 | 35 | "tree-sitter-typescript>=0.23.0", |
| 36 | + "tree-sitter-javascript>=0.23.0", | |
| 37 | + "tree-sitter-go>=0.23.0", | |
| 38 | + "tree-sitter-rust>=0.23.0", | |
| 39 | + "tree-sitter-java>=0.23.0", | |
| 36 | 40 | # CLI |
| 37 | 41 | "click>=8.1.0", |
| 38 | 42 | "rich>=13.0.0", |
| 39 | 43 | # MCP server |
| 40 | 44 | "mcp>=1.0.0", |
| 41 | 45 | |
| 42 | 46 | ADDED tests/test_go_parser.py |
| --- pyproject.toml | |
| +++ pyproject.toml | |
| @@ -10,11 +10,11 @@ | |
| 10 | license = "MIT" |
| 11 | requires-python = ">=3.12" |
| 12 | authors = [ |
| 13 | { name = "CONFLICT LLC" }, |
| 14 | ] |
| 15 | keywords = ["ast", "knowledge-graph", "code-analysis", "ai-agents", "mcp", "context-management", "falkordb"] |
| 16 | classifiers = [ |
| 17 | "Development Status :: 3 - Alpha", |
| 18 | "Intended Audience :: Developers", |
| 19 | "Operating System :: OS Independent", |
| 20 | "Programming Language :: Python :: 3", |
| @@ -31,10 +31,14 @@ | |
| 31 | "falkordblite>=0.8.0", # embedded SQLite-backed storage (zero-infra local) |
| 32 | # AST parsing — multi-language via tree-sitter grammars |
| 33 | "tree-sitter>=0.24.0", |
| 34 | "tree-sitter-python>=0.23.0", |
| 35 | "tree-sitter-typescript>=0.23.0", |
| 36 | # CLI |
| 37 | "click>=8.1.0", |
| 38 | "rich>=13.0.0", |
| 39 | # MCP server |
| 40 | "mcp>=1.0.0", |
| 41 | |
| 42 | DDED tests/test_go_parser.py |
| --- pyproject.toml | |
| +++ pyproject.toml | |
| @@ -10,11 +10,11 @@ | |
| 10 | license = "MIT" |
| 11 | requires-python = ">=3.12" |
| 12 | authors = [ |
| 13 | { name = "CONFLICT LLC" }, |
| 14 | ] |
| 15 | keywords = ["ast", "knowledge-graph", "code-analysis", "ai-agents", "mcp", "context-management", "falkordb", "go", "rust", "java", "typescript"] |
| 16 | classifiers = [ |
| 17 | "Development Status :: 3 - Alpha", |
| 18 | "Intended Audience :: Developers", |
| 19 | "Operating System :: OS Independent", |
| 20 | "Programming Language :: Python :: 3", |
| @@ -31,10 +31,14 @@ | |
| 31 | "falkordblite>=0.8.0", # embedded SQLite-backed storage (zero-infra local) |
| 32 | # AST parsing — multi-language via tree-sitter grammars |
| 33 | "tree-sitter>=0.24.0", |
| 34 | "tree-sitter-python>=0.23.0", |
| 35 | "tree-sitter-typescript>=0.23.0", |
| 36 | "tree-sitter-javascript>=0.23.0", |
| 37 | "tree-sitter-go>=0.23.0", |
| 38 | "tree-sitter-rust>=0.23.0", |
| 39 | "tree-sitter-java>=0.23.0", |
| 40 | # CLI |
| 41 | "click>=8.1.0", |
| 42 | "rich>=13.0.0", |
| 43 | # MCP server |
| 44 | "mcp>=1.0.0", |
| 45 | |
| 46 | DDED tests/test_go_parser.py |
| --- a/tests/test_go_parser.py | ||
| +++ b/tests/test_go_parser.py | ||
| @@ -0,0 +1,2 @@ | ||
| 1 | +func foo() { bar() }" | |
| 2 | + callestore.create_edg |
| --- a/tests/test_go_parser.py | |
| +++ b/tests/test_go_parser.py | |
| @@ -0,0 +1,2 @@ | |
| --- a/tests/test_go_parser.py | |
| +++ b/tests/test_go_parser.py | |
| @@ -0,0 +1,2 @@ | |
| 1 | func foo() { bar() }" |
| 2 | callestore.create_edg |
+1
-1
| --- tests/test_ingestion_code.py | ||
| +++ tests/test_ingestion_code.py | ||
| @@ -30,11 +30,11 @@ | ||
| 30 | 30 | assert LANGUAGE_MAP[".js"] == "javascript" |
| 31 | 31 | assert LANGUAGE_MAP[".jsx"] == "javascript" |
| 32 | 32 | |
| 33 | 33 | def test_no_entry_for_unknown(self): |
| 34 | 34 | assert ".rb" not in LANGUAGE_MAP |
| 35 | - assert ".go" not in LANGUAGE_MAP | |
| 35 | + assert ".php" not in LANGUAGE_MAP | |
| 36 | 36 | |
| 37 | 37 | |
| 38 | 38 | # ── ingest() ───────────────────────────────────────────────────────────────── |
| 39 | 39 | |
| 40 | 40 | class TestRepoIngester: |
| 41 | 41 | |
| 42 | 42 | ADDED tests/test_java_parser.py |
| 43 | 43 | ADDED tests/test_rust_parser.py |
| 44 | 44 | ADDED tests/test_typescript_parser.py |
| --- tests/test_ingestion_code.py | |
| +++ tests/test_ingestion_code.py | |
| @@ -30,11 +30,11 @@ | |
| 30 | assert LANGUAGE_MAP[".js"] == "javascript" |
| 31 | assert LANGUAGE_MAP[".jsx"] == "javascript" |
| 32 | |
| 33 | def test_no_entry_for_unknown(self): |
| 34 | assert ".rb" not in LANGUAGE_MAP |
| 35 | assert ".go" not in LANGUAGE_MAP |
| 36 | |
| 37 | |
| 38 | # ── ingest() ───────────────────────────────────────────────────────────────── |
| 39 | |
| 40 | class TestRepoIngester: |
| 41 | |
| 42 | DDED tests/test_java_parser.py |
| 43 | DDED tests/test_rust_parser.py |
| 44 | DDED tests/test_typescript_parser.py |
| --- tests/test_ingestion_code.py | |
| +++ tests/test_ingestion_code.py | |
| @@ -30,11 +30,11 @@ | |
| 30 | assert LANGUAGE_MAP[".js"] == "javascript" |
| 31 | assert LANGUAGE_MAP[".jsx"] == "javascript" |
| 32 | |
| 33 | def test_no_entry_for_unknown(self): |
| 34 | assert ".rb" not in LANGUAGE_MAP |
| 35 | assert ".php" not in LANGUAGE_MAP |
| 36 | |
| 37 | |
| 38 | # ── ingest() ───────────────────────────────────────────────────────────────── |
| 39 | |
| 40 | class TestRepoIngester: |
| 41 | |
| 42 | DDED tests/test_java_parser.py |
| 43 | DDED tests/test_rust_parser.py |
| 44 | DDED tests/test_typescript_parser.py |
+253
| --- a/tests/test_java_parser.py | ||
| +++ b/tests/test_java_parser.py | ||
| @@ -0,0 +1,253 @@ | ||
| 1 | +"""Tests for navegador.ingestion.java — JavaParser internal methods.""" | |
| 2 | + | |
| 3 | +from unittest.mock import MagicMock, patch | |
| 4 | + | |
| 5 | +import pytest | |
| 6 | + | |
| 7 | +from navegador.graph.schema import NodeLabel | |
| 8 | + | |
| 9 | + | |
| 10 | +class MockNode: | |
| 11 | + _id_counter = 0 | |
| 12 | + | |
| 13 | + def __init__(self, type_: str, text: bytes = b"", children: list = None, | |
| 14 | + start_byte: int = 0, end_byte: int = 0, | |
| 15 | + start_point: tuple = (0, 0), end_point: tuple = (0, 0), | |
| 16 | + parent=None): | |
| 17 | + MockNode._id_counter += 1 | |
| 18 | + self.id = MockNode._id_counter | |
| 19 | + self.type = type_ | |
| 20 | + self.children = children or [] | |
| 21 | + self.start_byte = start_byte | |
| 22 | + self.end_byte = end_byte | |
| 23 | + self.start_point = start_point | |
| 24 | + self.end_point = end_point | |
| 25 | + self.parent = parent | |
| 26 | + self._fields: dict = {} | |
| 27 | + for child in self.children: | |
| 28 | + child.parent = self | |
| 29 | + | |
| 30 | + def child_by_field_name(self, name: str): | |
| 31 | + return self._fields.get(name) | |
| 32 | + | |
| 33 | + def set_field(self, name: str, node): | |
| 34 | + self._fields[name] = node | |
| 35 | + node.parent = self | |
| 36 | + return self | |
| 37 | + | |
| 38 | + | |
| 39 | +def _text_node(text: bytes, type_: str = "identifier") -> MockNode: | |
| 40 | + return MockNode(type_, text, start_byte=0, end_byte=len(text)) | |
| 41 | + | |
| 42 | + | |
| 43 | +def _make_store(): | |
| 44 | + store = MagicMock() | |
| 45 | + store.query.return_value = MagicMock(result_set=[]) | |
| 46 | + return store | |
| 47 | + | |
| 48 | + | |
| 49 | +def _make_parser(): | |
| 50 | + from navegador.ingestion.java import JavaParser | |
| 51 | + parser = JavaParser.__new__(JavaParser) | |
| 52 | + parser._parser = MagicMock() | |
| 53 | + return parser | |
| 54 | + | |
| 55 | + | |
| 56 | +class TestJavaGetLanguage: | |
| 57 | + def test_raises_when_not_installed(self): | |
| 58 | + from navegador.ingestion.java import _get_java_language | |
| 59 | + with patch.dict("sys.modules", {"tree_sitter_java": None, "tree_sitter": None}): | |
| 60 | + with pytest.raises(ImportError, match="tree-sitter-java"): | |
| 61 | + _get_java_language() | |
| 62 | + | |
| 63 | + | |
| 64 | +class TestJavaNodeText: | |
| 65 | + def test_extracts_bytes(self): | |
| 66 | + from navegador.ingestion.java import _node_text | |
| 67 | + source = b"class Foo {}" | |
| 68 | + node = MockNode("identifier", start_byte=6, end_byte=9) | |
| 69 | + assert _node_text(node, source) == "Foo" | |
| 70 | + | |
| 71 | + | |
| 72 | +class TestJavadoc: | |
| 73 | + def test_extracts_javadoc(self): | |
| 74 | + from navegador.ingestion.java import _javadoc | |
| 75 | + source = b"/** My class */\nclass Foo {}" | |
| 76 | + comment = MockNode("block_comment", start_byte=0, end_byte=15) | |
| 77 | + cls_node = MockNode("class_declaration", start_byte=16, end_byte=28) | |
| 78 | + _parent = MockNode("program", children=[comment, cls_node]) | |
| 79 | + result = _javadoc(cls_node, source) | |
| 80 | + assert "My class" in result | |
| 81 | + | |
| 82 | + def test_ignores_regular_block_comment(self): | |
| 83 | + from navegador.ingestion.java import _javadoc | |
| 84 | + source = b"/* regular */\nclass Foo {}" | |
| 85 | + comment = MockNode("block_comment", start_byte=0, end_byte=13) | |
| 86 | + cls_node = MockNode("class_declaration", start_byte=14, end_byte=26) | |
| 87 | + MockNode("program", children=[comment, cls_node]) | |
| 88 | + result = _javadoc(cls_node, source) | |
| 89 | + assert result == "" | |
| 90 | + | |
| 91 | + | |
| 92 | +class TestJavaHandleClass: | |
| 93 | + def test_creates_class_node(self): | |
| 94 | + parser = _make_parser() | |
| 95 | + store = _make_store() | |
| 96 | + source = b"class Foo {}" | |
| 97 | + name_node = _text_node(b"Foo") | |
| 98 | + body = MockNode("class_body") | |
| 99 | + node = MockNode("class_declaration", start_point=(0, 0), end_point=(0, 11)) | |
| 100 | + node.set_field("name", name_node) | |
| 101 | + node.set_field("body", body) | |
| 102 | + _parent = MockNode("program", children=[node]) | |
| 103 | + stats = {"functions": 0, "classes": 0, "edges": 0} | |
| 104 | + parser._handle_class(node, source, "Foo.java", store, stats) | |
| 105 | + assert stats["classes"] == 1 | |
| 106 | + label = store.create_node.call_args[0][0] | |
| 107 | + assert label == NodeLabel.Class | |
| 108 | + | |
| 109 | + def test_creates_inherits_edge(self): | |
| 110 | + parser = _make_parser() | |
| 111 | + store = _make_store() | |
| 112 | + source = b"class Child extends Parent {}" | |
| 113 | + name_node = _text_node(b"Child") | |
| 114 | + parent_id = _text_node(b"Parent", "type_identifier") | |
| 115 | + superclass = MockNode("superclass", children=[parent_id]) | |
| 116 | + body = MockNode("class_body") | |
| 117 | + node = MockNode("class_declaration", start_point=(0, 0), end_point=(0, 28)) | |
| 118 | + node.set_field("name", name_node) | |
| 119 | + node.set_field("superclass", superclass) | |
| 120 | + node.set_field("body", body) | |
| 121 | + MockNode("program", children=[node]) | |
| 122 | + stats = {"functions": 0, "classes": 0, "edges": 0} | |
| 123 | + parser._handle_class(node, source, "Child.java", store, stats) | |
| 124 | + # Should have CONTAINS edge + INHERITS edge | |
| 125 | + assert stats["edges"] == 2 | |
| 126 | + | |
| 127 | + def test_ingests_methods_in_body(self): | |
| 128 | + parser = _make_parser() | |
| 129 | + store = _make_store() | |
| 130 | + source = b"class Foo { void bar() {} }" | |
| 131 | + name_node = _text_node(b"Foo") | |
| 132 | + method_name = _text_node(b"bar") | |
| 133 | + method_body = MockNode("block") | |
| 134 | + method = MockNode("method_declaration", start_point=(1, 2), end_point=(1, 14)) | |
| 135 | + method.set_field("name", method_name) | |
| 136 | + method.set_field("body", method_body) | |
| 137 | + body = MockNode("class_body", children=[method]) | |
| 138 | + node = MockNode("class_declaration", start_point=(0, 0), end_point=(0, 26)) | |
| 139 | + node.set_field("name", name_node) | |
| 140 | + node.set_field("body", body) | |
| 141 | + MockNode("program", children=[node]) | |
| 142 | + stats = {"functions": 0, "classes": 0, "edges": 0} | |
| 143 | + parser._handle_class(node, source, "Foo.java", store, stats) | |
| 144 | + assert stats["functions"] == 1 | |
| 145 | + | |
| 146 | + def test_skips_if_no_name(self): | |
| 147 | + parser = _make_parser() | |
| 148 | + store = _make_store() | |
| 149 | + node = MockNode("class_declaration") | |
| 150 | + stats = {"functions": 0, "classes": 0, "edges": 0} | |
| 151 | + parser._handle_class(node, b"", "X.java", store, stats) | |
| 152 | + assert stats["classes"] == 0 | |
| 153 | + | |
| 154 | + | |
| 155 | +class TestJavaHandleInterface: | |
| 156 | + def test_creates_interface_node(self): | |
| 157 | + parser = _make_parser() | |
| 158 | + store = _make_store() | |
| 159 | + source = b"interface Saveable {}" | |
| 160 | + name_node = _text_node(b"Saveable") | |
| 161 | + body = MockNode("interface_body") | |
| 162 | + node = MockNode("interface_declaration", start_point=(0, 0), end_point=(0, 20)) | |
| 163 | + node.set_field("name", name_node) | |
| 164 | + node.set_field("body", body) | |
| 165 | + MockNode("program", children=[node]) | |
| 166 | + stats = {"functions": 0, "classes": 0, "edges": 0} | |
| 167 | + parser._handle_interface(node, source, "Saveable.java", store, stats) | |
| 168 | + assert stats["classes"] == 1 | |
| 169 | + props = store.create_node.call_args[0][1] | |
| 170 | + assert "interface" in props.get("docstring", "") | |
| 171 | + | |
| 172 | + def test_skips_if_no_name(self): | |
| 173 | + parser = _make_parser() | |
| 174 | + store = _make_store() | |
| 175 | + node = MockNode("interface_declaration") | |
| 176 | + stats = {"functions": 0, "classes": 0, "edges": 0} | |
| 177 | + parser._handle_interface(node, b"", "X.java", store, stats) | |
| 178 | + assert stats["classes"] == 0 | |
| 179 | + | |
| 180 | + | |
| 181 | +class TestJavaHandleMethod: | |
| 182 | + def test_creates_method_node(self): | |
| 183 | + parser = _make_parser() | |
| 184 | + store = _make_store() | |
| 185 | + source = b"void save() {}" | |
| 186 | + name_node = _text_node(b"save") | |
| 187 | + body = MockNode("block") | |
| 188 | + node = MockNode("method_declaration", start_point=(0, 0), end_point=(0, 13)) | |
| 189 | + node.set_field("name", name_node) | |
| 190 | + node.set_field("body", body) | |
| 191 | + MockNode("class_body", children=[node]) | |
| 192 | + stats = {"functions": 0, "classes": 0, "edges": 0} | |
| 193 | + parser._handle_method(node, source, "Foo.java", store, stats, class_name="Foo") | |
| 194 | + assert stats["functions"] == 1 | |
| 195 | + label = store.create_node.call_args[0][0] | |
| 196 | + assert label == NodeLabel.Method | |
| 197 | + | |
| 198 | + def test_skips_if_no_name(self): | |
| 199 | + parser = _make_parser() | |
| 200 | + store = _make_store() | |
| 201 | + node = MockNode("method_declaration") | |
| 202 | + stats = {"functions": 0, "classes": 0, "edges": 0} | |
| 203 | + parser._handle_method(node, b"", "X.java", store, stats, class_name="X") | |
| 204 | + assert stats["functions"] == 0 | |
| 205 | + | |
| 206 | + | |
| 207 | +class TestJavaHandleImport: | |
| 208 | + def test_ingests_import(self): | |
| 209 | + parser = _make_parser() | |
| 210 | + store = _make_store() | |
| 211 | + source = b"import java.util.List;" | |
| 212 | + node = MockNode("import_declaration", start_byte=0, end_byte=22, | |
| 213 | + start_point=(0, 0)) | |
| 214 | + stats = {"functions": 0, "classes": 0, "edges": 0} | |
| 215 | + parser._handle_import(node, source, "Foo.java", store, stats) | |
| 216 | + assert stats["edges"] == 1 | |
| 217 | + props = store.create_node.call_args[0][1] | |
| 218 | + assert "java.util.List" in props["name"] | |
| 219 | + | |
| 220 | + def test_handles_static_import(self): | |
| 221 | + parser = _make_parser() | |
| 222 | + store = _make_store() | |
| 223 | + source = b"import static java.util.Collections.sort;" | |
| 224 | + node = MockNode("import_declaration", start_byte=0, end_byte=41, | |
| 225 | + start_point=(0, 0)) | |
| 226 | + stats = {"functions": 0, "classes": 0, "edges": 0} | |
| 227 | + parser._handle_import(node, source, "Foo.java", store, stats) | |
| 228 | + assert stats["edges"] == 1 | |
| 229 | + | |
| 230 | + | |
| 231 | +class TestJavaExtractCalls: | |
| 232 | + def test_extracts_method_invocation(self): | |
| 233 | + parser = _make_parser() | |
| 234 | + store = _make_void foo() { bar(); }re() | |
| 235 | + source = b"bar" | |
| 236 | + callee = _text_node(b"bar") | |
| 237 | + invocation = MockNode("method_invocation") | |
| 238 | + invocation.set_field("name", callee) | |
| 239 | + body = MockNode("block", children=[invocation]) | |
| 240 | + node = MockNode("method_declaration") | |
| 241 | + node.set_field("body", body) | |
| 242 | + stats = {"functions": 0, "classes": 0, "edges": 0} | |
| 243 | + parser._extract_calls(node, source, "Foo.java", "foo", store, stats) | |
| 244 | + assert sstore.creat | |
| 245 | + | |
| 246 | + def test_no_calls_in_empty_body(self): | |
| 247 | + parser = _make_parser() | |
| 248 | + store = _make_store() | |
| 249 | + node = MockNode("method_declaration") | |
| 250 | + node.set_field("body", MockNode("block")) | |
| 251 | + stats = {"functions": 0, "classes": 0, "edges": 0} | |
| 252 | + parser._extract_calls(node, b"", "X.java", "foo", store, stats) | |
| 253 | + |
| --- a/tests/test_java_parser.py | |
| +++ b/tests/test_java_parser.py | |
| @@ -0,0 +1,253 @@ | |
| --- a/tests/test_java_parser.py | |
| +++ b/tests/test_java_parser.py | |
| @@ -0,0 +1,253 @@ | |
| 1 | """Tests for navegador.ingestion.java — JavaParser internal methods.""" |
| 2 | |
| 3 | from unittest.mock import MagicMock, patch |
| 4 | |
| 5 | import pytest |
| 6 | |
| 7 | from navegador.graph.schema import NodeLabel |
| 8 | |
| 9 | |
| 10 | class MockNode: |
| 11 | _id_counter = 0 |
| 12 | |
| 13 | def __init__(self, type_: str, text: bytes = b"", children: list = None, |
| 14 | start_byte: int = 0, end_byte: int = 0, |
| 15 | start_point: tuple = (0, 0), end_point: tuple = (0, 0), |
| 16 | parent=None): |
| 17 | MockNode._id_counter += 1 |
| 18 | self.id = MockNode._id_counter |
| 19 | self.type = type_ |
| 20 | self.children = children or [] |
| 21 | self.start_byte = start_byte |
| 22 | self.end_byte = end_byte |
| 23 | self.start_point = start_point |
| 24 | self.end_point = end_point |
| 25 | self.parent = parent |
| 26 | self._fields: dict = {} |
| 27 | for child in self.children: |
| 28 | child.parent = self |
| 29 | |
| 30 | def child_by_field_name(self, name: str): |
| 31 | return self._fields.get(name) |
| 32 | |
| 33 | def set_field(self, name: str, node): |
| 34 | self._fields[name] = node |
| 35 | node.parent = self |
| 36 | return self |
| 37 | |
| 38 | |
| 39 | def _text_node(text: bytes, type_: str = "identifier") -> MockNode: |
| 40 | return MockNode(type_, text, start_byte=0, end_byte=len(text)) |
| 41 | |
| 42 | |
| 43 | def _make_store(): |
| 44 | store = MagicMock() |
| 45 | store.query.return_value = MagicMock(result_set=[]) |
| 46 | return store |
| 47 | |
| 48 | |
| 49 | def _make_parser(): |
| 50 | from navegador.ingestion.java import JavaParser |
| 51 | parser = JavaParser.__new__(JavaParser) |
| 52 | parser._parser = MagicMock() |
| 53 | return parser |
| 54 | |
| 55 | |
| 56 | class TestJavaGetLanguage: |
| 57 | def test_raises_when_not_installed(self): |
| 58 | from navegador.ingestion.java import _get_java_language |
| 59 | with patch.dict("sys.modules", {"tree_sitter_java": None, "tree_sitter": None}): |
| 60 | with pytest.raises(ImportError, match="tree-sitter-java"): |
| 61 | _get_java_language() |
| 62 | |
| 63 | |
| 64 | class TestJavaNodeText: |
| 65 | def test_extracts_bytes(self): |
| 66 | from navegador.ingestion.java import _node_text |
| 67 | source = b"class Foo {}" |
| 68 | node = MockNode("identifier", start_byte=6, end_byte=9) |
| 69 | assert _node_text(node, source) == "Foo" |
| 70 | |
| 71 | |
| 72 | class TestJavadoc: |
| 73 | def test_extracts_javadoc(self): |
| 74 | from navegador.ingestion.java import _javadoc |
| 75 | source = b"/** My class */\nclass Foo {}" |
| 76 | comment = MockNode("block_comment", start_byte=0, end_byte=15) |
| 77 | cls_node = MockNode("class_declaration", start_byte=16, end_byte=28) |
| 78 | _parent = MockNode("program", children=[comment, cls_node]) |
| 79 | result = _javadoc(cls_node, source) |
| 80 | assert "My class" in result |
| 81 | |
| 82 | def test_ignores_regular_block_comment(self): |
| 83 | from navegador.ingestion.java import _javadoc |
| 84 | source = b"/* regular */\nclass Foo {}" |
| 85 | comment = MockNode("block_comment", start_byte=0, end_byte=13) |
| 86 | cls_node = MockNode("class_declaration", start_byte=14, end_byte=26) |
| 87 | MockNode("program", children=[comment, cls_node]) |
| 88 | result = _javadoc(cls_node, source) |
| 89 | assert result == "" |
| 90 | |
| 91 | |
| 92 | class TestJavaHandleClass: |
| 93 | def test_creates_class_node(self): |
| 94 | parser = _make_parser() |
| 95 | store = _make_store() |
| 96 | source = b"class Foo {}" |
| 97 | name_node = _text_node(b"Foo") |
| 98 | body = MockNode("class_body") |
| 99 | node = MockNode("class_declaration", start_point=(0, 0), end_point=(0, 11)) |
| 100 | node.set_field("name", name_node) |
| 101 | node.set_field("body", body) |
| 102 | _parent = MockNode("program", children=[node]) |
| 103 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 104 | parser._handle_class(node, source, "Foo.java", store, stats) |
| 105 | assert stats["classes"] == 1 |
| 106 | label = store.create_node.call_args[0][0] |
| 107 | assert label == NodeLabel.Class |
| 108 | |
| 109 | def test_creates_inherits_edge(self): |
| 110 | parser = _make_parser() |
| 111 | store = _make_store() |
| 112 | source = b"class Child extends Parent {}" |
| 113 | name_node = _text_node(b"Child") |
| 114 | parent_id = _text_node(b"Parent", "type_identifier") |
| 115 | superclass = MockNode("superclass", children=[parent_id]) |
| 116 | body = MockNode("class_body") |
| 117 | node = MockNode("class_declaration", start_point=(0, 0), end_point=(0, 28)) |
| 118 | node.set_field("name", name_node) |
| 119 | node.set_field("superclass", superclass) |
| 120 | node.set_field("body", body) |
| 121 | MockNode("program", children=[node]) |
| 122 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 123 | parser._handle_class(node, source, "Child.java", store, stats) |
| 124 | # Should have CONTAINS edge + INHERITS edge |
| 125 | assert stats["edges"] == 2 |
| 126 | |
| 127 | def test_ingests_methods_in_body(self): |
| 128 | parser = _make_parser() |
| 129 | store = _make_store() |
| 130 | source = b"class Foo { void bar() {} }" |
| 131 | name_node = _text_node(b"Foo") |
| 132 | method_name = _text_node(b"bar") |
| 133 | method_body = MockNode("block") |
| 134 | method = MockNode("method_declaration", start_point=(1, 2), end_point=(1, 14)) |
| 135 | method.set_field("name", method_name) |
| 136 | method.set_field("body", method_body) |
| 137 | body = MockNode("class_body", children=[method]) |
| 138 | node = MockNode("class_declaration", start_point=(0, 0), end_point=(0, 26)) |
| 139 | node.set_field("name", name_node) |
| 140 | node.set_field("body", body) |
| 141 | MockNode("program", children=[node]) |
| 142 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 143 | parser._handle_class(node, source, "Foo.java", store, stats) |
| 144 | assert stats["functions"] == 1 |
| 145 | |
| 146 | def test_skips_if_no_name(self): |
| 147 | parser = _make_parser() |
| 148 | store = _make_store() |
| 149 | node = MockNode("class_declaration") |
| 150 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 151 | parser._handle_class(node, b"", "X.java", store, stats) |
| 152 | assert stats["classes"] == 0 |
| 153 | |
| 154 | |
| 155 | class TestJavaHandleInterface: |
| 156 | def test_creates_interface_node(self): |
| 157 | parser = _make_parser() |
| 158 | store = _make_store() |
| 159 | source = b"interface Saveable {}" |
| 160 | name_node = _text_node(b"Saveable") |
| 161 | body = MockNode("interface_body") |
| 162 | node = MockNode("interface_declaration", start_point=(0, 0), end_point=(0, 20)) |
| 163 | node.set_field("name", name_node) |
| 164 | node.set_field("body", body) |
| 165 | MockNode("program", children=[node]) |
| 166 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 167 | parser._handle_interface(node, source, "Saveable.java", store, stats) |
| 168 | assert stats["classes"] == 1 |
| 169 | props = store.create_node.call_args[0][1] |
| 170 | assert "interface" in props.get("docstring", "") |
| 171 | |
| 172 | def test_skips_if_no_name(self): |
| 173 | parser = _make_parser() |
| 174 | store = _make_store() |
| 175 | node = MockNode("interface_declaration") |
| 176 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 177 | parser._handle_interface(node, b"", "X.java", store, stats) |
| 178 | assert stats["classes"] == 0 |
| 179 | |
| 180 | |
| 181 | class TestJavaHandleMethod: |
| 182 | def test_creates_method_node(self): |
| 183 | parser = _make_parser() |
| 184 | store = _make_store() |
| 185 | source = b"void save() {}" |
| 186 | name_node = _text_node(b"save") |
| 187 | body = MockNode("block") |
| 188 | node = MockNode("method_declaration", start_point=(0, 0), end_point=(0, 13)) |
| 189 | node.set_field("name", name_node) |
| 190 | node.set_field("body", body) |
| 191 | MockNode("class_body", children=[node]) |
| 192 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 193 | parser._handle_method(node, source, "Foo.java", store, stats, class_name="Foo") |
| 194 | assert stats["functions"] == 1 |
| 195 | label = store.create_node.call_args[0][0] |
| 196 | assert label == NodeLabel.Method |
| 197 | |
| 198 | def test_skips_if_no_name(self): |
| 199 | parser = _make_parser() |
| 200 | store = _make_store() |
| 201 | node = MockNode("method_declaration") |
| 202 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 203 | parser._handle_method(node, b"", "X.java", store, stats, class_name="X") |
| 204 | assert stats["functions"] == 0 |
| 205 | |
| 206 | |
| 207 | class TestJavaHandleImport: |
| 208 | def test_ingests_import(self): |
| 209 | parser = _make_parser() |
| 210 | store = _make_store() |
| 211 | source = b"import java.util.List;" |
| 212 | node = MockNode("import_declaration", start_byte=0, end_byte=22, |
| 213 | start_point=(0, 0)) |
| 214 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 215 | parser._handle_import(node, source, "Foo.java", store, stats) |
| 216 | assert stats["edges"] == 1 |
| 217 | props = store.create_node.call_args[0][1] |
| 218 | assert "java.util.List" in props["name"] |
| 219 | |
| 220 | def test_handles_static_import(self): |
| 221 | parser = _make_parser() |
| 222 | store = _make_store() |
| 223 | source = b"import static java.util.Collections.sort;" |
| 224 | node = MockNode("import_declaration", start_byte=0, end_byte=41, |
| 225 | start_point=(0, 0)) |
| 226 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 227 | parser._handle_import(node, source, "Foo.java", store, stats) |
| 228 | assert stats["edges"] == 1 |
| 229 | |
| 230 | |
| 231 | class TestJavaExtractCalls: |
| 232 | def test_extracts_method_invocation(self): |
| 233 | parser = _make_parser() |
| 234 | store = _make_void foo() { bar(); }re() |
| 235 | source = b"bar" |
| 236 | callee = _text_node(b"bar") |
| 237 | invocation = MockNode("method_invocation") |
| 238 | invocation.set_field("name", callee) |
| 239 | body = MockNode("block", children=[invocation]) |
| 240 | node = MockNode("method_declaration") |
| 241 | node.set_field("body", body) |
| 242 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 243 | parser._extract_calls(node, source, "Foo.java", "foo", store, stats) |
| 244 | assert sstore.creat |
| 245 | |
| 246 | def test_no_calls_in_empty_body(self): |
| 247 | parser = _make_parser() |
| 248 | store = _make_store() |
| 249 | node = MockNode("method_declaration") |
| 250 | node.set_field("body", MockNode("block")) |
| 251 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 252 | parser._extract_calls(node, b"", "X.java", "foo", store, stats) |
| 253 |
+253
| --- a/tests/test_rust_parser.py | ||
| +++ b/tests/test_rust_parser.py | ||
| @@ -0,0 +1,253 @@ | ||
| 1 | +"""Tests for navegador.ingestion.rust — RustParser internal methods.""" | |
| 2 | + | |
| 3 | +from unittest.mock import MagicMock, patch | |
| 4 | + | |
| 5 | +import pytest | |
| 6 | + | |
| 7 | +from navegador.graph.schema import NodeLabel | |
| 8 | + | |
| 9 | + | |
| 10 | +class MockNode: | |
| 11 | + _id_counter = 0 | |
| 12 | + | |
| 13 | + def __init__(self, type_: str, text: bytes = b"", children: list = None, | |
| 14 | + start_byte: int = 0, end_byte: int = 0, | |
| 15 | + start_point: tuple = (0, 0), end_point: tuple = (0, 0), | |
| 16 | + parent=None): | |
| 17 | + MockNode._id_counter += 1 | |
| 18 | + self.id = MockNode._id_counter | |
| 19 | + self.type = type_ | |
| 20 | + self._text = text | |
| 21 | + self.children = children or [] | |
| 22 | + self.start_byte = start_byte | |
| 23 | + self.end_byte = end_byte | |
| 24 | + self.start_point = start_point | |
| 25 | + self.end_point = end_point | |
| 26 | + self.parent = parent | |
| 27 | + self._fields: dict = {} | |
| 28 | + for child in self.children: | |
| 29 | + child.parent = self | |
| 30 | + | |
| 31 | + def child_by_field_name(self, name: str): | |
| 32 | + return self._fields.get(name) | |
| 33 | + | |
| 34 | + def set_field(self, name: str, node): | |
| 35 | + self._fields[name] = node | |
| 36 | + node.parent = self | |
| 37 | + return self | |
| 38 | + | |
| 39 | + | |
| 40 | +def _text_node(text: bytes, type_: str = "identifier") -> MockNode: | |
| 41 | + return MockNode(type_, text, start_byte=0, end_byte=len(text)) | |
| 42 | + | |
| 43 | + | |
| 44 | +def _make_store(): | |
| 45 | + store = MagicMock() | |
| 46 | + store.query.return_value = MagicMock(result_set=[]) | |
| 47 | + return store | |
| 48 | + | |
| 49 | + | |
| 50 | +def _make_parser(): | |
| 51 | + from navegador.ingestion.rust import RustParser | |
| 52 | + parser = RustParser.__new__(RustParser) | |
| 53 | + parser._parser = MagicMock() | |
| 54 | + return parser | |
| 55 | + | |
| 56 | + | |
| 57 | +class TestRustGetLanguage: | |
| 58 | + def test_raises_when_not_installed(self): | |
| 59 | + from navegador.ingestion.rust import _get_rust_language | |
| 60 | + with patch.dict("sys.modules", {"tree_sitter_rust": None, "tree_sitter": None}): | |
| 61 | + with pytest.raises(ImportError, match="tree-sitter-rust"): | |
| 62 | + _get_rust_language() | |
| 63 | + | |
| 64 | + | |
| 65 | +class TestRustNodeText: | |
| 66 | + def test_extracts_bytes(self): | |
| 67 | + from navegador.ingestion.rust import _node_text | |
| 68 | + source = b"fn main() {}" | |
| 69 | + node = MockNode("identifier", start_byte=3, end_byte=7) | |
| 70 | + assert _node_text(node, source) == "main" | |
| 71 | + | |
| 72 | + | |
| 73 | +class TestRustDocComment: | |
| 74 | + def test_collects_triple_slash_comments(self): | |
| 75 | + from navegador.ingestion.rust import _doc_comment | |
| 76 | + source = b"/// Docs line 1\n/// Docs line 2\nfn foo() {}" | |
| 77 | + doc1 = MockNode("line_comment", start_byte=0, end_byte=15) | |
| 78 | + doc2 = MockNode("line_comment", start_byte=16, end_byte=31) | |
| 79 | + fn_node = MockNode("function_item", start_byte=32, end_byte=44) | |
| 80 | + _parent = MockNode("source_file", children=[doc1, doc2, fn_node]) | |
| 81 | + result = _doc_comment(fn_node, source) | |
| 82 | + assert "Docs line 1" in result | |
| 83 | + assert "Docs line 2" in result | |
| 84 | + | |
| 85 | + def test_ignores_non_doc_comments(self): | |
| 86 | + from navegador.ingestion.rust import _doc_comment | |
| 87 | + source = b"// regular comment\nfn foo() {}" | |
| 88 | + comment = MockNode("line_comment", start_byte=0, end_byte=18) | |
| 89 | + fn_node = MockNode("function_item", start_byte=19, end_byte=30) | |
| 90 | + MockNode("source_file", children=[comment, fn_node]) | |
| 91 | + result = _doc_comment(fn_node, source) | |
| 92 | + assert result == "" | |
| 93 | + | |
| 94 | + def test_no_parent(self): | |
| 95 | + from navegador.ingestion.rust import _doc_comment | |
| 96 | + fn_node = MockNode("function_item") | |
| 97 | + assert _doc_comment(fn_node, b"") == "" | |
| 98 | + | |
| 99 | + | |
| 100 | +class TestRustHandleFunction: | |
| 101 | + def test_creates_function_node(self): | |
| 102 | + parser = _make_parser() | |
| 103 | + store = _make_store() | |
| 104 | + source = b"fn foo() {}" | |
| 105 | + name = _text_node(b"foo") | |
| 106 | + node = MockNode("function_item", start_point=(0, 0), end_point=(0, 10)) | |
| 107 | + node.set_field("name", name) | |
| 108 | + stats = {"functions": 0, "classes": 0, "edges": 0} | |
| 109 | + parser._handle_function(node, source, "lib.rs", store, stats, impl_type=None) | |
| 110 | + assert stats["functions"] == 1 | |
| 111 | + label = store.create_node.call_args[0][0] | |
| 112 | + assert label == NodeLabel.Function | |
| 113 | + | |
| 114 | + def test_creates_method_when_impl_type_given(self): | |
| 115 | + parser = _make_parser() | |
| 116 | + store = _make_store() | |
| 117 | + source = b"fn save(&self) {}" | |
| 118 | + name = _text_node(b"save") | |
| 119 | + node = MockNode("function_item", start_point=(0, 0), end_point=(0, 16)) | |
| 120 | + node.set_field("name", name) | |
| 121 | + stats = {"functions": 0, "classes": 0, "edges": 0} | |
| 122 | + parser._handle_function(node, source, "lib.rs", store, stats, impl_type="Repo") | |
| 123 | + label = store.create_node.call_args[0][0] | |
| 124 | + assert label == NodeLabel.Method | |
| 125 | + | |
| 126 | + def test_skips_if_no_name(self): | |
| 127 | + parser = _make_parser() | |
| 128 | + store = _make_store() | |
| 129 | + node = MockNode("function_item", start_point=(0, 0), end_point=(0, 0)) | |
| 130 | + stats = {"functions": 0, "classes": 0, "edges": 0} | |
| 131 | + parser._handle_function(node, b"", "lib.rs", store, stats, impl_type=None) | |
| 132 | + assert stats["functions"] == 0 | |
| 133 | + | |
| 134 | + | |
| 135 | +class TestRustHandleImpl: | |
| 136 | + def test_handles_impl_block(self): | |
| 137 | + parser = _make_parser() | |
| 138 | + store = _make_store() | |
| 139 | + source = b"impl Repo { fn save(&self) {} }" | |
| 140 | + type_node = _text_node(b"Repo", "type_identifier") | |
| 141 | + name_node = _text_node(b"save") | |
| 142 | + fn_item = MockNode("function_item", start_point=(0, 12), end_point=(0, 28)) | |
| 143 | + fn_item.set_field("name", name_node) | |
| 144 | + body = MockNode("declaration_list", children=[fn_item]) | |
| 145 | + impl = MockNode("impl_item", start_point=(0, 0), end_point=(0, 30)) | |
| 146 | + impl.set_field("type", type_node) | |
| 147 | + impl.set_field("body", body) | |
| 148 | + stats = {"functions": 0, "classes": 0, "edges": 0} | |
| 149 | + parser._handle_impl(impl, source, "lib.rs", store, stats) | |
| 150 | + assert stats["functions"] == 1 | |
| 151 | + | |
| 152 | + def test_handles_impl_with_no_body(self): | |
| 153 | + parser = _make_parser() | |
| 154 | + store = _make_store() | |
| 155 | + impl = MockNode("impl_item") | |
| 156 | + stats = {"functions": 0, "classes": 0, "edges": 0} | |
| 157 | + parser._handle_impl(impl, b"", "lib.rs", store, stats) | |
| 158 | + assert stats["functions"] == 0 | |
| 159 | + | |
| 160 | + | |
| 161 | +class TestRustHandleType: | |
| 162 | + def test_ingests_struct(self): | |
| 163 | + parser = _make_parser() | |
| 164 | + store = _make_store() | |
| 165 | + source = b"struct Foo {}" | |
| 166 | + name = _text_node(b"Foo", "type_identifier") | |
| 167 | + node = MockNode("struct_item", start_point=(0, 0), end_point=(0, 12)) | |
| 168 | + node.set_field("name", name) | |
| 169 | + _parent = MockNode("source_file", children=[node]) | |
| 170 | + stats = {"functions": 0, "classes": 0, "edges": 0} | |
| 171 | + parser._handle_type(node, source, "lib.rs", store, stats) | |
| 172 | + assert stats["classes"] == 1 | |
| 173 | + props = store.create_node.call_args[0][1] | |
| 174 | + assert "struct" in props["docstring"] | |
| 175 | + | |
| 176 | + def test_ingests_enum(self): | |
| 177 | + parser = _make_parser() | |
| 178 | + store = _make_store() | |
| 179 | + source = b"enum Color { Red, Green }" | |
| 180 | + name = _text_node(b"Color", "type_identifier") | |
| 181 | + node = MockNode("enum_item", start_point=(0, 0), end_point=(0, 24)) | |
| 182 | + node.set_field("name", name) | |
| 183 | + MockNode("source_file", children=[node]) | |
| 184 | + stats = {"functions": 0, "classes": 0, "edges": 0} | |
| 185 | + parser._handle_type(node, source, "lib.rs", store, stats) | |
| 186 | + assert stats["classes"] == 1 | |
| 187 | + | |
| 188 | + def test_ingests_trait(self): | |
| 189 | + parser = _make_parser() | |
| 190 | + store = _make_store() | |
| 191 | + source = b"trait Saveable {}" | |
| 192 | + name = _text_node(b"Saveable", "type_identifier") | |
| 193 | + node = MockNode("trait_item", start_point=(0, 0), end_point=(0, 16)) | |
| 194 | + node.set_field("name", name) | |
| 195 | + MockNode("source_file", children=[node]) | |
| 196 | + stats = {"functions": 0, "classes": 0, "edges": 0} | |
| 197 | + parser._handle_type(node, source, "lib.rs", store, stats) | |
| 198 | + assert stats["classes"] == 1 | |
| 199 | + | |
| 200 | + def test_skips_if_no_name(self): | |
| 201 | + parser = _make_parser() | |
| 202 | + store = _make_store() | |
| 203 | + node = MockNode("struct_item") | |
| 204 | + stats = {"functions": 0, "classes": 0, "edges": 0} | |
| 205 | + parser._handle_type(node, b"", "lib.rs", store, stats) | |
| 206 | + assert stats["classes"] == 0 | |
| 207 | + | |
| 208 | + | |
| 209 | +class TestRustHandleUse: | |
| 210 | + def test_ingests_use_statement(self): | |
| 211 | + parser = _make_parser() | |
| 212 | + store = _make_store() | |
| 213 | + source = b"use std::collections::HashMap;" | |
| 214 | + node = MockNode("use_declaration", start_byte=0, end_byte=30, | |
| 215 | + start_point=(0, 0)) | |
| 216 | + stats = {"functions": 0, "classes": 0, "edges": 0} | |
| 217 | + parser._handle_use(node, source, "lib.rs", store, stats) | |
| 218 | + assert stats["edges"] == 1 | |
| 219 | + store.create_node.assert_called_once() | |
| 220 | + props = store.create_node.call_args[0][1] | |
| 221 | + assert "HashMap" in props["name"] or "std" in props["name"] | |
| 222 | + | |
| 223 | + | |
| 224 | +class TestRustExtractCalls: | |
| 225 | + def test_extracts_call(self): | |
| 226 | + parser = _make_parser() | |
| 227 | + store = _make_fn foo() { bar() }re() | |
| 228 | + source = b"bar" | |
| 229 | + callee = _text_node(b"bar") | |
| 230 | + call_node = MockNode("call_expression") | |
| 231 | + call_node.set_field("function", callee) | |
| 232 | + body = MockNode("block", children=[call_node]) | |
| 233 | + fn_node = MockNode("function_item") | |
| 234 | + fn_node.set_field("body", body) | |
| 235 | + stats = {"functions": 0, "classes": 0, "edges": 0} | |
| 236 | + parser._extract_calls(fn_node, source, "lib.rs", "foo", | |
| 237 | + NodeLabel.Funct | |
| 238 | + def test_handles_method_call_syntax(self): | |
| 239 | + parser = _make_parser() | |
| 240 | + store = _make_store() | |
| 241 | + source = b"Repo::save" | |
| 242 | + callee = _text_node(b"Repo::save") | |
| 243 | + call_node = MockNode("call_expression") | |
| 244 | + call_node.set_field("function", callee) | |
| 245 | + body = MockNode("block", children=[call_node]) | |
| 246 | + fn_node = MockNode("function_item") | |
| 247 | + fn_node.set_field("body", body) | |
| 248 | + stats = {"functions": 0, "classes": 0, "edges": 0} | |
| 249 | + parser._extract_calls(fn_node, source, "lib.rs", "foo", | |
| 250 | + NodeLabel.Function, store, stats) | |
| 251 | + # "Repo::save" → callee = "save" | |
| 252 | + edge_call = store.create_edge.call_args[0] | |
| 253 | + assert edge |
| --- a/tests/test_rust_parser.py | |
| +++ b/tests/test_rust_parser.py | |
| @@ -0,0 +1,253 @@ | |
| --- a/tests/test_rust_parser.py | |
| +++ b/tests/test_rust_parser.py | |
| @@ -0,0 +1,253 @@ | |
| 1 | """Tests for navegador.ingestion.rust — RustParser internal methods.""" |
| 2 | |
| 3 | from unittest.mock import MagicMock, patch |
| 4 | |
| 5 | import pytest |
| 6 | |
| 7 | from navegador.graph.schema import NodeLabel |
| 8 | |
| 9 | |
| 10 | class MockNode: |
| 11 | _id_counter = 0 |
| 12 | |
| 13 | def __init__(self, type_: str, text: bytes = b"", children: list = None, |
| 14 | start_byte: int = 0, end_byte: int = 0, |
| 15 | start_point: tuple = (0, 0), end_point: tuple = (0, 0), |
| 16 | parent=None): |
| 17 | MockNode._id_counter += 1 |
| 18 | self.id = MockNode._id_counter |
| 19 | self.type = type_ |
| 20 | self._text = text |
| 21 | self.children = children or [] |
| 22 | self.start_byte = start_byte |
| 23 | self.end_byte = end_byte |
| 24 | self.start_point = start_point |
| 25 | self.end_point = end_point |
| 26 | self.parent = parent |
| 27 | self._fields: dict = {} |
| 28 | for child in self.children: |
| 29 | child.parent = self |
| 30 | |
| 31 | def child_by_field_name(self, name: str): |
| 32 | return self._fields.get(name) |
| 33 | |
| 34 | def set_field(self, name: str, node): |
| 35 | self._fields[name] = node |
| 36 | node.parent = self |
| 37 | return self |
| 38 | |
| 39 | |
| 40 | def _text_node(text: bytes, type_: str = "identifier") -> MockNode: |
| 41 | return MockNode(type_, text, start_byte=0, end_byte=len(text)) |
| 42 | |
| 43 | |
| 44 | def _make_store(): |
| 45 | store = MagicMock() |
| 46 | store.query.return_value = MagicMock(result_set=[]) |
| 47 | return store |
| 48 | |
| 49 | |
| 50 | def _make_parser(): |
| 51 | from navegador.ingestion.rust import RustParser |
| 52 | parser = RustParser.__new__(RustParser) |
| 53 | parser._parser = MagicMock() |
| 54 | return parser |
| 55 | |
| 56 | |
| 57 | class TestRustGetLanguage: |
| 58 | def test_raises_when_not_installed(self): |
| 59 | from navegador.ingestion.rust import _get_rust_language |
| 60 | with patch.dict("sys.modules", {"tree_sitter_rust": None, "tree_sitter": None}): |
| 61 | with pytest.raises(ImportError, match="tree-sitter-rust"): |
| 62 | _get_rust_language() |
| 63 | |
| 64 | |
| 65 | class TestRustNodeText: |
| 66 | def test_extracts_bytes(self): |
| 67 | from navegador.ingestion.rust import _node_text |
| 68 | source = b"fn main() {}" |
| 69 | node = MockNode("identifier", start_byte=3, end_byte=7) |
| 70 | assert _node_text(node, source) == "main" |
| 71 | |
| 72 | |
| 73 | class TestRustDocComment: |
| 74 | def test_collects_triple_slash_comments(self): |
| 75 | from navegador.ingestion.rust import _doc_comment |
| 76 | source = b"/// Docs line 1\n/// Docs line 2\nfn foo() {}" |
| 77 | doc1 = MockNode("line_comment", start_byte=0, end_byte=15) |
| 78 | doc2 = MockNode("line_comment", start_byte=16, end_byte=31) |
| 79 | fn_node = MockNode("function_item", start_byte=32, end_byte=44) |
| 80 | _parent = MockNode("source_file", children=[doc1, doc2, fn_node]) |
| 81 | result = _doc_comment(fn_node, source) |
| 82 | assert "Docs line 1" in result |
| 83 | assert "Docs line 2" in result |
| 84 | |
| 85 | def test_ignores_non_doc_comments(self): |
| 86 | from navegador.ingestion.rust import _doc_comment |
| 87 | source = b"// regular comment\nfn foo() {}" |
| 88 | comment = MockNode("line_comment", start_byte=0, end_byte=18) |
| 89 | fn_node = MockNode("function_item", start_byte=19, end_byte=30) |
| 90 | MockNode("source_file", children=[comment, fn_node]) |
| 91 | result = _doc_comment(fn_node, source) |
| 92 | assert result == "" |
| 93 | |
| 94 | def test_no_parent(self): |
| 95 | from navegador.ingestion.rust import _doc_comment |
| 96 | fn_node = MockNode("function_item") |
| 97 | assert _doc_comment(fn_node, b"") == "" |
| 98 | |
| 99 | |
| 100 | class TestRustHandleFunction: |
| 101 | def test_creates_function_node(self): |
| 102 | parser = _make_parser() |
| 103 | store = _make_store() |
| 104 | source = b"fn foo() {}" |
| 105 | name = _text_node(b"foo") |
| 106 | node = MockNode("function_item", start_point=(0, 0), end_point=(0, 10)) |
| 107 | node.set_field("name", name) |
| 108 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 109 | parser._handle_function(node, source, "lib.rs", store, stats, impl_type=None) |
| 110 | assert stats["functions"] == 1 |
| 111 | label = store.create_node.call_args[0][0] |
| 112 | assert label == NodeLabel.Function |
| 113 | |
| 114 | def test_creates_method_when_impl_type_given(self): |
| 115 | parser = _make_parser() |
| 116 | store = _make_store() |
| 117 | source = b"fn save(&self) {}" |
| 118 | name = _text_node(b"save") |
| 119 | node = MockNode("function_item", start_point=(0, 0), end_point=(0, 16)) |
| 120 | node.set_field("name", name) |
| 121 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 122 | parser._handle_function(node, source, "lib.rs", store, stats, impl_type="Repo") |
| 123 | label = store.create_node.call_args[0][0] |
| 124 | assert label == NodeLabel.Method |
| 125 | |
| 126 | def test_skips_if_no_name(self): |
| 127 | parser = _make_parser() |
| 128 | store = _make_store() |
| 129 | node = MockNode("function_item", start_point=(0, 0), end_point=(0, 0)) |
| 130 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 131 | parser._handle_function(node, b"", "lib.rs", store, stats, impl_type=None) |
| 132 | assert stats["functions"] == 0 |
| 133 | |
| 134 | |
| 135 | class TestRustHandleImpl: |
| 136 | def test_handles_impl_block(self): |
| 137 | parser = _make_parser() |
| 138 | store = _make_store() |
| 139 | source = b"impl Repo { fn save(&self) {} }" |
| 140 | type_node = _text_node(b"Repo", "type_identifier") |
| 141 | name_node = _text_node(b"save") |
| 142 | fn_item = MockNode("function_item", start_point=(0, 12), end_point=(0, 28)) |
| 143 | fn_item.set_field("name", name_node) |
| 144 | body = MockNode("declaration_list", children=[fn_item]) |
| 145 | impl = MockNode("impl_item", start_point=(0, 0), end_point=(0, 30)) |
| 146 | impl.set_field("type", type_node) |
| 147 | impl.set_field("body", body) |
| 148 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 149 | parser._handle_impl(impl, source, "lib.rs", store, stats) |
| 150 | assert stats["functions"] == 1 |
| 151 | |
| 152 | def test_handles_impl_with_no_body(self): |
| 153 | parser = _make_parser() |
| 154 | store = _make_store() |
| 155 | impl = MockNode("impl_item") |
| 156 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 157 | parser._handle_impl(impl, b"", "lib.rs", store, stats) |
| 158 | assert stats["functions"] == 0 |
| 159 | |
| 160 | |
| 161 | class TestRustHandleType: |
| 162 | def test_ingests_struct(self): |
| 163 | parser = _make_parser() |
| 164 | store = _make_store() |
| 165 | source = b"struct Foo {}" |
| 166 | name = _text_node(b"Foo", "type_identifier") |
| 167 | node = MockNode("struct_item", start_point=(0, 0), end_point=(0, 12)) |
| 168 | node.set_field("name", name) |
| 169 | _parent = MockNode("source_file", children=[node]) |
| 170 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 171 | parser._handle_type(node, source, "lib.rs", store, stats) |
| 172 | assert stats["classes"] == 1 |
| 173 | props = store.create_node.call_args[0][1] |
| 174 | assert "struct" in props["docstring"] |
| 175 | |
| 176 | def test_ingests_enum(self): |
| 177 | parser = _make_parser() |
| 178 | store = _make_store() |
| 179 | source = b"enum Color { Red, Green }" |
| 180 | name = _text_node(b"Color", "type_identifier") |
| 181 | node = MockNode("enum_item", start_point=(0, 0), end_point=(0, 24)) |
| 182 | node.set_field("name", name) |
| 183 | MockNode("source_file", children=[node]) |
| 184 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 185 | parser._handle_type(node, source, "lib.rs", store, stats) |
| 186 | assert stats["classes"] == 1 |
| 187 | |
| 188 | def test_ingests_trait(self): |
| 189 | parser = _make_parser() |
| 190 | store = _make_store() |
| 191 | source = b"trait Saveable {}" |
| 192 | name = _text_node(b"Saveable", "type_identifier") |
| 193 | node = MockNode("trait_item", start_point=(0, 0), end_point=(0, 16)) |
| 194 | node.set_field("name", name) |
| 195 | MockNode("source_file", children=[node]) |
| 196 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 197 | parser._handle_type(node, source, "lib.rs", store, stats) |
| 198 | assert stats["classes"] == 1 |
| 199 | |
| 200 | def test_skips_if_no_name(self): |
| 201 | parser = _make_parser() |
| 202 | store = _make_store() |
| 203 | node = MockNode("struct_item") |
| 204 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 205 | parser._handle_type(node, b"", "lib.rs", store, stats) |
| 206 | assert stats["classes"] == 0 |
| 207 | |
| 208 | |
| 209 | class TestRustHandleUse: |
| 210 | def test_ingests_use_statement(self): |
| 211 | parser = _make_parser() |
| 212 | store = _make_store() |
| 213 | source = b"use std::collections::HashMap;" |
| 214 | node = MockNode("use_declaration", start_byte=0, end_byte=30, |
| 215 | start_point=(0, 0)) |
| 216 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 217 | parser._handle_use(node, source, "lib.rs", store, stats) |
| 218 | assert stats["edges"] == 1 |
| 219 | store.create_node.assert_called_once() |
| 220 | props = store.create_node.call_args[0][1] |
| 221 | assert "HashMap" in props["name"] or "std" in props["name"] |
| 222 | |
| 223 | |
| 224 | class TestRustExtractCalls: |
| 225 | def test_extracts_call(self): |
| 226 | parser = _make_parser() |
| 227 | store = _make_fn foo() { bar() }re() |
| 228 | source = b"bar" |
| 229 | callee = _text_node(b"bar") |
| 230 | call_node = MockNode("call_expression") |
| 231 | call_node.set_field("function", callee) |
| 232 | body = MockNode("block", children=[call_node]) |
| 233 | fn_node = MockNode("function_item") |
| 234 | fn_node.set_field("body", body) |
| 235 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 236 | parser._extract_calls(fn_node, source, "lib.rs", "foo", |
| 237 | NodeLabel.Funct |
| 238 | def test_handles_method_call_syntax(self): |
| 239 | parser = _make_parser() |
| 240 | store = _make_store() |
| 241 | source = b"Repo::save" |
| 242 | callee = _text_node(b"Repo::save") |
| 243 | call_node = MockNode("call_expression") |
| 244 | call_node.set_field("function", callee) |
| 245 | body = MockNode("block", children=[call_node]) |
| 246 | fn_node = MockNode("function_item") |
| 247 | fn_node.set_field("body", body) |
| 248 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 249 | parser._extract_calls(fn_node, source, "lib.rs", "foo", |
| 250 | NodeLabel.Function, store, stats) |
| 251 | # "Repo::save" → callee = "save" |
| 252 | edge_call = store.create_edge.call_args[0] |
| 253 | assert edge |
| --- a/tests/test_typescript_parser.py | ||
| +++ b/tests/test_typescript_parser.py | ||
| @@ -0,0 +1,270 @@ | ||
| 1 | +"""Tests for navegador.ingestion.typescript — TypeScriptParser internal methods.""" | |
| 2 | + | |
| 3 | +from unittest.mock import MagicMock, patch | |
| 4 | + | |
| 5 | +import pytest | |
| 6 | + | |
| 7 | +from navegador.graph.schema import NodeLabel | |
| 8 | + | |
| 9 | + | |
| 10 | +class MockNode: | |
| 11 | + _id_counter = 0 | |
| 12 | + | |
| 13 | + def __init__(self, type_: str, text: bytes = b"", children: list = None, | |
| 14 | + start_byte: int = 0, end_byte: int = 0, | |
| 15 | + start_point: tuple = (0, 0), end_point: tuple = (0, 0), | |
| 16 | + parent=None): | |
| 17 | + MockNode._id_counter += 1 | |
| 18 | + self.id = MockNode._id_counter | |
| 19 | + self.type = type_ | |
| 20 | + self.children = children or [] | |
| 21 | + self.start_byte = start_byte | |
| 22 | + self.end_byte = end_byte | |
| 23 | + self.start_point = start_point | |
| 24 | + self.end_point = end_point | |
| 25 | + self.parent = parent | |
| 26 | + self._fields: dict = {} | |
| 27 | + for child in self.children: | |
| 28 | + child.parent = self | |
| 29 | + | |
| 30 | + def child_by_field_name(self, name: str): | |
| 31 | + return self._fields.get(name) | |
| 32 | + | |
| 33 | + def set_field(self, name: str, node): | |
| 34 | + self._fields[name] = node | |
| 35 | + node.parent = self | |
| 36 | + return self | |
| 37 | + | |
| 38 | + | |
| 39 | +def _text_node(text: bytes, type_: str = "identifier") -> MockNode: | |
| 40 | + return MockNode(type_, text, start_byte=0, end_byte=len(text)) | |
| 41 | + | |
| 42 | + | |
| 43 | +def _make_store(): | |
| 44 | + store = MagicMock() | |
| 45 | + store.query.return_value = MagicMock(result_set=[]) | |
| 46 | + return store | |
| 47 | + | |
| 48 | + | |
| 49 | +def _make_parser(language: str = "typescript"): | |
| 50 | + from navegador.ingestion.typescript import TypeScriptParser | |
| 51 | + parser = TypeScriptParser.__new__(TypeScriptParser) | |
| 52 | + parser._parser = MagicMock() | |
| 53 | + parser._language = language | |
| 54 | + return parser | |
| 55 | + | |
| 56 | + | |
| 57 | +class TestTsGetLanguage: | |
| 58 | + def test_raises_when_ts_not_installed(self): | |
| 59 | + from navegador.ingestion.typescript import _get_ts_language | |
| 60 | + with patch.dict("sys.modules", {"tree_sitter_typescript": None, "tree_sitter": None}): | |
| 61 | + with pytest.raises(ImportError, match="tree-sitter-typescript"): | |
| 62 | + _get_ts_language("typescript") | |
| 63 | + | |
| 64 | + def test_raises_when_js_not_installed(self): | |
| 65 | + from navegador.ingestion.typescript import _get_ts_language | |
| 66 | + with patch.dict("sys.modules", {"tree_sitter_javascript": None, "tree_sitter": None}): | |
| 67 | + with pytest.raises(ImportError, match="tree-sitter-javascript"): | |
| 68 | + _get_ts_language("javascript") | |
| 69 | + | |
| 70 | + | |
| 71 | +class TestTsNodeText: | |
| 72 | + def test_extracts_text(self): | |
| 73 | + from navegador.ingestion.typescript import _node_text | |
| 74 | + source = b"class Foo {}" | |
| 75 | + node = MockNode("type_identifier", start_byte=6, end_byte=9) | |
| 76 | + assert _node_text(node, source) == "Foo" | |
| 77 | + | |
| 78 | + | |
| 79 | +class TestTsJsdoc: | |
| 80 | + def test_extracts_jsdoc(self): | |
| 81 | + from navegador.ingestion.typescript import _jsdoc | |
| 82 | + source = b"/** My class */\nclass Foo {}" | |
| 83 | + comment = MockNode("comment", start_byte=0, end_byte=15) | |
| 84 | + cls_node = MockNode("class_declaration", start_byte=16, end_byte=28) | |
| 85 | + MockNode("program", children=[comment, cls_node]) | |
| 86 | + result = _jsdoc(cls_node, source) | |
| 87 | + assert "My class" in result | |
| 88 | + | |
| 89 | + def test_ignores_single_line_comment(self): | |
| 90 | + from navegador.ingestion.typescript import _jsdoc | |
| 91 | + source = b"// not jsdoc\nclass Foo {}" | |
| 92 | + comment = MockNode("comment", start_byte=0, end_byte=12) | |
| 93 | + cls_node = MockNode("class_declaration", start_byte=13, end_byte=25) | |
| 94 | + MockNode("program", children=[comment, cls_node]) | |
| 95 | + result = _jsdoc(cls_node, source) | |
| 96 | + assert result == "" | |
| 97 | + | |
| 98 | + | |
| 99 | +class TestTsHandleClass: | |
| 100 | + def test_creates_class_node(self): | |
| 101 | + parser = _make_parser() | |
| 102 | + store = _make_store() | |
| 103 | + source = b"class Foo {}" | |
| 104 | + name_node = _text_node(b"Foo", "type_identifier") | |
| 105 | + body = MockNode("class_body") | |
| 106 | + node = MockNode("class_declaration", | |
| 107 | + children=[name_node, body], | |
| 108 | + start_point=(0, 0), end_point=(0, 11)) | |
| 109 | + MockNode("program", children=[node]) | |
| 110 | + stats = {"functions": 0, "classes": 0, "edges": 0} | |
| 111 | + parser._handle_class(node, source, "app.ts", store, stats) | |
| 112 | + assert stats["classes"] == 1 | |
| 113 | + label = store.create_node.call_args[0][0] | |
| 114 | + assert label == NodeLabel.Class | |
| 115 | + | |
| 116 | + def test_creates_inherits_edge(self): | |
| 117 | + parser = _make_parser() | |
| 118 | + store = _make_store() | |
| 119 | + source = b"class Child extends Parent {}" | |
| 120 | + name_node = _text_node(b"Child", "type_identifier") | |
| 121 | + parent_id = _text_node(b"Parent") | |
| 122 | + extends = MockNode("extends_clause", children=[parent_id]) | |
| 123 | + heritage = MockNode("class_heritage", children=[extends]) | |
| 124 | + body = MockNode("class_body") | |
| 125 | + node = MockNode("class_declaration", | |
| 126 | + children=[name_node, heritage, body], | |
| 127 | + start_point=(0, 0), end_point=(0, 28)) | |
| 128 | + MockNode("program", children=[node]) | |
| 129 | + stats = {"functions": 0, "classes": 0, "edges": 0} | |
| 130 | + parser._handle_class(node, source, "app.ts", store, stats) | |
| 131 | + assert stats["edges"] == 2 # CONTAINS + INHERITS | |
| 132 | + | |
| 133 | + def test_ingests_methods_in_body(self): | |
| 134 | + parser = _make_parser() | |
| 135 | + store = _make_store() | |
| 136 | + source = b"class Foo { bar() {} }" | |
| 137 | + name_node = _text_node(b"Foo", "type_identifier") | |
| 138 | + method_name = _text_node(b"bar", "property_identifier") | |
| 139 | + method_body = MockNode("statement_block") | |
| 140 | + method = MockNode("method_definition", | |
| 141 | + children=[method_name, method_body], | |
| 142 | + start_point=(1, 2), end_point=(1, 9)) | |
| 143 | + body = MockNode("class_body", children=[method]) | |
| 144 | + node = MockNode("class_declaration", | |
| 145 | + children=[name_node, body], | |
| 146 | + start_point=(0, 0), end_point=(0, 21)) | |
| 147 | + MockNode("program", children=[node]) | |
| 148 | + stats = {"functions": 0, "classes": 0, "edges": 0} | |
| 149 | + parser._handle_class(node, source, "app.ts", store, stats) | |
| 150 | + assert stats["functions"] == 1 | |
| 151 | + | |
| 152 | + def test_skips_if_no_name(self): | |
| 153 | + parser = _make_parser() | |
| 154 | + store = _make_store() | |
| 155 | + node = MockNode("class_declaration", children=[]) | |
| 156 | + stats = {"functions": 0, "classes": 0, "edges": 0} | |
| 157 | + parser._handle_class(node, b"", "app.ts", store, stats) | |
| 158 | + assert stats["classes"] == 0 | |
| 159 | + | |
| 160 | + | |
| 161 | +class TestTsHandleInterface: | |
| 162 | + def test_creates_interface_node(self): | |
| 163 | + parser = _make_parser() | |
| 164 | + store = _make_store() | |
| 165 | + source = b"interface IFoo {}" | |
| 166 | + name_node = _text_node(b"IFoo", "type_identifier") | |
| 167 | + node = MockNode("interface_declaration", | |
| 168 | + children=[name_node], | |
| 169 | + start_point=(0, 0), end_point=(0, 16)) | |
| 170 | + MockNode("program", children=[node]) | |
| 171 | + stats = {"functions": 0, "classes": 0, "edges": 0} | |
| 172 | + parser._handle_interface(node, source, "app.ts", store, stats) | |
| 173 | + assert stats["classes"] == 1 | |
| 174 | + props = store.create_node.call_args[0][1] | |
| 175 | + assert "interface" in props.get("docstring", "") | |
| 176 | + | |
| 177 | + def test_skips_if_no_name(self): | |
| 178 | + parser = _make_parser() | |
| 179 | + store = _make_store() | |
| 180 | + node = MockNode("interface_declaration", children=[]) | |
| 181 | + stats = {"functions": 0, "classes": 0, "edges": 0} | |
| 182 | + parser._handle_interface(node, b"", "app.ts", store, stats) | |
| 183 | + assert stats["classes"] == 0 | |
| 184 | + | |
| 185 | + | |
| 186 | +class TestTsHandleFunction: | |
| 187 | + def test_creates_function_node(self): | |
| 188 | + parser = _make_parser() | |
| 189 | + store = _make_store() | |
| 190 | + source = b"function foo() {}" | |
| 191 | + name_node = _text_node(b"foo") | |
| 192 | + body = MockNode("statement_block") | |
| 193 | + node = MockNode("function_declaration", | |
| 194 | + children=[name_node, body], | |
| 195 | + start_point=(0, 0), end_point=(0, 16)) | |
| 196 | + MockNode("program", children=[node]) | |
| 197 | + stats = {"functions": 0, "classes": 0, "edges": 0} | |
| 198 | + parser._handle_function(node, source, "app.ts", store, stats, class_name=None) | |
| 199 | + assert stats["functions"] == 1 | |
| 200 | + label = store.create_node.call_args[0][0] | |
| 201 | + assert label == NodeLabel.Function | |
| 202 | + | |
| 203 | + def test_creates_method_when_class_name_given(self): | |
| 204 | + parser = _make_parser() | |
| 205 | + store = _make_store() | |
| 206 | + source = b"foo() {}" | |
| 207 | + name_node = _text_node(b"foo", "property_identifier") | |
| 208 | + body = MockNode("statement_block") | |
| 209 | + node = MockNode("method_definition", | |
| 210 | + children=[name_node, body], | |
| 211 | + start_point=(0, 0), end_point=(0, 7)) | |
| 212 | + stats = {"functions": 0, "classes": 0, "edges": 0} | |
| 213 | + parser._handle_function(node, source, "app.ts", store, stats, class_name="Bar") | |
| 214 | + label = store.create_node.call_args[0][0] | |
| 215 | + assert label == NodeLabel.Method | |
| 216 | + | |
| 217 | + def test_skips_if_no_name(self): | |
| 218 | + parser = _make_parser() | |
| 219 | + store = _make_store() | |
| 220 | + node = MockNode("function_declaration", children=[]) | |
| 221 | + stats = {"functions": 0, "classes": 0, "edges": 0} | |
| 222 | + parser._handle_function(node, b"", "app.ts", store, stats, class_name=None) | |
| 223 | + assert stats["functions"] == 0 | |
| 224 | + | |
| 225 | + | |
| 226 | +class TestTsHandleLexical: | |
| 227 | + def test_ingests_const_arrow_function(self): | |
| 228 | + parser = _make_parser() | |
| 229 | + store = _make_store() | |
| 230 | + source = b"const foo = () => {}" | |
| 231 | + name_node = _text_node(b"foo") | |
| 232 | + arrow = MockNode("arrow_function", start_point=(1, 0), end_point=(1, 7)) | |
| 233 | + declarator = MockNode("variable_declarator") | |
| 234 | + declarator.set_field("name", name_node) | |
| 235 | + declarator.set_field("value", arrow) | |
| 236 | + node = MockNode("lexical_declaration", | |
| 237 | + children=[declarator], | |
| 238 | + start_point=(0, 0), end_point=(0, 19)) | |
| 239 | + MockNode("program", children=[node]) | |
| 240 | + stats = {"functions": 0, "classes": 0, "edges": 0} | |
| 241 | + parser._handle_lexical(node, source, "app.ts", store, stats) | |
| 242 | + assert stats["functions"] == 1 | |
| 243 | + | |
| 244 | + def test_ingests_const_function_expression(self): | |
| 245 | + parser = _make_parser() | |
| 246 | + store = _make_store() | |
| 247 | + source = b"const bar = function() {}" | |
| 248 | + name_node = _text_node(b"bar") | |
| 249 | + fn_expr = MockNode("function_expression") | |
| 250 | + declarator = MockNode("variable_declarator") | |
| 251 | + declarator.set_field("function foo() { bar() }e", name_node) | |
| 252 | + declarator.set_field("value", fn_expr) | |
| 253 | + node = MockNode("lexical_declaration", | |
| 254 | + children=[declarator], | |
| 255 | + start_point=(0, 0), end_point=(0, 24)) | |
| 256 | + MockNode("program", children=[node]) | |
| 257 | + stats = {"functions": 0, "classes": 0, "edges": 0} | |
| 258 | + parser._handle_lexical(node, source, "app.ts", store, stats) | |
| 259 | + assert stats["functions"] == 1 | |
| 260 | + | |
| 261 | + def test_skips_non_functioe_call[4]["name"] == "bar" | |
| 262 | + | |
| 263 | + def test_no_calls_in_empty_body(self): | |
| 264 | + parser = _make_parser() | |
| 265 | + store = _make_store() | |
| 266 | + fn_node = MockNode("function_declaration", | |
| 267 | + children=[MockNode("statement_block")]) | |
| 268 | + stats = {"functions": 0, "classes": 0, "edges": 0} | |
| 269 | + parser._extract_calls(fn_node, b"", "app.ts", "foo", | |
| 270 | + NodeLabel.Funct |
| --- a/tests/test_typescript_parser.py | |
| +++ b/tests/test_typescript_parser.py | |
| @@ -0,0 +1,270 @@ | |
| --- a/tests/test_typescript_parser.py | |
| +++ b/tests/test_typescript_parser.py | |
| @@ -0,0 +1,270 @@ | |
| 1 | """Tests for navegador.ingestion.typescript — TypeScriptParser internal methods.""" |
| 2 | |
| 3 | from unittest.mock import MagicMock, patch |
| 4 | |
| 5 | import pytest |
| 6 | |
| 7 | from navegador.graph.schema import NodeLabel |
| 8 | |
| 9 | |
| 10 | class MockNode: |
| 11 | _id_counter = 0 |
| 12 | |
| 13 | def __init__(self, type_: str, text: bytes = b"", children: list = None, |
| 14 | start_byte: int = 0, end_byte: int = 0, |
| 15 | start_point: tuple = (0, 0), end_point: tuple = (0, 0), |
| 16 | parent=None): |
| 17 | MockNode._id_counter += 1 |
| 18 | self.id = MockNode._id_counter |
| 19 | self.type = type_ |
| 20 | self.children = children or [] |
| 21 | self.start_byte = start_byte |
| 22 | self.end_byte = end_byte |
| 23 | self.start_point = start_point |
| 24 | self.end_point = end_point |
| 25 | self.parent = parent |
| 26 | self._fields: dict = {} |
| 27 | for child in self.children: |
| 28 | child.parent = self |
| 29 | |
| 30 | def child_by_field_name(self, name: str): |
| 31 | return self._fields.get(name) |
| 32 | |
| 33 | def set_field(self, name: str, node): |
| 34 | self._fields[name] = node |
| 35 | node.parent = self |
| 36 | return self |
| 37 | |
| 38 | |
| 39 | def _text_node(text: bytes, type_: str = "identifier") -> MockNode: |
| 40 | return MockNode(type_, text, start_byte=0, end_byte=len(text)) |
| 41 | |
| 42 | |
| 43 | def _make_store(): |
| 44 | store = MagicMock() |
| 45 | store.query.return_value = MagicMock(result_set=[]) |
| 46 | return store |
| 47 | |
| 48 | |
| 49 | def _make_parser(language: str = "typescript"): |
| 50 | from navegador.ingestion.typescript import TypeScriptParser |
| 51 | parser = TypeScriptParser.__new__(TypeScriptParser) |
| 52 | parser._parser = MagicMock() |
| 53 | parser._language = language |
| 54 | return parser |
| 55 | |
| 56 | |
| 57 | class TestTsGetLanguage: |
| 58 | def test_raises_when_ts_not_installed(self): |
| 59 | from navegador.ingestion.typescript import _get_ts_language |
| 60 | with patch.dict("sys.modules", {"tree_sitter_typescript": None, "tree_sitter": None}): |
| 61 | with pytest.raises(ImportError, match="tree-sitter-typescript"): |
| 62 | _get_ts_language("typescript") |
| 63 | |
| 64 | def test_raises_when_js_not_installed(self): |
| 65 | from navegador.ingestion.typescript import _get_ts_language |
| 66 | with patch.dict("sys.modules", {"tree_sitter_javascript": None, "tree_sitter": None}): |
| 67 | with pytest.raises(ImportError, match="tree-sitter-javascript"): |
| 68 | _get_ts_language("javascript") |
| 69 | |
| 70 | |
| 71 | class TestTsNodeText: |
| 72 | def test_extracts_text(self): |
| 73 | from navegador.ingestion.typescript import _node_text |
| 74 | source = b"class Foo {}" |
| 75 | node = MockNode("type_identifier", start_byte=6, end_byte=9) |
| 76 | assert _node_text(node, source) == "Foo" |
| 77 | |
| 78 | |
| 79 | class TestTsJsdoc: |
| 80 | def test_extracts_jsdoc(self): |
| 81 | from navegador.ingestion.typescript import _jsdoc |
| 82 | source = b"/** My class */\nclass Foo {}" |
| 83 | comment = MockNode("comment", start_byte=0, end_byte=15) |
| 84 | cls_node = MockNode("class_declaration", start_byte=16, end_byte=28) |
| 85 | MockNode("program", children=[comment, cls_node]) |
| 86 | result = _jsdoc(cls_node, source) |
| 87 | assert "My class" in result |
| 88 | |
| 89 | def test_ignores_single_line_comment(self): |
| 90 | from navegador.ingestion.typescript import _jsdoc |
| 91 | source = b"// not jsdoc\nclass Foo {}" |
| 92 | comment = MockNode("comment", start_byte=0, end_byte=12) |
| 93 | cls_node = MockNode("class_declaration", start_byte=13, end_byte=25) |
| 94 | MockNode("program", children=[comment, cls_node]) |
| 95 | result = _jsdoc(cls_node, source) |
| 96 | assert result == "" |
| 97 | |
| 98 | |
| 99 | class TestTsHandleClass: |
| 100 | def test_creates_class_node(self): |
| 101 | parser = _make_parser() |
| 102 | store = _make_store() |
| 103 | source = b"class Foo {}" |
| 104 | name_node = _text_node(b"Foo", "type_identifier") |
| 105 | body = MockNode("class_body") |
| 106 | node = MockNode("class_declaration", |
| 107 | children=[name_node, body], |
| 108 | start_point=(0, 0), end_point=(0, 11)) |
| 109 | MockNode("program", children=[node]) |
| 110 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 111 | parser._handle_class(node, source, "app.ts", store, stats) |
| 112 | assert stats["classes"] == 1 |
| 113 | label = store.create_node.call_args[0][0] |
| 114 | assert label == NodeLabel.Class |
| 115 | |
| 116 | def test_creates_inherits_edge(self): |
| 117 | parser = _make_parser() |
| 118 | store = _make_store() |
| 119 | source = b"class Child extends Parent {}" |
| 120 | name_node = _text_node(b"Child", "type_identifier") |
| 121 | parent_id = _text_node(b"Parent") |
| 122 | extends = MockNode("extends_clause", children=[parent_id]) |
| 123 | heritage = MockNode("class_heritage", children=[extends]) |
| 124 | body = MockNode("class_body") |
| 125 | node = MockNode("class_declaration", |
| 126 | children=[name_node, heritage, body], |
| 127 | start_point=(0, 0), end_point=(0, 28)) |
| 128 | MockNode("program", children=[node]) |
| 129 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 130 | parser._handle_class(node, source, "app.ts", store, stats) |
| 131 | assert stats["edges"] == 2 # CONTAINS + INHERITS |
| 132 | |
| 133 | def test_ingests_methods_in_body(self): |
| 134 | parser = _make_parser() |
| 135 | store = _make_store() |
| 136 | source = b"class Foo { bar() {} }" |
| 137 | name_node = _text_node(b"Foo", "type_identifier") |
| 138 | method_name = _text_node(b"bar", "property_identifier") |
| 139 | method_body = MockNode("statement_block") |
| 140 | method = MockNode("method_definition", |
| 141 | children=[method_name, method_body], |
| 142 | start_point=(1, 2), end_point=(1, 9)) |
| 143 | body = MockNode("class_body", children=[method]) |
| 144 | node = MockNode("class_declaration", |
| 145 | children=[name_node, body], |
| 146 | start_point=(0, 0), end_point=(0, 21)) |
| 147 | MockNode("program", children=[node]) |
| 148 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 149 | parser._handle_class(node, source, "app.ts", store, stats) |
| 150 | assert stats["functions"] == 1 |
| 151 | |
| 152 | def test_skips_if_no_name(self): |
| 153 | parser = _make_parser() |
| 154 | store = _make_store() |
| 155 | node = MockNode("class_declaration", children=[]) |
| 156 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 157 | parser._handle_class(node, b"", "app.ts", store, stats) |
| 158 | assert stats["classes"] == 0 |
| 159 | |
| 160 | |
| 161 | class TestTsHandleInterface: |
| 162 | def test_creates_interface_node(self): |
| 163 | parser = _make_parser() |
| 164 | store = _make_store() |
| 165 | source = b"interface IFoo {}" |
| 166 | name_node = _text_node(b"IFoo", "type_identifier") |
| 167 | node = MockNode("interface_declaration", |
| 168 | children=[name_node], |
| 169 | start_point=(0, 0), end_point=(0, 16)) |
| 170 | MockNode("program", children=[node]) |
| 171 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 172 | parser._handle_interface(node, source, "app.ts", store, stats) |
| 173 | assert stats["classes"] == 1 |
| 174 | props = store.create_node.call_args[0][1] |
| 175 | assert "interface" in props.get("docstring", "") |
| 176 | |
| 177 | def test_skips_if_no_name(self): |
| 178 | parser = _make_parser() |
| 179 | store = _make_store() |
| 180 | node = MockNode("interface_declaration", children=[]) |
| 181 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 182 | parser._handle_interface(node, b"", "app.ts", store, stats) |
| 183 | assert stats["classes"] == 0 |
| 184 | |
| 185 | |
| 186 | class TestTsHandleFunction: |
| 187 | def test_creates_function_node(self): |
| 188 | parser = _make_parser() |
| 189 | store = _make_store() |
| 190 | source = b"function foo() {}" |
| 191 | name_node = _text_node(b"foo") |
| 192 | body = MockNode("statement_block") |
| 193 | node = MockNode("function_declaration", |
| 194 | children=[name_node, body], |
| 195 | start_point=(0, 0), end_point=(0, 16)) |
| 196 | MockNode("program", children=[node]) |
| 197 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 198 | parser._handle_function(node, source, "app.ts", store, stats, class_name=None) |
| 199 | assert stats["functions"] == 1 |
| 200 | label = store.create_node.call_args[0][0] |
| 201 | assert label == NodeLabel.Function |
| 202 | |
| 203 | def test_creates_method_when_class_name_given(self): |
| 204 | parser = _make_parser() |
| 205 | store = _make_store() |
| 206 | source = b"foo() {}" |
| 207 | name_node = _text_node(b"foo", "property_identifier") |
| 208 | body = MockNode("statement_block") |
| 209 | node = MockNode("method_definition", |
| 210 | children=[name_node, body], |
| 211 | start_point=(0, 0), end_point=(0, 7)) |
| 212 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 213 | parser._handle_function(node, source, "app.ts", store, stats, class_name="Bar") |
| 214 | label = store.create_node.call_args[0][0] |
| 215 | assert label == NodeLabel.Method |
| 216 | |
| 217 | def test_skips_if_no_name(self): |
| 218 | parser = _make_parser() |
| 219 | store = _make_store() |
| 220 | node = MockNode("function_declaration", children=[]) |
| 221 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 222 | parser._handle_function(node, b"", "app.ts", store, stats, class_name=None) |
| 223 | assert stats["functions"] == 0 |
| 224 | |
| 225 | |
| 226 | class TestTsHandleLexical: |
| 227 | def test_ingests_const_arrow_function(self): |
| 228 | parser = _make_parser() |
| 229 | store = _make_store() |
| 230 | source = b"const foo = () => {}" |
| 231 | name_node = _text_node(b"foo") |
| 232 | arrow = MockNode("arrow_function", start_point=(1, 0), end_point=(1, 7)) |
| 233 | declarator = MockNode("variable_declarator") |
| 234 | declarator.set_field("name", name_node) |
| 235 | declarator.set_field("value", arrow) |
| 236 | node = MockNode("lexical_declaration", |
| 237 | children=[declarator], |
| 238 | start_point=(0, 0), end_point=(0, 19)) |
| 239 | MockNode("program", children=[node]) |
| 240 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 241 | parser._handle_lexical(node, source, "app.ts", store, stats) |
| 242 | assert stats["functions"] == 1 |
| 243 | |
| 244 | def test_ingests_const_function_expression(self): |
| 245 | parser = _make_parser() |
| 246 | store = _make_store() |
| 247 | source = b"const bar = function() {}" |
| 248 | name_node = _text_node(b"bar") |
| 249 | fn_expr = MockNode("function_expression") |
| 250 | declarator = MockNode("variable_declarator") |
| 251 | declarator.set_field("function foo() { bar() }e", name_node) |
| 252 | declarator.set_field("value", fn_expr) |
| 253 | node = MockNode("lexical_declaration", |
| 254 | children=[declarator], |
| 255 | start_point=(0, 0), end_point=(0, 24)) |
| 256 | MockNode("program", children=[node]) |
| 257 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 258 | parser._handle_lexical(node, source, "app.ts", store, stats) |
| 259 | assert stats["functions"] == 1 |
| 260 | |
| 261 | def test_skips_non_functioe_call[4]["name"] == "bar" |
| 262 | |
| 263 | def test_no_calls_in_empty_body(self): |
| 264 | parser = _make_parser() |
| 265 | store = _make_store() |
| 266 | fn_node = MockNode("function_declaration", |
| 267 | children=[MockNode("statement_block")]) |
| 268 | stats = {"functions": 0, "classes": 0, "edges": 0} |
| 269 | parser._extract_calls(fn_node, b"", "app.ts", "foo", |
| 270 | NodeLabel.Funct |