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>
This commit is contained in:
copilot-swe-agent[bot] 2026-05-16 10:35:42 +00:00 committed by GitHub
parent 08c12e9a14
commit ef43b2e452
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 1166 additions and 0 deletions

View file

@ -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,
});

View 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';
}

View 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;
}

View file

@ -0,0 +1,160 @@
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import {
findNodeAtRange,
nodeToCapture,
syntheticCapture,
type SyntaxNode,
} from '../../utils/ast-helpers.js';
import { getRustParser, getRustScopeQuery } from './query.js';
import { recordRustCacheHit, recordRustCacheMiss } from './cache-stats.js';
import { splitRustUseDeclaration } from './import-decomposer.js';
import { synthesizeRustReceiverBinding } from './receiver-binding.js';
import { getTreeSitterBufferSize } from '../../constants.js';
import { parseSourceSafe } from '../../../tree-sitter/safe-parse.js';
export function emitRustScopeCaptures(
sourceText: string,
_filePath: string,
cachedTree?: unknown,
): readonly CaptureMatch[] {
let tree = cachedTree as ReturnType<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
if (grouped['@scope.function'] !== undefined) {
const scopeCap = grouped['@scope.function']!;
const fnNode = findNodeAtRange(tree.rootNode, scopeCap.range, 'function_item');
if (fnNode !== null) {
const implNode = findEnclosingImpl(fnNode);
const receiver = synthesizeRustReceiverBinding(fnNode, implNode);
if (receiver !== null) out.push(receiver);
}
}
// Attach declaration arity for functions/methods
const declAnchor = grouped['@declaration.function'];
if (declAnchor !== undefined) {
const fnNode = findNodeAtRange(tree.rootNode, declAnchor.range, 'function_item');
if (fnNode !== null) {
const arity = computeRustDeclarationArity(fnNode);
if (arity.parameterCount !== undefined) {
grouped['@declaration.parameter-count'] = syntheticCapture(
'@declaration.parameter-count',
fnNode,
String(arity.parameterCount),
);
}
if (arity.requiredParameterCount !== undefined) {
grouped['@declaration.required-parameter-count'] = syntheticCapture(
'@declaration.required-parameter-count',
fnNode,
String(arity.requiredParameterCount),
);
}
}
}
// Attach call arity for call expressions
const callAnchor =
grouped['@reference.call.free'] ??
grouped['@reference.call.member'] ??
grouped['@reference.call.constructor'];
if (callAnchor !== undefined) {
const callNode = findNodeAtRange(tree.rootNode, callAnchor.range, 'call_expression') ??
findNodeAtRange(tree.rootNode, callAnchor.range, 'struct_expression');
if (callNode !== null) {
const arity = computeRustCallArity(callNode);
if (arity !== undefined) {
grouped['@reference.arity'] = syntheticCapture(
'@reference.arity',
callNode,
String(arity),
);
}
}
}
out.push(grouped);
}
return out;
}
function findEnclosingImpl(node: SyntaxNode): SyntaxNode | null {
let current: SyntaxNode | null = node.parent;
while (current !== null) {
if (current.type === 'impl_item') return current;
if (current.type === 'source_file' || current.type === 'mod_item') return null;
current = current.parent;
}
return null;
}
function computeRustDeclarationArity(
fnNode: SyntaxNode,
): { parameterCount?: number; requiredParameterCount?: number } {
const params = fnNode.childForFieldName('parameters');
if (params === null) return {};
let count = 0;
for (let i = 0; i < params.namedChildCount; i++) {
const child = params.namedChild(i);
if (child === null) continue;
if (child.type === 'self_parameter') continue;
if (child.type === 'parameter') count++;
}
// Rust has no default parameters or overloading
return { parameterCount: count, requiredParameterCount: count };
}
function computeRustCallArity(callNode: SyntaxNode): number | undefined {
if (callNode.type === 'struct_expression') {
const body = callNode.childForFieldName('body');
if (body === null) return 0;
let count = 0;
for (let i = 0; i < body.namedChildCount; i++) {
if (body.namedChild(i)?.type === 'field_initializer') count++;
if (body.namedChild(i)?.type === 'shorthand_field_initializer') count++;
}
return count;
}
const args = callNode.childForFieldName('arguments');
if (args === null) return 0;
let count = 0;
for (let i = 0; i < args.namedChildCount; i++) {
const child = args.namedChild(i);
if (child !== null) count++;
}
return count;
}

View file

@ -0,0 +1,208 @@
import type { CaptureMatch } from 'gitnexus-shared';
import { syntheticCapture } from '../../utils/ast-helpers.js';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
/**
* Decompose a Rust `use_declaration` into individual import captures.
* Handles simple paths, grouped imports ({A, B}), wildcards (*),
* renames (as), and `pub use` re-exports.
*/
export function splitRustUseDeclaration(node: SyntaxNode): CaptureMatch[] {
if (node.type !== 'use_declaration') return [];
const isReexport = hasVisibilityModifier(node);
const argument = getUseArgument(node);
if (argument === null) return [];
return decomposeUseArgument(argument, '', isReexport, node);
}
function hasVisibilityModifier(node: SyntaxNode): boolean {
for (let i = 0; i < node.childCount; i++) {
if (node.child(i)?.type === 'visibility_modifier') return true;
}
return false;
}
function getUseArgument(node: SyntaxNode): SyntaxNode | null {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child === null) continue;
if (
child.type === 'scoped_identifier' ||
child.type === 'scoped_use_list' ||
child.type === 'use_wildcard' ||
child.type === 'use_as_clause' ||
child.type === 'identifier' ||
child.type === 'use_list'
) {
return child;
}
}
return null;
}
function decomposeUseArgument(
node: SyntaxNode,
prefixPath: string,
isReexport: boolean,
anchor: SyntaxNode,
): CaptureMatch[] {
switch (node.type) {
case 'scoped_identifier': {
const path = buildScopedPath(node);
const segments = path.split('::');
const name = segments[segments.length - 1];
return [
makeImportCapture(
anchor,
isReexport ? 'reexport' : 'named',
joinPaths(prefixPath, path),
name,
undefined,
),
];
}
case 'scoped_use_list': {
const pathNode = node.childForFieldName('path');
const listNode = node.childForFieldName('list');
const pathStr = pathNode ? buildNodePath(pathNode) : '';
const fullPrefix = joinPaths(prefixPath, pathStr);
if (listNode === null) return [];
return decomposeUseList(listNode, fullPrefix, isReexport, anchor);
}
case 'use_list': {
return decomposeUseList(node, prefixPath, isReexport, anchor);
}
case 'use_wildcard': {
const wcPath = buildWildcardPath(node);
return [makeImportCapture(anchor, 'wildcard', joinPaths(prefixPath, wcPath), '*', undefined)];
}
case 'use_as_clause': {
const pathChild = node.childForFieldName('path');
const aliasChild = node.childForFieldName('alias');
if (pathChild === null || aliasChild === null) return [];
const originalName =
pathChild.type === 'scoped_identifier'
? buildScopedPath(pathChild)
: pathChild.text;
const aliasName = aliasChild.text;
const segments = originalName.split('::');
const importedName = segments[segments.length - 1];
return [
makeImportCapture(
anchor,
isReexport ? 'reexport' : 'named',
joinPaths(prefixPath, originalName),
importedName,
aliasName,
),
];
}
case 'identifier': {
return [
makeImportCapture(
anchor,
isReexport ? 'reexport' : 'named',
joinPaths(prefixPath, node.text),
node.text,
undefined,
),
];
}
default:
return [];
}
}
function decomposeUseList(
listNode: SyntaxNode,
prefix: string,
isReexport: boolean,
anchor: SyntaxNode,
): CaptureMatch[] {
const out: CaptureMatch[] = [];
for (let i = 0; i < listNode.namedChildCount; i++) {
const child = listNode.namedChild(i);
if (child === null) continue;
if (child.type === 'self') {
// `use crate::models::{self}` — imports the module itself
const segments = prefix.split('::').filter(Boolean);
const name = segments[segments.length - 1] ?? 'self';
out.push(makeImportCapture(anchor, 'namespace', prefix, name, undefined));
} else {
out.push(...decomposeUseArgument(child, prefix, isReexport, anchor));
}
}
return out;
}
function buildScopedPath(node: SyntaxNode): string {
if (node.type === 'scoped_identifier') {
const parts: string[] = [];
collectScopedParts(node, parts);
return parts.join('::');
}
return node.text;
}
function collectScopedParts(node: SyntaxNode, parts: string[]): void {
if (node.type === 'scoped_identifier') {
const pathNode = node.childForFieldName('path');
const nameNode = node.childForFieldName('name');
if (pathNode) collectScopedParts(pathNode, parts);
if (nameNode) parts.push(nameNode.text);
} else {
parts.push(node.text);
}
}
function buildNodePath(node: SyntaxNode): string {
if (node.type === 'scoped_identifier') {
return buildScopedPath(node);
}
return node.text;
}
function buildWildcardPath(node: SyntaxNode): string {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child === null) continue;
if (child.type === 'scoped_identifier') return buildScopedPath(child);
if (child.type === 'identifier') return child.text;
}
return '';
}
function joinPaths(prefix: string, suffix: string): string {
if (!prefix) return suffix;
if (!suffix) return prefix;
return `${prefix}::${suffix}`;
}
function makeImportCapture(
anchor: SyntaxNode,
kind: string,
source: string,
name: string,
alias: string | undefined,
): CaptureMatch {
const result: CaptureMatch = {
'@import.statement': syntheticCapture('@import.statement', anchor, anchor.text),
'@import.kind': syntheticCapture('@import.kind', anchor, kind),
'@import.source': syntheticCapture('@import.source', anchor, source),
'@import.name': syntheticCapture('@import.name', anchor, alias ?? name),
};
if (alias !== undefined) {
result['@import.alias'] = syntheticCapture('@import.alias', anchor, alias);
result['@import.original-name'] = syntheticCapture('@import.original-name', anchor, name);
}
return result;
}

View file

@ -0,0 +1,128 @@
/**
* Resolve a Rust `use` import path to a repo-relative file path.
*
* Rust module resolution rules:
* - `crate::foo::bar` `src/foo/bar.rs` or `src/foo/bar/mod.rs`
* - `super::foo` parent directory's `foo.rs` or `foo/mod.rs`
* - `self::foo` same directory's `foo.rs` or `foo/mod.rs`
* - External crate imports (no `crate::`/`super::`/`self::`) null
*/
export function resolveRustImportTarget(
targetRaw: string,
fromFile: string,
allFilePaths: ReadonlySet<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
return resolveWorkspaceCrate(segments, allFilePaths);
}
function findSrcRoot(filePath: string): string {
const normalized = filePath.replace(/\\/g, '/');
const srcIdx = normalized.lastIndexOf('/src/');
if (srcIdx !== -1) return normalized.slice(0, srcIdx + 4); // includes trailing /src
if (normalized.startsWith('src/')) return 'src';
return '';
}
function resolveModulePath(
pathSegments: string[],
baseDir: string,
allFilePaths: ReadonlySet<string>,
): 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>;
}

View file

@ -0,0 +1,12 @@
/**
* Rust scope-resolution hooks (RFC #909 Ring 3).
*/
export { emitRustScopeCaptures } from './captures.js';
export { getRustCaptureCacheStats, resetRustCaptureCacheStats } from './cache-stats.js';
export { interpretRustImport, interpretRustTypeBinding, normalizeRustTypeName } from './interpret.js';
export { splitRustUseDeclaration } from './import-decomposer.js';
export { synthesizeRustReceiverBinding } from './receiver-binding.js';
export { rustArityCompatibility } from './arity.js';
export { rustMergeBindings } from './merge-bindings.js';
export { rustBindingScopeFor, rustImportOwningScope, rustReceiverBinding } from './simple-hooks.js';
export { resolveRustImportTarget, type RustResolveContext } from './import-target.js';

View file

@ -0,0 +1,162 @@
import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared';
// ─── interpretImport ──────────────────────────────────────────────────────
export function interpretRustImport(captures: CaptureMatch): ParsedImport | null {
const kind = captures['@import.kind']?.text;
const source = captures['@import.source']?.text;
const name = captures['@import.name']?.text;
const alias = captures['@import.alias']?.text;
if (kind === undefined || source === undefined) return null;
if (kind === 'wildcard') return { kind: 'wildcard', targetRaw: source };
if (kind === 'namespace') {
if (name === undefined) return null;
return { kind: 'namespace', localName: name, importedName: name, targetRaw: source };
}
if (kind === 'reexport') {
if (name === undefined) return null;
const originalName = captures['@import.original-name']?.text;
return {
kind: 'named',
localName: alias ?? name,
importedName: originalName ?? name,
targetRaw: source,
isReexport: true,
};
}
// kind === 'named'
if (name === undefined) return null;
const originalName = captures['@import.original-name']?.text;
return {
kind: 'named',
localName: alias ?? name,
importedName: originalName ?? name,
targetRaw: source,
};
}
// ─── interpretTypeBinding ─────────────────────────────────────────────────
export function interpretRustTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null {
const name = captures['@type-binding.name']?.text;
const type = captures['@type-binding.type']?.text;
if (name === undefined || type === undefined) return null;
let source: TypeRef['source'] = 'annotation';
let normalizedType: string;
if (captures['@type-binding.self'] !== undefined) {
source = 'self';
normalizedType = normalizeRustTypeName(type);
} else if (captures['@type-binding.constructor'] !== undefined) {
source = 'constructor-inferred';
normalizedType = normalizeRustTypeName(type);
} else if (captures['@type-binding.call-return'] !== undefined) {
source = 'constructor-inferred';
normalizedType = normalizeRustCallReturnType(type);
} else if (captures['@type-binding.return'] !== undefined) {
source = 'return-annotation';
normalizedType = normalizeRustReturnType(type);
} else if (captures['@type-binding.assignment'] !== undefined) {
source = 'assignment-inferred';
normalizedType = normalizeRustTypeName(type);
} else if (captures['@type-binding.alias'] !== undefined) {
source = 'assignment-inferred';
normalizedType = normalizeRustTypeName(type);
} else if (captures['@type-binding.parameter'] !== undefined) {
source = 'parameter-annotation';
normalizedType = normalizeRustTypeName(type);
} else {
normalizedType = normalizeRustTypeName(type);
}
return { boundName: name, rawTypeName: normalizedType, source };
}
export function normalizeRustTypeName(text: string): string {
let t = text.trim();
// Strip reference prefixes (&, &mut, *const, *mut)
while (t.startsWith('&')) t = t.replace(/^&\s*(mut\s+)?/, '');
while (t.startsWith('*')) t = t.replace(/^\*\s*(const|mut)?\s*/, '');
// Unwrap common smart-pointer/container wrappers to their inner type
const wrappers = ['Box', 'Option', 'Arc', 'Rc', 'Mutex', 'RwLock', 'RefCell', 'Cell'];
for (const w of wrappers) {
if (t.startsWith(`${w}<`)) {
const inner = extractFirstGenericArg(t);
if (inner !== null) {
t = inner;
break;
}
}
}
if (t.startsWith('Vec<')) {
const inner = extractFirstGenericArg(t);
if (inner !== null) t = inner;
}
const bracket = t.indexOf('<');
if (bracket !== -1) t = t.slice(0, bracket);
// Take last segment of qualified paths (crate::foo::Bar → Bar)
const lastColon = t.lastIndexOf('::');
if (lastColon !== -1) t = t.slice(lastColon + 2);
return t.trim();
}
function extractFirstGenericArg(text: string): string | null {
const open = text.indexOf('<');
if (open === -1) return null;
let depth = 0;
for (let i = open; i < text.length; i++) {
if (text[i] === '<') depth++;
else if (text[i] === '>') {
depth--;
if (depth === 0) {
const inner = text.slice(open + 1, i).trim();
const comma = findTopLevelComma(inner);
return comma === -1 ? inner : inner.slice(0, comma).trim();
}
}
}
return null;
}
function findTopLevelComma(text: string): number {
let depth = 0;
for (let i = 0; i < text.length; i++) {
if (text[i] === '<') depth++;
else if (text[i] === '>') depth--;
else if (text[i] === ',' && depth === 0) return i;
}
return -1;
}
function normalizeRustCallReturnType(text: string): string {
let t = text.trim();
// For scoped calls like `Foo::new()`, extract the type part before `::`
const scopeIdx = t.indexOf('::');
if (scopeIdx !== -1) {
t = t.slice(0, scopeIdx);
}
return normalizeRustTypeName(t);
}
function normalizeRustReturnType(text: string): string {
let t = text.trim();
while (t.startsWith('&')) t = t.replace(/^&\s*(mut\s+)?/, '');
// Unwrap Result<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();
}

View 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;
});
}

View file

@ -0,0 +1,63 @@
import type { ParsedFile } from 'gitnexus-shared';
import { isClassLike, populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js';
/**
* Populate `ownerId` on Rust method defs.
*
* Rust methods are declared inside `impl TypeName { ... }` blocks, not
* directly inside struct bodies. The tree-sitter query creates Class scopes
* for impl blocks, and the generic `populateClassOwnedMembers` handles methods
* that are structurally nested inside those Class scopes. But we also need to
* bridge the impl block's methods to the actual struct def, since the impl
* block is semantically "owned by" the struct.
*
* Strategy:
* 1. Run the generic `populateClassOwnedMembers` (handles property fields in
* structs and methods in impl blocks).
* 2. For each method in an impl block's Class scope whose ownerId points to
* the impl block, re-point ownerId to the struct def (if found in the
* same module).
*/
export function populateRustOwners(parsed: ParsedFile): void {
populateClassOwnedMembers(parsed);
populateRustImplOwners(parsed);
}
function populateRustImplOwners(parsed: ParsedFile): void {
// Build a map of struct name → def nodeId from all scopes.
const structByName = new Map<string, string>();
for (const scope of parsed.scopes) {
for (const def of scope.ownedDefs) {
if (isClassLike(def.type) && (def.type === 'Struct' || def.type === 'Trait')) {
structByName.set(def.name, def.nodeId);
}
}
}
if (structByName.size === 0) return;
// For each method whose ownerId is unset, try to remap to the struct def
// using the self typeBinding's receiver type.
for (const scope of parsed.scopes) {
if (scope.kind !== 'Function') continue;
const methodDefs = scope.ownedDefs.filter(
(d) => d.type === 'Method' && d.ownerId === undefined,
);
if (methodDefs.length === 0) continue;
let receiverType: string | undefined;
for (const [, tb] of scope.typeBindings) {
if (tb.source === 'self') {
receiverType = tb.rawName;
break;
}
}
if (receiverType === undefined) continue;
const ownerId = structByName.get(receiverType);
if (ownerId !== undefined) {
for (const def of methodDefs) {
(def as { ownerId?: string }).ownerId = ownerId;
}
}
}
}

View file

@ -0,0 +1,136 @@
import Parser from 'tree-sitter';
import Rust from 'tree-sitter-rust';
const RUST_SCOPE_QUERY = `
;; Scopes
(source_file) @scope.module
(struct_item) @scope.class
(trait_item) @scope.class
(impl_item) @scope.class
(enum_item) @scope.class
(function_item) @scope.function
(closure_expression) @scope.function
(block) @scope.block
(if_expression) @scope.block
(match_expression) @scope.block
(for_expression) @scope.block
(while_expression) @scope.block
(loop_expression) @scope.block
(mod_item) @scope.namespace
;; Declarations struct
(struct_item
name: (type_identifier) @declaration.name) @declaration.struct
;; Declarations trait
(trait_item
name: (type_identifier) @declaration.name) @declaration.trait
;; Declarations enum
(enum_item
name: (type_identifier) @declaration.name) @declaration.enum
;; Declarations function (top-level or inside mod)
(function_item
name: (identifier) @declaration.name) @declaration.function
;; Declarations struct fields
(field_declaration
name: (field_identifier) @declaration.name
type: (_) @declaration.field-type) @declaration.field
;; Declarations variables (let bindings)
(let_declaration
pattern: (identifier) @declaration.name) @declaration.variable
;; Declarations const
(const_item
name: (identifier) @declaration.name) @declaration.const
;; Declarations static
(static_item
name: (identifier) @declaration.name) @declaration.const
;; Imports
(use_declaration) @import.statement
;; Type bindings parameter annotations
(parameter
pattern: (identifier) @type-binding.name
type: (_) @type-binding.type) @type-binding.parameter
;; Type bindings let with type annotation
(let_declaration
pattern: (identifier) @type-binding.name
type: (_) @type-binding.type) @type-binding.assignment
;; Type bindings struct literal constructor inference
(let_declaration
pattern: (identifier) @type-binding.name
value: (struct_expression
name: (_) @type-binding.type)) @type-binding.constructor
;; Type bindings call-return inference (let x = Foo::new())
(let_declaration
pattern: (identifier) @type-binding.name
value: (call_expression
function: (_) @type-binding.type)) @type-binding.call-return
;; Type bindings variable alias (let x = y)
(let_declaration
pattern: (identifier) @type-binding.name
value: (identifier) @type-binding.type) @type-binding.alias
;; Type bindings return type annotation
(function_item
name: (identifier) @type-binding.name
return_type: (_) @type-binding.type) @type-binding.return
;; References free calls
(call_expression
function: (identifier) @reference.name) @reference.call.free
;; References member calls (obj.method())
(call_expression
function: (field_expression
value: (_) @reference.receiver
field: (field_identifier) @reference.name)) @reference.call.member
;; References scoped calls (Foo::bar())
(call_expression
function: (scoped_identifier
name: (identifier) @reference.name)) @reference.call.free
;; References constructor calls (struct literal)
(struct_expression
name: (_) @reference.name) @reference.call.constructor
;; References field reads
(field_expression
value: (_) @reference.receiver
field: (field_identifier) @reference.name) @reference.read
;; References field writes (assignment)
(assignment_expression
left: (field_expression
value: (_) @reference.receiver
field: (field_identifier) @reference.name)) @reference.write
`;
let _parser: Parser | null = null;
let _query: Parser.Query | null = null;
export function getRustParser(): Parser {
if (_parser === null) {
_parser = new Parser();
_parser.setLanguage(Rust as Parameters<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;
}

View file

@ -0,0 +1,136 @@
import type { CaptureMatch } from 'gitnexus-shared';
import { syntheticCapture } from '../../utils/ast-helpers.js';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
/**
* Given a function_item node that is inside an impl_item, synthesize a
* self-type-binding capture if the function has a `self_parameter`.
*
* The impl_item structure:
* impl [TraitName for] TypeName { fn method(&self) { ... } }
*/
export function synthesizeRustReceiverBinding(
fnNode: SyntaxNode,
implNode: SyntaxNode | null,
): CaptureMatch | null {
if (fnNode.type !== 'function_item') return null;
if (implNode === null) return null;
const params = fnNode.childForFieldName('parameters');
if (params === null) return null;
let hasSelf = false;
for (let i = 0; i < params.namedChildCount; i++) {
if (params.namedChild(i)?.type === 'self_parameter') {
hasSelf = true;
break;
}
}
if (!hasSelf) return null;
const implType = getImplTargetType(implNode);
if (implType === null) return null;
return {
'@type-binding.self': syntheticCapture('@type-binding.self', fnNode, 'self'),
'@type-binding.name': syntheticCapture('@type-binding.name', fnNode, 'self'),
'@type-binding.type': syntheticCapture('@type-binding.type', fnNode, implType),
};
}
/**
* Extract the target type from an impl_item.
* `impl TypeName { ... }` "TypeName"
* `impl TraitName for TypeName { ... }` "TypeName"
*/
export function getImplTargetType(implNode: SyntaxNode): string | null {
if (implNode.type !== 'impl_item') return null;
// Look for `for` keyword — if present, impl is `impl Trait for Type`
let hasFor = false;
let typeAfterFor: SyntaxNode | null = null;
for (let i = 0; i < implNode.childCount; i++) {
const child = implNode.child(i);
if (child === null) continue;
if (child.type === 'for') {
hasFor = true;
continue;
}
if (hasFor && child.type === 'type_identifier') {
typeAfterFor = child;
break;
}
if (hasFor && child.type === 'scoped_type_identifier') {
typeAfterFor = child;
break;
}
if (hasFor && child.type === 'generic_type') {
typeAfterFor = child;
break;
}
}
if (hasFor && typeAfterFor !== null) {
return normalizeRustTypeName(typeAfterFor.text);
}
// No `for` keyword: impl TypeName { ... }
const typeField = implNode.childForFieldName('type');
if (typeField !== null) {
return normalizeRustTypeName(typeField.text);
}
// Fallback: find first type_identifier after `impl`
let afterImpl = false;
for (let i = 0; i < implNode.childCount; i++) {
const child = implNode.child(i);
if (child === null) continue;
if (child.type === 'impl') {
afterImpl = true;
continue;
}
if (afterImpl && (child.type === 'type_identifier' || child.type === 'generic_type')) {
return normalizeRustTypeName(child.text);
}
}
return null;
}
/**
* Extract the trait name from an impl_item when it's `impl Trait for Type`.
*/
export function getImplTraitName(implNode: SyntaxNode): string | null {
if (implNode.type !== 'impl_item') return null;
let afterImpl = false;
for (let i = 0; i < implNode.childCount; i++) {
const child = implNode.child(i);
if (child === null) continue;
if (child.type === 'impl') {
afterImpl = true;
continue;
}
if (child.type === 'for') {
break;
}
if (afterImpl && (child.type === 'type_identifier' || child.type === 'scoped_type_identifier')) {
for (let j = i + 1; j < implNode.childCount; j++) {
const next = implNode.child(j);
if (next === null) continue;
if (next.type === 'for') {
return normalizeRustTypeName(child.text);
}
break;
}
}
}
return null;
}
function normalizeRustTypeName(text: string): string {
let t = text.trim();
while (t.startsWith('&')) t = t.replace(/^&\s*(mut\s+)?/, '');
while (t.startsWith('*')) t = t.slice(1).trim();
const bracket = t.indexOf('<');
if (bracket !== -1) t = t.slice(0, bracket);
return t.trim();
}

View file

@ -0,0 +1,37 @@
import type { ParsedFile } from 'gitnexus-shared';
import { SupportedLanguages } from 'gitnexus-shared';
import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js';
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
import { rustProvider } from '../rust.js';
import {
rustArityCompatibility,
rustMergeBindings,
resolveRustImportTarget,
} from './index.js';
import { populateRustOwners } from './method-owners.js';
export const rustScopeResolver: ScopeResolver = {
language: SupportedLanguages.Rust,
languageProvider: rustProvider,
importEdgeReason: 'rust-scope: use',
resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) =>
resolveRustImportTarget(targetRaw, fromFile, allFilePaths, resolutionConfig),
mergeBindings: (existing, incoming, scopeId) =>
rustMergeBindings(existing, incoming, scopeId),
arityCompatibility: (callsite, def) => rustArityCompatibility(def, callsite),
buildMro: (graph, parsedFiles, nodeLookup) =>
buildMro(graph, parsedFiles, nodeLookup, defaultLinearize),
populateOwners: (parsed: ParsedFile) => populateRustOwners(parsed),
isSuperReceiver: () => false,
fieldFallbackOnMethodLookup: false,
hoistTypeBindingsToModule: true,
propagatesReturnTypesAcrossImports: true,
allowGlobalFreeCallFallback: true,
};

View 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;
}

View file

@ -74,6 +74,7 @@ export const MIGRATED_LANGUAGES: ReadonlySet<SupportedLanguages> = new Set<Suppo
SupportedLanguages.C,
SupportedLanguages.CPlusPlus,
SupportedLanguages.PHP,
SupportedLanguages.Rust,
]);
/**

View file

@ -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';
/** Map of `SupportedLanguages` `ScopeResolver`. The phase iterates
* this map intersected with `MIGRATED_LANGUAGES` (the per-language
@ -36,4 +37,5 @@ export const SCOPE_RESOLVERS: ReadonlyMap<SupportedLanguages, ScopeResolver> = n
[SupportedLanguages.C, cScopeResolver],
[SupportedLanguages.CPlusPlus, cppScopeResolver],
[SupportedLanguages.PHP, phpScopeResolver],
[SupportedLanguages.Rust, rustScopeResolver],
]);