diff --git a/gitnexus/src/core/ingestion/languages/rust.ts b/gitnexus/src/core/ingestion/languages/rust.ts index e7638e4ea..be4be7cba 100644 --- a/gitnexus/src/core/ingestion/languages/rust.ts +++ b/gitnexus/src/core/ingestion/languages/rust.ts @@ -32,6 +32,15 @@ import { rustVariableConfig } from '../variable-extractors/configs/rust.js'; import { createCallExtractor } from '../call-extractors/generic.js'; import { rustCallConfig } from '../call-extractors/configs/rust.js'; import { createHeritageExtractor } from '../heritage-extractors/generic.js'; +import { + emitRustScopeCaptures, + rustArityCompatibility, + rustBindingScopeFor, + rustImportOwningScope, + rustReceiverBinding, + interpretRustImport, + interpretRustTypeBinding, +} from './rust/index.js'; /** Rust impl_item: find the function_item child and extract its name as a Method. */ const rustExtractFunctionName = ( @@ -173,4 +182,12 @@ export const rustProvider = defineLanguage({ classExtractor: createClassExtractor(rustClassConfig), heritageExtractor: createHeritageExtractor(SupportedLanguages.Rust), builtInNames: BUILT_INS, + // ── RFC #909 Ring 3: scope-based resolution hooks ────────── + emitScopeCaptures: emitRustScopeCaptures, + interpretImport: interpretRustImport, + interpretTypeBinding: interpretRustTypeBinding, + bindingScopeFor: rustBindingScopeFor, + importOwningScope: rustImportOwningScope, + receiverBinding: rustReceiverBinding, + arityCompatibility: rustArityCompatibility, }); diff --git a/gitnexus/src/core/ingestion/languages/rust/arity.ts b/gitnexus/src/core/ingestion/languages/rust/arity.ts new file mode 100644 index 000000000..8b082c482 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/rust/arity.ts @@ -0,0 +1,15 @@ +import type { Callsite, SymbolDefinition } from 'gitnexus-shared'; + +export function rustArityCompatibility( + def: SymbolDefinition, + callsite: Callsite, +): 'compatible' | 'unknown' | 'incompatible' { + const max = def.parameterCount; + const min = def.requiredParameterCount; + if (max === undefined && min === undefined) return 'unknown'; + if (!Number.isFinite(callsite.arity) || callsite.arity < 0) return 'unknown'; + + if (min !== undefined && callsite.arity < min) return 'incompatible'; + if (max !== undefined && callsite.arity > max) return 'incompatible'; + return 'compatible'; +} diff --git a/gitnexus/src/core/ingestion/languages/rust/cache-stats.ts b/gitnexus/src/core/ingestion/languages/rust/cache-stats.ts new file mode 100644 index 000000000..8ba83ae89 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/rust/cache-stats.ts @@ -0,0 +1,18 @@ +let hits = 0; +let misses = 0; + +export function recordRustCacheHit(): void { + hits++; +} +export function recordRustCacheMiss(): void { + misses++; +} + +export function getRustCaptureCacheStats(): { readonly hits: number; readonly misses: number } { + return { hits, misses }; +} + +export function resetRustCaptureCacheStats(): void { + hits = 0; + misses = 0; +} diff --git a/gitnexus/src/core/ingestion/languages/rust/captures.ts b/gitnexus/src/core/ingestion/languages/rust/captures.ts new file mode 100644 index 000000000..90452db12 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/rust/captures.ts @@ -0,0 +1,160 @@ +import type { Capture, CaptureMatch } from 'gitnexus-shared'; +import { + findNodeAtRange, + nodeToCapture, + syntheticCapture, + type SyntaxNode, +} from '../../utils/ast-helpers.js'; +import { getRustParser, getRustScopeQuery } from './query.js'; +import { recordRustCacheHit, recordRustCacheMiss } from './cache-stats.js'; +import { splitRustUseDeclaration } from './import-decomposer.js'; +import { synthesizeRustReceiverBinding } from './receiver-binding.js'; +import { getTreeSitterBufferSize } from '../../constants.js'; +import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; + +export function emitRustScopeCaptures( + sourceText: string, + _filePath: string, + cachedTree?: unknown, +): readonly CaptureMatch[] { + let tree = cachedTree as ReturnType['parse']> | undefined; + if (tree === undefined) { + tree = parseSourceSafe(getRustParser(), sourceText, undefined, { + bufferSize: getTreeSitterBufferSize(sourceText), + }); + recordRustCacheMiss(); + } else { + recordRustCacheHit(); + } + + const rawMatches = getRustScopeQuery().matches(tree.rootNode); + const out: CaptureMatch[] = []; + + for (const m of rawMatches) { + const grouped: Record = {}; + for (const c of m.captures) { + const tag = '@' + c.name; + if (tag.startsWith('@_')) continue; + grouped[tag] = nodeToCapture(tag, c.node); + } + if (Object.keys(grouped).length === 0) continue; + + // Decompose use declarations into individual import captures + if (grouped['@import.statement'] !== undefined) { + const anchor = grouped['@import.statement']!; + const useNode = findNodeAtRange(tree.rootNode, anchor.range, 'use_declaration'); + if (useNode !== null) { + out.push(...splitRustUseDeclaration(useNode)); + continue; + } + } + + // Synthesize self receiver bindings for methods inside impl blocks + if (grouped['@scope.function'] !== undefined) { + const scopeCap = grouped['@scope.function']!; + const fnNode = findNodeAtRange(tree.rootNode, scopeCap.range, 'function_item'); + if (fnNode !== null) { + const implNode = findEnclosingImpl(fnNode); + const receiver = synthesizeRustReceiverBinding(fnNode, implNode); + if (receiver !== null) out.push(receiver); + } + } + + // Attach declaration arity for functions/methods + const declAnchor = grouped['@declaration.function']; + if (declAnchor !== undefined) { + const fnNode = findNodeAtRange(tree.rootNode, declAnchor.range, 'function_item'); + if (fnNode !== null) { + const arity = computeRustDeclarationArity(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), + ); + } + } + } + + // Attach call arity for call expressions + const callAnchor = + grouped['@reference.call.free'] ?? + grouped['@reference.call.member'] ?? + grouped['@reference.call.constructor']; + if (callAnchor !== undefined) { + const callNode = findNodeAtRange(tree.rootNode, callAnchor.range, 'call_expression') ?? + findNodeAtRange(tree.rootNode, callAnchor.range, 'struct_expression'); + if (callNode !== null) { + const arity = computeRustCallArity(callNode); + if (arity !== undefined) { + grouped['@reference.arity'] = syntheticCapture( + '@reference.arity', + callNode, + String(arity), + ); + } + } + } + + out.push(grouped); + } + + return out; +} + +function findEnclosingImpl(node: SyntaxNode): SyntaxNode | null { + let current: SyntaxNode | null = node.parent; + while (current !== null) { + if (current.type === 'impl_item') return current; + if (current.type === 'source_file' || current.type === 'mod_item') return null; + current = current.parent; + } + return null; +} + +function computeRustDeclarationArity( + fnNode: SyntaxNode, +): { parameterCount?: number; requiredParameterCount?: number } { + const params = fnNode.childForFieldName('parameters'); + if (params === null) return {}; + + let count = 0; + for (let i = 0; i < params.namedChildCount; i++) { + const child = params.namedChild(i); + if (child === null) continue; + if (child.type === 'self_parameter') continue; + if (child.type === 'parameter') count++; + } + // Rust has no default parameters or overloading + return { parameterCount: count, requiredParameterCount: count }; +} + +function computeRustCallArity(callNode: SyntaxNode): number | undefined { + if (callNode.type === 'struct_expression') { + const body = callNode.childForFieldName('body'); + if (body === null) return 0; + let count = 0; + for (let i = 0; i < body.namedChildCount; i++) { + if (body.namedChild(i)?.type === 'field_initializer') count++; + if (body.namedChild(i)?.type === 'shorthand_field_initializer') count++; + } + return count; + } + + const args = callNode.childForFieldName('arguments'); + if (args === null) return 0; + + let count = 0; + for (let i = 0; i < args.namedChildCount; i++) { + const child = args.namedChild(i); + if (child !== null) count++; + } + return count; +} diff --git a/gitnexus/src/core/ingestion/languages/rust/import-decomposer.ts b/gitnexus/src/core/ingestion/languages/rust/import-decomposer.ts new file mode 100644 index 000000000..b7a92fb9f --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/rust/import-decomposer.ts @@ -0,0 +1,208 @@ +import type { CaptureMatch } from 'gitnexus-shared'; +import { syntheticCapture } from '../../utils/ast-helpers.js'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +/** + * Decompose a Rust `use_declaration` into individual import captures. + * Handles simple paths, grouped imports ({A, B}), wildcards (*), + * renames (as), and `pub use` re-exports. + */ +export function splitRustUseDeclaration(node: SyntaxNode): CaptureMatch[] { + if (node.type !== 'use_declaration') return []; + + const isReexport = hasVisibilityModifier(node); + const argument = getUseArgument(node); + if (argument === null) return []; + + return decomposeUseArgument(argument, '', isReexport, node); +} + +function hasVisibilityModifier(node: SyntaxNode): boolean { + for (let i = 0; i < node.childCount; i++) { + if (node.child(i)?.type === 'visibility_modifier') return true; + } + return false; +} + +function getUseArgument(node: SyntaxNode): SyntaxNode | null { + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child === null) continue; + if ( + child.type === 'scoped_identifier' || + child.type === 'scoped_use_list' || + child.type === 'use_wildcard' || + child.type === 'use_as_clause' || + child.type === 'identifier' || + child.type === 'use_list' + ) { + return child; + } + } + return null; +} + +function decomposeUseArgument( + node: SyntaxNode, + prefixPath: string, + isReexport: boolean, + anchor: SyntaxNode, +): CaptureMatch[] { + switch (node.type) { + case 'scoped_identifier': { + const path = buildScopedPath(node); + const segments = path.split('::'); + const name = segments[segments.length - 1]; + return [ + makeImportCapture( + anchor, + isReexport ? 'reexport' : 'named', + joinPaths(prefixPath, path), + name, + undefined, + ), + ]; + } + + case 'scoped_use_list': { + const pathNode = node.childForFieldName('path'); + const listNode = node.childForFieldName('list'); + const pathStr = pathNode ? buildNodePath(pathNode) : ''; + const fullPrefix = joinPaths(prefixPath, pathStr); + if (listNode === null) return []; + return decomposeUseList(listNode, fullPrefix, isReexport, anchor); + } + + case 'use_list': { + return decomposeUseList(node, prefixPath, isReexport, anchor); + } + + case 'use_wildcard': { + const wcPath = buildWildcardPath(node); + return [makeImportCapture(anchor, 'wildcard', joinPaths(prefixPath, wcPath), '*', undefined)]; + } + + case 'use_as_clause': { + const pathChild = node.childForFieldName('path'); + const aliasChild = node.childForFieldName('alias'); + if (pathChild === null || aliasChild === null) return []; + const originalName = + pathChild.type === 'scoped_identifier' + ? buildScopedPath(pathChild) + : pathChild.text; + const aliasName = aliasChild.text; + const segments = originalName.split('::'); + const importedName = segments[segments.length - 1]; + return [ + makeImportCapture( + anchor, + isReexport ? 'reexport' : 'named', + joinPaths(prefixPath, originalName), + importedName, + aliasName, + ), + ]; + } + + case 'identifier': { + return [ + makeImportCapture( + anchor, + isReexport ? 'reexport' : 'named', + joinPaths(prefixPath, node.text), + node.text, + undefined, + ), + ]; + } + + default: + return []; + } +} + +function decomposeUseList( + listNode: SyntaxNode, + prefix: string, + isReexport: boolean, + anchor: SyntaxNode, +): CaptureMatch[] { + const out: CaptureMatch[] = []; + for (let i = 0; i < listNode.namedChildCount; i++) { + const child = listNode.namedChild(i); + if (child === null) continue; + + if (child.type === 'self') { + // `use crate::models::{self}` — imports the module itself + const segments = prefix.split('::').filter(Boolean); + const name = segments[segments.length - 1] ?? 'self'; + out.push(makeImportCapture(anchor, 'namespace', prefix, name, undefined)); + } else { + out.push(...decomposeUseArgument(child, prefix, isReexport, anchor)); + } + } + return out; +} + +function buildScopedPath(node: SyntaxNode): string { + if (node.type === 'scoped_identifier') { + const parts: string[] = []; + collectScopedParts(node, parts); + return parts.join('::'); + } + return node.text; +} + +function collectScopedParts(node: SyntaxNode, parts: string[]): void { + if (node.type === 'scoped_identifier') { + const pathNode = node.childForFieldName('path'); + const nameNode = node.childForFieldName('name'); + if (pathNode) collectScopedParts(pathNode, parts); + if (nameNode) parts.push(nameNode.text); + } else { + parts.push(node.text); + } +} + +function buildNodePath(node: SyntaxNode): string { + if (node.type === 'scoped_identifier') { + return buildScopedPath(node); + } + return node.text; +} + +function buildWildcardPath(node: SyntaxNode): string { + for (let i = 0; i < node.childCount; i++) { + const child = node.child(i); + if (child === null) continue; + if (child.type === 'scoped_identifier') return buildScopedPath(child); + if (child.type === 'identifier') return child.text; + } + return ''; +} + +function joinPaths(prefix: string, suffix: string): string { + if (!prefix) return suffix; + if (!suffix) return prefix; + return `${prefix}::${suffix}`; +} + +function makeImportCapture( + anchor: SyntaxNode, + kind: string, + source: string, + name: string, + alias: string | undefined, +): CaptureMatch { + const result: CaptureMatch = { + '@import.statement': syntheticCapture('@import.statement', anchor, anchor.text), + '@import.kind': syntheticCapture('@import.kind', anchor, kind), + '@import.source': syntheticCapture('@import.source', anchor, source), + '@import.name': syntheticCapture('@import.name', anchor, alias ?? name), + }; + if (alias !== undefined) { + result['@import.alias'] = syntheticCapture('@import.alias', anchor, alias); + result['@import.original-name'] = syntheticCapture('@import.original-name', anchor, name); + } + return result; +} diff --git a/gitnexus/src/core/ingestion/languages/rust/import-target.ts b/gitnexus/src/core/ingestion/languages/rust/import-target.ts new file mode 100644 index 000000000..0bf67e55e --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/rust/import-target.ts @@ -0,0 +1,128 @@ +/** + * Resolve a Rust `use` import path to a repo-relative file path. + * + * Rust module resolution rules: + * - `crate::foo::bar` → `src/foo/bar.rs` or `src/foo/bar/mod.rs` + * - `super::foo` → parent directory's `foo.rs` or `foo/mod.rs` + * - `self::foo` → same directory's `foo.rs` or `foo/mod.rs` + * - External crate imports (no `crate::`/`super::`/`self::`) → null + */ +export function resolveRustImportTarget( + targetRaw: string, + fromFile: string, + allFilePaths: ReadonlySet, + _resolutionConfig?: unknown, +): string | readonly string[] | null { + if (!targetRaw) return null; + + const segments = targetRaw.split('::').filter(Boolean); + if (segments.length === 0) return null; + + const fromNormalized = fromFile.replace(/\\/g, '/'); + const fromDir = fromNormalized.includes('/') + ? fromNormalized.slice(0, fromNormalized.lastIndexOf('/')) + : ''; + + if (segments[0] === 'crate') { + const cratePath = segments.slice(1); + return resolveModulePath(cratePath, findSrcRoot(fromNormalized), allFilePaths); + } + + if (segments[0] === 'super') { + const parentDir = fromDir.includes('/') + ? fromDir.slice(0, fromDir.lastIndexOf('/')) + : ''; + const restPath = segments.slice(1); + return resolveModulePath(restPath, parentDir, allFilePaths); + } + + if (segments[0] === 'self') { + const restPath = segments.slice(1); + return resolveModulePath(restPath, fromDir, allFilePaths); + } + + // External crate — try workspace-level resolution + return resolveWorkspaceCrate(segments, allFilePaths); +} + +function findSrcRoot(filePath: string): string { + const normalized = filePath.replace(/\\/g, '/'); + const srcIdx = normalized.lastIndexOf('/src/'); + if (srcIdx !== -1) return normalized.slice(0, srcIdx + 4); // includes trailing /src + if (normalized.startsWith('src/')) return 'src'; + return ''; +} + +function resolveModulePath( + pathSegments: string[], + baseDir: string, + allFilePaths: ReadonlySet, +): string | readonly string[] | null { + if (pathSegments.length === 0) { + const modPath = baseDir ? `${baseDir}/mod.rs` : 'mod.rs'; + if (allFilePaths.has(modPath)) return modPath; + return null; + } + + const modulePath = pathSegments.join('/'); + + // Try direct file + const directFile = baseDir ? `${baseDir}/${modulePath}.rs` : `${modulePath}.rs`; + if (allFilePaths.has(directFile)) return directFile; + + // Try mod.rs inside directory + const modFile = baseDir ? `${baseDir}/${modulePath}/mod.rs` : `${modulePath}/mod.rs`; + if (allFilePaths.has(modFile)) return modFile; + + // Try partial path resolution: for `use crate::models::User` where + // User is a type inside models.rs, resolve to `src/models.rs` + if (pathSegments.length >= 2) { + const parentPath = pathSegments.slice(0, -1).join('/'); + const parentFile = baseDir ? `${baseDir}/${parentPath}.rs` : `${parentPath}.rs`; + if (allFilePaths.has(parentFile)) return parentFile; + + const parentModFile = baseDir + ? `${baseDir}/${parentPath}/mod.rs` + : `${parentPath}/mod.rs`; + if (allFilePaths.has(parentModFile)) return parentModFile; + } + + // Fallback: try increasingly shorter path prefixes + for (let i = pathSegments.length - 2; i >= 1; i--) { + const prefix = pathSegments.slice(0, i).join('/'); + const prefixFile = baseDir ? `${baseDir}/${prefix}.rs` : `${prefix}.rs`; + if (allFilePaths.has(prefixFile)) return prefixFile; + const prefixModFile = baseDir ? `${baseDir}/${prefix}/mod.rs` : `${prefix}/mod.rs`; + if (allFilePaths.has(prefixModFile)) return prefixModFile; + } + + return null; +} + +function resolveWorkspaceCrate( + segments: string[], + allFilePaths: ReadonlySet, +): string | null { + const crateName = segments[0]; + const restSegments = segments.slice(1); + + const candidates = [ + restSegments.length > 0 + ? `${crateName}/src/${restSegments.join('/')}.rs` + : `${crateName}/src/lib.rs`, + restSegments.length > 0 + ? `${crateName}/src/${restSegments.join('/')}/mod.rs` + : `${crateName}/src/lib.rs`, + ]; + + for (const candidate of candidates) { + if (allFilePaths.has(candidate)) return candidate; + } + + return null; +} + +export interface RustResolveContext { + readonly fromFile: string; + readonly allFilePaths: ReadonlySet; +} diff --git a/gitnexus/src/core/ingestion/languages/rust/index.ts b/gitnexus/src/core/ingestion/languages/rust/index.ts new file mode 100644 index 000000000..65b85160c --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/rust/index.ts @@ -0,0 +1,12 @@ +/** + * Rust scope-resolution hooks (RFC #909 Ring 3). + */ +export { emitRustScopeCaptures } from './captures.js'; +export { getRustCaptureCacheStats, resetRustCaptureCacheStats } from './cache-stats.js'; +export { interpretRustImport, interpretRustTypeBinding, normalizeRustTypeName } from './interpret.js'; +export { splitRustUseDeclaration } from './import-decomposer.js'; +export { synthesizeRustReceiverBinding } from './receiver-binding.js'; +export { rustArityCompatibility } from './arity.js'; +export { rustMergeBindings } from './merge-bindings.js'; +export { rustBindingScopeFor, rustImportOwningScope, rustReceiverBinding } from './simple-hooks.js'; +export { resolveRustImportTarget, type RustResolveContext } from './import-target.js'; diff --git a/gitnexus/src/core/ingestion/languages/rust/interpret.ts b/gitnexus/src/core/ingestion/languages/rust/interpret.ts new file mode 100644 index 000000000..3bbb8c204 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/rust/interpret.ts @@ -0,0 +1,162 @@ +import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared'; + +// ─── interpretImport ────────────────────────────────────────────────────── + +export function interpretRustImport(captures: CaptureMatch): ParsedImport | null { + const kind = captures['@import.kind']?.text; + const source = captures['@import.source']?.text; + const name = captures['@import.name']?.text; + const alias = captures['@import.alias']?.text; + if (kind === undefined || source === undefined) return null; + + if (kind === 'wildcard') return { kind: 'wildcard', targetRaw: source }; + if (kind === 'namespace') { + if (name === undefined) return null; + return { kind: 'namespace', localName: name, importedName: name, targetRaw: source }; + } + if (kind === 'reexport') { + if (name === undefined) return null; + const originalName = captures['@import.original-name']?.text; + return { + kind: 'named', + localName: alias ?? name, + importedName: originalName ?? name, + targetRaw: source, + isReexport: true, + }; + } + // kind === 'named' + if (name === undefined) return null; + const originalName = captures['@import.original-name']?.text; + return { + kind: 'named', + localName: alias ?? name, + importedName: originalName ?? name, + targetRaw: source, + }; +} + +// ─── interpretTypeBinding ───────────────────────────────────────────────── + +export function interpretRustTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null { + const name = captures['@type-binding.name']?.text; + const type = captures['@type-binding.type']?.text; + if (name === undefined || type === undefined) return null; + + let source: TypeRef['source'] = 'annotation'; + let normalizedType: string; + + if (captures['@type-binding.self'] !== undefined) { + source = 'self'; + normalizedType = normalizeRustTypeName(type); + } else if (captures['@type-binding.constructor'] !== undefined) { + source = 'constructor-inferred'; + normalizedType = normalizeRustTypeName(type); + } else if (captures['@type-binding.call-return'] !== undefined) { + source = 'constructor-inferred'; + normalizedType = normalizeRustCallReturnType(type); + } else if (captures['@type-binding.return'] !== undefined) { + source = 'return-annotation'; + normalizedType = normalizeRustReturnType(type); + } else if (captures['@type-binding.assignment'] !== undefined) { + source = 'assignment-inferred'; + normalizedType = normalizeRustTypeName(type); + } else if (captures['@type-binding.alias'] !== undefined) { + source = 'assignment-inferred'; + normalizedType = normalizeRustTypeName(type); + } else if (captures['@type-binding.parameter'] !== undefined) { + source = 'parameter-annotation'; + normalizedType = normalizeRustTypeName(type); + } else { + normalizedType = normalizeRustTypeName(type); + } + + return { boundName: name, rawTypeName: normalizedType, source }; +} + +export function normalizeRustTypeName(text: string): string { + let t = text.trim(); + // Strip reference prefixes (&, &mut, *const, *mut) + while (t.startsWith('&')) t = t.replace(/^&\s*(mut\s+)?/, ''); + while (t.startsWith('*')) t = t.replace(/^\*\s*(const|mut)?\s*/, ''); + // Unwrap common smart-pointer/container wrappers to their inner type + const wrappers = ['Box', 'Option', 'Arc', 'Rc', 'Mutex', 'RwLock', 'RefCell', 'Cell']; + for (const w of wrappers) { + if (t.startsWith(`${w}<`)) { + const inner = extractFirstGenericArg(t); + if (inner !== null) { + t = inner; + break; + } + } + } + if (t.startsWith('Vec<')) { + const inner = extractFirstGenericArg(t); + if (inner !== null) t = inner; + } + const bracket = t.indexOf('<'); + if (bracket !== -1) t = t.slice(0, bracket); + // Take last segment of qualified paths (crate::foo::Bar → Bar) + const lastColon = t.lastIndexOf('::'); + if (lastColon !== -1) t = t.slice(lastColon + 2); + return t.trim(); +} + +function extractFirstGenericArg(text: string): string | null { + const open = text.indexOf('<'); + if (open === -1) return null; + let depth = 0; + for (let i = open; i < text.length; i++) { + if (text[i] === '<') depth++; + else if (text[i] === '>') { + depth--; + if (depth === 0) { + const inner = text.slice(open + 1, i).trim(); + const comma = findTopLevelComma(inner); + return comma === -1 ? inner : inner.slice(0, comma).trim(); + } + } + } + return null; +} + +function findTopLevelComma(text: string): number { + let depth = 0; + for (let i = 0; i < text.length; i++) { + if (text[i] === '<') depth++; + else if (text[i] === '>') depth--; + else if (text[i] === ',' && depth === 0) return i; + } + return -1; +} + +function normalizeRustCallReturnType(text: string): string { + let t = text.trim(); + // For scoped calls like `Foo::new()`, extract the type part before `::` + const scopeIdx = t.indexOf('::'); + if (scopeIdx !== -1) { + t = t.slice(0, scopeIdx); + } + return normalizeRustTypeName(t); +} + +function normalizeRustReturnType(text: string): string { + let t = text.trim(); + while (t.startsWith('&')) t = t.replace(/^&\s*(mut\s+)?/, ''); + // Unwrap Result, Option for return types + const wrappers = ['Result', 'Option']; + for (const w of wrappers) { + if (t.startsWith(`${w}<`)) { + const inner = extractFirstGenericArg(t); + if (inner !== null) { + t = inner; + break; + } + } + } + const bracket = t.indexOf('<'); + if (bracket !== -1) t = t.slice(0, bracket); + const lastColon = t.lastIndexOf('::'); + if (lastColon !== -1) t = t.slice(lastColon + 2); + return t.trim(); +} diff --git a/gitnexus/src/core/ingestion/languages/rust/merge-bindings.ts b/gitnexus/src/core/ingestion/languages/rust/merge-bindings.ts new file mode 100644 index 000000000..6e62da3aa --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/rust/merge-bindings.ts @@ -0,0 +1,27 @@ +import type { BindingRef } from 'gitnexus-shared'; + +const TIER: Record = { + local: 0, + namespace: 1, + import: 2, + reexport: 3, + wildcard: 4, +}; + +export function rustMergeBindings( + existing: readonly BindingRef[], + incoming: readonly BindingRef[], + _scopeId: string, +): BindingRef[] { + const seen = new Set(); + return [...existing, ...incoming] + .sort( + (a, b) => + (TIER[a.origin] ?? 99) - (TIER[b.origin] ?? 99) || a.def.nodeId.localeCompare(b.def.nodeId), + ) + .filter((binding) => { + if (seen.has(binding.def.nodeId)) return false; + seen.add(binding.def.nodeId); + return true; + }); +} diff --git a/gitnexus/src/core/ingestion/languages/rust/method-owners.ts b/gitnexus/src/core/ingestion/languages/rust/method-owners.ts new file mode 100644 index 000000000..897dc5865 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/rust/method-owners.ts @@ -0,0 +1,63 @@ +import type { ParsedFile } from 'gitnexus-shared'; +import { isClassLike, populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js'; + +/** + * Populate `ownerId` on Rust method defs. + * + * Rust methods are declared inside `impl TypeName { ... }` blocks, not + * directly inside struct bodies. The tree-sitter query creates Class scopes + * for impl blocks, and the generic `populateClassOwnedMembers` handles methods + * that are structurally nested inside those Class scopes. But we also need to + * bridge the impl block's methods to the actual struct def, since the impl + * block is semantically "owned by" the struct. + * + * Strategy: + * 1. Run the generic `populateClassOwnedMembers` (handles property fields in + * structs and methods in impl blocks). + * 2. For each method in an impl block's Class scope whose ownerId points to + * the impl block, re-point ownerId to the struct def (if found in the + * same module). + */ +export function populateRustOwners(parsed: ParsedFile): void { + populateClassOwnedMembers(parsed); + populateRustImplOwners(parsed); +} + +function populateRustImplOwners(parsed: ParsedFile): void { + // Build a map of struct name → def nodeId from all scopes. + const structByName = new Map(); + for (const scope of parsed.scopes) { + for (const def of scope.ownedDefs) { + if (isClassLike(def.type) && (def.type === 'Struct' || def.type === 'Trait')) { + structByName.set(def.name, def.nodeId); + } + } + } + if (structByName.size === 0) return; + + // For each method whose ownerId is unset, try to remap to the struct def + // using the self typeBinding's receiver type. + for (const scope of parsed.scopes) { + if (scope.kind !== 'Function') continue; + const methodDefs = scope.ownedDefs.filter( + (d) => d.type === 'Method' && d.ownerId === undefined, + ); + if (methodDefs.length === 0) continue; + + let receiverType: string | undefined; + for (const [, tb] of scope.typeBindings) { + if (tb.source === 'self') { + receiverType = tb.rawName; + break; + } + } + if (receiverType === undefined) continue; + + const ownerId = structByName.get(receiverType); + if (ownerId !== undefined) { + for (const def of methodDefs) { + (def as { ownerId?: string }).ownerId = ownerId; + } + } + } +} diff --git a/gitnexus/src/core/ingestion/languages/rust/query.ts b/gitnexus/src/core/ingestion/languages/rust/query.ts new file mode 100644 index 000000000..bd69c74a7 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/rust/query.ts @@ -0,0 +1,136 @@ +import Parser from 'tree-sitter'; +import Rust from 'tree-sitter-rust'; + +const RUST_SCOPE_QUERY = ` +;; Scopes +(source_file) @scope.module +(struct_item) @scope.class +(trait_item) @scope.class +(impl_item) @scope.class +(enum_item) @scope.class +(function_item) @scope.function +(closure_expression) @scope.function +(block) @scope.block +(if_expression) @scope.block +(match_expression) @scope.block +(for_expression) @scope.block +(while_expression) @scope.block +(loop_expression) @scope.block +(mod_item) @scope.namespace + +;; Declarations — struct +(struct_item + name: (type_identifier) @declaration.name) @declaration.struct + +;; Declarations — trait +(trait_item + name: (type_identifier) @declaration.name) @declaration.trait + +;; Declarations — enum +(enum_item + name: (type_identifier) @declaration.name) @declaration.enum + +;; Declarations — function (top-level or inside mod) +(function_item + name: (identifier) @declaration.name) @declaration.function + +;; Declarations — struct fields +(field_declaration + name: (field_identifier) @declaration.name + type: (_) @declaration.field-type) @declaration.field + +;; Declarations — variables (let bindings) +(let_declaration + pattern: (identifier) @declaration.name) @declaration.variable + +;; Declarations — const +(const_item + name: (identifier) @declaration.name) @declaration.const + +;; Declarations — static +(static_item + name: (identifier) @declaration.name) @declaration.const + +;; Imports +(use_declaration) @import.statement + +;; Type bindings — parameter annotations +(parameter + pattern: (identifier) @type-binding.name + type: (_) @type-binding.type) @type-binding.parameter + +;; Type bindings — let with type annotation +(let_declaration + pattern: (identifier) @type-binding.name + type: (_) @type-binding.type) @type-binding.assignment + +;; Type bindings — struct literal constructor inference +(let_declaration + pattern: (identifier) @type-binding.name + value: (struct_expression + name: (_) @type-binding.type)) @type-binding.constructor + +;; Type bindings — call-return inference (let x = Foo::new()) +(let_declaration + pattern: (identifier) @type-binding.name + value: (call_expression + function: (_) @type-binding.type)) @type-binding.call-return + +;; Type bindings — variable alias (let x = y) +(let_declaration + pattern: (identifier) @type-binding.name + value: (identifier) @type-binding.type) @type-binding.alias + +;; Type bindings — return type annotation +(function_item + name: (identifier) @type-binding.name + return_type: (_) @type-binding.type) @type-binding.return + +;; References — free calls +(call_expression + function: (identifier) @reference.name) @reference.call.free + +;; References — member calls (obj.method()) +(call_expression + function: (field_expression + value: (_) @reference.receiver + field: (field_identifier) @reference.name)) @reference.call.member + +;; References — scoped calls (Foo::bar()) +(call_expression + function: (scoped_identifier + name: (identifier) @reference.name)) @reference.call.free + +;; References — constructor calls (struct literal) +(struct_expression + name: (_) @reference.name) @reference.call.constructor + +;; References — field reads +(field_expression + value: (_) @reference.receiver + field: (field_identifier) @reference.name) @reference.read + +;; References — field writes (assignment) +(assignment_expression + left: (field_expression + value: (_) @reference.receiver + field: (field_identifier) @reference.name)) @reference.write +`; + +let _parser: Parser | null = null; +let _query: Parser.Query | null = null; + +export function getRustParser(): Parser { + if (_parser === null) { + _parser = new Parser(); + _parser.setLanguage(Rust as Parameters[0]); + } + return _parser; +} + +export function getRustScopeQuery(): Parser.Query { + if (_query === null) { + _query = new Parser.Query(Rust as Parameters[0], RUST_SCOPE_QUERY); + } + return _query; +} diff --git a/gitnexus/src/core/ingestion/languages/rust/receiver-binding.ts b/gitnexus/src/core/ingestion/languages/rust/receiver-binding.ts new file mode 100644 index 000000000..eaa42aae7 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/rust/receiver-binding.ts @@ -0,0 +1,136 @@ +import type { CaptureMatch } from 'gitnexus-shared'; +import { syntheticCapture } from '../../utils/ast-helpers.js'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +/** + * Given a function_item node that is inside an impl_item, synthesize a + * self-type-binding capture if the function has a `self_parameter`. + * + * The impl_item structure: + * impl [TraitName for] TypeName { fn method(&self) { ... } } + */ +export function synthesizeRustReceiverBinding( + fnNode: SyntaxNode, + implNode: SyntaxNode | null, +): CaptureMatch | null { + if (fnNode.type !== 'function_item') return null; + if (implNode === null) return null; + + const params = fnNode.childForFieldName('parameters'); + if (params === null) return null; + + let hasSelf = false; + for (let i = 0; i < params.namedChildCount; i++) { + if (params.namedChild(i)?.type === 'self_parameter') { + hasSelf = true; + break; + } + } + if (!hasSelf) return null; + + const implType = getImplTargetType(implNode); + if (implType === null) return null; + + return { + '@type-binding.self': syntheticCapture('@type-binding.self', fnNode, 'self'), + '@type-binding.name': syntheticCapture('@type-binding.name', fnNode, 'self'), + '@type-binding.type': syntheticCapture('@type-binding.type', fnNode, implType), + }; +} + +/** + * Extract the target type from an impl_item. + * `impl TypeName { ... }` → "TypeName" + * `impl TraitName for TypeName { ... }` → "TypeName" + */ +export function getImplTargetType(implNode: SyntaxNode): string | null { + if (implNode.type !== 'impl_item') return null; + + // Look for `for` keyword — if present, impl is `impl Trait for Type` + let hasFor = false; + let typeAfterFor: SyntaxNode | null = null; + for (let i = 0; i < implNode.childCount; i++) { + const child = implNode.child(i); + if (child === null) continue; + if (child.type === 'for') { + hasFor = true; + continue; + } + if (hasFor && child.type === 'type_identifier') { + typeAfterFor = child; + break; + } + if (hasFor && child.type === 'scoped_type_identifier') { + typeAfterFor = child; + break; + } + if (hasFor && child.type === 'generic_type') { + typeAfterFor = child; + break; + } + } + if (hasFor && typeAfterFor !== null) { + return normalizeRustTypeName(typeAfterFor.text); + } + + // No `for` keyword: impl TypeName { ... } + const typeField = implNode.childForFieldName('type'); + if (typeField !== null) { + return normalizeRustTypeName(typeField.text); + } + + // Fallback: find first type_identifier after `impl` + let afterImpl = false; + for (let i = 0; i < implNode.childCount; i++) { + const child = implNode.child(i); + if (child === null) continue; + if (child.type === 'impl') { + afterImpl = true; + continue; + } + if (afterImpl && (child.type === 'type_identifier' || child.type === 'generic_type')) { + return normalizeRustTypeName(child.text); + } + } + return null; +} + +/** + * Extract the trait name from an impl_item when it's `impl Trait for Type`. + */ +export function getImplTraitName(implNode: SyntaxNode): string | null { + if (implNode.type !== 'impl_item') return null; + + let afterImpl = false; + for (let i = 0; i < implNode.childCount; i++) { + const child = implNode.child(i); + if (child === null) continue; + if (child.type === 'impl') { + afterImpl = true; + continue; + } + if (child.type === 'for') { + break; + } + if (afterImpl && (child.type === 'type_identifier' || child.type === 'scoped_type_identifier')) { + for (let j = i + 1; j < implNode.childCount; j++) { + const next = implNode.child(j); + if (next === null) continue; + if (next.type === 'for') { + return normalizeRustTypeName(child.text); + } + break; + } + } + } + return null; +} + +function normalizeRustTypeName(text: string): string { + let t = text.trim(); + while (t.startsWith('&')) t = t.replace(/^&\s*(mut\s+)?/, ''); + while (t.startsWith('*')) t = t.slice(1).trim(); + const bracket = t.indexOf('<'); + if (bracket !== -1) t = t.slice(0, bracket); + return t.trim(); +} diff --git a/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts new file mode 100644 index 000000000..3622410e1 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts @@ -0,0 +1,37 @@ +import type { ParsedFile } from 'gitnexus-shared'; +import { SupportedLanguages } from 'gitnexus-shared'; +import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js'; +import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; +import { rustProvider } from '../rust.js'; +import { + rustArityCompatibility, + rustMergeBindings, + resolveRustImportTarget, +} from './index.js'; +import { populateRustOwners } from './method-owners.js'; + +export const rustScopeResolver: ScopeResolver = { + language: SupportedLanguages.Rust, + languageProvider: rustProvider, + importEdgeReason: 'rust-scope: use', + + resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) => + resolveRustImportTarget(targetRaw, fromFile, allFilePaths, resolutionConfig), + + mergeBindings: (existing, incoming, scopeId) => + rustMergeBindings(existing, incoming, scopeId), + + arityCompatibility: (callsite, def) => rustArityCompatibility(def, callsite), + + buildMro: (graph, parsedFiles, nodeLookup) => + buildMro(graph, parsedFiles, nodeLookup, defaultLinearize), + + populateOwners: (parsed: ParsedFile) => populateRustOwners(parsed), + + isSuperReceiver: () => false, + + fieldFallbackOnMethodLookup: false, + hoistTypeBindingsToModule: true, + propagatesReturnTypesAcrossImports: true, + allowGlobalFreeCallFallback: true, +}; diff --git a/gitnexus/src/core/ingestion/languages/rust/simple-hooks.ts b/gitnexus/src/core/ingestion/languages/rust/simple-hooks.ts new file mode 100644 index 000000000..2be487b03 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/rust/simple-hooks.ts @@ -0,0 +1,44 @@ +import type { + CaptureMatch, + ParsedImport, + Scope, + ScopeId, + ScopeTree, + TypeRef, +} from 'gitnexus-shared'; + +export function rustBindingScopeFor( + decl: CaptureMatch, + innermost: Scope, + _tree: ScopeTree, +): ScopeId | null { + // Keep self typeBindings in the method's Function scope so + // populateRustOwners can match Method defs to their receiver types. + if (decl['@type-binding.self'] !== undefined) { + return innermost.id; + } + return null; +} + +/** + * Rust `use` statements inside a function body should attach at function scope, + * not module scope. If the innermost scope is a Function, attach there. + */ +export function rustImportOwningScope( + _imp: ParsedImport, + innermost: Scope, + _tree: ScopeTree, +): ScopeId | null { + if (innermost.kind === 'Function') { + return innermost.id; + } + return null; +} + +export function rustReceiverBinding(functionScope: Scope): TypeRef | null { + if (functionScope.kind !== 'Function') return null; + for (const binding of functionScope.typeBindings.values()) { + if (binding.source === 'self') return binding; + } + return null; +} diff --git a/gitnexus/src/core/ingestion/registry-primary-flag.ts b/gitnexus/src/core/ingestion/registry-primary-flag.ts index 869157adb..be8ffd109 100644 --- a/gitnexus/src/core/ingestion/registry-primary-flag.ts +++ b/gitnexus/src/core/ingestion/registry-primary-flag.ts @@ -74,6 +74,7 @@ export const MIGRATED_LANGUAGES: ReadonlySet = new Set = n [SupportedLanguages.C, cScopeResolver], [SupportedLanguages.CPlusPlus, cppScopeResolver], [SupportedLanguages.PHP, phpScopeResolver], + [SupportedLanguages.Rust, rustScopeResolver], ]);