Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In React with TypeScript, a component that renders a generic component and passes it type arguments, like outline's `<PaginatedList<Document> items={…} />` or excalidraw's `<DropdownMenuItemContentRadio<"contain" | "overlap"> …>`, now links to the component it renders. Before, a tag followed by type arguments was skipped, so the generic component looked unused by the screens that render it, and `codegraph_explore`'s call paths, callers and impact stopped short of it. Re-index React projects after upgrading.
- In React, a component that a file declares itself as a value is now what that file's JSX renders, whatever else in the repository shares its name. A lazily loaded page, like `const RegisterPage = Loadable(lazy(() => import('./pages/Register')))`, a `dynamic(() => import(…))` component or one loaded only in development (`IS_DEV ? lazy(…) : null`), links to the component its module exports, including the one its loader picks and one an `export default observer(Login)` wraps; a wrapped component like `const Settings = observer(function Settings() {…})`, `React.memo(Rows, areEqual)` or `forwardRef(ComboboxInner) as {…}` links to the function it wraps; and an alias like `const DialogPortal = DialogPrimitive.Portal` links to its own declaration. Before, `<RegisterPage />` was linked to any lone component of that name elsewhere, often in another app of the same repository, so `codegraph_explore`, callers and impact followed it into the wrong app while the page it really renders had no caller. React Router layouts declared this way follow the same rule, and a TSX type named like a value the file declares now stays in that file. Re-index React projects after upgrading.
- In React, a JSX tag imported from one of your own modules now links to the component that module exports under that name, read the way a lazily loaded page's module already is. That includes going through an `index` file or barrel that passes the component on, like outline's `Sidebar/index.ts` with `import Sidebar from './App'; export default Sidebar` or its `Avatar/index.ts` with `const Avatar = AvatarWithHoverCard`, and past a wrapper such as `export default observer(AppSidebar)`, `React.memo(StaticCanvas, areEqual)` or mastodon's `const MemoizedBlurhash = memo(Blurhash)`. Before, a tag was matched by its name and the import only broke ties, so outline's `<Sidebar>` linked to an unrelated styled `Sidebar` in a sibling file, a default import under another name linked to whatever other component its module exported, and an import through a barrel could land on another app's component of the same name. A tag whose module exports an unnamed component, like `export default () => …`, now links to nothing instead of to a namesake elsewhere. A lazily loaded React Router page exported as such a value or wrapper links to the component it holds too, the one with the markup. `codegraph sync` and the file watcher redraw these links when only the file that passes the component on changes. Re-index React projects after upgrading.
- In a Rust Cargo workspace, a call into another crate written with its path, like `sim::items::ItemPool::for_def(…)`, now links to that crate's definition. That covers `use sim::…` imports, `pub use` re-exports, constants, enum variants, `crate::` and `super::` paths, and crates pulled in as path dependencies, including renamed ones. Before, only a call through a bare imported name connected, so callers, impact and `codegraph_explore` missed most cross-crate uses. A type written through a module, like `scheduler::Handle`, keeps resolving by its own path rather than to a same-named `use` import, and a dependency name that two crates point at different folders is left unresolved instead of guessed.

## [1.6.2] - 2026-10-03

Expand Down
241 changes: 241 additions & 0 deletions __tests__/rust-cross-crate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
/**
* Cross-crate references in a Cargo workspace: a path written from one member
* crate into another (`sim::items::ItemPool::for_def(…)`), through a re-export
* (`pub use rng::Rng`), through a renamed or non-member path dependency, and
* the `crate::` / `super::` paths inside a member crate.
*/
import { afterEach, beforeEach, expect, it } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { CodeGraph } from '../src';

let root: string;
let cg: CodeGraph | undefined;
beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-rust-cross-crate-')); });
afterEach(() => { cg?.close(); cg = undefined; fs.rmSync(root, { recursive: true, force: true }); });

async function index(files: Record<string, string>) {
for (const [file, text] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true });
fs.writeFileSync(path.join(root, file), text);
}
cg = await CodeGraph.init(root, { index: true });
}

/** `file:qualifiedName` of everything that has an edge of `kind` into the named symbol. */
function sources(file: string, qualifiedName: string, kind = 'calls'): string[] {
const target = cg!.getNodesInFile(file).find((n) => n.qualifiedName === qualifiedName);
expect(target, `${file}:${qualifiedName}`).toBeDefined();
return cg!.getIncomingEdges(target!.id).filter((e) => e.kind === kind).map((e) => {
const src = cg!.getNode(e.source)!;
return `${src.filePath}:${src.qualifiedName}`;
}).sort();
}

const WORKSPACE = `[workspace]
resolver = "2"
members = ["crates/sim", "crates/server", "crates/client"]

[workspace.dependencies]
sim = { path = "crates/sim" }
`;
const SIM_TOML = '[package]\nname = "sim"\nversion = "0.1.0"\nedition = "2021"\n';
const SIM_LIB = `pub mod items;
mod rng;
pub use rng::Rng;
pub const RANK_COUNT: usize = 6;
pub fn median_u32(v: &[u32]) -> u32 { v[0] }
`;
const SIM_ITEMS = `pub struct ItemPool { pub n: u32 }
impl ItemPool {
pub fn for_def(def: u32) -> ItemPool { ItemPool { n: def } }
}
pub enum Drop { Gold(u32), Nothing }
pub fn roll_item(p: &ItemPool) -> u32 { p.n }
`;
const SIM_RNG = 'pub struct Rng { s: u64 }\nimpl Rng {\n pub fn new(s: u64) -> Rng { Rng { s } }\n}\n';

const base = (server: Record<string, string>, serverToml = '[package]\nname = "server"\nversion = "0.1.0"\nedition = "2021"\n\n[dependencies]\nsim.workspace = true\n') => ({
'Cargo.toml': WORKSPACE,
'crates/sim/Cargo.toml': SIM_TOML,
'crates/sim/src/lib.rs': SIM_LIB,
'crates/sim/src/items.rs': SIM_ITEMS,
'crates/sim/src/rng.rs': SIM_RNG,
// A decoy: the same type and method names in another member crate.
'crates/client/Cargo.toml': '[package]\nname = "client"\nversion = "0.1.0"\nedition = "2021"\n',
'crates/client/src/lib.rs': 'pub mod items;\n',
'crates/client/src/items.rs': 'pub struct ItemPool;\nimpl ItemPool {\n pub fn for_def(def: u32) -> ItemPool { ItemPool }\n}\npub fn roll_item() -> u32 { 0 }\n',
'crates/server/Cargo.toml': serverToml,
...Object.fromEntries(Object.entries(server).map(([f, t]) => [`crates/server/src/${f}`, t])),
});

it('resolves a fully qualified call into another workspace crate to that crate', async () => {
await index(base({
'lib.rs': 'pub mod rift;\npub mod econ;\n',
'rift.rs': 'pub fn open() -> u32 {\n let pool = sim::items::ItemPool::for_def(3);\n sim::items::roll_item(&pool)\n}\n',
'econ/mod.rs': 'pub mod offline;\n',
'econ/offline.rs': 'pub fn tick() {\n let _ = sim::items::ItemPool::for_def(\n 4,\n );\n}\n',
}));
expect(sources('crates/sim/src/items.rs', 'ItemPool::for_def')).toEqual([
'crates/server/src/econ/offline.rs:tick',
'crates/server/src/rift.rs:open',
]);
expect(sources('crates/sim/src/items.rs', 'roll_item')).toEqual(['crates/server/src/rift.rs:open']);
expect(sources('crates/client/src/items.rs', 'ItemPool::for_def')).toEqual([]);
});

it('resolves a crate-root item, a re-export, a constant, and an enum variant across crates', async () => {
await index(base({
'lib.rs': 'pub mod rift;\n',
'rift.rs': `use sim::RANK_COUNT;
pub fn open(v: &[u32]) -> usize {
let _r = sim::Rng::new(7);
let _m = sim::median_u32(v);
let _d = sim::items::Drop::Gold(3);
RANK_COUNT
}
`,
}));
expect(sources('crates/sim/src/rng.rs', 'Rng::new')).toEqual(['crates/server/src/rift.rs:open']);
expect(sources('crates/sim/src/lib.rs', 'median_u32')).toEqual(['crates/server/src/rift.rs:open']);
expect(sources('crates/sim/src/items.rs', 'Drop::Gold')).toEqual(['crates/server/src/rift.rs:open']);
expect(sources('crates/sim/src/lib.rs', 'RANK_COUNT', 'imports')).toEqual(['crates/server/src/rift.rs:crates/server/src/rift.rs']);
});

it('resolves `use` imports of another crate and the calls through them', async () => {
await index(base({
'lib.rs': 'pub mod rift;\n',
'rift.rs': `use sim::items::{roll_item, ItemPool};
use sim::Rng;
pub fn open() -> u32 {
let _r = Rng::new(1);
roll_item(&ItemPool::for_def(2))
}
`,
}));
expect(sources('crates/sim/src/items.rs', 'ItemPool::for_def')).toEqual(['crates/server/src/rift.rs:open']);
expect(sources('crates/sim/src/items.rs', 'roll_item')).toEqual(['crates/server/src/rift.rs:open']);
expect(sources('crates/sim/src/rng.rs', 'Rng::new')).toEqual(['crates/server/src/rift.rs:open']);
expect(sources('crates/sim/src/items.rs', 'ItemPool', 'imports').length).toBeGreaterThan(0);
expect(sources('crates/sim/src/rng.rs', 'Rng', 'imports').length).toBeGreaterThan(0);
});

it('resolves a renamed path dependency and a path dependency outside the workspace members', async () => {
await index({
...base(
{ 'lib.rs': 'pub fn open() -> u32 {\n let p = core_sim::items::ItemPool::for_def(1);\n vendored::helper(p.n)\n}\n' },
'[package]\nname = "server"\nversion = "0.1.0"\nedition = "2021"\n\n[dependencies]\ncore_sim = { path = "../sim", package = "sim" }\nvendored = { path = "../../libs/vendored" }\n',
),
'libs/vendored/Cargo.toml': '[package]\nname = "vendored"\nversion = "0.1.0"\nedition = "2021"\n',
'libs/vendored/src/lib.rs': 'pub fn helper(n: u32) -> u32 { n }\n',
});
expect(sources('crates/sim/src/items.rs', 'ItemPool::for_def')).toEqual(['crates/server/src/lib.rs:open']);
expect(sources('libs/vendored/src/lib.rs', 'helper')).toEqual(['crates/server/src/lib.rs:open']);
});

it('resolves `crate::` and `super::` paths inside a member crate, constants included', async () => {
await index(base({
'lib.rs': 'pub mod ledger;\npub mod econ;\n',
'ledger.rs': 'pub const KEY_RANK_COUNT: usize = 6;\npub static LIMIT: u32 = 9;\npub fn persist() {}\n',
'econ/mod.rs': 'pub mod sinks;\npub fn shared() {}\n',
'econ/sinks.rs': `use crate::ledger::{KEY_RANK_COUNT, LIMIT};
pub fn drain() -> usize {
crate::ledger::persist();
super::shared();
KEY_RANK_COUNT + LIMIT as usize
}
`,
}));
expect(sources('crates/server/src/ledger.rs', 'persist')).toEqual(['crates/server/src/econ/sinks.rs:drain']);
expect(sources('crates/server/src/econ/mod.rs', 'shared')).toEqual(['crates/server/src/econ/sinks.rs:drain']);
expect(sources('crates/server/src/ledger.rs', 'KEY_RANK_COUNT', 'imports').length).toBeGreaterThan(0);
expect(sources('crates/server/src/ledger.rs', 'LIMIT', 'imports').length).toBeGreaterThan(0);
});

it('resolves a module-qualified type by its own path, not through a same-name `use` import', async () => {
// tokio shape: `scheduler::Handle` / `driver::Handle` in a file that also
// `use`s a third `Handle` — the qualifier decides, never the file-level binding.
await index(base({
'lib.rs': 'pub mod scheduler;\npub mod driver;\npub mod multi;\n',
'scheduler.rs': 'pub enum Handle { A, B }\n',
'driver.rs': 'pub struct Handle { pub n: u32 }\n',
'multi/mod.rs': 'mod handle;\npub(crate) use handle::Handle;\n',
'multi/handle.rs': 'pub struct Handle { pub n: u32 }\n',
'user.rs': `use crate::multi::Handle;
use crate::{driver, scheduler};
pub fn sched(h: &scheduler::Handle) -> u32 { match h { scheduler::Handle::A => 1, _ => 2 } }
pub fn drv(h: &driver::Handle) -> u32 { h.n }
pub fn own(h: &Handle) -> u32 { h.n }
`,
}));
const refs = (file: string, qn: string) => sources(file, qn, 'references');
expect(refs('crates/server/src/scheduler.rs', 'Handle')).toContain('crates/server/src/user.rs:sched');
expect(refs('crates/server/src/driver.rs', 'Handle')).toEqual(['crates/server/src/user.rs:drv']);
expect(refs('crates/server/src/multi/handle.rs', 'Handle')).not.toContain('crates/server/src/user.rs:sched');
expect(refs('crates/server/src/multi/handle.rs', 'Handle')).not.toContain('crates/server/src/user.rs:drv');
expect(refs('crates/server/src/multi/handle.rs', 'Handle')).toContain('crates/server/src/user.rs:own');
});

it('reads a module qualifier written across lines, instead of falling back to the same-name `use` import', async () => {
await index(base({
'lib.rs': 'pub mod scheduler;\npub mod multi;\n',
'scheduler.rs': 'pub enum Handle { A, B }\n',
'multi/mod.rs': 'mod handle;\npub(crate) use handle::Handle;\n',
'multi/handle.rs': 'pub struct Handle { pub n: u32 }\n',
'user.rs': `use crate::multi::Handle;
use crate::scheduler;
pub fn sched(
h: &scheduler::
Handle,
) -> u32 { 1 }
`,
}));
expect(sources('crates/server/src/scheduler.rs', 'Handle', 'references')).toContain('crates/server/src/user.rs:sched');
expect(sources('crates/server/src/multi/handle.rs', 'Handle', 'references')).not.toContain('crates/server/src/user.rs:sched');
});

it('does not read a commented-out path above a leaf as its qualifier', async () => {
await index(base({
'lib.rs': 'pub mod scheduler;\npub mod multi;\n',
'scheduler.rs': 'pub struct Handle { pub n: u32 }\nimpl Handle { pub fn new() -> Handle { Handle { n: 0 } } }\n',
'multi/mod.rs': 'mod handle;\npub(crate) use handle::Handle;\n',
'multi/handle.rs': 'pub struct Handle { pub n: u32 }\nimpl Handle { pub fn new() -> Handle { Handle { n: 1 } } }\n',
'user.rs': `use crate::multi::Handle;
pub fn make() -> Handle {
// crate::scheduler::
Handle { n: 3 }
}
pub fn make_block() -> Handle {
/* crate::scheduler:: */
Handle { n: 4 }
}
`,
}));
/** Every file:function with any edge into the file's `Handle` type or its `new`. */
const touches = (file: string) => cg!.getNodesInFile(file)
.filter((n) => n.name === 'Handle' || n.name === 'new')
.flatMap((n) => cg!.getIncomingEdges(n.id).map((e) => cg!.getNode(e.source)!))
.map((src) => `${src.filePath}:${src.qualifiedName}`);
for (const fn of ['make', 'make_block']) {
expect(touches('crates/server/src/scheduler.rs')).not.toContain(`crates/server/src/user.rs:${fn}`);
expect(touches('crates/server/src/multi/handle.rs')).toContain(`crates/server/src/user.rs:${fn}`);
}
});

it('leaves a path-dependency key unresolved when two crates bind it to different directories', async () => {
const pkg = (name: string, dep = '') => `[package]\nname = "${name}"\nversion = "0.1.0"\nedition = "2021"\n${dep}`;
await index({
'Cargo.toml': '[workspace]\nmembers = ["crates/server", "crates/client"]\n',
'crates/server/Cargo.toml': pkg('server', '\n[dependencies]\nutil = { path = "../../libs/util_a", package = "util_a" }\n'),
'crates/server/src/lib.rs': 'pub fn s() -> u32 { util::unique_a(1) }\n',
'crates/client/Cargo.toml': pkg('client', '\n[dependencies]\nutil = { path = "../../libs/util_b", package = "util_b" }\n'),
'crates/client/src/lib.rs': 'pub fn c() -> u32 { util::unique_b(2) }\n',
'libs/util_a/Cargo.toml': pkg('util_a'),
'libs/util_a/src/lib.rs': 'pub fn unique_a(n: u32) -> u32 { n }\npub fn unique_b(n: u32) -> u32 { n + 1 }\n',
'libs/util_b/Cargo.toml': pkg('util_b'),
'libs/util_b/src/lib.rs': 'pub fn unique_b(n: u32) -> u32 { n }\n',
});
// `client`'s `util` is util_b — it must never be answered with util_a's `unique_b`.
expect(sources('libs/util_a/src/lib.rs', 'unique_b')).not.toContain('crates/client/src/lib.rs:c');
});
Loading