From 29d9c222137e15ae83834399c588a6964e54e1d6 Mon Sep 17 00:00:00 2001 From: "Tapabrata \"Topo\" Pal" Date: Mon, 12 Oct 2026 16:32:08 -0400 Subject: [PATCH] call sites carry the called name's column A rename edit list built from call sites needs the token, not just the line: two same-named calls on one line, or a name also present in a string, made line-keyed edits rewrite the wrong text. Extract now fills CallSite.Col (1-based characters) for every language in one pass: the callee's last segment found on its line in the masked code (comments and strings blanked), as an identifier followed by '(', '<' or '!'; several same-named calls on a line take the occurrences in order; 0 when not found. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KXa6Cv1ZUXcdMqXcJfQFhX --- callcolumns.go | 91 ++++++++++++++++++++++++++++++++++ engine.go | 6 ++- strategies/callcolumns_test.go | 46 +++++++++++++++++ symbol.go | 4 ++ 4 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 callcolumns.go create mode 100644 strategies/callcolumns_test.go diff --git a/callcolumns.go b/callcolumns.go new file mode 100644 index 0000000..0d77b16 --- /dev/null +++ b/callcolumns.go @@ -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("(= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9' || b >= 0x80 +} diff --git a/engine.go b/engine.go index c9ad9ed..ead3cfc 100644 --- a/engine.go +++ b/engine.go @@ -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. diff --git a/strategies/callcolumns_test.go b/strategies/callcolumns_test.go new file mode 100644 index 0000000..73d642f --- /dev/null +++ b/strategies/callcolumns_test.go @@ -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) + } + } + } + } +} diff --git a/symbol.go b/symbol.go index 9969173..d2a8000 100644 --- a/symbol.go +++ b/symbol.go @@ -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"`