* test+feat: add cargo workspace crate resolution for rust resolver Agent-Logs-Url: https://github.com/miketheman/codegraph/sessions/0101633b-8b63-4951-a6ca-03efe7fafe0b Co-authored-by: miketheman <529516+miketheman@users.noreply.github.com> * perf: cache cargo workspace map during rust resolution Agent-Logs-Url: https://github.com/miketheman/codegraph/sessions/0101633b-8b63-4951-a6ca-03efe7fafe0b Co-authored-by: miketheman <529516+miketheman@users.noreply.github.com> * feat(rust): expand cargo workspace member globs and trust workspace hits - Parse glob entries in `[workspace].members` (e.g. `crates/*`, `helix-*`) via picomatch against a new optional `ResolutionContext.listDirectories` so workspaces that don't enumerate every member are covered. Implementation walks the static-prefix subtree with a depth cap and skips `target`, `node_modules`, `.git`, etc. - Bump Pattern 4's confidence to 0.95 when the workspace map produces a hit. The cargo manifest gives an unambiguous crate-name -> crate-root mapping, so workspace-driven module resolution should beat name-matcher's self-file matches (otherwise every file with `use foo::...` self-resolves at 0.7 and the cross-crate edge never materializes). - Validated against astral-sh/uv (`members = ["crates/*"]`, 67 crates, 567 .rs files): 1,969 cross-crate `imports` edges reaching 60 distinct member lib.rs files, up from 0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Colby McHenry <me@colbymchenry.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
240 lines
7.7 KiB
TypeScript
240 lines
7.7 KiB
TypeScript
/**
|
|
* Rust Framework Resolver
|
|
*
|
|
* Handles Actix-web, Rocket, Axum, and common Rust patterns.
|
|
*/
|
|
|
|
import { Node } from '../../types';
|
|
import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types';
|
|
import { stripCommentsForRegex } from '../strip-comments';
|
|
import { getCargoWorkspaceCrateMap } from './cargo-workspace';
|
|
|
|
const cargoWorkspaceMapCache = new WeakMap<ResolutionContext, Map<string, string>>();
|
|
|
|
function getCachedCargoWorkspaceCrateMap(context: ResolutionContext): Map<string, string> {
|
|
const cached = cargoWorkspaceMapCache.get(context);
|
|
if (cached) return cached;
|
|
const map = getCargoWorkspaceCrateMap(context);
|
|
cargoWorkspaceMapCache.set(context, map);
|
|
return map;
|
|
}
|
|
|
|
export const rustResolver: FrameworkResolver = {
|
|
name: 'rust',
|
|
languages: ['rust'],
|
|
|
|
detect(context: ResolutionContext): boolean {
|
|
// Check for Cargo.toml (Rust project signature)
|
|
return context.fileExists('Cargo.toml');
|
|
},
|
|
|
|
resolve(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null {
|
|
// Pattern 1: Handler references
|
|
if (ref.referenceName.endsWith('_handler') || ref.referenceName.startsWith('handle_')) {
|
|
const result = resolveByNameAndKind(ref.referenceName, FUNCTION_KINDS, HANDLER_DIRS, context);
|
|
if (result) {
|
|
return {
|
|
original: ref,
|
|
targetNodeId: result,
|
|
confidence: 0.8,
|
|
resolvedBy: 'framework',
|
|
};
|
|
}
|
|
}
|
|
|
|
// Pattern 2: Service/Repository trait implementations
|
|
if (ref.referenceName.endsWith('Service') || ref.referenceName.endsWith('Repository')) {
|
|
const result = resolveByNameAndKind(ref.referenceName, SERVICE_KINDS, SERVICE_DIRS, context);
|
|
if (result) {
|
|
return {
|
|
original: ref,
|
|
targetNodeId: result,
|
|
confidence: 0.8,
|
|
resolvedBy: 'framework',
|
|
};
|
|
}
|
|
}
|
|
|
|
// Pattern 3: Struct references (PascalCase)
|
|
if (/^[A-Z][a-zA-Z]+$/.test(ref.referenceName)) {
|
|
const result = resolveByNameAndKind(ref.referenceName, STRUCT_KINDS, MODEL_DIRS, context);
|
|
if (result) {
|
|
return {
|
|
original: ref,
|
|
targetNodeId: result,
|
|
confidence: 0.7,
|
|
resolvedBy: 'framework',
|
|
};
|
|
}
|
|
}
|
|
|
|
// Pattern 4: Module references
|
|
if (/^[a-z_]+$/.test(ref.referenceName)) {
|
|
const result = resolveModule(ref.referenceName, context);
|
|
if (result) {
|
|
// Workspace-manifest hits are an exact crate-name -> crate-root
|
|
// mapping straight from Cargo.toml, so we trust them above
|
|
// name-matcher self-file matches (which otherwise win at 0.7
|
|
// because every file containing `use foo::...` has its own
|
|
// import node named `foo`).
|
|
return {
|
|
original: ref,
|
|
targetNodeId: result.targetId,
|
|
confidence: result.fromWorkspace ? 0.95 : 0.6,
|
|
resolvedBy: 'framework',
|
|
};
|
|
}
|
|
}
|
|
|
|
return null;
|
|
},
|
|
|
|
extract(filePath, content) {
|
|
if (!filePath.endsWith('.rs')) return { nodes: [], references: [] };
|
|
const nodes: Node[] = [];
|
|
const references: UnresolvedRef[] = [];
|
|
const now = Date.now();
|
|
const safe = stripCommentsForRegex(content, 'rust');
|
|
|
|
// Actix-web / Rocket attribute: #[get("/path")] fn handler(..)
|
|
// Capture the method, path, and the fn identifier that follows.
|
|
const attrRegex = /#\[(get|post|put|patch|delete|head|options)\s*\(\s*["']([^"']+)["'][^\]]*\)\]/g;
|
|
let match: RegExpExecArray | null;
|
|
while ((match = attrRegex.exec(safe)) !== null) {
|
|
const [, method, routePath] = match;
|
|
const line = safe.slice(0, match.index).split('\n').length;
|
|
const upper = method!.toUpperCase();
|
|
|
|
const routeNode: Node = {
|
|
id: `route:${filePath}:${line}:${upper}:${routePath}`,
|
|
kind: 'route',
|
|
name: `${upper} ${routePath}`,
|
|
qualifiedName: `${filePath}::route:${routePath}`,
|
|
filePath,
|
|
startLine: line,
|
|
endLine: line,
|
|
startColumn: 0,
|
|
endColumn: match[0].length,
|
|
language: 'rust',
|
|
updatedAt: now,
|
|
};
|
|
nodes.push(routeNode);
|
|
|
|
const tail = safe.slice(match.index + match[0].length);
|
|
const fnMatch = tail.match(/\n\s*(?:pub\s+)?(?:async\s+)?fn\s+(\w+)/);
|
|
if (fnMatch) {
|
|
references.push({
|
|
fromNodeId: routeNode.id,
|
|
referenceName: fnMatch[1]!,
|
|
referenceKind: 'references',
|
|
line,
|
|
column: 0,
|
|
filePath,
|
|
language: 'rust',
|
|
});
|
|
}
|
|
}
|
|
|
|
// Axum: .route("/path", get(handler))
|
|
const axumRegex = /\.route\s*\(\s*"([^"]+)"\s*,\s*(get|post|put|patch|delete)\s*\(\s*(\w+)/g;
|
|
while ((match = axumRegex.exec(safe)) !== null) {
|
|
const [, routePath, method, handler] = match;
|
|
const line = safe.slice(0, match.index).split('\n').length;
|
|
const upper = method!.toUpperCase();
|
|
|
|
const routeNode: Node = {
|
|
id: `route:${filePath}:${line}:${upper}:${routePath}`,
|
|
kind: 'route',
|
|
name: `${upper} ${routePath}`,
|
|
qualifiedName: `${filePath}::route:${routePath}`,
|
|
filePath,
|
|
startLine: line,
|
|
endLine: line,
|
|
startColumn: 0,
|
|
endColumn: match[0].length,
|
|
language: 'rust',
|
|
updatedAt: now,
|
|
};
|
|
nodes.push(routeNode);
|
|
|
|
references.push({
|
|
fromNodeId: routeNode.id,
|
|
referenceName: handler!,
|
|
referenceKind: 'references',
|
|
line,
|
|
column: 0,
|
|
filePath,
|
|
language: 'rust',
|
|
});
|
|
}
|
|
|
|
return { nodes, references };
|
|
},
|
|
};
|
|
|
|
// Directory patterns
|
|
const HANDLER_DIRS = ['/handlers/', '/handler/', '/api/', '/routes/', '/controllers/'];
|
|
const SERVICE_DIRS = ['/services/', '/service/', '/repository/', '/domain/'];
|
|
const MODEL_DIRS = ['/models/', '/model/', '/entities/', '/entity/', '/domain/', '/types/'];
|
|
|
|
const FUNCTION_KINDS = new Set(['function']);
|
|
const SERVICE_KINDS = new Set(['struct', 'trait']);
|
|
const STRUCT_KINDS = new Set(['struct']);
|
|
|
|
/**
|
|
* Resolve a symbol by name using indexed queries instead of scanning all files.
|
|
*/
|
|
function resolveByNameAndKind(
|
|
name: string,
|
|
kinds: Set<string>,
|
|
preferredDirPatterns: string[],
|
|
context: ResolutionContext,
|
|
): string | null {
|
|
const candidates = context.getNodesByName(name);
|
|
if (candidates.length === 0) return null;
|
|
|
|
const kindFiltered = candidates.filter((n) => kinds.has(n.kind));
|
|
if (kindFiltered.length === 0) return null;
|
|
|
|
// Prefer candidates in framework-conventional directories
|
|
const preferred = kindFiltered.filter((n) =>
|
|
preferredDirPatterns.some((d) => n.filePath.includes(d))
|
|
);
|
|
|
|
if (preferred.length > 0) return preferred[0]!.id;
|
|
|
|
// Fall back to any match
|
|
return kindFiltered[0]!.id;
|
|
}
|
|
|
|
interface ModuleResolution {
|
|
targetId: string;
|
|
fromWorkspace: boolean;
|
|
}
|
|
|
|
function resolveModule(name: string, context: ResolutionContext): ModuleResolution | null {
|
|
// Rust modules can be either mod.rs in a directory or name.rs
|
|
const localPaths = [`src/${name}.rs`, `src/${name}/mod.rs`];
|
|
|
|
const workspaceCrates = getCachedCargoWorkspaceCrateMap(context);
|
|
const cratePath = workspaceCrates.get(name);
|
|
const workspacePaths = cratePath
|
|
? [`${cratePath}/src/lib.rs`, `${cratePath}/src/main.rs`]
|
|
: [];
|
|
|
|
const candidates: Array<{ path: string; fromWorkspace: boolean }> = [
|
|
...localPaths.map((path) => ({ path, fromWorkspace: false })),
|
|
...workspacePaths.map((path) => ({ path, fromWorkspace: true })),
|
|
];
|
|
|
|
for (const { path: modPath, fromWorkspace } of candidates) {
|
|
if (!context.fileExists(modPath)) continue;
|
|
const nodes = context.getNodesInFile(modPath);
|
|
const modNode = nodes.find((n) => n.kind === 'module');
|
|
if (modNode) return { targetId: modNode.id, fromWorkspace };
|
|
if (nodes.length > 0) return { targetId: nodes[0]!.id, fromWorkspace };
|
|
}
|
|
|
|
return null;
|
|
}
|