From 16bc05a208f154ddbcd07eb2848c00dbf328e333 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Sat, 10 Oct 2026 07:15:20 +0000 Subject: [PATCH] fix(go): a call through a local bound from a type assertion reaches the asserted type's method (#2478) Receiver inference read `v := T{}`, `var v T` and parameters, never a local bound from a type assertion. So `flusher.Flush()` after `flusher, ok := w.(http.Flusher)` went to whichever project method name matching picked, and calls like etcd's `fi.Action()` after `fi := s.kvindex.(*fakeIndex)` linked to nothing. A Go local whose declaration in scope at the call binds the value of a type assertion (`v := x.(T)`, `v, ok := x.(T)`, `var v, _ = x.(T)`) now has the asserted type, found where Go finds it, as #2444 does for a call made through the assertion itself (`x.(T).M()`). Calls, method values and field chains through the local (`wr.job.Start()`) reach the type's own method, its interface's, or one embedding promotes into it. The binding is the one the scope reader from #2448 finds in scope at the call: an `if` header's ends with its statement, and a nearer declaration in a block that has ended does not hide it. A type switch's variable and a value that only starts with an assertion (`x.(T).field`) resolve as before. A type from outside the project, a predeclared type or an alias of an outside type links nothing, as for the assertion itself. A type the index can't tell is left to name matching, as before: a type literal (`interface{ GetDeadline() time.Time }`) or a type declared inside a function (grpc-go's `internalInfo` and `grpcstatus`). A local bound from an assertion and named like a standard-library package (`parser, ok := builder.(balancer.ConfigParser)`) is no longer dropped as a call into that package. On etcd, harbor, kratos and grpc-go, 358 call sites change. 216 gain a link, 38 move to the asserted type's method and 89 keep their target, now resolved through the asserted type. 15 lose a link that went through a type from outside the project, or from a field read to a same-named function. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/go-asserted-locals.test.ts | 380 +++++++++++++++++++++++++++ src/resolution/index.ts | 7 +- src/resolution/name-matcher.ts | 190 ++++++++++++-- 4 files changed, 549 insertions(+), 29 deletions(-) create mode 100644 __tests__/go-asserted-locals.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 946bba27e..f615431b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Dart, a type is now linked from more of the places it is written, not only from parameters and return types: the type an `extension … on` targets, a field's type, a top-level variable's type, generic arguments such as `Future.value(null)` or Riverpod's `final reportProvider = Family()`, and a local variable's type, a cast or a type check inside a function. So `callers` and impact for a model class now reach the extensions, models and providers that use it. Thanks @mg-mg-mg for the report. (#2327) - In Go, calls through a method receiver or parameter of an unexported type, the usual shape of gRPC and HTTP handlers (`s.service.AddItem()` inside `func (s *server) Create()`), now resolve, and always within that type's own package: another package's `server` with a same-named method no longer takes the call, and a method the type gets from a struct or interface it embeds is found too. Re-index Go projects after upgrading. Thanks @GoDiao for the report and the fix. (#2323) - 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, 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. - In Go, a function or method passed as a value, like `sync.Pool{New: wm.new}`, now shows the function that passes it among its callers and impact even when it is named `new`, `nil`, `None`, `self` or a handful of similar words, or when a line break or comment sits beside the dot. Before, such a value was skipped, so a method used only that way looked unused. Re-index Go projects after upgrading. diff --git a/__tests__/go-asserted-locals.test.ts b/__tests__/go-asserted-locals.test.ts new file mode 100644 index 000000000..427399113 --- /dev/null +++ b/__tests__/go-asserted-locals.test.ts @@ -0,0 +1,380 @@ +/** + * A Go local bound from a type assertion has the asserted type: + * + * if f, ok := w.(http.Flusher); ok { + * f.Flush() // http.Flusher's, nothing in the project + * } + * wr := v.(*Wrapper) + * wr.Flush() // what *Wrapper has, promoted from *storage.Base + * + * Receiver inference read `v := T{}`, `var v T` and parameters, never + * `v := x.(T)` or `v, ok := x.(T)`, so such a call went to whichever project + * method name matching picked. The asserted type is now found where Go finds + * it, as for a call made through the assertion itself (`x.(T).M()`): in the + * file's own package or one it dot-imports for a bare name, in the imported + * project package for `pkg.T`, an alias being the type it names. A type from + * outside the project, a predeclared one or an alias of such a type links + * nothing. A type literal, or a type declared inside a function, says nothing + * the index can follow: the call goes to name matching, as before. The + * binding is the one in scope at the call: an assertion in an `if` header + * does not reach a later variable of that name, nor does a nearer + * declaration in a block that has ended hide it. A local named like a + * standard-library package (`parser`) is the local. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import CodeGraph from '../src/index'; + +/** + * The module the tests index. Namesakes sit where name matching looked + * first: an `alpha` package that sorts ahead of `storage`, and a same-file + * `sink` with each method name. + */ +const FILES: Record = { + 'go.mod': 'module example.com/app\n\ngo 1.22\n', + 'alpha/alpha.go': `package alpha + +type Store interface { + Fetch(key string) string +} + +type sink struct{} + +func (s *sink) Fetch(key string) string { return "" } + +func (s *sink) Flush() error { return nil } + +func (s *sink) Read(p []byte) (int, error) { return 0, nil } + +func (s *sink) Error() string { return "" } + +func (s *sink) Len() int { return 0 } + +func (s *sink) Start() {} + +type Ctx struct{} + +func (c *Ctx) Done() <-chan struct{} { return nil } + +type BufFlusher struct{} + +func (b *BufFlusher) Flush() error { return nil } +`, + 'storage/storage.go': `package storage + +type Store interface { + Fetch(key string) string +} + +type Base struct{} + +func (b *Base) Flush() error { return nil } +`, + 'store/store.go': `package store + +import ( + "context" + + "example.com/app/storage" +) + +type Reader interface { + Read(p []byte) (int, error) +} + +type ReadCloser interface { + Reader + Close() error +} + +type Job struct{} + +func (j *Job) Start() {} + +type Wrapper struct { + *storage.Base + job *Job +} + +type List[T any] struct{} + +func (l *List[T]) Len() int { return 0 } + +type Ctx = context.Context + +type Keeper = storage.Store +`, + 'store/use.go': `package store + +import ( + "net/http" + + "example.com/app/storage" +) + +type sink struct{} + +func (s *sink) Flush() error { return nil } + +func (s *sink) Fetch(key string) string { return "" } + +func (s *sink) Read(p []byte) (int, error) { return 0, nil } + +func (s *sink) Error() string { return "" } + +func (s *sink) Drain() {} + +func register(fn any) {} + +func lookup(key string) any { return nil } + +func Flusher(w http.ResponseWriter) { + if flusher, ok := w.(http.Flusher); ok { + flusher.Flush() + } +} + +func Promoted(v any) { + wr := v.(*Wrapper) + wr.Flush() +} + +func Embedded(v any, p []byte) { + rc, ok := v.(ReadCloser) + if ok { + rc.Read(p) + } +} + +func Qualified(v any) string { + var st, _ = v.(storage.Store) + return st.Fetch("k") +} + +func Aliased(v any) string { + k := v.(Keeper) + return k.Fetch("k") +} + +func Generic(v any) int { + l := v.(*List[int]) + return l.Len() +} + +func Quoted() string { + s, _ := lookup(")").(storage.Store) + return s.Fetch("k") +} + +func Outside(v any) { + c := v.(Ctx) + c.Done() + if err, ok := v.(error); ok { + err.Error() + } +} + +func Unseen(v any) { + d, _ := v.(interface{ Drain() }) + d.Drain() + type drainer interface{ Drain() } + if dr, ok := v.(drainer); ok { + dr.Drain() + } +} + +func Named(v any) string { + if parser, ok := v.(storage.Store); ok { + return parser.Fetch("k") + } + return "" +} + +func Chain(v any) { + wr := v.(*Wrapper) + wr.job.Start() +} + +func Values(v any, w http.ResponseWriter) { + st := v.(storage.Store) + register(st.Fetch) + f := w.(http.Flusher) + register(f.Flush) +} + +func Assigned(v any) string { + var st storage.Store + st = v.(*sink) + return st.Fetch("k") +} + +func Scoped(w http.ResponseWriter, sinks []*sink) { + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + for _, f := range sinks { + f.Drain() + } +} + +func Shadowed(v any) string { + s, ok := v.(storage.Store) + if !ok { + s := &sink{} + return s.Fetch("fallback") + } + return s.Fetch("k") +} + +func Commented(s *sink) { + // s, ok := v.(http.Flusher) + s.Drain() +} + +func Switched(v any) { + switch x := v.(type) { + case *sink: + x.Drain() + } +} +`, + 'dot/dot.go': `package dot + +import . "example.com/app/storage" + +func Get(v any) string { + s := v.(Store) + return s.Fetch("k") +} +`, +}; + +describe.each(['default', 'wasm'])('Go locals bound from a type assertion have the asserted type (%s)', (backend) => { + let root = ''; + let cg: CodeGraph | undefined; + let kernel: string | undefined; + + beforeAll(async () => { + kernel = process.env.CODEGRAPH_KERNEL; + if (backend === 'wasm') process.env.CODEGRAPH_KERNEL = '0'; + else delete process.env.CODEGRAPH_KERNEL; + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-go-asserted-')); + for (const [rel, content] of Object.entries(FILES)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + // One file with a Windows checkout's line endings. + fs.writeFileSync(path.join(root, rel), rel === 'store/use.go' ? content.replace(/\n/g, '\r\n') : content); + } + cg = await CodeGraph.init(root, { index: true }); + }, 60_000); + + afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + if (kernel === undefined) delete process.env.CODEGRAPH_KERNEL; + else process.env.CODEGRAPH_KERNEL = kernel; + }); + + /** + * ` -> ::` for every edge + * of `kind` the function `name` in `file` has to a function or method. + */ + function links(file: string, name: string, kind: 'calls' | 'references' = 'calls'): string[] { + const fn = cg!.getNodesInFile(file).find((n) => n.qualifiedName === name && n.kind === 'function'); + expect(fn, `${name} in ${file}`).toBeDefined(); + const lines = FILES[file]!.split('\n'); + return cg! + .getOutgoingEdges(fn!.id) + .filter((e) => e.kind === kind) + .map((e) => ({ e, target: cg!.getNode(e.target)! })) + .filter(({ target }) => target.kind === 'method' || target.kind === 'function') + .map(({ e, target }) => `${lines[e.line! - 1]!.trim()} -> ${target.filePath}::${target.qualifiedName}`) + .sort(); + } + + it('links nothing through an asserted type from outside the project', () => { + // Name matching took alpha's BufFlusher::Flush, whose owner shares the + // receiver's name. + expect(links('store/use.go', 'Flusher')).toEqual([]); + }); + + it("links the asserted project type's method: its own, an embedded type's, or one through a qualifier", () => { + // *Wrapper's Flush is promoted from *storage.Base; ReadCloser's Read from Reader. + expect(links('store/use.go', 'Promoted')).toEqual(['wr.Flush() -> storage/storage.go::Base::Flush']); + expect(links('store/use.go', 'Embedded')).toEqual(['rc.Read(p) -> store/store.go::Reader::Read']); + // `var v, _ = x.(T)` declares as `v, _ := x.(T)` does. + expect(links('store/use.go', 'Qualified')).toEqual(['return st.Fetch("k") -> storage/storage.go::Store::Fetch']); + expect(links('store/use.go', 'Generic')).toEqual(['return l.Len() -> store/store.go::List::Len']); + // A string argument holding a parenthesis is not the end of the call. + expect(links('store/use.go', 'Quoted')).toEqual([ + 'return s.Fetch("k") -> storage/storage.go::Store::Fetch', + 's, _ := lookup(")").(storage.Store) -> store/use.go::lookup', + ]); + }); + + it('follows an alias and a dot import to the type', () => { + expect(links('store/use.go', 'Aliased')).toEqual(['return k.Fetch("k") -> storage/storage.go::Store::Fetch']); + expect(links('dot/dot.go', 'Get')).toEqual(['return s.Fetch("k") -> storage/storage.go::Store::Fetch']); + }); + + it('links nothing through an alias of an outside type or a predeclared type', () => { + expect(links('store/use.go', 'Outside')).toEqual([]); + }); + + it('leaves a call through a type literal or a type declared in a function to name matching', () => { + // sink::Drain is the project's one Drain. + expect(links('store/use.go', 'Unseen')).toEqual([ + 'd.Drain() -> store/use.go::sink::Drain', + 'dr.Drain() -> store/use.go::sink::Drain', + ]); + }); + + it('takes a local named like a standard-library package for the local', () => { + expect(links('store/use.go', 'Named')).toEqual(['return parser.Fetch("k") -> storage/storage.go::Store::Fetch']); + }); + + it("reaches a field's method through an asserted local", () => { + expect(links('store/use.go', 'Chain')).toEqual(['wr.job.Start() -> store/store.go::Job::Start']); + }); + + it("links a method value taken from an asserted local as the call would", () => { + expect(links('store/use.go', 'Values', 'references')).toEqual([ + 'register(st.Fetch) -> storage/storage.go::Store::Fetch', + ]); + }); + + it("leaves an assertion assigned to a declared variable that variable's type", () => { + // `st = v.(*sink)` declares nothing: st is still a storage.Store. + expect(links('store/use.go', 'Assigned')).toEqual(['return st.Fetch("k") -> storage/storage.go::Store::Fetch']); + }); + + it('reads the binding in scope at the call', () => { + // The `if` header's f ends with its block: the loop's f is a *sink. + expect(links('store/use.go', 'Scoped')).toEqual(['f.Drain() -> store/use.go::sink::Drain']); + // The block's s := &sink{} ends with the block; the asserted s is back. + expect(links('store/use.go', 'Shadowed')).toEqual([ + 'return s.Fetch("fallback") -> store/use.go::sink::Fetch', + 'return s.Fetch("k") -> storage/storage.go::Store::Fetch', + ]); + // A comment declares nothing. + expect(links('store/use.go', 'Commented')).toEqual(['s.Drain() -> store/use.go::sink::Drain']); + // A type switch's variable has its clause's type, which is not read: the + // call resolves as it did before. + expect(links('store/use.go', 'Switched')).toEqual(['x.Drain() -> store/use.go::sink::Drain']); + }); + + it('never links a namesake in another package or a same-file type', () => { + const decoys = [ + ...cg!.getNodesInFile('alpha/alpha.go'), + ...cg!.getNodesInFile('store/use.go').filter((n) => ['sink::Flush', 'sink::Read', 'sink::Error'].includes(n.qualifiedName)), + ].filter((n) => n.kind === 'method'); + expect(decoys.length).toBe(12); + for (const decoy of decoys) { + expect( + cg!.getIncomingEdges(decoy.id).filter((e) => (e.kind === 'calls' || e.kind === 'references') && e.provenance !== 'heuristic'), + decoy.qualifiedName, + ).toEqual([]); + } + }); +}); diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 3003fc660..7c16dbd13 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, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead, isDartChainLink, matchDartChainLink, isDartAnnotation, matchDartAnnotation, isStdMethodName, isGoUnknownQualified, isGoBareName, goTypePositionTarget, GO_TYPE_KINDS, matchGoAssertedCall } from './name-matcher'; +import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, matchObjectPathCall, thisScopeCaller, isVisibleAcrossFiles, matchReference, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead, isDartChainLink, matchDartChainLink, isDartAnnotation, matchDartAnnotation, isStdMethodName, isGoUnknownQualified, isGoBareName, goTypePositionTarget, GO_TYPE_KINDS, matchGoAssertedCall, isGoAssertedLocal } from './name-matcher'; import { isVisibleCppMacro, clearCppMacroVisibility } from './cpp-macro-visibility'; import { isCppConstructorRef, matchCppConstructor } from './cpp-constructor'; import { isCppSupertypeRef, matchCppSupertype, clearCppSupertypeMemos } from './cpp-supertypes'; @@ -2872,7 +2872,10 @@ export class ReferenceResolver { const dotIdx = name.indexOf('.'); if (dotIdx > 0) { const pkg = name.substring(0, dotIdx); - if (GO_STDLIB_PACKAGES.has(pkg)) { + // Not a local bound from a type assertion that is named like one: + // grpc-go's `parser.ParseConfig(…)` after `parser, ok := + // b.(balancer.ConfigParser)`. + if (GO_STDLIB_PACKAGES.has(pkg) && !isGoAssertedLocal(pkg, ref, this.context)) { return true; } } diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 8ede67751..2cfcd124c 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -306,8 +306,11 @@ function matchMemberFunctionRef(ref: UnresolvedRef, context: ResolutionContext): } if (ref.language === 'go') { if (receiver.includes('.')) return matchGoFieldChainCall(receiver, member, ref, context); - const decl: { raw?: string } = {}; + const decl: GoReceiverDecl = {}; const type = inferLocalReceiverType(receiver, ref, context, decl); + if (decl.asserted !== undefined) { + return decl.asserted && resolveMethodOnType(decl.asserted.name, member, ref, context, 0.9, 'function-ref', decl.asserted.dir); + } if (type) { return resolveMethodOnType(type, member, ref, context, 0.9, 'function-ref', goDeclaredTypePackage(decl.raw, ref.filePath, context)); @@ -9887,6 +9890,7 @@ export function clearNameMatcherMemos(context: ResolutionContext): void { GO_EMBEDS.delete(context); GO_ALIAS_TARGETS.delete(context); GO_DOT_IMPORTS.delete(context); + GO_ASSERTION_BINDINGS.delete(context); JAVA_FILE_SCOPES.delete(context); JAVA_ANCESTORS.delete(context); SCALA_SUPERS.delete(context); @@ -10486,9 +10490,10 @@ function inferLocalReceiverType( * Receives the found type as the declaration spells it, before the * normalization the return value gets: Go's package qualifier (`pkg.Type` * vs a bare `server`) says which package declares it. Set on the - * incremental-scan path only, the one every Go receiver takes. + * incremental-scan path, the one every Go receiver takes, and with + * `asserted` for a Go local bound from a type assertion (GoReceiverDecl). */ - decl?: { raw?: string }, + decl?: GoReceiverDecl, ): string | null { // CFML scope prefixes: `variables.svc` / `this.svc` name a COMPONENT-scoped // field whose assignment or `property` declaration usually lives outside the @@ -10527,6 +10532,21 @@ function inferLocalReceiverType( phpProperty = true; } } + // A Go local bound from a type assertion (`f, ok := w.(http.Flusher)`) has + // the asserted type. The patterns below read no assertion, and scan lines + // with no block scope: an `if` header's `f` would type a loop's `f` below it. + // A type goWrittenType can't tell leaves the local to them and to name + // matching, as before: grpc-go's `ci.GetCommonAuthInfo()`, through an + // interface its function declares, reaches the one method of that name. + if (ref.language === 'go' && decl) { + const written = goAssertedLocalType(scanReceiver, ref, context); + const asserted = written === undefined ? undefined : goWrittenType(written, ref, context); + if (asserted !== undefined) { + decl.raw = written; + decl.asserted = asserted; + return normalizeInferredTypeName(written!) ?? written!; + } + } const escapedReceiver = scanReceiver.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const patterns = phpProperty @@ -10998,7 +11018,7 @@ export function matchMethodCall( matchVbTypedCall(objectOrClass!, methodName!, ref, context, (name) => isStdMethodName('vbnet', name))); if (typed !== undefined) return typed; } - const decl: { raw?: string } = {}; + const decl: GoReceiverDecl = {}; const cppDecl: CppReceiverDeclaration = {}; let inferredType = nmTimedT('mc-infer', ref, () => ref.language === 'cpp' @@ -11037,6 +11057,12 @@ export function matchMethodCall( inferredType = awaited.name; } if (inferredType) { + // A Go local bound from a type assertion calls a method of the type Go + // finds for it, or nothing the project declares: `flusher.Flush()` after + // `flusher, ok := w.(http.Flusher)` went to a project type named after it. + if (decl.asserted !== undefined) { + return decl.asserted && resolveMethodOnType(decl.asserted.name, methodName!, ref, context, 0.9, 'instance-method', decl.asserted.dir); + } // Java/Kotlin: when two classes share the simple name, the file's import // pins WHICH one (#314); Go: the package that declares the type (#2323). // Other languages disambiguate by call-site file. @@ -11740,30 +11766,132 @@ function goEmbeddedTypes(typeNode: Node, context: ResolutionContext): Array<{ na * column where its receiver expression starts, and name matching took any * method of that name: every gRPC handler etcd generates went to the * `UnimplementedKVServer` stub beside the `KVServer` interface. The type is - * the one Go finds: in the call's own package or a package it dot-imports for - * a bare name, in the imported package for a qualified one, and an alias is - * the type it names. A type found in none — one from outside the project - * (`http.Flusher`), a predeclared one (`error`) — an alias of such a type, or - * a type literal (`interface{ Flush() }`) links nothing. - * Undefined when the call is not made through an assertion. + * the one Go finds (goWrittenType). Through any other the call links + * nothing, even one goWrittenType can't tell: a bare name gives name matching + * no receiver to go on. Undefined when the call is not made through an + * assertion. */ export function matchGoAssertedCall(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null | undefined { if (ref.language !== 'go' || ref.referenceKind !== 'calls' || !/^[A-Za-z_]\w*$/.test(ref.referenceName)) return undefined; const asserted = goAssertedType(ref, context); if (asserted === undefined) return undefined; + const type = goWrittenType(asserted, ref, context); + return type ? resolveMethodOnType(type.name, ref.referenceName, ref, context, 0.9, 'instance-method', type.dir) : null; +} + +/** + * The project type a Go type written at `ref` names — `KVServer`, `*pipe`, + * `storage.Store`, `*List[int]` — with the directory of the package that + * declares it, found where Go finds it: in the file's own package or a + * package it dot-imports for a bare name, in the imported package for a + * qualified one. Null for a type found in none — one from outside the + * project (`http.Flusher`), a predeclared one (`error`) or an alias of such a + * type: none has a method the project declares. Undefined for one it can't + * tell: a type literal (`interface{ Flush() }`), a name no package of the + * project declares (a type declared inside a function, a type parameter), a + * type the imported project package doesn't hold. + */ +function goWrittenType(written: string, ref: UnresolvedRef, context: ResolutionContext): GoProjectType | null | undefined { // `KVServer`, `*pipe`, `storage.Store`, `*List[int]`: anything else is a type literal. - const type = /^\*?\s*(?:([A-Za-z_]\w*)\s*\.\s*)?([A-Za-z_]\w*)\s*(?:\[[\s\S]*\])?$/.exec(asserted); - if (!type) return null; - const [, qualifier, typeName] = type; - const declares = (dir: string | null | undefined): dir is string => !!dir && goPackageTypes(typeName!, dir, context).length > 0; - const dir = qualifier !== undefined - ? goImportPackageDir(qualifier, ref.filePath, context) - : [goPackageDir(ref.filePath), ...goDotImportDirs(ref.filePath, context)].find(declares); - if (!declares(dir)) return null; + const type = /^\*?\s*(?:([A-Za-z_]\w*)\s*\.\s*)?([A-Za-z_]\w*)\s*(?:\[[\s\S]*\])?$/.exec(written); + if (!type) return undefined; + const [, qualifier, name] = type; + const declares = (dir: string | null | undefined): dir is string => !!dir && goPackageTypes(name!, dir, context).length > 0; + let dir: string | null | undefined; + if (qualifier !== undefined) { + dir = goImportPackageDir(qualifier, ref.filePath, context); + if (!dir) return null; + if (!declares(dir)) return undefined; + } else { + dir = [goPackageDir(ref.filePath), ...goDotImportDirs(ref.filePath, context)].find(declares); + if (!dir) return GO_BUILTIN_FIELD_TYPES.has(name!) ? null : undefined; + } // An alias of a type from outside the project (`type Ctx = context.Context`) // has that type's methods, none the project declares. - if (goPackageTypes(typeName!, dir, context).every((t) => goAliasTarget(t, context) === null)) return null; - return resolveMethodOnType(typeName!, ref.referenceName, ref, context, 0.9, 'instance-method', dir); + if (goPackageTypes(name!, dir, context).every((t) => goAliasTarget(t, context) === null)) return null; + return { name: name!, dir }; +} + +/** A Go type the project declares, with the directory of its package. */ +interface GoProjectType { + name: string; + dir: string; +} + +/** + * What inferLocalReceiverType tells its caller besides the type: the type as + * the declaration spells it (`raw`), and for a Go local bound from a type + * assertion, the type goWrittenType finds for it (`asserted`; null for one + * with no method the project declares). `asserted` is unset when the local + * is declared some other way, or goWrittenType can't tell its type. + */ +interface GoReceiverDecl { + raw?: string; + asserted?: GoProjectType | null; +} + +const GO_ASSERTION_BINDINGS = new WeakMap>>(); + +/** + * The type a Go local was asserted to, as written (`http.Flusher`, + * `*Wrapper`, `storage.Store`), when the declaration of `name` in scope at + * `ref` binds the value of a type assertion: `f, ok := w.(http.Flusher)` in + * an `if` header, `wr := v.(*Wrapper)`, `var st, _ = v.(storage.Store)`. The + * scope reader says which declaration that is, so an `if` header's binding + * does not reach a later variable of the name, one in a block that has ended + * does not hide it, and a comment declares nothing. Undefined when the name + * in scope is declared some other way: a parameter, a type switch's + * variable, a value that only starts with an assertion (`x.(T).field`). + */ +function goAssertedLocalType(name: string, ref: UnresolvedRef, context: ResolutionContext): string | undefined { + if (!goAssertionBindings(ref.filePath, context).has(name)) return undefined; + const decl = goLocalDecl(name, ref, context); + const code = decl && decl.kind !== 'param' ? goScopeIndex(ref.filePath, context)?.code : undefined; + if (!decl || code === undefined) return undefined; + // The asserted value goes to the first name of `v, ok := x.(T)`. + if (/,[ \t]*$/.test(code.slice(code.lastIndexOf('\n', decl.at - 1) + 1, decl.at))) return undefined; + const head = /[A-Za-z_]\w*[ \t]*(?:,[ \t]*[A-Za-z_]\w*[ \t]*)?:?=(?!=)[ \t]*/y; + head.lastIndex = decl.at; + if (!head.exec(code)) return undefined; + const end = { at: 0 }; + const links = goChainLinks(code, head.lastIndex, end); + const last = links[links.length - 1]; + // The assertion is the whole value: nothing follows it, not even another + // value (`a, b := x.(T), y`). + if (links.length < 2 || last!.kind !== 'assert' || last!.text === 'type' || !/^(?:[;\r\n]|$)/.test(code.slice(end.at, end.at + 1))) { + return undefined; + } + return last!.text; +} + +/** + * Whether `name` at `ref` is a Go local bound from a type assertion whose + * type goWrittenType can tell, so that the call through it is that type's to + * resolve: after `parser, ok := b.(balancer.ConfigParser)`, `parser` is not + * the standard library's package. + */ +export function isGoAssertedLocal(name: string, ref: UnresolvedRef, context: ResolutionContext): boolean { + const written = goAssertedLocalType(name, ref, context); + return written !== undefined && goWrittenType(written, ref, context) !== undefined; +} + +/** + * The names a Go file may bind from a type assertion — the first name before + * each `:=` or `=` on a line that asserts a type — so that only calls through + * those names read the file's scopes. + */ +function goAssertionBindings(filePath: string, context: ResolutionContext): Set { + let memo = GO_ASSERTION_BINDINGS.get(context); + if (!memo) GO_ASSERTION_BINDINGS.set(context, (memo = new Map())); + let names = memo.get(filePath); + if (names) return names; + names = new Set(); + for (const line of context.getFileLines?.(filePath) ?? context.readFile(filePath)?.split(/\r?\n/) ?? []) { + if (!line.includes('.(') || line.length > 10_000) continue; + for (const m of line.matchAll(/(?:^|[^\w.])([A-Za-z_]\w*)\s*(?:,\s*[A-Za-z_]\w*\s*)?:?=(?!=)/g)) names.add(m[1]!); + } + memo.set(filePath, names); + return names; } /** How many lines past a Go call's first its chain is read on. */ @@ -11804,9 +11932,9 @@ interface GoChainLink { * The links of the Go expression that starts at `start`: its operand (a name * 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. + * inserts no semicolon. `end.at` receives the offset reading stopped at. */ -function goChainLinks(text: string, start: number): GoChainLink[] { +function goChainLinks(text: string, start: number, end?: { at: number }): GoChainLink[] { const links: GoChainLink[] = []; const NAME = /[A-Za-z_]\w*/y; let i = start; @@ -11824,7 +11952,10 @@ function goChainLinks(text: string, start: number): GoChainLink[] { return inner; }; const operand = text[i] === '(' ? group() : name(); - if (operand === null) return links; + if (operand === null) { + if (end) end.at = i; + return links; + } links.push({ kind: 'operand', text: operand }); for (;;) { while (text[i] === ' ' || text[i] === '\t') i++; @@ -11842,6 +11973,7 @@ function goChainLinks(text: string, start: number): GoChainLink[] { if (group() === null) break; } else break; } + if (end) end.at = i; return links; } @@ -11920,14 +12052,18 @@ function matchGoFieldChainCall( if (segs.length !== 2 || !segs[0] || !segs[1]) return null; const [base, field] = segs; - const decl: { raw?: string } = {}; + const decl: GoReceiverDecl = {}; const baseType = inferLocalReceiverType(base!, ref, context, decl); if (!baseType) return null; + // A base bound from a type assertion has the type Go finds for it, or one + // whose fields the project doesn't declare. + const asserted = decl.asserted; + if (asserted === null) return null; const fieldEsc = field!.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const fieldTypeRe = new RegExp(`\\b${fieldEsc}\\s+\\*?\\[?\\]?([A-Za-z_][\\w.]*)`); - let structs = context.getNodesByName(baseType).filter( + let structs = context.getNodesByName(asserted?.name ?? baseType).filter( (n) => (n.kind === 'struct' || n.kind === 'class') && n.language === 'go' ); // The struct the declaring package has is the one the base's type means (a @@ -11935,9 +12071,9 @@ function matchGoFieldChainCall( // names repeat across packages — harbor has a `daoTestSuite` in every DAO // package — and another package's same-named struct with a same-named // field is not this one (#2323). - const basePkg = goDeclaredTypePackage(decl.raw, ref.filePath, context); + const basePkg = asserted?.dir ?? goDeclaredTypePackage(decl.raw, ref.filePath, context); const declared = structs.filter((n) => goPackageDir(n.filePath) === basePkg); - if (declared.length > 0) structs = declared; + if (declared.length > 0 || asserted) structs = declared; structs = preferCallSiteFile(structs, ref.filePath); for (const s of structs) { const source = context.readFile(s.filePath);