Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
5 changes: 5 additions & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions build.zig.zon
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
19 changes: 19 additions & 0 deletions src/queries/kotlin.scm
Original file line number Diff line number Diff line change
@@ -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
]
127 changes: 119 additions & 8 deletions src/symbols.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
}

Expand All @@ -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() };
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}
20 changes: 20 additions & 0 deletions test/fixtures/kotlin/declarations.kt
Original file line number Diff line number Diff line change
@@ -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
}
45 changes: 45 additions & 0 deletions test/integration/lint_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions tests.zig
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading