diff --git a/CHANGELOG.md b/CHANGELOG.md index deb774534..4248ca03f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -395,6 +395,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In JavaScript and TypeScript, react-dom's `root.render()` no longer links to your own class component's `render()`, such as an error boundary's, when `root` is declared outside the function making the call, like a module's `let root: Root | null`, or holds what `createRoot(el)` returned. A call on a variable declared outside the calling function now goes by the type that declaration gives it, so one declared with your own class, like `const store = new Store()` at the top of a file, links to that class's method, and a class component's `render()` and lifecycle methods, which only React calls, are no longer a guess for an untyped value. Re-index JavaScript and TypeScript projects after upgrading. Thanks @inth3shadows for the report. (#2589) - In JavaScript and TypeScript, a method called on a freshly constructed object, like `new RegExp(p).exec(s)`, `new URL(u).toString()` or `new Runner().run()`, now links only to that class's own or inherited method, on the class the file imports when several share its name, and no longer to an unrelated project method of the same name; a class from outside your project links nothing. Re-index JavaScript and TypeScript projects after upgrading. Thanks @danusha2345. (#1945) - In Python, `import a.b as X` written inside a function now links its calls like `X.f()`, as the same import at module level already did. Thanks @Egrn for the report. (#2600) +- In Python, a call through a name the file imports as a module, like `other.update(…)` after `from pkg import other`, now links to that module only where nothing closer gives the name another value: a parameter, a local, a `with … as` or `except … as` target, a loop or comprehension variable, a lambda parameter or a `case` capture named `other` is that value, so its calls no longer show up as calls into the module. A `from pkg import third as other` written inside a function now links that function's `other.get()` to `third`, and no longer the rest of the file's. Re-index Python projects after upgrading. Thanks @BGamboa13. (#2632) - In Python, `from app import email` no longer links the importing file to a project variable named `app`, such as a FastAPI or Flask `app = FastAPI()`. (#2584) - In Python, a from-import of a module whose name has no dot, like `from loader import Registry` with `loader.py` at the repository root, or `from mypackage import helper` in a `src/` layout, now links the importing file to that module's file, as `import loader` and `from pkg.loader import Registry` already did. Before, the file's dependency on the module itself was left out, so `codegraph affected` and impact could miss the files that import it. When the file also imported something else under the module's name, like `from django.core import mail` beside `from mail import …`, the module was linked to that instead. Re-index Python projects after upgrading. - In Python, a call through a module-level instance, like `_svc.delete(obj)` after `_svc = SceneService()`, now links to the method its class has or inherits, even when other classes have a method of that name. (#2582) @@ -491,6 +492,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Vue, a ``: the ` +`; + const result = extractFromSource('src/components/Page.vue', code); + + expect(result.errors).toEqual([]); + expect(result.nodes.find((n) => n.name === 'ready')?.startLine).toBe(7); + expect(result.nodes.find((n) => n.name === 'go')?.startLine).toBe(8); + expect(calls(result, 'helper', 8)).toBe(true); + expect(calls(result, 'formatDate', 2)).toBe(true); + expect(renders(result, 'Header', 3)).toBe(true); + }); + + it('keeps the template it comes before', () => { + const code = ` +`; + const result = extractFromSource('src/components/First.vue', code); + + expect(result.errors).toEqual([]); + expect(result.nodes.find((n) => n.name === 'ready')?.startLine).toBe(7); + expect(calls(result, 'formatDate', 3)).toBe(true); + expect(renders(result, 'Header', 4)).toBe(true); + }); + + it("still reads a call in a self-closing +`; + const result = extractFromSource('src/components/Post.vue', code); + + expect(result.errors).toEqual([]); + expect(result.nodes.find((n) => n.name === 'ready')?.startLine).toBe(9); + expect(calls(result, 'toJsonLd', 3)).toBe(true); + expect(calls(result, 'formatDate', 4)).toBe(true); + expect(renders(result, 'Header', 5)).toBe(true); + }); + + // The template-call scan always took `` as a closing tag; with one + // pattern for every site, the script reader does too. + it('ends a block at a closing tag written with a space, ``', () => { + const code = ` + +`; + const result = extractFromSource('src/components/Spaced.vue', code); + + expect(result.errors).toEqual([]); + expect(result.nodes.find((n) => n.name === 'ready')?.startLine).toBe(5); + expect(calls(result, 'formatDate', 2)).toBe(true); + }); +}); diff --git a/codegraph-kernel/src/python.rs b/codegraph-kernel/src/python.rs index a65a26401..a0269f65d 100644 --- a/codegraph-kernel/src/python.rs +++ b/codegraph-kernel/src/python.rs @@ -72,6 +72,21 @@ fn body_docstring(node: Node, src: &str) -> Option { if cleaned.is_empty() { None } else { Some(cleaned) } } +/// The identifiers a Python assignment or loop target binds: a bare name, or +/// the names inside tuple, list and star targets. An attribute or subscript +/// store binds nothing. +fn target_names<'t>(node: Node<'t>, out: &mut Vec>) { + match node.kind() { + "identifier" => out.push(node), + "attribute" | "subscript" => {} + _ => { + for c in named_kids(node) { + target_names(c, out); + } + } + } +} + fn docstring_space(c: char) -> bool { // Match JavaScript trim, including BOM but excluding NEXT LINE. (c.is_whitespace() && c != '\u{85}') || c == '\u{feff}' @@ -344,9 +359,7 @@ impl<'t> Walker<'t> { if kind == "call" { self.extract_call(node); } - if kind == "assignment" { - self.emit_local_assignment_binding(node); - } + self.emit_statement_bindings(node); // An import inside a function body binds a name for that body: a row // scoped to the function (no import node, no `imports` ref — the // module-level walk owns those, as before). @@ -633,16 +646,51 @@ impl<'t> Walker<'t> { } } - /// `x = …` inside a function body: a nodeless `local` row for the name. - fn emit_local_assignment_binding(&mut self, node: Node<'t>) { + /// The names a function-body statement or pattern binds besides a + /// definition or import: assignment (augmented and tuple targets too), + /// `for` and `with`/`except … as` targets, a walrus, and a `case` capture. + /// Each is a nodeless `local` row scoped to the function. A comprehension + /// variable or lambda parameter binds in its own expression, narrower + /// than the line-granular scope a row carries; the resolver reads those + /// from the syntax tree (`python_expression_binds`). + fn emit_statement_bindings(&mut self, node: Node<'t>) { + let mut targets = Vec::new(); + match node.kind() { + "assignment" | "augmented_assignment" | "for_statement" => { + if let Some(left) = node.child_by_field_name("left") { + target_names(left, &mut targets); + } + } + "as_pattern" => { + if let Some(alias) = node.child_by_field_name("alias") { + target_names(alias, &mut targets); + } + } + "except_clause" | "except_group_clause" => { + if let Some(alias) = node.child_by_field_name("alias") { + target_names(alias, &mut targets); + } + } + "named_expression" => { + if let Some(name) = node.child_by_field_name("name") { + targets.push(name); + } + } + // `case other:` and `case Point(x=other)` capture; `other.MODE` is a value. + "dotted_name" if matches!(node.parent().map(|p| p.kind()), Some("case_pattern" | "keyword_pattern")) => { + if node.named_child_count() == 1 { + targets.extend(node.named_child(0)); + } + } + "splat_pattern" => targets.extend(node.named_child(0).filter(|c| c.kind() == "identifier")), + _ => {} + } let Some(scope) = self.enclosing_scope() else { return }; - let Some(left) = node.child_by_field_name("left") else { return }; - if left.kind() != "identifier" { - return; + for name_node in targets { + let name = self.text(name_node).to_string(); + let line = self.line_of(name_node); + self.push_binding_row(BINDING_LOCAL, &name, NONE, scope, line, None, false, None); } - let name = self.text(left).to_string(); - let line = self.line_of(left); - self.push_binding_row(BINDING_LOCAL, &name, NONE, scope, line, None, false, None); } /// Rows for an import statement without the node and refs `extract_import` diff --git a/codegraph-kernel/src/resolve/awaited.rs b/codegraph-kernel/src/resolve/awaited.rs index 7797936bc..ef2b5145a 100644 --- a/codegraph-kernel/src/resolve/awaited.rs +++ b/codegraph-kernel/src/resolve/awaited.rs @@ -57,7 +57,7 @@ fn awaited_names(text: &str) -> HashSet { } /// Push `c` blanked: one space per UTF-16 unit, a newline kept. -fn push_blank(out: &mut String, c: char) { +pub(super) fn push_blank(out: &mut String, c: char) { if c == '\n' { out.push('\n'); } else { diff --git a/codegraph-kernel/src/resolve/imports.rs b/codegraph-kernel/src/resolve/imports.rs index 2cc103201..1459446fb 100644 --- a/codegraph-kernel/src/resolve/imports.rs +++ b/codegraph-kernel/src/resolve/imports.rs @@ -888,6 +888,9 @@ impl KernelResolver { } let imports = if r.language == "python" && r.reference_kind != "imports" { let root = r.reference_name.split('.').next().unwrap(); + if self.python_expression_binds(root, r) { + return Ok(None); + } match self.receiver_binding(root, r)? { Some(binding) if binding.kind == "import" => { // Unaliased imports share the package object across scopes, diff --git a/codegraph-kernel/src/resolve/lang_scope.rs b/codegraph-kernel/src/resolve/lang_scope.rs index 25a8d5303..38657c514 100644 --- a/codegraph-kernel/src/resolve/lang_scope.rs +++ b/codegraph-kernel/src/resolve/lang_scope.rs @@ -598,11 +598,7 @@ impl KernelResolver { let to = (decl.end_line.max(0) as usize).min(lines.len()); let mixes: Vec = lines[from..to.max(from)] .iter() - .filter_map(|line| { - re!(r"^\s*(?:include|extend|prepend)\s+([A-Z:][A-Za-z0-9_:]*(?:\s*,\s*[A-Z:][A-Za-z0-9_:]*)*)") - .captures(line) - .map(|m| m[1].to_string()) - }) + .filter_map(|line| ruby_mixin_list(line)) .collect(); for mix in mixes { for name in re!(r"\s*,\s*").split(&mix) { @@ -967,3 +963,20 @@ impl KernelResolver { })) } } + +/// The constants an `include` / `extend` / `prepend` line names, comma +/// separated. A module a call returns (`include RouteSet.new.tap { … }.url_helpers`) +/// is no mixin of the constant the call starts at: the list ends before a +/// name that is followed by a `.` or `(`, as a name's own trailing word +/// characters never do. +fn ruby_mixin_list(line: &str) -> Option { + let m = re!(r"^\s*(?:include|extend|prepend)\s+([A-Z:][A-Za-z0-9_:]*(?:\s*,\s*[A-Z:][A-Za-z0-9_:]*)*)").captures(line)?; + let whole = m.get(1)?; + let mut names: Vec<&str> = re!(r"\s*,\s*").split(whole.as_str()).collect(); + let rest = line[whole.end()..].trim_start(); + if rest.starts_with(['.', '(']) { + // Backtracking drops the last name; the one before it is followed by `,`. + names.pop(); + } + (!names.is_empty()).then(|| names.join(",")) +} diff --git a/codegraph-kernel/src/resolve/method_call.rs b/codegraph-kernel/src/resolve/method_call.rs index 2ca88f29f..7a36ad352 100644 --- a/codegraph-kernel/src/resolve/method_call.rs +++ b/codegraph-kernel/src/resolve/method_call.rs @@ -660,6 +660,14 @@ impl KernelResolver { } else { None } + .or_else(|| { + // A Ruby method name may end in `!` or `?`: `writer.flush!`, `writer.ready?`. + if r.language == "ruby" { + re!(r"^([A-Za-z0-9_.]+)\.([A-Za-z0-9_]+[!?])$").captures(&r.reference_name) + } else { + None + } + }) .or_else(|| dot_re.captures(&r.reference_name)); if dot_match.is_none() && r.language == "cpp" { let op_re = re!(r"^([A-Za-z0-9_.]+)\.(operator[^A-Za-z0-9_\s.]+)$"); @@ -1490,7 +1498,16 @@ if inferred.is_none() && r.language == "dart" { resolved_by: "instance-method", })); } - if target.len() > 1 { + // Not for a Ruby name ending in `!` or `?` called on a value: + // `key?`, `empty?`, `respond_to?`, `is_a?` and `merge!` are what the + // core classes answer on nearly every receiver, and the receiver's + // words can't tell which project method of the name, if any, is + // meant. A constant receiver (`MarshalWithFallback.dumped?`) still + // names its owner. + let ruby_bang_or_predicate = r.language == "ruby" + && method_name.ends_with(['!', '?']) + && super::call_shape::receiver_link(&object_or_class).starts_with(|c: char| c.is_ascii_lowercase() || c == '_'); + if target.len() > 1 && !ruby_bang_or_predicate { let receiver_words = split_camel_case(super::call_shape::receiver_link(&object_or_class)); // Same-file candidates first, so a score tie resolves to the // call site's own file (`score > bestScore` keeps first seen). @@ -1663,7 +1680,7 @@ if inferred.is_none() && r.language == "dart" { // method (`def self.m`) when it and `def m` share the qualified // name, then the owner's Ruby ancestry (#2834). if c.language == "ruby" { - if let Some((found, own)) = self.ruby_method_of(&c.qualified_name, method, r)? { + if let Some((found, own)) = self.ruby_method_of(&c.qualified_name, method, r, "class")? { return Ok(Some(KCand { node: found, confidence: if own { confidence } else { 0.8 }, resolved_by })); } continue; diff --git a/codegraph-kernel/src/resolve/mod.rs b/codegraph-kernel/src/resolve/mod.rs index 60a12eb92..1e44aa08a 100644 --- a/codegraph-kernel/src/resolve/mod.rs +++ b/codegraph-kernel/src/resolve/mod.rs @@ -644,6 +644,7 @@ mod kotlin_receiver; mod this_member; mod member_fn_ref; mod python_globals; +mod python_expression_scope; mod object_literal; mod cpp; mod cpp_types; @@ -748,6 +749,9 @@ pub struct KernelResolver { rust_typed_refs: HashSet, /// rustTraitOf, by method id → the enclosing trait node's id. rust_trait_of_memo: HashMap>, + /// RUST_TYPE_MEMBERS (name-matcher.ts): a `Type::member` lookup, by + /// (type id, member) → the member node's id. + rust_type_member_memo: HashMap>, /// goDotImports, by file path. go_dot_imports_memo: HashMap, /// A Go bound-receiver arm proved the receiver has no project method — @@ -785,6 +789,10 @@ pub struct KernelResolver { dart_hierarchy_memo: HashMap<(String, i64), Rc>>, /// Kotlin supertypes named in a type's head, by type name. kotlin_supers_memo: HashMap>>, + /// rubyIvarClass (#3053): the class `initialize` gives an instance + /// variable, by `owner\0name`. `None` = untyped (left to the other + /// strategies); `Some(None)` = typed to a class the project lacks. + ruby_ivar_memo: HashMap>>>, /// kotlinFileScope, by file. declared_member_memo: HashMap<(String, String), Option>, declared_member_lines: HashMap>>, @@ -914,6 +922,14 @@ pub struct KernelResolver { /// getRazorUsings memo, by file. razor_usings_memo: HashMap>>, rust_crate_root_memo: HashMap>, + /// rustPathDeclsIn (name-matcher.ts): a file's `#[path]` module declarations, by file. + rust_path_decls_memo: HashMap>>, + /// rustPathLoads (name-matcher.ts): each file a `#[path]` loads → its declarations, once. + rust_path_loads_memo: Option>>>, + /// rustPathLoaders (name-matcher.ts): the declarations loading a file the name rules don't place. + rust_path_loaders_memo: HashMap>>, + /// A Rust file's `mod` layout: plain decls, `#[path]` decl names, inline chains, `fn main`. + rust_mod_facts_memo: HashMap>>, /// factory_initializer memo: (file, binding line, root, binding node). factory_init_memo: HashMap<(String, i64, String, Option), Rc>, /// C/C++ directive summaries and per-root macro timelines (#1838). @@ -1081,6 +1097,7 @@ impl KernelResolver { rust_trait_memo: HashMap::new(), rust_typed_refs: HashSet::new(), rust_trait_of_memo: HashMap::new(), + rust_type_member_memo: HashMap::new(), go_dot_imports_memo: HashMap::new(), go_mc_terminal: false, go_mc_untyped_local: false, @@ -1121,6 +1138,7 @@ impl KernelResolver { kotlin_receiver_types_memo: None, kotlin_scope_memo: HashMap::new(), kotlin_supers_memo: HashMap::new(), + ruby_ivar_memo: HashMap::new(), py_descendants_memo: HashMap::new(), py_bases_memo: HashMap::new(), cpp_visible_memo: None, @@ -1171,6 +1189,10 @@ impl KernelResolver { cobol_copybooks: None, razor_usings_memo: HashMap::new(), rust_crate_root_memo: HashMap::new(), + rust_path_decls_memo: HashMap::new(), + rust_path_loads_memo: None, + rust_path_loaders_memo: HashMap::new(), + rust_mod_facts_memo: HashMap::new(), factory_init_memo: HashMap::new(), cpp_macros: cpp::MacroCache::default(), cpp_namespaces: cpp_namespace::NamespaceCache::default(), diff --git a/codegraph-kernel/src/resolve/name_scope.rs b/codegraph-kernel/src/resolve/name_scope.rs index b6ba8740e..dacff7074 100644 --- a/codegraph-kernel/src/resolve/name_scope.rs +++ b/codegraph-kernel/src/resolve/name_scope.rs @@ -199,17 +199,25 @@ pub(super) fn is_bare_rust_name(r: &ResolveRefIn) -> bool { /// any form — is named only in its own module and the modules inside it: a `type Result` in /// `src/build/patch.rs` is `build::patch`'s, seen in that file and under `src/build/patch/`, never /// through `use crate::Result` elsewhere, and no glob passes it on. A `mod.rs`, `lib.rs` or -/// `main.rs` is the module of its whole directory. The same rule `is_visible_across_files` applies -/// to the pipeline's pick, here ahead of the ranking, so the crate's own `pub(crate) type Result` +/// `main.rs` is the module of its whole directory; a file a `#[path]` loads is inside the module +/// that declares it, wherever it sits. The same rule `is_visible_across_files` applies to the +/// pipeline's pick, here ahead of the ranking, so the crate's own `pub(crate) type Result` /// can win instead. -fn rust_item_visible_from(candidate: &KNode, r: &ResolveRefIn) -> bool { - if candidate.language != "rust" - || candidate.visibility.as_deref() != Some("private") - || !super::resolver_upstream::RUST_MODULE_ITEM_KINDS.contains(&candidate.kind.as_str()) - { - return true; +impl KernelResolver { + fn rust_item_visible_from(&mut self, candidate: &KNode, r: &ResolveRefIn) -> bool { + if candidate.language != "rust" + || candidate.visibility.as_deref() != Some("private") + || !super::resolver_upstream::RUST_MODULE_ITEM_KINDS.contains(&candidate.kind.as_str()) + { + return true; + } + candidate.file_path == r.file_path + || self.rust_file_under( + &r.file_path, + &super::names::rust_module_dir(&candidate.file_path), + &candidate.file_path, + ) } - candidate.file_path == r.file_path || r.file_path.starts_with(&format!("{}/", super::names::rust_module_dir(&candidate.file_path))) } impl KernelResolver { @@ -269,7 +277,7 @@ impl KernelResolver { /// the prelude's unless the file defines it or imports a project item of /// that name. pub(super) fn is_rust_name_in_scope(&mut self, candidate: &KNode, r: &ResolveRefIn) -> bool { - if !rust_item_visible_from(candidate, r) { + if !self.rust_item_visible_from(candidate, r) { return false; } if let Some(visible) = self.rust_precise_name_scope(candidate, r) { diff --git a/codegraph-kernel/src/resolve/names.rs b/codegraph-kernel/src/resolve/names.rs index 1414b0fa5..66661618c 100644 --- a/codegraph-kernel/src/resolve/names.rs +++ b/codegraph-kernel/src/resolve/names.rs @@ -482,7 +482,7 @@ impl KernelResolver { return Ok(true); } let owner = rust_module_dir(&candidate.file_path); - return Ok(r.file_path.starts_with(&format!("{}/", owner))); + return Ok(self.rust_file_under(&r.file_path, &owner, &candidate.file_path)); } if private_is_file_local(lang) { return Ok(candidate.visibility.as_deref() != Some("private")); diff --git a/codegraph-kernel/src/resolve/node_table.rs b/codegraph-kernel/src/resolve/node_table.rs index f2d73bed0..f9864f32c 100644 --- a/codegraph-kernel/src/resolve/node_table.rs +++ b/codegraph-kernel/src/resolve/node_table.rs @@ -547,6 +547,14 @@ pub(super) struct FileExportIndexK { pub(super) by_name: HashMap>, pub(super) default_component: Option>, pub(super) default_binding: Option>, + /// import-resolver.ts `defaultFnClass`: the file's first exported function + /// or class, the guess for `export default ` (`memo(Card)`), + /// which names no declaration. + pub(super) default_fn_class: Option>, + /// The file states an `export default` naming no declaration that is not + /// an anonymous function, class or arrow (upstream's `'unstated'`, the + /// only case `default_fn_class` answers; `'anonymous'` binds nothing). + pub(super) default_expr: bool, } /// WorkspacePackages (workspace-packages.ts). diff --git a/codegraph-kernel/src/resolve/python_expression_scope.rs b/codegraph-kernel/src/resolve/python_expression_scope.rs new file mode 100644 index 000000000..034b6ddcc --- /dev/null +++ b/codegraph-kernel/src/resolve/python_expression_scope.rs @@ -0,0 +1,78 @@ +//! A Python name bound by the expression around a reference: a lambda +//! parameter or a comprehension variable. Binding rows are scoped to whole +//! lines, so these single-expression scopes are read from the syntax tree. + +use super::iteration::{descendant_for_position, named_children}; +use super::*; + +/// Every identifier `node` names as a binding target, tuples and stars included. +fn binds(node: tree_sitter::Node, text: &str, name: &str) -> bool { + match node.kind() { + "identifier" => &text[node.start_byte()..node.end_byte()] == name, + "attribute" | "subscript" => false, + _ => named_children(node).into_iter().any(|c| binds(c, text, name)), + } +} + +impl KernelResolver { + /// Whether the lambda or comprehension the reference sits in binds `name` + /// there: `lambda other: other.f()`, `[other.f() for other in items]`. + /// A comprehension's first iterable is evaluated outside its scope. + pub(super) fn python_expression_binds(&mut self, name: &str, r: &ResolveRefIn) -> bool { + let Some(file) = self.read_file(&r.file_path) else { return false }; + let text = file.text(); + let Some(tree) = self.parsed_tree(&file, r) else { return false }; + let leaf = descendant_for_position( + tree.root_node(), + text, + ((r.line - 1).max(0) as usize, r.column.max(0) as usize), + ); + let at = leaf.start_byte(); + let inside = |n: tree_sitter::Node| n.start_byte() <= at && at < n.end_byte(); + let mut current = Some(leaf); + while let Some(node) = current { + current = node.parent(); + match node.kind() { + "lambda" => { + let in_body = node.child_by_field_name("body").is_some_and(inside); + let named = node.child_by_field_name("parameters").is_some_and(|params| { + named_children(params).into_iter().any(|param| { + let target = param + .child_by_field_name("name") + .or_else(|| { + matches!(param.kind(), "list_splat_pattern" | "dictionary_splat_pattern") + .then(|| param.named_child(0)) + .flatten() + }) + .unwrap_or(param); + binds(target, text, name) + }) + }); + if in_body && named { + return true; + } + } + "list_comprehension" | "set_comprehension" | "dictionary_comprehension" | "generator_expression" => { + let clauses: Vec<_> = named_children(node) + .into_iter() + .filter(|c| c.kind() == "for_in_clause") + .collect(); + let in_first_iterable = clauses + .first() + .and_then(|c| c.child_by_field_name("right")) + .is_some_and(inside); + if !in_first_iterable + && clauses + .iter() + .filter_map(|c| c.child_by_field_name("left")) + .any(|target| binds(target, text, name)) + { + return true; + } + } + _ => {} + } + } + false + } +} diff --git a/codegraph-kernel/src/resolve/query.rs b/codegraph-kernel/src/resolve/query.rs index 82a26456a..8aca4ee6d 100644 --- a/codegraph-kernel/src/resolve/query.rs +++ b/codegraph-kernel/src/resolve/query.rs @@ -562,6 +562,7 @@ impl KernelResolver { let nodes = self.nodes_in_file_unfiltered(file_path)?; let mut by_name: HashMap> = HashMap::new(); let mut default_component: Option> = None; + let mut default_fn_class: Option> = None; for n in nodes.iter() { if !n.is_exported { continue; @@ -570,8 +571,37 @@ impl KernelResolver { if default_component.is_none() && n.kind == "component" && matches!(std::path::Path::new(file_path).extension().and_then(|s| s.to_str()), Some("svelte" | "vue" | "astro")) { default_component = Some(n.clone()); } + if default_fn_class.is_none() && (n.kind == "function" || n.kind == "class") { + default_fn_class = Some(n.clone()); + } } let rows = self.bindings(file_path)?; + // `export default ` leaves a nodeless `default` marker row + // (as does an anonymous function, class or arrow, which binds nothing). + let marker_lines: Vec = rows + .iter() + .filter(|r| r.exported_as.as_deref() == Some("default") && r.node_id.is_none()) + .map(|r| r.line) + .collect(); + let mut default_expr = false; + if !marker_lines.is_empty() { + if let Some(lines) = self.read_file(file_path) { + let text = lines.text(); + let mut anonymous = false; + for &line in marker_lines.iter() { + // A default's head may span lines (`export default (\n x\n) => x`): + // upstream's readDefaultExport scans the whole statement, so the + // classifier gets the source from the marker's line to the end. + let head = text.split('\n').skip(line.saturating_sub(1) as usize).collect::>().join("\n"); + if export_default_head_is_anonymous(&head) { + anonymous = true; + } else { + default_expr = true; + } + } + default_expr &= !anonymous; + } + } let by_id: HashMap<&str, &Arc> = nodes.iter().map(|n| (n.id.as_str(), n)).collect(); let mut default_binding: Option> = None; if let Some(bound) = default_export_binding(&rows) { @@ -616,6 +646,8 @@ impl KernelResolver { by_name, default_component, default_binding, + default_fn_class, + default_expr, }); self.export_index.insert(file_path.to_string(), idx.clone()); Ok(idx) @@ -667,7 +699,8 @@ impl KernelResolver { if want.is_default { let direct = export_index.by_name.get("default").cloned() .or_else(|| export_index.default_binding.clone()) - .or_else(|| export_index.default_component.clone()); + .or_else(|| export_index.default_component.clone()) + .or_else(|| export_index.default_fn_class.clone().filter(|_| export_index.default_expr)); if let Some(d) = direct { return self.memo_symbol_opt(memo_key, Some(d.clone())); } @@ -763,6 +796,43 @@ pub(super) fn default_export_binding(rows: &[KBinding]) -> Option<&KBinding> { rows.iter().find(|r| r.exported_as.as_deref() == Some("default") && r.node_id.is_some()) } +/// readDefaultExport's `'anonymous'` (import-resolver.ts): the line a +/// nodeless `default` binding row marks opens `export default` on a +/// function, class or arrow with no name. `export default memo(Card)` or +/// `new Service()` is an expression and answers `false`; a head the line +/// doesn't begin conservatively answers `true`. +fn export_default_head_is_anonymous(line: &str) -> bool { + let Some(m) = re!(r"^[ \t]*export[ \t]+default[ \t]+").find(line) else { + return true; + }; + let mut rest = &line[m.end()..]; + if let Some(after) = rest.strip_prefix("async") { + rest = after.trim_start(); + } + if re!(r"^(?:function|class)").is_match(rest) { + return true; + } + // `x =>` is anonymous; `(…)` is an arrow's parameters when `=>` or a + // return type follows it, else a parenthesized expression. + if re!(r"^[A-Za-z_$][\w$]*[ \t]*=>").is_match(rest) { + return true; + } + if rest.starts_with('(') { + let mut depth = 0i32; + for (i, c) in rest.char_indices() { + if c == '(' { + depth += 1; + } else if c == ')' { + depth -= 1; + if depth == 0 { + return re!(r"^[ \t]*(?:=>|:)").is_match(&rest[i + 1..]); + } + } + } + } + false +} + #[cfg(test)] mod tests { use super::*; diff --git a/codegraph-kernel/src/resolve/ruby_paths.rs b/codegraph-kernel/src/resolve/ruby_paths.rs index a1f1ce321..9af9ed228 100644 --- a/codegraph-kernel/src/resolve/ruby_paths.rs +++ b/codegraph-kernel/src/resolve/ruby_paths.rs @@ -45,6 +45,45 @@ impl KernelResolver { }; } if r.reference_kind == "calls" { + // A call on an instance `Foo.new(…)` made, bare or namespaced + // (`new Foo().m`, `new A::B().m!`): an instance method of the class + // Ruby's lookup finds for `Foo` there, its own or one its ancestry + // declares — never a namesake's by the method name alone. A class + // the project doesn't declare (rack-protection's `Request.new(env)` + // is rack's) has no method here. + if let Some(c) = re!(r"^new (::)?([A-Z]\w*(?:::[A-Z]\w*)*)\(\)\.([A-Za-z_]\w*[?!=]?)$").captures(&r.reference_name) { + let class = self + .ruby_constants_named(&c[2], r, c.get(1).is_some())? + .into_iter() + .next() + .filter(|n| n.kind == "class"); + let method = match class { + Some(class) => self.ruby_method_of(&class.qualified_name, &c[3], r, "instance")?, + None => None, + }; + return Ok(Some(match method { + Some((method, own)) => ResolveOutcome::resolved(&method, if own { 0.9 } else { 0.85 }, "instance-method", true, None), + None => ResolveOutcome::unresolved(), + })); + } + // A call on an instance variable `@writer.flush`, typed by the + // class's `initialize`: an instance method of the class every + // assignment there gives the variable — or nothing when that + // class is not the project's. A variable `initialize` does not + // type is left to the other strategies, as before (#3053). + if let Some(c) = re!(r"^@(\w+)\.([A-Za-z_]\w*[?!=]?)$").captures(&r.reference_name) { + let Some(class) = self.ruby_ivar_class(&c[1], r)? else { + return Ok(None); + }; + let method = match class { + Some(class) => self.ruby_method_of(&class.qualified_name, &c[2], r, "instance")?, + None => None, + }; + return Ok(Some(match method { + Some((method, own)) => ResolveOutcome::resolved(&method, if own { 0.85 } else { 0.8 }, "instance-method", true, None), + None => ResolveOutcome::unresolved(), + })); + } let Some(c) = re!(r"^(::)?([A-Z]\w*(?:::[A-Z]\w*)*)\.([A-Za-z_]\w*[?!=]?)$").captures(r.reference_name.trim_start_matches("()")) else { return Ok(None); }; @@ -58,7 +97,7 @@ impl KernelResolver { let Some(owner) = owner else { return Ok(Some(ResolveOutcome::unresolved())); }; - return match self.ruby_method_of(&owner.qualified_name, &c[3], r)? { + return match self.ruby_method_of(&owner.qualified_name, &c[3], r, "class")? { Some((method, own)) => Ok(Some(ResolveOutcome::resolved( &method, if own { 0.85 } else { 0.8 }, @@ -105,6 +144,11 @@ impl KernelResolver { /// lexicalScope: the qualified name of the innermost class or module /// around `r`, or empty at the top level of its file. fn ruby_lexical_scope(&mut self, r: &ResolveRefIn) -> Res { + Ok(self.ruby_innermost_namespace(r)?.map(|i| i.qualified_name.clone()).unwrap_or_default()) + } + + /// innermostNamespace: the innermost class or module around `r`, if any. + fn ruby_innermost_namespace(&mut self, r: &ResolveRefIn) -> Res>> { let mut inner: Option> = None; for n in self.nodes_in_file(&r.file_path)?.iter() { if n.language != "ruby" || !matches!(n.kind.as_str(), "class" | "module") || n.start_line > r.line || n.end_line < r.line { @@ -114,22 +158,97 @@ impl KernelResolver { inner = Some(n.clone()); } } - Ok(inner.map(|i| i.qualified_name.clone()).unwrap_or_default()) + Ok(inner) + } + + /// ivarClass (#3053): `Some` the class of what `@name` holds where `r` + /// is written — `Some(None)` when that class is not the project's — + /// or `None` for the other strategies when `r` is not in an instance + /// method of a class, or `initialize` does not type the variable. + fn ruby_ivar_class(&mut self, name: &str, r: &ResolveRefIn) -> Res>>> { + let caller = self.node_by_id(&r.from_node_id)?; + match &caller { + Some(c) if c.language == "ruby" && c.kind == "method" && !c.is_static => {} + _ => return Ok(None), + } + let Some(owner) = self.ruby_innermost_namespace(r)? else { + return Ok(None); + }; + if owner.kind != "class" { + return Ok(None); + } + let key = format!("{}\0{}", owner.qualified_name, name); + if let Some(hit) = self.ruby_ivar_memo.get(&key) { + return Ok(hit.clone()); + } + let found = self.ruby_ivar_class_from_initialize(name, &owner.qualified_name, r)?; + self.ruby_ivar_memo.insert(key, found.clone()); + Ok(found) + } + + /// ivarClassFromInitialize: every assignment to `@name` in the owner's + /// `initialize` must be at a statement's start and a lone `Const.new(…)` + /// of one constant — then the class Ruby's lookup finds for that + /// constant from there. + fn ruby_ivar_class_from_initialize(&mut self, name: &str, owner: &str, r: &ResolveRefIn) -> Res>>> { + let mut written: Option<(String, bool, String, i64)> = None; + for init in self.nodes_by_qualified_name(&format!("{owner}::initialize"))?.iter() { + if init.kind != "method" || init.language != "ruby" || init.is_static { + continue; + } + let Some(lines) = self.read_file(&init.file_path) else { + return Ok(None); + }; + let mut body = String::new(); + for i in init.start_line - 1..init.end_line { + match lines.line(i as usize) { + Some(l) => { + body.push_str(l); + body.push('\n'); + } + None => break, + } + } + let body = strip_ruby_comments(&body); + let Some(assigned) = constructed_assignments(&body, name) else { + return Ok(None); + }; + for (path, absolute, line) in assigned { + match &written { + Some((p, a, _, _)) if *p != path || *a != absolute => return Ok(None), + None => written = Some((path, absolute, init.file_path.clone(), init.start_line + line)), + _ => {} + } + } + } + let Some((path, absolute, file, line)) = written else { + return Ok(None); + }; + let mut at = r.clone(); + at.file_path = file; + at.line = line; + at.reference_name = path.clone(); + at.reference_kind = "references".to_string(); + let class = self.ruby_constants_named(&path, &at, absolute)?.into_iter().next(); + Ok(Some(class.filter(|n| n.kind == "class"))) } /// methodOf: `name` declared on `owner_qn` itself (kind `class` first), /// else on the first ancestor in its Ruby ancestry that declares it. /// Returns the method and whether it is the owner's own. `r` is the call /// site: production code never reaches a method only a test declares. - pub(super) fn ruby_method_of(&mut self, owner_qn: &str, name: &str, r: &ResolveRefIn) -> Res, bool)>> { + pub(super) fn ruby_method_of(&mut self, owner_qn: &str, name: &str, r: &ResolveRefIn, kind: &str) -> Res, bool)>> { let production = !super::resolver_upstream::test_suite_path(&r.file_path); let own: Vec> = self .nodes_by_qualified_name(&format!("{owner_qn}::{name}"))? .iter() - .filter(|n| n.language == "ruby" && n.kind == "method" && !(production && super::resolver_upstream::test_suite_path(&n.file_path))) + // A receiver bound to an instance (`new C().m`, a typed `@x`) can + // only reach instance methods: an own class method does not stand + // in for one an ancestor declares. + .filter(|n| n.language == "ruby" && n.kind == "method" && !(kind == "instance" && n.is_static) && !(production && super::resolver_upstream::test_suite_path(&n.file_path))) .cloned() .collect(); - if let Some(m) = ruby_methods_of_kind_first(ruby_ordered(own, r), "class").into_iter().next() { + if let Some(m) = ruby_methods_of_kind_first(ruby_ordered(own, r), kind).into_iter().next() { return Ok(Some((m, true))); } let ancestry = self.ruby_ancestry(owner_qn)?; @@ -139,10 +258,10 @@ impl KernelResolver { let found: Vec> = self .nodes_by_qualified_name(&format!("{qn}::{name}"))? .iter() - .filter(|n| n.language == "ruby" && n.kind == "method" && !(production && super::resolver_upstream::test_suite_path(&n.file_path))) + .filter(|n| n.language == "ruby" && n.kind == "method" && !(kind == "instance" && n.is_static) && !(production && super::resolver_upstream::test_suite_path(&n.file_path))) .cloned() .collect(); - if let Some(m) = ruby_methods_of_kind_first(ruby_ordered(found, r), "class").into_iter().next() { + if let Some(m) = ruby_methods_of_kind_first(ruby_ordered(found, r), kind).into_iter().next() { return Ok(Some((m, false))); } } @@ -204,3 +323,182 @@ pub(super) fn ruby_methods_of_kind_first(methods: Vec>, kind: &str) - same.extend(other); same } + +/// constructedAssignments (#3053): each assignment to `@name` in `body` +/// (an `initialize`, comments blanked): the constant whose `.new` it +/// assigns, and the assignment's line within `body`. None when one of +/// them is anything else: not at the start of a statement (`@a, @b = …`), +/// or not a lone `Const.new(…)` (`Foo.new.tap {…}`, `writer || Foo.new`, +/// `build_writer`). +fn constructed_assignments(body: &str, name: &str) -> Option> { + // Upstream's `/(?])/g`, minus the + // look-around Rust's regex lacks: the boundary char is captured and + // skipped, the `=~>` tail is checked by hand. + let target = super::cached_pattern(&format!(r"(^|[^@\w])@{}\s*(?:\|\||&&|\*\*|<<|>>|[-+*/%|&^])?=", regex::escape(name))).ok()?; + let mut out = Vec::new(); + for m in target.captures_iter(body) { + let whole = m.get(0).unwrap(); + let at = whole.start() + m.get(1).unwrap().as_str().len(); + let after_eq = whole.end(); + match body.as_bytes().get(after_eq) { + Some(b'=') | Some(b'~') | Some(b'>') => continue, + _ => {} + } + let statement_start = body[..at].rfind(['\n', ';']).map_or(0, |i| i + 1); + if !body[statement_start..at].trim().is_empty() { + return None; + } + let (path, absolute) = constructed_value(body, after_eq)?; + let line = body[..at].matches('\n').count() as i64; + out.push((path, absolute, line)); + } + Some(out) +} + +/// constructedValue: the constant a value written at `from` constructs +/// when it is a lone `Const.new`, `A::B.new(…)` or `::Foo.new …`: its +/// arguments in parentheses or without them, a block, or a statement +/// modifier may follow, but nothing the value is passed through +/// (`.tap`, `||`). None otherwise. +fn constructed_value(s: &str, from: usize) -> Option<(String, bool)> { + let m = re!(r"^\s*(::)?([A-Z]\w*(?:::[A-Z]\w*)*)\s*\.\s*new").captures(&s[from..])?; + let mut i = from + m.get(0).unwrap().end(); + // upstream's `new(?![\w?!])` (`\w` is ASCII): `new?`/`new!`/`newly` + // are method names. + match s[i..].chars().next() { + Some(c) if c.is_ascii_alphanumeric() || matches!(c, '_' | '?' | '!') => return None, + _ => {} + } + if s.as_bytes().get(i) == Some(&b'(') { + i = skip_parens(s, i)?; + } + // The end of the statement, a block, or (after a space) a modifier or + // arguments without parentheses: anything but a chain or an operator. + let tail = &s[i.min(s.len())..]; + let ok = match tail.chars().next() { + None | Some('\n') | Some(';') | Some('{') => true, + _ => re!(r#"^[ \t]+(?:$|\n|;|\{|[\w:@$"'\[])"#).is_match(tail.get(..200).unwrap_or(tail)), + }; + // A chain may go on at the start of the next line: `Foo.new\n .tap {…}`. + let continued = re!(r"^[ \t]*\n\s*&?\.").is_match(tail.get(..200).unwrap_or(tail)); + (ok && !continued).then(|| (m[2].to_string(), m.get(1).is_some())) +} + +/// skipParens: the index past the parenthesized group opening at `i`, +/// Ruby strings inside it skipped; None when it never closes. +fn skip_parens(s: &str, i: usize) -> Option { + let b = s.as_bytes(); + let mut depth = 0i32; + let mut j = i; + while j < b.len() { + let c = b[j]; + if c == b'"' || c == b'\'' { + j += 1; + while j < b.len() && b[j] != c { + if b[j] == b'\\' { + j += 1; + } + j += 1; + } + j += 1; + continue; + } + if c == b'(' { + depth += 1; + } else if c == b')' { + depth -= 1; + if depth == 0 { + return Some(j + 1); + } + } + j += 1; + } + None +} + +/// stripCommentsForRegex(…, 'ruby') (upstream strip-comments.ts): +/// `#` line comments and `=begin`/`=end` blocks blanked (newlines kept, +/// so offsets still map); string literals skipped intact. +fn strip_ruby_comments(src: &str) -> String { + let s: Vec = src.chars().collect(); + let n = s.len(); + let mut out = String::with_capacity(src.len()); + let mut i = 0; + let mut at_line_start = true; + while i < n { + let c = s[i]; + // `=begin` opens a block comment only at a line's start (after + // optional whitespace); `=end` must sit at a line's start too. + if at_line_start && c == '=' && s[i..].iter().take(6).collect::() == "=begin" { + let start = i; + i += 6; + while i < n { + if s[i] == '\n' { + let mut j = i + 1; + while j < n && (s[j] == ' ' || s[j] == '\t') { + j += 1; + } + if s[j..].iter().take(4).collect::() == "=end" { + i = j + 4; + while i < n && s[i] != '\n' { + i += 1; + } + break; + } + } + i += 1; + } + for &ch in &s[start..i.min(n)] { + super::awaited::push_blank(&mut out, ch); + } + at_line_start = i > 0 && s[i - 1] == '\n'; + continue; + } + if c == '"' || c == '\'' { + let start = i; + i += 1; + while i < n && s[i] != c { + if s[i] == '\\' && i + 1 < n { + i += 2; + continue; + } + if s[i] == '\n' { + break; + } + i += 1; + } + if i < n && s[i] == c { + i += 1; + } + out.extend(&s[start..i.min(n)]); + at_line_start = false; + continue; + } + if c == '#' { + let start = i; + while i < n && s[i] != '\n' { + i += 1; + } + for &ch in &s[start..i] { + super::awaited::push_blank(&mut out, ch); + } + at_line_start = false; + continue; + } + if c == '\n' { + at_line_start = true; + out.push(c); + i += 1; + continue; + } + if c == ' ' || c == '\t' { + out.push(c); + i += 1; + continue; + } + at_line_start = false; + out.push(c); + i += 1; + } + out +} diff --git a/codegraph-kernel/src/resolve/rust_crates.rs b/codegraph-kernel/src/resolve/rust_crates.rs index dfb5fab8b..5e4edf58f 100644 --- a/codegraph-kernel/src/resolve/rust_crates.rs +++ b/codegraph-kernel/src/resolve/rust_crates.rs @@ -95,11 +95,53 @@ pub(super) fn rust_manifest_dependencies(text: &str) -> Vec { deps } +/// parseTargetPaths (cargo-workspace.ts): the target roots a manifest names with a `path` of their +/// own — `[lib] path = "…"`, each `[[bin]] path = "…"` (ripgrep's `crates/core/main.rs`), and each +/// `[[example]]`, `[[test]]` and `[[bench]]` one (dioxus's +/// `examples/07-fullstack/handling_errors.rs`), as written — relative to the manifest's directory. +fn rust_manifest_target_paths(text: &str) -> (Option, Vec, Vec) { + let mut lib = None; + let mut bins = Vec::new(); + let mut others = Vec::new(); + let mut table = ""; + for raw in text.lines() { + let line = raw.split('#').next().unwrap_or("").trim(); + if line.starts_with('[') { + table = match line { + "[lib]" => "lib", + "[[bin]]" => "bin", + _ if re!(r"^\[\[(?:example|test|bench)\]\]$").is_match(line) => "other", + _ => "", + }; + continue; + } + let Some(at) = (!table.is_empty()) + .then(|| re!(r#"^path\s*=\s*["']([^"'\n]+)["']"#).captures(line)) + .flatten() + .map(|m| m[1].replace('\\', "/")) + else { + continue; + }; + match table { + "lib" => lib = Some(at), + "bin" => bins.push(at), + _ => others.push(at), + } + } + (lib, bins, others) +} + pub(super) struct RustCrate { /// Its package name as code writes it (`tokio_test`). pub(super) name: String, /// Each dependency of any kind that is a project crate: the name code writes → that crate's directory. pub(super) keys: HashMap, + /// The library root its manifest gives a `path` of its own, as a project file. + pub(super) lib: Option, + /// The binaries' roots its manifest gives a `path` (ripgrep's `crates/core/main.rs`), as project files. + pub(super) bins: Vec, + /// The examples', tests' and benches' roots its manifest gives a `path`, as project files: each its own crate. + pub(super) others: Vec, } pub(super) struct RustCrateGraph { @@ -110,7 +152,7 @@ pub(super) struct RustCrateGraph { } /// `rel` from the project directory `dir`, as a project directory; None when it leaves the project. -fn join_project_dir(dir: &str, rel: &str) -> Option { +pub(super) fn join_project_dir(dir: &str, rel: &str) -> Option { let mut parts: Vec<&str> = Vec::new(); for segment in dir.split('/').chain(rel.split('/')) { match segment { @@ -146,7 +188,22 @@ impl GraphBuilder { let path = if dir.is_empty() { "Cargo.toml".to_string() } else { format!("{dir}/Cargo.toml") }; let Some(source) = resolver.read_file(&path) else { return false }; let Some(name) = package_name(source.text()) else { return false }; - self.crates.insert(dir.to_string(), RustCrate { name: name.clone(), keys: HashMap::new() }); + let (lib, bins, others) = rust_manifest_target_paths(source.text()); + let files = |rels: Vec| { + rels.iter() + .filter_map(|rel| join_project_dir(dir, rel)) + .collect() + }; + self.crates.insert( + dir.to_string(), + RustCrate { + name: name.clone(), + keys: HashMap::new(), + lib: lib.and_then(|rel| join_project_dir(dir, &rel)), + bins: files(bins), + others: files(others), + }, + ); self.manifests.insert(dir.to_string(), source.text().to_string()); self.by_name.entry(name).or_insert_with(|| dir.to_string()); self.order.push(dir.to_string()); diff --git a/codegraph-kernel/src/resolve/rust_impls.rs b/codegraph-kernel/src/resolve/rust_impls.rs index 5c1700915..b238d6f7c 100644 --- a/codegraph-kernel/src/resolve/rust_impls.rs +++ b/codegraph-kernel/src/resolve/rust_impls.rs @@ -134,7 +134,7 @@ impl KernelResolver { /// The crate dir a file belongs to: the longest crate dir prefixing it, or /// the root crate's "". - fn rust_crate_dir_of(file: &str, graph: &super::rust_crates::RustCrateGraph) -> Option { + pub(super) fn rust_crate_dir_of(file: &str, graph: &super::rust_crates::RustCrateGraph) -> Option { let mut best = graph.crates.contains_key("").then(String::new); for dir in graph.crates.keys() { if !dir.is_empty() diff --git a/codegraph-kernel/src/resolve/rust_modules.rs b/codegraph-kernel/src/resolve/rust_modules.rs index 8a60d8c8c..b8c674ee4 100644 --- a/codegraph-kernel/src/resolve/rust_modules.rs +++ b/codegraph-kernel/src/resolve/rust_modules.rs @@ -134,29 +134,82 @@ impl KernelResolver { } let first = segments[0]; if first == "crate" { + // The crate root's own `#[path]` decls are the module's — a name + // one claims never reaches a namesake `head.rs`. + if let Some(head) = segments.get(1) { + if let Some(root) = self.rust_root_module(from_file) { + if self + .rust_mod_facts(&root.file) + .is_some_and(|f| f.path_mods.contains(*head)) + { + return Ok(None); + } + } + } let root = self.rust_crate_root_dir(from_file)?; return Ok(self.rust_resolve_under(root, &segments[1..])); } if first == "self" { - return Ok( - self.rust_resolve_under(Some(rust_self_module_dir(from_file)), &segments[1..]) - ); + if segments.get(1).is_some_and(|h| { + self.rust_mod_facts(from_file) + .is_some_and(|f| f.path_mods.contains(*h)) + }) { + return Ok(None); + } + let dir = self.rust_module_dir_of(from_file); + return Ok(self.rust_resolve_under(Some(dir), &segments[1..])); } if first == "super" { let mut supers = 0usize; while segments.get(supers) == Some(&"super") { supers += 1; } - let mut dir = Some(rust_self_module_dir(from_file)); + // Walk `super` by the module rules: a `#[path]`-loaded file's + // `super` is the module that declares it, not a module of the + // directory it happens to sit in. + let mut scope = rust_paths::RustModule { + file: from_file.to_string(), + inline: Vec::new(), + }; for _ in 0..supers { - dir = dir.map(|d| pos_dirname(&d).to_string()); + let Some(s) = self.rust_super_module(&scope) else { + return Ok(None); + }; + scope = s; } - return Ok(self.rust_resolve_under(dir, &segments[supers..])); + if segments.get(supers).is_some_and(|h| { + self.rust_mod_facts(&scope.file) + .is_some_and(|f| f.path_mods.contains(*h)) + }) { + return Ok(None); + } + let mut dir = self.rust_module_dir_of(&scope.file); + for seg in &scope.inline { + dir = pos_join(&dir, seg); + } + return Ok(self.rust_resolve_under(Some(dir), &segments[supers..])); + } + // A `#[path]` decl of the head stays unknown; the name-only lookup + // below may not bypass it to a `head.rs` namesake (rustPathTarget). + if self + .rust_mod_facts(from_file) + .is_some_and(|f| f.path_mods.contains(segments[0])) + { + return Ok(None); } - let self_hit = self.rust_resolve_under(Some(rust_self_module_dir(from_file)), segments); + let dir = self.rust_module_dir_of(from_file); + let self_hit = self.rust_resolve_under(Some(dir), segments); if self_hit.is_some() { return Ok(self_hit); } + if let Some(root) = self.rust_root_module(from_file) { + if self + .rust_mod_facts(&root.file) + .is_some_and(|f| f.path_mods.contains(segments[0])) + { + return Ok(None); + } + } let root = self.rust_crate_root_dir(from_file)?; Ok(self.rust_resolve_under(root, segments)) } @@ -199,9 +252,38 @@ impl KernelResolver { /// named e.g. `rust_binder_main.rs` — falls back to climbing the `mod` /// declaration chain to the file no other file declares. pub(super) fn rust_crate_root_dir(&mut self, from_file: &str) -> Res> { + self.rust_crate_root_dir_at(from_file, 0) + } + + fn rust_crate_root_dir_at(&mut self, from_file: &str, depth: usize) -> Res> { if let Some(v) = self.rust_crate_root_memo.get(from_file) { return Ok(v.clone()); } + // A file a `#[path]` loads is in the crate of the file that declares + // it (rustCrateRootModule, claims chained ≤8); an example, test or + // bench its manifest gives a `path` of its own is its own crate's + // root, its directory the crate's. + if depth < 8 { + if let Some(claim) = self.rust_path_claim(from_file) { + let v = self.rust_crate_root_dir_at(&claim.file, depth + 1)?; + self.rust_crate_root_memo + .insert(from_file.to_string(), v.clone()); + return Ok(v); + } + } + if let Ok(graph) = self.rust_crate_graph() { + if Self::rust_crate_dir_of(from_file, &graph).is_some_and(|dir| { + graph + .crates + .get(&dir) + .is_some_and(|c| c.others.iter().any(|o| o == from_file)) + }) { + let v = Some(pos_dirname(from_file).to_string()); + self.rust_crate_root_memo + .insert(from_file.to_string(), v.clone()); + return Ok(v); + } + } let mut dir = pos_dirname(from_file).to_string(); let mut found = None; for _ in 0..64 { diff --git a/codegraph-kernel/src/resolve/rust_paths.rs b/codegraph-kernel/src/resolve/rust_paths.rs index df222d757..ea67e2faa 100644 --- a/codegraph-kernel/src/resolve/rust_paths.rs +++ b/codegraph-kernel/src/resolve/rust_paths.rs @@ -9,6 +9,55 @@ pub(in super::super) struct RustModule { pub(in super::super) inline: Vec, } +/// A `#[path = "…"] mod name;` declaration (name-matcher.ts's RustPathDecl). +#[derive(Clone, Debug)] +pub(in super::super) struct RustPathDecl { + /// The file it is written in, and the `mod` item's position: the module it + /// is declared in. + pub(in super::super) file: String, + pub(in super::super) line: i64, + pub(in super::super) col: i64, + /// The module name it declares. + pub(in super::super) name: String, + /// The inline modules around it (`[]` at the file's top level). + pub(in super::super) inline: Vec, + /// The file it loads, from the project's root, read as the Rust Reference + /// reads the attribute; None for one a `cfg_attr` sets (the build's cfg + /// decides) or one that leaves the project. + pub(in super::super) target: Option, +} + +/// A `#[path]` declaration as the tree reads it, before its target is joined +/// to the project: `None` where a `cfg_attr` sets the path. +pub(in super::super) struct RustRawPathDecl { + name: String, + line: i64, + col: i64, + inline: Vec, + target: Option, +} + +/// A Rust file's `mod` layout: plain decls, `#[path]` decl names, inline +/// chains, `fn main` (the pieces of upstream's rustUsesOf the module rules +/// read). +#[derive(Default)] +pub(in super::super) struct RustModFacts { + /// `mod name;` with no `#[path]` of its own: the name → the inline module + /// chains it is written in. + pub(in super::super) mods: HashMap>>, + /// Modules declared with a `#[path]` of their own (a `cfg_attr`'s too), + /// whose file can't be told from their name. + pub(in super::super) path_mods: HashSet, + /// The inline module chains the file's `mod name { … }` blocks open, + /// each ending at its own name. + pub(in super::super) inlines: HashSet>, + /// Whether it starts a binary of its own: a top-level `fn main` or + /// `#![no_main]` (a trybuild case, a fuzz target). + pub(in super::super) main: bool, + /// The file's `#[path]` declarations, targets unresolved. + pub(in super::super) raw_decls: Vec, +} + pub(in super::super) fn rust_children(n: tree_sitter::Node<'_>) -> Vec> { (0..n.named_child_count()) .filter_map(|i| n.named_child(i)) @@ -266,6 +315,32 @@ impl KernelResolver { out } pub(in super::super) fn rust_root_module(&mut self, file: &str) -> Option { + self.rust_root_module_at(file, 0) + } + + /// rustCrateRootModule (name-matcher.ts): a `#[path]`-loaded file's crate + /// is the one its declaring file is in (claims chained ≤8, as upstream); + /// an example, test or bench its manifest gives a `path` of its own is + /// its own crate's root. + fn rust_root_module_at(&mut self, file: &str, depth: usize) -> Option { + if depth < 8 { + if let Some(claim) = self.rust_path_claim(file) { + return self.rust_root_module_at(&claim.file, depth + 1); + } + } + if let Ok(graph) = self.rust_crate_graph() { + if Self::rust_crate_dir_of(file, &graph).is_some_and(|dir| { + graph + .crates + .get(&dir) + .is_some_and(|c| c.others.iter().any(|o| o == file)) + }) { + return Some(RustModule { + file: file.into(), + inline: Vec::new(), + }); + } + } let dir = self.rust_crate_root_dir(file).ok()??; for name in ["lib.rs", "main.rs"] { let p = pos_join(&dir, name); @@ -278,7 +353,410 @@ impl KernelResolver { } None } + + /// The `#[path]` read of a `mod` item's attribute run, nearest first as + /// upstream's rustAttributesBefore scans: `.0` is whether a `#[path]`/ + /// `cfg_attr`-`path` attribute is in the run at all (the name is a path + /// module's — a `#[path = ]` counts for the name but + /// declares no file, as upstream's skipped decl does); `.1` is the + /// declared target — `Some` the literal, `None` where a `cfg_attr` + /// decides — or the outer `None` when only a non-literal `path =` + /// appears (upstream records no decl then). + fn rust_mod_path_attr( + n: tree_sitter::Node<'_>, + text: &str, + ) -> (bool, Option>) { + let mut pathish = false; + let mut target: Option> = None; + let mut sib = n; + // Upstream scans attributes over comment-blanked code, so a doc + // comment between an attribute and its `mod` is read as whitespace. + while let Some(attr) = sib.prev_named_sibling().filter(|a| { + a.kind() == "attribute_item" + || matches!(a.kind(), "line_comment" | "block_comment") + }) { + if attr.kind() != "attribute_item" { + sib = attr; + continue; + } + let raw = rust_text(attr, text); + let literal = re!(r#"^#\s*\[\s*path\s*=\s*"([^"]*)"\s*\]$"#) + .captures(raw) + .map(|m| m[1].replace('\\', "/")); + let cfg_path = re!(r"^#\s*\[\s*cfg_attr\b[\s\S]*\bpath\s*=").is_match(raw); + pathish = pathish + || literal.is_some() + || cfg_path + || re!(r"^#\s*\[\s*path\s*=").is_match(raw); + if let Some(literal) = literal { + target = Some(Some(literal)); + } else if target.is_none() && cfg_path { + target = Some(None); + } + sib = attr; + } + (pathish, target) + } + + /// rustUsesOf's mod layout (name-matcher.ts): one pass over the file's + /// tree — the plain `mod name;` decls by the inline chain that owns each, + /// the names declared with a `#[path]` of their own, the inline chains + /// `mod name { … }` blocks open, and a top-level `fn main`/`#![no_main]`. + /// Memoized per file for the resolver's lifetime, as upstream memos + /// RUST_USES per context. + pub(in super::super) fn rust_mod_facts(&mut self, file: &str) -> Option> { + if let Some(hit) = self.rust_mod_facts_memo.get(file) { + return hit.clone(); + } + let facts = self.rust_read_mod_facts(file); + self.rust_mod_facts_memo.insert(file.into(), facts.clone()); + facts + } + + fn rust_read_mod_facts(&mut self, file: &str) -> Option> { + let (source, tree) = self.rust_tree_for(file)?; + let text = source.text(); + let mut facts = RustModFacts::default(); + let mut stack = vec![tree.root_node()]; + while let Some(n) = stack.pop() { + match n.kind() { + "mod_item" => { + if let Some(name_node) = n.child_by_field_name("name") { + let name = rust_text(name_node, text) + .trim_start_matches("r#") + .to_string(); + let inline = rust_inline_at(n, text); + let has_body = n.child_by_field_name("body").is_some(); + if has_body { + facts.inlines.insert(inline.clone()); + } + let (pathish, attr) = Self::rust_mod_path_attr(n, text); + if pathish { + facts.path_mods.insert(name.clone()); + if !has_body { + if let Some(target) = attr { + facts.raw_decls.push(RustRawPathDecl { + name, + line: n.start_position().row as i64 + 1, + col: n.start_position().column as i64, + inline, + target, + }); + } + } + } else if !has_body { + facts.mods.entry(name).or_default().push(inline); + } + } + } + "function_item" => { + if n.child_by_field_name("name") + .is_some_and(|x| rust_text(x, text) == "main") + && n.start_position().column == 0 + { + facts.main = true; + } + } + "inner_attribute_item" + if re!(r"^#!\s*\[\s*no_main\s*\]$") + .is_match(rust_text(n, text).trim_end()) => + { + facts.main = true; + } + _ => {} + } + stack.extend(rust_children(n)); + } + Some(Rc::new(facts)) + } + + /// rustPathDeclsIn (name-matcher.ts): the file's `#[path]` declarations + /// with their targets joined to project files. Both forms write `path =` + /// (`#[path = "…"]`, `#[cfg_attr(…, path = "…")]`); the bytes are checked + /// before the file is decoded, so a file without the word is never read. + fn rust_path_decls_in(&mut self, file: &str) -> Rc> { + if let Some(hit) = self.rust_path_decls_memo.get(file) { + return hit.clone(); + } + let decls = Rc::new(self.rust_read_path_decls(file)); + self.rust_path_decls_memo.insert(file.into(), decls.clone()); + decls + } + + fn rust_read_path_decls(&mut self, file: &str) -> Vec { + if !file.ends_with(".rs") { + return Vec::new(); + } + let Some(source) = self.read_file_uncached(file) else { + return Vec::new(); + }; + if !source.text().contains("path") || !re!(r"\bpath\s*=").is_match(source.text()) { + return Vec::new(); + } + let Some(facts) = self.rust_mod_facts(file) else { + return Vec::new(); + }; + let mut out = Vec::new(); + for raw in &facts.raw_decls { + let target = raw.target.as_ref().and_then(|literal| { + if re!(r"^(?:/|[A-Za-z]:)").is_match(literal) { + return None; + } + // A `#[path]` is read from the declaring file's directory — + // from the inline module's own directory when the decl sits + // inside `mod name { … }` (the Rust Reference's rule): the + // file's dir for a target root or a `mod.rs`, the file's + // `stem/` dir otherwise, then one step per inline name. + let mut base = pos_dirname(file).to_string(); + if !raw.inline.is_empty() { + base = if self.rust_is_target_root(file) + || file == "mod.rs" + || file.ends_with("/mod.rs") + { + base + } else { + file.strip_suffix(".rs").unwrap_or(file).to_string() + }; + for name in &raw.inline { + base = pos_join(&base, name); + } + } + super::super::rust_crates::join_project_dir(&base, literal) + }); + out.push(RustPathDecl { + file: file.into(), + line: raw.line, + col: raw.col, + name: raw.name.clone(), + inline: raw.inline.clone(), + target, + }); + } + out + } + + /// rustPathLoads (name-matcher.ts): every `#[path]` declaration in the + /// project by the file it loads — one sweep of the indexed files, each + /// byte-checked for `path` first, computed once per resolver. + fn rust_path_loads(&mut self) -> Option>>> { + if let Some(hit) = &self.rust_path_loads_memo { + return Some(hit.clone()); + } + let files = match self.sorted_files() { + Some(files) => files, + None => Arc::new(self.table().ok()?.files.iter().cloned().collect::>()), + }; + let mut out: HashMap> = HashMap::new(); + for file in files.iter().filter(|f| f.ends_with(".rs")) { + for decl in self.rust_path_decls_in(file).iter() { + if let Some(target) = &decl.target { + out.entry(target.clone()).or_default().push(decl.clone()); + } + } + } + let map = Rc::new(out); + self.rust_path_loads_memo = Some(map.clone()); + Some(map) + } + + /// rustPathLoaders (name-matcher.ts): the `#[path]` declarations naming + /// this file — none when the regular rules already place it (a target + /// root, a file with its own `fn main`, a `name.rs`/`name/mod.rs` a plain + /// `mod name;` declares). + fn rust_path_loaders(&mut self, file: &str) -> Rc> { + if let Some(hit) = self.rust_path_loaders_memo.get(file) { + return hit.clone(); + } + let loaders: Rc> = + if !file.ends_with(".rs") || self.rust_placed_by_name(file) { + Rc::new(Vec::new()) + } else { + Rc::new( + self.rust_path_loads() + .map(|m| m.get(file).cloned().unwrap_or_default()) + .unwrap_or_default(), + ) + }; + self.rust_path_loaders_memo.insert(file.into(), loaders.clone()); + loaders + } + + /// rustPathClaim (name-matcher.ts): the `#[path]` declaration that loads + /// this file, when exactly one does. Two cfg alternatives' decls make + /// which file the module is a cfg question the resolver does not answer. + pub(in super::super) fn rust_path_claim(&mut self, file: &str) -> Option { + let loaders = self.rust_path_loaders(file); + (loaders.len() == 1).then(|| loaders[0].clone()) + } + + /// isRustTargetRoot (name-matcher.ts): a file a manifest or the default + /// layout names a crate root — a `[lib]`/`[[bin]]`/`[[example]]`/ + /// `[[test]]`/`[[bench]]` `path`, `src/lib.rs`, `src/main.rs`, + /// `src/bin/*.rs`, a `tests`/`examples`/`benches` target, `build.rs` — + /// and, for a file no crate covers, a `lib.rs`/`main.rs` wherever it sits. + pub(in super::super) fn rust_is_target_root(&mut self, file: &str) -> bool { + let Ok(graph) = self.rust_crate_graph() else { + return re!(r"(?:^|/)(?:lib|main)\.rs$").is_match(file); + }; + let Some(dir) = Self::rust_crate_dir_of(file, &graph) else { + return re!(r"(?:^|/)(?:lib|main)\.rs$").is_match(file); + }; + if let Some(c) = graph.crates.get(&dir) { + if c.lib.as_deref() == Some(file) + || c.bins.iter().any(|b| b == file) + || c.others.iter().any(|o| o == file) + { + return true; + } + } + let rel = if dir.is_empty() { + file + } else { + &file[dir.len() + 1..] + }; + re!(r"^src/(?:lib|main)\.rs$|^src/bin/[^/]+(?:\.rs|/main\.rs)$|^(?:tests|examples|benches)/[^/]+(?:\.rs|/main\.rs)$|^build\.rs$").is_match(rel) + } + + /// rustPlacedByName (name-matcher.ts): the regular rules already place + /// the file — a crate root (a target root, or one with a `fn main`/ + /// `#![no_main]` of its own, as a trybuild case is), or a `name.rs`/ + /// `name/mod.rs` a plain `mod name;` declares in the module owning its + /// directory or in an inline module of one above it (≤4 levels, as + /// upstream). + fn rust_placed_by_name(&mut self, file: &str) -> bool { + if self.rust_is_target_root(file) + || self.rust_mod_facts(file).is_some_and(|f| f.main) + { + return true; + } + let dir = pos_dirname(file); + let is_mod = file == "mod.rs" || file.ends_with("/mod.rs"); + if is_mod && dir.is_empty() { + return false; + } + let base = if is_mod { dir } else { file }; + let base = pos_basename(base); + let name = base.strip_suffix(".rs").unwrap_or(base).to_string(); + let mut parent = if is_mod { + pos_dirname(dir).to_string() + } else { + dir.to_string() + }; + let mut inline: Vec = Vec::new(); + for _ in 0..4 { + let mut owners = vec![pos_join(&parent, "mod.rs")]; + if !parent.is_empty() { + owners.push(format!("{parent}.rs")); + } + for f in self.rust_rs_files_in_dir(&parent).iter() { + if self.rust_is_target_root(f) { + owners.push(f.clone()); + } + } + for owner in owners { + if owner == file || !self.file_exists(&owner) { + continue; + } + if let Some(facts) = self.rust_mod_facts(&owner) { + if facts.mods.contains_key(&name) + && (inline.is_empty() || facts.inlines.contains(&inline)) + { + return true; + } + } + } + if parent.is_empty() { + break; + } + inline.insert(0, pos_basename(&parent).to_string()); + parent = pos_dirname(&parent).to_string(); + } + false + } + + /// The directory under which the file's own submodules live — its own + /// directory when a `#[path]` loads it or the manifest layout makes it a + /// target root (rustc reads both like a `mod.rs`), else + /// `rust_self_module_dir`'s `/` (rustFileModule's `dir`, + /// name-matcher.ts). + pub(in super::super) fn rust_module_dir_of(&mut self, file: &str) -> String { + if self.rust_path_claim(file).is_some() || self.rust_is_target_root(file) { + return pos_dirname(file).to_string(); + } + rust_self_module_dir(file) + } + + /// rustModuleWithin (name-matcher.ts): `inner` is `outer` or a module it + /// declares, inline or in a file below its directory — and a file a + /// `#[path]` loads is inside the module that declares it (claims ≤8). + fn rust_module_within(&mut self, inner: &RustModule, outer: &RustModule, depth: usize) -> bool { + if inner.file == outer.file { + return inner.inline.starts_with(&outer.inline); + } + let mut dir = self.rust_module_dir_of(&outer.file); + for seg in &outer.inline { + dir = pos_join(&dir, seg); + } + if dir.is_empty() || inner.file.starts_with(&format!("{dir}/")) { + return true; + } + if depth < 8 && inner.inline.is_empty() { + if let Some(claim) = self.rust_path_claim(&inner.file) { + let at = self.rust_module_at(&claim.file, claim.line, claim.col); + return self.rust_module_within(&at, outer, depth + 1); + } + } + false + } + + /// rustFileUnder (name-matcher.ts): `file` is inside `owner`'s module — + /// under its submodule dir, or is the owner itself once the `#[path]` + /// claim chain is followed from the ref's file to the declaring one + /// (≤8 hops, as upstream). + pub(in super::super) fn rust_file_under( + &mut self, + file: &str, + owner_dir: &str, + owner_file: &str, + ) -> bool { + let mut f = file.to_string(); + for i in 0..8 { + if f.starts_with(&format!("{owner_dir}/")) || (i > 0 && f == owner_file) { + return true; + } + let Some(claim) = self.rust_path_claim(&f) else { + return false; + }; + f = claim.file; + } + false + } + fn rust_submodule(&mut self, m: &RustModule, name: &str) -> Option { + // A `#[path]` decl of the name in this scope is the file it names; + // two cfg alternatives (or a `cfg_attr`'s unknown target) leave the + // module unknown — never the `name.rs` a name-only lookup would find. + if self + .rust_mod_facts(&m.file) + .is_some_and(|f| f.path_mods.contains(name)) + { + let decls = self.rust_path_decls_in(&m.file); + let decls: Vec<&RustPathDecl> = decls + .iter() + .filter(|d| d.name == name && d.inline == m.inline) + .collect(); + return match decls.as_slice() { + [d] => d + .target + .clone() + .filter(|t| self.file_exists(t)) + .map(|file| RustModule { + file, + inline: Vec::new(), + }), + _ => None, + }; + } if let Some((source, tree)) = self.rust_tree_for(&m.file) { let body = Self::rust_module_body(&tree, source.text(), &m.inline)?; if rust_children(body).iter().any(|n| { @@ -292,7 +770,7 @@ impl KernelResolver { return Some(sub); } } - let mut dir = rust_self_module_dir(&m.file); + let mut dir = self.rust_module_dir_of(&m.file); for seg in &m.inline { dir = pos_join(&dir, seg); } @@ -309,12 +787,21 @@ impl KernelResolver { } None } - fn rust_super_module(&mut self, m: &RustModule) -> Option { + pub(in super::super) fn rust_super_module(&mut self, m: &RustModule) -> Option { let mut m = m.clone(); if m.inline.pop().is_some() { return Some(m); } - let dir = pos_dirname(&rust_self_module_dir(&m.file)).to_string(); + // A file a `#[path]` loads is declared in the module the attribute is + // in — not in a module of the directory it happens to sit in. + if let Some(claim) = self.rust_path_claim(&m.file) { + return Some(self.rust_module_at(&claim.file, claim.line, claim.col)); + } + // Above a crate's root there is no module (upstream's m.root flag). + if self.rust_is_target_root(&m.file) { + return None; + } + let dir = pos_dirname(&self.rust_module_dir_of(&m.file)).to_string(); for p in [ pos_join(&dir, "mod.rs"), pos_join(&dir, "lib.rs"), @@ -408,8 +895,16 @@ impl KernelResolver { p.extend_from_slice(&path[i + 1..]); return self.rust_walk_module(&p, &m, depth + 1); } - // Bare module names in Rust 2015 use paths may be crate-relative. + // Bare module names in Rust 2015 use paths may be crate-relative — + // but a `#[path]` decl of the head stays unknown; the fallback may + // not bypass it to a crate-root namesake (rustPathTarget's guard). if i == 0 { + if self + .rust_mod_facts(&from.file) + .is_some_and(|f| f.path_mods.contains(&path[0])) + { + return None; + } let root = self.rust_root_module(&from.file)?; if root != m { return self.rust_walk_module(path, &root, depth + 1); @@ -536,12 +1031,6 @@ impl KernelResolver { let Some(m) = self.rust_walk_module(prefix, from, 0) else { return Ok(None); }; - if self - .read_file(&m.file) - .is_some_and(|s| re!(r"#\s*\[\s*path\s*=").is_match(s.text())) - { - return Ok(None); - } self.rust_items_in_module(&m, leaf, public_only, seen) .map(Some) } @@ -729,8 +1218,12 @@ impl KernelResolver { } if let Some(sub) = self.rust_walk_module(&p, &m, 0) { seen_glob = true; + // A glob's view from outside the module is its `pub` + // surface; inside it (a `#[path]`-loaded file's + // `use super::*` included) private items count too. + let public_only = !self.rust_module_within(&m, &sub, 0); for item in self - .rust_items_in_module(&sub, name, false, &mut HashSet::new()) + .rust_items_in_module(&sub, name, public_only, &mut HashSet::new()) .ok()? { ids.insert(item.id.clone()); diff --git a/codegraph-kernel/src/resolve/rust_types.rs b/codegraph-kernel/src/resolve/rust_types.rs index 75537086d..e1cb9714e 100644 --- a/codegraph-kernel/src/resolve/rust_types.rs +++ b/codegraph-kernel/src/resolve/rust_types.rs @@ -1693,6 +1693,118 @@ impl KernelResolver { None }) } + /// The item `Type::member` names when `rust_method_on` finds no method: + /// the member of each type the written path or `use` resolves to + /// (rustTypeMember, #3025). + fn rust_path_type_member(&mut self, w: &Written, member: &str) -> Res>> { + for decl in self.rust_type_decls(w)? { + if let Some(hit) = self.rust_type_member(&decl, member)? { + return Ok(Some(hit)); + } + } + Ok(None) + } + + /// rustTypeMember (name-matcher.ts): the associated item `member` of the + /// Rust type `ty` — an enum's variant; else the type's own (inherent) + /// function or `const` in the type's crate, a trait impl's only when it + /// is the one item of that name; a trait's own declared method. An impl + /// of the type is written in the type's crate, so a namesake type's impl + /// in another crate is never it. + fn rust_type_member(&mut self, ty: &KNode, member: &str) -> Res>> { + if ty.language != "rust" + || !matches!(ty.kind.as_str(), "struct" | "enum" | "union" | "trait") + { + return Ok(None); + } + let key = format!("{}\0{}", ty.id, member); + if let Some(hit) = self.rust_type_member_memo.get(&key) { + return match hit { + Some(id) => self.node_by_id(id), + None => Ok(None), + }; + } + let mut found: Option> = None; + if ty.kind == "enum" { + let qualified = format!("{}::{}", ty.qualified_name, member); + found = self + .nodes_in_file(&ty.file_path)? + .iter() + .find(|n| { + n.kind == "enum_member" + && n.qualified_name == qualified + && n.start_line >= ty.start_line + && n.start_line <= ty.end_line + }) + .cloned(); + } + if found.is_none() { + let graph = self.rust_crate_graph()?; + let own = Self::rust_crate_dir_of(&ty.file_path, &graph); + let qualified = format!("{}::{}", ty.name, member); + let all: Vec> = self + .nodes_by_name(member)? + .iter() + .filter(|n| { + n.language == "rust" + && matches!( + n.kind.as_str(), + "method" | "function" | "variable" | "constant" | "enum_member" + ) + && n.qualified_name == qualified + && Self::rust_crate_dir_of(&n.file_path, &graph) == own + && (ty.kind != "trait" + || (n.file_path == ty.file_path + && n.start_line >= ty.start_line + && n.start_line <= ty.end_line)) + }) + .cloned() + .collect(); + let mut inherent = Vec::new(); + for n in &all { + if !self.rust_is_trait_impl_member(n)? { + inherent.push(n.clone()); + } + } + let pool = if !inherent.is_empty() { + inherent + } else if all.len() == 1 { + all + } else { + Vec::new() + }; + found = pool + .iter() + .find(|n| n.file_path == ty.file_path) + .cloned() + .or_else(|| pool.into_iter().next()); + } + self.rust_type_member_memo + .insert(key, found.as_ref().map(|n| n.id.clone())); + Ok(found) + } + + /// isRustTraitImplMethod (name-matcher.ts): `candidate` is a method + /// inside an `impl Trait for Type` block. + fn rust_is_trait_impl_member(&mut self, candidate: &KNode) -> Res { + if candidate.kind != "method" { + return Ok(false); + } + let Some((s, t)) = self.rust_tree_for(&candidate.file_path) else { + return Ok(false); + }; + let Some(f) = self.rust_declaration_node( + candidate, + &t, + s.text(), + &["function_item", "function_signature_item"], + ) else { + return Ok(false); + }; + Ok(rust_ancestor(f, &["impl_item"]) + .is_some_and(|imp| imp.child_by_field_name("trait").is_some())) + } + pub(super) fn rust_impl_start(&mut self, file: &str, line: i64, col: i64) -> Option { let (s, t) = self.rust_tree_for(file)?; rust_ancestor( @@ -1922,6 +2034,11 @@ impl KernelResolver { None if function.kind() == "field_expression" => { self.rust_derived_trait_method(&written, &method, r)? } + // `Type::member` — an enum's variant, a `const`, a trait's own + // declared method — of the type the path or `use` names (#3025). + None if function.kind() == "scoped_identifier" => { + self.rust_path_type_member(&written, &method)? + } None => None, }; if node.is_some() { diff --git a/codegraph-kernel/src/ruby.rs b/codegraph-kernel/src/ruby.rs index bb239b341..2882ee15b 100644 --- a/codegraph-kernel/src/ruby.rs +++ b/codegraph-kernel/src/ruby.rs @@ -633,6 +633,22 @@ impl<'t> Walker<'t> { self.push_ref_at(parent, &ref_path, imports_kind, node); } + /// rubyConstructedClass (tree-sitter.ts): the class a call receiver + /// constructs, as written — `Foo` for `Foo.new(key)`, `Billing::Card` for + /// `Billing::Card.new`, `::Foo` for `::Foo.new`; None for any other + /// receiver, a `new` on a value (`klass.new`) among them. + fn constructed_class(&self, receiver: Node<'t>) -> Option<&'t str> { + if receiver.kind() != "call" { + return None; + } + let method = receiver.child_by_field_name("method")?; + let made = receiver.child_by_field_name("receiver")?; + if self.text(method) != "new" || !is_constant_path(made) { + return None; + } + Some(self.text(made)) + } + /// extractCall — the bespoke ruby branch (tree-sitter.ts:3905-3960), /// reached only from the body walker. fn extract_call(&mut self, node: Node<'t>) { @@ -680,6 +696,10 @@ impl<'t> Walker<'t> { let skip = matches!(receiver_name, "self" | "super"); let callee = if skip { method_name.to_string() + } else if let Some(class_name) = self.constructed_class(receiver) { + // A call on what `Foo.new(…)` made: `new Foo().m`, not the + // receiver's text with its arguments in it. + format!("new {class_name}().{method_name}") } else { format!("{receiver_name}.{method_name}") }; diff --git a/src/extraction/astro-extractor.ts b/src/extraction/astro-extractor.ts index 0d048dae5..e6740cd49 100644 --- a/src/extraction/astro-extractor.ts +++ b/src/extraction/astro-extractor.ts @@ -2,7 +2,7 @@ import { Binding, Node, Edge, ExtractionResult, ExtractionError, UnresolvedRefer import { generateNodeId } from './tree-sitter-helpers'; import { extractEmbeddedBlock } from './block-extract'; import { isLanguageSupported } from './grammars'; -import { arrowScopes, declaredByArrow, foldScriptResult, sfcFileNode } from './sfc-script'; +import { arrowScopes, declaredByArrow, foldScriptResult, sfcFileNode, SFC_SCRIPT_ELEMENT, sfcBlockLineRanges } from './sfc-script'; /** * Astro built-in components — compiler-provided (``) or shipped by @@ -163,7 +163,7 @@ export class AstroExtractor { // A self-closing ``. - const scriptRegex = /]*?)?(?:\/>|>(?[\s\S]*?)<\/script>)/g; + const scriptRegex = new RegExp(SFC_SCRIPT_ELEMENT); let match; while ((match = scriptRegex.exec(this.source)) !== null) { @@ -227,16 +227,9 @@ export class AstroExtractor { coveredRanges.push([frontmatter.startLine - 1, frontmatter.endLine]); } - const tagRegex = /<(script|style)(\s[^>]*?)?(?:\/>|>[\s\S]*?<\/\1>)/g; - let tagMatch; - while ((tagMatch = tagRegex.exec(this.source)) !== null) { - // A self-closing tag has no body to skip, and its attributes - // (`set:html={serializeJsonLd(schema)}`) are template expressions. - if (tagMatch[0].endsWith('/>')) continue; - const startLine = (this.source.substring(0, tagMatch.index).match(/\n/g) || []).length; - const endLine = startLine + (tagMatch[0].match(/\n/g) || []).length; - coveredRanges.push([startLine, endLine]); - } + // A self-closing tag covers nothing: its attributes + // (`set:html={serializeJsonLd(schema)}`) are template expressions. + coveredRanges.push(...sfcBlockLineRanges(this.source)); return coveredRanges; } diff --git a/src/extraction/sfc-script.ts b/src/extraction/sfc-script.ts index eec289c40..83c100abd 100644 --- a/src/extraction/sfc-script.ts +++ b/src/extraction/sfc-script.ts @@ -42,6 +42,51 @@ export function sfcFileNode(filePath: string, source: string, language: Language }; } +/** + * A ``, and that script was parsed with the markup in between and + * contributed nothing (#2989). + * + * Shared by the three extractors and the viewer's syntax regions. It carries + * the `g` flag, so each loop runs on its own copy (`new RegExp(...)`). + */ +export const SFC_SCRIPT_ELEMENT = /]*?)?(?:\/>|>(?[\s\S]*?)<\/script\s*>)/g; + +/** + * A ``. - const scriptRegex = /]*?)?(?:\/>|>(?[\s\S]*?)<\/script>)/g; + const scriptRegex = new RegExp(SFC_SCRIPT_ELEMENT); let match; while ((match = scriptRegex.exec(this.source)) !== null) { @@ -236,20 +236,10 @@ export class SvelteExtractor { componentNodeId: string, _scriptBlocks: Array<{ content: string; startLine: number }> ): void { - // Build a set of line ranges covered by and ranges - const tagRegex = /<(script|style)(\s[^>]*?)?(?:\/>|>[\s\S]*?<\/\1>)/g; - let tagMatch; - while ((tagMatch = tagRegex.exec(this.source)) !== null) { - // A self-closing tag has no body to skip, and its attributes - // (`src={assetUrl('/analytics.js')}`) are template expressions. - if (tagMatch[0].endsWith('/>')) continue; - const startLine = (this.source.substring(0, tagMatch.index).match(/\n/g) || []).length; - const endLine = startLine + (tagMatch[0].match(/\n/g) || []).length; - coveredRanges.push([startLine, endLine]); - } + // Line ranges covered by `. -const SCRIPT_BLOCK = /]*?)?(?:\/>|>([\s\S]*?)<\/script>)/gi; +// The extractors' own script element, read case-insensitively here. A +// self-closing ``. +const SCRIPT_BLOCK = new RegExp(SFC_SCRIPT_ELEMENT.source, 'gi'); /** Astro's frontmatter: a `---` fence at the very top of the file. */ const ASTRO_FRONTMATTER = /^(---\r?\n)([\s\S]*?)\r?\n---/; @@ -389,7 +390,8 @@ export function syntaxRegionsFor(source: string, language: Language): SyntaxRegi while ((match = SCRIPT_BLOCK.exec(source)) !== null) { const body = match[2] ?? ''; if (body.trim() === '') continue; - const start = match.index + match[0].length - body.length - ''.length; + // The body starts right after the opening tag, whose attributes hold no `>`. + const start = match.index + match[0].indexOf('>') + 1; regions.push({ start, end: start + body.length, diff --git a/src/extraction/vue-extractor.ts b/src/extraction/vue-extractor.ts index 584631d02..a0415571c 100644 --- a/src/extraction/vue-extractor.ts +++ b/src/extraction/vue-extractor.ts @@ -2,7 +2,7 @@ import { Binding, Node, Edge, ExtractionResult, ExtractionError, UnresolvedRefer import { generateNodeId } from './tree-sitter-helpers'; import { extractEmbeddedBlock } from './block-extract'; import { isLanguageSupported } from './grammars'; -import { foldScriptResult, sfcFileNode, sfcScriptLanguage } from './sfc-script'; +import { foldScriptResult, sfcFileNode, sfcScriptLanguage, SFC_SCRIPT_ELEMENT, sfcBlockLineRanges } from './sfc-script'; import { vueOptionsMembers } from './vue-options-api'; import { vueTemplateCalls } from './vue-template-calls'; @@ -203,12 +203,15 @@ export class VueExtractor { language: Language; }> = []; - const scriptRegex = /]*)?>(?[\s\S]*?)<\/script>/g; + // A self-closing ``. + const scriptRegex = new RegExp(SFC_SCRIPT_ELEMENT); let match; while ((match = scriptRegex.exec(this.source)) !== null) { + if (match.groups?.content === undefined) continue; const attrs = match[1] || ''; - const content = match.groups?.content || match[2] || ''; + const content = match.groups.content; // TypeScript, TSX or JavaScript, from the lang attribute const language = sfcScriptLanguage(attrs, 'vue'); @@ -289,15 +292,9 @@ export class VueExtractor { // Ranges covered by