From a24619cf5f4d2e50fce34fe239cb4edac46e07a8 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Wed, 21 Oct 2026 23:38:21 -0500 Subject: [PATCH] fix(go): a call further along a chain links the method of the type the chain reaches A Go method called on a receiver the extractor keeps no name for, such as a fluent chain's later link (model.WithContext(0).Where(1).First()), a call on what a function returns (time.Now().UTC().Format(...)) or a composite literal (GitOracle{}.Worktree()), reached the resolver as its bare name at the column where the chain starts. Name matching then took a project method of that name by proximity, or the only one: the issue's GORM-style Where and First went to another query type, the standard library's Format to the one project Format, and a literal's method to a namesake type. A new Go resolver step reads the chain at that column and types the receiver link by link: a call's result, or the one a multi-value assignment takes; a conversion's or a composite literal's type; a generic call given its type arguments; an embedded field by its type's name; a package-level variable from the call it is set to. A result written with type parameters keeps them as parameters, read from the declaration, since the indexed signature leaves them out, and the helper that reads them now includes a one-line function's only line. The method is the one Go selects on that type. A type from outside the project, or one that can't be read, links nothing instead of a guess. When a chain calls one name twice, the bare ref is the later call. Validation: 8 new tests; 7 fail on main, and the repeated-name case fails under a first-link rule; the 43 Go test files pass. A/B against main c326b6be, edges removed/added: gin 80/72, etcd 1726/1327, prometheus 2249/1265, sonic (gorm/gen) 910/877, kubernetes 88288/84331. Every removed edge was a name-match guess except 173 in kubernetes: 171 are the same edge now owned by another ref of its chain, and 2 wrong links of metav1.Now().Time.Unix(). Sampled retargets, drops and additions read correct; express and flask dumps are byte-identical. Indexing cost is within noise, median of 3 interleaved runs, wall/CPU seconds main -> fix: etcd 9.5/28.6 -> 8.6/25.4, prometheus 10.5/42.1 -> 10.5/42.6, kubernetes 121.9/413.7 -> 121.3/416.8. Fixes #2543 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/go-chained-receivers.test.ts | 370 +++++++++++++++++++++++++ src/resolution/index.ts | 7 +- src/resolution/name-matcher.ts | 254 +++++++++++++++-- 4 files changed, 611 insertions(+), 21 deletions(-) create mode 100644 __tests__/go-chained-receivers.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 08981f81c..722fe254e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Go, a call made through a type assertion, like `srv.(KVServer).Range(ctx, in)` in generated gRPC code, `c.Reader.(*pipe).Close()` or `v.(storage.Store).Fetch(key)`, now links to that method of the asserted type, including one the type gets from a struct or interface it embeds, and to nothing when the type comes from outside your project. Before, it linked to whichever method shared the name, so every gRPC handler pointed at the `Unimplemented…Server` stub instead of the server interface. Re-index Go projects after upgrading. - In Go, a call through a variable that holds the result of a type assertion, like `st.Fetch(key)` after `st := v.(storage.Store)` or `rc.Read(p)` after `rc, ok := v.(ReadCloser)`, now links to that method of the asserted type, as a call made through the assertion itself does, including one the type gets from a struct or interface it embeds, and to nothing when the type comes from outside your project. Before, such a call linked to whichever method shared the name or to nothing, and a variable named like a standard-library package, like grpc-go's `parser, ok := builder.(balancer.ConfigParser)`, was taken for that package. Re-index Go projects after upgrading. - In Go, a method called on an element of a slice, array or map, like kubectl's `schemas[ix].ValidateBytes(data)`, `pods[i].DeepCopy()`, `m.Items[len(m.Items)-1].Unmarshal(…)` in generated protobuf code or `(*in)[i].DeepCopyInto(&(*out)[i])` in every deepcopy file, now links to that method of the element's type. The type is read from what is indexed: a parameter, a variable, a struct field or a named slice type like `type ConjunctiveSchema []Schema`, and an element held by pointer, like `[]*Item`, reaches `Item`'s methods. Before, such a call linked to whichever method shared its name, often one in an unrelated package, or to nothing when many types declare it, as with Kubernetes' `DeepCopyInto`. An element whose type comes from outside your project, like `[]*net.IPNet`, or is a built-in one like `[]error`, now links to nothing rather than to a project method of that name. Re-index Go projects after upgrading. +- In Go, a method called further along a chain now links to that method of the type the chain has reached, like `Where` and `First` in a GORM-generated query such as `query.Card.WithContext(ctx).Where(…).First()`, a method called on what a function returns, like the client from `store.New(…)`, or one called on a composite literal, like `GitOracle{}.Worktree()`. The type is read from a function's or method's results, a conversion, a literal or an embedded field. Before, such a call was matched by its method's name alone, so a query's `Where` linked to another table's query type, a literal's method to a type that only shares its name, and `time.Now().UTC().Format(…)` to the one project method named `Format`. A chain whose type comes from outside your project, or can't be worked out, now links to nothing rather than to a guess. Re-index Go projects after upgrading. Thanks @BX-D for the report and @inth3shadows for the extra cases. (#2543) - In Go, a method a type declares in a file too large to index, like the `String`, `Marshal` and `Unmarshal` every Kubernetes API type has in `core/v1/generated.pb.go`, is no longer replaced by the method of that name a type it embeds has. Before, `pod.String()` on a `*v1.Pod` linked to `TypeMeta`'s `String`, a method the call never runs; it now links to nothing, since the pod's own method isn't in the graph. A method the type really gets from a type it embeds, like `GetName` from `ObjectMeta`, still links. Re-index Go projects after upgrading. - In Go, calls into a module whose `go.mod` is not at the project root now resolve, whether it is a `server/` backend next to a `web/` frontend or one of several modules side by side as in etcd, so calls like `store.New()` and `s.db.CreateItem()` find their targets instead of being treated as calls into a third-party package. A name written through a package, like a `job.OPCommand` result type or a field of type `artifact.Manager`, now links to that package's symbol rather than a same-named one elsewhere, which also corrects links in projects with a single `go.mod`. Re-index Go projects after upgrading. Thanks @GoDiao for the report and @danusha2345 for the fix. (#2322) - In Go, an exported type written without a package, like `Node` in `func Walk(v Visitor, node Node)`, now links to the type of that name in its own package, whether it is a struct, an interface or another kind of type. Before, it could link to a struct of the same name in an unrelated package, so prometheus's PromQL parser functions pointed at the Kubernetes discovery `Node` struct instead of the parser's own `Node` interface, or, for a result type like `Appender` in `func (f *fanout) Appender(…) Appender`, to the method itself. A type from a package outside your project, like `apiv1.Node` or `http.Handler`, no longer links to a struct of the same name in the file that uses it; a variadic parameter like `...storage.Filter` now links to that package's type; a route handler written as a method value, like `h.Follow` in an Echo or Gin app, links to that method rather than a model struct named `Follow`; and a method called on another call's result or on an indexed value, like `err[i].Error()` or a chained `.String()`, is no longer recorded as creating a struct of the same name. Re-index Go projects after upgrading. diff --git a/__tests__/go-chained-receivers.test.ts b/__tests__/go-chained-receivers.test.ts new file mode 100644 index 000000000..9f097df0a --- /dev/null +++ b/__tests__/go-chained-receivers.test.ts @@ -0,0 +1,370 @@ +/** + * A Go method call whose receiver the extractor keeps no name for — a fluent + * chain's later link (`model.WithContext(0).Where(1).First()`), a call on what + * a function returns (`time.Now().UTC().Format(…)`), a composite literal + * (`GitOracle{}.Worktree()`) — reaches the resolver by its bare method name. + * Name matching then picked one of the project's methods of that name by + * proximity: a GORM-style chain's `Where` and `First` went to another query + * type's, a standard-library chain's `Format` to the only project `Format`, + * and a composite literal's method to a namesake type's (#2543). + * + * The receiver is now typed: a call gives its function's or method's first + * result (or the one a multi-value assignment takes), a conversion and a + * composite literal their type, an embedded field its type. The method is the + * one Go selects on that type, and nothing when the type comes from outside + * the project or can't be read. + */ +import { describe, it, expect, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import CodeGraph from '../src/index'; + +const GO_MOD = 'module example.com/app\n\ngo 1.22\n'; + +let tempDir: string; +let cg: CodeGraph | null = null; + +afterEach(() => { + cg?.close(); + cg = null; + if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true }); +}); + +async function index(files: Record): Promise { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-2543-')); + for (const [rel, source] of Object.entries({ 'go.mod': GO_MOD, ...files })) { + fs.mkdirSync(path.dirname(path.join(tempDir, rel)), { recursive: true }); + fs.writeFileSync(path.join(tempDir, rel), source); + } + cg = await CodeGraph.init(tempDir, { index: true }); + cg.resolveReferences(); + return cg; +} + +/** ` ` of every `calls` / `instantiates` edge out of the function `fromName`, sorted. */ +function callees(graph: CodeGraph, fromName: string): string[] { + const from = graph.getNodesByKind('function').find((n) => n.name === fromName); + expect(from, fromName).toBeDefined(); + return graph + .getOutgoingEdges(from!.id) + .filter((e) => e.kind === 'calls' || e.kind === 'instantiates') + .map((e) => { + const target = graph.getNode(e.target); + return `${e.kind} ${target?.kind} ${target?.qualifiedName}`; + }) + .sort(); +} + +describe('Go method calls on a receiver the extractor names no variable for (#2543)', () => { + it("follows the issue's factory → field → fluent chain to the session query type", async () => { + const graph = await index({ + 'query/query.go': 'package query\n\ntype Query struct { Session Session }\n', + 'query/card.go': + 'package query\n\ntype CardDo struct{}\nfunc (c CardDo) Where(value int) CardDo { return c }\nfunc (c CardDo) First() int { return 1 }\n', + 'query/session.go': + 'package query\n\ntype Session struct{}\ntype SessionDo struct{}\n' + + 'func (s Session) WithContext(ctx int) SessionDo { return SessionDo{} }\n' + + 'func (s SessionDo) Where(value int) SessionDo { return s }\nfunc (s SessionDo) First() int { return 2 }\n', + 'mysql/conn.go': 'package mysql\n\nimport "example.com/app/query"\n\nfunc Query() *query.Query { return &query.Query{} }\n', + 'mysql/session.go': + 'package mysql\n\nfunc Find() int {\n model := Query().Session\n return model.WithContext(0).\n Where(1).\n First()\n}\n', + }); + expect(callees(graph, 'Find')).toEqual([ + 'calls function Query', + 'calls method Session::WithContext', + 'calls method SessionDo::First', + 'calls method SessionDo::Where', + ]); + }); + + it("calls the composite literal's own type's method", async () => { + const graph = await index({ + 'main.go': `package main + +type GitOracle struct{} + +func (GitOracle) Worktree() string { return "" } + +type FakeOracle struct{} + +func (FakeOracle) Worktree() string { return "" } + +func use() string { return GitOracle{}.Worktree() } + +func useParen() string { return (&FakeOracle{}).Worktree() } + +func main() { use(); useParen() } +`, + }); + expect(callees(graph, 'use').filter((e) => e.startsWith('calls'))).toEqual(['calls method GitOracle::Worktree']); + expect(callees(graph, 'useParen').filter((e) => e.startsWith('calls'))).toEqual(['calls method FakeOracle::Worktree']); + }); + + it('links nothing for a chain on a standard-library value', async () => { + const graph = await index({ + 'main.go': `package main + +import ( + "net/http/httptest" + "time" +) + +type Report struct{} + +func (r Report) Format() string { return "" } +func (r Report) Get(key string) string { return key } + +func stamp() string { return time.Now().UTC().Format(time.RFC3339) } + +func header() string { + w := httptest.NewRecorder() + return w.Header().Get("Content-Type") +} + +func main() { stamp(); header() } +`, + }); + expect(callees(graph, 'stamp')).toEqual([]); + expect(callees(graph, 'header')).toEqual([]); + }); + + it('types a call result, a multi-value result, a conversion and an embedded field', async () => { + const graph = await index({ + 'store/store.go': `package store + +type KV interface { + Get(key string) string +} + +type Client struct { + KV + name string +} + +func (c *Client) Txn() *Txn { return &Txn{} } + +type Txn struct{} + +func (t *Txn) If(ok bool) *Txn { return t } +func (t *Txn) Commit() error { return nil } + +type Response struct{} + +func (r *Response) Header() string { return "" } + +func New(name string) (*Client, error) { return &Client{name: name}, nil } + +func Begin() (int, *Txn) { return 0, &Txn{} } +`, + 'store/other.go': `package store + +type Other struct{} + +func (o *Other) If(ok bool) *Other { return o } +func (o *Other) Commit() error { return nil } +func (o *Other) Get(key string) string { return key } +func (o *Other) Header() string { return "" } +`, + 'app/app.go': `package app + +import "example.com/app/store" + +type holder struct{ cli *store.Client } + +func run(h holder, raw any) error { + cli, err := store.New("x") + if err != nil { + return err + } + _ = h.cli.KV.Get("a") + _ = (*store.Response)(nil).Header() + _, txn := store.Begin() + _ = txn.If(true).Commit() + return cli.Txn().If(true).Commit() +} +`, + }); + expect(callees(graph, 'run').filter((e) => e.startsWith('calls'))).toEqual([ + 'calls function Begin', + 'calls function New', + 'calls method Client::Txn', + 'calls method KV::Get', + 'calls method Response::Header', + 'calls method Txn::Commit', + 'calls method Txn::Commit', + 'calls method Txn::If', + 'calls method Txn::If', + ]); + }); + + it('types a package-level variable from the call it is set to', async () => { + const graph = await index({ + 'scheme/scheme.go': `package scheme + +type Scheme struct{} + +func New() *Scheme { return &Scheme{} } + +func (s *Scheme) Test(name string) *Tester { return &Tester{} } + +type Tester struct{} + +func (t *Tester) Value(v any) *Tester { return t } +func (t *Tester) ExpectValid() {} +`, + 'scheme/decoy.go': `package scheme + +type Decoy struct{} + +func (d *Decoy) Value(v any) *Decoy { return d } +func (d *Decoy) ExpectValid() {} +`, + 'pkg/doc.go': 'package pkg\n\nimport "example.com/app/scheme"\n\nvar localScheme = scheme.New()\n', + 'pkg/doc_test.go': `package pkg + +func check() { + localScheme.Test("x").Value(1).ExpectValid() +} +`, + }); + expect(callees(graph, 'check')).toEqual([ + 'calls method Scheme::Test', + 'calls method Tester::ExpectValid', + 'calls method Tester::Value', + ]); + }); + + it('types a generic call and a generic literal given their type arguments', async () => { + const graph = await index({ + 'lib/lib.go': `package lib + +type ErrorList []string + +func (l ErrorList) MarkShortCircuit() ErrorList { return l } + +func PtrNoNils[T any](v *T) ErrorList { return nil } + +func Identity[T any](v T) T { return v } + +type Validator interface{ Validate() error } + +func Pick[V Validator](vs []V) V { return vs[0] } + +type List[T any] struct{ items []T } + +func (l List[T]) Len() int { return len(l.items) } +`, + 'lib/decoy.go': `package lib + +type Other struct{} + +func (Other) MarkShortCircuit() Other { return Other{} } +func (Other) Len() int { return 0 } +func (Other) Validate() error { return nil } + +// Named like Identity's type parameter. +type T struct{} + +func (T) Run() {} +`, + 'app/app.go': `package app + +import "example.com/app/lib" + +type A struct{} + +func (A) Run() {} + +type Report struct{} + +func (Report) Mark() {} + +type Box[T any] struct{ v T } + +func (b Box[T]) Size() int { return 0 } + +type Decoy struct{} + +func (Decoy) Run() {} +func (Decoy) Mark() {} +func (Decoy) Size() int { return 0 } + +type Probe struct{} + +func (Probe) Validate() error { return nil } + +func wrap[T any](v T) Report { return Report{} } + +func Check(v *int, probes []Probe) int { + lib.PtrNoNils[int](v).MarkShortCircuit() + lib.Identity[A](A{}).Run() + wrap[int](1).Mark() + _ = lib.Pick(probes).Validate() + return lib.List[int]{}.Len() + Box[int]{}.Size() +} +`, + }); + // `Identity`'s result is its type parameter, not the type `lib` has of + // that name; `Pick`'s has the methods of its constraint. + expect(callees(graph, 'Check').filter((e) => e.startsWith('calls method'))).toEqual([ + 'calls method Box::Size', + 'calls method ErrorList::MarkShortCircuit', + 'calls method List::Len', + 'calls method Report::Mark', + 'calls method Validator::Validate', + ]); + }); + + it('reads a later call of a name the chain already called on another type', async () => { + const graph = await index({ + 'main.go': `package main + +type A struct{} + +func (a *A) Next() *B { return &B{} } + +type B struct{} + +func (b *B) Next() *C { return &C{} } + +type C struct{} + +func walk(a *A) *C { return a.Next().Next() } + +func main() { walk(&A{}) } +`, + }); + expect(callees(graph, 'walk')).toEqual(['calls method A::Next', 'calls method B::Next']); + }); + + it("links nothing when the receiver's type can't be read, rather than guess by name", async () => { + const graph = await index({ + 'main.go': `package main + +type A struct{} + +func (A) Run() {} + +type B struct{} + +func (B) Run() {} + +type holder struct{ get func() A } + +func use(h holder) { + h.get().Run() +} + +func useLiteral() { + var cfg struct{ inner struct{ a A } } + cfg.inner.a.Run() +} + +func main() { use(holder{}); useLiteral() } +`, + }); + expect(callees(graph, 'use')).toEqual([]); + expect(callees(graph, 'useLiteral')).toEqual([]); + }); +}); diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 8c5a5899b..5913fab80 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -21,7 +21,7 @@ import { isImportableKind, CPP_DEFINE_SIGNATURE, } from './types'; -import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, matchObjectPathCall, thisScopeCaller, isVisibleAcrossFiles, matchReference, matchByFilePath, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, isRustForeignImplCallable, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead, isDartChainLink, matchDartChainLink, isDartAnnotation, matchDartAnnotation, isStdMethodName, isGoUnknownQualified, isGoProjectLocalCall, GO_STDLIB_PACKAGES, isGoBareName, goTypePositionTarget, goDotImportedSupertype, GO_TYPE_KINDS, matchGoAssertedCall, matchGoIndexedCall, isGoAssertedLocal, isGoLocallyBound, isPythonFromImportModule, pythonAttrRefResolves, isImportShadowedByJsBinding } from './name-matcher'; +import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, matchObjectPathCall, thisScopeCaller, isVisibleAcrossFiles, matchReference, matchByFilePath, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, isRustForeignImplCallable, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead, isDartChainLink, matchDartChainLink, isDartAnnotation, matchDartAnnotation, isStdMethodName, isGoUnknownQualified, isGoProjectLocalCall, GO_STDLIB_PACKAGES, isGoBareName, goTypePositionTarget, goDotImportedSupertype, GO_TYPE_KINDS, matchGoAssertedCall, matchGoIndexedCall, matchGoChainedCall, isGoAssertedLocal, isGoLocallyBound, isPythonFromImportModule, pythonAttrRefResolves, isImportShadowedByJsBinding } from './name-matcher'; import { isVisibleCppMacro, clearCppMacroVisibility } from './cpp-macro-visibility'; import { isCppConstructorRef, matchCppConstructor } from './cpp-constructor'; import { isCppSupertypeRef, matchCppSupertype, clearCppSupertypeMemos } from './cpp-supertypes'; @@ -1422,6 +1422,11 @@ export class ReferenceResolver { // deepcopy's `(*in)[i].DeepCopyInto(…)`): to a method of the element type. const indexed = matchGoIndexedCall(ref, this.context); if (indexed !== undefined) return indexed; + // And one on any other receiver the extractor keeps no name for: a + // fluent chain's later link, a call's result, a composite literal + // (`model.WithContext(0).Where(1)`, `GitOracle{}.Worktree()`, #2543). + const chained = matchGoChainedCall(ref, this.context); + if (chained !== undefined) return chained; // A section or snippet a Shopify theme's file names is that theme's own, // or nothing: Shopify never looks in another theme (see ./shopify-themes). diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 96524c2b1..d4a0d05a1 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -20497,8 +20497,8 @@ function goAssertedType(ref: UnresolvedRef, context: ResolutionContext): string } interface GoChainLink { - kind: 'operand' | 'select' | 'assert' | 'call' | 'index'; - /** An operand's or selector's name, an assertion's type, what a call's or an index's brackets hold. */ + kind: 'operand' | 'select' | 'assert' | 'call' | 'index' | 'composite'; + /** An operand's or selector's name, an assertion's type, what a call's, an index's or a composite literal's brackets hold. */ text: string; } @@ -20507,8 +20507,10 @@ interface GoChainLink { * or a parenthesized expression), then each selector, type assertion, call * and index. A line break ends it, except right after a `.`, where Go * inserts no semicolon. `end.at` receives the offset reading stopped at. + * With `composite`, a composite literal's braces right after a type + * (`GitOracle{}`, `query.Card{…}`, `List[int]{}`) are a link too. */ -function goChainLinks(text: string, start: number, end?: { at: number }): GoChainLink[] { +function goChainLinks(text: string, start: number, end?: { at: number }, composite = false): GoChainLink[] { const links: GoChainLink[] = []; const NAME = /[A-Za-z_]\w*/y; let i = start; @@ -20548,6 +20550,13 @@ function goChainLinks(text: string, start: number, end?: { at: number }): GoChai const inner = group(); if (inner === null) break; link.text = inner; + } else if ( + composite && text[i] === '{' && text[start] !== '(' && links.length <= 3 && + links.every((l) => l.kind === 'operand' || l.kind === 'select' || l.kind === 'index') + ) { + const inner = group(); + if (inner === null) break; + links.push({ kind: 'composite', text: inner }); } else break; } if (end) end.at = i; @@ -20764,6 +20773,50 @@ export function matchGoIndexedCall(ref: UnresolvedRef, context: ResolutionContex return goMethodOnElement(receiver, ref, context); } +/** + * A Go method called on a receiver the extractor keeps no name for — a + * fluent chain's later link (`model.WithContext(0).Where(1).First()`), a + * call on what a function returns (`time.Now().UTC().Format(…)`, + * `query.Use(db).User.Where(…)`), a composite literal (`GitOracle{}.Worktree()`, + * `(MsgPack{data}).Render(w)`) or a field three links deep — arrives by its + * bare name, at the column where its chain starts (#2543). Name matching + * then picked one of the project's methods of that name by proximity: a + * GORM-style chain's `Where` and `First` went to another query type's, and + * a standard-library chain's `Format` to the only project `Format`. + * + * The receiver is typed as matchGoIndexedCall types one, and further: a + * call gives its function's or method's first result (goCalledType, + * goMethodResultType), a conversion `T(x)` and a composite literal their + * type. The method is then the one Go selects on that type, or nothing when + * the type comes from outside the project. A receiver that can't be typed + * — a func value's call, a generic result, a receiver the index doesn't + * hold — links nothing either: guessing by the name is what picked the + * wrong query type. Undefined only when the call is not such a chain link. + */ +export function matchGoChainedCall(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null | undefined { + if (ref.language !== 'go' || ref.referenceKind !== 'calls' || !/^[A-Za-z_]\w*$/.test(ref.referenceName)) return undefined; + const index = goScopeIndex(ref.filePath, context); + if (!index || ref.column < 0) return undefined; + const at = (index.lineStarts[ref.line - 1] ?? index.code.length) + ref.column; + const code = index.code; + // A column inside a name is no expression's start, and a call written at + // its own name calls a function. + if (at > 0 && /\w/.test(code[at - 1]!)) return undefined; + const name = ref.referenceName; + if (code.startsWith(name, at) && /^\s*\(/.test(code.slice(at + name.length, at + name.length + 16))) return undefined; + // Every call of a chain shares its column. A first link the extractor + // names (`x.M`, `a.b.M`, `F().M`) arrives under that name, so a bare one + // of the same name is a later link: the last `.name(…)` is read. + const links = goChainLinks(code, at, undefined, true); + let k = -1; + for (let i = links.length - 2; i > 0 && k < 0; i--) { + if (links[i]!.kind === 'select' && links[i]!.text === name && links[i + 1]!.kind === 'call') k = i; + } + if (k < 0) return undefined; + const receiver = goReceiverChainType(links.slice(0, k), code[at] === '(', at, ref.filePath, index, context, 0); + return receiver ? goMethodOnElement(receiver, ref, context) ?? null : null; +} + /** * The method `ref` names on the type of its receiver. A pointer's is the * type pointed to's. @@ -20904,35 +20957,73 @@ function goNamedTypeDecls(t: GoTypeText, context: ResolutionContext): { name: st * reads. `paren` says the first link is a parenthesized expression. */ function goReceiverChainType( - links: GoChainLink[], paren: boolean, at: number, filePath: string, index: GoScopeIndex, + links: GoChainLink[], paren: boolean, at: number, filePath: string, index: GoScopeIndex | null, context: ResolutionContext, depth: number, + /** Which result of the chain's last link, a call returning several values, is wanted. */ + result = 0, ): GoTyped { const head = links[0]; if (!head || head.kind !== 'operand' || depth > GO_VALUE_DEPTH) return undefined; - // A call's result (`x.Items()[0]`) isn't typed here. - if (links.some((l) => l.kind === 'call')) return undefined; + // The result a call link gives: the one asked for when it ends the chain. + const resultOf = (k: number): number => (k === links.length - 1 ? result : 0); let t: GoTyped; let i = 1; - if (paren) { + if (paren && links[1]?.kind === 'call' && /^\*?\s*[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)?$/.test(head.text.trim())) { + // `(*pb.T)(r)`: a conversion to the type the parentheses hold, when they hold one. + const type = goTypeTextAt(head.text, filePath, at, index, context); + const decls = goNamedTypeDecls({ ...type, text: type.text.replace(/^\*/, '') }, context); + if (decls === null) return null; + if (!decls) return undefined; + t = type; + i = 2; + } else if (paren) { t = goExprType(head.text, at, filePath, index, context, depth + 1); } else { - const decl = goLocalDeclAt(index, head.text, at); - if (decl) { + const decl = index ? goLocalDeclAt(index, head.text, at) : undefined; + if (decl && index) { + // A func value's call (`run(x).M()`) is not typed here. + if (links[1]?.kind === 'call') return undefined; t = goDeclaredValueType(decl, filePath, index, context, depth + 1); } else if (context.getImportMappings(filePath, 'go').some((m) => m.localName === head.text)) { - // `pkg.Var`: a package-level variable of the package imported. + // `pkg.Var`, `pkg.New(…)`, `pkg.T{…}`, `pkg.T(x)`: a package-level + // variable, function or type of the package imported. if (links[1]?.kind !== 'select') return undefined; const dir = goImportPackageDir(head.text, filePath, context); if (dir == null) return dir === null && isGoOutsidePackage(head.text, filePath, context) ? null : undefined; - t = goPackageVarTypeText(links[1].text, dir, filePath, true, context, depth + 1); - i = 2; + // Type arguments written at the call or the literal: `pkg.F[T](…)`, `pkg.List[int]{…}`. + const args = goTypeArgsLink(links, 2); + const written = `${head.text}.${links[1].text}${args}`; + const at2 = args ? 3 : 2; + const next = links[at2]?.kind; + if (next === 'composite') t = goTypeTextAt(written, filePath, at, index, context); + else if (next === 'call') t = goCalledType(links[1].text, dir, written, filePath, true, at, index, context, depth + 1, resultOf(at2)); + else t = goPackageVarTypeText(links[1].text, dir, filePath, true, context, depth + 1); + i = next === 'composite' || next === 'call' ? at2 + 1 : 2; + } else if (links[1]?.kind === 'composite' || (links[1]?.kind === 'index' && links[2]?.kind === 'composite')) { + // `T{…}`, `List[int]{…}`. + const args = goTypeArgsLink(links, 1); + t = goTypeTextAt(`${head.text}${args}`, filePath, at, index, context); + i = args ? 3 : 2; + } else if (links[1]?.kind === 'call' || (links[1]?.kind === 'index' && links[2]?.kind === 'call')) { + // `new(T)`, `make(T, …)`, `append(xs, …)`, a function of the package, + // one given its type arguments (`F[T](…)`), a conversion `T(x)`. + const args = goTypeArgsLink(links, 1); + t = !args && /^(?:new|make|append)$/.test(head.text) + ? goExprType(`${head.text}(${links[1].text})`, at, filePath, index, context, depth + 1) + : goCalledType(head.text, goPackageDir(filePath), `${head.text}${args}`, filePath, false, at, index, context, depth + 1, resultOf(args ? 2 : 1)); + i = args ? 3 : 2; } else { t = goPackageVarTypeText(head.text, goPackageDir(filePath), filePath, false, context, depth + 1); } } + if (result > 0 && links[links.length - 1]?.kind !== 'call') return undefined; for (; i < links.length && t; i++) { const link = links[i]!; - if (link.kind === 'select') t = goFieldTypeOf(t, link.text, context, depth + 1); + if (link.kind === 'select' && links[i + 1]?.kind === 'call') { + // `x.M(…)`: what the method returns. + t = goMethodResultType(t, link.text, filePath, context, depth + 1, resultOf(i + 1)); + i++; + } else if (link.kind === 'select') t = goFieldTypeOf(t, link.text, context, depth + 1); else if (link.kind === 'index') t = goIsSliceIndex(link.text) ? goSlicedType(t, context, depth + 1) : goElementOf(t, context, depth + 1); // `.(T)` is a T, written where the expression is. else if (link.kind === 'assert' && link.text !== 'type') t = goTypeTextAt(link.text, filePath, at, index, context); @@ -20941,6 +21032,104 @@ function goReceiverChainType( return t; } +/** + * The type arguments a Go chain's link `k` gives the function or type before + * it, `[T]` of `F[T](…)` or `[int]` of `List[int]{…}`: an index link a call + * or a composite literal follows. Empty for any other link. + */ +function goTypeArgsLink(links: GoChainLink[], k: number): string { + const link = links[k]; + const next = links[k + 1]?.kind; + return link?.kind === 'index' && (next === 'call' || next === 'composite') ? `[${link.text}]` : ''; +} + +/** + * The type a Go call `name(…)` gives, `name` read in the package in `dir` + * (`written` as the call spells it): its function's first result, or the + * type itself for a conversion `T(x)`. A package's declarations of the + * function must agree (build tags); null for a function from outside the + * project, undefined for one the index doesn't hold or a result written + * with the function's type parameters. + */ +function goCalledType( + name: string, dir: string, written: string, fromFile: string, imported: boolean, at: number, index: GoScopeIndex | null, + context: ResolutionContext, depth: number, result = 0, +): GoTyped { + if (depth > GO_VALUE_DEPTH) return undefined; + const clause = imported ? undefined : goPackageClause(fromFile, context); + const testFile = (f: string): boolean => /_test\.go$/.test(f); + const fns = context.getNodesByName(name).filter((n) => + n.language === 'go' && n.kind === 'function' && goPackageDir(n.filePath) === dir && + (imported ? !testFile(n.filePath) : goPackageClause(n.filePath, context) === clause && (testFile(fromFile) || !testFile(n.filePath)))); + if (fns.length === 0) { + // `T(x)`: a conversion, when the package declares the type. + return result === 0 && goPackageTypes(name, dir, context).length > 0 ? goTypeTextAt(written, fromFile, at, index, context) : undefined; + } + let found: GoTyped; + for (const fn of fns) { + const t = goSignatureResultType(fn, context, result); + if (t === undefined || (found !== undefined && found?.text !== t.text)) return undefined; + found = t; + } + return found; +} + +/** + * The type a Go method call `x.name(…)` gives on an `x` of type t: the first + * result of the method Go selects (goMethodOnElement). Null when the method + * is no project type's (t comes from outside the project, or a project type + * gets it from an embedded outside one): what it returns is unknown to the + * project too. + */ +function goMethodResultType(t: GoTypeText, name: string, filePath: string, context: ResolutionContext, depth: number, result = 0): GoTyped { + if (depth > GO_VALUE_DEPTH) return undefined; + const probe: UnresolvedRef = { + fromNodeId: '', referenceName: name, referenceKind: 'calls', line: 1, column: 0, filePath, language: 'go', + }; + const method = goMethodOnElement(t, probe, context); + if (!method) return method; + const node = context.getNodeById?.(method.targetNodeId); + return node ? goSignatureResultType(node, context, result) : undefined; +} + +/** + * The first result type of a Go function or method node, read in its own + * file: `CardDo` of `(value int) CardDo`, `*Response` of `(ctx context.Context) + * (*Response, error)`, `int` of `() (n int, err error)`. Undefined for none. + * The indexed signature leaves a generic function's `[T any]` out, so the + * type parameters in scope at its declaration are read there and kept with + * the result: `T` of `func Get[T any]() T` is no package's type, while + * `Set[T]` is a `Set`. + */ +function goSignatureResultType(fn: Node, context: ResolutionContext, result = 0): GoTypeText | undefined { + const sig = fn.signature?.trim(); + if (!sig || sig[0] !== '(') return undefined; + const close = goClosingBracket(sig, 0); + if (close < 0) return undefined; + let text = sig.slice(close + 1).trim(); + if (!text) return undefined; + if (text[0] === '(') { + const end = goClosingBracket(text, 0); + if (end < 0) return undefined; + const parts = goSplitTopLevel(text.slice(1, end), ',').map((p) => p.trim()).filter(Boolean); + // Named results (`n int, err error`) share a type with the names before + // it (`a, b T`): a name's type is the first one written from it on. + const named = (p: string): string | undefined => { + const m = /^([A-Za-z_]\w*)\s+(\S[\s\S]*)$/.exec(p); + return m && !/^(?:chan|func|map|struct|interface)$/.test(m[1]!) ? m[2] : undefined; + }; + let wanted: string | undefined; + if (parts.some((p) => named(p) !== undefined)) { + for (let r = result; r < parts.length && wanted === undefined; r++) wanted = named(parts[r]!); + } else wanted = parts[result]; + if (!wanted) return undefined; + text = wanted.trim(); + } else if (result > 0) return undefined; + const index = goScopeIndex(fn.filePath, context); + const params = index ? goTypeParamsAt(index, fn.filePath, index.lineStarts[fn.startLine - 1] ?? 0, context) : undefined; + return { text: goNormalizeType(text), file: fn.filePath, params }; +} + /** A type written at offset `at` of a file, or at package level for no `index`. */ function goTypeTextAt(text: string, filePath: string, at: number, index: GoScopeIndex | null, context: ResolutionContext): GoTypeText { return { text: goNormalizeType(text), file: filePath, params: index ? goTypeParamsAt(index, filePath, at, context) : undefined }; @@ -20953,9 +21142,19 @@ function goTypeTextAt(text: string, filePath: string, at: number, index: GoScope */ function goExprType( expr: string, at: number, filePath: string, index: GoScopeIndex | null, context: ResolutionContext, depth: number, + /** For a call that returns several values, the one wanted. */ + result = 0, ): GoTyped { const s = expr.trim(); if (!s || depth > GO_VALUE_DEPTH) return undefined; + if (result > 0) { + // Only a call's results are counted: `v, ok := m[k]` is no call's. + if (!/\)\s*$/.test(s)) return undefined; + const end = { at: 0 }; + const links = goChainLinks(s, 0, end, true); + if (end.at !== s.length || links[links.length - 1]?.kind !== 'call') return undefined; + return goReceiverChainType(links, s.startsWith('('), at, filePath, index, context, depth + 1, result); + } if (s.startsWith('&')) { const t = goExprType(s.slice(1), at, filePath, index, context, depth + 1); return t ? { ...t, text: `*${t.text}` } : t; @@ -20983,14 +21182,19 @@ function goExprType( while (s[j] === ' ' || s[j] === '\t') j++; if ((s[j] === '{' || s[j] === '(') && goClosingBracket(s, j) === s.length - 1) { const type = goTypeTextAt(s.slice(0, typeEnd), filePath, at, index, context); - // `T(x)` is a conversion only when T is a type, not a function. - return s[j] === '{' || GO_TYPE_LITERAL.test(type.text) || goNamedTypeDecls(type, context) ? type : undefined; + if (s[j] === '{' || GO_TYPE_LITERAL.test(type.text)) return type; + // `T(x)` is a conversion only when T is a type. A name from a package + // outside the project, type or function, gives a value of an outside + // type; any other call is read as a call below. + const decls = goNamedTypeDecls(type, context); + if (decls) return type; + if (decls === null && /^[A-Za-z_]\w*\.[A-Za-z_]\w*$/.test(type.text)) return null; } } - if (!index) return undefined; - // A name, a field, an element, an assertion. + // A name, a field, an element, an assertion, a call, a composite literal's: + // at package level (no `index`) a name is never a local. const end = { at: 0 }; - const links = goChainLinks(s, 0, end); + const links = goChainLinks(s, 0, end, true); if (end.at !== s.length) return undefined; return goReceiverChainType(links, s.startsWith('('), at, filePath, index, context, depth + 1); } @@ -21013,7 +21217,10 @@ function goDeclaredValueType(decl: GoDecl, filePath: string, index: GoScopeIndex const values = goSplitTopLevel(rhs, ','); // `v, ok := m[key]`, `v, ok := x.(T)`. const value = values.length === n ? values[k] : values.length === 1 && n === 2 && k === 0 && /[\])]\s*$/.test(rhs) ? rhs : undefined; - return value === undefined ? undefined : goExprType(value, decl.at, filePath, index, context, depth + 1); + if (value !== undefined) return goExprType(value, decl.at, filePath, index, context, depth + 1); + // `c, err := f()`, `_, cli := newClient(…)`: the call's k-th result. + if (values.length === 1 && n > 1 && /\)\s*$/.test(rhs)) return goExprType(rhs, decl.at, filePath, index, context, depth + 1, k); + return undefined; } if (decl.type === undefined) return undefined; // A variadic `xs ...T` is a `[]T`. @@ -21085,6 +21292,12 @@ function goFieldTypeOf(t: GoTypeText, name: string, context: ResolutionContext, if (!struct) continue; const own = struct.members.fields.get(name); if (own !== undefined) found.push({ text: goNormalizeType(own), file: struct.file, params: struct.members.params }); + // An embedded field is named after its type: `client.KV` for `struct { KV; … }`. + for (const e of struct.members.embedded) { + if (goNormalizeType(e).replace(/^\*/, '').replace(/\[[\s\S]*\]$/, '').replace(/^[A-Za-z_]\w*\./, '') === name) { + found.push({ text: goNormalizeType(e), file: struct.file, params: struct.members.params }); + } + } for (const e of struct.members.embedded) { if (seen.has(`${struct.file}\0${e}`)) continue; seen.add(`${struct.file}\0${e}`); @@ -21285,7 +21498,8 @@ function goTypeParamsAt(index: GoScopeIndex, filePath: string, at: number, conte if (!params) { params = new Map(); const lineStart = index.lineStarts[fn.startLine - 1] ?? 0; - const head = index.code.slice(lineStart, index.lineStarts[Math.min(fn.endLine, fn.startLine + 40) - 1] ?? index.code.length); + // Through the end line: a one-line function's head is its only line. + const head = index.code.slice(lineStart, index.lineStarts[Math.min(fn.endLine, fn.startLine + 40)] ?? index.code.length); // `func Name[T any](`, or `func (s *Set[T, U]) Name(`, whose type // arguments name its type's parameters, constrained where the type is. const generic = /^\s*func\s*(?:\(\s*(?:[A-Za-z_]\w*\s+)?\*?\s*([A-Za-z_]\w*)\s*(\[)|[A-Za-z_]\w*\s*(\[))/.exec(head);