From 198f34f562be7ce5d1f9268317b1cfa430fc91d9 Mon Sep 17 00:00:00 2001 From: "Tobias J. Endres" Date: Tue, 25 Aug 2026 00:32:50 +0200 Subject: [PATCH] feat(lang_parser): add Kotlin language support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Query-based adapter per the 'no self-maintained lists' architecture: - queries/kotlin/tags.scm: vendored verbatim from fwcd/tree-sitter-kotlin @ 1852ea17 (community-maintained definitions and call references) - queries/kotlin/imports.scm: closes the one known upstream gap (import captures + unnamed companion objects); candidate for upstreaming into fwcd - kotlin_parser.py: pure query adapter — no declaration regexes, no hand-maintained node-type lists. Declares a capture CONTRACT that is verified at load time; missing captures fail loudly instead of silently producing an empty graph. - registry.py, tree_sitter_backend.py: wiring + grammar candidate - pyproject.toml: fwcd grammar pinned by commit until wheels are published Signed-off-by: Tobias J. Endres --- CoderMind/pyproject.toml | 4 + .../scripts/lang_parser/config/__init__.py | 2 + .../scripts/lang_parser/config/kotlin.py | 26 ++ .../scripts/lang_parser/kotlin_parser.py | 361 ++++++++++++++++++ .../lang_parser/queries/kotlin/imports.scm | 20 + .../lang_parser/queries/kotlin/tags.scm | 43 +++ CoderMind/scripts/lang_parser/registry.py | 16 +- .../lang_parser/tree_sitter_backend.py | 1 + 8 files changed, 472 insertions(+), 1 deletion(-) create mode 100644 CoderMind/scripts/lang_parser/config/kotlin.py create mode 100644 CoderMind/scripts/lang_parser/kotlin_parser.py create mode 100644 CoderMind/scripts/lang_parser/queries/kotlin/imports.scm create mode 100644 CoderMind/scripts/lang_parser/queries/kotlin/tags.scm diff --git a/CoderMind/pyproject.toml b/CoderMind/pyproject.toml index f73a08c..18880a6 100644 --- a/CoderMind/pyproject.toml +++ b/CoderMind/pyproject.toml @@ -24,6 +24,10 @@ dependencies = [ "tree-sitter-c>=0.24.2", "tree-sitter-cpp>=0.23.4", "tree-sitter-rust>=0.24.2", + # Kotlin: the fwcd grammar is required because lang_parser/queries/kotlin + # is written against its node types (import_header, companion_object). + # Pinned until fwcd publishes PyPI wheels. + "tree-sitter-kotlin @ git+https://github.com/fwcd/tree-sitter-kotlin.git@1852ea17b7f60fb3f9d84e0b1555d56b46b39fb1", "networkx", "rank_bm25", "rapidfuzz", diff --git a/CoderMind/scripts/lang_parser/config/__init__.py b/CoderMind/scripts/lang_parser/config/__init__.py index a4a77ef..3aff7c6 100644 --- a/CoderMind/scripts/lang_parser/config/__init__.py +++ b/CoderMind/scripts/lang_parser/config/__init__.py @@ -2,6 +2,7 @@ from .cpp import CPP_CONFIG from .go import GO_CONFIG from .javascript import JAVASCRIPT_CONFIG +from .kotlin import KOTLIN_CONFIG from .python import PYTHON_CONFIG from .rust import RUST_CONFIG from .typescript import TYPESCRIPT_CONFIG @@ -11,6 +12,7 @@ "CPP_CONFIG", "GO_CONFIG", "JAVASCRIPT_CONFIG", + "KOTLIN_CONFIG", "PYTHON_CONFIG", "RUST_CONFIG", "TYPESCRIPT_CONFIG", diff --git a/CoderMind/scripts/lang_parser/config/kotlin.py b/CoderMind/scripts/lang_parser/config/kotlin.py new file mode 100644 index 0000000..47d3225 --- /dev/null +++ b/CoderMind/scripts/lang_parser/config/kotlin.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from ..models import LanguageConfig + + +KOTLIN_CONFIG = LanguageConfig( + name="kotlin", + display_name="Kotlin", + extensions=(".kt", ".kts"), + markdown_fence="kotlin", + source_globs=("*.kt", "**/*.kt", "*.kts", "**/*.kts"), + test_globs=( + "**/src/test/**/*.kt", + "**/*Test.kt", + "**/*Tests.kt", + "**/test/**/*.kt", + ), + tree_sitter_language="kotlin", + class_node_types=("class_declaration", "object_declaration"), + function_node_types=("function_declaration",), + method_node_types=("function_declaration",), + import_node_types=("import", "import_header"), + module_path_style="go", + dependency_files=("build.gradle.kts", "build.gradle", "settings.gradle.kts", "pom.xml"), + entrypoint_candidates=("src/main/kotlin/Main.kt",), +) diff --git a/CoderMind/scripts/lang_parser/kotlin_parser.py b/CoderMind/scripts/lang_parser/kotlin_parser.py new file mode 100644 index 0000000..973d110 --- /dev/null +++ b/CoderMind/scripts/lang_parser/kotlin_parser.py @@ -0,0 +1,361 @@ +"""Kotlin language parser — query-based adapter (architecture v2). + +All Kotlin syntax knowledge lives in declarative tree-sitter query files: + +* ``queries/kotlin/tags.scm`` — vendored verbatim from + fwcd/tree-sitter-kotlin @ 1852ea17b7f60fb3f9d84e0b1555d56b46b39fb1 + (definitions and call references, community-maintained upstream) +* ``queries/kotlin/imports.scm`` — cmind extension closing the one known + upstream gap (import captures + unnamed companion objects); candidate + for upstreaming. + +This adapter contains no node-type names and no declaration regexes. It +knows only capture names, declared in ``CONTRACT``. At load time every +contract capture is verified against the compiled queries; a missing +capture fails loudly instead of silently producing an empty graph. +""" + +from __future__ import annotations + +from pathlib import Path + +from .base import BaseLanguageParser +from .config.kotlin import KOTLIN_CONFIG +from .extractors.fallback import dependency_from_import, delimiter_syntax_error, make_unit +from .models import LPDependency, LPFileResult +from .tree_sitter_backend import TreeSitterBackend + +_QUERIES_DIR = Path(__file__).parent / "queries" / "kotlin" + +# The capture contract: the ONLY syntax knowledge this adapter relies on. +# Derived from the LPFileResult contract (classes, functions/methods, +# type aliases, imports, invoke edges). Parent relationships are derived +# structurally from capture-node ancestry, not from extra captures. +CONTRACT: dict[str, str] = { + "class": "definition.class", + "function": "definition.function", + "type_alias": "definition.type", + "import": "definition.import", + "invoke": "reference.call", +} + +# Fallback name for unnamed companion objects (captured via imports.scm). +_COMPANION_FALLBACK = "companion" + + +class KotlinParser(BaseLanguageParser): + language = "kotlin" + + def __init__(self) -> None: + self.backend = TreeSitterBackend(KOTLIN_CONFIG.tree_sitter_language) + self._compiled_queries: dict[str, object] | None = None + + # ------------------------------------------------------------------ + # Public interface + # ------------------------------------------------------------------ + + def parse_file(self, path: str, source: str) -> LPFileResult: + parsed = self._parse(source) + if parsed is None: + language = KOTLIN_CONFIG.tree_sitter_language or "kotlin" + raise RuntimeError( + f"tree-sitter grammar for {language} unavailable: " + f"{self.backend.load_error}" + ) + tree_root = parsed.tree.root_node + matches = self._collect_matches(tree_root) + + lines = source.splitlines() + units: list[LPCodeUnit] = [] + dependencies: list[LPDependency] = [] + class_nodes: list[object] = [] + + # --- classes / objects / companions --------------------------- + # Class captures live in BOTH query files (tags.scm for named + # declarations, imports.scm for unnamed companions), so iterate both. + # Document order guarantees outer classes register before nested ones. + for _pat, m in [*matches["tags"], *matches["imports"]]: + if CONTRACT["class"] not in m: + continue + node = m[CONTRACT["class"]][0] + name = self._match_name(m) or _COMPANION_FALLBACK + parent = self._enclosing_class_name(node, class_nodes) + units.append( + make_unit( + name=name, + unit_type="class", + file_path=path, + parent=parent, + lines=lines, + line_start=node.start_point[0] + 1, + line_end=node.end_point[0] + 1, + language=self.language, + node_type=node.type, + ) + ) + class_nodes.append(node) + + # --- functions / methods -------------------------------------- + for _pat, m in matches["tags"]: + if CONTRACT["function"] not in m: + continue + node = m[CONTRACT["function"]][0] + name = self._match_name(m) + parent = self._enclosing_class_name(node, class_nodes) + units.append( + make_unit( + name=name, + unit_type="method" if parent else "function", + file_path=path, + parent=parent, + lines=lines, + line_start=node.start_point[0] + 1, + line_end=node.end_point[0] + 1, + language=self.language, + node_type=node.type, + ) + ) + + # --- type aliases --------------------------------------------- + for _pat, m in matches["tags"]: + if CONTRACT["type_alias"] not in m: + continue + node = m[CONTRACT["type_alias"]][0] + units.append( + make_unit( + name=self._match_name(m), + unit_type="typealias", + file_path=path, + parent=None, + lines=lines, + line_start=node.start_point[0] + 1, + line_end=node.end_point[0] + 1, + language=self.language, + node_type=node.type, + ) + ) + + # --- imports ---------------------------------------------------- + alias_by_node: dict[int, str] = {} + for _pat, m in matches["imports"]: + if CONTRACT["import"] not in m or "import.path" not in m: + continue + node = m[CONTRACT["import"]][0] + import_path = m["import.path"][0].text.decode() + if "import.alias" in m: + alias_by_node[node.id] = m["import.alias"][0].text.decode() + + for _pat, m in matches["imports"]: + if CONTRACT["import"] not in m or "import.path" not in m: + continue + node = m[CONTRACT["import"]][0] + import_path = m["import.path"][0].text.decode() + alias = alias_by_node.get(node.id) + qualifier = alias or import_path.rsplit(".", 1)[-1] + units.append( + make_unit( + name=import_path, + unit_type="import", + file_path=path, + parent=None, + lines=lines, + line_start=node.start_point[0] + 1, + line_end=node.end_point[0] + 1, + language=self.language, + node_type=node.type, + extra={ + "module": import_path, + "import_path": import_path, + "alias": alias, + "qualifier": qualifier, + }, + ) + ) + dep = dependency_from_import( + path=path, + module=import_path, + symbol=qualifier, + line=node.start_point[0] + 1, + language=self.language, + import_kind="kotlin_import", + ) + dep.extra.update({"alias": alias, "qualifier": qualifier}) + dependencies.append(dep) + + # --- invoke edges ------------------------------------------------ + dependencies.extend(self._extract_invokes(path, tree_root, matches, units)) + + return LPFileResult( + file_path=path, + language=self.language, + units=units, + dependencies=dependencies, + syntax_error=self._syntax_error(source), + ) + + def validate_syntax(self, path: str, source: str) -> tuple[bool, str | None]: + syntax_error = self._syntax_error(source) + return (syntax_error is None, syntax_error) + + # ------------------------------------------------------------------ + # Query loading + contract enforcement + # ------------------------------------------------------------------ + + def _load_queries(self) -> dict[str, object]: + if self._compiled_queries is not None: + return self._compiled_queries + language = self.backend.get_language() + if language is None: + raise RuntimeError( + "tree-sitter-kotlin grammar unavailable: " + f"{self.backend.load_error}" + ) + from tree_sitter import Query # local import: runtime >= 0.24 API + + compiled: dict[str, object] = {} + available: set[str] = set() + for name in ("tags", "imports"): + source = (_QUERIES_DIR / f"{name}.scm").read_text(encoding="utf-8") + query = Query(language, source) + compiled[name] = query + available.update( + query.capture_name(i) for i in range(query.capture_count) + ) + missing = sorted( + cap for cap in CONTRACT.values() if cap not in available + ) + if missing: + raise RuntimeError( + "Kotlin query files do not satisfy the capture contract. " + f"Missing captures: {missing}. " + f"The vendored .scm files under {_QUERIES_DIR} are out of sync " + "with the installed tree-sitter-kotlin grammar — update them " + "(or the grammar pin) before use." + ) + self._compiled_queries = compiled + return compiled + + def _collect_matches(self, tree_root) -> dict[str, list[dict]]: + from tree_sitter import QueryCursor + + queries = self._load_queries() + return { + "tags": QueryCursor(queries["tags"]).matches(tree_root), + "imports": QueryCursor(queries["imports"]).matches(tree_root), + } + + # ------------------------------------------------------------------ + # Structural helpers (capture-driven, no node-type knowledge) + # ------------------------------------------------------------------ + + @staticmethod + def _match_name(match: dict) -> str | None: + nodes = match.get("name") + if not nodes: + return None + return nodes[0].text.decode().strip("`") + + def _enclosing_class_name(self, node, class_nodes: list) -> str | None: + """Nearest captured ancestor that is a class-like definition.""" + class_ids = {id(c) for c in class_nodes} + ancestor = node.parent + while ancestor is not None: + if id(ancestor) in class_ids: + name_child = next( + ( + c + for c in ancestor.children + if c.type in ("type_identifier", "simple_identifier") + ), + None, + ) + if name_child is not None: + return name_child.text.decode().strip("`") + return _COMPANION_FALLBACK + ancestor = ancestor.parent + return None + + def _source_reference_for_line(self, path: str, units: list, line: int) -> str: + innermost = None + for unit in units: + if unit.unit_type == "import": + continue + if unit.line_start is None or unit.line_end is None: + continue + if unit.line_start <= line <= unit.line_end: + if innermost is None or ( + (unit.line_end - unit.line_start) + <= (innermost.line_end - innermost.line_start) + ): + innermost = unit + if innermost is not None and innermost.name: + return f"{path}::{innermost.name}" + return path + + def _extract_invokes( + self, + path: str, + tree_root, + matches: dict[str, list[dict]], + units: list, + ) -> list[LPDependency]: + import_aliases: dict[str, str] = {} + for unit in units: + if unit.unit_type != "import": + continue + qualifier = (unit.extra or {}).get("qualifier") + module = (unit.extra or {}).get("import_path") + if qualifier and module: + first_segment = qualifier.split(".")[0] + import_aliases.setdefault(first_segment, module) + + dependencies: list[LPDependency] = [] + seen: set[tuple[str, int, str]] = set() + for _pat, m in matches["tags"]: + if CONTRACT["invoke"] not in m or "name" not in m: + continue + node = m[CONTRACT["invoke"]][0] + name = m["name"][0].text.decode().strip("`") + line = node.start_point[0] + 1 + key = (name, line, node.type) + if key in seen: + continue + seen.add(key) + source_ref = self._source_reference_for_line(path, units, line) + destination = import_aliases.get(name.split(".")[0]) + dependencies.append( + LPDependency( + src=source_ref, + dst=destination, + relation="invokes", + symbol=name, + line=line, + confidence="high", + extra={ + "language": self.language, + "call_kind": "query_capture", + "node_type": node.type, + }, + ) + ) + return dependencies + + # ------------------------------------------------------------------ + # Syntax validation + # ------------------------------------------------------------------ + + def _syntax_error(self, source: str) -> str | None: + parsed = self.backend.parse(source) + if parsed is not None: + if self._tree_has_visible_error(parsed.tree.root_node): + return "tree-sitter reported syntax errors" + return None + return delimiter_syntax_error(source) + + def _tree_has_visible_error(self, node) -> bool: + if getattr(node, "is_error", False) or getattr(node, "is_missing", False): + return True + return any(self._tree_has_visible_error(child) for child in node.children) + + def _parse(self, source: str): + return self.backend.parse(source) diff --git a/CoderMind/scripts/lang_parser/queries/kotlin/imports.scm b/CoderMind/scripts/lang_parser/queries/kotlin/imports.scm new file mode 100644 index 0000000..a5a40ca --- /dev/null +++ b/CoderMind/scripts/lang_parser/queries/kotlin/imports.scm @@ -0,0 +1,20 @@ +; cmind-imports.scm — import captures for Kotlin +; +; This file closes the one known gap in fwcd's tags.scm for cmind's needs: +; imports (tags.scm upstream has no import capture at all). It is a +; candidate for upstreaming into fwcd/tree-sitter-kotlin; if accepted, +; this file can be deleted and the capture inherited from upstream. +; +; Grammar: fwcd/tree-sitter-kotlin @ 1852ea17b7f60fb3f9d84e0b1555d56b46b39fb1 + +(import_header + (identifier) @import.path) @definition.import + +(import_header + (import_alias + (type_identifier) @import.alias)) @definition.import + +; Unnamed companion objects: tags.scm only captures NAMED companion_object +; nodes. cmind's contract requires every companion to appear as a class +; unit, so capture unnamed ones too (adapter falls back to name "companion"). +(companion_object) @definition.class diff --git a/CoderMind/scripts/lang_parser/queries/kotlin/tags.scm b/CoderMind/scripts/lang_parser/queries/kotlin/tags.scm new file mode 100644 index 0000000..fa12bf6 --- /dev/null +++ b/CoderMind/scripts/lang_parser/queries/kotlin/tags.scm @@ -0,0 +1,43 @@ +; Classes +(class_declaration + (type_identifier) @name) @definition.class + +; Objects +(object_declaration + (type_identifier) @name) @definition.class + +; Functions (top-level and member) +(function_declaration + (simple_identifier) @name) @definition.function + +; Properties +(property_declaration + (variable_declaration + (simple_identifier) @name)) @definition.constant + +; Enum entries +(enum_entry + (simple_identifier) @name) @definition.constant + +; Type aliases +(type_alias + (type_identifier) @name) @definition.type + +; Companion objects (only named ones) +(companion_object + (type_identifier) @name) @definition.class + +; Function calls +(call_expression + (simple_identifier) @name) @reference.call + +; Method calls via navigation +(call_expression + (navigation_expression + (navigation_suffix + (simple_identifier) @name))) @reference.call + +; Constructor invocations (class references) +(constructor_invocation + (user_type + (type_identifier) @name)) @reference.class diff --git a/CoderMind/scripts/lang_parser/registry.py b/CoderMind/scripts/lang_parser/registry.py index a3e5092..e679d68 100644 --- a/CoderMind/scripts/lang_parser/registry.py +++ b/CoderMind/scripts/lang_parser/registry.py @@ -4,7 +4,16 @@ from pathlib import PurePosixPath from .base import BaseLanguageParser -from .config import C_CONFIG, CPP_CONFIG, GO_CONFIG, JAVASCRIPT_CONFIG, PYTHON_CONFIG, RUST_CONFIG, TYPESCRIPT_CONFIG +from .config import ( + C_CONFIG, + CPP_CONFIG, + GO_CONFIG, + JAVASCRIPT_CONFIG, + KOTLIN_CONFIG, + PYTHON_CONFIG, + RUST_CONFIG, + TYPESCRIPT_CONFIG, +) from .models import LanguageConfig, LPFileResult, NotSupported _CONFIGS: dict[str, LanguageConfig] = { @@ -15,6 +24,7 @@ C_CONFIG.name: C_CONFIG, CPP_CONFIG.name: CPP_CONFIG, RUST_CONFIG.name: RUST_CONFIG, + KOTLIN_CONFIG.name: KOTLIN_CONFIG, } _PARSERS: dict[str, BaseLanguageParser] = {} @@ -98,6 +108,10 @@ def get_parser(language: str) -> BaseLanguageParser: from .rust_parser import RustParser _PARSERS[key] = RustParser() + elif key == "kotlin": + from .kotlin_parser import KotlinParser + + _PARSERS[key] = KotlinParser() else: raise NotSupported(f"Unsupported language: {language}") return _PARSERS[key] diff --git a/CoderMind/scripts/lang_parser/tree_sitter_backend.py b/CoderMind/scripts/lang_parser/tree_sitter_backend.py index 42e83d6..c3db571 100644 --- a/CoderMind/scripts/lang_parser/tree_sitter_backend.py +++ b/CoderMind/scripts/lang_parser/tree_sitter_backend.py @@ -13,6 +13,7 @@ "c": (("tree_sitter_c", "language"),), "cpp": (("tree_sitter_cpp", "language"),), "rust": (("tree_sitter_rust", "language"),), + "kotlin": (("tree_sitter_kotlin", "language"),), }