mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
feat(ingestion): add Kotlin scope resolver (#1727)
* feat(ingestion): add Kotlin scope resolver * fix(ingestion): tighten Kotlin scope captures --------- Co-authored-by: Shining <xuenning@qiyi.com> Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
This commit is contained in:
parent
c34c36036f
commit
1b5c6e5b6a
18 changed files with 1250 additions and 1 deletions
|
|
@ -29,6 +29,16 @@ import { kotlinMethodConfig } from '../method-extractors/configs/jvm.js';
|
|||
import { createVariableExtractor } from '../variable-extractors/generic.js';
|
||||
import { kotlinVariableConfig } from '../variable-extractors/configs/jvm.js';
|
||||
import { createHeritageExtractor } from '../heritage-extractors/generic.js';
|
||||
import {
|
||||
emitKotlinScopeCaptures,
|
||||
interpretKotlinImport,
|
||||
interpretKotlinTypeBinding,
|
||||
kotlinArityCompatibility,
|
||||
kotlinBindingScopeFor,
|
||||
kotlinImportOwningScope,
|
||||
kotlinMergeBindings,
|
||||
kotlinReceiverBinding,
|
||||
} from './kotlin/index.js';
|
||||
|
||||
/** Check if a Kotlin function_declaration capture is inside a class_body (i.e., a method).
|
||||
* Kotlin grammar uses function_declaration for both top-level functions and class methods.
|
||||
|
|
@ -166,4 +176,14 @@ export const kotlinProvider = defineLanguage({
|
|||
if (isKotlinClassMethod(functionNode)) return 'Method';
|
||||
return defaultLabel;
|
||||
},
|
||||
|
||||
// ── RFC #909 Ring 3: scope-based resolution hooks ──
|
||||
emitScopeCaptures: emitKotlinScopeCaptures,
|
||||
interpretImport: interpretKotlinImport,
|
||||
interpretTypeBinding: interpretKotlinTypeBinding,
|
||||
bindingScopeFor: kotlinBindingScopeFor,
|
||||
importOwningScope: kotlinImportOwningScope,
|
||||
mergeBindings: (_scope, bindings) => kotlinMergeBindings(bindings),
|
||||
receiverBinding: kotlinReceiverBinding,
|
||||
arityCompatibility: kotlinArityCompatibility,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import { kotlinMethodConfig } from '../../method-extractors/configs/jvm.js';
|
||||
|
||||
export interface KotlinArityMetadata {
|
||||
readonly parameterCount: number | undefined;
|
||||
readonly requiredParameterCount: number | undefined;
|
||||
readonly parameterTypes: readonly string[] | undefined;
|
||||
}
|
||||
|
||||
export function computeKotlinArityMetadata(fnNode: SyntaxNode): KotlinArityMetadata {
|
||||
const params = kotlinMethodConfig.extractParameters?.(fnNode) ?? [];
|
||||
let hasVararg = false;
|
||||
const parameterTypes: string[] = [];
|
||||
for (const param of params) {
|
||||
if (param.isVariadic) hasVararg = true;
|
||||
if (param.type !== null) parameterTypes.push(param.type);
|
||||
}
|
||||
if (hasVararg) parameterTypes.push('vararg');
|
||||
|
||||
const required = params.filter((p) => !p.isOptional && !p.isVariadic).length;
|
||||
return {
|
||||
parameterCount: hasVararg ? undefined : params.length,
|
||||
requiredParameterCount: required,
|
||||
parameterTypes: parameterTypes.length > 0 ? parameterTypes : undefined,
|
||||
};
|
||||
}
|
||||
18
gitnexus/src/core/ingestion/languages/kotlin/arity.ts
Normal file
18
gitnexus/src/core/ingestion/languages/kotlin/arity.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import type { Callsite, SymbolDefinition } from 'gitnexus-shared';
|
||||
|
||||
export function kotlinArityCompatibility(
|
||||
def: SymbolDefinition,
|
||||
callsite: Callsite,
|
||||
): 'compatible' | 'unknown' | 'incompatible' {
|
||||
const min = def.requiredParameterCount;
|
||||
const max = def.parameterCount;
|
||||
if (min === undefined && max === undefined) return 'unknown';
|
||||
|
||||
const argCount = callsite.arity;
|
||||
if (!Number.isFinite(argCount) || argCount < 0) return 'unknown';
|
||||
|
||||
const hasVararg = def.parameterTypes?.some((t) => t === 'vararg') ?? false;
|
||||
if (min !== undefined && argCount < min) return 'incompatible';
|
||||
if (max !== undefined && argCount > max && !hasVararg) return 'incompatible';
|
||||
return 'compatible';
|
||||
}
|
||||
19
gitnexus/src/core/ingestion/languages/kotlin/cache-stats.ts
Normal file
19
gitnexus/src/core/ingestion/languages/kotlin/cache-stats.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
let hits = 0;
|
||||
let misses = 0;
|
||||
|
||||
export function recordKotlinCacheHit(): void {
|
||||
hits += 1;
|
||||
}
|
||||
|
||||
export function recordKotlinCacheMiss(): void {
|
||||
misses += 1;
|
||||
}
|
||||
|
||||
export function getKotlinCaptureCacheStats(): { readonly hits: number; readonly misses: number } {
|
||||
return { hits, misses };
|
||||
}
|
||||
|
||||
export function resetKotlinCaptureCacheStats(): void {
|
||||
hits = 0;
|
||||
misses = 0;
|
||||
}
|
||||
484
gitnexus/src/core/ingestion/languages/kotlin/captures.ts
Normal file
484
gitnexus/src/core/ingestion/languages/kotlin/captures.ts
Normal file
|
|
@ -0,0 +1,484 @@
|
|||
import type { Capture, CaptureMatch } from 'gitnexus-shared';
|
||||
import {
|
||||
findNodeAtRange,
|
||||
nodeToCapture,
|
||||
syntheticCapture,
|
||||
type SyntaxNode,
|
||||
} from '../../utils/ast-helpers.js';
|
||||
import { getTreeSitterBufferSize } from '../../constants.js';
|
||||
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
|
||||
import { computeKotlinArityMetadata } from './arity-metadata.js';
|
||||
import { splitKotlinImportHeader } from './import-decomposer.js';
|
||||
import { recordKotlinCacheHit, recordKotlinCacheMiss } from './cache-stats.js';
|
||||
import { normalizeKotlinType } from './interpret.js';
|
||||
import { synthesizeKotlinReceiverBinding } from './receiver-binding.js';
|
||||
import { getKotlinParser, getKotlinScopeQuery } from './query.js';
|
||||
|
||||
const FUNCTION_DECL_TAGS = ['@declaration.function'] as const;
|
||||
|
||||
export function emitKotlinScopeCaptures(
|
||||
sourceText: string,
|
||||
_filePath: string,
|
||||
cachedTree?: unknown,
|
||||
): readonly CaptureMatch[] {
|
||||
let tree = cachedTree as ReturnType<ReturnType<typeof getKotlinParser>['parse']> | undefined;
|
||||
if (tree === undefined) {
|
||||
tree = parseSourceSafe(getKotlinParser(), sourceText, undefined, {
|
||||
bufferSize: getTreeSitterBufferSize(sourceText),
|
||||
});
|
||||
recordKotlinCacheMiss();
|
||||
} else {
|
||||
recordKotlinCacheHit();
|
||||
}
|
||||
|
||||
const out: CaptureMatch[] = [];
|
||||
const returnTypes = collectKotlinReturnTypeTexts(tree.rootNode);
|
||||
out.push(...synthesizeKotlinLocalAssignmentBindings(tree.rootNode, returnTypes));
|
||||
out.push(...synthesizeKotlinLoopBindings(tree.rootNode, returnTypes));
|
||||
|
||||
for (const match of getKotlinScopeQuery().matches(tree.rootNode)) {
|
||||
const grouped: Record<string, Capture> = {};
|
||||
for (const capture of match.captures) {
|
||||
const tag = '@' + capture.name;
|
||||
grouped[tag] = nodeToCapture(tag, capture.node);
|
||||
}
|
||||
if (Object.keys(grouped).length === 0) continue;
|
||||
|
||||
if (grouped['@import.statement'] !== undefined) {
|
||||
const importNode = findNodeAtRange(
|
||||
tree.rootNode,
|
||||
grouped['@import.statement']!.range,
|
||||
'import_header',
|
||||
);
|
||||
if (importNode !== null) {
|
||||
const decomposed = splitKotlinImportHeader(importNode);
|
||||
if (decomposed !== null) {
|
||||
out.push(decomposed);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
grouped['@reference.call.free'] !== undefined &&
|
||||
grouped['@reference.receiver'] !== undefined
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (grouped['@reference.read.member'] !== undefined) {
|
||||
const anchor = grouped['@reference.read.member']!;
|
||||
const navNode = findNodeAtRange(tree.rootNode, anchor.range, 'navigation_expression');
|
||||
if (navNode === null || !shouldEmitReadMember(navNode)) continue;
|
||||
}
|
||||
|
||||
if (grouped['@scope.function'] !== undefined) {
|
||||
out.push(grouped);
|
||||
const fnNode = findNodeAtRange(
|
||||
tree.rootNode,
|
||||
grouped['@scope.function']!.range,
|
||||
'function_declaration',
|
||||
);
|
||||
if (fnNode !== null) {
|
||||
out.push(...synthesizeKotlinReceiverBinding(fnNode));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const declTag = FUNCTION_DECL_TAGS.find((tag) => grouped[tag] !== undefined);
|
||||
if (declTag !== undefined) {
|
||||
const fnNode = findNodeAtRange(
|
||||
tree.rootNode,
|
||||
grouped[declTag]!.range,
|
||||
'function_declaration',
|
||||
);
|
||||
if (fnNode !== null) {
|
||||
const arity = computeKotlinArityMetadata(fnNode);
|
||||
if (arity.parameterCount !== undefined) {
|
||||
grouped['@declaration.parameter-count'] = syntheticCapture(
|
||||
'@declaration.parameter-count',
|
||||
fnNode,
|
||||
String(arity.parameterCount),
|
||||
);
|
||||
}
|
||||
if (arity.requiredParameterCount !== undefined) {
|
||||
grouped['@declaration.required-parameter-count'] = syntheticCapture(
|
||||
'@declaration.required-parameter-count',
|
||||
fnNode,
|
||||
String(arity.requiredParameterCount),
|
||||
);
|
||||
}
|
||||
if (arity.parameterTypes !== undefined) {
|
||||
grouped['@declaration.parameter-types'] = syntheticCapture(
|
||||
'@declaration.parameter-types',
|
||||
fnNode,
|
||||
JSON.stringify(arity.parameterTypes),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const callTag = (
|
||||
['@reference.call.free', '@reference.call.member', '@reference.call.constructor'] as const
|
||||
).find((tag) => grouped[tag] !== undefined);
|
||||
if (callTag !== undefined && grouped['@reference.arity'] === undefined) {
|
||||
const callNode = findNodeAtRange(tree.rootNode, grouped[callTag]!.range, 'call_expression');
|
||||
if (callNode !== null) {
|
||||
const args = callArguments(callNode);
|
||||
grouped['@reference.arity'] = syntheticCapture(
|
||||
'@reference.arity',
|
||||
callNode,
|
||||
String(args.length),
|
||||
);
|
||||
grouped['@reference.parameter-types'] = syntheticCapture(
|
||||
'@reference.parameter-types',
|
||||
callNode,
|
||||
JSON.stringify(args.map(inferArgType)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
out.push(grouped);
|
||||
|
||||
const extensionFallback = extensionFreeCallFallback(grouped, tree.rootNode);
|
||||
if (extensionFallback !== null) out.push(extensionFallback);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function synthesizeKotlinLoopBindings(
|
||||
rootNode: SyntaxNode,
|
||||
returnTypes: ReadonlyMap<string, string>,
|
||||
): CaptureMatch[] {
|
||||
const out: CaptureMatch[] = [];
|
||||
for (const fnNode of descendantsOfType(rootNode, 'function_declaration')) {
|
||||
const localTypes = collectKotlinLocalTypeTexts(fnNode, returnTypes);
|
||||
for (const forNode of descendantsOfType(fnNode, 'for_statement')) {
|
||||
const variable = forNode.namedChildren.find((child) => child.type === 'variable_declaration');
|
||||
const name = variable?.namedChildren.find((child) => child.type === 'simple_identifier');
|
||||
if (variable === undefined || name === undefined) continue;
|
||||
|
||||
const explicitType = variable.namedChildren.find((child) => isKotlinTypeNode(child));
|
||||
const iterable = forNode.namedChildren.find(
|
||||
(child) => child.id !== variable.id && child.type !== 'control_structure_body',
|
||||
);
|
||||
const rawType =
|
||||
explicitType?.text ??
|
||||
(iterable === undefined
|
||||
? null
|
||||
: inferKotlinIterableElementType(iterable, localTypes, returnTypes));
|
||||
if (rawType === null || rawType.trim() === '') continue;
|
||||
|
||||
const anchor =
|
||||
forNode.namedChildren.find((child) => child.type === 'control_structure_body') ?? forNode;
|
||||
out.push({
|
||||
'@type-binding.annotation': nodeToCapture('@type-binding.annotation', anchor),
|
||||
'@type-binding.name': syntheticCapture('@type-binding.name', name, name.text),
|
||||
'@type-binding.type': syntheticCapture(
|
||||
'@type-binding.type',
|
||||
explicitType ?? iterable ?? name,
|
||||
normalizeKotlinType(rawType),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function synthesizeKotlinLocalAssignmentBindings(
|
||||
rootNode: SyntaxNode,
|
||||
returnTypes: ReadonlyMap<string, string>,
|
||||
): CaptureMatch[] {
|
||||
const out: CaptureMatch[] = [];
|
||||
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);
|
||||
if (inferred === null) continue;
|
||||
localTypes.set(inferred.name.text, inferred.rawType);
|
||||
if (inferred.synthetic) {
|
||||
out.push({
|
||||
'@type-binding.annotation': nodeToCapture('@type-binding.annotation', prop),
|
||||
'@type-binding.name': syntheticCapture(
|
||||
'@type-binding.name',
|
||||
inferred.name,
|
||||
inferred.name.text,
|
||||
),
|
||||
'@type-binding.type': syntheticCapture(
|
||||
'@type-binding.type',
|
||||
inferred.source,
|
||||
normalizeKotlinType(inferred.rawType),
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function collectKotlinLocalTypeTexts(
|
||||
fnNode: SyntaxNode,
|
||||
returnTypes: ReadonlyMap<string, string>,
|
||||
): Map<string, string> {
|
||||
const out = new Map<string, string>();
|
||||
for (const node of descendants(fnNode)) {
|
||||
if (node.type === 'parameter') {
|
||||
const name = descendantsOfType(node, 'simple_identifier')[0];
|
||||
const type = node.namedChildren.find((child) => isKotlinTypeNode(child));
|
||||
if (name !== undefined && type !== undefined) out.set(name.text, type.text);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (node.type === 'property_declaration') {
|
||||
const inferred = inferKotlinPropertyType(node, out, returnTypes);
|
||||
if (inferred !== null) out.set(inferred.name.text, inferred.rawType);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function collectKotlinReturnTypeTexts(rootNode: SyntaxNode): Map<string, string> {
|
||||
const out = new Map<string, string>();
|
||||
for (const fnNode of descendantsOfType(rootNode, 'function_declaration')) {
|
||||
const name = fnNode.namedChildren.find((child) => child.type === 'simple_identifier');
|
||||
const paramsIndex = fnNode.namedChildren.findIndex(
|
||||
(child) => child.type === 'function_value_parameters',
|
||||
);
|
||||
const type =
|
||||
paramsIndex < 0
|
||||
? undefined
|
||||
: fnNode.namedChildren.slice(paramsIndex + 1).find((child) => isKotlinTypeNode(child));
|
||||
if (name !== undefined && type !== undefined) out.set(name.text, type.text);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function inferKotlinPropertyType(
|
||||
prop: SyntaxNode,
|
||||
localTypes: ReadonlyMap<string, string>,
|
||||
returnTypes: ReadonlyMap<string, string>,
|
||||
): { 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');
|
||||
if (variable === undefined || name === undefined) return null;
|
||||
|
||||
const explicitType = variable.namedChildren.find((child) => isKotlinTypeNode(child));
|
||||
if (explicitType !== undefined) {
|
||||
return { name, rawType: explicitType.text, source: explicitType, synthetic: false };
|
||||
}
|
||||
|
||||
const value = prop.namedChildren.find(
|
||||
(child) => child.id !== variable.id && child.type !== 'binding_pattern_kind',
|
||||
);
|
||||
if (value?.type === 'simple_identifier') {
|
||||
const rawType = localTypes.get(value.text);
|
||||
return rawType === undefined ? null : { name, rawType, source: value, synthetic: true };
|
||||
}
|
||||
|
||||
if (value?.type === 'call_expression') {
|
||||
const callee = value.namedChildren.find((child) => child.type === 'simple_identifier');
|
||||
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 };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function inferKotlinIterableElementType(
|
||||
iterable: SyntaxNode,
|
||||
localTypes: ReadonlyMap<string, string>,
|
||||
returnTypes: ReadonlyMap<string, string>,
|
||||
): string | null {
|
||||
if (iterable.type === 'simple_identifier') {
|
||||
const raw = localTypes.get(iterable.text);
|
||||
return raw === undefined ? null : kotlinContainerElementType(raw, 'values');
|
||||
}
|
||||
|
||||
if (iterable.type === 'navigation_expression') {
|
||||
const receiver = iterable.namedChildren[0];
|
||||
const member = iterable.namedChildren
|
||||
.find((child) => child.type === 'navigation_suffix')
|
||||
?.namedChildren.find((child) => child.type === 'simple_identifier')?.text;
|
||||
if (receiver?.type !== 'simple_identifier') return null;
|
||||
const raw = localTypes.get(receiver.text);
|
||||
return raw === undefined ? null : kotlinContainerElementType(raw, member ?? 'values');
|
||||
}
|
||||
|
||||
if (iterable.type === 'call_expression') {
|
||||
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');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isUppercaseName(text: string): boolean {
|
||||
return /^[A-Z]/.test(text);
|
||||
}
|
||||
|
||||
function kotlinContainerElementType(rawType: string, member: string): string | null {
|
||||
const parsed = parseKotlinGeneric(rawType);
|
||||
if (parsed === null) return normalizeKotlinType(rawType);
|
||||
|
||||
const base = parsed.base.split('.').pop() ?? parsed.base;
|
||||
if (isKotlinMapType(base)) {
|
||||
if (member === 'keys') return parsed.args[0] ?? null;
|
||||
return parsed.args[1] ?? null;
|
||||
}
|
||||
if (isKotlinIterableType(base)) return parsed.args[0] ?? null;
|
||||
return normalizeKotlinType(rawType);
|
||||
}
|
||||
|
||||
function parseKotlinGeneric(text: string): { base: string; args: string[] } | null {
|
||||
const trimmed = text.trim().replace(/\?$/, '');
|
||||
const open = trimmed.indexOf('<');
|
||||
const close = trimmed.lastIndexOf('>');
|
||||
if (open < 0 || close < open) return null;
|
||||
return {
|
||||
base: trimmed.slice(0, open).trim(),
|
||||
args: splitTopLevelKotlinArgs(trimmed.slice(open + 1, close)),
|
||||
};
|
||||
}
|
||||
|
||||
function splitTopLevelKotlinArgs(text: string): string[] {
|
||||
const out: string[] = [];
|
||||
let depth = 0;
|
||||
let start = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (ch === '<') depth++;
|
||||
else if (ch === '>') depth--;
|
||||
else if (ch === ',' && depth === 0) {
|
||||
out.push(text.slice(start, i).trim());
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
out.push(text.slice(start).trim());
|
||||
return out.filter((arg) => arg.length > 0);
|
||||
}
|
||||
|
||||
function isKotlinMapType(base: string): boolean {
|
||||
return ['Map', 'MutableMap', 'HashMap', 'LinkedHashMap'].includes(base);
|
||||
}
|
||||
|
||||
function isKotlinIterableType(base: string): boolean {
|
||||
return [
|
||||
'List',
|
||||
'MutableList',
|
||||
'ArrayList',
|
||||
'Set',
|
||||
'MutableSet',
|
||||
'Collection',
|
||||
'Iterable',
|
||||
'Sequence',
|
||||
'Array',
|
||||
].includes(base);
|
||||
}
|
||||
|
||||
function isKotlinTypeNode(node: SyntaxNode): boolean {
|
||||
return (
|
||||
node.type === 'user_type' || node.type === 'nullable_type' || node.type === 'function_type'
|
||||
);
|
||||
}
|
||||
|
||||
function descendantsOfType(node: SyntaxNode, type: string): SyntaxNode[] {
|
||||
return descendants(node).filter((child) => child.type === type);
|
||||
}
|
||||
|
||||
function descendants(node: SyntaxNode): SyntaxNode[] {
|
||||
const out: SyntaxNode[] = [];
|
||||
for (let i = 0; i < node.namedChildCount; i++) {
|
||||
const child = node.namedChild(i);
|
||||
if (child === null) continue;
|
||||
out.push(child, ...descendants(child));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function shouldEmitReadMember(navNode: SyntaxNode): boolean {
|
||||
const parent = navNode.parent;
|
||||
if (parent === null) return true;
|
||||
if (parent.type === 'call_expression') return false;
|
||||
if (parent.type === 'directly_assignable_expression') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function callArguments(callNode: SyntaxNode): SyntaxNode[] {
|
||||
const suffix = callNode.namedChildren.find((child) => child.type === 'call_suffix');
|
||||
if (suffix === undefined) return [];
|
||||
|
||||
const valueArgs = suffix?.namedChildren.find((child) => child.type === 'value_arguments');
|
||||
const args = valueArgs?.namedChildren.filter((child) => child.type === 'value_argument') ?? [];
|
||||
const trailingLambdas = suffix.namedChildren.filter((child) => child.type === 'annotated_lambda');
|
||||
return [...args, ...trailingLambdas];
|
||||
}
|
||||
|
||||
function inferArgType(argNode: SyntaxNode): string {
|
||||
const value = argNode.namedChild(0) ?? argNode;
|
||||
switch (value.type) {
|
||||
case 'integer_literal':
|
||||
case 'long_literal':
|
||||
return 'Int';
|
||||
case 'real_literal':
|
||||
return 'Double';
|
||||
case 'string_literal':
|
||||
case 'line_string_literal':
|
||||
case 'multi_line_string_literal':
|
||||
return 'String';
|
||||
case 'character_literal':
|
||||
return 'Char';
|
||||
case 'boolean_literal':
|
||||
return 'Boolean';
|
||||
case 'call_expression': {
|
||||
const first = value.namedChild(0);
|
||||
return first?.type === 'simple_identifier' ? first.text : '';
|
||||
}
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function extensionFreeCallFallback(
|
||||
grouped: Record<string, Capture>,
|
||||
rootNode: SyntaxNode,
|
||||
): CaptureMatch | null {
|
||||
const member = grouped['@reference.call.member'];
|
||||
const receiver = grouped['@reference.receiver'];
|
||||
const name = grouped['@reference.name'];
|
||||
if (member === undefined || receiver === undefined || name === undefined) return null;
|
||||
|
||||
const callNode = findNodeAtRange(rootNode, member.range, 'call_expression');
|
||||
if (callNode === null) return null;
|
||||
const receiverNode = findNodeAtRange(rootNode, receiver.range);
|
||||
if (receiverNode === null || !isLiteralReceiver(receiverNode)) return null;
|
||||
|
||||
const out: Record<string, Capture> = {
|
||||
'@reference.call.free': syntheticCapture('@reference.call.free', callNode, callNode.text),
|
||||
'@reference.name': syntheticCapture('@reference.name', callNode, name.text),
|
||||
};
|
||||
if (grouped['@reference.arity'] !== undefined)
|
||||
out['@reference.arity'] = grouped['@reference.arity'];
|
||||
if (grouped['@reference.parameter-types'] !== undefined) {
|
||||
out['@reference.parameter-types'] = grouped['@reference.parameter-types'];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function isLiteralReceiver(node: SyntaxNode): boolean {
|
||||
return [
|
||||
'integer_literal',
|
||||
'long_literal',
|
||||
'real_literal',
|
||||
'string_literal',
|
||||
'line_string_literal',
|
||||
'multi_line_string_literal',
|
||||
'character_literal',
|
||||
'boolean_literal',
|
||||
].includes(node.type);
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import type { Capture, CaptureMatch } from 'gitnexus-shared';
|
||||
import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
|
||||
type KotlinImportKind = 'named' | 'alias' | 'wildcard';
|
||||
|
||||
interface KotlinImportSpec {
|
||||
readonly kind: KotlinImportKind;
|
||||
readonly source: string;
|
||||
readonly name: string;
|
||||
readonly alias?: string;
|
||||
readonly atNode: SyntaxNode;
|
||||
}
|
||||
|
||||
export function splitKotlinImportHeader(importNode: SyntaxNode): CaptureMatch | null {
|
||||
if (importNode.type !== 'import_header') return null;
|
||||
const spec = parseKotlinImport(importNode);
|
||||
if (spec === null) return null;
|
||||
|
||||
const out: Record<string, Capture> = {
|
||||
'@import.statement': nodeToCapture('@import.statement', importNode),
|
||||
'@import.kind': syntheticCapture('@import.kind', spec.atNode, spec.kind),
|
||||
'@import.source': syntheticCapture('@import.source', spec.atNode, spec.source),
|
||||
'@import.name': syntheticCapture('@import.name', spec.atNode, spec.name),
|
||||
};
|
||||
if (spec.alias !== undefined) {
|
||||
out['@import.alias'] = syntheticCapture('@import.alias', spec.atNode, spec.alias);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseKotlinImport(node: SyntaxNode): KotlinImportSpec | null {
|
||||
const identifier = node.namedChildren.find((child) => child.type === 'identifier');
|
||||
if (identifier === undefined) return null;
|
||||
const source = identifier.text.trim();
|
||||
if (source.length === 0) return null;
|
||||
|
||||
const hasWildcard = node.namedChildren.some((child) => child.type === 'wildcard_import');
|
||||
if (hasWildcard) {
|
||||
return { kind: 'wildcard', source, name: '*', atNode: node };
|
||||
}
|
||||
|
||||
const aliasNode = node.namedChildren.find((child) => child.type === 'import_alias');
|
||||
const alias = aliasNode?.namedChildren.find((child) => child.type === 'type_identifier')?.text;
|
||||
const importedName = source.split('.').pop() ?? source;
|
||||
if (alias !== undefined && alias.length > 0) {
|
||||
return { kind: 'alias', source, name: importedName, alias, atNode: node };
|
||||
}
|
||||
return { kind: 'named', source, name: importedName, atNode: node };
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared';
|
||||
|
||||
export interface KotlinResolveContext {
|
||||
readonly fromFile: string;
|
||||
readonly allFilePaths: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
export function resolveKotlinImportTarget(
|
||||
parsedImport: ParsedImport,
|
||||
workspaceIndex: WorkspaceIndex,
|
||||
): string | null {
|
||||
const ctx = workspaceIndex as KotlinResolveContext | undefined;
|
||||
if (
|
||||
ctx === undefined ||
|
||||
typeof (ctx as { fromFile?: unknown }).fromFile !== 'string' ||
|
||||
!((ctx as { allFilePaths?: unknown }).allFilePaths instanceof Set)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (parsedImport.kind === 'dynamic-unresolved') return null;
|
||||
if (parsedImport.targetRaw === null || parsedImport.targetRaw === '') return null;
|
||||
|
||||
const target = parsedImport.targetRaw.endsWith('.*')
|
||||
? parsedImport.targetRaw.slice(0, -2)
|
||||
: parsedImport.targetRaw;
|
||||
const pathLike = target.replace(/\./g, '/');
|
||||
|
||||
return (
|
||||
findKotlinFile(ctx.allFilePaths, pathLike) ??
|
||||
findKotlinFile(ctx.allFilePaths, pathLike.split('/').slice(0, -1).join('/')) ??
|
||||
findByProgressivePrefixStrip(ctx.allFilePaths, pathLike)
|
||||
);
|
||||
}
|
||||
|
||||
function findKotlinFile(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, '/');
|
||||
if (!extensions.some((ext) => file.endsWith(ext))) continue;
|
||||
for (const ext of extensions) {
|
||||
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;
|
||||
}
|
||||
|
||||
function findByProgressivePrefixStrip(
|
||||
allFilePaths: ReadonlySet<string>,
|
||||
pathLike: string,
|
||||
): string | null {
|
||||
const segments = pathLike.split('/').filter(Boolean);
|
||||
for (let skip = 1; skip < segments.length; skip++) {
|
||||
const found = findKotlinFile(allFilePaths, segments.slice(skip).join('/'));
|
||||
if (found !== null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
12
gitnexus/src/core/ingestion/languages/kotlin/index.ts
Normal file
12
gitnexus/src/core/ingestion/languages/kotlin/index.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
export { emitKotlinScopeCaptures } from './captures.js';
|
||||
export { getKotlinCaptureCacheStats, resetKotlinCaptureCacheStats } from './cache-stats.js';
|
||||
export { interpretKotlinImport, interpretKotlinTypeBinding } from './interpret.js';
|
||||
export { kotlinArityCompatibility } from './arity.js';
|
||||
export { resolveKotlinImportTarget, type KotlinResolveContext } from './import-target.js';
|
||||
export { kotlinMergeBindings } from './merge-bindings.js';
|
||||
export { populateKotlinOwners } from './owners.js';
|
||||
export {
|
||||
kotlinBindingScopeFor,
|
||||
kotlinImportOwningScope,
|
||||
kotlinReceiverBinding,
|
||||
} from './simple-hooks.js';
|
||||
71
gitnexus/src/core/ingestion/languages/kotlin/interpret.ts
Normal file
71
gitnexus/src/core/ingestion/languages/kotlin/interpret.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared';
|
||||
|
||||
export function interpretKotlinImport(captures: CaptureMatch): ParsedImport | null {
|
||||
const kind = captures['@import.kind']?.text;
|
||||
const source = captures['@import.source']?.text;
|
||||
const name = captures['@import.name']?.text;
|
||||
if (kind === undefined || source === undefined) return null;
|
||||
|
||||
switch (kind) {
|
||||
case 'named':
|
||||
return {
|
||||
kind: 'named',
|
||||
localName: name ?? source.split('.').pop() ?? source,
|
||||
importedName: name ?? source.split('.').pop() ?? source,
|
||||
targetRaw: source,
|
||||
};
|
||||
case 'alias': {
|
||||
const alias = captures['@import.alias']?.text;
|
||||
if (alias === undefined || name === undefined) return null;
|
||||
return {
|
||||
kind: 'alias',
|
||||
localName: alias,
|
||||
importedName: name,
|
||||
alias,
|
||||
targetRaw: source,
|
||||
};
|
||||
}
|
||||
case 'wildcard':
|
||||
return { kind: 'wildcard', targetRaw: source.endsWith('.*') ? source : `${source}.*` };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function interpretKotlinTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null {
|
||||
const nameCap = captures['@type-binding.name'];
|
||||
const typeCap = captures['@type-binding.type'];
|
||||
if (nameCap === undefined || typeCap === undefined) return null;
|
||||
|
||||
let source: TypeRef['source'] = 'annotation';
|
||||
if (captures['@type-binding.self'] !== undefined) source = 'self';
|
||||
else if (captures['@type-binding.parameter'] !== undefined) source = 'parameter-annotation';
|
||||
else if (captures['@type-binding.return'] !== undefined) source = 'return-annotation';
|
||||
else if (captures['@type-binding.constructor'] !== undefined) source = 'constructor-inferred';
|
||||
|
||||
return {
|
||||
boundName: nameCap.text,
|
||||
rawTypeName: normalizeKotlinType(typeCap.text),
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeKotlinType(text: string): string {
|
||||
let out = text.trim();
|
||||
while (out.endsWith('?')) out = out.slice(0, -1).trim();
|
||||
const lastDot = out.lastIndexOf('.');
|
||||
if (lastDot >= 0) out = out.slice(lastDot + 1);
|
||||
|
||||
const collection = out.match(
|
||||
/^(?:List|MutableList|ArrayList|Set|MutableSet|Collection|Iterable|Sequence|Array)<([^,<>]+)>$/,
|
||||
);
|
||||
if (collection !== null) return normalizeKotlinType(collection[1]!);
|
||||
|
||||
const map = out.match(/^(?:Map|MutableMap|HashMap|LinkedHashMap)<[^,<>]+,\s*([^,<>]+)>$/);
|
||||
if (map !== null) return normalizeKotlinType(map[1]!);
|
||||
|
||||
const erased = out.match(/^([A-Za-z_][A-Za-z0-9_]*)<.+>$/s);
|
||||
if (erased !== null) return erased[1]!;
|
||||
|
||||
return out;
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import type { BindingRef } from 'gitnexus-shared';
|
||||
|
||||
function tierOf(binding: BindingRef): number {
|
||||
switch (binding.origin) {
|
||||
case 'local':
|
||||
return 0;
|
||||
case 'import':
|
||||
case 'namespace':
|
||||
case 'reexport':
|
||||
return 1;
|
||||
case 'wildcard':
|
||||
return 2;
|
||||
default:
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
|
||||
export function kotlinMergeBindings(bindings: readonly BindingRef[]): readonly BindingRef[] {
|
||||
if (bindings.length === 0) return bindings;
|
||||
const best = Math.min(...bindings.map(tierOf));
|
||||
const seen = new Map<string, BindingRef>();
|
||||
for (const binding of bindings) {
|
||||
if (tierOf(binding) === best) seen.set(binding.def.nodeId, binding);
|
||||
}
|
||||
return [...seen.values()];
|
||||
}
|
||||
50
gitnexus/src/core/ingestion/languages/kotlin/owners.ts
Normal file
50
gitnexus/src/core/ingestion/languages/kotlin/owners.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import type { ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared';
|
||||
import { isClassLike, populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js';
|
||||
|
||||
export function populateKotlinOwners(parsed: ParsedFile): void {
|
||||
populateClassOwnedMembers(parsed);
|
||||
populateCompanionMembersOnEnclosingClass(parsed);
|
||||
}
|
||||
|
||||
function populateCompanionMembersOnEnclosingClass(parsed: ParsedFile): void {
|
||||
const scopesById = new Map<ScopeId, ParsedFile['scopes'][number]>();
|
||||
for (const scope of parsed.scopes) scopesById.set(scope.id, scope);
|
||||
|
||||
for (const scope of parsed.scopes) {
|
||||
if (scope.kind !== 'Function' || scope.parent === null) continue;
|
||||
const parent = scopesById.get(scope.parent);
|
||||
if (parent === undefined || parent.kind !== 'Class') continue;
|
||||
if (parent.ownedDefs.some((def) => isClassLike(def.type))) continue;
|
||||
|
||||
const enclosing = findEnclosingClassWithDef(parent.parent, scopesById);
|
||||
if (enclosing === undefined) continue;
|
||||
for (const def of scope.ownedDefs) {
|
||||
if (def.ownerId !== undefined) continue;
|
||||
(def as { ownerId?: string }).ownerId = enclosing.nodeId;
|
||||
qualify(def, enclosing);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findEnclosingClassWithDef(
|
||||
start: ScopeId | null,
|
||||
scopesById: ReadonlyMap<ScopeId, ParsedFile['scopes'][number]>,
|
||||
): SymbolDefinition | undefined {
|
||||
let current = start;
|
||||
while (current !== null) {
|
||||
const scope = scopesById.get(current);
|
||||
if (scope === undefined) return undefined;
|
||||
if (scope.kind === 'Class') {
|
||||
const classDef = scope.ownedDefs.find((def) => isClassLike(def.type));
|
||||
if (classDef !== undefined) return classDef;
|
||||
}
|
||||
current = scope.parent;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function qualify(def: SymbolDefinition, owner: SymbolDefinition): void {
|
||||
if (def.qualifiedName === undefined || def.qualifiedName.includes('.')) return;
|
||||
if (owner.qualifiedName === undefined || owner.qualifiedName.length === 0) return;
|
||||
(def as { qualifiedName: string }).qualifiedName = `${owner.qualifiedName}.${def.qualifiedName}`;
|
||||
}
|
||||
116
gitnexus/src/core/ingestion/languages/kotlin/query.ts
Normal file
116
gitnexus/src/core/ingestion/languages/kotlin/query.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import Parser from 'tree-sitter';
|
||||
import Kotlin from 'tree-sitter-kotlin';
|
||||
|
||||
const KOTLIN_SCOPE_QUERY = `
|
||||
;; Scopes
|
||||
(source_file) @scope.module
|
||||
(class_declaration) @scope.class
|
||||
(object_declaration) @scope.class
|
||||
(companion_object) @scope.class
|
||||
(function_declaration) @scope.function
|
||||
|
||||
;; Declarations — types
|
||||
(class_declaration
|
||||
"interface"
|
||||
(type_identifier) @declaration.name) @declaration.interface
|
||||
|
||||
(class_declaration
|
||||
"class"
|
||||
(type_identifier) @declaration.name) @declaration.class
|
||||
|
||||
(object_declaration
|
||||
(type_identifier) @declaration.name) @declaration.class
|
||||
|
||||
(companion_object
|
||||
(type_identifier) @declaration.name) @declaration.class
|
||||
|
||||
(type_alias
|
||||
(type_identifier) @declaration.name) @declaration.type_alias
|
||||
|
||||
;; Declarations — functions / methods / properties
|
||||
(function_declaration
|
||||
(simple_identifier) @declaration.name) @declaration.function
|
||||
|
||||
(property_declaration
|
||||
(variable_declaration
|
||||
(simple_identifier) @declaration.name)) @declaration.property
|
||||
|
||||
(class_parameter
|
||||
(binding_pattern_kind)
|
||||
(simple_identifier) @declaration.name) @declaration.property
|
||||
|
||||
;; Imports
|
||||
(import_header) @import.statement
|
||||
|
||||
;; Type bindings — parameters
|
||||
(parameter
|
||||
(simple_identifier) @type-binding.name
|
||||
[(user_type) (nullable_type) (function_type)] @type-binding.type) @type-binding.parameter
|
||||
|
||||
;; Type bindings — property / local annotations
|
||||
(property_declaration
|
||||
(variable_declaration
|
||||
(simple_identifier) @type-binding.name
|
||||
[(user_type) (nullable_type) (function_type)] @type-binding.type)) @type-binding.annotation
|
||||
|
||||
(class_parameter
|
||||
(binding_pattern_kind)
|
||||
(simple_identifier) @type-binding.name
|
||||
[(user_type) (nullable_type) (function_type)] @type-binding.type) @type-binding.annotation
|
||||
|
||||
;; Type bindings — constructor-inferred val user = User(...)
|
||||
(property_declaration
|
||||
(variable_declaration
|
||||
(simple_identifier) @type-binding.name)
|
||||
(call_expression
|
||||
(simple_identifier) @type-binding.type)) @type-binding.constructor
|
||||
|
||||
;; Type bindings — return annotations after function parameters
|
||||
(function_declaration
|
||||
(simple_identifier) @type-binding.name
|
||||
(function_value_parameters)
|
||||
[(user_type) (nullable_type) (function_type)] @type-binding.type) @type-binding.return
|
||||
|
||||
;; References — direct calls / constructor syntax
|
||||
(call_expression
|
||||
(simple_identifier) @reference.name) @reference.call.free
|
||||
|
||||
;; References — member calls: obj.method()
|
||||
(call_expression
|
||||
(navigation_expression
|
||||
(_) @reference.receiver
|
||||
(navigation_suffix
|
||||
(simple_identifier) @reference.name))) @reference.call.member
|
||||
|
||||
;; References — property writes
|
||||
(assignment
|
||||
(directly_assignable_expression
|
||||
(_) @reference.receiver
|
||||
(navigation_suffix
|
||||
(simple_identifier) @reference.name))
|
||||
(_)) @reference.write.member
|
||||
|
||||
;; References — property reads
|
||||
(navigation_expression
|
||||
(_) @reference.receiver
|
||||
(navigation_suffix
|
||||
(simple_identifier) @reference.name)) @reference.read.member
|
||||
`;
|
||||
|
||||
let parser: Parser | null = null;
|
||||
let query: Parser.Query | null = null;
|
||||
|
||||
export function getKotlinParser(): Parser {
|
||||
if (parser === null) {
|
||||
parser = new Parser();
|
||||
parser.setLanguage(Kotlin as Parameters<Parser['setLanguage']>[0]);
|
||||
}
|
||||
return parser;
|
||||
}
|
||||
|
||||
export function getKotlinScopeQuery(): Parser.Query {
|
||||
if (query === null) {
|
||||
query = new Parser.Query(Kotlin as Parameters<Parser['setLanguage']>[0], KOTLIN_SCOPE_QUERY);
|
||||
}
|
||||
return query;
|
||||
}
|
||||
106
gitnexus/src/core/ingestion/languages/kotlin/receiver-binding.ts
Normal file
106
gitnexus/src/core/ingestion/languages/kotlin/receiver-binding.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import type { Capture, CaptureMatch } from 'gitnexus-shared';
|
||||
import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import { normalizeKotlinType } from './interpret.js';
|
||||
|
||||
const TYPE_DECL_NODE_TYPES = new Set([
|
||||
'class_declaration',
|
||||
'object_declaration',
|
||||
'companion_object',
|
||||
]);
|
||||
|
||||
export function synthesizeKotlinReceiverBinding(fnNode: SyntaxNode): CaptureMatch[] {
|
||||
if (fnNode.type !== 'function_declaration') return [];
|
||||
|
||||
const anchorNode = findFunctionBody(fnNode);
|
||||
if (anchorNode === null) return [];
|
||||
|
||||
const extensionReceiver = extensionReceiverType(fnNode);
|
||||
if (extensionReceiver !== null) {
|
||||
return [buildReceiverMatch(anchorNode, 'this', extensionReceiver)];
|
||||
}
|
||||
|
||||
const enclosingType = findEnclosingTypeDeclaration(fnNode);
|
||||
if (enclosingType === null) return [];
|
||||
|
||||
const enclosingName = typeDeclarationName(enclosingType);
|
||||
if (enclosingName === null) return [];
|
||||
|
||||
const out = [buildReceiverMatch(anchorNode, 'this', enclosingName)];
|
||||
const superName = firstSuperclassText(enclosingType);
|
||||
if (superName !== null) out.push(buildReceiverMatch(anchorNode, 'super', superName));
|
||||
return out;
|
||||
}
|
||||
|
||||
function findFunctionBody(fnNode: SyntaxNode): SyntaxNode | null {
|
||||
for (let i = 0; i < fnNode.namedChildCount; i++) {
|
||||
const child = fnNode.namedChild(i);
|
||||
if (child?.type === 'function_body') return child;
|
||||
}
|
||||
return fnNode;
|
||||
}
|
||||
|
||||
function extensionReceiverType(fnNode: SyntaxNode): string | null {
|
||||
for (let i = 0; i < fnNode.namedChildCount; i++) {
|
||||
const child = fnNode.namedChild(i);
|
||||
if (child === null) continue;
|
||||
if (child.type === 'simple_identifier') return null;
|
||||
if (child.type === 'user_type' || child.type === 'nullable_type') {
|
||||
return normalizeKotlinType(child.text);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findEnclosingTypeDeclaration(node: SyntaxNode): SyntaxNode | null {
|
||||
let current = node.parent;
|
||||
while (current !== null) {
|
||||
if (TYPE_DECL_NODE_TYPES.has(current.type)) return current;
|
||||
current = current.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function typeDeclarationName(typeNode: SyntaxNode): string | null {
|
||||
if (typeNode.type === 'companion_object') {
|
||||
return (
|
||||
typeNode.namedChildren.find((child) => child.type === 'type_identifier')?.text ??
|
||||
enclosingNonCompanionTypeName(typeNode) ??
|
||||
'Companion'
|
||||
);
|
||||
}
|
||||
return typeNode.namedChildren.find((child) => child.type === 'type_identifier')?.text ?? null;
|
||||
}
|
||||
|
||||
function enclosingNonCompanionTypeName(node: SyntaxNode): string | null {
|
||||
let current = node.parent;
|
||||
while (current !== null) {
|
||||
if (current.type === 'class_declaration' || current.type === 'object_declaration') {
|
||||
return current.namedChildren.find((child) => child.type === 'type_identifier')?.text ?? null;
|
||||
}
|
||||
current = current.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function firstSuperclassText(typeNode: SyntaxNode): string | null {
|
||||
if (typeNode.type !== 'class_declaration') return null;
|
||||
for (const child of typeNode.namedChildren) {
|
||||
if (child.type !== 'delegation_specifier') continue;
|
||||
const ctor = child.namedChildren.find((n) => n.type === 'constructor_invocation');
|
||||
const userType =
|
||||
ctor?.namedChildren.find((n) => n.type === 'user_type') ??
|
||||
child.namedChildren.find((n) => n.type === 'user_type');
|
||||
const name = userType?.namedChildren.find((n) => n.type === 'type_identifier')?.text;
|
||||
if (name !== undefined) return normalizeKotlinType(name);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildReceiverMatch(anchorNode: SyntaxNode, name: string, typeText: string): CaptureMatch {
|
||||
const out: Record<string, Capture> = {
|
||||
'@type-binding.self': nodeToCapture('@type-binding.self', anchorNode),
|
||||
'@type-binding.name': syntheticCapture('@type-binding.name', anchorNode, name),
|
||||
'@type-binding.type': syntheticCapture('@type-binding.type', anchorNode, typeText),
|
||||
};
|
||||
return out;
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import { SupportedLanguages, type ParsedFile } from 'gitnexus-shared';
|
||||
import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js';
|
||||
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
|
||||
import { kotlinProvider } from '../kotlin.js';
|
||||
import {
|
||||
kotlinArityCompatibility,
|
||||
kotlinMergeBindings,
|
||||
populateKotlinOwners,
|
||||
resolveKotlinImportTarget,
|
||||
type KotlinResolveContext,
|
||||
} from './index.js';
|
||||
|
||||
/**
|
||||
* Kotlin scope resolver for RFC #909 Ring 3.
|
||||
*
|
||||
* Kotlin is intentionally registered but not yet listed in
|
||||
* `MIGRATED_LANGUAGES`, matching the Java migration pattern from #1482:
|
||||
* the resolver can run in shadow/forced mode, while production default
|
||||
* stays on the legacy DAG until registry-primary parity reaches the
|
||||
* RFC threshold. Forced mode currently passes 154/175 fixtures (88%),
|
||||
* including core import, receiver, companion, default-param, vararg,
|
||||
* constructor, local assignment-chain, and collection-iteration fixtures.
|
||||
* Remaining gaps are advanced TypeEnv behaviors such as smart casts,
|
||||
* cross-file iterable return propagation, method-chain fixpoint cases,
|
||||
* overload target-id selection, virtual dispatch, and interface default
|
||||
* method dispatch.
|
||||
*/
|
||||
export const kotlinScopeResolver: ScopeResolver = {
|
||||
language: SupportedLanguages.Kotlin,
|
||||
languageProvider: kotlinProvider,
|
||||
importEdgeReason: 'kotlin-scope: import',
|
||||
|
||||
resolveImportTarget: (targetRaw, fromFile, allFilePaths) => {
|
||||
const ws: KotlinResolveContext = { fromFile, allFilePaths };
|
||||
return resolveKotlinImportTarget(
|
||||
{ kind: 'named', localName: '_', importedName: '_', targetRaw },
|
||||
ws,
|
||||
);
|
||||
},
|
||||
|
||||
mergeBindings: (existing, incoming) => [...kotlinMergeBindings([...existing, ...incoming])],
|
||||
|
||||
arityCompatibility: (callsite, def) => kotlinArityCompatibility(def, callsite),
|
||||
|
||||
buildMro: (graph, parsedFiles, nodeLookup) =>
|
||||
buildMro(graph, parsedFiles, nodeLookup, defaultLinearize),
|
||||
|
||||
populateOwners: (parsed: ParsedFile) => populateKotlinOwners(parsed),
|
||||
|
||||
isSuperReceiver: (text) => text.trim() === 'super',
|
||||
|
||||
fieldFallbackOnMethodLookup: false,
|
||||
propagatesReturnTypesAcrossImports: true,
|
||||
collapseMemberCallsByCallerTarget: false,
|
||||
hoistTypeBindingsToModule: true,
|
||||
};
|
||||
36
gitnexus/src/core/ingestion/languages/kotlin/simple-hooks.ts
Normal file
36
gitnexus/src/core/ingestion/languages/kotlin/simple-hooks.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import type {
|
||||
CaptureMatch,
|
||||
ParsedImport,
|
||||
Scope,
|
||||
ScopeId,
|
||||
ScopeTree,
|
||||
TypeRef,
|
||||
} from 'gitnexus-shared';
|
||||
|
||||
export function kotlinBindingScopeFor(
|
||||
decl: CaptureMatch,
|
||||
innermost: Scope,
|
||||
tree: ScopeTree,
|
||||
): ScopeId | null {
|
||||
if (decl['@type-binding.return'] === undefined) return null;
|
||||
|
||||
let current: Scope | undefined = innermost;
|
||||
while (current !== undefined && current.kind !== 'Module') {
|
||||
if (current.parent === null) break;
|
||||
current = tree.getScope(current.parent);
|
||||
}
|
||||
return current?.kind === 'Module' ? current.id : null;
|
||||
}
|
||||
|
||||
export function kotlinImportOwningScope(
|
||||
_imp: ParsedImport,
|
||||
_innermost: Scope,
|
||||
_tree: ScopeTree,
|
||||
): ScopeId | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function kotlinReceiverBinding(functionScope: Scope): TypeRef | null {
|
||||
if (functionScope.kind !== 'Function') return null;
|
||||
return functionScope.typeBindings.get('this') ?? functionScope.typeBindings.get('super') ?? null;
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ import { cScopeResolver } from '../../languages/c/scope-resolver.js';
|
|||
import { cppScopeResolver } from '../../languages/cpp/scope-resolver.js';
|
||||
import { phpScopeResolver } from '../../languages/php/scope-resolver.js';
|
||||
import { javascriptScopeResolver } from '../../languages/javascript/scope-resolver.js';
|
||||
import { kotlinScopeResolver } from '../../languages/kotlin/scope-resolver.js';
|
||||
|
||||
/** Map of `SupportedLanguages` → `ScopeResolver`. The phase iterates
|
||||
* this map intersected with `MIGRATED_LANGUAGES` (the per-language
|
||||
|
|
@ -38,4 +39,5 @@ export const SCOPE_RESOLVERS: ReadonlyMap<SupportedLanguages, ScopeResolver> = n
|
|||
[SupportedLanguages.CPlusPlus, cppScopeResolver],
|
||||
[SupportedLanguages.PHP, phpScopeResolver],
|
||||
[SupportedLanguages.JavaScript, javascriptScopeResolver],
|
||||
[SupportedLanguages.Kotlin, kotlinScopeResolver],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
/**
|
||||
* Kotlin: data class extends + implements interfaces + ambiguous import disambiguation
|
||||
*/
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import { describe, expect, beforeAll } from 'vitest';
|
||||
import path from 'path';
|
||||
import {
|
||||
FIXTURES,
|
||||
CROSS_FILE_FIXTURES,
|
||||
createResolverParityIt,
|
||||
getRelationships,
|
||||
getNodesByLabel,
|
||||
getNodesByLabelFull,
|
||||
|
|
@ -14,6 +15,8 @@ import {
|
|||
type PipelineResult,
|
||||
} from './helpers.js';
|
||||
|
||||
const it = createResolverParityIt('kotlin');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Heritage: data class extends + implements interfaces (delegation specifiers)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
79
gitnexus/test/unit/kotlin-scope-captures.test.ts
Normal file
79
gitnexus/test/unit/kotlin-scope-captures.test.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { emitKotlinScopeCaptures } from '../../src/core/ingestion/languages/kotlin/captures.js';
|
||||
|
||||
function captureTexts(source: string): Array<Record<string, string>> {
|
||||
return emitKotlinScopeCaptures(source, 'fixture.kt').map((match) =>
|
||||
Object.fromEntries(Object.entries(match).map(([tag, capture]) => [tag, capture.text])),
|
||||
);
|
||||
}
|
||||
|
||||
describe('Kotlin scope captures', () => {
|
||||
it('counts trailing lambda call suffixes as call arguments', () => {
|
||||
const captures = captureTexts(`
|
||||
fun run(items: List<String>) {
|
||||
items.forEach { println(it) }
|
||||
}
|
||||
`);
|
||||
|
||||
const forEach = captures.find(
|
||||
(match) =>
|
||||
match['@reference.call.member'] === 'items.forEach { println(it) }' &&
|
||||
match['@reference.name'] === 'forEach',
|
||||
);
|
||||
|
||||
expect(forEach?.['@reference.arity']).toBe('1');
|
||||
});
|
||||
|
||||
it('does not synthesize extension free-call fallback for chained regular member calls', () => {
|
||||
const captures = captureTexts(`
|
||||
class Service {
|
||||
fun current(): Service = this
|
||||
fun save() {}
|
||||
}
|
||||
|
||||
fun run(service: Service) {
|
||||
service.current().save()
|
||||
}
|
||||
`);
|
||||
|
||||
const syntheticSaveFreeCalls = captures.filter(
|
||||
(match) =>
|
||||
match['@reference.call.free'] === 'service.current().save()' &&
|
||||
match['@reference.name'] === 'save',
|
||||
);
|
||||
|
||||
expect(syntheticSaveFreeCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps literal-receiver extension fallback for extension-call candidates', () => {
|
||||
const captures = captureTexts(`
|
||||
fun String.slug(): String = this
|
||||
|
||||
fun run() {
|
||||
"hello".slug()
|
||||
}
|
||||
`);
|
||||
|
||||
const slugFreeCall = captures.find(
|
||||
(match) =>
|
||||
match['@reference.call.free'] === '"hello".slug()' && match['@reference.name'] === 'slug',
|
||||
);
|
||||
|
||||
expect(slugFreeCall).toBeDefined();
|
||||
});
|
||||
|
||||
it('does not emit a self type binding keyed by the extension function name', () => {
|
||||
const captures = captureTexts(`
|
||||
fun String.slug(): String = this
|
||||
`);
|
||||
|
||||
const spuriousSelfBinding = captures.find(
|
||||
(match) =>
|
||||
match['@type-binding.self'] !== undefined &&
|
||||
match['@type-binding.name'] === 'slug' &&
|
||||
match['@type-binding.type'] === 'String',
|
||||
);
|
||||
|
||||
expect(spuriousSelfBinding).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue