From 4a16f472bff6d27e76143d04425e562324a8d743 Mon Sep 17 00:00:00 2001 From: CodeGraph Test Date: Sat, 3 Oct 2026 14:03:09 +0800 Subject: [PATCH 1/2] fix(resolve): keep Rust method calls that lost their receiver off functions and std names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The extractor keeps a Rust receiver only for an identifier, `self`, one `self.` hop and a `path::call()` receiver. Any other receiver (a plain call, a deeper field chain, `vec![…]`, `?`, `.await`, a chain broken across lines) leaves the bare method name, so `root().join(name)` resolved like a bare call and bound the project's only `join` method at 0.9. In this repository, 964 calls from 169 files landed on `LineIndex::join`. Resolution now reads such a call's shape from its source line, where the ref's column is the call expression's start. These KEEP-RUST rules are stricter than upstream: - R1: a call with neither receiver nor path never binds a method; - R2: a call whose receiver was dropped binds only a method, never through a `use` binding and never by a fuzzy guess; - R3: such a call binds nothing when its name is one of 307 std methods; - R4: a bare `Ok`/`Err`/`Some`/`None` binds a project variant only when a `use` naming it, or a glob, is in its own block and module. `rust_code_mask` moves from the Tauri resolver into a shared `rust_lexical` module, which also records where each `use` item is visible. Extraction version 21. On this repository the four rules remove 3,284 edges (R3 2,743, R1 296, R4 193, R2 52). Every sampled one is false, and no golden moves. --- .../codegraph-resolve/src/frameworks/tauri.rs | 210 +---- crates/codegraph-resolve/src/lib.rs | 1 + crates/codegraph-resolve/src/name_matcher.rs | 721 +++++++++++++++++- crates/codegraph-resolve/src/resolver.rs | 18 + crates/codegraph-resolve/src/rust_lexical.rs | 461 +++++++++++ crates/codegraph-resolve/src/source_facts.rs | 11 + .../tier2/rust_receiver/src/chains.rs | 23 + .../fixtures/tier2/rust_receiver/src/lib.rs | 33 + .../tier2/rust_receiver/src/outcome.rs | 4 + .../fixtures/tier2/rust_receiver/src/store.rs | 35 + .../fixtures/tier2/rust_receiver/src/util.rs | 3 + .../tier2/rust_receiver/src/variants.rs | 44 ++ .../tests/tier2_false_edges.rs | 125 +++ crates/codegraph-store/src/index_state.rs | 2 +- crates/codegraph-store/tests/index_state.rs | 2 +- docs/upstream-sync/UPSTREAM.md | 55 ++ 16 files changed, 1534 insertions(+), 214 deletions(-) create mode 100644 crates/codegraph-resolve/src/rust_lexical.rs create mode 100644 crates/codegraph-resolve/tests/fixtures/tier2/rust_receiver/src/chains.rs create mode 100644 crates/codegraph-resolve/tests/fixtures/tier2/rust_receiver/src/lib.rs create mode 100644 crates/codegraph-resolve/tests/fixtures/tier2/rust_receiver/src/outcome.rs create mode 100644 crates/codegraph-resolve/tests/fixtures/tier2/rust_receiver/src/store.rs create mode 100644 crates/codegraph-resolve/tests/fixtures/tier2/rust_receiver/src/util.rs create mode 100644 crates/codegraph-resolve/tests/fixtures/tier2/rust_receiver/src/variants.rs diff --git a/crates/codegraph-resolve/src/frameworks/tauri.rs b/crates/codegraph-resolve/src/frameworks/tauri.rs index 34bae39f..6c120b36 100644 --- a/crates/codegraph-resolve/src/frameworks/tauri.rs +++ b/crates/codegraph-resolve/src/frameworks/tauri.rs @@ -45,6 +45,7 @@ use std::sync::OnceLock; use codegraph_core::types::{EdgeKind, Language, Node, NodeKind}; use crate::framework::FrameworkResolver; +use crate::rust_lexical::{read_ident, rust_code_mask}; use crate::types::{RefView, ResolutionContext, ResolvedBy, ResolvedRef}; /// The Rust attribute that marks a function as an IPC command. Held as a `&str` @@ -451,25 +452,6 @@ fn skip_balanced(bytes: &[u8], mask: &[bool], i: usize, open: u8, close: u8) -> None } -fn read_ident(bytes: &[u8], i: usize) -> Option<(&str, usize)> { - if i >= bytes.len() || !is_ident_start(bytes[i]) { - return None; - } - let mut j = i; - while j < bytes.len() && is_ident_byte(bytes[j]) { - j += 1; - } - std::str::from_utf8(&bytes[i..j]).ok().map(|s| (s, j)) -} - -fn is_ident_start(b: u8) -> bool { - b.is_ascii_alphabetic() || b == b'_' || b >= 0x80 -} - -fn is_ident_byte(b: u8) -> bool { - b.is_ascii_alphanumeric() || b == b'_' || b >= 0x80 -} - fn is_rust_source(path: &str) -> bool { path.ends_with(".rs") && !FIXTURE_PREFIXES.iter().any(|p| path.starts_with(p)) } @@ -480,196 +462,6 @@ fn is_js_family(path: &str) -> bool { .any(|ext| path.ends_with(ext)) } -// --------------------------------------------------------------------------- -// The Rust lexical mask -// --------------------------------------------------------------------------- - -/// Byte mask over `src`: `true` where the byte is ordinary Rust code. -/// -/// One forward scan tracking the states a textual attribute match can hide in, -/// each of which was measured to fabricate an edge (or, for the last one, to lose -/// a real command) when unhandled: -/// -/// * line comments, including `///` and `//!`; -/// * block comments, **nestable** — Rust nests, unlike C++, so a C++-shaped mask -/// closes at the first `*/` and reads the trailing bytes as code; -/// * raw strings `r"…"` / `r#"…"#` and their byte forms `br##"…"##`, with the -/// HASH COUNT captured so a custom delimiter still closes correctly; -/// * normal and byte strings, `"…"` / `b"…"`, with `\` escapes; -/// * char literals with `\` escapes (`'\''`) that DECLINE to open on a lifetime -/// `'a` — a mask that treats every `'` as a delimiter masks from the lifetime -/// forward and can swallow a genuine attribute, which is a silent feature death -/// rather than a fabrication. -fn rust_code_mask(src: &str) -> Vec { - let bytes = src.as_bytes(); - let mut mask = vec![true; bytes.len()]; - let mut i = 0usize; - while i < bytes.len() { - let b = bytes[i]; - if b == b'/' && bytes.get(i + 1) == Some(&b'/') { - while i < bytes.len() && bytes[i] != b'\n' { - mask[i] = false; - i += 1; - } - continue; - } - if b == b'/' && bytes.get(i + 1) == Some(&b'*') { - i = mask_nested_block_comment(bytes, &mut mask, i); - continue; - } - if is_ident_start(b) { - if let Some((quote, hashes)) = raw_string_prefix(bytes, i) { - mask[i..quote].fill(false); - i = mask_raw_string(bytes, &mut mask, quote, hashes); - continue; - } - let (_, after) = read_ident(bytes, i).expect("ident start"); - i = after; - continue; - } - if b == b'"' { - i = mask_quoted(bytes, &mut mask, i, b'"', false); - continue; - } - if b == b'\'' { - if char_literal_end(bytes, i).is_some() { - i = mask_quoted(bytes, &mut mask, i, b'\'', true); - } else { - i += 1; - } - continue; - } - i += 1; - } - mask -} - -/// Mask a nestable block comment starting at `i` (`/*`). Returns the index just -/// past the outermost `*/`. -fn mask_nested_block_comment(bytes: &[u8], mask: &mut [bool], i: usize) -> usize { - let mut depth = 0usize; - let mut k = i; - while k < bytes.len() { - if bytes[k] == b'/' && bytes.get(k + 1) == Some(&b'*') { - depth += 1; - mask[k] = false; - mask[k + 1] = false; - k += 2; - continue; - } - if bytes[k] == b'*' && bytes.get(k + 1) == Some(&b'/') { - mask[k] = false; - mask[k + 1] = false; - k += 2; - depth -= 1; - if depth == 0 { - return k; - } - continue; - } - mask[k] = false; - k += 1; - } - bytes.len() -} - -/// If a raw-string prefix (`r"`, `r##"`, `br"`, `br##"`) starts at `i`, return -/// the index of its opening quote and its hash count. -fn raw_string_prefix(bytes: &[u8], i: usize) -> Option<(usize, usize)> { - let mut j = i; - if bytes.get(j) == Some(&b'b') { - j += 1; - } - if bytes.get(j) != Some(&b'r') { - return None; - } - j += 1; - let hash_start = j; - while bytes.get(j) == Some(&b'#') { - j += 1; - } - if bytes.get(j) != Some(&b'"') { - return None; - } - Some((j, j - hash_start)) -} - -/// Mask a raw string whose opening quote is at `quote` and which closes on a `"` -/// followed by exactly `hashes` `#`. -fn mask_raw_string(bytes: &[u8], mask: &mut [bool], quote: usize, hashes: usize) -> usize { - mask[quote] = false; - let mut k = quote + 1; - while k < bytes.len() { - if bytes[k] == b'"' && closes_raw_string(bytes, k, hashes) { - let end = (k + hashes).min(bytes.len() - 1); - mask[k..=end].fill(false); - return (k + hashes + 1).min(bytes.len()); - } - mask[k] = false; - k += 1; - } - bytes.len() -} - -fn closes_raw_string(bytes: &[u8], quote: usize, hashes: usize) -> bool { - (1..=hashes).all(|n| bytes.get(quote + n) == Some(&b'#')) -} - -/// Mask a `delim`-quoted literal starting at `start`, honouring `\` escapes. -/// -/// `reset_at_newline` is `true` only for char literals: a Rust string may legally -/// span lines, while an unclosed `'` means the disambiguation misjudged a -/// lifetime, and failing open there is safer than masking the rest of the file. -fn mask_quoted( - bytes: &[u8], - mask: &mut [bool], - start: usize, - delim: u8, - reset_at_newline: bool, -) -> usize { - mask[start] = false; - let mut i = start + 1; - while i < bytes.len() { - let b = bytes[i]; - if b == b'\n' && reset_at_newline { - return i; - } - mask[i] = false; - if b == b'\\' { - if i + 1 < bytes.len() { - mask[i + 1] = false; - } - i += 2; - continue; - } - i += 1; - if b == delim { - return i; - } - } - bytes.len() -} - -/// Index just past the closing `'` when `i` opens a genuine char literal, or -/// `None` when it opens a LIFETIME or label. -/// -/// `'\''` takes the escape branch; `'a'` is a literal because the byte after the -/// single character is the closing quote; `'a` in `&'a str` and `'static` are -/// lifetimes, so they open no state at all. -fn char_literal_end(bytes: &[u8], i: usize) -> Option { - if bytes.get(i + 1) == Some(&b'\\') { - let mut k = i + 2; - while k < bytes.len() && bytes[k] != b'\'' && bytes[k] != b'\n' { - k += 1; - } - return (bytes.get(k) == Some(&b'\'')).then_some(k + 1); - } - let rest = std::str::from_utf8(&bytes[i + 1..]).ok()?; - let ch = rest.chars().next()?; - let after = i + 1 + ch.len_utf8(); - (bytes.get(after) == Some(&b'\'')).then_some(after + 1) -} - // --------------------------------------------------------------------------- // Call side (D3): the `invoke('name')` call sites // --------------------------------------------------------------------------- diff --git a/crates/codegraph-resolve/src/lib.rs b/crates/codegraph-resolve/src/lib.rs index 800b8304..2c04616e 100644 --- a/crates/codegraph-resolve/src/lib.rs +++ b/crates/codegraph-resolve/src/lib.rs @@ -31,6 +31,7 @@ mod object_literal; pub mod path_aliases; pub mod pathutil; pub mod resolver; +mod rust_lexical; pub mod snapshot_context; pub mod source_facts; pub mod strip_comments; diff --git a/crates/codegraph-resolve/src/name_matcher.rs b/crates/codegraph-resolve/src/name_matcher.rs index 98aaa690..318a7709 100644 --- a/crates/codegraph-resolve/src/name_matcher.rs +++ b/crates/codegraph-resolve/src/name_matcher.rs @@ -701,6 +701,467 @@ fn is_bare_go_call(reference: &RefView, context: &dyn ResolutionContext) -> bool reference.language == Language::Go && is_receiver_less_call(reference, context) } +/// How a Rust `calls` ref recorded under a bare name was written (KEEP-RUST, +/// P15). The extractor keeps a receiver only for an identifier, `self`, one +/// `self.` hop and a `path::call()` receiver; any other receiver — +/// `root().join(x)`, a deeper field chain, `vec![…]`, `?`, `.await`, indexing, a +/// chain broken across lines — leaves the bare method name, with the ref's +/// column at the start of the whole call expression. The source tells the two +/// apart. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum RustCallShape { + /// `join(x)`: neither receiver nor path, so a free function, never a method. + Bare, + /// `root().join(x)`: a method call whose receiver the extractor dropped. + Method, +} + +/// How far past the ref's column a dropped receiver's `.name(` is looked for. +const RUST_RECEIVER_WINDOW: usize = 4096; + +/// The shape of a Rust `calls` ref whose name has neither `.` nor `::`, read from +/// its source: [`RustCallShape::Bare`] when the call expression starts with the +/// name and a `(` or `::<` follows, [`RustCallShape::Method`] when it starts with +/// something else and a `.name(` follows within [`RUST_RECEIVER_WINDOW`]. +/// `None` — no source, a column off its line, neither pattern — keeps every +/// gate that reads the shape inert. +pub(crate) fn rust_call_shape( + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + let name = reference.reference_name.as_str(); + if reference.language != Language::Rust + || reference.reference_kind != EdgeKind::Calls + || name.is_empty() + || name.contains('.') + || name.contains("::") + { + return None; + } + let facts = context.source_facts(&reference.file_path)?; + let start = rust_ref_offset(reference, &facts)?; + let rest = &facts.source()[start..]; + if let Some(after) = rest.strip_prefix(name) { + if after.bytes().next().is_some_and(is_rust_ident_byte) { + return None; + } + let after = after.trim_start(); + return (after.starts_with('(') || after.starts_with("::<")).then_some(RustCallShape::Bare); + } + let mut end = rest.len().min(RUST_RECEIVER_WINDOW); + while !rest.is_char_boundary(end) { + end -= 1; + } + rust_method_call_follows(&rest[..end], name).then_some(RustCallShape::Method) +} + +/// The byte offset of `reference`'s column in its file, when the column lies on +/// its line at a char boundary. +fn rust_ref_offset(reference: &RefView, facts: &SourceFacts) -> Option { + let line_index = usize::try_from(reference.line).ok()?.checked_sub(1)?; + let line = facts.raw_line(line_index)?; + let column = usize::try_from(reference.column).ok()?; + if column > line.len() || !line.is_char_boundary(column) { + return None; + } + Some(facts.lines().raw_line_start(line_index)? + column) +} + +fn is_rust_ident_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || byte == b'_' || byte >= 0x80 +} + +/// Whether `text` holds a method call of `name`: one `.`, the whole word +/// `name`, an optional `::<…>` turbofish, then `(`, with whitespace allowed +/// between the parts. +fn rust_method_call_follows(text: &str, name: &str) -> bool { + text.match_indices(name).any(|(at, _)| { + let bytes = text.as_bytes(); + let end = at + name.len(); + if bytes.get(end).is_some_and(|b| is_rust_ident_byte(*b)) { + return false; + } + let before = text[..at].trim_end(); + if !before.ends_with('.') || before.ends_with("..") { + return false; + } + let mut after = text[end..].trim_start(); + if let Some(generics) = after.strip_prefix("::<") { + let mut depth = 1usize; + let Some(close) = generics.bytes().position(|b| { + match b { + b'<' => depth += 1, + b'>' => depth -= 1, + _ => {} + } + depth == 0 + }) else { + return false; + }; + after = generics[close + 1..].trim_start(); + } + after.starts_with('(') + }) +} + +/// Method names a Rust call whose receiver the extractor dropped may not bind +/// on the name alone (KEEP-RUST, P15 R3; the Rust analogue of upstream's +/// `JS_BUILTIN_METHODS`, #1987). Every entry is a stable (Rust 1.98) method, +/// callable with method syntax, of `Iterator` / `DoubleEndedIterator`, +/// `Option`, `Result`, slices, `Vec`, `VecDeque`, `str`, `String`, `char` / `u8` +/// ASCII, integers, `Duration` / `Instant`, the std maps and sets and `Entry`, +/// `Path` / `PathBuf` / `OsStr` / `DirEntry` / `OpenOptions`, `Mutex` / `RwLock` +/// / `OnceLock` / `Rc` / `Arc` / `Weak` / `RefCell` / `Cell` / atomics, the +/// `io` / `fmt` I/O traits, `process::Command` / `Child`, `mpsc`, or the +/// conversion and comparison traits — a curated subset, the ones commonly +/// reached through call chains. A unique project method that shares one of +/// these names is no evidence of the receiver's type: `root().join(name)` is +/// `Path::join`. Sorted for binary search; changing it is a resolution change. +const RUST_STD_METHODS: [&str; 307] = [ + "abs", + "all", + "ancestors", + "and_then", + "any", + "append", + "arg", + "args", + "as_bytes", + "as_deref", + "as_deref_mut", + "as_micros", + "as_millis", + "as_mut", + "as_nanos", + "as_os_str", + "as_ref", + "as_secs", + "as_secs_f64", + "as_slice", + "as_str", + "back", + "binary_search", + "binary_search_by", + "binary_search_by_key", + "borrow", + "borrow_mut", + "by_ref", + "bytes", + "canonicalize", + "capacity", + "chain", + "char_indices", + "chars", + "checked_add", + "checked_sub", + "chunks", + "clamp", + "clear", + "clone", + "clone_from", + "cloned", + "cmp", + "collect", + "compare_exchange", + "components", + "concat", + "contains", + "contains_key", + "copied", + "count", + "current_dir", + "cycle", + "dedup", + "dedup_by_key", + "deref", + "deref_mut", + "difference", + "display", + "drain", + "duration_since", + "elapsed", + "ends_with", + "entry", + "enumerate", + "env", + "env_remove", + "eq", + "eq_ignore_ascii_case", + "err", + "exists", + "expect", + "expect_err", + "extend", + "extend_from_slice", + "extension", + "fetch_add", + "fetch_sub", + "file_name", + "file_stem", + "file_type", + "fill", + "filter", + "filter_map", + "find", + "find_map", + "first", + "flat_map", + "flatten", + "flush", + "fmt", + "fold", + "for_each", + "front", + "fuse", + "ge", + "get", + "get_mut", + "get_or_init", + "get_or_insert", + "get_or_insert_with", + "gt", + "hash", + "insert", + "insert_str", + "inspect", + "inspect_err", + "intersection", + "into", + "into_bytes", + "into_inner", + "into_iter", + "into_keys", + "into_os_string", + "into_string", + "into_values", + "is_absolute", + "is_alphabetic", + "is_alphanumeric", + "is_ascii", + "is_ascii_alphabetic", + "is_ascii_alphanumeric", + "is_ascii_digit", + "is_ascii_lowercase", + "is_ascii_punctuation", + "is_ascii_uppercase", + "is_ascii_whitespace", + "is_char_boundary", + "is_dir", + "is_empty", + "is_err", + "is_err_and", + "is_file", + "is_lowercase", + "is_none", + "is_none_or", + "is_ok", + "is_ok_and", + "is_relative", + "is_some", + "is_some_and", + "is_subset", + "is_symlink", + "is_uppercase", + "is_whitespace", + "iter", + "iter_mut", + "join", + "keys", + "kill", + "kind", + "last", + "le", + "len", + "lines", + "lock", + "lt", + "map", + "map_err", + "map_or", + "map_or_else", + "map_while", + "match_indices", + "matches", + "max", + "max_by", + "max_by_key", + "metadata", + "min", + "min_by", + "min_by_key", + "ne", + "next", + "nth", + "ok", + "ok_or", + "ok_or_else", + "open", + "or_default", + "or_else", + "or_insert", + "or_insert_with", + "output", + "parent", + "parse", + "partial_cmp", + "partition", + "path", + "peek", + "peekable", + "pop", + "pop_back", + "pop_front", + "position", + "pow", + "product", + "push", + "push_back", + "push_front", + "push_str", + "read", + "read_dir", + "read_exact", + "read_line", + "read_link", + "read_to_end", + "read_to_string", + "recv", + "reduce", + "remove", + "repeat", + "replace", + "replacen", + "reserve", + "resize", + "retain", + "rev", + "reverse", + "rfind", + "rposition", + "rsplit", + "rsplit_once", + "rsplitn", + "saturating_add", + "saturating_sub", + "scan", + "seek", + "send", + "set", + "set_extension", + "skip", + "skip_while", + "sort", + "sort_by", + "sort_by_key", + "sort_unstable", + "sort_unstable_by", + "sort_unstable_by_key", + "spawn", + "split", + "split_at", + "split_off", + "split_once", + "split_terminator", + "split_whitespace", + "splitn", + "starts_with", + "status", + "stderr", + "stdin", + "stdout", + "step_by", + "strip_prefix", + "strip_suffix", + "sum", + "swap", + "take", + "take_while", + "to_ascii_lowercase", + "to_ascii_uppercase", + "to_digit", + "to_lowercase", + "to_owned", + "to_path_buf", + "to_str", + "to_string", + "to_string_lossy", + "to_uppercase", + "to_vec", + "transpose", + "trim", + "trim_end", + "trim_end_matches", + "trim_matches", + "trim_start", + "trim_start_matches", + "truncate", + "try_borrow", + "try_borrow_mut", + "try_fold", + "try_for_each", + "try_into", + "try_lock", + "try_read", + "try_write", + "union", + "unwrap", + "unwrap_err", + "unwrap_or", + "unwrap_or_default", + "unwrap_or_else", + "unzip", + "upgrade", + "values", + "values_mut", + "wait", + "windows", + "with_extension", + "with_file_name", + "wrapping_add", + "wrapping_sub", + "write", + "write_all", + "write_fmt", + "write_str", + "xor", + "zip", +]; + +fn is_rust_std_method(name: &str) -> bool { + RUST_STD_METHODS.binary_search(&name).is_ok() +} + +/// The prelude's enum variants. A bare one is `Option` / `Result`'s unless a +/// `use` in scope imports a project variant of that name (KEEP-RUST, P15 R4). +fn is_rust_prelude_variant(name: &str) -> bool { + matches!(name, "Ok" | "Err" | "Some" | "None") +} + +/// Whether a `use` visible at `reference`'s call site names its name or is a +/// glob. Unknown source counts as such a `use`, which keeps today's binding. +fn rust_variant_import_in_scope(reference: &RefView, context: &dyn ResolutionContext) -> bool { + let Some(facts) = context.source_facts(&reference.file_path) else { + return true; + }; + let Some(offset) = rust_ref_offset(reference, &facts) else { + return true; + }; + facts.rust_use_in_scope(offset, &reference.reference_name) +} + +/// A Rust method call whose receiver the extractor dropped (KEEP-RUST, P15): +/// `root().join(x)`, or a chain broken across lines. The resolver sends it here +/// ahead of import resolution, and nowhere else. It never binds through a +/// `use`, takes no fuzzy guess, binds only a same-named method (R2), and never +/// one that shares a std method's name (R3); [`match_by_exact_name`] applies +/// both for this shape. +pub fn match_rust_lost_receiver_call( + reference: &RefView, + context: &dyn ResolutionContext, +) -> Option { + match_by_exact_name(reference, context) +} + /// Sticky binding patterns for one plain identifier /// (`localBindingPatterns`, upstream v1.6.1), anchored at the offset they are /// tried from. `\b`/`\w` are ASCII, as in upstream's JavaScript. The @@ -939,6 +1400,15 @@ pub fn match_by_exact_name( { return Some(action); } + // KEEP-RUST (P15): a bare-named Rust call's shape, read from its source. A + // method call whose receiver the extractor dropped never binds a std + // method's name (R3). + let rust_shape = rust_call_shape(reference, context); + if rust_shape == Some(RustCallShape::Method) && is_rust_std_method(&reference.reference_name) { + return None; + } + let rust_bare = rust_shape == Some(RustCallShape::Bare); + let rust_method = rust_shape == Some(RustCallShape::Method); let same_name = context.get_nodes_by_name_shared(&reference.reference_name); // `NAME(...)` where NAME is a function-like macro somewhere in the project // is an expansion or a call to a same-named function — never the macro @@ -966,8 +1436,20 @@ pub fn match_by_exact_name( let candidates: Vec> = reachable .iter() .filter(|n| node_is_eligible_target(reference.reference_kind, n)) - // A receiver-less JS/TS or Go call cannot reach a method (#1714, #1857). - .filter(|n| !((bare_js || bare_go) && n.kind == NodeKind::Method)) + // A receiver-less JS/TS, Go or Rust call cannot reach a method (#1714, + // #1857; P15 R1). + .filter(|n| !((bare_js || bare_go || rust_bare) && n.kind == NodeKind::Method)) + // A Rust method call whose receiver was dropped reaches only a method: + // never a function, type, enum member or import (P15 R2). + .filter(|n| !rust_method || n.kind == NodeKind::Method) + // A bare `Ok` / `Err` / `Some` / `None` is the prelude's unless a `use` + // in its scope imports a project variant of that name (P15 R4). + .filter(|n| { + !(rust_bare + && n.kind == NodeKind::EnumMember + && is_rust_prelude_variant(&reference.reference_name) + && !rust_variant_import_in_scope(reference, context)) + }) // A name the file binds itself shadows every other file's symbol of // that name, so a bare call has no cross-file candidate. .filter(|n| { @@ -3459,6 +3941,12 @@ fn find_best_match<'a, T: Borrow>( /// Fuzzy match — last resort with lower confidence (`matchFuzzy`, /// `name-matcher.ts:1060-1088`). pub fn match_fuzzy(reference: &RefView, context: &dyn ResolutionContext) -> Option { + // A Rust method call whose receiver was dropped takes no fuzzy guess + // (KEEP-RUST, P15 R2); a bare Rust call still never reaches a method (R1). + let rust_shape = rust_call_shape(reference, context); + if rust_shape == Some(RustCallShape::Method) { + return None; + } let lower_name = reference.reference_name.to_lowercase(); let candidates = context.get_nodes_by_lower_name_shared(&lower_name); if candidates.len() > ambiguous_name_ceiling() { @@ -3503,7 +3991,8 @@ pub fn match_fuzzy(reference: &RefView, context: &dyn ResolutionContext) -> Opti context, ))); if bare_js_rejects - || (candidate.kind == NodeKind::Method && is_bare_go_call(reference, context)) + || (candidate.kind == NodeKind::Method + && (is_bare_go_call(reference, context) || rust_shape == Some(RustCallShape::Bare))) // A builtin method call (`res.text()`) whose only same-named project // symbol is some function's closure must decline (#1708). || !is_lexically_reachable(candidate, reference, context) @@ -10046,6 +10535,232 @@ mod tests { )); } + // ====== KEEP-RUST P15 — Rust calls that lost their receiver ====== + + #[test] + fn rust_call_shape_reads_bare_and_dropped_receiver_calls() { + let source = " join(x);\n parse::(s);\n root().join(x);\n root()\n .join(x);\n joined(x);\n root().joined(x);\n 0..len();\n root().collect::>(x);\n"; + let ctx = Ctx::default().file("src/a.rs", source); + let shape = |name: &str, line: i64, column: i64| { + rust_call_shape( + &at_column( + name, + EdgeKind::Calls, + "src/a.rs", + Language::Rust, + line, + column, + ), + &ctx, + ) + }; + assert_eq!(shape("join", 1, 4), Some(RustCallShape::Bare)); + assert_eq!( + shape("parse", 2, 4), + Some(RustCallShape::Bare), + "a turbofish still opens a bare call" + ); + assert_eq!( + shape("join", 3, 4), + Some(RustCallShape::Method), + "root().join dropped its receiver" + ); + assert_eq!( + shape("join", 4, 4), + Some(RustCallShape::Method), + "the `.join(` may sit on a later line" + ); + assert_eq!(shape("join", 6, 4), None, "`joined(` is another name"); + assert_eq!(shape("join", 7, 4), None, "`.joined(` is not `.join(`"); + assert_eq!( + shape("len", 8, 7), + Some(RustCallShape::Bare), + "a range end is a bare call" + ); + assert_eq!( + shape("collect", 9, 4), + Some(RustCallShape::Method), + "a turbofish after a dropped receiver" + ); + assert_eq!(shape("join", 1, 400), None, "a column past the line end"); + assert_eq!(shape("join", 99, 0), None, "a line past the file end"); + // Only Rust `calls` refs whose name has no `.` / `::`, and only with source. + let unknown = |name: &str, kind: EdgeKind, path: &str, lang: Language| { + rust_call_shape(&at_column(name, kind, path, lang, 1, 4), &ctx) + }; + assert_eq!( + unknown("join", EdgeKind::References, "src/a.rs", Language::Rust), + None + ); + assert_eq!( + unknown("p.join", EdgeKind::Calls, "src/a.rs", Language::Rust), + None + ); + assert_eq!( + unknown("a::join", EdgeKind::Calls, "src/a.rs", Language::Rust), + None + ); + assert_eq!( + unknown("join", EdgeKind::Calls, "src/missing.rs", Language::Rust), + None + ); + assert_eq!( + unknown("join", EdgeKind::Calls, "src/a.rs", Language::TypeScript), + None + ); + } + + #[test] + fn rust_receiver_gates_narrow_exact_name_candidates() { + let source = "fn f() {\n root().join(x);\n store().nodes_by_ids(x);\n helper();\n root().build();\n Err(1);\n}\n"; + let method = |name: &str, qualified: &str| { + mk( + &format!("method:{name}"), + NodeKind::Method, + name, + qualified, + "src/lib.rs", + Language::Rust, + ) + }; + let function = |name: &str| { + mk( + &format!("function:{name}"), + NodeKind::Function, + name, + name, + "src/util.rs", + Language::Rust, + ) + }; + let at = |name: &str, line: i64| { + at_column(name, EdgeKind::Calls, "src/a.rs", Language::Rust, line, 4) + }; + let target = |reference: &RefView, ctx: &Ctx| { + match_by_exact_name(reference, ctx).map(|r| r.target_node_id) + }; + + // R3: a lone project method sharing a std method's name binds nothing. + let ctx = Ctx::default() + .file("src/a.rs", source) + .name("join", vec![method("join", "LineIndex::join")]); + assert_eq!(target(&at("join", 2), &ctx), None); + // R2: a project-specific name binds its lone method, never a free function. + let ctx = Ctx::default().file("src/a.rs", source).name( + "nodes_by_ids", + vec![method("nodes_by_ids", "Store::nodes_by_ids")], + ); + assert_eq!( + target(&at("nodes_by_ids", 3), &ctx).as_deref(), + Some("method:nodes_by_ids") + ); + let ctx = Ctx::default() + .file("src/a.rs", source) + .name("build", vec![function("build")]); + assert_eq!(target(&at("build", 5), &ctx), None); + // R1: a bare call binds the free fn, and never a method. + let ctx = Ctx::default().file("src/a.rs", source).name( + "helper", + vec![method("helper", "Store::helper"), function("helper")], + ); + assert_eq!( + target(&at("helper", 4), &ctx).as_deref(), + Some("function:helper") + ); + let ctx = Ctx::default() + .file("src/a.rs", source) + .name("helper", vec![method("helper", "Store::helper")]); + assert_eq!(target(&at("helper", 4), &ctx), None); + // R4: a bare `Err` is the prelude's unless a `use` in scope imports one. + let variant = mk( + "enum_member:Err", + NodeKind::EnumMember, + "Err", + "Outcome::Err", + "src/outcome.rs", + Language::Rust, + ); + let ctx = Ctx::default() + .file("src/a.rs", source) + .name("Err", vec![variant.clone()]); + assert_eq!(target(&at("Err", 6), &ctx), None); + let imported = format!("use crate::outcome::Outcome::Err;\n{source}"); + let ctx = Ctx::default() + .file("src/a.rs", &imported) + .name("Err", vec![variant]); + assert_eq!( + target(&at("Err", 7), &ctx).as_deref(), + Some("enum_member:Err") + ); + // No source: the shape is unknown and today's binding stands. + let ctx = Ctx::default().name("join", vec![method("join", "LineIndex::join")]); + assert_eq!(target(&at("join", 2), &ctx).as_deref(), Some("method:join")); + } + + #[test] + fn rust_receiver_gates_close_the_fuzzy_fallback() { + // Fuzzy matching is case-insensitive, so `Join` / `Helper` are its only + // candidates for `join` / `helper`; the shapes still gate them. + let source = "fn f() {\n root().join(x);\n helper(x);\n}\n"; + let lone = |name: &str, kind: NodeKind| { + vec![mk( + &format!("{kind:?}:{name}"), + kind, + name, + name, + "src/x.rs", + Language::Rust, + )] + }; + let fuzzy = |name: &str, line: i64, ctx: &Ctx| { + match_fuzzy( + &at_column(name, EdgeKind::Calls, "src/a.rs", Language::Rust, line, 4), + ctx, + ) + }; + let ctx = Ctx::default() + .file("src/a.rs", source) + .lower("join", lone("Join", NodeKind::Method)); + assert!( + fuzzy("join", 2, &ctx).is_none(), + "a dropped receiver takes no fuzzy guess" + ); + let ctx = Ctx::default() + .file("src/a.rs", source) + .lower("helper", lone("Helper", NodeKind::Method)); + assert!( + fuzzy("helper", 3, &ctx).is_none(), + "a bare call never reaches a method" + ); + let ctx = Ctx::default() + .file("src/a.rs", source) + .lower("helper", lone("Helper", NodeKind::Function)); + assert!( + fuzzy("helper", 3, &ctx).is_some(), + "a bare call may still reach a function" + ); + } + + #[test] + fn rust_std_methods_are_sorted_and_unique() { + assert!(RUST_STD_METHODS.windows(2).all(|pair| pair[0] < pair[1])); + for name in [ + "join", + "iter", + "as_str", + "is_empty", + "take", + "get_or_init", + "open", + "path", + ] { + assert!(is_rust_std_method(name), "{name}"); + } + for name in ["nodes_by_ids", "current_db", "downgrade", "json"] { + assert!(!is_rust_std_method(name), "{name}"); + } + } + #[test] fn binding_sites_agree_with_the_full_search_for_plain_names() { let sources = [ diff --git a/crates/codegraph-resolve/src/resolver.rs b/crates/codegraph-resolve/src/resolver.rs index b3a087bf..e8130909 100644 --- a/crates/codegraph-resolve/src/resolver.rs +++ b/crates/codegraph-resolve/src/resolver.rs @@ -1822,6 +1822,24 @@ impl ReferenceResolver { return (candidates.into_iter().reduce(highest_confidence), None); } + // KEEP-RUST (P15): a Rust method call whose receiver the extractor + // dropped — `root().join(x)`, a chain broken across lines — is still a + // method call. It never resolves through a `use` binding or a fuzzy + // guess: it binds only a same-named method, and never one that shares a + // std method's name. + if crate::name_matcher::rust_call_shape(reference, context) + == Some(crate::name_matcher::RustCallShape::Method) + { + if let Some(result) = self.gate_language( + crate::name_matcher::match_rust_lost_receiver_call(reference, context), + reference, + context, + ) { + candidates.push(result); + } + return (candidates.into_iter().reduce(highest_confidence), None); + } + // Strategy 2: import-based resolution (index.ts:704-708). if let Some(import_result) = self.gate_language(resolve_via_import(reference, context), reference, context) diff --git a/crates/codegraph-resolve/src/rust_lexical.rs b/crates/codegraph-resolve/src/rust_lexical.rs new file mode 100644 index 00000000..702ef45d --- /dev/null +++ b/crates/codegraph-resolve/src/rust_lexical.rs @@ -0,0 +1,461 @@ +//! Lexical facts about Rust source that resolution reads without a parser. +//! +//! [`rust_code_mask`] tells code bytes from comment and literal bytes. It was +//! written for the Tauri resolver, which still uses it to find +//! `#[tauri::command]` attributes, and it backs [`RustUseScopes`], which records +//! where each `use` item is visible so a bare enum variant can be checked +//! against the imports actually in scope (KEEP-RUST, P15). + +/// Byte mask over `src`: `true` where the byte is ordinary Rust code. +/// +/// One forward scan tracking the states a textual attribute match can hide in, +/// each of which was measured to fabricate an edge (or, for the last one, to lose +/// a real command) when unhandled: +/// +/// * line comments, including `///` and `//!`; +/// * block comments, **nestable** — Rust nests, unlike C++, so a C++-shaped mask +/// closes at the first `*/` and reads the trailing bytes as code; +/// * raw strings `r"…"` / `r#"…"#` and their byte forms `br##"…"##`, with the +/// HASH COUNT captured so a custom delimiter still closes correctly; +/// * normal and byte strings, `"…"` / `b"…"`, with `\` escapes; +/// * char literals with `\` escapes (`'\''`) that DECLINE to open on a lifetime +/// `'a` — a mask that treats every `'` as a delimiter masks from the lifetime +/// forward and can swallow a genuine attribute, which is a silent feature death +/// rather than a fabrication. +pub(crate) fn rust_code_mask(src: &str) -> Vec { + let bytes = src.as_bytes(); + let mut mask = vec![true; bytes.len()]; + let mut i = 0usize; + while i < bytes.len() { + let b = bytes[i]; + if b == b'/' && bytes.get(i + 1) == Some(&b'/') { + while i < bytes.len() && bytes[i] != b'\n' { + mask[i] = false; + i += 1; + } + continue; + } + if b == b'/' && bytes.get(i + 1) == Some(&b'*') { + i = mask_nested_block_comment(bytes, &mut mask, i); + continue; + } + if is_ident_start(b) { + if let Some((quote, hashes)) = raw_string_prefix(bytes, i) { + mask[i..quote].fill(false); + i = mask_raw_string(bytes, &mut mask, quote, hashes); + continue; + } + let (_, after) = read_ident(bytes, i).expect("ident start"); + i = after; + continue; + } + if b == b'"' { + i = mask_quoted(bytes, &mut mask, i, b'"', false); + continue; + } + if b == b'\'' { + if char_literal_end(bytes, i).is_some() { + i = mask_quoted(bytes, &mut mask, i, b'\'', true); + } else { + i += 1; + } + continue; + } + i += 1; + } + mask +} + +/// Mask a nestable block comment starting at `i` (`/*`). Returns the index just +/// past the outermost `*/`. +fn mask_nested_block_comment(bytes: &[u8], mask: &mut [bool], i: usize) -> usize { + let mut depth = 0usize; + let mut k = i; + while k < bytes.len() { + if bytes[k] == b'/' && bytes.get(k + 1) == Some(&b'*') { + depth += 1; + mask[k] = false; + mask[k + 1] = false; + k += 2; + continue; + } + if bytes[k] == b'*' && bytes.get(k + 1) == Some(&b'/') { + mask[k] = false; + mask[k + 1] = false; + k += 2; + depth -= 1; + if depth == 0 { + return k; + } + continue; + } + mask[k] = false; + k += 1; + } + bytes.len() +} + +/// If a raw-string prefix (`r"`, `r##"`, `br"`, `br##"`) starts at `i`, return +/// the index of its opening quote and its hash count. +fn raw_string_prefix(bytes: &[u8], i: usize) -> Option<(usize, usize)> { + let mut j = i; + if bytes.get(j) == Some(&b'b') { + j += 1; + } + if bytes.get(j) != Some(&b'r') { + return None; + } + j += 1; + let hash_start = j; + while bytes.get(j) == Some(&b'#') { + j += 1; + } + if bytes.get(j) != Some(&b'"') { + return None; + } + Some((j, j - hash_start)) +} + +/// Mask a raw string whose opening quote is at `quote` and which closes on a `"` +/// followed by exactly `hashes` `#`. +fn mask_raw_string(bytes: &[u8], mask: &mut [bool], quote: usize, hashes: usize) -> usize { + mask[quote] = false; + let mut k = quote + 1; + while k < bytes.len() { + if bytes[k] == b'"' && closes_raw_string(bytes, k, hashes) { + let end = (k + hashes).min(bytes.len() - 1); + mask[k..=end].fill(false); + return (k + hashes + 1).min(bytes.len()); + } + mask[k] = false; + k += 1; + } + bytes.len() +} + +fn closes_raw_string(bytes: &[u8], quote: usize, hashes: usize) -> bool { + (1..=hashes).all(|n| bytes.get(quote + n) == Some(&b'#')) +} + +/// Mask a `delim`-quoted literal starting at `start`, honouring `\` escapes. +/// +/// `reset_at_newline` is `true` only for char literals: a Rust string may legally +/// span lines, while an unclosed `'` means the disambiguation misjudged a +/// lifetime, and failing open there is safer than masking the rest of the file. +fn mask_quoted( + bytes: &[u8], + mask: &mut [bool], + start: usize, + delim: u8, + reset_at_newline: bool, +) -> usize { + mask[start] = false; + let mut i = start + 1; + while i < bytes.len() { + let b = bytes[i]; + if b == b'\n' && reset_at_newline { + return i; + } + mask[i] = false; + if b == b'\\' { + if i + 1 < bytes.len() { + mask[i + 1] = false; + } + i += 2; + continue; + } + i += 1; + if b == delim { + return i; + } + } + bytes.len() +} + +/// Index just past the closing `'` when `i` opens a genuine char literal, or +/// `None` when it opens a LIFETIME or label. +/// +/// `'\''` takes the escape branch; `'a'` is a literal because the byte after the +/// single character is the closing quote; `'a` in `&'a str` and `'static` are +/// lifetimes, so they open no state at all. +fn char_literal_end(bytes: &[u8], i: usize) -> Option { + if bytes.get(i + 1) == Some(&b'\\') { + let mut k = i + 2; + while k < bytes.len() && bytes[k] != b'\'' && bytes[k] != b'\n' { + k += 1; + } + return (bytes.get(k) == Some(&b'\'')).then_some(k + 1); + } + let rest = std::str::from_utf8(&bytes[i + 1..]).ok()?; + let ch = rest.chars().next()?; + let after = i + 1 + ch.len_utf8(); + (bytes.get(after) == Some(&b'\'')).then_some(after + 1) +} + +/// The identifier starting at byte `i`, and the index just past it. +pub(crate) fn read_ident(bytes: &[u8], i: usize) -> Option<(&str, usize)> { + if i >= bytes.len() || !is_ident_start(bytes[i]) { + return None; + } + let mut j = i; + while j < bytes.len() && is_ident_byte(bytes[j]) { + j += 1; + } + std::str::from_utf8(&bytes[i..j]).ok().map(|s| (s, j)) +} + +fn is_ident_start(b: u8) -> bool { + b.is_ascii_alphabetic() || b == b'_' || b >= 0x80 +} + +fn is_ident_byte(b: u8) -> bool { + b.is_ascii_alphanumeric() || b == b'_' || b >= 0x80 +} + +/// Where each `use` item of one Rust file is visible (KEEP-RUST, P15 R4). +/// +/// A `use` is visible throughout the innermost brace block that holds it, or the +/// whole file at the top level, before or after the item and in that block's +/// nested blocks. An inline `mod NAME { … }` body is a name-resolution boundary: +/// a child module sees a parent's names only through its own `use super::…`. +/// Braces are matched over code bytes only ([`rust_code_mask`]); when they do not +/// balance, every `use` counts file-wide, which can only keep a binding the +/// resolver would make without this record. +#[derive(Debug, Default)] +pub(crate) struct RustUseScopes { + uses: Vec, + /// Inline `mod NAME { … }` bodies, as `(open, close)` brace offsets. + modules: Vec<(usize, usize)>, +} + +/// A `use` item as the scan meets it, before brace pairs are known. +struct ScannedUse { + /// The innermost open brace around the item; `None` is the file. + block_open: Option, + /// The innermost inline module's open brace around the item. + module_open: Option, + glob: bool, + names: Vec, +} + +#[derive(Debug)] +struct UseItem { + /// The block the item is visible in, as `(open, close)` byte offsets. + block: (usize, usize), + /// The open brace of the inline module that holds the item; `None` is the file. + module: Option, + glob: bool, + names: Vec, +} + +impl RustUseScopes { + pub(crate) fn scan(src: &str) -> Self { + let bytes = src.as_bytes(); + let mask = rust_code_mask(src); + let mut stack: Vec = Vec::new(); + let mut module_opens: Vec = Vec::new(); + let mut pairs: std::collections::HashMap = std::collections::HashMap::new(); + let mut found: Vec = Vec::new(); + let mut balanced = true; + let mut i = 0usize; + while i < bytes.len() { + if !mask[i] { + i += 1; + continue; + } + match bytes[i] { + b'{' => { + if follows_mod_name(bytes, &mask, i) { + module_opens.push(i); + } + stack.push(i); + i += 1; + } + b'}' => { + match stack.pop() { + Some(open) => { + pairs.insert(open, i); + } + None => balanced = false, + } + i += 1; + } + // `use<'a>` is precise capturing in a type, not an item. + _ if is_keyword_at(bytes, &mask, i, b"use") + && next_code_byte(bytes, &mask, i + 3) != Some(b'<') => + { + let end = (i + 3..bytes.len()) + .find(|&j| mask[j] && bytes[j] == b';') + .unwrap_or(bytes.len()); + let mut glob = false; + let mut names = Vec::new(); + let mut j = i + 3; + while j < end { + if !mask[j] { + j += 1; + } else if bytes[j] == b'*' { + glob = true; + j += 1; + } else if let Some((ident, after)) = read_ident(bytes, j) { + names.push(ident.to_string()); + j = after; + } else { + j += 1; + } + } + found.push(ScannedUse { + block_open: stack.last().copied(), + module_open: stack + .iter() + .rev() + .find(|open| module_opens.contains(open)) + .copied(), + glob, + names, + }); + // A use tree's own braces balance inside the item. + i = end; + } + _ => i += 1, + } + } + let balanced = balanced && stack.is_empty(); + let whole_file = (0, bytes.len()); + let uses = found + .into_iter() + .map(|found| UseItem { + block: match found + .block_open + .and_then(|open| pairs.get(&open).map(|close| (open, *close))) + { + Some(span) if balanced => span, + _ => whole_file, + }, + module: found.module_open.filter(|_| balanced), + glob: found.glob, + names: found.names, + }) + .collect(); + let modules = if balanced { + module_opens + .iter() + .filter_map(|open| pairs.get(open).map(|close| (*open, *close))) + .collect() + } else { + Vec::new() + }; + Self { uses, modules } + } + + /// Whether a `use` visible at byte `offset` names `name` or is a glob. + pub(crate) fn covers(&self, offset: usize, name: &str) -> bool { + let module = self + .modules + .iter() + .filter(|(open, close)| *open < offset && offset < *close) + .map(|(open, _)| *open) + .max(); + self.uses.iter().any(|item| { + item.module == module + && item.block.0 <= offset + && offset <= item.block.1 + && (item.glob || item.names.iter().any(|n| n == name)) + }) + } +} + +/// Whether the code byte at `i` starts the keyword `word`, on word boundaries. +fn is_keyword_at(bytes: &[u8], mask: &[bool], i: usize, word: &[u8]) -> bool { + bytes[i..].starts_with(word) + && mask[i..i + word.len()].iter().all(|code| *code) + && (i == 0 || !mask[i - 1] || !is_ident_byte(bytes[i - 1])) + && bytes + .get(i + word.len()) + .is_none_or(|b| !is_ident_byte(*b) || !mask[i + word.len()]) +} + +/// The first code byte at or after `i` that is not whitespace. +fn next_code_byte(bytes: &[u8], mask: &[bool], i: usize) -> Option { + (i..bytes.len()) + .find(|&j| mask[j] && !bytes[j].is_ascii_whitespace()) + .map(|j| bytes[j]) +} + +/// Whether the `{` at `brace` opens an inline module: `mod NAME {`, skipping +/// whitespace and comments between the parts. +fn follows_mod_name(bytes: &[u8], mask: &[bool], brace: usize) -> bool { + let skip_back = |mut j: usize| { + while j > 0 && (!mask[j - 1] || bytes[j - 1].is_ascii_whitespace()) { + j -= 1; + } + j + }; + let name_end = skip_back(brace); + let mut name_start = name_end; + while name_start > 0 && mask[name_start - 1] && is_ident_byte(bytes[name_start - 1]) { + name_start -= 1; + } + if name_start == name_end { + return false; + } + let mod_end = skip_back(name_start); + mod_end >= 3 && mod_end < name_start && is_keyword_at(bytes, mask, mod_end - 3, b"mod") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn at(src: &str, needle: &str) -> usize { + src.find(needle).expect("needle in source") + } + + #[test] + fn use_scopes_follow_blocks_and_inline_modules() { + let src = "use a::Outcome::Err;\n\ + fn top() { Err(1) }\n\ + mod child { fn f() { Err(2) } }\n\ + mod globbed { use super::*; fn g() { Err(3) } }\n\ + fn local() { use a::Outcome::Ok; Ok(4) }\n\ + fn sibling() { Ok(5) }\n\ + // use a::Outcome::Some;\n\ + fn commented() { Some(6) }\n\ + fn capture() -> impl Sized + use<> { None }\n\ + pub(crate) mod braced /* inner */ { use a::Outcome::{Ok, Some}; fn h() { Some(7) } }\n"; + let scopes = RustUseScopes::scan(src); + assert!(scopes.covers(at(src, "Err(1)"), "Err")); + assert!( + !scopes.covers(at(src, "Err(2)"), "Err"), + "a parent use does not reach a child module" + ); + assert!( + scopes.covers(at(src, "Err(3)"), "Err"), + "the child's own glob does" + ); + assert!(scopes.covers(at(src, "Ok(4)"), "Ok")); + assert!( + !scopes.covers(at(src, "Ok(5)"), "Ok"), + "a fn-local use does not reach a sibling fn" + ); + assert!( + !scopes.covers(at(src, "Some(6)"), "Some"), + "a commented-out use is no use" + ); + assert!( + !scopes.covers(at(src, "None }"), "None"), + "`use<>` precise capturing is no use item" + ); + assert!( + scopes.covers(at(src, "Some(7)"), "Some"), + "a braced use tree, past a comment" + ); + } + + #[test] + fn unbalanced_braces_make_every_use_file_wide() { + let src = "fn g() { Err(0) }\nmod child { use a::Err;\nfn f() { Err(1) }\n"; + let scopes = RustUseScopes::scan(src); + assert!(scopes.covers(at(src, "Err(0)"), "Err")); + assert!(scopes.covers(at(src, "Err(1)"), "Err")); + } +} diff --git a/crates/codegraph-resolve/src/source_facts.rs b/crates/codegraph-resolve/src/source_facts.rs index 529b29f1..59c4a24a 100644 --- a/crates/codegraph-resolve/src/source_facts.rs +++ b/crates/codegraph-resolve/src/source_facts.rs @@ -135,6 +135,7 @@ pub struct SourceFacts { literal_binding_targets: Mutex>>, awaited_raw_names: OnceLock>, awaited_index: OnceLock>, + rust_use_scopes: OnceLock, } impl SourceFacts { @@ -157,6 +158,7 @@ impl SourceFacts { literal_binding_targets: Mutex::new(HashMap::new()), awaited_raw_names: OnceLock::new(), awaited_index: OnceLock::new(), + rust_use_scopes: OnceLock::new(), } } @@ -165,6 +167,15 @@ impl SourceFacts { &self.source } + /// Whether a Rust `use` item visible at byte `offset` names `name` or is a + /// glob (KEEP-RUST, P15 R4); see [`crate::rust_lexical::RustUseScopes`]. The + /// file's `use` items are scanned once, on first ask. + pub(crate) fn rust_use_in_scope(&self, offset: usize, name: &str) -> bool { + self.rust_use_scopes + .get_or_init(|| crate::rust_lexical::RustUseScopes::scan(&self.source)) + .covers(offset, name) + } + /// Line index of the raw source. pub fn lines(&self) -> &LineIndex { self.lines.get_or_init(|| LineIndex::new(&self.source)) diff --git a/crates/codegraph-resolve/tests/fixtures/tier2/rust_receiver/src/chains.rs b/crates/codegraph-resolve/tests/fixtures/tier2/rust_receiver/src/chains.rs new file mode 100644 index 00000000..fc352476 --- /dev/null +++ b/crates/codegraph-resolve/tests/fixtures/tier2/rust_receiver/src/chains.rs @@ -0,0 +1,23 @@ +use crate::util::join; +use crate::{make_opt, root, LineIndex}; + +pub fn dropped_join(name: &str) -> std::path::PathBuf { + root().join(name) +} + +pub fn broken_chain(name: &str) -> std::path::PathBuf { + root() + .join(name) +} + +pub fn dropped_take() -> Option { + make_opt().take() +} + +pub fn explicit_path(idx: &LineIndex) -> String { + LineIndex::join(idx, ";") +} + +pub fn imported_free_fn() -> String { + join(&["a", "b"]) +} diff --git a/crates/codegraph-resolve/tests/fixtures/tier2/rust_receiver/src/lib.rs b/crates/codegraph-resolve/tests/fixtures/tier2/rust_receiver/src/lib.rs new file mode 100644 index 00000000..de306d6e --- /dev/null +++ b/crates/codegraph-resolve/tests/fixtures/tier2/rust_receiver/src/lib.rs @@ -0,0 +1,33 @@ +pub mod chains; +pub mod outcome; +pub mod store; +pub mod util; +pub mod variants; + +pub struct LineIndex; + +impl LineIndex { + pub fn join(&self, sep: &str) -> String { + sep.to_string() + } + + pub fn joined(&self) -> String { + self.join(",") + } +} + +pub struct Cache; + +impl Cache { + pub fn take(&mut self) -> Option { + None + } +} + +pub fn root() -> std::path::PathBuf { + std::path::PathBuf::new() +} + +pub fn make_opt() -> Option { + None +} diff --git a/crates/codegraph-resolve/tests/fixtures/tier2/rust_receiver/src/outcome.rs b/crates/codegraph-resolve/tests/fixtures/tier2/rust_receiver/src/outcome.rs new file mode 100644 index 00000000..30e04f20 --- /dev/null +++ b/crates/codegraph-resolve/tests/fixtures/tier2/rust_receiver/src/outcome.rs @@ -0,0 +1,4 @@ +pub enum Outcome { + Ok(u8), + Err(u8), +} diff --git a/crates/codegraph-resolve/tests/fixtures/tier2/rust_receiver/src/store.rs b/crates/codegraph-resolve/tests/fixtures/tier2/rust_receiver/src/store.rs new file mode 100644 index 00000000..82f0eaef --- /dev/null +++ b/crates/codegraph-resolve/tests/fixtures/tier2/rust_receiver/src/store.rs @@ -0,0 +1,35 @@ +pub struct Store; + +pub fn helper() -> u8 { + 2 +} + +pub fn store() -> Store { + Store +} + +pub fn lookup() -> usize { + store().nodes_by_ids(&[1, 2]) +} + +impl Store { + pub fn nodes_by_ids(&self, ids: &[u8]) -> usize { + ids.len() + } + + pub fn helper(&self) -> u8 { + 1 + } + + pub fn only_method(&self) -> u8 { + 3 + } +} + +pub fn calls_helper() -> u8 { + helper() +} + +pub fn calls_only_method() -> u8 { + only_method() +} diff --git a/crates/codegraph-resolve/tests/fixtures/tier2/rust_receiver/src/util.rs b/crates/codegraph-resolve/tests/fixtures/tier2/rust_receiver/src/util.rs new file mode 100644 index 00000000..94d49bbf --- /dev/null +++ b/crates/codegraph-resolve/tests/fixtures/tier2/rust_receiver/src/util.rs @@ -0,0 +1,3 @@ +pub fn join(parts: &[&str]) -> String { + parts.concat() +} diff --git a/crates/codegraph-resolve/tests/fixtures/tier2/rust_receiver/src/variants.rs b/crates/codegraph-resolve/tests/fixtures/tier2/rust_receiver/src/variants.rs new file mode 100644 index 00000000..63f9b4be --- /dev/null +++ b/crates/codegraph-resolve/tests/fixtures/tier2/rust_receiver/src/variants.rs @@ -0,0 +1,44 @@ +use crate::outcome::Outcome::Err; + +pub fn top_level_use() -> crate::outcome::Outcome { + Err(1) +} + +pub mod child { + pub fn parent_use_does_not_leak() -> Result { + Err(2) + } +} + +pub mod globbed { + use super::*; + + pub fn child_glob() -> crate::outcome::Outcome { + Err(3) + } +} + +pub mod braced { + use crate::outcome::Outcome::{Err, Ok}; + + pub fn braced_use() -> crate::outcome::Outcome { + let _ = Ok(0); + Err(4) + } +} + +pub mod scoped { + pub fn fn_local_use() -> crate::outcome::Outcome { + use crate::outcome::Outcome::Err; + Err(5) + } + + pub fn sibling_fn() -> Result { + Err(6) + } + + // use crate::outcome::Outcome::Err; + pub fn commented_use() -> Result { + Err(7) + } +} diff --git a/crates/codegraph-resolve/tests/tier2_false_edges.rs b/crates/codegraph-resolve/tests/tier2_false_edges.rs index a206817d..4b0568cc 100644 --- a/crates/codegraph-resolve/tests/tier2_false_edges.rs +++ b/crates/codegraph-resolve/tests/tier2_false_edges.rs @@ -544,3 +544,128 @@ fn same_name_recursion_self_edge_survives() { "and it must be the only calls edge: {described:?}" ); } + +// ====== KEEP-RUST — Rust method calls that lost their receiver (P15) ====== + +#[test] +fn rust_dropped_receivers_are_extracted_as_bare_method_names() { + // Given `root().join(name)`, the same call broken across two lines, and + // `make_opt().take()`, + // When `chains.rs` is extracted, + // Then each call is a bare `join` / `take` ref: a receiver that is a plain + // call keeps no receiver text, which is the shape every case below rests on. + let root = fixture_root("rust_receiver"); + let result = extract_file(&root, "src/chains.rs").expect("extract chains.rs"); + let calls: Vec<(String, i64)> = result + .unresolved_references + .iter() + .filter(|r| r.reference_kind == EdgeKind::Calls) + .map(|r| (r.reference_name.clone(), r.line)) + .collect(); + for (name, line) in [("join", 5), ("join", 9), ("take", 14)] { + assert!( + calls.contains(&(name.to_string(), line)), + "expected a bare `{name}` call ref at line {line}: {calls:?}" + ); + } +} + +#[test] +fn rust_method_call_with_a_dropped_receiver_binds_no_std_named_method() { + // Given method calls whose receiver the extractor dropped — `root().join`, + // the same chain broken across lines, `make_opt().take()` — beside the + // project's only `join` / `take` methods and an imported free fn `join`, + // When the pipeline resolves, + // Then none binds: a unique project method sharing a std method's name is + // no evidence of the receiver's type, and a method call never reaches a + // free function, through `use` or otherwise. + let g = resolve_fixture("rust_receiver"); + for from in ["dropped_join", "broken_chain", "dropped_take"] { + let landed = calls_from(&g, from); + assert!( + !landed + .iter() + .any(|t| t.starts_with("join@") || t.starts_with("take@")), + "{from} must not bind a same-named project method or function: {landed:?}" + ); + } +} + +#[test] +fn rust_receiver_gates_keep_validated_and_project_specific_calls() { + // Given `self.join(..)`, the path `LineIndex::join(..)` and + // `store().nodes_by_ids(..)`, + // When the pipeline resolves, + // Then each keeps its edge: validated receivers, paths and project-specific + // method names are outside the gates. + let g = resolve_fixture("rust_receiver"); + assert_eq!(calls_from(&g, "joined"), vec!["join@src/lib.rs:L10"]); + assert!( + calls_from(&g, "explicit_path").contains(&"join@src/lib.rs:L10".to_string()), + "{:?}", + calls_from(&g, "explicit_path") + ); + assert!( + calls_from(&g, "lookup").contains(&"nodes_by_ids@src/store.rs:L16".to_string()), + "{:?}", + calls_from(&g, "lookup") + ); +} + +#[test] +fn rust_bare_call_never_binds_a_method() { + // Given a bare `helper()` beside a free fn `helper` and a nearer method + // `Store::helper`, a bare `join(..)` of the imported free fn beside the + // method `LineIndex::join`, and a bare `only_method()` whose only namesake + // is a method, + // When the pipeline resolves, + // Then `helper()` and `join(..)` bind their free fns and `only_method()` + // binds nothing: a Rust call with neither receiver nor path never reaches a + // method. + let g = resolve_fixture("rust_receiver"); + assert_eq!( + calls_from(&g, "calls_helper"), + vec!["helper@src/store.rs:L3"] + ); + assert_eq!( + calls_from(&g, "imported_free_fn"), + vec!["join@src/util.rs:L1"] + ); + assert!( + calls_from(&g, "calls_only_method").is_empty(), + "{:?}", + calls_from(&g, "calls_only_method") + ); +} + +#[test] +fn rust_bare_prelude_variant_binds_a_project_variant_only_with_a_use_in_scope() { + // Given bare `Err(..)` / `Ok(..)` calls beside a project `enum Outcome { Ok, Err }`, + // When the pipeline resolves, + // Then a call binds `Outcome::Err` only where a `use` naming it, or a glob, + // is in its own block and module: the top level, a child's `use super::*`, + // a braced use, a fn-local use. A parent's use does not reach a child + // module, a fn-local use does not reach a sibling fn, and a commented-out + // use is no use; those calls are the prelude's `Err`. + let g = resolve_fixture("rust_receiver"); + let err = "Err@src/outcome.rs:L3".to_string(); + for from in ["top_level_use", "child_glob", "braced_use", "fn_local_use"] { + assert!( + calls_from(&g, from).contains(&err), + "{from}: {:?}", + calls_from(&g, from) + ); + } + assert!( + calls_from(&g, "braced_use").contains(&"Ok@src/outcome.rs:L2".to_string()), + "{:?}", + calls_from(&g, "braced_use") + ); + for from in ["parent_use_does_not_leak", "sibling_fn", "commented_use"] { + assert!( + !calls_from(&g, from).iter().any(|t| t.starts_with("Err@")), + "{from}: {:?}", + calls_from(&g, from) + ); + } +} diff --git a/crates/codegraph-store/src/index_state.rs b/crates/codegraph-store/src/index_state.rs index f794032d..0bb3f260 100644 --- a/crates/codegraph-store/src/index_state.rs +++ b/crates/codegraph-store/src/index_state.rs @@ -115,7 +115,7 @@ pub const CURRENT_STORAGE_PROTOCOL: u64 = 2; /// The extraction-pipeline version this binary produces. This is the single /// source of truth: no other crate may define its own copy. -pub const CURRENT_EXTRACTION_VERSION: u64 = 20; +pub const CURRENT_EXTRACTION_VERSION: u64 = 21; /// The `project_metadata` key under which a built index records the extraction /// version it was produced with. Single source of truth for the key spelling. diff --git a/crates/codegraph-store/tests/index_state.rs b/crates/codegraph-store/tests/index_state.rs index 09291079..b623de81 100644 --- a/crates/codegraph-store/tests/index_state.rs +++ b/crates/codegraph-store/tests/index_state.rs @@ -119,7 +119,7 @@ fn assert_corrupt( fn index_state_constants_and_canonical_payload_are_exact() { /// The extraction version the immediately preceding release wrote. Read from /// `index_state.rs` at implementation time; see plan decision C1 step 0. - const PREVIOUS_EXTRACTION_VERSION: u64 = 19; + const PREVIOUS_EXTRACTION_VERSION: u64 = 20; assert_eq!(CURRENT_STORAGE_PROTOCOL, 2); assert_eq!(CURRENT_EXTRACTION_VERSION, PREVIOUS_EXTRACTION_VERSION + 1); diff --git a/docs/upstream-sync/UPSTREAM.md b/docs/upstream-sync/UPSTREAM.md index 4a3a17b0..9027089b 100644 --- a/docs/upstream-sync/UPSTREAM.md +++ b/docs/upstream-sync/UPSTREAM.md @@ -49,6 +49,9 @@ - #280's dependency updates, with extraction version 20. See the dated entries below, including the `v0.53.0` release record. +- **On `main`, not yet released:** four KEEP-RUST resolution gates for Rust + method calls whose receiver the extractor dropped, with extraction version + 21. See the 2026-10-03 "four KEEP-RUST gates" entry below. - **UI family, phase 1 (browser viewer):** started after the owner selected the Penpot direction D design on 2026-10-02. The viewer — the `codegraph-ui` server over upstream `v1.6.1`'s `ui/` frontend, behind `CODEGRAPH_UI=1` as @@ -159,6 +162,58 @@ below remain immutable historical evidence. ## Sync log +### 2026-10-03 — Rust method calls that lost their receiver: four KEEP-RUST gates + +On 2026-10-03 the owner asked for the cause of `join`, `iter` and `as_str` +topping this repository's most-called lists to be fixed where it is +statically decidable. The design passed the kirocodex plan gate in round 4 +(6 → 1 → 1 → 0). + +**Cause.** `extract_call` keeps a Rust receiver only in these cases: + +- an identifier; +- `self`; +- one `self.` hop; +- a `path::call()` receiver. + +Every other receiver leaves the bare method name: a plain call `root()`, a +deeper field chain, `vec![…]`, `?`, `.await`, indexing, or a chain broken +across lines. Upstream's `tree-sitter.ts` does the same. Resolution then treated +`root().join(name)` as a bare call, and the project's only `join` method won at +exact-match 0.9. That method is `LineIndex::join`, so 964 calls from 169 files +landed on it. + +Upstream has no Rust counterpart to its JS/TS gates (#1987, #1714/#1857, +#1683). These rules are stricter than upstream `v1.6.1` (`f4ddf50`): + +| rule | a Rust `calls` ref | binds | +| --- | --- | --- | +| R1 | with neither receiver nor path (`helper()`) | never a method | +| R2 | whose receiver was dropped (`root().join(x)`) | only a method, never through `use`, never by a fuzzy guess | +| R3 | whose receiver was dropped, named in `RUST_STD_METHODS` (307 std names) | nothing | +| R4 | bare `Ok` / `Err` / `Some` / `None` | a project enum variant only when a `use` naming it, or a glob, is in its own block and module | + +**Shape.** It is read from the source. The ref's column is the call +expression's start, so a bare call starts with its name. A dropped receiver +starts with something else, and its `.name(` follows within 4 KiB. An unknown +shape (no source, or a column off its line) keeps the old behaviour. + +**Measured** on an archive of `6cc9507`: old binary `0.53.0`, new binary this +change, bundle excluded both times. + +- 3,284 edges removed, each attributed to one rule: R3 2,743, R1 296, R4 193, + R2 52. +- A hand review of 20 removed edges per rule found every one false. +- 48 edges added where a filtered candidate gave way to the right one. In 24 of + them a bare `node(…)` now reaches the free fn instead of a same-named method. + In 13, `.extract(…)` reaches `FrameworkResolver::extract` because R2 brought + the candidates under the ambiguity ceiling. +- 451 Method-shaped edges with project-specific names survive. About a third + of a 20-edge sample are third-party crate methods (`is_match`, `body`) or a std + type outside the list (`SocketAddr::port`): the known limit of a std list. +- No golden of any corpus moved, and `sync` after moving `LineIndex::join` + equals `index --force`. + ### 2026-10-03 — `v0.53.0` RELEASED: the browser viewer and #303 shipped Release-please PR [#299](https://github.com/sunerpy/codegraph-rust/pull/299) From ec1bf0f5b5ca313dee6454d006e7f1dc44f36346 Mon Sep 17 00:00:00 2001 From: CodeGraph Test Date: Sat, 3 Oct 2026 14:19:01 +0800 Subject: [PATCH 2/2] fix(resolve): count only the names a use tree introduces as R4 evidence `RustUseScopes` recorded every identifier of a `use` item, so `use crate::outcome::Outcome::Err as Failure;` counted as importing `Err`, and a later bare `Err(..)`, which is the prelude's, kept its false binding to the project variant. A use tree now contributes: - each leaf's alias (`as _` contributes nothing); - otherwise the leaf's last path segment; - for a `self` leaf, the segment before its group. The tier-2 fixture gains the aliased case, and a unit test covers aliases, braced groups, `self` leaves and `as _`. --- crates/codegraph-resolve/src/rust_lexical.rs | 99 ++++++++++++++++--- .../tier2/rust_receiver/src/variants.rs | 9 ++ .../tests/tier2_false_edges.rs | 12 ++- 3 files changed, 101 insertions(+), 19 deletions(-) diff --git a/crates/codegraph-resolve/src/rust_lexical.rs b/crates/codegraph-resolve/src/rust_lexical.rs index 702ef45d..20bdca9d 100644 --- a/crates/codegraph-resolve/src/rust_lexical.rs +++ b/crates/codegraph-resolve/src/rust_lexical.rs @@ -287,22 +287,7 @@ impl RustUseScopes { let end = (i + 3..bytes.len()) .find(|&j| mask[j] && bytes[j] == b';') .unwrap_or(bytes.len()); - let mut glob = false; - let mut names = Vec::new(); - let mut j = i + 3; - while j < end { - if !mask[j] { - j += 1; - } else if bytes[j] == b'*' { - glob = true; - j += 1; - } else if let Some((ident, after)) = read_ident(bytes, j) { - names.push(ident.to_string()); - j = after; - } else { - j += 1; - } - } + let (glob, names) = use_tree_names(bytes, &mask, i + 3, end); found.push(ScannedUse { block_open: stack.last().copied(), module_open: stack @@ -364,6 +349,71 @@ impl RustUseScopes { } } +/// The names a use tree in `bytes[start..end]` brings into scope, and whether it +/// holds a glob. A leaf introduces its alias (`Err as Failure` → `Failure`, and +/// `as _` → nothing), else its last path segment; a `self` leaf in a group +/// introduces the segment before the group (`a::b::{self}` → `b`). +fn use_tree_names(bytes: &[u8], mask: &[bool], start: usize, end: usize) -> (bool, Vec) { + let mut glob = false; + let mut names = Vec::new(); + // The last segment of the path before each open group, for `self` leaves. + let mut prefixes: Vec> = Vec::new(); + let mut last: Option = None; + let mut aliasing = false; + let mut j = start; + while j < end { + if !mask[j] || bytes[j].is_ascii_whitespace() { + j += 1; + continue; + } + match bytes[j] { + b'*' => { + glob = true; + last = None; + j += 1; + } + b'{' => { + prefixes.push(last.take()); + j += 1; + } + b',' | b'}' => { + if let Some(name) = last.take() { + names.push(name); + } + if bytes[j] == b'}' { + prefixes.pop(); + } + aliasing = false; + j += 1; + } + _ => { + let Some((ident, after)) = read_ident(bytes, j) else { + j += 1; + continue; + }; + if ident == "as" { + aliasing = true; + last = None; + } else if aliasing { + aliasing = false; + if ident != "_" { + names.push(ident.to_string()); + } + } else if ident == "self" { + last = prefixes.last().cloned().flatten(); + } else { + last = Some(ident.to_string()); + } + j = after; + } + } + } + if let Some(name) = last { + names.push(name); + } + (glob, names) +} + /// Whether the code byte at `i` starts the keyword `word`, on word boundaries. fn is_keyword_at(bytes: &[u8], mask: &[bool], i: usize, word: &[u8]) -> bool { bytes[i..].starts_with(word) @@ -451,6 +501,23 @@ mod tests { ); } + #[test] + fn a_use_brings_in_its_leaves_or_their_aliases_only() { + let src = "use a::Outcome::Err as Failure;\n\ + use a::Outcome::{Ok as Fine, Some, None as _};\n\ + use a::b::{self, c::{d, self}};\n\ + use a::Outcome::Err as _;\n\ + fn f() { Err(1); Failure(2); Ok(3); Fine(4); Some(5); None; b; c; d }\n"; + let scopes = RustUseScopes::scan(src); + let call = at(src, "Err(1)"); + for introduced in ["Failure", "Fine", "Some", "b", "c", "d"] { + assert!(scopes.covers(call, introduced), "{introduced}"); + } + for not_introduced in ["Err", "Ok", "None", "Outcome", "a", "_"] { + assert!(!scopes.covers(call, not_introduced), "{not_introduced}"); + } + } + #[test] fn unbalanced_braces_make_every_use_file_wide() { let src = "fn g() { Err(0) }\nmod child { use a::Err;\nfn f() { Err(1) }\n"; diff --git a/crates/codegraph-resolve/tests/fixtures/tier2/rust_receiver/src/variants.rs b/crates/codegraph-resolve/tests/fixtures/tier2/rust_receiver/src/variants.rs index 63f9b4be..aa3bf68b 100644 --- a/crates/codegraph-resolve/tests/fixtures/tier2/rust_receiver/src/variants.rs +++ b/crates/codegraph-resolve/tests/fixtures/tier2/rust_receiver/src/variants.rs @@ -42,3 +42,12 @@ pub mod scoped { Err(7) } } + +pub mod aliased { + use crate::outcome::Outcome::Err as Failure; + + pub fn alias_is_not_err() -> Result { + let _ = Failure(0); + Err(8) + } +} diff --git a/crates/codegraph-resolve/tests/tier2_false_edges.rs b/crates/codegraph-resolve/tests/tier2_false_edges.rs index 4b0568cc..ace7e365 100644 --- a/crates/codegraph-resolve/tests/tier2_false_edges.rs +++ b/crates/codegraph-resolve/tests/tier2_false_edges.rs @@ -645,8 +645,9 @@ fn rust_bare_prelude_variant_binds_a_project_variant_only_with_a_use_in_scope() // Then a call binds `Outcome::Err` only where a `use` naming it, or a glob, // is in its own block and module: the top level, a child's `use super::*`, // a braced use, a fn-local use. A parent's use does not reach a child - // module, a fn-local use does not reach a sibling fn, and a commented-out - // use is no use; those calls are the prelude's `Err`. + // module, a fn-local use does not reach a sibling fn, a commented-out use is + // no use, and `use …::Err as Failure` brings in `Failure`, not `Err`; those + // calls are the prelude's `Err`. let g = resolve_fixture("rust_receiver"); let err = "Err@src/outcome.rs:L3".to_string(); for from in ["top_level_use", "child_glob", "braced_use", "fn_local_use"] { @@ -661,7 +662,12 @@ fn rust_bare_prelude_variant_binds_a_project_variant_only_with_a_use_in_scope() "{:?}", calls_from(&g, "braced_use") ); - for from in ["parent_use_does_not_leak", "sibling_fn", "commented_use"] { + for from in [ + "parent_use_does_not_leak", + "sibling_fn", + "commented_use", + "alias_is_not_err", + ] { assert!( !calls_from(&g, from).iter().any(|t| t.starts_with("Err@")), "{from}: {:?}",