diff --git a/CHANGELOG.md b/CHANGELOG.md index a9d37c030..3f4278e2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -97,6 +97,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In JavaScript and TypeScript, an import written `import { default as AppRoot } from './routes/app/root'`, as in bulletproof-react's router, now counts as the module's default import, the same as `import AppRoot from './routes/app/root'`. Before, it was read as an import of an export named `default`, which no module has, so a call, a route or a JSX attribute that used `AppRoot` was matched by its name alone: it could link to another file's `AppRoot`, or to nothing when the default export has a name of its own. Svelte, Vue and Astro script blocks are read the same way. - In React, a component's JSX no longer links it to a class or component that only shares a name with one of its type arguments or with a variable of its own. A type in angle brackets, like `Document` in ` items={…} />`, `User` in `useState()` or `Entry` in a generic `(…) =>` component, is no longer read as a tag. A tag naming a variable or parameter the component sets itself, like `` after `const Content = isDropdown ? DropdownMenu.SubContent : ContextMenu.SubContent` or `` in `widgets.map((Widget) => )`, now links to nothing, unless the component declares a component of that name inside itself. Before, these linked to an unrelated class or component elsewhere in the repository: outline's document lists showed up among the callers of its `Document` model class, and its menus among the callers of the command bar's `Content`, so `codegraph_explore`, callers and impact followed renders that never happen. Re-index React projects after upgrading. - In JavaScript and TypeScript, a name imported from a package your `package.json` lists no longer links to a project symbol that only shares its name when `tsconfig.json` or `jsconfig.json` has a catch-all path alias, like `"*": ["./typings/*"]` or `"*": ["src/*", "node_modules/*"]`, or an alias that points the package at a file in `node_modules`, like `"lit/decorators": ["./node_modules/lit/decorators.js"]`. Such an alias made every package look like part of the project, so `import { Typography } from '@mui/material'` was linked to the project's own `Typography` and every lit `@property()` decorator to an unrelated class's `property` field, and `codegraph callers`, impact and `codegraph affected` listed code that never used them. An import the alias does map to a file of your project, like a `.d.ts` you keep for an untyped package or `components/Button` through `"*": ["src/*"]`, links as before. Re-index affected projects after upgrading. +- In Go, more method calls now reach the right method: through a variable named like a standard-library package (`ring`, `token`, `parser`), a parameter whose type comes from another package (`s *store.Store`), and 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) ## [1.6.2] - 2026-10-03 diff --git a/__tests__/go-receiver-type.test.ts b/__tests__/go-receiver-type.test.ts new file mode 100644 index 000000000..302982076 --- /dev/null +++ b/__tests__/go-receiver-type.test.ts @@ -0,0 +1,285 @@ +/** + * Go method calls through a receiver whose type is written somewhere other + * than a plain parameter of the caller's own package: + * + * - a variable named like a standard-library package the file does not + * import (`ring *ringLog`, `token := get()`), which used to be skipped as + * a call into that 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/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() } +func untyped(get func() net.Conn) { token := get(); token.LocalAddr() } + +func main() {} +`, + 'store/store.go': `package store + +type Store struct{} + +func NewStore() *Store { return &Store{} } + +func (s *Store) Put(k string) {} +`, + // 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', 'untyped'])( + '%s: a receiver typed outside the project, or untyped, 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']); + }); +}); + +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/src/resolution/index.ts b/src/resolution/index.ts index 20b42145b..febfa510e 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, GO_STDLIB_PACKAGES, matchGoAssertedCall } from './name-matcher'; import { isVisibleCppMacro, clearCppMacroVisibility } from './cpp-macro-visibility'; import { isCppConstructorRef, matchCppConstructor } from './cpp-constructor'; import { isCppSupertypeRef, matchCppSupertype, clearCppSupertypeMemos } from './cpp-supertypes'; @@ -133,20 +133,6 @@ const PYTHON_BUILT_IN_METHODS = new Set([ 'read', 'write', 'readline', 'readlines', 'close', 'flush', 'seek', ]); -const GO_STDLIB_PACKAGES = new Set([ - 'fmt', 'os', 'io', 'net', 'http', 'log', 'math', 'sort', 'sync', - 'time', 'path', 'bytes', 'strings', 'strconv', 'errors', 'context', - 'json', 'xml', 'csv', 'html', 'template', 'regexp', 'reflect', - 'runtime', 'testing', 'flag', 'bufio', 'crypto', 'encoding', - 'filepath', 'hash', 'mime', 'rand', 'signal', 'sql', 'syscall', - 'unicode', 'unsafe', 'atomic', 'binary', 'debug', 'exec', 'heap', - 'ring', 'scanner', 'tar', 'zip', 'gzip', 'zlib', 'tls', 'url', - 'user', 'pprof', 'trace', 'ast', 'build', 'parser', 'printer', - 'token', 'types', 'cgo', 'plugin', 'race', 'ioutil', - // Kubernetes-common stdlib aliases - 'utilruntime', 'utilwait', 'utilnet', -]); - const GO_BUILT_INS = new Set([ 'make', 'new', 'len', 'cap', 'append', 'copy', 'delete', 'close', 'panic', 'recover', 'print', 'println', 'complex', 'real', 'imag', @@ -2805,7 +2791,12 @@ export class ReferenceResolver { const dotIdx = name.indexOf('.'); if (dotIdx > 0) { const pkg = name.substring(0, dotIdx); - if (GO_STDLIB_PACKAGES.has(pkg)) { + // Only when the file imports it: an unimported `ring` / `token` / + // `url` is a local variable (`func flush(ring *ringLog) { ring.Write(b) }`). + if ( + GO_STDLIB_PACKAGES.has(pkg) && + this.context.getImportMappings(ref.filePath, 'go').some((i) => i.localName === pkg) + ) { return true; } } diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 0934c2844..a3075d409 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -304,14 +304,18 @@ 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: GoDeclaredType = {}; const type = inferLocalReceiverType(receiver, ref, context, decl); 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; + // A local variable named like a standard-library package (see matchMethodCall). + if (GO_STDLIB_PACKAGES.has(receiver)) return null; } else { const owner = context.getNodesInFile(ref.filePath).filter(n => n.kind === 'class' && n.startLine <= ref.line && n.endLine >= ref.line) @@ -2066,9 +2070,13 @@ export function goTypePositionTarget(result: ResolvedRef, ref: UnresolvedRef, co function isGoExternalQualified(ref: UnresolvedRef, context: ResolutionContext): boolean { const { imported, typePackage } = goRefQualification(ref, context); const pkg = imported ?? typePackage; - if (!pkg) return false; - return !(pkg.source.startsWith('.') || pkg.source.includes('/internal/') || - context.getGoPackageDir?.(pkg.source, ref.filePath) != null); + return !!pkg && isGoExternalImport(pkg, ref.filePath, context); +} + +/** Whether a Go file's import names a package from outside the project's modules. */ +function isGoExternalImport(imported: ImportMapping, filePath: string, context: ResolutionContext): boolean { + return !(imported.source.startsWith('.') || imported.source.includes('/internal/') || + context.getGoPackageDir?.(imported.source, filePath) != null); } /** @@ -9542,7 +9550,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. @@ -9769,8 +9777,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 @@ -10237,7 +10246,7 @@ function inferLocalReceiverType( * vs a bare `server`) says which package declares it. Set on the * incremental-scan path only, the one every Go receiver takes. */ - decl?: { raw?: string }, + decl?: GoDeclaredType, ): string | null { // CFML scope prefixes: `variables.svc` / `this.svc` name a COMPONENT-scoped // field whose assignment or `property` declaration usually lives outside the @@ -10296,8 +10305,10 @@ 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; @@ -10311,10 +10322,21 @@ 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; }; @@ -10339,8 +10361,8 @@ function inferLocalReceiverType( for (let i = callIdx; i >= startIdx; i--) { const type = matchLine(i); if (type) { - states.set(key, { hi: callIdx, ansIdx: i, ansType: type, ansRaw: lastRaw }); - if (decl) decl.raw = lastRaw; + states.set(key, { hi: callIdx, ansIdx: i, ansType: type, ansRaw: lastRaw, ansFile: lastFile }); + if (decl) { decl.raw = lastRaw; decl.file = lastFile; } return type; } } @@ -10354,18 +10376,19 @@ function inferLocalReceiverType( state.ansIdx = i; state.ansType = type; state.ansRaw = lastRaw; + state.ansFile = lastFile; break; } } state.hi = callIdx; if (state.ansIdx < startIdx) return null; - if (decl) decl.raw = state.ansRaw; + if (decl) { decl.raw = state.ansRaw; decl.file = state.ansFile; } return state.ansType; } for (let i = callIdx; i >= startIdx; i--) { const type = matchLine(i); if (type) { - if (decl) decl.raw = lastRaw; + if (decl) { decl.raw = lastRaw; decl.file = lastFile; } return type; } } @@ -10747,7 +10770,7 @@ export function matchMethodCall( matchVbTypedCall(objectOrClass!, methodName!, ref, context, (name) => isStdMethodName('vbnet', name))); if (typed !== undefined) return typed; } - const decl: { raw?: string } = {}; + const decl: GoDeclaredType = {}; const cppDecl: CppReceiverDeclaration = {}; let inferredType = nmTimedT('mc-infer', ref, () => ref.language === 'cpp' @@ -10785,6 +10808,10 @@ export function matchMethodCall( if (!awaited.name || TS_PRIMITIVE_TYPES.has(awaited.name)) return null; inferredType = awaited.name; } + // 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' && isGoExternalType(decl.raw, decl.file ?? ref.filePath, context)) return null; if (inferredType) { // 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). @@ -10795,7 +10822,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) : undefined; const typedMatch = nmTimedT('mc-rmot', ref, () => resolveMethodOnType( inferredType, @@ -10872,6 +10899,13 @@ export function matchMethodCall( } } + // Go: a receiver named like a standard-library package the file does not + // import (`ring`, `token`, `url`) is a local variable, and only its declared + // type above may resolve it. These calls were skipped as standard-library + // calls before they reached the resolver, so one whose type is unknown + // keeps getting no edge rather than a guess by method name. + if (ref.language === 'go' && dotMatch && GO_STDLIB_PACKAGES.has(objectOrClass!)) return null; + // Go 2-hop field chain `base.field.Method` (#1276): the base's type comes // from the enclosing scope (typed parameter / method receiver / local var), // the field's declared type from that struct's own declaration lines, and @@ -11298,6 +11332,21 @@ function isOutOfRepoBinding(name: string, ref: UnresolvedRef, context: Resolutio return !!binding && context.isOutOfRepoImport?.(binding.source, ref.filePath, ref.language) === true; } +/** Go standard-library package names: a `pkg.Func` reference through one the file imports is skipped. */ +export const GO_STDLIB_PACKAGES = new Set([ + 'fmt', 'os', 'io', 'net', 'http', 'log', 'math', 'sort', 'sync', + 'time', 'path', 'bytes', 'strings', 'strconv', 'errors', 'context', + 'json', 'xml', 'csv', 'html', 'template', 'regexp', 'reflect', + 'runtime', 'testing', 'flag', 'bufio', 'crypto', 'encoding', + 'filepath', 'hash', 'mime', 'rand', 'signal', 'sql', 'syscall', + 'unicode', 'unsafe', 'atomic', 'binary', 'debug', 'exec', 'heap', + 'ring', 'scanner', 'tar', 'zip', 'gzip', 'zlib', 'tls', 'url', + 'user', 'pprof', 'trace', 'ast', 'build', 'parser', 'printer', + 'token', 'types', 'cgo', 'plugin', 'race', 'ioutil', + // Kubernetes-common stdlib aliases + 'utilruntime', 'utilwait', 'utilnet', +]); + /** The directory of a Go file, which is its package: Go keeps one package per directory. */ function goPackageDir(filePath: string): string { return path.posix.dirname(filePath.replace(/\\/g, '/')); @@ -11328,6 +11377,102 @@ function goDeclaredTypePackage(raw: string | undefined, filePath: string, contex return goImportPackageDir(name.slice(0, dot), filePath, context) ?? undefined; } +/** + * What Go receiver inference found: the type as its declaration spells it, + * and the file that spelling is read in when it is not the call's own — a + * callee's result type is written in the callee's file. + */ +type GoDeclaredType = { raw?: string; file?: string }; + +/** + * 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 && isGoExternalImport(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. + */ +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 imported = context.getImportMappings(filePath, 'go').find((imp) => imp.localName === callee.slice(0, dot)); + if (!imported) return null; + if (isGoExternalImport(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; + // `(params) *T`, `(params) (*T, error)`, `(params) (t *T, err error)`. + 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']); @@ -11403,28 +11548,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) : goPackageDir(typeNode.filePath); 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( @@ -11660,9 +11815,11 @@ function matchGoFieldChainCall( if (segs.length !== 2 || !segs[0] || !segs[1]) return null; const [base, field] = segs; - const decl: { raw?: string } = {}; + const decl: GoDeclaredType = {}; const baseType = inferLocalReceiverType(base!, ref, context, decl); if (!baseType) return null; + const declFile = decl.file ?? ref.filePath; + if (isGoExternalType(decl.raw, declFile, context)) return null; const fieldEsc = field!.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const fieldTypeRe = new RegExp(`\\b${fieldEsc}\\s+\\*?\\[?\\]?([A-Za-z_][\\w.]*)`); @@ -11675,7 +11832,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 = goDeclaredTypePackage(decl.raw, ref.filePath, context); + const basePkg = goDeclaredTypePackage(decl.raw, declFile, context); const declared = structs.filter((n) => goPackageDir(n.filePath) === basePkg); if (declared.length > 0) structs = declared; structs = preferCallSiteFile(structs, ref.filePath);