mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-29 06:32:28 +00:00
* Initial plan * feat: add Rust scope-resolution hooks (RFC #909 Ring 3) Implement the scope-based resolution pipeline for Rust, following the established pattern from Go and other migrated languages. New files in gitnexus/src/core/ingestion/languages/rust/: - query.ts: tree-sitter scope query covering scopes, declarations, imports, type bindings, and references - cache-stats.ts: parse cache hit/miss counters - import-decomposer.ts: decomposes use declarations into individual import captures (handles grouped, wildcard, renamed, re-exported) - receiver-binding.ts: synthesizes self type bindings for impl methods - interpret.ts: interprets captures into ParsedImport/ParsedTypeBinding - arity.ts: arity compatibility checker (no overloading in Rust) - merge-bindings.ts: local-shadows-import binding merge strategy - simple-hooks.ts: binding scope, import owning scope, receiver binding - import-target.ts: resolves Rust module paths (crate/super/self) - method-owners.ts: bridges impl block methods to struct defs - captures.ts: main emit function with import decomposition and self-binding synthesis - scope-resolver.ts: ScopeResolver implementation - index.ts: barrel re-exports Wiring changes: - rust.ts: add scope hook imports and properties to defineLanguage - registry.ts: register rustScopeResolver in SCOPE_RESOLVERS - registry-primary-flag.ts: add Rust to MIGRATED_LANGUAGES Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * feat(LANG-rust): add scope-resolution hooks and register Rust ScopeResolver Implements RFC #909 Ring 3 deliverables: - query.ts: tree-sitter scope query for Rust - captures.ts: emitRustScopeCaptures with method reclassification - import-decomposer.ts: use statement decomposition (groups, renames, globs) - interpret.ts: interpretRustImport + interpretRustTypeBinding - import-target.ts: crate/module/super/self path resolution - receiver-binding.ts: self/&self/&mut self receiver synthesis - method-owners.ts: impl block → struct ownership bridging - arity.ts: no-overloading arity check - merge-bindings.ts: local > import > wildcard binding precedence - simple-hooks.ts: binding/import scope, receiver binding - scope-resolver.ts: ScopeResolver contract implementation - Wired into rustProvider (rust.ts) with scope hooks - Registered in SCOPE_RESOLVERS (pipeline/registry.ts) - NOT yet added to MIGRATED_LANGUAGES (29 advanced pattern tests pending) Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/8f14b730-79d4-4356-9505-325750d71f84 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * Add Rust scope-resolution integration tests (RFC #909 Ring 3) Tests cover the core deliverables for the Rust scope-resolution pipeline: - impl blocks and trait implementations - Module resolution (crate::, super::, self::) - Struct fields and type bindings - Self/&self/&mut self receiver binding - Generic functions (V1 ignores generic args) - Grouped imports (use foo::{A, B}) - Renamed imports (use foo::Bar as Baz) - Arity checking (no overloading) - Struct literal constructor inference - Return type inference - Scoped/qualified calls (Foo::new()) - Enum declarations - Multiple impl blocks - Free function calls - Typed let bindings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * test(LANG-rust): add 28 scope-resolution integration tests Covers 15 test suites validating: impl blocks, trait impls, grouped imports, renamed imports, module resolution, receiver binding, arity filtering, struct literal inference, return type inference, qualified calls, struct fields, enums, multiple impl blocks, free calls, and typed let bindings. Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/8f14b730-79d4-4356-9505-325750d71f84 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * feat(LANG-rust): add implicit crate path fallback, 35 scope tests - Import resolver now falls back to crate-relative for unqualified module paths (Rust 2015 edition compat) - Added 7 more test cases: re-exports, shadowing, closures, default trait methods (35 total, exceeding ≥30 requirement) Agent-Logs-Url: https://github.com/abhigyanpatwari/GitNexus/sessions/8f14b730-79d4-4356-9505-325750d71f84 Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> * feat(rust): add Rust to MIGRATED_LANGUAGES with 100% scope-resolution parity 35/35 integration tests pass under both REGISTRY_PRIMARY_RUST=0 (legacy) and =1 (scope-resolution). Rust scope-resolution is now the default production call-resolution path. * test(rust): add pipeline benchmark matching PHP benchmark pattern Generates synthetic Rust codebases at 100/250/500 files with structs, impl blocks, traits, cross-module use declarations, and method calls. Measures wall-clock time, peak heap, and scaling ratios. Results: sub-linear scaling (0.79x ratio), 500 files in 3.8s with workers, 110MB peak heap. Gated by GITNEXUS_BENCH=1. * chore(autofix): apply prettier + eslint fixes via /autofix command * perf(rust): optimize captures, type normalization, and method-owner linking - Cache findEnclosingImpl result to avoid duplicate tree walk per function - Avoid double namedChild accessor call in struct field arity counting - Extract regex constants (REF_PREFIX_RE, PTR_PREFIX_RE) from hot normalization loops - Replace O(s) suffix-match scan with O(1) Map lookup in method-owner linking Benchmark: 500 files 3806ms → 2970ms (-22%), 250 files 2432ms → 1752ms (-28%) * fix(rust): resolve CodeQL alerts — file-system race and dead code - Remove existsSync+appendFileSync/writeFileSync TOCTOU in benchmark fixture generator; appendFileSync creates if missing - Remove always-true guard on computeRustCallArity return; narrow return type from number|undefined to number - Remove no-op .filter() in fixture generator * feat(rust): hoist impl return-type bindings to struct scope for chain resolution Synthesize a module-level duplicate of @type-binding.return captures for methods inside impl blocks. The scope-extractor's auto-hoist places these on the struct's Class scope, making them visible to the compound receiver chain resolver via classScopeByDefId. Without this, method return types are only on the impl block scope (which has no class-like def and is not indexed by classScopeByDefId), so chains like svc.get_user().save() cannot follow the intermediate return type. This is the structural prerequisite for chain resolution, pattern binding, and for-loop element-type parity (28 remaining tests). The cross-file return-type propagation step still needs wiring for full parity. * fix(rust): use implNode anchor for return-type hoisting — fixes chain resolution Use the enclosing impl_item node (not tree.rootNode) as the synthetic capture anchor. The scope-extractor's auto-hoist places bindings whose anchor matches the innermost scope on the parent scope. With implNode, the binding lands on the Module scope (parent of impl's Class scope), giving declaredAtScope the correct context for findClassBindingInScope to resolve the return type across the scope chain. Unlocks: chain calls (svc.get_user().save()), return-type inference, assignment chains, cross-file binding propagation, call-result binding, deep field chains — 28→27 failing tests. * feat(rust): add populateRangeBindings hook + .await query capture Implement populateRustRangeBindings (Phase 2 hook, same pattern as Go's populateGoRangeBindings) to populate type bindings that need runtime type lookup — for-loop element types, if-let/while-let captured patterns, match arm patterns, and struct destructuring field types. Also add tree-sitter query capture for let x = fn().await — unwraps await_expression to find the inner call_expression. 28→19 failing tests: fixes for-loop Tier 1c, .iter()/.into_iter(), async .await, if-let captured_pattern. * fix(rust): fix tuple_struct_pattern variable extraction + Result<T,E> raw type lookup - Skip wrapper type identifier when finding bound variable in tuple_struct_pattern (Some(user) was binding 'Some' not 'user') - Add lookupRawParameterType to read unstripped generic type from AST for Ok/Err pattern resolution (normalizeRustTypeName strips generics) 28→15 failing tests: fixes if-let Some, if-let Ok/Err, match arm patterns. * fix(rust): fix match_arm parent traversal + raw return type for for-loop calls - Walk up from match_arm through match_block to find match_expression for source variable extraction - Add lookupRawFunctionReturnType to find unstripped return type from AST for same-file for-loop call expression iterables 28→14 failing tests. * feat(rust): inject field type bindings on struct scopes for chain resolution Walk struct_item AST nodes and inject field types (e.g., address -> Address) as typeBindings on the struct's Class scope. The compound receiver chain resolver uses these to follow field chains like user.address.save(). Also fixes: match_arm parent traversal to match_expression, lookupFieldType to check typeBindings first. 28→11 failing tests: fixes field type chains, deep chains, struct destructuring. * feat(rust): cross-file return type lookup for for-loop call iterables Build allReturnTypes map across all parsedFiles in Phase 2 first pass, then use it to resolve for-loop iterables like `for x in get_fn()` when get_fn is defined in another file. 28→9 failing tests. * feat(rust): cross-file field type map for struct destructuring Build allFieldTypes map across parsedFiles in Phase 2 first pass. Used by processStructDestructuring to resolve `let Point { x, y } = p` when Point is defined in another file. 28→7 failing tests. * fix(rust): compound assignment write capture + pending assignment fixpoint - Add compound_assignment_expr query for +=, -=, etc. field writes - Add processPendingAssignments with 3-pass fixpoint for field access and method call result variable bindings (let addr = user.address, let city = addr.get_city()) 28→5 failing tests. * fix(rust): identity method return-type bindings for unwrap/expect chains Inject unwrap/expect/clone/as_ref/as_mut as return-type bindings on struct scopes that return the struct's own type. Since normalizeRustTypeName already unwraps Option<T> → T, calling .unwrap() on a value typed as T is semantically an identity — the return type equals the receiver type. 28→3 failing tests: fixes user.unwrap().save() and repo.unwrap().save() chains. * fix(rust): skip enum variant call-return bindings + cross-file pending assignments + identity alias - Skip Some/None/Ok/Err in @type-binding.call-return — these are enum variant constructors, not type names; let the annotation capture win - Add identifier alias handler in processPendingAssignments for `let alias = opt` chains - Cross-file field type and method return type lookup in pending assignment fixpoint via findFieldTypeAcrossFiles/findMethodReturnTypeAcrossFiles - Identity method bindings (unwrap/expect/clone) on struct scopes 155/156 tests pass (99.4%). Remaining: trait default method dispatch via MRO (repo.count() where count has default impl on Repository trait). * feat(rust): 100% scope-resolution parity — MRO with same-file IMPLEMENTS + trait default method reclassification - Add buildRustMro that includes same-file IMPLEMENTS edges in the MRO chain, so trait default methods (e.g., repo.count()) resolve through the struct → trait ancestry walk - Only add IMPLEMENTS to MRO when struct and trait are in the same file; cross-file trait calls require the trait to be imported (Rust semantics) - Reclassify function_item inside trait_item as @declaration.method so default trait methods register in the model's methods lookup 156/156 legacy parity tests pass. 35/35 scope tests pass. 0 regressions. * fix(rust): address code review findings — null guard, name collision, scope order - Fix Array.find() null guard: check === undefined not === null in processCapturedPattern (find() never returns null) - Fix allReturnTypes/allFieldTypes name collision: delete entry on second occurrence so colliding names (new, default, Config) produce no result rather than a wrong result - Fix lookupTypeInScopes: search function scope then module scope only, skip unrelated Class scopes that could shadow names from other functions --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: magyargergo <11230420+magyargergo@users.noreply.github.com> Co-authored-by: Gergő Magyar <gergomagyar@icloud.com> Co-authored-by: Test <test@example.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
parent
9f99259e5c
commit
bdb3b8aaa3
19 changed files with 3265 additions and 0 deletions
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
15
gitnexus/src/core/ingestion/languages/rust/arity.ts
Normal file
15
gitnexus/src/core/ingestion/languages/rust/arity.ts
Normal file
|
|
@ -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';
|
||||
}
|
||||
18
gitnexus/src/core/ingestion/languages/rust/cache-stats.ts
Normal file
18
gitnexus/src/core/ingestion/languages/rust/cache-stats.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
223
gitnexus/src/core/ingestion/languages/rust/captures.ts
Normal file
223
gitnexus/src/core/ingestion/languages/rust/captures.ts
Normal file
|
|
@ -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<ReturnType<typeof getRustParser>['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<string, Capture> = {};
|
||||
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<string, Capture | undefined>)['@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;
|
||||
}
|
||||
207
gitnexus/src/core/ingestion/languages/rust/import-decomposer.ts
Normal file
207
gitnexus/src/core/ingestion/languages/rust/import-decomposer.ts
Normal file
|
|
@ -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),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
129
gitnexus/src/core/ingestion/languages/rust/import-target.ts
Normal file
129
gitnexus/src/core/ingestion/languages/rust/import-target.ts
Normal file
|
|
@ -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<string>,
|
||||
_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>,
|
||||
): 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>,
|
||||
): 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<string>;
|
||||
}
|
||||
16
gitnexus/src/core/ingestion/languages/rust/index.ts
Normal file
16
gitnexus/src/core/ingestion/languages/rust/index.ts
Normal file
|
|
@ -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';
|
||||
166
gitnexus/src/core/ingestion/languages/rust/interpret.ts
Normal file
166
gitnexus/src/core/ingestion/languages/rust/interpret.ts
Normal file
|
|
@ -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<T, E>, Option<T> 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();
|
||||
}
|
||||
27
gitnexus/src/core/ingestion/languages/rust/merge-bindings.ts
Normal file
27
gitnexus/src/core/ingestion/languages/rust/merge-bindings.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import type { BindingRef } from 'gitnexus-shared';
|
||||
|
||||
const TIER: Record<BindingRef['origin'], number> = {
|
||||
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<string>();
|
||||
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;
|
||||
});
|
||||
}
|
||||
68
gitnexus/src/core/ingestion/languages/rust/method-owners.ts
Normal file
68
gitnexus/src/core/ingestion/languages/rust/method-owners.ts
Normal file
|
|
@ -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<string, string>();
|
||||
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<string, string>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
149
gitnexus/src/core/ingestion/languages/rust/query.ts
Normal file
149
gitnexus/src/core/ingestion/languages/rust/query.ts
Normal file
|
|
@ -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<Parser['setLanguage']>[0]);
|
||||
}
|
||||
return _parser;
|
||||
}
|
||||
|
||||
export function getRustScopeQuery(): Parser.Query {
|
||||
if (_query === null) {
|
||||
_query = new Parser.Query(Rust as Parameters<Parser['setLanguage']>[0], RUST_SCOPE_QUERY);
|
||||
}
|
||||
return _query;
|
||||
}
|
||||
708
gitnexus/src/core/ingestion/languages/rust/range-binding.ts
Normal file
708
gitnexus/src/core/ingestion/languages/rust/range-binding.ts
Normal file
|
|
@ -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<string, string>;
|
||||
readonly treeCache?: { get(filePath: string): unknown };
|
||||
},
|
||||
): void {
|
||||
const parser = getRustParser();
|
||||
const allReturnTypes = new Map<string, string>();
|
||||
const allFieldTypes = new Map<string, Map<string, string>>();
|
||||
|
||||
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<typeof parser.parse> | 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<string, string>();
|
||||
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<typeof parser.parse> | 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<string, Scope>,
|
||||
): 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<string, Scope>,
|
||||
): 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<string, Scope>,
|
||||
moduleScope: Scope,
|
||||
allReturnTypes: ReadonlyMap<string, string>,
|
||||
): 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<string, Scope>,
|
||||
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<string, Scope>,
|
||||
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<string, Scope>,
|
||||
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<string, Scope>,
|
||||
moduleScope: Scope,
|
||||
allFieldTypes?: ReadonlyMap<string, Map<string, string>>,
|
||||
): 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<string, TypeRef>).set(method, {
|
||||
rawName: name,
|
||||
declaredAtScope: scope.id,
|
||||
source: 'return-annotation',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function processPendingAssignments(
|
||||
root: SyntaxNode,
|
||||
parsed: ParsedFile,
|
||||
allParsedFiles: readonly ParsedFile[],
|
||||
scopeMap: ReadonlyMap<string, Scope>,
|
||||
moduleScope: Scope,
|
||||
allReturnTypes: ReadonlyMap<string, string>,
|
||||
): 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<string, Scope>,
|
||||
moduleScope: Scope,
|
||||
allReturnTypes?: ReadonlyMap<string, string>,
|
||||
): 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<string, Scope>,
|
||||
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<string, Scope>,
|
||||
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<string, Scope>,
|
||||
_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<string, TypeRef>).set(name, {
|
||||
rawName: typeName,
|
||||
declaredAtScope: scope.id,
|
||||
source: 'annotation',
|
||||
});
|
||||
}
|
||||
|
||||
function findEnclosingFunctionScope(
|
||||
node: SyntaxNode,
|
||||
scopeMap: ReadonlyMap<ScopeId, Scope>,
|
||||
): 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;
|
||||
}
|
||||
139
gitnexus/src/core/ingestion/languages/rust/receiver-binding.ts
Normal file
139
gitnexus/src/core/ingestion/languages/rust/receiver-binding.ts
Normal file
|
|
@ -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();
|
||||
}
|
||||
79
gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts
Normal file
79
gitnexus/src/core/ingestion/languages/rust/scope-resolver.ts
Normal file
|
|
@ -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<ScopeResolver['buildMro']>[0],
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
nodeLookup: Parameters<ScopeResolver['buildMro']>[2],
|
||||
): Map<string, string[]> {
|
||||
const baseMro = buildMro(graph, parsedFiles, nodeLookup, defaultLinearize);
|
||||
|
||||
const defIdByGraphId = new Map<string, string>();
|
||||
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<string, string>();
|
||||
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,
|
||||
};
|
||||
44
gitnexus/src/core/ingestion/languages/rust/simple-hooks.ts
Normal file
44
gitnexus/src/core/ingestion/languages/rust/simple-hooks.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -78,6 +78,7 @@ export const MIGRATED_LANGUAGES: ReadonlySet<SupportedLanguages> = new Set<Suppo
|
|||
SupportedLanguages.JavaScript,
|
||||
SupportedLanguages.Kotlin,
|
||||
SupportedLanguages.Java,
|
||||
SupportedLanguages.Rust,
|
||||
]);
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { javaScopeResolver } from '../../languages/java/scope-resolver.js';
|
|||
import { cScopeResolver } from '../../languages/c/scope-resolver.js';
|
||||
import { cppScopeResolver } from '../../languages/cpp/scope-resolver.js';
|
||||
import { phpScopeResolver } from '../../languages/php/scope-resolver.js';
|
||||
import { rustScopeResolver } from '../../languages/rust/scope-resolver.js';
|
||||
import { javascriptScopeResolver } from '../../languages/javascript/scope-resolver.js';
|
||||
import { kotlinScopeResolver } from '../../languages/kotlin/scope-resolver.js';
|
||||
|
||||
|
|
@ -38,6 +39,7 @@ export const SCOPE_RESOLVERS: ReadonlyMap<SupportedLanguages, ScopeResolver> = n
|
|||
[SupportedLanguages.C, cScopeResolver],
|
||||
[SupportedLanguages.CPlusPlus, cppScopeResolver],
|
||||
[SupportedLanguages.PHP, phpScopeResolver],
|
||||
[SupportedLanguages.Rust, rustScopeResolver],
|
||||
[SupportedLanguages.JavaScript, javascriptScopeResolver],
|
||||
[SupportedLanguages.Kotlin, kotlinScopeResolver],
|
||||
]);
|
||||
|
|
|
|||
1023
gitnexus/test/integration/resolvers/rust-scope.test.ts
Normal file
1023
gitnexus/test/integration/resolvers/rust-scope.test.ts
Normal file
File diff suppressed because it is too large
Load diff
234
gitnexus/test/integration/rust-pipeline-benchmark.test.ts
Normal file
234
gitnexus/test/integration/rust-pipeline-benchmark.test.ts
Normal file
|
|
@ -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<string>();
|
||||
|
||||
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<BenchResult> {
|
||||
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<never>((_, 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);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue