diff --git a/CHANGELOG.md b/CHANGELOG.md index 26d23d097..08226636c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,6 +83,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Go, working out which structs implement an interface now counts the methods of the interfaces it embeds, and the methods a struct gets from the types it embeds. Before, only each type's own methods counted: etcd's `AuthReadTx`, which embeds `UnsafeAuthReader` and adds `RLock` and `RUnlock`, was listed as implemented by every read-write lock in the project, an interface that only embeds others had no implementations at all, and a struct that gets its methods from an embedded base, like prometheus's service discoveries embedding `refresh.Discovery` or gin's `Engine` embedding `RouterGroup`, was missing. `codegraph_explore`, impact and the type hierarchy now list the right implementations, and a call through the interface reaches a method the embedding struct overrides, like gin's `Engine.Use`. Re-index Go projects after upgrading. - In Go, a call through an interface now reaches a method that the implementing struct gets from a type it embeds: prometheus's `targetScraper` gets `Report` from the `*Target` it embeds, so a call through the `scraper` interface now reaches `Target.Report`, and etcd's `Hasher.MemberID` reaches `EtcdServer.MemberID` through `hasherAdapter`. Before, the call reached only structs that declare the method themselves, often just a test double, so `codegraph_explore`'s call path stopped at the interface. The method is picked the way Go picks it, from the nearest embedded type, and none is linked when two embedded types at the same depth both have it or it comes from an embedded interface. Re-index Go projects after upgrading. - In Go, a type defined over a slice, map, function or basic type now counts as implementing the interfaces its methods satisfy, just as a struct does: prometheus's `type staticDiscoverer []*targetgroup.Group` is a `Discoverer`, gin's `type formSource map[string][]string` a `setter`, etcd's `type Int64Comparable int64` a `Comparable`, and an adapter like `type HandlerFunc func(…)` a `Handler`. Before, only structs were considered, so these types were missing from an interface's implementations in `codegraph_explore`, impact and the type hierarchy, and a call through the interface, like `Discoverer.Run` or `setter.TrySet`, stopped there instead of reaching their methods. Re-index Go projects after upgrading. +- In Go, a type alias like `type Event = mvccpb.Event`, alone or inside a `type ( … )` group, is now indexed and links to the type it names, so impact on that type reaches the code that uses the alias. Before, aliases were missing: a use written through the alias's package, like `clientv3.Event` or prometheus's `storage.AOptions{…}`, linked to nothing, and one inside the package, like `&PrometheusVersion{}` in prometheus's `web` package, linked to whichever type of that name the project had, there a test helper's struct. A method called on a value of an alias's type now reaches the aliased type's method rather than a namesake's, and a generic alias like `type Items[T any] = List[T]` links to `List`, so `xs.Len()` on one reaches `List.Len` instead of nothing. Re-index Go projects after upgrading. - In Angular templates, a property binding, an interpolation, a structural directive or a control-flow block that calls one of the component's own members, like `[name]="icon()"`, `{{ label() }}`, `*ngIf="isOpen()"` or `@if (loading()) {`, now links the component to that member, and so does reading a getter, like `[disabled]="!canSave"`, or handing a method to a child component, like `[displayWith]="displayFn"` or `trackBy: trackById`. Before, only event bindings such as `(click)="save()"` were read, so a signal, a `computed` value, a getter or a method used only from a template had no callers and looked unused. A pipe, a template variable, a call on another object such as `form.reset()`, and a plain field read like `[value]="title"` still link nothing. Re-index Angular projects after upgrading. - React Router routes kept in a table of their own are now indexed: the ASP.NET Core React template's `AppRoutes` array that `App.js` maps into ``, a `routes` array imported into `useRoutes(routes)` or `createBrowserRouter(routes)` or returned by a function as in `useRoutes(routes(isLoggedIn))`, route objects kept one per file and listed in `createBrowserRouter([MainRoutes, LoginRoutes])`, and routes written in place in `useRoutes([…])`. Before, route objects were read only from a file that itself creates a data router, so these apps had no routes, and their `navigate('/login')` calls and `` links led nowhere. Each route links to the page its `element` renders, past a guard like `` or ``, and through `lazy(() => import(…))` to the page it loads rather than a same-named page elsewhere in the repository. An `index: true` route is the page at its parent's address, and a parent route around others counts as their layout. A `{ path, element }` list that nothing hands to the router, such as a menu, still makes no routes. Re-index React projects after upgrading. - In Go, a call or type written through an import whose path ends in a version, or in something other than the package's name, is now known to go through that import: `yaml.Unmarshal(…)` after `import "go.yaml.in/yaml/v3"` or `"gopkg.in/yaml.v3"`, `sqlite3.Error` after `import "github.com/mattn/go-sqlite3"`, `klog.V(2)` after `import "k8s.io/klog/v2"`. Before, only the last part of the path named such an import (`v3`, `yaml.v3`, `go-sqlite3`), so the name was matched on its own and could link to any project function, method or type that shared it: kubernetes' `klog.V(…)` calls were linked to a logging wrapper's `V` method, and etcd's `semver.Version` parameters to an unrelated `Version` function. A name from another module now links to nothing, and one through a package of your own project, like `kit.New()` after `import "example.com/kit/v2"`, links to that package's symbol. A comment in an import block is also no longer taken for the name of the import after it. Re-index Go projects after upgrading. Thanks @danusha2345 for the comment fix. (#2374) diff --git a/__tests__/fixtures/kernel-parity/torture.go b/__tests__/fixtures/kernel-parity/torture.go index 3aada94af..9e1070456 100644 --- a/__tests__/fixtures/kernel-parity/torture.go +++ b/__tests__/fixtures/kernel-parity/torture.go @@ -45,6 +45,32 @@ type Exact interface{ int64 } type Dur int +// Aliases (`=`): the types they name are references; a literal makes a struct or interface. +type Alias = pkga.Widget + +type ( + LocalAlias = Widget + PtrAlias = *Stack[int] + FnAlias = func(w Widget) error + MapAlias = map[string][]pkga.Item + Defined Widget +) + +type AnonAlias = struct { + *Base + n int +} + +type IfaceAlias = interface { + Render() string +} + +type WordAlias = uint + +func useAlias(a *Alias) LocalAlias { + return LocalAlias{} +} + func NewRegistry() *Registry { w := Widget{name: "w"} q := pkga.Widget{} diff --git a/__tests__/go-type-alias.test.ts b/__tests__/go-type-alias.test.ts new file mode 100644 index 000000000..38f7e4443 --- /dev/null +++ b/__tests__/go-type-alias.test.ts @@ -0,0 +1,394 @@ +/** + * A Go alias declaration (`type Event = mvccpb.Event`, `type ( A = B )`) was + * in neither extractor's graph. tree-sitter-go parses it as a `type_alias` + * beside the `type_spec` of a defined type (`type WatchChan <-chan + * WatchResponse`), and both extractors only read `type_spec`, so etcd's + * client/v3 `Event` had no node while the line below it did. + * + * An alias is now a node of the kind a declaration of its type gets: a + * `type_alias` that references the types it names, a `struct` or an + * `interface` when it names a struct or interface literal. Each reference sits + * on the type's name, where resolution reads a package qualifier back. A + * generic alias (`type Set[T any] = …`, Go 1.24) has no rule in this grammar: + * it parses as a `type_spec` around an error, so the kernel hands the file to + * the wasm extractor, which reads it as an alias too, without its type + * parameters. + * + * Resolution treats the alias as the type it names. A bare `Event{}` or + * `*Event` in its package links to the alias, a method called on an `*Event` + * is the aliased type's method, methods written with the alias as the + * receiver make it implement what they satisfy, and an alias whose target is + * written through a package the index doesn't know (`clientv3` under an + * unaliased `go.etcd.io/etcd/client/v3`, known as `v3` or `client`) never + * links to itself or to a namesake. + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import CodeGraph from '../src/index'; +import { extractFromSource } from '../src/extraction'; +import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars'; +import { tryKernelExtract, resetKernelForTests } from '../src/extraction/kernel'; +import type { ExtractionResult } from '../src/types'; + +const KERNEL_PATH = path.join( + __dirname, + '..', + 'codegraph-kernel', + 'prebuilds', + `${process.platform}-${process.arch}`, + 'codegraph-kernel.node' +); +const kernelAvailable = fs.existsSync(KERNEL_PATH) || process.env.CODEGRAPH_KERNEL_EXPECT === '1'; + +/** Every alias shape the grammar has a rule for, beside defined types. */ +const ALIAS_SOURCE = `package clientv3 + +import ( + "context" + + "go.etcd.io/etcd/api/v3/mvccpb" +) + +// Event is the event a watcher reports. +type Event = mvccpb.Event + +type WatchChan <-chan WatchResponse + +type WatchResponse struct { + Events []*Event +} + +type ( + // Local names a type of this package. + Local = WatchResponse + Ptr = *WatchResponse + Slice = []WatchResponse + Handler = func(ctx context.Context, r WatchResponse) error + Index = map[string]*mvccpb.KeyValue + Defined WatchResponse +) + +type Plain = List[WatchResponse] + +type Anon = struct { + Key string +} + +type Closer = interface { + Close() error +} + +type Type = string + +type Word = uint +`; + +/** Generic aliases: no grammar rule, so a parse error the kernel defers. */ +const GENERIC_SOURCE = `package clientv3 + +type List[T any] struct { + items []T +} + +// Set is a set of comparable values. +type Set[T comparable] = map[T]struct{} + +type Items[T any] = List[T] + +type Pairs[K comparable, V any] = map[K]List[V] +`; + +const ENV_KEYS = ['CODEGRAPH_KERNEL', 'CODEGRAPH_KERNEL_LANGS'] as const; + +/** ` ` of every node but the file and its imports, in source order. */ +function declarations(result: ExtractionResult): string[] { + return result.nodes.filter((n) => n.kind !== 'file' && n.kind !== 'import').map((n) => `${n.kind} ${n.name}`); +} + +/** ` ` for every `references` ref out of a type declaration, sorted. */ +function typeRefs(result: ExtractionResult): string[] { + const byId = new Map(result.nodes.map((n) => [n.id, n])); + return result.unresolvedReferences + .filter((r) => r.referenceKind === 'references' && ['type_alias', 'struct', 'interface'].includes(byId.get(r.fromNodeId)?.kind ?? '')) + .map((r) => `${byId.get(r.fromNodeId)!.name} ${r.referenceName}`) + .sort(); +} + +describe('Go alias declarations are extracted', () => { + let savedEnv: Record = {}; + + beforeAll(async () => { + await initGrammars(); + await loadGrammarsForLanguages(['go']); + }); + + beforeEach(() => { + savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])); + resetKernelForTests(); + }); + + afterEach(() => { + for (const k of ENV_KEYS) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } + resetKernelForTests(); + }); + + function extract(backend: 'kernel' | 'wasm', file: string, source: string): ExtractionResult { + if (backend === 'wasm') { + process.env.CODEGRAPH_KERNEL = '0'; + return extractFromSource(file, source, 'go'); + } + delete process.env.CODEGRAPH_KERNEL; + process.env.CODEGRAPH_KERNEL_LANGS = 'all'; + const result = tryKernelExtract(file, source, 'go'); + expect(result, `kernel extraction of ${file}`).not.toBeNull(); + return result!; + } + + /** Each type ref sits on its name, where resolution reads a package qualifier back. */ + function expectRefsOnTheirNames(result: ExtractionResult, source: string): void { + const lines = source.split(/\r?\n/); + const refs = result.unresolvedReferences.filter((r) => r.referenceKind === 'references'); + expect(refs.length).toBeGreaterThan(0); + for (const r of refs) { + expect(lines[r.line - 1]!.startsWith(r.referenceName, r.column), `${r.referenceName} at ${r.line}:${r.column}`).toBe(true); + } + } + + const backends = kernelAvailable ? (['kernel', 'wasm'] as const) : (['wasm'] as const); + + for (const crlf of [false, true]) { + const eol = (s: string) => (crlf ? s.replace(/\n/g, '\r\n') : s); + const label = crlf ? ' (CRLF)' : ''; + + it.each(backends)(`an alias is a node of its type's kind: %s${label}`, (backend) => { + const result = extract(backend, 'client/v3/watch.go', eol(ALIAS_SOURCE)); + expect(declarations(result)).toEqual([ + 'type_alias Event', + 'type_alias WatchChan', + 'struct WatchResponse', + 'type_alias Local', + 'type_alias Ptr', + 'type_alias Slice', + 'type_alias Handler', + 'type_alias Index', + 'type_alias Defined', + 'type_alias Plain', + 'struct Anon', + 'interface Closer', + 'method Close', + 'type_alias Type', + 'type_alias Word', + ]); + const event = result.nodes.find((n) => n.name === 'Event')!; + expect(event.isExported).toBe(true); + expect(event.startLine).toBe(10); + // A doc comment is read as for any type declaration: inside a group. + expect(result.nodes.find((n) => n.name === 'Local')!.docstring).toBe('Local names a type of this package.'); + // Nested under the file, as a defined type is. + const file = result.nodes.find((n) => n.kind === 'file')!; + expect(result.edges.some((e) => e.kind === 'contains' && e.source === file.id && e.target === event.id)).toBe(true); + }); + + it.each(backends)(`an alias references the types it names: %s${label}`, (backend) => { + const source = eol(ALIAS_SOURCE); + const result = extract(backend, 'client/v3/watch.go', source); + // The package of mvccpb.Event / context.Context stays in the source; + // predeclared types (string, uint, error) are no references. + expect(typeRefs(result)).toEqual([ + 'Event Event', + 'Handler Context', + 'Handler WatchResponse', + 'Index KeyValue', + 'Local WatchResponse', + 'Plain List', + 'Plain WatchResponse', + 'Ptr WatchResponse', + 'Slice WatchResponse', + ]); + expectRefsOnTheirNames(result, source); + }); + } + + it.each([false, true])('a generic alias is an alias without its type parameters (wasm; CRLF: %s)', (crlf) => { + const source = crlf ? GENERIC_SOURCE.replace(/\n/g, '\r\n') : GENERIC_SOURCE; + if (kernelAvailable) { + delete process.env.CODEGRAPH_KERNEL; + process.env.CODEGRAPH_KERNEL_LANGS = 'all'; + // No grammar rule: a parse error, which the kernel leaves to wasm. + expect(tryKernelExtract('client/v3/set.go', source, 'go')).toBeNull(); + } + const result = extract('wasm', 'client/v3/set.go', source); + expect(declarations(result)).toEqual(['struct List', 'type_alias Set', 'type_alias Items', 'type_alias Pairs']); + expect(result.nodes.find((n) => n.name === 'Set')!.isExported).toBe(true); + expect(typeRefs(result)).toEqual(['Items List', 'Pairs List']); + expectRefsOnTheirNames(result, source); + }); +}); + +describe('an indexed Go module resolves through its aliases', () => { + let root = ''; + let cg: CodeGraph; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-go-alias-')); + const files: Record = { + 'go.mod': 'module go.etcd.io/etcd\n\ngo 1.24\n', + 'api/v3/mvccpb/kv.go': [ + 'package mvccpb', + '', + 'type Event struct {', + '\tType int', + '}', + '', + 'func (e *Event) IsCreate() bool { return e.Type == 0 }', + '', + ].join('\n'), + // A namesake package that sorts first, so a lookup by name meets its + // `Event` and `Event::IsCreate` before mvccpb's. + 'alpha/event.go': [ + 'package alpha', + '', + 'type Event struct{}', + '', + 'func (e *Event) IsCreate() bool { return false }', + '', + ].join('\n'), + 'client/v3/watch.go': [ + 'package clientv3', + '', + 'import "go.etcd.io/etcd/api/v3/mvccpb"', + '', + 'type Event = mvccpb.Event', + '', + 'type WatchResponse struct {', + '\tEvents []*Event', + '}', + '', + 'func (wr *WatchResponse) Err() error { return nil }', + '', + 'type (', + '\tLocal = WatchResponse', + '\tPtr = *Local', + ')', + '', + 'func newEvent() Event { return Event{} }', + '', + 'func isCreate(e *Event) bool { return e.IsCreate() }', + '', + 'func check(p Ptr) error { return p.Err() }', + '', + ].join('\n'), + // A generic alias: the file parses with an error and goes to wasm. + 'client/v3/list.go': [ + 'package clientv3', + '', + 'type List[T any] struct{ items []T }', + '', + 'func (l *List[T]) Len() int { return len(l.items) }', + '', + 'type Items[T any] = List[T]', + '', + 'func count(xs *Items[int]) int { return xs.Len() }', + '', + ].join('\n'), + // prometheus's discovery/xds: methods written on the alias are SDConfig's. + 'discovery/kuma.go': [ + 'package discovery', + '', + 'type Config interface {', + '\tName() string', + '}', + '', + 'type SDConfig struct{}', + '', + 'type KumaSDConfig = SDConfig', + '', + 'func (*KumaSDConfig) Name() string { return "kuma" }', + '', + ].join('\n'), + 'watcher/watcher.go': [ + 'package watcher', + '', + 'import "go.etcd.io/etcd/client/v3"', + '', + '// The package is clientv3, though the index knows the import as v3 or client.', + 'type Event = clientv3.Event', + '', + ].join('\n'), + }; + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + cg = await CodeGraph.init(root, { index: true }); + }, 60_000); + + afterAll(() => { + cg?.destroy(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + }); + + /** `kind file:qualifiedName` of each non-contains edge out of `name` in `file`, sorted. */ + const linksFrom = (file: string, name: string) => { + const sources = cg.getNodesInFile(file).filter((n) => n.name === name && n.kind !== 'import'); + expect(sources, `${name} in ${file}`).toHaveLength(1); + const links = cg.getOutgoingEdgesFrom(sources.map((n) => n.id)) + .filter((e) => e.kind !== 'contains') + .map((e) => ({ kind: e.kind, target: cg.getNode(e.target)! })) + .map(({ kind, target }) => `${kind} ${target.filePath}:${target.qualifiedName}`); + return [...new Set(links)].sort(); + }; + + it('an alias references the type it names, in the package it is written through', () => { + expect(linksFrom('client/v3/watch.go', 'Event')).toEqual(['references api/v3/mvccpb/kv.go:Event']); + expect(linksFrom('client/v3/watch.go', 'Local')).toEqual(['references client/v3/watch.go:WatchResponse']); + expect(linksFrom('client/v3/watch.go', 'Ptr')).toEqual(['references client/v3/watch.go:Local']); + expect(linksFrom('client/v3/list.go', 'Items')).toEqual(['references client/v3/list.go:List']); + }); + + it('a bare name in the alias package is the alias', () => { + expect(linksFrom('client/v3/watch.go', 'newEvent')).toEqual([ + 'instantiates client/v3/watch.go:Event', + 'references client/v3/watch.go:Event', + ]); + }); + + it('a method called on an alias is the aliased type’s', () => { + expect(linksFrom('client/v3/watch.go', 'isCreate')).toEqual([ + 'calls api/v3/mvccpb/kv.go:Event::IsCreate', + 'references client/v3/watch.go:Event', + ]); + // Through two aliases, one of a pointer. + expect(linksFrom('client/v3/watch.go', 'check')).toEqual([ + 'calls client/v3/watch.go:WatchResponse::Err', + 'references client/v3/watch.go:Ptr', + ]); + expect(linksFrom('client/v3/list.go', 'count')).toEqual([ + 'calls client/v3/list.go:List::Len', + 'references client/v3/list.go:Items', + ]); + }); + + it('an alias owning methods written on it implements what they satisfy', () => { + expect(linksFrom('discovery/kuma.go', 'KumaSDConfig')).toEqual([ + 'implements discovery/kuma.go:Config', + 'references discovery/kuma.go:SDConfig', + ]); + // A call through Config.Name reaches the method written on the alias. + const config = cg.getNodesInFile('discovery/kuma.go').find((n) => n.name === 'Config')!; + const name = cg.getOutgoingEdgesFrom([config.id], ['contains']).map((e) => cg.getNode(e.target)!).find((n) => n.name === 'Name')!; + expect(cg.getOutgoingEdgesFrom([name.id], ['calls']).map((e) => cg.getNode(e.target)?.qualifiedName)).toEqual(['KumaSDConfig::Name']); + }); + + it('an alias written through a package the index does not know links to nothing', () => { + // Not to itself, nor to client/v3's, mvccpb's or alpha's `Event`. + expect(linksFrom('watcher/watcher.go', 'Event')).toEqual([]); + const event = cg.getNodesInFile('watcher/watcher.go').find((n) => n.name === 'Event')!; + expect(cg.getUnresolvedReferencesFrom(event.id).map((r) => `${r.referenceKind} ${r.referenceName}`)).toEqual(['references Event']); + }); +}); diff --git a/codegraph-kernel/src/go.rs b/codegraph-kernel/src/go.rs index 3a93bacf1..f0ccbab76 100644 --- a/codegraph-kernel/src/go.rs +++ b/codegraph-kernel/src/go.rs @@ -395,7 +395,7 @@ impl<'t> Walker<'t> { } else if kind == "method_declaration" { self.extract_method(node); skip_children = true; - } else if kind == "type_spec" { + } else if kind == "type_spec" || kind == "type_alias" { skip_children = self.extract_type_alias(node); } else if matches!(kind, "var_declaration" | "short_var_declaration" | "const_declaration") && !self.inside_class_like() @@ -529,7 +529,8 @@ impl<'t> Walker<'t> { self.stack.pop(); } - /// extractTypeAlias for Go: type_spec → struct / interface / plain alias. + /// extractTypeAlias for Go: type_spec (`type A B`) and type_alias + /// (`type A = B`) → struct / interface / plain alias. fn extract_type_alias(&mut self, node: Node<'t>) -> bool { stack_guard!(); let name = self.extract_name(node); @@ -582,14 +583,22 @@ impl<'t> Walker<'t> { return true; } - self.create_node( + let row = self.create_node( "type_alias", &name, node, Extra { docstring, is_exported, ..Extra::default() }, ); - // (go type_spec has no `value` field — no type-ref walk; TS/tsx member - // extraction is TS-family-only) + // (go has no `value` field — no TS-style type-ref walk or member + // extraction.) An alias references what its `type` field names; a + // defined type (`type_spec`) declares a type of its own. + if let Some(row) = row { + let references = edge_kind_index("references").unwrap(); + for ty in self.alias_type_names(node) { + let text = self.text(ty).to_string(); + self.push_ref_at(row, &text, references, ty); + } + } false } @@ -863,6 +872,47 @@ impl<'t> Walker<'t> { } } + /// goAliasTypeNames (languages/go.ts): the name nodes of the types an + /// alias's `type` field names, in source order, but its own type + /// parameters and the predeclared types. Only a `type_alias` is one here: + /// a generic alias, which tree-sitter-go 0.23 parses as a `type_spec` + /// around an error, never reaches the kernel (its file defers to wasm). + fn alias_type_names(&self, node: Node<'t>) -> Vec> { + let mut names = Vec::new(); + if node.kind() != "type_alias" { + return names; + } + let Some(ty) = node.child_by_field_name("type") else { return names }; + let mut params: HashSet<&str> = HashSet::new(); + if let Some(list) = node.child_by_field_name("type_parameters") { + for decl in (0..list.named_child_count()).filter_map(|i| list.named_child(i)) { + for c in (0..decl.named_child_count()).filter_map(|j| decl.named_child(j)) { + if c.kind() == "identifier" { + params.insert(self.text(c)); + } + } + } + } + self.collect_alias_type_names(ty, ¶ms, &mut names); + names + } + + fn collect_alias_type_names(&self, node: Node<'t>, params: &HashSet<&str>, out: &mut Vec>) { + stack_guard!(); + if node.kind() == "type_identifier" { + let text = self.text(node); + if !params.contains(text) && !is_go_predeclared_type(text) { + out.push(node); + } + return; + } + for i in 0..node.named_child_count() { + if let Some(c) = node.named_child(i) { + self.collect_alias_type_names(c, params, out); + } + } + } + /// extractInheritance — the Go branches: interface embedding (a type_elem /// holding one named type; a union, `~T` or basic type is a constraint) /// and struct embedding (field_declaration without a field_identifier), diff --git a/src/extraction/languages/go.ts b/src/extraction/languages/go.ts index 1bad7ff22..1ecbf8612 100644 --- a/src/extraction/languages/go.ts +++ b/src/extraction/languages/go.ts @@ -64,6 +64,40 @@ export function goEmbeddedTypeName(type: SyntaxNode | null | undefined, source: return GO_PREDECLARED_TYPES.has(getNodeText(type, source)) ? undefined : type; } +/** + * The name nodes of the types a Go alias declaration names, in source order: + * `Event` in `type Event = mvccpb.Event`, `List` and `Event` in `type Page = + * List[Event]`, each where it is written, so resolution reads a package + * qualifier back. The alias's own type parameters (`T` in `type Items[T any] + * = List[T]`) and Go's predeclared types are not types it names. A generic + * alias has no rule in tree-sitter-go 0.23: it parses as a `type_spec` whose + * `=` is an error, and reads as an alias here too. Undefined for a defined + * type (`type WatchChan <-chan Event`), which declares a type of its own. + */ +export function goAliasTypeNames(node: SyntaxNode, source: string): SyntaxNode[] | undefined { + const isAlias = node.type === 'type_alias' || + (node.type === 'type_spec' && node.children.some((c: SyntaxNode) => c.type === 'ERROR' && getNodeText(c, source).trim() === '=')); + const type = isAlias ? getChildByField(node, 'type') : null; + if (!type) return undefined; + const params = new Set(); + for (const decl of getChildByField(node, 'type_parameters')?.namedChildren ?? []) { + for (const c of decl.namedChildren) { + if (c.type === 'identifier') params.add(getNodeText(c, source)); + } + } + const names: SyntaxNode[] = []; + const walk = (n: SyntaxNode): void => { + if (n.type === 'type_identifier') { + const text = getNodeText(n, source); + if (!params.has(text) && !GO_PREDECLARED_TYPES.has(text)) names.push(n); + return; + } + for (const c of n.namedChildren) walk(c); + }; + walk(type); + return names; +} + export const goExtractor: LanguageExtractor = { functionTypes: ['function_declaration'], classTypes: [], // Go doesn't have classes @@ -71,7 +105,7 @@ export const goExtractor: LanguageExtractor = { interfaceTypes: [], // Handled via type_spec → resolveTypeAliasKind structTypes: [], // Handled via type_spec → resolveTypeAliasKind enumTypes: [], - typeAliasTypes: ['type_spec'], // Go type declarations + typeAliasTypes: ['type_spec', 'type_alias'], // `type A B` and `type A = B` importTypes: ['import_declaration'], callTypes: ['call_expression'], variableTypes: ['var_declaration', 'short_var_declaration', 'const_declaration'], @@ -92,7 +126,8 @@ export const goExtractor: LanguageExtractor = { return sig; }, resolveTypeAliasKind: (node, _source) => { - // Go type_spec: `type Foo struct { ... }` or `type Bar interface { ... }` + // Go type_spec: `type Foo struct { ... }` or `type Bar interface { ... }`, + // and an alias of a literal (`type Foo = struct { ... }`) alike. // The inner type is in the 'type' field of the type_spec node const typeChild = getChildByField(node, 'type'); if (!typeChild) return undefined; diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 580cccce5..8aa00c32e 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -24,7 +24,7 @@ import { EXTRACTORS } from './languages'; import { stripCppTemplateArgs, isCppConstructorDeclaration } from './languages/c-cpp'; import { NestedIntervals, scanCppBraceScopes, type CppBraceScopes } from './languages/cpp-brace-scopes'; import { rustImplTypeName } from './languages/rust'; -import { goEmbeddedTypeName } from './languages/go'; +import { goAliasTypeNames, goEmbeddedTypeName } from './languages/go'; import { dartMisparsedGenericCall, dartReceiverOf, isDartTypeName, pushDartTypeRefs } from './languages/dart'; import { LiquidExtractor } from './liquid-extractor'; import { RazorExtractor } from './razor-extractor'; @@ -1430,8 +1430,9 @@ export class TreeSitterExtractor { skipChildren = true; // extractEnum visits body children } // Check for type alias declarations (e.g. `type X = ...` in TypeScript) - // For Go, type_spec wraps struct/interface definitions — resolveTypeAliasKind - // detects these and extractTypeAlias creates the correct node kind. + // For Go, type_spec (and type_alias, `type A = B`) wraps struct/interface + // definitions — resolveTypeAliasKind detects these and extractTypeAlias + // creates the correct node kind. else if (this.extractor.typeAliasTypes.includes(nodeType)) { skipChildren = this.extractTypeAlias(node); } @@ -3871,6 +3872,17 @@ export class TreeSitterExtractor { // entry's string-literal name as a searchable member (issue #634). this.extractTsTupleContractNames(value, typeAliasNode); } + } else if (this.language === 'go') { + // Go's `type Event = mvccpb.Event` names its type in the `type` field. + for (const type of goAliasTypeNames(node, this.source) ?? []) { + this.unresolvedReferences.push({ + fromNodeId: typeAliasNode.id, + referenceName: getNodeText(type, this.source), + referenceKind: 'references', + line: type.startPosition.row + 1, + column: type.startPosition.column, + }); + } } } return false; diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index d84861728..87229132a 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -828,7 +828,9 @@ function isGoEmbedding(e: Edge, kindOf: (id: string) => NodeKind | null): boolea * `type_alias` that owns them through `contains` edges. Go gives it none of * the methods declared on the type it is written over, so it is matched by * the methods it declares. A true alias (`type A = B`) is B, not a type of its - * own, and is not extracted as one. + * own: its `type_alias` owns only methods written with it as the receiver + * (`func (c *KumaSDConfig) Name()`), which are B's, and stands in for B with + * those. One without any is no implementer. * * Both method sets include what embedding brings in, read off the declared * `extends`/`implements` edge each embedded type is. An interface has the diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 425a83f7a..68665c127 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -3231,6 +3231,12 @@ export class ReferenceResolver { return target && !isImportableKind(target.kind) ? null : result; } + // A Go alias names its type as an embedding does (below): written through + // a package that is none of the file's imports as indexed, the type found + // by its bare name is another package's namesake, or the alias itself. + if (ref.language === 'go' && ref.referenceKind === 'references' && + this.nodeById(ref.fromNodeId)?.kind === 'type_alias' && isGoUnknownQualified(ref, this.context)) return null; + // A Go type position — a parameter or result type, a composite literal's // type — names a type of the package Go reads it from (route handlers are // `references` too, but values). diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 1c46a5d0b..36365398d 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -9184,6 +9184,7 @@ export function clearNameMatcherMemos(context: ResolutionContext): void { SCALA_OBJECT_PACKAGES.delete(context); GO_QUALIFIERS.delete(context); GO_EMBEDS.delete(context); + GO_ALIAS_TARGETS.delete(context); JAVA_FILE_SCOPES.delete(context); JAVA_ANCESTORS.delete(context); SCALA_SUPERS.delete(context); @@ -10899,7 +10900,18 @@ function resolveGoMethodInPackage( const types = goPackageTypes(typeName, pkgDir, context); if (types.length === 0) return undefined; if (depth >= 4) return null; + let unplaced = 0; for (const t of types) { + // An alias is the type it names, and has that type's methods. + const aliased = goAliasTarget(t, context); + if (aliased !== undefined) { + const via = aliased && resolveMethodOnType( + aliased.name, methodName, ref, context, confidence, resolvedBy, aliased.pkgDir, depth + 1, + ); + if (via) return via; + if (!aliased) unplaced++; + continue; + } for (const embedded of goEmbeddedTypes(t, context)) { const via = resolveMethodOnType( embedded.name, methodName, ref, context, confidence, resolvedBy, embedded.pkgDir, depth + 1, @@ -10907,7 +10919,48 @@ function resolveGoMethodInPackage( if (via) return via; } } - return null; + // An alias of a type from outside the project (`type Ctx = context.Context`) + // or of no named type declares nothing here: the method is looked up by name, + // as it was before aliases had nodes. + return unplaced === types.length ? undefined : null; +} + +const GO_ALIAS_TARGETS = new WeakMap>(); + +/** + * The type a Go alias names, with the directory of the package that declares + * it — `mvccpb.Event` for `type Event = mvccpb.Event`, `Local` for `type Ptr + * = *Local`, `List` for `type Items[T any] = List[T]` — read from the + * declaration, as an embedding is. Null for an alias of anything else: a type + * from outside the project's packages, a predeclared one, a `func(…)` or + * `map[…]…`. Undefined when the node is no alias: a struct, an interface, or a + * defined type (`type Dur int`), which declares a type of its own. + */ +function goAliasTarget(typeNode: Node, context: ResolutionContext): { name: string; pkgDir: string } | null | undefined { + if (typeNode.kind !== 'type_alias') return undefined; + 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); + // `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)); + 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; + } + memo.set(typeNode.id, target); + return target; } /** The declarations of Go type `typeName` in the package at directory `pkgDir`. */