Skip to content

Commit 6ac2066

Browse files
mikethemanCopilotcolbymchenryclaude
authored
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>
1 parent 1cbd5a8 commit 6ac2066

5 files changed

Lines changed: 619 additions & 18 deletions

File tree

__tests__/frameworks.test.ts

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,313 @@ describe('rustResolver.extract', () => {
260260
});
261261
});
262262

263+
describe('rustResolver.resolve cargo workspace crates', () => {
264+
it('resolves crate name from workspace member lib.rs', () => {
265+
const workspaceCargo = `
266+
[workspace]
267+
members = ["crates/mytool-core", "crates/mytool-fetcher"]
268+
`;
269+
const coreCargo = `
270+
[package]
271+
name = "mytool-core"
272+
version = "0.1.0"
273+
`;
274+
const libNode: Node = {
275+
id: 'module:crates/mytool-core/src/lib.rs:mytool_core:1',
276+
kind: 'module',
277+
name: 'mytool_core',
278+
qualifiedName: 'crates/mytool-core/src/lib.rs::mytool_core',
279+
filePath: 'crates/mytool-core/src/lib.rs',
280+
language: 'rust',
281+
startLine: 1,
282+
endLine: 1,
283+
startColumn: 0,
284+
endColumn: 0,
285+
updatedAt: Date.now(),
286+
};
287+
288+
const context = {
289+
getNodesInFile: (fp: string) => (fp === 'crates/mytool-core/src/lib.rs' ? [libNode] : []),
290+
getNodesByName: () => [],
291+
getNodesByQualifiedName: () => [],
292+
getNodesByKind: () => [],
293+
fileExists: (p: string) => (
294+
p === 'Cargo.toml' ||
295+
p === 'crates/mytool-core/Cargo.toml' ||
296+
p === 'crates/mytool-core/src/lib.rs'
297+
),
298+
readFile: (p: string) => {
299+
if (p === 'Cargo.toml') return workspaceCargo;
300+
if (p === 'crates/mytool-core/Cargo.toml') return coreCargo;
301+
return null;
302+
},
303+
getProjectRoot: () => '/test',
304+
getAllFiles: () => [
305+
'Cargo.toml',
306+
'crates/mytool-core/Cargo.toml',
307+
'crates/mytool-core/src/lib.rs',
308+
],
309+
getNodesByLowerName: () => [],
310+
getImportMappings: () => [],
311+
};
312+
313+
const ref = {
314+
fromNodeId: 'fn:crates/mytool-fetcher/src/main.rs:main:1',
315+
referenceName: 'mytool_core',
316+
referenceKind: 'references' as const,
317+
line: 1,
318+
column: 1,
319+
filePath: 'crates/mytool-fetcher/src/main.rs',
320+
language: 'rust' as const,
321+
};
322+
323+
const result = rustResolver.resolve(ref, context);
324+
expect(result?.targetNodeId).toBe(libNode.id);
325+
expect(result?.resolvedBy).toBe('framework');
326+
// Workspace-manifest hits are unambiguous and must beat name-matcher's
327+
// self-file matches (0.7) so cross-crate `imports` edges materialize.
328+
expect(result?.confidence).toBeGreaterThanOrEqual(0.9);
329+
});
330+
331+
it('resolves crate name from workspace member main.rs when lib.rs is absent', () => {
332+
const workspaceCargo = `
333+
[workspace]
334+
members = [
335+
"crates/mytool-runner",
336+
]
337+
`;
338+
const runnerCargo = `
339+
[package]
340+
name = "mytool-runner"
341+
version = "0.1.0"
342+
`;
343+
const mainNode: Node = {
344+
id: 'module:crates/mytool-runner/src/main.rs:mytool_runner:1',
345+
kind: 'module',
346+
name: 'mytool_runner',
347+
qualifiedName: 'crates/mytool-runner/src/main.rs::mytool_runner',
348+
filePath: 'crates/mytool-runner/src/main.rs',
349+
language: 'rust',
350+
startLine: 1,
351+
endLine: 1,
352+
startColumn: 0,
353+
endColumn: 0,
354+
updatedAt: Date.now(),
355+
};
356+
357+
const context = {
358+
getNodesInFile: (fp: string) => (fp === 'crates/mytool-runner/src/main.rs' ? [mainNode] : []),
359+
getNodesByName: () => [],
360+
getNodesByQualifiedName: () => [],
361+
getNodesByKind: () => [],
362+
fileExists: (p: string) => (
363+
p === 'Cargo.toml' ||
364+
p === 'crates/mytool-runner/Cargo.toml' ||
365+
p === 'crates/mytool-runner/src/main.rs'
366+
),
367+
readFile: (p: string) => {
368+
if (p === 'Cargo.toml') return workspaceCargo;
369+
if (p === 'crates/mytool-runner/Cargo.toml') return runnerCargo;
370+
return null;
371+
},
372+
getProjectRoot: () => '/test',
373+
getAllFiles: () => [
374+
'Cargo.toml',
375+
'crates/mytool-runner/Cargo.toml',
376+
'crates/mytool-runner/src/main.rs',
377+
],
378+
getNodesByLowerName: () => [],
379+
getImportMappings: () => [],
380+
};
381+
382+
const ref = {
383+
fromNodeId: 'fn:crates/mytool-runner/src/main.rs:main:1',
384+
referenceName: 'mytool_runner',
385+
referenceKind: 'references' as const,
386+
line: 1,
387+
column: 1,
388+
filePath: 'crates/mytool-runner/src/main.rs',
389+
language: 'rust' as const,
390+
};
391+
392+
const result = rustResolver.resolve(ref, context);
393+
expect(result?.targetNodeId).toBe(mainNode.id);
394+
expect(result?.resolvedBy).toBe('framework');
395+
});
396+
397+
it('resolves crate name when members uses a glob (crates/*)', () => {
398+
const workspaceCargo = `
399+
[workspace]
400+
members = ["crates/*"]
401+
`;
402+
const fooCargo = `
403+
[package]
404+
name = "mytool-foo"
405+
version = "0.1.0"
406+
`;
407+
const barCargo = `
408+
[package]
409+
name = "mytool-bar"
410+
version = "0.1.0"
411+
`;
412+
const fooLib: Node = {
413+
id: 'module:crates/mytool-foo/src/lib.rs:mytool_foo:1',
414+
kind: 'module',
415+
name: 'mytool_foo',
416+
qualifiedName: 'crates/mytool-foo/src/lib.rs::mytool_foo',
417+
filePath: 'crates/mytool-foo/src/lib.rs',
418+
language: 'rust',
419+
startLine: 1,
420+
endLine: 1,
421+
startColumn: 0,
422+
endColumn: 0,
423+
updatedAt: Date.now(),
424+
};
425+
const barLib: Node = {
426+
id: 'module:crates/mytool-bar/src/lib.rs:mytool_bar:1',
427+
kind: 'module',
428+
name: 'mytool_bar',
429+
qualifiedName: 'crates/mytool-bar/src/lib.rs::mytool_bar',
430+
filePath: 'crates/mytool-bar/src/lib.rs',
431+
language: 'rust',
432+
startLine: 1,
433+
endLine: 1,
434+
startColumn: 0,
435+
endColumn: 0,
436+
updatedAt: Date.now(),
437+
};
438+
439+
const filesByPath: Record<string, string> = {
440+
'Cargo.toml': workspaceCargo,
441+
'crates/mytool-foo/Cargo.toml': fooCargo,
442+
'crates/mytool-bar/Cargo.toml': barCargo,
443+
};
444+
const nodesByFile: Record<string, Node[]> = {
445+
'crates/mytool-foo/src/lib.rs': [fooLib],
446+
'crates/mytool-bar/src/lib.rs': [barLib],
447+
};
448+
const dirsByPath: Record<string, string[]> = {
449+
'.': ['crates'],
450+
crates: ['mytool-foo', 'mytool-bar'],
451+
'crates/mytool-foo': ['src'],
452+
'crates/mytool-bar': ['src'],
453+
};
454+
455+
const context = {
456+
getNodesInFile: (fp: string) => nodesByFile[fp] ?? [],
457+
getNodesByName: () => [],
458+
getNodesByQualifiedName: () => [],
459+
getNodesByKind: () => [],
460+
fileExists: (p: string) => (
461+
Object.prototype.hasOwnProperty.call(filesByPath, p) ||
462+
Object.prototype.hasOwnProperty.call(nodesByFile, p)
463+
),
464+
readFile: (p: string) => filesByPath[p] ?? null,
465+
getProjectRoot: () => '/test',
466+
getAllFiles: () => [
467+
'Cargo.toml',
468+
...Object.keys(filesByPath).filter((p) => p !== 'Cargo.toml'),
469+
...Object.keys(nodesByFile),
470+
],
471+
getNodesByLowerName: () => [],
472+
getImportMappings: () => [],
473+
listDirectories: (rel: string) => dirsByPath[rel] ?? [],
474+
};
475+
476+
const fooRef = {
477+
fromNodeId: 'fn:crates/mytool-bar/src/lib.rs:other:1',
478+
referenceName: 'mytool_foo',
479+
referenceKind: 'references' as const,
480+
line: 1,
481+
column: 1,
482+
filePath: 'crates/mytool-bar/src/lib.rs',
483+
language: 'rust' as const,
484+
};
485+
const barRef = {
486+
fromNodeId: 'fn:crates/mytool-foo/src/lib.rs:other:1',
487+
referenceName: 'mytool_bar',
488+
referenceKind: 'references' as const,
489+
line: 1,
490+
column: 1,
491+
filePath: 'crates/mytool-foo/src/lib.rs',
492+
language: 'rust' as const,
493+
};
494+
495+
expect(rustResolver.resolve(fooRef, context)?.targetNodeId).toBe(fooLib.id);
496+
expect(rustResolver.resolve(barRef, context)?.targetNodeId).toBe(barLib.id);
497+
});
498+
499+
it('resolves crate name when members uses a name glob at root (helix-*)', () => {
500+
const workspaceCargo = `
501+
[workspace]
502+
members = ["helix-*"]
503+
`;
504+
const coreCargo = `
505+
[package]
506+
name = "helix-core"
507+
version = "0.1.0"
508+
`;
509+
const coreLib: Node = {
510+
id: 'module:helix-core/src/lib.rs:helix_core:1',
511+
kind: 'module',
512+
name: 'helix_core',
513+
qualifiedName: 'helix-core/src/lib.rs::helix_core',
514+
filePath: 'helix-core/src/lib.rs',
515+
language: 'rust',
516+
startLine: 1,
517+
endLine: 1,
518+
startColumn: 0,
519+
endColumn: 0,
520+
updatedAt: Date.now(),
521+
};
522+
523+
const filesByPath: Record<string, string> = {
524+
'Cargo.toml': workspaceCargo,
525+
'helix-core/Cargo.toml': coreCargo,
526+
};
527+
const nodesByFile: Record<string, Node[]> = {
528+
'helix-core/src/lib.rs': [coreLib],
529+
};
530+
const dirsByPath: Record<string, string[]> = {
531+
'.': ['helix-core', 'docs', 'target'],
532+
'helix-core': ['src'],
533+
};
534+
535+
const context = {
536+
getNodesInFile: (fp: string) => nodesByFile[fp] ?? [],
537+
getNodesByName: () => [],
538+
getNodesByQualifiedName: () => [],
539+
getNodesByKind: () => [],
540+
fileExists: (p: string) => (
541+
Object.prototype.hasOwnProperty.call(filesByPath, p) ||
542+
Object.prototype.hasOwnProperty.call(nodesByFile, p)
543+
),
544+
readFile: (p: string) => filesByPath[p] ?? null,
545+
getProjectRoot: () => '/test',
546+
getAllFiles: () => [
547+
'Cargo.toml',
548+
...Object.keys(filesByPath).filter((p) => p !== 'Cargo.toml'),
549+
...Object.keys(nodesByFile),
550+
],
551+
getNodesByLowerName: () => [],
552+
getImportMappings: () => [],
553+
listDirectories: (rel: string) => dirsByPath[rel] ?? [],
554+
};
555+
556+
const ref = {
557+
fromNodeId: 'fn:helix-core/src/lib.rs:other:1',
558+
referenceName: 'helix_core',
559+
referenceKind: 'references' as const,
560+
line: 1,
561+
column: 1,
562+
filePath: 'helix-core/src/lib.rs',
563+
language: 'rust' as const,
564+
};
565+
566+
expect(rustResolver.resolve(ref, context)?.targetNodeId).toBe(coreLib.id);
567+
});
568+
});
569+
263570
import { aspnetResolver } from '../src/resolution/frameworks/csharp';
264571

265572
describe('aspnetResolver.extract', () => {

0 commit comments

Comments
 (0)