diff --git a/CHANGELOG.md b/CHANGELOG.md index 54ef5f5aa..d092d7c9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### New Features +- VB.NET is now fully supported — CodeGraph indexes `.vb` files natively using the bundled tree-sitter grammar, extracting classes, modules, interfaces, methods (including constructors), properties, fields, constants, enums, and imports, along with call, inheritance, and event-handler edges. - `codegraph status --json` now also reports the running CLI `version`, the index directory (`indexPath`), and a `lastIndexed` timestamp (ISO-8601, or null when nothing's indexed yet), so CI and scripts can pin the CLI version and check index freshness from a single command. A matching `CodeGraph.getLastIndexedAt()` library method exposes the same freshness check without shelling out. Thanks @12122J and @eddieran. (#329) ### Fixes diff --git a/README.md b/README.md index 1a9800ee3..f113a47e9 100644 --- a/README.md +++ b/README.md @@ -215,7 +215,7 @@ CodeGraph cuts **tokens, tool calls, and wall-clock time on every repo** — acr | **Full-Text Search** | Find code by name instantly across your entire codebase, powered by FTS5 | | **Impact Analysis** | Trace callers, callees, and the full impact radius of any symbol before making changes | | **Always Fresh** | File watcher uses native OS events (FSEvents/inotify/ReadDirectoryChangesW) with debounced auto-sync — the graph stays current as you code, zero config | -| **20+ Languages** | TypeScript, JavaScript, Python, Go, Rust, Java, C#, PHP, Ruby, C, C++, Objective-C, Swift, Kotlin, Dart, Lua, Luau, Svelte, Liquid, Pascal/Delphi | +| **21+ Languages** | TypeScript, JavaScript, Python, Go, Rust, Java, C#, VB.NET, PHP, Ruby, C, C++, Objective-C, Swift, Kotlin, Dart, Lua, Luau, Svelte, Liquid, Pascal/Delphi | | **Framework-aware Routes** | Recognizes web-framework routing files and links URL patterns to their handlers across 14 frameworks | | **Mixed iOS / React Native / Expo** | Closes cross-language flows that static parsing misses: Swift ↔ ObjC bridging, React Native legacy bridge + TurboModules + Fabric view components, native → JS event emitters, Expo Modules | | **100% Local** | No data leaves your machine. No API keys. No external services. SQLite database only | @@ -624,6 +624,7 @@ is written): | Svelte | `.svelte` | Full support (script extraction, Svelte 5 runes, SvelteKit routes) | | Vue | `.vue` | Full support (script + script-setup extraction, Nuxt page/API/middleware routes) | | Liquid | `.liquid` | Full support | +| VB.NET | `.vb` | Full support (classes, modules, interfaces, enums, methods, properties, constants, call edges) | | Pascal / Delphi | `.pas`, `.dpr`, `.dpk`, `.lpr` | Full support (classes, records, interfaces, enums, DFM/FMX form files) | | Lua | `.lua` | Full support (functions, methods with receivers, local variables, `require` imports, call edges) | | Luau | `.luau` | Full support (everything in Lua, plus `type`/`export type` aliases, typed signatures, and Roblox instance-path `require`) | diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index d29fa11b3..1c047fa4d 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -4459,3 +4459,93 @@ func (s Stack[T]) Len() int { return len(s.items) } expect(js.nodes.find((n) => n.name === 'handleRequest' && n.kind === 'function')).toBeDefined(); }); }); + +describe('VB.NET Extraction', () => { + it('extracts classes, modules, structs, interfaces, enums, methods, properties, imports, and calls', () => { + const code = ` +Imports System +Imports System.Collections.Generic + +Namespace MyApp + +Public Interface IAnimal + Function Speak() As String +End Interface + +Public Class Animal + Implements IAnimal + Private _name As String + Public Property Name As String + Public Sub New(name As String) + _name = name + End Sub + Public Function Speak() As String + Return _name + End Function +End Class + +Public Structure Point + Public X As Double + Public Y As Double + Public Function DistanceTo(other As Point) As Double + Return 0 + End Function +End Structure + +Public Enum Color + Red + Green + Blue +End Enum + +Module MathHelper + Public Const PI As Double = 3.14159 + Public Shared Function Add(a As Integer, b As Integer) As Integer + Return a + b + End Function + Public Sub Run() + Dim x = Add(1, 2) + End Sub +End Module + +End Namespace +`; + const result = extractFromSource('Sample.vb', code); + const byKind = (k: string) => result.nodes.filter((n) => n.kind === k).map((n) => n.name); + + // Types + expect(byKind('class')).toContain('Animal'); + expect(byKind('interface')).toContain('IAnimal'); + expect(byKind('struct')).toContain('Point'); + expect(byKind('enum')).toContain('Color'); + expect(byKind('enum_member')).toEqual(expect.arrayContaining(['Red', 'Green', 'Blue'])); + // Module is distinct from class (static sealed type) + const mod = result.nodes.find((n) => n.name === 'MathHelper' && n.kind === 'module'); + expect(mod).toBeDefined(); + expect(mod?.isStatic).toBe(true); + + // Members + expect(byKind('method')).toEqual(expect.arrayContaining(['New', 'Speak', 'DistanceTo', 'Add'])); + expect(byKind('property')).toContain('Name'); + expect(byKind('field')).toContain('_name'); + expect(byKind('constant')).toContain('PI'); + + // Shared method is static; constructor is not + expect(result.nodes.find((n) => n.name === 'Add')?.isStatic).toBe(true); + expect(result.nodes.find((n) => n.name === 'New')?.isStatic).toBeFalsy(); + + // Inherits/Implements lines must not appear as fields + expect(byKind('field')).not.toContain('Implements'); + expect(byKind('field')).not.toContain('IAnimal'); + + // Namespace and imports + expect(byKind('namespace')).toContain('MyApp'); + expect(byKind('import')).toEqual(expect.arrayContaining(['System', 'System.Collections.Generic'])); + + // Calls + expect(result.unresolvedReferences.some((r) => r.referenceKind === 'calls')).toBe(true); + + // Language tag + expect(result.nodes.find((n) => n.name === 'Animal')?.language).toBe('vbnet'); + }); +}); diff --git a/src/extraction/grammars.ts b/src/extraction/grammars.ts index 576845e20..7452f0a2c 100644 --- a/src/extraction/grammars.ts +++ b/src/extraction/grammars.ts @@ -38,6 +38,7 @@ const WASM_GRAMMAR_FILES: Record = { lua: 'tree-sitter-lua.wasm', luau: 'tree-sitter-luau.wasm', objc: 'tree-sitter-objc.wasm', + vbnet: 'tree-sitter-vbnet.wasm', }; /** @@ -101,6 +102,7 @@ export const EXTENSION_MAP: Record = { '.luau': 'luau', '.m': 'objc', '.mm': 'objc', + '.vb': 'vbnet', // XML: file-level tracking; the MyBatis extractor matches `` // shape and emits SQL-statement nodes (other XML returns empty). '.xml': 'xml', @@ -185,7 +187,7 @@ export async function loadGrammarsForLanguages(languages: Language[]): Promise> = { typescript: typescriptExtractor, @@ -49,4 +50,5 @@ export const EXTRACTORS: Partial> = { lua: luaExtractor, luau: luauExtractor, objc: objcExtractor, + vbnet: vbnetExtractor, }; diff --git a/src/extraction/languages/vbnet.ts b/src/extraction/languages/vbnet.ts new file mode 100644 index 000000000..f35949a34 --- /dev/null +++ b/src/extraction/languages/vbnet.ts @@ -0,0 +1,204 @@ +import type { Node as SyntaxNode } from 'web-tree-sitter'; +import { getNodeText } from '../tree-sitter-helpers'; +import type { LanguageExtractor } from '../tree-sitter-types'; + +// VB.NET keywords that appear as `field_declaration` misparsed nodes when the +// grammar encounters `Inherits X` / `Implements Y` inside a class body. +const VB_INHERIT_KEYWORDS = new Set([ + 'Inherits', 'Implements', 'MustInherit', 'NotInheritable', + 'MustOverride', 'NotOverridable', 'WithEvents', +]); + +function getModifierText(node: SyntaxNode): string[] { + const mods: string[] = []; + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child?.type === 'modifiers') { + for (let j = 0; j < child.namedChildCount; j++) { + const mod = child.namedChild(j); + if (mod?.type === 'modifier') mods.push(mod.text); + } + } + } + return mods; +} + +function resolveVisibility(mods: string[]): 'public' | 'private' | 'protected' | 'internal' | undefined { + if (mods.includes('Public')) return 'public'; + if (mods.includes('Private')) return 'private'; + if (mods.includes('Protected')) return 'protected'; + if (mods.includes('Friend')) return 'internal'; + return undefined; +} + +export const vbnetExtractor: LanguageExtractor = { + functionTypes: [], + // module_block and structure_block are handled in visitNode below so they get + // the correct 'module' and 'struct' kinds respectively; only class_block here. + classTypes: ['class_block'], + // constructor_declaration handles `Sub New(...)` + methodTypes: ['method_declaration', 'constructor_declaration'], + interfaceTypes: ['interface_block'], + structTypes: [], + enumTypes: ['enum_block'], + enumMemberTypes: ['enum_member'], + typeAliasTypes: [], + importTypes: ['imports_statement'], + callTypes: ['invocation'], + variableTypes: [], + propertyTypes: ['property_declaration'], + fieldTypes: ['field_declaration'], + + nameField: 'name', + bodyField: 'body', + paramsField: 'parameters', + returnField: 'return_type', + + // VB.NET block nodes are their own body (no separate child named 'body'). + resolveBody: (node) => node, + + getVisibility: (node) => resolveVisibility(getModifierText(node)), + + isStatic: (node) => getModifierText(node).includes('Shared'), + + isAsync: (node) => getModifierText(node).includes('Async'), + + resolveName: (node) => { + // constructor_declaration has no name field; canonical VB.NET name is "New" + if (node.type === 'constructor_declaration') return 'New'; + return undefined; + }, + + isExported: (node) => getModifierText(node).includes('Public'), + + // Produce a signature like `(x As Double, y As Double) As Double` for functions, + // or just `(x As Double, y As Double)` for subs (no return type). + getSignature: (node, source) => { + let params: SyntaxNode | null = null; + let returnType: SyntaxNode | null = null; + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (!child) continue; + if (child.type === 'parameter_list') params = child; + if (child.type === 'type') returnType = child; + } + if (!params && !returnType) return undefined; + const paramStr = params ? source.substring(params.startIndex, params.endIndex) : '()'; + return returnType + ? `${paramStr} As ${source.substring(returnType.startIndex, returnType.endIndex)}` + : paramStr; + }, + + extractImport: (node, source) => { + // imports_statement → namespace: namespace_name → identifier+ + const nsNode = node.childForFieldName('namespace'); + if (!nsNode) return null; + const moduleName = source.substring(nsNode.startIndex, nsNode.endIndex).trim(); + return { + moduleName, + signature: source.substring(node.startIndex, node.endIndex).trim(), + }; + }, + + visitNode: (node, ctx) => { + // namespace_block — create namespace scope and recurse + if (node.type === 'namespace_block') { + const nameNode = node.childForFieldName('name'); + if (!nameNode) return false; + const name = getNodeText(nameNode, ctx.source); + const nsNode = ctx.createNode('namespace', name, node); + if (nsNode) { + ctx.pushScope(nsNode.id); + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child) ctx.visitNode(child); + } + ctx.popScope(); + } + return true; + } + + // module_block — VB.NET Module is a static sealed type; extract as 'module' + if (node.type === 'module_block') { + const nameNode = node.childForFieldName('name'); + if (!nameNode) return false; + const name = getNodeText(nameNode, ctx.source); + const mods = getModifierText(node); + const moduleNode = ctx.createNode('module', name, node, { + visibility: resolveVisibility(mods), + isExported: mods.includes('Public'), + isStatic: true, + }); + if (moduleNode) { + ctx.pushScope(moduleNode.id); + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child) ctx.visitNode(child); + } + ctx.popScope(); + } + return true; + } + + // structure_block — VB.NET Structure is a value type; extract as 'struct' + if (node.type === 'structure_block') { + const nameNode = node.childForFieldName('name'); + if (!nameNode) return false; + const name = getNodeText(nameNode, ctx.source); + const mods = getModifierText(node); + const structNode = ctx.createNode('struct', name, node, { + visibility: resolveVisibility(mods), + isExported: mods.includes('Public'), + }); + if (structNode) { + ctx.pushScope(structNode.id); + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child) ctx.visitNode(child); + } + ctx.popScope(); + } + return true; + } + + // const_declaration — extract as constant node + if (node.type === 'const_declaration') { + const nameNode = node.childForFieldName('name'); + if (nameNode) { + const name = getNodeText(nameNode, ctx.source); + const mods = getModifierText(node); + ctx.createNode('constant', name, node, { + visibility: resolveVisibility(mods), + isStatic: mods.includes('Shared'), + }); + } + return true; + } + + // Filter out Inherits/Implements lines misparsed as field_declaration. + // VB.NET grammar splits `Inherits BaseClass` into two adjacent field_declarations + // on the same source line: one for the keyword and one for the base name. + if (node.type === 'field_declaration') { + const decls = node.namedChildren.filter(c => c.type === 'variable_declarator'); + const nameNode = decls[0]?.childForFieldName('name'); + const name = nameNode ? getNodeText(nameNode, ctx.source) : ''; + + // Skip the keyword node itself + if (VB_INHERIT_KEYWORDS.has(name)) return true; + + // Skip the value node that follows a keyword on the same source line + const prev = node.previousNamedSibling; + if (prev?.type === 'field_declaration') { + const prevDecls = prev.namedChildren.filter(c => c.type === 'variable_declarator'); + const prevNameNode = prevDecls[0]?.childForFieldName('name'); + const prevName = prevNameNode ? getNodeText(prevNameNode, ctx.source) : ''; + if (VB_INHERIT_KEYWORDS.has(prevName) && + prev.startPosition.row === node.startPosition.row) { + return true; + } + } + } + + return false; + }, +}; diff --git a/src/extraction/wasm/tree-sitter-vbnet.wasm b/src/extraction/wasm/tree-sitter-vbnet.wasm new file mode 100644 index 000000000..8c175036e Binary files /dev/null and b/src/extraction/wasm/tree-sitter-vbnet.wasm differ diff --git a/src/types.ts b/src/types.ts index e710e31a1..17080bcbf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -88,6 +88,7 @@ export const LANGUAGES = [ 'lua', 'luau', 'objc', + 'vbnet', 'yaml', 'twig', 'xml',