From b23125de9119b46ea9108e5b60e3b32e35c26b9b Mon Sep 17 00:00:00 2001 From: danusha2345 Date: Sat, 3 Oct 2026 12:05:29 +0300 Subject: [PATCH] fix(rust): resolve bare calls through exact use bindings A bare call bound by a `use` (`use crate::util::take; take(3)`, also through `as` aliases, nested groups, `self`/`super`/`crate` paths and block-local imports) now resolves to the item that path names before name matching runs. A parameter, `let` or nested fn of that name still shadows the import. A binding the resolver cannot follow (another workspace crate, a `pub use` re-export, a relative path inside an inline module) keeps the previous resolution. A module path (`crate::util::take(3)`) names the module's own item: never a method, an associated item of an impl or trait body, or a fn-local item. A module-level item wins over an inline module's item reached through a re-export. Includes and supersedes #2259. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/rust-module-path-call.test.ts | 171 +++++++++++++++ __tests__/rust-use-bindings.test.ts | 274 ++++++++++++++++++++++++ __tests__/rust-use-call.test.ts | 213 ++++++++++++++++++ src/resolution/import-resolver.ts | 216 ++++++++++++------- src/resolution/index.ts | 7 +- src/resolution/rust-use-bindings.ts | 256 ++++++++++++++++++++++ 7 files changed, 1059 insertions(+), 79 deletions(-) create mode 100644 __tests__/rust-module-path-call.test.ts create mode 100644 __tests__/rust-use-bindings.test.ts create mode 100644 __tests__/rust-use-call.test.ts create mode 100644 src/resolution/rust-use-bindings.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4eb9e73572..778b808d2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -154,6 +154,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 ` items={…} />` or excalidraw's ` …>`, 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, `` 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 `` 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 Rust, a call to a function brought in by `use`, like `take(3)` after `use crate::util::take;` (also through an `as` alias, a nested group or a `use` inside a function), and a call written as a module path, like `crate::util::take(3)` or `super::util::take(3)`, now link to the function that module declares. Before, they could land on a same-named function of another module, or on a same-named method declared above the function. (#2308) ## [1.6.2] - 2026-10-03 diff --git a/__tests__/rust-module-path-call.test.ts b/__tests__/rust-module-path-call.test.ts new file mode 100644 index 0000000000..db11a29f7e --- /dev/null +++ b/__tests__/rust-module-path-call.test.ts @@ -0,0 +1,171 @@ +/** + * A Rust call written through a module path — `crate::util::take(3)`, + * `super::util::take(3)`, `self::util::take(3)`, `util::take(3)` after + * `use crate::util;` — is the free function that module declares, and so is + * a `use crate::util::take;`. A path never names a method (that takes + * `Type::method`), nor a function local to another function's body, even when + * the module's file declares one of the same name above the free function. + */ +import { describe, it, expect, afterAll, beforeAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +let root = ''; +let cg: CodeGraph; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-rust-module-path-')); + const files: Record = { + 'Cargo.toml': '[package]\nname = "app"\nversion = "0.1.0"\n', + 'src/lib.rs': `mod util; +mod only; +mod a; +mod caller; +mod importer; +mod handle; +mod worker; + +pub fn via_self() -> usize { + self::util::take(1) +} +`, + 'src/util.rs': `pub struct Buf; + +impl Buf { + pub fn take(&self, n: usize) -> usize { + n + } +} + +pub trait Take { + fn take(&self) -> usize; +} + +fn outer() -> usize { + fn take() -> usize { + 0 + } + take() +} + +pub fn take(n: usize) -> usize { + n +} +`, + 'src/only.rs': `pub struct Only; + +impl Only { + pub fn grab(&self) -> usize { + 0 + } +} +`, + 'src/a/mod.rs': 'pub mod b;\n', + 'src/a/b.rs': `pub struct Deep; + +impl Deep { + pub fn get(&self) -> usize { + 0 + } +} + +pub fn get() -> usize { + 1 +} +`, + 'src/importer.rs': 'use crate::util::take;\n', + 'src/handle.rs': 'pub struct Handle;\n', + 'src/worker.rs': `pub use crate::handle::Handle; +mod dump; + +pub trait Lock { + type Handle; +} + +pub struct Guard; + +impl Lock for Guard { + type Handle = u8; +} +`, + 'src/worker/dump.rs': 'use super::Handle;\n', + 'src/caller.rs': `use crate::util; + +pub fn via_crate() -> usize { + crate::util::take(1) +} + +pub fn via_super() -> usize { + super::util::take(1) +} + +pub fn via_module() -> usize { + util::take(1) +} + +pub fn via_nested() -> usize { + crate::a::b::get() +} + +pub fn via_method_only() -> usize { + crate::only::grab() +} + +pub fn via_missing() -> usize { + crate::nowhere::take(1) +} +`, + }; + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + cg = await CodeGraph.init(root, { index: true }); +}); + +afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +/** What the function `name` in `file` calls, as `kind qualifiedName file:line`. */ +const callsFrom = (file: string, name: string): string[] => { + const fn = cg.getNodesInFile(file).find((n) => n.name === name)!; + return cg.getOutgoingEdgesFrom([fn.id]).filter((e) => e.kind === 'calls') + .map((e) => cg.getNode(e.target)!).map((t) => `${t.kind} ${t.qualifiedName} ${t.filePath}:${t.startLine}`); +}; + +describe('Rust calls through a module path', () => { + it('reach the free function the module declares, never a same-named method or local fn', () => { + const take = 'function take src/util.rs:20'; + expect(callsFrom('src/caller.rs', 'via_crate')).toEqual([take]); + expect(callsFrom('src/caller.rs', 'via_super')).toEqual([take]); + expect(callsFrom('src/caller.rs', 'via_module')).toEqual([take]); + expect(callsFrom('src/lib.rs', 'via_self')).toEqual([take]); + }); + + it('bind a use of the function to it', () => { + const ids = cg.getNodesInFile('src/importer.rs').map((n) => n.id); + const targets = cg.getOutgoingEdgesFrom(ids).filter((e) => e.kind === 'imports') + .map((e) => cg.getNode(e.target)!).map((t) => `${t.kind} ${t.qualifiedName} ${t.filePath}:${t.startLine}`); + expect(targets).toEqual(['function take src/util.rs:20']); + }); + + it('never bind a use to an associated type of the module\'s impl or trait', () => { + const ids = cg.getNodesInFile('src/worker/dump.rs').map((n) => n.id); + const targets = cg.getOutgoingEdgesFrom(ids).filter((e) => e.kind === 'imports') + .map((e) => cg.getNode(e.target)!).map((t) => `${t.kind} ${t.qualifiedName} ${t.filePath}:${t.startLine}`); + expect(targets.filter((t) => t.includes('src/worker.rs'))).toEqual([]); + }); + + it('walk a nested module path to its file', () => { + expect(callsFrom('src/caller.rs', 'via_nested')).toEqual(['function get src/a/b.rs:9']); + }); + + it('link nothing when the module declares no such free function, or is not in the index', () => { + expect(callsFrom('src/caller.rs', 'via_method_only')).toEqual([]); + expect(callsFrom('src/caller.rs', 'via_missing')).toEqual([]); + }); +}); diff --git a/__tests__/rust-use-bindings.test.ts b/__tests__/rust-use-bindings.test.ts new file mode 100644 index 0000000000..d38dd0aa5c --- /dev/null +++ b/__tests__/rust-use-bindings.test.ts @@ -0,0 +1,274 @@ +import { describe, expect, it } from 'vitest'; +import { parseRustUseBindings } from '../src/resolution/rust-use-bindings'; + +function at(source: string, marker: string, name: string) { + const offset = source.indexOf(marker); + expect(offset).toBeGreaterThanOrEqual(0); + const before = source.slice(0, offset); + const line = before.split('\n').length; + const column = before.slice(before.lastIndexOf('\n') + 1).length; + return parseRustUseBindings(source).get(name, line, column); +} + +function inBindingScope(source: string, call: string, declaration: string) { + const position = (marker: string) => { + const offset = source.indexOf(marker); + expect(offset).toBeGreaterThanOrEqual(0); + const before = source.slice(0, offset); + return [before.split('\n').length, before.slice(before.lastIndexOf('\n') + 1).length] as const; + }; + const site = position(call); + const decl = position(declaration); + return parseRustUseBindings(source).isInBindingScope('take', site[0], site[1], decl[0], decl[1]); +} + +describe('Rust exact use bindings at a source location', () => { + it('flattens multiline/nested groups, aliases, module self and raw identifiers', () => { + const source = `pub use crate::{ + util::{self as tools, take as chosen, nested::{read, write}}, + other::r#match, util::{self}, skip as _, + }; + fn call() { chosen(); } + `; + const index = parseRustUseBindings(source); + expect(index.get('chosen', 5, 16)).toBe('crate::util::take'); + expect(index.get('take', 5, 16)).toBeUndefined(); + expect(index.get('read', 5, 16)).toBe('crate::util::nested::read'); + expect(index.get('write', 5, 16)).toBe('crate::util::nested::write'); + expect(index.get('tools', 5, 16)).toBe('crate::util'); + expect(index.get('util', 5, 16)).toBe('crate::util'); + expect(index.get('match', 5, 16)).toBe('crate::other::match'); + expect(index.get('_', 5, 16)).toBeUndefined(); + }); + + it('nearest block wins, sibling blocks and functions do not leak imports', () => { + const source = `use crate::a::take; +fn one() { + take(); // outer + { use crate::b::take; take(); /* inner */ } + { take(); /* sibling */ } + take(); // after +} +fn two() { use crate::c::take; take(); /* two */ } +fn three() { take(); /* three */ } +`; + expect(at(source, 'take(); // outer', 'take')).toBe('crate::a::take'); + expect(at(source, 'take(); /* inner */', 'take')).toBe('crate::b::take'); + expect(at(source, 'take(); /* sibling */', 'take')).toBe('crate::a::take'); + expect(at(source, 'take(); // after', 'take')).toBe('crate::a::take'); + expect(at(source, 'take(); /* two */', 'take')).toBe('crate::c::take'); + expect(at(source, 'take(); /* three */', 'take')).toBe('crate::a::take'); + }); + + it('inline modules stop inheritance and decline relative anchors', () => { + const source = `use crate::a::outer; +mod child { + use crate::b::inner; + use self::local::take; + use super::parent::read; + use relative::write; + fn call() { inner(); /* child */ } + mod grandchild { fn call() { inner(); /* grandchild */ } } +} +fn call() { outer(); /* root */ } +`; + expect(at(source, 'inner(); /* child */', 'outer')).toBeUndefined(); + expect(at(source, 'inner(); /* child */', 'inner')).toBe('crate::b::inner'); + for (const name of ['take', 'read', 'write']) expect(at(source, 'inner(); /* child */', name)).toBeNull(); + expect(at(source, 'inner(); /* grandchild */', 'inner')).toBeUndefined(); + expect(at(source, 'outer(); /* root */', 'inner')).toBeUndefined(); + expect(at(source, 'outer(); /* root */', 'outer')).toBe('crate::a::outer'); + }); + + it('conflicting same-scope imports are ambiguous; a nearer binding still wins', () => { + const source = `use crate::a::take; +use crate::b::take; +fn call() { take(); /* ambiguous */ { use crate::c::take; take(); /* local */ } } +`; + expect(at(source, 'take(); /* ambiguous */', 'take')).toBeNull(); + expect(at(source, 'take(); /* local */', 'take')).toBe('crate::c::take'); + }); + + it('comments, normal/raw/byte strings and chars never provide imports or scopes', () => { + const source = `use crate::real::take; +// use crate::wrong::take; mod fake { +/* outer /* use crate::wrong::take; } */ still { */ +const NORMAL: &str = "use crate::wrong::take; { } \\\""; +const RAW: &str = r##"quoted " use crate::wrong::take; mod fake {"##; +const BYTES: &[u8] = br#"use crate::wrong::take; }"#; +fn call<'a>(value: &'a str) { + let chars = ('{', '}', '\\'', '\\u{007b}', '🦀'); + take(); /* actual */ +} +`; + expect(at(source, 'take(); /* actual */', 'take')).toBe('crate::real::take'); + }); + + it('root relative paths are preserved, explicit global paths work in modules; globs bind no name', () => { + const source = `use self::util::take; +use super::other::read; +use crate::helpers::*; +mod child { use ::external::write; fn call() { write(); /* child */ } } +fn call() { take(); /* root */ } +`; + expect(at(source, 'take(); /* root */', 'take')).toBe('self::util::take'); + expect(at(source, 'take(); /* root */', 'read')).toBe('super::other::read'); + expect(at(source, 'take(); /* root */', 'missing')).toBeUndefined(); + expect(at(source, 'write(); /* child */', 'write')).toBe('::external::write'); + }); + + it('accepts UTF-16 columns and CRLF, rejecting invalid source positions', () => { + const source = 'use crate::real::take;\r\nfn call() { let crab = "🦀"; take(); }\r\n'; + expect(at(source, 'take();', 'take')).toBe('crate::real::take'); + const index = parseRustUseBindings(source); + expect(index.get('take', 0, 0)).toBeUndefined(); + expect(index.get('take', 2, 1000)).toBeUndefined(); + const column = source.split('\n')[1]!.indexOf('take();'); + expect(index.get('take', 2, column)).toBe('crate::real::take'); + }); + it('a root import can be shadowed by a nested function declaration', () => { + const source = `use crate::util::take; +fn caller() { + fn take() {} + take(); /* call */ +} +`; + expect(inBindingScope(source, 'take(); /* call */', 'fn take()')).toBe(true); + expect(parseRustUseBindings(source).isInBindingScope('missing', 4, 2, 3, 2)).toBe(false); + }); + + it('an inner import is closer than top-level and outer-local function declarations', () => { + const source = `fn take() {} +fn caller() { + fn take_local() {} + { use crate::util::take; take(); /* call */ } +} +`; + expect(inBindingScope(source, 'take(); /* call */', 'fn take()')).toBe(false); + expect(inBindingScope(source, 'take(); /* call */', 'fn take_local()')).toBe(false); + }); + + it('parameter headers shadow root imports, but body/block imports are closer', () => { + const source = `use crate::util::take; +fn root(take: fn()) { take(); /* root */ } +fn body(take: fn()) { use crate::other::take; take(); /* body */ } +fn block(take: fn()) { { use crate::other::take; take(); /* block */ } } +`; + expect(inBindingScope(source, 'take(); /* root */', 'fn root(')).toBe(true); + expect(inBindingScope(source, 'take(); /* body */', 'fn body(')).toBe(false); + expect(inBindingScope(source, 'take(); /* block */', 'fn block(')).toBe(false); + }); + + it('proves only rooted inline module paths, without accepting local or file declarations', () => { + const source = `#[cfg(test)] +mod support { + pub mod nested { pub mod panic; } + pub mod from_file; +} +mod other { pub mod nested {} } +fn caller() { mod hidden { mod nested {} } } +fn blocks() { { mod hidden_block {} } } +const TEXT: &str = "mod fake { mod nested {} }"; +mod unclosed { +`; + const index = parseRustUseBindings(source); + expect(index.hasInlineModule(['support'])).toBe(true); + expect(index.hasInlineModule(['support', 'nested'])).toBe(true); + expect(index.hasInlineModule(['other', 'nested'])).toBe(true); + for (const path of [[], ['nested'], ['support', 'panic'], ['support', 'nested', 'panic'], + ['support', 'from_file'], ['hidden'], ['hidden', 'nested'], ['hidden_block'], ['fake'], ['unclosed']]) { + expect(index.hasInlineModule(path), path.join('::')).toBe(false); + } + }); + + it('local let shadows only after its initializer; use lookup keeps the type namespace', () => { + const source = `use crate::util::take; +fn caller() { + take(); /* before */ + let ref mut take: fn() = { take(); /* initializer */ function_pointer }; + take(); /* after */ +} +`; + const index = parseRustUseBindings(source); + expect(index.isShadowedByLocalValue('take', 3, 2)).toBe(false); + expect(index.isShadowedByLocalValue('take', 4, 28)).toBe(false); + expect(index.isShadowedByLocalValue('take', 5, 2)).toBe(true); + expect(index.get('take', 5, 2)).toBe('crate::util::take'); + }); + + it('an inner use wins over an outer let; sibling/inner lets never spill outside their scopes', () => { + const source = `use crate::util::take; +fn caller() { + let take = || {}; + { use crate::other::take; take(); /* import wins */ } + take(); /* outer let */ +} +fn sibling() { + { let take = || {}; take(); /* inner let */ } + take(); /* outside let */ +} +`; + const index = parseRustUseBindings(source); + expect(index.isShadowedByLocalValue('take', 4, 27)).toBe(false); + expect(index.isShadowedByLocalValue('take', 5, 2)).toBe(true); + expect(index.isShadowedByLocalValue('take', 8, 21)).toBe(true); + expect(index.isShadowedByLocalValue('take', 9, 2)).toBe(false); + }); + + it('if-let/while-let and values inside literals/comments do not leak into the outer block', () => { + const source = `use crate::util::take; +fn caller() { + if let Some(take) = value { } + while let Some(take) = value { } + let text = r#"let take = || {};"#; + /* let take = || {}; */ + take(); +} +`; + expect(parseRustUseBindings(source).isShadowedByLocalValue('take', 7, 2)).toBe(false); + }); + + it('module target declarations belong to the file root, excluding inline/function/macro bodies', () => { + const source = `pub fn root() {} +mod hidden { pub fn nested() {} } +fn caller() { fn local() {} } +macro_rules! define { () => { pub fn generated() {} } } +`; + const index = parseRustUseBindings(source); + expect(index.isModuleLevelDeclaration(1, 0)).toBe(true); + expect(index.isModuleLevelDeclaration(2, 13)).toBe(false); + expect(index.isModuleLevelDeclaration(3, 14)).toBe(false); + expect(index.isModuleLevelDeclaration(4, 29)).toBe(false); + expect(index.isModuleLevelDeclaration(0, 0)).toBe(false); + }); + + it('tells inline module items apart from impl, trait and macro bodies', () => { + const source = `mod imp { pub fn lone() {} mod deep { pub fn deeper() {} } } +impl Lock for Guard { type Handle = u8; } +trait Lock { type Handle; } +macro_rules! define { () => { pub fn generated() {} } } +pub fn root() {} +`; + const index = parseRustUseBindings(source); + const declaredAt = (marker: string) => { + const before = source.slice(0, source.indexOf(marker)); + return [before.split('\n').length, before.slice(before.lastIndexOf('\n') + 1).length] as const; + }; + expect(index.isInlineModuleDeclaration(...declaredAt('pub fn lone'))).toBe(true); + expect(index.isInlineModuleDeclaration(...declaredAt('pub fn deeper'))).toBe(true); + for (const marker of ['type Handle = u8', 'type Handle;', 'pub fn generated', 'pub fn root']) { + expect(index.isInlineModuleDeclaration(...declaredAt(marker)), marker).toBe(false); + } + }); + + it('a precise-capturing `use<…>` bound or a stray brace never ends the file scope', () => { + const source = `use crate::util::take; +fn captures<'a>(x: &'a u8) -> impl Sized + use<'a> { let y = 1; x } +} +pub fn after() { take(); } +`; + const index = parseRustUseBindings(source); + expect(index.isModuleLevelDeclaration(4, 0)).toBe(true); + expect(index.get('take', 4, 17)).toBe('crate::util::take'); + }); +}); diff --git a/__tests__/rust-use-call.test.ts b/__tests__/rust-use-call.test.ts new file mode 100644 index 0000000000..6ab7512c2b --- /dev/null +++ b/__tests__/rust-use-call.test.ts @@ -0,0 +1,213 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +let root = ''; +let cg: CodeGraph; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-rust-use-call-')); + const files: Record = { + 'Cargo.toml': '[package]\nname = "app"\nversion = "0.1.0"\nedition = "2021"\n\n[dependencies]\nexternal = "1"\n', + 'src/lib.rs': 'pub mod a;\npub mod util;\npub mod caller;\npub mod child;\npub mod method_only;\npub fn root_take() {}\npub enum Root { RootGood(i32) }\n', + 'src/a.rs': 'pub fn take() {}\npub fn consume() {}\npub fn selected() {}\npub enum Noise { take }\n', + 'src/util.rs': `pub struct Buf; +impl Buf { pub fn take(&self) {} } +pub fn outer() { + fn take() {} + take(); +} +pub fn take() {} +`, + 'src/method_only.rs': 'pub struct Buf;\nimpl Buf { pub fn take(&self) {} }\n', + 'src/caller.rs': `use crate::util::take; +pub fn imported() { take(); } +pub fn shadowed() { + fn take() {} + take(); +} +pub fn parameter(take: fn()) { take(); } +pub fn destructured((take, _): (fn(), i32)) { take(); } +pub fn type_label(callback: fn(take: i32)) { take(); } +pub fn generic(callback: T) { take(); } +pub fn after_binding() { + take(); + let take = || {}; + take(); +} +`, + 'src/alias.rs': `use crate::util::take as consume; +pub fn aliased() { consume(); } +`, + 'src/group.rs': `use crate::{util::{take as selected}, a::{take as other}}; +pub fn grouped() { selected(); } +pub fn second() { other(); } +`, + 'src/child/mod.rs': `use super::util::take; +use self::util::take as local_take; +pub fn parent() { take(); } +pub fn child() { local_take(); } +`, + 'src/child/util.rs': 'pub fn take() {}\n', + 'src/scoped.rs': `pub fn first() { + use crate::util::take; + take(); +} +pub fn second() { + use crate::a::take; + take(); +} +`, + 'src/outer_shadow.rs': `fn take() {} +pub fn imported_in_block() { + use crate::util::take; + take(); +} +pub fn inner_import(take: fn()) { + { + use crate::util::take; + take(); + } +} +pub fn before_binding() { + use crate::util::take; + take(); + let take = || {}; +} +`, + 'src/tests_mod.rs': 'pub fn tested_helper() {}\n#[cfg(test)]\nmod tests {\n use super::tested_helper;\n fn uses() { tested_helper(); }\n}\n', + 'src/external.rs': 'use external::take as consume;\npub fn external_call() { consume(); }\n', + 'src/models.rs': 'pub enum E { Good(i32) }\n', + 'src/model_decoy.rs': 'pub enum E { Real(i32) }\nmod hidden { pub enum E { Good(i32) } }\n', + 'src/model_decoy_call.rs': 'use crate::model_decoy::E::Good;\npub fn model_decoy() { Good(1); }\n', + 'src/variant.rs': 'use crate::models::E::Good;\npub fn variant() { Good(1); }\n', + 'src/variant_alias.rs': 'use crate::models::E::Good as Build;\npub fn alias_variant() { Build(1); }\n', + 'src/root_call.rs': 'use crate::root_take as consume;\npub fn root_call() { consume(); }\n', + 'src/root_variant.rs': 'use crate::Root::RootGood;\npub fn root_variant() { RootGood(1); }\n', + 'src/same_file.rs': 'fn same_take() {}\nuse self::same_take as consume;\npub fn same_file() { consume(); }\n', + 'src/inline_reexport.rs': '#[cfg(unix)]\nmod imp { pub fn lone() {} }\npub use imp::*;\nuse self::lone as aliased;\npub fn via_alias() { aliased(); }\npub fn via_path() { crate::inline_reexport::lone(); }\n', + 'src/duplicate.rs': '#[cfg(unix)]\npub fn take() {}\n#[cfg(windows)]\npub fn take() {}\n', + 'src/duplicate_call.rs': 'use crate::duplicate::take;\npub fn duplicate_call() { take(); }\n', + 'tests/bad_inline.rs': 'mod hidden { mod support { pub mod panic; } }\nuse support::panic::test_panic;\npub fn bad_inline() { test_panic(); }\n', + 'tests/support/panic.rs': 'pub fn test_panic() {}\n', + 'src/sync.rs': 'use crate::a::take;\npub fn synced() { take(); }\n', + 'src/unicode.rs': 'use crate::util::take;\npub fn unicode() { let crab = "🦀Я"; take(); }\n', + 'src/unicode_scoped.rs': 'pub fn unicode_scoped() { let crab = "🦀Я"; { use crate::util::take as consume; consume(); } }\n', + 'crates/one/Cargo.toml': '[package]\nname = "one"\nversion = "0.1.0"\n', + 'crates/one/src/lib.rs': 'pub mod util;\n', + 'crates/one/src/util.rs': 'pub fn take() {}\npub fn only_in_one() {}\n', + 'crates/two/Cargo.toml': '[package]\nname = "two"\nversion = "0.1.0"\n', + 'crates/two/src/lib.rs': 'pub mod util;\npub mod caller;\n', + 'crates/two/src/util.rs': 'pub fn take() {}\n', + 'crates/two/src/caller.rs': 'use crate::util::take;\nuse one::util::only_in_one;\npub fn workspace_call() { take(); }\npub fn cross_crate() { only_in_one(); }\n', + }; + for (const [relative, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, relative)), { recursive: true }); + fs.writeFileSync(path.join(root, relative), content); + } + cg = await CodeGraph.init(root, { index: true }); +}); + +afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +const calls = (file: string, name: string) => { + const caller = cg.getNodesInFile(file).find((n) => n.name === name && n.kind === 'function'); + expect(caller).toBeDefined(); + return cg.getOutgoingEdgesFrom([caller!.id]).filter((e) => e.kind === 'calls') + .map((e) => cg.getNode(e.target)!) + .map((n) => `${n.kind} ${n.qualifiedName} ${n.filePath}:${n.startLine}`); +}; + +const take = 'function take src/util.rs:7'; + +describe('Rust bare calls bound by use', () => { + it('chooses the imported free function among same-name methods and functions', () => { + expect(calls('src/caller.rs', 'imported')).toEqual([take]); + }); + + it('follows an alias even when no declaration carries its local name', () => { + expect(calls('src/alias.rs', 'aliased')).toEqual([take]); + expect(calls('src/group.rs', 'grouped')).toEqual([take]); + expect(calls('src/group.rs', 'second')).toEqual(['function take src/a.rs:1']); + }); + + it('anchors self and super imports on the current module', () => { + expect(calls('src/child/mod.rs', 'parent')).toEqual([take]); + expect(calls('src/child/mod.rs', 'child')).toEqual(['function take src/child/util.rs:1']); + }); + + it('keeps block-local imports in their own scopes', () => { + expect(calls('src/scoped.rs', 'first')).toEqual([take]); + expect(calls('src/scoped.rs', 'second')).toEqual(['function take src/a.rs:1']); + }); + + it('does not replace a local function or parameter with an imported function', () => { + expect(calls('src/caller.rs', 'shadowed')).toEqual(['function shadowed::take src/caller.rs:4']); + expect(calls('src/caller.rs', 'parameter')).toEqual([]); + expect(calls('src/caller.rs', 'destructured')).toEqual([]); + expect(calls('src/caller.rs', 'type_label')).toEqual([take]); + expect(calls('src/caller.rs', 'generic')).toEqual([take]); + expect(calls('src/caller.rs', 'after_binding')).toEqual([take]); + }); + + it('lets a closer use shadow an outer declaration, and ignores later let bindings', () => { + expect(calls('src/outer_shadow.rs', 'imported_in_block')).toEqual([take]); + expect(calls('src/outer_shadow.rs', 'inner_import')).toEqual([take]); + expect(calls('src/outer_shadow.rs', 'before_binding')).toEqual([take]); + }); + + it('does not guess a target for an alias or an enum variant it cannot follow', () => { + expect(calls('src/external.rs', 'external_call')).toEqual([]); + expect(calls('src/model_decoy_call.rs', 'model_decoy')).toEqual([]); + }); + + it('leaves an import it cannot follow to the general resolution, as before', () => { + // Another crate of the workspace, named by its package. + expect(calls('crates/two/src/caller.rs', 'cross_crate')).toEqual(['function only_in_one crates/one/src/util.rs:2']); + // A relative path inside an inline module, the usual `mod tests { use super::…; }`. + expect(calls('src/tests_mod.rs', 'uses')).toEqual(['function tested_helper src/tests_mod.rs:1']); + expect(calls('tests/bad_inline.rs', 'bad_inline')).toEqual(['function test_panic tests/support/panic.rs:1']); + }); + + it('reaches an inline module\'s function through the module\'s re-export', () => { + expect(calls('src/inline_reexport.rs', 'via_alias')).toEqual(['function lone src/inline_reexport.rs:2']); + expect(calls('src/inline_reexport.rs', 'via_path')).toEqual(['function lone src/inline_reexport.rs:2']); + }); + + it('takes the first of two #[cfg] variants of the imported function', () => { + expect(calls('src/duplicate_call.rs', 'duplicate_call')).toEqual(['function take src/duplicate.rs:2']); + }); + + it('keeps a variant attached to its imported enum, including a renamed variant', () => { + expect(calls('src/variant.rs', 'variant')).toEqual(['enum_member E::Good src/models.rs:1']); + expect(calls('src/variant_alias.rs', 'alias_variant')).toEqual(['enum_member E::Good src/models.rs:1']); + expect(calls('src/root_variant.rs', 'root_variant')).toEqual(['enum_member Root::RootGood src/lib.rs:7']); + }); + + it('binds crate-root functions and same-file aliases', () => { + expect(calls('src/root_call.rs', 'root_call')).toEqual(['function root_take src/lib.rs:6']); + expect(calls('src/same_file.rs', 'same_file')).toEqual(['function same_take src/same_file.rs:1']); + }); + + it('uses UTF-16 source columns after Cyrillic and emoji on the call line', () => { + expect(calls('src/unicode.rs', 'unicode')).toEqual([take]); + expect(calls('src/unicode_scoped.rs', 'unicode_scoped')).toEqual([take]); + }); + + it('resolves crate relative to the caller rather than another package', () => { + expect(calls('crates/two/src/caller.rs', 'workspace_call')) + .toEqual(['function take crates/two/src/util.rs:1']); + }); + + it('refreshes the exact binding after a source edit and sync', async () => { + expect(calls('src/sync.rs', 'synced')).toEqual(['function take src/a.rs:1']); + fs.writeFileSync(path.join(root, 'src/sync.rs'), 'use crate::util::take;\npub fn synced() { take(); }\n'); + await cg.sync(); + expect(calls('src/sync.rs', 'synced')).toEqual([take]); + }); +}); diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index 9423eaebff..7fe5bb61ed 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -15,6 +15,7 @@ import { stripCommentsForRegex } from './strip-comments'; import { makeLineAt } from './synth-utils'; import { dartDirectiveFile } from './dart-libraries'; import { moduleTail } from '../db/reference-tail'; +import { parseRustUseBindings } from './rust-use-bindings'; import { resolveMethodOnType, resolveObjectLiteralMember, @@ -22,6 +23,7 @@ import { localReceiverTypePatterns, normalizeInferredTypeName, goRefQualifier, + isLexicallyReachable, } from './name-matcher'; /** @@ -324,6 +326,7 @@ export function clearImportResolverMemos(context: ResolutionContext): void { pythonModuleFileMemos.delete(context); PY_MODULE_SYMBOLS.delete(context); PROJECT_FOLDER_SUFFIXES.delete(context); + rustUseBindingMemos.delete(context); } export function resolveImportPath( @@ -2479,9 +2482,17 @@ function resolveRustPathReference( const modSegs = segments.slice(0, -1); const file = resolveRustModuleFile(modSegs, ref.filePath, context); - if (!file || file === ref.filePath) return null; - - const target = context.getNodesInFile(file).find( + if (!file) return null; + + // The module's own item. A path never names a method (that is + // `Type::method`), nor a fn local to another fn's body: the file's first + // same-named node was taken, so an `impl Buf { fn take }` above + // `pub fn take` caught every `crate::util::take(…)`. Two module-level items + // of one name are `#[cfg]` variants of it; the first one stands for both. + // With none, the path reaches an inline module's item through a re-export + // (`#[cfg(…)] mod imp { … }` + `pub use imp::*;`), never an associated item + // of an `impl` or `trait` body. + const candidates = context.getNodesInFile(file).filter( (n) => n.name === leaf && (n.kind === 'function' || @@ -2491,16 +2502,109 @@ function resolveRustPathReference( n.kind === 'trait' || n.kind === 'type_alias' || n.kind === 'constant' || - n.kind === 'method' || n.kind === 'class' || - n.kind === 'interface') + n.kind === 'interface') && + isLexicallyReachable(n, ref, context) ); + const target = + candidates.find((n) => rustUseBindings(file, context).isModuleLevelDeclaration(n.startLine, n.startColumn)) ?? + candidates.find((n) => rustUseBindings(file, context).isInlineModuleDeclaration(n.startLine, n.startColumn)); if (target) { return { original: ref, targetNodeId: target.id, confidence: 0.9, resolvedBy: 'import' }; } return null; } +const rustUseBindingMemos = new WeakMap>>(); + +function rustUseBindings(filePath: string, context: ResolutionContext): ReturnType { + let memo = rustUseBindingMemos.get(context); + if (!memo) rustUseBindingMemos.set(context, (memo = new Map())); + let bindings = memo.get(filePath); + if (!bindings) { + bindings = parseRustUseBindings(context.readFile(filePath) ?? ''); + memo.set(filePath, bindings); + } + return bindings; +} + +/** Inspect parameter patterns, excluding their types (including nested fn types). */ +function rustParameterBinds(signature: string, name: string): boolean { + // Both Rust extractors store `(parameters) -> return_type`, without the fn head. + const open = signature.indexOf('('); + if (open < 0) return false; + let depth = 0; + let start = open + 1; + let typed = false; + for (let i = start; i < signature.length; i++) { + const ch = signature[i]!; + if (depth === 0 && ch === ')') break; + if (depth === 0 && ch === ',') { start = i + 1; typed = false; } + else if (!typed && depth === 0 && ch === ':' && signature[i + 1] !== ':' && signature[i - 1] !== ':') { + if (new RegExp(`\\b${name}\\b`).test(signature.slice(start, i))) return true; + typed = true; + } + if ('([{<'.includes(ch)) depth++; + else if (')]}'.includes(ch) || (ch === '>' && signature[i - 1] !== '-')) depth = Math.max(0, depth - 1); + } + return false; +} + +/** + * A bare Rust call whose name a `use` binds: `use crate::util::take; take(3)` + * is `util`'s free function, not a same-named function of another module. + * Undefined leaves the call to the general resolution; null leaves it unresolved. + */ +export function resolveRustImportedCall(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null | undefined { + if (ref.language !== 'rust' || ref.referenceKind !== 'calls' || !/^[A-Za-z_]\w*$/.test(ref.referenceName)) return undefined; + const line = context.getFileLines?.(ref.filePath)?.[ref.line - 1] ?? context.readFile(ref.filePath)?.split('\n')[ref.line - 1]; + if (line === undefined) return undefined; + const column = ref.column; + // Chained leaf refs point at the receiver/head. Never substitute another + // occurrence of the same name on the line for this call's recorded start. + if (!line.startsWith(ref.referenceName, column) || /(?:\.|::)\s*$/.test(line.slice(0, column))) return undefined; + const bindings = rustUseBindings(ref.filePath, context); + const usePath = bindings.get(ref.referenceName, ref.line, ref.column); + // No import of the name here, two different ones in one scope (`#[cfg]` + // variants), or a relative path inside an inline module that the file-based + // module walk cannot anchor (`mod tests { use super::parse; }`): the general + // resolution handles the call, as before. + if (!usePath) return undefined; + if (bindings.isShadowedByLocalValue(ref.referenceName, ref.line, ref.column)) return null; + + // A block's function/variable or parameter shadows the module's import. + const caller = context.getNodeById?.(ref.fromNodeId); + if (caller?.signature && rustParameterBinds(caller.signature, ref.referenceName) && + bindings.isInBindingScope(ref.referenceName, ref.line, ref.column, caller.startLine, caller.startColumn)) return null; + const local = context.getNodesByName(ref.referenceName).filter((n) => n.filePath === ref.filePath && + (n.kind === 'function' || n.kind === 'variable' || n.kind === 'constant') && isLexicallyReachable(n, ref, context) && + bindings.isInBindingScope(ref.referenceName, ref.line, ref.column, n.startLine, n.startColumn) && + (n.kind === 'function' || n.startLine < ref.line || (n.startLine === ref.line && n.startColumn <= ref.column))); + if (local.some((n) => n.kind === 'function')) return undefined; + if (local.length > 0) return null; + + const resolved = resolveRustPathReference({ ...ref, referenceName: usePath }, context); + if (resolved) return { ...resolved, original: ref }; + // `use crate::models::E::Good as Build` names a particular enum's variant, + // never a same-named enum member elsewhere (or a free function). + const segments = usePath.split('::').filter(Boolean); + const ownerRef = { ...ref, referenceName: segments.slice(0, -1).join('::'), referenceKind: 'references' as const }; + const ownerResult = resolveRustPathReference(ownerRef, context); + const owner = ownerResult && context.getNodeById?.(ownerResult.targetNodeId); + if (owner?.kind === 'enum') { + const variants = context.getNodesInFile(owner.filePath).filter((n) => n.kind === 'enum_member' && + n.qualifiedName === `${owner.qualifiedName}::${segments.at(-1)}` && + (n.startLine > owner.startLine || (n.startLine === owner.startLine && n.startColumn >= owner.startColumn)) && + (n.endLine < owner.endLine || (n.endLine === owner.endLine && n.endColumn <= owner.endColumn))); + return variants.length === 1 ? { original: ref, targetNodeId: variants[0]!.id, confidence: 0.9, resolvedBy: 'import' } : null; + } + // A path this resolver cannot follow (another crate of the workspace, a + // `pub use` re-export, a path through an inline module to its item) keeps + // the general resolution, as before. An alias does not: no declaration carries its + // local name, so a same-named match is never the imported item. + return segments.at(-1) === ref.referenceName ? undefined : null; +} + /** The crate-root directory (holds `lib.rs`/`main.rs`), walking up from a file. */ function rustCrateRootDir(fromFileAbs: string, context: ResolutionContext): string | null { const projectRoot = context.getProjectRoot(); @@ -2545,35 +2649,50 @@ function resolveRustModuleFile( // Walk a sequence of module segments down from `startDir`, mapping each to a // `.rs` or `/mod.rs` file. Returns the leaf module's file, or null // if `startDir` is null or any segment has no file on disk. - const resolveUnder = (startDir: string | null, rest: string[]): string | null => { + const resolveUnder = (startDir: string | null, rest: string[], sourceFile?: string): string | null => { if (!startDir) return null; + if (rest.length === 0) return sourceFile ?? null; let dir = startDir; let targetFile: string | null = null; + let inlinePath: string[] = []; for (const seg of rest) { if (seg === 'self' || seg === 'crate' || seg === 'super') continue; const asFile = toRel(path.join(dir, seg + '.rs')); const asMod = toRel(path.join(dir, seg, 'mod.rs')); if (context.fileExists(asFile)) targetFile = asFile; else if (context.fileExists(asMod)) targetFile = asMod; - else return null; + else { + inlinePath.push(seg); + if (!sourceFile || !rustUseBindings(sourceFile, context).hasInlineModule(inlinePath)) return null; + // Inline modules have no file/node namespace of their own. Only use + // this proof to reach a later file-backed submodule, never its flat nodes. + targetFile = null; + } + if (targetFile) { sourceFile = targetFile; inlinePath = []; } dir = path.join(dir, seg); } return targetFile; }; const first = segments[0]!; + const crateRoot = rustCrateRootDir(fromAbs, context); + const rootFiles = crateRoot ? ['lib.rs', 'main.rs'].map((name) => toRel(path.join(crateRoot, name))) + .filter((file) => context.fileExists(file)) : []; + const rootFile = rootFiles.includes(fromFile) ? fromFile : rootFiles.length === 1 ? rootFiles[0] : undefined; if (first === 'crate') { - return resolveUnder(rustCrateRootDir(fromAbs, context), segments.slice(1)); + return resolveUnder(crateRoot, segments.slice(1), rootFile); } if (first === 'self') { - return resolveUnder(rustSelfModuleDir(fromAbs), segments.slice(1)); + return resolveUnder(rustSelfModuleDir(fromAbs), segments.slice(1), fromFile); } if (first === 'super') { let supers = 0; while (segments[supers] === 'super') supers++; let dir: string | null = rustSelfModuleDir(fromAbs); for (let s = 0; s < supers && dir; s++) dir = path.dirname(dir); - return resolveUnder(dir, segments.slice(supers)); + const parents = dir === crateRoot ? rootFiles : [toRel(path.join(dir, 'mod.rs')), + toRel(path.join(path.dirname(dir), path.basename(dir) + '.rs'))].filter((file) => context.fileExists(file)); + return resolveUnder(dir, segments.slice(supers), parents.length === 1 ? parents[0] : undefined); } // Bare path. In expression position (`submodule::item()` — the router-assembly // and general cross-module-call pattern) the prefix is a SUBMODULE of the @@ -2581,8 +2700,13 @@ function resolveRustModuleFile( // Fall back to crate-relative for 2015-edition / crate-root items. External // crate paths (`serde::de::Error`) miss both and fall through to name-matching. return ( - resolveUnder(rustSelfModuleDir(fromAbs), segments) ?? - resolveUnder(rustCrateRootDir(fromAbs, context), segments) + resolveUnder(rustSelfModuleDir(fromAbs), segments, fromFile) ?? + // Cargo's standalone integration-test/example/bench files own their + // directory, unlike `src/foo.rs`. The source must prove the inline prefix. + (/(?:^|\/)(?:tests|examples|benches)\/[^/]+\.rs$/.test(fromFile) && + rustUseBindings(fromFile, context).hasInlineModule([first]) + ? resolveUnder(path.dirname(fromAbs), segments, fromFile) : null) ?? + resolveUnder(crateRoot, segments, rootFile) ); } @@ -2987,70 +3111,6 @@ function resolveStaticMember( return candidates[0]; } -/** - * Rust `use` declarations, flattened to `localName → full path`. - * - * Rust is the one supported language with NO `ImportMapping` extraction (see - * `extractImportMappings`), so this is the only channel that can tell whether - * a bare type name in a Rust file was brought in by a `use`. Handles nested - * groups (`use a::{b::C, d as E}`), globs (skipped — they bind no single - * name), and `as` aliases. - */ -function collectRustUseBindings(content: string): Map { - const out = new Map(); - - // Expand one level of `{...}` at a time so `a::{b::{C, D}, E}` flattens. - const expand = (spec: string): string[] => { - const open = spec.indexOf('{'); - if (open === -1) return [spec.trim()]; - const prefix = spec.slice(0, open); - let depth = 0; - let close = -1; - for (let i = open; i < spec.length; i++) { - if (spec[i] === '{') depth++; - else if (spec[i] === '}') { - depth--; - if (depth === 0) { close = i; break; } - } - } - if (close === -1) return []; - const suffix = spec.slice(close + 1); - const inner = spec.slice(open + 1, close); - const parts: string[] = []; - let depth2 = 0; - let start = 0; - for (let i = 0; i <= inner.length; i++) { - const ch = inner[i]; - if (ch === '{') depth2++; - else if (ch === '}') depth2--; - if (i === inner.length || (ch === ',' && depth2 === 0)) { - const seg = inner.slice(start, i).trim(); - if (seg) parts.push(seg); - start = i + 1; - } - } - return parts.flatMap((p) => expand(prefix + p + suffix)); - }; - - // `use` items end at the first `;`. Attributes/visibility (`pub use`) are - // irrelevant to the binding itself. - const useRe = /(^|\n)\s*(?:pub(?:\([^)]*\))?\s+)?use\s+([^;]+);/g; - let m: RegExpExecArray | null; - while ((m = useRe.exec(content)) !== null) { - for (const spec of expand(m[2]!.replace(/\s+/g, ' '))) { - const aliasMatch = /^(.*?)\s+as\s+([A-Za-z_]\w*)$/.exec(spec); - const rawPath = (aliasMatch ? aliasMatch[1]! : spec).trim(); - if (!rawPath || rawPath.endsWith('*')) continue; - const segments = rawPath.split('::').map((s) => s.trim()).filter(Boolean); - const leaf = segments[segments.length - 1]; - if (!leaf) continue; - const local = aliasMatch ? aliasMatch[2]! : leaf; - out.set(local, segments.join('::')); - } - } - return out; -} - /** * Is `name`, as used in `ref`'s file, bound by an import whose module lives * OUTSIDE the repository? @@ -3090,9 +3150,9 @@ export function isBoundToOutOfRepoImport( if (ref.language === 'rust') { const content = context.readFile(ref.filePath); if (!content) return false; - const usePath = collectRustUseBindings(content).get(name); + const usePath = rustUseBindings(ref.filePath, context).get(name, ref.line, ref.column); if (!usePath) return false; - const segments = usePath.split('::'); + const segments = usePath.split('::').filter(Boolean); if (segments.length < 2 || !RUST_STDLIB_ROOTS.has(segments[0]!)) return false; // 2015-edition crate-relative paths can shadow a stdlib root with a local // module of the same name — if the path walks to a real file, it's local. diff --git a/src/resolution/index.ts b/src/resolution/index.ts index cac05d6ce9..7740ff7ada 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -33,7 +33,7 @@ import { gateDartLocal, clearDartLocalScopeMemos } from './dart-local-scope'; import { clearCppTypeAliasMemos } from './cpp-type-aliases'; import { clearCppIncluderMemos } from './cpp-includers'; import { matchShopifyThemeFile } from './shopify-themes'; -import { resolveViaImport, resolvePhpImportedStaticCall, resolvePhpQualifiedClassRef, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, isDartImportRef, isLuaRequireRef, isJsPathImportRef, isBoundToOutOfRepoImport, importBindingTail, clearImportResolverMemos, resolveImportPath, isExternalImport } from './import-resolver'; +import { resolveViaImport, resolvePhpImportedStaticCall, resolvePhpQualifiedClassRef, resolveJvmImport, extractImportMappings, extractReExports, loadCppIncludeDirs, isPhpIncludePathRef, isCobolCopybookRef, isNixPathImportRef, isDartImportRef, isLuaRequireRef, isJsPathImportRef, isBoundToOutOfRepoImport, importBindingTail, clearImportResolverMemos, resolveImportPath, isExternalImport, resolveRustImportedCall } from './import-resolver'; import { ResolverPool, minRefsForPool, shouldEngageAdaptively } from './resolver-pool'; import { resolveAliasBinding } from './alias-binding'; import { detectFrameworks, getResolvingFrameworks } from './frameworks'; @@ -1320,6 +1320,11 @@ export class ReferenceResolver { const phpQualified = resolvePhpQualifiedClassRef(ref, this.context); if (phpQualified !== undefined) return this.gateLanguage(phpQualified, ref); + // Rust `use …::take as consume; consume()` can have no node named + // `consume`. Its explicit binding takes precedence over name heuristics. + const rustImportedCall = resolveRustImportedCall(ref, this.context); + if (rustImportedCall !== undefined) return this.gateLanguage(rustImportedCall, ref); + // Fast pre-filter: skip if no symbol with this name exists anywhere // AND the name doesn't match a local import. The import escape is // necessary because re-export rename chains (`import { login } diff --git a/src/resolution/rust-use-bindings.ts b/src/resolution/rust-use-bindings.ts new file mode 100644 index 0000000000..47ae92a9da --- /dev/null +++ b/src/resolution/rust-use-bindings.ts @@ -0,0 +1,256 @@ +/** Exact named Rust `use` bindings at a source location; globs stay unsupported. */ +export interface RustUseBindings { + /** + * The path the nearest `use` binds `name` to. 1-based line, 0-based UTF-16 + * column, as in CodeGraph nodes and references. Undefined when nothing imports + * the name there; null when one scope imports it twice, or an inline module + * imports it through a relative path the file-based module walk cannot anchor. + */ + get(name: string, line: number, column: number): string | null | undefined; + /** Whether a declaration lies within the nearest binding's lexical scope. */ + isInBindingScope(name: string, line: number, column: number, declarationLine: number, declarationColumn: number): boolean; + /** Only rooted module-level inline declarations, never function/block-local modules. */ + hasInlineModule(path: readonly string[]): boolean; + /** Simple standalone `let NAME = ...` values, visible after their declaration. */ + isShadowedByLocalValue(name: string, line: number, column: number): boolean; + /** Source proof for a direct file-module item, excluding inline modules/blocks/macros. */ + isModuleLevelDeclaration(line: number, column: number): boolean; + /** An item of a module declared inline (`mod imp { … }`), never of a fn, impl or trait body. */ + isInlineModuleDeclaration(line: number, column: number): boolean; +} + +interface Scope { + start: number; + end: number; + parent: Scope | null; + module: boolean; + inlineModule: boolean; + modulePath: string[] | null; + /** Created on first write: most scopes (fn bodies, match arms, struct bodies) bind nothing. */ + bindings?: Map; + values?: Map; +} + +/** Keep offsets/newlines while hiding comments, strings, chars; lifetimes remain. */ +function maskNonCode(source: string): string { + const out = source.split(''); + const blank = (from: number, to: number) => { + for (let j = from; j < to; j++) if (source[j] !== '\n' && source[j] !== '\r') out[j] = ' '; + }; + for (let i = 0; i < source.length;) { + const start = i; + if (source.startsWith('//', i)) { + const end = source.indexOf('\n', i); + i = end < 0 ? source.length : end; + } else if (source.startsWith('/*', i)) { + i += 2; + let depth = 1; + while (i < source.length && depth > 0) { + if (source.startsWith('/*', i)) { depth++; i += 2; } + else if (source.startsWith('*/', i)) { depth--; i += 2; } + else i++; + } + } else { + const raw = (i === 0 || !/[\w#]/.test(source[i - 1]!)) + ? /^(?:br|cr|r)(#*)"/.exec(source.slice(i)) : null; + if (raw) { + const close = '"' + raw[1]!; + const end = source.indexOf(close, i + raw[0].length); + i = end < 0 ? source.length : end + close.length; + } else if (source[i] === '"') { + i++; + while (i < source.length) { + if (source[i] === '\\') i += 2; + else if (source[i++] === '"') break; + } + } else { + // Unlike a lifetime ('a / 'static), a char has one scalar or escape + a closing quote. + const char = source[i] === "'" + ? /^'(?:\\(?:u\{[0-9a-fA-F_]+\}|x[0-9a-fA-F]{2}|[^\r\n])|[^'\\\r\n])'/u.exec(source.slice(i)) : null; + if (char) i += char[0].length; + else { i++; continue; } + } + } + blank(start, Math.min(i, source.length)); + } + return out.join(''); +} + +/** Flatten nested groups: `a::{b::{C, D}, E}` → `a::b::C`, `a::b::D`, `a::E`. */ +function expandUse(spec: string): string[] { + const open = spec.indexOf('{'); + if (open < 0) return [spec.trim()]; + let depth = 0; + let close = -1; + for (let i = open; i < spec.length; i++) { + if (spec[i] === '{') depth++; + else if (spec[i] === '}' && --depth === 0) { close = i; break; } + } + if (close < 0) return []; + const inner = spec.slice(open + 1, close); + const parts: string[] = []; + let start = 0; + depth = 0; + for (let i = 0; i <= inner.length; i++) { + if (inner[i] === '{') depth++; + else if (inner[i] === '}') depth--; + if (i === inner.length || (inner[i] === ',' && depth === 0)) { + const part = inner.slice(start, i).trim(); + if (part) parts.push(part); + start = i + 1; + } + } + return parts.flatMap(part => expandUse(spec.slice(0, open) + part + spec.slice(close + 1))); +} + +function addUse(scope: Scope, spec: string): void { + for (const item of expandUse(spec)) { + const alias = /^(.*?)\s+as\s+(r#)?([A-Za-z_]\w*)$/.exec(item.trim()); + const raw = (alias ? alias[1]! : item).trim(); + if (!raw || raw.endsWith('*')) continue; + const absolute = raw.startsWith('::'); + const segments = raw.split('::').map(s => s.trim().replace(/^r#/, '')).filter(Boolean); + if (!segments.length || segments.some(s => !/^[A-Za-z_]\w*$/.test(s))) continue; + // `a::{self}` binds `a`; `a::{self as b}` binds `b`, both to the module `a`. + if (segments.at(-1) === 'self' && segments.length > 1) segments.pop(); + const name = alias ? alias[3]! : segments.at(-1)!; + if (name === '_' || name === 'self' || name === 'super' || name === 'crate') continue; + // The file-based module resolver cannot anchor relative paths inside inline modules. + const path = scope.inlineModule && !absolute && segments[0] !== 'crate' + ? null : (absolute ? '::' : '') + segments.join('::'); + const bindings = (scope.bindings ??= new Map()); + const previous = bindings.get(name); + bindings.set(name, previous !== undefined && previous !== path ? null : path); + } +} + +/** A let's initializer may contain blocks/calls/arrays with their own semicolons. */ +function statementEnd(code: string, start: number): number | null { + let depth = 0; + for (let i = start; i < code.length; i++) { + if ('{[('.includes(code[i]!)) depth++; + else if ('}])'.includes(code[i]!)) { + if (depth === 0) return null; + depth--; + } else if (code[i] === ';' && depth === 0) return i + 1; + } + return null; +} + +/** Parse once per file/context; this view does not read files or resolve graph nodes. */ +export function parseRustUseBindings(content: string): RustUseBindings { + const code = maskNonCode(content); + const root: Scope = { start: 0, end: content.length, parent: null, module: true, inlineModule: false, modulePath: [] }; + const scopes = [root]; + const inlineModules = new Set(); + let current = root; + let boundary = 0; + const tokens = /r#[A-Za-z_]\w*|[A-Za-z_]\w*|[{};]/g; + for (let token; (token = tokens.exec(code)) !== null;) { + const word = token[0]; + const at = token.index; + // `impl Trait + use<'a>` (precise capturing) is a bound, not an import. + if (word === 'use' && !code.slice(tokens.lastIndex).trimStart().startsWith('<')) { + const end = code.indexOf(';', tokens.lastIndex); + if (end < 0) break; + addUse(current, code.slice(tokens.lastIndex, end)); + tokens.lastIndex = end + 1; // group braces belong to the use tree, not lexical scopes. + boundary = end + 1; + } else if (word === 'let' && !current.module && code.slice(boundary, at).trim() === '') { + // No `if let` / `while let` / destructuring: those need their own pattern scopes. + const declaration = /^let\s+(?:ref\s+)?(?:mut\s+)?(?:r#)?([A-Za-z_]\w*)\s*(?::[^=;{}]*)?=(?!=)/.exec(code.slice(at)); + if (declaration && declaration[1] !== '_') { + const end = statementEnd(code, at + declaration[0].length); + if (end !== null) { + const values = (current.values ??= new Map()); + const positions = values.get(declaration[1]!) ?? []; + positions.push(end); + values.set(declaration[1]!, positions); + } + } + } else if (word === '{') { + const moduleName = /\bmod\s+(?:r#)?([A-Za-z_]\w*)\s*$/.exec(code.slice(boundary, at))?.[1]; + const module = moduleName !== undefined; + const modulePath = module && current.module && current.modulePath !== null + ? [...current.modulePath, moduleName!] : null; + current = { start: at + 1, end: content.length, parent: current, module, + inlineModule: module || current.inlineModule, modulePath }; + scopes.push(current); + boundary = at + 1; + } else if (word === '}') { + // A stray `}` never closes the file's own scope. + if (current !== root) { + current.end = at; + if (current.modulePath?.length) inlineModules.add(current.modulePath.join('::')); + current = current.parent ?? root; + } + boundary = at + 1; + } else if (word === ';') boundary = at + 1; + } + const lines = [0]; + for (let i = 0; i < content.length; i++) if (content[i] === '\n') lines.push(i + 1); + // Line lengths come from the line starts, so the memoized view does not keep the source alive. + const length = content.length; + const offsetAt = (line: number, column: number): number | null => { + if (!Number.isInteger(line) || line < 1 || line > lines.length || !Number.isInteger(column) || column < 0) return null; + const start = lines[line - 1]!; + return column <= (lines[line] ?? length + 1) - 1 - start ? start + column : null; + }; + const scopeAt = (offset: number): Scope | null => { + let lo = 0; + let hi = scopes.length; + while (lo + 1 < hi) { + const mid = (lo + hi) >>> 1; + if (scopes[mid]!.start <= offset) lo = mid; + else hi = mid; + } + let scope: Scope | null = scopes[lo]!; + while (scope && scope.end <= offset) scope = scope.parent; + return scope; + }; + const bindingScope = (name: string, line: number, column: number): Scope | null => { + const offset = offsetAt(line, column); + if (offset === null) return null; + let scope = scopeAt(offset); + while (scope) { + if (scope.bindings?.has(name)) return scope; + if (scope.module) break; + scope = scope.parent; + } + return null; + }; + return { + get(name, line, column) { + return bindingScope(name, line, column)?.bindings?.get(name); + }, + isInBindingScope(name, line, column, declarationLine, declarationColumn) { + const scope = bindingScope(name, line, column); + const declaration = offsetAt(declarationLine, declarationColumn); + return !!scope && declaration !== null && declaration >= scope.start && declaration < scope.end; + }, + hasInlineModule(path) { + return path.length > 0 && inlineModules.has(path.join('::')); + }, + isShadowedByLocalValue(name, line, column) { + const binding = bindingScope(name, line, column); + const offset = offsetAt(line, column); + if (!binding || offset === null) return false; + let scope = scopeAt(offset); + while (scope) { + if (scope.values?.get(name)?.some(start => start <= offset)) return true; + if (scope === binding || scope.module) break; + scope = scope.parent; + } + return false; + }, + isModuleLevelDeclaration(line, column) { + const offset = offsetAt(line, column); + return offset !== null && scopeAt(offset) === root; + }, + isInlineModuleDeclaration(line, column) { + const offset = offsetAt(line, column); + const scope = offset === null ? null : scopeAt(offset); + return !!scope && scope !== root && scope.module; + }, + }; +}