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
91 changes: 91 additions & 0 deletions callcolumns.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package astkit

import (
"strings"
"unicode/utf8"

"github.com/provasign/astkit/textmask"
)

// fillCallColumns sets each call site's Col: the 1-based character column
// of the called name on its line. The name is the callee's last segment
// ("out.value" -> "value"), found in the line's code (comments and strings
// masked) as an identifier followed by "(", "<" (type arguments) or "!" (a
// Rust macro). Several same-named calls on one line take the occurrences
// in order. A site whose name cannot be found keeps 0 (unknown).
func fillCallColumns(lang string, syms []Symbol, src []byte) {
text := string(src)
if textmask.Supported(lang) {
text = textmask.MaskFile(lang, text)
}
var lines []string
lineOf := func(n int) string {
if lines == nil {
lines = strings.Split(text, "\n")
}
if n < 1 || n > len(lines) {
return ""
}
return lines[n-1]
}
type key struct {
line int
name string
}
cache := map[key][]int{}
used := map[key]int{}
for i := range syms {
for j := range syms[i].CallSites {
cs := &syms[i].CallSites[j]
if cs.Line <= 0 || cs.Col > 0 {
continue
}
name := cs.Callee
for _, sep := range []string{".", "::", "->", ":"} {
if k := strings.LastIndex(name, sep); k >= 0 {
name = name[k+len(sep):]
}
}
if name == "" {
continue
}
k := key{cs.Line, name}
cols, ok := cache[k]
if !ok {
cols = nameColumns(lineOf(cs.Line), name)
cache[k] = cols
}
if n := used[k]; n < len(cols) {
cs.Col = cols[n]
used[k] = n + 1
}
}
}
}

// nameColumns returns the 1-based character columns where name occurs in
// line as a called identifier.
func nameColumns(line, name string) []int {
var out []int
for start := 0; ; {
i := strings.Index(line[start:], name)
if i < 0 {
return out
}
at := start + i
end := at + len(name)
start = end
if at > 0 && isIdentByte(line[at-1]) || end < len(line) && isIdentByte(line[end]) {
continue
}
rest := strings.TrimLeft(line[end:], " \t")
if rest == "" || !strings.ContainsRune("(<!", rune(rest[0])) {
continue
}
out = append(out, utf8.RuneCountInString(line[:at])+1)
}
}

func isIdentByte(b byte) bool {
return b == '_' || b == '$' || b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9' || b >= 0x80
}
6 changes: 5 additions & 1 deletion engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,11 @@ func (r *Registry) Extract(lang LanguageKey, tree *sitter.Tree, src []byte) ([]S
if err := guardDepth(tree); err != nil {
return nil, err
}
return s.Extract(tree, src)
syms, err := s.Extract(tree, src)
if err == nil {
fillCallColumns(string(lang), syms, src)
}
return syms, err
}

// ExtractImports is a convenience analogous to Extract.
Expand Down
46 changes: 46 additions & 0 deletions strategies/callcolumns_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package strategies_test

import (
"testing"

"github.com/provasign/astkit"
)

// Each call site carries the 1-based column of the called name: an edit
// list can then name one token, even with two same-named calls on a line.
// A name inside a string is not a call.
func TestCallSitesCarryTheNameColumn(t *testing.T) {
for _, c := range []struct {
lang astkit.LanguageKey
src string
fn string
want map[string][]int // callee -> columns
}{
{astkit.LangPython, "def f(w):\n w.value(\"value(x)\"); w.value(2)\n", "f", map[string][]int{"w.value": {7, 28}}},
{astkit.LangGo, "package p\n\nfunc f(w W) {\n\tw.Value(1)\n}\n", "f", map[string][]int{"w.Value": {4}}},
{astkit.LangJava, "class C {\n void f(W w) { w.value(\"a\"); w.value(2L); }\n}\n", "C.f", map[string][]int{"w.value": {19, 33}}},
{astkit.LangRust, "fn f() {\n println!(\"x\");\n helper(1);\n}\n", "f", map[string][]int{"helper": {5}}},
} {
syms, _ := extract(t, c.lang, c.src)
var got map[string][]int
for _, s := range syms {
if s.QualifiedName == c.fn || s.Name == c.fn {
got = map[string][]int{}
for _, cs := range s.CallSites {
got[cs.Callee] = append(got[cs.Callee], cs.Col)
}
}
}
for callee, cols := range c.want {
if len(got[callee]) != len(cols) {
t.Errorf("%s %s: columns %v, want %v (all %v)", c.lang, callee, got[callee], cols, got)
continue
}
for i := range cols {
if got[callee][i] != cols[i] {
t.Errorf("%s %s: columns %v, want %v", c.lang, callee, got[callee], cols)
}
}
}
}
}
4 changes: 4 additions & 0 deletions symbol.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ type LineRange struct {
type CallSite struct {
Callee string `json:"callee"`
Line int `json:"line"`
// Col is the 1-based character column of the called name on Line
// (0 = unknown): with it a consumer edits exactly that token, even with
// several same-named calls on one line.
Col int `json:"col,omitempty"`
// Write marks an attribute site used as an assignment target. It is false
// for calls and ordinary reads, preserving older serialized indexes.
Write bool `json:"write,omitempty"`
Expand Down
Loading