diff --git a/CHANGELOG.md b/CHANGELOG.md index 8611ba779..22b0c0ac0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- In Rust, a method call now resolves by the type its receiver is written with: a parameter, local or field of a project type calls that type's method, a struct literal like `Xyz { x: 1.0 }.to_val()` calls its own type's, and a receiver whose written type is a slice, a `HashMap`, a `use … as` alias of a dependency or anything else from outside the project no longer links to a same-named project method. Before, `names.contains(&k)` on a slice, `vis.map.get("a")` on a map field and `J::from(i)` under `use serde_json::Value as J` each gained a false call edge. (#2614) - In TypeScript, an awaited call with type arguments like `await fetchApi("/me")` or `await api.get(url)` now links to the function it calls. Before, the call was dropped, so typed fetch wrappers looked like they had no callers. (#2586) - In React, a component rendered at module scope, in a top-level constant or inside a test's `it(…)` callback, like `createRoot(el).render()` or `render()`, now counts as rendered there, so an app's entry reaches its root component and a component's tests show among its callers. (#2587) - In Python, `import a.b as X` written inside a function now links its calls like `X.f()`, as the same import at module level already did. Thanks @Egrn for the report. (#2600) diff --git a/__tests__/rust-receiver-written-type.test.ts b/__tests__/rust-receiver-written-type.test.ts new file mode 100644 index 000000000..8c3cd5e38 --- /dev/null +++ b/__tests__/rust-receiver-written-type.test.ts @@ -0,0 +1,200 @@ +/** + * A Rust method call resolves by the type its receiver is written with, when + * it is written down (#2614). `names.contains(&k)` on a `&[&str]` parameter + * went to `Obj::contains`, `vis.map.get("a")` on a `HashMap` field to + * `Ext::get`, `J::from(i)` under `use serde_json::Value as J` to a local + * `impl From for Fail`, and `Xyz { x: 1.0 }.to_val()` to + * `Stat::to_val` — each a same-named method of another type, picked by name + * while the type stood in the source. A receiver of a project type calls that + * type's method; one of a type from outside the project calls nothing of the + * project's; one whose type is not written resolves as before. + */ +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-receiver-')); + const files: Record = { + 'Cargo.toml': `[package] +name = "repro" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde_json = "1" +`, + 'src/main.rs': `use std::collections::HashMap; +use serde_json::Value as J; + +pub enum Val { + Null, + Int(i64), +} + +pub struct Obj { + keys: Vec, +} + +impl Obj { + pub fn contains(&self, k: &str) -> bool { + self.keys.iter().any(|x| x == k) + } +} + +pub struct Ext; + +impl Ext { + pub fn get(name: &str) -> Ext { + let _ = name; + Ext + } +} + +pub struct Fail; + +impl From for Fail { + fn from(_: std::io::Error) -> Fail { + Fail + } +} + +pub struct Xyz { + x: f64, +} + +pub struct Stat { + n: f64, +} + +pub trait ToVal { + fn to_val(&self) -> Val; +} + +impl ToVal for Xyz { + fn to_val(&self) -> Val { + Val::Int(self.x as i64) + } +} + +impl ToVal for Stat { + fn to_val(&self) -> Val { + Val::Int(self.n as i64) + } +} + +// 1. slice::contains, not Obj::contains +fn slice_has(names: &[&str], k: &str) -> bool { + names.contains(&k) +} + +// 2. HashMap::get on a struct field, not Ext::get +pub struct Vis { + map: HashMap, +} + +fn map_get(vis: &Vis) -> Option<&i32> { + vis.map.get("a") +} + +// 3. serde_json::Value::from via alias J, not Fail::from +fn to_json(i: i64) -> J { + J::from(i) +} + +// 4. method on struct literal: Xyz::to_val, not Stat::to_val +fn centroid() -> Val { + Xyz { x: 1.0 }.to_val() +} + +// A project type written on the parameter, on a local, on a field read. +pub struct Holder { + obj: Obj, + stats: Vec, +} + +fn typed_param(o: &Obj) -> bool { + o.contains("a") +} + +fn typed_local() -> bool { + let o = Obj { keys: vec![] }; + o.contains("b") +} + +fn through_field(h: &Holder) -> bool { + h.obj.contains("c") +} + +// A field whose container does not auto-deref: nothing of the project's. +fn vec_field(h: &Holder) -> usize { + h.stats.len() +} + +// A receiver nothing types: resolved as before (the one \`contains\` a name alone finds). +fn untyped(items: Vec) -> bool { + items.iter().any(|it| it.contains("d")) +} + +fn main() { + let o = Obj { keys: vec![] }; + let _ = o.contains("a"); + let _ = Ext::get("x"); + let _ = Stat { n: 0.0 }.to_val(); + let _ = slice_has(&["a"], "a"); + let _ = map_get(&Vis { map: HashMap::new() }); + let _ = to_json(1); + let _ = centroid(); + let h = Holder { obj: Obj { keys: vec![] }, stats: vec![] }; + let _ = (typed_param(&o), typed_local(), through_field(&h), vec_field(&h), untyped(vec![])); +} +`, + }; + 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 }); +}); + +/** The project methods `fn` calls, as `Owner::method`, sorted. */ +const callsOf = (fn: string): string[] => { + const caller = cg.getNodesInFile('src/main.rs').find((n) => n.name === fn && n.kind === 'function')!; + return cg.getOutgoingEdges(caller.id).filter((e) => e.kind === 'calls') + .map((e) => cg.getNode(e.target)!).filter((t) => t.kind === 'method').map((t) => t.qualifiedName).sort(); +}; + +describe('a Rust method call resolves by the receiver type the source writes (#2614)', () => { + it('a slice parameter, a HashMap field and an aliased serde_json path call nothing of the project', () => { + expect(callsOf('slice_has')).toEqual([]); + expect(callsOf('map_get')).toEqual([]); + expect(callsOf('to_json')).toEqual([]); + expect(callsOf('vec_field')).toEqual([]); + }); + + it('a struct literal calls its own type\'s method, not a namesake', () => { + expect(callsOf('centroid')).toEqual(['Xyz::to_val']); + }); + + it('a project type on a parameter, a local or a field read calls its method', () => { + expect(callsOf('typed_param')).toEqual(['Obj::contains']); + expect(callsOf('typed_local')).toEqual(['Obj::contains']); + expect(callsOf('through_field')).toEqual(['Obj::contains']); + expect(callsOf('main')).toEqual(expect.arrayContaining(['Obj::contains', 'Ext::get', 'Stat::to_val'])); + expect(callsOf('main')).not.toContain('Xyz::to_val'); + }); + + it('a receiver nothing types resolves as before', () => { + expect(callsOf('untyped')).toEqual(['Obj::contains']); + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 0862d9814..fc1adbaa6 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -5763,6 +5763,11 @@ function rustGoReceiverName(text: string): string { const literal = /^\(\s*&?\s*([A-Za-z_][\w.]*)\s*\{/.exec(src.slice(j, i + 1)); if (literal) return literal[1]!; } + // `Xyz { x: 1.0 }.to_val()` — a struct literal names its type (#2614). + if (ch === '}' && out === '') { + const literal = /(?:^|[^\w.:])([A-Z]\w*)\s*$/.exec(src.slice(0, j)); + if (literal) return literal[1]!; + } i = j - 1; } else if (/[\w$.:!]/.test(ch)) { if (ch !== '!') out = ch + out; @@ -11734,6 +11739,10 @@ export function matchByExactName( const scalaBare = ref.language === 'scala' && ref.referenceKind === 'calls' && /^[A-Za-z_$][\w$]*$/.test(ref.referenceName); const rustGoShape = (ref.language === 'rust' || ref.language === 'go') && ref.referenceKind === 'calls' && /^[A-Za-z_]\w*$/.test(ref.referenceName) ? rustGoCallShape(ref, context) : null; + // `vis.map.get("a")` / `Xyz { x: 1.0 }.to_val()` arrive as bare `get` / `to_val`: + // the receiver's written type picks the method, or rules every one out (#2614). + const rustReceiver = ref.language === 'rust' && rustGoShape?.shape === 'chained' + ? rustWrittenReceiverType(rustGoShape.receiver, ref, context) : null; const kotlinStdChain = ref.language === 'kotlin' && ref.referenceKind === 'calls' && KOTLIN_STD_METHODS.has(ref.referenceName) ? kotlinChainReceiver(ref, context) : null; const swiftShape = ref.language === 'swift' && ref.referenceKind === 'calls' && /^[A-Za-z_]\w*$/.test(ref.referenceName) @@ -11743,6 +11752,8 @@ export function matchByExactName( !(kotlinStdChain !== null && !isKotlinStdChainTarget(n, kotlinStdChain)) && !(swiftShape && !isSwiftCallTarget(n, swiftShape, ref, context)) && !(rustGoShape && !isRustGoCallTarget(n, rustGoShape)) && + !(rustReceiver === 'outside') && + !(typeof rustReceiver === 'object' && rustReceiver !== null && n.qualifiedName !== `${rustReceiver.type}::${ref.referenceName}`) && !(scalaBare && !isScalaMemberInScope(n, ref, context)) && !(csharpBare && !isCsharpMemberInScope(n, ref, context)) && !(objcShape === 'c-call' && OBJC_MEMBER_KINDS.has(n.kind)) && @@ -19384,6 +19395,20 @@ export function matchMethodCall( return matchRustSelfFieldCall(field, methodName!, ref, context); } + // Rust call through a parameter, a local or a field read on one, or on a + // struct literal — `names.contains(&k)`, `vis.map.get("a")`, + // `Xyz { x: 1.0 }.to_val()` (#2614). EXCLUSIVE like the branches above + // whenever the receiver's type is written down: a project type calls its + // own method or nothing, and a slice, a `HashMap` or anything else from + // outside the project calls nothing of the project's. A receiver whose + // type is not written (a closure's argument, a `for` binding) resolves as + // before. + if (ref.language === 'rust' && dotMatch) { + const written = rustWrittenReceiverType(objectOrClass!, ref, context); + if (written === 'outside') return null; + if (written) return resolveMethodOnType(written.type, methodName!, ref, context, 0.9, 'instance-method'); + } + // TS/JS call through a field of the enclosing class — `this.mailer.send()`, // emitted as `this.mailer.send` (#1496). Same discipline as the Rust branch // above, and EXCLUSIVE for the same reason: the field's declared type off @@ -19592,6 +19617,13 @@ export function matchMethodCall( if (ref.language === 'rust' && match === colonMatch && /^[a-z_][\w]*(?:::[a-z_]\w*)*$/.test(objectOrClass!)) { return null; } + // Rust `J::from(i)` under `use serde_json::Value as J`: a path through a + // name a `use` binds from the standard library or a dependency names that + // crate's item, never a project type's same-named method (#2614). + if (ref.language === 'rust' && match === colonMatch) { + const uses = rustUsesOf(ref.filePath, context); + if (uses.external.has(objectOrClass!) && !uses.bound.has(objectOrClass!)) return null; + } // `string.Equals(…)`, `object.ReferenceEquals(…)`: a C# keyword type. if (ref.language === 'csharp' && /^(?:string|object|int|long|short|byte|bool|char|double|float|decimal|uint|ulong|ushort|sbyte)$/.test(objectOrClass!)) { return null; @@ -23165,6 +23197,135 @@ export function rustFieldTypeName(raw: string): string | null { return seg; } +/** + * The type a Rust receiver is written with where `ref` calls through it + * (#2614): a project type's name, `'outside'` when what is written names + * nothing of the project's, or null when nothing is written down. Read from + * the source alone, never guessed from the receiver's name: + * + * - a struct literal or unit struct, `Xyz { x: 1.0 }.to_val()`, names its + * type; + * - a parameter or local of the enclosing function, by its declaration — + * `names: &[&str]`, `let m: HashMap<_, _> = …`, `let o = Obj { … }`, + * `let o = Obj::new(…)` — the last one before the call; + * - a field read on either, `vis.map`, by the field's declared type. + * + * A written type is outside the project when it is a slice, tuple, array, raw + * pointer or primitive; a name a `use` binds from the standard library or a + * dependency (`HashMap`, `use serde_json::Value as J`); or a name no Rust + * type of the project declares. A generic parameter, a closure, a `for` + * binding or a value the declaration does not type stays unknown (null): the + * call resolves as before. The reporter's `names.contains(&k)` on a slice + * went to `Obj::contains`, `vis.map.get("a")` on a `HashMap` field to + * `Ext::get`, `J::from(i)` to a local `impl From for Fail`, and + * `Xyz { x: 1.0 }.to_val()` to `Stat::to_val` — a same-named method of + * another type, when the type is right there in the source. + */ +function rustWrittenReceiverType(receiver: string, ref: UnresolvedRef, context: ResolutionContext): { type: string } | 'outside' | null { + const [head, ...links] = receiver.split('.'); + if (!head || !/^[A-Za-z_]\w*$/.test(head) || head === 'self' || head === 'Self') return null; + let written: string | null; + if (/^[A-Z]/.test(head)) { + written = head; // `Xyz { … }` / a unit struct — rustGoReceiverName drops the braces + } else { + written = rustLocalDeclaredType(head, ref, context); + if (written === null) return null; + } + let type = rustWrittenTypeOf(written, ref, context); + for (const link of links) { + if (type === 'outside' || type === null) return type; + const field = rustDeclaredFieldType(type.type, link, ref, context); + if (field === null) return null; + type = rustWrittenTypeOf(field, ref, context); + } + return type; +} + +/** What a written Rust type names: a project type, `'outside'`, or null for one that cannot be told (a generic parameter). */ +function rustWrittenTypeOf(written: string, ref: UnresolvedRef, context: ResolutionContext): { type: string } | 'outside' | null { + const raw = written.trim().replace(/^&\s*(?:'\w+\s+)?(?:mut\s+)?/, ''); + // A slice, array, tuple, raw pointer or function type is nobody's project type. + if (/^[[(*]|^(?:unsafe\s+|extern\s+"[^"]*"\s+)?fn\b/.test(raw)) return 'outside'; + const name = rustFieldTypeName(written); + if (name === null) { + const seg = raw.replace(/[<>+].*$/, '').trim().split('::').filter(Boolean).pop() ?? ''; + // A primitive (`str`, `u8`, `bool`); a single letter is a generic parameter. + return /^[A-Za-z_]\w*$/.test(seg) && RUST_NON_PROJECT_FIELD_TYPES.has(seg) ? 'outside' : null; + } + const uses = rustUsesOf(ref.filePath, context); + if (uses.external.has(name) && !uses.bound.has(name)) return 'outside'; + const declared = context.getNodesByName(name).filter((n) => n.language === 'rust' && + (n.kind === 'struct' || n.kind === 'enum' || n.kind === 'union' || n.kind === 'class' || n.kind === 'trait' || n.kind === 'type_alias')); + if (declared.length === 0) return 'outside'; + // A trait (`impl IntoResettable`, `&dyn Source`) names any of its + // implementors, and an alias whatever it stands for: neither is a type whose + // own methods the call can be checked against, so the call resolves as before. + return declared.every((n) => n.kind === 'trait' || n.kind === 'type_alias') ? null : { type: name }; +} + +/** + * The type the enclosing function declares `name` with, as written: a + * parameter's annotation, a `let name: T`, or a `let name = T { … }` / + * `T::new(…)` / `T::default()`. The last declaration before the call wins + * (shadowing). Null when the function cannot be read or declares `name` + * without a type we can read. + */ +function rustLocalDeclaredType(name: string, ref: UnresolvedRef, context: ResolutionContext): string | null { + const caller = context.getNodeById?.(ref.fromNodeId); + if (!caller || (caller.kind !== 'function' && caller.kind !== 'method') || caller.filePath !== ref.filePath) return null; + const lines = context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split(/\r?\n/); + if (!lines) return null; + const strip = (l: string): string => l.replace(/\/\/.*$/, '').replace(/\/\*.*?\*\//g, ''); + const n = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + // The signature: from the function's first line to the one that opens its body. + let open = caller.startLine - 1; + while (open < Math.min(lines.length, ref.line) - 1 && !/\{/.test(strip(lines[open]!))) open++; + const signature = lines.slice(caller.startLine - 1, open + 1).map(strip).join(' ').replace(/\{.*$/, ''); + let found: string | null = null; + const param = new RegExp(`(? 0 ? caller.qualifiedName.slice(0, sep).split('::').pop()! : null; + } + return found; +} + +/** + * The declared type text of `field` in the project's Rust type `owner`, read + * from the struct's own declaration lines (fields are not graph nodes); null + * when no such field is declared there. + */ +function rustDeclaredFieldType(owner: string, field: string, ref: UnresolvedRef, context: ResolutionContext): string | null { + const owners = preferCallSiteFile(context.getNodesByName(owner), ref.filePath).filter( + (n) => (n.kind === 'struct' || n.kind === 'union' || n.kind === 'class') && n.language === 'rust'); + const fieldRe = new RegExp(`(?