diff --git a/gitnexus/src/core/ingestion/languages/kotlin.ts b/gitnexus/src/core/ingestion/languages/kotlin.ts index 78d8726e6d..426eb49f86 100644 --- a/gitnexus/src/core/ingestion/languages/kotlin.ts +++ b/gitnexus/src/core/ingestion/languages/kotlin.ts @@ -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, }); diff --git a/gitnexus/src/core/ingestion/languages/kotlin/arity-metadata.ts b/gitnexus/src/core/ingestion/languages/kotlin/arity-metadata.ts new file mode 100644 index 0000000000..5cdf76cf63 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/kotlin/arity-metadata.ts @@ -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, + }; +} diff --git a/gitnexus/src/core/ingestion/languages/kotlin/arity.ts b/gitnexus/src/core/ingestion/languages/kotlin/arity.ts new file mode 100644 index 0000000000..1f31ee965c --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/kotlin/arity.ts @@ -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'; +} diff --git a/gitnexus/src/core/ingestion/languages/kotlin/cache-stats.ts b/gitnexus/src/core/ingestion/languages/kotlin/cache-stats.ts new file mode 100644 index 0000000000..824752d60e --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/kotlin/cache-stats.ts @@ -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; +} diff --git a/gitnexus/src/core/ingestion/languages/kotlin/captures.ts b/gitnexus/src/core/ingestion/languages/kotlin/captures.ts new file mode 100644 index 0000000000..be8b3503db --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/kotlin/captures.ts @@ -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['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 = {}; + 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, +): 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, +): CaptureMatch[] { + const out: CaptureMatch[] = []; + for (const fnNode of descendantsOfType(rootNode, 'function_declaration')) { + const localTypes = new Map(); + 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, +): Map { + const out = new Map(); + 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 { + const out = new Map(); + 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, + returnTypes: ReadonlyMap, +): { 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, + returnTypes: ReadonlyMap, +): 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, + 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 = { + '@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); +} diff --git a/gitnexus/src/core/ingestion/languages/kotlin/import-decomposer.ts b/gitnexus/src/core/ingestion/languages/kotlin/import-decomposer.ts new file mode 100644 index 0000000000..6a4216b0b3 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/kotlin/import-decomposer.ts @@ -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 = { + '@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 }; +} diff --git a/gitnexus/src/core/ingestion/languages/kotlin/import-target.ts b/gitnexus/src/core/ingestion/languages/kotlin/import-target.ts new file mode 100644 index 0000000000..63b89b1136 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/kotlin/import-target.ts @@ -0,0 +1,76 @@ +import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared'; + +export interface KotlinResolveContext { + readonly fromFile: string; + readonly allFilePaths: ReadonlySet; +} + +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, 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, + 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; +} diff --git a/gitnexus/src/core/ingestion/languages/kotlin/index.ts b/gitnexus/src/core/ingestion/languages/kotlin/index.ts new file mode 100644 index 0000000000..206128e52c --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/kotlin/index.ts @@ -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'; diff --git a/gitnexus/src/core/ingestion/languages/kotlin/interpret.ts b/gitnexus/src/core/ingestion/languages/kotlin/interpret.ts new file mode 100644 index 0000000000..625deb0c2a --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/kotlin/interpret.ts @@ -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; +} diff --git a/gitnexus/src/core/ingestion/languages/kotlin/merge-bindings.ts b/gitnexus/src/core/ingestion/languages/kotlin/merge-bindings.ts new file mode 100644 index 0000000000..c91d9ed07e --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/kotlin/merge-bindings.ts @@ -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(); + for (const binding of bindings) { + if (tierOf(binding) === best) seen.set(binding.def.nodeId, binding); + } + return [...seen.values()]; +} diff --git a/gitnexus/src/core/ingestion/languages/kotlin/owners.ts b/gitnexus/src/core/ingestion/languages/kotlin/owners.ts new file mode 100644 index 0000000000..db6ddf6322 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/kotlin/owners.ts @@ -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(); + 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, +): 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}`; +} diff --git a/gitnexus/src/core/ingestion/languages/kotlin/query.ts b/gitnexus/src/core/ingestion/languages/kotlin/query.ts new file mode 100644 index 0000000000..d8b0244b85 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/kotlin/query.ts @@ -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[0]); + } + return parser; +} + +export function getKotlinScopeQuery(): Parser.Query { + if (query === null) { + query = new Parser.Query(Kotlin as Parameters[0], KOTLIN_SCOPE_QUERY); + } + return query; +} diff --git a/gitnexus/src/core/ingestion/languages/kotlin/receiver-binding.ts b/gitnexus/src/core/ingestion/languages/kotlin/receiver-binding.ts new file mode 100644 index 0000000000..da89272a87 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/kotlin/receiver-binding.ts @@ -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 = { + '@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; +} diff --git a/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts new file mode 100644 index 0000000000..2b20808ad5 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/kotlin/scope-resolver.ts @@ -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, +}; diff --git a/gitnexus/src/core/ingestion/languages/kotlin/simple-hooks.ts b/gitnexus/src/core/ingestion/languages/kotlin/simple-hooks.ts new file mode 100644 index 0000000000..be07602eb9 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/kotlin/simple-hooks.ts @@ -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; +} diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts index 4759598a93..ac862cd617 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/registry.ts @@ -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 = n [SupportedLanguages.CPlusPlus, cppScopeResolver], [SupportedLanguages.PHP, phpScopeResolver], [SupportedLanguages.JavaScript, javascriptScopeResolver], + [SupportedLanguages.Kotlin, kotlinScopeResolver], ]); diff --git a/gitnexus/test/integration/resolvers/kotlin.test.ts b/gitnexus/test/integration/resolvers/kotlin.test.ts index 056a2514af..d70a240d14 100644 --- a/gitnexus/test/integration/resolvers/kotlin.test.ts +++ b/gitnexus/test/integration/resolvers/kotlin.test.ts @@ -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) // --------------------------------------------------------------------------- diff --git a/gitnexus/test/unit/kotlin-scope-captures.test.ts b/gitnexus/test/unit/kotlin-scope-captures.test.ts new file mode 100644 index 0000000000..bba271ac0f --- /dev/null +++ b/gitnexus/test/unit/kotlin-scope-captures.test.ts @@ -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> { + 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) { + 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(); + }); +});