From c1be86e42728e40ca5d1060efdaab6ecaf8ef4a4 Mon Sep 17 00:00:00 2001 From: davidmoshal <650245+davidmoshal@users.noreply.github.com> Date: Sat, 26 Sep 2026 20:21:29 -0700 Subject: [PATCH] feat: add Kotlin syntax-aware file and symbol anchors Include property accessors in symbol fingerprints and extraction. Add Kotlin fixtures, unit coverage and command-line regressions; wire the symbols tests into the test runner. Agent-Session: codex:01a0dfc2-f38e-7373-9a5d-4ddae6a60ae4 --- README.md | 4 +- build.zig | 5 ++ build.zig.zon | 5 ++ src/queries/kotlin.scm | 19 ++++ src/symbols.zig | 127 +++++++++++++++++++++++++-- test/fixtures/kotlin/declarations.kt | 20 +++++ test/integration/lint_test.zig | 45 ++++++++++ tests.zig | 1 + 8 files changed, 217 insertions(+), 9 deletions(-) create mode 100644 src/queries/kotlin.scm create mode 100644 test/fixtures/kotlin/declarations.kt diff --git a/README.md b/README.md index ccab8df..3aa1d47 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,9 @@ drift refs Reverse lookup — which docs reference a given file ## How staleness works -Each anchor's `sig` field records a fingerprint of the code at the time it was linked. `drift check` recomputes the fingerprint from the current file and compares. For supported languages (TypeScript, Python, Rust, Go, Zig, Java), comparison is syntax-aware — drift parses with tree-sitter and hashes a normalized AST fingerprint (node kinds + token text, no whitespace or position data). Reformatting won't trigger false positives. Symbol-level anchors (`#AuthConfig`) narrow this to just that declaration's subtree. Unsupported languages fall back to raw content comparison. +Each anchor's `sig` field records a fingerprint of the code at the time it was linked. `drift check` recomputes the fingerprint from the current file and compares. For supported languages (TypeScript, Python, Rust, Go, Zig, Java, Kotlin), comparison is syntax-aware — drift parses with tree-sitter and hashes a normalized AST fingerprint (node kinds + token text, no whitespace or position data). Reformatting that preserves the parsed syntax tree won't trigger false positives. Symbol-level anchors (`#AuthConfig`) narrow this to just that declaration's subtree. Unsupported languages fall back to raw content comparison. + +Kotlin support covers `.kt` and `.kts` files using `fwcd/tree-sitter-kotlin` 0.3.8. Symbol anchors support classes, interfaces, objects, named companion objects, functions, properties (including getters and setters), enum entries, and type aliases. Constructor parameter properties, destructuring names, and unnamed companion objects are not separate symbol targets; anchor the enclosing declaration or file instead. Symbol names are unqualified and select the first match, as for other languages. Parser limitations can still affect fingerprints; for example, moving comments around accessors can change the parsed tree. No VCS history is needed for staleness detection — `drift check` works entirely from the stored signature and current file content. diff --git a/build.zig b/build.zig index 74405ba..67cba7a 100644 --- a/build.zig +++ b/build.zig @@ -231,6 +231,11 @@ fn linkGrammars(b: *std.Build, module: *std.Build.Module) void { .parser_path = "src/parser.c", .scanner_path = null, }, + .{ + .dep_name = "tree_sitter_kotlin", + .parser_path = "src/parser.c", + .scanner_path = "src/scanner.c", + }, .{ .dep_name = "tree_sitter_markdown", .parser_path = "tree-sitter-markdown/src/parser.c", diff --git a/build.zig.zon b/build.zig.zon index f38a302..42fda55 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -55,6 +55,11 @@ .hash = "N-V-__8AAOphUwCl_jXY5BvJ_I-kB6cZuE48ZpMar9Gq2SiD", .lazy = true, }, + .tree_sitter_kotlin = .{ + .url = "git+https://github.com/fwcd/tree-sitter-kotlin?ref=0.3.8#e1a2d5ad1f61f5740677183cd4125bb071cd2f30", + .hash = "N-V-__8AAJDidQH49dvfObVZZawejaO4XMsuHJtV-esBrGJc", + .lazy = true, + }, .minish = .{ .url = "https://github.com/CogitatorTech/minish/archive/v0.3.0.tar.gz", .hash = "minish-0.3.0-SQtSTYI3AgCxWdWbKxS_lvmfbp0wJk29ZW5C9CozJaxm", diff --git a/src/queries/kotlin.scm b/src/queries/kotlin.scm new file mode 100644 index 0000000..ee632e4 --- /dev/null +++ b/src/queries/kotlin.scm @@ -0,0 +1,19 @@ +; Symbol declarations in fwcd/tree-sitter-kotlin. +; class_declaration includes classes, interfaces, data classes and enum classes. +[ + (class_declaration + (type_identifier) @name) @definition + (object_declaration + (type_identifier) @name) @definition + (companion_object + (type_identifier) @name) @definition + (function_declaration + (simple_identifier) @name) @definition + (property_declaration + (variable_declaration + (simple_identifier) @name)) @definition + (enum_entry + (simple_identifier) @name) @definition + (type_alias + (type_identifier) @name) @definition +] diff --git a/src/symbols.zig b/src/symbols.zig index ff77db5..abb413c 100644 --- a/src/symbols.zig +++ b/src/symbols.zig @@ -12,6 +12,7 @@ extern fn tree_sitter_rust() callconv(.c) *const ts.Language; extern fn tree_sitter_go() callconv(.c) *const ts.Language; extern fn tree_sitter_zig() callconv(.c) *const ts.Language; extern fn tree_sitter_java() callconv(.c) *const ts.Language; +extern fn tree_sitter_kotlin() callconv(.c) *const ts.Language; pub const LanguageQuery = struct { language: *const ts.Language, @@ -40,6 +41,9 @@ pub fn languageForExtension(ext: []const u8) ?LanguageQuery { if (std.mem.eql(u8, ext, ".java")) { return .{ .language = tree_sitter_java(), .query_source = @embedFile("queries/java.scm") }; } + if (std.mem.eql(u8, ext, ".kt") or std.mem.eql(u8, ext, ".kts")) { + return .{ .language = tree_sitter_kotlin(), .query_source = @embedFile("queries/kotlin.scm") }; + } return null; } @@ -104,6 +108,45 @@ fn fingerprintNodeSyntax(source: []const u8, node: ts.Node) u64 { return hasher.final(); } +// fwcd/tree-sitter-kotlin can put accessors on following lines beside a +// property_declaration rather than inside it. Include those accessors in the +// property's fingerprint and extracted range, but not the next declaration. +fn kotlinPropertyEnd(node: ts.Node) ts.Node { + var last = node; + var next = node.nextNamedSibling(); + while (next) |sibling| : (next = sibling.nextNamedSibling()) { + const kind = sibling.kind(); + if (std.mem.eql(u8, kind, "getter") or std.mem.eql(u8, kind, "setter")) { + last = sibling; + } else if (!std.mem.eql(u8, kind, "line_comment") and !std.mem.eql(u8, kind, "multiline_comment")) { + break; + } + } + return last; +} + +fn fingerprintKotlinProperty(source: []const u8, node: ts.Node) u64 { + var hasher = std.hash.XxHash3.init(0); + hashTaggedBytes(&hasher, '(', node.kind()); + + var child_index: u32 = 0; + while (child_index < node.childCount()) : (child_index += 1) { + hashNormalizedNodeSyntax(&hasher, source, node.child(child_index).?); + } + + // Fold sibling accessors into the same property node as inline accessors, + // so moving an accessor to its own line does not change the fingerprint. + const end = kotlinPropertyEnd(node).endByte(); + var next = node.nextNamedSibling(); + while (next) |sibling| : (next = sibling.nextNamedSibling()) { + if (sibling.endByte() > end) break; + hashNormalizedNodeSyntax(&hasher, source, sibling); + } + + hashTaggedBytes(&hasher, ')', node.kind()); + return hasher.final(); +} + /// Compute a normalized syntax fingerprint for an entire file. /// /// The fingerprint is based on the file's tree-sitter syntax tree, not its raw @@ -143,6 +186,9 @@ pub fn fingerprintSymbolSyntax( defer query.destroy(); const definition_node = findMatchingDefinitionNode(query, tree.rootNode(), source, target_symbol) orelse return null; + if (lang_query.language == tree_sitter_kotlin() and std.mem.eql(u8, definition_node.kind(), "property_declaration")) { + return fingerprintKotlinProperty(source, definition_node); + } return fingerprintNodeSyntax(source, definition_node); } @@ -166,6 +212,9 @@ pub fn extractSymbolContent( defer query.destroy(); const definition_node = findMatchingDefinitionNode(query, tree.rootNode(), source, target_symbol) orelse return null; + if (lang_query.language == tree_sitter_kotlin() and std.mem.eql(u8, definition_node.kind(), "property_declaration")) { + return .{ definition_node.startByte(), kotlinPropertyEnd(definition_node).endByte() }; + } return .{ definition_node.startByte(), definition_node.endByte() }; } @@ -218,7 +267,7 @@ test "fingerprintFileSyntax works for zig source" { } test "symbol queries compile for every supported language" { - const extensions = [_][]const u8{ ".ts", ".py", ".rs", ".go", ".zig", ".java" }; + const extensions = [_][]const u8{ ".ts", ".py", ".rs", ".go", ".zig", ".java", ".kt", ".kts" }; for (extensions) |ext| { const lang_query = languageForExtension(ext) orelse return error.TestUnexpectedResult; var error_offset: u32 = 0; @@ -259,15 +308,77 @@ test "zig variable_declaration query matches only the declared name, not the ini try std.testing.expect(fingerprintSymbolSyntax(source, lang_query, "imported_name") == null); } -test "computeFingerprint matches for real zig files" { - const allocator = std.testing.allocator; +test "Kotlin queries resolve declaration families" { + const source = @embedFile("../test/fixtures/kotlin/declarations.kt"); + for ([_][]const u8{ "State", "IDLE", "Item", "Worker", "run", "Registry", "count", "Container", "Factory", "create", "Label", "limit", "twice" }) |name| { + try std.testing.expect(computeFingerprint(source, "sample.kt", name) != null); + } + try std.testing.expect(computeFingerprint(source, "sample.kt", "missing") == null); +} - const files = [_][]const u8{ "src/main.zig", "src/markdown.zig", "src/lockfile.zig", "src/vcs.zig" }; - for (files) |path| { - const content = std.fs.cwd().readFileAlloc(allocator, path, 10 * 1024 * 1024) catch continue; - defer allocator.free(content); +test "Kotlin fingerprints ignore formatting and isolate function changes" { + const initial = "fun twice(value: Int): Int { return value * 2 }\nfun other(): Int = 1\n"; + const formatted = "fun twice(\n value: Int\n): Int {\n return value * 2\n}\nfun other(): Int = 1\n"; + const reordered = "fun other(): Int = 99\nfun twice(value: Int): Int { return value * 2 }\n"; + const changed = "fun twice(value: Int): Int { return value * 3 }\nfun other(): Int = 1\n"; + for ([_][]const u8{ "sample.kt", "sample.kts" }) |path| { + const original = computeFingerprint(initial, path, "twice") orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(original, computeFingerprint(formatted, path, "twice").?); + try std.testing.expectEqual(original, computeFingerprint(reordered, path, "twice").?); + try std.testing.expect(original != computeFingerprint(changed, path, "twice").?); + try std.testing.expectEqual(computeFingerprint(initial, path, null).?, computeFingerprint(formatted, path, null).?); + try std.testing.expect(computeFingerprint(initial, path, null).? != computeFingerprint(changed, path, null).?); + } +} - const fp = computeFingerprint(content, path, null); +test "Kotlin scripts support shebang and executable statements" { + const source = "#!/usr/bin/env kotlin\nfun twice(value: Int): Int = value * 2\nprintln(twice(3))\n"; + try std.testing.expect(computeFingerprint(source, "sample.kts", null) != null); + try std.testing.expect(computeFingerprint(source, "sample.kts", "twice") != null); +} + +test "Kotlin property getter and setter changes alter the property fingerprint" { + const initial = "class Counter {\nvar amount: Int = 0\nget() = field\nset(value) { field = value + 1 }\n}\n"; + const changed_getter = "class Counter {\nvar amount: Int = 0\nget() = field + 2\nset(value) { field = value + 1 }\n}\n"; + const changed_setter = "class Counter {\nvar amount: Int = 0\nget() = field\nset(value) { field = value + 3 }\n}\n"; + const original = computeFingerprint(initial, "sample.kt", "amount") orelse return error.TestUnexpectedResult; + try std.testing.expect(original != computeFingerprint(changed_getter, "sample.kt", "amount").?); + try std.testing.expect(original != computeFingerprint(changed_setter, "sample.kt", "amount").?); +} + +test "Kotlin property extraction includes accessors but not the following declaration" { + const property = "var amount: Int = 0\n// getter\nget() = field\n/* setter */\nset(value) { field = value + 1 }"; + const source = property ++ "\n// unrelated\nfun other(): Int = 99\n"; + const lang_query = languageForExtension(".kt").?; + const range = extractSymbolContent(source, lang_query, "amount") orelse return error.TestUnexpectedResult; + try std.testing.expectEqualStrings(property, source[range[0]..range[1]]); +} + +test "Kotlin property accessor layout and following declarations do not alter its fingerprint" { + const initial = "var amount: Int = 0 get() = field\nset(value) { field = value }\nfun other(): Int = 1\n"; + const formatted = "var amount: Int = 0\nget() = field\nset(value) { field = value }\nfun other(): Int = 99\n"; + try std.testing.expectEqual(computeFingerprint(initial, "sample.kt", "amount").?, computeFingerprint(formatted, "sample.kt", "amount").?); +} + +test "Kotlin accessor visibility changes alter the property fingerprint" { + const initial = "class Counter {\nvar amount: Int = 0\nprivate set\n}\n"; + const changed = "class Counter {\nvar amount: Int = 0\ninternal set\n}\n"; + try std.testing.expect(computeFingerprint(initial, "sample.kt", "amount").? != computeFingerprint(changed, "sample.kt", "amount").?); +} + +test "Kotlin property does not include the next property's accessors" { + const property = "val first: Int = 1"; + const initial = property ++ "\n// second property\nvar second: Int = 0\nset(value) { field = value }\n"; + const changed = property ++ "\n// second property\nvar second: Int = 0\nset(value) { field = value + 1 }\n"; + const lang_query = languageForExtension(".kt").?; + const range = extractSymbolContent(initial, lang_query, "first") orelse return error.TestUnexpectedResult; + try std.testing.expectEqualStrings(property, initial[range[0]..range[1]]); + try std.testing.expectEqual(computeFingerprint(initial, "sample.kt", "first").?, computeFingerprint(changed, "sample.kt", "first").?); +} + +test "computeFingerprint matches for real zig files" { + inline for (.{ "main.zig", "markdown.zig", "lockfile.zig", "vcs.zig" }) |path| { + const fp = computeFingerprint(@embedFile(path), path, null); try std.testing.expect(fp != null); } } diff --git a/test/fixtures/kotlin/declarations.kt b/test/fixtures/kotlin/declarations.kt new file mode 100644 index 0000000..43b215c --- /dev/null +++ b/test/fixtures/kotlin/declarations.kt @@ -0,0 +1,20 @@ +package example + +enum class State { IDLE, RUNNING } +data class Item(val quantity: Int) +interface Worker { + fun run(): Int +} +object Registry { + val count: Int = 1 +} +class Container { + companion object Factory { + fun create(): Container = Container() + } +} +typealias Label = String +val limit: Int = 10 +fun twice(value: Int): Int { + return value * 2 +} diff --git a/test/integration/lint_test.zig b/test/integration/lint_test.zig index 0459fce..c975141 100644 --- a/test/integration/lint_test.zig +++ b/test/integration/lint_test.zig @@ -65,6 +65,51 @@ fn expectFormattingOnlySymbolChangeIsFresh( try helpers.expectNotContains(check_result.stdout, "STALE"); } +test "Kotlin symbol anchors remain fresh for formatting and stale for edits or removal" { + const allocator = std.testing.allocator; + var repo = try helpers.TempRepo.init(allocator); + defer repo.cleanup(); + + try repo.writeFile("src/example.kt", "fun twice(value: Int): Int { return value * 2 }\n"); + try repo.writeFile("docs/doc.md", "# Kotlin function\n"); + try linkDoc(&repo, "docs/doc.md", "src/example.kt#twice"); + + const fresh = try repo.runDrift(&.{"check"}); + defer fresh.deinit(allocator); + try helpers.expectExitCode(fresh.term, 0); + + try repo.writeFile("src/example.kt", "fun other(): Int = 1\nfun twice(\n value: Int\n): Int {\n return value * 2\n}\n"); + const formatted = try repo.runDrift(&.{"check"}); + defer formatted.deinit(allocator); + try helpers.expectExitCode(formatted.term, 0); + + try repo.writeFile("src/example.kt", "fun twice(value: Int): Int { return value * 3 }\n"); + const changed = try repo.runDrift(&.{"check"}); + defer changed.deinit(allocator); + try helpers.expectExitCode(changed.term, 1); + try helpers.expectContains(changed.stdout, "changed after doc"); + + try repo.writeFile("src/example.kt", "fun other(): Int = 1\n"); + const removed = try repo.runDrift(&.{"check"}); + defer removed.deinit(allocator); + try helpers.expectExitCode(removed.term, 1); + try helpers.expectContains(removed.stdout, "symbol not found"); +} + +test "Kotlin property anchors detect accessor edits" { + const allocator = std.testing.allocator; + var repo = try helpers.TempRepo.init(allocator); + defer repo.cleanup(); + try repo.writeFile("src/example.kt", "class Counter {\nvar amount: Int = 0\nget() = field\nset(value) { field = value }\n}\n"); + try repo.writeFile("docs/doc.md", "# Kotlin property\n"); + try linkDoc(&repo, "docs/doc.md", "src/example.kt#amount"); + try repo.writeFile("src/example.kt", "class Counter {\nvar amount: Int = 0\nget() = field\nset(value) { field = value + 1 }\n}\n"); + const changed = try repo.runDrift(&.{"check"}); + defer changed.deinit(allocator); + try helpers.expectExitCode(changed.term, 1); + try helpers.expectContains(changed.stdout, "changed after doc"); +} + test "lint reports ok when no lockfile exists" { const allocator = std.testing.allocator; var repo = try helpers.TempRepo.init(allocator); diff --git a/tests.zig b/tests.zig index 73cb030..978ddd8 100644 --- a/tests.zig +++ b/tests.zig @@ -1,6 +1,7 @@ test { _ = @import("src/main.zig"); _ = @import("src/lockfile.zig"); + _ = @import("src/symbols.zig"); _ = @import("test/payload_validate_test.zig"); // Integration tests