Skip to content

Commit 67b5469

Browse files
committed
feat(rust): enhance AST extraction with scoped function references and sibling file submodule handling
1 parent 1498c3b commit 67b5469

5 files changed

Lines changed: 225 additions & 11 deletions

File tree

internal/cachecov/coverage_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ var versionCoverage = map[int][]string{
144144
110: {"TestJavaIO_FeignClientMethodPerformsIO", "TestJavaIO_SpringDataRepositoryMethodPerformsIO", "TestJavaIO_QueryAnnotationPerformsIO", "TestJavaIO_PlainServiceMethodNotPerformsIO", "TestJavaIO_ControllerHandlerNotPerformsIO", "TestGolden"}, // Java emits io_direct/performs_io on DB/network round-trips (@FeignClient / Spring Data repository interfaces, @Query/@Modifying/@Procedure, Room @Dao ops) so isExpensiveJvmCall's I/O index is populated on pure-Java; type-level seed avoids the JAX-RS/@GetMapping inbound-handler trap; no transitive fixpoint (GAP-JV-02)
145145
111: {"TestGolden", "TestOwnsFile", "TestAST_ImplTraitForType_EmitsImplements"}, // Rust extractor added: symbols, implements post-pass, dependency/calls/route facts, cache participation
146146
112: {"TestGolden", "TestAST_CallInsideMacroArgument", "TestAST_MatchesMacroGuardCall", "TestAST_FunctionPassedAsCallbackArgument", "TestAST_OrdinaryArgument_NoPhantomReference", "TestAST_FunctionReferenceInStructField", "TestAST_FunctionReferenceTakenByAddress", "TestAST_SerdeDefaultAttribute_ReferencesFunction", "TestAST_SerdeSkipSerializingIfAttribute_ReferencesFunction", "TestAST_ClapValueParserAttribute_ReferencesFunction", "TestAST_LocalFunctionPassedAsCallbackWithinSameBody", "TestAST_FunctionReferenceNestedInsideMacroArgument"}, // Rust call-reference precision: macro-arg calls, function-as-value references (incl. nested inside a macro arg, e.g. vec![Box::new(f)]), serde/clap attribute refs, stale-owner-pointer fix
147+
113: {"TestAST_MergeStrategyAttribute_ReferencesScopedFunction", "TestExtract_UnprefixedUseOfSiblingFileSubmodule", "TestAST_ThiserrorAttributeCall_ReferencesFunction", "TestAST_MacroRulesBodyCall_EmitsFileRef", "TestAST_ItemLevelMacroInvocationCall_EmitsFileRef"}, // Rust: merge-attribute scoped-path fix, sibling-file submodule use-resolution, calls embedded in any attribute macro, owner-less macro content (macro_rules! bodies, item-level macro invocations) recorded via file_ref
147148
}
148149

149150
func TestCacheVersionCoverage(t *testing.T) {

internal/engine/cache.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -399,7 +399,19 @@ import (
399399
// now tracked as references. Also fixes a latent bug where a nested item (a local `fn`
400400
// inside a function body) could reallocate the fact slice and silently drop the
401401
// enclosing function's own relations recorded afterward.
402-
const cacheVersion = "v112"
402+
// v113: Rust — a function reference nested inside a macro argument (vec![Box::new(f)])
403+
// is now tracked; the merge crate's #[merge(strategy = mod::path)] attribute (and any
404+
// other flattened scoped-path attribute value, previously mis-read as just its first
405+
// segment) resolves correctly; an unprefixed `use foo::bar;` where foo is a body-less
406+
// `mod foo;` in the same file/mod scope is now classified internal instead of external
407+
// (it shares its parent's directory, so classifyUsePath had nothing to match it
408+
// against); an attribute can embed an ordinary call — thiserror's #[error("{}",
409+
// helper(x))] — scanned the same way as a macro invocation regardless of macro name;
410+
// and calls/references made in macro content with no enclosing symbol (a macro_rules!
411+
// template body, or an item-level macro invocation standing in for a whole function
412+
// like `ffi_fn! { fn foo() { ... } }`) are now recorded on a file_ref fact instead of
413+
// silently dropped for lack of an owner.
414+
const cacheVersion = "v113"
403415

404416
// extractorCache holds per-extractor facts keyed by a content hash of the files
405417
// the extractor depends on. It is loaded from disk at the start of a snapshot and

internal/extractors/rustextractor/rust_ast.go

Lines changed: 108 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ func extractFileAST(src []byte, relFile string, crates []crateInfo, moduleDirs m
3636
crateDir: nearestCrateDir(dir, crates),
3737
crates: crates,
3838
moduleDirs: moduleDirs,
39+
fileRefIdx: -1,
3940
}
4041
w.walkSourceFile(root)
4142

@@ -78,6 +79,14 @@ type astWalker struct {
7879
// reallocate its backing array and strand a raw pointer.
7980
ownerStack []int
8081

82+
// fileRefIdx: index into out of the lazily-created file-scope reference
83+
// fact (facts.KindFileRef), or -1 until first used. Catches calls/
84+
// references made in macro content with no enclosing symbol — a
85+
// macro_rules! template body, or an item-level macro invocation standing
86+
// in for a whole function (e.g. `ffi_fn! { fn foo() { ... } }`) — which
87+
// emitEdge would otherwise silently drop for lack of an owner.
88+
fileRefIdx int
89+
8190
// modStack/typeStack hold the enclosing inline-`mod { }` and
8291
// impl/trait-block names, so a nested declaration's canonical name is
8392
// "<dir>.<mod1>.<mod2>...<Type>.<name>" — the same qualification scheme
@@ -93,6 +102,10 @@ type astWalker struct {
93102
// mod/file scope, order-independent. Parallel to modStack.
94103
modFnStack []map[string]bool
95104

105+
// modSubmoduleStack[len-1]: body-less `mod foo;` names declared directly
106+
// in the enclosing mod/file scope. Parallel to modStack.
107+
modSubmoduleStack []map[string]bool
108+
96109
// decisions counts cyclomatic decision points in the function/method body
97110
// currently being walked; saved/restored around nested function items.
98111
decisions int
@@ -174,7 +187,9 @@ func (w *astWalker) currentMethods() map[string]bool {
174187

175188
func (w *astWalker) walkSourceFile(root *sitter.Node) {
176189
w.modFnStack = append(w.modFnStack, collectFnNames(root, w.src))
190+
w.modSubmoduleStack = append(w.modSubmoduleStack, collectSubmoduleNames(root, w.src))
177191
w.walkItemsTrackingAttrs(root)
192+
w.modSubmoduleStack = w.modSubmoduleStack[:len(w.modSubmoduleStack)-1]
178193
w.modFnStack = w.modFnStack[:len(w.modFnStack)-1]
179194
}
180195

@@ -190,6 +205,18 @@ func (w *astWalker) isKnownFn(name string) bool {
190205
return false
191206
}
192207

208+
// isKnownSubmodule checks every enclosing mod/file scope for a body-less
209+
// `mod name;` declaration, so `use name::item;` (no self::/crate:: prefix)
210+
// is recognized as a reference to that sibling file, not an external crate.
211+
func (w *astWalker) isKnownSubmodule(name string) bool {
212+
for i := len(w.modSubmoduleStack) - 1; i >= 0; i-- {
213+
if w.modSubmoduleStack[i][name] {
214+
return true
215+
}
216+
}
217+
return false
218+
}
219+
193220
// walkItemsTrackingAttrs iterates parent's children like walkChild, but first
194221
// tracks preceding attribute_item siblings so a `#[cfg(test)] mod { ... }` can
195222
// be detected and routed into test mode (enterTestMod) instead of being walked
@@ -322,7 +349,9 @@ func (w *astWalker) handleMod(node *sitter.Node) {
322349
}
323350
w.modStack = append(w.modStack, name)
324351
w.modFnStack = append(w.modFnStack, collectFnNames(body, w.src))
352+
w.modSubmoduleStack = append(w.modSubmoduleStack, collectSubmoduleNames(body, w.src))
325353
w.walkItemsTrackingAttrs(body)
354+
w.modSubmoduleStack = w.modSubmoduleStack[:len(w.modSubmoduleStack)-1]
326355
w.modFnStack = w.modFnStack[:len(w.modFnStack)-1]
327356
w.modStack = w.modStack[:len(w.modStack)-1]
328357
}
@@ -585,7 +614,18 @@ func (w *astWalker) emitDependency(segs []string, line int) {
585614
if len(segs) == 0 || segs[len(segs)-1] == "" {
586615
return
587616
}
588-
target, source := classifyUsePath(segs, w.dir, w.crateDir, w.crates, w.moduleDirs)
617+
var target, source string
618+
switch {
619+
case segs[0] != "self" && segs[0] != "super" && segs[0] != "crate" && w.isKnownSubmodule(segs[0]):
620+
// An unprefixed `use foo::bar;` where "foo" is a body-less `mod foo;`
621+
// declared in this same file/mod scope is a reference to that sibling
622+
// file, not an external crate — classifyUsePath can't see that on its
623+
// own since foo.rs shares its parent's directory (no subdirectory to
624+
// find in moduleDirs).
625+
target, source = joinRustPath(w.dir, segs, w.moduleDirs), "internal"
626+
default:
627+
target, source = classifyUsePath(segs, w.dir, w.crateDir, w.crates, w.moduleDirs)
628+
}
589629
raw := strings.Join(segs, "::")
590630
w.out = append(w.out, facts.Fact{
591631
Kind: facts.KindDependency,
@@ -840,7 +880,9 @@ func (w *astWalker) handleCallExpression(node *sitter.Node) {
840880
// while walking a #[cfg(test)] module (w.inTestMod) — as a deduplicated
841881
// RelCalls reference into testRefRels. KindTestRef carries only RelCalls (per
842882
// its doc comment), so a would-be RelInstantiates from a test still just
843-
// proves the target is used, not constructed for real.
883+
// proves the target is used, not constructed for real. With no owner and not
884+
// in test mode, it's file-scope macro content (see fileRefIdx) — recorded
885+
// there instead of dropped.
844886
func (w *astWalker) emitEdge(kind, target string) {
845887
if w.inTestMod {
846888
if w.testRefSeen == nil {
@@ -855,22 +897,40 @@ func (w *astWalker) emitEdge(kind, target string) {
855897
}
856898
owner := w.currentOwner()
857899
if owner == nil {
900+
idx := w.ensureFileRefFact()
901+
w.out[idx].Relations = append(w.out[idx].Relations, facts.Relation{Kind: kind, Target: target})
858902
return
859903
}
860904
owner.Relations = append(owner.Relations, facts.Relation{Kind: kind, Target: target})
861905
}
862906

907+
// ensureFileRefFact returns the index of this file's lazily-created
908+
// file-scope reference fact (facts.KindFileRef), creating it on first use.
909+
func (w *astWalker) ensureFileRefFact() int {
910+
if w.fileRefIdx < 0 {
911+
w.out = append(w.out, facts.Fact{
912+
Kind: facts.KindFileRef,
913+
Name: w.relFile,
914+
File: w.relFile,
915+
Props: map[string]any{"language": "rust"},
916+
})
917+
w.fileRefIdx = len(w.out) - 1
918+
}
919+
return w.fileRefIdx
920+
}
921+
863922
// attrFnRefKeys: field-attribute options whose value names a function —
864-
// serde's #[serde(default = "some_fn")] (string) or clap's
865-
// #[arg(value_parser = some_fn)] (bare path) — resolved by that macro.
923+
// serde's #[serde(default = "some_fn")] (string), clap's
924+
// #[arg(value_parser = some_fn)] (bare path), or the merge crate's
925+
// #[merge(strategy = mod::path)] (scoped path) — resolved by that macro.
866926
var attrFnRefKeys = map[string]bool{
867927
"default": true, "skip_serializing_if": true,
868928
"serialize_with": true, "deserialize_with": true, "with": true,
869-
"value_parser": true,
929+
"value_parser": true, "strategy": true,
870930
}
871931

872932
// attrFnRefMacros: attribute macro names worth scanning for attrFnRefKeys.
873-
var attrFnRefMacros = map[string]bool{"serde": true, "arg": true}
933+
var attrFnRefMacros = map[string]bool{"serde": true, "arg": true, "merge": true}
874934

875935
// scanAttributeFnRefs walks a struct/enum body for #[serde(...)]/#[arg(...)]
876936
// attributes referencing a function by name.
@@ -882,6 +942,13 @@ func (w *astWalker) scanAttributeFnRefs(body *sitter.Node) {
882942
walk = func(n *sitter.Node) {
883943
if n.Kind() == "attribute" {
884944
w.scanAttribute(n)
945+
// Beyond the curated key=value macros above, an attribute can embed
946+
// an ordinary call — thiserror's #[error("{}", helper(x))] — using
947+
// the exact same flattened shape as a macro invocation's token_tree,
948+
// so the same scan applies regardless of which macro this is.
949+
if tree := findChildByKind(n, "token_tree"); tree != nil {
950+
w.scanTokenTreeCalls(tree)
951+
}
885952
return
886953
}
887954
for i := uint(0); i < uint(n.ChildCount()); i++ {
@@ -912,15 +979,24 @@ func (w *astWalker) scanAttribute(attr *sitter.Node) {
912979
if content := findChildByKind(v, "string_content"); content != nil {
913980
name = nodeText(content, w.src)
914981
}
915-
case "identifier", "scoped_identifier":
982+
case "identifier":
983+
// A scoped path (mod::path::fn) is flattened into separate
984+
// identifier/"::" siblings inside a macro-like token_tree, not a
985+
// single scoped_identifier node — walk to the last segment.
986+
j := i + 2
987+
for j+2 < n && tree.Child(j+1).Kind() == "::" && tree.Child(j+2).Kind() == "identifier" {
988+
j += 2
989+
}
990+
name = nodeText(tree.Child(j), w.src)
991+
case "scoped_identifier":
916992
name = nodeText(v, w.src)
993+
if idx := strings.LastIndex(name, "::"); idx >= 0 {
994+
name = name[idx+2:]
995+
}
917996
}
918997
if name == "" {
919998
continue
920999
}
921-
if idx := strings.LastIndex(name, "::"); idx >= 0 {
922-
name = name[idx+2:]
923-
}
9241000
w.emitEdge(facts.RelCalls, name)
9251001
}
9261002
}
@@ -1059,6 +1135,28 @@ func collectFnNames(body *sitter.Node, src []byte) map[string]bool {
10591135
return names
10601136
}
10611137

1138+
// collectSubmoduleNames returns the names declared by body-less `mod foo;`
1139+
// items directly in body — a file-based submodule (foo.rs or foo/mod.rs),
1140+
// as opposed to an inline `mod foo { ... }` block. Used to recognize an
1141+
// unprefixed `use foo::bar;` as a reference to that sibling file rather than
1142+
// an external crate.
1143+
func collectSubmoduleNames(body *sitter.Node, src []byte) map[string]bool {
1144+
names := make(map[string]bool)
1145+
if body == nil {
1146+
return names
1147+
}
1148+
for i := uint(0); i < uint(body.ChildCount()); i++ {
1149+
c := body.Child(i)
1150+
if c.Kind() != "mod_item" || c.ChildByFieldName("body") != nil {
1151+
continue
1152+
}
1153+
if n := c.ChildByFieldName("name"); n != nil {
1154+
names[nodeText(n, src)] = true
1155+
}
1156+
}
1157+
return names
1158+
}
1159+
10621160
// simpleTypeName returns a type node's simple (rightmost) identifier, e.g.
10631161
// "Wrapper" for "Wrapper<T>" or "MyError" for "&MyError". Used for impl-block
10641162
// types/traits, where generics and references must be stripped.

internal/extractors/rustextractor/rust_ast_test.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -829,3 +829,73 @@ fn handle_error(s: String) -> String { s }
829829
t.Errorf("expected RelCalls -> pkg.handle_error, got %+v", c.Relations)
830830
}
831831
}
832+
833+
func TestAST_MergeStrategyAttribute_ReferencesScopedFunction(t *testing.T) {
834+
ff := extractAST(t, `
835+
pub struct Config {
836+
#[merge(strategy = merge_strategies_extend::overwrite_always)]
837+
name: Option<String>,
838+
}
839+
mod merge_strategies_extend {
840+
pub fn overwrite_always(a: &mut Option<String>, b: Option<String>) { *a = b; }
841+
}
842+
`)
843+
s, ok := findFact(ff, "pkg.Config")
844+
if !ok {
845+
t.Fatal("expected fact for pkg.Config")
846+
}
847+
if !hasRelation(s, facts.RelCalls, "overwrite_always") {
848+
t.Errorf("expected RelCalls -> overwrite_always, got %+v", s.Relations)
849+
}
850+
}
851+
852+
func TestAST_ThiserrorAttributeCall_ReferencesFunction(t *testing.T) {
853+
ff := extractAST(t, `
854+
#[derive(Debug, thiserror::Error)]
855+
pub enum ProfileError {
856+
#[error("{}", not_found_message(.searched, .explicit_profiles_dir))]
857+
NotFound {
858+
searched: Vec<String>,
859+
explicit_profiles_dir: bool,
860+
},
861+
}
862+
fn not_found_message(searched: &[String], explicit: &bool) -> String { String::new() }
863+
`)
864+
e, ok := findFact(ff, "pkg.ProfileError")
865+
if !ok {
866+
t.Fatal("expected fact for pkg.ProfileError")
867+
}
868+
if !hasRelation(e, facts.RelCalls, "pkg.not_found_message") {
869+
t.Errorf("expected RelCalls -> pkg.not_found_message, got %+v", e.Relations)
870+
}
871+
}
872+
873+
func TestAST_MacroRulesBodyCall_EmitsFileRef(t *testing.T) {
874+
ff := extractAST(t, `
875+
fn utf8(name: &str) -> String { name.to_string() }
876+
877+
macro_rules! table_schema {
878+
(@field $name:expr, utf8) => { utf8($name) };
879+
}
880+
`)
881+
refs := findFactsByKind(ff, facts.KindFileRef)
882+
if len(refs) != 1 || !hasRelation(refs[0], facts.RelCalls, "pkg.utf8") {
883+
t.Errorf("expected a KindFileRef -> pkg.utf8, got %+v", refs)
884+
}
885+
}
886+
887+
func TestAST_ItemLevelMacroInvocationCall_EmitsFileRef(t *testing.T) {
888+
ff := extractAST(t, `
889+
fn with_cow(x: i32) {}
890+
891+
ffi_fn! {
892+
fn my_ffi_func(x: i32) {
893+
with_cow(x);
894+
}
895+
}
896+
`)
897+
refs := findFactsByKind(ff, facts.KindFileRef)
898+
if len(refs) != 1 || !hasRelation(refs[0], facts.RelCalls, "pkg.with_cow") {
899+
t.Errorf("expected a KindFileRef -> pkg.with_cow, got %+v", refs)
900+
}
901+
}

internal/extractors/rustextractor/rust_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,3 +152,36 @@ func TestExtract_ImplSplitAcrossFilesStillAttaches(t *testing.T) {
152152
t.Errorf("expected RelImplements -> Display attached across files, got %+v", w.Relations)
153153
}
154154
}
155+
156+
func TestExtract_UnprefixedUseOfSiblingFileSubmodule(t *testing.T) {
157+
repo, files := writeRustRepo(t, map[string]string{
158+
"Cargo.toml": "[package]\nname = \"demo\"\n",
159+
"src/mod.rs": `
160+
pub mod writer;
161+
pub use writer::spawn_writer;
162+
163+
pub fn start() {
164+
spawn_writer();
165+
}
166+
`,
167+
"src/writer.rs": "pub fn spawn_writer() {}\n",
168+
})
169+
ff, err := New().Extract(context.Background(), repo, files)
170+
if err != nil {
171+
t.Fatalf("Extract error: %v", err)
172+
}
173+
start, ok := findFact(ff, "src.start")
174+
if !ok {
175+
t.Fatalf("expected fact for src.start, got %+v", ff)
176+
}
177+
if !hasRelation(start, facts.RelCalls, "src.spawn_writer") {
178+
t.Errorf("expected RelCalls -> src.spawn_writer, got %+v", start.Relations)
179+
}
180+
dep, ok := findFact(ff, "src -> writer::spawn_writer")
181+
if !ok {
182+
t.Fatalf("expected a dependency fact for writer::spawn_writer, got %+v", findFactsByKind(ff, facts.KindDependency))
183+
}
184+
if dep.Props["source"] != "internal" {
185+
t.Errorf("sibling-file submodule source = %v, want internal", dep.Props["source"])
186+
}
187+
}

0 commit comments

Comments
 (0)