mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-29 06:32:28 +00:00
fix(rust): type locals from module-qualified factory calls (#3657)
* fix(scope): follow namespace factory return types and record untyped drops `import mod as rp; x = rp.make(); x.m()` lost the receiver type even when `make` is declared `-> C`: `x` was bound to the raw dotted name `rp.make`, and nothing followed a dotted name to a function's return type. The inline `rp.make().m()` failed the same way in the compound resolver, which read `ns.f()` only as a construction. Resolve `ns.f` to `f`'s return annotation when `ns` is a namespace import, the fact `propagateImportedReturnTypes` already mirrors for a named import. The post-finalize chain follow uses it for bindings and the compound resolver for inline calls. Only sites that emitted nothing change: the full lang-resolution corpus emits the identical 2,838 CALLS/ACCESSES edges. Record the drops that left `impact` at "exact": - a plain receiver bound to a namespace function with no usable return type (`x = ns.untyped()`); - an outer call whose `file:line:col` key a resolved inner site already claimed (`ns.untyped().m()`, `s.repo.Delete(id)`). Only a duplicate capture of the same member hides a drop now. Receiver baseline: call drops 128 -> 151, reads 44 -> 48, ten shape-arm cells INVISIBLE-GAP -> VISIBLE-GAP; every new drop has no CALLS edge to its member (BASELINE.md lists them). The python.factory-qualified accuracy gap is removed; the python-scope fingerprint and capture golden gain the new fixture only. Fixes #3498 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(dart): type locals from prefixed-import factory calls `import 'mod.dart' as rp; final x = rp.make(); x.m()` gave `x` no type: local call inference accepted only a bare callee (`Callee()`), and the prefix-stripping type normalizer would have reduced `rp.make` to `make`. Bind a local initialized by exactly `prefix.member(...)` (direct or awaited) to the dotted callee and keep its prefix, so the shared chain follow types it from the namespace function's return annotation. Only call-derived bindings keep the prefix; annotated `prefix.Type` spellings are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(zig): type locals from namespace-module factory calls `const rp = @import("mod.zig"); const x = rp.make(); x.m()` bound `x` to the namespace handle `rp` itself: the call-return rule reads a non-value head as the type being constructed (`Counter.init()`), and import bindings are not values. The member name was gone before the shared chain follow could use the new namespace return-type lookup. For a module-level handle on a lowercase file (a namespace module, by Zig convention), bind the dotted callee `rp.make` so the shared chain follow types it from `make`'s return annotation. File-struct handles (`@import("Page.zig")`, any spelling of the handle) keep naming the struct, so `Page.init()` is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(bench): rebaseline captures and receiver counts for Dart and Zig - receiver-resolution: the Dart fixture's untyped factory adds one call drop (151 -> 152); every typed form in the Dart and Zig fixtures resolves. - scope-capture (dart): two existing dart-method-enrichment locals initialized by `obj.method()` / `Type.method()` gain a call-derived binding, plus the new fixture's captures. A full lang-resolution A/B emits the identical edges outside the new fixtures. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(rust): type locals from module-qualified factory calls `mod modx; use crate::modx as rp; let x = rp::make(); x.m()` left `x` untyped although `make` is declared `-> C`. The call-return rule kept only the text before the first `::` (the `Foo::new()` idiom), so `x` was bound to the module `rp` and the function name was lost. `modx::make()` and `crate::modx::make()` failed the same way, and the inline `rp::make().m()` failed in the compound resolver, which cannot split a `::` path. With a `let modx = Other;` in scope, `modx::make()` even typed `x` as `Other` and emitted a wrong `Other.m` edge. - Rust keeps the whole callee path when every segment is lower-case: a module function, by rustc's lint-enforced casing. `Foo::new()` and `Self::new()` still read as the type. - New `ScopeResolver.resolveQualifiedCallee` hook. The shared chain follow, the compound call branch and the drop recorder hand a callee text no scope binds to the provider, which returns the callable it names; the shared side reads that callable's return annotation by definition identity and never splits the path. Rust answers with the module-tree walk behind `resolveQualifiedFreeCall`, so a call's type and its CALLS edge come from the same resolution. - A receiver bound to an in-program module function with no return annotation (`x = rp::untyped()`) records a `receiver-unresolved` drop, as #3498 does for `ns.untyped()`. - `rp::make()` did not resolve as a call either: the module-name filter rejected the alias `rp`, and under Cargo the import binds the module's `mod modx;` declaration rather than `modx.rs`. The `use` candidate now admits an alias that names a module and reads a module from its top-level declaration. A full lang-resolution A/B against the parent emits identical CALLS and ACCESSES edges outside the new fixture, except one new correct edge: `rust-ambiguous` `main -> models::Handler.handle`, typed from `let h = services::create_handler()`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(bench): rebaseline rust captures and receiver counts - receiver-resolution: the new rust-qualified-factory-return fixture's two untyped sites (`x = rp::untyped(); x.m()` and inline `modx::untyped().m()`) are each a call and a callee-position read drop: call drops 152 -> 154, reads 48 -> 50, all kinds 220 -> 224. No other drop moves in a same-corpus A/B, and shape-arm cells are unchanged. - scope-capture (rust): corpus growth only. emitRustScopeCaptures is untouched, and parking the new fixture reproduces the prior fingerprint byte-for-byte; it adds 131 capture groups and 3 fixture entries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * perf(scope): reuse qualified call file sets * docs(scope): clarify finalized type refinement * fix(rust): scope generic alias parameters --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
a09f76be39
commit
65b101407e
24 changed files with 903 additions and 64 deletions
|
|
@ -396,6 +396,7 @@ Single interface a language implements to plug into the pipeline. Contract fully
|
|||
| `hasFileLocalCallableLinkage?` | Precise internal-linkage predicate used only when joining callable declarations/prototypes to cross-file definitions; C/C++ use it for `static` free functions |
|
||||
| `constructorCallTargetsClass?` | A constructor-form call `Type(...)` links to the Class def rather than its explicit Constructor def — default off; Swift and Dart opt in |
|
||||
| `constructionSyntax?` | How the language spells construction, so an INLINE constructor receiver (`Service(db).m()`, `new Service(db).m()`, `Service.new.m()`) can be typed — `bare` / `keyword` / `selector`; default off, opt in per language only where measured to be needed (#2708) |
|
||||
| `resolveQualifiedCallee?` | `(calleeText, inScope, …) → callable \| undefined` — resolve a qualified callee only the language can read (`rp::make` in `x = rp::make()` / `rp::make().m()`) so chain-follow types the call from its return annotation; default off |
|
||||
| `resolveQualifiedTypeSpellings?` | A receiver's declared or return type written QUALIFIED (`rp::C x`, `f(a::Config c)`) that binds nothing through the scope chain resolves through `resolveQualifiedInheritanceBase` — qualifier matched against the candidate's namespace, refuse on a tie — instead of by its tail, which binds the nearer same-named class. Runs after every existing lookup and after the template-argument match; default off, C++ opts in |
|
||||
| `splitQualifiedCallee?` | `(callee, callerScope?, scopes?, position?) → { receiver, member } \| undefined` — map a qualified callee spelling (`rp::make`) onto a receiver/member pair that retains the full written namespace qualifier, so a factory result (`auto x = rp::make(); x.m()`, `rp::make().m()`) is typed from the definition `resolveQualifiedReceiverMember` returns and that definition's own declared return type. Caller scope, indexes and source position let a provider reject shadowed or aliased namespace qualifiers only when visible at the factory expression. Shared code never splits on a separator; default off, C++ implements it |
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,30 @@
|
|||
# Receiver-resolution baseline
|
||||
|
||||
## Rust module-qualified factory results (#3498)
|
||||
|
||||
The provider resolves module-qualified factory callees such as `rp::make`,
|
||||
`modx::make` and `crate::modx::make` before reading their own return annotations.
|
||||
Bound and inline receivers share that lookup. Untyped factory results retain
|
||||
an unresolved-receiver drop. Type-qualified construction remains separate.
|
||||
|
||||
The merged corpus contains 5,351 resolution records, compared with main
|
||||
`f6e42092`'s 5,319: 32 records added, none removed, all in Rust. The additions include the
|
||||
new factory fixture and `rust-ambiguous`'s `models::Handler.handle` receiver.
|
||||
Shape states are unchanged. The fixture's two untyped receivers add two call
|
||||
drops and two callee-read drops: calls 135 → 137, reads 27 → 29, all kinds
|
||||
182 → 186; writes remain 20. The final benchmark check verifies this baseline.
|
||||
|
||||
Qualified candidates retain Unicode names and accept uppercase module names.
|
||||
Lexical type claims distinguish type-qualified construction from module calls,
|
||||
including lowercase types and derived `Default`. A resolved type import or local
|
||||
type claim cannot fall back to a same-named module. Const generic values remain
|
||||
in Rust's value namespace. Local type aliases are not expanded by this change.
|
||||
|
||||
The merge retains main's Zig capture behavior. Filename casing cannot establish
|
||||
whether an imported file is a namespace; restoring main preserves the lowercase
|
||||
`config.zig` file-struct edge. Python root-package imports and method-return
|
||||
ownership use main's declaration checks. A cold Rust disk-scope regression and
|
||||
a repeated-member call regression cover the shared producer and drop paths.
|
||||
## Zig receiver gaps from the #3498 drop triage
|
||||
|
||||
Measured against main `2b6542737`, the four Zig fixes clear 18
|
||||
|
|
@ -982,6 +1007,9 @@ Shape states remain unchanged; counts are 133 call drops and
|
|||
160 drops of all kinds (27 reads,
|
||||
0 writes). The counts include both main and this branch's fixes.
|
||||
|
||||
### Rust qualified factories after #3653
|
||||
|
||||
Main `2b4456553` is merged normally. The receiver-drop guard preserves both qualified-factory evidence and the module-export exclusion. Fresh build, both TypeScript configurations and all 28 Rust boundary, capture-golden and CommonJS checks pass. The measured baseline has 135 call drops, 164 total drops, 29 reads and 0 writes.
|
||||
### C++ qualified receivers after #3653
|
||||
|
||||
Main `2b4456553` is merged normally. The field lookup retains both the declared namespace purpose and provider-qualified type handling. Build and both TypeScript configurations pass, and all 59 focused C++ and CommonJS merge checks pass. The measured receiver baseline has 133 call drops, 160 total drops, 27 reads and 0 writes.
|
||||
|
|
@ -989,8 +1017,15 @@ Main `2b4456553` is merged normally. The field lookup retains both the declared
|
|||
|
||||
Main `2b4456553` is merged normally. The measured baseline has 130 call drops and 156 across all kinds, with 26 reads and zero writes. Main has 133 calls, 27 reads and zero writes (160 total). Every shape state matches main. The fresh build and both TypeScript configurations pass, as do all 68 namespace-boundary, capture, hook and CommonJS merge checks.
|
||||
|
||||
### Rust qualified factories after #3655
|
||||
|
||||
Main `caacc3bac` is merged normally. The C# capture baseline retains main's fixture additions, and receiver counts are regenerated on the combined source: 132 call drops, 160 total, 28 reads and zero writes. All shape states match the preceding Rust baseline. Fresh build, both TypeScript configurations and 23 Rust factory, capture and CommonJS checks pass. Final hosted CI remains required.
|
||||
### C++ qualified receivers after #3655
|
||||
|
||||
Main `caacc3bac` is merged normally. The regenerated combined baseline has 130 call drops,156 total,26 reads and zero writes; all shape states match. These counts include the C# namespace fix from main. The broad C++ resolver run was interrupted for the prioritized focused merge checks and is not reported as a pass. Fresh exact-head CI remains required.
|
||||
|
||||
The prioritized merge validation passes a fresh package build, both TypeScript configurations and 56 tests across six C++, CommonJS and C# namespace-boundary files.
|
||||
|
||||
### Rust qualified factories after #3656
|
||||
|
||||
Main `a09f76be3` is merged normally. The shared receiver pass keeps Rust's provider-qualified callee hook and C++'s position-aware qualified-member lookup. Fresh package build, both TypeScript configurations and 302 tests across 20 Rust, C++, Go, CommonJS and sealed-disk-scope files pass. All 16 capture scaling checks pass without changing their budgets. The regenerated receiver check passes with 132 call drops, 160 across all kinds, 28 reads and zero writes; shape states are unchanged. The corpus adds 32 Rust resolution records to main's 5,392, removes none, and totals 5,424. Exact-head hosted CI remains required.
|
||||
|
|
|
|||
|
|
@ -199,11 +199,11 @@
|
|||
}
|
||||
},
|
||||
"countArm": {
|
||||
"callDrops": 130,
|
||||
"totalDropsAllKinds": 156,
|
||||
"callDrops": 132,
|
||||
"totalDropsAllKinds": 160,
|
||||
"bySiteKind": {
|
||||
"call": 130,
|
||||
"read": 26
|
||||
"call": 132,
|
||||
"read": 28
|
||||
},
|
||||
"callDropsByExtension": {
|
||||
".java": 49,
|
||||
|
|
@ -211,10 +211,10 @@
|
|||
".ts": 16,
|
||||
".zig": 10,
|
||||
".tsx": 6,
|
||||
".rs": 6,
|
||||
".cs": 5,
|
||||
".go": 4,
|
||||
".php": 4,
|
||||
".rs": 4,
|
||||
".kt": 4,
|
||||
".dart": 2,
|
||||
".js": 1,
|
||||
|
|
@ -222,13 +222,13 @@
|
|||
},
|
||||
"callDropsByShape": {
|
||||
"chain-field": 54,
|
||||
"no-chain": 31,
|
||||
"no-chain": 33,
|
||||
"chain-call": 31,
|
||||
"<<unclassified>>": 13,
|
||||
"chain-unwrap": 1
|
||||
},
|
||||
"callDropsByOrigin": {
|
||||
"in-program": 53,
|
||||
"in-program": 55,
|
||||
"external": 44,
|
||||
"unknown": 31,
|
||||
"<<unclassified>>": 2
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@
|
|||
},
|
||||
"rust": {
|
||||
"_lexical_ownership_3532": "#3532: lexical ownership captures and fixture updates; fixture identities are relative to lang-resolution so serialized scope IDs are independent of checkout location. Full capture content remains hashed; scaling_budget is unchanged.",
|
||||
"fingerprint": "7d314a184cba1e5c47260d0a5018107146437f56a2bd183ae0670f0d77a3fbd2",
|
||||
"fingerprint": "19045ed3d4ee9fcea36e949284b385f8d0a24e471cb05f77d8b35d8a1a8dd7fa",
|
||||
"scaling_budget": 1.5,
|
||||
"_rebaselined_generic_instantiation_2912": "#2912: RUST_SCOPE_QUERY tags trait-impl heritage with the instantiation the impl was written with (`impl Validator<String> for V`), so interface dispatch can prune implementors of an instantiation the receiver cannot hold. Additive capture text on existing impl matches — the same matches are minted, carrying one more field — so this is digest drift, not a capture-set change: capture_groups_fp (3556) and fixture_count (202) are both unchanged, which is the check that no match appeared or vanished. Prior 116a971fee0004f340477aff69fa110a1d92bd8ba882d7c926483c6b1e8ca2b9 -> e61653008ff2de506cfd47f905fa9eb22d82fbbfe94d2a1d8190c358211b57b7; scaling 1.018 < 1.5. Only rust and dart move; the other 13 languages are byte-identical.",
|
||||
"_rebaselined_mod_node_identity_2745_review": "#2745 review: added rust-2742-mod-members, rust-2742-nested-mods and rust-2742-type-vs-module under lang-resolution for the container/owner-edge fix, nested inline modules, and the imported-type-vs-module precedence. emitRustScopeCaptures is unchanged — verified by removing ONLY those three fixture dirs and re-running, which reproduces the prior fingerprint exactly, so the shift is purely corpus growth (fixture_count 196 -> 202, capture_groups_fp 3432 -> 3556). Prior 90fda086a4e13aa069a5981f63ed58ab1c71f1ed3da5e1480a080e1992b0d3e5 -> 05acbaca48427e0d9e0793bcd0ce4057712d3716b5e7868189c12e05ef8dd300; scaling 1.022 local / 1.057 CI < 1.5. NOTE for the next fixture author: a new rust-* fixture drifts BOTH this bench baseline and the rust-captures-golden snapshot. Updating only the golden is how this reached CI red.",
|
||||
|
|
@ -110,9 +110,11 @@
|
|||
"_rebaselined_2766_receiver_chain_wire_v2": "#2766: receiver-chain wire format v1 -> v2 (name-free `await` / `index` step kinds). The VERSION prefix is part of every emitted `@reference.receiver-chain` capture, so every chain-minting language's capture text changed. WIRE-FORMAT CHANGE, NOT A CAPTURE-SET CHANGE: the same chains are minted for the same sites, spelled `2|…` instead of `1|…`. Exactly the 12 chain-minting languages drifted; c, cobol and dart did not, which is the check that this is the prefix and not a capture regression. Accompanied by SCHEMA_BUMP 34 -> 37 and INCREMENTAL_SCHEMA_VERSION 28 -> 31 so a stale index is rejected rather than replaying chains a v2 decoder refuses. Prior 83812d82f0e2c3eb552f3246381ca3dd5ccd6783d63aba3325f1343e7772280c -> 6174889b8c98e0af430fa54c268dc781989ca9a8172d690eebae37a95f77e809.",
|
||||
"capture_groups_small": 5507,
|
||||
"capture_groups_large": 17607,
|
||||
"capture_groups_fp": 3556,
|
||||
"fixture_count": 202,
|
||||
"_rebaselined_3486_semantic_rename": "#3486: Compared actual origin/main (191a954b), committed dd3bf and frozen provider source over the same unchanged corpus. 28 existing field-read matches gain receiver-chain tags; removing only these added tags restores the complete main capture set. No group is added or removed. Fixture entries 204; changed entries 6; capture groups 3568 -> 3568. Fingerprint regenerated from the runner's canonicalized capture records. Scaling budgets unchanged."
|
||||
"capture_groups_fp": 3699,
|
||||
"fixture_count": 207,
|
||||
"_rebaselined_3486_semantic_rename": "#3486: Compared actual origin/main (191a954b), committed dd3bf and frozen provider source over the same unchanged corpus. 28 existing field-read matches gain receiver-chain tags; removing only these added tags restores the complete main capture set. No group is added or removed. Fixture entries 204; changed entries 6; capture groups 3568 -> 3568. Fingerprint regenerated from the runner's canonicalized capture records. Scaling budgets unchanged.",
|
||||
"_rebaselined_3498_qualified_factory": "Rust module-qualified factory fixture adds four files. Provider-qualified callee and return bindings retain the complete module path. All other language fingerprints equal merged main. Measured on the combined main merge; native boundary tests cover untyped and disk-sealed calls.",
|
||||
"_rebaselined_3498_qualified_factory_review": "Review follow-up: fixture comment distinguishes module factories from type-qualified/untyped controls. Current measured Rust corpus: 207 files, 3699 capture groups, synthetic 5507/17607; fingerprint 19045ed3d4ee9fcea36e949284b385f8d0a24e471cb05f77d8b35d8a1a8dd7fa. Constructor inference retains Unicode/uppercase qualified candidates until lexical type ownership is available. Other 15 fingerprints unchanged; all 16 scaling ratios below unchanged 1.5 budgets (Rust 1.092)."
|
||||
},
|
||||
"php": {
|
||||
"fingerprint": "310d2c1cddf3b36264f988a78486f5c287a1094f8539dd165e8b58a5258febd5",
|
||||
|
|
|
|||
|
|
@ -158,8 +158,16 @@ function findTopLevelComma(text: string): number {
|
|||
return -1;
|
||||
}
|
||||
|
||||
// Preserve possible module-function paths until finalized type bindings can
|
||||
// distinguish module factories from associated functions. Casing is a lint.
|
||||
const RUST_MODULE_FN_PATH_RE =
|
||||
/^(?:(?:r#)?[_\p{XID_Start}][\p{XID_Continue}]*::)+(?:r#)?[_\p{XID_Start}][\p{XID_Continue}]*$/u;
|
||||
|
||||
function normalizeRustCallReturnType(text: string): string {
|
||||
let t = text.trim();
|
||||
let t = text.trim().replace(/\s*::\s*/g, '::');
|
||||
// Kept whole so `resolveQualifiedCallee` can read the function's return
|
||||
// annotation; the head alone would name the module, not a type (#3498).
|
||||
if (!t.startsWith('Self::') && RUST_MODULE_FN_PATH_RE.test(t)) return t;
|
||||
// For scoped calls like `Foo::new()`, extract the type part before `::`
|
||||
const scopeIdx = t.indexOf('::');
|
||||
if (scopeIdx !== -1) {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ const SCOPE_TYPES = new Set([
|
|||
'impl_item',
|
||||
'enum_item',
|
||||
'union_item',
|
||||
'type_item',
|
||||
]);
|
||||
|
||||
/** Pattern identifiers own values even when no useful target/type was indexed. */
|
||||
|
|
@ -89,6 +90,10 @@ export function collectRustNameClaims(root: SyntaxNode): ReadonlyMap<number, rea
|
|||
availableFrom: { startLine: node.endPosition.row + 1, startCol: node.endPosition.column },
|
||||
inactive: 'outer',
|
||||
});
|
||||
} else if (node.type === 'type_parameter') {
|
||||
const name = node.childForFieldName('name');
|
||||
if (name !== null)
|
||||
add(ownerOf(node.parent), [name.text], node, { purpose: 'type', hoisted: true });
|
||||
} else if (node.type === 'parameter') {
|
||||
const pattern = node.childForFieldName('pattern');
|
||||
if (pattern !== null) add(ownerOf(node.parent), patternNames(pattern), node);
|
||||
|
|
|
|||
|
|
@ -35,7 +35,11 @@
|
|||
import type { ParsedFile, Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared';
|
||||
import { perFileSet } from '../../utils/per-file-set.js';
|
||||
import { isOverloadableCallable } from '../../utils/callable-labels.js';
|
||||
import { lookupBindingsAt } from '../../scope-resolution/scope/walkers.js';
|
||||
import {
|
||||
isClassLike,
|
||||
lookupBindingsAt,
|
||||
lookupNameClaim,
|
||||
} from '../../scope-resolution/scope/walkers.js';
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import type { WorkspaceResolutionIndex } from '../../scope-resolution/workspace-index.js';
|
||||
import {
|
||||
|
|
@ -58,9 +62,44 @@ const moduleIndexFor = perFileSet(
|
|||
(allFilePaths: ReadonlySet<string>): RustModuleIndex => buildRustModuleIndex(allFilePaths),
|
||||
);
|
||||
|
||||
/** What path resolution reads off the calling file. */
|
||||
type RustCaller = Pick<ParsedFile, 'filePath' | 'moduleScope'>;
|
||||
|
||||
/** The callee name ending a `::` path (`make` in `crate::modx::make`). */
|
||||
const RUST_PATH_TAIL_RE = /::\s*((?:r#)?[_\p{XID_Start}][\p{XID_Continue}]*)\s*$/u;
|
||||
|
||||
/**
|
||||
* `ScopeResolver.resolveQualifiedCallee`: the function a callee path names
|
||||
* (`rp::make` in `let x = rp::make()` or `rp::make().m()`), resolved over the
|
||||
* module tree exactly like the free call itself, so the call's type and its
|
||||
* CALLS edge cannot disagree (#3498). A type-qualified path (`Foo::new`)
|
||||
* names no module and is refused.
|
||||
*/
|
||||
export function resolveRustQualifiedCallee(
|
||||
calleeText: string,
|
||||
inScope: ScopeId,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
workspaceIndex: WorkspaceResolutionIndex,
|
||||
allFilePaths: ReadonlySet<string>,
|
||||
): SymbolDefinition | undefined {
|
||||
const name = RUST_PATH_TAIL_RE.exec(calleeText)?.[1];
|
||||
if (name === undefined) return undefined;
|
||||
const filePath = scopes.scopeTree.getScope(inScope)?.filePath;
|
||||
if (filePath === undefined) return undefined;
|
||||
const moduleScope = workspaceIndex.moduleScopeByFile.get(filePath);
|
||||
if (moduleScope === undefined) return undefined;
|
||||
return resolveRustQualifiedFreeCall(
|
||||
{ name, rawQualifiedName: calleeText, inScope },
|
||||
{ filePath, moduleScope: moduleScope.id },
|
||||
scopes,
|
||||
workspaceIndex,
|
||||
allFilePaths,
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveRustQualifiedFreeCall(
|
||||
site: { readonly name: string; readonly rawQualifiedName?: string; readonly inScope: ScopeId },
|
||||
callerParsed: ParsedFile,
|
||||
callerParsed: RustCaller,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
workspaceIndex: WorkspaceResolutionIndex,
|
||||
allFilePaths: ReadonlySet<string>,
|
||||
|
|
@ -99,7 +138,13 @@ export function resolveRustQualifiedFreeCall(
|
|||
// which is the part that scales with the workspace.
|
||||
const index = moduleIndexFor(allFilePaths);
|
||||
const pass = passIndexFor(workspaceIndex, index, scopes);
|
||||
if (!couldNameAModule(qualifier, pass.knownModuleNames)) {
|
||||
// A `use crate::modx as rp;` alias is a module name only in this file, so the
|
||||
// workspace-wide filter cannot know it; candidate 2 resolves it.
|
||||
if (
|
||||
!couldNameAModule(qualifier, pass.knownModuleNames) &&
|
||||
importedModulesNamed(qualifier[0], scopes, workspaceIndex, index, site.inScope).next().done ===
|
||||
true
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
|
@ -117,6 +162,7 @@ export function resolveRustQualifiedFreeCall(
|
|||
scopes,
|
||||
workspaceIndex,
|
||||
index,
|
||||
site.inScope,
|
||||
)) {
|
||||
const hit =
|
||||
findMemberInModule(targetModule, site.name, scopes, workspaceIndex, index) ??
|
||||
|
|
@ -132,7 +178,7 @@ export function resolveRustQualifiedFreeCall(
|
|||
* makes `super::` correct from inside an inline module.
|
||||
*/
|
||||
function callerModuleOf(
|
||||
callerParsed: ParsedFile,
|
||||
callerParsed: RustCaller,
|
||||
inScope: ScopeId,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
index: RustModuleIndex,
|
||||
|
|
@ -166,10 +212,11 @@ function* candidateModules(
|
|||
anchored: { readonly module: RustModule; readonly anchored: boolean },
|
||||
qualifier: readonly string[],
|
||||
callerModule: RustModule,
|
||||
callerParsed: ParsedFile,
|
||||
callerParsed: RustCaller,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
workspaceIndex: WorkspaceResolutionIndex,
|
||||
index: RustModuleIndex,
|
||||
inScope: ScopeId,
|
||||
): Generator<RustModule> {
|
||||
if (anchored.anchored) {
|
||||
yield anchored.module;
|
||||
|
|
@ -188,34 +235,59 @@ function* candidateModules(
|
|||
// A `mod` declaration — inline or file-backed — emits a `Namespace` def
|
||||
// bound in the declaring scope, so the check is a binding lookup.
|
||||
const head = qualifier[0];
|
||||
if (head === undefined) return;
|
||||
const claim = lookupNameClaim(inScope, head, scopes, { purpose: 'type' });
|
||||
if (claim.status !== 'absent' && claim.bindings.length === 0 && claim.imports.length === 0)
|
||||
return;
|
||||
if (
|
||||
head !== undefined &&
|
||||
declaresSubmodule(callerParsed, callerModule, head, workspaceIndex, index, scopes)
|
||||
) {
|
||||
yield { crateRoot: callerModule.crateRoot, segments: [...callerModule.segments, ...qualifier] };
|
||||
claim.bindings.some(
|
||||
(binding) =>
|
||||
isClassLike(binding.def.type) ||
|
||||
binding.def.type === 'Typedef' ||
|
||||
binding.def.type === 'TypeAlias',
|
||||
)
|
||||
)
|
||||
return;
|
||||
if (claim.imports.length > 0) {
|
||||
for (const importedModule of importedModulesNamed(
|
||||
head,
|
||||
scopes,
|
||||
workspaceIndex,
|
||||
index,
|
||||
inScope,
|
||||
)) {
|
||||
yield {
|
||||
crateRoot: importedModule.crateRoot,
|
||||
segments: [...importedModule.segments, ...qualifier.slice(1)],
|
||||
};
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. A `use` binding for the first segment. Finalize already resolved the
|
||||
// import to a file, so the module path comes back through the same
|
||||
// file → module mapping as everything else. Covers `use crate::tools;`,
|
||||
// `use crate::tools::{self}` and `use crate::a::b as tools`.
|
||||
//
|
||||
// The binding must name a MODULE, not a symbol inside one. Import
|
||||
// resolution deliberately strips a trailing symbol segment when probing for
|
||||
// a file, so `use crate::client::ClientBuilder;` also
|
||||
// lands on `client/mod.rs`. Taking that at face value made the imported
|
||||
// TYPE look like the module `client`, and `ClientBuilder::new()` then
|
||||
// resolved against `client`'s module members — binding an associated
|
||||
// function to an unrelated module-level `new` (#2741 review H2).
|
||||
for (const edge of scopes.imports.get(callerParsed.moduleScope) ?? []) {
|
||||
if (edge.localName !== head || edge.targetFile === null) continue;
|
||||
const importedModule = moduleOfFile(edge.targetFile, index);
|
||||
if (importedModule === undefined) continue;
|
||||
if (!importNamesModule(edge.targetExportedName, importedModule)) continue;
|
||||
yield {
|
||||
crateRoot: importedModule.crateRoot,
|
||||
segments: [...importedModule.segments, ...qualifier.slice(1)],
|
||||
};
|
||||
const namespaces = claim.bindings.filter((binding) => binding.def.type === 'Namespace');
|
||||
if (namespaces.length > 0) {
|
||||
const modules = new Map<string, RustModule>();
|
||||
for (const { def } of namespaces) {
|
||||
const target = moduleOfImport(
|
||||
{ targetFile: def.filePath, targetExportedName: head, targetDefId: def.nodeId },
|
||||
scopes,
|
||||
workspaceIndex,
|
||||
index,
|
||||
);
|
||||
if (target !== undefined)
|
||||
modules.set(`${target.crateRoot}\0${target.segments.join('::')}`, target);
|
||||
}
|
||||
if (modules.size === 1) {
|
||||
const selected = modules.values().next().value!;
|
||||
yield {
|
||||
crateRoot: selected.crateRoot,
|
||||
segments: [...selected.segments, ...qualifier.slice(1)],
|
||||
};
|
||||
}
|
||||
if (modules.size > 0) return;
|
||||
}
|
||||
if (declaresSubmodule(callerParsed, callerModule, head, workspaceIndex, index, scopes)) {
|
||||
yield { crateRoot: callerModule.crateRoot, segments: [...callerModule.segments, ...qualifier] };
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Crate-root-relative (`a::b::f()` written from a nested module — 2015
|
||||
|
|
@ -238,11 +310,69 @@ function* candidateModules(
|
|||
// callee the source never names. The base emitted no edge at all, and per the
|
||||
// doctrine quoted in `ids.ts` that is the correct failure direction: a missing
|
||||
// edge is recoverable, a fabricated caller silently misleads `impact`.
|
||||
if (callerModule.segments.length > 0 && !headBoundLocally(callerParsed, head, scopes)) {
|
||||
if (
|
||||
claim.status === 'absent' &&
|
||||
callerModule.segments.length > 0 &&
|
||||
!headBoundLocally(callerParsed, head, scopes)
|
||||
) {
|
||||
yield { crateRoot: callerModule.crateRoot, segments: [...qualifier] };
|
||||
}
|
||||
}
|
||||
|
||||
/** Modules selected by the nearest lexical type-namespace import. */
|
||||
function* importedModulesNamed(
|
||||
name: string | undefined,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
workspaceIndex: WorkspaceResolutionIndex,
|
||||
index: RustModuleIndex,
|
||||
inScope: ScopeId,
|
||||
): Generator<RustModule> {
|
||||
if (name === undefined) return;
|
||||
for (const edge of lookupNameClaim(inScope, name, scopes, { purpose: 'type' }).imports) {
|
||||
if (edge.localName !== name) continue;
|
||||
const importedModule = moduleOfImport(edge, scopes, workspaceIndex, index);
|
||||
if (importedModule !== undefined) yield importedModule;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The module a `use` binding names, or `undefined` when it names a symbol.
|
||||
*
|
||||
* Path-based import resolution lands on the module's own file
|
||||
* (`use crate::tools;` → `tools.rs`). Cargo-aware resolution instead binds the
|
||||
* module's DECLARATION — the `Namespace` def `mod tools;` leaves in its parent's
|
||||
* file — so the module is the parent's path plus the def's name. Only a
|
||||
* declaration bound at its file's top level (or carrying its enclosing path in
|
||||
* `namespacePrefix`) is read this way; a nested inline `mod` def records no
|
||||
* enclosing path, and guessing one could name the wrong module.
|
||||
*/
|
||||
function moduleOfImport(
|
||||
edge: {
|
||||
readonly targetFile: string | null;
|
||||
readonly targetExportedName: string;
|
||||
readonly targetDefId?: string;
|
||||
},
|
||||
scopes: ScopeResolutionIndexes,
|
||||
workspaceIndex: WorkspaceResolutionIndex,
|
||||
index: RustModuleIndex,
|
||||
): RustModule | undefined {
|
||||
const declaration =
|
||||
edge.targetDefId === undefined ? undefined : scopes.defs.get(edge.targetDefId);
|
||||
if (declaration !== undefined && declaration.type !== 'Namespace') return undefined;
|
||||
if (declaration?.type === 'Namespace' && declaration.qualifiedName !== undefined) {
|
||||
const name = tailSegment(declaration.qualifiedName);
|
||||
if (!isModuleLevelMember(declaration, name, workspaceIndex)) return undefined;
|
||||
const parent = moduleOfDef(declaration.filePath, declaration.namespacePrefix, index);
|
||||
return parent === undefined
|
||||
? undefined
|
||||
: { crateRoot: parent.crateRoot, segments: [...parent.segments, name] };
|
||||
}
|
||||
if (edge.targetFile === null) return undefined;
|
||||
const importedModule = moduleOfFile(edge.targetFile, index);
|
||||
if (importedModule === undefined) return undefined;
|
||||
return importNamesModule(edge.targetExportedName, importedModule) ? importedModule : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is `name` bound in the caller's own module to anything that is NOT a module?
|
||||
*
|
||||
|
|
@ -252,7 +382,7 @@ function* candidateModules(
|
|||
* case, which candidate 2 already handles, is not double-counted here.
|
||||
*/
|
||||
function headBoundLocally(
|
||||
callerParsed: ParsedFile,
|
||||
callerParsed: RustCaller,
|
||||
name: string | undefined,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
): boolean {
|
||||
|
|
@ -289,7 +419,7 @@ function tailSegment(qualifiedName: string): string {
|
|||
* contributes nothing and cannot outrank a real `use` binding (#2741 review).
|
||||
*/
|
||||
function declaresSubmodule(
|
||||
callerParsed: ParsedFile,
|
||||
callerParsed: RustCaller,
|
||||
callerModule: RustModule,
|
||||
name: string,
|
||||
workspaceIndex: WorkspaceResolutionIndex,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ const RUST_SCOPE_QUERY = `
|
|||
(function_signature_item) @scope.function
|
||||
(closure_expression) @scope.function
|
||||
(block) @scope.block
|
||||
(type_item) @scope.block
|
||||
(if_expression) @scope.block
|
||||
(match_expression) @scope.block
|
||||
(match_arm) @scope.block
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { ParsedFile } from 'gitnexus-shared';
|
||||
import type { ParsedFile, TypeRef } from 'gitnexus-shared';
|
||||
import { SupportedLanguages } from 'gitnexus-shared';
|
||||
import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js';
|
||||
import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js';
|
||||
|
|
@ -6,7 +6,7 @@ import { indexOnlyElementType } from '../../type-extractors/shared.js';
|
|||
import { rustProvider } from '../rust.js';
|
||||
import { rustArityCompatibility, rustMergeBindings, resolveRustImportTarget } from './index.js';
|
||||
import { populateRustOwners } from './method-owners.js';
|
||||
import { resolveRustQualifiedFreeCall } from './qualified-call.js';
|
||||
import { resolveRustQualifiedCallee, resolveRustQualifiedFreeCall } from './qualified-call.js';
|
||||
import { populateRustRangeBindings } from './range-binding.js';
|
||||
import {
|
||||
isClassLike,
|
||||
|
|
@ -170,6 +170,33 @@ export const rustScopeResolver: ScopeResolver = {
|
|||
resolveQualifiedFreeCall: (site, callerParsed, scopes, workspaceIndex, allFilePaths) =>
|
||||
resolveRustQualifiedFreeCall(site, callerParsed, scopes, workspaceIndex, allFilePaths),
|
||||
|
||||
resolveQualifiedCallee: resolveRustQualifiedCallee,
|
||||
|
||||
// A qualified initializer may name either a module factory or an associated
|
||||
// function. Restore the constructor's type only after lexical type ownership
|
||||
// is known, before imported-return propagation follows the remaining paths.
|
||||
populateNamespaceSiblings: (parsedFiles, scopes) => {
|
||||
for (const parsed of parsedFiles) {
|
||||
for (const scope of parsed.scopes) {
|
||||
for (const [name, ref] of scope.typeBindings) {
|
||||
if (ref.source !== 'constructor-inferred') continue;
|
||||
const separator = ref.rawName.lastIndexOf('::');
|
||||
if (separator < 1) continue;
|
||||
const owner = ref.rawName.slice(0, separator);
|
||||
if (
|
||||
findClassBindingInScope(ref.declaredAtScope, owner, scopes, undefined, {
|
||||
uniqueQualifiedNameFallback: false,
|
||||
purpose: 'type',
|
||||
position: ref.lookupPosition ?? ref.bindingRange,
|
||||
}) !== undefined
|
||||
) {
|
||||
(scope.typeBindings as Map<string, TypeRef>).set(name, { ...ref, rawName: owner });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
buildMro: (graph, parsedFiles, nodeLookup) => buildRustMro(graph, parsedFiles, nodeLookup),
|
||||
|
||||
emitHeritageEdges: (graph, parsedFiles, nodeLookup, scopes, recordTypeArguments) =>
|
||||
|
|
|
|||
|
|
@ -1469,6 +1469,39 @@ export interface ScopeResolver {
|
|||
allFilePaths: ReadonlySet<string>,
|
||||
) => SymbolDefinition | undefined;
|
||||
|
||||
/**
|
||||
* Optional resolver for a qualified CALLEE TEXT whose path syntax only the
|
||||
* language can read — Rust `rp::make` in `let x = rp::make(); x.m()` and in
|
||||
* the inline receiver `rp::make().m()`, where `rp` names a module rather than
|
||||
* a value or a type (#3498).
|
||||
*
|
||||
* The shared walkers never split this text. They hand it over verbatim, with
|
||||
* the scope the call sits in, and only after their own lookups found nothing:
|
||||
* - `followChainPostFinalize`, for a typeBinding rawName no scope binds (so
|
||||
* the provider's call-return binding must keep the whole callee path);
|
||||
* - the compound-receiver call branch, for an undotted callee no scope binds;
|
||||
* - the receiver-bound drop recorder, so a receiver bound to an in-program
|
||||
* callable with no return annotation records a `receiver-unresolved` drop
|
||||
* instead of passing as an exact miss.
|
||||
*
|
||||
* Return the callable the path names, or `undefined` when the text is not a
|
||||
* qualified path in this language, names nothing in the workspace, or is
|
||||
* ambiguous. The shared side reads that callable's `return-annotation` type
|
||||
* fact by definition identity, so a callable without one types nothing.
|
||||
* Refusal must be cheap: the hook is asked about every name these sites fail
|
||||
* to resolve.
|
||||
*
|
||||
* Languages whose qualified callees are dotted member expressions covered by
|
||||
* namespace imports (`ns.make()`) leave this undefined.
|
||||
*/
|
||||
readonly resolveQualifiedCallee?: (
|
||||
calleeText: string,
|
||||
inScope: ScopeId,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
workspaceIndex: WorkspaceResolutionIndex,
|
||||
allFilePaths: ReadonlySet<string>,
|
||||
) => SymbolDefinition | undefined;
|
||||
|
||||
/**
|
||||
* Optional resolver for qualified-receiver member calls where the
|
||||
* receiver is a namespace (not a class) and ordinary scope-chain /
|
||||
|
|
@ -1706,8 +1739,8 @@ export interface ScopeResolver {
|
|||
) => ArityVerdict;
|
||||
|
||||
/**
|
||||
* Optional post-finalize hook to inject cross-file bindings that
|
||||
* aren't modeled via explicit imports. Runs after
|
||||
* Optional post-finalize hook to complete provider-owned namespace and type
|
||||
* facts that need finalized lexical ownership. Runs after
|
||||
* `buildWorkspaceResolutionIndex` and before
|
||||
* `propagateImportedReturnTypes`.
|
||||
*
|
||||
|
|
@ -1715,8 +1748,10 @@ export interface ScopeResolver {
|
|||
* makes names visible across files without a syntactic import —
|
||||
* for example a shared-namespace convention where types declared
|
||||
* in the same namespace see each other without a `using` / `import`
|
||||
* statement. Languages that require explicit imports for cross-file
|
||||
* visibility leave this undefined.
|
||||
* statement. The hook may also refine existing scope type bindings before
|
||||
* return propagation, such as distinguishing a qualified constructor from a
|
||||
* namespace factory. Add visible bindings through bindingAugmentations;
|
||||
* finalized indexes.bindings remains immutable.
|
||||
*/
|
||||
readonly populateNamespaceSiblings?: (
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ import type {
|
|||
} from 'gitnexus-shared';
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import type { WorkspaceResolutionIndex } from '../workspace-index.js';
|
||||
import type { ScopeResolver } from '../contract/scope-resolver.js';
|
||||
import { definitionIdPosition } from '../utils/definition-id.js';
|
||||
import { followedStep } from '../../utils/type-ref-chain.js';
|
||||
import {
|
||||
|
|
@ -241,6 +242,48 @@ export function namespaceCallReturnType(
|
|||
: undefined;
|
||||
}
|
||||
|
||||
/** `ScopeResolver.resolveQualifiedCallee` bound to one resolution pass. */
|
||||
export type QualifiedCalleeResolver = (
|
||||
calleeText: string,
|
||||
inScope: ScopeId,
|
||||
) => SymbolDefinition | undefined;
|
||||
|
||||
/** Bind the provider's qualified-callee hook to this pass's indexes, or
|
||||
* `undefined` when the language has none. Reuse the pipeline's file-path set
|
||||
* when supplied; standalone callers build one lazily. */
|
||||
export function bindQualifiedCalleeResolver(
|
||||
hook: ScopeResolver['resolveQualifiedCallee'],
|
||||
scopes: ScopeResolutionIndexes,
|
||||
workspaceIndex: WorkspaceResolutionIndex,
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
filePaths?: ReadonlySet<string>,
|
||||
): QualifiedCalleeResolver | undefined {
|
||||
if (hook === undefined) return undefined;
|
||||
let allFilePaths = filePaths;
|
||||
return (calleeText, inScope) => {
|
||||
allFilePaths ??= new Set(parsedFiles.map((parsed) => parsed.filePath));
|
||||
return hook(calleeText, inScope, scopes, workspaceIndex, allFilePaths);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A callee only the provider can read (`rp::make`, #3498): the return
|
||||
* annotation of the callable it resolves to, read by definition identity like
|
||||
* the producer lookup in {@link followChainPostFinalize}. `undefined` when the
|
||||
* provider resolves nothing or the callable declares no return type.
|
||||
*/
|
||||
export function qualifiedCalleeReturnType(
|
||||
calleeText: string,
|
||||
inScope: ScopeId,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
resolveCallee: QualifiedCalleeResolver | undefined,
|
||||
): TypeRef | undefined {
|
||||
const callee = resolveCallee?.(calleeText, inScope);
|
||||
if (callee === undefined) return undefined;
|
||||
const returned = producerTypeRef(callee.nodeId, scopes);
|
||||
return returned?.source === 'return-annotation' ? returned : undefined;
|
||||
}
|
||||
|
||||
/** Walk `ref.rawName` through the scope chain's typeBindings looking
|
||||
* for a terminal class-like rawName. Mirrors the in-extractor
|
||||
* `followChainedRef` but operates on post-finalize Scope objects so
|
||||
|
|
@ -250,6 +293,7 @@ export function followChainPostFinalize(
|
|||
start: TypeRef,
|
||||
fromScopeId: ScopeId,
|
||||
scopes: ScopeResolutionIndexes,
|
||||
resolveCallee?: QualifiedCalleeResolver,
|
||||
): TypeRef {
|
||||
let current = start;
|
||||
const visited = new Set<string>();
|
||||
|
|
@ -318,6 +362,10 @@ export function followChainPostFinalize(
|
|||
const ws = scopes.workspaceTypeBindings?.get(current.rawName);
|
||||
if (ws !== undefined && ws !== current) next = ws;
|
||||
}
|
||||
// Last, a callee path no scope binds (`rp::make`): the provider names the
|
||||
// callable, and its return annotation continues the chain (#3498).
|
||||
if (next === undefined && claim.status === 'absent')
|
||||
next = qualifiedCalleeReturnType(current.rawName, lookupScope, scopes, resolveCallee);
|
||||
if (next === undefined) return current;
|
||||
if (visited.has(next.rawName)) return current;
|
||||
visited.add(next.rawName);
|
||||
|
|
@ -346,8 +394,17 @@ export function propagateImportedReturnTypes(
|
|||
parsedFiles: readonly ParsedFile[],
|
||||
indexes: ScopeResolutionIndexes,
|
||||
index: WorkspaceResolutionIndex,
|
||||
resolveQualifiedCallee?: ScopeResolver['resolveQualifiedCallee'],
|
||||
allFilePaths?: ReadonlySet<string>,
|
||||
): void {
|
||||
const moduleScopeByFile = index.moduleScopeByFile;
|
||||
const resolveCallee = bindQualifiedCalleeResolver(
|
||||
resolveQualifiedCallee,
|
||||
indexes,
|
||||
index,
|
||||
parsedFiles,
|
||||
allFilePaths,
|
||||
);
|
||||
const scopesByFile = new Map(parsedFiles.map((file) => [file.filePath, file.scopes]));
|
||||
|
||||
// Walk SCCs in reverse-topological order (`indexes.sccs` is leaves-
|
||||
|
|
@ -401,7 +458,12 @@ export function propagateImportedReturnTypes(
|
|||
|
||||
// Chain-follow inside the source module so we mirror the
|
||||
// terminal type, not an intermediate intra-source reference.
|
||||
const terminal = followChainPostFinalize(sourceTypeRef, sourceModule.id, indexes);
|
||||
const terminal = followChainPostFinalize(
|
||||
sourceTypeRef,
|
||||
sourceModule.id,
|
||||
indexes,
|
||||
resolveCallee,
|
||||
);
|
||||
|
||||
// Mutating typeBindings is safe because draftToScope
|
||||
// produced a non-frozen Map (Contract Invariant I3/I8).
|
||||
|
|
@ -423,7 +485,7 @@ export function propagateImportedReturnTypes(
|
|||
// later (closer-to-root) SCC will see x's terminal type rather
|
||||
// than an intra-module call ref.
|
||||
for (const [name, ref] of importerModule.typeBindings) {
|
||||
const resolved = followChainPostFinalize(ref, importerModule.id, indexes);
|
||||
const resolved = followChainPostFinalize(ref, importerModule.id, indexes, resolveCallee);
|
||||
if (resolved !== ref) {
|
||||
(importerModule.typeBindings as Map<string, TypeRef>).set(name, resolved);
|
||||
}
|
||||
|
|
@ -439,7 +501,7 @@ export function propagateImportedReturnTypes(
|
|||
for (const scope of parsed.scopes) {
|
||||
if (scope.id === moduleScopeId) continue;
|
||||
for (const [name, ref] of scope.typeBindings) {
|
||||
const resolved = followChainPostFinalize(ref, scope.id, indexes);
|
||||
const resolved = followChainPostFinalize(ref, scope.id, indexes, resolveCallee);
|
||||
if (resolved !== ref) {
|
||||
(scope.typeBindings as Map<string, TypeRef>).set(name, resolved);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ import {
|
|||
type CalleeIdCaptureCtx,
|
||||
} from '../graph-bridge/edges.js';
|
||||
import { constructionSiteReason } from './free-call-fallback.js';
|
||||
import { bindQualifiedCalleeResolver } from './imported-return-types.js';
|
||||
import type { CalleeIdSink } from '../graph-bridge/callee-id-sink.js';
|
||||
import {
|
||||
classOfQualifiedTypeSpelling,
|
||||
|
|
@ -215,6 +216,7 @@ type ReceiverBoundProviderSubset = Pick<
|
|||
| 'isBareClassReceiverCandidate'
|
||||
| 'importsBindAtLexicalScope'
|
||||
| 'resolveNamespaceChains'
|
||||
| 'resolveQualifiedCallee'
|
||||
| 'resolveQualifiedTypeSpellings'
|
||||
| 'splitQualifiedCallee'
|
||||
>;
|
||||
|
|
@ -417,6 +419,8 @@ export function emitReceiverBoundCalls(
|
|||
index: WorkspaceResolutionIndex,
|
||||
model: SemanticModel,
|
||||
options: {
|
||||
/** Stable pass identity for provider indexes shared with return propagation. */
|
||||
readonly allFilePaths?: ReadonlySet<string>;
|
||||
readonly recordResolutionOutcome?: ResolutionOutcomeRecorder;
|
||||
readonly recordSourceBinding?: SourceBindingRecorder;
|
||||
/** Resolved-callee-id capture sink (#2227 U2). Threaded in under `--pdg`
|
||||
|
|
@ -510,6 +514,17 @@ export function emitReceiverBoundCalls(
|
|||
return def !== undefined && CALLABLE_TYPES.has(def.type);
|
||||
});
|
||||
};
|
||||
const resolveProviderQualifiedCallee = bindQualifiedCalleeResolver(
|
||||
provider.resolveQualifiedCallee,
|
||||
scopes,
|
||||
index,
|
||||
parsedFiles,
|
||||
options.allFilePaths,
|
||||
);
|
||||
const bindsQualifiedCallable = (typeRef: TypeRef): boolean => {
|
||||
const definition = resolveProviderQualifiedCallee?.(typeRef.rawName, typeRef.declaredAtScope);
|
||||
return definition !== undefined && CALLABLE_TYPES.has(definition.type);
|
||||
};
|
||||
// A class-like def NESTED in `owner` (`A.Item` inside `A`): its qualified
|
||||
// name is the owner's plus the segment — the identity the structure phase
|
||||
// and `populateClassOwnedMembers` agree on — so the qualified-name index
|
||||
|
|
@ -653,7 +668,8 @@ export function emitReceiverBoundCalls(
|
|||
const splitQualifiedCallee = provider.splitQualifiedCallee;
|
||||
const resolveQualifiedReceiverMember = provider.resolveQualifiedReceiverMember;
|
||||
const resolveQualifiedCallee =
|
||||
splitQualifiedCallee === undefined || resolveQualifiedReceiverMember === undefined
|
||||
resolveProviderQualifiedCallee ??
|
||||
(splitQualifiedCallee === undefined || resolveQualifiedReceiverMember === undefined
|
||||
? undefined
|
||||
: (
|
||||
callee: string,
|
||||
|
|
@ -670,7 +686,7 @@ export function emitReceiverBoundCalls(
|
|||
parsedFiles,
|
||||
);
|
||||
return def === 'ambiguous' ? undefined : def;
|
||||
};
|
||||
});
|
||||
const compoundOpts = {
|
||||
fieldFallback,
|
||||
elementTypeOf: provider.elementTypeOf,
|
||||
|
|
@ -3245,7 +3261,8 @@ export function emitReceiverBoundCalls(
|
|||
// could not type (`x = ns.untyped()`) is a drop too, not an exact miss.
|
||||
(typeRef !== undefined &&
|
||||
BARE_TYPE_NAME_RE.test(receiverName) &&
|
||||
bindsNamespaceCallable(typeRef, namespaceTargets))) &&
|
||||
(bindsNamespaceCallable(typeRef, namespaceTargets) ||
|
||||
bindsQualifiedCallable(typeRef)))) &&
|
||||
provider.isModuleExportAssignment?.(site, scopes, parsed) !== true
|
||||
) {
|
||||
// Decoded once: both the shape census and the origin classifier read the
|
||||
|
|
|
|||
|
|
@ -1021,11 +1021,9 @@ export function runScopeResolution(
|
|||
});
|
||||
logHeapProbe('sr-post-workspaceIndex', `lang=${provider.language}`);
|
||||
|
||||
// Cross-file implicit-namespace visibility (C#). Must run before
|
||||
// propagateImportedReturnTypes so the latter pass sees siblings'
|
||||
// class bindings when chasing return-type chains across files.
|
||||
// The hook writes to `bindingAugmentations` only; finalized
|
||||
// `indexes.bindings` remains immutable post-finalize (I8).
|
||||
// Complete implicit namespace visibility and refine provider-owned type facts
|
||||
// before return propagation. New bindings use bindingAugmentations; existing
|
||||
// scope typeBindings may be refined. indexes.bindings stays immutable (I8).
|
||||
if (provider.populateNamespaceSiblings !== undefined) {
|
||||
provider.populateNamespaceSiblings(parsedFiles, indexes, {
|
||||
fileContents: getFileContents(),
|
||||
|
|
@ -1049,7 +1047,13 @@ export function runScopeResolution(
|
|||
// with chain-follow per importer; quadratic regressions show up
|
||||
// here, not in finalize).
|
||||
if (provider.propagatesReturnTypesAcrossImports !== false) {
|
||||
propagateImportedReturnTypes(parsedFiles, indexes, workspaceIndex);
|
||||
propagateImportedReturnTypes(
|
||||
parsedFiles,
|
||||
indexes,
|
||||
workspaceIndex,
|
||||
provider.resolveQualifiedCallee,
|
||||
allFilePaths,
|
||||
);
|
||||
}
|
||||
|
||||
const tRangeBindStart = PROF ? process.hrtime.bigint() : 0n;
|
||||
|
|
@ -1245,6 +1249,7 @@ export function runScopeResolution(
|
|||
{
|
||||
recordResolutionOutcome,
|
||||
recordSourceBinding,
|
||||
allFilePaths,
|
||||
calleeIdSink: calleeIdAccumulator,
|
||||
// The pass's only source of positive EXTERNAL evidence for a dropped
|
||||
// receiver (`console.log`, `fetch(...)`). Same hook, same spelling as
|
||||
|
|
@ -1349,6 +1354,7 @@ export function runScopeResolution(
|
|||
calleeIdSink: calleeIdAccumulator,
|
||||
recordSourceBinding,
|
||||
isBuiltInName: provider.languageProvider.isBuiltInName,
|
||||
allFilePaths,
|
||||
heritageTypeArguments,
|
||||
unresolvedInheritanceByClass,
|
||||
},
|
||||
|
|
|
|||
4
gitnexus/test/fixtures/lang-resolution/rust-qualified-factory-return/Cargo.toml
vendored
Normal file
4
gitnexus/test/fixtures/lang-resolution/rust-qualified-factory-return/Cargo.toml
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
[package]
|
||||
name = "rust-qualified-factory-return"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
51
gitnexus/test/fixtures/lang-resolution/rust-qualified-factory-return/src/main.rs
vendored
Normal file
51
gitnexus/test/fixtures/lang-resolution/rust-qualified-factory-return/src/main.rs
vendored
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
mod modx;
|
||||
mod shadow;
|
||||
|
||||
use crate::modx as rp;
|
||||
use crate::modx::Other;
|
||||
|
||||
// Module-factory bindings below are `C`; type-qualified and untyped cases are controls.
|
||||
fn alias_call() -> i32 {
|
||||
let x = rp::make();
|
||||
x.m()
|
||||
}
|
||||
|
||||
fn module_call() -> i32 {
|
||||
let x = modx::make();
|
||||
x.m()
|
||||
}
|
||||
|
||||
fn crate_call() -> i32 {
|
||||
let x = crate::modx::make();
|
||||
x.m()
|
||||
}
|
||||
|
||||
fn inline_alias() -> i32 {
|
||||
rp::make().m()
|
||||
}
|
||||
|
||||
fn inline_module() -> i32 {
|
||||
modx::make().m()
|
||||
}
|
||||
|
||||
fn inline_crate() -> i32 {
|
||||
crate::modx::make().m()
|
||||
}
|
||||
|
||||
// A type-qualified call keeps naming the type: `Other::make()` is `Other`'s
|
||||
// associated function, not the module-level `make`.
|
||||
fn type_qualified() -> i32 {
|
||||
let x = Other::make();
|
||||
x.m()
|
||||
}
|
||||
|
||||
fn untyped_alias() -> i32 {
|
||||
let x = rp::untyped();
|
||||
x.m()
|
||||
}
|
||||
|
||||
fn untyped_inline() -> i32 {
|
||||
modx::untyped().m()
|
||||
}
|
||||
|
||||
fn main() {}
|
||||
27
gitnexus/test/fixtures/lang-resolution/rust-qualified-factory-return/src/modx.rs
vendored
Normal file
27
gitnexus/test/fixtures/lang-resolution/rust-qualified-factory-return/src/modx.rs
vendored
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
pub struct C;
|
||||
|
||||
impl C {
|
||||
pub fn m(&self) -> i32 {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Other;
|
||||
|
||||
impl Other {
|
||||
// Shares its name with the module-level `make` below, but returns `Other`.
|
||||
pub fn make() -> Other {
|
||||
Other
|
||||
}
|
||||
|
||||
pub fn m(&self) -> i32 {
|
||||
2
|
||||
}
|
||||
}
|
||||
|
||||
pub fn make() -> C {
|
||||
C
|
||||
}
|
||||
|
||||
// No return annotation: nothing in the program says what a call returns.
|
||||
pub fn untyped() {}
|
||||
11
gitnexus/test/fixtures/lang-resolution/rust-qualified-factory-return/src/shadow.rs
vendored
Normal file
11
gitnexus/test/fixtures/lang-resolution/rust-qualified-factory-return/src/shadow.rs
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
use crate::modx;
|
||||
use crate::modx::Other;
|
||||
|
||||
// A path's leading segment names a module in the type namespace, so a `let`
|
||||
// value named `modx` does not shadow it: `x` is still a `C`, while
|
||||
// `modx.m()` dispatches on the local `Other`.
|
||||
pub fn local_shadow() -> i32 {
|
||||
let modx = Other;
|
||||
let x = modx::make();
|
||||
x.m() + modx.m()
|
||||
}
|
||||
|
|
@ -651,6 +651,18 @@
|
|||
"captureGroups": 13,
|
||||
"digest": "84d0e3980d4f725f8e0831b753a2570ddace613ebf6847ee1a5e86b9fcd4148b"
|
||||
},
|
||||
"rust-qualified-factory-return/src/main.rs": {
|
||||
"captureGroups": 83,
|
||||
"digest": "9983071279163d36e0a14bbf913db27942d6e4a634b44b24c5943b4f3fea26b5"
|
||||
},
|
||||
"rust-qualified-factory-return/src/modx.rs": {
|
||||
"captureGroups": 31,
|
||||
"digest": "f83d9262f14ed04035419b9ff1a1f4fb6e7189b528474ab9d72a0ebcf09e5845"
|
||||
},
|
||||
"rust-qualified-factory-return/src/shadow.rs": {
|
||||
"captureGroups": 17,
|
||||
"digest": "60d8b27fe58f6e18f736a1d66c2a092fe48aaeab1dfdbc689fdb5bb555fb95cc"
|
||||
},
|
||||
"rust-qualified-trait/src/main.rs": {
|
||||
"captureGroups": 8,
|
||||
"digest": "8c71cad628449f8c770fffe66f0aa1c33fc84c82de86fdb770887e00a937adcd"
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
getRelationships,
|
||||
getNodesByLabel,
|
||||
getNodesByLabelFull,
|
||||
getResolutionOutcomes,
|
||||
findDanglingEdges,
|
||||
edgeSet,
|
||||
runPipelineFromRepo,
|
||||
|
|
@ -217,6 +218,15 @@ describe('Rust ambiguous symbol resolution', () => {
|
|||
expect(imp.targetFilePath).not.toMatch(/src\/other\//);
|
||||
}
|
||||
});
|
||||
|
||||
it('types h from services::create_handler() as the imported models::Handler (#3498)', () => {
|
||||
const calls = getRelationships(result, 'CALLS').filter(
|
||||
(edge) => edge.source === 'main' && edge.target === 'handle',
|
||||
);
|
||||
expect(calls.map((edge) => edge.rel.targetId)).toEqual([
|
||||
'Function:src/models/handler.rs:Handler.handle#0',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rust call resolution with arity filtering', () => {
|
||||
|
|
@ -3019,3 +3029,83 @@ describe('Rust type-qualified calls outrank a same-named module (#2745 review)',
|
|||
expect(ids).toContain('Function:src/lib.rs:Buffer.with_capacity');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module-qualified factory calls type their result (#3498 follow-up).
|
||||
//
|
||||
// `let x = rp::make(); x.m()` bound `x` to `rp`: the call-return rule kept only
|
||||
// the text before the first `::` (the `Foo::new()` idiom), so the function name
|
||||
// was gone and `make`'s declared `-> C` was never read. The aliased module path
|
||||
// `rp::make()` also failed to resolve as a call, because `rp` names no module
|
||||
// file.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Rust module-qualified factory result types (#3498 follow-up)', () => {
|
||||
let result: PipelineResult;
|
||||
|
||||
beforeAll(async () => {
|
||||
result = await runPipelineFromRepo(
|
||||
path.join(FIXTURES, 'rust-qualified-factory-return'),
|
||||
() => {},
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
const callsFrom = (source: string, target: string) =>
|
||||
getRelationships(result, 'CALLS')
|
||||
.filter((edge) => edge.source === source && edge.target === target)
|
||||
.map((edge) => edge.rel.targetId)
|
||||
.sort();
|
||||
|
||||
it.each([
|
||||
'alias_call',
|
||||
'module_call',
|
||||
'crate_call',
|
||||
'inline_alias',
|
||||
'inline_module',
|
||||
'inline_crate',
|
||||
])("types %s from make's return annotation", (source) => {
|
||||
expect(callsFrom(source, 'm')).toEqual(['Function:src/modx.rs:C.m#0']);
|
||||
});
|
||||
|
||||
it('does not let a let-bound value shadow the module segment of a path', () => {
|
||||
expect(callsFrom('local_shadow', 'm')).toEqual([
|
||||
'Function:src/modx.rs:C.m#0',
|
||||
'Function:src/modx.rs:Other.m#0',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps reading a type-qualified call as the type', () => {
|
||||
expect(callsFrom('type_qualified', 'm')).toEqual(['Function:src/modx.rs:Other.m#0']);
|
||||
});
|
||||
|
||||
it.each(['untyped_alias', 'untyped_inline'])(
|
||||
'does not guess a receiver type for %s',
|
||||
(source) => {
|
||||
expect(callsFrom(source, 'm')).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
['alias_call', 'make'],
|
||||
['inline_alias', 'make'],
|
||||
['untyped_alias', 'untyped'],
|
||||
])('resolves the aliased module path in %s to modx::%s', (source, target) => {
|
||||
const edges = getRelationships(result, 'CALLS').filter(
|
||||
(edge) => edge.source === source && edge.target === target,
|
||||
);
|
||||
expect(edges.map((edge) => [edge.rel.targetId, edge.rel.reason])).toEqual([
|
||||
[`Function:src/modx.rs:${target}`, 'import-resolved'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('records the untyped receivers as dropped sites instead of an exact miss', () => {
|
||||
const dropped = getResolutionOutcomes(result)
|
||||
.filter((outcome) => outcome.kind === 'suppressed')
|
||||
.filter(
|
||||
(outcome) => outcome.reason === 'receiver-unresolved' && outcome.filePath === 'src/main.rs',
|
||||
)
|
||||
.map((outcome) => `${outcome.range.startLine}:${outcome.siteKind}:${outcome.name}`)
|
||||
.sort();
|
||||
expect(dropped).toEqual(['44:call:m', '44:read:m', '48:call:m', '48:read:m']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
|
||||
import { TransitionalScopeTree } from '../../src/storage/scope-index-store.js';
|
||||
|
||||
describe('Rust qualified factory boundaries', () => {
|
||||
it('keeps an alias type parameter out of the enclosing module', async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'rust-alias-parameter-'));
|
||||
try {
|
||||
await fs.writeFile(
|
||||
path.join(root, 'main.rs'),
|
||||
'mod T { pub struct Product; impl Product { pub fn m(&self) {} } pub fn make() -> Product { Product } }\ntype Alias<T> = Option<T>;\nfn caller() { let x = T::make(); x.m(); }\n',
|
||||
);
|
||||
const result = await runPipelineFromRepo(root, () => {}, {});
|
||||
const calls = [...result.graph.iterRelationships()].filter(
|
||||
(edge) =>
|
||||
edge.type === 'CALLS' &&
|
||||
result.graph.getNode(edge.sourceId)?.properties.name === 'caller',
|
||||
);
|
||||
expect(
|
||||
calls.map((edge) => result.graph.getNode(edge.targetId)?.properties.name).sort(),
|
||||
).toEqual(['m', 'make']);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 120000);
|
||||
it('retains the outer unresolved call when its member repeats the inner callee', async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'rust-qualified-repeat-'));
|
||||
try {
|
||||
await fs.writeFile(
|
||||
path.join(root, 'main.rs'),
|
||||
'mod factory { pub fn untyped() {} }\nfn caller() { factory::untyped().untyped(); }\n',
|
||||
);
|
||||
const result = await runPipelineFromRepo(root, () => {}, {});
|
||||
const drops = result.resolutionOutcomes.filter(
|
||||
(outcome) =>
|
||||
outcome.kind === 'suppressed' &&
|
||||
outcome.reason === 'receiver-unresolved' &&
|
||||
outcome.siteKind === 'call' &&
|
||||
outcome.name === 'untyped',
|
||||
);
|
||||
expect(drops).toHaveLength(1);
|
||||
const calls = [...result.graph.iterRelationships()].filter(
|
||||
(edge) =>
|
||||
edge.type === 'CALLS' &&
|
||||
result.graph.getNode(edge.sourceId)?.properties.name === 'caller' &&
|
||||
result.graph.getNode(edge.targetId)?.properties.name === 'untyped',
|
||||
);
|
||||
expect(calls).toHaveLength(1);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
it('resolves a cold inline-only module after sealing disk scopes', async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'rust-qualified-disk-'));
|
||||
const store = await fs.mkdtemp(path.join(os.tmpdir(), 'rust-qualified-store-'));
|
||||
const previous = process.env.GITNEXUS_DISK_SCOPE_INDEX;
|
||||
const original = TransitionalScopeTree.prototype.seal;
|
||||
let seals = 0;
|
||||
TransitionalScopeTree.prototype.seal = function (...args: Parameters<typeof original>) {
|
||||
seals++;
|
||||
return original.apply(this, args);
|
||||
};
|
||||
try {
|
||||
process.env.GITNEXUS_DISK_SCOPE_INDEX = '1';
|
||||
await fs.writeFile(
|
||||
path.join(root, 'main.rs'),
|
||||
'mod factory { pub fn untyped() {} }\nfn caller() { factory::untyped().m(); }\n',
|
||||
);
|
||||
const result = await runPipelineFromRepo(root, () => {}, { parsedFileStorePath: store });
|
||||
expect(seals).toBe(1);
|
||||
expect(
|
||||
[...result.graph.iterRelationships()].filter(
|
||||
(edge) =>
|
||||
edge.type === 'CALLS' && result.graph.getNode(edge.targetId)?.properties.name === 'm',
|
||||
),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
result.resolutionOutcomes.some(
|
||||
(outcome) =>
|
||||
outcome.kind === 'suppressed' &&
|
||||
outcome.reason === 'receiver-unresolved' &&
|
||||
outcome.siteKind === 'call' &&
|
||||
outcome.name === 'm',
|
||||
),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
TransitionalScopeTree.prototype.seal = original;
|
||||
if (previous === undefined) delete process.env.GITNEXUS_DISK_SCOPE_INDEX;
|
||||
else process.env.GITNEXUS_DISK_SCOPE_INDEX = previous;
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
await fs.rm(store, { recursive: true, force: true });
|
||||
}
|
||||
}, 120000);
|
||||
});
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
|
||||
|
||||
describe('Rust qualified factory lexical and declaration identity', () => {
|
||||
let root: string;
|
||||
let result: Awaited<ReturnType<typeof runPipelineFromRepo>>;
|
||||
beforeAll(async () => {
|
||||
root = await fs.mkdtemp(path.join(os.tmpdir(), 'rust-factory-collisions-'));
|
||||
await fs.mkdir(path.join(root, 'src'));
|
||||
await fs.writeFile(
|
||||
path.join(root, 'Cargo.toml'),
|
||||
'[package]\nname="collisions"\nversion="0.1.0"\nedition="2021"\n',
|
||||
);
|
||||
for (const [file, name] of [
|
||||
['a', 'A'],
|
||||
['b', 'B'],
|
||||
])
|
||||
await fs.writeFile(
|
||||
path.join(root, `src/${file}.rs`),
|
||||
`pub struct ${name}; impl ${name} { pub fn m(&self) {} } pub fn make() -> ${name} { ${name} }`,
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(root, 'src/model.rs'),
|
||||
`#[allow(non_camel_case_types)] pub struct model; impl model { pub fn make() -> Self { Self } pub fn m(&self) {} } pub struct Wrong; impl Wrong { pub fn m(&self) {} } pub fn make() -> Wrong { Wrong }`,
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(root, 'src/main.rs'),
|
||||
`
|
||||
mod a; mod b; mod model;
|
||||
use crate::a as factory;
|
||||
use crate::model::model as Maker;
|
||||
fn scoped_import() { use crate::b as factory; factory::make().m(); }
|
||||
fn type_import() { Maker::make().m(); }
|
||||
`,
|
||||
);
|
||||
result = await runPipelineFromRepo(root, () => {}, {});
|
||||
}, 120000);
|
||||
afterAll(async () => {
|
||||
if (root) await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
const targets = (caller: string, name: string) =>
|
||||
[...result.graph.iterRelationships()]
|
||||
.filter(
|
||||
(edge) =>
|
||||
edge.type === 'CALLS' &&
|
||||
result.graph.getNode(edge.sourceId)?.properties.name === caller &&
|
||||
result.graph.getNode(edge.targetId)?.properties.name === name,
|
||||
)
|
||||
.map((edge) => edge.targetId);
|
||||
it('uses the nearest module import', () => {
|
||||
expect(targets('scoped_import', 'm')).toEqual(['Function:src/b.rs:B.m#0']);
|
||||
});
|
||||
it('does not reinterpret a resolved type import as its module', () => {
|
||||
expect(targets('type_import', 'm')).not.toContain('Function:src/model.rs:Wrong.m#0');
|
||||
expect(targets('type_import', 'make')).not.toContain('Function:src/model.rs:make');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
|
||||
|
||||
describe('Rust qualified factory spelling follows declarations', () => {
|
||||
let root: string;
|
||||
let result: Awaited<ReturnType<typeof runPipelineFromRepo>>;
|
||||
beforeAll(async () => {
|
||||
root = await fs.mkdtemp(path.join(os.tmpdir(), 'rust-factory-spellings-'));
|
||||
await fs.mkdir(path.join(root, 'src'));
|
||||
await fs.writeFile(
|
||||
path.join(root, 'Cargo.toml'),
|
||||
'[package]\nname="spellings"\nversion="0.1.0"\nedition="2021"\n',
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(root, 'src/Factory.rs'),
|
||||
'pub struct Upper; impl Upper { pub fn m(&self) {} } pub fn make() -> Upper { Upper }',
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(root, 'src/módulo.rs'),
|
||||
'pub struct Unicode; impl Unicode { pub fn m(&self) {} } pub fn hacer() -> Unicode { Unicode } pub fn créer() -> Unicode { Unicode }',
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(root, 'src/main.rs'),
|
||||
`
|
||||
mod Factory;
|
||||
mod módulo;
|
||||
#[allow(non_camel_case_types)] struct lowercase_type;
|
||||
impl lowercase_type { fn new() -> Self { Self } fn m(&self) {} }
|
||||
#[derive(Default)] #[allow(non_camel_case_types)] struct derived_type;
|
||||
impl derived_type { fn m(&self) {} }
|
||||
struct Decoy; impl Decoy { fn m(&self) {} }
|
||||
fn lower_type() { let x = lowercase_type::new(); x.m(); }
|
||||
fn derived_default() { let x = derived_type::default(); x.m(); }
|
||||
fn upper_module() { let x = Factory::make(); x.m(); }
|
||||
fn unicode_module() { let x = módulo::hacer(); x.m(); }
|
||||
fn unicode_function() { let x = módulo::créer(); x.m(); }
|
||||
`,
|
||||
);
|
||||
result = await runPipelineFromRepo(root, () => {}, {});
|
||||
}, 120000);
|
||||
afterAll(async () => {
|
||||
if (root) await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
it.each([
|
||||
['lower_type', 'Function:src/main.rs:lowercase_type.m#0'],
|
||||
['derived_default', 'Function:src/main.rs:derived_type.m#0'],
|
||||
['upper_module', 'Function:src/Factory.rs:Upper.m#0'],
|
||||
['unicode_module', 'Function:src/módulo.rs:Unicode.m#0'],
|
||||
['unicode_function', 'Function:src/módulo.rs:Unicode.m#0'],
|
||||
])('%s resolves its declared receiver', (caller, target) => {
|
||||
expect(
|
||||
[...result.graph.iterRelationships()]
|
||||
.filter(
|
||||
(edge) =>
|
||||
edge.type === 'CALLS' &&
|
||||
result.graph.getNode(edge.sourceId)?.properties.name === caller &&
|
||||
result.graph.getNode(edge.targetId)?.properties.name === 'm',
|
||||
)
|
||||
.map((edge) => edge.targetId),
|
||||
).toEqual([target]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
|
||||
|
||||
describe('Rust qualified factory type shadows', () => {
|
||||
let root: string;
|
||||
let result: Awaited<ReturnType<typeof runPipelineFromRepo>>;
|
||||
beforeAll(async () => {
|
||||
root = await fs.mkdtemp(path.join(os.tmpdir(), 'rust-factory-type-shadow-'));
|
||||
await fs.writeFile(
|
||||
path.join(root, 'main.rs'),
|
||||
`
|
||||
mod factory { pub struct Wrong; impl Wrong { pub fn m(&self) {} } pub fn make() -> Wrong { Wrong } }
|
||||
mod módulo { pub struct UnicodeWrong; impl UnicodeWrong { pub fn m(&self) {} } pub fn make() -> UnicodeWrong { UnicodeWrong } }
|
||||
struct Other; impl Other { fn make() -> Self { Self } fn m(&self) {} }
|
||||
fn type_alias() { type factory = Other; let x = factory::make(); x.m(); }
|
||||
fn type_parameter<factory>() { factory::make().m(); }
|
||||
fn const_parameter<const factory: usize>() { factory::make().m(); }
|
||||
fn unicode_type_parameter<módulo>() { módulo::make().m(); }
|
||||
struct ConstHolder<const factory: usize>;
|
||||
impl<const factory: usize> ConstHolder<factory> { fn struct_const_parameter() { factory::make().m(); } }
|
||||
`,
|
||||
);
|
||||
result = await runPipelineFromRepo(root, () => {}, {});
|
||||
}, 120000);
|
||||
afterAll(async () => {
|
||||
if (root) await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
const targets = (caller: string) =>
|
||||
[...result.graph.iterRelationships()]
|
||||
.filter(
|
||||
(edge) =>
|
||||
edge.type === 'CALLS' &&
|
||||
result.graph.getNode(edge.sourceId)?.properties.name === caller &&
|
||||
result.graph.getNode(edge.targetId)?.properties.name === 'm',
|
||||
)
|
||||
.map((edge) => edge.targetId);
|
||||
it('does not borrow a module through a local type alias', () => {
|
||||
expect(targets('type_alias')).toEqual([]);
|
||||
});
|
||||
it('does not borrow a module through a type parameter', () => {
|
||||
expect(targets('type_parameter')).toEqual([]);
|
||||
});
|
||||
it('keeps a module path visible beside a const generic value', () => {
|
||||
expect(targets('const_parameter')).toEqual(['Function:main.rs:factory.Wrong.m#0']);
|
||||
});
|
||||
it('does not borrow a module through a Unicode type parameter', () => {
|
||||
expect(targets('unicode_type_parameter')).toEqual([]);
|
||||
});
|
||||
it('keeps a module visible beside an impl const generic', () => {
|
||||
expect(targets('struct_const_parameter')).toEqual(['Function:main.rs:factory.Wrong.m#0']);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import type { CaptureMatch } from 'gitnexus-shared';
|
||||
import { interpretRustTypeBinding } from '../../../../src/core/ingestion/languages/rust/interpret.js';
|
||||
|
||||
const RANGE = { startLine: 0, startCol: 0, endLine: 0, endCol: 0 };
|
||||
|
||||
/** A minimal `let x = <callee>()` call-return CaptureMatch. */
|
||||
function callReturnBinding(callee: string): CaptureMatch {
|
||||
return {
|
||||
'@type-binding.name': { name: '@type-binding.name', range: RANGE, text: 'x' },
|
||||
'@type-binding.type': { name: '@type-binding.type', range: RANGE, text: callee },
|
||||
'@type-binding.call-return': { name: '@type-binding.call-return', range: RANGE, text: '' },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserve qualified callees until finalized scope ownership distinguishes a
|
||||
* module factory from an associated function. Self and generic constructors
|
||||
* keep their existing type spelling (#3498 follow-up).
|
||||
*/
|
||||
describe('Rust call-return binding of a qualified callee', () => {
|
||||
it.each([
|
||||
['Foo::new', 'Foo::new'],
|
||||
['Self::new', 'Self'],
|
||||
['Vec::<u8>::new', 'Vec'],
|
||||
['make', 'make'],
|
||||
])('reads %s as %s', (callee, expected) => {
|
||||
expect(interpretRustTypeBinding(callReturnBinding(callee))?.rawTypeName).toBe(expected);
|
||||
});
|
||||
|
||||
it.each(['rp::make', 'crate::modx::make', 'self::make', 'super::modx::make', 'a::b::make'])(
|
||||
'keeps the module path %s',
|
||||
(callee) => {
|
||||
expect(interpretRustTypeBinding(callReturnBinding(callee))?.rawTypeName).toBe(callee);
|
||||
},
|
||||
);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue