mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-14 07:59:40 +00:00
Give each binding of a use tree the import edge that v8 produces for
its equivalent plain use declaration. Preserve aliases, path comments,
separator whitespace, nested prefixes and self/glob binding behavior.
Keep the old group edge as call evidence until the call pass finishes;
exclude per-binding edges from that pass, then remove private markers.
Plain imports and all call edges retain their current-v8 behavior.
AST cache schema 6 -> 7 rejects results written before expansion.
Replace the earlier broad resolver in #4135 with this syntax-only fix.
The resolver is preserved on local branch wip/rust-use-resolver.
On current v8, all 30 new tests and the full 6642-test suite pass.
Fresh ripgrep builds have 864 import edges instead of 247, with every
call unchanged. Existing name-based import rewiring can still produce
wrong matches, just as it does for equivalent plain declarations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
(cherry picked from commit 2669ee4f11)
899 lines
44 KiB
Python
899 lines
44 KiB
Python
"""Rust extractor. Moved verbatim from graphify/extract.py."""
|
|
from __future__ import annotations
|
|
|
|
|
|
import re
|
|
from pathlib import Path
|
|
from graphify.extractors.base import (
|
|
_LANGUAGE_BUILTIN_GLOBALS,
|
|
_file_stem,
|
|
_make_id,
|
|
_read_source_bytes,
|
|
_read_text,
|
|
)
|
|
|
|
|
|
# Rust prelude / std types. Language-local (like _GO_PREDECLARED_FUNCS and
|
|
# _RUST_TRAIT_METHOD_BLOCKLIST below) rather than added to the shared
|
|
# _LANGUAGE_BUILTIN_GLOBALS: the shared set is consulted with no language gate,
|
|
# and `Path`, `Result`, `Error`, `From`, `Into`, `Iterator`, `Default` and
|
|
# friends are ordinary user type names in the other languages that read it.
|
|
#
|
|
# Without this set a Rust codebase resolves every `Option`/`Vec`/`String`/
|
|
# `Result` annotation to a few canonical nodes, so the language's own primitives
|
|
# become the top god nodes (a ~1,200-file workspace had `String` at #1 with
|
|
# ~1,700 edges). Filtering is definition-aware: see _rust_collect_type_refs.
|
|
_RUST_BUILTIN_TYPES: frozenset[str] = frozenset({
|
|
# prelude types and aliases
|
|
"String", "str", "Option", "Result", "Vec", "VecDeque", "Box", "Rc",
|
|
"Arc", "Weak", "RefCell", "Cell", "Cow", "Pin",
|
|
# collections
|
|
"HashMap", "HashSet", "BTreeMap", "BTreeSet", "BinaryHeap",
|
|
# paths / OS strings
|
|
"Path", "PathBuf", "OsStr", "OsString", "CStr", "CString",
|
|
# time / ranges / markers
|
|
"Duration", "Instant", "SystemTime", "Ordering", "Range", "RangeInclusive",
|
|
"RangeFrom", "RangeTo", "RangeFull", "PhantomData", "ManuallyDrop",
|
|
"NonZeroU8", "NonZeroU16", "NonZeroU32", "NonZeroU64", "NonZeroUsize",
|
|
# variants and core traits
|
|
"Some", "None", "Ok", "Err", "Self",
|
|
"Default", "Clone", "Copy", "Debug", "Display", "Error", "From", "Into",
|
|
"TryFrom", "TryInto", "AsRef", "AsMut", "Iterator", "IntoIterator",
|
|
"Extend", "PartialEq", "Eq", "PartialOrd", "Ord", "Hash", "Send", "Sync",
|
|
"Sized", "Drop", "Deref", "DerefMut", "Future", "Fn", "FnMut", "FnOnce",
|
|
})
|
|
|
|
|
|
def _rust_collect_type_refs(
|
|
node,
|
|
source: bytes,
|
|
generic: bool,
|
|
out: list[tuple[str, str]],
|
|
local_types: frozenset[str] = frozenset(),
|
|
) -> None:
|
|
"""Walk a Rust type expression; append (name, role) tuples.
|
|
|
|
A name in ``_RUST_BUILTIN_TYPES`` is skipped unless ``local_types`` holds it:
|
|
a file that defines its own ``struct Result<T>`` shadows the prelude, and its
|
|
references must survive. Filtering the bare name alone would erase a
|
|
legitimate user type that happens to share a std name.
|
|
"""
|
|
|
|
def _keep(text: str) -> bool:
|
|
return bool(text) and (text not in _RUST_BUILTIN_TYPES or text in local_types)
|
|
|
|
if node is None:
|
|
return
|
|
t = node.type
|
|
if t == "primitive_type":
|
|
return
|
|
if t == "type_identifier":
|
|
text = _read_text(node, source)
|
|
if _keep(text):
|
|
out.append((text, "generic_arg" if generic else "type"))
|
|
return
|
|
if t == "scoped_type_identifier":
|
|
text = _read_text(node, source).rsplit("::", 1)[-1]
|
|
if _keep(text):
|
|
out.append((text, "generic_arg" if generic else "type"))
|
|
return
|
|
if t == "generic_type":
|
|
name_node = node.child_by_field_name("type")
|
|
if name_node is None:
|
|
for c in node.children:
|
|
if c.type in ("type_identifier", "scoped_type_identifier"):
|
|
name_node = c
|
|
break
|
|
if name_node is not None:
|
|
text = _read_text(name_node, source).rsplit("::", 1)[-1]
|
|
if _keep(text):
|
|
out.append((text, "generic_arg" if generic else "type"))
|
|
for c in node.children:
|
|
if c.type == "type_arguments":
|
|
for arg in c.children:
|
|
if arg.is_named:
|
|
_rust_collect_type_refs(arg, source, True, out, local_types)
|
|
return
|
|
if t in ("reference_type", "pointer_type", "array_type", "tuple_type", "slice_type"):
|
|
for c in node.children:
|
|
if c.is_named:
|
|
_rust_collect_type_refs(c, source, generic, out, local_types)
|
|
return
|
|
if node.is_named:
|
|
for c in node.children:
|
|
if c.is_named:
|
|
_rust_collect_type_refs(c, source, generic, out, local_types)
|
|
|
|
|
|
def _rust_simple_generic_impl_key(node, source: bytes) -> str | None:
|
|
"""Return a stable owner/arity key for a deliberately narrow impl shape."""
|
|
if node.child_by_field_name("trait") is not None:
|
|
return None
|
|
parameters = node.child_by_field_name("type_parameters")
|
|
owner_type = node.child_by_field_name("type")
|
|
if parameters is None or owner_type is None or owner_type.type != "generic_type":
|
|
return None
|
|
if any(child.type == "where_clause" for child in node.named_children):
|
|
return None
|
|
|
|
parameter_names: list[str] = []
|
|
for parameter in parameters.named_children:
|
|
if parameter.type != "type_parameter":
|
|
return None
|
|
named = parameter.named_children
|
|
if len(named) != 1 or named[0].type != "type_identifier":
|
|
return None
|
|
name = _read_text(named[0], source)
|
|
if not name or name in parameter_names:
|
|
return None
|
|
parameter_names.append(name)
|
|
if not parameter_names:
|
|
return None
|
|
|
|
owner = owner_type.child_by_field_name("type")
|
|
if owner is None or owner.type != "type_identifier":
|
|
return None
|
|
arguments = next(
|
|
(child for child in owner_type.named_children if child.type == "type_arguments"),
|
|
None,
|
|
)
|
|
if arguments is None:
|
|
return None
|
|
argument_names = [
|
|
_read_text(argument, source)
|
|
for argument in arguments.named_children
|
|
if argument.type == "type_identifier"
|
|
]
|
|
if len(argument_names) != len(arguments.named_children):
|
|
return None
|
|
if argument_names != parameter_names:
|
|
return None
|
|
|
|
owner_name = _read_text(owner, source)
|
|
return f"{owner_name}/{len(parameter_names)}" if owner_name else None
|
|
|
|
|
|
def _rust_use_module(argument: str) -> str:
|
|
"""The name a plain `use` imports from: the last path segment of its argument, as
|
|
written (`a::b` -> `b`, `a::b as c` -> `b as c`, `a::*` -> `a`)."""
|
|
clean = argument.split("{")[0].rstrip(":").rstrip("*").rstrip(":")
|
|
return clean.split("::")[-1].strip()
|
|
|
|
|
|
def _rust_use_bindings(node, source: bytes, prefix: str = "", self_path: str = "") -> list[tuple[str, int]]:
|
|
"""Each binding of a `use` tree, as the argument of a plain `use` that binds the same
|
|
name, with its line: `a::{b, c as d, e::{self}, f::*}` gives `b`, `c as d`, `a::e`
|
|
and `f::*` (as `use a::b;` and so on would: only the last segment counts). Paths
|
|
keep their text as written."""
|
|
t, line = node.type, node.start_point[0] + 1
|
|
if t == "use_list":
|
|
return [binding for child in node.named_children for binding in _rust_use_bindings(child, source, prefix, self_path)]
|
|
if t == "scoped_use_list":
|
|
path, group = node.child_by_field_name("path"), node.child_by_field_name("list")
|
|
lead = prefix + source[node.start_byte:group.start_byte].decode("utf-8", errors="replace") if group is not None else ""
|
|
owner = prefix + _read_text(path, source) if path is not None else ""
|
|
return _rust_use_bindings(group, source, lead, owner) if group is not None else []
|
|
alias = ""
|
|
if t == "use_as_clause":
|
|
clause, node = node, node.child_by_field_name("path")
|
|
alias = source[node.end_byte:clause.end_byte].decode("utf-8", errors="replace") if node is not None else ""
|
|
if node is not None and node.type == "self" and self_path: # `a::{self}` binds `a`
|
|
return [(self_path + alias, line)]
|
|
if node is not None and node.type in ("use_wildcard", "identifier", "scoped_identifier", "crate", "self", "super"):
|
|
return [(prefix + _read_text(node, source) + alias, line)]
|
|
return []
|
|
|
|
|
|
_RUST_TRAIT_METHOD_BLOCKLIST: frozenset[str] = frozenset({
|
|
"new", "default", "parse", "from_str", "now", "clone", "into", "from",
|
|
"to_string", "to_owned", "len", "is_empty", "iter", "next", "build",
|
|
"start", "run", "init", "app", "get", "set", "push", "pop", "insert",
|
|
"remove", "contains", "collect", "map", "filter", "unwrap", "expect",
|
|
"ok", "err", "some", "none", "send", "recv", "lock", "read", "write",
|
|
})
|
|
|
|
_RUST_PRIMITIVE_TYPES: frozenset[str] = frozenset({
|
|
"bool", "char", "str", "f32", "f64",
|
|
"i8", "i16", "i32", "i64", "i128", "isize",
|
|
"u8", "u16", "u32", "u64", "u128", "usize",
|
|
})
|
|
|
|
|
|
def _rust_qualifier_type(path_node, source: bytes) -> str | None:
|
|
"""The type a `Type::f()` call is qualified by, or None for a module path.
|
|
|
|
`Foo`, `crate::a::Foo`, `Foo::<T>`, `Vec::<u8>` and `i32` give their last
|
|
segment without generic arguments; `Self` is returned as is for the caller to
|
|
map to the enclosing impl type. Any other lowercase last segment is a module
|
|
(`fs::read`, `self::helper`), and `<T as Trait>` has no segment of its own:
|
|
both give None.
|
|
"""
|
|
if path_node is None:
|
|
return None
|
|
last = _rust_type_last_segment(_read_text(path_node, source))
|
|
return last if last[:1].isupper() or last in _RUST_PRIMITIVE_TYPES else None
|
|
|
|
|
|
def _rust_type_last_segment(text: str) -> str:
|
|
"""`crate::a::Foo<T>` -> `Foo`: the last path segment, generic arguments dropped.
|
|
A leading `&`, `&'a mut` or `dyn` (`impl Tr for &'a Foo`) is not part of it."""
|
|
text = re.sub(r"^(?:&\s*(?:'\w+\s+)?(?:mut\s+)?|dyn\s+)", "", text.strip())
|
|
depth, kept = 0, []
|
|
for ch in text:
|
|
if ch == "<":
|
|
depth += 1
|
|
elif ch == ">":
|
|
depth = max(depth - 1, 0)
|
|
elif depth == 0:
|
|
kept.append(ch)
|
|
segments = [s.strip() for s in "".join(kept).split("::") if s.strip()]
|
|
return segments[-1] if segments else ""
|
|
|
|
|
|
def extract_rust(path: Path) -> dict:
|
|
"""Extract functions, structs, enums, traits, impl methods, statics/consts, and use declarations from a .rs file."""
|
|
try:
|
|
import tree_sitter_rust as tsrust
|
|
from tree_sitter import Language, Parser
|
|
except ImportError:
|
|
return {"nodes": [], "edges": [], "error": "tree-sitter-rust not installed"}
|
|
|
|
try:
|
|
language = Language(tsrust.language())
|
|
parser = Parser(language)
|
|
source = _read_source_bytes(path)
|
|
tree = parser.parse(source)
|
|
root = tree.root_node
|
|
except Exception as e:
|
|
return {"nodes": [], "edges": [], "error": str(e)}
|
|
|
|
stem = _file_stem(path)
|
|
str_path = str(path)
|
|
nodes: list[dict] = []
|
|
edges: list[dict] = []
|
|
seen_ids: set[str] = set()
|
|
function_bodies: list[tuple[str, object, str | None, str | None]] = []
|
|
impl_keys: dict[str, str | None] = {}
|
|
|
|
# Names this file defines as a type (struct/enum/trait/union/alias) before it
|
|
# starts referring to them. A local definition shadows the prelude, so its
|
|
# references must not be dropped by the builtin-name filter further down.
|
|
local_types: set[str] = set()
|
|
|
|
def _scan_local_types(scan_node) -> None:
|
|
if scan_node.type in ("struct_item", "enum_item", "trait_item", "union_item", "type_item"):
|
|
name_node = scan_node.child_by_field_name("name")
|
|
if name_node is not None:
|
|
name = _read_text(name_node, source)
|
|
if name:
|
|
local_types.add(name)
|
|
for child in scan_node.children:
|
|
_scan_local_types(child)
|
|
|
|
_scan_local_types(root)
|
|
local_type_names = frozenset(local_types)
|
|
|
|
# `macro_rules!` macros defined in this file, keyed by bare name. A macro and a
|
|
# function can share a name in Rust (`vec!` vs `vec`), and a macro is invoked
|
|
# as `name!(...)` (a `macro_invocation`, never a `call_expression`), so macros
|
|
# are resolved through their own registry rather than the function/type
|
|
# label_to_nid to avoid cross-binding.
|
|
macro_nids_by_name: dict[str, str] = {}
|
|
|
|
# (bare impl type, method name) -> the method nodes this file's `impl` blocks
|
|
# define, so an in-file `Type::f()` / `self.f()` binds to a method of that type.
|
|
impl_methods: dict[tuple[str, str], set[str]] = {}
|
|
|
|
def add_node(nid: str, label: str, line: int) -> None:
|
|
if nid not in seen_ids:
|
|
seen_ids.add(nid)
|
|
nodes.append({
|
|
"id": nid,
|
|
"label": label,
|
|
"file_type": "code",
|
|
"source_file": str_path,
|
|
"source_location": f"L{line}",
|
|
})
|
|
|
|
def add_edge(src: str, tgt: str, relation: str, line: int,
|
|
confidence: str = "EXTRACTED", weight: float = 1.0,
|
|
context: str | None = None) -> None:
|
|
edge = {
|
|
"source": src,
|
|
"target": tgt,
|
|
"relation": relation,
|
|
"confidence": confidence,
|
|
"source_file": str_path,
|
|
"source_location": f"L{line}",
|
|
"weight": weight,
|
|
}
|
|
if context:
|
|
edge["context"] = context
|
|
edges.append(edge)
|
|
|
|
file_nid = _make_id(str(path))
|
|
add_node(file_nid, path.name, 1)
|
|
|
|
# Pre-scan the struct/enum/trait names declared in this file so a type used
|
|
# above its declaration resolves exactly as a use below it already does,
|
|
# rather than to a sourceless stub that the corpus-level rewire only folds
|
|
# back when the name is unique across the corpus (#3782). Only these three
|
|
# items are pre-registered: walk() never makes a node of a `type` alias or a
|
|
# `union`, so registering one would leave its references pointing at no node.
|
|
# The scan never descends further than walk() does, so every id registered
|
|
# here ends up as a node. Ids are casefolded, so `fn handle` and
|
|
# `struct Handle` (or `struct HANDLE` and `struct Handle`) share one and
|
|
# walk() keeps whichever comes first: a type is only pre-registered when
|
|
# every file-level item behind its id is that same type (`#[cfg]` twins count
|
|
# once), and it is matched by its exact name, since a `type HANDLE` alias or
|
|
# an imported `HANDLE` shares the id of a local `struct Handle`. Otherwise the
|
|
# forward reference keeps the sourceless stub v8's extractor gives it. An
|
|
# `impl` block counts as the type it names, because walk() gives it the node
|
|
# of its type text (`impl Tr for HANDLE` collides with `struct Handle`,
|
|
# `impl Tr for &Handle<'_>` does not); methods and impl-scoped items have
|
|
# impl-qualified ids and are not counted.
|
|
items_by_id: dict[str, set[tuple[bool, str]]] = {}
|
|
declared_type_ids: set[str] = set()
|
|
|
|
def _scan_type_items(node) -> None:
|
|
t = node.type
|
|
if t in ("struct_item", "enum_item", "trait_item", "function_item",
|
|
"function_signature_item", "static_item", "const_item"):
|
|
name_node = node.child_by_field_name("name")
|
|
if name_node:
|
|
name = _read_text(name_node, source)
|
|
if t in ("static_item", "const_item") and name == "_":
|
|
return
|
|
nid = _make_id(stem, name)
|
|
is_type = t in ("struct_item", "enum_item", "trait_item")
|
|
items_by_id.setdefault(nid, set()).add((is_type, name))
|
|
if is_type:
|
|
declared_type_ids.add(nid)
|
|
return
|
|
if t == "impl_item":
|
|
type_node = node.child_by_field_name("type")
|
|
if type_node is not None:
|
|
type_name = _read_text(type_node, source).strip()
|
|
refs: list[tuple[str, str]] = []
|
|
_rust_collect_type_refs(type_node, source, False, refs)
|
|
bare = next((ref for ref, role in refs if role == "type"), type_name)
|
|
items_by_id.setdefault(_make_id(stem, type_name), set()).add((True, bare))
|
|
return
|
|
if t == "use_declaration":
|
|
return
|
|
for child in node.children:
|
|
_scan_type_items(child)
|
|
|
|
_scan_type_items(root)
|
|
local_type_names = {
|
|
next(iter(items))[1] for nid, items in items_by_id.items()
|
|
if nid in declared_type_ids and len(items) == 1
|
|
}
|
|
|
|
def ensure_named_node(name: str, line: int) -> str:
|
|
nid = _make_id(stem, name)
|
|
if nid in seen_ids or name in local_type_names:
|
|
return nid
|
|
nid = _make_id(name)
|
|
if nid not in seen_ids:
|
|
# The name isn't defined in this file, so this is a cross-file reference
|
|
# (e.g. a `Thing` type annotation imported from another module). Emit a
|
|
# SOURCELESS stub — like the inheritance-base path below — so the
|
|
# corpus-level rewire can collapse it onto the real definition. A sourced
|
|
# stub here makes _disambiguate_colliding_node_ids bake the referencing
|
|
# file's path (with extension) into the id and blocks the rewire, which is
|
|
# the phantom-duplicate-node bug (#1402).
|
|
seen_ids.add(nid)
|
|
nodes.append({
|
|
"id": nid,
|
|
"label": name,
|
|
"file_type": "code",
|
|
"source_file": "",
|
|
"source_location": "",
|
|
"origin_file": str_path,
|
|
})
|
|
return nid
|
|
|
|
def emit_param_return_refs(func_node, func_nid: str, line: int) -> None:
|
|
params = func_node.child_by_field_name("parameters")
|
|
if params is not None:
|
|
for p in params.children:
|
|
if p.type != "parameter":
|
|
continue
|
|
type_node = p.child_by_field_name("type")
|
|
refs: list[tuple[str, str]] = []
|
|
_rust_collect_type_refs(type_node, source, False, refs, local_type_names)
|
|
for ref_name, role in refs:
|
|
ctx = "generic_arg" if role == "generic_arg" else "parameter_type"
|
|
tgt = ensure_named_node(ref_name, line)
|
|
if tgt != func_nid:
|
|
add_edge(func_nid, tgt, "references", line, context=ctx)
|
|
return_type = func_node.child_by_field_name("return_type")
|
|
if return_type is not None:
|
|
refs = []
|
|
_rust_collect_type_refs(return_type, source, False, refs, local_type_names)
|
|
for ref_name, role in refs:
|
|
ctx = "generic_arg" if role == "generic_arg" else "return_type"
|
|
tgt = ensure_named_node(ref_name, line)
|
|
if tgt != func_nid:
|
|
add_edge(func_nid, tgt, "references", line, context=ctx)
|
|
|
|
def walk(
|
|
node,
|
|
parent_impl_nid: str | None = None,
|
|
parent_impl_type: str | None = None,
|
|
parent_impl_key: str | None = None,
|
|
) -> None:
|
|
t = node.type
|
|
|
|
if t == "function_item":
|
|
name_node = node.child_by_field_name("name")
|
|
if name_node:
|
|
func_name = _read_text(name_node, source)
|
|
line = node.start_point[0] + 1
|
|
if parent_impl_nid:
|
|
func_nid = _make_id(parent_impl_nid, func_name)
|
|
add_node(func_nid, f".{func_name}()", line)
|
|
add_edge(parent_impl_nid, func_nid, "method", line)
|
|
if parent_impl_type:
|
|
owner = _rust_type_last_segment(parent_impl_type)
|
|
impl_methods.setdefault((owner, func_name), set()).add(func_nid)
|
|
else:
|
|
func_nid = _make_id(stem, func_name)
|
|
add_node(func_nid, f"{func_name}()", line)
|
|
add_edge(file_nid, func_nid, "contains", line)
|
|
emit_param_return_refs(node, func_nid, line)
|
|
body = node.child_by_field_name("body")
|
|
if body:
|
|
function_bodies.append((
|
|
func_nid,
|
|
body,
|
|
parent_impl_type,
|
|
parent_impl_key,
|
|
))
|
|
return
|
|
|
|
if t == "function_signature_item":
|
|
# `fn greet(&self) -> String;` — a trait method with no body. This node type
|
|
# had no branch at all, so a trait's required methods were unreachable: the
|
|
# only method nodes in a graph came from impl blocks, and a trait with no
|
|
# implementor in the corpus contributed none. Mirrors function_item, minus
|
|
# the body walk (there is no body).
|
|
name_node = node.child_by_field_name("name")
|
|
if name_node:
|
|
func_name = _read_text(name_node, source)
|
|
line = node.start_point[0] + 1
|
|
if parent_impl_nid:
|
|
func_nid = _make_id(parent_impl_nid, func_name)
|
|
add_node(func_nid, f".{func_name}()", line)
|
|
add_edge(parent_impl_nid, func_nid, "method", line)
|
|
else:
|
|
func_nid = _make_id(stem, func_name)
|
|
add_node(func_nid, f"{func_name}()", line)
|
|
add_edge(file_nid, func_nid, "contains", line)
|
|
emit_param_return_refs(node, func_nid, line)
|
|
return
|
|
|
|
if t == "macro_definition":
|
|
# `macro_rules! name { ... }` defines a named, invocable item. This node
|
|
# type had no branch, so the macro was dropped entirely: it was never a
|
|
# node, and a `name!(...)` invocation of it had nothing to resolve to.
|
|
# The id is macro-qualified so it never collides with a same-named fn or
|
|
# type (`vec!` vs `vec`); the `!` suffix in the label marks it a macro.
|
|
name_node = node.child_by_field_name("name")
|
|
if name_node:
|
|
macro_name = _read_text(name_node, source)
|
|
if macro_name:
|
|
line = node.start_point[0] + 1
|
|
macro_nid = _make_id(stem, "macro", macro_name)
|
|
add_node(macro_nid, f"{macro_name}!", line)
|
|
add_edge(file_nid, macro_nid, "contains", line)
|
|
macro_nids_by_name.setdefault(macro_name, macro_nid)
|
|
return
|
|
|
|
if t in ("struct_item", "enum_item", "trait_item"):
|
|
name_node = node.child_by_field_name("name")
|
|
if name_node:
|
|
item_name = _read_text(name_node, source)
|
|
line = node.start_point[0] + 1
|
|
item_nid = _make_id(stem, item_name)
|
|
add_node(item_nid, item_name, line)
|
|
declaration_node = next(n for n in nodes if n["id"] == item_nid)
|
|
declaration_node["_rust_declaration_count"] = (
|
|
declaration_node.get("_rust_declaration_count", 0) + 1
|
|
)
|
|
add_edge(file_nid, item_nid, "contains", line)
|
|
if t == "trait_item":
|
|
for c in node.children:
|
|
if c.type != "trait_bounds":
|
|
continue
|
|
for sub in c.children:
|
|
if not sub.is_named:
|
|
continue
|
|
refs: list[tuple[str, str]] = []
|
|
_rust_collect_type_refs(sub, source, False, refs, local_type_names)
|
|
for idx, (ref_name, _role) in enumerate(refs):
|
|
tgt = ensure_named_node(ref_name, line)
|
|
if tgt == item_nid:
|
|
continue
|
|
rel = "inherits" if idx == 0 else "references"
|
|
if rel == "inherits":
|
|
add_edge(item_nid, tgt, "inherits", line)
|
|
else:
|
|
add_edge(item_nid, tgt, "references", line,
|
|
context="generic_arg")
|
|
if t == "struct_item":
|
|
for c in node.children:
|
|
if c.type != "field_declaration_list":
|
|
continue
|
|
for field in c.children:
|
|
if field.type != "field_declaration":
|
|
continue
|
|
type_node = field.child_by_field_name("type")
|
|
if type_node is None:
|
|
for fc in field.children:
|
|
if fc.type in ("type_identifier", "generic_type",
|
|
"scoped_type_identifier",
|
|
"reference_type", "primitive_type"):
|
|
type_node = fc
|
|
break
|
|
refs = []
|
|
_rust_collect_type_refs(type_node, source, False, refs, local_type_names)
|
|
for ref_name, role in refs:
|
|
ctx = "generic_arg" if role == "generic_arg" else "field"
|
|
tgt = ensure_named_node(ref_name, field.start_point[0] + 1)
|
|
if tgt != item_nid:
|
|
add_edge(item_nid, tgt, "references",
|
|
field.start_point[0] + 1, context=ctx)
|
|
# Tuple structs (`struct Wrapper(pub Logger, Config);`) nest their
|
|
# positional field types directly under ordered_field_declaration_list
|
|
# with no field_declaration wrapper -- the same shape handled for tuple
|
|
# enum variants below. Without this branch these field type references
|
|
# are silently dropped.
|
|
for c in node.children:
|
|
if c.type != "ordered_field_declaration_list":
|
|
continue
|
|
fline = c.start_point[0] + 1
|
|
for tc in c.children:
|
|
if tc.type not in ("type_identifier", "generic_type",
|
|
"scoped_type_identifier", "reference_type",
|
|
"primitive_type", "tuple_type", "array_type"):
|
|
continue
|
|
refs = []
|
|
_rust_collect_type_refs(tc, source, False, refs, local_type_names)
|
|
for ref_name, role in refs:
|
|
ctx = "generic_arg" if role == "generic_arg" else "field"
|
|
tgt = ensure_named_node(ref_name, fline)
|
|
if tgt != item_nid:
|
|
add_edge(item_nid, tgt, "references", fline, context=ctx)
|
|
if t == "enum_item":
|
|
# Variant payload types nest under enum_variant_list ->
|
|
# enum_variant -> ordered_field_declaration_list (tuple variant,
|
|
# `Click(Logger)`) | field_declaration_list (struct variant,
|
|
# `Resize { size: Dim }`). Neither was traversed, so every
|
|
# enum-variant type reference was silently dropped.
|
|
_TYPE_NODES = ("type_identifier", "generic_type",
|
|
"scoped_type_identifier", "reference_type",
|
|
"primitive_type", "tuple_type", "array_type")
|
|
|
|
def _emit_enum_type(type_node, at_line):
|
|
if type_node is None:
|
|
return
|
|
refs2: list[tuple[str, str]] = []
|
|
_rust_collect_type_refs(type_node, source, False, refs2, local_type_names)
|
|
for ref_name, role in refs2:
|
|
ctx = "generic_arg" if role == "generic_arg" else "field"
|
|
tgt = ensure_named_node(ref_name, at_line)
|
|
if tgt != item_nid:
|
|
add_edge(item_nid, tgt, "references", at_line, context=ctx)
|
|
|
|
for c in node.children:
|
|
if c.type != "enum_variant_list":
|
|
continue
|
|
for variant in c.children:
|
|
if variant.type != "enum_variant":
|
|
continue
|
|
vline = variant.start_point[0] + 1
|
|
# Emit a node per variant with a case_of edge back to
|
|
# the enum. Only the variants' payload types were
|
|
# walked before, so the variants themselves (`Circle`,
|
|
# `Square`, `Empty`) never became nodes and the enum
|
|
# was left a memberless leaf. This is the Rust parity
|
|
# of Java #1719 / Kotlin #1738 / Swift / Scala enums.
|
|
# The variant name is the enum_variant's `identifier`.
|
|
vname_node = next(
|
|
(vc for vc in variant.children
|
|
if vc.type == "identifier"),
|
|
None,
|
|
)
|
|
if vname_node is not None:
|
|
vname = _read_text(vname_node, source)
|
|
if vname:
|
|
variant_nid = _make_id(item_nid, vname)
|
|
add_node(variant_nid, vname, vline)
|
|
add_edge(item_nid, variant_nid, "case_of", vline)
|
|
for vc in variant.children:
|
|
if vc.type == "ordered_field_declaration_list":
|
|
for tc in vc.children:
|
|
if tc.type in _TYPE_NODES:
|
|
_emit_enum_type(tc, vline)
|
|
elif vc.type == "field_declaration_list":
|
|
for field in vc.children:
|
|
if field.type != "field_declaration":
|
|
continue
|
|
type_node = field.child_by_field_name("type")
|
|
_emit_enum_type(type_node, field.start_point[0] + 1)
|
|
if t == "trait_item":
|
|
# The methods a trait declares are its contract, and they were not
|
|
# reached: this branch returns below, so nothing walked the body.
|
|
# Descend the same way impl_item does, attributing each method to
|
|
# the trait node — so `explain <Trait>` can list what an
|
|
# implementor must provide.
|
|
body = node.child_by_field_name("body")
|
|
if body:
|
|
for child in body.children:
|
|
walk(child, parent_impl_nid=item_nid)
|
|
return
|
|
|
|
if t in ("static_item", "const_item"):
|
|
# `static NAME: T = …;` / `const NAME: T = …;` at module level, or an
|
|
# associated const inside an impl. Neither node type had a branch, so a
|
|
# constant reached the graph only through files that referenced it,
|
|
# never from the Rust that defines it (#3471).
|
|
name_node = node.child_by_field_name("name")
|
|
if name_node:
|
|
item_name = _read_text(name_node, source)
|
|
if item_name == "_":
|
|
return
|
|
line = node.start_point[0] + 1
|
|
if parent_impl_nid:
|
|
item_nid = _make_id(parent_impl_nid, item_name)
|
|
add_node(item_nid, f".{item_name}", line)
|
|
add_edge(parent_impl_nid, item_nid, "contains", line)
|
|
else:
|
|
item_nid = _make_id(stem, item_name)
|
|
add_node(item_nid, item_name, line)
|
|
add_edge(file_nid, item_nid, "contains", line)
|
|
type_node = node.child_by_field_name("type")
|
|
if type_node is not None:
|
|
refs: list[tuple[str, str]] = []
|
|
_rust_collect_type_refs(type_node, source, False, refs, local_type_names)
|
|
for ref_name, role in refs:
|
|
tgt = ensure_named_node(ref_name, line)
|
|
if tgt == item_nid:
|
|
continue
|
|
ctx = "generic_arg" if role == "generic_arg" else "field"
|
|
add_edge(item_nid, tgt, "references", line, context=ctx)
|
|
return
|
|
|
|
if t == "impl_item":
|
|
type_node = node.child_by_field_name("type")
|
|
trait_node = node.child_by_field_name("trait")
|
|
impl_nid: str | None = None
|
|
impl_type_bare: str | None = None
|
|
impl_key: str | None = None
|
|
if type_node:
|
|
type_name = _read_text(type_node, source).strip()
|
|
impl_nid = _make_id(stem, type_name)
|
|
add_node(impl_nid, type_name, node.start_point[0] + 1)
|
|
impl_key = _rust_simple_generic_impl_key(node, source)
|
|
# Bare name (generics stripped) for typing a `self.` receiver
|
|
# inside this block's methods (#2234) — `impl Foo<T>` types
|
|
# `self` as `Foo`, not the literal `Foo<T>` text.
|
|
impl_type_bare = type_name.split("<")[0].strip()
|
|
if trait_node is not None and impl_nid is not None:
|
|
refs: list[tuple[str, str]] = []
|
|
_rust_collect_type_refs(trait_node, source, False, refs, local_type_names)
|
|
for idx, (ref_name, _role) in enumerate(refs):
|
|
tgt = ensure_named_node(ref_name, node.start_point[0] + 1)
|
|
if tgt == impl_nid:
|
|
continue
|
|
if idx == 0:
|
|
add_edge(impl_nid, tgt, "implements", node.start_point[0] + 1)
|
|
else:
|
|
add_edge(impl_nid, tgt, "references", node.start_point[0] + 1,
|
|
context="generic_arg")
|
|
body = node.child_by_field_name("body")
|
|
if body:
|
|
has_methods = any(
|
|
child.type in ("function_item", "function_signature_item")
|
|
for child in body.children
|
|
)
|
|
if impl_nid is not None and has_methods:
|
|
if impl_nid not in impl_keys:
|
|
impl_keys[impl_nid] = impl_key
|
|
elif impl_keys[impl_nid] != impl_key:
|
|
impl_keys[impl_nid] = None
|
|
impl_node = next(n for n in nodes if n["id"] == impl_nid)
|
|
if impl_keys[impl_nid]:
|
|
impl_node["_rust_impl_key"] = impl_keys[impl_nid]
|
|
else:
|
|
impl_node.pop("_rust_impl_key", None)
|
|
for child in body.children:
|
|
walk(
|
|
child,
|
|
parent_impl_nid=impl_nid,
|
|
parent_impl_type=impl_type_bare,
|
|
parent_impl_key=impl_key,
|
|
)
|
|
return
|
|
|
|
if t == "use_declaration":
|
|
arg = node.child_by_field_name("argument")
|
|
if arg:
|
|
module_name = _rust_use_module(_read_text(arg, source))
|
|
grouped = arg.type in ("use_list", "scoped_use_list")
|
|
if module_name:
|
|
tgt_nid = _make_id(module_name)
|
|
add_edge(file_nid, tgt_nid, "imports_from", node.start_point[0] + 1, context="import")
|
|
if grouped: # call evidence as in v8, then dropped (extract.py)
|
|
edges[-1]["_rust_use_group"] = True
|
|
# A group imports what the plain `use` of each of its bindings would.
|
|
for binding, line in _rust_use_bindings(arg, source) if grouped else []:
|
|
if binding_name := _rust_use_module(binding):
|
|
add_edge(file_nid, _make_id(binding_name), "imports_from", line, context="import")
|
|
edges[-1]["_rust_use_binding"] = True # not call evidence: v8 had no such edge
|
|
return
|
|
|
|
for child in node.children:
|
|
walk(child, parent_impl_nid=None)
|
|
|
|
walk(root)
|
|
|
|
# A bare `f()` can only reach a free function or a type (a tuple struct or
|
|
# variant constructor), and `x.f()` only a method, so each call shape looks up
|
|
# its own index: one shared name index let `drop(x)` bind to `impl Drop`'s
|
|
# `drop` and `x.len()` to a free `fn len`.
|
|
label_to_nid: dict[str, str] = {}
|
|
method_label_to_nid: dict[str, str] = {}
|
|
for n in nodes:
|
|
raw = n["label"]
|
|
normalised = raw.strip("()").lstrip(".")
|
|
if raw.startswith("."):
|
|
if raw.endswith(")"):
|
|
method_label_to_nid[normalised] = n["id"]
|
|
else:
|
|
label_to_nid[normalised] = n["id"]
|
|
|
|
# Nodes whose label has no `()` suffix are data definitions (structs, enums,
|
|
# traits, statics), not callables. In Rust `Foo(x)` / `Foo { .. }` onto one
|
|
# of them constructs a value rather than invoking a function, so the edge is
|
|
# a `references` (context "constructor"), not a `calls`. Without this a
|
|
# newtype used everywhere (`ClientId(id)`) reads as a top call hub.
|
|
type_nids: set[str] = {n["id"] for n in nodes if not n["label"].endswith(")")}
|
|
|
|
seen_call_pairs: set[tuple[str, str]] = set()
|
|
raw_calls: list[dict] = []
|
|
|
|
def walk_calls(
|
|
node,
|
|
caller_nid: str,
|
|
self_type: str | None = None,
|
|
self_impl_key: str | None = None,
|
|
) -> None:
|
|
if node.type == "function_item":
|
|
return
|
|
if node.type == "macro_invocation":
|
|
# `name!(...)` invoking a macro_rules! macro defined in this file.
|
|
# Resolve only a bare `identifier` macro against the local registry;
|
|
# a `scoped_identifier` (`log::info!`) is cross-module/crate and stays
|
|
# unresolved (fail-closed), matching how scoped calls are handled above.
|
|
macro_node = node.child_by_field_name("macro")
|
|
if macro_node is not None and macro_node.type == "identifier":
|
|
macro_name = _read_text(macro_node, source)
|
|
tgt_nid = macro_nids_by_name.get(macro_name)
|
|
if tgt_nid and tgt_nid != caller_nid:
|
|
pair = (caller_nid, tgt_nid)
|
|
if pair not in seen_call_pairs:
|
|
seen_call_pairs.add(pair)
|
|
line = node.start_point[0] + 1
|
|
edges.append({
|
|
"source": caller_nid,
|
|
"target": tgt_nid,
|
|
"relation": "calls",
|
|
"context": "call",
|
|
"confidence": "EXTRACTED",
|
|
"source_file": str_path,
|
|
"source_location": f"L{line}",
|
|
"weight": 1.0,
|
|
})
|
|
# Fall through to the generic child recursion below rather than
|
|
# returning, preserving the prior traversal of the invocation's
|
|
# subtree (its arguments are a raw token tree, so this neither adds
|
|
# nor drops any nested edges relative to before).
|
|
if node.type == "call_expression":
|
|
func_node = node.child_by_field_name("function")
|
|
callee_name: str | None = None
|
|
is_member_call: bool = False
|
|
is_scoped_call: bool = False
|
|
is_self_call: bool = False
|
|
qualifier_type: str | None = None
|
|
if func_node:
|
|
if func_node.type == "identifier":
|
|
callee_name = _read_text(func_node, source)
|
|
elif func_node.type == "field_expression":
|
|
is_member_call = True
|
|
field = func_node.child_by_field_name("field")
|
|
if field:
|
|
callee_name = _read_text(field, source)
|
|
receiver = func_node.child_by_field_name("value")
|
|
if receiver is not None and receiver.type == "self":
|
|
is_self_call = True
|
|
elif func_node.type == "scoped_identifier":
|
|
# Type::method() — still allow in-file EXTRACTED match, but
|
|
# skip cross-file resolution: bare last-segment lookup ignores
|
|
# crate boundaries and produces spurious INFERRED edges (#908).
|
|
is_scoped_call = True
|
|
name = func_node.child_by_field_name("name")
|
|
if name:
|
|
callee_name = _read_text(name, source)
|
|
qualifier_type = _rust_qualifier_type(func_node.child_by_field_name("path"), source)
|
|
if qualifier_type == "Self":
|
|
# A trait's default method has no impl type: "" matches
|
|
# no method, so `Self::f()` there binds nothing in-file.
|
|
qualifier_type = self_type or ""
|
|
if (
|
|
callee_name
|
|
and callee_name not in _LANGUAGE_BUILTIN_GLOBALS
|
|
and (callee_name not in _RUST_BUILTIN_TYPES or callee_name in local_types)
|
|
):
|
|
if qualifier_type is not None or (is_self_call and self_type):
|
|
# `Type::f()` / `Self::f()` / `self.f()` name the type whose
|
|
# method is called: bind only to that type's method in this
|
|
# file. Matching the bare name instead sent `Arc::new(x)` and
|
|
# `B::new()` to whichever `fn new` the file happened to define.
|
|
owner = _rust_type_last_segment(qualifier_type or self_type or "")
|
|
owned = impl_methods.get((owner, callee_name), set())
|
|
tgt_nid = next(iter(owned)) if len(owned) == 1 else None
|
|
if tgt_nid is None and qualifier_type is not None:
|
|
# `Enum::Variant(x)` constructs a value: the variant node.
|
|
named = label_to_nid.get(callee_name)
|
|
tgt_nid = named if named in type_nids else None
|
|
elif is_member_call:
|
|
tgt_nid = method_label_to_nid.get(callee_name)
|
|
else:
|
|
tgt_nid = label_to_nid.get(callee_name)
|
|
if tgt_nid and tgt_nid != caller_nid:
|
|
pair = (caller_nid, tgt_nid)
|
|
if pair not in seen_call_pairs:
|
|
seen_call_pairs.add(pair)
|
|
line = node.start_point[0] + 1
|
|
is_constructor = tgt_nid in type_nids
|
|
edges.append({
|
|
"source": caller_nid,
|
|
"target": tgt_nid,
|
|
"relation": "references" if is_constructor else "calls",
|
|
"context": "constructor" if is_constructor else "call",
|
|
"confidence": "EXTRACTED",
|
|
"source_file": str_path,
|
|
"source_location": f"L{line}",
|
|
"weight": 1.0,
|
|
})
|
|
elif not is_scoped_call and callee_name.lower() not in _RUST_TRAIT_METHOD_BLOCKLIST:
|
|
rc_entry = {
|
|
"caller_nid": caller_nid,
|
|
"callee": callee_name,
|
|
"is_member_call": is_member_call,
|
|
"source_file": str_path,
|
|
"source_location": f"L{node.start_point[0] + 1}",
|
|
}
|
|
if is_self_call and self_type:
|
|
rc_entry["rust_self_type"] = self_type
|
|
if self_impl_key:
|
|
rc_entry["rust_self_impl_key"] = self_impl_key
|
|
raw_calls.append(rc_entry)
|
|
for child in node.children:
|
|
walk_calls(child, caller_nid, self_type, self_impl_key)
|
|
|
|
for caller_nid, body_node, impl_type, impl_key in function_bodies:
|
|
walk_calls(body_node, caller_nid, impl_type, impl_key)
|
|
|
|
valid_ids = seen_ids
|
|
clean_edges = []
|
|
for edge in edges:
|
|
src, tgt = edge["source"], edge["target"]
|
|
if src in valid_ids and (tgt in valid_ids or edge["relation"] in ("imports", "imports_from")):
|
|
clean_edges.append(edge)
|
|
|
|
return {"nodes": nodes, "edges": clean_edges, "raw_calls": raw_calls}
|