Files
codegraph/src/resolution/frameworks/rust.ts
T
Mike FiedlerGitHubClaude Opus 4.7copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>Colby McHenry
6ac2066a7a test+feat: add cargo workspace crate resolution for rust resolver (#151)
* 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>
2026-05-12 13:25:14 -05:00

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