diff --git a/README.md b/README.md index 34b6071..7932baa 100644 --- a/README.md +++ b/README.md @@ -81,25 +81,30 @@ concurrency, and whether to compress. `Result` carries `[]File` and a rendered `Tree` string. `Result.Markdown(w)` and `Result.XML(w)` write the packed document. -`Build(root string, opts Options) (*Graph, error)` walks `root` the same way as -`Pack` and returns a directed code graph. Nodes are files, modules, -declarations, and unresolved external call targets; each edge has a relation -(`contains`, `imports`, `calls`) and a confidence (`extracted` for facts read -directly from one AST, `inferred` for cross-file name resolution). `Graph` -methods query the result: `Def(name)` looks up by node ID, bare name, or -qualified name; `Callers`/`Callees` return one-hop call edges; +`Build(root string, opts Options) (*Graph, error)` shares the `Pack` walker but +keeps `bin/` entrypoints and returns a directed code graph. Ruby files include +`.rb`, `.gemspec`, and extensionless files with a Ruby shebang. Nodes are +files, modules, declarations, and unresolved external call targets; each edge +has a relation (`contains`, `imports`, `loads`, `calls`) and a confidence +(`extracted` for facts read directly from one AST, `inferred` for name +resolution). Call edges retain the receiver form, dispatch form, arguments, +and byte span. `Graph` methods query the result: `Def(name)` looks up by node +ID, bare name, or qualified name; `Callers`/`Callees` return one-hop call edges; `Affected(seeds, opts)` returns reverse-reachable evidence paths from a set of 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. Call extraction and cross-file resolution currently cover Go -and Python; for other supported languages the graph contains file, symbol, +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. `Tree(paths []string) string` renders a box-drawing directory tree from a flat path list. `Supported(filename string) bool` reports whether a file's extension maps to a -language with an outlining query. +language with an outlining query. `SupportedSource(src, filename)` also checks +source-based detection such as Ruby shebangs. `SetParseTimeout(d time.Duration)` overrides the per-file parse timeout (default 1s). Must be called before the first `Outline` or `Pack` call. diff --git a/analyse.go b/analyse.go index 151af9d..08bd07c 100644 --- a/analyse.go +++ b/analyse.go @@ -10,16 +10,18 @@ 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 + Name string + Kind string + Line int + Exported bool + NameAt uint32 + Start uint32 + End uint32 + SigEnd uint32 + Parent int + Params []string + Singleton bool + Owner string } func (d decl) symID(path string) string { @@ -110,6 +112,13 @@ func declsFromMatch(src []byte, l *lang, m ts.QueryMatch) []decl { sigEnd = body.StartByte() } params := extractParams(src, l, definition) + singleton := l.name == "ruby" && definition.Type(l.language) == "singleton_method" + owner := "" + if singleton { + if object := definition.ChildByFieldName("object", l.language); object != nil { + owner = object.Text(src) + } + } out := make([]decl, 0, len(names)) for _, n := range names { if n == nil { @@ -120,16 +129,18 @@ 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, + 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, + Owner: owner, }) } return out @@ -173,6 +184,8 @@ func extractParams(src []byte, l *lang, def *ts.Node) []string { collect(def.ChildByFieldName("result", l.language)) case "python": collect(def.ChildByFieldName("parameters", l.language)) + case "ruby": + collect(def.ChildByFieldName("parameters", l.language)) } return out } diff --git a/analyse_test.go b/analyse_test.go index 8e18f47..475eeb7 100644 --- a/analyse_test.go +++ b/analyse_test.go @@ -132,6 +132,84 @@ func main() { } } +func TestAnalyseRubyCalls(t *testing.T) { + src := []byte("class Worker\n" + + " def run(client, uri)\n" + + " File.read(\"config.yml\")\n" + + " Net::HTTP.get(uri)\n" + + " system(\"echo\", \"hello\")\n" + + " client.get(uri)\n" + + " self.finish\n" + + " factory().run\n" + + " `echo hi`\n" + + " end\n\n" + + " def finish\n" + + " end\n\n" + + " def self.build(name = \"default\")\n" + + " new(name)\n" + + " end\n" + + "end\n") + a, ok := analyse(src, "worker.rb") + if !ok { + t.Fatal("analyse returned false") + } + + iRun, dRun := declByName(t, a.Decls, "run") + _, dBuild := declByName(t, a.Decls, "build") + for _, p := range []string{"client", "uri"} { + if !slices.Contains(dRun.Params, p) { + t.Errorf("Ruby params missing %q: %v", p, dRun.Params) + } + } + if !dBuild.Singleton { + t.Error("self.build should be a singleton method") + } + if dBuild.Owner != "self" { + t.Errorf("self.build owner = %q", dBuild.Owner) + } + if !slices.Contains(dBuild.Params, "name") { + t.Errorf("Ruby optional param missing: %v", dBuild.Params) + } + + read := callByName(t, a.Calls, "read") + if read.Receiver != "File" || read.ReceiverKind != ReceiverConstant || read.In != iRun { + t.Errorf("File.read: receiver=%q kind=%q in=%d", read.Receiver, read.ReceiverKind, read.In) + } + if read.End <= read.Start || len(read.Arguments) != 1 || read.Arguments[0].Kind != "string" { + t.Errorf("File.read call facts = %#v", read) + } + + get := callByName(t, a.Calls, "get") + if get.Receiver != "Net::HTTP" || get.ReceiverKind != ReceiverConstant { + t.Errorf("Net::HTTP.get: receiver=%q kind=%q", get.Receiver, get.ReceiverKind) + } + + system := callByName(t, a.Calls, "system") + if system.ReceiverKind != ReceiverBare || len(system.Arguments) != 2 { + t.Errorf("system call facts = %#v", system) + } + + finish := callByName(t, a.Calls, "finish") + if finish.Receiver != "self" || finish.ReceiverKind != ReceiverSelf { + t.Errorf("self.finish: receiver=%q kind=%q", finish.Receiver, finish.ReceiverKind) + } + + var expression Call + for _, c := range a.Calls { + if c.Name == "run" && c.Receiver == "factory()" { + expression = c + } + } + if expression.ReceiverKind != ReceiverExpression { + t.Errorf("factory().run call facts = %#v", expression) + } + + subshell := callByName(t, a.Calls, "`") + if subshell.Dispatch != DispatchSubshell || subshell.Receiver != "Kernel" { + t.Errorf("subshell call facts = %#v", subshell) + } +} + func TestAnalyseParams(t *testing.T) { a, ok := analyse([]byte(`package m func F(a, b int, c ...string) (err error) { return } diff --git a/build.go b/build.go index b1a3d79..3083cb5 100644 --- a/build.go +++ b/build.go @@ -124,7 +124,7 @@ func analyseOne(root, path string, opts Options) fileAnalysis { fa.skipped = "too-large" return fa } - if _, ok := detect(path); !ok { + if _, ok := detect(path); !ok && !shebangCandidate(path) { fa.skipped = "unsupported" return fa } @@ -133,6 +133,10 @@ func analyseOne(root, path string, opts Options) fileAnalysis { fa.skipped = "unreadable" return fa } + if _, ok := detectSource(src, path); !ok { + fa.skipped = "unsupported" + return fa + } a, ok := analyse(src, path) if !ok { fa.skipped = "parse-failed" @@ -143,6 +147,17 @@ func analyseOne(root, path string, opts Options) fileAnalysis { return fa } +// shebangCandidate reports whether a path might be identified by its +// content: only extensionless files are read for shebang sniffing, so +// files with unsupported extensions are skipped without reading. +func shebangCandidate(path string) bool { + base := path + if i := strings.LastIndexByte(base, '/'); i >= 0 { + base = base[i+1:] + } + return !strings.Contains(base, ".") +} + func emitFileNodes(g *Graph, files []fileAnalysis) { for _, f := range files { fid := FileID(f.path) @@ -160,7 +175,7 @@ func emitFileNodes(g *Graph, files []fileAnalysis) { ID: sid, Kind: d.Kind, Name: d.Name, - Qualified: qualified(f.a.Decls, i), + Qualified: qualified(f.a.Lang, f.a.Decls, i), File: f.path, Line: d.Line, Start: int(d.Start), @@ -180,7 +195,10 @@ func emitFileNodes(g *Graph, files []fileAnalysis) { } } -func qualified(decls []decl, i int) string { +func qualified(lang string, decls []decl, i int) string { + if lang == "ruby" { + return rubyQualified(decls, i) + } parts := []string{decls[i].Name} for p := decls[i].Parent; p >= 0; p = decls[p].Parent { parts = append(parts, decls[p].Name) @@ -191,6 +209,33 @@ func qualified(decls []decl, i int) string { return strings.Join(parts, ".") } +func rubyQualified(decls []decl, i int) string { + d := decls[i] + var owner []string + for p := d.Parent; p >= 0; p = decls[p].Parent { + if decls[p].Kind == KindClass || decls[p].Kind == KindType { + owner = append(owner, decls[p].Name) + } + } + for left, right := 0, len(owner)-1; left < right; left, right = left+1, right-1 { + owner[left], owner[right] = owner[right], owner[left] + } + if d.Kind == KindFunc { + if d.Singleton && d.Owner != "" && d.Owner != "self" { + return d.Owner + "." + d.Name + } + if len(owner) == 0 { + return d.Name + } + separator := "#" + if d.Singleton { + separator = "." + } + return strings.Join(owner, "::") + separator + d.Name + } + return strings.Join(append(owner, d.Name), "::") +} + func signature(src []byte, d decl) string { if int(d.Start) >= len(src) { return "" diff --git a/build_test.go b/build_test.go index b583c0c..d4b2cf9 100644 --- a/build_test.go +++ b/build_test.go @@ -41,6 +41,15 @@ func nodeByName(g *Graph, kind, name string) *Node { return nil } +func nodeByQualified(g *Graph, qualified string) *Node { + for i := range g.Nodes { + if g.Nodes[i].Qualified == qualified { + return &g.Nodes[i] + } + } + return nil +} + func TestBuildGoCrossPackage(t *testing.T) { root := t.TempDir() writeFiles(t, root, map[string]string{ @@ -159,6 +168,175 @@ def handler(name): } } +func TestBuildRubyCalls(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{ + "worker.rb": `class Worker + def run(client) + helper() + self.helper + File.read("config.yml") + client.get("/") + end + + def helper + end + + def self.run + build() + end + + def self.build + end + + build() +end + +def Worker.configure +end + +def system(command) +end + +def invoke + system("echo") + Worker.run + Worker.configure +end +`, + }) + + g, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + + worker := nodeByQualified(g, "Worker") + instanceRun := nodeByQualified(g, "Worker#run") + singletonRun := nodeByQualified(g, "Worker.run") + helper := nodeByQualified(g, "Worker#helper") + build := nodeByQualified(g, "Worker.build") + configure := nodeByQualified(g, "Worker.configure") + invoke := nodeByQualified(g, "invoke") + localSystem := nodeByQualified(g, "system") + if worker == nil || instanceRun == nil || singletonRun == nil || helper == nil || build == nil || configure == nil || invoke == nil || localSystem == nil { + t.Fatalf("missing Ruby nodes: worker=%v instance=%v singleton=%v helper=%v build=%v configure=%v invoke=%v system=%v", + worker, instanceRun, singletonRun, helper, build, configure, invoke, localSystem) + } + + if e := edge(g, instanceRun.ID, helper.ID, RelCalls); e == nil || e.Conf != ConfExtracted || e.Call == nil { + t.Errorf("missing instance helper call with facts: %v", e) + } + if e := edge(g, singletonRun.ID, build.ID, RelCalls); e == nil || e.Conf != ConfExtracted { + t.Errorf("missing singleton build call: %v", e) + } + if e := edge(g, worker.ID, build.ID, RelCalls); e == nil || e.Conf != ConfExtracted { + t.Errorf("missing class-body build call: %v", e) + } + if e := edge(g, invoke.ID, localSystem.ID, RelCalls); e == nil || e.Conf != ConfExtracted { + t.Errorf("local system should shadow Kernel.system: %v", e) + } + if e := edge(g, invoke.ID, singletonRun.ID, RelCalls); e == nil || e.Conf != ConfExtracted { + t.Errorf("missing Worker.run call: %v", e) + } + if e := edge(g, invoke.ID, configure.ID, RelCalls); e == nil || e.Conf != ConfExtracted { + t.Errorf("missing Worker.configure call: %v", e) + } + + read := ExtID("ruby", "File", "read") + if e := edge(g, instanceRun.ID, read, RelCalls); e == nil || e.Call == nil || len(e.Call.Arguments) != 1 { + t.Errorf("missing File.read external call facts: %v", e) + } + get := ExtID("ruby", "client", "get") + if e := edge(g, instanceRun.ID, get, RelCalls); e == nil || e.Call == nil || e.Call.ReceiverKind != ReceiverLocal { + t.Errorf("missing unresolved client.get call facts: %v", e) + } +} + +func TestBuildRubyLoadsAndExecutables(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{ + "app.rb": `require "json" +require_relative "lib/helper" +Kernel.require "net/http" +require "#{name}/client" +require File.join("plugins", name) +`, + "lib/helper.rb": "module Helper\nend\n", + "bin/tool": "#!/usr/bin/env ruby\nFile.delete(\"stale\")\n", + "sample.gemspec": "Gem::Specification.new do |spec|\n spec.name = \"sample\"\nend\n", + }) + + g, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + for _, file := range []string{"app.rb", "lib/helper.rb", "bin/tool", "sample.gemspec"} { + if g.Node(FileID(file)) == nil { + t.Errorf("missing Ruby file node %q", file) + } + } + if e := edge(g, FileID("bin/tool"), ExtID("ruby", "File", "delete"), RelCalls); e == nil { + t.Error("Ruby executable was selected but not analysed") + } + + relative := ModID("ruby", "lib/helper") + if e := edge(g, FileID("app.rb"), relative, RelLoads); e == nil || e.Operation != "require_relative" { + t.Errorf("missing require_relative load edge: %v", e) + } + if e := edge(g, relative, FileID("lib/helper.rb"), RelContains); e == nil { + t.Errorf("require_relative did not resolve helper: %v", e) + } + if g.Node(ModID("ruby", "#{name}/client")) != nil { + t.Error("dynamic require became a fixed module") + } + if g.Node(ModID("ruby", "plugins")) != nil { + t.Error("nested File.join string became a fixed module") + } + if e := edge(g, FileID("app.rb"), ExtID("ruby", "", "require"), RelCalls); e == nil { + t.Error("dynamic require was omitted instead of retained as an unresolved call") + } +} + +func TestShebangCandidate(t *testing.T) { + cases := map[string]bool{ + "bin/tool": true, + "Rakefile": true, + "bin/v1.2/tool": true, + "lib/helper.rb": false, + "logo.png": false, + ".gitignore": false, + } + for path, want := range cases { + if got := shebangCandidate(path); got != want { + t.Errorf("shebangCandidate(%q) = %t, want %t", path, got, want) + } + } +} + +func TestBuildRubyConstantShadow(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{ + "shadow.rb": `class File +end + +def read + File.read("local") +end +`, + }) + g, err := Build(root, Options{}) + if err != nil { + t.Fatal(err) + } + if g.Node(ExtID("ruby", "File", "read")) != nil { + t.Error("local File constant resolved as core File") + } + if g.Node(ExtID("ruby", "local:File", "read")) == nil { + t.Error("missing unresolved target for shadowing File constant") + } +} + func TestBuildSameFileExtracted(t *testing.T) { root := t.TempDir() writeFiles(t, root, map[string]string{ diff --git a/call.go b/call.go index 6c5988c..2b01f57 100644 --- a/call.go +++ b/call.go @@ -2,16 +2,45 @@ package outline import ts "github.com/odvcencio/gotreesitter" -// Call is one call site. Receiver is the leftmost identifier of a member -// call (a.b()) or empty for a bare call (b()). In is the index into the -// file's decl slice of the innermost enclosing declaration, or -1 for -// top-level calls. +// ReceiverKind describes the syntax used to select a call receiver. +type ReceiverKind string + +const ( + ReceiverBare ReceiverKind = "bare" + ReceiverConstant ReceiverKind = "constant" + ReceiverLocal ReceiverKind = "local" + ReceiverSelf ReceiverKind = "self" + ReceiverExpression ReceiverKind = "expression" +) + +// DispatchKind describes how a call reaches its target. +type DispatchKind string + +const ( + DispatchDirect DispatchKind = "direct" + DispatchSubshell DispatchKind = "subshell" +) + +// Argument is one source argument to a call. +type Argument struct { + Kind string `json:"kind"` + Text string `json:"text"` + Start uint32 `json:"start"` + End uint32 `json:"end"` +} + +// Call is one call site. In refers to the innermost enclosing declaration +// during graph construction and is omitted from JSON. type Call struct { - Receiver string - Name string - Line int - Start uint32 - In int + Receiver string `json:"receiver,omitempty"` + ReceiverKind ReceiverKind `json:"receiver_kind"` + Name string `json:"name"` + Dispatch DispatchKind `json:"dispatch"` + Arguments []Argument `json:"arguments,omitempty"` + Line int `json:"line"` + Start uint32 `json:"start"` + End uint32 `json:"end"` + In int `json:"-"` } func callsFor(src []byte, l *lang, root *ts.Node, decls []decl) ([]Call, bool) { @@ -21,6 +50,8 @@ func callsFor(src []byte, l *lang, root *ts.Node, decls []decl) ([]Call, bool) { calls = goCalls(src, l.language, root) case "python": calls = pythonCalls(src, l.language, root) + case "ruby": + calls = rubyCalls(src, l.language, root) default: return nil, false } @@ -40,10 +71,11 @@ func goCalls(src []byte, language *ts.Language, root *ts.Node) []Call { if fn == nil { return } - c := Call{Line: sourceLine(node), Start: node.StartByte()} + c := newCall(src, language, node) switch fn.Type(language) { case "identifier": c.Name = fn.Text(src) + c.ReceiverKind = ReceiverBare case "selector_expression": recv := fn.ChildByFieldName("operand", language) member := fn.ChildByFieldName("field", language) @@ -53,6 +85,10 @@ func goCalls(src []byte, language *ts.Language, root *ts.Node) []Call { c.Name = member.Text(src) if recv != nil && recv.Type(language) == "identifier" { c.Receiver = recv.Text(src) + c.ReceiverKind = ReceiverLocal + } else if recv != nil { + c.Receiver = recv.Text(src) + c.ReceiverKind = ReceiverExpression } default: return @@ -72,10 +108,11 @@ func pythonCalls(src []byte, language *ts.Language, root *ts.Node) []Call { if fn == nil { return } - c := Call{Line: sourceLine(node), Start: node.StartByte()} + c := newCall(src, language, node) switch fn.Type(language) { case "identifier": c.Name = fn.Text(src) + c.ReceiverKind = ReceiverBare case "attribute": recv := fn.ChildByFieldName("object", language) member := fn.ChildByFieldName("attribute", language) @@ -84,6 +121,11 @@ func pythonCalls(src []byte, language *ts.Language, root *ts.Node) []Call { } c.Name = member.Text(src) c.Receiver = leftmostIdentifier(src, language, recv) + c.ReceiverKind = ReceiverLocal + if c.Receiver == "" && recv != nil { + c.Receiver = recv.Text(src) + c.ReceiverKind = ReceiverExpression + } default: return } @@ -92,6 +134,85 @@ func pythonCalls(src []byte, language *ts.Language, root *ts.Node) []Call { return calls } +func rubyCalls(src []byte, language *ts.Language, root *ts.Node) []Call { + var calls []Call + walkNamed(root, func(node *ts.Node) { + switch node.Type(language) { + case "subshell": + calls = append(calls, Call{ + Receiver: "Kernel", + ReceiverKind: ReceiverConstant, + Name: "`", + Dispatch: DispatchSubshell, + Line: sourceLine(node), + Start: node.StartByte(), + End: node.EndByte(), + }) + case "call": + method := node.ChildByFieldName("method", language) + if method == nil { + return + } + if _, ok := rubyImport(src, language, node); ok { + return + } + name := method.Text(src) + c := newCall(src, language, node) + c.Name = name + receiver := node.ChildByFieldName("receiver", language) + if receiver == nil { + c.ReceiverKind = ReceiverBare + } else { + c.Receiver = receiver.Text(src) + switch receiver.Type(language) { + case "constant", "scope_resolution": + c.ReceiverKind = ReceiverConstant + case "identifier": + c.ReceiverKind = ReceiverLocal + case "self": + c.ReceiverKind = ReceiverSelf + default: + c.ReceiverKind = ReceiverExpression + } + } + calls = append(calls, c) + } + }) + return calls +} + +func newCall(src []byte, language *ts.Language, node *ts.Node) Call { + c := Call{ + Dispatch: DispatchDirect, + Line: sourceLine(node), + Start: node.StartByte(), + End: node.EndByte(), + } + args := node.ChildByFieldName("arguments", language) + if args == nil { + return c + } + for i := range args.NamedChildCount() { + arg := args.NamedChild(i) + c.Arguments = append(c.Arguments, Argument{ + Kind: arg.Type(language), + Text: arg.Text(src), + Start: arg.StartByte(), + End: arg.EndByte(), + }) + } + return c +} + +func rubyLoadMethod(name string) bool { + switch name { + case "require", "require_relative", "load", "autoload": + return true + default: + return false + } +} + // 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 { diff --git a/cmd/outline/main_test.go b/cmd/outline/main_test.go index da0388f..403a401 100644 --- a/cmd/outline/main_test.go +++ b/cmd/outline/main_test.go @@ -83,6 +83,20 @@ func TestCLIPythonAffected(t *testing.T) { } } +func TestCLIRubyAffected(t *testing.T) { + dir := testdata("cli-ruby") + + out := run(t, "affected", "-dir", dir, "-inferred", "ext:ruby:File:read") + if !strings.Contains(out, "Worker#run") { + t.Errorf("affected output missing Ruby method:\n%s", out) + } + + out = run(t, "affected", "-dir", dir, "-inferred", "ext:ruby:File:delete") + if !strings.Contains(out, "bin/tool") { + t.Errorf("affected output missing Ruby executable:\n%s", out) + } +} + func TestCLICallersInferred(t *testing.T) { dir := testdata("cli-go") out := run(t, "callers", "-dir", dir, "Load") diff --git a/graph.go b/graph.go index 9f5c03c..3b22b20 100644 --- a/graph.go +++ b/graph.go @@ -10,7 +10,7 @@ import ( // SchemaVersion is bumped whenever Node, Edge, or Graph change in a way a // reader must understand. -const SchemaVersion = 1 +const SchemaVersion = 2 // Node kinds. const ( @@ -28,6 +28,7 @@ const ( const ( RelContains = "contains" RelImports = "imports" + RelLoads = "loads" RelCalls = "calls" RelReferences = "references" RelInherits = "inherits" @@ -58,12 +59,14 @@ type Node struct { // Edge is one directed relationship between two nodes. type Edge struct { - From string `json:"from"` - To string `json:"to"` - Rel string `json:"rel"` - Conf string `json:"conf"` - File string `json:"file,omitempty"` - Line int `json:"line,omitempty"` + From string `json:"from"` + To string `json:"to"` + Rel string `json:"rel"` + Conf string `json:"conf"` + File string `json:"file,omitempty"` + Line int `json:"line,omitempty"` + Operation string `json:"operation,omitempty"` + Call *Call `json:"call,omitempty"` } // Graph is a whole-repository code graph. @@ -186,8 +189,8 @@ func (g *Graph) edgeSlice(idx []int) []Edge { return out } -// sortStable orders Nodes by ID and Edges by (From, To, Rel, File, Line) so -// two builds of the same input produce byte-identical JSON. +// sortStable orders Nodes and Edges so two builds of the same input produce +// byte-identical JSON. func (g *Graph) sortStable() { sort.Slice(g.Nodes, func(i, j int) bool { return g.Nodes[i].ID < g.Nodes[j].ID }) sort.Slice(g.Edges, func(i, j int) bool { @@ -204,7 +207,16 @@ func (g *Graph) sortStable() { if a.File != b.File { return a.File < b.File } - return a.Line < b.Line + if a.Line != b.Line { + return a.Line < b.Line + } + if a.Operation != b.Operation { + return a.Operation < b.Operation + } + if a.Call == nil || b.Call == nil { + return a.Call == nil && b.Call != nil + } + return a.Call.Start < b.Call.Start }) g.built = false } diff --git a/graph_test.go b/graph_test.go index eb499c0..f8de58b 100644 --- a/graph_test.go +++ b/graph_test.go @@ -69,7 +69,14 @@ func TestGraphJSONDeterministic(t *testing.T) { }, Edges: []Edge{ {From: "sym:b.go:0", To: "file:a.go", Rel: RelReferences}, - {From: "file:a.go", To: "sym:b.go:0", Rel: RelContains}, + { + From: "file:a.go", To: "sym:b.go:0", Rel: RelCalls, + Call: &Call{ + Receiver: "File", ReceiverKind: ReceiverConstant, + Name: "read", Dispatch: DispatchDirect, Start: 10, End: 24, + Arguments: []Argument{{Kind: "string", Text: `"a"`, Start: 20, End: 23}}, + }, + }, }, } } @@ -93,4 +100,7 @@ func TestGraphJSONDeterministic(t *testing.T) { if round.Edges[0].From != "file:a.go" { t.Errorf("edges not sorted: first from = %q", round.Edges[0].From) } + if round.Edges[0].Call == nil || round.Edges[0].Call.ReceiverKind != ReceiverConstant || len(round.Edges[0].Call.Arguments) != 1 { + t.Errorf("call facts did not round-trip: %#v", round.Edges[0].Call) + } } diff --git a/ignore.go b/ignore.go index 811ddc6..ae36888 100644 --- a/ignore.go +++ b/ignore.go @@ -1,8 +1,6 @@ package outline -// defaultIgnore is added on top of the project's .gitignore. It covers -// vendored dependencies, build output, lockfiles and editor cruft that a -// repository might reasonably commit but that add noise to a packed outline. +// defaultIgnore is shared by Pack and Build. var defaultIgnore = []byte(` .git/ .hg/ @@ -32,7 +30,6 @@ __pycache__/ dist/ build/ out/ -bin/ obj/ coverage/ .nyc_output/ @@ -78,3 +75,7 @@ Thumbs.db .vscode/ *.iml `) + +// packIgnore removes executable entrypoints from packed context. Build keeps +// them so source graphs include repository commands. +var packIgnore = []byte("bin/\n") diff --git a/import_hyrum.go b/import_hyrum.go index da2f22b..8a57437 100644 --- a/import_hyrum.go +++ b/import_hyrum.go @@ -48,26 +48,59 @@ func goImports(src []byte, language *ts.Language, root *ts.Node) []Import { func rubyImports(src []byte, language *ts.Language, root *ts.Node) []Import { var imports []Import walkNamed(root, func(node *ts.Node) { - if node.Type(language) != "call" || node.NamedChildCount() < 2 { - return - } - function := node.NamedChild(0) - if function.Type(language) != "identifier" || function.Text(src) != "require" { - return - } - arguments := node.NamedChild(1) - moduleNode := firstDescendantType(arguments, language, "string") - if moduleNode == nil { - return - } - module := sourceString(moduleNode.Text(src)) - if module != "" { - imports = append(imports, Import{Module: module, Kind: ImportSideEffect, Line: sourceLine(node)}) + if imported, ok := rubyImport(src, language, node); ok { + imports = append(imports, imported) } }) return imports } +func rubyImport(src []byte, language *ts.Language, node *ts.Node) (Import, bool) { + if node.Type(language) != "call" { + return Import{}, false + } + method := node.ChildByFieldName("method", language) + if method == nil || !rubyLoadMethod(method.Text(src)) { + return Import{}, false + } + receiver := node.ChildByFieldName("receiver", language) + if receiver != nil && (receiver.Type(language) != "constant" || receiver.Text(src) != "Kernel") { + return Import{}, false + } + arguments := node.ChildByFieldName("arguments", language) + if arguments == nil { + return Import{}, false + } + index := 0 + form := method.Text(src) + if form == "autoload" { + index = 1 + } + if index >= int(arguments.NamedChildCount()) { + return Import{}, false + } + module, ok := rubyStringLiteral(src, language, arguments.NamedChild(index)) + if !ok || module == "" { + return Import{}, false + } + return Import{ + Module: module, Kind: ImportSideEffect, Form: form, + Relative: form == "require_relative", Line: sourceLine(node), + }, true +} + +func rubyStringLiteral(src []byte, language *ts.Language, node *ts.Node) (string, bool) { + if node == nil || node.Type(language) != "string" { + return "", false + } + for i := range node.NamedChildCount() { + if node.NamedChild(i).Type(language) != "string_content" { + return "", false + } + } + return sourceString(node.Text(src)), true +} + func rustImports(src []byte, language *ts.Language, root *ts.Node) []Import { var imports []Import walkNamed(root, func(node *ts.Node) { diff --git a/import_test.go b/import_test.go index ed1358a..98a6efc 100644 --- a/import_test.go +++ b/import_test.go @@ -171,10 +171,18 @@ import ( }, { filename: "app.rb", - src: "require \"octokit\"\nrequire 'octokit/client'\n", + src: `require "octokit" +require 'octokit/client' +require_relative "helper" +Kernel.require "net/http" +require "#{name}/client" +require File.join("plugins", name) +`, want: []Import{ - {Module: "octokit", Kind: ImportSideEffect, Line: 1}, - {Module: "octokit/client", Kind: ImportSideEffect, Line: 2}, + {Module: "octokit", Kind: ImportSideEffect, Form: "require", Line: 1}, + {Module: "octokit/client", Kind: ImportSideEffect, Form: "require", Line: 2}, + {Module: "helper", Kind: ImportSideEffect, Form: "require_relative", Relative: true, Line: 3}, + {Module: "net/http", Kind: ImportSideEffect, Form: "require", Line: 4}, }, }, { diff --git a/outline.go b/outline.go index 57f4d94..2d190a9 100644 --- a/outline.go +++ b/outline.go @@ -102,41 +102,41 @@ var langs = map[string]*lang{ } var byExt = map[string]string{ - ".go": "go", - ".rb": "ruby", - ".py": "python", - ".pyi": "python", - ".js": "javascript", - ".jsx": "javascript", - ".mjs": "javascript", - ".cjs": "javascript", - ".ts": "typescript", - ".mts": "typescript", - ".cts": "typescript", - ".tsx": "tsx", - ".rs": "rust", - ".java": "java", - ".c": "c", - ".h": "c", - ".cpp": "cpp", - ".cc": "cpp", - ".cxx": "cpp", - ".hpp": "cpp", - ".hh": "cpp", - ".hxx": "cpp", - ".cs": "csharp", - ".php": "php", - ".kt": "kotlin", - ".kts": "kotlin", - ".swift": "swift", - ".scala": "scala", - ".sc": "scala", - ".dart": "dart", - ".ex": "elixir", - ".exs": "elixir", - ".erl": "erlang", - ".hrl": "erlang", - ".hs": "haskell", + ".go": "go", + ".rb": "ruby", + ".py": "python", + ".pyi": "python", + ".js": "javascript", + ".jsx": "javascript", + ".mjs": "javascript", + ".cjs": "javascript", + ".ts": "typescript", + ".mts": "typescript", + ".cts": "typescript", + ".tsx": "tsx", + ".rs": "rust", + ".java": "java", + ".c": "c", + ".h": "c", + ".cpp": "cpp", + ".cc": "cpp", + ".cxx": "cpp", + ".hpp": "cpp", + ".hh": "cpp", + ".hxx": "cpp", + ".cs": "csharp", + ".php": "php", + ".kt": "kotlin", + ".kts": "kotlin", + ".swift": "swift", + ".scala": "scala", + ".sc": "scala", + ".dart": "dart", + ".ex": "elixir", + ".exs": "elixir", + ".erl": "erlang", + ".hrl": "erlang", + ".hs": "haskell", ".clj": "clojure", ".cljs": "clojure", ".cljc": "clojure", @@ -187,6 +187,9 @@ func detect(filename string) (*lang, bool) { if i := strings.LastIndexByte(base, '/'); i >= 0 { base = base[i+1:] } + if strings.HasSuffix(base, ".gemspec") { + return langs["ruby"], true + } if name, ok := byName[base]; ok { return langs[name], true } @@ -201,6 +204,26 @@ func detect(filename string) (*lang, bool) { return langs[name], true } +func detectSource(src []byte, filename string) (*lang, bool) { + if l, ok := detect(filename); ok { + return l, true + } + line, _, _ := bytes.Cut(src, []byte{'\n'}) + if !bytes.HasPrefix(line, []byte("#!")) { + return nil, false + } + for field := range bytes.FieldsSeq(line[2:]) { + name := field + if i := bytes.LastIndexByte(name, '/'); i >= 0 { + name = name[i+1:] + } + if bytes.HasPrefix(name, []byte("ruby")) { + return langs["ruby"], true + } + } + return nil, false +} + // chunk is a contiguous run of source lines to keep. type chunk struct { startRow uint32 @@ -265,7 +288,7 @@ func outlineSource(src []byte, filename string, collectSymbols bool) (string, [] } func parseSource(src []byte, filename string) (*lang, *ts.Tree, bool) { - l, ok := detect(filename) + l, ok := detectSource(src, filename) if !ok { return nil, nil, false } @@ -378,6 +401,12 @@ func Supported(filename string) bool { return ok } +// SupportedSource reports whether Outline can detect the language of source. +func SupportedSource(src []byte, filename string) bool { + _, ok := detectSource(src, filename) + return ok +} + func init() { for _, l := range langs { if _, err := queryFS.ReadFile("queries/" + l.name + ".scm"); err != nil { diff --git a/outline_lang_test.go b/outline_lang_test.go index f5a32bf..394a068 100644 --- a/outline_lang_test.go +++ b/outline_lang_test.go @@ -31,6 +31,18 @@ func (tc langCase) run(t *testing.T) { } } +func TestRubySourceDetection(t *testing.T) { + if !Supported("package.gemspec") { + t.Error("gemspec should be detected as Ruby") + } + if !SupportedSource([]byte("#!/usr/bin/env ruby\nputs 'ok'\n"), "bin/tool") { + t.Error("Ruby shebang should detect an extensionless executable") + } + if SupportedSource([]byte("#!/bin/sh\necho ok\n"), "bin/tool") { + t.Error("non-Ruby extensionless executable detected as Ruby") + } +} + func TestOutlinePython(t *testing.T) { langCase{ filename: "sample.py", diff --git a/pack.go b/pack.go index 7c534c2..97f698a 100644 --- a/pack.go +++ b/pack.go @@ -76,6 +76,7 @@ func Pack(root string, opts Options) (*Result, error) { m := gitignore.New(abs) m.AddPatterns(defaultIgnore, "") + m.AddPatterns(packIgnore, "") for _, p := range opts.Ignore { m.AddPatterns([]byte(p+"\n"), "") } diff --git a/resolve.go b/resolve.go index 411f058..3594123 100644 --- a/resolve.go +++ b/resolve.go @@ -100,6 +100,10 @@ func (r *resolver) moduleID(f *fileAnalysis, imp Import) string { if f.a.Lang == "python" && strings.HasPrefix(name, ".") { name = path.Dir(f.path) + "/" + name } + if f.a.Lang == "ruby" && imp.Relative { + name = path.Clean(path.Join(path.Dir(f.path), name)) + name = strings.TrimSuffix(name, ".rb") + } return ModID(f.a.Lang, name) } @@ -121,11 +125,19 @@ func (r *resolver) emitModules(g *Graph) { g.Nodes = append(g.Nodes, Node{ ID: mid, Kind: KindModule, Name: imp.Module, }) - r.modules[mid] = r.moduleFiles(f.a.Lang, imp.Module, p) + } + for _, resolved := range r.moduleFiles(f, imp) { + if !slices.Contains(r.modules[mid], resolved) { + r.modules[mid] = append(r.modules[mid], resolved) + } + } + rel := RelImports + if f.a.Lang == "ruby" { + rel = RelLoads } g.Edges = append(g.Edges, Edge{ - From: fid, To: mid, Rel: RelImports, Conf: ConfExtracted, - File: p, Line: imp.Line, + From: fid, To: mid, Rel: rel, Conf: ConfExtracted, + File: p, Line: imp.Line, Operation: imp.Form, }) } } @@ -140,12 +152,27 @@ func (r *resolver) emitModules(g *Graph) { // moduleFiles resolves an import module string to zero or more // repository-relative file paths already in r.files. -func (r *resolver) moduleFiles(lang, module, from string) []string { - switch lang { +func (r *resolver) moduleFiles(f *fileAnalysis, imp Import) []string { + switch f.a.Lang { case "go": - return r.goModuleFiles(module) + return r.goModuleFiles(imp.Module) case "python": - return r.pyModuleFiles(module, from) + return r.pyModuleFiles(imp.Module, f.path) + case "ruby": + return r.rubyModuleFiles(imp, f.path) + } + return nil +} + +func (r *resolver) rubyModuleFiles(imp Import, from string) []string { + if !imp.Relative { + return nil + } + base := path.Clean(path.Join(path.Dir(from), imp.Module)) + for _, candidate := range []string{base, base + ".rb"} { + if _, ok := r.files[candidate]; ok { + return []string{candidate} + } } return nil } @@ -331,15 +358,19 @@ func (r *resolver) emitCalls(g *Graph) { Qualified: extQualified(to), }) } + site := c g.Edges = append(g.Edges, Edge{ From: from, To: to, Rel: RelCalls, Conf: conf, - File: p, Line: c.Line, + File: p, Line: c.Line, Call: &site, }) } } } func (r *resolver) resolveCall(f *fileAnalysis, sc scope, c Call) (string, string) { + if f.a.Lang == "ruby" { + return r.resolveRubyCall(f, c) + } if c.Receiver == "" { if sid, shadowed := lexical(f, sc, c.In, c.Name); sid != "" { return sid, ConfExtracted @@ -363,6 +394,79 @@ 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 { + return owner, false + } + switch decls[in].Kind { + case KindFunc: + singleton = decls[in].Singleton + case KindClass, KindType: + singleton = true + } + for at := in; at >= 0; at = decls[at].Parent { + if decls[at].Kind == KindClass || decls[at].Kind == KindType { + return at, singleton + } + } + 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/syntax.go b/syntax.go index 4bc4c7c..0b64b50 100644 --- a/syntax.go +++ b/syntax.go @@ -28,14 +28,15 @@ type Name struct { // Import is one source import form. A statement containing more than one // form, such as a JavaScript default plus named import, produces one Import -// value for each form. Names is empty for side-effect and wildcard imports, -// and when a local binding cannot be established from the statement alone, -// such as an unaliased Go import. +// value for each form. Form and Relative retain language-specific loading +// details when they affect graph construction. type Import struct { - Module string - Kind ImportKind - Names []Name - Line int + Module string + Kind ImportKind + Names []Name + Form string + Relative bool + Line int } // Ref is a direct member access on a caller-supplied receiver. diff --git a/testdata/cli-ruby/app.rb b/testdata/cli-ruby/app.rb new file mode 100644 index 0000000..31afbe3 --- /dev/null +++ b/testdata/cli-ruby/app.rb @@ -0,0 +1,7 @@ +require_relative "lib/helper" + +class Worker + def run(path) + File.read(path) + end +end diff --git a/testdata/cli-ruby/bin/tool b/testdata/cli-ruby/bin/tool new file mode 100644 index 0000000..0201847 --- /dev/null +++ b/testdata/cli-ruby/bin/tool @@ -0,0 +1,3 @@ +#!/usr/bin/env ruby + +File.delete("stale") diff --git a/testdata/cli-ruby/lib/helper.rb b/testdata/cli-ruby/lib/helper.rb new file mode 100644 index 0000000..528693b --- /dev/null +++ b/testdata/cli-ruby/lib/helper.rb @@ -0,0 +1,5 @@ +module Helper + def self.ready? + true + end +end