feat(extraction): add Visual Basic .NET language support (.vb) (#648, #639, #170) (#1164)

Vendored patched govindbanura/tree-sitter-vbnet grammar (MIT, ~20-fix patch
+ new external scanner for XML literals and multi-line LINQ continuation;
provenance + rebuild instructions in docs/grammars/tree-sitter-vbnet.md),
vbnet extractor with VB-specific call/index disambiguation, Inherits/
Implements heritage, As New instantiation, events, Declare P/Invoke, and
MustOverride abstract members.

Parse health on five real repos: PolicyPlus 100%, CompactGUI 100%,
staxrip 95.2%, SCrawler 87.2%, PCL 87.5% (upstream grammar: 3-18%).
Retrieval A/B (sonnet): 26-43% faster with 0-5 file reads vs 7-20 without.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-07-03 11:55:45 -05:00
committed by GitHub
co-authored by Claude Fable 5
parent d7afc8cc1f
commit 63e1b5a23a
12 changed files with 1953 additions and 5 deletions
+6 -1
View File
@@ -43,6 +43,7 @@ const WASM_GRAMMAR_FILES: Record<GrammarLanguage, string> = {
cfscript: 'tree-sitter-cfscript.wasm',
cfquery: 'tree-sitter-cfquery.wasm',
cobol: 'tree-sitter-cobol.wasm',
vbnet: 'tree-sitter-vbnet.wasm',
};
/**
@@ -131,6 +132,9 @@ export const EXTENSION_MAP: Record<string, Language> = {
'.cob': 'cobol',
'.cobol': 'cobol',
'.cpy': 'cobol',
// VB.NET: vendored grammar (patched govindbanura/tree-sitter-vbnet) — classes,
// modules, interfaces, structures, properties, events, Handles clauses, LINQ.
'.vb': 'vbnet',
// Spring config: `application.properties` / `application-*.properties`. Same
// shape as the `.yml` variants — the YAML/properties extractor emits one node
// per leaf key, and the Spring resolver links `@Value("${k}")` references.
@@ -249,7 +253,7 @@ export async function loadGrammarsForLanguages(languages: Language[]): Promise<v
// `class Foo(...)` as an ERROR that swallows the whole class (#237); we
// vendor the upstream ABI-15 tree-sitter-c-sharp 0.23.5 wasm, which parses
// primary constructors natively.
const wasmPath = (lang === 'pascal' || lang === 'scala' || lang === 'lua' || lang === 'luau' || lang === 'csharp' || lang === 'r' || lang === 'cfml' || lang === 'cfscript' || lang === 'cfquery' || lang === 'cobol')
const wasmPath = (lang === 'pascal' || lang === 'scala' || lang === 'lua' || lang === 'luau' || lang === 'csharp' || lang === 'r' || lang === 'cfml' || lang === 'cfscript' || lang === 'cfquery' || lang === 'cobol' || lang === 'vbnet')
? path.join(__dirname, 'wasm', wasmFile)
: require.resolve(`tree-sitter-wasms/out/${wasmFile}`);
const language = await WasmLanguage.load(wasmPath);
@@ -468,6 +472,7 @@ export function getLanguageDisplayName(language: Language): string {
cfscript: 'CFScript',
cfquery: 'CFQuery (SQL)',
cobol: 'COBOL',
vbnet: 'Visual Basic .NET',
unknown: 'Unknown',
};
return names[language] || language;
+2
View File
@@ -30,6 +30,7 @@ import { objcExtractor } from './objc';
import { cfscriptExtractor } from './cfscript';
import { cfqueryExtractor } from './cfquery';
import { cobolExtractor } from './cobol';
import { vbnetExtractor } from './vbnet';
export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
typescript: typescriptExtractor,
@@ -57,4 +58,5 @@ export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
cfscript: cfscriptExtractor,
cfquery: cfqueryExtractor,
cobol: cobolExtractor,
vbnet: vbnetExtractor,
};
+137
View File
@@ -0,0 +1,137 @@
import type { Node as SyntaxNode } from 'web-tree-sitter';
import { getNodeText } from '../tree-sitter-helpers';
import type { LanguageExtractor } from '../tree-sitter-types';
/**
* The vendored VB.NET grammar has no true end-of-file token (its `_eof` rule is
* a literal-`$` placeholder that never matches real input), so a file whose
* last line lacks a trailing newline ends every parse with a MISSING-newline
* error on the final statement. Appending a newline is offset-preserving for
* all existing content.
*/
export function ensureTrailingNewline(source: string): string {
return source.endsWith('\n') ? source : source + '\n';
}
/** Case-insensitive member-modifier scan (VB keywords are case-insensitive). */
function hasModifier(node: SyntaxNode, re: RegExp): boolean {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type === 'member_modifier' && re.test(child.text)) return true;
}
return false;
}
/**
* A VB.NET method's declared return type (`Function Foo(...) As Bar`),
* normalized to the bare class name a chained `Foo.Create().Bar()` could be
* called on (the #645/#608 mechanism). The type lives in the method's
* `as_clause` child; predefined types (Integer/String/…) and arrays yield
* undefined, generics `List(Of Foo)` unwrap to the base type, and a dotted
* `Ns.Foo` reduces to the simple name. Subs have no as_clause → undefined.
*/
function extractVbnetReturnType(node: SyntaxNode, source: string): string | undefined {
const asClause = node.namedChildren.find((c: SyntaxNode) => c.type === 'as_clause');
if (!asClause) return undefined;
const typeNode = asClause.childForFieldName('declared_type');
if (!typeNode || typeNode.type === 'predefined_type' || typeNode.type === 'array_type') return undefined;
let t = getNodeText(typeNode, source).trim();
t = t.replace(/\?+$/, ''); // nullable `Foo?`
t = t.replace(/\(\s*Of\b[^)]*\)/gi, ''); // generics `List(Of Foo)` → `List`
const last = t.split('.').pop()?.trim();
if (!last || !/^[A-Za-z_]\w*$/.test(last)) return undefined;
return last;
}
export const vbnetExtractor: LanguageExtractor = {
preParse: ensureTrailingNewline,
functionTypes: [],
// VB Modules are static containers (Shared members, no instantiation) —
// indexed as classes so their members get normal containment/qualification.
classTypes: ['class_declaration', 'module_declaration'],
methodTypes: [
'method_declaration',
'constructor_declaration',
// `Declare Function GetWindowLong Lib "user32" ...` (P/Invoke)
'external_method_declaration',
// Interface members are distinct node types in this grammar (unlike C#).
'interface_method_declaration',
// `MustOverride Sub/Function ...` — body-less abstract members.
'abstract_method_declaration',
],
interfaceTypes: ['interface_declaration'],
structTypes: ['structure_declaration'],
enumTypes: ['enum_declaration'],
enumMemberTypes: ['enum_member_declaration'],
typeAliasTypes: ['delegate_declaration'],
packageTypes: ['namespace_declaration'],
extractPackage: (node: SyntaxNode, source: string) => {
const name = node.childForFieldName('name');
return name ? getNodeText(name, source) : null;
},
importTypes: ['imports_statement'],
// VB uses parentheses for BOTH calls and indexing, so the grammar can only
// split them heuristically (empty parens → invocation, args → array access;
// even Roslyn parses both as InvocationExpression and disambiguates during
// binding). Both are treated as call sites — extractCall has a vbnet branch
// — and name matching simply never resolves an index read on a collection.
callTypes: ['invocation_expression', 'array_access_expression', 'generic_invocation_expression'],
variableTypes: ['declaration_statement'],
fieldTypes: ['field_declaration'],
propertyTypes: ['property_declaration', 'interface_property_declaration', 'abstract_property_declaration'],
nameField: 'name',
bodyField: 'body',
paramsField: 'parameters',
// Method/property statements are direct children of the declaration node
// (this grammar has no body wrapper), so the node is its own body — without
// this, calls inside every Sub/Function would be skipped.
resolveBody: (node: SyntaxNode) => node,
getReturnType: extractVbnetReturnType,
getVisibility: (node) => {
if (hasModifier(node, /^private$/i)) return 'private';
if (hasModifier(node, /^protected(\s+friend)?$/i)) return 'protected';
if (hasModifier(node, /^friend$/i)) return 'internal';
return 'public'; // VB members default to Public in practice
},
isStatic: (node) => hasModifier(node, /^shared$/i),
isConst: (node) => hasModifier(node, /^const$/i) || (hasModifier(node, /^shared$/i) && hasModifier(node, /^readonly$/i)),
isAsync: (node) => hasModifier(node, /^async$/i),
extractImport: (node, source) => {
const importText = source.substring(node.startIndex, node.endIndex).trim();
// `Imports System.Collections.Generic` / `Imports Alias = Some.Namespace` /
// `Imports Global.Company.Product`. The name reference is the last
// qualified/simple/global name child (skips the alias identifier).
const nameNode = [...node.namedChildren]
.reverse()
.find((c: SyntaxNode) =>
c.type === 'qualified_name' || c.type === 'simple_name' || c.type === 'global_qualified_name' || c.type === 'identifier'
);
if (nameNode) {
return { moduleName: getNodeText(nameNode, source), signature: importText };
}
return null;
},
visitNode: (node, ctx) => {
// Events are indexed so `RaiseEvent X` / `Handles obj.X` flows have a
// findable declaration (WinForms/WPF code is built around them).
if (node.type === 'event_declaration' || node.type === 'custom_event_declaration') {
const nameNode = node.childForFieldName('name');
if (nameNode) {
ctx.createNode('field', getNodeText(nameNode, ctx.source), node);
}
return true;
}
// `Sub New(...)` lexes as one token with no name field — without this,
// constructors index as `<anonymous>`.
if (node.type === 'constructor_declaration') {
const ctor = ctx.createNode('method', 'New', node);
if (ctor) {
ctx.pushScope(ctor.id);
ctx.visitFunctionBody(node, ctor.id);
ctx.popScope();
}
return true;
}
return false;
},
};
+99 -2
View File
@@ -1153,7 +1153,7 @@ export class TreeSitterExtractor {
// produce an `instantiates` reference. Children still walked so
// nested calls inside the constructor args (`new Foo(bar())`) get
// their own `calls` refs.
else if (INSTANTIATION_KINDS.has(nodeType)) {
else if (INSTANTIATION_KINDS.has(nodeType) || this.isVbnetConstructorShapedArrayCreation(node)) {
this.extractInstantiation(node);
// Java/C# `new T(...) { ... }` — anonymous class with body. Without
// extracting it as a class node + its methods, the interface→impl
@@ -3498,6 +3498,51 @@ export class TreeSitterExtractor {
const callerId = this.nodeStack[this.nodeStack.length - 1];
if (!callerId) return;
// VB.NET: `foo(args)` is syntactically ambiguous between a call and an
// index read, so the grammar parses non-empty parens as
// array_access_expression (field `array`, not `function`) — even Roslyn
// parses both as InvocationExpression and resolves during binding. Treat
// all three shapes as call sites: the callee is the member/identifier
// under the array/function field, qualified with a simple-identifier
// receiver for resolution. Index reads on collections simply never
// resolve to a callable, so they cost nothing.
if (
this.language === 'vbnet' &&
(node.type === 'array_access_expression' ||
node.type === 'invocation_expression' ||
node.type === 'generic_invocation_expression')
) {
const fn = getChildByField(node, 'function') || getChildByField(node, 'array');
if (!fn) return;
let calleeName = '';
if (fn.type === 'member_access_expression') {
const member = getChildByField(fn, 'member');
const memberName = member ? getNodeText(member, this.source) : '';
if (!memberName) return;
const receiver = getChildByField(fn, 'object');
const SKIP = new Set(['me', 'mybase', 'myclass']);
if (receiver && receiver.type === 'identifier' && !SKIP.has(getNodeText(receiver, this.source).toLowerCase())) {
calleeName = `${getNodeText(receiver, this.source)}.${memberName}`;
} else {
calleeName = memberName;
}
} else if (fn.type === 'identifier') {
calleeName = getNodeText(fn, this.source);
} else {
return; // parenthesized/chained receivers: no static name to link
}
if (calleeName) {
this.unresolvedReferences.push({
fromNodeId: callerId,
referenceName: calleeName,
referenceKind: 'calls',
line: node.startPosition.row + 1,
column: node.startPosition.column,
});
}
return;
}
// Ruby `call` nodes use `receiver` + `method` fields (tree-sitter-ruby), not
// the `object`/`name`/`function` fields the branches below expect — so
// without this they fell through to the generic path, which took the
@@ -3904,6 +3949,24 @@ export class TreeSitterExtractor {
* Children are still walked so nested calls inside the constructor
* arguments (`new Foo(bar())`) get their own `calls` references.
*/
/**
* VB.NET `New Invoice(1)` is syntactically ambiguous between constructing
* Invoice with an argument and allocating an Invoice array of bound 1; the
* grammar parses the parenthesized form as array_creation_expression. A
* user-defined type with no `{...}` array initializer is overwhelmingly a
* constructor call, so treat it as an instantiation. Predefined element
* types (`New Byte(1023)`) and brace-initialized forms stay arrays.
*/
private isVbnetConstructorShapedArrayCreation(node: SyntaxNode): boolean {
if (this.language !== 'vbnet' || node.type !== 'array_creation_expression') return false;
const typeNode = getChildByField(node, 'type');
if (!typeNode || typeNode.type === 'predefined_type' || typeNode.type === 'array_type') return false;
for (const child of node.namedChildren) {
if (child?.type === 'array_initializer') return false;
}
return true;
}
private extractInstantiation(node: SyntaxNode): void {
if (this.nodeStack.length === 0) return;
const fromId = this.nodeStack[this.nodeStack.length - 1];
@@ -3965,6 +4028,13 @@ export class TreeSitterExtractor {
// because no class is named with the angle-bracket suffix.
const ltIdx = className.indexOf('<');
if (ltIdx > 0) className = className.slice(0, ltIdx);
// VB.NET spells generics with parentheses: `New List(Of String)` /
// `New Dictionary(Of K, V)(cap)` — strip from the `(` so the bare
// type name is what resolution matches.
if (this.language === 'vbnet') {
const parenIdx = className.indexOf('(');
if (parenIdx > 0) className = className.slice(0, parenIdx);
}
// For namespaced/qualified constructors (`new ns.Foo()`,
// `new ns::Foo()`) keep the trailing identifier — that's what
// matches a class node in the index.
@@ -4374,7 +4444,7 @@ export class TreeSitterExtractor {
if (this.extractor!.callTypes.includes(nodeType)) {
this.extractCall(node);
} else if (INSTANTIATION_KINDS.has(nodeType)) {
} else if (INSTANTIATION_KINDS.has(nodeType) || this.isVbnetConstructorShapedArrayCreation(node)) {
// `new Foo()` inside a function body — emit an `instantiates`
// reference. Without this branch the body walker only knew
// about `call_expression`, so constructor invocations
@@ -4747,6 +4817,33 @@ export class TreeSitterExtractor {
}
}
// VB.NET: `Inherits Base` / `Implements IFoo, IBar(Of T)` are STATEMENTS
// inside the class body (children of the class node), not header clauses.
// Each name is a simple/qualified/generic reference; generics unwrap to
// the base identifier and dotted paths keep the trailing segment.
if (
this.language === 'vbnet' &&
(child.type === 'inherits_statement' || child.type === 'implements_statement')
) {
const kind = child.type === 'inherits_statement' ? 'extends' : 'implements';
for (const ref of child.namedChildren) {
if (!ref || (ref.type !== 'simple_name' && ref.type !== 'qualified_name' && ref.type !== 'generic_name' && ref.type !== 'global_qualified_name')) continue;
let name = getNodeText(ref, this.source);
name = name.replace(/\(\s*Of\b[^)]*\)/gi, '');
const lastDot = name.lastIndexOf('.');
if (lastDot >= 0) name = name.slice(lastDot + 1);
name = name.trim();
if (!name) continue;
this.unresolvedReferences.push({
fromNodeId: classId,
referenceName: name,
referenceKind: kind,
line: ref.startPosition.row + 1,
column: ref.startPosition.column,
});
}
}
// C#: `class Movie : BaseItem, IPlugin` → base_list with identifier children
// base_list combines both base class and interfaces in a single colon-separated list.
// We emit all as 'extends' since the syntax doesn't distinguish them.
Binary file not shown.
+1
View File
@@ -99,6 +99,7 @@ export const LANGUAGES = [
'cfscript',
'cfquery',
'cobol',
'vbnet',
'unknown',
] as const;