diff --git a/CHANGELOG.md b/CHANGELOG.md index 8611ba779..59cc8ea35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- In Go, more method calls now reach the right method: through a parameter whose type comes from another package (`s *store.Store`), and through the result of a function or a conversion (`r := newRing()`, `s := store.NewStore()`). A call on a value whose type comes from outside your project, like `ctx.Done()` on a `context.Context` or `req.WithContext(…)` on an `*http.Request`, or on an alias of such a type, no longer links to an unrelated project method that happens to share the name. Re-index Go projects after upgrading. (#1954) - In TypeScript, an awaited call with type arguments like `await fetchApi("/me")` or `await api.get(url)` now links to the function it calls. Before, the call was dropped, so typed fetch wrappers looked like they had no callers. (#2586) - In React, a component rendered at module scope, in a top-level constant or inside a test's `it(…)` callback, like `createRoot(el).render()` or `render()`, now counts as rendered there, so an app's entry reaches its root component and a component's tests show among its callers. (#2587) - In Python, `import a.b as X` written inside a function now links its calls like `X.f()`, as the same import at module level already did. Thanks @Egrn for the report. (#2600) diff --git a/__tests__/go-receiver-type.test.ts b/__tests__/go-receiver-type.test.ts new file mode 100644 index 000000000..83a51f22c --- /dev/null +++ b/__tests__/go-receiver-type.test.ts @@ -0,0 +1,334 @@ +/** + * Go method calls through a receiver whose type is written somewhere other + * than a plain parameter of the caller's own package: + * + * - a package-qualified parameter (`s *store.Store`); + * - the result of a function or a conversion (`r := newRing()`, `s := + * store.NewStore()`, `list := web.Users(names)`). + * + * A receiver typed outside the project (`conn net.Conn`, `ctx + * context.Context`, `c, _ := net.Dial(…)`, an alias `type Ctx = + * context.Context`) gets no edge rather than a project method that happens + * to share the name. A project function's result + * declared as an outside type by value (`http.RoundTripper`) is left as it + * was: that is usually an interface a project type implements. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { CodeGraph } from '../src'; + +async function indexProject(files: Record): Promise<{ dir: string; cg: CodeGraph }> { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-go-recv-')); + fs.writeFileSync(path.join(dir, 'go.mod'), 'module example.com/app\n\ngo 1.22\n'); + for (const [file, source] of Object.entries(files)) { + fs.mkdirSync(path.join(dir, path.dirname(file)), { recursive: true }); + fs.writeFileSync(path.join(dir, file), source); + } + const cg = CodeGraph.initSync(dir); + await cg.indexAll(); + return { dir, cg }; +} + +/** `file::Qualified::name` of every method `caller` (a function name) calls. */ +async function methodCallees(cg: CodeGraph, caller: string): Promise { + const node = (await cg.searchNodes(caller, { limit: 20 })).find( + (r) => r.node.name === caller && r.node.kind === 'function', + ); + expect(node, caller).toBeDefined(); + return (await cg.getCallees(node!.node.id)) + .filter((c) => c.node.kind === 'method') + .map((c) => `${c.node.filePath}::${c.node.qualifiedName}`) + .sort(); +} + +describe('Go receiver typing', () => { + let dir: string; + let cg: CodeGraph; + + beforeAll(async () => { + ({ dir, cg } = await indexProject({ + 'main.go': `package main + +import ( + "bytes" + "net" + "net/http" + "os" + + "example.com/app/other" + "example.com/app/store" +) + +type ringLog struct{ buf []byte } + +func (r *ringLog) Write(b []byte) {} +func (r *ringLog) Reset() {} +func (r *ringLog) Reader() *reader { return &reader{} } + +type reader struct{} + +func (r *reader) Reset() {} + +type Engine struct{} + +func (e *Engine) Run() *Engine { return e } +func (e *Engine) Stop() {} + +func newRing() *ringLog { return &ringLog{} } +func NewEngine(n int) (*Engine, error) { return &Engine{}, nil } +func open() (*os.File, error) { return nil, nil } +func wrap(e *Engine) *ringLog { return nil } + +type limiter struct{} + +func (l *limiter) RoundTrip(r *http.Request) (*http.Response, error) { return nil, nil } + +func transport() http.RoundTripper { return &limiter{} } + +func lowerParam(ring *ringLog, b []byte) { ring.Write(b) } +func lowerValueParam(ring ringLog) { ring.Reset() } +func groupedParam(a, ring *ringLog) { ring.Reset() } +func lowerCtor() { r := newRing(); r.Reset() } +func exportedCtor() { e, _ := NewEngine(1); e.Stop() } +func qualifiedParam(s *store.Store) { s.Put("a") } +func qualifiedCtor() { s := store.NewStore(); s.Put("b") } + +func multiLineCtor() { + e, err := NewEngine( + 1, + ) + _ = err + e.Stop() +} + +func ifCtor() { + if r := newRing(); r != nil { + r.Write(nil) + } +} + +// \`r\` is what Reader returns, which is not read: not a ringLog. +func chainedCtor() { r := newRing().Reader(); r.Reset() } + +// On the binding's own line \`e\` is still the parameter. +func selfArg(e *Engine) { + if e := wrap(e.Run()); e != nil { + e.Write(nil) + } +} + +func stdlibParam(buf *bytes.Buffer) { buf.Truncate(0) } +func stdlibCtor() { c, _ := net.Dial("tcp", ""); c.RemoteAddr() } +func projectFuncReturningStdlib() { f, _ := open(); f.Truncate(0) } +func interfaceResult() { rt := transport(); rt.RoundTrip(nil) } +func netParam(conn net.Conn) { conn.LocalAddr() } + +// The \`r\` of the block, or of the function literal, is gone by the call. +func blockShadow() { + r := newRing() + if true { + r := other.New() + _ = r + } + r.Write(nil) +} + +func closureShadow() { + r := newRing() + go func() { r := other.New(); _ = r }() + r.Write(nil) +} + +type Holder struct{} + +func (h *Holder) New() *Item { return &Item{} } + +type Item struct{} + +func (i *Item) Save() {} + +// \`store\` is the parameter, not the imported package. +func shadowPkg(store *Holder) { x := store.New(); x.Save() } + +func main() {} +`, + 'store/store.go': `package store + +type Store struct{} + +func NewStore() *Store { return &Store{} } + +func (s *Store) Put(k string) {} + +type Thing struct{} + +func New() *Thing { return &Thing{} } + +func (t *Thing) Save() {} +`, + 'other/other.go': `package other + +type Other struct{} + +func New() *Other { return &Other{} } + +func (o *Other) Write(b []byte) {} +`, + // Same-named methods elsewhere, so a guess by name has somewhere wrong to go. + 'decoy/decoy.go': `package decoy + +type Decoy struct{} + +func (d *Decoy) Write(b []byte) {} +func (d *Decoy) Reset() {} +func (d *Decoy) Run() {} +func (d *Decoy) Stop() {} +func (d *Decoy) Put(k string) {} +func (d *Decoy) Truncate(n int) {} +func (d *Decoy) RemoteAddr() {} +func (d *Decoy) LocalAddr() {} + +type Store struct{} + +func (s *Store) Put(k string) {} +`, + })); + }); + + afterAll(() => { + cg?.close(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it.each([ + ['lowerParam', ['main.go::ringLog::Write']], + ['lowerValueParam', ['main.go::ringLog::Reset']], + ['groupedParam', ['main.go::ringLog::Reset']], + ])('%s: a receiver named like a standard-library package resolves on its type', async (caller, expected) => { + expect(await methodCallees(cg, caller)).toEqual(expected); + }); + + it.each([ + ['lowerCtor', ['main.go::ringLog::Reset']], + ['exportedCtor', ['main.go::Engine::Stop']], + ['multiLineCtor', ['main.go::Engine::Stop']], + ['ifCtor', ['main.go::ringLog::Write']], + ['qualifiedCtor', ['store/store.go::Store::Put']], + ])('%s: a receiver bound to a call resolves on what the callee returns', async (caller, expected) => { + expect(await methodCallees(cg, caller)).toEqual(expected); + }); + + it('a package-qualified parameter resolves in that package', async () => { + expect(await methodCallees(cg, 'qualifiedParam')).toEqual(['store/store.go::Store::Put']); + }); + + it('a binding is not typed by the head of a call chain, nor on its own line', async () => { + expect(await methodCallees(cg, 'chainedCtor')).not.toContain('main.go::ringLog::Reset'); + expect(await methodCallees(cg, 'selfArg')).toEqual(['main.go::Engine::Run']); + }); + + it.each(['stdlibParam', 'stdlibCtor', 'projectFuncReturningStdlib', 'netParam'])( + '%s: a receiver typed outside the project gets no edge', + async (caller) => { + expect(await methodCallees(cg, caller)).toEqual([]); + }, + ); + + it('an outside interface a project function returns still reaches its implementation', async () => { + expect(await methodCallees(cg, 'interfaceResult')).toEqual(['main.go::limiter::RoundTrip']); + }); + + it.each(['blockShadow', 'closureShadow'])( + '%s: a `:=` in a block that closed before the call is another variable', + async (caller) => { + expect(await methodCallees(cg, caller)).toEqual(['main.go::ringLog::Write']); + }, + ); + + it('a parameter named like an imported package is the parameter, and types what its method returns', async () => { + expect(await methodCallees(cg, 'shadowPkg')).toEqual(['main.go::Holder::New', 'main.go::Item::Save']); + }); +}); + +describe('Go receiver typing through conversions and aliases', () => { + let dir: string; + let cg: CodeGraph; + + beforeAll(async () => { + ({ dir, cg } = await indexProject({ + 'web/context.go': `package web + +type Context struct{} + +func (c *Context) MakeAuditRecord() {} + +type Users []string + +func (u Users) Usernames() []string { return nil } +`, + 'api4/handlers.go': `package api4 + +import ( + "context" + + "example.com/app/web" +) + +type Context = web.Context + +type ( + Handler = *web.Context + Ctx = context.Context +) + +type Defined web.Context + +func projectAlias(h Handler) { h.MakeAuditRecord() } +func conversion(names []string) { list := web.Users(names); list.Usernames() } +func definedType(d *Defined) { d.MakeAuditRecord() } +func stdlibReceiver(ctx context.Context) { ctx.Done() } +func aliasedStdlib(ctx Ctx) { ctx.Done() } +`, + // Same-named types and methods elsewhere, so neither a lookup by type + // name nor a guess by method name has a single answer to fall back on. + 'decoy/decoy.go': `package decoy + +type Context struct{} + +func (c *Context) MakeAuditRecord() {} + +// Named like the alias of an outside type in api4. +type Ctx struct{} + +func (c *Ctx) Done() {} + +type Decoy struct{} + +func (d *Decoy) Done() {} +func (d *Decoy) Usernames() {} +`, + })); + }); + + afterAll(() => { + cg?.close(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('a receiver bound to a conversion resolves on the converted type', async () => { + expect(await methodCallees(cg, 'conversion')).toEqual(['web/context.go::Users::Usernames']); + }); + + it('an alias of a project type still resolves on that type', async () => { + expect(await methodCallees(cg, 'projectAlias')).toEqual(['web/context.go::Context::MakeAuditRecord']); + }); + + it.each(['definedType', 'stdlibReceiver', 'aliasedStdlib'])( + '%s: a defined type, or a receiver typed outside the project, gets no edge', + async (caller) => { + expect(await methodCallees(cg, caller)).toEqual([]); + }, + ); +}); diff --git a/__tests__/go-std-method-receivers.test.ts b/__tests__/go-std-method-receivers.test.ts index 9866345a2..af368f15a 100644 --- a/__tests__/go-std-method-receivers.test.ts +++ b/__tests__/go-std-method-receivers.test.ts @@ -310,12 +310,15 @@ describe('Go standard method names on a receiver nothing types', () => { it.each([ // `for _, id := range ids` ['idsText', 'types/id.go::ID::String instance-method@0.65'], - // `urlsMap := types.NewURLsMap()` - ['urls', 'types/id.go::URLsMap::String instance-method@0.65'], ])('still guesses for %s through a receiver named after the owner type', (fn, target) => { expect(calls(fn)).toEqual([target]); }); + // `urlsMap := types.NewURLsMap()`: the type the package's function returns. + it('links urls through the type the call it is assigned from returns', () => { + expect(calls('urls')).toEqual(['types/id.go::URLsMap::String instance-method@0.9']); + }); + it.each([ // `var ( query promql.Query )`, read past the table's `query string`; // `Exec`, no standard name, is typed by that declaration too. diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 0862d9814..fbadfbae4 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -320,8 +320,10 @@ function matchMemberFunctionRef(ref: UnresolvedRef, context: ResolutionContext): // alias of one holds no project method to take. if (decl.declared === null || (decl.declared !== undefined && goHasNoProjectMethods(decl.declared, ref, context))) return null; if (type) { + const declFile = decl.file ?? ref.filePath; + if (isGoExternalType(decl.raw, declFile, context)) return null; return resolveMethodOnType(type, member, ref, context, 0.9, 'function-ref', - goDeclaredTypePackage(decl.raw, ref.filePath, context)); + goDeclaredTypePackage(decl.raw, declFile, context)); } const types = context.getNodesByName(receiver).filter(n => n.language === 'go' && (n.kind === 'struct' || n.kind === 'interface')); if (types.length) return types.length === 1 ? resolveMethodOnType(receiver, member, ref, context, 0.9, 'function-ref') : null; @@ -17661,7 +17663,7 @@ const PATTERN_MEMO_CAP = 8192; * ReferenceResolver.clearCaches calls clearNameMatcherMemos alongside * clearImportResolverMemos. */ -type InferScanState = { hi: number; ansIdx: number; ansType: string | null; ansRaw?: string }; +type InferScanState = { hi: number; ansIdx: number; ansType: string | null; ansRaw?: string; ansFile?: string }; const INFER_SCAN_STATES = new WeakMap>(); /** Awaited inference caches are scoped to the resolver's stable-source window. @@ -17967,8 +17969,9 @@ function buildLocalReceiverTypePatterns(language: Language, r: string): RegExp[] // are accepted only where a parameter list puts them: after `(`, `,` // or nothing, and before `,`, `)` or the line's end — unlike the // keyword-free pattern below, which a lowercase `ident type` pair in - // ordinary code would satisfy (#2323). - new RegExp(`(?:^|[(,])\\s*${r}\\s+\\*?([a-z_]\\w*)\\s*(?:\\[[^\\]]*\\])?\\s*(?:[,)]|$)`), // func (s *server) / f(h *handler) + // ordinary code would satisfy (#2323). The same slot holds a type of + // another package (`s *store.Store`, `conn net.Conn`). + new RegExp(`(?:^|[(,])\\s*${r}\\s+\\*?([a-z_]\\w*(?:\\.[A-Za-z_]\\w*)?)\\s*(?:\\[[^\\]]*\\])?\\s*(?:[,)]|$)`), // func (s *server) / f(h *handler) / f(s *store.Store) // A typed parameter / method receiver (`func use(lg Logger)`, // `func (l Logger) M()`) — name-before-type with no `var`/`:=` (#1125). // PascalCase-guarded (unlike the anchored patterns above) to keep the @@ -18510,6 +18513,7 @@ export function inferLocalReceiverType( // 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. + let goDeclLine: number | undefined; if (ref.language === 'go' && decl) { const written = goAssertedLocalType(scanReceiver, ref, context); const asserted = written === undefined ? undefined : goWrittenType(written, ref, context); @@ -18523,14 +18527,18 @@ export function inferLocalReceiverType( // *v1.Pod`), which the patterns below don't read, each name of `a, b // rest.Interface`, a `var (…)` spec. The patterns scan back for the name // with no block scope, so they can also read another declaration of it. - const type = /^[A-Za-z_]\w*$/.test(scanReceiver) ? goLocalDecl(scanReceiver, ref, context)?.type : undefined; - if (type !== undefined) { - const named = goNamedType(type); + const local = /^[A-Za-z_]\w*$/.test(scanReceiver) ? goLocalDeclarationAt(scanReceiver, ref, context) : undefined; + if (local?.type !== undefined) { + const named = goNamedType(local.type); decl.declared = named; if (!named) return null; decl.raw = named; return named.slice(named.indexOf('.') + 1); } + // A `:=` declares the innermost block's own variable: one in a block or + // a function literal that closed before the call is another `r`. The + // scan reads the one declaration in scope and no other line. + goDeclLine = local?.line; } const escapedReceiver = scanReceiver.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); @@ -18552,11 +18560,14 @@ export function inferLocalReceiverType( ? 0 : Math.max(0, enclosingScopeStartLine(ref, context) - 1); - // The raw capture behind matchLine's latest non-null answer (see `decl`). + // The raw capture behind matchLine's latest non-null answer (see `decl`), + // and the file it is spelled in when that is not the call's own. let lastRaw: string | undefined; + let lastFile: string | undefined; const matchLine = (i: number): string | null => { const line = lines[i]; if (!line) return null; + if (goDeclLine !== undefined && i !== goDeclLine - 1) return null; // A generated/minified line (one multi-KB statement) is not something a // human-written local declaration lives on, and regexing it per ref is // pure waste — skip it rather than scan it. @@ -18567,10 +18578,21 @@ export function inferLocalReceiverType( const type = normalizeInferredTypeName(m[1]); if (type) { lastRaw = m[1]; + lastFile = undefined; return type; } } } + if (ref.language === 'go') { + // `r := newRing()` / `s, err := store.NewStore()`: what the callee returns. + const result = goBoundCallResult(lines, i, escapedReceiver, ref.filePath, context); + const type = result && normalizeInferredTypeName(result.raw); + if (type) { + lastRaw = result.raw; + lastFile = result.file; + return type; + } + } return null; }; @@ -18589,15 +18611,16 @@ export function inferLocalReceiverType( // semantics. if (!componentScoped) { const states = getInferScanStates(context); - const key = `${ref.filePath}|${startIdx}|${ref.language}|${scanReceiver}`; + const key = `${ref.filePath}|${startIdx}|${ref.language}|${scanReceiver}|${goDeclLine ?? ''}`; const state = states.get(key); if (!state) { for (let i = callIdx; i >= startIdx; i--) { const type = matchLine(i); if (type) { - states.set(key, { hi: callIdx, ansIdx: i, ansType: type, ansRaw: lastRaw }); + states.set(key, { hi: callIdx, ansIdx: i, ansType: type, ansRaw: lastRaw, ansFile: lastFile }); if (decl) { decl.raw = lastRaw; + decl.file = lastFile; decl.line = i + 1; } return type; @@ -18613,6 +18636,7 @@ export function inferLocalReceiverType( state.ansIdx = i; state.ansType = type; state.ansRaw = lastRaw; + state.ansFile = lastFile; break; } } @@ -18620,6 +18644,7 @@ export function inferLocalReceiverType( if (state.ansIdx < startIdx) return null; if (decl) { decl.raw = state.ansRaw; + decl.file = state.ansFile; decl.line = state.ansIdx + 1; } return state.ansType; @@ -18629,6 +18654,7 @@ export function inferLocalReceiverType( if (type) { if (decl) { decl.raw = lastRaw; + decl.file = lastFile; decl.line = i + 1; } return type; @@ -19216,6 +19242,11 @@ export function matchMethodCall( if (ref.language === 'rust' && inferredType === 'Pin' && !isRustProjectNamed('Pin', RUST_DECLARED_TYPE_KINDS, context)) { return matchRustPinnedCall(objectOrClass!, methodName!, ref, context); } + // A Go receiver declared with a type from outside the project — `conn + // net.Conn`, `ctx context.Context`, `req *http.Request` — has no project + // method: `ctx.Done()` is not the one project type that declares a `Done`. + if (inferredType && ref.language === 'go' && decl.asserted === undefined && + isGoExternalType(decl.raw, decl.file ?? ref.filePath, context)) return null; 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 @@ -19240,7 +19271,7 @@ export function matchMethodCall( .getImportMappings(ref.filePath, ref.language) .find((i) => i.localName === inferredType)?.source : ref.language === 'go' - ? goDeclaredTypePackage(decl.raw, ref.filePath, context) + ? goDeclaredTypePackage(decl.raw, decl.file ?? ref.filePath, context) : ESM_FAMILY.has(ref.language) && !awaited ? nmTimedT('mc-tsimport', ref, () => tsImportedTypeFile(decl.raw ?? inferredType!, ref, context)) : undefined; @@ -20060,6 +20091,134 @@ function goDeclaredTypePackage(raw: string | undefined, filePath: string, contex return goImportPackageDir(name.slice(0, dot), filePath, context) ?? undefined; } +/** + * Whether a Go type spelled `raw` in `filePath` is declared outside the + * project: a type of a package from outside the project's modules + * (`net.Conn`, `*bytes.Buffer`), or an alias of one (`type Context = + * gin.Context`). Such a type has no method in the index. + */ +function isGoExternalType(raw: string | undefined, filePath: string, context: ResolutionContext, depth = 0): boolean { + if (!raw) return false; + const name = raw.replace(/[*&\s]/g, '').replace(/\[.*$/, ''); + const dot = name.indexOf('.'); + let pkgDir: string | null | undefined = goPackageDir(filePath); + if (dot >= 0) { + const imported = context.getImportMappings(filePath, 'go').find((i) => i.localName === name.slice(0, dot)); + if (imported && isGoOutsideImport(imported, filePath, context)) return true; + pkgDir = imported && context.getGoPackageDir?.(imported.source, filePath); + if (pkgDir == null) return false; + } + if (depth >= 4) return false; + const types = goPackageTypes(name.slice(dot + 1), pkgDir, context); + return types.length > 0 && types.every((t) => isGoExternalType(goAliasedType(t, context), t.filePath, context, depth + 1)); +} + +/** + * The type a Go binding gives receiver `r` from a call, when line `i` binds + * it first — `r := newRing()`, `r, err := store.NewStore(cfg)`, `if r := + * find(id); r != nil` — and the call is the whole right-hand side (`r := + * newRing().Reader()` is typed by `Reader`, which isn't read). A call that + * takes `r` itself (`r := wrap(r.Inner())`) is skipped: on that line `r` is + * still the outer variable. The type is the callee's first result as its + * signature spells it, for a function of the call's own package or of an + * imported project package; the callee itself for a conversion (`list := + * model.BotList(bots)`) and for a function of a package outside the + * project, whose result is a type from outside it too. `store.New()` with + * `store` a parameter or local of the function is a call on that variable, + * which shadows the package of its name: the result of the method of its + * declared type, when the declaration writes one. + */ +function goBoundCallResult( + lines: string[], + i: number, + r: string, + filePath: string, + context: ResolutionContext, +): { raw: string; file?: string } | null { + const [bind, self] = memoPatterns(`go-bind|${r}`, () => [ + new RegExp(`(?:^|[;{]|\\b(?:if|switch)\\s)\\s*${r}\\s*(?:,\\s*[A-Za-z_]\\w*\\s*)*:=\\s*([A-Za-z_]\\w*(?:\\.[A-Za-z_]\\w*)?)\\s*\\(`), + new RegExp(`\\b${r}\\b`), + ]); + const m = bind!.exec(lines[i]!); + if (!m) return null; + // The call's closing parenthesis, up to a few lines down, ends the statement. + let args = ''; + let closed = false; + for (let j = i, from = m.index + m[0].length - 1, depth = 0; j < Math.min(lines.length, i + 40) && !closed; j++, from = 0) { + const text = lines[j]!; + let k = from; + for (; k < text.length && !closed; k++) { + if (text[k] === '(') depth++; + else if (text[k] === ')' && --depth === 0) closed = true; + } + if (closed && !/^\s*(?:[;{}]|\/\/|$)/.test(text.slice(k))) return null; + args += text.slice(from, k) + '\n'; + } + if (!closed || self!.test(args)) return null; + + const callee = m[1]!; + const dot = callee.indexOf('.'); + const name = callee.slice(dot + 1); + let pkgDir = goPackageDir(filePath); + if (dot >= 0) { + const head = callee.slice(0, dot); + const local = goLocalDeclarationAt(head, { filePath, line: i + 1, column: m.index + m[0].indexOf(head) }, context); + if (local) { + const method = local.type === undefined ? null : goMethodOfWrittenType(local.type, name, filePath, context); + return method ? goFirstResult(method, context) : null; + } + const imported = context.getImportMappings(filePath, 'go').find((imp) => imp.localName === head); + if (!imported) return null; + if (isGoOutsideImport(imported, filePath, context)) return { raw: callee }; + const dir = context.getGoPackageDir?.(imported.source, filePath); + if (dir == null) return null; + pkgDir = dir; + } + const fn = context.getNodesByName(name).find( + (n) => n.kind === 'function' && n.language === 'go' && goPackageDir(n.filePath) === pkgDir + ); + if (!fn) return goPackageTypes(name, pkgDir, context).length > 0 ? { raw: callee } : null; + return goFirstResult(fn, context); +} + +/** + * The method `methodName` of the Go type a declaration writes as `written` + * (`*Holder`, `store.Holder`), in that type's own package; null for a type + * from outside the project, or one that declares no such method. + */ +function goMethodOfWrittenType(written: string, methodName: string, filePath: string, context: ResolutionContext): Node | null { + const named = goNamedType(written); + if (!named) return null; + const dot = named.indexOf('.'); + let pkgDir = goPackageDir(filePath); + if (dot >= 0) { + const imported = context.getImportMappings(filePath, 'go').find((imp) => imp.localName === named.slice(0, dot)); + if (!imported || isGoOutsideImport(imported, filePath, context)) return null; + const dir = context.getGoPackageDir?.(imported.source, filePath); + if (dir == null) return null; + pkgDir = dir; + } + const qualified = `${named.slice(dot + 1)}::${methodName}`; + return context.getNodesByName(methodName).find( + (n) => n.kind === 'method' && n.language === 'go' && n.qualifiedName === qualified && goPackageDir(n.filePath) === pkgDir + ) ?? null; +} + +/** + * The first result of the Go function or method `fn` as its signature spells + * it: `(params) *T`, `(params) (*T, error)`, `(params) (t *T, err error)`. + */ +function goFirstResult(fn: Node, context: ResolutionContext): { raw: string; file?: string } | null { + const results = /^\s*\((?:[^()]|\([^()]*\))*\)\s*\(?\s*([^,()]*)/.exec(fn.signature ?? '')?.[1]?.trim(); + const raw = results?.split(/\s+/).pop()?.replace(/\[.*$/, ''); + if (!raw || !/^\*?[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)?$/.test(raw)) return null; + // A value of an outside type that project code returns is usually an + // interface a project type implements (`http.RoundTripper`, + // `health.Checker`): unknown, where a pointer (`*os.File`) is that type. + if (!raw.startsWith('*') && isGoExternalType(raw, fn.filePath, context)) return null; + return { raw, file: fn.filePath }; +} + /** The node kinds a Go `type` declaration produces. */ export const GO_TYPE_KINDS: ReadonlySet = new Set(['struct', 'interface', 'type_alias']); @@ -20141,28 +20300,38 @@ function goAliasTarget(typeNode: Node, context: ResolutionContext): { name: stri let memo = GO_ALIAS_TARGETS.get(context); if (!memo) GO_ALIAS_TARGETS.set(context, (memo = new Map())); if (memo.has(typeNode.id)) return memo.get(typeNode.id); - const lines = context.getFileLines?.(typeNode.filePath) ?? context.readFile(typeNode.filePath)?.split(/\r?\n/) ?? []; - // From the alias's name, where the node starts, to the end of its type. - const decl = lines - .slice(Math.max(0, typeNode.startLine - 1), typeNode.endLine ?? typeNode.startLine) - .map((l, i) => (i === 0 ? l.slice(typeNode.startColumn ?? 0) : l).replace(/\/\/.*$/, '').replace(/\/\*.*?\*\//g, '')) - .join(' '); - // `Name =` or `Name[T any] =`; a defined type has no `=` there. - const head = /^\s*[A-Za-z_]\w*\s*(?:\[[^\]]*\])?\s*=/.exec(decl); + const aliased = goAliasedType(typeNode, context); // `T`, `*T`, `pkg.T`, each perhaps with type arguments. - const m = head && /^\s*(?:\*\s*)?([A-Za-z_]\w*)(?:\s*\.\s*([A-Za-z_]\w*))?\s*(?:\[.*\])?\s*;?\s*$/.exec(decl.slice(head[0].length)); + const m = aliased !== undefined && /^\s*(?:\*\s*)?([A-Za-z_]\w*)(?:\s*\.\s*([A-Za-z_]\w*))?\s*(?:\[.*\])?\s*;?\s*$/.exec(aliased); let target: { name: string; pkgDir: string } | null | undefined; if (m) { const pkgDir = m[2] ? goImportPackageDir(m[1]!, typeNode.filePath, context) : goBareTypeDir(m[1]!, typeNode.filePath, context); const name = m[2] ?? m[1]!; target = pkgDir == null || (!m[2] && GO_BUILTIN_FIELD_TYPES.has(name)) ? null : { name, pkgDir }; } else { - target = head ? null : undefined; + target = aliased !== undefined ? null : undefined; } memo.set(typeNode.id, target); return target; } +/** + * What a Go alias declaration writes after its `=` (`context.Context`, + * `*Local`, `func()`). Undefined when the node is no alias. + */ +function goAliasedType(typeNode: Node, context: ResolutionContext): string | undefined { + if (typeNode.kind !== 'type_alias') return undefined; + const lines = context.getFileLines?.(typeNode.filePath) ?? context.readFile(typeNode.filePath)?.split(/\r?\n/) ?? []; + // From the alias's name, where the node starts, to the end of its type. + const decl = lines + .slice(Math.max(0, typeNode.startLine - 1), typeNode.endLine ?? typeNode.startLine) + .map((l, i) => (i === 0 ? l.slice(typeNode.startColumn ?? 0) : l).replace(/\/\/.*$/, '').replace(/\/\*.*?\*\//g, '')) + .join(' '); + // `Name =` or `Name[T any] =`; a defined type has no `=` there. + const head = /^\s*[A-Za-z_]\w*\s*(?:\[[^\]]*\])?\s*=/.exec(decl); + return head ? decl.slice(head[0].length) : undefined; +} + /** The declarations of Go type `typeName` in the package at directory `pkgDir`. */ function goPackageTypes(typeName: string, pkgDir: string, context: ResolutionContext): Node[] { return context.getNodesByName(typeName).filter( @@ -20294,16 +20463,19 @@ interface GoProjectType { /** * 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. + * the declaration spells it (`raw`), the file that spelling is read in when + * it is not the call's own (`file` — a callee's result type is written in the + * callee's file), 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. * `declared` is the named type a Go parameter or local's declaration in * scope writes, as the scope reader finds it (`clock.PassiveClock`, `Store`), * or null for a type literal; unset when that declaration writes no type. */ interface GoReceiverDecl { raw?: string; + file?: string; asserted?: GoProjectType | null; declared?: string | null; /** The line the inference read the declaration on. */ @@ -23059,6 +23231,8 @@ function matchGoFieldChainCall( const asserted = decl.asserted; if (asserted === null) return null; if (decl.declared && goHasNoProjectMethods(decl.declared, ref, context)) return null; + const declFile = decl.file ?? ref.filePath; + if (!asserted && isGoExternalType(decl.raw, declFile, context)) return null; const fieldEsc = field!.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const fieldTypeRe = new RegExp(`\\b${fieldEsc}\\s+\\*?\\[?\\]?([A-Za-z_][\\w.]*)`); @@ -23071,7 +23245,7 @@ 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 = asserted?.dir ?? goDeclaredTypePackage(decl.raw, ref.filePath, context); + const basePkg = asserted?.dir ?? goDeclaredTypePackage(decl.raw, declFile, context); const declared = structs.filter((n) => goPackageDir(n.filePath) === basePkg); if (declared.length > 0 || asserted) structs = declared; structs = preferCallSiteFile(structs, ref.filePath);