* 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>
245 lines
6.1 KiB
TypeScript
245 lines
6.1 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|