There’s already an unofficial tree-sitter grammar for AL: SShadowS/tree-sitter-al. It was my starting point and I’m glad the work existed. But the generated parser.c is roughly 90MB — a single C file. Zed compiles tree-sitter grammars to WASM at install time, and 90MB of C doesn’t survive that. Compilation times out or runs out of memory, depending on the machine.
So I built one from scratch. It lives at Brad-Fullwood/AL-Tree-Sitter on the dev branch, MIT licensed, and it’s a git submodule in the Zed AL extension. Different enough from SShadowS’s approach that calling it a fork would be misleading.
Extracting from CodeAnalysis.dll
Instead of reverse-engineering AL from the TextMate grammar or reading the spec by hand, I pull the data directly from Microsoft’s compiler.
Microsoft.Dynamics.Nav.CodeAnalysis.dll ships with the BC SDK. It’s the same assembly the AL language server uses internally, and it has a complete representation of the language: syntax kinds, keyword classifications, property kinds, trigger types, builtin types. Everything you need to define a grammar is in there, in machine-readable form.
al-extract is a C# tool that uses .NET reflection to read the DLL. It hits SyntaxKind, SyntaxFacts, PropertyKind, TriggerTypeKind, and NavTypeKind, then calls the classification methods on each item. Output is data/al-language-data.json.
What comes out:
| Data | Source | Count |
|---|---|---|
| Keywords (classified) | SyntaxKind enum + SyntaxFacts.IsKeyword() | 159 |
| Keyword categories | SyntaxFacts.IsControlKeyword(), IsPropertyKeyword(), etc. | 6 categories |
| Properties | PropertyKind enum + per-context validity | 313 |
| Triggers | TriggerTypeKind enum | 127+ |
| Builtin types | NavTypeKind enum + Compilation.GetTypeByNavTypeKind() | 158 |
| Token classification | SyntaxFacts.IsLiteral(), IsPunctuation(), IsOperatorToken() | Complete |
| Preprocessor keywords | SyntaxFacts.IsPreprocessorKeyword() | All |
al-gen is a Rust tool that reads that JSON and generates everything else: grammar.js, src/parser.c, src/scanner.c, src/keywords.c, all the query files (highlights.scm, indents.scm, outline.scm, brackets.scm, folds.scm), and data/builtin_variables.json.
When a new BC version ships:
cd tree-sitter-al/
dotnet run --project generator/tools/al-extract # CodeAnalysis.dll → data/al-language-data.json
cargo run --release -p al-gen # JSON → grammar.js, parser.c, queries
# Commit generated files
People consuming the grammar don’t need any of this. The generated files are committed. Only regeneration requires the tooling.
Hand-authoring a grammar for a language this size is a losing bet. AL has 313 properties, 159 keywords, 127 triggers. Write them manually and you’re already wrong, and you’ll fall further behind with every BC release. Generate from the authoritative source and you’re as correct as the compiler, automatically.
Case-insensitivity in C
AL doesn’t care about case. Begin, begin, BEGIN are the same token. tree-sitter’s JavaScript DSL has no built-in support for this. You could write /[Bb][Ee][Gg][Ii][Nn]/ for every keyword, but that makes grammar.js enormous and would balloon parser.c — which is exactly the 90MB problem I was trying to avoid.
The solution is an external scanner: a C file with a tree_sitter_al_external_scanner_scan function that tree-sitter calls when it needs to check certain symbols. The scanner reads characters directly from the lexer, lowercases each one, builds up a candidate string, then binary-searches a sorted keyword table.
// scanner.c (simplified)
static const char *KEYWORDS[] = {
"begin", "codeunit", "database", "field", /* ... all keywords */
};
static bool scan_keyword(TSLexer *lexer, const bool *valid_symbols) {
char buf[64] = {0};
int len = 0;
while (isalpha(lexer->lookahead) || lexer->lookahead == '_') {
buf[len++] = tolower(lexer->lookahead);
lexer->advance(lexer, false);
if (len >= 63) break;
}
int lo = 0, hi = KEYWORD_COUNT - 1;
while (lo <= hi) {
int mid = (lo + hi) / 2;
int cmp = strcmp(buf, KEYWORDS[mid]);
if (cmp == 0) return emit_keyword(lexer, mid, valid_symbols);
if (cmp < 0) hi = mid - 1;
else lo = mid + 1;
}
return false;
}
O(log n) per token. The keyword table is generated by al-gen, sorted at generation time. The scanner file stays small because it contains logic, not data — the actual keyword list lives in the generated keywords.c. That split is what keeps parser.c from getting huge.
Preprocessor state
AL has a C-style preprocessor: #if, #else, #endif, #pragma warning disable. tree-sitter has no preprocessing phase. It’s incremental, works on the raw character stream, and has no concept of dead code. You have to handle this in the grammar itself.
The scanner implements a state machine with a 64-level if-stack. Both branches of #if/#else stay in the concrete syntax tree — the “dead” branch is still there and you can navigate, query, and highlight it. The scanner tracks which branch is live.
#define MAX_IF_DEPTH 64
typedef struct {
bool stack[MAX_IF_DEPTH];
uint8_t depth;
} PreprocState;
tree-sitter’s incremental re-parsing requires the scanner to serialize its state into a byte buffer. It’s an awkward protocol: you implement tree_sitter_al_external_scanner_serialize and tree_sitter_al_external_scanner_deserialize and manually copy your struct in and out of a raw byte array. The blob limit is 1024 bytes; PreprocState is 65 bytes.
AL code like this parses correctly:
#if BC25
// BC 25 and later
#else
// older versions
#endif
#pragma warning disable AL0432
Both the BC25 branch and the else branch show up in the CST. An editor that wants to dim inactive code can query the tree and check the preprocessor context on each node.
The one thing it can’t handle is #define used as a flag value. If a defined symbol controls a condition, the scanner doesn’t know the definition’s value, so it can’t evaluate the branch. Those cases fail to parse — about 1.6% of files in practice.
Keywords as identifiers
Name, Value, Type, Field, Record are AL keywords. They’re also valid identifiers. A field named Type is legal. A variable named Value is legal. This creates real ambiguity that lookahead alone can’t resolve.
tree-sitter handles it with GLR parsing. When there’s ambiguity, it pursues both parses in parallel until one becomes invalid. The grammar declares the known conflicts explicitly:
conflicts: ($) => [
[$.identifier, $.keyword_type],
[$.identifier, $.keyword_field],
[$.member_expression, $.qualified_name],
// ... 15 more
],
18 conflicts total. Parse time stays under 5ms on a 5,000-line file. GLR is slower than LALR in the worst case, but the conflicts are localized to specific token pairs.
I could have excluded these keywords from the conflict set and called them non-reserved, which would be simpler. The trade-off is losing the ability to distinguish Value used as a keyword from Value used as a field name. They mean different things semantically, and I want the tree to reflect that — the Zed extension uses the distinction for semantic token coloring.
Query files
al-gen generates the query files from node-types.json, which is the output of tree-sitter generate describing all node types the grammar produces. The generated queries are a starting point.
highlights.scm starts generated with all node types mapped to capture names matching tree-sitter’s conventional highlight scheme: @keyword, @type, @function, @string, @comment, and so on. After that I go through by hand and tune edge cases — distinguishing trigger names from procedure names, marking obsolete objects differently, handling the various object declaration forms. It’s maybe 20% manual editing on top of what’s generated.
indents.scm, outline.scm, brackets.scm, and folds.scm are smaller and closer to the generated output. They cover indentation heuristics, symbol outline content, bracket matching, and folding ranges.
WASM constraints
The external scanner has to compile to WASM. Two things bit me when I first tried:
malloc/free won’t work in the WASM target tree-sitter uses. Everything has to be stack-allocated. The keyword buffer is a fixed 64-byte array on the stack — fine in practice since AL keywords max out around 20 characters.
The scanner state blob also has a hard 1024-byte limit. Exceed it and incremental re-parsing breaks silently. Not a crash, just wrong behavior. I found this by noticing that re-opening a file after an edit would sometimes miscolor tokens. PreprocState at 65 bytes has plenty of headroom.
tree-sitter build --wasm
Zed runs this at install time. It finishes in seconds because scanner.c and keywords.c are small — the design decision to separate data from logic pays off here.
Parse rate
98.4% of files in Microsoft’s BCApps repo parse without errors:
$ tree-sitter parse --stat src/**/*.al
Total parses: 12,847 | Total failures: 203 | Success rate: 98.4%
The 203 failures are almost all #define-as-flag-value. BCApps is a useful test target because it’s large and throws patterns you won’t find in docs: deeply nested triggers, unusual property combinations, objects spanning thousands of lines. If something is going to break the grammar, it shows up there.
Integration
The grammar is a git submodule in the Zed AL extension under al-syntax/. The crate’s build.rs compiles the three C files using the cc crate:
// al-syntax/build.rs (simplified)
cc::Build::new()
.file("tree-sitter-al/src/parser.c")
.file("tree-sitter-al/src/scanner.c")
.file("tree-sitter-al/src/keywords.c")
.include("tree-sitter-al/src")
.compile("tree-sitter-al");
No tree-sitter CLI at build time. The generated files are committed, so the build is self-contained. It also works with any tree-sitter editor — Neovim, Helix, Emacs. Add it as a grammar, point it at your AL files.
parser.c for this grammar is around 2MB. That’s what you get when you separate data from logic and generate from the compiler instead of hand-authoring.
The 98.4% parse rate will probably stay there. Getting higher means solving the #define evaluation problem — the scanner would need to receive symbol definitions at parse time, which isn’t something tree-sitter’s external scanner interface makes easy. It might be worth a more invasive approach eventually. For now, 98.4% covers enough real AL code to be useful, and the failures are predictable enough that they don’t cause confusion.