From db8293e13e95e86480291bc7ac05b2ca1a23ae80 Mon Sep 17 00:00:00 2001 From: prosdev Date: Wed, 10 Dec 2025 12:26:54 -0800 Subject: [PATCH] feat(core): add Go generics support to scanner Extend GoScanner to detect and extract generic type information: - Generic structs: type Stack[T any] struct - Generic interfaces: type Comparable[T any] interface - Generic functions: func Map[T, U any](...) - Methods on generic types: func (s *Stack[T]) Push(...) Metadata additions: - isGeneric: true for any generic declaration - typeParameters: ['T any', 'K comparable'] array of type params Updated tree-sitter query to handle generic_type nodes in receivers. 9 new tests, 1552 total passing. Closes #130 --- .../scanner/__tests__/fixtures/go/generics.go | 82 +++++++++++++++ .../core/src/scanner/__tests__/go.test.ts | 99 +++++++++++++++++++ packages/core/src/scanner/go.ts | 79 +++++++++++++-- 3 files changed, 252 insertions(+), 8 deletions(-) create mode 100644 packages/core/src/scanner/__tests__/fixtures/go/generics.go diff --git a/packages/core/src/scanner/__tests__/fixtures/go/generics.go b/packages/core/src/scanner/__tests__/fixtures/go/generics.go new file mode 100644 index 0000000..1ca893e --- /dev/null +++ b/packages/core/src/scanner/__tests__/fixtures/go/generics.go @@ -0,0 +1,82 @@ +package generics + +// Stack is a generic stack data structure. +type Stack[T any] struct { + items []T +} + +// Push adds an item to the stack. +func (s *Stack[T]) Push(item T) { + s.items = append(s.items, item) +} + +// Pop removes and returns the top item. +func (s *Stack[T]) Pop() (T, bool) { + if len(s.items) == 0 { + var zero T + return zero, false + } + item := s.items[len(s.items)-1] + s.items = s.items[:len(s.items)-1] + return item, true +} + +// Pair holds two values of potentially different types. +type Pair[K comparable, V any] struct { + Key K + Value V +} + +// NewPair creates a new Pair. +func NewPair[K comparable, V any](key K, value V) *Pair[K, V] { + return &Pair[K, V]{Key: key, Value: value} +} + +// Map applies a function to each element of a slice. +func Map[T, U any](slice []T, fn func(T) U) []U { + result := make([]U, len(slice)) + for i, v := range slice { + result[i] = fn(v) + } + return result +} + +// Filter returns elements that satisfy the predicate. +func Filter[T any](slice []T, predicate func(T) bool) []T { + var result []T + for _, v := range slice { + if predicate(v) { + result = append(result, v) + } + } + return result +} + +// Reduce reduces a slice to a single value. +func Reduce[T, U any](slice []T, initial U, fn func(U, T) U) U { + result := initial + for _, v := range slice { + result = fn(result, v) + } + return result +} + +// Comparable is an interface for comparable types. +type Comparable[T any] interface { + Compare(other T) int +} + +// Ordered is an interface for ordered types. +type Ordered interface { + ~int | ~int8 | ~int16 | ~int32 | ~int64 | + ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | + ~float32 | ~float64 | ~string +} + +// Min returns the minimum of two ordered values. +func Min[T Ordered](a, b T) T { + if a < b { + return a + } + return b +} diff --git a/packages/core/src/scanner/__tests__/go.test.ts b/packages/core/src/scanner/__tests__/go.test.ts index 36377b9..8115e11 100644 --- a/packages/core/src/scanner/__tests__/go.test.ts +++ b/packages/core/src/scanner/__tests__/go.test.ts @@ -255,4 +255,103 @@ describe('GoScanner', () => { }); }); }); + + describe('generics support', () => { + let genericsDocuments: Document[]; + + beforeAll(async () => { + genericsDocuments = await scanner.scan(['generics.go'], fixturesDir); + }); + + describe('generic structs', () => { + it('should extract generic struct Stack[T any]', () => { + const stack = genericsDocuments.find( + (d) => d.metadata.name === 'Stack' && d.type === 'class' + ); + expect(stack).toBeDefined(); + expect(stack?.metadata.custom?.isGeneric).toBe(true); + expect(stack?.metadata.custom?.typeParameters).toEqual(['T any']); + expect(stack?.metadata.signature).toContain('[T any]'); + }); + + it('should extract generic struct with multiple type parameters', () => { + const pair = genericsDocuments.find( + (d) => d.metadata.name === 'Pair' && d.type === 'class' + ); + expect(pair).toBeDefined(); + expect(pair?.metadata.custom?.isGeneric).toBe(true); + expect(pair?.metadata.custom?.typeParameters).toEqual(['K comparable', 'V any']); + }); + }); + + describe('generic functions', () => { + it('should extract generic function Map[T, U any]', () => { + const mapFn = genericsDocuments.find( + (d) => d.metadata.name === 'Map' && d.type === 'function' + ); + expect(mapFn).toBeDefined(); + expect(mapFn?.metadata.custom?.isGeneric).toBe(true); + expect(mapFn?.metadata.custom?.typeParameters).toEqual(['T', 'U any']); + }); + + it('should extract generic function with constraints', () => { + const minFn = genericsDocuments.find( + (d) => d.metadata.name === 'Min' && d.type === 'function' + ); + expect(minFn).toBeDefined(); + expect(minFn?.metadata.custom?.isGeneric).toBe(true); + expect(minFn?.metadata.custom?.typeParameters).toEqual(['T Ordered']); + }); + + it('should extract NewPair generic constructor', () => { + const newPair = genericsDocuments.find( + (d) => d.metadata.name === 'NewPair' && d.type === 'function' + ); + expect(newPair).toBeDefined(); + expect(newPair?.metadata.custom?.isGeneric).toBe(true); + expect(newPair?.metadata.custom?.typeParameters).toEqual(['K comparable', 'V any']); + }); + }); + + describe('generic methods', () => { + it('should extract method on generic receiver Stack[T]', () => { + const push = genericsDocuments.find( + (d) => d.metadata.name === 'Stack.Push' && d.type === 'method' + ); + expect(push).toBeDefined(); + expect(push?.metadata.custom?.receiver).toBe('Stack'); + expect(push?.metadata.custom?.isGeneric).toBe(true); + }); + + it('should extract Pop method on generic Stack', () => { + const pop = genericsDocuments.find( + (d) => d.metadata.name === 'Stack.Pop' && d.type === 'method' + ); + expect(pop).toBeDefined(); + expect(pop?.metadata.custom?.receiver).toBe('Stack'); + expect(pop?.metadata.custom?.receiverPointer).toBe(true); + }); + }); + + describe('generic interfaces', () => { + it('should extract generic interface Comparable[T any]', () => { + const comparable = genericsDocuments.find( + (d) => d.metadata.name === 'Comparable' && d.type === 'interface' + ); + expect(comparable).toBeDefined(); + expect(comparable?.metadata.custom?.isGeneric).toBe(true); + expect(comparable?.metadata.custom?.typeParameters).toEqual(['T any']); + }); + }); + + describe('non-generic items in generic file', () => { + it('should not mark non-generic interface as generic', () => { + const ordered = genericsDocuments.find( + (d) => d.metadata.name === 'Ordered' && d.type === 'interface' + ); + expect(ordered).toBeDefined(); + expect(ordered?.metadata.custom?.isGeneric).toBeUndefined(); + }); + }); + }); }); diff --git a/packages/core/src/scanner/go.ts b/packages/core/src/scanner/go.ts index 1f935f9..5ef3bf2 100644 --- a/packages/core/src/scanner/go.ts +++ b/packages/core/src/scanner/go.ts @@ -21,7 +21,7 @@ const GO_QUERIES = { name: (identifier) @name) @definition `, - // Method declarations with receivers + // Method declarations with receivers (handles both regular and generic types) methods: ` (method_declaration receiver: (parameter_list @@ -29,7 +29,9 @@ const GO_QUERIES = { name: (identifier)? @receiver_name type: [ (pointer_type (type_identifier) @receiver_type) + (pointer_type (generic_type (type_identifier) @receiver_type)) (type_identifier) @receiver_type + (generic_type (type_identifier) @receiver_type) ])) @receiver name: (field_identifier) @name) @definition `, @@ -196,6 +198,9 @@ export class GoScanner implements Scanner { const exported = this.isExported(name); const snippet = this.truncateSnippet(fullText); + // Check for generics + const { isGeneric, typeParameters } = this.extractTypeParameters(signature); + documents.push({ id: `${file}:${name}:${startLine}`, text: this.buildEmbeddingText('function', name, signature, docstring), @@ -210,7 +215,10 @@ export class GoScanner implements Scanner { exported, docstring, snippet, - custom: isTestFile ? { isTest: true } : undefined, + custom: { + ...(isTestFile ? { isTest: true } : {}), + ...(isGeneric ? { isGeneric, typeParameters } : {}), + }, }, }); } @@ -240,7 +248,9 @@ export class GoScanner implements Scanner { const methodName = nameCapture.node.text; const receiverType = receiverTypeCapture?.node.text || 'Unknown'; - const name = `${receiverType}.${methodName}`; + // Strip type parameters from receiver for cleaner name (Stack[T] -> Stack) + const baseReceiverType = receiverType.replace(/\[.*\]/, ''); + const name = `${baseReceiverType}.${methodName}`; const startLine = defCapture.node.startPosition.row + 1; const endLine = defCapture.node.endPosition.row + 1; const fullText = defCapture.node.text; @@ -253,6 +263,12 @@ export class GoScanner implements Scanner { const receiverText = receiverCapture?.node.text || ''; const receiverPointer = receiverText.includes('*'); + // Check for generics (receiver has type params like Stack[T]) + const receiverHasGenerics = receiverType.includes('['); + const { isGeneric: signatureHasGenerics, typeParameters } = + this.extractTypeParameters(signature); + const isGeneric = receiverHasGenerics || signatureHasGenerics; + documents.push({ id: `${file}:${name}:${startLine}`, text: this.buildEmbeddingText('method', name, signature, docstring), @@ -268,9 +284,10 @@ export class GoScanner implements Scanner { docstring, snippet, custom: { - receiver: receiverType, + receiver: baseReceiverType, receiverPointer, ...(isTestFile ? { isTest: true } : {}), + ...(isGeneric ? { isGeneric, typeParameters } : {}), }, }, }); @@ -301,7 +318,13 @@ export class GoScanner implements Scanner { const startLine = defCapture.node.startPosition.row + 1; const endLine = defCapture.node.endPosition.row + 1; const fullText = defCapture.node.text; - const signature = `type ${name} struct`; + + // Check for generics in the full declaration text + const { isGeneric, typeParameters } = this.extractTypeParameters(fullText); + const signature = isGeneric + ? `type ${name}[${typeParameters?.join(', ')}] struct` + : `type ${name} struct`; + const docstring = extractGoDocComment(sourceText, startLine); const exported = this.isExported(name); const snippet = this.truncateSnippet(fullText); @@ -320,7 +343,10 @@ export class GoScanner implements Scanner { exported, docstring, snippet, - custom: isTestFile ? { isTest: true } : undefined, + custom: { + ...(isTestFile ? { isTest: true } : {}), + ...(isGeneric ? { isGeneric, typeParameters } : {}), + }, }, }); } @@ -350,7 +376,13 @@ export class GoScanner implements Scanner { const startLine = defCapture.node.startPosition.row + 1; const endLine = defCapture.node.endPosition.row + 1; const fullText = defCapture.node.text; - const signature = `type ${name} interface`; + + // Check for generics in the full declaration text + const { isGeneric, typeParameters } = this.extractTypeParameters(fullText); + const signature = isGeneric + ? `type ${name}[${typeParameters?.join(', ')}] interface` + : `type ${name} interface`; + const docstring = extractGoDocComment(sourceText, startLine); const exported = this.isExported(name); const snippet = this.truncateSnippet(fullText); @@ -369,7 +401,10 @@ export class GoScanner implements Scanner { exported, docstring, snippet, - custom: isTestFile ? { isTest: true } : undefined, + custom: { + ...(isTestFile ? { isTest: true } : {}), + ...(isGeneric ? { isGeneric, typeParameters } : {}), + }, }, }); } @@ -498,6 +533,34 @@ export class GoScanner implements Scanner { return fullText.slice(0, braceIndex).trim(); } + /** + * Extract type parameters from a generic declaration. + * Returns { isGeneric, typeParameters } where typeParameters is an array like ["T any", "K comparable"] + */ + private extractTypeParameters(signature: string): { + isGeneric: boolean; + typeParameters?: string[]; + } { + // Match type parameters in brackets: func Name[T any, K comparable](...) or type Name[T any] struct + // Look for [ before ( for functions, or [ after type name for types + const typeParamMatch = signature.match(/\[([^\]]+)\]/); + if (!typeParamMatch) { + return { isGeneric: false }; + } + + const params = typeParamMatch[1]; + // Split by comma, but handle constraints like "~int | ~string" + const typeParameters = params + .split(/,\s*/) + .map((p) => p.trim()) + .filter((p) => p.length > 0); + + return { + isGeneric: true, + typeParameters, + }; + } + /** * Build embedding text for vector search */