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
62 changes: 62 additions & 0 deletions strategies/extractors.go
Original file line number Diff line number Diff line change
Expand Up @@ -1860,9 +1860,45 @@ func qualLast(path string) string {
func extractJavaNodes(root *sitter.Node, filePath, blobSHA string, src []byte, imports []string) []astkit.Symbol {
var out []astkit.Symbol
javaVisit(root, filePath, blobSHA, src, imports, "", &out)
javaReattachOrphans(out)
return out
}

// javaReattachOrphans gives a member with no enclosing type the innermost
// type whose span contains it. Java has no top-level methods or fields: a
// parentless one comes from error recovery splitting a class body around a
// broken statement mid-edit (15 BeanDeserializer methods lost their class
// after an unterminated char literal, 2026-10-14).
func javaReattachOrphans(out []astkit.Symbol) {
for i := range out {
m := &out[i]
if m.ParentName != "" || (m.Kind != astkit.KindMethod && m.Kind != astkit.KindConstructor && m.Kind != astkit.KindField) {
continue
}
best := -1
for j := range out {
t := out[j]
switch t.Kind {
case astkit.KindClass, astkit.KindInterface, astkit.KindEnum:
default:
continue
}
if t.Span.Start > m.Span.Start || t.Span.End < m.Span.End {
continue
}
if best < 0 || t.Span.End-t.Span.Start < out[best].Span.End-out[best].Span.Start {
best = j
}
}
if best < 0 {
continue
}
owner := out[best]
m.ParentName = owner.Name
m.QualifiedName = owner.QualifiedName + "." + m.Name
}
}

func javaVisit(node *sitter.Node, filePath, blobSHA string, src []byte, imports []string, parentClass string, out *[]astkit.Symbol) {
for i := 0; i < int(node.ChildCount()); i++ {
n := node.Child(i)
Expand All @@ -1882,11 +1918,20 @@ func javaVisit(node *sitter.Node, filePath, blobSHA string, src []byte, imports
if parentClass == "" {
continue
}
if name := n.ChildByFieldName("name"); name == nil || javaReservedWord[name.Content(src)] {
continue // recovered from statements, not a declaration
}
javaMethodDecl(n, astkit.KindMethod, filePath, blobSHA, src, imports, parentClass, out)
case "constructor_declaration":
if parentClass == "" {
continue
}
// A constructor is named after its class. Error recovery reads
// `if (x) {` after an unterminated literal as a constructor
// named "if" (jackson BeanDeserializer edit, 2026-10-14).
if name := n.ChildByFieldName("name"); name == nil || name.Content(src) != parentClass[strings.LastIndexByte(parentClass, '.')+1:] {
continue
}
javaMethodDecl(n, astkit.KindConstructor, filePath, blobSHA, src, imports, parentClass, out)
case "compact_constructor_declaration":
if parentClass == "" {
Expand Down Expand Up @@ -2265,7 +2310,24 @@ func javaLambdaDecl(n *sitter.Node, filePath, blobSHA string, src []byte, import
javaNestedExecutables(n, filePath, blobSHA, src, imports, parentClass, out)
}

// javaReservedWord holds Java keywords and literals that can never be a
// declared name or a field's type. Error recovery around a broken statement
// (`return bean;`, `if (...) {`) produces such "declarations".
var javaReservedWord = map[string]bool{
"abstract": true, "assert": true, "break": true, "case": true, "catch": true, "class": true,
"const": true, "continue": true, "default": true, "do": true, "else": true, "enum": true,
"extends": true, "final": true, "finally": true, "for": true, "goto": true, "if": true,
"implements": true, "import": true, "instanceof": true, "interface": true, "native": true,
"new": true, "package": true, "private": true, "protected": true, "public": true,
"return": true, "static": true, "strictfp": true, "super": true, "switch": true,
"synchronized": true, "this": true, "throw": true, "throws": true, "transient": true,
"try": true, "volatile": true, "while": true, "true": true, "false": true, "null": true,
}

func javaFieldDecl(n *sitter.Node, filePath, blobSHA string, src []byte, imports []string, parentClass string, out *[]astkit.Symbol) {
if t := n.ChildByFieldName("type"); t != nil && javaReservedWord[strings.TrimSpace(t.Content(src))] {
return // `return bean;` recovered as a field of type "return"
}
raw := n.Content(src)
// Full header, not FirstLine: an annotation on its own line before the
// field made FirstLine return just "@Deprecated" as the signature.
Expand Down
36 changes: 34 additions & 2 deletions strategies/metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -772,10 +772,14 @@ func rustCallSites(body *sitter.Node, src []byte) []astkit.CallSite {
// SearcherTester::new(...).line_number(false) gives
// line_number the "new()" call-result qualifier
// instead of arriving bare.
qual := qualifierName(fn.ChildByFieldName("value"), src,
value := fn.ChildByFieldName("value")
qual := qualifierName(value, src,
[]string{"identifier", "self"},
map[string]string{"field_expression": "field", "scoped_identifier": "name"},
map[string]string{"call_expression": "function"})
if qual == "" {
qual = rustExprReceiver(value)
}
return joinQualified(qual, string(field.Content(src)))
}
case "scoped_identifier":
Expand Down Expand Up @@ -845,7 +849,8 @@ func rustMacroCallSites(body *sitter.Node, src []byte) []astkit.CallSite {
if tt := internalast.FindChildByType(n, "token_tree"); tt != nil {
// Comments and string/char literals in the token tree
// are prose, not calls: `assert!(ok, "see bar(1)")`.
text := textmask.Mask("rust", tt.Content(src))
raw := tt.Content(src)
text := textmask.Mask("rust", raw)
line := int(n.StartPoint().Row) + 1
for _, m := range rustMacroCallRe.FindAllStringSubmatchIndex(text, -1) {
path := text[m[2]:m[3]]
Expand All @@ -857,6 +862,8 @@ func rustMacroCallSites(body *sitter.Node, src []byte) []astkit.CallSite {
} else if i := strings.LastIndexByte(path, '.'); i >= 0 {
name = path[i+1:]
qual = rustLastSegment(path[:i])
} else if m[0] >= 2 && raw[m[0]-1] == '.' && raw[m[0]-2] == '"' {
qual = rustStrReceiver // `"match:fg:magenta".parse()` in vec![...]
} else if q := rustMacroChainQualifier(text, m[0]); q != "" {
// `ig.matched("", false).is_ignore()`: the regex
// scan (no nested-structure awareness) matches
Expand Down Expand Up @@ -1852,3 +1859,28 @@ func findChildByType(n *sitter.Node, typeName string) *sitter.Node {
}
return nil
}

// Receivers that are not a path: a string literal is a str and a range
// index (`doc[start..]`) a slice, so the call is a std method unless the
// repository implements one on those types. Left bare, the call matched
// every same-named function and method (ripgrep: "test".as_bytes(),
// doc[start..].find(...)).
const (
rustStrReceiver = "<str>"
rustSliceReceiver = "<slice>"
)

func rustExprReceiver(value *sitter.Node) string {
if value == nil {
return ""
}
switch value.Type() {
case "string_literal", "raw_string_literal":
return rustStrReceiver
case "index_expression":
if internalast.FindChildByType(value, "range_expression") != nil {
return rustSliceReceiver
}
}
return ""
}
24 changes: 24 additions & 0 deletions strategies/metadata_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1479,3 +1479,27 @@ func TestJava_AnonymousClassesAsLambdaBodiesAndEnumArguments(t *testing.T) {
t.Errorf("anonymous=%d getUrl=%d lambdas=%d; want 3, 1, 3", anon, getURL, lambdas)
}
}

// A method call on a string literal or a range index keeps a receiver
// marker instead of arriving bare: `"x".as_bytes()` is a str method and
// `doc[start..].find(..)` a slice method, not any same-named function.
func TestRust_LiteralAndSliceReceiversAreMarked(t *testing.T) {
src := `fn f(doc: &str, start: usize) {
let a = "test".as_bytes();
let b = doc[start..].find('}');
let c = vec!["x".parse().unwrap()];
}
`
syms, _ := extract(t, astkit.LangRust, src)
got := map[string]bool{}
for _, s := range syms {
for _, cs := range s.CallSites {
got[cs.Callee] = true
}
}
for _, want := range []string{"<str>.as_bytes", "<slice>.find", "<str>.parse"} {
if !got[want] {
t.Errorf("missing %q in %v", want, got)
}
}
}
142 changes: 134 additions & 8 deletions textmask/textmask.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,13 @@
// Known gaps, all heuristic boundaries a lexer cannot settle without a
// parser:
// - JavaScript/TypeScript regex literals are told from division by the
// previous token. `)` and `]` mean division (so `if (x) /re/.test(s)` is
// misread), `}` means regex, except `{x} />` (a JSX self-closing tag).
// A TypeScript non-null `x! / 2` is division.
// - JSX/TSX text between tags is lexed as code: an apostrophe in
// `<p>Don't</p>` opens a quote that runs to the end of that line, and
// `//` in `<a>http://x</a>` starts a comment. A quoted string right
// after `=` may span lines (a JSX attribute value).
// previous token. `]` means division, `)` too unless it closes an
// if/while/for/with condition, `}` means regex, except `{x} />` (a JSX
// self-closing tag). A TypeScript non-null `x! / 2` is division.
// - JSX elements are recognised where an expression starts, in TSX and
// JavaScript (not plain TypeScript, where `<T>x` is a type assertion);
// their text children are literal text. JSX that does not close
// masks to the end of the source.
// - Swift regex literals (`/.../`, `#/.../#`) are not recognized.
// - C/C++ strings inside `#define` bodies are masked although tree-sitter
// treats a macro body as opaque text; `#error`/`#warning` text is kept.
Expand Down Expand Up @@ -416,6 +416,11 @@ func (s *scanner) code(i int, closer byte, fmtStop bool) int {
if s.fam == famJS {
s.regexOK = true
}
// condParens marks each open '(' that follows if/while/for/with: after
// its ')' a statement starts, so `/` opens a regex (`if (x) /re/.test(s)`
// was read as division).
var condParens []bool
lastWord := ""
for i < n {
c := s.src[i]
switch c {
Expand All @@ -424,6 +429,10 @@ func (s *scanner) code(i int, closer byte, fmtStop bool) int {
continue
case '(', '[', '{':
depth++
if c == '(' {
condParens = append(condParens, lastWord == "if" || lastWord == "while" || lastWord == "for" || lastWord == "with")
}
lastWord = ""
i++
s.regexOK = true
continue
Expand All @@ -434,8 +443,14 @@ func (s *scanner) code(i int, closer byte, fmtStop bool) int {
if depth > 0 {
depth--
}
cond := false
if c == ')' && len(condParens) > 0 {
cond = condParens[len(condParens)-1]
condParens = condParens[:len(condParens)-1]
}
lastWord = ""
i++
s.regexOK = c == '}'
s.regexOK = c == '}' || cond
continue
case ':':
if fmtStop && depth == 0 && !s.at(i+1, ":") && !s.at(i-1, ":") {
Expand All @@ -459,12 +474,14 @@ func (s *scanner) code(i int, closer byte, fmtStop bool) int {
if s.fam == famJS {
s.regexOK = jsRegexKeyword(s.src[i:k])
}
lastWord = s.src[i:k]
i = k
continue
}
if isDigit(c) {
i = s.number(i)
s.regexOK = false
lastWord = ""
continue
}
j := s.token(i)
Expand All @@ -474,9 +491,11 @@ func (s *scanner) code(i int, closer byte, fmtStop bool) int {
if j > i {
i = j
s.regexOK = false
lastWord = ""
continue
}
// Plain punctuation.
lastWord = ""
if s.fam == famJS {
s.regexOK = jsRegexAfter(c)
if c == '!' && i > 0 && (isIdentByte(s.src[i-1]) || s.src[i-1] == ')' || s.src[i-1] == ']') {
Expand Down Expand Up @@ -641,6 +660,15 @@ func (s *scanner) token(i int) int {
if s.regexOK && !(next == '>' && s.prevByte(i) == '}') {
return s.jsRegex(i)
}
case '<':
// Where an expression starts, `<` is never a comparison: in
// TSX/JSX it opens an element, whose text children are prose
// (`<code>src/*.ts</code>` opened a block comment to EOF, an
// apostrophe or backtick a literal). Plain TypeScript reads
// `<T>x` as a type assertion, so it is excluded.
if s.regexOK && s.lang != "typescript" && s.jsxTagStart(i) {
return s.jsxElement(i)
}
}
case famPython:
switch c {
Expand Down Expand Up @@ -1561,3 +1589,101 @@ func (s *scanner) jcl() {
ls = le + 1
}
}

// jsxTagStart reports whether `<` at i opens a JSX element or fragment: `<>`
// or a tag name followed by whitespace, `>`, `/` or an attribute — never a
// generic parameter list (`<T,>(x) =>`).
func (s *scanner) jsxTagStart(i int) bool {
k := i + 1
if k < len(s.src) && s.src[k] == '>' {
return true // fragment
}
if k >= len(s.src) || !isIdentStart(s.src[k]) {
return false
}
for k < len(s.src) && (isIdentByte(s.src[k]) || s.src[k] == '.' || s.src[k] == '-' || s.src[k] == ':') {
k++
}
if k >= len(s.src) {
return false
}
switch s.src[k] {
case ' ', '\t', '\n', '\r', '>', '/':
return true
}
return false
}

// jsxTag lexes an opening or self-closing tag from `<` at i: attribute
// strings are literals, `{...}` attribute values are code. It returns the
// offset after the tag and whether it closed itself.
func (s *scanner) jsxTag(i int) (int, bool) {
n := len(s.src)
k := i + 1
for k < n {
switch c := s.src[k]; c {
case '"', '\'':
k = s.quoted(k, k, c, false, true)
case '{':
k = s.hole(k+1, '}')
case '/':
if k+1 < n && s.src[k+1] == '>' {
return k + 2, true
}
k++
case '>':
return k + 1, false
default:
k++
}
}
return n, false
}

// jsxElement lexes a JSX element from `<` at i through its closing tag:
// text children are literal text, `{...}` children are code, nested
// elements are followed. It returns the offset after the element.
func (s *scanner) jsxElement(i int) int {
n := len(s.src)
k, self := s.jsxTag(i)
if self {
return k
}
depth, seg := 1, k
for k < n {
switch s.src[k] {
case '{':
s.lit(seg, k)
k = s.hole(k+1, '}')
seg = k
case '<':
s.lit(seg, k)
if k+1 < n && s.src[k+1] == '/' {
j := strings.IndexByte(s.src[k:], '>')
if j < 0 {
return n
}
k += j + 1
seg = k
if depth--; depth == 0 {
return k
}
continue
}
if !s.jsxTagStart(k) {
k++ // a stray `<` in text
seg = k - 1
continue
}
j, closed := s.jsxTag(k)
k, seg = j, j
if !closed {
depth++
}
default:
k++
}
}
s.lit(seg, n)
return n
}
Loading
Loading