diff --git a/__tests__/frameworks.test.ts b/__tests__/frameworks.test.ts index 9922b70..8eb33e2 100644 --- a/__tests__/frameworks.test.ts +++ b/__tests__/frameworks.test.ts @@ -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 = { + 'Cargo.toml': workspaceCargo, + 'crates/mytool-foo/Cargo.toml': fooCargo, + 'crates/mytool-bar/Cargo.toml': barCargo, + }; + const nodesByFile: Record = { + 'crates/mytool-foo/src/lib.rs': [fooLib], + 'crates/mytool-bar/src/lib.rs': [barLib], + }; + const dirsByPath: Record = { + '.': ['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 = { + 'Cargo.toml': workspaceCargo, + 'helix-core/Cargo.toml': coreCargo, + }; + const nodesByFile: Record = { + 'helix-core/src/lib.rs': [coreLib], + }; + const dirsByPath: Record = { + '.': ['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', () => { diff --git a/src/resolution/frameworks/cargo-workspace.ts b/src/resolution/frameworks/cargo-workspace.ts new file mode 100644 index 0000000..2a81284 --- /dev/null +++ b/src/resolution/frameworks/cargo-workspace.ts @@ -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, 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(); + + 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(); + 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 { + const result = new Map(); + 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; +} diff --git a/src/resolution/frameworks/rust.ts b/src/resolution/frameworks/rust.ts index f0ce272..a94cf50 100644 --- a/src/resolution/frameworks/rust.ts +++ b/src/resolution/frameworks/rust.ts @@ -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>(); + +function getCachedCargoWorkspaceCrateMap(context: ResolutionContext): Map { + 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; diff --git a/src/resolution/index.ts b/src/resolution/index.ts index f5c2234..34aa4b9 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -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; diff --git a/src/resolution/types.ts b/src/resolution/types.ts index ceeacc2..6fe11a0 100644 --- a/src/resolution/types.ts +++ b/src/resolution/types.ts @@ -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[]; } /**