Skip to content
Closed
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 @@ -21,6 +21,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Fixes

- A Rust method call on a field — `self.inner.run()` — now resolves to the method on that field's type instead of to whatever same-named method happened to sit nearest. Rust has no implicit `self`, so every call on a field is written this way, and the receiver was being dropped: the call became a bare `run`, which then matched a same-named method in the calling file, in the calling type, or on an unrelated type altogether. Calls on fields whose type is external — `Vec`, `Arc<Mutex<…>>`, a type from another crate — were the worst affected, because the nearest project method is never the right answer for them; `self.items.len()` could be recorded as a call to the enclosing type's own `len`, a self-recursive edge the source never had. On ripgrep and tokio this removed roughly half of all self-recursive call edges, and corrected hundreds more that pointed at the wrong type. Fields whose type cannot be established stay unresolved rather than being guessed, so callers, impact and flow answers no longer include fabricated dependencies. `Box`, `Rc` and `Arc` resolve through to what they wrap; `Option`, `Mutex` and other containers keep their own methods. (#1585)
- Naming a file by its path in a `codegraph_explore` query now works reliably: the path is resolved against the index and that file is guaranteed a place at the top of the answer. Previously the path was broken into fragments — bracketed route segments like SvelteKit's `[id]` made this worst — and pieces like `page` or `runs` matched every sibling file, so the file you actually named could be crowded out of the answer entirely. A path that doesn't match any indexed file is now called out instead of silently ignored.
- Plainly-worded `codegraph_explore` questions now find camelCase code: a query like "auto-scroll to bottom" can reach a function named `scrollFeedToBottom`, because query words are matched against the words inside identifiers, not just whole names.
- Variables and constants now count when `codegraph_explore` picks its starting symbols, so state held in plain variables — `$state`-style variables in Svelte, for example — no longer gets overlooked.
Expand Down
292 changes: 292 additions & 0 deletions __tests__/resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2858,6 +2858,298 @@ func (mx *Mux) dispatch() {
}, 30000);
});

describe('Rust field receiver calls (#1585)', () => {
// `self.inner.run()` used to emit a BARE `run` ref: the receiver is a
// field_expression, not a plain identifier, so it never reached the
// qualified branch. Rust has no implicit `self`, so EVERY call on a field
// takes that shape, and the bare name exact-matched whatever same-named
// method sat nearest — including the calling method itself, fabricating a
// self-recursive edge. Field receivers now resolve exclusively via
// validated field inference: external field types produce NO edge,
// in-project ones produce the correct edge.
it('an external field type produces no edge; an in-project one resolves past a same-file decoy', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1585-'));
try {
fs.writeFileSync(
path.join(tmpDir, 'Cargo.toml'),
'[package]\nname = "repro"\nversion = "0.1.0"\nedition = "2021"\n'
);
fs.mkdirSync(path.join(tmpDir, 'src'));
fs.writeFileSync(path.join(tmpDir, 'src', 'lib.rs'), 'pub mod inner;\npub mod outer;\n');
fs.writeFileSync(
path.join(tmpDir, 'src', 'inner.rs'),
`pub struct Inner {
pub n: usize,
}

impl Inner {
pub fn run(&mut self) {
self.n += 1;
}
}
`
);
// Decoy::run sits in the CALLER's file, so file proximity elects it
// over Inner::run — the exact wrong answer this resolves away from.
fs.writeFileSync(
path.join(tmpDir, 'src', 'outer.rs'),
`use crate::inner::Inner;

pub struct Decoy {
pub flag: bool,
}

impl Decoy {
pub fn run(&mut self) {
self.flag = true;
}
}

pub struct Outer {
pub inner: Inner,
pub items: Vec<usize>,
}

impl Outer {
pub fn go(&mut self) {
self.inner.run();
}

pub fn count(&self) -> usize {
self.items.len()
}
}
`
);

const cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();

// The in-project field resolves to ITS type's method, not the decoy's.
const innerRun = (await cg.searchNodes('run', { limit: 10 })).find(
(r) => r.node.kind === 'method' && r.node.qualifiedName === 'Inner::run'
);
expect(innerRun).toBeDefined();
expect((await cg.getCallers(innerRun!.node.id)).map((c) => c.node.name)).toContain('go');

const decoyRun = (await cg.searchNodes('run', { limit: 10 })).find(
(r) => r.node.kind === 'method' && r.node.qualifiedName === 'Decoy::run'
);
expect(decoyRun).toBeDefined();
expect((await cg.getCallers(decoyRun!.node.id)).map((c) => c.node.name)).not.toContain('go');

// `items: Vec<usize>` is external: `self.items.len()` must bind to
// nothing rather than to the enclosing type's own `count`.
const count = (await cg.searchNodes('count', { limit: 10 })).find(
(r) => r.node.kind === 'method'
);
expect(count).toBeDefined();
expect((await cg.getCallees(count!.node.id)).map((c) => c.node.name)).toHaveLength(0);
cg.close();
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}, 30000);

// The receiver is the field's type CONSTRUCTOR — `Core<'a, u8>` answers
// with `Core`'s method — except for the smart pointers that Deref, whose
// single type argument is unwrapped. Containers that own their methods
// (`Option`, `Mutex`, `Vec`) must NOT be unwrapped: their method belongs to
// the container, and following the argument would fabricate an edge.
it('deref wrappers unwrap to their argument; containers and generics keep their constructor', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1585b-'));
try {
fs.writeFileSync(
path.join(tmpDir, 'Cargo.toml'),
'[package]\nname = "wrappers"\nversion = "0.1.0"\nedition = "2021"\n'
);
fs.mkdirSync(path.join(tmpDir, 'src'));
fs.writeFileSync(path.join(tmpDir, 'src', 'lib.rs'), 'pub mod inner;\npub mod outer;\n');
fs.writeFileSync(
path.join(tmpDir, 'src', 'inner.rs'),
`pub struct Inner {
pub n: usize,
}

impl Inner {
pub fn tick(&self) -> usize {
self.n
}

pub fn lock(&self) -> usize {
self.n
}
}

pub struct Core<'a, T> {
pub tag: &'a str,
pub item: T,
}

impl<'a, T> Core<'a, T> {
pub fn roll(&self) -> usize {
0
}
}
`
);
fs.writeFileSync(
path.join(tmpDir, 'src', 'outer.rs'),
`use crate::inner::{Core, Inner};
use std::sync::{Arc, Mutex};

pub struct Outer<'a> {
pub boxed: Box<Inner>,
pub guarded: Arc<Mutex<Inner>>,
pub core: Core<'a, u8>,
}

impl<'a> Outer<'a> {
pub fn via_box(&self) -> usize {
self.boxed.tick()
}

pub fn via_mutex(&self) -> usize {
self.guarded.lock()
}

pub fn via_generic(&self) -> usize {
self.core.roll()
}
}
`
);

const cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();

const find = async (qualified: string) =>
(await cg.searchNodes(qualified.split('::').pop()!, { limit: 10 })).find(
(r) => r.node.kind === 'method' && r.node.qualifiedName === qualified
);

// Box<Inner> derefs: the call reaches Inner.
const tick = await find('Inner::tick');
expect(tick).toBeDefined();
expect((await cg.getCallers(tick!.node.id)).map((c) => c.node.name)).toContain('via_box');

// Arc<Mutex<Inner>> stops at Mutex, which owns `lock` — Inner::lock is
// a same-named decoy that must not be bound.
const lock = await find('Inner::lock');
expect(lock).toBeDefined();
expect((await cg.getCallers(lock!.node.id)).map((c) => c.node.name)).not.toContain(
'via_mutex'
);

// A plain generic keeps its constructor: Core<'a, u8> answers with Core.
const roll = await find('Core::roll');
expect(roll).toBeDefined();
expect((await cg.getCallers(roll!.node.id)).map((c) => c.node.name)).toContain(
'via_generic'
);
cg.close();
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}, 30000);

// A tuple struct names its fields by position, so `self.0` reads the first
// type out of the declaration. Positions must not be interchangeable: two
// fields of different types answer their own methods.
it('tuple-struct fields resolve by position', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-1585c-'));
try {
fs.writeFileSync(
path.join(tmpDir, 'Cargo.toml'),
'[package]\nname = "tuples"\nversion = "0.1.0"\nedition = "2021"\n'
);
fs.mkdirSync(path.join(tmpDir, 'src'));
fs.writeFileSync(path.join(tmpDir, 'src', 'lib.rs'), 'pub mod def;\npub mod imp;\n');
fs.writeFileSync(
path.join(tmpDir, 'src', 'def.rs'),
`pub struct First {
pub n: usize,
}

impl First {
pub fn go(&self) -> usize {
1
}
}

pub struct Second {
pub n: usize,
}

impl Second {
pub fn go(&self) -> usize {
2
}
}

pub struct Pair(pub First, pub Second);

pub struct Opaque(pub Vec<usize>);
`
);
fs.writeFileSync(
path.join(tmpDir, 'src', 'imp.rs'),
`use crate::def::{Opaque, Pair};

pub struct Decoy;

impl Decoy {
pub fn go(&self) -> usize {
99
}

pub fn len(&self) -> usize {
99
}
}

impl Pair {
pub fn take_first(&self) -> usize {
self.0.go()
}

pub fn take_second(&self) -> usize {
self.1.go()
}
}

impl Opaque {
pub fn size(&self) -> usize {
self.0.len()
}
}
`
);

const cg = CodeGraph.initSync(tmpDir);
await cg.indexAll();

const callersOf = async (qualified: string) => {
const node = (await cg.searchNodes(qualified.split('::').pop()!, { limit: 10 })).find(
(r) => r.node.kind === 'method' && r.node.qualifiedName === qualified
);
expect(node).toBeDefined();
return (await cg.getCallers(node!.node.id)).map((c) => c.node.name);
};

expect(await callersOf('First::go')).toEqual(['take_first']);
expect(await callersOf('Second::go')).toEqual(['take_second']);
// A same-named method in the calling file stays out of both.
expect(await callersOf('Decoy::go')).toHaveLength(0);
// `Opaque(Vec<usize>)` is external: no edge rather than the decoy's len.
expect(await callersOf('Decoy::len')).toHaveLength(0);
cg.close();
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}, 30000);
});

describe('Imported singleton instance-method calls (#1292)', () => {
// `reproStore.notifyJoinGuildStatus()` after `import { reproStore }` used
// to emit its calls edge to the CONSTANT (resolvedBy:'import'), while the
Expand Down
26 changes: 26 additions & 0 deletions src/extraction/tree-sitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4517,6 +4517,32 @@ export class TreeSitterExtractor {
// Go receivers resolve strictly via validated field-hop
// inference (see matchGoFieldChainCall) or stay unresolved.
calleeName = `${getNodeText(receiver, this.source).replace(/\s+/g, '')}.${methodName}`;
} else if (
this.language === 'rust' &&
receiver &&
receiver.type === 'field_expression' &&
/^self\.(?:[A-Za-z_]\w*|\d+)$/.test(getNodeText(receiver, this.source).replace(/\s+/g, ''))
) {
// Rust field receiver `self.inner.run(...)`, or `self.0.run(...)`
// on a tuple struct: keep the receiver so resolution can infer the
// field's declared type from the enclosing type's declaration.
// Rust has no implicit `self`, so EVERY call on a field is written
// this way — and the receiver, being a field_expression rather
// than a plain identifier, never reached the qualified branch
// above. The bare method name that remained exact-matched an
// unrelated same-named method whenever the field's type is
// external (`Vec`, `Arc<…>`) or simply lives elsewhere,
// fabricating internal dependencies — including self-recursive
// edges the source never had. These receivers resolve strictly
// via validated field inference (see matchRustSelfFieldCall) or
// stay unresolved.
//
// A DEEPER chain (`self.a.b.run()`) keeps the bare-name behavior:
// every hop would have to type, and measured on tokio the hops
// that fail are mostly ones the bare name happened to get right,
// so making them exclusive costs more correct edges than it
// removes wrong ones.
calleeName = `${getNodeText(receiver, this.source).replace(/\s+/g, '')}.${methodName}`;
} else {
calleeName = methodName;
}
Expand Down
Loading