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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Fixes

- Indexing a project that contains only config-style files (YAML, Twig, or `.properties`) no longer misleadingly reports "No files found to index" — these files are tracked at the file level and are now counted as indexed. Thanks @luojiyin1987 (#357).
- Kotlin calls through a field receiver (`fooConverter.convert(...)`) now resolve to the class named by the file's `import`, even when two packages declare a class with the same name (`com.example.a.FooConverter` vs `com.example.b.FooConverter`) — previously the call landed on whichever class was found first, ignoring the import. Two gaps caused it: Kotlin class properties weren't being indexed at all (so the receiver's type was invisible), and Kotlin's import lines were skipped because the parser required a trailing `;` that Kotlin doesn't use. Both are fixed, and disambiguation now matches the class's package-qualified name rather than assuming the class lives in a file named after it (Kotlin commonly puts `FooConverter` in `Converters.kt`). The equivalent Java case already worked; this brings Kotlin to parity (#314).

## [0.9.7] - 2026-05-28

Expand Down
32 changes: 32 additions & 0 deletions __tests__/extraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1323,6 +1323,38 @@ fun util(): Int = 42
expect(util?.qualifiedName).toBe('com.example.foo::util');
});

it('extracts class properties with a typed field signature', () => {
// Kotlin `property_declaration` parses as variable_declaration →
// simple_identifier (+ user_type), not the `variable_declarator` shape
// Java/C# use, so before the fix class properties produced NO field node
// at all — which broke receiver-type inference for `field.method()` calls
// (#314).
const code = `
package com.example.web

class Handler {
private val fooConverter: FooConverter = FooConverter()
var count: Int = 0
private val nullable: Bar? = null
}
`;
const result = extractFromSource('Handler.kt', code);

const fooField = result.nodes.find((n) => n.kind === 'field' && n.name === 'fooConverter');
expect(fooField).toBeDefined();
// Signature must be "<Type> <name>" so inferJavaFieldReceiverType reads it.
expect(fooField?.signature).toBe('FooConverter fooConverter');
expect(fooField?.qualifiedName).toBe('com.example.web::Handler::fooConverter');

const countField = result.nodes.find((n) => n.kind === 'field' && n.name === 'count');
expect(countField?.signature).toBe('Int count');

// Kotlin nullable `Bar?` is normalized to `Bar` so it resolves like the
// non-null type.
const nullableField = result.nodes.find((n) => n.kind === 'field' && n.name === 'nullable');
expect(nullableField?.signature).toBe('Bar nullable');
});

it('handles a single-segment package', () => {
const code = `
package foo
Expand Down
58 changes: 58 additions & 0 deletions __tests__/resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -971,6 +971,64 @@ public class Handler {
);
});

it('Kotlin import disambiguates same-name classes in non-eponymous files (#314)', async () => {
// The Kotlin twist on #314: the class lives in a file NOT named after
// it (`Converters.kt`, the idiomatic Kotlin layout), so the file-path
// suffix heuristic (`com/example/b/FooConverter.kt`) can't match — only
// the namespace `qualifiedName` (`com.example.b::FooConverter`, from the
// package directive) disambiguates. Two prerequisites had to hold:
// (1) the `val fooConverter: FooConverter` property must extract as a
// field so the receiver type is known (Kotlin property extraction);
// (2) resolveMethodOnType must prefer the candidate whose qn matches the
// imported FQN, not the path suffix.
const aDir = path.join(tempDir, 'module-a/src/main/kotlin/com/example/a');
const bDir = path.join(tempDir, 'module-b/src/main/kotlin/com/example/b');
const webDir = path.join(tempDir, 'web/src/main/kotlin/com/example/web');
fs.mkdirSync(aDir, { recursive: true });
fs.mkdirSync(bDir, { recursive: true });
fs.mkdirSync(webDir, { recursive: true });

fs.writeFileSync(
path.join(aDir, 'Converters.kt'),
`package com.example.a
class FooConverter { fun convert(x: String): String { return "a:" + x } }
`
);
fs.writeFileSync(
path.join(bDir, 'Converters.kt'),
`package com.example.b
class FooConverter { fun convert(x: String): String { return "b:" + x } }
`
);
// Caller imports the module-b converter; module-a is lexically first in
// the candidate list, so a correct result proves the import wins.
fs.writeFileSync(
path.join(webDir, 'Handler.kt'),
`package com.example.web

import com.example.b.FooConverter

class Handler {
private val fooConverter: FooConverter = FooConverter()
fun use(): String { return fooConverter.convert("input") }
}
`
);

cg = await CodeGraph.init(tempDir, { index: true });

const use = cg
.getNodesByKind('method')
.find((n) => n.qualifiedName.endsWith('Handler::use'));
expect(use).toBeDefined();
const calls = cg.getOutgoingEdges(use!.id).filter((e) => e.kind === 'calls');
expect(calls.length).toBeGreaterThanOrEqual(1);

const target = cg.getNode(calls[0]!.target);
expect(target?.name).toBe('convert');
expect(target?.qualifiedName).toBe('com.example.b::FooConverter::convert');
});

it('C# extracts references from method/property/field types (#381)', async () => {
// Pre-#381, every C# project produced ZERO `references` edges:
// csharp.ts was missing returnField, and the type-leaf walker
Expand Down
36 changes: 36 additions & 0 deletions src/extraction/tree-sitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1053,6 +1053,42 @@ export class TreeSitterExtractor {
}
}

// Kotlin property_declaration: `private val x: Foo = ...` parses as
// variable_declaration → simple_identifier (name) + user_type (type).
// It reuses the `variable_declaration` wrapper C# uses but holds a bare
// `simple_identifier`, not a `variable_declarator`, so neither branch
// above matches and the identifier-only fallback below misses it too
// (Kotlin's grammar has no field names). Without this branch Kotlin class
// properties produce NO node, so receiver-type inference can't see the
// field's declared type and same-name cross-package calls mis-resolve (#314).
if (declarators.length === 0 && this.language === 'kotlin') {
const varDecl = node.namedChildren.find(c => c.type === 'variable_declaration');
const nameNode = varDecl?.namedChildren.find(c => c.type === 'simple_identifier');
if (varDecl && nameNode) {
const name = getNodeText(nameNode, this.source);
// The type sits as a sibling of the name inside variable_declaration.
// Emit a "<Type> <name>" signature (matching extractField's Java/C#
// shape) so inferJavaFieldReceiverType can read the declared type;
// strip Kotlin's nullable `?` so `Foo?` resolves like `Foo`.
const typeNode = varDecl.namedChildren.find(c => c.type !== 'simple_identifier');
const typeText = typeNode
? getNodeText(typeNode, this.source).replace(/\?$/, '').trim()
: undefined;
const signature = typeText ? `${typeText} ${name}` : name;
const fieldNode = this.createNode('field', name, node, {
docstring,
signature,
visibility,
isStatic,
});
if (fieldNode) {
this.extractDecoratorsFor(node, fieldNode.id);
this.extractTypeAnnotations(node, fieldNode.id);
}
return;
}
}

if (declarators.length > 0) {
// Get field type from the type child
// Java: type is a direct child of field_declaration
Expand Down
10 changes: 8 additions & 2 deletions src/resolution/import-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -733,8 +733,14 @@ function extractJavaImports(content: string): ImportMapping[] {
const stripped = content
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\/\/[^\n]*/g, '');
// `import [static] <fqn>[.*];`
const re = /^\s*import\s+(static\s+)?([\w.]+(?:\.\*)?)\s*;/gm;
// `import [static] <fqn>[.*][;]` — the trailing `;` is optional because
// Kotlin imports omit it (`import com.example.Foo`). Without this, Kotlin
// produced ZERO import mappings, so receiver-FQN disambiguation (the
// `importedFqn` passed to resolveMethodOnType) never fired for Kotlin and
// same-name classes fell back to lexical order (#314). A bare `import …` with
// a Kotlin `as Alias` suffix still captures the FQN (alias unhandled, as in
// the Java path). Java's mandatory `;` keeps matching unchanged.
const re = /^\s*import\s+(static\s+)?([\w.]+(?:\.\*)?)\s*;?/gm;
let match: RegExpExecArray | null;
while ((match = re.exec(stripped)) !== null) {
const fqn = match[2]!;
Expand Down
17 changes: 17 additions & 0 deletions src/resolution/name-matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,23 @@ function resolveMethodOnType(
if (matches.length === 0) return null;

if (matches.length > 1 && preferredFqn) {
// Prefer the candidate whose namespace-qualified name matches the imported
// FQN. #412 indexes JVM declarations as `<pkg>::<Class>` from the file's
// `package` directive — independent of the file name — so this disambiguates
// Kotlin (where `FooConverter` can live in `Converters.kt`) that the
// file-path-suffix heuristic below can't. `com.example.b.FooConverter`
// → expected method qn `com.example.b::FooConverter::convert`.
const lastDot = preferredFqn.lastIndexOf('.');
if (lastDot > 0) {
const expectedQn = `${preferredFqn.slice(0, lastDot)}::${preferredFqn.slice(lastDot + 1)}::${methodName}`;
const byQn = matches.find((m) => m.qualifiedName === expectedQn);
if (byQn) {
return { original: ref, targetNodeId: byQn.id, confidence, resolvedBy };
}
}
// Fall back to file-path-suffix match for Java's class-name = file-name
// layout (covers files indexed before the namespace wrapper, or any case
// the qn match misses).
const ext = ref.language === 'kotlin' ? '.kt' : '.java';
const fqnPath = preferredFqn.replace(/\./g, '/') + ext;
const chosen = matches.find((m) => {
Expand Down