Introduction
This book introduces mdexp — a documentation preprocessor for literate programming with embedded snapshots. It is aimed at newcomers who want to understand what the tool does, why it is useful, and what kind of workflows it enables.
This book is meant to be a short introductory read — not the full documentation. The content is intentionally concise: our goal is to give a clear picture of the approach and what it makes possible. For installation, detailed reference, and how-to guides, see the main documentation site.
What is mdexp?
mdexp is a native binary that preprocesses annotated source files
into readable documents. You write a compilable program whose
comments contain lightweight directives marking which parts are
prose, which are code examples, and which are program output to
capture. Running mdexp pp on that file extracts these parts and
assembles them into the output document.
Because the source file is compiled and tested in the usual way, code examples are type-checked and program output is verified by the test framework on every build. The documentation cannot drift from the code.
A quick example
Consider a source file that documents a compression library. It defines a type, shows it in the documentation, then generates a reference table from the same type:
(* @mdexp
## Compression
The library supports several compression formats.
@mdexp.code *)
type compression = None | Gzip | Zstd
(* @mdexp Supported formats: *)
let%expect_test "compression table" =
List.iter
(fun (name, ratio, speed) ->
Printf.printf "| %s | %s | %s |\n" name ratio speed)
[ "none", "-", "fastest"
; "gzip", "~70%", "moderate"
; "zstd", "~75%", "fast"
];
(* @mdexp.snapshot *)
[%expect {|
| none | - | fastest |
| gzip | ~70% | moderate |
| zstd | ~75% | fast | |}]
;;
Three directives drive the extraction: @mdexp marks prose,
@mdexp.code marks code to include, and @mdexp.snapshot captures
verified test output. Running mdexp pp on this file produces:
## Compression
The library supports several compression formats.
```ocaml
type compression = None | Gzip | Zstd
```
Supported formats:
| none | - | fastest |
| gzip | ~70% | moderate |
| zstd | ~75% | fast |
The table is not hand-written markdown — it is printed by a test that the build system verifies on every run. Add a variant to the type and the compiler reminds you to update the match; change the output and the test runner catches the drift. The documentation cannot fall out of sync.
How it fits together
The documentation is type-driven: code examples are real code that the compiler type-checks, and program output is captured by snapshot assertions that the test framework verifies on every build.
The host language compiler, the snapshot framework, mdexp, and the rendering tool each handle one concern. You get full editor support (LSP, type hints) while writing, and the build catches any drift.
The tool is language-agnostic by design — neither the host language nor the output format is fixed. The examples in this book use OCaml and Markdown, but the snapshot mechanism also works with other host languages (see Snapshots), and the broader design space is discussed in Broadening the View.
What’s in this book
The first three chapters cover the building blocks — the directives that make mdexp work:
- Prose — writing documentation text
- Code Blocks — embedding type-checked code examples
- Snapshots — capturing verified program output, with a note on other host languages
From there, we explore what becomes possible when you combine these building blocks with the host language’s type system:
- EDSL — building embedded DSLs that both render documentation and verify its content
- Broadening the View — further use cases, design choices, and the broader pattern
Acknowledgements
This book was created through detailed iteration and review, combining hand-written content with assistance from Claude Code (Claude Opus 4.6). The conceptual content, structure, and table of contents were entirely human-designed and precisely prompted; every section was manually reviewed. We benefited from LLM assistance for drafting prose, catching mistakes, and iterating on the code examples.
Prose
The @mdexp directive marks comments as documentation to extract.
There are several styles, each suited to different situations.
Line-by-line comments
Wrap each line of prose in its own comment, bracketed by @mdexp
and @mdexp.end:
(* @mdexp *)
(* This is the first line of prose. *)
(* *)
(* This is after a blank line. *)
(* @mdexp.end *)
mdexp strips the comment markers and outputs the text. A blank
comment line (* *) becomes a blank line in the output, useful
for separating paragraphs.
This line-by-line style is more natural in host languages like Zig, where comments are single-line by nature. In OCaml, block comments are usually more convenient (see below).
Block comments
For longer prose, use a multi-line block comment. Everything between
the opening (* @mdexp and the closing *) becomes documentation.
No @mdexp.end is needed — the end of the block comment
implicitly ends the directive:
(* @mdexp
## My Section
This is a paragraph inside a block comment.
It can span multiple lines naturally.
*)
This avoids the repetitive comment wrapper on every line and is the most common style throughout this book.
Single-line directives
A single-line (* @mdexp ... *) is self-closing — handy for
short notes between code sections:
(* @mdexp A brief note between functions. *)
let x = 42
(* @mdexp Another note. *)
Each produces a standalone paragraph in the output.
Code Blocks
The @mdexp.code directive marks a region of code for inclusion in
the documentation. The extracted code is wrapped in a fenced code
block, tagged with the host language detected from the file
extension (e.g. .ml → ocaml).
Including compilable code
Code between @mdexp.code and the next directive is real,
compilable OCaml. The compiler type-checks it along with the
rest of the file. Here is what the source looks like:
(* @mdexp.code *)
let answer = 42
let question_of_life () = Printf.printf "The answer is %d\n" answer
(* @mdexp.end *)
And here is the markdown that mdexp produces from it:
let answer = 42
let question_of_life () = Printf.printf "The answer is %d\n" answer
Code outside the directives is invisible to the document but still compiled and tested. For instance, the function above is exercised in an expect test that does not appear in the output.
Prose to code transition
A block comment can transition directly from prose into a code
block by placing @mdexp.code before the closing comment marker.
This avoids the need for a separate comment:
(* @mdexp
Here is an example:
@mdexp.code *)
let greet name = Printf.printf "Hello, %s!\n" name
(* @mdexp.end *)
This produces the prose paragraph followed by the code block in a single, natural flow:
Here is an example:
let greet name = Printf.printf "Hello, %s!\n" name
Explicit language
The language tag is inferred from the file extension. You can override it
with @mdexp.code { lang: "<lang>" }:
(* @mdexp.code { lang: "bash" } *)
(* opam install mdexp *)
(* @mdexp.end *)
This produces a bash-tagged code fence:
opam install mdexp
Note that the bash command is written inside OCaml comments. This is one case where commented code makes sense, since the content is not OCaml and cannot be compiled.
Snapshots
The @mdexp.snapshot directive captures verified program output and
includes it in the documentation. Place @mdexp.snapshot right
before the snapshot assertion. mdexp extracts the content of the
assertion’s block string and emits it as part of the document.
Because the test framework verifies the assertion on every run, the documented output cannot drift from reality.
How it works
The directive does not depend on a particular testing library. It looks
for the next OCaml block string ({|...|}) after @mdexp.snapshot
and extracts its content. This means any framework whose assertions
contain a block string of the expected output is supported.
mdexp currently ships with support for two OCaml snapshot frameworks:
- ppx_expect — Jane Street’s inline
expect tests, using
[%expect {|...|}] - Expect tests without ppx — using
Windtrap’s
expect {|...|}plain function calls
The sections that follow show each in action. The same
@mdexp.snapshot directive works with both — only the surrounding
test syntax differs.
Beyond OCaml, we have done preliminary compatibility testing with
expect-test frameworks in Rust and Zig. These are not yet
used in production, but the directive syntax is designed to be
host-language agnostic — the same @mdexp.snapshot mechanism
applies wherever a block string carries the expected output.
Snapshot configuration
By default, snapshot content is emitted as plain text. You can configure the output with a short inline annotation:
@mdexp.snapshot { lang: "json" }— wrap in a fenced code block with the given language tag@mdexp.snapshot { block: true }— wrap in a plain fence (no language)
ppx_expect
ppx_expect is Jane Street’s
inline expect-test framework for OCaml. Tests are written as
let%expect_test with [%expect {|...|}] assertions.
How it works
Place @mdexp.snapshot before the [%expect] block. mdexp
extracts the block string content and emits it in the document.
Here is what an annotated test looks like (the + lines highlight
the mdexp directives):
let%expect_test "greeting" =
print_endline "Hello, World!";
+ (* @mdexp.snapshot *)
[%expect {| Hello, World! |}]
;;
The let%expect_test wrapper and [%expect] assertion are not
included in the output — only the block string content appears.
In this case, the rendered document shows:
Hello, World!
Multi-line output
Multi-line output works the same way. The block string content is dedented automatically:
let%expect_test "list" =
List.iter ~f:print_endline [ "First"; "Second"; "Third" ];
+ (* @mdexp.snapshot { lang: "txt" } *)
[%expect
{|
First
Second
Third |}]
;;
This produces:
First
Second
Third
Snapshot configuration
By default, the snapshot content is emitted as plain text. As we’ve seen above, an inline annotation can change this. Here is another example:
{ lang: "json" } wraps the content in a fenced code block with
the given language tag:
let%expect_test "json output" =
print_endline {|{ "name": "mdexp", "version": "1.0" }|};
+ (* @mdexp.snapshot { lang: "json" } *)
[%expect {|{ "name": "mdexp", "version": "1.0" }|}]
;;
This produces:
{ "name": "mdexp", "version": "1.0" }
{ block: true } wraps the content in a plain fence without a
language tag:
let%expect_test "block mode" =
print_endline "some output";
+ (* @mdexp.snapshot { block: true } *)
[%expect {|some output|}]
;;
This produces:
some output
Expect tests without ppx
Windtrap is a no-ppx alternative
for writing expect tests in OCaml. Tests use plain function calls — expect {|...|} — instead of ppx syntax.
Place @mdexp.snapshot before the expect call, and mdexp extracts the
block string content, just as it does with [%expect].
Example
open Windtrap
let greet name = Printf.printf "Hello, %s!\n" name
Let’s add mdexp directives to a windtrap test:
let () =
let tests =
[ test "greeting" (fun () ->
+ (* @mdexp When you greet the world, like so: *)
+ (* @mdexp.code *)
greet "World";
+ (* @mdexp the following happens: *)
+ (* @mdexp.snapshot { lang: "text" } *)
expect {|Hello, World!|})
]
in
(run "Windtrap Snapshots" tests [@coverage off])
;;
This would yield the following markdown:
When you greet the world, like so:
greet "World";
the following happens:
Hello, World!
EDSL
The previous chapters covered the three directives that drive mdexp: prose, code blocks, and snapshots. With those building blocks in hand, we can now explore what becomes possible when you combine them with the host language’s type system.
A powerful use case is building small embedded domain-specific languages (EDSLs). The idea is to write OCaml code that serves two purposes at once:
- Render human-readable content for the document (tables, math, diagrams).
- Evaluate and verify the same content programmatically.
Because both the rendering and the verification live in the same compilable source file, the documentation cannot drift from reality.
Tables as generated content
As a warm-up, consider generating a markdown table from typed data. Rather than writing the table by hand, we define the data as typed OCaml values:
type planet =
{ name : string
; distance_au : float
; moons : int
}
let planets =
[ { name = "Mercury"; distance_au = 0.39; moons = 0 }
; { name = "Venus"; distance_au = 0.72; moons = 0 }
; { name = "Earth"; distance_au = 1.00; moons = 1 }
; { name = "Mars"; distance_au = 1.52; moons = 2 }
]
;;
Then we use the print-table library to produce the markdown table
from this data. The snapshot captures the output, so the table is
verified on every test run:
| Planet | Distance (AU) | Moons |
|---|---|---|
| Mercury | 0.39 | 0 |
| Venus | 0.72 | 0 |
| Earth | 1.00 | 1 |
| Mars | 1.52 | 2 |
Background invariants
The table above is generated from the planets list. But we can also
assert properties of that same data — checks that run during
testing but do not appear in the rendered document. This is the
“dual interpretation” idea: the same data is both rendered (for the
reader) and verified (for correctness).
In the documentation source, we check that distances are positive and
sorted. This test has no @mdexp directives, so it is invisible to the
document, yet it runs on every build.
If a change reorders the rows or introduces a negative distance, the test fails — even though the reader never sees it. The document stays correct because the code enforces it.
A math EDSL
Now for a more ambitious example, let’s build a small EDSL for mathematical expressions. The same OCaml value can be:
- rendered to LaTeX syntax (for the document), and
- evaluated to an integer (for verification).
The expression type
type expr =
| Int of int
| Var of string
| Add of expr * expr
| Mul of expr * expr
| Frac of
{ num : expr
; den : expr
}
| Sum of
{ var : string
; from : expr
; to_ : expr
; body : expr
}
Rendering to LaTeX
The to_latex function turns an expression into a LaTeX string.
Parentheses are inserted automatically based on operator
precedence — there is no Paren constructor in the type:
let rec to_latex_prec prec = function
| Int n -> Int.to_string n
| Var x -> x
| Add (a, b) ->
let s = to_latex_prec 1 a ^ " + " ^ to_latex_prec 1 b in
if prec > 1 then "(" ^ s ^ ")" else s
| Mul (a, b) ->
let s = to_latex_prec 2 a ^ " \\cdot " ^ to_latex_prec 2 b in
if prec > 2 then "(" ^ s ^ ")" else s
| Frac { num; den } ->
"\\frac{" ^ to_latex_prec 0 num ^ "}{" ^ to_latex_prec 0 den ^ "}"
| Sum { var; from; to_; body } ->
Printf.sprintf
"\\sum_{%s=%s}^{%s} %s"
var
(to_latex_prec 0 from)
(to_latex_prec 0 to_)
(to_latex_prec 0 body)
;;
let to_latex e = to_latex_prec 0 e
Evaluation
The eval function computes the integer value of an expression given
variable bindings:
let rec eval env = function
| Int n -> n
| Var x -> List.assoc x env
| Add (a, b) -> eval env a + eval env b
| Mul (a, b) -> eval env a * eval env b
| Frac { num; den } -> eval env num / eval env den
| Sum { var; from; to_; body } ->
let lo = eval env from in
let hi = eval env to_ in
let rec loop i acc =
if i > hi then acc else loop (i + 1) (acc + eval ((var, i) :: env) body)
in
loop lo 0
;;
Stating and checking an identity
We can now state the classic identity:
\[ \sum_{i=1}^{n} i = \frac{n \cdot \left(n + 1\right)}{2} \]
Both sides are encoded as expr values:
let sum_expr = Sum { var = "i"; from = Int 1; to_ = Var "n"; body = Var "i" }
let closed_form = Frac { num = Mul (Var "n", Add (Var "n", Int 1)); den = Int 2 }
The EDSL renders them to the LaTeX shown above:
\[ \sum_{i=1}^{n} i = \frac{n \cdot (n + 1)}{2} \]
We can verify the identity by evaluating both sides for concrete values of n:
| n | Sum | Formula |
|---|---|---|
| 0 | 0 | 0 |
| 1 | 1 | 1 |
| 5 | 15 | 15 |
| 10 | 55 | 55 |
| 100 | 5050 | 5050 |
Every row shows that the sum and the closed-form formula agree. But we can do better: a background test exhaustively checks all values up to 1000, without cluttering the document:
The key insight is that the same expr values drove both the
rendered LaTeX and the numerical verification. There is no separate,
hand-maintained formula that could go out of sync — the EDSL is the
single source of truth.
An embedded proof assistant
We now push the idea further. Instead of just checking the final result, we build a small proof assistant that verifies each step of a mathematical derivation. Each step is a named algebraic rule — “commutativity of multiplication”, “factor a common term” — applied at a specific location in the expression tree. The system computes the result of each rule application, and verification is structural: expressions are compared for exact equality, not evaluated numerically.
Structural equality
First we need a function that compares two expressions for exact structural equality:
let rec equal_expr a b =
match a, b with
| Int x, Int y -> x = y
| Var x, Var y -> String.equal x y
| Add (a1, a2), Add (b1, b2) | Mul (a1, a2), Mul (b1, b2) ->
equal_expr a1 b1 && equal_expr a2 b2
| Frac { num = n1; den = d1 }, Frac { num = n2; den = d2 } ->
equal_expr n1 n2 && equal_expr d1 d2
| ( Sum { var = v1; from = f1; to_ = t1; body = b1 }
, Sum { var = v2; from = f2; to_ = t2; body = b2 } ) ->
String.equal v1 v2 && equal_expr f1 f2 && equal_expr t1 t2 && equal_expr b1 b2
| (Int _ | Var _ | Add _ | Mul _ | Frac _ | Sum _), _ -> false
;;
Variable substitution
We also need a function to substitute a variable with an expression
throughout a term. This implementation does not perform full
capture-avoiding substitution (a general solution would require
alpha-conversion) and raises if a Sum binder shadows the target
variable. This is sufficient for the current usage, where
replacements are either closed terms or variables that do not
collide with inner binders.
let rec subst_var ~var ~by = function
| Int _ as e -> e
| Var x -> if String.equal x var then by else Var x
| Add (a, b) -> Add (subst_var ~var ~by a, subst_var ~var ~by b)
| Mul (a, b) -> Mul (subst_var ~var ~by a, subst_var ~var ~by b)
| Frac { num; den } ->
Frac { num = subst_var ~var ~by num; den = subst_var ~var ~by den }
| Sum { var = v; from; to_; body } ->
if String.equal v var
then Code_error.raise "subst_var: variable shadowed by Sum binder" []
else
Sum
{ var = v
; from = subst_var ~var ~by from
; to_ = subst_var ~var ~by to_
; body = subst_var ~var ~by body
}
;;
Algebraic rules
Each rule is a small, self-contained algebraic transformation. Here is the minimal set needed for our proof:
type rule =
| Eval_const
| Peel_sum
| Subst of
{ from : expr
; to_ : expr
}
| Fold_add_right
| Add_to_frac
| Factor_right
| Comm_mul
Each rule has a short label and a description:
| Rule | Transformation |
|---|---|
| evaluate | evaluate a constant expression |
| peel last term | sum(a, b+1, f) -> sum(a, b, f) + f(b+1) |
| substitution | replace a sub-expression |
| fold added constants | (e + a) + b -> e + (a + b) |
| common denominator | a/c + b -> (a + b*c) / c |
| factor | ac + bc -> (a + b) * c |
| commute | a * b -> b * a |
Locations
A rule is applied at a specific position in the expression tree. Locations navigate into subexpressions:
type loc =
| Here
| In_left of loc
| In_right of loc
| In_num of loc
Applying rules
The apply_rule function applies a rule at the top level of an
expression. It raises if the rule does not match:
let apply_rule rule expr =
match rule, expr with
| Eval_const, _ -> Int (eval [] expr)
| Peel_sum, Sum { var; from; to_; body } ->
(match to_ with
| Add (rest, Int 1) ->
Add (Sum { var; from; to_ = rest; body }, subst_var ~var ~by:to_ body)
| _ -> Code_error.raise "Cannot peel: upper bound not of form e+1" [])
| Subst { from; to_ }, _ ->
if equal_expr expr from
then to_
else Code_error.raise "Subst: expression does not match" []
| Fold_add_right, Add (Add (e, Int a), Int b) -> Add (e, Int (a + b))
| Add_to_frac, Add (Frac { num; den }, b) -> Frac { num = Add (num, Mul (den, b)); den }
| Factor_right, Add (Mul (a, c1), Mul (b, c2)) ->
if equal_expr c1 c2
then Mul (Add (a, b), c1)
else Code_error.raise "Factor_right: right factors differ" []
| Comm_mul, Mul (a, b) -> Mul (b, a)
| _ -> Code_error.raise "Rule does not apply" []
;;
The apply_at function navigates to a location and applies the
rule there:
let rec apply_at loc rule expr =
match loc, expr with
| Here, _ -> apply_rule rule expr
| In_left loc, Add (a, b) -> Add (apply_at loc rule a, b)
| In_left loc, Mul (a, b) -> Mul (apply_at loc rule a, b)
| In_right loc, Add (a, b) -> Add (a, apply_at loc rule b)
| In_right loc, Mul (a, b) -> Mul (a, apply_at loc rule b)
| In_num loc, Frac { num; den } -> Frac { num = apply_at loc rule num; den }
| _ -> Code_error.raise "Location does not match expression shape" []
;;
Proof structure
A proof is a starting expression, a sequence of tactic steps, and an expected goal. Each step names a rule, a location, and an optional annotation for rendering:
type proof_step =
{ rule : rule
; loc : loc
; label : string option
}
type proof =
{ start : expr
; steps : proof_step list
; goal : expr
}
run_proof executes the proof: it applies each tactic in sequence,
computing the intermediate expressions, and checks that the final
expression matches the goal structurally:
type computed_step =
{ result : expr
; justification : string
}
type derivation =
{ start : expr
; steps : computed_step list
}
let run_proof (p : proof) : derivation =
let final, rev_steps =
List.fold_left p.steps ~init:(p.start, []) ~f:(fun (current, acc) step ->
let result = apply_at step.loc step.rule current in
let justification =
match step.label with
| Some s -> s ^ " [" ^ rule_label step.rule ^ "]"
| None -> rule_label step.rule
in
result, { result; justification } :: acc)
in
require (equal_expr final p.goal);
{ start = p.start; steps = List.rev rev_steps }
;;
Rendering
The renderer turns a derivation into a LaTeX aligned environment:
let render_derivation d =
let buf = Buffer.create 256 in
Buffer.add_string buf "\\\\[\n\\begin{aligned}\n";
Buffer.add_string buf (to_latex d.start);
let len = List.length d.steps in
List.iteri d.steps ~f:(fun i step ->
Buffer.add_string buf "\n &= ";
Buffer.add_string buf (to_latex step.result);
Buffer.add_string buf " && \\text{(";
Buffer.add_string buf step.justification;
Buffer.add_string buf ")}";
if i < len - 1 then Buffer.add_string buf " \\\\\\\\");
Buffer.add_string buf "\n\\end{aligned}\n\\\\]";
Buffer.contents buf
;;
Proof by induction
We prove the identity by induction on n. Each proof is a sequence of named rules — no hand-written result expressions. The system computes every intermediate step and verifies the goal structurally.
Base case (n = 1): we substitute n with 1 in both sides of
the identity and evaluate:
let at_n n = subst_var ~var:"n" ~by:(Int n)
let base_case =
run_proof
{ start = at_n 1 sum_expr
; steps = [ { rule = Eval_const; loc = Here; label = None } ]
; goal = Int 1
}
;;
let base_case_rhs =
run_proof
{ start = at_n 1 closed_form
; steps = [ { rule = Eval_const; loc = Here; label = None } ]
; goal = Int 1
}
;;
Inductive step: assuming the identity holds for n, we show it holds for n + 1. Five rules drive the derivation:
let n_plus_1 = Add (Var "n", Int 1)
let n_plus_2 = Add (Var "n", Int 2)
let inductive_step =
run_proof
{ start = Sum { var = "i"; from = Int 1; to_ = n_plus_1; body = Var "i" }
; steps =
[ { rule = Peel_sum; loc = Here; label = None }
; { rule =
Subst
{ from = Sum { var = "i"; from = Int 1; to_ = Var "n"; body = Var "i" }
; to_ = Frac { num = Mul (Var "n", n_plus_1); den = Int 2 }
}
; loc = In_left Here
; label = Some "induction hypothesis"
}
; { rule = Add_to_frac; loc = Here; label = None }
; { rule = Factor_right; loc = In_num Here; label = None }
; { rule = Comm_mul; loc = In_num Here; label = None }
]
; goal = Frac { num = Mul (n_plus_1, n_plus_2); den = Int 2 }
}
;;
The rendered derivations read exactly like a pencil-and-paper proof, but every step has been machine-checked.
Base case (n = 1): the left-hand side is
\[ \begin{aligned} \sum_{i=1}^{1} i &= 1 && \text{(evaluate)} \end{aligned} \]
and the right-hand side is
\[ \begin{aligned} \frac{1 \cdot (1 + 1)}{2} &= 1 && \text{(evaluate)} \end{aligned} \]
Both sides equal \( 1 \). ✓
Inductive step: assuming the identity holds for n, we derive it for n + 1:
\[ \begin{aligned} \sum_{i=1}^{n + 1} i &= \sum_{i=1}^{n} i + n + 1 && \text{(peel last term)} \\ &= \frac{n \cdot (n + 1)}{2} + n + 1 && \text{(induction hypothesis [substitution])} \\ &= \frac{n \cdot (n + 1) + 2 \cdot (n + 1)}{2} && \text{(common denominator)} \\ &= \frac{(n + 2) \cdot (n + 1)}{2} && \text{(factor)} \\ &= \frac{(n + 1) \cdot (n + 2)}{2} && \text{(commute)} \end{aligned} \]
and the closed form for n + 1 simplifies to the same expression:
\[ \begin{aligned} \frac{(n + 1) \cdot (n + 1 + 1)}{2} &= \frac{(n + 1) \cdot (n + 2)}{2} && \text{(fold added constants)} \end{aligned} \]
Both sides equal \( \frac{(n + 1) \cdot (n + 2)}{2} \). ✓
Takeaways
The examples in this chapter illustrate the same core principle at increasing levels of ambition:
- Tables — typed data generates markdown, and background tests enforce invariants the reader never sees.
- Math rendering — a single
exprvalue drives both the LaTeX output and numerical verification. - Proof assistant — algebraic rules compute every intermediate step, and structural equality replaces numerical spot-checking.
In each case the pattern is the same: the host language defines a small domain model, mdexp renders it into the document, and the test harness verifies it. Because the rendered content and the verification share the same source, the documentation cannot drift from reality.
Broadening the View
Now that you have a feel for how mdexp works, let’s step back and consider where the approach leads and where you might take it.
The previous chapters focused on mechanics: directives, snapshots, and the EDSL pattern. Here we broaden the view to explore additional use cases, discuss design choices, and invite you to think about how this pattern of compilable, verified documentation might apply to your own projects, whether or not you use mdexp itself.
Cram-style tests in OCaml
OCaml’s ecosystem uses .t cram files to test command-line tools:
you write a shell session with expected output and the test runner
checks it. mdexp offers a path to express the same tests in OCaml
and we are experimenting with a Mdexp_cram library (wip, unpublished).
The “background” part of the process becomes the evaluation environment
(setting up state, running commands, capturing output) while the visible
part reads as a tutorial showing the session step by step.
This means the same file can serve as both a functional test and a user-facing walkthrough: the test runner verifies correctness, and mdexp extracts the narrative.
Client/server application testing
The pattern extends naturally to networked applications. The background spins up a running server, and the visible part scripts a series of CLI client calls that exercise the service. Each call and its output become a snapshot in the document; the full session builds up an end-to-end functional test.
The reader sees a coherent tutorial — “first create a resource, then query it, then update it” — while the build system verifies that every response matches expectations. The documentation becomes a live integration test.
Your own EDSLs
The EDSL chapter showed a math expression language and a toy proof assistant. But there is nothing special about math — the same pattern applies wherever you want to both render and verify structured content.
The key point is that mdexp does not provide or prescribe any of
these EDSLs. It provides the infrastructure — directives, snapshot
capture, the pp pipeline — and you build whatever domain-specific
layer makes sense for your project. It is just OCaml; there is no
plugin architecture to learn. Your EDSL lives in your codebase, uses
your types, and evolves with your project.
Host and output languages
Neither the host language nor the output format is a hard constraint of the system. mdexp’s directive syntax is designed to work with any language that has block comments, and we have prepared support for host-language expect-test frameworks written in Zig and Rust alongside OCaml. Similarly, the output is not limited to Markdown — it could just as well be Typst, reStructuredText, or any other text format.
That said, we have focused our effort on the OCaml + Markdown combination for practical reasons:
- OCaml is well suited to the kind of work mdexp encourages. The examples in this book — symbolic computation, algebraic rewriting, proof construction — benefit from a language where you can manipulate structured data at a high level without worrying about memory management. Rust and Zig are equally appealing here — each handles memory in its own way without burdening documentation-oriented code. Which language fits best may depend on the libraries you need to pull into your document. We would not be surprised if mdexp found a place in multi-language codebases, with different chapters written in different host languages.
- Markdown integrates well with the rest of our tooling: static site generators, mdbook, slipshow, and the broader ecosystem of tools that consume Markdown as input.
Other combinations are possible and welcome; the architecture does not privilege any particular pairing.
Closing thoughts
Beyond the specific tool, the underlying pattern is worth reflecting on: write documentation as a compilable source file, use the type system and test framework to keep it honest, and let a thin preprocessor extract the readable output. This is not a new idea — literate programming is decades old — but the combination with snapshot testing and embedded DSLs gives it a practical edge that we believe makes it click in interesting ways.
mdexp is one implementation of this pattern, intentionally minimal. It reads directives, extracts content, and produces output. Everything else — the types, the tests, the rendering logic, the EDSLs — is ordinary code in your project. The tool stays small and predictable; the power comes from what you build on top of it.
We hope this introductory book has given you both a working understanding of mdexp and a sense of the broader possibilities. For deeper coverage — reference material, how-to guides, and additional examples — see the main documentation site.