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..20bdca9d --- /dev/null +++ b/crates/codegraph-resolve/src/rust_lexical.rs @@ -0,0 +1,528 @@ +//! 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 (glob, names) = use_tree_names(bytes, &mask, i + 3, end); + 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)) + }) + } +} + +/// 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) + && 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 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"; + 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..aa3bf68b --- /dev/null +++ b/crates/codegraph-resolve/tests/fixtures/tier2/rust_receiver/src/variants.rs @@ -0,0 +1,53 @@ +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) + } +} + +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 a206817d..ace7e365 100644 --- a/crates/codegraph-resolve/tests/tier2_false_edges.rs +++ b/crates/codegraph-resolve/tests/tier2_false_edges.rs @@ -544,3 +544,134 @@ 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, 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"] { + 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", + "alias_is_not_err", + ] { + 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)