mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-01 02:01:24 +00:00
Merge branch 'main' into fix/fts-non-fatal-in-analyze
This commit is contained in:
commit
cfc2b7d463
2 changed files with 283 additions and 24 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import type { Capture, CaptureMatch } from 'gitnexus-shared';
|
||||
import type { Capture, CaptureMatch, Range } from 'gitnexus-shared';
|
||||
import {
|
||||
findNodeAtRange,
|
||||
nodeToCapture,
|
||||
|
|
@ -73,6 +73,30 @@ export function emitKotlinScopeCaptures(
|
|||
if (navNode === null || !shouldEmitReadMember(navNode)) continue;
|
||||
}
|
||||
|
||||
// Virtual dispatch via constructor type (#1762). When a property
|
||||
// declaration carries BOTH an explicit type annotation AND a
|
||||
// constructor-style call value (e.g. `val animal: Animal = Dog()`),
|
||||
// suppress the annotation capture so the constructor-inferred
|
||||
// binding wins. This matches Kotlin's virtual dispatch semantics:
|
||||
// `animal.speak()` should resolve to the overriding `Dog.speak`
|
||||
// (the dynamic type), not `Animal.speak` (the static annotation).
|
||||
//
|
||||
// The annotation source has higher precedence than constructor-
|
||||
// inferred in the generic scope-extractor (see
|
||||
// `typeBindingStrength` in scope-extractor.ts), so the only way to
|
||||
// make the constructor type prevail is to drop the annotation at
|
||||
// emission time.
|
||||
if (
|
||||
grouped['@type-binding.annotation'] !== undefined &&
|
||||
grouped['@type-binding.name'] !== undefined &&
|
||||
grouped['@type-binding.type'] !== undefined
|
||||
) {
|
||||
const annotation = grouped['@type-binding.annotation']!;
|
||||
if (propertyDeclHasConstructorValue(tree.rootNode, annotation.range)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (grouped['@scope.function'] !== undefined) {
|
||||
out.push(grouped);
|
||||
const fnNode = findNodeAtRange(
|
||||
|
|
@ -290,10 +314,11 @@ function synthesizeKotlinLocalAssignmentBindings(
|
|||
returnTypes: ReadonlyMap<string, string>,
|
||||
): CaptureMatch[] {
|
||||
const out: CaptureMatch[] = [];
|
||||
const classMembers = collectKotlinClassMembers(rootNode);
|
||||
for (const fnNode of descendantsOfType(rootNode, 'function_declaration')) {
|
||||
const localTypes = new Map<string, string>();
|
||||
for (const prop of descendantsOfType(fnNode, 'property_declaration')) {
|
||||
const inferred = inferKotlinPropertyType(prop, localTypes, returnTypes);
|
||||
const inferred = inferKotlinPropertyType(prop, localTypes, returnTypes, classMembers);
|
||||
if (inferred === null) continue;
|
||||
localTypes.set(inferred.name.text, inferred.rawType);
|
||||
if (inferred.synthetic) {
|
||||
|
|
@ -316,6 +341,77 @@ function synthesizeKotlinLocalAssignmentBindings(
|
|||
return out;
|
||||
}
|
||||
|
||||
interface KotlinClassMembers {
|
||||
/** className → fieldName → raw type text */
|
||||
readonly fields: ReadonlyMap<string, ReadonlyMap<string, string>>;
|
||||
/** className → methodName → raw return type text */
|
||||
readonly methods: ReadonlyMap<string, ReadonlyMap<string, string>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-file class-member index — primary-constructor `val`/`var` params,
|
||||
* body property declarations, and method return types. Used by
|
||||
* `inferKotlinPropertyType` to walk single-level field and method chains
|
||||
* like `val addr = user.address` and `val city = addr.getCity()` (#1760).
|
||||
*
|
||||
* Indexes by simple class name only. Multi-class collisions inside a
|
||||
* single file will pick whichever class was visited last for that name
|
||||
* — acceptable because Kotlin forbids same-name top-level classes in
|
||||
* one file and per-file resolution is the design boundary here.
|
||||
*/
|
||||
function collectKotlinClassMembers(rootNode: SyntaxNode): KotlinClassMembers {
|
||||
const fields = new Map<string, Map<string, string>>();
|
||||
const methods = new Map<string, Map<string, string>>();
|
||||
for (const cls of descendantsOfType(rootNode, 'class_declaration')) {
|
||||
const className = cls.namedChildren.find((child) => child.type === 'type_identifier')?.text;
|
||||
if (className === undefined) continue;
|
||||
const fmap = fields.get(className) ?? new Map<string, string>();
|
||||
const mmap = methods.get(className) ?? new Map<string, string>();
|
||||
|
||||
const primary = cls.namedChildren.find((child) => child.type === 'primary_constructor');
|
||||
if (primary !== undefined) {
|
||||
for (const param of primary.namedChildren) {
|
||||
if (param.type !== 'class_parameter') continue;
|
||||
// Constructor params are class fields ONLY when prefixed with
|
||||
// `val`/`var` (binding_pattern_kind). Plain `fn(x: Int)`-style
|
||||
// params remain locals to the constructor.
|
||||
if (param.namedChildren.find((c) => c.type === 'binding_pattern_kind') === undefined) {
|
||||
continue;
|
||||
}
|
||||
const fname = param.namedChildren.find((c) => c.type === 'simple_identifier')?.text;
|
||||
const ftype = param.namedChildren.find((c) => isKotlinTypeNode(c))?.text;
|
||||
if (fname !== undefined && ftype !== undefined) fmap.set(fname, ftype);
|
||||
}
|
||||
}
|
||||
|
||||
const body = cls.namedChildren.find((child) => child.type === 'class_body');
|
||||
if (body !== undefined) {
|
||||
for (const member of body.namedChildren) {
|
||||
if (member.type === 'property_declaration') {
|
||||
const v = member.namedChildren.find((c) => c.type === 'variable_declaration');
|
||||
const fname = v?.namedChildren.find((c) => c.type === 'simple_identifier')?.text;
|
||||
const ftype = v?.namedChildren.find((c) => isKotlinTypeNode(c))?.text;
|
||||
if (fname !== undefined && ftype !== undefined) fmap.set(fname, ftype);
|
||||
} else if (member.type === 'function_declaration') {
|
||||
const mname = member.namedChildren.find((c) => c.type === 'simple_identifier')?.text;
|
||||
const paramsIdx = member.namedChildren.findIndex(
|
||||
(c) => c.type === 'function_value_parameters',
|
||||
);
|
||||
const rtype =
|
||||
paramsIdx < 0
|
||||
? undefined
|
||||
: member.namedChildren.slice(paramsIdx + 1).find((c) => isKotlinTypeNode(c))?.text;
|
||||
if (mname !== undefined && rtype !== undefined) mmap.set(mname, rtype);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fields.set(className, fmap);
|
||||
methods.set(className, mmap);
|
||||
}
|
||||
return { fields, methods };
|
||||
}
|
||||
|
||||
function collectKotlinLocalTypeTexts(
|
||||
fnNode: SyntaxNode,
|
||||
returnTypes: ReadonlyMap<string, string>,
|
||||
|
|
@ -357,6 +453,7 @@ function inferKotlinPropertyType(
|
|||
prop: SyntaxNode,
|
||||
localTypes: ReadonlyMap<string, string>,
|
||||
returnTypes: ReadonlyMap<string, string>,
|
||||
classMembers?: KotlinClassMembers,
|
||||
): { name: SyntaxNode; rawType: string; source: SyntaxNode; synthetic: boolean } | null {
|
||||
const variable = prop.namedChildren.find((child) => child.type === 'variable_declaration');
|
||||
const name = variable?.namedChildren.find((child) => child.type === 'simple_identifier');
|
||||
|
|
@ -375,18 +472,74 @@ function inferKotlinPropertyType(
|
|||
return rawType === undefined ? null : { name, rawType, source: value, synthetic: true };
|
||||
}
|
||||
|
||||
if (value?.type === 'navigation_expression') {
|
||||
// `val addr = user.address` — receiver type → field on that class (#1760).
|
||||
const chained = inferKotlinNavigationFieldType(value, localTypes, classMembers);
|
||||
if (chained === null) return null;
|
||||
return { name, rawType: chained, source: value, synthetic: true };
|
||||
}
|
||||
|
||||
if (value?.type === 'call_expression') {
|
||||
const callee = value.namedChildren.find((child) => child.type === 'simple_identifier');
|
||||
const callee = value.namedChildren.find(
|
||||
(child) => child.type === 'simple_identifier' || child.type === 'navigation_expression',
|
||||
);
|
||||
if (callee === undefined) return null;
|
||||
const rawType =
|
||||
returnTypes.get(callee.text) ?? (isUppercaseName(callee.text) ? callee.text : null);
|
||||
if (rawType === null) return null;
|
||||
return { name, rawType, source: callee, synthetic: true };
|
||||
if (callee.type === 'simple_identifier') {
|
||||
const rawType =
|
||||
returnTypes.get(callee.text) ?? (isUppercaseName(callee.text) ? callee.text : null);
|
||||
if (rawType === null) return null;
|
||||
return { name, rawType, source: callee, synthetic: true };
|
||||
}
|
||||
// `val city = addr.getCity()` — receiver type → method return on that class (#1760).
|
||||
const chained = inferKotlinNavigationCallReturnType(callee, localTypes, classMembers);
|
||||
if (chained === null) return null;
|
||||
return { name, rawType: chained, source: callee, synthetic: true };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Resolve `receiver.field` → field's declared type, where `receiver`
|
||||
* is a simple identifier whose type is in `localTypes` and `field`
|
||||
* is declared on that type in `classMembers.fields`. Returns null
|
||||
* when any link in the chain is unknown — safe over-conservative. */
|
||||
function inferKotlinNavigationFieldType(
|
||||
nav: SyntaxNode,
|
||||
localTypes: ReadonlyMap<string, string>,
|
||||
classMembers: KotlinClassMembers | undefined,
|
||||
): string | null {
|
||||
if (classMembers === undefined) return null;
|
||||
const receiver = nav.namedChild(0);
|
||||
if (receiver === null || receiver.type !== 'simple_identifier') return null;
|
||||
const member = nav.namedChildren
|
||||
.find((c) => c.type === 'navigation_suffix')
|
||||
?.namedChildren.find((c) => c.type === 'simple_identifier')?.text;
|
||||
if (member === undefined) return null;
|
||||
const recvType = localTypes.get(receiver.text);
|
||||
if (recvType === undefined) return null;
|
||||
return classMembers.fields.get(normalizeKotlinType(recvType))?.get(member) ?? null;
|
||||
}
|
||||
|
||||
/** Resolve `receiver.method()` → method's declared return type, where
|
||||
* `receiver` is a simple identifier whose type is in `localTypes` and
|
||||
* `method` is declared on that type in `classMembers.methods`. */
|
||||
function inferKotlinNavigationCallReturnType(
|
||||
navCallee: SyntaxNode,
|
||||
localTypes: ReadonlyMap<string, string>,
|
||||
classMembers: KotlinClassMembers | undefined,
|
||||
): string | null {
|
||||
if (classMembers === undefined) return null;
|
||||
const receiver = navCallee.namedChild(0);
|
||||
if (receiver === null || receiver.type !== 'simple_identifier') return null;
|
||||
const methodName = navCallee.namedChildren
|
||||
.find((c) => c.type === 'navigation_suffix')
|
||||
?.namedChildren.find((c) => c.type === 'simple_identifier')?.text;
|
||||
if (methodName === undefined) return null;
|
||||
const recvType = localTypes.get(receiver.text);
|
||||
if (recvType === undefined) return null;
|
||||
return classMembers.methods.get(normalizeKotlinType(recvType))?.get(methodName) ?? null;
|
||||
}
|
||||
|
||||
function inferKotlinIterableElementType(
|
||||
iterable: SyntaxNode,
|
||||
localTypes: ReadonlyMap<string, string>,
|
||||
|
|
@ -411,7 +564,16 @@ function inferKotlinIterableElementType(
|
|||
const callee = iterable.namedChildren.find((child) => child.type === 'simple_identifier');
|
||||
if (callee === undefined) return null;
|
||||
const raw = returnTypes.get(callee.text);
|
||||
return raw === undefined ? null : kotlinContainerElementType(raw, 'values');
|
||||
if (raw !== undefined) return kotlinContainerElementType(raw, 'values');
|
||||
// Cross-file fallback (#1759): the callee's return type is unknown
|
||||
// locally because the function lives in another file. Emit the
|
||||
// callee name itself as the binding's rawName; `propagateImported
|
||||
// ReturnTypes` will chain-follow `loopvar → callee → <ElementType>`
|
||||
// once the imported module's `callee → ElementType` mirror lands at
|
||||
// module scope. If `callee` isn't actually an imported callable
|
||||
// (e.g. a local lambda or unrelated symbol), chain-follow fails
|
||||
// safely and no edge is emitted.
|
||||
return callee.text;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
|
@ -508,6 +670,21 @@ function shouldEmitReadMember(navNode: SyntaxNode): boolean {
|
|||
return true;
|
||||
}
|
||||
|
||||
/** True when the property_declaration anchored at `range` has a
|
||||
* `call_expression` value sibling (i.e. `val x: T = Foo()`). Used to
|
||||
* suppress the explicit-annotation type-binding capture so the
|
||||
* constructor-inferred binding wins (#1762). */
|
||||
function propertyDeclHasConstructorValue(rootNode: SyntaxNode, range: Range): boolean {
|
||||
const propNode = findNodeAtRange(rootNode, range, 'property_declaration');
|
||||
if (propNode === null) return false;
|
||||
const variable = propNode.namedChildren.find((c) => c.type === 'variable_declaration');
|
||||
if (variable === undefined) return false;
|
||||
const value = propNode.namedChildren.find(
|
||||
(c) => c.id !== variable.id && c.type !== 'binding_pattern_kind',
|
||||
);
|
||||
return value?.type === 'call_expression';
|
||||
}
|
||||
|
||||
function callArguments(callNode: SyntaxNode): SyntaxNode[] {
|
||||
const suffix = callNode.namedChildren.find((child) => child.type === 'call_suffix');
|
||||
if (suffix === undefined) return [];
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export interface KotlinResolveContext {
|
|||
export function resolveKotlinImportTarget(
|
||||
parsedImport: ParsedImport,
|
||||
workspaceIndex: WorkspaceIndex,
|
||||
): string | null {
|
||||
): string | readonly string[] | null {
|
||||
const ctx = workspaceIndex as KotlinResolveContext | undefined;
|
||||
if (
|
||||
ctx === undefined ||
|
||||
|
|
@ -25,22 +25,49 @@ export function resolveKotlinImportTarget(
|
|||
: parsedImport.targetRaw;
|
||||
const pathLike = target.replace(/\./g, '/');
|
||||
|
||||
// Resolution tiers, most-specific first:
|
||||
// 1. The full `pathLike` matches a `.kt`/`.kts` file directly
|
||||
// (`import util.User` → `util/User.kt`).
|
||||
// 2. Stripped (last-segment removed) `pathLike` matches a file
|
||||
// directly (`import util.OneArg.writeAudit` → `util/OneArg.kt`,
|
||||
// a class-or-object holding `writeAudit`).
|
||||
// 3. Stripped `pathLike` matches a *package directory* — fan out to
|
||||
// every `.kt`/`.kts` file inside it (`import models.getRepo` →
|
||||
// `[models/User.kt, models/Repo.kt]`). The finalize pass walks
|
||||
// each candidate and picks the one whose `localDefs` actually
|
||||
// export the imported name (#1759).
|
||||
// 4. Progressive prefix strip for deeper namespace aliases that
|
||||
// don't map 1:1 to directories.
|
||||
const stripped = pathLike.split('/').slice(0, -1).join('/');
|
||||
return (
|
||||
findKotlinFile(ctx.allFilePaths, pathLike) ??
|
||||
findKotlinFile(ctx.allFilePaths, pathLike.split('/').slice(0, -1).join('/')) ??
|
||||
findKotlinExactOrSuffix(ctx.allFilePaths, stripped) ??
|
||||
findKotlinPackageFiles(ctx.allFilePaths, stripped) ??
|
||||
findByProgressivePrefixStrip(ctx.allFilePaths, pathLike)
|
||||
);
|
||||
}
|
||||
|
||||
function findKotlinFile(allFilePaths: ReadonlySet<string>, pathLike: string): string | null {
|
||||
return (
|
||||
findKotlinExactOrSuffix(allFilePaths, pathLike) ??
|
||||
findKotlinDirectoryChild(allFilePaths, pathLike)
|
||||
);
|
||||
}
|
||||
|
||||
/** Exact (`file === pathLike+ext`) or suffix (`file ends with /pathLike+ext`)
|
||||
* match — does NOT fall back to picking an arbitrary file inside a
|
||||
* `pathLike/` directory. Used by the stripped-path tier in
|
||||
* `resolveKotlinImportTarget` so a package import like `models.getRepo`
|
||||
* delegates to `findKotlinPackageFiles` (multi-file fan-out) instead of
|
||||
* silently committing to the first directory child. */
|
||||
function findKotlinExactOrSuffix(
|
||||
allFilePaths: ReadonlySet<string>,
|
||||
pathLike: string,
|
||||
): string | null {
|
||||
if (pathLike === '') return null;
|
||||
const extensions = ['.kt', '.kts'];
|
||||
const suffix = `/${pathLike}`;
|
||||
const dirPrefix = `${pathLike}/`;
|
||||
const suffixDirPrefix = `/${dirPrefix}`;
|
||||
|
||||
let suffixFile: string | null = null;
|
||||
let directoryChild: string | null = null;
|
||||
|
||||
for (const raw of allFilePaths) {
|
||||
const file = raw.replace(/\\/g, '/');
|
||||
|
|
@ -49,18 +76,73 @@ function findKotlinFile(allFilePaths: ReadonlySet<string>, pathLike: string): st
|
|||
if (file === `${pathLike}${ext}`) return raw;
|
||||
if (suffixFile === null && file.endsWith(`${suffix}${ext}`)) suffixFile = raw;
|
||||
}
|
||||
if (directoryChild === null) {
|
||||
const atRoot = file.startsWith(dirPrefix);
|
||||
const atNested = file.includes(suffixDirPrefix);
|
||||
if (atRoot || atNested) {
|
||||
const idx = atRoot ? 0 : file.indexOf(suffixDirPrefix) + 1;
|
||||
const after = file.slice(idx + dirPrefix.length);
|
||||
if (after.length > 0 && !after.includes('/')) directoryChild = raw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return suffixFile ?? directoryChild;
|
||||
return suffixFile;
|
||||
}
|
||||
|
||||
/** First directory child of `pathLike/` — preserves the legacy single-
|
||||
* file fallback for cases where `pathLike` itself is an unqualified
|
||||
* package reference (rare in real Kotlin code; some fixtures rely on
|
||||
* it). Multi-file package fan-out goes through
|
||||
* `findKotlinPackageFiles` instead. */
|
||||
function findKotlinDirectoryChild(
|
||||
allFilePaths: ReadonlySet<string>,
|
||||
pathLike: string,
|
||||
): string | null {
|
||||
if (pathLike === '') return null;
|
||||
const extensions = ['.kt', '.kts'];
|
||||
const dirPrefix = `${pathLike}/`;
|
||||
const suffixDirPrefix = `/${dirPrefix}`;
|
||||
|
||||
for (const raw of allFilePaths) {
|
||||
const file = raw.replace(/\\/g, '/');
|
||||
if (!extensions.some((ext) => file.endsWith(ext))) continue;
|
||||
const atRoot = file.startsWith(dirPrefix);
|
||||
const atNested = file.includes(suffixDirPrefix);
|
||||
if (!atRoot && !atNested) continue;
|
||||
const idx = atRoot ? 0 : file.indexOf(suffixDirPrefix) + 1;
|
||||
const after = file.slice(idx + dirPrefix.length);
|
||||
if (after.length > 0 && !after.includes('/')) return raw;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return every `.kt`/`.kts` file inside the package directory `dirPath`
|
||||
* (e.g. `models` → `['models/User.kt', 'models/Repo.kt']`). Used as a
|
||||
* fallback when an import like `models.getRepo` does not resolve to a
|
||||
* file named after the symbol — in Kotlin the symbol can live in any
|
||||
* file inside the package directory. The finalize pass walks each
|
||||
* candidate and picks the one whose `localDefs` actually export the
|
||||
* imported name (#1759).
|
||||
*/
|
||||
function findKotlinPackageFiles(
|
||||
allFilePaths: ReadonlySet<string>,
|
||||
dirPath: string,
|
||||
): readonly string[] | null {
|
||||
if (dirPath === '') return null;
|
||||
const extensions = ['.kt', '.kts'];
|
||||
const dirPrefix = `${dirPath}/`;
|
||||
const suffixDirPrefix = `/${dirPrefix}`;
|
||||
const out: string[] = [];
|
||||
|
||||
for (const raw of allFilePaths) {
|
||||
const file = raw.replace(/\\/g, '/');
|
||||
if (!extensions.some((ext) => file.endsWith(ext))) continue;
|
||||
const atRoot = file.startsWith(dirPrefix);
|
||||
const atNested = file.includes(suffixDirPrefix);
|
||||
if (!atRoot && !atNested) continue;
|
||||
const idx = atRoot ? 0 : file.indexOf(suffixDirPrefix) + 1;
|
||||
const after = file.slice(idx + dirPrefix.length);
|
||||
// Direct children only — `models/sub/Util.kt` is a different package
|
||||
// (`models.sub`) and must not be merged with `models`.
|
||||
if (after.length === 0 || after.includes('/')) continue;
|
||||
out.push(raw);
|
||||
}
|
||||
|
||||
return out.length === 0 ? null : out;
|
||||
}
|
||||
|
||||
function findByProgressivePrefixStrip(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue