Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

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. .mlocaml).

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:

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:

  1. Render human-readable content for the document (tables, math, diagrams).
  2. 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:

PlanetDistance (AU)Moons
Mercury0.390
Venus0.720
Earth1.001
Mars1.522

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:

nSumFormula
000
111
51515
105555
10050505050

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:

RuleTransformation
evaluateevaluate a constant expression
peel last termsum(a, b+1, f) -> sum(a, b, f) + f(b+1)
substitutionreplace a sub-expression
fold added constants(e + a) + b -> e + (a + b)
common denominatora/c + b -> (a + b*c) / c
factorac + bc -> (a + b) * c
commutea * 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:

  1. Tables — typed data generates markdown, and background tests enforce invariants the reader never sees.
  2. Math rendering — a single expr value drives both the LaTeX output and numerical verification.
  3. 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.