Skip to content

Commit 54aaaa8

Browse files
committed
feat(rust): enhance Rust AST extraction with function reference tracking and macro argument handling
1 parent 8dda941 commit 54aaaa8

4 files changed

Lines changed: 143 additions & 51 deletions

File tree

internal/cachecov/coverage_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ var versionCoverage = map[int][]string{
145145
111: {"TestExtract_HTTPHandlerProp", "TestBindHTTPHandlers_BindsViaSignature", "TestBindHTTPHandlers_RejectsNonHandlerBySignature", "TestBindHTTPHandlers_AmbiguousMethodNameSkipped", "TestBindHTTPHandlers_MiddlewareNotBound", "TestBindHTTPHandlers_Idempotent", "TestBindHTTPHandlers_CrossRepoNotBound", "TestGolden"}, // Go emits http_handler on func(http.ResponseWriter, *http.Request); bindHTTPHandlers uses it to resolve a route's registration-site handler expression to the declaring symbol, rejecting same-named non-handlers (the wiring package's null-object stub) by SIGNATURE rather than by name, and skipping ambiguous method names outright (new/18) // 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)
146146
112: {"TestStorage_TypeORMEntity", "TestStorage_DrizzleTable", "TestStorage_PrismaModels", "TestStorage_NoORMDependencyEmitsNothing", "TestIODirect_ORMCallSeedsPerformsIO", "TestGolden"}, // TypeScript emits storage facts for TypeORM @Entity / Drizzle pgTable / Prisma models (schema.prisma read off-glob); ORM query methods seed io_direct so performs_io propagates through a repository wrapper, making a per-iteration wrapper call a detectable N+1 (GAP-XL-04, new/26)
147147
113: {"TestGolden", "TestOwnsFile", "TestAST_ImplTraitForType_EmitsImplements"}, // Rust extractor added: symbols, implements post-pass, dependency/calls/route facts, cache participation
148+
114: {"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
148149
}
149150

150151
func TestCacheVersionCoverage(t *testing.T) {

internal/engine/cache.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -423,14 +423,19 @@ import (
423423
// N+1s and is why the TS detector was narrowed in the first place. Detection is gated on
424424
// the package.json dependency, so a class coincidentally decorated @Entity in a non-ORM
425425
// repo models nothing. Cached TypeScript snapshots must re-extract (GAP-XL-04, new/26).
426-
// v114: adds the Rust extractor (fn/struct/enum/trait/type/const/static symbols; impl/trait
426+
// v113: adds the Rust extractor (fn/struct/enum/trait/type/const/static symbols; impl/trait
427427
// "implements" edges attached via a post-pass; use-based dependency facts classified
428428
// internal/external/stdlib; calls/instantiates edges; cyclomatic complexity; Axum route
429-
// facts; calls inside a macro invocation's token_tree; a function passed by name as a
430-
// value — call argument, struct field, &f; serde default/skip_serializing_if attribute
431-
// strings). Rust is a new FileOwner, so its arrival also reshuffles which files count as
429+
// facts). Rust is a new FileOwner, so its arrival also reshuffles which files count as
432430
// "shared" for every other cached extractor's key in a mixed-language repo. Cached
433431
// snapshots of any repo containing Rust files must re-extract.
432+
// v114: Rust call-reference precision — calls inside a macro invocation's token_tree
433+
// (bail!/format!/matches! arguments), a function passed by name as a value (call
434+
// argument, struct field, &f, a local fn declared earlier in the same body), and
435+
// serde/clap attribute strings/paths (default, skip_serializing_if, value_parser) are
436+
// now tracked as references. Also fixes a latent bug where a nested item (a local `fn`
437+
// inside a function body) could reallocate the fact slice and silently drop the
438+
// enclosing function's own relations recorded afterward.
434439
const cacheVersion = "v114"
435440

436441
// extractorCache holds per-extractor facts keyed by a content hash of the files

internal/extractors/rustextractor/rust_ast.go

Lines changed: 82 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,11 @@ type astWalker struct {
7272
out []facts.Fact
7373
impls []implPair
7474

75-
// ownerStack[len-1] points at the symbol fact currently being constructed.
76-
// Calls/instantiations discovered while walking that symbol's body are
77-
// appended to its Relations slice.
78-
ownerStack []*facts.Fact
75+
// ownerStack[len-1] indexes into out for the symbol fact currently being
76+
// constructed. Stored as an index, not a *facts.Fact: a nested item (a
77+
// local `fn` inside a function body) appends to out too, which can
78+
// reallocate its backing array and strand a raw pointer.
79+
ownerStack []int
7980

8081
// modStack/typeStack hold the enclosing inline-`mod { }` and
8182
// impl/trait-block names, so a nested declaration's canonical name is
@@ -145,13 +146,13 @@ func (w *astWalker) qualifyMod(name string) string {
145146
return strings.Join(parts, ".")
146147
}
147148

148-
func (w *astWalker) pushOwner(f *facts.Fact) { w.ownerStack = append(w.ownerStack, f) }
149-
func (w *astWalker) popOwner() { w.ownerStack = w.ownerStack[:len(w.ownerStack)-1] }
149+
func (w *astWalker) pushOwner(idx int) { w.ownerStack = append(w.ownerStack, idx) }
150+
func (w *astWalker) popOwner() { w.ownerStack = w.ownerStack[:len(w.ownerStack)-1] }
150151
func (w *astWalker) currentOwner() *facts.Fact {
151152
if len(w.ownerStack) == 0 {
152153
return nil
153154
}
154-
return w.ownerStack[len(w.ownerStack)-1]
155+
return &w.out[w.ownerStack[len(w.ownerStack)-1]]
155156
}
156157

157158
func (w *astWalker) pushType(name string, methods map[string]bool) {
@@ -177,11 +178,16 @@ func (w *astWalker) walkSourceFile(root *sitter.Node) {
177178
w.modFnStack = w.modFnStack[:len(w.modFnStack)-1]
178179
}
179180

180-
func (w *astWalker) currentModFns() map[string]bool {
181-
if len(w.modFnStack) == 0 {
182-
return nil
181+
// isKnownFn checks every enclosing scope (function-local, then outward
182+
// through mod/file scope), not just the innermost, so a value-reference
183+
// inside a function body still resolves to an outer mod-level sibling.
184+
func (w *astWalker) isKnownFn(name string) bool {
185+
for i := len(w.modFnStack) - 1; i >= 0; i-- {
186+
if w.modFnStack[i][name] {
187+
return true
188+
}
183189
}
184-
return w.modFnStack[len(w.modFnStack)-1]
190+
return false
185191
}
186192

187193
// walkItemsTrackingAttrs iterates parent's children like walkChild, but first
@@ -344,8 +350,8 @@ func (w *astWalker) handleStruct(node *sitter.Node) {
344350
Relations: []facts.Relation{{Kind: facts.RelDeclares, Target: w.dir}},
345351
})
346352
ownerIdx := len(w.out) - 1
347-
w.pushOwner(&w.out[ownerIdx])
348-
w.scanSerdeAttributeRefs(node.ChildByFieldName("body"))
353+
w.pushOwner(ownerIdx)
354+
w.scanAttributeFnRefs(node.ChildByFieldName("body"))
349355
w.popOwner()
350356
}
351357

@@ -368,8 +374,8 @@ func (w *astWalker) handleEnum(node *sitter.Node) {
368374
Relations: []facts.Relation{{Kind: facts.RelDeclares, Target: w.dir}},
369375
})
370376
ownerIdx := len(w.out) - 1
371-
w.pushOwner(&w.out[ownerIdx])
372-
w.scanSerdeAttributeRefs(node.ChildByFieldName("body"))
377+
w.pushOwner(ownerIdx)
378+
w.scanAttributeFnRefs(node.ChildByFieldName("body"))
373379
w.popOwner()
374380
}
375381

@@ -460,12 +466,14 @@ func (w *astWalker) handleFunction(node *sitter.Node) {
460466

461467
w.out = append(w.out, f)
462468
ownerIdx := len(w.out) - 1
463-
w.pushOwner(&w.out[ownerIdx])
469+
w.pushOwner(ownerIdx)
464470

465471
savedDecisions := w.decisions
466472
w.decisions = 0
467473
if body := node.ChildByFieldName("body"); body != nil {
474+
w.modFnStack = append(w.modFnStack, collectFnNames(body, w.src))
468475
w.walkForCalls(body)
476+
w.modFnStack = w.modFnStack[:len(w.modFnStack)-1]
469477
}
470478
w.out[ownerIdx].Props["cyclomatic"] = 1 + w.decisions
471479
w.decisions = savedDecisions
@@ -544,8 +552,7 @@ func (w *astWalker) handleConstOrStatic(node *sitter.Node, symbolKind string) {
544552
Relations: []facts.Relation{{Kind: facts.RelDeclares, Target: w.dir}},
545553
}
546554
w.out = append(w.out, f)
547-
owner := &w.out[len(w.out)-1]
548-
w.pushOwner(owner)
555+
w.pushOwner(len(w.out) - 1)
549556
if valueNode := node.ChildByFieldName("value"); valueNode != nil {
550557
w.walkForCalls(valueNode)
551558
}
@@ -712,25 +719,45 @@ func (w *astWalker) walkForCalls(node *sitter.Node) {
712719
// `identifier` immediately followed by a parenthesized token_tree sibling.
713720
func (w *astWalker) scanTokenTreeCalls(node *sitter.Node) {
714721
n := node.ChildCount()
715-
for i := uint(0); i+1 < n; i++ {
722+
for i := uint(0); i < n; i++ {
716723
c := node.Child(i)
717724
if c.Kind() != "identifier" {
718725
continue
719726
}
720-
next := node.Child(i + 1)
721-
if next.Kind() != "token_tree" || next.ChildCount() == 0 || next.Child(0).Kind() != "(" {
722-
continue
727+
var next *sitter.Node
728+
if i+1 < n {
729+
next = node.Child(i + 1)
723730
}
724-
name := nodeText(c, w.src)
725-
if isCapitalized(name) {
726-
w.emitEdge(facts.RelInstantiates, name)
731+
if next != nil && next.Kind() == "token_tree" && next.ChildCount() > 0 && next.Child(0).Kind() == "(" {
732+
name := nodeText(c, w.src)
733+
if isCapitalized(name) {
734+
w.emitEdge(facts.RelInstantiates, name)
735+
continue
736+
}
737+
if i > 0 && node.Child(i-1).Kind() == "." {
738+
w.emitEdge(facts.RelCalls, name)
739+
continue
740+
}
741+
if target := w.resolveCall(name); target != "" {
742+
w.emitEdge(facts.RelCalls, target)
743+
}
727744
continue
728745
}
729-
if i > 0 && node.Child(i-1).Kind() == "." {
730-
w.emitEdge(facts.RelCalls, name)
731-
continue
746+
// Not applied as a call: may still be a function passed by name as a
747+
// value nested inside a macro's own argument, e.g. Box::new(f) inside
748+
// vec![...]. Skip path segments (preceded/followed by "."/"::") and
749+
// attribute-style `key = value` pairs, already handled by scanAttribute.
750+
if i > 0 {
751+
if pk := node.Child(i - 1).Kind(); pk == "." || pk == "::" {
752+
continue
753+
}
732754
}
733-
if target := w.resolveCall(name); target != "" {
755+
if next != nil {
756+
if nk := next.Kind(); nk == "::" || nk == "=" {
757+
continue
758+
}
759+
}
760+
if target := w.resolveValueReference(nodeText(c, w.src)); target != "" {
734761
w.emitEdge(facts.RelCalls, target)
735762
}
736763
}
@@ -833,23 +860,28 @@ func (w *astWalker) emitEdge(kind, target string) {
833860
owner.Relations = append(owner.Relations, facts.Relation{Kind: kind, Target: target})
834861
}
835862

836-
// serdeAttrFnKeys: serde options whose value is a string naming a function,
837-
// e.g. #[serde(default = "some_fn")] — resolved by serde's derive macro.
838-
var serdeAttrFnKeys = map[string]bool{
863+
// 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.
866+
var attrFnRefKeys = map[string]bool{
839867
"default": true, "skip_serializing_if": true,
840868
"serialize_with": true, "deserialize_with": true, "with": true,
869+
"value_parser": true,
841870
}
842871

843-
// scanSerdeAttributeRefs walks a struct/enum body for #[serde(...)]
872+
// attrFnRefMacros: attribute macro names worth scanning for attrFnRefKeys.
873+
var attrFnRefMacros = map[string]bool{"serde": true, "arg": true}
874+
875+
// scanAttributeFnRefs walks a struct/enum body for #[serde(...)]/#[arg(...)]
844876
// attributes referencing a function by name.
845-
func (w *astWalker) scanSerdeAttributeRefs(body *sitter.Node) {
877+
func (w *astWalker) scanAttributeFnRefs(body *sitter.Node) {
846878
if body == nil {
847879
return
848880
}
849881
var walk func(n *sitter.Node)
850882
walk = func(n *sitter.Node) {
851883
if n.Kind() == "attribute" {
852-
w.scanSerdeAttribute(n)
884+
w.scanAttribute(n)
853885
return
854886
}
855887
for i := uint(0); i < uint(n.ChildCount()); i++ {
@@ -859,10 +891,10 @@ func (w *astWalker) scanSerdeAttributeRefs(body *sitter.Node) {
859891
walk(body)
860892
}
861893

862-
// scanSerdeAttribute scans one `serde(...)` attribute's token_tree for
863-
// `key = "value"` pairs keyed by serdeAttrFnKeys.
864-
func (w *astWalker) scanSerdeAttribute(attr *sitter.Node) {
865-
if attr.NamedChildCount() == 0 || nodeText(attr.NamedChild(0), w.src) != "serde" {
894+
// scanAttribute scans one attribute's token_tree for `key = value` pairs
895+
// keyed by attrFnRefKeys, where value is a string literal or a bare path.
896+
func (w *astWalker) scanAttribute(attr *sitter.Node) {
897+
if attr.NamedChildCount() == 0 || !attrFnRefMacros[nodeText(attr.NamedChild(0), w.src)] {
866898
return
867899
}
868900
tree := findChildByKind(attr, "token_tree")
@@ -871,18 +903,21 @@ func (w *astWalker) scanSerdeAttribute(attr *sitter.Node) {
871903
}
872904
n := tree.ChildCount()
873905
for i := uint(0); i+2 < n; i++ {
874-
if !serdeAttrFnKeys[nodeText(tree.Child(i), w.src)] || tree.Child(i+1).Kind() != "=" {
906+
if !attrFnRefKeys[nodeText(tree.Child(i), w.src)] || tree.Child(i+1).Kind() != "=" {
875907
continue
876908
}
877-
lit := tree.Child(i + 2)
878-
if lit.Kind() != "string_literal" {
879-
continue
909+
name := ""
910+
switch v := tree.Child(i + 2); v.Kind() {
911+
case "string_literal":
912+
if content := findChildByKind(v, "string_content"); content != nil {
913+
name = nodeText(content, w.src)
914+
}
915+
case "identifier", "scoped_identifier":
916+
name = nodeText(v, w.src)
880917
}
881-
content := findChildByKind(lit, "string_content")
882-
if content == nil {
918+
if name == "" {
883919
continue
884920
}
885-
name := nodeText(content, w.src)
886921
if idx := strings.LastIndex(name, "::"); idx >= 0 {
887922
name = name[idx+2:]
888923
}
@@ -914,7 +949,7 @@ func (w *astWalker) resolveValueReference(name string) string {
914949
if methods := w.currentMethods(); methods[name] {
915950
return w.dir + "." + w.qualify(name)
916951
}
917-
if fns := w.currentModFns(); fns[name] {
952+
if w.isKnownFn(name) {
918953
return w.dir + "." + w.qualifyMod(name)
919954
}
920955
if target, ok := w.importMap[name]; ok {

internal/extractors/rustextractor/rust_ast_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -778,3 +778,54 @@ fn is_false(b: &bool) -> bool { !b }
778778
t.Errorf("expected RelCalls -> is_false, got %+v", s.Relations)
779779
}
780780
}
781+
782+
func TestAST_ClapValueParserAttribute_ReferencesFunction(t *testing.T) {
783+
ff := extractAST(t, `
784+
pub struct Args {
785+
#[arg(value_parser = check_target)]
786+
target: String,
787+
}
788+
fn check_target(s: &str) -> Result<String, String> { Ok(s.to_string()) }
789+
`)
790+
s, ok := findFact(ff, "pkg.Args")
791+
if !ok {
792+
t.Fatal("expected fact for pkg.Args")
793+
}
794+
if !hasRelation(s, facts.RelCalls, "check_target") {
795+
t.Errorf("expected RelCalls -> check_target, got %+v", s.Relations)
796+
}
797+
}
798+
799+
func TestAST_LocalFunctionPassedAsCallbackWithinSameBody(t *testing.T) {
800+
ff := extractAST(t, `
801+
fn caller(a: Vec<Option<String>>) {
802+
fn normalize_item(opt: &Option<String>) -> Option<String> { opt.clone() }
803+
let normalized: Vec<Option<String>> = a.iter().map(normalize_item).collect();
804+
}
805+
`)
806+
c, ok := findFact(ff, "pkg.caller")
807+
if !ok {
808+
t.Fatal("expected fact for pkg.caller")
809+
}
810+
if !hasRelation(c, facts.RelCalls, "pkg.normalize_item") {
811+
t.Errorf("expected RelCalls -> pkg.normalize_item, got %+v", c.Relations)
812+
}
813+
}
814+
815+
func TestAST_FunctionReferenceNestedInsideMacroArgument(t *testing.T) {
816+
ff := extractAST(t, `
817+
fn caller() {
818+
let transforms: Vec<Box<dyn Fn(String) -> String>> = vec![
819+
Box::new(handle_error),
820+
];
821+
}
822+
fn handle_error(s: String) -> String { s }
823+
`)
824+
c, ok := findFact(ff, "pkg.caller")
825+
if !ok {
826+
t.Fatal("expected fact for pkg.caller")
827+
}
828+
if !hasRelation(c, facts.RelCalls, "pkg.handle_error") {
829+
t.Errorf("expected RelCalls -> pkg.handle_error, got %+v", c.Relations)
830+
}
831+
}

0 commit comments

Comments
 (0)