From 312b901894b8f6186ee3055b5f48ab94d5f956d1 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Thu, 1 Oct 2026 01:58:11 +0000 Subject: [PATCH] fix(rust): Self::X is the impl's associated item, V::X no struct (#2231) In isRustNameInScope, a type reference written `Self::Name` is in scope only as a type alias declared in the enclosing impl/trait block above it, and one written through a generic type parameter (`V::Value`) names no project item. Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/rust-self-associated-type.test.ts | 65 +++++++++++++++++++++ src/resolution/name-matcher.ts | 32 ++++++++++ 3 files changed, 98 insertions(+) create mode 100644 __tests__/rust-self-associated-type.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ddac078f..ec97e15c4 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, `Self::Error` in a signature now resolves to the enclosing impl's or trait's own associated type. A path through a generic parameter, like `V::Value`, no longer resolves to an unrelated struct of that name. Before, serde's `Self::Error` signatures pointed at its one `struct Error`, and axum's `Self::Error` at `axum-core`'s error type. - In Scala, a package object's member now resolves only where it is in scope: in its own package, the packages under it, or a file that imports through that package. Before, cats' laws resolved `Eq`, `Order` and `Monoid` to the `algebra` package object's aliases rather than the `cats` package object's, and sttp's akka tests reached a zio package object's `whenRequestMatches`. - A type that extends another type of the same name now links to that other type instead of to itself. When the source spells out the qualifier, that qualifier picks between same-named candidates. Before, cats' `trait BigDecimalInstances extends cats.kernel.instances.BigDecimalInstances` and every `trait AllOps … with Functor.AllOps[…]` pointed back at the declaring trait, and Newtonsoft.Json's `JsonConverter : JsonConverter` extended itself. - In pytest tests, a parameter now takes the class its fixture returns, so a call on it reaches that class's method. If the class comes from a library, the call no longer reaches the project's only method of that name. Fixtures a `conftest.py` pulls in with `from … import *` or `pytest_plugins` now count as in reach. Before, flask's `runner.invoke(…)` on click's `CliRunner()` linked to the project's `FlaskCliRunner.invoke`, and `@app.get(…)` on a `Flask(…)` fixture linked to an unrelated `get`. diff --git a/__tests__/rust-self-associated-type.test.ts b/__tests__/rust-self-associated-type.test.ts new file mode 100644 index 000000000..2c34e56a3 --- /dev/null +++ b/__tests__/rust-self-associated-type.test.ts @@ -0,0 +1,65 @@ +/** + * In Rust, `Self::Error` in a signature is the enclosing impl's (or trait's) + * own associated type, and `V::Value` an associated type of a generic's bound + * — never a struct of that name. serde's `Self::Error`s went to + * `de::value::Error`, the one `struct Error` the crate defines. + */ +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-self-assoc-')); + const files: Record = { + 'Cargo.toml': '[package]\nname = "serde"\n', + 'src/de/value.rs': `pub struct Error { + msg: String, +} + +pub struct Value; +`, + 'src/private/de.rs': `use crate::de::value::{Error, Value}; + +pub trait Deserializer { + type Error; +} + +pub struct ContentDeserializer { + err: E, +} + +impl Deserializer for ContentDeserializer { + type Error = E; + + fn deserialize_any(self, visitor: V) -> Result { + todo!() + } +} +`, + }; + 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 }); +}); + +describe('Rust Self:: and generic-parameter paths', () => { + it('name the impl’s associated type, never a struct of that name', () => { + const ids = cg.getNodesInFile('src/private/de.rs').map((n) => n.id); + const targets = cg.getOutgoingEdgesFrom(ids).filter((e) => e.kind === 'references' && e.line === 14) + .map((e) => cg.getNode(e.target)!).map((t) => `${t.kind} ${t.filePath}:${t.startLine}`); + expect(targets).toContain('type_alias src/private/de.rs:12'); + expect(targets.filter((t) => t.includes('src/de/value.rs'))).toEqual([]); + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 5fe968917..b148f2e7f 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -4347,6 +4347,14 @@ export function isRustNameInScope(candidate: Node, ref: UnresolvedRef, context: // and a path from std (`std::io::Error`) is std's. const seg = pathed[2] ?? ''; const root = /^([A-Za-z_]\w*)/.exec(pathed[1] ?? '')?.[1] ?? seg; + // `Self::Error` in a signature is the enclosing impl's (or trait's) own + // associated type, and `V::Value` an associated type of a generic's bound — + // never a struct of that name: serde's 334 `Self::Error`s went to + // `de::value::Error`. + if (ref.referenceKind === 'references' && (pathed[1] ?? '') === '' && line !== undefined) { + if (seg === 'Self') return candidate.kind === 'type_alias' && isInEnclosingRustImpl(candidate, ref, context); + if (isRustGenericParam(seg, ref, context)) return false; + } if ((root === 'std' || root === 'core' || root === 'alloc') && candidate.filePath !== ref.filePath) return false; // A project crate's name re-exports as `crate::` does: `clap::Command` is clap_builder's. if (seg === '' || seg === 'crate' || seg === 'self' || seg === 'super' || seg === 'Self' || candidate.filePath === ref.filePath || @@ -4385,6 +4393,30 @@ export function isRustNameInScope(candidate: Node, ref: UnresolvedRef, context: return uses.names.has(name) || rustGlobCovers(uses, candidate, ref); } +/** The line of the `impl` / `trait` header above `ref` in its file (0 for none). */ +function rustEnclosingImplLine(ref: UnresolvedRef, context: ResolutionContext): number { + const lines = context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split(/\r?\n/) ?? []; + for (let i = ref.line - 1; i >= 0; i--) { + if (/^\s*(?:pub(?:\([^)]*\))?\s+)?(?:unsafe\s+)?(?:impl|trait)\b/.test(lines[i] ?? '')) return i + 1; + } + return 0; +} + +/** Whether `candidate` is declared in the same `impl` / `trait` block as `ref`, above it. */ +function isInEnclosingRustImpl(candidate: Node, ref: UnresolvedRef, context: ResolutionContext): boolean { + const header = rustEnclosingImplLine(ref, context); + return header > 0 && candidate.filePath === ref.filePath && candidate.startLine >= header && candidate.startLine <= ref.line; +} + +/** Whether `name` is a generic type parameter of the function or impl around `ref` (`fn f`, `impl<'de, E>`). */ +function isRustGenericParam(name: string, ref: UnresolvedRef, context: ResolutionContext): boolean { + if (!/^[A-Z]\w*$/.test(name)) return false; + const lines = context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split(/\r?\n/) ?? []; + const from = Math.max(0, rustEnclosingImplLine(ref, context) - 1); + const text = lines.slice(from, ref.line).join('\n'); + return new RegExp(`<[^<>]*(?:<[^<>]*>[^<>]*)*\\b${name}\\b\\s*(?:[:,>=])`).test(text); +} + /** What only exists inside a type, reachable through a receiver alone. */ const TYPE_MEMBER_KINDS: ReadonlySet = new Set(['method', 'property', 'field', 'enum_member']);