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 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-01 01:58:11 +00:00
committed by GitHub
parent 3878123f8a
commit 312b901894
3 changed files with 98 additions and 0 deletions
+1
View File
@@ -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<T> : 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`.
@@ -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<string, string> = {
'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<E> {
err: E,
}
impl<E> Deserializer for ContentDeserializer<E> {
type Error = E;
fn deserialize_any<V: Visitor>(self, visitor: V) -> Result<V::Value, Self::Error> {
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([]);
});
});
+32
View File
@@ -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<V: Visitor>`, `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<string> = new Set(['method', 'property', 'field', 'enum_member']);