Просмотр исходного кода

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>
Colby Mchenry 2 месяцев назад
Родитель
Сommit
63e1b5a23a

+ 24 - 1
.claude/skills/agent-eval/corpus.json

@@ -446,5 +446,28 @@
       "files": "~270",
       "question": "How does an incoming player chat message travel from packet handling to being broadcast to the other connected players? Name the programs on the path in order."
     }
+  ],
+  "VB.NET": [
+    {
+      "name": "policyplus",
+      "repo": "https://github.com/Fleex255/PolicyPlus",
+      "size": "Small",
+      "files": "~94",
+      "question": "When the user toggles a policy to Enabled in the policy-setting editor and clicks OK, how does the new state end up written into the loaded policy source (POL file or registry)? Trace the path from the EditSetting dialog to the concrete write."
+    },
+    {
+      "name": "scrawler",
+      "repo": "https://github.com/AAndyProgram/SCrawler",
+      "size": "Medium",
+      "files": "~320",
+      "question": "When a user download is started for a Reddit user, how does the request flow from the user-level download entry point through the shared downloader base into the Reddit site plugin, and where do downloaded media items get appended to the user's content list?"
+    },
+    {
+      "name": "staxrip",
+      "repo": "https://github.com/staxrip/staxrip",
+      "size": "Medium",
+      "files": "~145",
+      "question": "When a job finishes video encoding, how does staxrip decide which muxer runs and how does the muxer command line get built and executed? Trace from job processing to the mkvmerge invocation."
+    }
   ]
-}
+}

+ 1 - 0
CHANGELOG.md

@@ -11,6 +11,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
 ### New Features
 
+- CodeGraph now indexes **Visual Basic .NET** (`.vb`) — classes, Modules, interfaces, structures, enums, properties, events, `MustOverride` abstract members, and `Declare` P/Invoke signatures, with `Inherits`/`Implements` hierarchy edges, call edges (resolved through VB's ambiguous call-vs-index parentheses), and `New`/`As New` instantiation links. Real-world VB styles parse cleanly: WinForms designer files, interpolated and multi-line strings, XML literals (embedded `<%= %>` expressions included), single-line and multi-line LINQ queries, multi-line lambdas, `Handles`/`WithEvents` event wiring, Custom Events, date literals, classic type-character identifiers (`i%`, `name$`), and non-English (Unicode) identifiers. (#648, #639, #170)
 - CodeGraph now indexes **COBOL** (`.cbl`, `.cob`, `.cpy`) — programs, sections and paragraphs with `PERFORM`/`GO TO` call edges, `CALL` cross-program calls, `COPY` copybook imports (standalone copybooks included), and DATA DIVISION records with 88-level condition names, in both fixed and free source format. Impact queries work on data items: every `MOVE`/`ADD`/`COMPUTE`/`SUBTRACT` write-site links back to the field it changes, so "what touches this copybook field" answers across programs. CICS flows connect too: `EXEC CICS LINK`/`XCTL` program targets, `EXEC SQL INCLUDE` copybooks, and pseudo-conversational `RETURN TRANSID(...)` hops resolve to the program owning the transaction id. (#590, #648)
 - CodeGraph now indexes **CFML** (`.cfc`, `.cfm`, `.cfs`) — both the classic tag-based style (`<cfcomponent>`/`<cffunction>`) and modern bare-script `component { ... }` syntax, including `extends`/`implements`, embedded `<cfscript>` blocks (at any nesting depth, including inside `<cfif>`/`<cfloop>`/`<cftry>`), call edges, and calls embedded in `#hash#` expressions inside `<cfquery>` SQL bodies. Files saved with a UTF-8 byte-order mark and tags with unquoted attribute values — both common in long-lived CFML codebases — are handled too. Thanks @ghedwards. (#1118)
 - CFML inheritance written as a component path now links to the right component. `extends="coldbox.system.web.Controller"` names its supertype by dotted path and `extends="../base"` by relative path (the FW/1 style) — both previously produced no inheritance edge at all, which on framework-style CFML apps hid most of the type hierarchy from impact and blast-radius analysis (on ColdBox's own core, over 90% of inheritance was invisible). Resolution is deliberately conservative: the target's directory layout must corroborate the declared path — so a supertype that lives in an out-of-repo library (testbox, mxunit, an installed framework) correctly stays unlinked rather than being guessed at, and an ambiguous path produces no edge rather than a wrong one. (#1152)

+ 2 - 1
README.md

@@ -244,7 +244,7 @@ The reliable, universal payoff is **surgical context and speed**: CodeGraph coll
 | **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, Metal, Swift, Kotlin, Scala, Dart, Lua, Luau, R, CFML, COBOL, Svelte, Vue, Astro, Liquid, Pascal/Delphi |
+| **20+ Languages** | TypeScript, JavaScript, Python, Go, Rust, Java, C#, VB.NET, PHP, Ruby, C, C++, Objective-C, Metal, Swift, Kotlin, Scala, Dart, Lua, Luau, R, CFML, COBOL, Svelte, Vue, Astro, Liquid, Pascal/Delphi |
 | **Framework-aware Routes** | Recognizes web-framework routing files and links URL patterns to their handlers across 17 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 |
@@ -717,6 +717,7 @@ is written):
 | Luau | `.luau` | Full support (everything in Lua, plus `type`/`export type` aliases, typed signatures, and Roblox instance-path `require`) |
 | CFML | `.cfc`, `.cfm`, `.cfs` | Full support (tag-based `<cfcomponent>`/`<cffunction>` and bare-script `component { ... }` styles, `extends`/`implements`, embedded `<cfscript>` delegation, call edges) |
 | COBOL | `.cbl`, `.cob`, `.cpy` | Full support (programs, sections/paragraphs with PERFORM/GO TO call edges, CALL 'literal' cross-program calls, COPY copybook imports — including standalone `.cpy` files — DATA DIVISION records/fields/88-levels, EXEC CICS LINK/XCTL and EXEC SQL INCLUDE targets; fixed and free format) |
+| Visual Basic .NET | `.vb` | Full support (classes, Modules, interfaces, structures, enums, properties, events, `Declare` P/Invoke, `Handles`/`WithEvents`, `Inherits`/`Implements` edges, call edges through VB's call/index paren ambiguity, `As New` instantiation, interpolated strings, LINQ, Unicode identifiers) |
 
 ## Measured cross-file coverage
 

+ 217 - 0
__tests__/extraction.test.ts

@@ -8536,3 +8536,220 @@ DO-WORK.
     expect(result.nodes.find((n) => n.kind === 'function')?.name).toBe('DO-WORK');
   });
 });
+
+// =============================================================================
+// VB.NET (.vb) — vendored patched govindbanura/tree-sitter-vbnet grammar
+// =============================================================================
+
+describe('VB.NET Extraction', () => {
+  it('should detect .vb as vbnet', () => {
+    expect(detectLanguage('Service.vb')).toBe('vbnet');
+    expect(detectLanguage('app/Forms/MainForm.vb')).toBe('vbnet');
+    expect(isSourceFile('Service.vb')).toBe(true);
+  });
+
+  const SAMPLE = `Imports System
+Imports System.Collections.Generic
+
+Namespace Acme.Billing
+
+    Public Interface IRepository
+        Function GetById(ByVal id As Integer) As Invoice
+    End Interface
+
+    Public Enum InvoiceState
+        Draft = 0
+        Sent
+        Paid
+    End Enum
+
+    Public Structure Money
+        Public Amount As Decimal
+    End Structure
+
+    Public MustInherit Class EntityBase
+        Public Property Id As Integer
+    End Class
+
+    Public Class Invoice
+        Inherits EntityBase
+        Implements IRepository
+
+        Private ReadOnly _lines As New List(Of String)
+        Public Const MaxLines As Integer = 100
+        Public Event Paid(ByVal amount As Decimal)
+
+        Public Property State As InvoiceState
+
+        Public Sub New(ByVal id As Integer)
+            Me.Id = id
+        End Sub
+
+        Public Function GetById(ByVal id As Integer) As Invoice Implements IRepository.GetById
+            Return New Invoice(id)
+        End Function
+
+        Public Sub AddLine(ByVal description As String)
+            _lines.Add(description)
+            Validate(description)
+        End Sub
+
+        Private Sub Validate(ByVal text As String)
+            If text.Length > MaxLines Then Throw New ArgumentException("too long")
+        End Sub
+    End Class
+
+    ' lowercase keywords: VB is case-insensitive
+    public module Helpers
+        public function Twice(byval n as integer) as integer
+            return n * 2
+        end function
+
+        Public Sub Run()
+            Dim inv = New Invoice(1)
+            inv.AddLine("widget")
+            Dim d As New Dictionary(Of String, Integer)
+            Helpers.Twice(21)
+        End Sub
+    end module
+End Namespace
+`;
+
+  it('should extract classes, modules, interfaces, structures, and enums', () => {
+    const result = extractFromSource('Invoice.vb', SAMPLE);
+    const kinds = (kind: string) => result.nodes.filter((n) => n.kind === kind).map((n) => n.name);
+    expect(kinds('class')).toEqual(expect.arrayContaining(['EntityBase', 'Invoice', 'Helpers']));
+    expect(kinds('interface')).toContain('IRepository');
+    expect(kinds('struct')).toContain('Money');
+    expect(kinds('enum')).toContain('InvoiceState');
+    expect(kinds('enum_member')).toEqual(expect.arrayContaining(['Draft', 'Sent', 'Paid']));
+  });
+
+  it('should extract methods, constructors, properties, fields, and events (case-insensitive keywords)', () => {
+    const result = extractFromSource('Invoice.vb', SAMPLE);
+    const methods = result.nodes.filter((n) => n.kind === 'method').map((n) => n.name);
+    expect(methods).toEqual(expect.arrayContaining(['GetById', 'AddLine', 'Validate', 'Twice', 'Run']));
+    const props = result.nodes.filter((n) => n.kind === 'property').map((n) => n.name);
+    expect(props).toEqual(expect.arrayContaining(['Id', 'State']));
+    const fields = result.nodes.filter((n) => n.kind === 'field' || n.kind === 'constant').map((n) => n.name);
+    expect(fields).toEqual(expect.arrayContaining(['_lines', 'MaxLines']));
+    // Event declarations index as findable members
+    expect(fields).toContain('Paid');
+  });
+
+  it('should qualify types with their namespace', () => {
+    const result = extractFromSource('Invoice.vb', SAMPLE);
+    const invoice = result.nodes.find((n) => n.kind === 'class' && n.name === 'Invoice');
+    expect(invoice?.qualifiedName).toContain('Acme.Billing');
+  });
+
+  it('should emit Inherits as extends and Implements as implements references', () => {
+    const result = extractFromSource('Invoice.vb', SAMPLE);
+    const extendsRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'extends');
+    expect(extendsRefs.map((r) => r.referenceName)).toContain('EntityBase');
+    const implementsRefs = result.unresolvedReferences.filter((r) => r.referenceKind === 'implements');
+    expect(implementsRefs.map((r) => r.referenceName)).toContain('IRepository');
+  });
+
+  it('should extract calls through both invocation and index-shaped parens', () => {
+    const result = extractFromSource('Invoice.vb', SAMPLE);
+    const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
+    // `_lines.Add(description)` parses as array_access (non-empty parens) — still a call site
+    expect(calls).toContain('_lines.Add');
+    // bare call with args
+    expect(calls).toContain('Validate');
+    // qualified module call
+    expect(calls).toContain('Helpers.Twice');
+  });
+
+  it('should emit instantiates for New, with VB generic syntax stripped', () => {
+    const result = extractFromSource('Invoice.vb', SAMPLE);
+    const insts = result.unresolvedReferences.filter((r) => r.referenceKind === 'instantiates').map((r) => r.referenceName);
+    expect(insts).toContain('Invoice');
+    // `As New Dictionary(Of String, Integer)` → bare type name, not `Dictionary(Of ...)`
+    expect(insts.some((n) => n.includes('(') || /\bOf\b/.test(n))).toBe(false);
+  });
+
+  it('should extract Imports as import nodes', () => {
+    const result = extractFromSource('Invoice.vb', SAMPLE);
+    const imports = result.nodes.filter((n) => n.kind === 'import').map((n) => n.name);
+    expect(imports).toEqual(expect.arrayContaining(['System', 'System.Collections.Generic']));
+  });
+
+  it('should parse a file without a trailing newline (preParse guard)', () => {
+    const code = 'Class Tail\n    Sub Go()\n        Log("x")\n    End Sub\nEnd Class';
+    const result = extractFromSource('Tail.vb', code);
+    expect(result.nodes.find((n) => n.kind === 'class')?.name).toBe('Tail');
+    expect(result.nodes.find((n) => n.kind === 'method')?.name).toBe('Go');
+  });
+});
+
+describe('VB.NET Extraction — scanner-backed constructs', () => {
+  it('should parse XML literals as opaque literals without breaking siblings', () => {
+    const code = `Class Muxer
+    Function WriteTags() As Object
+        Dim xml = <Tags>
+                      <%= From tag In Tags Select <Tag><Name><%= tag.Name %></Name></Tag> %>
+                  </Tags>
+        Return xml
+    End Function
+
+    Sub After()
+        Log("still extracted")
+    End Sub
+End Class
+`;
+    const result = extractFromSource('Muxer.vb', code);
+    const methods = result.nodes.filter((n) => n.kind === 'method').map((n) => n.name);
+    expect(methods).toEqual(expect.arrayContaining(['WriteTags', 'After']));
+  });
+
+  it('should parse multi-line LINQ query clauses', () => {
+    const code = `Class T
+    Function Big() As Integer
+        Dim big = From l In _lines
+                  Where l.Length > 3
+                  Select l.Length
+        Return big.Sum()
+    End Function
+End Class
+`;
+    const result = extractFromSource('Linq.vb', code);
+    const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
+    expect(calls).toContain('big.Sum');
+    expect(result.nodes.find((n) => n.kind === 'method')?.name).toBe('Big');
+  });
+
+  it('should extract MustOverride members without derailing following members', () => {
+    const code = `MustInherit Class VideoEncoder
+    MustOverride ReadOnly Property OutputExt As String
+
+    Public MustOverride Sub ShowConfigDialog(Optional param As Object = Nothing)
+
+    MustOverride Function GetError() As String
+
+    Sub New()
+        CanEdit = True
+    End Sub
+End Class
+`;
+    const result = extractFromSource('VideoEncoder.vb', code);
+    const methods = result.nodes.filter((n) => n.kind === 'method').map((n) => n.name);
+    expect(methods).toEqual(expect.arrayContaining(['ShowConfigDialog', 'GetError', 'New']));
+    const props = result.nodes.filter((n) => n.kind === 'property').map((n) => n.name);
+    expect(props).toContain('OutputExt');
+  });
+
+  it('should parse nullable declarator shorthand (Dim x? = expr)', () => {
+    const code = `Class T
+    Sub M(folderInfo As Object)
+        Dim SteamFolderData? = Parser.GetSteamNameAndID(folderInfo)
+        Use(SteamFolderData)
+    End Sub
+End Class
+`;
+    const result = extractFromSource('Factory.vb', code);
+    const calls = result.unresolvedReferences.filter((r) => r.referenceKind === 'calls').map((r) => r.referenceName);
+    expect(calls).toContain('Parser.GetSteamNameAndID');
+  });
+});

+ 171 - 0
docs/grammars/tree-sitter-vbnet.md

@@ -0,0 +1,171 @@
+# tree-sitter-vbnet.wasm — provenance & rebuild
+
+`src/extraction/wasm/tree-sitter-vbnet.wasm` is built from
+[govindbanura/tree-sitter-vbnet](https://github.com/govindbanura/tree-sitter-vbnet)
+(MIT) at commit `538b7087bf80e86004531b392fe1186379c0a2b5` with the patch in
+`tree-sitter-vbnet.patch` applied. The patch carries two files: `grammar.js`
+(edits) and `src/scanner.c` (a new external scanner; upstream has none). The
+upstream repo checks in no generated `src/`, so everything else is produced by
+`tree-sitter generate`.
+
+Alternatives considered: `CodeAnt-AI/tree-sitter-vb-dotnet` (22★) has **no
+license file** and its git history stopped in July 2025 — unusable for
+vendoring; `gabriel-gubert/tree-sitter-vbnet` is a 470-line VBScript-flavored
+toy. The Roslyn-based approach (PR #627) was withdrawn by its author in favor
+of tree-sitter — a Roslyn sidecar would add a .NET runtime dependency to a
+local-first npm tool.
+
+## What the patch adds
+
+Upstream parses textbook VB.NET but fails on the constructs that dominate real
+codebases (measured: 3–18% of files parsed clean across PolicyPlus, CompactGUI,
+and staxrip before patching). Each item below was found by parse-error census
+on those repos plus SCrawler and PCL:
+
+1. **Generic type arguments in dotted names** — `System.Collections.Generic.
+   Dictionary(Of K, V)`, `Implements IRepository(Of Invoice)`, and
+   method-level `Implements I(Of T).Member` (generic segments were only
+   accepted unqualified). Open generic types (`GetType(LoaderTask(Of ,))`)
+   parse too.
+2. **Interpolated strings** `$"… {expr[,align][:fmt]} …"` with `""`/`{{`/`}}`
+   escapes — including multi-line bodies and content pieces that begin with an
+   apostrophe: the pieces carry lexical precedence 101 (above `comment`'s 100)
+   because the comment **extra** otherwise fires *inside* the string rule and
+   eats the rest of the line, closing quote included.
+3. **Date/time literals** `#1/15/2020#` — previously lexed as a preprocessor
+   directive that swallowed to end-of-line. Directives are now constrained to
+   `#` + letter (`#If`, `#Region`, …), which real directives always satisfy.
+4. **VB 14 multi-line string literals** (a `"…"` literal may span lines since
+   VS 2015) and single-token `string_literal`/`character_literal` (`"["c`) —
+   the old multi-token form let extras interleave mid-string.
+5. **Numeric literal forms** — hex/octal/binary (`&HFF`, `&O777`, `&B1010`),
+   digit separators (`1_000`), type characters (`6.0!`, `50.0#`, `1.5@`,
+   `123&`, `7%`), and lowercase `f/r/d` suffixes. WinForms `.Designer.vb`
+   files are full of `6.0!`.
+6. **Identifier type characters and Unicode identifiers** — `Dim i% = 0`,
+   `Dim r$ = …` (classic VB style, pervasive in SCrawler) and full Unicode
+   identifiers (`CrashReason.Java虚拟机参数有误` — PCL is written in Chinese).
+   The identifier token is now `[\p{L}\p{Nl}_][\p{L}\p{Nl}\p{Nd}\p{Mn}\p{Pc}]*
+   [%$&!#@]?` with the `u` regex flag. **The `u` flag requires
+   tree-sitter-cli ≥ 0.25** — 0.24.x silently drops the `\p{…}` classes.
+7. **`As New T(args)` initializer clauses** — `as_clause` embeds a full
+   `object_creation_expression` for the `As New` form, so `Dim x As New
+   StringBuilder` / `Property P As New List(Of String)` produce instantiation
+   nodes. `Dim x? = expr` nullable declarators parse as well.
+8. **Statement separators and single-line forms** — `:` as a statement
+   terminator and block opener (`Class X : Inherits Y`, `Case 1 : Return "X"`),
+   single-line `If … Then stmt Else stmt` (via terminator-less inline statement
+   variants, aliased to the normal statement node names), inline `RaiseEvent`,
+   and optional `Then` on block `If` and `ElseIf` (legal VB, used in staxrip).
+9. **Multi-line lambdas** — `Sub(…) … End Sub` / `Function(…) … End Function`
+   bodies (upstream had a statement-block body with no `End` closer, so every
+   block lambda broke its surrounding argument list), `Async`/`Iterator`
+   lambda modifiers, `ByVal`/`ByRef` lambda parameters, and single-line
+   `Sub() If cond Then …` statement bodies.
+10. **Member declarations** — `Declare [Auto|Ansi|Unicode] Sub/Function … Lib
+    "dll" [Alias "…"]` P/Invoke declarations, `Custom Event … AddHandler/
+    RemoveHandler/RaiseEvent … End Event`, stacked attribute lines above one
+    member, property `= initializer` before `Implements`, type-less
+    auto-properties, and **`MustOverride` body-less methods and properties**:
+    `MustOverride` lexes as a dedicated token (removed from the
+    `member_modifier` alternation) that only `abstract_method_declaration` /
+    `abstract_property_declaration` accept, making the body-less parse
+    deterministic. (A GLR body-less alternative on `method_declaration` was
+    tried first and measurably poisoned error recovery — 100%→60% clean on
+    PolicyPlus — before being replaced with the token split.)
+11. **Expressions** — VB 15 tuple literals `(a, b)`, array literals
+    `{1, 2, 3}` (plus nested `{{k, v}, …}` dictionary groups, replacing the
+    ambiguous upstream `dictionary_initializer`), omitted argument slots
+    (`f(a,, b)` — Optional parameters passed positionally), `TypeOf x IsNot T`,
+    generic method calls without parens (`items.OfType(Of Panel)`),
+    null-conditional indexing `x?(0)`, and `Global.`-qualified type names.
+12. **LINQ queries** — query expressions no longer require a trailing
+    `Select`/`Group` clause, `Aggregate`-led queries, and
+    `Distinct`/`Skip`/`Take` clauses.
+
+### External scanner (`src/scanner.c`, new)
+
+Two constructs are not LR(1)-parseable with tree-sitter's newline-as-extra
+treatment; both get external tokens:
+
+- **`QUERY_CLAUSE_CONTINUATION`** — multi-line LINQ (`From x In xs` ↵
+  `Where …`). At a clause boundary the newline alone cannot distinguish
+  "query continues on the next line" from "statement ends here". The scanner
+  looks past the newline run at the next word and emits the continuation
+  token only when it is a query-clause keyword (with a `Select Case`
+  guard), so the decision is made by the lexer instead of the LR table.
+- **`XML_LITERAL`** — whole VB XML literals (`<Tags><Tag/></Tags>`) consumed
+  as one opaque token: element nesting, attributes, comments, CDATA,
+  processing instructions, and **nested** `<%= … %>` embedded expressions
+  (the staxrip `WriteTagfile` shape). Valid only where a literal can begin an
+  expression, so a relational `<` (which always *follows* an expression)
+  never collides. The scanner never skips a leading newline (it must remain
+  available as a statement terminator).
+
+The scanner is stateless (serialize/deserialize are no-ops).
+
+The `_eof` hack upstream (a literal-`$` token) cannot match a real
+end-of-file, so files whose last line has no trailing newline would end with a
+MISSING-newline error; the extractor's `preParse` appends a trailing newline
+instead of patching that in the grammar.
+
+## Measured parse health (at vendoring time)
+
+| Corpus | Clean parses |
+|---|---|
+| Fleex255/PolicyPlus (94 `.vb`) | 94/94 (100%) — upstream: 3/94 |
+| IridiumIO/CompactGUI (66) | 66/66 (100%) — upstream: 12/66 |
+| staxrip/staxrip (145) | 138/145 (95.2%) — upstream: 22/145 |
+| AAndyProgram/SCrawler (320) | 279/320 (87.2%) |
+| Meloong-Git/PCL (112, Chinese identifiers) | 98/112 (87.5%) |
+
+Known remaining gap (localized ERROR regions, deliberately unpatched):
+
+- **Column-0 GoTo labels** (`Recheck:` at the start of a line inside indented
+  code — the classic VB label style, used heavily in PCL). The `word:`
+  keyword-extraction token interacts badly with a newline immediately followed
+  by a word at column 0, consuming the newline and dropping the previous
+  statement's terminator. Removing `word:` fixes labels but reintroduces
+  keyword-prefix identifier bugs corpus-wide (measured: staxrip 95%→28%), so
+  `word:` stays and column-0 labels keep a localized error; indented labels
+  parse fine. Worth an upstream tree-sitter investigation eventually.
+
+## Rebuild
+
+```bash
+git clone https://github.com/govindbanura/tree-sitter-vbnet
+cd tree-sitter-vbnet
+git checkout 538b7087bf80e86004531b392fe1186379c0a2b5
+git apply path/to/tree-sitter-vbnet.patch   # patches grammar.js, adds src/scanner.c
+# tree-sitter needs a tree-sitter.json (upstream ships none); grammar name is
+# `vbnet` (C symbols tree_sitter_vbnet*):
+cat > tree-sitter.json <<'JSON'
+{
+  "grammars": [
+    { "name": "vbnet", "camelcase": "Vbnet", "scope": "source.vbnet",
+      "path": ".", "file-types": ["vb"] }
+  ],
+  "metadata": { "version": "0.1.0", "license": "MIT",
+    "description": "VB.NET grammar for tree-sitter",
+    "links": { "repository": "https://github.com/govindbanura/tree-sitter-vbnet" } }
+}
+JSON
+npm install tree-sitter-cli@0.25.10   # ≥0.25 REQUIRED: the /u regex flag (Unicode
+                                      # identifiers) is dropped silently by 0.24.x
+npx tree-sitter generate              # src/scanner.c from the patch is picked up
+npx tree-sitter build --wasm -o tree-sitter-vbnet.wasm   # needs emscripten or Docker
+```
+
+Upstream's checked-in `test/corpus` expectations predate its own grammar.js
+(every corpus test fails at the pinned commit, before any patching), so the
+five-repo parse-health sweep above — plus 16 construct repros and the
+`__tests__/extraction.test.ts` VB.NET block — is the regression baseline.
+
+## Upstreaming
+
+Not yet sent. The patch is one large, coherent "parse real-world VB.NET"
+change; if upstream shows signs of life it can be offered as a PR the same way
+the COBOL patch was ([tree-sitter-cobol#41](https://github.com/yutaro-sakamoto/tree-sitter-cobol/pull/41)),
+with the corpus numbers above as the motivation. Until then,
+`git apply tree-sitter-vbnet.patch` on upstream commit `538b708` reproduces
+the vendored grammar exactly.

+ 1293 - 0
docs/grammars/tree-sitter-vbnet.patch

@@ -0,0 +1,1293 @@
+diff --git a/grammar.js b/grammar.js
+index 21c8523..00bf2b1 100644
+--- a/grammar.js
++++ b/grammar.js
+@@ -21,10 +21,14 @@ const PRECEDENCE = {
+ 
+ module.exports = grammar({
+   name: "vbnet",
++  word: ($) => $._identifier_token,
++
++  externals: ($) => [$._query_clause_continuation, $.xml_literal],
++
+   extras: $ => [
+     $.comment,
+     $.preprocessor_directive,
+-    /[ \t\v\f]+/,
++    /[ \t\v\f\uFEFF]+/,
+     /\r?\n/,
+     /_[ \t]*\r?\n/,
+   ],
+@@ -38,17 +42,14 @@ module.exports = grammar({
+     $._type,
+   ],
+   conflicts: ($) => [
++    [$.binary_expression, $.assignment_expression, $.inline_assignment_statement],
+     [$.simple_name, $.generic_name],
+     [$.await_expression, $.with_expression],
+     [$._name_reference, $._type],
+     [$.object_creation_expression, $.array_creation_expression, $.array_type],
+-    [$.string_literal, $.character_literal],
+     [$._primary_expression, $.variable_declarator],
+-    [$.lambda_expression, $.with_expression],
+-    [$.lambda_expression],
+     [$.binary_expression, $.assignment_expression],
+     [$._primary_expression, $.select_element],
+-    [$.with_expression, $.select_element],
+     [$.select_clause],
+     [$._primary_expression, $.group_element],
+     [$._terminator, $._block_terminator],
+@@ -66,7 +67,6 @@ module.exports = grammar({
+     [$.aggregate_element, $.into_element],
+     [$.group_by_clause],
+     [$.else_clause],
+-    [$.if_statement],
+     [$.case_else_clause],
+     [$.case_clause],
+     [$.elseif_clause],
+@@ -75,7 +75,6 @@ module.exports = grammar({
+     [$._primary_expression, $.simple_name, $.generic_name],
+     [$._primary_expression, $.variable_declarator, $.labeled_statement],
+     [$._statement, $.with_statement],
+-    [$.dictionary_initializer, $.collection_initializer],
+     [
+       $.class_declaration,
+       $.structure_declaration,
+@@ -89,7 +88,6 @@ module.exports = grammar({
+     ],
+   ],
+ 
+-  word: ($) => $._identifier_token,
+ 
+   rules: {
+     source_file: ($) =>
+@@ -157,13 +155,14 @@ module.exports = grammar({
+       ),
+ 
+     _terminator: ($) =>
+-      choice(seq(optional(":"), choice("\n", "\r\n")), $._eof),
++      prec.right(choice(seq(optional(":"), choice("\n", "\r\n")), prec(-1, ":"), $._eof)),
+ 
+-    _block_terminator: ($) => prec.right(repeat1(choice("\n", "\r\n", $._eof))),
++    _block_terminator: ($) => prec.right(repeat1(choice("\n", "\r\n", ":", $._eof))),
+ 
+     _eof: ($) => token(prec(-10, "$")),
+ 
+-    _identifier_token: ($) => token(prec(-1, /[a-zA-Z_][a-zA-Z0-9_]*/)),
++    _identifier_token: ($) =>
++      token(prec(-1, /[\p{L}\p{Nl}_][\p{L}\p{Nl}\p{Nd}\p{Mn}\p{Pc}]*[%$&!#@]?/u)),
+ 
+     identifier: ($) =>
+       choice(
+@@ -194,7 +193,6 @@ module.exports = grammar({
+             ci("MustInherit"),
+             ci("NotInheritable"),
+             ci("Overrides"),
+-            ci("MustOverride"),
+             ci("NotOverridable"),
+             ci("Overridable"),
+             ci("Overloads"),
+@@ -225,43 +223,6 @@ module.exports = grammar({
+         $._statement  // Fallback to regular statements
+       ),
+ 
+-    _non_statement_identifier: $ => choice(
+-      // Match identifiers that don't start with statement keywords
+-      token(prec(1, seq(
+-        negative_lookahead(seq(
+-          choice(
+-            /[Tt][Rr][Yy]/,
+-            /[Ss][Ee][Ll][Ee][Cc][Tt]/,
+-            /[Ii][Ff]/,
+-            /[Ww][Hh][Ii][Ll][Ee]/,
+-            /[Dd][Oo]/,
+-            /[Ff][Oo][Rr]/,
+-            /[Uu][Ss][Ii][Nn][Gg]/,
+-            /[Ww][Ii][Tt][Hh]/,
+-            /[Tt][Hh][Rr][Oo][Ww]/,
+-            /[Rr][Ee][Tt][Uu][Rr][Nn]/,
+-            /[Ee][Xx][Ii][Tt]/,
+-            /[Cc][Oo][Nn][Tt][Ii][Nn][Uu][Ee]/,
+-            /[Ss][Tt][Oo][Pp]/,
+-            /[Ee][Nn][Dd]/,
+-            /[Gg][Oo][Tt][Oo]/,
+-            /[Rr][Ee][Ss][Uu][Mm][Ee]/,
+-            /[Ee][Rr][Rr][Oo][Rr]/,
+-            /[Oo][Nn]/,
+-            /[Rr][Ee][Dd][Ii][Mm]/,
+-            /[Ee][Rr][Aa][Ss][Ee]/,
+-            /[Ss][Yy][Nn][Cc][Ll][Oo][Cc][Kk]/,
+-            /[Rr][Aa][Ii][Ss][Ee][Ee][Vv][Ee][Nn][Tt]/,
+-            /[Aa][Dd][Dd][Hh][Aa][Nn][Dd][Ll][Ee][Rr]/,
+-            /[Rr][Ee][Mm][Oo][Vv][Ee][Hh][Aa][Nn][Dd][Ll][Ee][Rr]/
+-          ),
+-          choice(/\s/, /\r/, /\n/, /$/)
+-        )),
+-        /[a-zA-Z_][a-zA-Z0-9_]*/
+-      ))),
+-      seq("[", /[^\]]+/, "]") // Bracketed identifier can be anything
+-    ),
+-
+     local_declaration_modifier: ($) =>
+       choice(ci("Dim"), ci("Const"), ci("Static")),
+ 
+@@ -300,6 +261,8 @@ module.exports = grammar({
+     _primary_expression: ($) =>
+       choice(
+         $._literal,
++        $.array_literal,
++        $.tuple_expression,
+         $.identifier,
+         $.parenthesized_expression,
+         $.generic_invocation_expression,  // Add this before regular invocation
+@@ -320,17 +283,26 @@ module.exports = grammar({
+       ),
+ 
+     generic_invocation_expression: ($) =>
+-      prec(
+-        PRECEDENCE.INVOCATION + 1,  
++      prec.right(
++        PRECEDENCE.INVOCATION + 1,
+         seq(
+           field("function", $._expression),
+           field("type_arguments", $.type_argument_list),
+-          field("arguments", $.argument_list)
++          optional(field("arguments", $.argument_list))
+         )
+       ),
+ 
+     parenthesized_expression: ($) => seq("(", $._expression, ")"),
+ 
++    tuple_expression: ($) =>
++      seq(
++        "(",
++        choice($._expression, seq(field("name", $.identifier), ":=", $._expression)),
++        ",",
++        commaSep1(choice($._expression, seq(field("name", $.identifier), ":=", $._expression))),
++        ")"
++      ),
++
+     member_access_expression: ($) =>
+       prec.left(
+         PRECEDENCE.MEMBER_ACCESS,
+@@ -350,20 +322,23 @@ module.exports = grammar({
+         )
+       ),
+ 
+-    argument_list: ($) => seq("(", optional(commaSep1($._argument)), ")"),
++    argument_list: ($) => seq("(", optional($._arguments), ")"),
+ 
+-    _argument: ($) =>
+-      choice($._expression, $.named_argument, $.omitted_argument),
++    // Argument slots may be omitted between commas (`f(a,, b)`, `f(, x)`) —
++    // VB passes Optional parameters positionally by leaving the slot empty.
++    _arguments: ($) =>
++      seq($._argument, repeat(seq(",", optional($._argument)))),
+ 
+-    omitted_argument: ($) => token(prec(1, ",")),
++    _argument: ($) => choice($._expression, $.named_argument),
+ 
+     array_access_expression: ($) =>
+       prec(
+         PRECEDENCE.ARRAY_ACCESS,
+         seq(
+           field("array", $._expression),
++          optional("?"),
+           "(",
+-          field("indices", commaSep1($._expression)),
++          field("indices", $._arguments),
+           ")"
+         )
+       ),
+@@ -471,16 +446,53 @@ module.exports = grammar({
+       ),
+ 
+     lambda_expression: ($) =>
+-      seq(
+-        choice(ci("Function"), ci("Sub")),
+-        field("parameters", $.lambda_parameter_list),
+-        field("body", choice($._expression, $._statement_block))
++      choice(
++        prec.right(
++          seq(
++            optional(choice(ci("Async"), ci("Iterator"))),
++            choice(ci("Function"), ci("Sub")),
++            field("parameters", $.lambda_parameter_list),
++            field(
++              "body",
++              choice(
++                $._expression,
++                alias($.inline_if_statement, $.if_statement),
++                alias($.inline_throw_statement, $.throw_statement)
++              )
++            )
++          )
++        ),
++        seq(
++          optional(choice(ci("Async"), ci("Iterator"))),
++          ci("Function"),
++          field("parameters", $.lambda_parameter_list),
++          optional($.as_clause),
++          $._block_terminator,
++          repeat($._statement),
++          ci("End"), ci("Function")
++        ),
++        seq(
++          optional(choice(ci("Async"), ci("Iterator"))),
++          ci("Sub"),
++          field("parameters", $.lambda_parameter_list),
++          $._block_terminator,
++          repeat($._statement),
++          ci("End"), ci("Sub")
++        )
+       ),
+ 
+     lambda_parameter_list: ($) => seq("(", commaSep($.lambda_parameter), ")"),
+ 
+     lambda_parameter: ($) =>
+-      seq(field("name", $.identifier), optional($.as_clause)),
++      seq(
++        optional(field("modifiers", repeat1(choice(ci("ByVal"), ci("ByRef"))))),
++        field("name", $.identifier),
++        optional(choice(
++          $.array_rank_specifier,
++          seq("(", field("bounds", commaSep1($._expression)), ")")
++        )),
++        optional($.as_clause)
++      ),
+ 
+     typeof_is_expression: ($) =>
+       prec.left(
+@@ -488,7 +500,7 @@ module.exports = grammar({
+         seq(
+           ci("TypeOf"),
+           field("expression", $._expression),
+-          ci("Is"),
++          choice(seq(ci("Is"), ci("Not")), token(prec(12, ci("IsNot"))), ci("Is")),
+           field("type", $._type)
+         )
+       ),
+@@ -504,29 +516,12 @@ module.exports = grammar({
+             "initializer",
+             choice(
+               $.object_initializer, 
+-              $.collection_initializer,
+-              $.dictionary_initializer  // Add this new option
++              $.collection_initializer
+             )
+           )
+         )
+       ),
+ 
+-    dictionary_initializer: ($) =>
+-      seq(
+-        ci("From"), 
+-        "{", 
+-        commaSep($.dictionary_element), 
+-        "}"
+-      ),
+-    
+-    dictionary_element: ($) =>
+-      seq(
+-        "{",
+-        field("key", $._expression),
+-        ",",
+-        field("value", $._expression),
+-        "}"
+-      ),
+ 
+ 
+     object_initializer: ($) =>
+@@ -551,6 +546,8 @@ module.exports = grammar({
+     collection_initializer: ($) =>
+       seq(ci("From"), "{", commaSep($._expression), "}"),
+ 
++    array_literal: ($) => seq("{", commaSep($._expression), "}"),
++
+ 
+     array_creation_expression: ($) =>
+       seq(
+@@ -646,6 +643,9 @@ module.exports = grammar({
+       seq(field("object", $._expression), "!", field("member", $.identifier)),
+     _literal: ($) =>
+       choice(
++        $.xml_literal,
++        $.interpolated_string_literal,
++        $.date_literal,
+         $.string_literal,
+         $.character_literal,
+         $.integer_literal,
+@@ -654,16 +654,49 @@ module.exports = grammar({
+         $.nothing_literal,
+         $.date_literal
+       ),
+-    string_literal: ($) => seq('"', repeat(choice(/[^"\n]+/, '""')), '"'),
+-    character_literal: ($) => seq('"', choice(/[^"\n]/, '""'), '"', "c"),
++    string_literal: ($) => token(seq('"', repeat(choice(/[^"]/, '""')), '"')),
++    interpolated_string_literal: ($) =>
++      seq(
++        '$"',
++        repeat(
++          choice(
++            token.immediate(prec(101, /[^"{}]+/)),
++            token.immediate(prec(101, '""')),
++            token.immediate(prec(101, "{{")),
++            token.immediate(prec(101, "}}")),
++            $.interpolation
++          )
++        ),
++        token.immediate('"')
++      ),
++    interpolation: ($) =>
++      seq(
++        token.immediate(prec(101, "{")),
++        $._expression,
++        optional(seq(",", /-?\d+/)),
++        optional(seq(":", /[^}"\r\n]+/)),
++        "}"
++      ),
++    date_literal: ($) => token(prec(2, /#[ \t]*\d[^#\r\n]*#/)),
++    character_literal: ($) => token(seq('"', choice(/[^"\r\n]/, '""'), '"', /[cC]/)),
+     integer_literal: ($) =>
+-      token(seq(/\d+/, optional(choice("I", "UI", "L", "UL", "S", "US")))),
++      token(
++        seq(
++          choice(
++            /\d+(_+\d+)*/,
++            /&[Hh][0-9A-Fa-f]+(_+[0-9A-Fa-f]+)*/,
++            /&[Oo][0-7]+(_+[0-7]+)*/,
++            /&[Bb][01]+(_+[01]+)*/
++          ),
++          optional(choice(/[Uu][SsIiLl]/, /[SsIiLl]/, "%", "&"))
++        )
++      ),
+     floating_point_literal: ($) =>
+       token(
+         seq(
+           choice(seq(/\d+/, ".", /\d+/), seq(".", /\d+/), /\d+/),
+           optional(/[eE][+-]?\d+/),
+-          optional(choice("F", "D", "R"))
++          optional(/[FfRrDd!#@]/)
+         )
+       ),
+     boolean_literal: ($) => choice(ci("True"), ci("False")),
+@@ -672,7 +705,7 @@ module.exports = grammar({
+ 
+     // Names and types
+     _name_reference: ($) =>
+-      choice($.simple_name, $.qualified_name, $.global_qualified_name),
++      choice($.simple_name, $.generic_name, $.qualified_name, $.global_qualified_name),
+ 
+     simple_name: ($) => $.identifier,
+ 
+@@ -682,7 +715,7 @@ module.exports = grammar({
+         seq(
+           field("qualifier", $._name_reference),
+           ".",
+-          field("name", $.simple_name)
++          field("name", choice($.simple_name, $.generic_name))
+         )
+       ),
+     global_qualified_name: ($) =>
+@@ -694,6 +727,7 @@ module.exports = grammar({
+         $.simple_name,
+         $.qualified_name,
+         $.generic_name,
++        $.global_qualified_name,
+         $.predefined_type,
+         $.array_type,
+         $.tuple_type
+@@ -720,7 +754,8 @@ module.exports = grammar({
+       ),
+ 
+     generic_name: ($) => seq(field("name", $.identifier), $.type_argument_list),
+-    type_argument_list: ($) => seq("(", ci("Of"), commaSep1($._type), ")"),
++    type_argument_list: ($) =>
++      seq("(", ci("Of"), optional($._type), repeat(seq(",", optional($._type))), ")"),
+     array_type: ($) =>
+       seq(field("element_type", $._type), $.array_rank_specifier),
+     array_rank_specifier: ($) => seq("(", repeat(","), ")"),
+@@ -742,10 +777,14 @@ module.exports = grammar({
+         field("type", $._type)
+       ),
+ 
+-    as_clause: ($) => seq(
+-      ci("As"),
+-      field("declared_type", $._type)
+-    ),
++    as_clause: ($) =>
++      seq(
++        ci("As"),
++        choice(
++          $.object_creation_expression,
++          field("declared_type", $._type)
++        )
++      ),
+ 
+     implements_member_clause: ($) =>
+       seq(ci("Implements"), commaSep1($._name_reference)),
+@@ -856,7 +895,7 @@ module.exports = grammar({
+ 
+     enum_member_declaration: ($) =>
+       seq(
+-        optional(field("attributes", $.attribute_list)),
++        repeat(field("attributes", $.attribute_list)),
+         field("name", $.identifier),
+         optional(seq("=", field("value", $._expression))),
+         $._terminator
+@@ -882,6 +921,10 @@ module.exports = grammar({
+         prec(10, $.inherits_statement),
+         prec(10, $.implements_statement),
+         prec(100, $.constructor_declaration),
++        prec(150, $.external_method_declaration),
++        prec(160, $.custom_event_declaration),
++        prec(170, $.abstract_method_declaration),
++        prec(171, $.abstract_property_declaration),
+         // Explicitly add statements that can appear at class level
+         prec(200, $.try_statement),      // Very high precedence
+         prec(200, $.select_statement),   // Very high precedence
+@@ -936,33 +979,21 @@ module.exports = grammar({
+     variable_declarator: ($) =>
+       seq(
+         field("name", $.identifier),
++        optional("?"),
+         optional(choice(
+           // Array bounds specification
+           seq("(", field("bounds", commaSep1($._expression)), ")"),
+           // Array rank specification
+           $.array_rank_specifier
+         )),
+-        optional(choice(
+-          // Regular type declaration
+-          $.as_clause,
+-          seq(
+-            ci("As"),
+-            ci("New"),
+-            field("type", $._type),
+-            optional(field("arguments", $.argument_list)),
+-            optional(field("initializer", choice(
+-              $.object_initializer,
+-              $.collection_initializer
+-            )))
+-          )
+-        )),
++        optional($.as_clause),
+         optional(seq("=", field("initializer", $._expression)))
+       ),
+ 
+ 
+     field_declaration: ($) =>
+       prec.dynamic(-100, seq(
+-        optional(field("attributes", $.attribute_list)),
++        repeat(field("attributes", $.attribute_list)),
+         optional(field("modifiers", repeat1($.member_modifier))),
+         commaSep1($.variable_declarator),
+         $._terminator
+@@ -970,7 +1001,8 @@ module.exports = grammar({
+ 
+     field_variable_declarator: ($) =>
+       seq(
+-        field("name", $._non_statement_identifier),
++        field("name", $.identifier),
++        optional("?"),
+         optional($.array_rank_specifier),
+         optional($.as_clause),
+         optional(seq("=", field("initializer", $._expression)))
+@@ -978,12 +1010,13 @@ module.exports = grammar({
+ 
+     property_declaration: ($) =>
+       seq(
+-        optional(field("attributes", $.attribute_list)),
++        repeat(field("attributes", $.attribute_list)),
+         optional(field("modifiers", repeat1($.member_modifier))), // Changed
+         ci("Property"),
+         field("name", $.identifier),
+         optional(field("parameters", $.parameter_list)),
+-        $.as_clause,
++        optional($.as_clause),
++        optional(seq("=", field("initializer", $._expression))),
+         optional($.implements_member_clause),
+         optional(seq("=", field("initializer", $._expression))),
+         choice(
+@@ -1001,7 +1034,7 @@ module.exports = grammar({
+ 
+     property_getter: ($) =>
+       seq(
+-        optional(field("attributes", $.attribute_list)),
++        repeat(field("attributes", $.attribute_list)),
+         optional(field("modifiers", repeat1($.member_modifier))),
+         ci("Get"),
+         $._block_terminator,
+@@ -1013,7 +1046,7 @@ module.exports = grammar({
+ 
+     property_setter: ($) =>
+       seq(
+-        optional(field("attributes", $.attribute_list)),
++        repeat(field("attributes", $.attribute_list)),
+         optional(field("modifiers", repeat1($.member_modifier))),
+         ci("Set"),
+         optional(seq("(", field("parameter", $.parameter), ")")),
+@@ -1028,7 +1061,7 @@ module.exports = grammar({
+       prec(
+         1,
+         seq(
+-          optional(field("attributes", $.attribute_list)),
++          repeat(field("attributes", $.attribute_list)),
+           optional(field("modifiers", repeat1($.member_modifier))),
+           choice(
+             seq(
+@@ -1063,7 +1096,7 @@ module.exports = grammar({
+       prec(
+         100,
+         seq(
+-          optional(field("attributes", $.attribute_list)),
++          repeat(field("attributes", $.attribute_list)),
+           optional(field("modifiers", repeat1($.member_modifier))),
+           $._sub_new,
+           optional(field("parameters", $.parameter_list)),
+@@ -1087,7 +1120,7 @@ module.exports = grammar({
+ 
+     event_declaration: ($) =>
+       seq(
+-        optional(field("attributes", $.attribute_list)),
++        repeat(field("attributes", $.attribute_list)),
+         optional(field("modifiers", repeat1($.member_modifier))), // Changed
+         ci("Event"),
+         field("name", $.identifier),
+@@ -1098,7 +1131,7 @@ module.exports = grammar({
+ 
+     operator_declaration: ($) =>
+       seq(
+-        optional(field("attributes", $.attribute_list)),
++        repeat(field("attributes", $.attribute_list)),
+         optional(field("modifiers", repeat1($.member_modifier))), // Changed
+         ci("Operator"),
+         field(
+@@ -1152,7 +1185,7 @@ module.exports = grammar({
+ 
+     interface_method_declaration: ($) =>
+       seq(
+-        optional(field("attributes", $.attribute_list)),
++        repeat(field("attributes", $.attribute_list)),
+         optional(field("modifiers", repeat1($.member_modifier))), // Changed
+         choice(ci("Sub"), ci("Function")),
+         field("name", $.identifier),
+@@ -1164,7 +1197,7 @@ module.exports = grammar({
+ 
+     interface_property_declaration: ($) =>
+       seq(
+-        optional(field("attributes", $.attribute_list)),
++        repeat(field("attributes", $.attribute_list)),
+         optional(field("modifiers", repeat1($.member_modifier))), // Changed
+         ci("Property"),
+         field("name", $.identifier),
+@@ -1175,7 +1208,7 @@ module.exports = grammar({
+ 
+     interface_event_declaration: ($) =>
+       seq(
+-        optional(field("attributes", $.attribute_list)),
++        repeat(field("attributes", $.attribute_list)),
+         optional(field("modifiers", repeat1($.member_modifier))), // Changed
+         ci("Event"),
+         field("name", $.identifier),
+@@ -1209,7 +1242,7 @@ module.exports = grammar({
+ 
+     parameter: ($) =>
+       seq(
+-        optional(field("attributes", $.attribute_list)),
++        repeat(field("attributes", $.attribute_list)),
+         optional(
+           field(
+             "modifiers",
+@@ -1273,7 +1306,7 @@ module.exports = grammar({
+ 
+     declaration_statement: ($) =>
+       prec(1, seq(  // Add precedence
+-        optional(field("attributes", $.attribute_list)),
++        repeat(field("attributes", $.attribute_list)),
+         field("modifiers", choice(
+           $.local_declaration_modifier,
+           repeat1($.local_declaration_modifier)
+@@ -1282,6 +1315,65 @@ module.exports = grammar({
+         $._terminator
+       )),
+ 
++    _inline_statements: ($) =>
++      prec.right(seq($._inline_statement, repeat(seq(":", $._inline_statement)))),
++
++    _inline_statement: ($) =>
++      choice(
++        alias($.inline_if_statement, $.if_statement),
++        alias($.inline_assignment_statement, $.assignment_statement),
++        alias($.inline_return_statement, $.return_statement),
++        alias($.inline_throw_statement, $.throw_statement),
++        alias($.inline_goto_statement, $.goto_statement),
++        alias($.inline_exit_statement, $.exit_statement),
++        alias($.inline_continue_statement, $.continue_statement),
++        alias($.inline_call_statement, $.call_statement),
++        alias($.inline_raiseevent_statement, $.raiseevent_statement),
++        alias($.inline_expression_statement, $.expression_statement)
++      ),
++
++    inline_assignment_statement: ($) =>
++      prec.right(
++        PRECEDENCE.ASSIGNMENT + 1,
++        seq(
++          field("left", choice(
++            $.identifier,
++            $.member_access_expression,
++            $.array_access_expression
++          )),
++          field(
++            "operator",
++            choice("=", "+=", "-=", "*=", "/=", "\\=", "^=", "&=", "<<=", ">>=")
++          ),
++          field("right", $._expression)
++        )
++      ),
++    inline_return_statement: ($) => prec.right(1, seq(ci("Return"), optional($._expression))),
++    inline_throw_statement: ($) => prec.right(1, seq(ci("Throw"), optional($._expression))),
++    inline_goto_statement: ($) => seq(ci("GoTo"), field("label", $.identifier)),
++    inline_exit_statement: ($) =>
++      seq(
++        ci("Exit"),
++        choice(ci("Do"), ci("For"), ci("While"), ci("Select"), ci("Sub"), ci("Function"), ci("Property"), ci("Try"))
++      ),
++    inline_continue_statement: ($) =>
++      seq(ci("Continue"), choice(ci("Do"), ci("For"), ci("While"))),
++    inline_call_statement: ($) => prec.right(1, seq(ci("Call"), $._expression)),
++    inline_raiseevent_statement: ($) =>
++      prec.right(1, seq(ci("RaiseEvent"), field("event", $.identifier), optional(field("arguments", $.argument_list)))),
++    inline_expression_statement: ($) => prec.right(1, $._expression),
++
++    inline_if_statement: ($) =>
++      prec.right(
++        seq(
++          ci("If"),
++          field("condition", $._expression),
++          ci("Then"),
++          field("then_statement", $._inline_statements),
++          optional(seq(ci("Else"), field("else_statement", $._inline_statements)))
++        )
++      ),
++
+     expression_statement: ($) => seq($._expression, $._terminator),
+ 
+     assignment_statement: ($) =>
+@@ -1301,21 +1393,96 @@ module.exports = grammar({
+           $._terminator
+         )
+       ),
++    custom_event_declaration: ($) =>
++      seq(
++        repeat(field("attributes", $.attribute_list)),
++        optional(field("modifiers", repeat1($.member_modifier))),
++        ci("Custom"),
++        ci("Event"),
++        field("name", $.identifier),
++        $.as_clause,
++        optional($.implements_member_clause),
++        $._block_terminator,
++        repeat($.event_accessor),
++        ci("End"), ci("Event"),
++        $._terminator
++      ),
++
++    event_accessor: ($) =>
++      seq(
++        choice(ci("AddHandler"), ci("RemoveHandler"), ci("RaiseEvent")),
++        optional(field("parameters", $.parameter_list)),
++        $._block_terminator,
++        repeat($._statement),
++        ci("End"),
++        choice(ci("AddHandler"), ci("RemoveHandler"), ci("RaiseEvent")),
++        $._terminator
++      ),
++
++    _mustoverride_token: ($) => token(prec(11, ci("MustOverride"))),
++
++    abstract_method_declaration: ($) =>
++      seq(
++        repeat(field("attributes", $.attribute_list)),
++        optional(field("modifiers", repeat1($.member_modifier))),
++        $._mustoverride_token,
++        optional(field("modifiers", repeat1($.member_modifier))),
++        choice(ci("Sub"), ci("Function")),
++        field("name", $.identifier),
++        optional(field("type_parameters", $.type_parameter_list)),
++        optional(field("parameters", $.parameter_list)),
++        optional($.as_clause),
++        optional($.implements_member_clause),
++        $._terminator
++      ),
++
++    abstract_property_declaration: ($) =>
++      seq(
++        repeat(field("attributes", $.attribute_list)),
++        optional(field("modifiers", repeat1($.member_modifier))),
++        $._mustoverride_token,
++        optional(field("modifiers", repeat1($.member_modifier))),
++        ci("Property"),
++        field("name", $.identifier),
++        optional(field("parameters", $.parameter_list)),
++        optional($.as_clause),
++        optional($.implements_member_clause),
++        $._terminator
++      ),
++
++    external_method_declaration: ($) =>
++      seq(
++        repeat(field("attributes", $.attribute_list)),
++        optional(field("modifiers", repeat1($.member_modifier))),
++        ci("Declare"),
++        optional(choice(ci("Auto"), ci("Ansi"), ci("Unicode"))),
++        choice(ci("Sub"), ci("Function")),
++        field("name", $.identifier),
++        ci("Lib"),
++        field("library", $.string_literal),
++        optional(seq(ci("Alias"), field("alias", $.string_literal))),
++        optional(field("parameters", $.parameter_list)),
++        optional($.as_clause),
++        $._terminator
++      ),
++
+     call_statement: ($) => seq(ci("Call"), $._expression, $._terminator),
+ 
+     if_statement: ($) =>
+       seq(
+         ci("If"),
+         field("condition", $._expression),
+-        ci("Then"),
+         choice(
+-          // Single-line if
++          // Single-line if: inline statements, optional Else, one terminator
+           seq(
+-            field("then_statement", $._statement),
+-            optional(seq(ci("Else"), field("else_statement", $._statement)))
++            ci("Then"),
++            field("then_statement", $._inline_statements),
++            optional(seq(ci("Else"), field("else_statement", $._inline_statements))),
++            $._terminator
+           ),
+           // Multi-line if
+           seq(
++            optional(ci("Then")),
+             $._block_terminator,
+             field("then_branch", repeat($._statement)),
+             repeat($.elseif_clause),
+@@ -1331,7 +1498,7 @@ module.exports = grammar({
+       seq(
+         ci("ElseIf"),
+         field("condition", $._expression),
+-        ci("Then"),
++        optional(ci("Then")),
+         $._block_terminator,
+         field("body", repeat($._statement))
+       ),
+@@ -1445,7 +1612,7 @@ module.exports = grammar({
+     using_statement: ($) =>
+       seq(
+         ci("Using"),
+-        field("resource", choice($.variable_declarator, $._expression)),
++        commaSep1(field("resource", choice($.variable_declarator, $._expression))),
+         $._block_terminator,
+         field("body", repeat($._statement)),
+         ci("End"),
+@@ -1590,14 +1757,25 @@ module.exports = grammar({
+         $._terminator
+       ), // LINQ Query expressions
+     query_expression: ($) =>
+-      seq($.from_clause, repeat($.query_body_clause), $.select_or_group_clause),
+-    from_clause: ($) => seq(ci("From"), commaSep1($.collection_range_variable)),
++      prec.right(
++        choice(
++          seq(
++            $.from_clause,
++            repeat(seq(optional($._query_clause_continuation), $.query_body_clause)),
++            optional(seq(optional($._query_clause_continuation), $.select_or_group_clause))
++          ),
++          $.aggregate_clause
++        )
++      ),
++    from_clause: ($) => prec.right(seq(ci("From"), commaSep1($.collection_range_variable))),
+     collection_range_variable: ($) =>
+-      seq(
+-        field("variable", $.identifier),
+-        optional($.as_clause),
+-        ci("In"),
+-        field("collection", $._expression)
++      prec.right(
++        seq(
++          field("variable", $.identifier),
++          optional($.as_clause),
++          ci("In"),
++          field("collection", $._expression)
++        )
+       ),
+     query_body_clause: ($) =>
+       choice(
+@@ -1606,48 +1784,62 @@ module.exports = grammar({
+         $.where_clause,
+         $.join_clause,
+         $.order_by_clause,
+-        $.aggregate_clause
+-      ),
+-    let_clause: ($) => seq(ci("Let"), commaSep1($.expression_range_variable)),
++        $.aggregate_clause,
++        $.distinct_clause,
++        $.skip_take_clause
++      ),
++    distinct_clause: ($) => ci("Distinct"),
++    skip_take_clause: ($) =>
++      prec.right(seq(choice(ci("Skip"), ci("Take")), optional(ci("While")), $._expression)),
++    let_clause: ($) => prec.right(seq(ci("Let"), commaSep1($.expression_range_variable))),
+     expression_range_variable: ($) =>
+-      seq(
++      prec.right(seq(
+         field("variable", $.identifier),
+         "=",
+         field("expression", $._expression)
+-      ),
+-    where_clause: ($) => seq(ci("Where"), field("condition", $._expression)),
++      )),
++    where_clause: ($) =>
++      prec.right(seq(ci("Where"), field("condition", $._expression))),
+     join_clause: ($) =>
+-      seq(
+-        ci("Join"),
+-        $.collection_range_variable,
+-        ci("On"),
+-        commaSep1($.join_condition)
++      prec.right(
++        seq(
++          ci("Join"),
++          $.collection_range_variable,
++          optional($._query_clause_continuation),
++          ci("On"),
++          commaSep1($.join_condition)
++        )
+       ),
+     join_condition: ($) =>
+-      seq(
++      prec.right(seq(
+         field("left", $._expression),
+         ci("Equals"),
+         field("right", $._expression)
+-      ),
+-    order_by_clause: ($) => seq(ci("Order"), ci("By"), commaSep1($.ordering)),
++      )),
++    order_by_clause: ($) => prec.right(seq(ci("Order"), ci("By"), commaSep1($.ordering))),
+     ordering: ($) =>
+-      seq(
+-        field("expression", $._expression),
+-        optional(choice(ci("Ascending"), ci("Descending")))
++      prec.right(
++        seq(
++          field("expression", $._expression),
++          optional(choice(ci("Ascending"), ci("Descending")))
++        )
+       ),
+     aggregate_clause: ($) =>
+-      seq(
+-        ci("Aggregate"),
+-        $.collection_range_variable,
+-        repeat($.query_body_clause),
+-        ci("Into"),
+-        commaSep1($.aggregate_element)
++      prec.right(
++        seq(
++          ci("Aggregate"),
++          $.collection_range_variable,
++          repeat(seq(optional($._query_clause_continuation), $.query_body_clause)),
++          optional($._query_clause_continuation),
++          ci("Into"),
++          commaSep1($.aggregate_element)
++        )
+       ),
+     aggregate_element: ($) =>
+-      seq(
++      prec.right(seq(
+         optional(seq(field("name", $.identifier), "=")),
+         field("function", $.aggregate_function)
+-      ),
++      )),
+     aggregate_function: ($) =>
+       seq(
+         field(
+@@ -1668,42 +1860,44 @@ module.exports = grammar({
+     select_or_group_clause: ($) => choice($.select_clause, $.group_by_clause),
+     select_clause: ($) => seq(ci("Select"), commaSep1($.select_element)),
+     select_element: ($) =>
+-      choice(
++      prec.right(choice(
+         $._expression,
+         seq(
+           field("name", $.identifier),
+           "=",
+           field("expression", $._expression)
+         )
+-      ),
++      )),
+     group_by_clause: ($) =>
+-      seq(
+-        ci("Group"),
+-        commaSep1($.group_element),
+-        ci("By"),
+-        commaSep1($.by_element),
+-        ci("Into"),
+-        commaSep1($.into_element)
++      prec.right(
++        seq(
++          ci("Group"),
++          commaSep1($.group_element),
++          ci("By"),
++          commaSep1($.by_element),
++          ci("Into"),
++          commaSep1($.into_element)
++        )
+       ),
+     group_element: ($) =>
+-      choice(
++      prec.right(choice(
+         $._expression,
+         seq(
+           field("name", $.identifier),
+           "=",
+           field("expression", $._expression)
+         )
+-      ),
++      )),
+     by_element: ($) =>
+-      choice(
++      prec.right(choice(
+         $._expression,
+         seq(
+           field("name", $.identifier),
+           "=",
+           field("expression", $._expression)
+         )
+-      ),
+-    into_element: ($) => choice($.identifier, $.aggregate_element),
++      )),
++    into_element: ($) => prec.right(choice($.identifier, $.aggregate_element)),
+ 
+     // Comments
+     comment: ($) => token(prec(100, choice(
+@@ -1714,7 +1908,7 @@ module.exports = grammar({
+     ))),
+ 
+     // Preprocessor Directives
+-    preprocessor_directive: ($) => token(seq("#", /[^\r\n]*/)),
++    preprocessor_directive: ($) => token(seq("#", /[ \t]*[A-Za-z][^\r\n]*/)),
+   },
+ });
+ 
+diff --git a/src/scanner.c b/src/scanner.c
+new file mode 100644
+index 0000000..8334fdb
+--- /dev/null
++++ b/src/scanner.c
+@@ -0,0 +1,303 @@
++#include "tree_sitter/parser.h"
++#include <string.h>
++#include <wctype.h>
++
++// External tokens for the two constructs that are not LR(1)-parseable with
++// tree-sitter's newline-as-extra treatment:
++//
++// 1. QUERY_CLAUSE_CONTINUATION — a newline run that CONTINUES a LINQ query
++//    (`From x In xs` ↵ `Where …`). At a clause boundary the parser cannot
++//    know from the newline alone whether the query ends (newline = statement
++//    terminator) or continues (newline = insignificant, next line starts with
++//    a query keyword). The scanner looks past the newline at the next word
++//    and only emits the token when it is a query-clause keyword — so the
++//    terminator/continuation choice is made by the lexer, not the LR table.
++//
++// 2. XML_LITERAL — a whole VB XML literal (`<Tags><Tag/></Tags>`, including
++//    `<%= expr %>` embedded expressions, comments, CDATA) consumed as one
++//    opaque token. Only valid where a literal can begin an expression, so a
++//    relational `<` (which always FOLLOWS an expression) can never collide.
++
++enum TokenType {
++  QUERY_CLAUSE_CONTINUATION,
++  XML_LITERAL,
++};
++
++void *tree_sitter_vbnet_external_scanner_create(void) { return NULL; }
++void tree_sitter_vbnet_external_scanner_destroy(void *payload) { (void)payload; }
++unsigned tree_sitter_vbnet_external_scanner_serialize(void *payload, char *buffer) {
++  (void)payload; (void)buffer;
++  return 0; // stateless
++}
++void tree_sitter_vbnet_external_scanner_deserialize(void *payload, const char *buffer, unsigned length) {
++  (void)payload; (void)buffer; (void)length;
++}
++
++static inline void advance(TSLexer *lexer) { lexer->advance(lexer, false); }
++static inline void skip_ws(TSLexer *lexer) { lexer->advance(lexer, true); }
++
++// Read one alphabetic word (advancing past it), lowercased into buf.
++// Returns its length (0 if the next char is not a letter).
++static int read_word(TSLexer *lexer, char *buf, int cap) {
++  int n = 0;
++  while (iswalpha((wint_t)lexer->lookahead)) {
++    if (n < cap - 1) buf[n] = (char)towlower((wint_t)lexer->lookahead);
++    n++;
++    advance(lexer);
++  }
++  buf[n < cap - 1 ? n : cap - 1] = 0;
++  return n;
++}
++
++static bool is_query_keyword(const char *w) {
++  static const char *kws[] = {
++    "where", "select", "order",    "group", "join", "let",  "skip",
++    "take",  "distinct", "aggregate", "from",  "into", "on",   NULL,
++  };
++  for (int i = 0; kws[i]; i++)
++    if (strcmp(w, kws[i]) == 0) return true;
++  return false;
++}
++
++// [ \t\r]* '\n' [ \t\r\n]*  followed by a query-clause keyword.
++// The token covers only the whitespace/newline run (mark_end before the
++// keyword lookahead), so the clause keyword itself lexes normally after it.
++static bool scan_query_continuation(TSLexer *lexer) {
++  bool saw_newline = false;
++  for (;;) {
++    int32_t c = lexer->lookahead;
++    if (c == ' ' || c == '\t' || c == '\r') {
++      advance(lexer);
++    } else if (c == '\n') {
++      saw_newline = true;
++      advance(lexer);
++    } else {
++      break;
++    }
++  }
++  if (!saw_newline) return false;
++  lexer->mark_end(lexer);
++
++  char w[12];
++  int n = read_word(lexer, w, sizeof w);
++  if (n == 0 || n >= (int)(sizeof w)) return false;
++  if (!is_query_keyword(w)) return false;
++
++  // `Select Case` opens a statement, never a select clause.
++  if (strcmp(w, "select") == 0) {
++    while (lexer->lookahead == ' ' || lexer->lookahead == '\t') advance(lexer);
++    char w2[8];
++    int m = read_word(lexer, w2, sizeof w2);
++    if (m > 0 && m < (int)(sizeof w2) && strcmp(w2, "case") == 0) return false;
++  }
++
++  lexer->result_symbol = QUERY_CLAUSE_CONTINUATION;
++  return true;
++}
++
++// Consume a VB string inside an embedded `<%= … %>` region ("" escapes).
++static void consume_vb_string(TSLexer *lexer) {
++  advance(lexer); // opening quote
++  for (;;) {
++    if (lexer->lookahead == 0) return;
++    if (lexer->lookahead == '"') {
++      advance(lexer);
++      if (lexer->lookahead != '"') return; // closing (not an escaped "")
++      // escaped quote: fall through, keep consuming
++    }
++    advance(lexer);
++  }
++}
++
++// Consume `<%= … %>` starting at the '%' (the '<' is already consumed).
++// Embedded expressions NEST (`<%= From t In ts Select <Tag><%= t.Name %></Tag> %>`,
++// the staxrip WriteTagfile shape), so track <% / %> depth.
++static bool consume_embedded_expression(TSLexer *lexer) {
++  advance(lexer); // '%'
++  int depth = 1;
++  for (;;) {
++    int32_t c = lexer->lookahead;
++    if (c == 0) return false;
++    if (c == '"') { consume_vb_string(lexer); continue; }
++    if (c == '<') {
++      advance(lexer);
++      if (lexer->lookahead == '%') { depth++; advance(lexer); }
++      continue;
++    }
++    if (c == '%') {
++      advance(lexer);
++      if (lexer->lookahead == '>') {
++        advance(lexer);
++        if (--depth == 0) return true;
++      }
++      continue;
++    }
++    advance(lexer);
++  }
++}
++
++// Consume `<!-- … -->` / `<![CDATA[ … ]]>` / `<!DOCTYPE …>` starting at '!'.
++static bool consume_bang_construct(TSLexer *lexer) {
++  advance(lexer); // '!'
++  if (lexer->lookahead == '-') {
++    // comment: to -->
++    int dashes = 0;
++    for (;;) {
++      int32_t c = lexer->lookahead;
++      if (c == 0) return false;
++      if (c == '-') { dashes++; advance(lexer); continue; }
++      if (c == '>' && dashes >= 2) { advance(lexer); return true; }
++      dashes = 0;
++      advance(lexer);
++    }
++  }
++  if (lexer->lookahead == '[') {
++    // CDATA: to ]]>
++    int brackets = 0;
++    for (;;) {
++      int32_t c = lexer->lookahead;
++      if (c == 0) return false;
++      if (c == ']') { brackets++; advance(lexer); continue; }
++      if (c == '>' && brackets >= 2) { advance(lexer); return true; }
++      brackets = 0;
++      advance(lexer);
++    }
++  }
++  // DOCTYPE-ish: to bare >
++  for (;;) {
++    int32_t c = lexer->lookahead;
++    if (c == 0) return false;
++    if (c == '>') { advance(lexer); return true; }
++    advance(lexer);
++  }
++}
++
++// Consume `<? … ?>` starting at the '?'.
++static bool consume_processing_instruction(TSLexer *lexer) {
++  advance(lexer); // '?'
++  for (;;) {
++    int32_t c = lexer->lookahead;
++    if (c == 0) return false;
++    if (c == '?') {
++      advance(lexer);
++      if (lexer->lookahead == '>') { advance(lexer); return true; }
++      continue;
++    }
++    advance(lexer);
++  }
++}
++
++static bool is_name_start(int32_t c) {
++  return iswalpha((wint_t)c) || c == '_';
++}
++
++static bool scan_xml_literal(TSLexer *lexer) {
++  // Only same-line blanks are skipped: a leading newline must stay unconsumed
++  // so it can serve as a statement/block terminator (a successful scan would
++  // otherwise swallow it as trivia). XML on a continuation line still works:
++  // the newline is consumed as an extra first, then the scanner re-runs.
++  while (lexer->lookahead == ' ' || lexer->lookahead == '\t') skip_ws(lexer);
++  if (lexer->lookahead != '<') return false;
++  advance(lexer);
++  // Only element-start markup opens an XML literal here; comments/PIs as the
++  // outermost construct (`Dim d = <?xml …`) are rare enough to leave alone,
++  // and a bare `<` (comparison) never begins an expression.
++  if (!is_name_start(lexer->lookahead)) return false;
++
++  int depth = 0;      // open (unclosed) elements
++  bool in_tag = true; // inside <...> of an element tag
++  bool closing = false;
++  int32_t quote = 0;
++
++  for (;;) {
++    int32_t c = lexer->lookahead;
++    if (c == 0) return false; // unterminated: let the normal parser error
++
++    if (in_tag) {
++      if (quote) {
++        if (c == quote) quote = 0;
++        advance(lexer);
++        continue;
++      }
++      if (c == '"' || c == '\'') { quote = c; advance(lexer); continue; }
++      if (c == '<') {
++        advance(lexer);
++        if (lexer->lookahead == '%') {
++          // attribute value embedded expression: attr=<%= x %>
++          if (!consume_embedded_expression(lexer)) return false;
++          continue;
++        }
++        continue; // stray < in a tag: malformed, keep consuming
++      }
++      if (c == '/') {
++        advance(lexer);
++        if (lexer->lookahead == '>') {
++          // self-closing tag
++          advance(lexer);
++          in_tag = false;
++          if (depth == 0) break; // single self-closed root: done
++          continue;
++        }
++        continue;
++      }
++      if (c == '>') {
++        advance(lexer);
++        if (closing) {
++          depth--;
++          if (depth <= 0) break; // root element closed: done
++        } else {
++          depth++;
++        }
++        in_tag = false;
++        continue;
++      }
++      advance(lexer);
++      continue;
++    }
++
++    // text content between tags
++    if (c == '<') {
++      advance(lexer);
++      int32_t d = lexer->lookahead;
++      if (d == '/') { advance(lexer); closing = true; in_tag = true; continue; }
++      if (d == '%') {
++        if (!consume_embedded_expression(lexer)) return false;
++        continue;
++      }
++      if (d == '!') {
++        if (!consume_bang_construct(lexer)) return false;
++        continue;
++      }
++      if (d == '?') {
++        if (!consume_processing_instruction(lexer)) return false;
++        continue;
++      }
++      closing = false;
++      in_tag = true; // opening tag
++      continue;
++    }
++    advance(lexer);
++  }
++
++  lexer->mark_end(lexer);
++  lexer->result_symbol = XML_LITERAL;
++  return true;
++}
++
++bool tree_sitter_vbnet_external_scanner_scan(void *payload, TSLexer *lexer, const bool *valid_symbols) {
++  (void)payload;
++  int32_t c = lexer->lookahead;
++  bool ws = (c == ' ' || c == '\t' || c == '\n' || c == '\r');
++
++  // A scan attempt advances the lexer, so exactly ONE attempt runs per
++  // invocation — never chain a second attempt after a failed first (its
++  // reads would start mid-stream). The leading character picks the branch;
++  // a false return discards the attempt entirely.
++  if (valid_symbols[QUERY_CLAUSE_CONTINUATION] && ws) {
++    return scan_query_continuation(lexer);
++  }
++  if (valid_symbols[XML_LITERAL] && (c == '<' || ws)) {
++    return scan_xml_literal(lexer);
++  }
++  return false;
++}

+ 6 - 1
src/extraction/grammars.ts

@@ -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 - 0
src/extraction/languages/index.ts

@@ -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 - 0
src/extraction/languages/vbnet.ts

@@ -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
src/extraction/tree-sitter.ts

@@ -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.

BIN
src/extraction/wasm/tree-sitter-vbnet.wasm


+ 1 - 0
src/types.ts

@@ -99,6 +99,7 @@ export const LANGUAGES = [
   'cfscript',
   'cfquery',
   'cobol',
+  'vbnet',
   'unknown',
 ] as const;