mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-11-12 08:41:34 +00:00
* fix(rust): see through Box, Rc and Arc on struct field types (#3498) The struct-field normalizer in rust/range-binding.ts cut a type at its first `<`. A `repo: Box<User>` field was typed `Box`, so `self.repo.save()` bound nothing. It also stripped a leading `&` but not a lifetime, so a borrowed field, which always carries one (`&'a User`), came out as `'a User`. Box, Rc and Arc auto-deref on a method call, so the field now takes their first type argument: bare or path-qualified (`std::rc::Rc<User>`), and nested (`Rc<Box<User>>`). Borrows with a lifetime or `mut` are stripped, and `Box<dyn Trait>` reduces to the trait through the stripDynBound helper that parameters already use (#2604). RefCell, Cell, Mutex, RwLock, Option and Vec are deliberately left alone. A method on the inner type needs `.borrow()`, `.get()`, `.lock()`, `.read()`, a match or an index first, so the receiver really is the wrapper, and a direct call through one must not bind. Shape arm: rust `decoratedFieldType` moves INVISIBLE-GAP -> RESOLVES, with the edge on `User::save`. The corpus has no smart-pointer or borrowed struct field, so the count arm and every other cell are unchanged, and the corpus edge (2838) and drop (192) sets match main exactly. Scope-capture fingerprints are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(rust): keep opaque pointer fields wrapped * fix(rust): refuse opaque macro expansions * test(rust): pin pointer wrapper targets and opacity limits (#3665) - Assert a same-file Box keeps its own save target while User.save stays unbound (review thread on custom same-named wrappers). - Pin the documented workspace-wide recall limit: an unrelated unresolved import or macro_rules! keeps an otherwise valid pointer field wrapped. - Drop per-case logging and group the opaque/expansion cases in describe blocks. - Collapse the stacked pointer-field receipts in BASELINE.md into one current section listing every workspace-wide refusal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: preserve Rust integration notes * test(rust): assert complete pointer targets --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
af5fd9665c
commit
673dc6968e
10 changed files with 996 additions and 16 deletions
|
|
@ -1,5 +1,19 @@
|
|||
# Receiver-resolution baseline
|
||||
|
||||
## Rust smart-pointer field types (#3498)
|
||||
|
||||
The struct-field normalizer in `rust/range-binding.ts` cut a type at its first
|
||||
`<`, so a `repo: Box<User>` field was typed `Box` and `self.repo.save()` bound
|
||||
nothing. It now unwraps proven standard `Box`, `Rc` and `Arc` pointers to a
|
||||
lexically resolved, unqualified pointee (including nested standard pointers),
|
||||
strips `&'a` / `&'a mut` borrows, and reduces `Box<dyn Trait>` to the trait.
|
||||
Qualified pointees, projections and callable types remain wrapped. `RefCell`, `Cell`, `Mutex`,
|
||||
`RwLock`, `Option` and `Vec` stay as written, because a method on the inner type
|
||||
needs an explicit step. `rust.decoratedFieldType` moves from VISIBLE-GAP to
|
||||
RESOLVES, and the edge lands on `User::save`. No corpus fixture has a
|
||||
smart-pointer or borrowed struct field, so the count arm and every other shape
|
||||
cell are unchanged.
|
||||
|
||||
## C# nullable field types (#3498)
|
||||
|
||||
A field written `User? repo;` has a `nullable_type` as its declared type.
|
||||
|
|
@ -1192,6 +1206,71 @@ Main `65b101407` is merged normally. The shared caller retains Rust's provider-q
|
|||
|
||||
Main `65b101407` is merged normally. Origin evidence retains the qualified-type lookup policy and both pipeline passes retain workspace file paths. Package build, both pinned TypeScript configurations, 425 focused tests across 25 files, and all 16 capture checks pass. The regenerated receiver baseline preserves every shape state and main's 132 call drops, 160 total, 28 reads and zero writes. Origin labels are 30 external, 45 unknown, 55 in-program and two unclassified. The 5,424-record resolution corpus is byte-identical to main. Exact-head hosted CI remains required.
|
||||
|
||||
### Standard pointer ownership
|
||||
|
||||
Only known std/alloc Box, Rc and Arc paths, unshadowed prelude Box, and
|
||||
lexically selected explicit standard imports permit pointer unwrapping. A
|
||||
custom qualified wrapper, local Box, foreign Rc import, or nested custom
|
||||
wrapper cannot borrow User’s methods, and a same-file `Box` keeps its own
|
||||
`save` target. Any visible glob conservatively blocks prelude Box inference.
|
||||
Normalization without declaration-scope evidence keeps the wrapper rather than
|
||||
introducing a new dereference guess.
|
||||
|
||||
### Pointer dispatch precision
|
||||
|
||||
Flattening a pointer field loses Rust's ordered autoderef lookup. A pointer's
|
||||
own `as_ref` or a local trait implementation on `Rc<User>` must take precedence
|
||||
over an identically named method on `User`. The field now stays wrapped when
|
||||
its pointee declares a method in the conservative Rust 1.99.1 pointer-member
|
||||
inventory, or when local trait implementations make pointer ownership opaque.
|
||||
This guard deliberately declines the entire field: a valid `User.save()` call
|
||||
on a field whose `User` also declares `as_ref()` remains unresolved. Separate
|
||||
controls retain all three pointer kinds' unconflicted `SafeUser.save()` edges.
|
||||
The inventory includes conditional and blanket implementations; it is a refusal
|
||||
guard, not a replacement for Rust's method selection.
|
||||
|
||||
Absolute paths do not bypass an `extern crate ... as std/alloc` claim. Field
|
||||
bindings carried into assigned locals retain their declaration scope and
|
||||
position. The safety prepass is linear in declarations; it reuses retained
|
||||
trees within the existing 16 MiB source budget and adds a parse pass when trees
|
||||
cannot be retained.
|
||||
|
||||
### Workspace-wide opacity
|
||||
|
||||
Impls, traits and macro expansions outside the parsed declaration model can
|
||||
change which method Rust selects. Every pointer field in the analyzed
|
||||
workspace therefore stays wrapped when any Rust file contains one of these:
|
||||
|
||||
- an impl whose self type cannot be resolved;
|
||||
- a non-standard import, including a glob, that does not resolve to workspace
|
||||
declarations;
|
||||
- a `macro_rules!` definition, an item-position or qualified macro call,
|
||||
`include!`, or a macro whose input contains `impl` or another macro call;
|
||||
- an attribute outside the known compiler attributes, or a derive outside the
|
||||
nine built-in derives.
|
||||
|
||||
One such line can suppress otherwise valid pointer-field calls in another
|
||||
file. A crate with any unresolved dependency import or any `macro_rules!` gets
|
||||
no pointer-field unwrapping, and expected-limit tests pin both cases.
|
||||
`derive(Clone)` and a simple `println!` expression keep their positive
|
||||
controls. This is a conservative field-typing guard, not Rust method lookup or
|
||||
macro expansion.
|
||||
|
||||
### Pointer field validation
|
||||
|
||||
Native probes reproduced each guarded wrong edge before its fix: six ownership
|
||||
negatives, eight pointer-dispatch edges (pointer methods, qualified pointees, a
|
||||
crate alias and local trait impls), four opaque-dispatch cases and six
|
||||
macro-expansion cases. The old main normalizer emits none of them.
|
||||
After merging main `c30449394` normally, the combined tree passes a fresh
|
||||
package build, both pinned TypeScript configurations, lint with zero errors,
|
||||
and 351 tests in 13 Rust test files, including main's qualified-factory,
|
||||
call-return and capture-golden checks. The regenerated receiver baseline keeps
|
||||
main's counts (132 call drops, 160 drops of all kinds, 28 reads and no writes)
|
||||
and differs from main only in `rust.decoratedFieldType`. The 5,501-record
|
||||
resolution corpus is byte-identical to main `c30449394`. Exact-head hosted CI
|
||||
remains required.
|
||||
|
||||
## PHP producer ownership boundary
|
||||
|
||||
Functions declared in multi-namespace files remain ambiguity evidence, but
|
||||
|
|
@ -1209,3 +1288,21 @@ return-result typing; its pre-existing direct CALLS behavior is outside this fix
|
|||
Main c30449394 is merged normally. Package-factory fallback declines inherited receivers whose captured bases cannot be re-established through current bindings and the canonical MRO. Global qualified names need one declared type; declaration-local facts remain available. Companions need captured proof that their header declares no supertype, and that proof survives the worker/cache carrier. Older carriers retain companion identity but supply no such proof. These conservative boundaries do not model a build-system classpath or complete companion inheritance.
|
||||
|
||||
The integrated build, both TypeScript configurations, 345 tests across ten files, Kotlin capture fingerprint and regenerated receiver check pass. The baseline remains 136 call drops, 166 drops across all kinds, 30 reads and zero writes, with unchanged shape states. The 5,521-record corpus is byte-identical to the candidate before the companion-header fix. These tests overlap earlier runs. Exact-head hosted CI remains required.
|
||||
|
||||
|
||||
### Rust pointer fields on the prepared C#/PHP/Kotlin stack
|
||||
|
||||
The reviewed C# parent `b74fe72b8` is merged normally, retaining PHP
|
||||
`6149ce156` and Kotlin `d5a7c73fa`. The Rust implementation is byte-identical
|
||||
to `1da61e025`. A fresh package build, both pinned TypeScript configurations,
|
||||
and 351 tests across 13 Rust files pass on this combined source. The Rust
|
||||
capture fingerprint remains `19045ed3d4ee9fce`, with 207 fixture files and
|
||||
3,699 groups; its measured scaling ratio is 0.989 within the unchanged budget.
|
||||
|
||||
The regenerated receiver check preserves the parent's count arm: 138 call
|
||||
drops, 168 drops across all kinds, 30 reads and no writes. Its only shape-state
|
||||
change against that parent is `rust.decoratedFieldType` from VISIBLE-GAP to
|
||||
RESOLVES. The 5,559-record resolution corpus is byte-identical to the C#
|
||||
parent, with no additions or removals. These fixture results do not measure
|
||||
real-repository usefulness or pre-pass cost. Exact-head hosted CI remains
|
||||
required.
|
||||
|
|
|
|||
|
|
@ -147,7 +147,7 @@
|
|||
"indexElement": "RESOLVES",
|
||||
"fieldReceiverCall": "RESOLVES",
|
||||
"decoratedReceiverBase": "RESOLVES",
|
||||
"decoratedFieldType": "VISIBLE-GAP"
|
||||
"decoratedFieldType": "RESOLVES"
|
||||
},
|
||||
"c": {
|
||||
"plainChain": "N/A",
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ const ENUM_VARIANT_NAMES = new Set(['Some', 'None', 'Ok', 'Err']);
|
|||
// the principal trait name. Reference/pointer sigils are stripped by the
|
||||
// caller first; wrapper unwrapping (Box<T> etc.) runs before this so the
|
||||
// unwrapped inner text still gets the same treatment.
|
||||
function stripDynBound(t: string): string {
|
||||
export function stripDynBound(t: string): string {
|
||||
if (!DYN_PREFIX_RE.test(t)) return t;
|
||||
t = t.replace(DYN_PREFIX_RE, '');
|
||||
const plus = t.indexOf('+');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,231 @@
|
|||
import type { ParsedFile, ScopeId, SymbolDefinition } from 'gitnexus-shared';
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import { findClassBindingInScope, lookupNameClaim } from '../../scope-resolution/scope/walkers.js';
|
||||
|
||||
// Conservative receiver-method inventory from Rust 1.99.1, including conditional
|
||||
// and blanket implementations. An overlap keeps the whole field wrapped until
|
||||
// dispatch can preserve ordered autoderef; this is not a method-selection table.
|
||||
// https://doc.rust-lang.org/1.99.1/std/boxed/struct.Box.html
|
||||
// https://doc.rust-lang.org/1.99.1/std/rc/struct.Rc.html
|
||||
// https://doc.rust-lang.org/1.99.1/std/sync/struct.Arc.html
|
||||
const POINTER_MEMBER_NAMES: ReadonlySet<string> = new Set(
|
||||
`
|
||||
advance_back_by advance_by all allocate allocate_zeroed any array_chunks as_fd as_handle
|
||||
as_mut as_raw_fd as_ref as_socket as_utf8_pattern assume_init async_call async_call_mut
|
||||
async_call_once borrow borrow_mut by_ref bytes call call_mut call_once cause chain clamp
|
||||
clone clone_from clone_into clone_to_uninit cloned cmp cmp_by collect collect_into
|
||||
consume copied count cycle deallocate deref deref_mut description downcast
|
||||
downcast_unchecked drop enumerate eq eq_by extend extend_one extend_reserve fill_buf
|
||||
filter filter_map find find_map finish flat_map flatten flush fmt fold for_each fuse ge
|
||||
grow grow_zeroed gt has_data_left hash inspect intersperse intersperse_with into
|
||||
into_array into_async_iter into_future into_iter into_searcher is_contained_in is_empty
|
||||
is_partitioned is_prefix_of is_read_vectored is_sorted is_sorted_by is_sorted_by_key
|
||||
is_suffix_of is_write_vectored last le len lines lt map map_while map_windows max max_by
|
||||
max_by_key min min_by min_by_key ne next next_back next_chunk next_chunk_back nth
|
||||
nth_back partial_cmp partial_cmp_by partition partition_in_place peekable pin_drop poll
|
||||
poll_next position product provide read read_array read_be read_buf read_buf_exact
|
||||
read_exact read_le read_line read_to_end read_to_string read_until read_vectored reduce
|
||||
resume rev rewind rfind rfold rposition scan seek seek_relative share shrink size_hint
|
||||
skip skip_until skip_while source split step_by stream_len stream_position
|
||||
strip_prefix_of strip_suffix_of sum take take_while to_owned to_string try_collect
|
||||
try_find try_fold try_for_each try_into try_reduce try_rfold type_id unzip write
|
||||
write_all write_all_vectored write_fmt write_i128 write_i16 write_i32 write_i64 write_i8
|
||||
write_isize write_length_prefix write_str write_u128 write_u16 write_u32 write_u64
|
||||
write_u8 write_usize write_vectored zip
|
||||
`
|
||||
.trim()
|
||||
.split(/\s+/),
|
||||
);
|
||||
const POINTER_NAMES = new Set(['Box', 'Rc', 'Arc']);
|
||||
// Compiler attributes that cannot introduce arbitrary dispatch implementations.
|
||||
// cfg_attr and procedural/tool attributes deliberately remain opaque.
|
||||
const COMPILER_ATTRIBUTES = new Set([
|
||||
'allow',
|
||||
'warn',
|
||||
'deny',
|
||||
'forbid',
|
||||
'expect',
|
||||
'cfg',
|
||||
'cold',
|
||||
'inline',
|
||||
'must_use',
|
||||
'repr',
|
||||
'non_exhaustive',
|
||||
'deprecated',
|
||||
'doc',
|
||||
'test',
|
||||
'ignore',
|
||||
'should_panic',
|
||||
'track_caller',
|
||||
'no_mangle',
|
||||
'export_name',
|
||||
'link',
|
||||
'link_name',
|
||||
'link_section',
|
||||
'used',
|
||||
'unsafe',
|
||||
'default',
|
||||
'automatically_derived',
|
||||
'derive',
|
||||
]);
|
||||
const COMPILER_DERIVES = new Set([
|
||||
'Clone',
|
||||
'Copy',
|
||||
'Debug',
|
||||
'Default',
|
||||
'PartialEq',
|
||||
'Eq',
|
||||
'PartialOrd',
|
||||
'Ord',
|
||||
'Hash',
|
||||
]);
|
||||
|
||||
export interface RustPointerFieldSafety {
|
||||
readonly externCrateAliases: ReadonlySet<string>;
|
||||
readonly opaqueWrappers: ReadonlySet<string>;
|
||||
readonly opaquePointees: ReadonlySet<string>;
|
||||
readonly membersByType: ReadonlyMap<string, ReadonlySet<string>>;
|
||||
}
|
||||
|
||||
/** Index declaration evidence once, before any pointer fields are flattened. */
|
||||
export function buildRustPointerFieldSafety(
|
||||
parsedFiles: readonly ParsedFile[],
|
||||
indexes: ScopeResolutionIndexes,
|
||||
rootFor: (parsed: ParsedFile) => SyntaxNode | null,
|
||||
scopeFor: (parsed: ParsedFile, node: SyntaxNode) => ScopeId | undefined,
|
||||
): RustPointerFieldSafety {
|
||||
const externCrateAliases = new Set<string>();
|
||||
const opaqueWrappers = new Set<string>();
|
||||
const opaquePointees = new Set<string>();
|
||||
const membersByType = new Map<string, Set<string>>();
|
||||
const typeDef = (
|
||||
parsed: ParsedFile,
|
||||
node: SyntaxNode,
|
||||
name: string,
|
||||
): SymbolDefinition | undefined => {
|
||||
const scope = scopeFor(parsed, node);
|
||||
return scope === undefined
|
||||
? undefined
|
||||
: findClassBindingInScope(scope, name, indexes, undefined, {
|
||||
uniqueQualifiedNameFallback: false,
|
||||
purpose: 'type',
|
||||
});
|
||||
};
|
||||
for (const parsed of parsedFiles) {
|
||||
const root = rootFor(parsed);
|
||||
if (root === null) continue;
|
||||
// An unresolved non-standard import may bring a blanket extension trait.
|
||||
// Macro definitions may emit impls absent from the parsed declaration model.
|
||||
// Neither supplies enough dispatch evidence to erase a wrapper safely.
|
||||
const hasUnknownImport = parsed.parsedImports.some((entry) => {
|
||||
const importedRoot = entry.targetRaw.replace(/^::/, '').split('::')[0]!;
|
||||
if (['std', 'core', 'alloc', 'self', 'super', 'crate'].includes(importedRoot)) return false;
|
||||
if (!('localName' in entry)) return true;
|
||||
const claim = lookupNameClaim(
|
||||
entry.declaredAtScope ?? parsed.moduleScope,
|
||||
entry.localName,
|
||||
indexes,
|
||||
{ purpose: 'type' },
|
||||
);
|
||||
return claim.imports.length === 0 || claim.imports.some((edge) => edge.targetFile === null);
|
||||
});
|
||||
const hasOpaqueAttribute = root
|
||||
.descendantsOfType(['attribute_item', 'inner_attribute_item'])
|
||||
.some((attribute) => {
|
||||
const name = /^#!?\[\s*([A-Za-z_][A-Za-z0-9_]*)\s*(?:\(|=|\])/.exec(attribute.text)?.[1];
|
||||
if (name === undefined || !COMPILER_ATTRIBUTES.has(name)) return true;
|
||||
if (name !== 'derive') return false;
|
||||
const args = /^#!?\[\s*derive\s*\(([\s\S]*)\)\s*\]$/.exec(attribute.text)?.[1];
|
||||
return (
|
||||
args === undefined ||
|
||||
args
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.some((part) => !COMPILER_DERIVES.has(part))
|
||||
);
|
||||
});
|
||||
const hasOpaqueInvocation = root.descendantsOfType('macro_invocation').some((macro) => {
|
||||
const name = macro.childForFieldName('macro')?.text ?? '';
|
||||
const parent = macro.parent;
|
||||
const owner = parent?.type === 'expression_statement' ? parent.parent : parent;
|
||||
if (
|
||||
name.includes('::') ||
|
||||
name === 'include' ||
|
||||
owner?.type === 'source_file' ||
|
||||
owner?.type === 'declaration_list'
|
||||
)
|
||||
return true;
|
||||
const tokens = macro.namedChildren.find((child) => child.type === 'token_tree')?.text ?? '';
|
||||
// Macro token trees are not parsed as declarations. Reject hidden impls
|
||||
// and nested invocations rather than guessing what their expansion emits.
|
||||
return /\bimpl\b|\b[A-Za-z_][A-Za-z0-9_]*\s*!/.test(tokens);
|
||||
});
|
||||
if (
|
||||
hasUnknownImport ||
|
||||
hasOpaqueAttribute ||
|
||||
hasOpaqueInvocation ||
|
||||
root.descendantsOfType('macro_definition').length > 0
|
||||
) {
|
||||
for (const pointer of POINTER_NAMES) opaqueWrappers.add(pointer);
|
||||
}
|
||||
for (const declaration of root.descendantsOfType('extern_crate_declaration')) {
|
||||
const alias = declaration.childForFieldName('alias')?.text;
|
||||
if (alias !== undefined && alias !== declaration.childForFieldName('name')?.text)
|
||||
externCrateAliases.add(alias);
|
||||
}
|
||||
for (const node of root.descendantsOfType(['impl_item', 'trait_item'])) {
|
||||
const isImpl = node.type === 'impl_item';
|
||||
const written = node.childForFieldName(isImpl ? 'type' : 'name')?.text;
|
||||
if (written === undefined) continue;
|
||||
const base = written
|
||||
.replace(/^&\s*(?:'[\w]+\s+)?(?:mut\s+)?/, '')
|
||||
.split('<')[0]!
|
||||
.trim();
|
||||
const definition = typeDef(parsed, node, base);
|
||||
if (isImpl && node.childForFieldName('trait') !== null) {
|
||||
const tail = base.split('::').at(-1)!;
|
||||
if (POINTER_NAMES.has(tail)) opaqueWrappers.add(tail);
|
||||
else if (definition !== undefined) opaquePointees.add(definition.nodeId);
|
||||
else for (const pointer of POINTER_NAMES) opaqueWrappers.add(pointer);
|
||||
}
|
||||
if (definition === undefined) {
|
||||
// Qualified inherent impls can still attach methods downstream even
|
||||
// when their self type cannot be proved here. Do not flatten through it.
|
||||
if (isImpl) for (const pointer of POINTER_NAMES) opaqueWrappers.add(pointer);
|
||||
continue;
|
||||
}
|
||||
const members = membersByType.get(definition.nodeId) ?? new Set<string>();
|
||||
const body = node.childForFieldName('body');
|
||||
for (const member of body?.namedChildren ?? []) {
|
||||
if (member.type !== 'function_item' && member.type !== 'function_signature_item') continue;
|
||||
const name = member.childForFieldName('name')?.text;
|
||||
if (name !== undefined) members.add(name);
|
||||
}
|
||||
membersByType.set(definition.nodeId, members);
|
||||
}
|
||||
}
|
||||
return { externCrateAliases, opaqueWrappers, opaquePointees, membersByType };
|
||||
}
|
||||
|
||||
/** No qualified-tail guessing, projections or callable types after autoderef. */
|
||||
export function canFlattenRustPointerTarget(
|
||||
target: string,
|
||||
scope: ScopeId,
|
||||
indexes: ScopeResolutionIndexes,
|
||||
safety: RustPointerFieldSafety,
|
||||
): boolean {
|
||||
if (target.includes('::') || /[()[\]]/.test(target) || target.startsWith('<')) return false;
|
||||
const base = target.split('<')[0]!.trim();
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(base)) return false;
|
||||
const definition = findClassBindingInScope(scope, base, indexes, undefined, {
|
||||
uniqueQualifiedNameFallback: false,
|
||||
purpose: 'type',
|
||||
});
|
||||
if (definition === undefined || safety.opaquePointees.has(definition.nodeId)) return false;
|
||||
for (const member of safety.membersByType.get(definition.nodeId) ?? []) {
|
||||
if (POINTER_MEMBER_NAMES.has(member)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
|
@ -1,12 +1,18 @@
|
|||
import type { ParsedFile, Scope, TypeRef } from 'gitnexus-shared';
|
||||
import type { ParsedFile, Scope, ScopeId, TypeRef } from 'gitnexus-shared';
|
||||
import { lookupLexicalName } from 'gitnexus-shared';
|
||||
import type { ScopeResolutionIndexes } from '../../model/scope-resolution-indexes.js';
|
||||
import { getRustParser } from './query.js';
|
||||
import { stripDynBound } from './interpret.js';
|
||||
import { getTreeSitterBufferSize } from '../../constants.js';
|
||||
import { parseSourceSafe, ParseTimeoutError } from '../../../tree-sitter/safe-parse.js';
|
||||
import type { SyntaxNode } from '../../utils/ast-helpers.js';
|
||||
import { logger } from '../../../logger.js';
|
||||
import { lookupBindingsAt, lookupNameClaim } from '../../scope-resolution/scope/walkers.js';
|
||||
import {
|
||||
buildRustPointerFieldSafety,
|
||||
canFlattenRustPointerTarget,
|
||||
type RustPointerFieldSafety,
|
||||
} from './pointer-field-safety.js';
|
||||
|
||||
/**
|
||||
* Populate type bindings for patterns and iterators that the tree-sitter
|
||||
|
|
@ -147,6 +153,12 @@ export function populateRustRangeBindings(
|
|||
}
|
||||
const treeStore: Map<string, RustTree> | undefined =
|
||||
totalSourceBytes <= TREE_REUSE_SOURCE_BUDGET_BYTES ? new Map() : undefined;
|
||||
const pointerSafety = buildRustPointerFieldSafety(
|
||||
parsedFiles,
|
||||
indexes,
|
||||
(parsed) => getOrParseTree(parser, parsed.filePath, ctx, treeStore)?.rootNode ?? null,
|
||||
(parsed, node) => lexicalScopeForNode(node, scopeRangesByFile.get(parsed.filePath)!)?.id,
|
||||
);
|
||||
|
||||
for (const parsed of parsedFiles) {
|
||||
const tree = getOrParseTree(parser, parsed.filePath, ctx, treeStore);
|
||||
|
|
@ -211,10 +223,25 @@ export function populateRustRangeBindings(
|
|||
const fieldName = field.childForFieldName('name');
|
||||
const fieldType = field.childForFieldName('type');
|
||||
if (fieldName !== null && fieldType !== null) {
|
||||
fields.set(fieldName.text, normalizeFieldType(fieldType.text));
|
||||
fields.set(
|
||||
fieldName.text,
|
||||
normalizeFieldType(
|
||||
fieldType.text,
|
||||
parsed,
|
||||
indexes,
|
||||
declarationScope?.id,
|
||||
pointerSafety,
|
||||
),
|
||||
);
|
||||
if (declarationScope !== undefined) {
|
||||
fieldRefs.set(fieldName.text, {
|
||||
rawName: normalizeFieldType(fieldType.text),
|
||||
rawName: normalizeFieldType(
|
||||
fieldType.text,
|
||||
parsed,
|
||||
indexes,
|
||||
declarationScope.id,
|
||||
pointerSafety,
|
||||
),
|
||||
declaredSpelling: fieldType.text,
|
||||
declaredAtScope: declarationScope.id,
|
||||
lookupPosition: {
|
||||
|
|
@ -248,7 +275,7 @@ export function populateRustRangeBindings(
|
|||
// Publish per-type member bindings for the whole workspace before resolving
|
||||
// assignments. Otherwise an importer processed before its defining file can
|
||||
// miss a field or identity-method type solely because of file order.
|
||||
processFieldTypeBindings(tree.rootNode, parsed, scopeMap);
|
||||
processFieldTypeBindings(tree.rootNode, parsed, scopeMap, indexes, pointerSafety);
|
||||
processIdentityMethodBindings(parsed);
|
||||
}
|
||||
|
||||
|
|
@ -302,6 +329,8 @@ function processFieldTypeBindings(
|
|||
root: SyntaxNode,
|
||||
parsed: ParsedFile,
|
||||
scopeMap: ReadonlyMap<string, Scope>,
|
||||
indexes: ScopeResolutionIndexes,
|
||||
pointerSafety: RustPointerFieldSafety,
|
||||
): void {
|
||||
for (const structNode of root.descendantsOfType('struct_item')) {
|
||||
const nameNode = structNode.childForFieldName('name');
|
||||
|
|
@ -318,8 +347,24 @@ function processFieldTypeBindings(
|
|||
const fieldType = field.childForFieldName('type');
|
||||
if (fieldName === null || fieldType === null) continue;
|
||||
|
||||
const normalizedType = normalizeFieldType(fieldType.text);
|
||||
injectTypeBinding(structScope, fieldName.text, normalizedType);
|
||||
const normalizedType = normalizeFieldType(
|
||||
fieldType.text,
|
||||
parsed,
|
||||
indexes,
|
||||
structScope.id,
|
||||
pointerSafety,
|
||||
);
|
||||
injectTypeBinding(structScope, fieldName.text, {
|
||||
rawName: normalizedType,
|
||||
declaredSpelling: fieldType.text,
|
||||
declaredAtScope: structScope.id,
|
||||
lookupPosition: {
|
||||
startLine: fieldType.startPosition.row + 1,
|
||||
startCol: fieldType.startPosition.column,
|
||||
},
|
||||
lookupPurpose: 'type',
|
||||
source: 'annotation',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -341,14 +386,159 @@ function findScopeForNode(
|
|||
return null;
|
||||
}
|
||||
|
||||
function normalizeFieldType(text: string): string {
|
||||
/**
|
||||
* Owning pointers that auto-deref on a method call: `self.repo.save()` on a
|
||||
* `Box<User>`, `Rc<User>` or `Arc<User>` field reaches `User::save` with no
|
||||
* explicit step, so the field is typed as its argument.
|
||||
*
|
||||
* Interior-mutability and container wrappers are left as written on purpose.
|
||||
* `RefCell`, `Cell`, `Mutex` and `RwLock` need `.borrow()`, `.get()`,
|
||||
* `.lock()` or `.read()` first, `Option` needs a match or an unwrap, and
|
||||
* `Vec` needs an index or an iterator, so the receiver's type really is the
|
||||
* wrapper. Element access into those goes through `unwrapGeneric`.
|
||||
*/
|
||||
const DEREF_TRANSPARENT_POINTERS: ReadonlySet<string> = new Set(['Box', 'Rc', 'Arc']);
|
||||
|
||||
const STANDARD_POINTER_PATHS: ReadonlySet<string> = new Set([
|
||||
'std::boxed::Box',
|
||||
'alloc::boxed::Box',
|
||||
'std::rc::Rc',
|
||||
'alloc::rc::Rc',
|
||||
'std::sync::Arc',
|
||||
'alloc::sync::Arc',
|
||||
]);
|
||||
|
||||
/** A familiar terminal name alone does not establish standard-library ownership. */
|
||||
function isStandardPointer(
|
||||
spelling: string,
|
||||
parsed: ParsedFile,
|
||||
indexes: ScopeResolutionIndexes,
|
||||
scopeId: ScopeId,
|
||||
externCrateAliases: ReadonlySet<string>,
|
||||
): boolean {
|
||||
const absolute = spelling.startsWith('::');
|
||||
const path = absolute ? spelling.slice(2) : spelling;
|
||||
const root = path.split('::')[0];
|
||||
if (externCrateAliases.has(root)) return false;
|
||||
const claim = lookupNameClaim(scopeId, root, indexes, { purpose: 'type' });
|
||||
if (path.includes('::')) {
|
||||
return STANDARD_POINTER_PATHS.has(path) && (absolute || claim.status === 'absent');
|
||||
}
|
||||
if (!DEREF_TRANSPARENT_POINTERS.has(path)) return false;
|
||||
if (claim.status === 'absent') {
|
||||
if (path !== 'Box') return false;
|
||||
// An unresolved glob can shadow the prelude without a materialized binding.
|
||||
const ancestors = new Set<ScopeId>();
|
||||
let current: ScopeId | null = scopeId;
|
||||
while (current !== null && !ancestors.has(current)) {
|
||||
ancestors.add(current);
|
||||
current = indexes.scopeTree.getScope(current)?.parent ?? null;
|
||||
}
|
||||
return !parsed.parsedImports.some(
|
||||
(entry) =>
|
||||
entry.kind === 'wildcard' &&
|
||||
(entry.declaredAtScope === undefined || ancestors.has(entry.declaredAtScope)),
|
||||
);
|
||||
}
|
||||
if (claim.bindings.length > 0 || claim.imports.length === 0 || claim.scope === undefined)
|
||||
return false;
|
||||
const owner = claim.scope;
|
||||
const imports = parsed.parsedImports.filter(
|
||||
(entry) =>
|
||||
'localName' in entry &&
|
||||
entry.localName === path &&
|
||||
(entry.declaredAtScope === owner.id ||
|
||||
(entry.declaredAtScope === undefined && owner.kind === 'Module')) &&
|
||||
claim.imports.some(
|
||||
(edge) =>
|
||||
edge.atRange !== undefined &&
|
||||
entry.atRange !== undefined &&
|
||||
edge.atRange.startLine === entry.atRange.startLine &&
|
||||
edge.atRange.startCol === entry.atRange.startCol,
|
||||
),
|
||||
);
|
||||
return (
|
||||
imports.length > 0 &&
|
||||
claim.imports.every((edge) => edge.targetFile === null) &&
|
||||
imports.every((entry) => {
|
||||
const importedPath = entry.targetRaw.replace(/^::/, '');
|
||||
return (
|
||||
STANDARD_POINTER_PATHS.has(importedPath) &&
|
||||
!externCrateAliases.has(importedPath.split('::')[0]!) &&
|
||||
(entry.targetRaw.startsWith('::') ||
|
||||
lookupNameClaim(owner.id, importedPath.split('::')[0], indexes, { purpose: 'type' })
|
||||
.status === 'absent')
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** `&T`, `&mut T`, `&'a T`, `&'a mut T`. A struct field that borrows always
|
||||
* carries a lifetime, so the lifetime form is the common one. */
|
||||
const BORROW_PREFIX_RE = /^&\s*(?:'[A-Za-z_][A-Za-z0-9_]*\s+)?(?:mut\s+)?/;
|
||||
|
||||
function stripBorrow(text: string): string {
|
||||
let t = text.trim();
|
||||
if (t.startsWith('&')) t = t.replace(/^&\s*(mut\s+)?/, '');
|
||||
while (t.startsWith('&')) t = t.replace(BORROW_PREFIX_RE, '').trim();
|
||||
return t;
|
||||
}
|
||||
|
||||
/** Final `::` segment: `std::rc::Rc` -> `Rc`. */
|
||||
function lastPathSegment(text: string): string {
|
||||
const lastColon = text.lastIndexOf('::');
|
||||
return lastColon === -1 ? text : text.slice(lastColon + 2);
|
||||
}
|
||||
|
||||
/** `Box<User>` / `std::rc::Rc<Box<User>>` -> `User`; anything else as is. */
|
||||
function unwrapDerefPointers(text: string, ownsPointer: (spelling: string) => boolean): string {
|
||||
let t = stripBorrow(text);
|
||||
for (;;) {
|
||||
const open = t.indexOf('<');
|
||||
if (open === -1 || !t.endsWith('>')) return t;
|
||||
if (!ownsPointer(t.slice(0, open).trim())) return t;
|
||||
const args = t.slice(open + 1, -1).trim();
|
||||
const comma = findTopLevelComma(args);
|
||||
t = stripBorrow(comma === -1 ? args : args.slice(0, comma));
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeFieldType(
|
||||
text: string,
|
||||
parsed?: ParsedFile,
|
||||
indexes?: ScopeResolutionIndexes,
|
||||
scopeId?: ScopeId,
|
||||
pointerSafety?: RustPointerFieldSafety,
|
||||
): string {
|
||||
// `Box<dyn Repo + Send>` dispatches on the trait, the same as that spelling
|
||||
// on a parameter (#2604).
|
||||
let unwrapped = false;
|
||||
let declined = false;
|
||||
let t = stripDynBound(
|
||||
unwrapDerefPointers(text, (spelling) => {
|
||||
if (
|
||||
parsed === undefined ||
|
||||
indexes === undefined ||
|
||||
scopeId === undefined ||
|
||||
pointerSafety === undefined ||
|
||||
!isStandardPointer(spelling, parsed, indexes, scopeId, pointerSafety.externCrateAliases)
|
||||
)
|
||||
return false;
|
||||
if (pointerSafety.opaqueWrappers.has(lastPathSegment(spelling))) {
|
||||
declined = true;
|
||||
return false;
|
||||
}
|
||||
unwrapped = true;
|
||||
return true;
|
||||
}),
|
||||
);
|
||||
if (
|
||||
declined ||
|
||||
(unwrapped && !canFlattenRustPointerTarget(t, scopeId!, indexes!, pointerSafety!))
|
||||
)
|
||||
return stripBorrow(text);
|
||||
const bracket = t.indexOf('<');
|
||||
if (bracket !== -1) t = t.slice(0, bracket);
|
||||
const lastColon = t.lastIndexOf('::');
|
||||
if (lastColon !== -1) t = t.slice(lastColon + 2);
|
||||
return t.trim();
|
||||
return lastPathSegment(t.trim()).trim();
|
||||
}
|
||||
|
||||
/** Get-or-create the inner map for `file` and record `name -> value`. */
|
||||
|
|
@ -894,7 +1084,7 @@ function findFieldTypeAcrossFiles(
|
|||
structName: string,
|
||||
fieldName: string,
|
||||
allParsedFiles: readonly ParsedFile[],
|
||||
): string | null {
|
||||
): TypeRef | null {
|
||||
for (const pf of allParsedFiles) {
|
||||
for (const scope of pf.scopes) {
|
||||
if (scope.kind !== 'Class') continue;
|
||||
|
|
@ -903,7 +1093,7 @@ function findFieldTypeAcrossFiles(
|
|||
);
|
||||
if (!hasDef) continue;
|
||||
const tb = scope.typeBindings.get(fieldName);
|
||||
if (tb !== undefined) return tb.rawName;
|
||||
if (tb !== undefined) return tb;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
/**
|
||||
* Rust: trait implementations + ambiguous module import disambiguation
|
||||
*/
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import path from 'path';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import {
|
||||
FIXTURES,
|
||||
CROSS_FILE_FIXTURES,
|
||||
|
|
@ -13,6 +15,7 @@ import {
|
|||
findDanglingEdges,
|
||||
edgeSet,
|
||||
runPipelineFromRepo,
|
||||
writeFixtureRepo,
|
||||
type PipelineResult,
|
||||
type RelEdge,
|
||||
} from './helpers.js';
|
||||
|
|
@ -1503,6 +1506,160 @@ describe('Field type resolution (Rust)', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Smart-pointer and borrowed struct fields as call receivers (#3498)
|
||||
//
|
||||
// The struct-field normalizer cut everything from the first `<`, so a
|
||||
// `Box<User>` field was typed `Box` and `self.repo.save()` bound nothing. It
|
||||
// also stripped `&` but not a lifetime, so `&'a User` came out as `'a User`.
|
||||
// Box, Rc and Arc auto-deref on a method call, so they now unwrap to their
|
||||
// argument. RefCell, Cell, Mutex, RwLock, Option and Vec need `.borrow()`,
|
||||
// `.get()`, `.lock()`, `.read()`, a match or an index first, so a direct call
|
||||
// on them must still bind nothing. `Address` and `User` both declare `save`,
|
||||
// so a by-name fallback cannot pass the positive rows.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Rust smart-pointer field receivers (#3498)', () => {
|
||||
let result: PipelineResult;
|
||||
let root: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-rust-pointer-fields-'));
|
||||
writeFixtureRepo(root, {
|
||||
'address.rs': `pub struct Address;
|
||||
|
||||
impl Address {
|
||||
pub fn save(&self) {}
|
||||
}
|
||||
`,
|
||||
'user.rs': `pub struct User;
|
||||
|
||||
impl User {
|
||||
pub fn save(&self) {}
|
||||
}
|
||||
`,
|
||||
'saver.rs': `pub trait Saver {
|
||||
fn persist(&self);
|
||||
}
|
||||
`,
|
||||
'main.rs': `mod address;
|
||||
mod saver;
|
||||
mod user;
|
||||
use saver::Saver;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
use user::User;
|
||||
|
||||
struct Holder<'a> {
|
||||
boxed: Box<User>,
|
||||
rc: Rc<User>,
|
||||
arc: Arc<User>,
|
||||
qualified_rc: std::rc::Rc<User>,
|
||||
nested: Rc<Box<User>>,
|
||||
borrowed: &'a User,
|
||||
borrowed_mut: &'a mut User,
|
||||
dyn_boxed: Box<dyn Saver>,
|
||||
plain: User,
|
||||
ref_cell: RefCell<User>,
|
||||
cell: Cell<User>,
|
||||
mutex: Mutex<User>,
|
||||
rw_lock: RwLock<User>,
|
||||
option: Option<User>,
|
||||
vec: Vec<User>,
|
||||
arc_ref_cell: Arc<RefCell<User>>,
|
||||
}
|
||||
|
||||
impl<'a> Holder<'a> {
|
||||
fn via_boxed(&self) { self.boxed.save(); }
|
||||
fn via_rc(&self) { self.rc.save(); }
|
||||
fn via_arc(&self) { self.arc.save(); }
|
||||
fn via_qualified_rc(&self) { self.qualified_rc.save(); }
|
||||
fn via_nested(&self) { self.nested.save(); }
|
||||
fn via_borrowed(&self) { self.borrowed.save(); }
|
||||
fn via_borrowed_mut(&self) { self.borrowed_mut.save(); }
|
||||
fn via_dyn_boxed(&self) { self.dyn_boxed.persist(); }
|
||||
fn via_plain(&self) { self.plain.save(); }
|
||||
fn via_ref_cell(&self) { self.ref_cell.save(); }
|
||||
fn via_cell(&self) { self.cell.save(); }
|
||||
fn via_mutex(&self) { self.mutex.save(); }
|
||||
fn via_rw_lock(&self) { self.rw_lock.save(); }
|
||||
fn via_option(&self) { self.option.save(); }
|
||||
fn via_vec(&self) { self.vec.save(); }
|
||||
fn via_arc_ref_cell(&self) { self.arc_ref_cell.save(); }
|
||||
}
|
||||
|
||||
fn via_boxed_param(u: Box<User>) { u.save(); }
|
||||
|
||||
fn main() {}
|
||||
`,
|
||||
});
|
||||
result = await runPipelineFromRepo(root, () => {});
|
||||
}, 60000);
|
||||
|
||||
afterAll(() => {
|
||||
if (root) fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ caller: 'via_boxed', spelling: 'Box<User>', member: 'save', targets: ['Function:user.rs'] },
|
||||
{ caller: 'via_rc', spelling: 'Rc<User>', member: 'save', targets: ['Function:user.rs'] },
|
||||
{ caller: 'via_arc', spelling: 'Arc<User>', member: 'save', targets: ['Function:user.rs'] },
|
||||
{
|
||||
caller: 'via_qualified_rc',
|
||||
spelling: 'std::rc::Rc<User>',
|
||||
member: 'save',
|
||||
targets: ['Function:user.rs'],
|
||||
},
|
||||
{
|
||||
caller: 'via_nested',
|
||||
spelling: 'Rc<Box<User>>',
|
||||
member: 'save',
|
||||
targets: ['Function:user.rs'],
|
||||
},
|
||||
{ caller: 'via_borrowed', spelling: "&'a User", member: 'save', targets: ['Function:user.rs'] },
|
||||
{
|
||||
caller: 'via_borrowed_mut',
|
||||
spelling: "&'a mut User",
|
||||
member: 'save',
|
||||
targets: ['Function:user.rs'],
|
||||
},
|
||||
{
|
||||
caller: 'via_dyn_boxed',
|
||||
spelling: 'Box<dyn Saver>',
|
||||
member: 'persist',
|
||||
targets: ['Function:saver.rs'],
|
||||
},
|
||||
{
|
||||
caller: 'via_plain',
|
||||
spelling: 'User (control)',
|
||||
member: 'save',
|
||||
targets: ['Function:user.rs'],
|
||||
},
|
||||
{
|
||||
caller: 'via_boxed_param',
|
||||
spelling: 'Box<User> parameter (control)',
|
||||
member: 'save',
|
||||
targets: ['Function:user.rs'],
|
||||
},
|
||||
{ caller: 'via_ref_cell', spelling: 'RefCell<User>', member: 'save', targets: [] },
|
||||
{ caller: 'via_cell', spelling: 'Cell<User>', member: 'save', targets: [] },
|
||||
{ caller: 'via_mutex', spelling: 'Mutex<User>', member: 'save', targets: [] },
|
||||
{ caller: 'via_rw_lock', spelling: 'RwLock<User>', member: 'save', targets: [] },
|
||||
{ caller: 'via_option', spelling: 'Option<User>', member: 'save', targets: [] },
|
||||
{ caller: 'via_vec', spelling: 'Vec<User>', member: 'save', targets: [] },
|
||||
{ caller: 'via_arc_ref_cell', spelling: 'Arc<RefCell<User>>', member: 'save', targets: [] },
|
||||
])(
|
||||
'resolves $member called from $caller through $spelling to $targets',
|
||||
({ caller, member, targets }) => {
|
||||
const actual = getRelationships(result, 'CALLS')
|
||||
.filter((edge) => edge.source === caller && edge.target === member)
|
||||
.map((edge) => `${edge.targetLabel}:${edge.targetFilePath}`);
|
||||
expect(actual).toEqual(targets);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 8B: Deep field chain resolution (3-level)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { writeFixtureRepo, runPipelineFromRepo, getRelationships } from './resolvers/helpers.js';
|
||||
const cases = [
|
||||
['dependency macro', 'ext::declare_describe!();', false],
|
||||
['macro-use dependency', '#[macro_use] extern crate ext; declare_describe!();', false],
|
||||
['attribute macro', '#[ext::declare_describe] struct Marker;', false],
|
||||
['derive macro', '#[derive(ext::Describe)] struct Marker;', false],
|
||||
['include-generated items', 'include!("generated.rs");', false],
|
||||
[
|
||||
'nested dependency macro',
|
||||
'fn seed(){ println!("{}", { ext::declare_describe!(); 0 }); }',
|
||||
false,
|
||||
],
|
||||
// Expected limits: unrelated opaque evidence disables inference workspace-wide.
|
||||
['unrelated external import', 'use serde::Serialize;', false],
|
||||
['unrelated macro definition', 'macro_rules! noop { () => {}; }', false],
|
||||
['standard derive', '#[derive(Clone)] struct Marker;', true],
|
||||
['standard macro expression', 'fn seed(){ println!("seed"); }', true],
|
||||
] as const;
|
||||
describe('Rust pointer fields near macro expansion', () => {
|
||||
it.each(cases)(
|
||||
'%s keeps expansion-sensitive pointer ownership',
|
||||
async (_name, prefix, positive) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pointer-expansion-'));
|
||||
try {
|
||||
writeFixtureRepo(root, {
|
||||
'src/lib.rs': `use std::rc::Rc;
|
||||
trait Describe { fn describe(&self); }
|
||||
${prefix}
|
||||
struct User; impl User { fn describe(&self) {} }
|
||||
struct Other; impl Other { fn describe(&self) {} }
|
||||
struct Holder { repo: Rc<User> }
|
||||
fn probe(h: &Holder) { h.repo.describe(); }
|
||||
`,
|
||||
});
|
||||
const r = await runPipelineFromRepo(root, () => {});
|
||||
const targets = getRelationships(r, 'CALLS')
|
||||
.filter((e) => e.source === 'probe')
|
||||
.map((e) => e.rel.targetId);
|
||||
expect(targets).toEqual(positive ? ['Function:src/lib.rs:User.describe#0'] : []);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
180000,
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { writeFixtureRepo, runPipelineFromRepo, getRelationships } from './resolvers/helpers.js';
|
||||
const cases = [
|
||||
[
|
||||
'super inherent',
|
||||
'use std::rc::Rc;\nstruct User; impl User { fn save(&self) {} }\nmod ext { impl super::User { pub fn as_ref(&self) {} } }\nstruct Holder { repo: Rc<User> }\nimpl Holder { fn probe(&self) { self.repo.as_ref(); } }',
|
||||
],
|
||||
[
|
||||
'crate inherent',
|
||||
'use std::rc::Rc;\nstruct User; impl User { fn save(&self) {} }\nmod ext { impl crate::User { pub fn as_ref(&self) {} } }\nstruct Holder { repo: Rc<User> }\nimpl Holder { fn probe(&self) { self.repo.as_ref(); } }',
|
||||
],
|
||||
[
|
||||
'declined nested chain',
|
||||
'use std::rc::Rc;\nstruct User;impl User { fn as_ref(&self) {} }\nstruct Holder { repo: Rc<User> }\nstruct Outer { holder: Holder }\nfn probe(o: &Outer) { o.holder.repo.as_ref(); }',
|
||||
],
|
||||
[
|
||||
'unresolved external trait',
|
||||
'use std::rc::Rc;\nuse external::Describe;\nstruct User; impl User { fn describe(&self) {} }\nstruct Holder { repo: Rc<User> }\nimpl Holder { fn probe(&self) { self.repo.describe(); } }',
|
||||
],
|
||||
[
|
||||
'macro blanket trait',
|
||||
'use std::rc::Rc;\nmacro_rules! declare_describe { () => { trait Describe { fn describe(&self); } impl<T> Describe for T { fn describe(&self) {} } } }\ndeclare_describe!();\nstruct User; impl User { fn describe(&self) {} }\nstruct Holder { repo: Rc<User> }\nimpl Holder { fn probe(&self) { self.repo.describe(); } }',
|
||||
],
|
||||
] as const;
|
||||
describe('Rust pointer fields with opaque dispatch evidence', () => {
|
||||
it.each(cases)(
|
||||
'%s does not invent pointee dispatch',
|
||||
async (_name, source) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pointer-synthesis-'));
|
||||
try {
|
||||
writeFixtureRepo(root, {
|
||||
'src/lib.rs':
|
||||
source +
|
||||
'\nstruct Unrelated; impl Unrelated { fn as_ref(&self) {} fn describe(&self) {} }',
|
||||
});
|
||||
const r = await runPipelineFromRepo(root, () => {});
|
||||
const edges = getRelationships(r, 'CALLS').filter((e) => e.source === 'probe');
|
||||
expect(edges.map((e) => e.rel.targetId)).toEqual([]);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
180000,
|
||||
);
|
||||
});
|
||||
154
gitnexus/test/integration/rust-smart-pointer-dispatch.test.ts
Normal file
154
gitnexus/test/integration/rust-smart-pointer-dispatch.test.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
getRelationships,
|
||||
runPipelineFromRepo,
|
||||
writeFixtureRepo,
|
||||
type PipelineResult,
|
||||
} from './resolvers/helpers.js';
|
||||
|
||||
const roots: string[] = [];
|
||||
afterAll(() => {
|
||||
for (const root of roots) fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function analyze(files: Record<string, string>): Promise<PipelineResult> {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-rust-pointer-dispatch-'));
|
||||
roots.push(root);
|
||||
writeFixtureRepo(root, files);
|
||||
return runPipelineFromRepo(root, () => {}, {});
|
||||
}
|
||||
|
||||
describe('pointer and pointee member ownership', () => {
|
||||
let result: PipelineResult;
|
||||
beforeAll(async () => {
|
||||
result = await analyze({
|
||||
'main.rs': `use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
struct User;
|
||||
impl User { fn as_ref(&self) {} fn save(&self) {} }
|
||||
struct SafeUser;
|
||||
impl SafeUser { fn save(&self) {} }
|
||||
struct Holder {
|
||||
boxed: Box<User>, rc: Rc<User>, arc: Arc<User>,
|
||||
safe_boxed: Box<SafeUser>, safe_rc: Rc<SafeUser>, safe_arc: Arc<SafeUser>
|
||||
}
|
||||
fn boxed_builtin(h: &Holder) { h.boxed.as_ref(); }
|
||||
fn rc_builtin(h: &Holder) { h.rc.as_ref(); }
|
||||
fn arc_builtin(h: &Holder) { h.arc.as_ref(); }
|
||||
fn boxed_target(h: &Holder) { h.boxed.save(); }
|
||||
fn rc_target(h: &Holder) { h.rc.save(); }
|
||||
fn arc_target(h: &Holder) { h.arc.save(); }
|
||||
fn safe_boxed(h: &Holder) { h.safe_boxed.save(); }
|
||||
fn safe_rc(h: &Holder) { h.safe_rc.save(); }
|
||||
fn safe_arc(h: &Holder) { h.safe_arc.save(); }
|
||||
`,
|
||||
});
|
||||
}, 120000);
|
||||
|
||||
it.each(['boxed_builtin', 'rc_builtin', 'arc_builtin'])(
|
||||
'%s never substitutes User.as_ref for a pointer method',
|
||||
(name) => {
|
||||
expect(getRelationships(result, 'CALLS').filter((e) => e.source === name)).toEqual([]);
|
||||
},
|
||||
);
|
||||
it.each(['boxed_target', 'rc_target', 'arc_target'])(
|
||||
'%s conservatively leaves other methods unresolved on a conflicted field',
|
||||
(name) => {
|
||||
// save() is legal Rust. Whole-field refusal deliberately loses this edge
|
||||
// until dispatch can represent ordered autoderef for individual members.
|
||||
expect(getRelationships(result, 'CALLS').filter((e) => e.source === name)).toEqual([]);
|
||||
},
|
||||
);
|
||||
it.each(['safe_boxed', 'safe_rc', 'safe_arc'])(
|
||||
'%s still resolves an unconflicted pointee method',
|
||||
(name) => {
|
||||
expect(
|
||||
getRelationships(result, 'CALLS')
|
||||
.filter((e) => e.source === name && e.target === 'save')
|
||||
.map((e) => e.rel.targetId),
|
||||
).toEqual(['Function:main.rs:SafeUser.save#0']);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('pointer declaration ownership', () => {
|
||||
it('does not replace a qualified pointee with a local namesake', async () => {
|
||||
const result = await analyze({
|
||||
'src/lib.rs': `mod remote;
|
||||
struct User;
|
||||
impl User { fn save(&self) {} }
|
||||
struct Holder { repo: Box<remote::User> }
|
||||
impl Holder {
|
||||
fn direct(&self) { self.repo.save(); }
|
||||
fn assigned(&self) { let value = self.repo; value.save(); }
|
||||
}
|
||||
fn destructured(h: Holder) { let Holder { repo } = h; repo.save(); }
|
||||
`,
|
||||
'src/remote.rs': 'pub struct User; impl User { pub fn save(&self) {} }',
|
||||
});
|
||||
for (const name of ['direct', 'assigned', 'destructured']) {
|
||||
expect(
|
||||
getRelationships(result, 'CALLS').filter(
|
||||
(e) => e.source === name && e.target === 'save' && e.targetFilePath === 'src/lib.rs',
|
||||
),
|
||||
).toEqual([]);
|
||||
}
|
||||
}, 120000);
|
||||
|
||||
it.each([
|
||||
['standard crate', '', true],
|
||||
[
|
||||
'crate alias',
|
||||
`#![no_std]
|
||||
extern crate self as std;
|
||||
pub mod boxed {
|
||||
pub struct Box<T> { pub value: T }
|
||||
impl<T> Box<T> { pub fn save(&self) {} }
|
||||
}
|
||||
`,
|
||||
false,
|
||||
],
|
||||
] as const)(
|
||||
'%s respects absolute wrapper ownership',
|
||||
async (_name, prefix, standard) => {
|
||||
const result = await analyze({
|
||||
'src/lib.rs': `${prefix}
|
||||
struct User;
|
||||
impl User { fn save(&self) {} }
|
||||
struct Holder { repo: ::std::boxed::Box<User> }
|
||||
impl Holder { fn direct(&self) { self.repo.save(); } }
|
||||
`,
|
||||
});
|
||||
const calls = getRelationships(result, 'CALLS').filter(
|
||||
(e) => e.source === 'direct' && e.rel.targetId === 'Function:src/lib.rs:User.save#0',
|
||||
);
|
||||
expect(calls).toHaveLength(standard ? 1 : 0);
|
||||
},
|
||||
120000,
|
||||
);
|
||||
|
||||
it.each(['Rc<User>', '&Rc<User>'])(
|
||||
'does not bypass a local trait implementation on %s',
|
||||
async (receiver) => {
|
||||
const result = await analyze({
|
||||
'main.rs': `use std::rc::Rc;
|
||||
trait Save { fn save(&self); }
|
||||
struct User;
|
||||
impl User { fn save(&self) {} }
|
||||
impl Save for ${receiver} { fn save(&self) {} }
|
||||
struct Holder { repo: Rc<User> }
|
||||
fn probe(h: &Holder) { h.repo.save(); }
|
||||
`,
|
||||
});
|
||||
expect(
|
||||
getRelationships(result, 'CALLS').filter(
|
||||
(e) => e.source === 'probe' && e.rel.targetId === 'Function:main.rs:User.save#0',
|
||||
),
|
||||
).toEqual([]);
|
||||
},
|
||||
120000,
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { getRelationships, runPipelineFromRepo, writeFixtureRepo } from './resolvers/helpers.js';
|
||||
const custom = (name: string) =>
|
||||
`mod custom { pub struct ${name}<T> { pub value:T } impl<T> ${name}<T> { pub fn save(&self) {} } }`;
|
||||
const localBox = `struct Box<T> { value:T } impl<T> Box<T> { fn save(&self) {} }`;
|
||||
describe('Rust field pointer ownership', () => {
|
||||
it.each([
|
||||
['custom Box', 'custom::Box<User>', custom('Box'), ['Function:src/lib.rs:custom.Box.save#0']],
|
||||
['custom Rc', 'custom::Rc<User>', custom('Rc'), ['Function:src/lib.rs:custom.Rc.save#0']],
|
||||
['custom Arc', 'custom::Arc<User>', custom('Arc'), ['Function:src/lib.rs:custom.Arc.save#0']],
|
||||
['shadowed prelude Box', 'Box<User>', localBox, ['Function:src/lib.rs:Box.save#0']],
|
||||
['foreign imported Rc', 'Rc<User>', 'use foreign::Rc;', []],
|
||||
['nested custom Box', 'std::boxed::Box<custom::Box<User>>', custom('Box'), []],
|
||||
['std qualified Box', 'std::boxed::Box<User>', '', ['Function:src/lib.rs:User.save#0']],
|
||||
['std qualified Rc', 'std::rc::Rc<User>', '', ['Function:src/lib.rs:User.save#0']],
|
||||
['std qualified Arc', 'std::sync::Arc<User>', '', ['Function:src/lib.rs:User.save#0']],
|
||||
['prelude Box', 'Box<User>', '', ['Function:src/lib.rs:User.save#0']],
|
||||
['imported standard Rc', 'Rc<User>', 'use std::rc::Rc;', ['Function:src/lib.rs:User.save#0']],
|
||||
[
|
||||
'imported standard Arc',
|
||||
'Arc<User>',
|
||||
'use std::sync::Arc;',
|
||||
['Function:src/lib.rs:User.save#0'],
|
||||
],
|
||||
] as const)(
|
||||
'%s',
|
||||
async (_label, type, declaration, expectedTargets) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-rust-pointer-'));
|
||||
try {
|
||||
writeFixtureRepo(root, {
|
||||
'Cargo.toml': '[package]\nname="pointer_probe"\nversion="0.1.0"\nedition="2021"\n',
|
||||
'src/lib.rs': `${declaration}
|
||||
struct User; impl User { fn save(&self) {} }
|
||||
struct Decoy; impl Decoy { fn save(&self) {} }
|
||||
struct Host { repo: ${type} }
|
||||
impl Host { fn probe(&self) { self.repo.save(); } }
|
||||
`,
|
||||
});
|
||||
const result = await runPipelineFromRepo(root, () => {}, {});
|
||||
const targets = getRelationships(result, 'CALLS')
|
||||
.filter((e) => e.source === 'probe')
|
||||
.map((e) => e.rel.targetId);
|
||||
expect(targets).toEqual(expectedTargets);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
120000,
|
||||
);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue