Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 15 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
53 changes: 33 additions & 20 deletions analyse.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
78 changes: 78 additions & 0 deletions analyse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
51 changes: 48 additions & 3 deletions build.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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"
Expand All @@ -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)
Expand All @@ -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),
Expand All @@ -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)
Expand All @@ -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 ""
Expand Down
Loading
Loading