Writing the Grammar
I shipped ORB on November 28th and immediately started worrying about editor support. A template language that shows up as plain text in your editor is a non-starter. People will just go back to ERB.
Tree-sitter is the parser generator that powers highlighting in Zed, Neovim, and Helix. You write a grammar in JavaScript, tree-sitter compiles it into a C parser, and editors use that parser for syntax highlighting, code folding, and structural navigation. If I wanted ORB to work properly in modern editors, this was the path.
I had the grammar shipped by the end of the same day. One commit, everything at once. That makes it look effortless in the git history, which is misleading. I’d been iterating on it locally, loading it into Zed, testing against real templates, tweaking rules. There were plenty of false starts that the commit log doesn’t show.
The syntax, borrowed honestly
ORB’s syntax borrows heavily from Surface and HEEx in the Elixir world. Component names starting with uppercase, {expression} attribute syntax, {#if}/{#for} blocks, {!-- --} private comments. I didn’t try to disguise the influence. Surface got the template ergonomics right, and there’s no reason to invent different syntax for the same concepts just to be original.
The grammar has seven top-level node types: HTML tags, components, text, printing expressions ({{ }}), non-printing expressions ({% %}), control flow blocks, and comments. Tags and components look similar but are separate rules because the name patterns differ (lowercase vs. uppercase) and editors need to color them differently.
Balanced braces without a custom scanner
This was the real problem. ORB embeds Ruby code inside delimiters, and Ruby uses curly braces everywhere: hash literals, blocks, string interpolation. The parser needs to track brace nesting to know where an expression ends.
<div class={items.map { |i| i.name }.join(", ")}>
Three closing braces before the >. The parser has to match them correctly. The first two close Ruby constructs, the third closes the ORB expression.
Tree-sitter grammars usually handle this kind of thing with an external scanner, a custom C file that runs alongside the generated parser. It’s the “proper” way, and it can handle arbitrary edge cases. But it also means maintaining C code that has to stay in sync with the grammar, which felt like overkill for a v0.1.0 of a language that’s still changing.
Instead I expressed it purely in the grammar DSL. The _expression_chunks rule allows any text except braces, plus recursively balanced { ... } pairs. It doesn’t handle truly pathological cases like Ruby string literals containing unbalanced braces. But it works on every real template I’ve written, and keeping the entire grammar in one JavaScript file, no C, no build complexity, felt like the right trade-off for now.
Language injection
Tree-sitter has this nice concept where you tag parts of your syntax tree as belonging to a different language, and the editor runs a second parser over those regions. It’s how HTML files get CSS highlighting inside <style> tags.
For ORB, every expression region gets tagged for Ruby injection. The injections.scm file is about thirty lines. The interesting decision was variable scoping. I punted entirely. The locals.scm file is one line, a comment: “Not declaring scopes/defs; Ruby handles variables.” The injected Ruby parser deals with it. Template variables come from the view context anyway, not from the template itself, so there’s nothing useful for the ORB grammar to track.
No tests, no README
I shipped without a test corpus. Tree-sitter grammars usually have a test/corpus/ directory with input files and expected parse trees. Mine doesn’t. The binding tests verify the grammar loads, not that it parses correctly.
There’s also no README. The Cargo.toml and pyproject.toml both reference one that doesn’t exist.
I’m not proud of this, but I’m also not going to pretend I had time. I shipped a template language, a tree-sitter grammar, and a VS Code extension on the same day. Something had to give, and it was documentation and formal tests. The grammar works on real files. I’ll backfill the test corpus when edge cases show up in practice.
The #Markdown special case
One thing I’m happy with: the grammar treats the #Markdown component specially. Content inside <#Markdown>...</#Markdown> is parsed as plain text, not as ORB nodes. This is right because that content is Markdown that’ll get injected as a separate language. If the grammar tried to parse it as ORB, any stray { in your Markdown would break the entire file’s highlighting.
Getting this right required separate start_markdown and end_markdown rules outside the generic component handling. It’s a small thing, but it’s the difference between syntax highlighting that works and syntax highlighting that goes red the moment you write a code block in Markdown.
What it’s like writing a grammar
It’s a weird kind of programming. You’re not writing code that executes. You’re describing the shape of a language, and then a tool generates the code. The feedback loop is: change a rule, regenerate, reload the editor, look at the colors. When the colors are wrong, the problem is usually structural: a rule is too greedy, two rules conflict, a precedence is off. Fixing it means thinking about the grammar as a whole.
I like this kind of work when it goes well. It’s a puzzle. This one went well, mostly because I was lucky enough to design ORB’s syntax in a way that the grammar DSL could handle without external help. If I’d made different syntax choices, I might still be writing a C scanner.