I’ve written a lot of Rust over the past couple of years building AL tooling, and the Zed AL extension is where most of it converges. This article is the technical companion to the feature overview — it covers how the pieces fit together, why certain architectural decisions were made, and what the tradeoffs look like in practice.
Crate structure
The workspace is split into seven crates arranged in a strict dependency hierarchy:
zed-al (WASM, Zed extension API)
└── al-lsp (binary, the language server)
└── al-core (library, shared state and logic)
├── al-syntax (tree-sitter grammar wrapper, incremental parsing)
├── al-symbols (symbol index, .app loading, NuGet resolution)
└── al-semantic (type inference, scope resolution, insight engine)
Three tool crates sit alongside this tree:
al-cli (command-line interface)
al-explorer (file system watcher and workspace scanner)
al-mcp (MCP server for AI tool integration)
The rule that governs the tool crates is strict: al-cli, al-explorer, and al-mcp have zero compile-time dependency on al-core. They communicate with the running al-lsp binary through a Unix socket protocol, and that’s the only coupling. The reason for this is practical: al-core links against netcorehost to host the CLR in-process. That’s a platform-specific native dependency with non-trivial link-time requirements. Keeping it out of the tool crates means they compile fast, link cleanly on any target, and can be cross-compiled without pulling in the .NET hosting machinery.
The constraint is enforced in CI with cargo tree --edges no-dev | grep al-core. If any of the three tool crates shows al-core in their dependency tree, the build fails. It’s a one-line check and it’s caught attempted shortcuts several times.
The daemon model
al-lsp runs in one of two modes: LSP mode and daemon mode.
In LSP mode, the binary communicates over stdio using the Language Server Protocol. This is what Zed spawns when you open an AL workspace. Standard LSP client-server model, nothing unusual.
In daemon mode, al-lsp binds a Unix domain socket at a well-known path (by default $XDG_RUNTIME_DIR/al-lsp-{workspace-hash}.sock). The socket accepts a lightweight JSON-RPC protocol — not full LSP, but a subset of it extended with AL-specific methods. The tool crates connect to this socket to query the warm symbol index, trigger builds, run diagnostics, and interact with the Insight Engine.
The key property of daemon mode is that one process serves multiple clients while sharing a single warmed state. When al-cli runs a query, it connects to the socket that Zed already has running. The symbol index — which takes a few seconds to build on first load — is already warm. The CLR is already initialized. There’s no startup cost.
Zed is the primary client and starts the process in LSP mode. The daemon socket opens automatically once the workspace is initialized. If you run al from the terminal while Zed is open with the same workspace, you’re talking to the same process. If Zed isn’t running, al starts its own al-lsp instance in daemon-only mode (no stdio LSP, just the socket).
The LSP mode and daemon mode use the same tokio runtime and share the same workspace state. There’s no synchronization boundary between the two protocol handlers — they both hold an Arc<RwLock<WorkspaceState>> and contend normally.
Workspace state
WorkspaceState is the central struct. It holds:
AlProject: the parsedapp.json, resolved dependency graph, and build configurationDocumentStore: in-memory map of open documents and their current parse treesSymbolIndex: the DashMap-backed index of all symbols across the workspace and all loaded packagesInsightEngine: the four computed graphs (call, event, object, table relation) and their derived data (dead code sets, impact maps)SemanticBridge: the interface to the .NET bridge for type resolution and CodeAnalysis integrationDiagnosticStore: the current diagnostic set, versioned per document
The read/write pattern is asymmetric. Reads (hover, completions, find references, workspace symbol) are extremely frequent and need to be fast. Writes (document change, diagnostic update, symbol index rebuild) are infrequent by comparison. DashMap handles the symbol index’s read concurrency well — sharded locks mean multiple readers on different shards don’t contend. The RwLock<WorkspaceState> wrapper handles the top-level struct; most operations take a read lock and only upgrade to a write lock when a document changes or a build completes.
The .NET bridge
Some things genuinely need the .NET runtime. CodeCop and AppSourceCop are shipped as managed assemblies. The BC compiler (alc) is .NET. Certain type metadata from the BC SDK is only available in a form that’s convenient to access through Microsoft.Dynamics.Nav.CodeAnalysis.dll.
Rather than spawn a subprocess, the bridge hosts the CLR in-process using netcorehost. The netcorehost crate wraps the native hostfxr library, which is the standard way to embed .NET in a native process. From the Rust side, you call hostfxr_initialize_for_runtime_config, load an assembly, and call methods through a function pointer obtained via load_assembly_and_get_function_pointer.
On the managed side, AlBridge.dll exposes its entry points with [UnmanagedCallersOnly]. The methods take and return blittable types — primitive integers, UTF-8 byte pointers with explicit lengths. No marshaling of complex objects, no COM interop, no P/Invoke overhead beyond the function call itself. The serialization boundary is JSON over byte arrays, which sounds heavy but is fast enough in practice because the calls are infrequent (diagnostic runs, symbol extraction, not per-keystroke operations).
The bridge wraps every managed call in a 2-second timeout enforced from the Rust side. If the CLR doesn’t respond within 2 seconds, the call returns an error and the extension falls back gracefully — usually by returning empty diagnostics or a partial result. This is the important constraint: the bridge must never block an interactive LSP response. Hover, completions, and go-to-definition never touch the bridge. Only the async diagnostic phase does.
The CLR initialization happens once, during workspace startup. The netcorehost runtime stays loaded for the lifetime of the process. There’s no way to unload a .NET runtime that’s been hosted in-process, so this is a deliberate one-way initialization. The 2-second startup cost happens once and is amortized over the session.
Symbol loading
When the workspace initializes, the extension resolves all .app packages referenced in app.json (from .alpackages and any configured symbol paths) and loads their symbol metadata.
Each .app file is a zip archive. The symbol metadata is in a protobuf-encoded blob at a known path inside the archive. Loading happens in parallel: one rayon thread per package. Each thread opens the file with memmap2, skips to the central directory, locates the symbol blob, and deserializes it. The deserialized symbols go into DashMap inserts, which are lock-free for non-colliding shards.
The choice of memmap2 over buffered File::read_to_end matters for large packages. The BC base app symbol package is around 40MB. Memory-mapping it means the OS handles page loading lazily — if the protobuf parser only needs to touch 8MB of it (which is typical for the symbol blob), only 8MB of pages get faulted in. With buffered reads, you pay the full I/O cost upfront.
After initial load, the index is kept warm. Document edits update the in-workspace symbols incrementally. Package changes (new .alpackages entries after a symbol download) trigger a partial re-index of just the new packages, not a full rebuild.
Initialization sequence
When Zed opens an AL workspace, the extension goes through 11 steps in order:
- Parse
app.jsonand validate its structure - Resolve the dependency graph from
app.jsonagainst available packages - Initialize the
netcorehostruntime and loadAlBridge.dll - Start the diagnostic phase 2 worker (the async CodeCop runner)
- Start the file watcher for workspace
.alfiles - Load all
.appsymbol packages in parallel - Parse all workspace
.alfiles with tree-sitter and build the initial symbol index for workspace symbols - Build the initial Insight Engine graphs
- Open the Unix daemon socket
- Send
initializednotification to Zed - Start the document change listener
Steps 1-3 are sequential and fast (under 100ms total). Steps 5-8 run concurrently after step 4 starts the background worker. The extension sends initialized only after the symbol index is ready (step 10), so Zed’s progress indicator accurately reflects when hover and completions will actually work.
The file watcher (step 5) uses notify with the backend appropriate for the platform — inotify on Linux, FSEvents on macOS, ReadDirectoryChangesW on Windows. New files get parsed and indexed incrementally. Deleted files have their symbols removed. External edits (someone else on the team editing a file outside Zed) trigger a re-parse of that document.
The grammar pipeline
The tree-sitter grammar for AL doesn’t exist in the tree-sitter grammar ecosystem. I wrote it. The process for keeping it accurate is the interesting part.
CodeAnalysis.dll (part of the BC SDK) exposes the AL grammar in machine-readable form. The al-extract tool reads this and produces a grammar specification: all the node types, their fields, their relationships, whether they’re named or anonymous, which are error-recovery nodes. This specification is a JSON file checked into the repository.
al-gen takes that JSON and produces grammar.js — the tree-sitter grammar definition. This is a code generation step, not hand-authoring. Hand-authoring a grammar for a language as large as AL would be error-prone and hard to keep in sync with BC releases. Code generation means when Dynamics 365 Business Central ships a new version with new syntax, you re-run al-extract against the new SDK, run al-gen, and the grammar updates automatically.
grammar.js goes through tree-sitter generate, which produces parser.c — the actual parser. parser.c is checked into the repository and compiled as part of the build. Users don’t need the tree-sitter CLI; the generated parser is committed and ready.
The pipeline runs as part of the release process whenever the BC SDK version bumps. In CI, there’s a check that verifies parser.c matches what tree-sitter generate would produce from the current grammar.js. If they’re out of sync, the build fails.
Binary distribution
The zed-al crate is a WASM module — it’s what Zed loads as the extension. WASM modules in Zed can’t run arbitrary native code, so zed-al doesn’t contain any AL logic itself. It’s a thin wrapper that:
- Defines the extension manifest (languages, file associations, LSP server configuration)
- On first activation, checks whether the
al-lspbinary for the current platform is already downloaded - If not, downloads it from the GitHub releases page for the current extension version
- Tells Zed where the binary is so Zed can spawn it as the language server
The download URL is constructed from the extension version and the current platform triple (e.g., x86_64-unknown-linux-gnu, aarch64-apple-darwin, x86_64-pc-windows-msvc). GitHub Actions builds the binary for all four targets on each release and attaches them as release assets. The WASM extension itself is architecture-independent — there’s one extension.wasm that handles all platforms by downloading the right binary.
The binary is verified with a SHA-256 checksum published alongside each release asset. The WASM module checks the checksum after download and refuses to use a binary that doesn’t match. This is basic supply chain hygiene — it won’t stop a compromised GitHub account, but it stops download corruption and man-in-the-middle attacks on plain HTTP (though GitHub releases use HTTPS).
What I got wrong the first time
The initial design put al-explorer as a consumer of al-core directly, with the Insight Engine graphs being computed inside al-explorer for the file-system view. This seemed logical — the explorer needs the graphs to display object relationships. But it meant compiling al-core (and therefore netcorehost) into al-explorer, which broke cross-compilation, added 40 seconds to the al-explorer build time, and made the crate hard to test in isolation.
Moving to the socket model — al-explorer talks to the running al-lsp daemon over the Unix socket — eliminated the dependency and all the problems that came with it. The trade-off is that al-explorer needs the daemon to be running to show Insight Engine data. If the daemon isn’t running (no Zed session, no al process), the explorer falls back to showing only the file tree without the graph overlays. I think that’s the right call.
The other thing I got wrong was the two-phase diagnostic design — specifically, the debounce value. I started at 300ms and it was too aggressive. On a fast typist working in a large codeunit, the CodeCop runner was being reinvoked every few hundred milliseconds, stacking up tasks and creating visible lag in the Problems panel as results flickered. Moving to 800ms with explicit cancellation of in-flight invocations (tokio task handles, .abort() on superseded runs) fixed the flickering. The tree-sitter layer at 300ms kept instant feedback for syntax errors, which is all you actually need that fast.
Performance accounting
The performance numbers in the feature article come from benchmarks that run in CI on every pull request. The setup is a synthetic workspace with 30,000 symbols (roughly BC base app scale) and 500 workspace documents.
- Hover:
criterionbench over 1,000 iterations, p99 < 500μs - Completions: same setup, p99 < 3ms
- Workspace symbol search: fuzzy match over all 30,000 symbols, p99 < 5ms
- Symbol index cold load: parallelized
.apploading, median < 4 seconds on the test machine (8-core, NVMe) - Incremental parse on edit: isolated to changed region, p99 < 200μs for a single procedure edit
The benchmarks don’t include IPC overhead (for the daemon socket clients) or LSP encoding/decoding overhead. Those are real costs but they’re dominated by transport, not computation, and transport is Zed’s problem. The numbers I care about are the computation costs inside the language server.
Where to go from here
The codebase is public on GitHub. If you want to understand the symbol loading code, start with al-symbols/src/loader.rs. The semantic layer is in al-semantic/src/inference.rs — it’s the most complex part of the codebase and the part most likely to have interesting bugs. The .NET bridge is in al-core/src/bridge/mod.rs and the managed side is in AlBridge/src/AlBridge.cs.
If you’re building AL tooling yourself and want to share the symbol loading infrastructure, al-symbols is designed to be usable as a standalone library. The crate has no dependency on the LSP machinery — it’s pure symbol loading and indexing. Open an issue if you want to use it and something is missing from the public API.