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>
This commit is contained in:
Mike Fiedler
2026-05-12 13:25:14 -05:00
committed by GitHub
co-authored by Claude Opus 4.7 copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Colby McHenry
parent 1cbd5a8123
commit 6ac2066a7a
5 changed files with 619 additions and 18 deletions
+307
View File
@@ -260,6 +260,313 @@ describe('rustResolver.extract', () => {
});
});
describe('rustResolver.resolve cargo workspace crates', () => {
it('resolves crate name from workspace member lib.rs', () => {
const workspaceCargo = `
[workspace]
members = ["crates/mytool-core", "crates/mytool-fetcher"]
`;
const coreCargo = `
[package]
name = "mytool-core"
version = "0.1.0"
`;
const libNode: Node = {
id: 'module:crates/mytool-core/src/lib.rs:mytool_core:1',
kind: 'module',
name: 'mytool_core',
qualifiedName: 'crates/mytool-core/src/lib.rs::mytool_core',
filePath: 'crates/mytool-core/src/lib.rs',
language: 'rust',
startLine: 1,
endLine: 1,
startColumn: 0,
endColumn: 0,
updatedAt: Date.now(),
};
const context = {
getNodesInFile: (fp: string) => (fp === 'crates/mytool-core/src/lib.rs' ? [libNode] : []),
getNodesByName: () => [],
getNodesByQualifiedName: () => [],
getNodesByKind: () => [],
fileExists: (p: string) => (
p === 'Cargo.toml' ||
p === 'crates/mytool-core/Cargo.toml' ||
p === 'crates/mytool-core/src/lib.rs'
),
readFile: (p: string) => {
if (p === 'Cargo.toml') return workspaceCargo;
if (p === 'crates/mytool-core/Cargo.toml') return coreCargo;
return null;
},
getProjectRoot: () => '/test',
getAllFiles: () => [
'Cargo.toml',
'crates/mytool-core/Cargo.toml',
'crates/mytool-core/src/lib.rs',
],
getNodesByLowerName: () => [],
getImportMappings: () => [],
};
const ref = {
fromNodeId: 'fn:crates/mytool-fetcher/src/main.rs:main:1',
referenceName: 'mytool_core',
referenceKind: 'references' as const,
line: 1,
column: 1,
filePath: 'crates/mytool-fetcher/src/main.rs',
language: 'rust' as const,
};
const result = rustResolver.resolve(ref, context);
expect(result?.targetNodeId).toBe(libNode.id);
expect(result?.resolvedBy).toBe('framework');
// Workspace-manifest hits are unambiguous and must beat name-matcher's
// self-file matches (0.7) so cross-crate `imports` edges materialize.
expect(result?.confidence).toBeGreaterThanOrEqual(0.9);
});
it('resolves crate name from workspace member main.rs when lib.rs is absent', () => {
const workspaceCargo = `
[workspace]
members = [
"crates/mytool-runner",
]
`;
const runnerCargo = `
[package]
name = "mytool-runner"
version = "0.1.0"
`;
const mainNode: Node = {
id: 'module:crates/mytool-runner/src/main.rs:mytool_runner:1',
kind: 'module',
name: 'mytool_runner',
qualifiedName: 'crates/mytool-runner/src/main.rs::mytool_runner',
filePath: 'crates/mytool-runner/src/main.rs',
language: 'rust',
startLine: 1,
endLine: 1,
startColumn: 0,
endColumn: 0,
updatedAt: Date.now(),
};
const context = {
getNodesInFile: (fp: string) => (fp === 'crates/mytool-runner/src/main.rs' ? [mainNode] : []),
getNodesByName: () => [],
getNodesByQualifiedName: () => [],
getNodesByKind: () => [],
fileExists: (p: string) => (
p === 'Cargo.toml' ||
p === 'crates/mytool-runner/Cargo.toml' ||
p === 'crates/mytool-runner/src/main.rs'
),
readFile: (p: string) => {
if (p === 'Cargo.toml') return workspaceCargo;
if (p === 'crates/mytool-runner/Cargo.toml') return runnerCargo;
return null;
},
getProjectRoot: () => '/test',
getAllFiles: () => [
'Cargo.toml',
'crates/mytool-runner/Cargo.toml',
'crates/mytool-runner/src/main.rs',
],
getNodesByLowerName: () => [],
getImportMappings: () => [],
};
const ref = {
fromNodeId: 'fn:crates/mytool-runner/src/main.rs:main:1',
referenceName: 'mytool_runner',
referenceKind: 'references' as const,
line: 1,
column: 1,
filePath: 'crates/mytool-runner/src/main.rs',
language: 'rust' as const,
};
const result = rustResolver.resolve(ref, context);
expect(result?.targetNodeId).toBe(mainNode.id);
expect(result?.resolvedBy).toBe('framework');
});
it('resolves crate name when members uses a glob (crates/*)', () => {
const workspaceCargo = `
[workspace]
members = ["crates/*"]
`;
const fooCargo = `
[package]
name = "mytool-foo"
version = "0.1.0"
`;
const barCargo = `
[package]
name = "mytool-bar"
version = "0.1.0"
`;
const fooLib: Node = {
id: 'module:crates/mytool-foo/src/lib.rs:mytool_foo:1',
kind: 'module',
name: 'mytool_foo',
qualifiedName: 'crates/mytool-foo/src/lib.rs::mytool_foo',
filePath: 'crates/mytool-foo/src/lib.rs',
language: 'rust',
startLine: 1,
endLine: 1,
startColumn: 0,
endColumn: 0,
updatedAt: Date.now(),
};
const barLib: Node = {
id: 'module:crates/mytool-bar/src/lib.rs:mytool_bar:1',
kind: 'module',
name: 'mytool_bar',
qualifiedName: 'crates/mytool-bar/src/lib.rs::mytool_bar',
filePath: 'crates/mytool-bar/src/lib.rs',
language: 'rust',
startLine: 1,
endLine: 1,
startColumn: 0,
endColumn: 0,
updatedAt: Date.now(),
};
const filesByPath: Record<string, string> = {
'Cargo.toml': workspaceCargo,
'crates/mytool-foo/Cargo.toml': fooCargo,
'crates/mytool-bar/Cargo.toml': barCargo,
};
const nodesByFile: Record<string, Node[]> = {
'crates/mytool-foo/src/lib.rs': [fooLib],
'crates/mytool-bar/src/lib.rs': [barLib],
};
const dirsByPath: Record<string, string[]> = {
'.': ['crates'],
crates: ['mytool-foo', 'mytool-bar'],
'crates/mytool-foo': ['src'],
'crates/mytool-bar': ['src'],
};
const context = {
getNodesInFile: (fp: string) => nodesByFile[fp] ?? [],
getNodesByName: () => [],
getNodesByQualifiedName: () => [],
getNodesByKind: () => [],
fileExists: (p: string) => (
Object.prototype.hasOwnProperty.call(filesByPath, p) ||
Object.prototype.hasOwnProperty.call(nodesByFile, p)
),
readFile: (p: string) => filesByPath[p] ?? null,
getProjectRoot: () => '/test',
getAllFiles: () => [
'Cargo.toml',
...Object.keys(filesByPath).filter((p) => p !== 'Cargo.toml'),
...Object.keys(nodesByFile),
],
getNodesByLowerName: () => [],
getImportMappings: () => [],
listDirectories: (rel: string) => dirsByPath[rel] ?? [],
};
const fooRef = {
fromNodeId: 'fn:crates/mytool-bar/src/lib.rs:other:1',
referenceName: 'mytool_foo',
referenceKind: 'references' as const,
line: 1,
column: 1,
filePath: 'crates/mytool-bar/src/lib.rs',
language: 'rust' as const,
};
const barRef = {
fromNodeId: 'fn:crates/mytool-foo/src/lib.rs:other:1',
referenceName: 'mytool_bar',
referenceKind: 'references' as const,
line: 1,
column: 1,
filePath: 'crates/mytool-foo/src/lib.rs',
language: 'rust' as const,
};
expect(rustResolver.resolve(fooRef, context)?.targetNodeId).toBe(fooLib.id);
expect(rustResolver.resolve(barRef, context)?.targetNodeId).toBe(barLib.id);
});
it('resolves crate name when members uses a name glob at root (helix-*)', () => {
const workspaceCargo = `
[workspace]
members = ["helix-*"]
`;
const coreCargo = `
[package]
name = "helix-core"
version = "0.1.0"
`;
const coreLib: Node = {
id: 'module:helix-core/src/lib.rs:helix_core:1',
kind: 'module',
name: 'helix_core',
qualifiedName: 'helix-core/src/lib.rs::helix_core',
filePath: 'helix-core/src/lib.rs',
language: 'rust',
startLine: 1,
endLine: 1,
startColumn: 0,
endColumn: 0,
updatedAt: Date.now(),
};
const filesByPath: Record<string, string> = {
'Cargo.toml': workspaceCargo,
'helix-core/Cargo.toml': coreCargo,
};
const nodesByFile: Record<string, Node[]> = {
'helix-core/src/lib.rs': [coreLib],
};
const dirsByPath: Record<string, string[]> = {
'.': ['helix-core', 'docs', 'target'],
'helix-core': ['src'],
};
const context = {
getNodesInFile: (fp: string) => nodesByFile[fp] ?? [],
getNodesByName: () => [],
getNodesByQualifiedName: () => [],
getNodesByKind: () => [],
fileExists: (p: string) => (
Object.prototype.hasOwnProperty.call(filesByPath, p) ||
Object.prototype.hasOwnProperty.call(nodesByFile, p)
),
readFile: (p: string) => filesByPath[p] ?? null,
getProjectRoot: () => '/test',
getAllFiles: () => [
'Cargo.toml',
...Object.keys(filesByPath).filter((p) => p !== 'Cargo.toml'),
...Object.keys(nodesByFile),
],
getNodesByLowerName: () => [],
getImportMappings: () => [],
listDirectories: (rel: string) => dirsByPath[rel] ?? [],
};
const ref = {
fromNodeId: 'fn:helix-core/src/lib.rs:other:1',
referenceName: 'helix_core',
referenceKind: 'references' as const,
line: 1,
column: 1,
filePath: 'helix-core/src/lib.rs',
language: 'rust' as const,
};
expect(rustResolver.resolve(ref, context)?.targetNodeId).toBe(coreLib.id);
});
});
import { aspnetResolver } from '../src/resolution/frameworks/csharp';
describe('aspnetResolver.extract', () => {
@@ -0,0 +1,244 @@
/**
* Cargo Workspace Resolver Helper
*
* Parses a project's root Cargo.toml and member crate manifests to
* build a crate-name -> member-directory map. Used by the Rust
* resolver to resolve `use crate_name::...` references that point
* into workspace member crates.
*/
import picomatch from 'picomatch';
import { ResolutionContext } from '../types';
const GLOB_CHARS = /[*?[\]{}!]/;
const SKIP_DIRS = new Set(['target', 'node_modules', '.git', 'dist', 'build']);
const MAX_GLOB_WALK_DEPTH = 5;
function getSection(content: string, sectionName: string): string | null {
const lines = content.split('\n');
let inSection = false;
const sectionLines: string[] = [];
for (const line of lines) {
const trimmed = line.trim();
if (!inSection) {
if (trimmed === `[${sectionName}]`) {
inSection = true;
}
continue;
}
if (/^\[[^\]]+\]$/.test(trimmed)) {
break;
}
sectionLines.push(line);
}
if (!inSection) return null;
return sectionLines.join('\n');
}
function extractQuotedValues(valueList: string): string[] {
const values: string[] = [];
let quote: '"' | "'" | null = null;
let escaped = false;
let current = '';
for (const ch of valueList) {
if (!quote) {
if (ch === '"' || ch === "'") {
quote = ch;
current = '';
}
continue;
}
if (escaped) {
current += ch;
escaped = false;
continue;
}
if (ch === '\\') {
escaped = true;
continue;
}
if (ch === quote) {
values.push(current.trim());
quote = null;
current = '';
continue;
}
current += ch;
}
return values.filter(Boolean);
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function getArrayValue(section: string, key: string): string | null {
const keyRegex = new RegExp(`\\b${escapeRegExp(key)}\\b\\s*=`, 'm');
const keyMatch = keyRegex.exec(section);
if (!keyMatch) return null;
let i = keyMatch.index + keyMatch[0].length;
while (i < section.length && /\s/.test(section.charAt(i))) i++;
if (section.charAt(i) !== '[') return null;
i++;
let inQuote: '"' | "'" | null = null;
let escaped = false;
let depth = 1;
const start = i;
while (i < section.length) {
const ch = section.charAt(i);
if (inQuote) {
if (escaped) {
escaped = false;
} else if (ch === '\\') {
escaped = true;
} else if (ch === inQuote) {
inQuote = null;
}
i++;
continue;
}
if (ch === '"' || ch === "'") {
inQuote = ch;
i++;
continue;
}
if (ch === '[') {
depth++;
i++;
continue;
}
if (ch === ']') {
depth--;
if (depth === 0) {
return section.slice(start, i);
}
i++;
continue;
}
i++;
}
return null;
}
function parseWorkspaceMembers(cargoToml: string): string[] {
const workspaceSection = getSection(cargoToml, 'workspace');
if (!workspaceSection) return [];
const membersValue = getArrayValue(workspaceSection, 'members');
if (!membersValue) return [];
return extractQuotedValues(membersValue);
}
function parsePackageName(cargoToml: string): string | null {
const packageSection = getSection(cargoToml, 'package');
if (!packageSection) return null;
const packageNameMatch = packageSection.match(/name\s*=\s*["']([^"'\n]+)["']/);
return packageNameMatch?.[1]?.trim() ?? null;
}
function addCrateAlias(map: Map<string, string>, crateName: string, memberPath: string): void {
const normalized = crateName.replace(/-/g, '_');
map.set(crateName, memberPath);
if (normalized !== crateName) {
map.set(normalized, memberPath);
}
}
function cleanPath(memberPath: string): string {
return memberPath.replace(/\\/g, '/').replace(/\/$/, '');
}
function expandGlobMember(member: string, context: ResolutionContext): string[] {
if (!context.listDirectories) return [];
const firstGlobIdx = member.search(GLOB_CHARS);
const staticPrefix = member
.slice(0, firstGlobIdx)
.replace(/[^/]*$/, '')
.replace(/\/$/, '');
const matcher = picomatch(member, { dot: false });
const matches: string[] = [];
const seen = new Set<string>();
function walk(dir: string, depth: number): void {
if (depth > MAX_GLOB_WALK_DEPTH) return;
const children = context.listDirectories!(dir);
for (const child of children) {
if (SKIP_DIRS.has(child) || child.startsWith('.')) continue;
const rel = dir === '.' ? child : `${dir}/${child}`;
if (matcher(rel) && !seen.has(rel)) {
seen.add(rel);
matches.push(rel);
}
walk(rel, depth + 1);
}
}
walk(staticPrefix || '.', 0);
return matches;
}
function expandMembers(members: string[], context: ResolutionContext): string[] {
const expanded: string[] = [];
const seen = new Set<string>();
for (const member of members) {
const candidates = GLOB_CHARS.test(member)
? expandGlobMember(member, context)
: [member];
for (const candidate of candidates) {
const cleaned = cleanPath(candidate);
if (seen.has(cleaned)) continue;
seen.add(cleaned);
expanded.push(cleaned);
}
}
return expanded;
}
/**
* Build a map from crate-name aliases to workspace member directory paths.
* Example: "mytool-core" and "mytool_core" -> "crates/mytool-core"
*
* Supports glob members (e.g. `members = ["crates/*"]`) via picomatch
* when the context exposes `listDirectories`.
*/
export function getCargoWorkspaceCrateMap(context: ResolutionContext): Map<string, string> {
const result = new Map<string, string>();
const rootCargoToml = context.readFile('Cargo.toml');
if (!rootCargoToml) return result;
const rawMembers = parseWorkspaceMembers(rootCargoToml);
const members = expandMembers(rawMembers, context);
for (const memberPath of members) {
const memberCargoPath = `${memberPath}/Cargo.toml`;
const memberCargoToml = context.readFile(memberCargoPath);
if (!memberCargoToml) continue;
const packageName = parsePackageName(memberCargoToml);
if (!packageName) continue;
addCrateAlias(result, packageName, memberPath);
}
return result;
}
+41 -18
View File
@@ -7,6 +7,17 @@
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',
@@ -61,10 +72,15 @@ export const rustResolver: FrameworkResolver = {
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,
confidence: 0.6,
targetNodeId: result.targetId,
confidence: result.fromWorkspace ? 0.95 : 0.6,
resolvedBy: 'framework',
};
}
@@ -191,25 +207,32 @@ function resolveByNameAndKind(
return kindFiltered[0]!.id;
}
function resolveModule(name: string, context: ResolutionContext): string | null {
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 possiblePaths = [
`src/${name}.rs`,
`src/${name}/mod.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 modPath of possiblePaths) {
if (context.fileExists(modPath)) {
const nodes = context.getNodesInFile(modPath);
const modNode = nodes.find((n) => n.kind === 'module');
if (modNode) {
return modNode.id;
}
// If no explicit module node, return the first node in the file
if (nodes.length > 0) {
return nodes[0]!.id;
}
}
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;
+18
View File
@@ -257,6 +257,24 @@ export class ReferenceResolver {
return this.queries.getAllFilePaths();
},
listDirectories: (relativePath: string) => {
const target = relativePath === '.' || relativePath === ''
? this.projectRoot
: path.join(this.projectRoot, relativePath);
try {
return fs
.readdirSync(target, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name);
} catch (error) {
logDebug('Failed to list directory for resolution', {
relativePath,
error: String(error),
});
return [];
}
},
getNodesByLowerName: (lowerName: string) => {
const cached = this.lowerNameCache.get(lowerName);
if (cached !== undefined) return cached;
+9
View File
@@ -98,6 +98,15 @@ export interface ResolutionContext {
* re-export chains when this is provided.
*/
getReExports?(filePath: string, language: Language): ReExport[];
/**
* List immediate subdirectories of `relativePath` (relative to the
* project root). Returns an empty array when the path doesn't exist
* or isn't a directory. Used by framework resolvers that need to
* walk build-system metadata (e.g. Cargo workspace globs). Optional
* so external context implementations and test fixtures compile
* without modification.
*/
listDirectories?(relativePath: string): string[];
}
/**