mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-11-14 22:35:47 +08:00
Which types implement this trait is the first question when a flow crosses a
trait or interface boundary, and no command answered it: node shows members and
callers, and impact mixes implementors with dependents and tests under one list.
The command renders the shared hierarchy derivation (type-hierarchy.ts, the one
codegraph_explore and the viewer read), not a third walk. Each row gives the
kind, the edge that ties the type (implements or extends, and from depth 2 the
type it hangs off), file:line, and [test] or [synthesized]. --depth follows
subclasses of subclasses, --file narrows an ambiguous name, --json gives the
same rows. Test code is read in a new test-code.ts: the file path, a test
segment in the qualified name, and for Rust a test cfg module in the source,
since the extractor neither names such a module nor prefixes what it declares.
Changes on top of the contributor's commit:
- The command opens the index with openIndexToRead, as every other read command
does. A plain open repairs and migrates: run during another process's bulk
load it made the writer's next write fail with database is locked (issue
2913), and on an older release's index a read took the schema from 4 to 16. It
joins the read-commands test, which was red on all three scenarios.
- The Rust module reader counts braces over the existing blankRustCode instead
of its own masker, which missed character literals, raw strings and block
comments, so one '{' in a test module left a mock unmarked. It also reads test
inside cfg(all(...)), the form tokio and others use.
- The README lists the command; the changelog bullet credits the author.
Validated on real repos. Across 2,761 Rust files (8,819 declarations, ripgrep,
gitbutler, lightningcss, Cap, yaak and three more) the new reader keeps all 196
test-module declarations the old one found and adds 20, spot-checked as real
fixtures in test modules. implementations ran on ripgrep (Matcher, Sink), gin
(ResponseWriter, IRouter, Binding, with Go's implicit edges marked synthesized),
flask (View at depth 2), excalidraw and Minestom. CLI start stays within 3 ms
(116 ms to 119 ms, median of 25, interleaved). The site's CLI page is left for
the release, so a push does not deploy a command nobody has yet.
Original PR #2839 by @danusha2345.
Fixes #2833
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
228 lines
8.9 KiB
TypeScript
228 lines
8.9 KiB
TypeScript
/**
|
|
* `codegraph implementations <name>` and the type hierarchy's descendants (#2833).
|
|
*
|
|
* The issue's Cargo workspace: a trait in one crate, a real implementor in a
|
|
* second, a mock in that crate's `#[cfg(test)]` module, and a third crate that
|
|
* only depends on the trait. Plus a TypeScript chain for `--depth`.
|
|
*/
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
import { spawnSync } from 'child_process';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { CodeGraph } from '../src';
|
|
import { inCfgTestModule } from '../src/graph/test-code';
|
|
|
|
const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
|
|
let projectRoot: string;
|
|
let cg: CodeGraph;
|
|
|
|
function write(file: string, source: string) {
|
|
const absolute = path.join(projectRoot, file);
|
|
fs.mkdirSync(path.dirname(absolute), { recursive: true });
|
|
fs.writeFileSync(absolute, source);
|
|
}
|
|
|
|
function run(args: string[]) {
|
|
return spawnSync(process.execPath, [BIN, 'implementations', '-p', projectRoot, ...args], {
|
|
encoding: 'utf-8',
|
|
env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', CODEGRAPH_WASM_RELAUNCHED: '1', NO_COLOR: '1' },
|
|
timeout: 30_000,
|
|
});
|
|
}
|
|
|
|
function json(args: string[]) {
|
|
const result = run([...args, '--json']);
|
|
expect(result.status, result.stderr).toBe(0);
|
|
return JSON.parse(result.stdout);
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-impl-2833-'));
|
|
write('Cargo.toml', '[workspace]\nmembers = ["api", "store-real", "app"]\n');
|
|
write('api/Cargo.toml', '[package]\nname = "api"\nversion = "0.1.0"\n');
|
|
write('api/src/lib.rs', 'pub trait Store {\n fn put(&self, key: &str);\n}\n');
|
|
write('store-real/Cargo.toml', '[package]\nname = "store-real"\nversion = "0.1.0"\n[dependencies]\napi = { path = "../api" }\n');
|
|
write('store-real/src/lib.rs', [
|
|
'use api::Store;',
|
|
'pub struct RealStore { pub n: u8 }',
|
|
'impl Store for RealStore { fn put(&self, key: &str) { let _ = key; } }',
|
|
'',
|
|
'#[cfg(test)]',
|
|
'mod tests {',
|
|
' use api::Store;',
|
|
' struct MockStore { n: u8 }',
|
|
' impl Store for MockStore { fn put(&self, _key: &str) {} }',
|
|
' #[test]',
|
|
' fn mock_puts() { MockStore { n: 0 }.put("k"); }',
|
|
'}',
|
|
'',
|
|
].join('\n'));
|
|
write('app/Cargo.toml', '[package]\nname = "app"\nversion = "0.1.0"\n[dependencies]\napi = { path = "../api" }\nstore-real = { path = "../store-real" }\n');
|
|
write('app/src/lib.rs', [
|
|
'use api::Store;',
|
|
'pub fn save(store: &dyn Store) { store.put("a"); }',
|
|
'pub fn build() -> store_real::RealStore { store_real::RealStore { n: 0 } }',
|
|
'',
|
|
].join('\n'));
|
|
write('ts/shapes.ts', [
|
|
'export interface Shape { area(): number; }',
|
|
'export class Square implements Shape { area() { return 1; } }',
|
|
'export class Cube extends Square { volume() { return 1; } }',
|
|
'export function measure(s: Shape) { return s.area(); }',
|
|
'',
|
|
].join('\n'));
|
|
write('ts/__tests__/mock-shape.ts', [
|
|
"import { Shape } from '../shapes';",
|
|
'export class MockShape implements Shape { area() { return 0; } }',
|
|
'',
|
|
].join('\n'));
|
|
cg = CodeGraph.initSync(projectRoot);
|
|
await cg.indexAll();
|
|
}, 60_000);
|
|
|
|
afterAll(() => {
|
|
cg.destroy();
|
|
fs.rmSync(projectRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('getTypeHierarchy()', () => {
|
|
it('returns the implementors of a trait, not only its ancestors', () => {
|
|
const store = cg.getNodesByName('Store').find((n) => n.kind === 'trait');
|
|
expect(store).toBeDefined();
|
|
const hierarchy = cg.getTypeHierarchy(store!.id);
|
|
const implementors = hierarchy.edges
|
|
.filter((e) => e.kind === 'implements' && e.target === store!.id)
|
|
.map((e) => hierarchy.nodes.get(e.source)?.name)
|
|
.sort();
|
|
expect(implementors).toEqual(['MockStore', 'RealStore']);
|
|
});
|
|
|
|
it('still returns the ancestors of a subtype', () => {
|
|
const cube = cg.getNodesByName('Cube').find((n) => n.kind === 'class');
|
|
const hierarchy = cg.getTypeHierarchy(cube!.id);
|
|
const names = [...hierarchy.nodes.values()].map((n) => n.name).sort();
|
|
expect(names).toEqual(['Cube', 'Shape', 'Square']);
|
|
});
|
|
});
|
|
|
|
describe('codegraph implementations', () => {
|
|
it('lists a trait\'s implementors with the edge kind, and marks the #[cfg(test)] mock as test code', () => {
|
|
const out = json(['Store']);
|
|
expect(out.ambiguous).toBe(false);
|
|
expect(out.definitions).toHaveLength(1);
|
|
const def = out.definitions[0];
|
|
expect(def.definition.kind).toBe('trait');
|
|
expect(def.directSubtypes).toBe(2);
|
|
// Order within a level is the hierarchy walk's, not this command's.
|
|
const rows = def.implementations.map((r: { name: string; relation: string; test: boolean; filePath: string; startLine: number }) =>
|
|
[r.name, r.relation, r.test, `${r.filePath}:${r.startLine}`]).sort();
|
|
expect(rows).toEqual([
|
|
['MockStore', 'implements', true, 'store-real/src/lib.rs:8'],
|
|
['RealStore', 'implements', false, 'store-real/src/lib.rs:2'],
|
|
]);
|
|
// Dependents and the trait's own method are not implementors.
|
|
const names = def.implementations.map((r: { name: string }) => r.name);
|
|
expect(names).not.toContain('save');
|
|
expect(names).not.toContain('build');
|
|
expect(names).not.toContain('put');
|
|
});
|
|
|
|
it('prints one line per implementor with its location and a [test] tag', () => {
|
|
const result = run(['Store']);
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(result.stdout).toContain('Implementations of trait Store (rust)');
|
|
expect(result.stdout).toMatch(/struct\s+RealStore\s+implements\s+store-real\/src\/lib\.rs:2\n/);
|
|
expect(result.stdout).toMatch(/struct\s+MockStore\s+implements\s+store-real\/src\/lib\.rs:8\s+\[test\]\n/);
|
|
expect(result.stdout).not.toContain('save');
|
|
});
|
|
|
|
it('lists direct implementors by default and follows subclasses with --depth', () => {
|
|
const direct = json(['Shape']);
|
|
const directRows = direct.definitions[0].implementations.map((r: { name: string; depth: number }) => [r.name, r.depth]).sort();
|
|
expect(directRows).toEqual([['MockShape', 1], ['Square', 1]]);
|
|
expect(direct.definitions[0].implementations.find((r: { name: string }) => r.name === 'MockShape').test).toBe(true);
|
|
|
|
const deep = json(['Shape', '--depth', '2']);
|
|
const deepRows = deep.definitions[0].implementations.map((r: { name: string; depth: number; relation: string; via?: string }) =>
|
|
[r.name, r.depth, r.relation, r.via]);
|
|
// Breadth-first: depth 1 is complete before depth 2 begins.
|
|
expect(deepRows.slice(0, 2).sort()).toEqual([
|
|
['MockShape', 1, 'implements', undefined],
|
|
['Square', 1, 'implements', undefined],
|
|
]);
|
|
expect(deepRows.slice(2)).toEqual([['Cube', 2, 'extends', 'Square']]);
|
|
const text = run(['Shape', '-d', '2']);
|
|
expect(text.stdout).toMatch(/\n {4}class\s+Cube\s+extends Square\s+ts\/shapes\.ts:3\n/);
|
|
});
|
|
|
|
it('says so when the symbol is not a type', () => {
|
|
const result = run(['measure']);
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(result.stdout).toContain('not a type something can implement or extend');
|
|
});
|
|
|
|
it('says so when the symbol is unknown', () => {
|
|
const result = run(['NoSuchType']);
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(result.stdout).toContain('not found');
|
|
});
|
|
});
|
|
|
|
describe('inCfgTestModule()', () => {
|
|
const source = [
|
|
'pub struct A;',
|
|
'#[cfg(test)]',
|
|
'#[allow(dead_code)]',
|
|
'mod tests {',
|
|
' struct Mock { x: u8 }',
|
|
' fn braces() { let _ = "{"; }',
|
|
'}',
|
|
'pub struct B;',
|
|
'#[cfg(not(test))]',
|
|
'mod real { pub struct C; }',
|
|
].join('\n');
|
|
it('is true inside the module and false before, after and in other modules', () => {
|
|
expect(inCfgTestModule(source, 1)).toBe(false);
|
|
expect(inCfgTestModule(source, 5)).toBe(true);
|
|
expect(inCfgTestModule(source, 6)).toBe(true);
|
|
expect(inCfgTestModule(source, 8)).toBe(false);
|
|
expect(inCfgTestModule(source, 10)).toBe(false);
|
|
});
|
|
|
|
it('finds the end of the module past a brace in a character, a raw string or a block comment', () => {
|
|
const tricky = [
|
|
'#[cfg(test)]',
|
|
'mod tests {',
|
|
" const OPEN: char = '{';",
|
|
' const RAW: &str = r#"{"#;',
|
|
' /* { */',
|
|
' struct Mock;',
|
|
'}',
|
|
'pub struct After;',
|
|
].join('\n');
|
|
expect(inCfgTestModule(tricky, 6)).toBe(true);
|
|
expect(inCfgTestModule(tricky, 8)).toBe(false);
|
|
});
|
|
|
|
it('reads test inside cfg(all(...)), and not a negated test', () => {
|
|
const gated = [
|
|
'#[cfg(all(test, not(loom)))]',
|
|
'mod tests {',
|
|
' struct Mock;',
|
|
'}',
|
|
'#[cfg(all(feature = "sync", test))]',
|
|
'mod sync_tests {',
|
|
' struct SyncMock;',
|
|
'}',
|
|
'#[cfg(all(unix, not(test)))]',
|
|
'mod real {',
|
|
' struct Real;',
|
|
'}',
|
|
].join('\n');
|
|
expect(inCfgTestModule(gated, 3)).toBe(true);
|
|
expect(inCfgTestModule(gated, 7)).toBe(true);
|
|
expect(inCfgTestModule(gated, 11)).toBe(false);
|
|
});
|
|
});
|