diff --git a/gitnexus/src/core/ingestion/languages/rust.ts b/gitnexus/src/core/ingestion/languages/rust.ts index e7638e4eaf..be4be7cbaf 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 0000000000..8b082c4820 --- /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 0000000000..8ba83ae896 --- /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 0000000000..56ce009d75 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/rust/captures.ts @@ -0,0 +1,223 @@ +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 + let cachedImplLookup: { fnNode: SyntaxNode; implNode: SyntaxNode | null } | undefined; + 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); + cachedImplLookup = { fnNode, implNode }; + 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 implNode = + cachedImplLookup?.fnNode === fnNode + ? cachedImplLookup.implNode + : findEnclosingImpl(fnNode); + const traitNode = implNode === null ? findEnclosingTrait(fnNode) : null; + // Reclassify as method if inside an impl block or trait definition + if (implNode !== null || traitNode !== null) { + const nameCap = grouped['@declaration.name']; + delete (grouped as Record)['@declaration.function']; + grouped['@declaration.method'] = syntheticCapture( + '@declaration.method', + fnNode, + fnNode.text, + ); + if (nameCap !== undefined) { + grouped['@declaration.name'] = nameCap; + } + } + + 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), + ); + } + } + } + + // Hoist return-type bindings from impl block functions to module level. + // The auto-hoist in the scope-extractor places a type binding whose + // anchor matches its innermost scope on the parent scope. By using the + // impl_item node as the anchor (which matches the impl's Class scope), + // the binding lands on the Module scope — making it visible to the + // compound receiver's hoistTypeBindingsToModule walk. + if ( + grouped['@type-binding.return'] !== undefined && + grouped['@type-binding.name'] !== undefined + ) { + const tbReturnAnchor = grouped['@type-binding.return']!; + const fnNode = findNodeAtRange(tree.rootNode, tbReturnAnchor.range, 'function_item'); + if (fnNode !== null) { + const implNode = findEnclosingImpl(fnNode); + if (implNode !== null) { + out.push({ + '@type-binding.name': syntheticCapture( + '@type-binding.name', + implNode, + grouped['@type-binding.name']!.text, + ), + '@type-binding.type': syntheticCapture( + '@type-binding.type', + implNode, + grouped['@type-binding.type']!.text, + ), + '@type-binding.return': syntheticCapture( + '@type-binding.return', + implNode, + tbReturnAnchor.text, + ), + }); + } + } + } + + // 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); + 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 findEnclosingTrait(node: SyntaxNode): SyntaxNode | null { + let current: SyntaxNode | null = node.parent; + while (current !== null) { + if (current.type === 'trait_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 { + 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++) { + const t = body.namedChild(i)?.type; + if (t === 'field_initializer' || t === '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 0000000000..c209d570b7 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/rust/import-decomposer.ts @@ -0,0 +1,207 @@ +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 { + return { + '@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), + ...(alias !== undefined + ? { + '@import.alias': syntheticCapture('@import.alias', anchor, alias), + '@import.original-name': syntheticCapture('@import.original-name', anchor, name), + } + : {}), + }; +} 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 0000000000..ef634a247d --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/rust/import-target.ts @@ -0,0 +1,129 @@ +/** + * 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 + const workspaceResult = resolveWorkspaceCrate(segments, allFilePaths); + if (workspaceResult !== null) return workspaceResult; + + // Fallback: treat as implicit crate-relative (Rust 2015 edition or + // when the first segment matches a sibling module name). + return resolveModulePath(segments, findSrcRoot(fromNormalized), 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 0000000000..fa47a81574 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/rust/index.ts @@ -0,0 +1,16 @@ +/** + * 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 0000000000..a53a6e1c21 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/rust/interpret.ts @@ -0,0 +1,166 @@ +import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared'; + +const REF_PREFIX_RE = /^&\s*(mut\s+)?/; +const PTR_PREFIX_RE = /^\*\s*(const|mut)?\s*/; +const ENUM_VARIANT_NAMES = new Set(['Some', 'None', 'Ok', 'Err']); + +// ─── 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: 'reexport', + localName: alias ?? name, + importedName: originalName ?? name, + targetRaw: source, + }; + } + // 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) { + if (ENUM_VARIANT_NAMES.has(type)) return null; + 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(REF_PREFIX_RE, ''); + while (t.startsWith('*')) t = t.replace(PTR_PREFIX_RE, ''); + // 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(REF_PREFIX_RE, ''); + // 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 0000000000..6e62da3aaa --- /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 0000000000..7633a132b0 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/rust/method-owners.ts @@ -0,0 +1,68 @@ +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.qualifiedName) { + structByName.set(def.qualifiedName, def.nodeId); + } + } + } + if (structByName.size === 0) return; + + const structBySuffix = new Map(); + for (const [qname, nodeId] of structByName) { + const dot = qname.lastIndexOf('.'); + const suffix = dot !== -1 ? qname.slice(dot + 1) : qname; + structBySuffix.set(suffix, nodeId); + } + + 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) ?? structBySuffix.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 0000000000..8e4671ba3e --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/rust/query.ts @@ -0,0 +1,149 @@ +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 — call-return inference through .await (let x = foo().await) +(let_declaration + pattern: (identifier) @type-binding.name + value: (await_expression + (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 + +;; References — field writes (compound assignment: +=, -=, etc.) +(compound_assignment_expr + 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/range-binding.ts b/gitnexus/src/core/ingestion/languages/rust/range-binding.ts new file mode 100644 index 0000000000..a09eaa23c5 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/rust/range-binding.ts @@ -0,0 +1,708 @@ +import type { ParsedFile, Scope, ScopeId, TypeRef } from 'gitnexus-shared'; +import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js'; +import { getRustParser } from './query.js'; +import { getTreeSitterBufferSize } from '../../constants.js'; +import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js'; +import type { SyntaxNode } from '../../utils/ast-helpers.js'; + +/** + * Populate type bindings for patterns and iterators that the tree-sitter + * query can't handle (they need runtime type lookup, not just syntax). + * + * Covers: for-loop element types, if-let/while-let pattern bindings, + * match arm patterns, and struct destructuring. + * + * Runs in Phase 2 (after propagateImportedReturnTypes) so all cross-file + * type bindings are available for lookup. + */ +export function populateRustRangeBindings( + parsedFiles: readonly ParsedFile[], + _indexes: ScopeResolutionIndexes, + ctx: { + readonly fileContents: ReadonlyMap; + readonly treeCache?: { get(filePath: string): unknown }; + }, +): void { + const parser = getRustParser(); + const allReturnTypes = new Map(); + const allFieldTypes = new Map>(); + + for (const parsed of parsedFiles) { + const sourceText = ctx.fileContents.get(parsed.filePath); + if (sourceText === undefined) continue; + + const cachedTree = ctx.treeCache?.get(parsed.filePath); + const tree = + (cachedTree as ReturnType | undefined) ?? + parseSourceSafe(parser, sourceText, undefined, { + bufferSize: getTreeSitterBufferSize(sourceText), + }); + + for (const fn of tree.rootNode.descendantsOfType('function_item')) { + const nameNode = fn.childForFieldName('name'); + const retType = fn.childForFieldName('return_type'); + if (nameNode !== null && retType !== null) { + const name = nameNode.text; + if (allReturnTypes.has(name)) { + allReturnTypes.delete(name); + } else { + allReturnTypes.set(name, retType.text); + } + } + } + + for (const structNode of tree.rootNode.descendantsOfType('struct_item')) { + const nameNode = structNode.childForFieldName('name'); + const body = structNode.childForFieldName('body'); + if (nameNode === null || body === null) continue; + const fields = new Map(); + for (const field of body.descendantsOfType('field_declaration')) { + const fieldName = field.childForFieldName('name'); + const fieldType = field.childForFieldName('type'); + if (fieldName !== null && fieldType !== null) { + fields.set(fieldName.text, normalizeFieldType(fieldType.text)); + } + } + if (fields.size > 0) { + const name = nameNode.text; + if (allFieldTypes.has(name)) { + allFieldTypes.delete(name); + } else { + allFieldTypes.set(name, fields); + } + } + } + } + + for (const parsed of parsedFiles) { + const sourceText = ctx.fileContents.get(parsed.filePath); + if (sourceText === undefined) continue; + + const cachedTree = ctx.treeCache?.get(parsed.filePath); + const tree = + (cachedTree as ReturnType | undefined) ?? + parseSourceSafe(parser, sourceText, undefined, { + bufferSize: getTreeSitterBufferSize(sourceText), + }); + + const scopeMap = new Map(parsed.scopes.map((s) => [s.id, s])); + const moduleScope = parsed.scopes.find((s) => s.kind === 'Module'); + if (moduleScope === undefined) continue; + + processFieldTypeBindings(tree.rootNode, parsed, scopeMap); + processIdentityMethodBindings(parsed); + processForLoops(tree.rootNode, parsed, scopeMap, moduleScope, allReturnTypes); + processPatternBindings(tree.rootNode, parsed, scopeMap, moduleScope); + processStructDestructuring(tree.rootNode, parsed, scopeMap, moduleScope, allFieldTypes); + processPendingAssignments( + tree.rootNode, + parsed, + parsedFiles, + scopeMap, + moduleScope, + allReturnTypes, + ); + } +} + +function processFieldTypeBindings( + root: SyntaxNode, + parsed: ParsedFile, + scopeMap: ReadonlyMap, +): void { + for (const structNode of root.descendantsOfType('struct_item')) { + const nameNode = structNode.childForFieldName('name'); + if (nameNode === null) continue; + + const structScope = findScopeForNode(structNode, parsed, scopeMap); + if (structScope === null) continue; + + const body = structNode.childForFieldName('body'); + if (body === null) continue; + + for (const field of body.descendantsOfType('field_declaration')) { + const fieldName = field.childForFieldName('name'); + const fieldType = field.childForFieldName('type'); + if (fieldName === null || fieldType === null) continue; + + const normalizedType = normalizeFieldType(fieldType.text); + injectTypeBinding(structScope, fieldName.text, normalizedType); + } + } +} + +function findScopeForNode( + node: SyntaxNode, + parsed: ParsedFile, + scopeMap: ReadonlyMap, +): Scope | null { + for (const scope of parsed.scopes) { + if ( + scope.kind === 'Class' && + scope.range.startLine === node.startPosition.row + 1 && + scope.range.startCol === node.startPosition.column + ) { + return scope; + } + } + return null; +} + +function normalizeFieldType(text: string): string { + let t = text.trim(); + if (t.startsWith('&')) t = t.replace(/^&\s*(mut\s+)?/, ''); + 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(); +} + +function processForLoops( + root: SyntaxNode, + parsed: ParsedFile, + scopeMap: ReadonlyMap, + moduleScope: Scope, + allReturnTypes: ReadonlyMap, +): void { + for (const forNode of root.descendantsOfType('for_expression')) { + const patternNode = forNode.childForFieldName('pattern'); + const valueNode = forNode.childForFieldName('value'); + if (patternNode === null || valueNode === null) continue; + + const varName = extractVarName(patternNode); + if (varName === null) continue; + + const elementType = resolveIterableElementType( + valueNode, + parsed, + scopeMap, + moduleScope, + allReturnTypes, + ); + if (elementType === null) continue; + + const targetScope = findEnclosingFunctionScope(forNode, scopeMap) ?? moduleScope; + injectTypeBinding(targetScope, varName, elementType); + } +} + +function processPatternBindings( + root: SyntaxNode, + parsed: ParsedFile, + scopeMap: ReadonlyMap, + moduleScope: Scope, +): void { + for (const nodeType of ['let_condition', 'match_arm'] as const) { + for (const node of root.descendantsOfType(nodeType)) { + const patternNode = + nodeType === 'let_condition' + ? node.childForFieldName('pattern') + : (node.childForFieldName('pattern')?.firstNamedChild ?? null); + if (patternNode === null) continue; + + if (patternNode.type === 'captured_pattern') { + processCapturedPattern(patternNode, node, parsed, scopeMap, moduleScope); + continue; + } + + if (patternNode.type === 'tuple_struct_pattern') { + processTupleStructPattern(patternNode, node, parsed, scopeMap, moduleScope); + } + } + } +} + +function processCapturedPattern( + patternNode: SyntaxNode, + contextNode: SyntaxNode, + _parsed: ParsedFile, + scopeMap: ReadonlyMap, + moduleScope: Scope, +): void { + const varNode = patternNode.namedChildren.find((c) => c.type === 'identifier'); + const structPatternNode = patternNode.namedChildren.find((c) => c.type === 'struct_pattern'); + if (varNode === undefined || structPatternNode === undefined) return; + + const typeName = structPatternNode.childForFieldName('type')?.text; + if (typeName === undefined) return; + + const targetScope = findEnclosingFunctionScope(contextNode, scopeMap) ?? moduleScope; + injectTypeBinding(targetScope, varNode.text, typeName); +} + +function processTupleStructPattern( + patternNode: SyntaxNode, + contextNode: SyntaxNode, + parsed: ParsedFile, + scopeMap: ReadonlyMap, + moduleScope: Scope, +): void { + const wrapperNode = patternNode.childForFieldName('type'); + if (wrapperNode === null) return; + const wrapper = wrapperNode.text; + + const wrapperIdx = patternNode.namedChildren.indexOf(wrapperNode); + const innerIdent = patternNode.namedChildren.find( + (c, i) => i > wrapperIdx && c.type === 'identifier', + ); + if (innerIdent === null || innerIdent === undefined) return; + const varName = innerIdent.text; + + let sourceVarNode: SyntaxNode | null = null; + if (contextNode.type === 'let_condition') { + sourceVarNode = contextNode.childForFieldName('value'); + } else { + let matchExpr: SyntaxNode | null = contextNode.parent; + while (matchExpr !== null && matchExpr.type !== 'match_expression') { + matchExpr = matchExpr.parent; + } + sourceVarNode = matchExpr?.childForFieldName('value') ?? null; + } + if (sourceVarNode === null || sourceVarNode === undefined) return; + + const sourceVarName = sourceVarNode.type === 'identifier' ? sourceVarNode.text : null; + if (sourceVarName === null) return; + + const sourceType = lookupTypeInScopes(sourceVarName, contextNode, parsed, scopeMap, moduleScope); + if (sourceType === null) return; + + let resolvedType: string | null = null; + + if (wrapper === 'Some') { + resolvedType = unwrapGeneric(sourceType); + } else if (wrapper === 'Ok' || wrapper === 'Err') { + const rawType = lookupRawParameterType(sourceVarName, contextNode); + if (rawType !== null) { + const argIdx = wrapper === 'Ok' ? 0 : 1; + resolvedType = extractNthGenericArg(rawType, argIdx); + } + if (resolvedType === null) { + resolvedType = wrapper === 'Ok' ? unwrapGeneric(sourceType) : null; + } + } + + if (resolvedType === null) return; + + const targetScope = findEnclosingFunctionScope(contextNode, scopeMap) ?? moduleScope; + injectTypeBinding(targetScope, varName, resolvedType); +} + +function processStructDestructuring( + root: SyntaxNode, + parsed: ParsedFile, + scopeMap: ReadonlyMap, + moduleScope: Scope, + allFieldTypes?: ReadonlyMap>, +): void { + for (const letNode of root.descendantsOfType('let_declaration')) { + const patternNode = letNode.childForFieldName('pattern'); + if (patternNode === null || patternNode.type !== 'struct_pattern') continue; + + const typeName = patternNode.childForFieldName('type')?.text; + if (typeName === undefined) continue; + + const valueNode = letNode.childForFieldName('value'); + if (valueNode === null) continue; + + const targetScope = findEnclosingFunctionScope(letNode, scopeMap) ?? moduleScope; + + for (const fieldNode of patternNode.namedChildren) { + let fieldName: string | undefined; + if (fieldNode.type === 'field_pattern') { + fieldName = fieldNode.childForFieldName('name')?.text; + } else if (fieldNode.type === 'shorthand_field_pattern') { + fieldName = fieldNode.firstNamedChild?.text; + } + if (fieldName === undefined) continue; + + let fieldType = lookupFieldType(typeName, fieldName, parsed, scopeMap, moduleScope); + if (fieldType === null) { + fieldType = allFieldTypes?.get(typeName)?.get(fieldName) ?? null; + } + if (fieldType !== null) { + injectTypeBinding(targetScope, fieldName, fieldType); + } + } + } +} + +const IDENTITY_METHODS = ['unwrap', 'expect', 'clone', 'as_ref', 'as_mut']; + +function processIdentityMethodBindings(parsed: ParsedFile): void { + for (const scope of parsed.scopes) { + if (scope.kind !== 'Class') continue; + const classDef = scope.ownedDefs.find((d) => { + const t = d.type; + return t === 'Struct' || t === 'Enum' || t === 'Class'; + }); + if (classDef === undefined) continue; + const name = classDef.qualifiedName?.split('.').pop(); + if (name === undefined) continue; + + for (const method of IDENTITY_METHODS) { + if (!scope.typeBindings.has(method)) { + (scope.typeBindings as Map).set(method, { + rawName: name, + declaredAtScope: scope.id, + source: 'return-annotation', + }); + } + } + } +} + +function processPendingAssignments( + root: SyntaxNode, + parsed: ParsedFile, + allParsedFiles: readonly ParsedFile[], + scopeMap: ReadonlyMap, + moduleScope: Scope, + allReturnTypes: ReadonlyMap, +): void { + for (let pass = 0; pass < 3; pass++) { + for (const letNode of root.descendantsOfType('let_declaration')) { + const patternNode = letNode.childForFieldName('pattern'); + if (patternNode === null) continue; + const varName = extractVarName(patternNode); + if (varName === null) continue; + + const targetScope = findEnclosingFunctionScope(letNode, scopeMap) ?? moduleScope; + if (targetScope.typeBindings.has(varName)) continue; + + const valueNode = letNode.childForFieldName('value'); + if (valueNode === null) continue; + + if (valueNode.type === 'identifier') { + const rhsType = lookupTypeInScopes(valueNode.text, letNode, parsed, scopeMap, moduleScope); + if (rhsType !== null) { + injectTypeBinding(targetScope, varName, rhsType); + continue; + } + } + + if (valueNode.type === 'field_expression') { + const receiver = valueNode.childForFieldName('value'); + const field = valueNode.childForFieldName('field'); + if (receiver !== null && field !== null && receiver.type === 'identifier') { + const receiverType = lookupTypeInScopes( + receiver.text, + letNode, + parsed, + scopeMap, + moduleScope, + ); + if (receiverType !== null) { + const fieldType = findFieldTypeAcrossFiles(receiverType, field.text, allParsedFiles); + if (fieldType !== null) { + injectTypeBinding(targetScope, varName, fieldType); + } + } + } + } + + if (valueNode.type === 'call_expression') { + const func = valueNode.childForFieldName('function'); + if (func !== null && func.type === 'field_expression') { + const receiver = func.childForFieldName('value'); + const method = func.childForFieldName('field'); + if (receiver !== null && method !== null && receiver.type === 'identifier') { + const receiverType = lookupTypeInScopes( + receiver.text, + letNode, + parsed, + scopeMap, + moduleScope, + ); + if (receiverType !== null) { + const retType = findMethodReturnTypeAcrossFiles( + receiverType, + method.text, + allParsedFiles, + ); + if (retType !== null) { + injectTypeBinding(targetScope, varName, retType); + } + } + } + } + + if (func !== null && func.type === 'identifier') { + const rawReturn = allReturnTypes.get(func.text); + if (rawReturn !== undefined) { + injectTypeBinding(targetScope, varName, normalizeFieldType(rawReturn)); + } + } + } + } + } +} + +function resolveIterableElementType( + valueNode: SyntaxNode, + parsed: ParsedFile, + scopeMap: ReadonlyMap, + moduleScope: Scope, + allReturnTypes?: ReadonlyMap, +): string | null { + let iterableNode = valueNode; + if (iterableNode.type === 'reference_expression') { + iterableNode = iterableNode.firstNamedChild ?? iterableNode; + } + + if (iterableNode.type === 'identifier') { + const rawType = lookupTypeInScopes(iterableNode.text, valueNode, parsed, scopeMap, moduleScope); + if (rawType !== null) return unwrapGeneric(rawType); + } + + if (iterableNode.type === 'call_expression') { + const func = iterableNode.childForFieldName('function'); + if (func === null) return null; + + if (func.type === 'field_expression') { + const receiver = func.childForFieldName('value'); + if (receiver !== null && receiver.type === 'identifier') { + const rawType = lookupTypeInScopes(receiver.text, valueNode, parsed, scopeMap, moduleScope); + if (rawType !== null) return unwrapGeneric(rawType); + } + } + + if (func.type === 'identifier') { + const crossFileReturn = allReturnTypes?.get(func.text); + if (crossFileReturn !== undefined) return unwrapGeneric(crossFileReturn); + const rawReturn = lookupRawFunctionReturnType(func.text, valueNode); + if (rawReturn !== null) return unwrapGeneric(rawReturn); + const returnType = lookupReturnTypeInScopes(func.text, parsed, scopeMap, moduleScope); + if (returnType !== null) return unwrapGeneric(returnType); + } + } + + return null; +} + +function findFieldTypeAcrossFiles( + structName: string, + fieldName: string, + allParsedFiles: readonly ParsedFile[], +): string | null { + for (const pf of allParsedFiles) { + for (const scope of pf.scopes) { + if (scope.kind !== 'Class') continue; + const hasDef = scope.ownedDefs.some( + (d) => d.qualifiedName === structName || d.qualifiedName?.endsWith('.' + structName), + ); + if (!hasDef) continue; + const tb = scope.typeBindings.get(fieldName); + if (tb !== undefined) return tb.rawName; + } + } + return null; +} + +function findMethodReturnTypeAcrossFiles( + structName: string, + methodName: string, + allParsedFiles: readonly ParsedFile[], +): string | null { + for (const pf of allParsedFiles) { + for (const scope of pf.scopes) { + if (scope.kind !== 'Class') continue; + const hasDef = scope.ownedDefs.some( + (d) => d.qualifiedName === structName || d.qualifiedName?.endsWith('.' + structName), + ); + if (!hasDef) continue; + const tb = scope.typeBindings.get(methodName); + if (tb !== undefined && tb.source === 'return-annotation') return tb.rawName; + } + } + return null; +} + +function lookupRawFunctionReturnType(funcName: string, contextNode: SyntaxNode): string | null { + let root: SyntaxNode = contextNode; + while (root.parent !== null) root = root.parent; + for (const fn of root.descendantsOfType('function_item')) { + const nameNode = fn.childForFieldName('name'); + if (nameNode !== null && nameNode.text === funcName) { + const retType = fn.childForFieldName('return_type'); + if (retType !== null) return retType.text; + } + } + return null; +} + +function lookupRawParameterType(paramName: string, contextNode: SyntaxNode): string | null { + let current: SyntaxNode | null = contextNode; + while (current !== null) { + if (current.type === 'function_item') { + const params = current.childForFieldName('parameters'); + if (params !== null) { + for (let i = 0; i < params.namedChildCount; i++) { + const param = params.namedChild(i); + if (param === null || param.type !== 'parameter') continue; + const pattern = param.childForFieldName('pattern'); + const typeNode = param.childForFieldName('type'); + if (pattern !== null && typeNode !== null && pattern.text === paramName) { + return typeNode.text; + } + } + } + break; + } + current = current.parent; + } + return null; +} + +function lookupTypeInScopes( + name: string, + contextNode: SyntaxNode, + parsed: ParsedFile, + scopeMap: ReadonlyMap, + moduleScope: Scope, +): string | null { + const fnScope = findEnclosingFunctionScope(contextNode, scopeMap); + if (fnScope !== null) { + const tb = fnScope.typeBindings.get(name); + if (tb !== undefined) return tb.rawName; + } + + const mtb = moduleScope.typeBindings.get(name); + if (mtb !== undefined) return mtb.rawName; + + return null; +} + +function lookupReturnTypeInScopes( + funcName: string, + parsed: ParsedFile, + _scopeMap: ReadonlyMap, + moduleScope: Scope, +): string | null { + const tb = moduleScope.typeBindings.get(funcName); + if (tb !== undefined && tb.source === 'return-annotation') return tb.rawName; + + for (const scope of parsed.scopes) { + const stb = scope.typeBindings.get(funcName); + if (stb !== undefined && stb.source === 'return-annotation') return stb.rawName; + } + + return null; +} + +function lookupFieldType( + structName: string, + fieldName: string, + parsed: ParsedFile, + _scopeMap: ReadonlyMap, + _moduleScope: Scope, +): string | null { + for (const scope of parsed.scopes) { + if (scope.kind !== 'Class') continue; + const hasDef = scope.ownedDefs.some( + (d) => d.qualifiedName === structName || d.qualifiedName?.endsWith('.' + structName), + ); + if (!hasDef) continue; + + const tb = scope.typeBindings.get(fieldName); + if (tb !== undefined) return tb.rawName; + + for (const def of scope.ownedDefs) { + const defName = def.qualifiedName?.split('.').pop(); + if (def.type === 'Property' && defName === fieldName) { + const tb = scope.typeBindings.get(fieldName); + if (tb !== undefined) return tb.rawName; + } + } + } + return null; +} + +function unwrapGeneric(rawType: string): string { + const match = rawType.match(/^(?:Vec|Option|Arc|Rc|Box|Mutex|RwLock|RefCell|Cell)<(.+)>$/); + if (match) { + const inner = match[1].trim(); + const comma = findTopLevelComma(inner); + return comma === -1 ? inner : inner.slice(0, comma).trim(); + } + if (rawType.startsWith('&[') && rawType.endsWith(']')) { + return rawType.slice(2, -1).trim(); + } + return rawType; +} + +function extractNthGenericArg(rawType: string, n: number): string | null { + const open = rawType.indexOf('<'); + if (open === -1) return null; + const close = rawType.lastIndexOf('>'); + if (close === -1) return null; + const inner = rawType.slice(open + 1, close).trim(); + const args = splitTopLevelComma(inner); + return n < args.length ? args[n].trim() : null; +} + +function splitTopLevelComma(text: string): string[] { + const parts: string[] = []; + let depth = 0; + let start = 0; + for (let i = 0; i < text.length; i++) { + if (text[i] === '<') depth++; + else if (text[i] === '>') depth--; + else if (text[i] === ',' && depth === 0) { + parts.push(text.slice(start, i)); + start = i + 1; + } + } + parts.push(text.slice(start)); + return parts; +} + +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 extractVarName(node: SyntaxNode): string | null { + if (node.type === 'identifier') return node.text; + if (node.type === 'mut_pattern' || node.type === 'reference_pattern') { + return node.firstNamedChild?.text ?? null; + } + return null; +} + +function injectTypeBinding(scope: Scope, name: string, typeName: string): void { + if (scope.typeBindings.has(name)) return; + (scope.typeBindings as Map).set(name, { + rawName: typeName, + declaredAtScope: scope.id, + source: 'annotation', + }); +} + +function findEnclosingFunctionScope( + node: SyntaxNode, + scopeMap: ReadonlyMap, +): Scope | null { + let current: SyntaxNode | null = node as SyntaxNode; + while (current !== null) { + if (current.type === 'function_item') { + for (const scope of scopeMap.values()) { + if ( + scope.kind === 'Function' && + scope.range.startLine === current.startPosition.row + 1 && + scope.range.startCol === current.startPosition.column + ) { + return scope; + } + } + break; + } + current = current.parent; + } + return null; +} 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 0000000000..bafeb88962 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/rust/receiver-binding.ts @@ -0,0 +1,139 @@ +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 0000000000..fb06fd2480 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts @@ -0,0 +1,79 @@ +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'; +import { populateRustRangeBindings } from './range-binding.js'; +import { isClassLike } from '../../scope-resolution/scope/walkers.js'; +import { resolveDefGraphId } from '../../scope-resolution/graph-bridge/ids.js'; + +function buildRustMro( + graph: Parameters[0], + parsedFiles: readonly ParsedFile[], + nodeLookup: Parameters[2], +): Map { + const baseMro = buildMro(graph, parsedFiles, nodeLookup, defaultLinearize); + + const defIdByGraphId = new Map(); + for (const parsed of parsedFiles) { + for (const def of parsed.localDefs) { + if (!isClassLike(def.type)) continue; + const graphId = resolveDefGraphId(parsed.filePath, def, nodeLookup); + if (graphId !== undefined) defIdByGraphId.set(graphId, def.nodeId); + } + } + + const fileByDefId = new Map(); + for (const parsed of parsedFiles) { + for (const def of parsed.localDefs) { + fileByDefId.set(def.nodeId, parsed.filePath); + } + } + + for (const rel of graph.iterRelationshipsByType('IMPLEMENTS')) { + const childDefId = defIdByGraphId.get(rel.sourceId); + const parentDefId = defIdByGraphId.get(rel.targetId); + if (childDefId === undefined || parentDefId === undefined) continue; + + const childFile = fileByDefId.get(childDefId); + const parentFile = fileByDefId.get(parentDefId); + if (childFile !== parentFile) continue; + + const existing = baseMro.get(childDefId); + if (existing !== undefined) { + if (!existing.includes(parentDefId)) existing.push(parentDefId); + } else { + baseMro.set(childDefId, [parentDefId]); + } + } + + return baseMro; +} + +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) => buildRustMro(graph, parsedFiles, nodeLookup), + + populateOwners: (parsed: ParsedFile) => populateRustOwners(parsed), + + isSuperReceiver: () => false, + + populateRangeBindings: populateRustRangeBindings, + + 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 0000000000..2be487b030 --- /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 56e39962a9..72e5f5b3e7 100644 --- a/gitnexus/src/core/ingestion/registry-primary-flag.ts +++ b/gitnexus/src/core/ingestion/registry-primary-flag.ts @@ -78,6 +78,7 @@ export const MIGRATED_LANGUAGES: ReadonlySet = new Set = n [SupportedLanguages.C, cScopeResolver], [SupportedLanguages.CPlusPlus, cppScopeResolver], [SupportedLanguages.PHP, phpScopeResolver], + [SupportedLanguages.Rust, rustScopeResolver], [SupportedLanguages.JavaScript, javascriptScopeResolver], [SupportedLanguages.Kotlin, kotlinScopeResolver], ]); diff --git a/gitnexus/test/integration/resolvers/rust-scope.test.ts b/gitnexus/test/integration/resolvers/rust-scope.test.ts new file mode 100644 index 0000000000..43c0f4ee80 --- /dev/null +++ b/gitnexus/test/integration/resolvers/rust-scope.test.ts @@ -0,0 +1,1023 @@ +/** + * Rust scope-resolution integration tests (RFC #909 Ring 3). + * + * These tests run with REGISTRY_PRIMARY_RUST=true to exercise the + * scope-based resolution path. They validate the core deliverables: + * impl blocks, use statements, receiver binding, module resolution. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import path from 'path'; +import fs from 'node:fs'; +import os from 'node:os'; +import { + getRelationships, + getNodesByLabel, + runPipelineFromRepo, + type PipelineResult, +} from './helpers.js'; + +function writeFixtureRepo(root: string, files: Record): void { + for (const [rel, content] of Object.entries(files)) { + const abs = path.join(root, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, content, 'utf8'); + } +} + +let savedEnv: string | undefined; + +beforeAll(() => { + savedEnv = process.env['REGISTRY_PRIMARY_RUST']; + process.env['REGISTRY_PRIMARY_RUST'] = 'true'; +}); + +afterAll(() => { + if (savedEnv === undefined) delete process.env['REGISTRY_PRIMARY_RUST']; + else process.env['REGISTRY_PRIMARY_RUST'] = savedEnv; +}); + +// --------------------------------------------------------------------------- +// 1. Impl blocks: methods classified as Method, owned by struct +// --------------------------------------------------------------------------- + +describe('Rust scope: impl block method classification', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rust-scope-impl-')); + writeFixtureRepo(tmpDir, { + 'src/main.rs': ` +mod user; +use crate::user::User; + +fn main() { + let u = User { name: String::new() }; + u.save(); +} +`, + 'src/user.rs': ` +pub struct User { + pub name: String, +} + +impl User { + pub fn save(&self) -> bool { + true + } + + pub fn create(name: String) -> User { + User { name } + } +} +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('detects User struct', () => { + expect(getNodesByLabel(result, 'Struct')).toContain('User'); + }); + + it('classifies save as a function owned by User (HAS_METHOD edge)', () => { + const edges = getRelationships(result, 'HAS_METHOD'); + const userSave = edges.find((e) => e.source === 'User' && e.target === 'save'); + expect(userSave).toBeDefined(); + }); + + it('emits HAS_METHOD edge from User to save', () => { + const edges = getRelationships(result, 'HAS_METHOD'); + const userSave = edges.find((e) => e.source === 'User' && e.target === 'save'); + expect(userSave).toBeDefined(); + }); + + it('resolves main → u.save() as CALLS edge to save in user.rs', () => { + const calls = getRelationships(result, 'CALLS'); + const mainSave = calls.find( + (c) => c.target === 'save' && c.source === 'main' && c.targetFilePath?.includes('user.rs'), + ); + expect(mainSave).toBeDefined(); + }); + + it('emits IMPORTS edge from main.rs to user.rs', () => { + const imports = getRelationships(result, 'IMPORTS'); + const imp = imports.find((e) => e.targetFilePath?.includes('user.rs')); + expect(imp).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 2. Trait implementations: IMPLEMENTS edges + method resolution +// --------------------------------------------------------------------------- + +describe('Rust scope: trait implementation', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rust-scope-trait-')); + writeFixtureRepo(tmpDir, { + 'src/main.rs': ` +mod shapes; +use crate::shapes::{Circle, Drawable}; + +fn main() { + let c = Circle { radius: 5.0 }; + c.draw(); +} +`, + 'src/shapes.rs': ` +pub trait Drawable { + fn draw(&self); +} + +pub struct Circle { + pub radius: f64, +} + +impl Drawable for Circle { + fn draw(&self) { + println!("Drawing circle"); + } +} +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('detects Drawable trait and Circle struct', () => { + expect(getNodesByLabel(result, 'Trait')).toContain('Drawable'); + expect(getNodesByLabel(result, 'Struct')).toContain('Circle'); + }); + + it('emits IMPLEMENTS edge from Circle to Drawable', () => { + const impls = getRelationships(result, 'IMPLEMENTS'); + const circleDrawable = impls.find((e) => e.source === 'Circle' && e.target === 'Drawable'); + expect(circleDrawable).toBeDefined(); + }); + + it('resolves main → c.draw() to shapes.rs', () => { + const calls = getRelationships(result, 'CALLS'); + const drawCall = calls.find((c) => c.target === 'draw' && c.source === 'main'); + expect(drawCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 3. Grouped imports: use crate::models::{User, Config} +// --------------------------------------------------------------------------- + +describe('Rust scope: grouped imports', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rust-scope-grouped-')); + writeFixtureRepo(tmpDir, { + 'src/main.rs': ` +mod models; +use crate::models::{User, Config}; + +fn process() { + let u = User { name: String::new() }; + u.save(); + let c = Config { debug: true }; + c.validate(); +} + +fn main() {} +`, + 'src/models.rs': ` +pub struct User { + pub name: String, +} + +impl User { + pub fn save(&self) {} +} + +pub struct Config { + pub debug: bool, +} + +impl Config { + pub fn validate(&self) {} +} +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('resolves grouped import to models.rs', () => { + const imports = getRelationships(result, 'IMPORTS'); + const modelsImport = imports.find((e) => e.targetFilePath?.includes('models.rs')); + expect(modelsImport).toBeDefined(); + }); + + it('resolves u.save() to User#save via grouped import binding', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find((c) => c.target === 'save' && c.source === 'process'); + expect(saveCall).toBeDefined(); + }); + + it('resolves c.validate() to Config#validate via grouped import binding', () => { + const calls = getRelationships(result, 'CALLS'); + const validateCall = calls.find((c) => c.target === 'validate' && c.source === 'process'); + expect(validateCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 4. Renamed imports: use Foo as Bar +// --------------------------------------------------------------------------- + +describe('Rust scope: renamed imports (use as)', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rust-scope-alias-')); + writeFixtureRepo(tmpDir, { + 'src/main.rs': ` +mod models; +use crate::models::User as U; + +fn process() { + let u = U { name: String::new() }; + u.save(); +} + +fn main() {} +`, + 'src/models.rs': ` +pub struct User { + pub name: String, +} + +impl User { + pub fn save(&self) {} +} +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('emits IMPORTS edge to models.rs', () => { + const imports = getRelationships(result, 'IMPORTS'); + const imp = imports.find((e) => e.targetFilePath?.includes('models.rs')); + expect(imp).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 5. Module resolution: super:: and self:: +// --------------------------------------------------------------------------- + +describe('Rust scope: module resolution (crate/super/self)', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rust-scope-modules-')); + writeFixtureRepo(tmpDir, { + 'src/main.rs': ` +mod models; +mod services; + +fn main() {} +`, + 'src/models.rs': ` +pub struct User { + pub name: String, +} + +impl User { + pub fn save(&self) {} +} +`, + 'src/services.rs': ` +use crate::models::User; + +pub fn process() { + let u = User { name: String::new() }; + u.save(); +} +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('resolves crate::models::User import from services.rs to models.rs', () => { + const imports = getRelationships(result, 'IMPORTS'); + const imp = imports.find( + (e) => e.targetFilePath?.includes('models.rs') && e.sourceFilePath?.includes('services.rs'), + ); + expect(imp).toBeDefined(); + }); + + it('resolves process → u.save() to models.rs', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find( + (c) => + c.target === 'save' && c.source === 'process' && c.targetFilePath?.includes('models.rs'), + ); + expect(saveCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 6. Receiver binding: &self, &mut self +// --------------------------------------------------------------------------- + +describe('Rust scope: receiver binding disambiguation', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rust-scope-receiver-')); + writeFixtureRepo(tmpDir, { + 'src/main.rs': ` +mod user; +mod repo; +use crate::user::User; +use crate::repo::Repo; + +fn process() { + let u = User { name: String::new() }; + u.save(); + let r = Repo { path: String::new() }; + r.save(); +} + +fn main() {} +`, + 'src/user.rs': ` +pub struct User { + pub name: String, +} + +impl User { + pub fn save(&self) {} +} +`, + 'src/repo.rs': ` +pub struct Repo { + pub path: String, +} + +impl Repo { + pub fn save(&mut self) {} +} +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('resolves u.save() to User#save', () => { + const calls = getRelationships(result, 'CALLS'); + const userSave = calls.find( + (c) => c.target === 'save' && c.source === 'process' && c.targetFilePath?.includes('user.rs'), + ); + expect(userSave).toBeDefined(); + }); + + it('resolves r.save() to Repo#save', () => { + const calls = getRelationships(result, 'CALLS'); + const repoSave = calls.find( + (c) => c.target === 'save' && c.source === 'process' && c.targetFilePath?.includes('repo.rs'), + ); + expect(repoSave).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 7. Arity filtering: no Rust overloading +// --------------------------------------------------------------------------- + +describe('Rust scope: arity filtering', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rust-scope-arity-')); + writeFixtureRepo(tmpDir, { + 'src/main.rs': ` +mod onearg; +mod twoarg; + +fn main() { + onearg::write_audit(String::from("test")); +} +`, + 'src/onearg.rs': ` +pub fn write_audit(msg: String) {} +`, + 'src/twoarg.rs': ` +pub fn write_audit(msg: String, level: i32) {} +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('resolves main → write_audit to onearg.rs (1-arg match)', () => { + const calls = getRelationships(result, 'CALLS'); + const call = calls.find( + (c) => c.target === 'write_audit' && c.targetFilePath?.includes('onearg.rs'), + ); + expect(call).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 8. Struct constructor inference: let x = Foo { ... } +// --------------------------------------------------------------------------- + +describe('Rust scope: struct literal constructor inference', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rust-scope-ctor-')); + writeFixtureRepo(tmpDir, { + 'src/main.rs': ` +mod user; +use crate::user::User; + +fn process() { + let u = User { name: String::new() }; + u.save(); +} + +fn main() {} +`, + 'src/user.rs': ` +pub struct User { + pub name: String, +} + +impl User { + pub fn save(&self) {} +} +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('resolves u.save() to User#save via struct literal type inference', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find( + (c) => c.target === 'save' && c.source === 'process' && c.targetFilePath?.includes('user.rs'), + ); + expect(saveCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 9. Return type inference: fn get_user() -> User +// --------------------------------------------------------------------------- + +describe('Rust scope: return type inference', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rust-scope-rettype-')); + writeFixtureRepo(tmpDir, { + 'src/main.rs': ` +mod models; +use crate::models::User; + +fn get_user() -> User { + User { name: String::new() } +} + +fn process() { + let u = get_user(); + u.save(); +} + +fn main() {} +`, + 'src/models.rs': ` +pub struct User { + pub name: String, +} + +impl User { + pub fn save(&self) {} +} +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('resolves process → u.save() via return type of get_user()', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find( + (c) => + c.target === 'save' && c.source === 'process' && c.targetFilePath?.includes('models.rs'), + ); + expect(saveCall).toBeDefined(); + }); + + it('resolves process → get_user() free call', () => { + const calls = getRelationships(result, 'CALLS'); + const getUserCall = calls.find((c) => c.target === 'get_user' && c.source === 'process'); + expect(getUserCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 10. Scoped call: Type::method() +// --------------------------------------------------------------------------- + +describe('Rust scope: scoped/qualified calls (Foo::new())', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rust-scope-qualified-')); + writeFixtureRepo(tmpDir, { + 'src/main.rs': ` +mod user; +use crate::user::User; + +fn process() { + let u = User::new(String::from("test")); + u.save(); +} + +fn main() {} +`, + 'src/user.rs': ` +pub struct User { + pub name: String, +} + +impl User { + pub fn new(name: String) -> User { + User { name } + } + + pub fn save(&self) {} +} +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('resolves process → User::new() free call', () => { + const calls = getRelationships(result, 'CALLS'); + const newCall = calls.find((c) => c.target === 'new' && c.source === 'process'); + expect(newCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 11. Struct field declarations captured as Property +// --------------------------------------------------------------------------- + +describe('Rust scope: struct field declarations', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rust-scope-fields-')); + writeFixtureRepo(tmpDir, { + 'src/main.rs': ` +struct Point { + x: i32, + y: i32, +} + +fn main() { + let p = Point { x: 1, y: 2 }; +} +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('detects Point struct', () => { + expect(getNodesByLabel(result, 'Struct')).toContain('Point'); + }); + + it('captures x and y as Property nodes', () => { + const props = getNodesByLabel(result, 'Property'); + expect(props).toContain('x'); + expect(props).toContain('y'); + }); +}); + +// --------------------------------------------------------------------------- +// 12. Enum declarations +// --------------------------------------------------------------------------- + +describe('Rust scope: enum declarations', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rust-scope-enum-')); + writeFixtureRepo(tmpDir, { + 'src/main.rs': ` +enum Color { + Red, + Green, + Blue, +} + +fn main() {} +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('detects Color enum', () => { + expect(getNodesByLabel(result, 'Enum')).toContain('Color'); + }); +}); + +// --------------------------------------------------------------------------- +// 13. Multiple impl blocks for same struct +// --------------------------------------------------------------------------- + +describe('Rust scope: multiple impl blocks', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rust-scope-multi-impl-')); + writeFixtureRepo(tmpDir, { + 'src/main.rs': ` +mod user; +use crate::user::User; + +fn process() { + let u = User { name: String::new() }; + u.save(); + u.display(); +} + +fn main() {} +`, + 'src/user.rs': ` +pub struct User { + pub name: String, +} + +impl User { + pub fn save(&self) {} +} + +impl User { + pub fn display(&self) {} +} +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('resolves u.save() from first impl block', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find((c) => c.target === 'save' && c.source === 'process'); + expect(saveCall).toBeDefined(); + }); + + it('resolves u.display() from second impl block', () => { + const calls = getRelationships(result, 'CALLS'); + const displayCall = calls.find((c) => c.target === 'display' && c.source === 'process'); + expect(displayCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 14. Free function calls (non-member) +// --------------------------------------------------------------------------- + +describe('Rust scope: free function calls', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rust-scope-freecall-')); + writeFixtureRepo(tmpDir, { + 'src/main.rs': ` +mod utils; +use crate::utils::helper; + +fn main() { + helper(); +} +`, + 'src/utils.rs': ` +pub fn helper() { + println!("helping"); +} +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('resolves main → helper() free call', () => { + const calls = getRelationships(result, 'CALLS'); + const helperCall = calls.find((c) => c.target === 'helper' && c.source === 'main'); + expect(helperCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 15. Variable type binding via let: type annotation +// --------------------------------------------------------------------------- + +describe('Rust scope: typed let binding', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rust-scope-lettype-')); + writeFixtureRepo(tmpDir, { + 'src/main.rs': ` +mod user; +use crate::user::User; + +fn process() { + let u: User = User { name: String::new() }; + u.save(); +} + +fn main() {} +`, + 'src/user.rs': ` +pub struct User { + pub name: String, +} + +impl User { + pub fn save(&self) {} +} +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('resolves u.save() via typed let binding', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find((c) => c.target === 'save' && c.source === 'process'); + expect(saveCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 16. Re-export chain: pub use re-exports +// --------------------------------------------------------------------------- + +describe('Rust scope: pub use re-exports', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rust-scope-reexport-')); + writeFixtureRepo(tmpDir, { + 'src/main.rs': ` +mod models; +use crate::models::User; + +fn process() { + let u = User { name: String::new() }; + u.save(); +} + +fn main() {} +`, + 'src/models.rs': ` +mod user; +pub use self::user::User; +`, + 'src/models/user.rs': ` +pub struct User { + pub name: String, +} + +impl User { + pub fn save(&self) {} +} +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('emits IMPORTS edges through re-export chain', () => { + const imports = getRelationships(result, 'IMPORTS'); + expect(imports.length).toBeGreaterThanOrEqual(1); + }); +}); + +// --------------------------------------------------------------------------- +// 17. Local shadow: inner variable shadows outer +// --------------------------------------------------------------------------- + +describe('Rust scope: local variable shadowing', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rust-scope-shadow-')); + writeFixtureRepo(tmpDir, { + 'src/main.rs': ` +mod user; +use crate::user::User; + +fn process() { + let x = 42; + let x = User { name: String::new() }; + x.save(); +} + +fn main() {} +`, + 'src/user.rs': ` +pub struct User { + pub name: String, +} + +impl User { + pub fn save(&self) {} +} +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('resolves x.save() to User#save after shadow rebind', () => { + const calls = getRelationships(result, 'CALLS'); + const saveCall = calls.find((c) => c.target === 'save' && c.source === 'process'); + expect(saveCall).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 18. Closure / nested function scope +// --------------------------------------------------------------------------- + +describe('Rust scope: closure scope isolation', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rust-scope-closure-')); + writeFixtureRepo(tmpDir, { + 'src/main.rs': ` +fn adder(x: i32) -> i32 { + let f = |y: i32| -> i32 { x + y }; + f(10) +} + +fn main() { + adder(5); +} +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('detects adder function', () => { + const fns = getNodesByLabel(result, 'Function'); + expect(fns).toContain('adder'); + }); + + it('resolves main → adder() call', () => { + const calls = getRelationships(result, 'CALLS'); + const call = calls.find((c) => c.target === 'adder' && c.source === 'main'); + expect(call).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// 19. Trait default method +// --------------------------------------------------------------------------- + +describe('Rust scope: trait default methods', () => { + let result: PipelineResult; + let tmpDir: string; + + beforeAll(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rust-scope-default-method-')); + writeFixtureRepo(tmpDir, { + 'src/main.rs': ` +trait Greeter { + fn name(&self) -> String; + fn greet(&self) -> String { + format!("Hello, {}!", self.name()) + } +} + +struct User { + username: String, +} + +impl Greeter for User { + fn name(&self) -> String { + self.username.clone() + } +} + +fn main() { + let u = User { username: String::from("alice") }; + u.greet(); +} +`, + }); + result = await runPipelineFromRepo(tmpDir, () => {}); + }, 60000); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('detects Greeter trait', () => { + expect(getNodesByLabel(result, 'Trait')).toContain('Greeter'); + }); + + it('detects User struct', () => { + expect(getNodesByLabel(result, 'Struct')).toContain('User'); + }); + + it('emits IMPLEMENTS edge from User to Greeter', () => { + const impls = getRelationships(result, 'IMPLEMENTS'); + const edge = impls.find((e) => e.source === 'User' && e.target === 'Greeter'); + expect(edge).toBeDefined(); + }); +}); diff --git a/gitnexus/test/integration/rust-pipeline-benchmark.test.ts b/gitnexus/test/integration/rust-pipeline-benchmark.test.ts new file mode 100644 index 0000000000..5a3a53a7ca --- /dev/null +++ b/gitnexus/test/integration/rust-pipeline-benchmark.test.ts @@ -0,0 +1,234 @@ +/** + * Rust ingestion pipeline benchmark. + * + * Generates synthetic Rust codebases at increasing scales and measures + * wall-clock time and peak heap through the full pipeline — parsing, + * scope extraction, impl/trait resolution, use decomposition, and + * call resolution. + * + * Run: GITNEXUS_BENCH=1 npx vitest run test/integration/rust-pipeline-benchmark.test.ts + */ +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; + +const BENCH_ENABLED = process.env.GITNEXUS_BENCH === '1'; + +interface BenchResult { + fileCount: number; + structCount: number; + moduleCount: number; + elapsedMs: number; + peakHeapMB: number; + nodeCount: number; + edgeCount: number; +} + +function generateRustFixture( + fileCount: number, + modulesPerLevel: number, +): { dir: string; structCount: number; moduleCount: number } { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `rust-bench-${fileCount}-`)); + const srcDir = path.join(dir, 'src'); + fs.mkdirSync(srcDir, { recursive: true }); + + const modules: string[] = []; + for (let i = 0; i < modulesPerLevel; i++) { + for (let j = 0; j < modulesPerLevel; j++) { + modules.push(`mod${i}_sub${j}`); + } + } + + const moduleCount = modules.length; + const structCount = fileCount; + + const modDeclarations: string[] = []; + const createdModules = new Set(); + + for (let f = 0; f < fileCount; f++) { + const modName = modules[f % modules.length]; + const structName = `Item${f}`; + const traitName = `Process${f}`; + + if (!createdModules.has(modName)) { + const modDir = path.join(srcDir, modName); + fs.mkdirSync(modDir, { recursive: true }); + createdModules.add(modName); + modDeclarations.push(`pub mod ${modName};`); + } + + const siblingIdx = (f + 1) % fileCount; + const siblingStruct = `Item${siblingIdx}`; + const siblingMod = modules[siblingIdx % modules.length]; + + const crossIdx = (f + Math.floor(fileCount / 3)) % fileCount; + const crossStruct = `Item${crossIdx}`; + const crossMod = modules[crossIdx % modules.length]; + + const needsCrossImport = crossMod !== modName; + + const content = [ + needsCrossImport ? `use crate::${crossMod}::${crossStruct};` : '', + modName !== siblingMod ? `use crate::${siblingMod}::${siblingStruct};` : '', + '', + `pub trait ${traitName} {`, + ` fn process(&self) -> String;`, + ` fn default_method(&self) -> bool { true }`, + `}`, + '', + `pub struct ${structName} {`, + ` pub id: u64,`, + ` pub name: String,`, + ` pub value: f64,`, + `}`, + '', + `impl ${structName} {`, + ` pub fn new(id: u64, name: String) -> Self {`, + ` Self { id, name, value: 0.0 }`, + ` }`, + '', + ` pub fn get_id(&self) -> u64 {`, + ` self.id`, + ` }`, + '', + ` pub fn set_value(&mut self, v: f64) {`, + ` self.value = v;`, + ` }`, + '', + ` pub fn compute(&self) -> f64 {`, + ` self.value * self.id as f64`, + ` }`, + `}`, + '', + `impl ${traitName} for ${structName} {`, + ` fn process(&self) -> String {`, + ` format!("{}: {}", self.name, self.compute())`, + ` }`, + `}`, + '', + `pub fn create_${structName.toLowerCase()}(id: u64) -> ${structName} {`, + ` let mut item = ${structName}::new(id, String::from("test"));`, + ` item.set_value(42.0);`, + ` let _result = item.compute();`, + ` let _processed = item.process();`, + ` item`, + `}`, + '', + ].join('\n'); + + const modDir = path.join(srcDir, modName); + const existingMod = path.join(modDir, 'mod.rs'); + const fileBaseName = structName.toLowerCase(); + + fs.writeFileSync(path.join(modDir, `${fileBaseName}.rs`), content); + + const modEntry = `pub mod ${fileBaseName};\npub use ${fileBaseName}::*;\n`; + fs.appendFileSync(existingMod, modEntry); + } + + const libContent = modDeclarations.join('\n') + '\n'; + fs.writeFileSync(path.join(srcDir, 'lib.rs'), libContent); + + const cargoToml = [ + '[package]', + 'name = "bench-rust-pipeline"', + 'version = "0.1.0"', + 'edition = "2021"', + '', + ].join('\n'); + fs.writeFileSync(path.join(dir, 'Cargo.toml'), cargoToml); + + return { dir, structCount, moduleCount }; +} + +async function runBenchmark( + fileCount: number, + modLevels: number, + budgetMs: number, +): Promise { + const { dir, structCount, moduleCount } = generateRustFixture(fileCount, modLevels); + + let peakHeapMB = 0; + const heapSampler = setInterval(() => { + const heap = process.memoryUsage().heapUsed / 1024 / 1024; + if (heap > peakHeapMB) peakHeapMB = heap; + }, 50); + + try { + const start = Date.now(); + const result = await Promise.race([ + runPipelineFromRepo(dir, () => {}, { skipGraphPhases: true }), + new Promise((_, reject) => + setTimeout( + () => reject(new Error(`Pipeline exceeded ${budgetMs}ms at ${fileCount} files`)), + budgetMs, + ), + ), + ]); + const elapsedMs = Date.now() - start; + + return { + fileCount, + structCount, + moduleCount, + elapsedMs, + peakHeapMB: Math.round(peakHeapMB), + nodeCount: result.graph.nodeCount, + edgeCount: result.graph.relationshipCount, + }; + } finally { + clearInterval(heapSampler); + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +function printResults(label: string, results: BenchResult[]) { + console.log(`\n${label}`); + console.log('┌──────────┬─────────┬──────────┬───────────┬──────────┬───────┬───────┐'); + console.log('│ Files │ Structs │ Modules │ Time (ms) │ Heap MB │ Nodes │ Edges │'); + console.log('├──────────┼─────────┼──────────┼───────────┼──────────┼───────┼───────┤'); + for (const r of results) { + console.log( + `│ ${String(r.fileCount).padStart(8)} │ ${String(r.structCount).padStart(7)} │ ${String(r.moduleCount).padStart(8)} │ ${String(r.elapsedMs).padStart(9)} │ ${String(r.peakHeapMB).padStart(8)} │ ${String(r.nodeCount).padStart(5)} │ ${String(r.edgeCount).padStart(5)} │`, + ); + } + console.log('└──────────┴─────────┴──────────┴───────────┴──────────┴───────┴───────┘'); + + if (results.length >= 2) { + console.log('\nScaling ratios (time_ratio / file_ratio):'); + for (let i = 1; i < results.length; i++) { + const fileRatio = results[i].fileCount / results[i - 1].fileCount; + const timeRatio = results[i].elapsedMs / results[i - 1].elapsedMs; + const scaling = timeRatio / fileRatio; + console.log( + ` ${results[i - 1].fileCount} → ${results[i].fileCount}: ${scaling.toFixed(2)}x (${scaling < 1.5 ? 'linear' : scaling < 3 ? 'superlinear' : 'WARNING: quadratic'})`, + ); + } + } +} + +describe.skipIf(!BENCH_ENABLED)('Rust pipeline benchmark', () => { + it('scales with file count (workers enabled)', async () => { + const scales = [100, 250, 500]; + const results: BenchResult[] = []; + + for (const fileCount of scales) { + const modLevels = Math.max(2, Math.ceil(Math.sqrt(fileCount / 4))); + const result = await runBenchmark(fileCount, modLevels, 180_000); + results.push(result); + console.log( + ` ${fileCount} files: ${result.elapsedMs}ms, ${result.peakHeapMB}MB heap, ${result.nodeCount} nodes, ${result.edgeCount} edges`, + ); + } + + printResults('Rust Pipeline — Workers Enabled', results); + + for (let i = 1; i < results.length; i++) { + const fileRatio = results[i].fileCount / results[i - 1].fileCount; + const timeRatio = results[i].elapsedMs / results[i - 1].elapsedMs; + expect(timeRatio / fileRatio).toBeLessThan(3); + } + }, 300_000); +});