From 09538f42aac484aacedb5f8808a8e6dc681c9645 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Mon, 5 Oct 2026 12:55:19 +0100 Subject: [PATCH 01/11] Restrict query rendering to selected edges --- cmd/outline/main.go | 24 +++++++++++---- cmd/outline/main_test.go | 63 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 5 deletions(-) diff --git a/cmd/outline/main.go b/cmd/outline/main.go index 60fcaa7..fa72f4f 100644 --- a/cmd/outline/main.go +++ b/cmd/outline/main.go @@ -191,17 +191,19 @@ func cmdNeighbours(args []string, callers bool) error { } opts := c.traverse() ids := []string{seed} + var selected []outline.Edge for _, e := range edges { if !opts.Allow(e) { continue } + selected = append(selected, e) if callers { ids = append(ids, e.From) } else { ids = append(ids, e.To) } } - return g.Text(os.Stdout, ids, c.budget, opts.Allow) + return writeEdges(g, ids, selected, c.budget) } func cmdAffected(args []string) error { @@ -227,7 +229,7 @@ func cmdAffected(args []string) error { seeds = append(seeds, id) } opts := c.traverse() - return writePaths(g, g.Affected(seeds, opts), c.budget, opts.Allow) + return writePaths(g, g.Affected(seeds, opts), c.budget) } func cmdPath(args []string) error { @@ -257,7 +259,7 @@ func cmdPath(args []string) error { if p == nil { return fmt.Errorf("no path from %s to %s", from, to) } - return writePaths(g, []outline.Path{p}, c.budget, opts.Allow) + return writePaths(g, []outline.Path{p}, c.budget) } // resolveOne turns a user-supplied seed into exactly one node ID, returning @@ -278,11 +280,13 @@ func resolveOne(g *outline.Graph, name string) (string, error) { return "", fmt.Errorf("%s", b.String()) } -func writePaths(g *outline.Graph, paths []outline.Path, budget int, allow func(outline.Edge) bool) error { +func writePaths(g *outline.Graph, paths []outline.Path, budget int) error { seen := make(map[string]bool) var ids []string + var edges []outline.Edge for _, p := range paths { for _, e := range p { + edges = append(edges, e) if !seen[e.From] { seen[e.From] = true ids = append(ids, e.From) @@ -294,5 +298,15 @@ func writePaths(g *outline.Graph, paths []outline.Path, budget int, allow func(o } } fmt.Printf("%d paths, %d nodes\n", len(paths), len(ids)) - return g.Text(os.Stdout, ids, budget, allow) + return writeEdges(g, ids, edges, budget) +} + +func writeEdges(g *outline.Graph, ids []string, edges []outline.Edge, budget int) error { + selected := make(map[outline.Edge]bool, len(edges)) + for _, e := range edges { + selected[e] = true + } + return g.Text(os.Stdout, ids, budget, func(e outline.Edge) bool { + return selected[e] + }) } diff --git a/cmd/outline/main_test.go b/cmd/outline/main_test.go index 403a401..6b4858e 100644 --- a/cmd/outline/main_test.go +++ b/cmd/outline/main_test.go @@ -5,6 +5,7 @@ import ( "os/exec" "path/filepath" "runtime" + "slices" "strings" "testing" ) @@ -122,3 +123,65 @@ func TestCLIUsage(t *testing.T) { t.Errorf("usage not printed: %s", out) } } + +func TestCLIQuerySelection(t *testing.T) { + cases := []struct { + language string + filename string + source string + }{ + {"go", "app.go", "package app\nfunc entry(){middle()}\nfunc middle(){sink();sink();side()}\nfunc sink(){}\nfunc side(){}\n"}, + {"python", "app.py", "def entry():\n middle()\ndef middle():\n sink()\n sink()\n side()\ndef sink():\n pass\ndef side():\n pass\n"}, + {"ruby", "app.rb", "def entry\n middle()\nend\ndef middle\n sink()\n sink()\n side()\nend\ndef sink\nend\ndef side\nend\n"}, + } + for _, c := range cases { + t.Run(c.language, func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, c.filename), []byte(c.source), 0o600); err != nil { + t.Fatal(err) + } + graph := filepath.Join(t.TempDir(), "graph.json") + run(t, "graph", "-o", graph, dir) + queries := []struct { + name string + args []string + nodes string + edges string + }{ + {"affected", []string{"affected", "-depth", "1", "sink"}, "middle,sink", "middle>sink"}, + {"path", []string{"path", "middle", "sink"}, "middle,sink", "middle>sink"}, + {"callers", []string{"callers", "sink"}, "middle,sink", "middle>sink,middle>sink"}, + {"callees", []string{"callees", "middle"}, "middle,side,sink", "middle>side,middle>sink,middle>sink"}, + } + for _, q := range queries { + t.Run(q.name, func(t *testing.T) { + args := append([]string{q.args[0], "-g", graph}, q.args[1:]...) + out := run(t, args...) + assertQuerySelection(t, out, q.nodes, q.edges) + }) + } + }) + } +} + +func assertQuerySelection(t *testing.T, out, wantNodes, wantEdges string) { + t.Helper() + var nodes, edges []string + for line := range strings.SplitSeq(out, "\n") { + fields := strings.Fields(line) + if len(fields) == 0 { + continue + } + switch fields[0] { + case "NODE": + nodes = append(nodes, fields[1]) + case "EDGE": + edges = append(edges, fields[1]+">"+fields[3]) + } + } + slices.Sort(nodes) + slices.Sort(edges) + if strings.Join(nodes, ",") != wantNodes || strings.Join(edges, ",") != wantEdges { + t.Fatalf("nodes=%v edges=%v, want nodes=%s edges=%s\n%s", nodes, edges, wantNodes, wantEdges, out) + } +} From e63c5f6d835d404e58632b35ab810e1bca373482 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Mon, 5 Oct 2026 13:08:05 +0100 Subject: [PATCH 02/11] Respect local bindings when resolving calls --- analyse.go | 10 ++-- binding.go | 127 ++++++++++++++++++++++++++++++++++++++++++ binding_test.go | 144 ++++++++++++++++++++++++++++++++++++++++++++++++ resolve.go | 22 +++++++- 4 files changed, 296 insertions(+), 7 deletions(-) create mode 100644 binding.go create mode 100644 binding_test.go diff --git a/analyse.go b/analyse.go index 08bd07c..071c292 100644 --- a/analyse.go +++ b/analyse.go @@ -30,10 +30,11 @@ func (d decl) symID(path string) string { // analysis is the per-file fact set that graph resolution consumes. type analysis struct { - Lang string - Decls []decl - Imports []Import - Calls []Call + Lang string + Decls []decl + Imports []Import + Calls []Call + Bindings []binding } // analyse parses src once and returns every fact the graph builder needs @@ -53,6 +54,7 @@ func analyse(src []byte, filename string) (*analysis, bool) { a.Decls = extractDecls(src, l, root, matches) a.Imports, _ = importsFor(src, l, root) a.Calls, _ = callsFor(src, l, root, a.Decls) + a.Bindings = bindingsFor(src, l, root, a.Decls) return a, true } diff --git a/binding.go b/binding.go new file mode 100644 index 0000000..fb444a2 --- /dev/null +++ b/binding.go @@ -0,0 +1,127 @@ +package outline + +import ts "github.com/odvcencio/gotreesitter" + +type binding struct { + Name string + In int + Start uint32 + End uint32 + Decl int +} + +func bindingsFor(src []byte, l *lang, root *ts.Node, decls []decl) []binding { + if l.name != "go" && l.name != "python" { + return nil + } + var out []binding + walkNamed(root, func(node *ts.Node) { + names, scope, start := bindingNode(src, l, node) + if scope == nil || len(names) == 0 { + return + } + in := enclosing(decls, scope.StartByte()) + target := bindingDeclaration(l, node, decls) + for _, name := range names { + if l.name != "go" || name != "_" { + out = append(out, binding{Name: name, In: in, Start: start, End: scope.EndByte(), Decl: target}) + } + } + }) + return out +} + +func bindingNode(src []byte, l *lang, node *ts.Node) ([]string, *ts.Node, uint32) { + switch l.name { + case "go": + names := goBindingNames(src, l.language, node) + if len(names) > 0 { + return names, goBindingScope(node, l.language), node.EndByte() + } + case "python": + switch node.Type(l.language) { + case "assignment", "augmented_assignment", "for_statement": + names := bindingNames(src, l.language, node.ChildByFieldName("left", l.language)) + if len(names) > 0 { + if scope := pythonBindingScope(node, l.language); scope != nil { + return names, scope, scope.StartByte() + } + } + } + } + return nil, nil, 0 +} + +func bindingDeclaration(l *lang, node *ts.Node, decls []decl) int { + if l.name == "go" { + switch node.Type(l.language) { + case "type_spec", "type_alias": + return enclosing(decls, node.StartByte()) + } + } + return -1 +} + +func bindingNames(src []byte, language *ts.Language, node *ts.Node) []string { + if node == nil { + return nil + } + switch node.Type(language) { + case "identifier", "type_identifier": + return []string{node.Text(src)} + case "expression_list", "pattern_list", "tuple_pattern", "list_pattern", "tuple", "list", "list_splat_pattern": + var out []string + for i := range node.NamedChildCount() { + out = append(out, bindingNames(src, language, node.NamedChild(i))...) + } + return out + } + return nil +} + +func goBindingNames(src []byte, language *ts.Language, node *ts.Node) []string { + switch node.Type(language) { + case "short_var_declaration": + return bindingNames(src, language, node.ChildByFieldName("left", language)) + case "type_spec", "type_alias": + return bindingNames(src, language, node.ChildByFieldName("name", language)) + case "range_clause": + for i := range node.ChildCount() { + if node.Child(i).Type(language) == ":=" { + return bindingNames(src, language, node.ChildByFieldName("left", language)) + } + } + case "var_spec", "const_spec": + var out []string + for i := range node.NamedChildCount() { + child := node.NamedChild(i) + if child.Type(language) == "identifier" { + out = append(out, child.Text(src)) + } + } + return out + } + return nil +} + +func goBindingScope(node *ts.Node, language *ts.Language) *ts.Node { + for parent := node.Parent(); parent != nil; parent = parent.Parent() { + switch parent.Type(language) { + case "block", "if_statement", "for_statement", "expression_switch_statement", "type_switch_statement", "communication_case", "expression_case", "type_case": + return parent + } + } + return nil +} + +func pythonBindingScope(node *ts.Node, language *ts.Language) *ts.Node { + for parent := node.Parent(); parent != nil; parent = parent.Parent() { + switch parent.Type(language) { + case "function_definition": + return parent + case "class_definition": + return nil + } + } + return nil +} diff --git a/binding_test.go b/binding_test.go new file mode 100644 index 0000000..4d7e589 --- /dev/null +++ b/binding_test.go @@ -0,0 +1,144 @@ +package outline + +import "testing" + +func TestBuildGoLocalBindings(t *testing.T) { + cases := []struct { + name string + body string + resolved int + unresolved int + }{ + {"short declaration", "helper := func(){}; helper()", 0, 1}, + {"var declaration", "var helper = func(){}; helper()", 0, 1}, + {"initializer", "helper := helper; helper()", 0, 1}, + {"initializer call", "helper := func(){ helper() }; helper()", 1, 1}, + {"before declaration", "helper(); helper := func(){}; helper()", 1, 1}, + {"sibling block", "{ helper := func(){}; helper() }; helper()", 1, 1}, + {"if initializer", "if helper := func(){}; true { helper() }; helper()", 1, 1}, + {"range variable", "for _, helper := range []func(){helper} { helper() }; helper()", 1, 1}, + {"destructuring", "helper, other := func(){}, 1; helper(); _ = other", 0, 1}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{"app.go": "package app\nfunc helper(){}\nfunc entry(){" + c.body + "}\n"}) + assertBindingCalls(t, root, "go", c.resolved, c.unresolved) + }) + } +} + +func TestBuildPythonLocalBindings(t *testing.T) { + cases := []struct { + name string + body string + resolved int + unresolved int + }{ + {"assignment", " helper = lambda: None\n helper()\n", 0, 1}, + {"before assignment", " helper()\n helper = lambda: None\n helper()\n", 0, 2}, + {"destructuring", " helper, other = (lambda: None), 1\n helper()\n", 0, 1}, + {"loop variable", " for helper in []:\n helper()\n", 0, 1}, + {"sibling function", " def nested():\n helper = lambda: None\n helper()\n helper()\n", 1, 0}, + {"nested definition", " helper = lambda: None\n def nested():\n def helper():\n pass\n helper()\n helper()\n", 0, 1}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{"app.py": "def helper():\n pass\ndef entry():\n" + c.body}) + assertBindingCalls(t, root, "python", c.resolved, c.unresolved) + }) + } +} + +func assertBindingCalls(t *testing.T, root, language string, wantResolved, wantUnresolved int) { + t.Helper() + g, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + entry := nodeByName(g, KindFunc, "entry") + helper := nodeByName(g, KindFunc, "helper") + if entry == nil || helper == nil { + t.Fatal("missing fixture functions") + } + resolved, unresolved := 0, 0 + for _, e := range g.Callees(entry.ID) { + switch e.To { + case helper.ID: + resolved++ + case ExtID(language, "", "helper"): + unresolved++ + } + } + if resolved != wantResolved || unresolved != wantUnresolved { + t.Fatalf("resolved=%d unresolved=%d, want %d/%d; calls=%v", resolved, unresolved, wantResolved, wantUnresolved, g.Callees(entry.ID)) + } +} + +func TestBuildLocalImportShadow(t *testing.T) { + cases := []struct{ name, language, filename, source string }{ + {"Go variable", "go", "app.go", "package app\nimport \"example.com/app/util\"\nfunc entry(){util := struct{Run func()}{func(){}}; util.Run()}\n"}, + {"Go type", "go", "app.go", "package app\nimport \"example.com/app/util\"\ntype Runner struct{}\nfunc (Runner) Run(){}\nfunc entry(){type util = Runner; util.Run(util{})}\n"}, + {"Python variable", "python", "app.py", "import util\ndef entry():\n util = object()\n util.Run()\n"}, + {"Python class", "python", "app.py", "import util\nclass util:\n def Run():\n pass\ndef entry():\n util.Run()\n"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{ + "go.mod": "module example.com/app\n", + c.filename: c.source, + "util/util.go": "package util\nfunc Run(){}\n", + "util.py": "def Run():\n pass\n", + }) + g, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + entry := nodeByName(g, KindFunc, "entry") + if edge(g, entry.ID, ExtID(c.language, "util", "Run"), RelCalls) == nil { + t.Fatalf("local receiver resolved through import: %v", g.Callees(entry.ID)) + } + }) + } +} + +func TestBuildGoBlockTypeScope(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{ + "go.mod": "module example.com/app\n", + "app.go": "package app\nimport \"example.com/app/util\"\ntype Runner struct{}\nfunc entry(){ { type util = Runner; _ = util{} }; util.Run() }\n", + "util/util.go": "package util\nfunc Run(){}\n", + }) + g, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + entry := nodeByName(g, KindFunc, "entry") + run := nodeByName(g, KindFunc, "Run") + if edge(g, entry.ID, run.ID, RelCalls) == nil { + t.Fatalf("block type hid the import after leaving scope: %v", g.Callees(entry.ID)) + } +} + +func TestBuildCallableLocalBoundaries(t *testing.T) { + cases := []struct{ language, filename, source, target string }{ + {"python", "app.py", "def _():\n pass\ndef entry():\n _ = lambda: None\n _()\n", ExtID("python", "", "_")}, + {"ruby", "app.rb", "def helper\nend\ndef entry\n helper = -> { nil }\n helper.call\nend\n", ExtID("ruby", "helper", "call")}, + } + for _, c := range cases { + t.Run(c.language, func(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{c.filename: c.source}) + g, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + entry := nodeByName(g, KindFunc, "entry") + if edge(g, entry.ID, c.target, RelCalls) == nil { + t.Fatalf("callable local lost its unresolved target: %v", g.Callees(entry.ID)) + } + }) + } +} diff --git a/resolve.go b/resolve.go index 3594123..92a9f01 100644 --- a/resolve.go +++ b/resolve.go @@ -36,6 +36,7 @@ type scope struct { // children maps a decl index (or -1 for top level) to its direct // child decl indices, for lexical resolution of nested calls. children map[int][]int + bindings map[int][]binding } func newResolver(root string, files []fileAnalysis, hints ResolutionHints) *resolver { @@ -271,6 +272,10 @@ func (r *resolver) fileScope(f *fileAnalysis) scope { syms: make(map[string]string), mods: make(map[string]string), children: make(map[int][]int), + bindings: make(map[int][]binding), + } + for _, b := range f.a.Bindings { + sc.bindings[b.In] = append(sc.bindings[b.In], b) } if f.a.Lang == "go" { maps.Copy(sc.syms, r.goPkgs[path.Dir(f.path)]) @@ -372,7 +377,7 @@ func (r *resolver) resolveCall(f *fileAnalysis, sc scope, c Call) (string, strin return r.resolveRubyCall(f, c) } if c.Receiver == "" { - if sid, shadowed := lexical(f, sc, c.In, c.Name); sid != "" { + if sid, shadowed := lexical(f, sc, c.In, c.Name, c.Start); sid != "" { return sid, ConfExtracted } else if shadowed { return ExtID(f.a.Lang, "", c.Name), ConfInferred @@ -382,7 +387,7 @@ func (r *resolver) resolveCall(f *fileAnalysis, sc scope, c Call) (string, strin } return ExtID(f.a.Lang, "", c.Name), ConfInferred } - if _, shadowed := lexical(f, sc, c.In, c.Receiver); shadowed { + if sid, shadowed := lexical(f, sc, c.In, c.Receiver, c.Start); sid != "" || shadowed { return ExtID(f.a.Lang, c.Receiver, c.Name), ConfInferred } if mid, ok := sc.mods[c.Receiver]; ok { @@ -471,12 +476,23 @@ func rubySingletonMethod(f *fileAnalysis, receiver, name string) (target string, // innermost visible decl matching name. If a parameter of an enclosing // function matches first, it reports shadowed instead. Class bodies do // not contribute their members to enclosed functions' scopes. -func lexical(f *fileAnalysis, sc scope, in int, name string) (id string, shadowed bool) { +func lexical(f *fileAnalysis, sc scope, in int, name string, pos uint32) (id string, shadowed bool) { decls := f.a.Decls at := in for { + for _, b := range sc.bindings[at] { + if b.Name == name && b.Start <= pos && pos < b.End { + if b.Decl >= 0 { + return decls[b.Decl].symID(f.path), false + } + return "", true + } + } if at < 0 || at == in || decls[at].Kind != KindClass { for _, ci := range sc.children[at] { + if f.a.Lang == "go" && at >= 0 && decls[ci].Kind != KindFunc { + continue + } if decls[ci].Name == name { return decls[ci].symID(f.path), false } From bb7d62ae2cc34210e16de1e8c398ce22c5aa4d62 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Mon, 5 Oct 2026 13:13:42 +0100 Subject: [PATCH 03/11] Separate Go package and test resolution scopes --- analyse.go | 10 +++++ go_scope_test.go | 111 +++++++++++++++++++++++++++++++++++++++++++++++ resolve.go | 66 ++++++++++++++++++++-------- 3 files changed, 170 insertions(+), 17 deletions(-) create mode 100644 go_scope_test.go diff --git a/analyse.go b/analyse.go index 071c292..7379805 100644 --- a/analyse.go +++ b/analyse.go @@ -31,6 +31,7 @@ func (d decl) symID(path string) string { // analysis is the per-file fact set that graph resolution consumes. type analysis struct { Lang string + Package string Decls []decl Imports []Import Calls []Call @@ -51,6 +52,15 @@ func analyse(src []byte, filename string) (*analysis, bool) { matches := l.query.Execute(tree) a := &analysis{Lang: l.name} + if l.name == "go" { + for i := range root.NamedChildCount() { + node := root.NamedChild(i) + if node.Type(l.language) == "package_clause" && node.NamedChildCount() > 0 { + a.Package = node.NamedChild(0).Text(src) + break + } + } + } a.Decls = extractDecls(src, l, root, matches) a.Imports, _ = importsFor(src, l, root) a.Calls, _ = callsFor(src, l, root, a.Decls) diff --git a/go_scope_test.go b/go_scope_test.go new file mode 100644 index 0000000..6e56610 --- /dev/null +++ b/go_scope_test.go @@ -0,0 +1,111 @@ +package outline + +import "testing" + +func TestBuildGoPackageScopes(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{ + "go.mod": "module example.com/app\n", + "main.go": "package app\nfunc entry(){testOnly(); externalOnly(); helper()}\nfunc helper(){}\n", + "main_test.go": "package app\nfunc testOnly(){}\nfunc internalTest(){helper();testOnly();externalOnly()}\n", + "external_test.go": "package app_test\nfunc externalOnly(){}\nfunc externalTest(){externalOnly();helper()}\n", + }) + g, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + entry := nodeByName(g, KindFunc, "entry") + internal := nodeByName(g, KindFunc, "internalTest") + external := nodeByName(g, KindFunc, "externalTest") + helper := nodeByName(g, KindFunc, "helper") + internalOnly := nodeByName(g, KindFunc, "testOnly") + externalOnly := nodeByName(g, KindFunc, "externalOnly") + for _, c := range []struct { + from, to string + resolved bool + }{ + {entry.ID, helper.ID, true}, + {entry.ID, internalOnly.ID, false}, + {entry.ID, externalOnly.ID, false}, + {internal.ID, helper.ID, true}, + {internal.ID, internalOnly.ID, true}, + {internal.ID, externalOnly.ID, false}, + {external.ID, externalOnly.ID, true}, + {external.ID, helper.ID, false}, + } { + if got := edge(g, c.from, c.to, RelCalls) != nil; got != c.resolved { + t.Errorf("call %s -> %s resolved=%t, want %t", c.from, c.to, got, c.resolved) + } + if !c.resolved && edge(g, c.from, ExtID("go", "", g.Node(c.to).Name), RelCalls) == nil { + t.Errorf("unresolved call %s -> %s was lost", c.from, c.to) + } + } +} + +func TestBuildTestOnlyScope(t *testing.T) { + cases := []struct{ language, filename, testfile, source, testsource string }{ + {"python", "app.py", "test_app.py", "def entry():\n testOnly()\n", "def testOnly():\n pass\n"}, + {"ruby", "app.rb", "test_app.rb", "def entry\n testOnly()\nend\n", "def testOnly\nend\n"}, + } + for _, c := range cases { + t.Run(c.language, func(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{c.filename: c.source, c.testfile: c.testsource}) + g, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + entry := nodeByName(g, KindFunc, "entry") + if edge(g, entry.ID, ExtID(c.language, "", "testOnly"), RelCalls) == nil { + t.Fatalf("separate test-only declaration resolved into the application: %v", g.Callees(entry.ID)) + } + }) + } +} + +func TestBuildGoImportsExcludeTests(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{ + "go.mod": "module example.com/app\n", + "main.go": "package app\nimport \"example.com/app/util\"\nfunc entry(){util.Ready();util.InternalOnly();util.ExternalOnly()}\n", + "util/util.go": "package util\nfunc Ready(){}\n", + "util/util_test.go": "package util\nfunc InternalOnly(){}\n", + "util/external_test.go": "package util_test\nfunc ExternalOnly(){}\n", + }) + g, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + entry := nodeByName(g, KindFunc, "entry") + ready := nodeByName(g, KindFunc, "Ready") + if edge(g, entry.ID, ready.ID, RelCalls) == nil { + t.Fatal("production export did not resolve") + } + for _, name := range []string{"InternalOnly", "ExternalOnly"} { + if edge(g, entry.ID, ExtID("go", "example.com/app/util", name), RelCalls) == nil { + t.Errorf("test export %s did not remain external", name) + } + } +} + +func TestBuildGoAmbiguousVariants(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{ + "go.mod": "module example.com/app\n", + "main.go": "package app\nimport \"example.com/app/util\"\nfunc entry(){util.Run();variant()}\n", + "linux.go": "//go:build linux\n\npackage app\nfunc variant(){}\n", + "windows.go": "//go:build windows\n\npackage app\nfunc variant(){}\n", + "util/linux.go": "//go:build linux\n\npackage util\nfunc Run(){}\n", + "util/windows.go": "//go:build windows\n\npackage util\nfunc Run(){}\n", + }) + g, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + entry := nodeByName(g, KindFunc, "entry") + for _, target := range []string{ExtID("go", "example.com/app/util", "Run"), ExtID("go", "", "variant")} { + if edge(g, entry.ID, target, RelCalls) == nil { + t.Errorf("ambiguous target %s resolved arbitrarily: %v", target, g.Callees(entry.ID)) + } + } +} diff --git a/resolve.go b/resolve.go index 92a9f01..9373f0b 100644 --- a/resolve.go +++ b/resolve.go @@ -17,7 +17,7 @@ type resolver struct { files map[string]*fileAnalysis goModule string - goPkgs map[string]map[string]string + goPkgs map[goPackage]map[string]string pyRoots []string // modules maps a mod: ID to the repository-relative files that @@ -57,28 +57,51 @@ func newResolver(root string, files []fileAnalysis, hints ResolutionHints) *reso return r } -// indexGoPackages groups top-level declarations from every Go file by -// directory so same-package calls resolve without an import edge. -func indexGoPackages(files []fileAnalysis) map[string]map[string]string { - pkgs := make(map[string]map[string]string) +type goPackage struct { + Dir string + Name string + Tests bool +} + +func fileGoPackage(f *fileAnalysis) goPackage { + return goPackage{Dir: path.Dir(f.path), Name: f.a.Package, Tests: strings.HasSuffix(f.path, "_test.go")} +} + +func indexGoPackages(files []fileAnalysis) map[goPackage]map[string]string { + pkgs := make(map[goPackage]map[string]string) for i := range files { f := &files[i] if f.a == nil || f.a.Lang != "go" { continue } - dir := path.Dir(f.path) - if pkgs[dir] == nil { - pkgs[dir] = make(map[string]string) + key := fileGoPackage(f) + keys := []goPackage{key} + if !key.Tests { + key.Tests = true + keys = append(keys, key) } - for _, d := range f.a.Decls { - if d.Parent == -1 { - pkgs[dir][d.Name] = d.symID(f.path) + for _, key := range keys { + if pkgs[key] == nil { + pkgs[key] = make(map[string]string) + } + for _, d := range f.a.Decls { + if d.Parent == -1 { + addUniqueSymbol(pkgs[key], d.Name, d.symID(f.path)) + } } } } return pkgs } +func addUniqueSymbol(symbols map[string]string, name, id string) { + if previous, exists := symbols[name]; exists && previous != id { + symbols[name] = "" + } else if !exists { + symbols[name] = id + } +} + func readGoModule(root string) string { data, err := os.ReadFile(filepath.Join(root, "go.mod")) if err != nil { @@ -191,12 +214,17 @@ func (r *resolver) goModuleFiles(module string) []string { } dir := strings.TrimPrefix(rel, "/") var files []string + packageName := "" for _, p := range r.paths { f := r.files[p] - if f.a == nil || f.a.Lang != "go" { + if f.a == nil || f.a.Lang != "go" || strings.HasSuffix(p, "_test.go") { continue } if path.Dir(p) == dir || (dir == "" && path.Dir(p) == ".") { + if packageName != "" && f.a.Package != packageName { + return nil + } + packageName = f.a.Package files = append(files, p) } } @@ -259,7 +287,11 @@ func (r *resolver) moduleExports(mid string) map[string]string { if d.Parent != -1 || !d.Exported { continue } - m[d.Name] = d.symID(p) + if f.a.Lang == "go" { + addUniqueSymbol(m, d.Name, d.symID(p)) + } else { + m[d.Name] = d.symID(p) + } } } r.exports[mid] = m @@ -278,7 +310,7 @@ func (r *resolver) fileScope(f *fileAnalysis) scope { sc.bindings[b.In] = append(sc.bindings[b.In], b) } if f.a.Lang == "go" { - maps.Copy(sc.syms, r.goPkgs[path.Dir(f.path)]) + maps.Copy(sc.syms, r.goPkgs[fileGoPackage(f)]) } for i, d := range f.a.Decls { sc.children[d.Parent] = append(sc.children[d.Parent], i) @@ -307,7 +339,7 @@ func (r *resolver) fileScope(f *fileAnalysis) scope { if local == "" { local = n.Name } - if sid, ok := exp[n.Name]; ok { + if sid := exp[n.Name]; sid != "" { sc.syms[local] = sid } else { sc.syms[local] = ExtID(f.a.Lang, modName(mid), n.Name) @@ -382,7 +414,7 @@ func (r *resolver) resolveCall(f *fileAnalysis, sc scope, c Call) (string, strin } else if shadowed { return ExtID(f.a.Lang, "", c.Name), ConfInferred } - if sid, ok := sc.syms[c.Name]; ok { + if sid := sc.syms[c.Name]; sid != "" { return sid, ConfInferred } return ExtID(f.a.Lang, "", c.Name), ConfInferred @@ -391,7 +423,7 @@ func (r *resolver) resolveCall(f *fileAnalysis, sc scope, c Call) (string, strin return ExtID(f.a.Lang, c.Receiver, c.Name), ConfInferred } if mid, ok := sc.mods[c.Receiver]; ok { - if sid, ok := r.moduleExports(mid)[c.Name]; ok { + if sid := r.moduleExports(mid)[c.Name]; sid != "" { return sid, ConfInferred } return ExtID(f.a.Lang, modName(mid), c.Name), ConfInferred From feb2ffe1b1b85416360878b4c327645008fcdb49 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Mon, 5 Oct 2026 13:16:46 +0100 Subject: [PATCH 04/11] Mark recovered syntax errors as partial graph coverage --- analyse.go | 15 ++++++++------- build.go | 4 ++++ coverage_test.go | 38 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 7 deletions(-) create mode 100644 coverage_test.go diff --git a/analyse.go b/analyse.go index 7379805..4dc2d2e 100644 --- a/analyse.go +++ b/analyse.go @@ -30,12 +30,13 @@ func (d decl) symID(path string) string { // analysis is the per-file fact set that graph resolution consumes. type analysis struct { - Lang string - Package string - Decls []decl - Imports []Import - Calls []Call - Bindings []binding + Lang string + Package string + Decls []decl + Imports []Import + Calls []Call + Bindings []binding + SyntaxErrors bool } // analyse parses src once and returns every fact the graph builder needs @@ -51,7 +52,7 @@ func analyse(src []byte, filename string) (*analysis, bool) { root := tree.RootNode() matches := l.query.Execute(tree) - a := &analysis{Lang: l.name} + a := &analysis{Lang: l.name, SyntaxErrors: root.HasError()} if l.name == "go" { for i := range root.NamedChildCount() { node := root.NamedChild(i) diff --git a/build.go b/build.go index 3083cb5..61b4cb1 100644 --- a/build.go +++ b/build.go @@ -169,6 +169,10 @@ func emitFileNodes(g *Graph, files []fileAnalysis) { } continue } + if f.a.SyntaxErrors { + g.Warnings = append(g.Warnings, f.path+": syntax-errors") + g.Complete = false + } for i, d := range f.a.Decls { sid := d.symID(f.path) g.Nodes = append(g.Nodes, Node{ diff --git a/coverage_test.go b/coverage_test.go new file mode 100644 index 0000000..37c1cfe --- /dev/null +++ b/coverage_test.go @@ -0,0 +1,38 @@ +package outline + +import ( + "strings" + "testing" +) + +func TestBuildRecoveredSyntax(t *testing.T) { + cases := []struct{ language, filename, valid, broken string }{ + {"go", "app.go", "package app\nfunc valid(){}\n", "func broken(\n"}, + {"python", "app.py", "def valid():\n pass\n", "def broken(:\n"}, + {"ruby", "app.rb", "def valid\nend\n", "def broken(\n"}, + } + for _, c := range cases { + t.Run(c.language, func(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{c.filename: c.valid}) + good, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + if !good.Complete || len(good.Warnings) != 0 { + t.Fatalf("valid input marked incomplete: complete=%t warnings=%v", good.Complete, good.Warnings) + } + writeFiles(t, root, map[string]string{c.filename: c.valid + c.broken}) + partial, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + if partial.Complete || !strings.Contains(strings.Join(partial.Warnings, "\n"), c.filename+": syntax-errors") { + t.Fatalf("syntax errors not reported: complete=%t warnings=%v", partial.Complete, partial.Warnings) + } + if nodeByName(partial, KindFunc, "valid") == nil { + t.Fatal("valid definition lost after syntax recovery") + } + }) + } +} From 5313cdc46052d37a86e4172f5084388fe88853a9 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Mon, 5 Oct 2026 13:23:14 +0100 Subject: [PATCH 05/11] Include resolution context in graph fingerprints --- build.go | 12 ++++++--- digest_test.go | 73 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 digest_test.go diff --git a/build.go b/build.go index 61b4cb1..abab1a0 100644 --- a/build.go +++ b/build.go @@ -73,11 +73,12 @@ func Build(root string, opts Options) (*Graph, error) { sort.Strings(paths) files := analyseAll(abs, paths, opts) + r := newResolver(abs, files, opts.Resolution) g := &Graph{ SchemaVersion: SchemaVersion, ToolVersion: ToolVersion, - SourceDigest: sourceDigest(files), + SourceDigest: sourceDigest(files, r.goModule, r.pyRoots), Complete: !truncated, } if truncated { @@ -85,7 +86,6 @@ func Build(root string, opts Options) (*Graph, error) { } emitFileNodes(g, files) - r := newResolver(abs, files, opts.Resolution) r.emitModules(g) r.emitCalls(g) @@ -280,7 +280,7 @@ func sanitise(s string) string { return b.String() } -func sourceDigest(files []fileAnalysis) string { +func sourceDigest(files []fileAnalysis, goModule string, sourceRoots []string) string { h := sha256.New() for _, f := range files { h.Write([]byte(f.path)) @@ -288,5 +288,11 @@ func sourceDigest(files []fileAnalysis) string { fh := sha256.Sum256(f.src) h.Write(fh[:]) } + moduleHash := sha256.Sum256([]byte(goModule)) + h.Write(moduleHash[:]) + for _, root := range sourceRoots { + rootHash := sha256.Sum256([]byte(root)) + h.Write(rootHash[:]) + } return hex.EncodeToString(h.Sum(nil)) } diff --git a/digest_test.go b/digest_test.go new file mode 100644 index 0000000..745af7b --- /dev/null +++ b/digest_test.go @@ -0,0 +1,73 @@ +package outline + +import "testing" + +func TestBuildDigestGoModule(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{ + "go.mod": "module example.com/app\n", + "app.go": "package app\nimport \"example.com/app/util\"\nfunc entry(){util.Ready()}\n", + "util/util.go": "package util\nfunc Ready(){}\n", + }) + local, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + entry := nodeByName(local, KindFunc, "entry") + ready := nodeByName(local, KindFunc, "Ready") + if edge(local, entry.ID, ready.ID, RelCalls) == nil { + t.Fatal("fixture did not resolve the local module") + } + writeFiles(t, root, map[string]string{"go.mod": "module example.com/other\n"}) + external, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + if edge(external, entry.ID, ExtID("go", "example.com/app/util", "Ready"), RelCalls) == nil { + t.Fatal("changed module identity did not alter resolution") + } + if local.SourceDigest == external.SourceDigest { + t.Fatal("changed Go module resolution retained the same digest") + } +} + +func TestBuildDigestSourceRoots(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{ + "app.py": "import helper\ndef entry():\n helper.Ready()\n", + "first/helper.py": "def Ready():\n pass\n", + "second/helper.py": "def Ready():\n pass\n", + }) + cases := []struct { + roots []string + target string + }{ + {nil, ""}, + {[]string{"first", "second"}, "first/helper.py"}, + {[]string{"second", "first"}, "second/helper.py"}, + } + digests := make(map[string]bool) + for _, c := range cases { + opts := Options{Resolution: ResolutionHints{SourceRoots: c.roots}} + g, err := Build(root, opts) + if err != nil { + t.Fatal(err) + } + entry := nodeByName(g, KindFunc, "entry") + calls := g.Callees(entry.ID) + if len(calls) != 1 || g.Node(calls[0].To).File != c.target { + t.Fatalf("roots=%v target=%s calls=%v", c.roots, c.target, calls) + } + if digests[g.SourceDigest] { + t.Fatalf("different resolution inputs shared digest %s", g.SourceDigest) + } + digests[g.SourceDigest] = true + repeated, err := Build(root, opts) + if err != nil { + t.Fatal(err) + } + if repeated.SourceDigest != g.SourceDigest { + t.Fatal("same resolution inputs produced a different digest") + } + } +} From 60d758f743e40a4b689443aab962c958d3a40b55 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Mon, 5 Oct 2026 13:50:33 +0100 Subject: [PATCH 06/11] Preserve Go method receiver ownership and scope --- analyse.go | 6 ++ build.go | 16 ++++- go_method.go | 47 ++++++++++++++ go_method_test.go | 153 ++++++++++++++++++++++++++++++++++++++++++++++ resolve.go | 23 ++++--- 5 files changed, 234 insertions(+), 11 deletions(-) create mode 100644 go_method.go create mode 100644 go_method_test.go diff --git a/analyse.go b/analyse.go index 4dc2d2e..1cc0813 100644 --- a/analyse.go +++ b/analyse.go @@ -21,6 +21,7 @@ type decl struct { Parent int Params []string Singleton bool + Method bool Owner string } @@ -126,7 +127,11 @@ func declsFromMatch(src []byte, l *lang, m ts.QueryMatch) []decl { } params := extractParams(src, l, definition) singleton := l.name == "ruby" && definition.Type(l.language) == "singleton_method" + method := l.name == "go" && definition.Type(l.language) == "method_declaration" owner := "" + if method { + owner = goReceiverOwner(src, l, definition) + } if singleton { if object := definition.ChildByFieldName("object", l.language); object != nil { owner = object.Text(src) @@ -153,6 +158,7 @@ func declsFromMatch(src []byte, l *lang, m ts.QueryMatch) []decl { Parent: -1, Params: params, Singleton: singleton, + Method: method, Owner: owner, }) } diff --git a/build.go b/build.go index abab1a0..47ac5cd 100644 --- a/build.go +++ b/build.go @@ -85,7 +85,7 @@ func Build(root string, opts Options) (*Graph, error) { g.Warnings = append(g.Warnings, fmt.Sprintf("file limit %d reached; graph is partial", opts.MaxFiles)) } - emitFileNodes(g, files) + emitFileNodes(g, files, r.goMethodOwners()) r.emitModules(g) r.emitCalls(g) @@ -158,7 +158,7 @@ func shebangCandidate(path string) bool { return !strings.Contains(base, ".") } -func emitFileNodes(g *Graph, files []fileAnalysis) { +func emitFileNodes(g *Graph, files []fileAnalysis, methodOwners map[string]string) { for _, f := range files { fid := FileID(f.path) g.Nodes = append(g.Nodes, Node{ID: fid, Kind: KindFile, Name: f.path, File: f.path}) @@ -188,11 +188,15 @@ func emitFileNodes(g *Graph, files []fileAnalysis) { Sig: signature(f.src, d), }) from := fid + conf := ConfExtracted if d.Parent >= 0 { from = f.a.Decls[d.Parent].symID(f.path) + } else if owner := methodOwners[sid]; owner != "" { + from = owner + conf = ConfInferred } g.Edges = append(g.Edges, Edge{ - From: from, To: sid, Rel: RelContains, Conf: ConfExtracted, + From: from, To: sid, Rel: RelContains, Conf: conf, File: f.path, Line: d.Line, }) } @@ -204,8 +208,14 @@ func qualified(lang string, decls []decl, i int) string { return rubyQualified(decls, i) } parts := []string{decls[i].Name} + if decls[i].Method && decls[i].Owner != "" { + parts = append(parts, decls[i].Owner) + } for p := decls[i].Parent; p >= 0; p = decls[p].Parent { parts = append(parts, decls[p].Name) + if decls[p].Method && decls[p].Owner != "" { + parts = append(parts, decls[p].Owner) + } } for l, r := 0, len(parts)-1; l < r; l, r = l+1, r-1 { parts[l], parts[r] = parts[r], parts[l] diff --git a/go_method.go b/go_method.go new file mode 100644 index 0000000..9b8b592 --- /dev/null +++ b/go_method.go @@ -0,0 +1,47 @@ +package outline + +import ts "github.com/odvcencio/gotreesitter" + +func goReceiverOwner(src []byte, l *lang, definition *ts.Node) string { + receiver := definition.ChildByFieldName("receiver", l.language) + if receiver == nil || receiver.NamedChildCount() != 1 { + return "" + } + typ := receiver.NamedChild(0).ChildByFieldName("type", l.language) + if owner := firstDescendantType(typ, l.language, "type_identifier"); owner != nil { + return owner.Text(src) + } + return "" +} + +func (r *resolver) goMethodOwners() map[string]string { + types := make(map[string]bool) + for _, p := range r.paths { + f := r.files[p] + if f.a == nil || f.a.Lang != "go" { + continue + } + for _, d := range f.a.Decls { + if d.Kind == KindType && d.Parent == -1 { + types[d.symID(p)] = true + } + } + } + owners := make(map[string]string) + for _, p := range r.paths { + f := r.files[p] + if f.a == nil || f.a.Lang != "go" { + continue + } + for _, d := range f.a.Decls { + if !d.Method || d.Owner == "" { + continue + } + owner := r.goPkgs[fileGoPackage(f)][d.Owner] + if types[owner] { + owners[d.symID(p)] = owner + } + } + } + return owners +} diff --git a/go_method_test.go b/go_method_test.go new file mode 100644 index 0000000..ebc4153 --- /dev/null +++ b/go_method_test.go @@ -0,0 +1,153 @@ +package outline + +import ( + "strings" + "testing" +) + +func TestBuildGoMethodOwners(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{ + "types.go": "package app\ntype A struct{}\ntype B struct{}\ntype Box[T any] struct{}\n", + "methods.go": `package app +func (a A) Run() { Probe() } +func (b *B) Run() {} +func (*A) Probe() {} +func (box *Box[T]) Get() {} +func (Box[T]) Put() {} +func entry() { Probe(); Run() } +`, + }) + g, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"A.Run", "B.Run", "A.Probe", "Box.Get", "Box.Put"} { + methods := g.Def(name) + if len(methods) != 1 { + t.Errorf("method %s definitions=%v", name, methods) + continue + } + owner, _, _ := strings.Cut(name, ".") + typeNode := nodeByName(g, KindType, owner) + if edge(g, typeNode.ID, methods[0].ID, RelContains) == nil { + t.Errorf("owner %s does not contain method %s", owner, name) + } + } + entry := nodeByName(g, KindFunc, "entry") + for _, name := range []string{"Probe", "Run"} { + if edge(g, entry.ID, ExtID("go", "", name), RelCalls) == nil { + t.Errorf("bare %s incorrectly resolved to a method: %v", name, g.Callees(entry.ID)) + } + } + if methods := g.Def("A.Run"); len(methods) == 1 { + if edge(g, methods[0].ID, ExtID("go", "", "Probe"), RelCalls) == nil { + t.Error("bare call inside a method resolved to another method") + } + } +} + +func TestBuildGoMethodsAreNotPackageFunctions(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{ + "go.mod": "module example.com/app\n", + "main.go": `package app +import "example.com/app/util" +type Local struct{} +func (Local) Ready() {} +func entry() { Ready(); OnlyMethod(); util.Ready(); util.OnlyMethod() } +`, + "functions.go": "package app\nfunc Ready(){}\n", + "methods.go": "package app\nfunc (Local) OnlyMethod(){}\n", + "util/util.go": `package util +type Worker struct{} +func (Worker) Ready() {} +func (Worker) OnlyMethod() {} +func Ready() {} +`, + }) + g, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + entry := nodeByName(g, KindFunc, "entry") + if calls := g.Callees(entry.ID); len(calls) != 4 { + t.Fatalf("calls=%v, want all four source calls", calls) + } + for _, call := range g.Callees(entry.ID) { + target := g.Node(call.To) + switch call.Call.Name { + case "Ready": + if target.Kind != KindFunc || target.Qualified != "Ready" { + t.Errorf("package function bound to method: %+v", target) + } + case "OnlyMethod": + if target.Kind != KindExternal { + t.Errorf("method entered package lookup: %+v", target) + } + } + } +} + +func TestBuildGoMethodOwnerScopes(t *testing.T) { + for _, c := range []struct { + name, ownerFile, source string + resolved bool + }{ + {"production", "types.go", "package app\ntype Worker struct{}\n", true}, + {"test-only", "types_test.go", "package app\ntype Worker struct{}\n", false}, + {"other-package", "types_test.go", "package app_test\ntype Worker struct{}\n", false}, + {"wrong-kind", "types.go", "package app\nvar Worker = 1\n", false}, + {"ambiguous", "types.go", "package app\ntype Worker struct{}\n", false}, + } { + t.Run(c.name, func(t *testing.T) { + root := t.TempDir() + files := map[string]string{ + "methods.go": "package app\nfunc (Worker) Run(){}\n", + c.ownerFile: c.source, + } + if c.name == "ambiguous" { + files["variant.go"] = "package app\ntype Worker struct{}\n" + } + writeFiles(t, root, files) + g, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + methods := g.Def("Worker.Run") + if len(methods) != 1 { + t.Fatalf("method definitions=%v", methods) + } + for _, e := range g.In(methods[0].ID) { + if e.Rel != RelContains { + continue + } + owner := g.Node(e.From) + if (owner.Kind == KindType) != c.resolved { + t.Errorf("owner=%+v, want type owner=%t", owner, c.resolved) + } + } + }) + } +} + +func TestBuildMethodBareCallsAcrossLanguages(t *testing.T) { + for _, c := range []struct{ lang, filename, source string }{ + {"go", "app.go", "package app\ntype A struct{}\nfunc (A) Probe(){}\nfunc entry(){Probe()}\n"}, + {"python", "app.py", "class A:\n def Probe(self):\n pass\ndef entry():\n Probe()\n"}, + {"ruby", "app.rb", "class A\n def Probe\n end\nend\ndef entry\n Probe()\nend\n"}, + } { + t.Run(c.lang, func(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{c.filename: c.source}) + g, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + entry := nodeByName(g, KindFunc, "entry") + if edge(g, entry.ID, ExtID(c.lang, "", "Probe"), RelCalls) == nil { + t.Fatalf("bare call bound to a class/type method: %v", g.Callees(entry.ID)) + } + }) + } +} diff --git a/resolve.go b/resolve.go index 9373f0b..0024340 100644 --- a/resolve.go +++ b/resolve.go @@ -85,7 +85,7 @@ func indexGoPackages(files []fileAnalysis) map[goPackage]map[string]string { pkgs[key] = make(map[string]string) } for _, d := range f.a.Decls { - if d.Parent == -1 { + if d.Parent == -1 && !d.Method { addUniqueSymbol(pkgs[key], d.Name, d.symID(f.path)) } } @@ -284,7 +284,7 @@ func (r *resolver) moduleExports(mid string) map[string]string { continue } for _, d := range f.a.Decls { - if d.Parent != -1 || !d.Exported { + if d.Parent != -1 || !d.Exported || d.Method { continue } if f.a.Lang == "go" { @@ -312,12 +312,7 @@ func (r *resolver) fileScope(f *fileAnalysis) scope { if f.a.Lang == "go" { maps.Copy(sc.syms, r.goPkgs[fileGoPackage(f)]) } - for i, d := range f.a.Decls { - sc.children[d.Parent] = append(sc.children[d.Parent], i) - if d.Parent == -1 { - sc.syms[d.Name] = d.symID(f.path) - } - } + sc.addDeclarations(f) for _, imp := range f.a.Imports { mid := r.moduleID(f, imp) switch imp.Kind { @@ -352,6 +347,18 @@ func (r *resolver) fileScope(f *fileAnalysis) scope { return sc } +func (sc *scope) addDeclarations(f *fileAnalysis) { + for i, d := range f.a.Decls { + if d.Method { + continue + } + sc.children[d.Parent] = append(sc.children[d.Parent], i) + if d.Parent == -1 { + sc.syms[d.Name] = d.symID(f.path) + } + } +} + func defaultAlias(lang, module string) string { switch lang { case "go": From e9e44c53a2e6dc03c5f86d2e9aa88dbf83ec89d8 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Mon, 5 Oct 2026 21:06:59 +0200 Subject: [PATCH 07/11] Resolve Python submodules and source re-exports --- analyse.go | 6 + call.go | 12 +- import.go | 11 +- import_registry.go | 2 +- python_resolve.go | 329 +++++++++++++++++++++++++++++++++++++++++ python_resolve_test.go | 225 ++++++++++++++++++++++++++++ resolve.go | 46 ++++-- 7 files changed, 610 insertions(+), 21 deletions(-) create mode 100644 python_resolve.go create mode 100644 python_resolve_test.go diff --git a/analyse.go b/analyse.go index 1cc0813..43c2164 100644 --- a/analyse.go +++ b/analyse.go @@ -38,6 +38,8 @@ type analysis struct { Calls []Call Bindings []binding SyntaxErrors bool + PyImports []pythonImport + PyExports *pythonExports } // analyse parses src once and returns every fact the graph builder needs @@ -67,6 +69,10 @@ func analyse(src []byte, filename string) (*analysis, bool) { a.Imports, _ = importsFor(src, l, root) a.Calls, _ = callsFor(src, l, root, a.Decls) a.Bindings = bindingsFor(src, l, root, a.Decls) + if l.name == "python" { + a.PyImports = pythonImportFacts(src, l, root, a.Decls) + a.PyExports = pythonExportFacts(src, l, root) + } return a, true } diff --git a/call.go b/call.go index 2b01f57..f6e3350 100644 --- a/call.go +++ b/call.go @@ -120,7 +120,7 @@ func pythonCalls(src []byte, language *ts.Language, root *ts.Node) []Call { return } c.Name = member.Text(src) - c.Receiver = leftmostIdentifier(src, language, recv) + c.Receiver = pythonReceiver(src, language, recv) c.ReceiverKind = ReceiverLocal if c.Receiver == "" && recv != nil { c.Receiver = recv.Text(src) @@ -213,13 +213,15 @@ func rubyLoadMethod(name string) bool { } } -// leftmostIdentifier walks a chained attribute expression (a.b.c) and -// returns the base identifier text, or "" if the base is not a plain name. -func leftmostIdentifier(src []byte, language *ts.Language, node *ts.Node) string { +func pythonReceiver(src []byte, language *ts.Language, node *ts.Node) string { + if node == nil { + return "" + } + text := node.Text(src) for node != nil { switch node.Type(language) { case "identifier": - return node.Text(src) + return text case "attribute": node = node.ChildByFieldName("object", language) default: diff --git a/import.go b/import.go index 06d9c0c..c826180 100644 --- a/import.go +++ b/import.go @@ -9,6 +9,9 @@ import ( const ( importDeclaration = "import_declaration" importStatement = "import_statement" + importFromStatement = "import_from_statement" + futureImportStatement = "future_import_statement" + aliasedImport = "aliased_import" javascriptCallExpression = "call_expression" ) @@ -80,7 +83,7 @@ func pythonImports(src []byte, language *ts.Language, root *ts.Node) []Import { switch node.Type(language) { case importStatement: imports = append(imports, pythonModuleImports(src, language, node)...) - case "import_from_statement", "future_import_statement": + case importFromStatement, futureImportStatement: if imported, ok := pythonFromImport(src, language, node); ok { imports = append(imports, imported) } @@ -96,7 +99,7 @@ func pythonModuleImports(src []byte, language *ts.Language, statement *ts.Node) module := "" alias := "" switch child.Type(language) { - case "aliased_import": + case aliasedImport: if child.NamedChildCount() > 0 { module = child.NamedChild(0).Text(src) } @@ -133,7 +136,7 @@ func pythonFromImport( moduleNode := statement.NamedChild(0) module := moduleNode.Text(src) firstName := 1 - if statement.Type(language) == "future_import_statement" { + if statement.Type(language) == futureImportStatement { module = "__future__" firstName = 0 } @@ -149,7 +152,7 @@ func pythonFromImport( imported.Kind = ImportWildcard imported.Names = nil return imported, true - case "aliased_import": + case aliasedImport: if child.NamedChildCount() == 0 { continue } diff --git a/import_registry.go b/import_registry.go index 5a76b10..5feae91 100644 --- a/import_registry.go +++ b/import_registry.go @@ -498,7 +498,7 @@ func nimImports(src []byte, language *ts.Language, root *ts.Node) []Import { for i := range list.NamedChildCount() { imports = append(imports, nimImportExpression(src, language, list.NamedChild(i), sourceLine(node))...) } - case "import_from_statement": + case importFromStatement: if imported, ok := nimFromImport(src, language, node); ok { imports = append(imports, imported) } diff --git a/python_resolve.go b/python_resolve.go new file mode 100644 index 0000000..01f41d9 --- /dev/null +++ b/python_resolve.go @@ -0,0 +1,329 @@ +package outline + +import ( + "path" + "slices" + "strings" + + ts "github.com/odvcencio/gotreesitter" +) + +const maxPythonAliasDepth = 64 + +type pythonImport struct { + Import + In int + End uint32 + BoundModule string +} + +type pythonTarget struct { + id, module, from string + loaded []string +} + +type pythonBinding struct { + target pythonTarget + found bool + uncertain bool +} + +type pythonLookup struct{ file, name string } + +type pythonExports struct { + names map[string]bool + known bool +} + +func pythonExportFacts(src []byte, l *lang, root *ts.Node) *pythonExports { + var exports *pythonExports + walkNamed(root, func(node *ts.Node) { + if node.Type(l.language) != "assignment" || ancestorNode(node, "function_definition", l.language) != nil || ancestorNode(node, "class_definition", l.language) != nil { + return + } + left := node.ChildByFieldName("left", l.language) + if left == nil || left.Text(src) != "__all__" { + return + } + if exports != nil { + exports.known = false + return + } + exports = &pythonExports{names: make(map[string]bool)} + value := node.ChildByFieldName("right", l.language) + if value == nil || (value.Type(l.language) != "list" && value.Type(l.language) != "tuple") { + return + } + exports.known = true + for i := range value.NamedChildCount() { + child := value.NamedChild(i) + text := child.Text(src) + if child.Type(l.language) != "string" || strings.ContainsAny(text, "\\\n\r") || sourceString(text) == "" { + exports.known = false + return + } + exports.names[sourceString(text)] = true + } + }) + return exports +} + +func (b *pythonBinding) add(target pythonTarget) { + if target.id == "" && target.module == "" { + b.uncertain = true + } + if b.found && (b.target.id != target.id || b.target.module != target.module || b.target.from != target.from) { + b.uncertain = true + } + if !b.found { + b.target = target + } + for _, loaded := range target.loaded { + if !slices.Contains(b.target.loaded, loaded) { + b.target.loaded = append(b.target.loaded, loaded) + } + } + b.found = true +} + +func (b pythonBinding) resolved() pythonTarget { + if b.uncertain { + return pythonTarget{} + } + return b.target +} + +func pythonImportFacts(src []byte, l *lang, root *ts.Node, decls []decl) []pythonImport { + var facts []pythonImport + walkNamed(root, func(node *ts.Node) { + var imports []Import + switch node.Type(l.language) { + case importStatement: + imports = pythonModuleImports(src, l.language, node) + case importFromStatement, futureImportStatement: + if imp, ok := pythonFromImport(src, l.language, node); ok { + imports = []Import{imp} + } + } + for i, imp := range imports { + bound := imp.Module + if imp.Kind == ImportModule && node.NamedChild(i).Type(l.language) != aliasedImport { + bound, _, _ = strings.Cut(imp.Module, ".") + } + facts = append(facts, pythonImport{ + Import: imp, In: enclosing(decls, node.StartByte()), End: node.EndByte(), BoundModule: bound, + }) + } + }) + return facts +} + +func (r *resolver) pyLookup(module, from, name string, seen map[pythonLookup]bool) pythonBinding { + files := r.pyModuleFiles(module, from) + if len(files) != 1 || r.files[files[0]].a == nil { + return pythonBinding{} + } + return r.pyFileLookup(r.files[files[0]], name, seen) +} + +func (r *resolver) pyFileLookup(f *fileAnalysis, name string, seen map[pythonLookup]bool) pythonBinding { + key := pythonLookup{f.path, name} + if seen[key] || len(seen) >= maxPythonAliasDepth { + return pythonBinding{found: true, uncertain: true} + } + seen[key] = true + defer delete(seen, key) + var result pythonBinding + for _, d := range f.a.Decls { + if d.Parent == -1 && d.Name == name { + result.add(pythonTarget{id: d.symID(f.path)}) + } + } + for _, imp := range f.a.PyImports { + if imp.In != -1 { + continue + } + if binding := r.pyImportName(f, imp, name, seen); binding.found { + result.add(binding.resolved()) + } + } + return result +} + +func (r *resolver) pyImportName(f *fileAnalysis, imp pythonImport, name string, seen map[pythonLookup]bool) pythonBinding { + if imp.Kind == ImportWildcard { + return r.pyWildcardLookup(imp.Module, f.path, name, seen) + } + var result pythonBinding + for _, n := range imp.Names { + local := n.Alias + if local == "" { + local = n.Name + } + if local != name { + continue + } + if imp.Kind == ImportModule { + target := pythonTarget{module: imp.BoundModule, from: f.path} + if imp.Module != imp.BoundModule { + target.loaded = []string{imp.Module} + } + result.add(target) + } else { + result.add(r.pyNamedImport(f, imp, n.Name, seen)) + } + } + return result +} + +func (r *resolver) pyNamedImport(f *fileAnalysis, imp pythonImport, name string, seen map[pythonLookup]bool) pythonTarget { + module := pythonChildModule(imp.Module, name) + files := r.pyModuleFiles(imp.Module, f.path) + packageModule := len(files) == 0 || path.Base(files[0]) == "__init__.py" + if packageModule && len(files) == 1 && files[0] == f.path && len(r.pyModuleFiles(module, f.path)) == 1 { + return pythonTarget{module: module, from: f.path} + } + if binding := r.pyLookup(imp.Module, f.path, name, seen); binding.found { + return binding.resolved() + } + if files := r.pyModuleFiles(module, f.path); packageModule && len(files) == 1 { + return pythonTarget{module: module, from: f.path} + } + if len(r.pyModuleFiles(imp.Module, f.path)) == 0 { + return pythonTarget{id: ExtID("python", modName(r.moduleID(f, imp.Import)), name)} + } + return pythonTarget{} +} + +func (r *resolver) pyWildcardLookup(module, from, name string, seen map[pythonLookup]bool) pythonBinding { + files := r.pyModuleFiles(module, from) + if len(files) != 1 || r.files[files[0]].a == nil { + return pythonBinding{} + } + f := r.files[files[0]] + if exports := f.a.PyExports; exports != nil { + if !exports.known { + return pythonBinding{found: true, uncertain: true} + } + if !exports.names[name] { + return pythonBinding{} + } + } else if strings.HasPrefix(name, "_") { + return pythonBinding{} + } + return r.pyFileLookup(f, name, seen) +} + +func (r *resolver) emitPythonSubmodules(g *Graph, seen map[string]bool) { + for _, p := range r.paths { + f := r.files[p] + if f.a == nil || f.a.Lang != "python" { + continue + } + for _, imp := range f.a.PyImports { + if imp.Kind != ImportNamed { + continue + } + for _, n := range imp.Names { + target := r.pyNamedImport(f, imp, n.Name, make(map[pythonLookup]bool)) + if target.module == "" { + continue + } + mid := r.moduleID(r.files[target.from], Import{Module: target.module}) + if !seen[mid] { + seen[mid] = true + g.Nodes = append(g.Nodes, Node{ID: mid, Kind: KindModule, Name: target.module}) + } + for _, file := range r.pyModuleFiles(target.module, target.from) { + if !slices.Contains(r.modules[mid], file) { + r.modules[mid] = append(r.modules[mid], file) + } + } + g.Edges = append(g.Edges, Edge{ + From: FileID(p), To: mid, Rel: RelImports, Conf: ConfInferred, File: p, Line: imp.Line, + }) + } + } + } +} + +func pythonChildModule(module, name string) string { + if strings.HasSuffix(module, ".") { + return module + name + } + return module + "." + name +} + +func (r *resolver) pyMember(target pythonTarget, name string, seen map[pythonLookup]bool) pythonTarget { + if target.module == "" { + return pythonTarget{} + } + if binding := r.pyLookup(target.module, target.from, name, seen); binding.found { + return binding.resolved() + } + child := pythonChildModule(target.module, name) + for _, loaded := range target.loaded { + if loaded == child || strings.HasPrefix(loaded, child+".") { + return pythonTarget{module: child, from: target.from, loaded: target.loaded} + } + } + if len(r.pyModuleFiles(target.module, target.from)) == 0 { + return pythonTarget{id: ExtID("python", target.module, name)} + } + return pythonTarget{} +} + +func (r *resolver) pyCallBinding(f *fileAnalysis, c Call, name string, seen map[pythonLookup]bool) pythonBinding { + for at := c.In; at >= 0; at = f.a.Decls[at].Parent { + if at != c.In && f.a.Decls[at].Kind == KindClass { + continue + } + var result pythonBinding + for _, imp := range f.a.PyImports { + if imp.In != at { + continue + } + if binding := r.pyImportName(f, imp, name, seen); binding.found { + if c.Start < imp.End { + result.add(pythonTarget{}) + } else { + result.add(binding.resolved()) + } + } + } + if result.found { + return result + } + } + return r.pyFileLookup(f, name, seen) +} + +func (r *resolver) resolvePythonCall(f *fileAnalysis, sc scope, c Call) (string, string) { + name := c.Name + if c.Receiver != "" { + name, _, _ = strings.Cut(c.Receiver, ".") + } + sid, shadowed := lexical(f, sc, c.In, name, c.Start) + if sid != "" && c.Receiver == "" { + return sid, ConfExtracted + } + if sid != "" || shadowed || c.ReceiverKind == ReceiverExpression { + return ExtID("python", c.Receiver, c.Name), ConfInferred + } + seen := make(map[pythonLookup]bool) + target := r.pyCallBinding(f, c, name, seen).resolved() + if c.Receiver != "" { + for _, member := range strings.Split(c.Receiver, ".")[1:] { + target = r.pyMember(target, member, seen) + } + target = r.pyMember(target, c.Name, seen) + } + if target.id != "" { + conf := ConfInferred + if c.Receiver == "" && strings.HasPrefix(target.id, "sym:"+idEscape(f.path)+":") { + conf = ConfExtracted + } + return target.id, conf + } + return ExtID("python", c.Receiver, c.Name), ConfInferred +} diff --git a/python_resolve_test.go b/python_resolve_test.go new file mode 100644 index 0000000..9fc7f71 --- /dev/null +++ b/python_resolve_test.go @@ -0,0 +1,225 @@ +package outline + +import "testing" + +func TestBuildPythonPackageJoins(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{ + "app.py": `import pkg +import pkg.loader +import pkg.loader as direct +from pkg import loader as sub +from pkg import encode as public +def entry(): + pkg.encode() + public() + pkg.loader.run() + direct.run() + sub.run() +`, + "pkg/__init__.py": "from .api import encode\n", + "pkg/api.py": "from .impl import encode\n", + "pkg/impl.py": "def encode():\n pass\n", + "pkg/loader.py": "def run():\n pass\n", + "pkg.py": "def other():\n pass\n", + }) + g, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + entry := nodeByName(g, KindFunc, "entry") + encode := nodeByName(g, KindFunc, "encode") + run := nodeByName(g, KindFunc, "run") + loader := ModID("python", "pkg.loader") + if edge(g, FileID("app.py"), loader, RelImports) == nil || edge(g, loader, FileID("pkg/loader.py"), RelContains) == nil { + t.Error("resolved submodule import did not retain its source connection") + } + calls := g.Callees(entry.ID) + if len(calls) != 5 { + t.Fatalf("calls=%v, want all five calls", calls) + } + for _, call := range calls { + want := run.ID + if call.Call.Name == "encode" || call.Call.Name == "public" { + want = encode.ID + } + if call.To != want { + t.Errorf("%s.%s target=%s, want %s", call.Call.Receiver, call.Call.Name, call.To, want) + } + } +} + +func TestBuildPythonModuleReexports(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{ + "app.py": "import pkg\nfrom pkg import exposed as local\ndef entry():\n pkg.exposed.run()\n local.run()\n", + "pkg/__init__.py": "from . import loader\nfrom . import loader as exposed\n", + "pkg/loader.py": "def run():\n pass\n", + }) + g, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + entry := nodeByName(g, KindFunc, "entry") + run := nodeByName(g, KindFunc, "run") + if calls := g.Callees(entry.ID); len(calls) != 2 { + t.Fatalf("calls=%v", calls) + } + for _, call := range g.Callees(entry.ID) { + if call.To != run.ID { + t.Errorf("module re-export target=%s, want %s", call.To, run.ID) + } + } +} + +func TestBuildPythonUncertainReexports(t *testing.T) { + for _, c := range []struct{ name, source string }{ + {"cycle", "from .other import run\n"}, + {"conflict", "from .one import run\nfrom .two import run\n"}, + {"missing-conflict", "from .one import run\nfrom .missing import run\n"}, + {"nested-import", "def setup():\n from .one import run\n"}, + {"class-import", "class Setup:\n from .one import run\n"}, + {"definition-conflict", "from .one import run\ndef run():\n pass\n"}, + } { + t.Run(c.name, func(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{ + "app.py": "import pkg\ndef entry():\n pkg.run()\n", + "pkg/__init__.py": c.source, + "pkg/other.py": "from . import run\n", + "pkg/one.py": "def run():\n pass\n", + "pkg/two.py": "def run():\n pass\n", + }) + g, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + entry := nodeByName(g, KindFunc, "entry") + if edge(g, entry.ID, ExtID("python", "pkg", "run"), RelCalls) == nil { + t.Fatalf("uncertain re-export bound to a source target: %v", g.Callees(entry.ID)) + } + }) + } +} + +func TestBuildPythonReceiverChains(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{ + "app.py": `import pkg +def entry(): + pkg.missing.run() + factory().run() +def shadow(pkg): + pkg.run() +def factory(): + pass +`, + "pkg/__init__.py": "def run():\n pass\n", + "pkg/missing.py": "def run():\n pass\n", + }) + g, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + entry := nodeByName(g, KindFunc, "entry") + for _, call := range g.Callees(entry.ID) { + if call.Call.Name == "run" && g.Node(call.To).Kind != KindExternal { + t.Errorf("receiver chain collapsed into a package function: %+v", call) + } + } + shadow := nodeByName(g, KindFunc, "shadow") + if edge(g, shadow.ID, ExtID("python", "pkg", "run"), RelCalls) == nil { + t.Error("parameter receiver resolved through a module") + } +} + +func TestBuildPythonStandaloneModuleHasNoSubmodules(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{ + "app.py": "from util import loader\ndef entry():\n loader.run()\n", + "util.py": "def other():\n pass\n", + "util/loader.py": "def run():\n pass\n", + }) + g, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + entry := nodeByName(g, KindFunc, "entry") + if edge(g, entry.ID, ExtID("python", "loader", "run"), RelCalls) == nil { + t.Fatalf("standalone util.py incorrectly provided a submodule: %v", g.Callees(entry.ID)) + } +} + +func TestBuildPythonWildcardExports(t *testing.T) { + for _, c := range []struct { + name, exports string + resolved bool + }{ + {"implicit", "", true}, + {"listed", "__all__ = ['run']\n", true}, + {"excluded", "__all__ = []\n", false}, + {"dynamic", "__all__ = names()\n", false}, + {"duplicate", "__all__ = ['run']\n__all__ = []\n", false}, + } { + t.Run(c.name, func(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{ + "app.py": "from pkg import *\ndef entry():\n run()\n", + "pkg/__init__.py": c.exports + "from .impl import run\n", + "pkg/impl.py": "def run():\n pass\n", + }) + g, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + entry := nodeByName(g, KindFunc, "entry") + run := nodeByName(g, KindFunc, "run") + if got := edge(g, entry.ID, run.ID, RelCalls) != nil; got != c.resolved { + t.Fatalf("resolved=%t, want %t: %v", got, c.resolved, g.Callees(entry.ID)) + } + }) + } +} + +func TestBuildPythonLocalImports(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{ + "app.py": `from one import run +def local(): + run() + from two import run + run() +def entry(): + run() +class Holder: + from two import run + def method(self): + run() +`, + "one.py": "def run():\n pass\n", + "two.py": "def run():\n pass\n", + }) + g, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + for _, c := range []struct { + function string + files []string + }{ + {"local", []string{"", "two.py"}}, + {"entry", []string{"one.py"}}, + {"method", []string{"one.py"}}, + } { + fn := nodeByName(g, KindFunc, c.function) + calls := g.Callees(fn.ID) + if len(calls) != len(c.files) { + t.Fatalf("%s calls=%v", c.function, calls) + } + for i, call := range calls { + if got := g.Node(call.To).File; got != c.files[i] { + t.Errorf("%s call %d target=%s, want file %s", c.function, i, call.To, c.files[i]) + } + } + } +} diff --git a/resolve.go b/resolve.go index 0024340..ee9ee6e 100644 --- a/resolve.go +++ b/resolve.go @@ -165,6 +165,7 @@ func (r *resolver) emitModules(g *Graph) { }) } } + r.emitPythonSubmodules(g, seen) for mid, paths := range r.modules { for _, p := range paths { g.Edges = append(g.Edges, Edge{ @@ -238,8 +239,8 @@ func (r *resolver) pyModuleFiles(module, from string) []string { rel := strings.ReplaceAll(module, ".", "/") for _, root := range r.pyRoots { for _, cand := range []string{ - path.Join(root, rel+".py"), path.Join(root, rel, "__init__.py"), + path.Join(root, rel+".py"), } { if _, ok := r.files[cand]; ok { return []string{cand} @@ -262,8 +263,14 @@ func (r *resolver) pyRelative(module, from string) []string { base := dir if rest != "" { base = path.Join(dir, rest) + } else { + candidate := path.Join(base, "__init__.py") + if _, ok := r.files[candidate]; ok { + return []string{candidate} + } + return nil } - for _, cand := range []string{base + ".py", path.Join(base, "__init__.py")} { + for _, cand := range []string{path.Join(base, "__init__.py"), base + ".py"} { if _, ok := r.files[cand]; ok { return []string{cand} } @@ -313,6 +320,9 @@ func (r *resolver) fileScope(f *fileAnalysis) scope { maps.Copy(sc.syms, r.goPkgs[fileGoPackage(f)]) } sc.addDeclarations(f) + if f.a.Lang == "python" { + return sc + } for _, imp := range f.a.Imports { mid := r.moduleID(f, imp) switch imp.Kind { @@ -415,6 +425,9 @@ func (r *resolver) resolveCall(f *fileAnalysis, sc scope, c Call) (string, strin if f.a.Lang == "ruby" { return r.resolveRubyCall(f, c) } + if f.a.Lang == "python" { + return r.resolvePythonCall(f, sc, c) + } if c.Receiver == "" { if sid, shadowed := lexical(f, sc, c.In, c.Name, c.Start); sid != "" { return sid, ConfExtracted @@ -527,15 +540,8 @@ func lexical(f *fileAnalysis, sc scope, in int, name string, pos uint32) (id str return "", true } } - if at < 0 || at == in || decls[at].Kind != KindClass { - for _, ci := range sc.children[at] { - if f.a.Lang == "go" && at >= 0 && decls[ci].Kind != KindFunc { - continue - } - if decls[ci].Name == name { - return decls[ci].symID(f.path), false - } - } + if sid := lexicalDeclaration(f, sc, in, at, name); sid != "" { + return sid, false } if at < 0 { return "", false @@ -547,6 +553,24 @@ func lexical(f *fileAnalysis, sc scope, in int, name string, pos uint32) (id str } } +func lexicalDeclaration(f *fileAnalysis, sc scope, in, at int, name string) string { + if at < 0 && f.a.Lang == "python" { + return "" + } + if at >= 0 && at != in && f.a.Decls[at].Kind == KindClass { + return "" + } + for _, ci := range sc.children[at] { + if f.a.Lang == "go" && at >= 0 && f.a.Decls[ci].Kind != KindFunc { + continue + } + if f.a.Decls[ci].Name == name { + return f.a.Decls[ci].symID(f.path) + } + } + return "" +} + func goVersionSuffix(s string) bool { if len(s) < 2 || s[0] != 'v' { return false From f7418194629ed8931352dd881996771ecebcdfab Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Mon, 5 Oct 2026 21:19:24 +0200 Subject: [PATCH 08/11] Resolve loaded Ruby singleton and module-function calls --- analyse.go | 32 +++-- build.go | 2 +- resolve.go | 74 ++--------- ruby_resolve.go | 291 +++++++++++++++++++++++++++++++++++++++++++ ruby_resolve_test.go | 257 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 579 insertions(+), 77 deletions(-) create mode 100644 ruby_resolve.go create mode 100644 ruby_resolve_test.go diff --git a/analyse.go b/analyse.go index 43c2164..9d92e63 100644 --- a/analyse.go +++ b/analyse.go @@ -10,19 +10,20 @@ import ( // retains the full definition span and nesting so callers can compute node // identities and containment before the tree is released. type decl struct { - Name string - Kind string - Line int - Exported bool - NameAt uint32 - Start uint32 - End uint32 - SigEnd uint32 - Parent int - Params []string - Singleton bool - Method bool - Owner string + Name string + Kind string + Line int + Exported bool + NameAt uint32 + Start uint32 + End uint32 + SigEnd uint32 + Parent int + Params []string + Singleton bool + Method bool + ModuleFunction bool + Owner string } func (d decl) symID(path string) string { @@ -40,6 +41,7 @@ type analysis struct { SyntaxErrors bool PyImports []pythonImport PyExports *pythonExports + RubyLoads []Import } // analyse parses src once and returns every fact the graph builder needs @@ -73,6 +75,10 @@ func analyse(src []byte, filename string) (*analysis, bool) { a.PyImports = pythonImportFacts(src, l, root, a.Decls) a.PyExports = pythonExportFacts(src, l, root) } + if l.name == "ruby" { + a.RubyLoads = rubyLoadFacts(src, l, root) + rubyModuleFunctions(a, src, l, root) + } return a, true } diff --git a/build.go b/build.go index 47ac5cd..f0c5107 100644 --- a/build.go +++ b/build.go @@ -242,7 +242,7 @@ func rubyQualified(decls []decl, i int) string { return d.Name } separator := "#" - if d.Singleton { + if d.Singleton || d.ModuleFunction { separator = "." } return strings.Join(owner, "::") + separator + d.Name diff --git a/resolve.go b/resolve.go index ee9ee6e..5777139 100644 --- a/resolve.go +++ b/resolve.go @@ -16,9 +16,10 @@ type resolver struct { paths []string files map[string]*fileAnalysis - goModule string - goPkgs map[goPackage]map[string]string - pyRoots []string + goModule string + goPkgs map[goPackage]map[string]string + pyRoots []string + rubyScopes map[string]rubyScope // modules maps a mod: ID to the repository-relative files that // implement it, once resolved. @@ -41,11 +42,12 @@ type scope struct { func newResolver(root string, files []fileAnalysis, hints ResolutionHints) *resolver { r := &resolver{ - root: root, - hints: hints, - files: make(map[string]*fileAnalysis, len(files)), - modules: make(map[string][]string), - exports: make(map[string]map[string]string), + root: root, + hints: hints, + files: make(map[string]*fileAnalysis, len(files)), + modules: make(map[string][]string), + exports: make(map[string]map[string]string), + rubyScopes: make(map[string]rubyScope), } for i := range files { r.paths = append(r.paths, files[i].path) @@ -451,24 +453,6 @@ func (r *resolver) resolveCall(f *fileAnalysis, sc scope, c Call) (string, strin return ExtID(f.a.Lang, c.Receiver, c.Name), ConfInferred } -func (r *resolver) resolveRubyCall(f *fileAnalysis, c Call) (string, string) { - switch c.ReceiverKind { - case ReceiverBare, ReceiverSelf: - owner, singleton := rubyCallContext(f.a.Decls, c.In) - if sid := rubyMethod(f, owner, c.Name, singleton); sid != "" { - return sid, ConfExtracted - } - case ReceiverConstant: - receiver := strings.TrimPrefix(c.Receiver, "::") - if sid, local := rubySingletonMethod(f, receiver, c.Name); sid != "" { - return sid, ConfExtracted - } else if local { - return ExtID("ruby", "local:"+receiver, c.Name), ConfInferred - } - } - return ExtID("ruby", c.Receiver, c.Name), ConfInferred -} - func rubyCallContext(decls []decl, in int) (owner int, singleton bool) { owner = -1 if in < 0 { @@ -476,7 +460,7 @@ func rubyCallContext(decls []decl, in int) (owner int, singleton bool) { } switch decls[in].Kind { case KindFunc: - singleton = decls[in].Singleton + singleton = decls[in].Singleton || decls[in].ModuleFunction case KindClass, KindType: singleton = true } @@ -488,42 +472,6 @@ func rubyCallContext(decls []decl, in int) (owner int, singleton bool) { return owner, singleton } -func rubyMethod(f *fileAnalysis, owner int, name string, singleton bool) string { - for i, d := range f.a.Decls { - if d.Parent == owner && d.Kind == KindFunc && d.Name == name && d.Singleton == singleton { - return f.a.Decls[i].symID(f.path) - } - } - return "" -} - -func rubySingletonMethod(f *fileAnalysis, receiver, name string) (target string, local bool) { - for i, d := range f.a.Decls { - if d.Kind == KindFunc && d.Singleton && d.Owner == receiver && d.Name == name { - if target != "" { - return "", true - } - target = d.symID(f.path) - local = true - continue - } - if d.Kind != KindClass && d.Kind != KindType { - continue - } - if d.Name != receiver && rubyQualified(f.a.Decls, i) != receiver { - continue - } - local = true - if sid := rubyMethod(f, i, name, true); sid != "" { - if target != "" { - return "", true - } - target = sid - } - } - return target, local -} - // lexical walks outward from the enclosing declaration, returning the // innermost visible decl matching name. If a parameter of an enclosing // function matches first, it reports shadowed instead. Class bodies do diff --git a/ruby_resolve.go b/ruby_resolve.go new file mode 100644 index 0000000..5b235a2 --- /dev/null +++ b/ruby_resolve.go @@ -0,0 +1,291 @@ +package outline + +import ( + "sort" + "strings" + + ts "github.com/odvcencio/gotreesitter" +) + +const rubyModuleFunction = "module_function" + +type rubyMethodKey struct { + owner, name string + singleton bool +} + +type rubyScope struct { + methods map[rubyMethodKey]string + constants map[string]string + copies map[string]bool +} + +func rubyLoadFacts(src []byte, l *lang, root *ts.Node) []Import { + var loads []Import + walkNamed(root, func(node *ts.Node) { + imp, ok := rubyImport(src, l.language, node) + if !ok || !rubyImmediateLoad(node, l.language) { + return + } + loads = append(loads, imp) + }) + return loads +} + +func rubyImmediateLoad(node *ts.Node, language *ts.Language) bool { + for parent := node.Parent(); parent != nil; parent = parent.Parent() { + switch parent.Type(language) { + case "program", "body_statement", "module", "class", "singleton_class": + default: + return false + } + } + return true +} + +type rubyMode struct { + In int + Start uint32 + Enabled bool +} + +func rubyModeFacts(a *analysis, src []byte, l *lang, root *ts.Node) []rubyMode { + var modes []rubyMode + add := func(name string, start uint32, in int) { + if name == rubyModuleFunction || name == "public" || name == "private" || name == "protected" { + modes = append(modes, rubyMode{in, start, name == rubyModuleFunction}) + } + } + for _, c := range a.Calls { + if len(c.Arguments) == 0 && (c.ReceiverKind == ReceiverBare || c.ReceiverKind == ReceiverSelf) { + add(c.Name, c.Start, c.In) + } + } + walkNamed(root, func(node *ts.Node) { + if node.Type(l.language) == "identifier" && node.Parent() != nil && node.Parent().Type(l.language) == "body_statement" { + add(node.Text(src), node.StartByte(), enclosing(a.Decls, node.StartByte())) + } + }) + sort.Slice(modes, func(i, j int) bool { return modes[i].Start < modes[j].Start }) + return modes +} + +func rubyModuleFunctions(a *analysis, src []byte, l *lang, root *ts.Node) { + modes := rubyModeFacts(a, src, l, root) + for i := range a.Decls { + d := &a.Decls[i] + if d.Kind != KindFunc || d.Singleton || d.Parent < 0 || a.Decls[d.Parent].Kind != KindType { + continue + } + if rubyCustomModuleFunction(a, d.Parent) { + continue + } + mode := false + for _, event := range modes { + if event.In == d.Parent && event.Start < d.Start { + mode = event.Enabled + } + } + d.ModuleFunction = mode || rubyNamedModuleCopy(a, *d) + } +} + +func rubyCustomModuleFunction(a *analysis, owner int) bool { + name := rubyQualified(a.Decls, owner) + for _, d := range a.Decls { + if d.Singleton && d.Name == rubyModuleFunction && (d.Parent == owner || d.Owner == name) { + return true + } + } + return false +} + +func rubyNamedModuleCopy(a *analysis, d decl) bool { + for _, c := range a.Calls { + if c.Name != rubyModuleFunction || c.In != d.Parent || d.End > c.Start { + continue + } + if c.ReceiverKind != ReceiverBare && c.ReceiverKind != ReceiverSelf { + continue + } + for _, arg := range c.Arguments { + if rubyLiteralMethod(arg) == d.Name { + return true + } + } + } + return false +} + +func rubyLiteralMethod(arg Argument) string { + switch arg.Kind { + case "simple_symbol": + return strings.TrimPrefix(arg.Text, ":") + case "string", "delimited_symbol": + text := strings.TrimPrefix(arg.Text, ":") + if !strings.ContainsAny(text, "\\#\n\r") { + return sourceString(text) + } + } + return "" +} + +func (r *resolver) rubyFiles(f *fileAnalysis) []*fileAnalysis { + var files []*fileAnalysis + queue := []string{f.path} + seen := make(map[string]bool) + for len(queue) > 0 { + p := queue[0] + queue = queue[1:] + if seen[p] { + continue + } + seen[p] = true + loaded := r.files[p] + if loaded == nil || loaded.a == nil || loaded.a.Lang != "ruby" { + continue + } + files = append(files, loaded) + for _, imp := range loaded.a.RubyLoads { + queue = append(queue, r.rubyModuleFiles(imp, p)...) + } + } + return files +} + +func (r *resolver) rubyFileScope(f *fileAnalysis) rubyScope { + if sc, ok := r.rubyScopes[f.path]; ok { + return sc + } + sc := rubyScope{ + methods: make(map[rubyMethodKey]string), constants: make(map[string]string), copies: make(map[string]bool), + } + files := r.rubyFiles(f) + for _, loaded := range files { + for i, d := range loaded.a.Decls { + if d.Kind != KindClass && d.Kind != KindType && d.Kind != KindConst { + continue + } + name := strings.TrimPrefix(rubyQualified(loaded.a.Decls, i), "::") + previous, exists := sc.constants[name] + if exists && previous != d.Kind { + sc.constants[name] = "" + } else if !exists { + sc.constants[name] = d.Kind + } + } + } + for _, loaded := range files { + rubyIndexMethods(sc, loaded) + } + r.rubyScopes[f.path] = sc + return sc +} + +func rubyIndexMethods(sc rubyScope, f *fileAnalysis) { + for i, d := range f.a.Decls { + if d.Kind != KindFunc { + continue + } + owner, _ := rubyCallContext(f.a.Decls, i) + name := "" + if owner >= 0 { + name = strings.TrimPrefix(rubyQualified(f.a.Decls, owner), "::") + } + if d.Singleton && d.Owner != "" && d.Owner != "self" { + name, _ = rubyReceiverOwner(sc, f.a.Decls, d.Parent, d.Owner) + } + id := d.symID(f.path) + keys := []rubyMethodKey{{name, d.Name, d.Singleton}} + if d.ModuleFunction { + keys = append(keys, rubyMethodKey{name, d.Name, true}) + sc.copies[id] = true + } + for _, key := range keys { + if previous, exists := sc.methods[key]; exists && previous != id { + sc.methods[key] = "" + } else if !exists { + sc.methods[key] = id + } + } + } +} + +func rubyReceiverOwner(sc rubyScope, decls []decl, in int, receiver string) (string, bool) { + name := strings.TrimPrefix(receiver, "::") + candidates := []string{name} + if !strings.HasPrefix(receiver, "::") { + candidates = nil + for at := in; at >= 0; at = decls[at].Parent { + if decls[at].Kind == KindClass || decls[at].Kind == KindType { + candidates = append(candidates, rubyQualified(decls, at)+"::"+name) + } + } + candidates = append(candidates, name) + } + for _, candidate := range candidates { + if _, exists := sc.constants[candidate]; exists { + return candidate, true + } + head, _, _ := strings.Cut(name, "::") + head = strings.TrimSuffix(candidate, name) + head + if _, exists := sc.constants[head]; exists { + return candidate, true + } + for key := range sc.methods { + if key.owner == candidate { + return candidate, true + } + } + } + return name, false +} + +func rubyBlockedOwner(sc rubyScope, owner string) bool { + parts := strings.Split(owner, "::") + for i := range parts { + if kind, exists := sc.constants[strings.Join(parts[:i+1], "::")]; exists && (kind == "" || kind == KindConst) { + return true + } + } + return false +} + +func (r *resolver) resolveRubyCall(f *fileAnalysis, c Call) (string, string) { + sc := r.rubyFileScope(f) + owner := "" + singleton := false + local := false + switch c.ReceiverKind { + case ReceiverBare, ReceiverSelf: + at, classMethod := rubyCallContext(f.a.Decls, c.In) + singleton = classMethod + if at >= 0 { + owner = rubyQualified(f.a.Decls, at) + } + if c.In >= 0 { + d := f.a.Decls[c.In] + if d.Singleton && d.Owner != "" && d.Owner != "self" { + owner, _ = rubyReceiverOwner(sc, f.a.Decls, d.Parent, d.Owner) + } + } + case ReceiverConstant: + owner, local = rubyReceiverOwner(sc, f.a.Decls, c.In, c.Receiver) + singleton = true + default: + return ExtID("ruby", c.Receiver, c.Name), ConfInferred + } + if !rubyBlockedOwner(sc, owner) { + if id := sc.methods[rubyMethodKey{owner, c.Name, singleton}]; id != "" { + conf := ConfInferred + if strings.HasPrefix(id, "sym:"+idEscape(f.path)+":") && !sc.copies[id] { + conf = ConfExtracted + } + return id, conf + } + } + if local { + return ExtID("ruby", "local:"+owner, c.Name), ConfInferred + } + return ExtID("ruby", c.Receiver, c.Name), ConfInferred +} diff --git a/ruby_resolve_test.go b/ruby_resolve_test.go new file mode 100644 index 0000000..d76bb0f --- /dev/null +++ b/ruby_resolve_test.go @@ -0,0 +1,257 @@ +package outline + +import "testing" + +func TestBuildRubyLoadedSingletons(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{ + "app.rb": "require_relative 'lib/bridge'\ndef entry\n Helper.ready\n Other.ready\nend\n", + "lib/bridge.rb": "require_relative 'helper'\nrequire_relative '../app'\n", + "lib/helper.rb": "module Helper\n def self.ready\n File.read('config')\n end\nend\n", + "unloaded.rb": "module Other\n def self.ready\n end\nend\n", + }) + g, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + entry := nodeByQualified(g, "entry") + ready := nodeByQualified(g, "Helper.ready") + if ready == nil || ready.Kind != KindFunc { + t.Fatalf("missing singleton definition: %+v", ready) + } + if e := edge(g, entry.ID, ready.ID, RelCalls); e == nil || e.Conf != ConfInferred { + t.Fatalf("loaded singleton target missing: %v", g.Callees(entry.ID)) + } + if edge(g, entry.ID, ExtID("ruby", "Other", "ready"), RelCalls) == nil { + t.Error("unloaded source was used as a target") + } + if path := g.Path(entry.ID, ExtID("ruby", "File", "read"), TraverseOptions{IncludeInferred: true}); len(path) != 2 { + t.Fatalf("loaded source path=%v", path) + } +} + +func TestBuildRubyModuleFunctions(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{ + "app.rb": "require_relative 'utils'\ndef entry\n Rack::Utils.escape('a b')\n Named.run\n Named.other\nend\n", + "utils.rb": `module Rack + module Utils + module_function + def escape(value) + encode(value) + end + def encode(value) + URI.encode_www_form_component(value) + end + end +end +module Named + def run + end + module_function :run + def other + end +end +`, + }) + g, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + entry := nodeByQualified(g, "entry") + for _, name := range []string{"Rack::Utils.escape", "Named.run"} { + target := nodeByQualified(g, name) + if target == nil || target.Kind != KindFunc || edge(g, entry.ID, target.ID, RelCalls) == nil { + t.Errorf("module function %s did not resolve", name) + } + } + escape := nodeByQualified(g, "Rack::Utils.escape") + encode := nodeByQualified(g, "Rack::Utils.encode") + if escape != nil && encode != nil && edge(g, escape.ID, encode.ID, RelCalls) == nil { + t.Error("module-function body did not call the copied module method") + } + if edge(g, entry.ID, ExtID("ruby", "local:Named", "other"), RelCalls) == nil { + t.Error("named module_function incorrectly applied to subsequent definitions") + } +} + +func TestBuildRubyConflictingLoadedMethods(t *testing.T) { + for _, c := range []struct{ name, extra string }{ + {"duplicate", "module Helper\n def self.ready\n end\nend\n"}, + {"assignment", "Helper = Object.new\n"}, + } { + t.Run(c.name, func(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{ + "app.rb": "require_relative 'one'\nrequire_relative 'two'\ndef entry\n Helper.ready\nend\n", + "one.rb": "module Helper\n def self.ready\n end\nend\n", + "two.rb": c.extra, + }) + g, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + entry := nodeByQualified(g, "entry") + calls := g.Callees(entry.ID) + if len(calls) != 1 || g.Node(calls[0].To).Kind != KindExternal { + t.Fatalf("conflicting definitions selected one target: %v", calls) + } + }) + } +} + +func TestBuildRubyScopedConstants(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{ + "app.rb": `module Other + module Helper + def self.ready + end + end +end +def entry + Helper.ready +end +`, + }) + g, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + entry := nodeByQualified(g, "entry") + if calls := g.Callees(entry.ID); len(calls) != 1 || g.Node(calls[0].To).Kind != KindExternal { + t.Fatalf("unqualified constant bound to unrelated nested owner: %v", calls) + } +} + +func TestBuildRubyModuleFunctionBoundaries(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{ + "app.rb": `module M + module_function + def copied + end + public + def instance + end + module Nested + def instance + end + end +end +module Named + def run + File.read('old') + end + module_function :run + def run + File.read('new') + end +end +def entry + M.copied + M.instance + M::Nested.instance + Named.run +end +`, + }) + g, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + entry := nodeByQualified(g, "entry") + for _, name := range []string{"M.copied", "Named.run"} { + fn := nodeByQualified(g, name) + if fn == nil || fn.Kind != KindFunc || edge(g, entry.ID, fn.ID, RelCalls) == nil { + t.Errorf("copied method %s missing", name) + } + } + for _, owner := range []string{"M", "M::Nested"} { + if edge(g, entry.ID, ExtID("ruby", "local:"+owner, "instance"), RelCalls) == nil { + t.Errorf("module-function mode leaked into %s", owner) + } + } + run := nodeByQualified(g, "Named.run") + if run != nil { + calls := g.Callees(run.ID) + if len(calls) != 1 || calls[0].Call.Arguments[0].Text != "'old'" { + t.Errorf("named copy changed after instance method redefinition: %v", calls) + } + } +} + +func TestBuildRubyDeferredLoadsRemainUnknown(t *testing.T) { + for _, c := range []struct{ name, load string }{ + {"function", "def setup\n require_relative 'helper'\nend\n"}, + {"conditional", "if enabled\n require_relative 'helper'\nend\n"}, + } { + t.Run(c.name, func(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{ + "app.rb": c.load + "def entry\n Helper.ready\nend\n", + "helper.rb": "module Helper\n def self.ready\n end\nend\n", + }) + g, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + entry := nodeByQualified(g, "entry") + if edge(g, entry.ID, ExtID("ruby", "Helper", "ready"), RelCalls) == nil { + t.Fatalf("deferred source load produced a resolved target: %v", g.Callees(entry.ID)) + } + }) + } +} + +func TestBuildRubyQualifiedConstantShadow(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{ + "app.rb": `module Helper + module Nested + def self.ready + end + end +end +module Outer + Helper = Object.new + def self.entry + Helper::Nested.ready + end +end +`, + }) + g, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + entry := nodeByQualified(g, "Outer.entry") + if calls := g.Callees(entry.ID); len(calls) != 1 || g.Node(calls[0].To).Kind != KindExternal { + t.Fatalf("qualified constant bypassed a local shadow: %v", calls) + } +} + +func TestBuildRubyCustomModuleFunctionRemainsUnknown(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{ + "app.rb": `module Custom + def self.module_function(*names) + end + module_function + def run + end +end +def entry + Custom.run +end +`, + }) + g, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + entry := nodeByQualified(g, "entry") + if edge(g, entry.ID, ExtID("ruby", "local:Custom", "run"), RelCalls) == nil { + t.Fatalf("custom module_function call inferred a native copy: %v", g.Callees(entry.ID)) + } +} From 7607508519fdca3942a5a560c1957e704738730b Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Mon, 5 Oct 2026 22:01:56 +0200 Subject: [PATCH 09/11] Update graph query examples and Ruby resolution documentation --- README.md | 13 +++++-------- cmd/outline/main_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 9cceed8..c605bd6 100644 --- a/README.md +++ b/README.md @@ -106,10 +106,11 @@ ID, bare name, or qualified name; `Callers`/`Callees` return one-hop call edges; sinks; `Path(from, to, opts)` returns the shortest forward call chain; `JSON(w)` writes sorted output so repeated builds of unchanged input are byte-identical. Go and Python include cross-file call resolution. Ruby includes -direct calls, same-file calls within the same class or module, distinct instance -and singleton method names, unresolved dynamic receivers, subshells, and local -`require_relative` load edges. Other supported languages contain file, symbol, -module, and import structure with call edges omitted. +direct calls within the same class or module and follows literal +`require_relative` chains to singleton and `module_function` bodies in loaded +files. It retains distinct instance and singleton method names, unresolved +dynamic receivers, subshells, and local load edges. Other supported languages +contain file, symbol, module, and import structure with call edges omitted. `Tree(paths []string) string` renders a box-drawing directory tree from a flat path list. @@ -148,11 +149,7 @@ $ outline affected -inferred -dir testdata/cli-go ext:go:os/exec:Command NODE Handler func main.go:9 exported=true sig=func Handler(name string) error NODE os/exec.Command external exported=false sig= NODE main func main.go:17 exported=false sig=func main() -EDGE Handler --calls[inferred]--> Run at main.go:14 -NODE Run external exported=false sig= EDGE Handler --calls[inferred]--> os/exec.Command at main.go:14 -EDGE Handler --calls[inferred]--> Load at main.go:10 -NODE Load func store/store.go:7 exported=true sig=func Load(name string) (Record, error) EDGE main --calls[extracted]--> Handler at main.go:18 ``` diff --git a/cmd/outline/main_test.go b/cmd/outline/main_test.go index 6b4858e..3bbcce8 100644 --- a/cmd/outline/main_test.go +++ b/cmd/outline/main_test.go @@ -70,6 +70,32 @@ func TestCLIGoAffected(t *testing.T) { } } +func TestCLIReadmeAffectedExample(t *testing.T) { + repo := filepath.Join("..", "..") + readme, err := os.ReadFile(filepath.Join(repo, "README.md")) + if err != nil { + t.Fatal(err) + } + command := "affected -inferred -dir testdata/cli-go ext:go:os/exec:Command" + _, after, found := strings.Cut(string(readme), "$ outline "+command+"\n") + if !found { + t.Fatal("README affected example missing") + } + want, _, found := strings.Cut(after, "```") + if !found { + t.Fatal("README example fence missing") + } + cmd := exec.Command(binPath, strings.Fields(command)...) + cmd.Dir = repo + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("README command failed: %v\n%s", err, out) + } + if string(out) != want { + t.Fatalf("README example differs from CLI output:\n%s\nwant:\n%s", out, want) + } +} + func TestCLIPythonAffected(t *testing.T) { dir := testdata("cli-py") From 1dc2a31b0a02e942a7f41c23fd1232e5ad59f374 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Mon, 5 Oct 2026 22:04:15 +0200 Subject: [PATCH 10/11] Share Ruby method indexes for equivalent load closures --- ruby_resolve.go | 18 ++++++++++- ruby_scope_test.go | 77 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 ruby_scope_test.go diff --git a/ruby_resolve.go b/ruby_resolve.go index 5b235a2..b6fad66 100644 --- a/ruby_resolve.go +++ b/ruby_resolve.go @@ -157,10 +157,15 @@ func (r *resolver) rubyFileScope(f *fileAnalysis) rubyScope { if sc, ok := r.rubyScopes[f.path]; ok { return sc } + files := r.rubyFiles(f) + key := rubyScopeKey(files) + if sc, ok := r.rubyScopes[key]; ok { + r.rubyScopes[f.path] = sc + return sc + } sc := rubyScope{ methods: make(map[rubyMethodKey]string), constants: make(map[string]string), copies: make(map[string]bool), } - files := r.rubyFiles(f) for _, loaded := range files { for i, d := range loaded.a.Decls { if d.Kind != KindClass && d.Kind != KindType && d.Kind != KindConst { @@ -179,9 +184,20 @@ func (r *resolver) rubyFileScope(f *fileAnalysis) rubyScope { rubyIndexMethods(sc, loaded) } r.rubyScopes[f.path] = sc + r.rubyScopes[key] = sc return sc } +func rubyScopeKey(files []*fileAnalysis) string { + sort.Slice(files, func(i, j int) bool { return files[i].path < files[j].path }) + paths := make([]string, len(files)) + for i, f := range files { + paths[i] = f.path + } + // NUL cannot occur in a file path. + return "\x00" + strings.Join(paths, "\x00") +} + func rubyIndexMethods(sc rubyScope, f *fileAnalysis) { for i, d := range f.a.Decls { if d.Kind != KindFunc { diff --git a/ruby_scope_test.go b/ruby_scope_test.go new file mode 100644 index 0000000..7d5fba0 --- /dev/null +++ b/ruby_scope_test.go @@ -0,0 +1,77 @@ +package outline + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestBuildRubyEquivalentLoadClosures(t *testing.T) { + for _, conflict := range []bool{false, true} { + t.Run(fmt.Sprintf("conflict=%t", conflict), func(t *testing.T) { + root := t.TempDir() + files := map[string]string{ + "barrel.rb": "require_relative 'a'\nrequire_relative 'b'\nrequire_relative 'helper'\n", + "a.rb": "require_relative 'barrel'\ndef entry_a\n Helper.ready\nend\n", + "b.rb": "require_relative 'barrel'\ndef entry_b\n Helper.ready\nend\n", + "helper.rb": "require_relative 'barrel'\nmodule Helper\n def self.ready\n File.read('config')\n end\nend\n", + "alone.rb": "def entry_alone\n Helper.ready\nend\n", + } + if conflict { + files["barrel.rb"] += "require_relative 'conflict'\n" + files["conflict.rb"] = "require_relative 'barrel'\nmodule Helper\n def self.ready\n end\nend\n" + } + writeFiles(t, root, files) + g, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"entry_a", "entry_b", "entry_alone"} { + entry := nodeByQualified(g, name) + calls := g.Callees(entry.ID) + if len(calls) != 1 { + t.Fatalf("%s calls=%v", name, calls) + } + target := g.Node(calls[0].To) + if conflict || name == "entry_alone" { + if target.Kind != KindExternal { + t.Fatalf("%s selected conflicting or unloaded source: %+v", name, target) + } + } else if target.File != "helper.rb" || target.Qualified != "Helper.ready" { + t.Fatalf("equivalent load closure lost %s target: %+v", name, target) + } + } + }) + } +} + +func BenchmarkBuildRubySharedLoadClosure(b *testing.B) { + const files, methods = 64, 20 + root := b.TempDir() + var barrel strings.Builder + for i := range files { + name := fmt.Sprintf("worker_%03d.rb", i) + fmt.Fprintf(&barrel, "require_relative '%s'\n", strings.TrimSuffix(name, ".rb")) + var source strings.Builder + fmt.Fprintf(&source, "require_relative 'barrel'\nmodule Worker%d\n", i) + for method := range methods { + fmt.Fprintf(&source, " def self.method%d\n File.read('config')\n end\n", method) + } + source.WriteString("end\n") + if err := os.WriteFile(filepath.Join(root, name), []byte(source.String()), 0o600); err != nil { + b.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(root, "barrel.rb"), []byte(barrel.String()), 0o600); err != nil { + b.Fatal(err) + } + b.ReportAllocs() + b.ResetTimer() + for range b.N { + if _, err := Build(root, Options{}); err != nil { + b.Fatal(err) + } + } +} From 4a1605449ac56907de7460173e8aaf2066d83ff1 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Thu, 8 Oct 2026 07:40:15 +0200 Subject: [PATCH 11/11] Fix Python local binding order and conditional Ruby method copies --- analyse.go | 28 +++++----- binding.go | 13 ++++- python_resolve.go | 97 +++++++++++++++++++++++++------- python_resolve_test.go | 122 +++++++++++++++++++++++++++++++++++++++++ ruby_resolve.go | 44 +++++++++++---- ruby_resolve_test.go | 28 ++++++++++ 6 files changed, 283 insertions(+), 49 deletions(-) diff --git a/analyse.go b/analyse.go index 9d92e63..c4ace6b 100644 --- a/analyse.go +++ b/analyse.go @@ -23,6 +23,7 @@ type decl struct { Singleton bool Method bool ModuleFunction bool + Conditional bool Owner string } @@ -159,19 +160,20 @@ func declsFromMatch(src []byte, l *lang, m ts.QueryMatch) []decl { continue } out = append(out, decl{ - Name: name, - Kind: normalizeSymbolKind(l.name, kind, name, definition, src, l.language), - Line: int(n.StartPoint().Row) + 1, - Exported: exported || symbolExported(l.name, name, definition, src, l.language), - NameAt: n.StartByte(), - Start: start, - End: end, - SigEnd: sigEnd, - Parent: -1, - Params: params, - Singleton: singleton, - Method: method, - Owner: owner, + Name: name, + Kind: normalizeSymbolKind(l.name, kind, name, definition, src, l.language), + Line: int(n.StartPoint().Row) + 1, + Exported: exported || symbolExported(l.name, name, definition, src, l.language), + NameAt: n.StartByte(), + Start: start, + End: end, + SigEnd: sigEnd, + Parent: -1, + Params: params, + Singleton: singleton, + Method: method, + Owner: owner, + Conditional: l.name == "python" && pythonConditional(definition, l.language), }) } return out diff --git a/binding.go b/binding.go index fb444a2..6155ca8 100644 --- a/binding.go +++ b/binding.go @@ -2,11 +2,14 @@ package outline import ts "github.com/odvcencio/gotreesitter" +const forStatement = "for_statement" + type binding struct { Name string In int Start uint32 End uint32 + At uint32 Decl int } @@ -22,9 +25,13 @@ func bindingsFor(src []byte, l *lang, root *ts.Node, decls []decl) []binding { } in := enclosing(decls, scope.StartByte()) target := bindingDeclaration(l, node, decls) + at := node.EndByte() + if l.name == "python" && node.Type(l.language) == forStatement { + at = node.StartByte() + } for _, name := range names { if l.name != "go" || name != "_" { - out = append(out, binding{Name: name, In: in, Start: start, End: scope.EndByte(), Decl: target}) + out = append(out, binding{Name: name, In: in, Start: start, End: scope.EndByte(), At: at, Decl: target}) } } }) @@ -40,7 +47,7 @@ func bindingNode(src []byte, l *lang, node *ts.Node) ([]string, *ts.Node, uint32 } case "python": switch node.Type(l.language) { - case "assignment", "augmented_assignment", "for_statement": + case "assignment", "augmented_assignment", forStatement: names := bindingNames(src, l.language, node.ChildByFieldName("left", l.language)) if len(names) > 0 { if scope := pythonBindingScope(node, l.language); scope != nil { @@ -107,7 +114,7 @@ func goBindingNames(src []byte, language *ts.Language, node *ts.Node) []string { func goBindingScope(node *ts.Node, language *ts.Language) *ts.Node { for parent := node.Parent(); parent != nil; parent = parent.Parent() { switch parent.Type(language) { - case "block", "if_statement", "for_statement", "expression_switch_statement", "type_switch_statement", "communication_case", "expression_case", "type_case": + case "block", "if_statement", forStatement, "expression_switch_statement", "type_switch_statement", "communication_case", "expression_case", "type_case": return parent } } diff --git a/python_resolve.go b/python_resolve.go index 01f41d9..31772c1 100644 --- a/python_resolve.go +++ b/python_resolve.go @@ -15,6 +15,7 @@ type pythonImport struct { In int End uint32 BoundModule string + Conditional bool } type pythonTarget struct { @@ -112,12 +113,26 @@ func pythonImportFacts(src []byte, l *lang, root *ts.Node, decls []decl) []pytho } facts = append(facts, pythonImport{ Import: imp, In: enclosing(decls, node.StartByte()), End: node.EndByte(), BoundModule: bound, + Conditional: pythonConditional(node, l.language), }) } }) return facts } +func pythonConditional(node *ts.Node, language *ts.Language) bool { + for parent := node.Parent(); parent != nil; parent = parent.Parent() { + switch parent.Type(language) { + case "function_definition", "class_definition", "module": + return false + case "block", "decorated_definition": + default: + return true + } + } + return false +} + func (r *resolver) pyLookup(module, from, name string, seen map[pythonLookup]bool) pythonBinding { files := r.pyModuleFiles(module, from) if len(files) != 1 || r.files[files[0]].a == nil { @@ -273,25 +288,69 @@ func (r *resolver) pyMember(target pythonTarget, name string, seen map[pythonLoo return pythonTarget{} } -func (r *resolver) pyCallBinding(f *fileAnalysis, c Call, name string, seen map[pythonLookup]bool) pythonBinding { +type pythonScopeBinding struct { + pythonBinding + at uint32 + deferred bool +} + +func (b *pythonScopeBinding) assign(target pythonTarget, at, pos uint32, conditional bool) { + // Enclosing scopes can change before a nested body runs. + if b.deferred { + if conditional { + target = pythonTarget{} + } + b.add(target) + return + } + b.found = true + if at > pos || at < b.at { + return + } + if target.module != "" && target.module == b.target.module && target.from == b.target.from && !b.uncertain { + for _, loaded := range b.target.loaded { + if !slices.Contains(target.loaded, loaded) { + target.loaded = append(target.loaded, loaded) + } + } + } + b.at = at + b.target = target + b.uncertain = conditional +} + +func (r *resolver) pyLocalBinding(f *fileAnalysis, sc scope, c Call, at int, name string, seen map[pythonLookup]bool) pythonBinding { + result := pythonScopeBinding{deferred: at != c.In} + result.found = slices.Contains(f.a.Decls[at].Params, name) + for _, b := range sc.bindings[at] { + if b.Name == name && b.Start <= c.Start && c.Start < b.End { + result.assign(pythonTarget{}, b.At, c.Start, false) + } + } + for _, ci := range sc.children[at] { + d := f.a.Decls[ci] + if d.Name != name { + continue + } + result.assign(pythonTarget{id: d.symID(f.path)}, d.End, c.Start, d.Conditional) + } + for _, imp := range f.a.PyImports { + if imp.In != at { + continue + } + if binding := r.pyImportName(f, imp, name, seen); binding.found { + result.assign(binding.resolved(), imp.End, c.Start, imp.Conditional) + } + } + return result.pythonBinding +} + +func (r *resolver) pyCallBinding(f *fileAnalysis, sc scope, c Call, name string, seen map[pythonLookup]bool) pythonBinding { for at := c.In; at >= 0; at = f.a.Decls[at].Parent { if at != c.In && f.a.Decls[at].Kind == KindClass { continue } - var result pythonBinding - for _, imp := range f.a.PyImports { - if imp.In != at { - continue - } - if binding := r.pyImportName(f, imp, name, seen); binding.found { - if c.Start < imp.End { - result.add(pythonTarget{}) - } else { - result.add(binding.resolved()) - } - } - } - if result.found { + if result := r.pyLocalBinding(f, sc, c, at, name, seen); result.found { return result } } @@ -303,15 +362,11 @@ func (r *resolver) resolvePythonCall(f *fileAnalysis, sc scope, c Call) (string, if c.Receiver != "" { name, _, _ = strings.Cut(c.Receiver, ".") } - sid, shadowed := lexical(f, sc, c.In, name, c.Start) - if sid != "" && c.Receiver == "" { - return sid, ConfExtracted - } - if sid != "" || shadowed || c.ReceiverKind == ReceiverExpression { + if c.ReceiverKind == ReceiverExpression { return ExtID("python", c.Receiver, c.Name), ConfInferred } seen := make(map[pythonLookup]bool) - target := r.pyCallBinding(f, c, name, seen).resolved() + target := r.pyCallBinding(f, sc, c, name, seen).resolved() if c.Receiver != "" { for _, member := range strings.Split(c.Receiver, ".")[1:] { target = r.pyMember(target, member, seen) diff --git a/python_resolve_test.go b/python_resolve_test.go index 9fc7f71..6d3052f 100644 --- a/python_resolve_test.go +++ b/python_resolve_test.go @@ -2,6 +2,128 @@ package outline import "testing" +func TestBuildPythonInnerImports(t *testing.T) { + for _, c := range []struct{ name, outer, body string }{ + {"assignment", "def outer():\n run = lambda: None\n", " from helper import run\n run()\n"}, + {"parameter", "def outer(run):\n", " from helper import run\n run()\n"}, + {"definition", "def outer():\n def run():\n pass\n", " from helper import run\n run()\n"}, + {"module", "def outer(helper):\n", " import helper\n helper.run()\n"}, + } { + t.Run(c.name, func(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{ + "app.py": c.outer + " def entry():\n" + c.body, + "helper.py": "def run():\n pass\n", + }) + g, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + entry := nodeByQualified(g, "outer.entry") + if entry == nil { + t.Fatal("missing entry function") + } + calls := g.Callees(entry.ID) + if len(calls) != 1 || g.Node(calls[0].To).File != "helper.py" { + t.Fatalf("inner import lost to an outer binding: %v", calls) + } + }) + } +} + +func TestBuildPythonLocalRebinding(t *testing.T) { + for _, c := range []struct{ name, body, file, qualified string }{ + {"definition after assignment", " run = None\n def run():\n pass\n run()\n", "app.py", "entry.run"}, + {"assignment after definition", " def run():\n pass\n run = lambda: None\n run()\n", "", "run"}, + {"call before reassignment", " def run():\n pass\n run()\n run = None\n", "app.py", "entry.run"}, + {"call before definition", " run()\n def run():\n pass\n", "", "run"}, + {"conditional definition", " run = None\n if enabled:\n def run():\n pass\n run()\n", "", "run"}, + {"import after assignment", " run = None\n from helper import run\n run()\n", "helper.py", "run"}, + {"assignment after import", " from helper import run\n run = lambda: None\n run()\n", "", "run"}, + {"conditional import", " if enabled:\n from helper import run\n run()\n", "", "run"}, + } { + t.Run(c.name, func(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{ + "app.py": "def run():\n pass\ndef entry():\n" + c.body, + "helper.py": "def run():\n pass\n", + }) + g, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + entry := nodeByQualified(g, "entry") + if entry == nil { + t.Fatal("missing entry function") + } + calls := g.Callees(entry.ID) + if len(calls) != 1 { + t.Fatalf("calls=%v, want one call", calls) + } + target := g.Node(calls[0].To) + if target.File != c.file || target.Qualified != c.qualified { + t.Fatalf("target=%+v, want %s in %s", target, c.qualified, c.file) + } + }) + } +} + +func TestBuildPythonClosureDefinition(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{"app.py": `def outer(): + def entry(): + run() + def run(): + run() + entry() +`}) + g, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + run := nodeByQualified(g, "outer.run") + entry := nodeByQualified(g, "outer.entry") + if run == nil || entry == nil { + t.Fatal("missing nested functions") + } + for _, caller := range []string{entry.ID, run.ID} { + if calls := g.Callees(caller); len(calls) != 1 || calls[0].To != run.ID { + t.Fatalf("closure target lost to source order: %v", calls) + } + } +} + +func TestBuildPythonLocalModuleImports(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{ + "app.py": "def entry():\n import pkg.one\n import pkg.two\n pkg.one.run()\n pkg.two.run()\n", + "pkg/__init__.py": "", + "pkg/one.py": "def run():\n pass\n", + "pkg/two.py": "def run():\n pass\n", + }) + g, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + entry := nodeByQualified(g, "entry") + if entry == nil { + t.Fatal("missing entry function") + } + calls := g.Callees(entry.ID) + if len(calls) != 2 { + t.Fatalf("calls=%v, want two calls", calls) + } + for _, call := range calls { + want := "pkg/one.py" + if call.Call.Receiver == "pkg.two" { + want = "pkg/two.py" + } + if target := g.Node(call.To); target.File != want { + t.Errorf("target=%+v, want file %s", target, want) + } + } +} + func TestBuildPythonPackageJoins(t *testing.T) { root := t.TempDir() writeFiles(t, root, map[string]string{ diff --git a/ruby_resolve.go b/ruby_resolve.go index b6fad66..c4b26ea 100644 --- a/ruby_resolve.go +++ b/ruby_resolve.go @@ -24,7 +24,7 @@ func rubyLoadFacts(src []byte, l *lang, root *ts.Node) []Import { var loads []Import walkNamed(root, func(node *ts.Node) { imp, ok := rubyImport(src, l.language, node) - if !ok || !rubyImmediateLoad(node, l.language) { + if !ok || !rubyImmediateStatement(node, l.language) { return } loads = append(loads, imp) @@ -32,7 +32,7 @@ func rubyLoadFacts(src []byte, l *lang, root *ts.Node) []Import { return loads } -func rubyImmediateLoad(node *ts.Node, language *ts.Language) bool { +func rubyImmediateStatement(node *ts.Node, language *ts.Language) bool { for parent := node.Parent(); parent != nil; parent = parent.Parent() { switch parent.Type(language) { case "program", "body_statement", "module", "class", "singleton_class": @@ -49,29 +49,49 @@ type rubyMode struct { Enabled bool } -func rubyModeFacts(a *analysis, src []byte, l *lang, root *ts.Node) []rubyMode { +func rubyModeFacts(a *analysis, src []byte, l *lang, root *ts.Node, immediate map[uint32]bool) []rubyMode { var modes []rubyMode - add := func(name string, start uint32, in int) { + add := func(name string, start uint32, in int, unconditional bool) { if name == rubyModuleFunction || name == "public" || name == "private" || name == "protected" { - modes = append(modes, rubyMode{in, start, name == rubyModuleFunction}) + modes = append(modes, rubyMode{in, start, unconditional && name == rubyModuleFunction}) } } for _, c := range a.Calls { if len(c.Arguments) == 0 && (c.ReceiverKind == ReceiverBare || c.ReceiverKind == ReceiverSelf) { - add(c.Name, c.Start, c.In) + add(c.Name, c.Start, c.In, immediate[c.Start]) } } walkNamed(root, func(node *ts.Node) { - if node.Type(l.language) == "identifier" && node.Parent() != nil && node.Parent().Type(l.language) == "body_statement" { - add(node.Text(src), node.StartByte(), enclosing(a.Decls, node.StartByte())) + if node.Type(l.language) == "identifier" && rubyModeStatement(node, l.language) { + add(node.Text(src), node.StartByte(), enclosing(a.Decls, node.StartByte()), rubyImmediateStatement(node, l.language)) } }) sort.Slice(modes, func(i, j int) bool { return modes[i].Start < modes[j].Start }) return modes } +func rubyModeStatement(node *ts.Node, language *ts.Language) bool { + parent := node.Parent() + if parent == nil { + return false + } + switch parent.Type(language) { + case "body_statement", "then", "else", "do": + return true + case "if_modifier", "unless_modifier", "while_modifier", "until_modifier", "rescue_modifier": + return parent.NamedChild(0).StartByte() == node.StartByte() + } + return false +} + func rubyModuleFunctions(a *analysis, src []byte, l *lang, root *ts.Node) { - modes := rubyModeFacts(a, src, l, root) + immediate := make(map[uint32]bool) + walkNamed(root, func(node *ts.Node) { + if node.Type(l.language) == "call" && rubyImmediateStatement(node, l.language) { + immediate[node.StartByte()] = true + } + }) + modes := rubyModeFacts(a, src, l, root, immediate) for i := range a.Decls { d := &a.Decls[i] if d.Kind != KindFunc || d.Singleton || d.Parent < 0 || a.Decls[d.Parent].Kind != KindType { @@ -86,7 +106,7 @@ func rubyModuleFunctions(a *analysis, src []byte, l *lang, root *ts.Node) { mode = event.Enabled } } - d.ModuleFunction = mode || rubyNamedModuleCopy(a, *d) + d.ModuleFunction = mode || rubyNamedModuleCopy(a, *d, immediate) } } @@ -100,9 +120,9 @@ func rubyCustomModuleFunction(a *analysis, owner int) bool { return false } -func rubyNamedModuleCopy(a *analysis, d decl) bool { +func rubyNamedModuleCopy(a *analysis, d decl, immediate map[uint32]bool) bool { for _, c := range a.Calls { - if c.Name != rubyModuleFunction || c.In != d.Parent || d.End > c.Start { + if c.Name != rubyModuleFunction || c.In != d.Parent || d.End > c.Start || !immediate[c.Start] { continue } if c.ReceiverKind != ReceiverBare && c.ReceiverKind != ReceiverSelf { diff --git a/ruby_resolve_test.go b/ruby_resolve_test.go index d76bb0f..7e713b3 100644 --- a/ruby_resolve_test.go +++ b/ruby_resolve_test.go @@ -2,6 +2,34 @@ package outline import "testing" +func TestBuildRubyConditionalModuleFunctions(t *testing.T) { + for _, c := range []struct{ name, body string }{ + {"named modifier", " def run\n end\n module_function :run if false\n"}, + {"named block", " def run\n end\n if enabled\n module_function :run\n end\n"}, + {"mode modifier", " module_function if false\n def run\n end\n"}, + {"mode block", " if enabled\n module_function\n end\n def run\n end\n"}, + {"conditional visibility", " module_function\n public if enabled\n def run\n end\n"}, + {"conditional visibility block", " module_function\n if enabled\n public\n end\n def run\n end\n"}, + } { + t.Run(c.name, func(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{"app.rb": "module M\n" + c.body + "end\ndef entry\n M.run\nend\n"}) + g, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + entry := nodeByQualified(g, "entry") + if entry == nil { + t.Fatal("missing entry method") + } + calls := g.Callees(entry.ID) + if len(calls) != 1 || calls[0].To != ExtID("ruby", "local:M", "run") { + t.Fatalf("conditional copy resolved to a source method: %v", calls) + } + }) + } +} + func TestBuildRubyLoadedSingletons(t *testing.T) { root := t.TempDir() writeFiles(t, root, map[string]string{