The Tooling Cost of a DSL
The parser is a weekend. What people expect the moment they depend on your language — positioned diagnostics, error recovery, a formatter, editor support, a debugging story, documentation, versioning and a migration path — is the project, and it never finishes.
We have a working parser and interpreter. Why is nobody using our language?
Not the program but everything around it: the artifacts a language must produce for each program besides its result. A span-carrying AST that survives errors, a diagnostic set with positions and suggestions, a canonical formatting of the source, an index for navigation and completion, a mapping from run-time behaviour back to source positions, and a record of which version of the language a given program was written against. Each of those is a representation the implementation has to maintain, and a language that maintains only the AST can execute programs and cannot support anyone writing them.
Every tool in this list is only as good as the spans the front end recorded. A diagnostic can point at a construct only if that construct's source range survived to the point where the error was detected; a formatter can round-trip a file only if comments and trivia were preserved, which an AST built for execution discards; a language server can answer a question about a file with an error in it only if the parser produced a tree despite the error. Those are preconditions on the front end's design, not features that can be added later — which is why retrofitting them means rewriting the parser.
Key points
- The parser is a small fraction of the work; the tooling around it is the project and it does not end.
- Users hit the items in a fixed order and stop at the first one missing, so diagnostics with positions are the entry fee.
- Error recovery matters more than it sounds: every editor feature must work on a file that is currently invalid.
- A formatter needs comments and trivia that an execution-oriented AST discards, which is a first-week architecture decision.
- A debugging story is the item most often missing entirely and the one users can least work around.
- Source spans threaded through every stage are the precondition for almost every item on the list.
- Retrofitting spans, recovery or a concrete syntax tree generally means rewriting the front end.
- The largest available savings are going internal, adopting an existing language, or staying inside a data format's ecosystem.
The list, in the order users hit it
A new user of your language meets these in a specific sequence, and the sequence matters because they stop at the first one that is missing. It is worth reading the list as an ordering rather than as a set.
They write a program with a mistake in it. If the message says "unexpected token" with no position, they are done — not because the message is rude, but because they have no way to proceed. Diagnostics with a line, a column and a statement of what was expected are the entry fee, and nothing below matters until they exist.
They fix that mistake and hit the next one. If the file had three errors and the parser reported the first and gave up, each fix-run cycle finds one, and a five-error file is five cycles. Error recovery — producing a tree despite an error, so checking can continue and report the rest — is what turns that into one cycle. It is also what makes everything below possible, because every editor feature has to work on a file that is currently invalid, which is the normal state of a file being typed.
Then they open the file in an editor and see plain grey text. Syntax highlighting is the cheapest item on this list and the most immediately felt; it is also usually a per-editor grammar file, so "an editor" means several.
Then they write more and want to know what is available. Completion, hover documentation and go-to-definition — which in practice means a language server, because writing one server is cheaper than writing plugins for each editor. See [[language-server]] and [[lsp]].
Then two people format a file differently and the review is about whitespace. A formatter, ideally with no options, removes an entire category of argument permanently.
Then a program does the wrong thing and they ask why. A debugging story — which might be stepping, or a trace, or an explain command, but has to be something — is the item most often missing entirely, and the one users are least able to work around.
And then the language changes. Versioning and a migration path decide whether that is an afternoon or a quarter.
| Item | What users expect | What it costs to build | What its absence causes |
|---|---|---|---|
| Positioned diagnostics | Line, column, what was expected, and ideally a suggestion | Spans threaded through every stage from the lexer onward | Adoption stops here; nothing below is reached |
| Error recovery | All the errors in the file, not the first one | A parser designed to produce a tree despite errors — see [[error-recovery]] | One error per run; editor features cannot work on invalid files |
| Syntax highlighting | Colour in whatever editor they use | A grammar file per editor family | The language feels unofficial and unsupported |
| Language server | Completion, hover docs, go-to-definition, rename | A server implementing LSP over an incremental front end | Users work in a plain text buffer with no feedback until they run something |
| Formatter | One canonical layout, no options | A printer over a tree that preserved comments and trivia | Reviews argue about whitespace forever |
| Debugging story | Some way to find out why a program did what it did | Source-position mapping plus a stepper, a trace or an explain mode | Users cannot diagnose their own programs and escalate everything |
| Documentation | A reference plus runnable examples for every construct | Written, maintained, and tested against the implementation | The examples become the specification and drift from it |
| Versioning and migration | Old programs keep working, or a tool updates them | A version marker per program plus an automated rewriter | Every change is a breaking change and nothing can evolve |
| Packaging and reuse | A way to share and version common definitions | A module system, a resolver and a registry | Copy-paste, and a fix applied in eleven places out of twelve |
Why the parser felt like the whole job
The parser is the part with a textbook, a clear success criterion and a satisfying moment when it first accepts a real file. Everything else on the list has no clear finish line, is judged by taste, and only reveals its absence when a user gives up quietly. That asymmetry is why the estimate is always wrong in the same direction.
There is also a structural reason the rest is harder: the parser only has to handle correct programs, and every tool below has to handle incorrect ones. A parser that produces a tree or an error is complete. A language server has to produce a *useful tree for a file that is currently invalid*, because a file being edited is invalid most of the time — the user is halfway through typing a name and wants completion for it. That is a fundamentally different design, and a parser that was not built for it usually has to be rewritten rather than extended.
The same applies to the formatter. An AST built for execution throws away comments, blank lines and the author's choices about layout, because none of it affects the result. A formatter needs all of it. That is [[concrete-syntax-tree]], and the decision about which tree to build is made in the first week and is expensive to revisit in the second year.
This is the concrete content of the claim in [[should-i-build-a-dsl]] that the tooling must be in the same plan as the language. It is not a scheduling preference. Several items on the list are constraints on the front end's architecture, and they cannot be added afterwards without redoing it.
Spans, and the one decision everything depends on
If there is one implementation decision that determines whether this list is achievable, it is threading source positions through every stage from the first line of the lexer. A span on every token, a span on every AST node, a span on every value the checker computes, and a span carried into whatever the program becomes.
Every item in the list consumes them. A diagnostic is a message plus a span. A quick fix is a span plus a replacement. Go-to-definition is a span-to-span mapping. A formatter needs spans to know what the original text was. A run-time error that names a source location needs the span to have survived into the generated code or the bytecode — which is exactly [[source-maps]] and [[debug-information]] at a smaller scale.
Adding spans afterwards means touching every stage, every node type and every error path, which in practice means a rewrite. This is the same lesson as [[source-locations]] in the main pipeline, and it is worth repeating here because DSL implementations skip it far more often than compilers do: the first version is written to execute programs, spans are not needed to execute programs, and the cost of their absence arrives with the first user.
1// Without spans — everything below is unreachable.2Err("expected an integer, found a string")3// > expected an integer, found a string4// The user has a 200-line file. Where?5 6// With spans threaded from the lexer.7Err(Diagnostic {8 message: "expected an integer, found a string",9 span: Span { file: "rules.dsl", line: 12, col: 9, len: 5 },10 expected: "int",11 found: "string",12 suggestion: Some(Fix { span, replacement: "150" }),13})14// > rules.dsl:12:9: expected an integer, found a string15// > 12 | age: required int in 0.."150"16// > | ^^^^^ expected int here17// > = help: remove the quotes to write a numberThe second form is not a nicer message; it is a different set of capabilities. The span makes the underline, the caret, the quick fix, the go-to-definition and the editor squiggle all possible from the same data. One field, threaded everywhere, is most of what separates a usable language from a parser.
Reducing the bill
The list is long enough that the honest response is usually to avoid it, and that is the point of [[should-i-build-a-dsl]]. But when a language is genuinely justified, there are real ways to pay less.
Go internal. An internal DSL inherits every item on the list from the host — highlighting, completion, formatting, debugging, packaging — at the cost of notation. This is the single largest available saving and it is why [[internal-vs-external-dsl]] is the decision it is.
Adopt rather than build. CEL, Rego, Starlark, Jsonnet, Lua and CUE all exist, are specified, are sandboxable and arrive with tooling and a community. A language that nearly fits and has a language server usually beats a perfect one that has nothing.
Use the data format's ecosystem. JSON or YAML with a JSON Schema gets validation, editor completion and documentation generation without any of your own front end. The ceiling is low — see [[configuration-languages]] — and below that ceiling the tooling is free.
Build on infrastructure that exists. Tree-sitter gives incremental parsing and highlighting from one grammar and is consumed by several editors. LSP means one server rather than a plugin per editor. A parser generator removes the most mechanical part of the front end. None of these removes the design work, and all of them remove implementation work.
Sequence honestly. If the full list cannot be funded, ship the items in the order users hit them, and be explicit that the language is limited until they exist. Diagnostics first, then recovery, then a language server. A language with excellent diagnostics and no formatter is usable; a language with a formatter and no diagnostics is not.
- Internal DSL: inherits the entire list from the host, at the cost of notation.
- Adopt an existing embeddable language: specified semantics, a sandbox and tooling you did not write.
- Data plus schema: the format's ecosystem is your front end, with a low ceiling.
- Tree-sitter, LSP and a parser generator: real infrastructure that removes implementation, not design.
- If funding is limited, ship in the order users hit the items — diagnostics before everything.
How it works
The steps, in the order the compiler takes them.
- Record a span on every token in the lexer and propagate it to every AST node, every checked value and every emitted artifact.
- Design the parser to produce a tree in the presence of errors, with error nodes standing in for what could not be parsed, so later phases still run.
- Preserve comments and layout trivia in the tree if a formatter or a refactoring tool is in scope, since an execution-oriented tree cannot be retrofitted with them.
- Build the front end as a library that answers queries about a file, and put the language server, the formatter and the compiler on top of that same library rather than duplicating it.
- Make diagnostics data — message, span, expected, found, suggested fix — rather than strings, so the same diagnostic renders in a terminal, in an editor squiggle and as a quick fix.
- Emit a source-position mapping into whatever the program becomes, so run-time errors name the user's file rather than the implementation's.
- Record a language version in each program from the first release, so a future migration tool has something to dispatch on.
- Write documentation examples as tests, so they cannot drift from the implementation.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- Adoption stops at the author: the language works and its first error message is "unexpected token", so nobody else can get past their first mistake.
- Users edit in a plain text buffer with no highlighting or completion and discover every error only by running the build, which makes iteration minutes rather than seconds.
- A file with five mistakes takes five fix-and-run cycles, because the parser stops at the first error.
- A program produces the wrong result and there is no way to find out why, so every question becomes a message to the language's author.
- A language change breaks every existing program and there is no migration tool, so the change is abandoned and the wart becomes permanent.
- Documentation examples stop matching the implementation, and users trust the examples, so bug reports describe a language that no longer exists.
- A formatter is requested two years in and turns out to require a different parse tree, so it never ships.
- Common definitions are copy-pasted because there is no module system, and a fix lands in eleven of the twelve copies.
When it helps
- Estimating a DSL proposal honestly, where this list turns an enthusiastic plan into a scoped one.
- Deciding between internal and external, where the list is the entire cost side of the comparison.
- Prioritising work on an existing DSL, where the order users hit the items tells you what to build next.
- Evaluating a third-party DSL before adopting it: check the list, because you will need every item and you will not be able to add them.
When it hurts
- As a reason never to build anything, which would have blocked several languages that were clearly worth building.
- When it is used to justify shipping tooling before the language design has stabilised, so the tools encode a design that is still moving.
- When treated as a fixed sequential checklist rather than as constraints on the front end, which is what the architecture-affecting items actually are.
- When applied to a genuinely single-user internal tool, where several items are honestly unnecessary and the ceremony is the waste.
What it costs
Every one of these is paid by something.
- Building the tooling buys adoption, since adoption is bounded by the tooling rather than by the semantics, and pays an ongoing engineering commitment that scales with the number of editors, the rate of language change and the number of users.
- Threading spans everywhere buys every diagnostic, every editor feature and every source-mapped run-time error, and pays memory on every node plus discipline in every transformation — the same trade as
[[source-locations]], in a smaller system with less tolerance for it. - Keeping a concrete syntax tree buys a formatter, refactoring and accurate editor features, and pays a larger tree, a second representation to keep consistent, and complexity in every pass that walks it.
- Adopting an existing language buys the whole list for free and pays the loss of design freedom: its restrictions, its syntax and its semantics are not yours, so a guarantee you wanted may not be expressible.
- Shipping the list in priority order buys a usable language sooner and pays in visible gaps: users will ask for the missing items repeatedly, and each answer costs credibility if the plan is not stated.
What else you could do
What a different compiler or language does instead, and when that is better.
- Do not build the language — the recommendation in
[[should-i-build-a-dsl]]and the correct one most of the time. - Build it internally, inheriting the host's entire toolchain — see
[[internal-vs-external-dsl]]. - Adopt an existing embeddable language with its tooling and community: CEL, Rego, Starlark, Jsonnet, Lua, CUE.
- Stay inside a data format with a schema, so the format's ecosystem provides validation, completion and documentation — see
[[configuration-languages]]. - Generate host code from a specification, so the host's debugger, profiler and editor handle everything downstream and the only tool you own is the generator.
See it for yourself
The flag, dump or tool that shows you this directly.
- Take any DSL you use and run the list against it: introduce an error and read the message; introduce two and count how many are reported; open it in your editor and check for completion; look for a formatter and a migration tool.
- Compare error messages from a language with excellent diagnostics — rustc, Elm — against one that stops at "unexpected token". The difference is span data plus effort, not cleverness.
- Check whether a DSL has an LSP server: search for
--lsp, a*-language-serverbinary, or an editor extension. Its presence is the best single proxy for whether the tooling was funded. - Look at how a DSL handles a file mid-edit: type half an identifier and see whether completion works. That answers whether the parser recovers, which answers most other questions.
- Read the release notes for a breaking change and look for a migration tool. Its absence tells you what future changes will cost.
Plausible wrong readings
Stated the way a confident engineer states them.
- "The parser is the hard part." The parser has a textbook and a clear finish line. Everything else has neither, and everything else is what users touch.
- "We can add the language server later." It requires a parser that recovers from errors and a front end organised as a query interface. If yours is not, later means a rewrite.
- "Our users are internal, so the bar is lower." Internal users have the same expectations and less patience, and they cannot search the web for your error message.
- "Good documentation makes up for bad error messages." An error message is read at the moment of confusion by someone who is not reading documentation. It is the documentation that matters most.
- "A formatter is a nice-to-have." It is the cheapest way to permanently end a category of review argument, and it is far cheaper before the tree design is fixed than after.
Misconceptions
The claim, and what is actually true.
Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
Writing a small language is mostly not writing the language. Once people depend on it they expect error messages that say which line is wrong, colour in their editor, completion, a formatter so nobody argues about layout, some way to find out why a program did the wrong thing, documentation, and a promise that their existing files will keep working. That list is the actual project, and it is why building a language is a much bigger commitment than it looks.
practical
If you are building one, do two things from day one. Put a source span on everything — every token, every node, every value, every emitted instruction — because almost every item on the list is unreachable without them and adding them later means starting over. And decide in the first week whether you need a formatter or a language server, because both require a parse tree that keeps comments and recovers from errors, and an execution-oriented tree does neither. If you are evaluating someone else's DSL, run the list against it: type half an identifier and see whether completion works, introduce two errors and count how many are reported. Those two checks predict most of what depending on it will feel like.
advanced
The structural insight is that the compiler and the tools are the same program, and every language implementation eventually discovers it. A batch compiler processes a valid file once and exits; a language server answers queries about an invalid file, repeatedly, while it changes under it. Those are different enough that treating them as separate implementations produces two front ends that disagree — which is a bug class where the editor accepts what the compiler rejects and users trust the wrong one. The resolution the industry converged on is to build the front end as a library with an incremental, query-based interface and put both the compiler and the server on top of it, which is what rust-analyzer, the TypeScript compiler and Roslyn all do. For a DSL that is a large architectural commitment, and it is a fair summary of the whole lesson that the right answer for a small language is usually to inherit somebody else's rather than to make it — which is what [[internal-vs-external-dsl]] is really choosing between.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
If you were asked this in an interview
- A team has a working DSL that nobody outside the team uses. What do you check first?
- Why can a language server not be added on top of an ordinary batch parser?
- What single implementation decision most determines whether a DSL can have good tooling?
Connections
- DevOps / Production Engineering — Owning and staffing a language toolchain in the build path over its lifetimeEvery item in this list is ongoing work, and the decisive question in
[[should-i-build-a-dsl]]is whether it has a funded owner. Who maintains it, what happens when they leave, and how a language embedded in the build gets decommissioned are organisational questions this lesson creates and cannot answer. - Testing & Reliability Engineering — Testing a language implementation: golden diagnostics, round-tripping a formatter, and testing documentation examplesThe tools in this list need their own testing discipline — a formatter must be idempotent and round-trip, diagnostics need golden tests so messages do not silently regress, and documentation examples must be executed. Those techniques are general testing practice applied here rather than compiler-specific work.