fix(parser): emit call edges for generic calls in Scala, Go, C++ and Swift - #538
Conversation
extractCall switched on the callee node and handled only `identifier` and
`field_expression`, falling through to `default: return` for anything else.
tree-sitter-scala wraps any callee carrying explicit type arguments in a
`generic_function` node, so `f[T]()`, `obj.m[T](x)` and `Future[Int] { … }`
hit that default and emitted nothing at all — no edge, not even an
unresolved stub. `query callers` on such a method then answers
`total_edges: 0` with the `likely_unused` caveat, making a resolution
failure indistinguishable from dead code.
Unwrapping `generic_function` to its real callee lets the two existing cases
apply unchanged, so a generic call is recorded exactly like its plain
spelling — which is what the test asserts, pairing each shape with and
without type arguments.
The call patterns pinned `function:` to `(identifier)` / `(selector_expression)`,
and a Go call spelling ONE type argument matches neither — through two
different mechanisms, so fixing only the obvious one leaves half the calls
missing:
Zero[int]() obj.M[int]() -> function: (index_expression …)
OneArg[int](1) obj.N[int](1) -> type_conversion_expression, not a
call_expression at all
Two or more type arguments were never affected: `F[string, int](a, b)` keeps
a plain callee with a sibling `type_arguments` field, which is why the gap
looked like it did not exist.
Both shapes are genuinely ambiguous in the grammar — an index_expression
callee is also how `handlers[key]()` parses, and a conversion to a generic
type is spelled exactly like a one-argument generic call. Widening the
patterns therefore emits an unresolved stub for those too. That is safe for a
reason specific to Go rather than luck: a package-scope name is a func, a var
or a type and never two of them, so a stub named after a variable or a type
finds no function candidate and stays unresolved — and an unresolved target
is a placeholder that reverse walks ignore. The resolver test asserts exactly
that, alongside a real cross-file generic call that does resolve.
The C++ call query matched two callee shapes — a bare `identifier` and a `field_expression` with a `field_identifier` — and C++ has five. The three it missed emitted no call edge at all: generic<int>() -> function: (template_function …) obj.m<int>() / ptr->m<int>() -> field: (template_method …) ns::helper() / std::move(x) -> function: (qualified_identifier …) The last one is not about templates and is the larger of the two losses: every namespace-qualified free call in the language was dropped, so a codebase that qualifies consistently recorded almost no free calls at all. Rust already carries the equivalent `scoped_identifier` pattern; C++ now matches it, binding on the trailing name the call actually names.
Two losses in the same dispatch, the second far larger than the first. Adding type arguments reroutes ANY Swift call away from `call_expression`: `lowerGeneric<Int>()`, `obj.doThing<Int>()` and `MyType<Int>(x: 1)` all parse as a `constructor_expression` whose constructed type swallows the whole dotted path. Nothing in the query matched one, so every generic call — free function, member, or initializer — emitted nothing. The constructed type's own `type_identifier` children give the path back; the ones nested in a `type_arguments` list are the arguments and are excluded. The member branch was then gated to factory chains only, on the stated belief that the bare-identifier pattern stayed "authoritative for ordinary obj.method()". It never was: that pattern needs a `simple_identifier` as a DIRECT child of the call, and a member call nests it under a `navigation_expression`. So every plain `obj.method()` and `self.method()` emitted no edge either — Swift was recording free calls and factory chains and nothing in between. Removing the gate restores what every other language already does.
|
Closing the loop on the one claim in this PR that was an argument rather than a measurement. Removing the Swift member-call gate was justified by "every other language emits member calls, so Swift was the outlier" — but the gate's own comment existed to stop the graph being flooded, and there was no Swift corpus in the repo to check against. There is one off-repo: 78 files of real iOS app code plus vendored CocoaPods sources. Measured at
Two things fall out. The gate was hiding ~95% of Swift member calls. Only 97 of 2049 survived it — the factory-chain ones. A 5% member share was never plausible for Cocoa code, where message passing is the dominant idiom; the 55% after the fix is what iOS code actually looks like. It is not flooding. At 47.7 call edges per file Swift now sits below both Go corpora (51.0 and 102.0). Node counts are unchanged at 2455 — this recovers edges that were always implied by the source, and lands Swift in the same density band as a language nobody considers noisy. Method: extract each file with the real |
Fixes #537.
Four more extractors share the blind spot found in #534: a call query that pins the callee to a plain identifier node drops every call spelling explicit type arguments, because those grammars wrap the callee in a different node. The call emits no edge at all — not an unresolved stub — so
query callersanswerstotal_edges: 0with thelikely_unusedcaveat, and a resolution failure is indistinguishable from dead code.This is a recurring class here:
rust.gocarries a note that the turbofish version of it cost 5,301 unrecorded call sites.Every shape below was pinned by dumping the real parse tree and confirmed by running the real extractor before and after — no query was fixed on inference. One commit per language, each with its own
extractorVersionsbump so already-indexed files re-extract instead of keeping the edge-less graph.Scala —
generic_functionextractCallswitched on the callee and handled onlyidentifier/field_expression, falling through todefault: return. Any callee with type arguments is wrapped:f[T](),obj.m[T](x)andFuture[Int] { … }all emitted nothing. Unwrapping to the real callee lets the two existing cases apply unchanged.Go — two mechanisms, not one
The patterns pinned
function:to(identifier)/(selector_expression). A Go call with one type argument matches neither, via two different routes — fixing only the obvious one leaves half the calls missing:Zero[int](),obj.M[int]()function: (index_expression …)OneArg[int](1),obj.N[int](1)type_conversion_expression— not a call at allTwo or more type arguments were never affected (
F[string, int](a, b)keeps a plain callee with a siblingtype_argumentsfield), which is why the gap looked like it didn't exist.Both shapes are genuinely ambiguous in the grammar. An
index_expressioncallee is also howhandlers[key]()parses, and a conversion to a generic type is spelled exactly like a one-argument generic call. Widening therefore emits an unresolved stub for those too.That is safe for a reason specific to Go rather than luck: a package-scope name is a func, a var or a type — never two of them. A stub named after a variable or a type finds no function candidate and stays unresolved, and an unresolved target is a placeholder reverse walks ignore.
TestResolveGo_IndexAndConversionDoNotBindWronglyasserts no call edge in that fixture ever lands on a non-callable node, and its sibling asserts a real cross-file generic call still resolves.C++ — three of five callee shapes were missing
The query matched a bare
identifierand afield_expressionwith afield_identifier. C++ has five:generic<int>()→function: (template_function …)obj.m<int>()/ptr->m<int>()→field: (template_method …)ns::helper()/std::move(x)→function: (qualified_identifier …)The last is not about templates and is the larger loss: every namespace-qualified free call in the language was dropped, so a codebase that qualifies consistently recorded almost no free calls at all.
rust.goalready carries the equivalentscoped_identifierpattern; C++ now matches it.Swift — two losses, the second much larger
Adding type arguments reroutes any Swift call away from
call_expressionentirely:lowerGeneric<Int>(),obj.doThing<Int>()andMyType<Int>(x: 1)all becameconstructor_expression, which nothing in the query matched. The constructed type's owntype_identifierchildren give the dotted path back; the ones nested insidetype_argumentsare the arguments and are excluded.The second loss was not in the issue. The member branch was gated to factory chains only, on the stated belief that the bare-identifier pattern stayed "authoritative for ordinary
obj.method()". It never was — that pattern needs asimple_identifieras a direct child of the call, and a member call nests it under anavigation_expression. So every plainobj.method()andself.method()emitted no edge either:Swift was recording free calls and factory chains and nothing in between. Removing the gate restores what every other language already does, and the generic fix would have been incoherent without it —
o.doThing<Int>()resolving whileo.doThing()did not.Verified clean, and why
Probed the same way and left alone: typescript (.ts + .tsx), java, kotlin, rust, dart, c, php. In TS and Java the type arguments are a sibling field of the callee; in Kotlin they live inside
call_suffix; Dart's sit insideargument_part; C and PHP have no generic call syntax.Verification
go test ./internal/...passes in full;golangci-lintreports 0 issues. Each language's test pairs the plain and generic spellings of every call shape and asserts they behave identically — the property that was actually violated, and the one that makes the regression hard to reintroduce quietly.Note for merging
Touches the same
extractorVersionsmap as #536, so whichever merges second will want a one-line conflict resolution. The two PRs are otherwise independent.