Repository: tweag/nickel
Pull request #2016 (2024-07-30 00:24:10)

chore(deps): bump actions/github-script from 6 to 7
Bumps [actions/github-script](https://github.com/actions/github-script) from 6 to 7.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a href="https://github.com/actions/github-script/releases">actions/github-script's releases</a>.</em></p>
<blockquote>
<h2>v7.0.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Add base-url option by <a href="https://github.com/robandpdx"><code>@​robandpdx</code></a> in <a href="https://redirect.github.com/actions/github-script/pull/429">actions/github-script#429</a></li>
<li>Expose async-function argument type by <a href="https://github.com/viktorlott"><code>@​viktorlott</code></a> in <a href="https://redirect.github.com/actions/github-script/pull/402">actions/github-script#402</a>, see for details <a href="https://github.com/actions/github-script#use-scripts-with-jsdoc-support">https://github.com/actions/github-script#use-scripts-with-jsdoc-support</a></li>
<li>Update dependencies and use Node 20 by <a href="https://github.com/joshmgross"><code>@​joshmgross</code></a> in <a href="https://redirect.github.com/actions/github-script/pull/425">actions/github-script#425</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/navarroaxel"><code>@​navarroaxel</code></a> made their first contribution in <a href="https://redirect.github.com/actions/github-script/pull/285">actions/github-script#285</a></li>
<li><a href="https://github.com/robandpdx"><code>@​robandpdx</code></a> made their first contribution in <a href="https://redirect.github.com/actions/github-script/pull/429">actions/github-script#429</a></li>
<li><a href="https://github.com/viktorlott"><code>@​viktorlott</code></a> made their first contribution in <a href="https://redirect.github.com/actions/github-script/pull/402">actions/github-script#402</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a href="https://github.com/actions/github-script/compare/v6.4.1...v7.0.0">https://github.com/actions/github-script/compare/v6.4.1...v7.0.0</a></p>
<h2>v6.4.1</h2>
<h2>What's Changed</h2>
<ul>
<li>Add <code>@​octokit/plugin-request-log</code>, to produce debug output for requests by <a href="https://github.com/mjpieters"><code>@​mjpieters</code></a> in <a href="https://redirect.github.com/actions/github-script/pull/358">actions/github-script#358</a></li>
<li>fix input handling by <a href="https://github.com/mjpieters"><code>@​mjpieters</code></a> in <a href="https://redirect.github.com/actions/github-script/pull/357">actions/github-script#357</a></li>
<li>Remove unused dependencies by <a href="https://github.com/mjpieters"><code>@​mjpieters</code></a> in <a href="https://redirect.github.com/actions/github-script/pull/356">actions/github-script#356</a></li>
<li>Default debug to current runner debug state by <a href="https://github.com/mjpieters"><code>@​mjpieters</code></a> in <a href="https://redirect.github.com/actions/github-script/pull/363">actions/github-script#363</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/mjpieters"><code>@​mjpieters</code></a> made their first contribution in <a href="https://redirect.github.com/actions/github-script/pull/358">actions/github-script#358</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a href="https://github.com/actions/github-script/compare/v6.4.0...v6.4.1">https://github.com/actions/github-script/compare/v6.4.0...v6.4.1</a></p>
<h2>v6.4.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Bump json5 from 2.1.3 to 2.2.3 by <a href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a href="https://redirect.github.com/actions/github-script/pull/319">actions/github-script#319</a></li>
<li>Bump minimatch from 3.0.4 to 3.1.2 by <a href="https://github.com/dependabot"><code>@​dependabot</code></a> in <a href="https://redirect.github.com/actions/github-script/pull/320">actions/github-script#320</a></li>
<li>Add node-fetch by <a href="https://github.com/danmichaelo"><code>@​danmichaelo</code></a> in <a href="https://redirect.github.com/actions/github-script/pull/321">actions/github-script#321</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/jongwooo"><code>@​jongwooo</code></a> made their first contribution in <a href="https://redirect.github.com/actions/github-script/pull/313">actions/github-script#313</a></li>
<li><a href="https://github.com/austinvazquez"><code>@​austinvazquez</code></a> made their first contribution in <a href="https://redirect.github.com/actions/github-script/pull/306">actions/github-script#306</a></li>
<li><a href="https://github.com/danmichaelo"><code>@​danmichaelo</code></a> made their first contribution in <a href="https://redirect.github.com/actions/github-script/pull/321">actions/github-script#321</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a href="https://github.com/actions/github-script/compare/v6.3.3...v6.4.0">https://github.com/actions/github-script/compare/v6.3.3...v6.4.0</a></p>
<h2>v6.3.3</h2>
<h2>What's Changed</h2>
<ul>
<li>Update <code>@actions/glob</code> to 0.3.0 by <a href="https://github.com/nineinchnick"><code>@​nineinchnick</code></a> in <a href="https://redirect.github.com/actions/github-script/pull/279">actions/github-script#279</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a href="https://github.com/nineinchnick"><code>@​nineinchnick</code></a> made their first contribution in <a href="https://redirect.github.com/actions/github-script/pull/279">actions/github-script#279</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a href="https://github.com/actions/github-script/compare/v6.3.2...v6.3.3">https://github.com/actions/github-script/compare/v6.3.2...v6.3.3</a></p>
<h2>v6.3.2</h2>
<h2>What's Changed</h2>
<ul>
<li>Update <code>@​actions/core</code> to 1.10.0 by <a href="https://github.com/rentziass"><code>@​rentziass</code></a> in <a href="https://redirect.github.com/actions/github-script/pull/295">actions/github-script#295</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a href="https://github.com/actions/github-script/commit/60a0d83039c74a4aee543508d2ffcb1c3799cdea"><code>60a0d83</code></a> Merge pull request <a href="https://redirect.github.com/actions/github-script/issues/440">#440</a> from actions/joshmgross/v7.0.1</li>
<li><a href="https://github.com/actions/github-script/commit/b7fb2001b410c9390cbe9e2c7d5cab7eefb7b29c"><code>b7fb200</code></a> Update version to 7.0.1</li>
<li><a href="https://github.com/actions/github-script/commit/12e22ed06b6cf302663f90a925045516e24c8bad"><code>12e22ed</code></a> Merge pull request <a href="https://redirect.github.com/actions/github-script/issues/439">#439</a> from actions/joshmgross/avoid-setting-base-url</li>
<li><a href="https://github.com/actions/github-script/commit/d319f8f5b55ea40916b869b2ca9ae2f059c60abf"><code>d319f8f</code></a> Avoid setting <code>baseUrl</code> to undefined when input is not provided</li>
<li><a href="https://github.com/actions/github-script/commit/e69ef5462fd455e02edcaf4dd7708eda96b9eda0"><code>e69ef54</code></a> Merge pull request <a href="https://redirect.github.com/actions/github-script/issues/425">#425</a> from actions/joshmgross/node-20</li>
<li><a href="https://github.com/actions/github-script/commit/ee0914b839b704083a749f8c77a73a9270674a32"><code>ee0914b</code></a> Update licenses</li>
<li><a href="https://github.com/actions/github-script/commit/d6fc56f33b017d55917d045da1a94efa2120f039"><code>d6fc56f</code></a> Use <code>@types/node</code> for Node 20</li>
<li><a href="https://github.com/actions/github-script/commit/384d6cf581452587d1e31f553c97ab165427124a"><code>384d6cf</code></a> Fix quotations in tests</li>
<li><a href="https://github.com/actions/github-script/commit/84724927e3e992f17768c17f57a47a85ea2a5160"><code>8472492</code></a> Only validate GraphQL <code>previews</code></li>
<li><a href="https://github.com/actions/github-script/commit/84903f51828821af5d03d3884c6ba699a2d44d6e"><code>84903f5</code></a> Remove <code>node-fetch</code> from type</li>
<li>Additional commits viewable in <a href="https://github.com/actions/github-script/compare/v6...v7">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/github-script&package-manager=github_actions&previous-version=6&new-version=7)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

You can trigger a rebase of this PR by commenting `@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)


</details>

> **Note**
> Automatic rebases have been disabled on this pull request as it has been open for over 30 days.


github-actions[bot]: <h1><a href="https://bencher.dev/"><img src="https://bencher.dev/favicon.svg" width="32" height="32" alt="🐰" /></a>Bencher</h1><table><tr><td>Report</td><td>Tue, September 10, 2024 at 10:18:10 UTC</td></tr><tr><td>Project</td><td><a href="https://bencher.dev/perf/nickel">nickel</a></td></tr><tr><td>Branch</td><td>2016/merge</td></tr><tr><td>Testbed</td><td>ubuntu-latest</td></tr></table><blockquote><p><b>⚠️ WARNING:</b> The following Measure does not have a Threshold. Without a Threshold, no Alerts will ever be generated!</p><ul><li>Latency (latency)</li></ul><p><a href="https://bencher.dev/console/projects/nickel/thresholds/add">Click here to create a new Threshold</a><br />For more information, see <a href="https://bencher.dev/docs/explanation/thresholds/">the Threshold documentation</a>.<br />To only post results if a Threshold exists, set <a href="https://bencher.dev/docs/explanation/bencher-run/#--ci-only-thresholds">the <code lang="rust">--ci-only-thresholds</code> CLI flag</a>.</p></blockquote><details><summary>Click to view all benchmark results</summary><table><tr><th>Benchmark</th><th>Latency</th><th>Latency Results<br/>nanoseconds (ns)</th></tr><tr><td>fibonacci 10</td><td>➖ (<a href="https://bencher.dev/perf/nickel?branches=1726432c-8890-40b4-9874-f0b7d12465c1&testbeds=66263ed0-9556-4385-9f30-26466ee26211&benchmarks=3cfaf19e-c9c5-4341-95cd-703456d0fc9f&measures=2b5b9371-696f-422d-b1a5-262ce8157b6b&start_time=1723371490000&end_time=1725963490000">view plot</a>)</td><td>507,370.00</td></tr><tr><td>pidigits 100</td><td>➖ (<a href="https://bencher.dev/perf/nickel?branches=1726432c-8890-40b4-9874-f0b7d12465c1&testbeds=66263ed0-9556-4385-9f30-26466ee26211&benchmarks=5be25c57-d46f-4b48-b6f0-dee01b84220a&measures=2b5b9371-696f-422d-b1a5-262ce8157b6b&start_time=1723371490000&end_time=1725963490000">view plot</a>)</td><td>3,194,300.00</td></tr><tr><td>product 30</td><td>➖ (<a href="https://bencher.dev/perf/nickel?branches=1726432c-8890-40b4-9874-f0b7d12465c1&testbeds=66263ed0-9556-4385-9f30-26466ee26211&benchmarks=2f5f7f6a-4e54-4542-aa09-da5671953fbb&measures=2b5b9371-696f-422d-b1a5-262ce8157b6b&start_time=1723371490000&end_time=1725963490000">view plot</a>)</td><td>789,630.00</td></tr><tr><td>scalar 10</td><td>➖ (<a href="https://bencher.dev/perf/nickel?branches=1726432c-8890-40b4-9874-f0b7d12465c1&testbeds=66263ed0-9556-4385-9f30-26466ee26211&benchmarks=211bb66a-58f8-488b-97e4-7ea032870038&measures=2b5b9371-696f-422d-b1a5-262ce8157b6b&start_time=1723371490000&end_time=1725963490000">view plot</a>)</td><td>1,469,900.00</td></tr><tr><td>sum 30</td><td>➖ (<a href="https://bencher.dev/perf/nickel?branches=1726432c-8890-40b4-9874-f0b7d12465c1&testbeds=66263ed0-9556-4385-9f30-26466ee26211&benchmarks=3f1710c5-ce75-4dfb-aadb-1435fed8a093&measures=2b5b9371-696f-422d-b1a5-262ce8157b6b&start_time=1723371490000&end_time=1725963490000">view plot</a>)</td><td>780,920.00</td></tr></table></details><br/><small><a href="https://bencher.dev">Bencher - Continuous Benchmarking</a></small><br/><small><a href="https://bencher.dev/perf/nickel">View Public Perf Page</a></small><br/><small><a href="https://bencher.dev/docs/">Docs</a> | <a href="https://bencher.dev/repo/">Repo</a> | <a href="https://bencher.dev/chat/">Chat</a> | <a href="https://bencher.dev/help/">Help</a></small><div id="bencher.dev/projects/nickel/id/2016-merge/ubuntu-latest/rust_criterion"></div>

Patch:
diff --git a/.github/workflows/benchmark-master.yaml b/.github/workflows/benchmark-master.yaml
index 9ad8053b..7209a3c5 100644
--- a/.github/workflows/benchmark-master.yaml
+++ b/.github/workflows/benchmark-master.yaml
@@ -10,7 +10,7 @@ jobs:
       - uses: actions/checkout@v4
         with:
           fetch-depth: 0
-      - uses: cachix/install-nix-action@V28
+      - uses: cachix/install-nix-action@v29
         name: "Installing Nix"
         with:
           extra_nix_config: |
diff --git a/.github/workflows/benchmark-pr.yaml b/.github/workflows/benchmark-pr.yaml
index e73e6180..1ff78f8d 100644
--- a/.github/workflows/benchmark-pr.yaml
+++ b/.github/workflows/benchmark-pr.yaml
@@ -22,7 +22,7 @@ jobs:
       - uses: actions/checkout@v4
         with:
           fetch-depth: 0
-      - uses: cachix/install-nix-action@V28
+      - uses: cachix/install-nix-action@v29
         name: "Installing Nix"
         with:
           extra_nix_config: |
diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml
index 1cb3dc86..a821a6f2 100644
--- a/.github/workflows/continuous-integration.yml
+++ b/.github/workflows/continuous-integration.yml
@@ -57,7 +57,7 @@ jobs:
         fetch-depth: 0
 
     - name: Installing Nix
-      uses: cachix/install-nix-action@V28
+      uses: cachix/install-nix-action@v29
       with:
         nix_path: nixpkgs=channel:nixos-unstable
         extra_nix_config: |
diff --git a/.github/workflows/release-artifacts.yaml b/.github/workflows/release-artifacts.yaml
index 1eea0323..b65b222e 100644
--- a/.github/workflows/release-artifacts.yaml
+++ b/.github/workflows/release-artifacts.yaml
@@ -92,7 +92,7 @@ jobs:
       - uses: actions/checkout@v4
         with:
           ref: ${{ github.event_name == 'release' && '' || github.event.inputs.release_tag }}
-      - uses: cachix/install-nix-action@V28
+      - uses: cachix/install-nix-action@v29
         name: "Installing Nix"
         with:
           extra_nix_config: |
diff --git a/.github/workflows/update-flake-lock.yml b/.github/workflows/update-flake-lock.yml
index a3ae724c..44dfe273 100644
--- a/.github/workflows/update-flake-lock.yml
+++ b/.github/workflows/update-flake-lock.yml
@@ -11,7 +11,7 @@ jobs:
       - name: Checkout repository
         uses: actions/checkout@v4
       - name: Install Nix
-        uses: cachix/install-nix-action@V28
+        uses: cachix/install-nix-action@v29
         with:
           extra_nix_config: |
             access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/upload-pr-benchmarks.yaml b/.github/workflows/upload-pr-benchmarks.yaml
index 7694b882..a0e37ae1 100644
--- a/.github/workflows/upload-pr-benchmarks.yaml
+++ b/.github/workflows/upload-pr-benchmarks.yaml
@@ -15,7 +15,7 @@ jobs:
       PR_EVENT: event.json
     steps:
       - name: Download Benchmark Results
-        uses: actions/github-script@v6
+        uses: actions/github-script@v7
         with:
           script: |
             async function downloadArtifact(artifactName) {
@@ -46,7 +46,7 @@ jobs:
           unzip $BENCHMARK_RESULTS.zip
           unzip $PR_EVENT.zip
       - name: Export PR Event Data
-        uses: actions/github-script@v6
+        uses: actions/github-script@v7
         with:
           script: |
             let fs = require('fs');
diff --git a/Cargo.lock b/Cargo.lock
index 2592cc09..6ed1258e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1736,6 +1736,7 @@ version = "1.8.0"
 dependencies = [
  "clap 4.5.2",
  "clap_complete",
+ "comrak",
  "directories",
  "git-version",
  "insta",
@@ -1743,6 +1744,8 @@ dependencies = [
  "metrics-util",
  "nickel-lang-core",
  "nickel-lang-utils",
+ "once_cell",
+ "regex",
  "serde",
  "serde_json",
  "tempfile",
diff --git a/cli/Cargo.toml b/cli/Cargo.toml
index d8623a41..be944961 100644
--- a/cli/Cargo.toml
+++ b/cli/Cargo.toml
@@ -18,7 +18,7 @@ bench = false
 [features]
 default = ["repl", "doc", "format", "spanned-deser"]
 repl = ["nickel-lang-core/repl"]
-doc = ["nickel-lang-core/doc"]
+doc = ["nickel-lang-core/doc", "comrak"]
 format = ["nickel-lang-core/format", "dep:tempfile"]
 spanned-deser = ["nickel-lang-core/spanned-deser"]
 metrics = ["dep:metrics", "dep:metrics-util", "nickel-lang-core/metrics"]
@@ -39,6 +39,10 @@ clap_complete = { workspace = true }
 metrics = { workspace = true, optional = true }
 metrics-util = { workspace = true, optional = true }
 
+comrak = { workspace = true, optional = true }
+once_cell.workspace = true
+regex.workspace = true
+
 [dev-dependencies]
 nickel-lang-utils.workspace = true
 test-generator.workspace = true
diff --git a/cli/src/cli.rs b/cli/src/cli.rs
index 21237af4..37339f2a 100644
--- a/cli/src/cli.rs
+++ b/cli/src/cli.rs
@@ -13,7 +13,7 @@ use nickel_lang_core::error::report::ErrorFormat;
 use crate::repl::ReplCommand;
 
 #[cfg(feature = "doc")]
-use crate::doc::DocCommand;
+use crate::{doc::DocCommand, doctest::TestCommand};
 
 #[cfg(feature = "format")]
 use crate::format::FormatCommand;
@@ -63,7 +63,7 @@ pub struct GlobalOptions {
 /// Available subcommands.
 #[derive(clap::Subcommand, Debug)]
 pub enum Command {
-    /// Evaluate a Nickel program and pretty-print the result.
+    /// Evaluates a Nickel program and pretty-prints the result.
     Eval(EvalCommand),
     /// Converts the parsed representation (AST) back to Nickel source code and
     /// prints it. Used for debugging purpose
@@ -72,7 +72,7 @@ pub enum Command {
     Export(ExportCommand),
     /// Prints the metadata attached to an attribute, given as a path
     Query(QueryCommand),
-    /// Typechecks the program but do not run it
+    /// Typechecks the program but does not run it
     Typecheck(TypecheckCommand),
     /// Starts a REPL session
     #[cfg(feature = "repl")]
@@ -80,6 +80,9 @@ pub enum Command {
     /// Generates the documentation files for the specified nickel file
     #[cfg(feature = "doc")]
     Doc(DocCommand),
+    /// Tests the documentation examples in the specified nickel file
+    #[cfg(feature = "doc")]
+    Test(TestCommand),
     /// Format Nickel files
     #[cfg(feature = "format")]
     Format(FormatCommand),
diff --git a/cli/src/error.rs b/cli/src/error.rs
index ea0fea03..28de2308 100644
--- a/cli/src/error.rs
+++ b/cli/src/error.rs
@@ -59,6 +59,7 @@ pub enum Error {
     ///
     /// Upon receiving this error, the caller should simply exit without proceeding with evaluation.
     CustomizeInfoPrinted,
+    FailedTests,
 }
 
 impl IntoDiagnostics<FileId> for CliUsageError {
@@ -258,6 +259,7 @@ impl Error {
             #[cfg(feature = "format")]
             Error::Format { error } => report_standalone("format error", Some(error.to_string())),
             Error::CliUsage { error, mut program } => program.report(error, format),
+            Error::FailedTests => report_standalone("tests failed", None),
             Error::CustomizeInfoPrinted => {
                 // Nothing to do, the caller should simply exit.
             }
diff --git a/cli/src/main.rs b/cli/src/main.rs
index 18df7356..387c32c0 100644
--- a/cli/src/main.rs
+++ b/cli/src/main.rs
@@ -2,6 +2,8 @@
 
 #[cfg(feature = "doc")]
 mod doc;
+#[cfg(feature = "doc")]
+mod doctest;
 #[cfg(feature = "format")]
 mod format;
 #[cfg(feature = "metrics")]
@@ -48,6 +50,8 @@ fn main() -> ExitCode {
 
         #[cfg(feature = "doc")]
         Command::Doc(doc) => doc.run(opts.global),
+        #[cfg(feature = "doc")]
+        Command::Test(test) => test.run(opts.global),
 
         #[cfg(feature = "format")]
         Command::Format(format) => format.run(opts.global),
diff --git a/core/src/cache.rs b/core/src/cache.rs
index e120f110..b8afa375 100644
--- a/core/src/cache.rs
+++ b/core/src/cache.rs
@@ -1,5 +1,6 @@
 //! Source cache.
 
+use crate::closurize::Closurize as _;
 use crate::error::{Error, ImportError, ParseError, ParseErrors, TypecheckError};
 use crate::eval::cache::Cache as EvalCache;
 use crate::eval::Closure;
@@ -231,6 +232,8 @@ pub enum EntryState {
     Transforming,
     /// The entry and its transitive imports have been transformed.
     Transformed,
+    /// The entry has been closurized.
+    Closurized,
 }
 
 pub enum EntryOrigin {}
@@ -705,6 +708,43 @@ impl Cache {
         }
     }
 
+    /// Applies a custom transform to an input and its imports, leaving them
+    /// in the same state as before. Requires that the input has been parsed.
+    /// In order for the transform to apply to imports, they need to have been
+    /// resolved.
+    pub fn custom_transform<E>(
+        &mut self,
+        file_id: FileId,
+        transform: &mut impl FnMut(&mut Cache, RichTerm) -> Result<RichTerm, E>,
+    ) -> Result<(), CacheError<E>> {
+        match self.entry_state(file_id) {
+            Some(state) if state >= EntryState::Parsed => {
+                if state < EntryState::Transforming {
+                    let cached_term = self.terms.remove(&file_id).unwrap();
+                    let term = transform(self, cached_term.term)?;
+                    self.terms.insert(
+                        file_id,
+                        TermEntry {
+                            term,
+                            state: EntryState::Transforming,
+                            ..cached_term
+                        },
+                    );
+
+                    if let Some(imports) = self.imports.get(&file_id).cloned() {
+                        for f in imports.into_iter() {
+                            self.custom_transform(f, transform)?;
+                        }
+                    }
+                    // TODO: We're setting the state back to whatever it was.
+                    self.update_state(file_id, state);
+                }
+                Ok(())
+            }
+            _ => Err(CacheError::NotParsed),
+        }
+    }
+
     /// Apply program transformations to all the fields of a record.
     ///
     /// Used to transform stdlib modules and other records loaded in the environment, when using
@@ -811,6 +851,47 @@ impl Cache {
         }
     }
 
+    /// Replace a cache entry by a closurized version of itself. If it contains imports,
+    /// closurize them recursively.
+    ///
+    /// Closurization is not required before evaluation, but it has two benefits:
+    /// - the closurized term uses the evaluation cache, so if it is imported in multiple
+    ///   places then they will share a cache
+    /// - the eval cache's built-in mechanism for preventing infinite recursion will also
+    ///   apply to recursive imports.
+    ///
+    /// The main disadvantage of closurization is that it makes the AST less useful. You
+    /// wouldn't want to closurize before pretty-printing, for example.
+    pub fn closurize<C: eval::cache::Cache>(
+        &mut self,
+        file_id: FileId,
+        cache: &mut C,
+    ) -> Result<CacheOp<()>, CacheError<()>> {
+        match self.entry_state(file_id) {
+            Some(state) if state >= EntryState::Closurized => Ok(CacheOp::Cached(())),
+            Some(state) if state >= EntryState::Parsed => {
+                let cached_term = self.terms.remove(&file_id).unwrap();
+                let term = cached_term.term.closurize(cache, eval::Environment::new());
+                self.terms.insert(
+                    file_id,
+                    TermEntry {
+                        term,
+                        state: EntryState::Closurized,
+                        ..cached_term
+                    },
+                );
+
+                if let Some(imports) = self.imports.get(&file_id).cloned() {
+                    for f in imports.into_iter() {
+                        self.closurize(f, cache)?;
+                    }
+                }
+                Ok(CacheOp::Done(()))
+            }
+            _ => Err(CacheError::NotParsed),
+        }
+    }
+
     /// Resolve every imports of an entry of the cache, and update its state accordingly, or do
     /// nothing if the imports of the entry have already been resolved. Require that the
     /// corresponding source has been parsed.
@@ -895,7 +976,8 @@ impl Cache {
                 | EntryState::Typechecking
                 | EntryState::Typechecked
                 | EntryState::Transforming
-                | EntryState::Transformed,
+                | EntryState::Transformed
+                | EntryState::Closurized,
             ) => Ok(CacheOp::Cached((Vec::new(), Vec::new()))),
             None => Err(CacheError::NotParsed),
         }
diff --git a/core/src/error/mod.rs b/core/src/error/mod.rs
index faef695e..0d952e1e 100644
--- a/core/src/error/mod.rs
+++ b/core/src/error/mod.rs
@@ -894,7 +894,7 @@ impl ParseError {
 }
 
 pub const INTERNAL_ERROR_MSG: &str =
-    "This error should not happen. This is likely a bug in the Nickel interpreter. Please consider\
+    "This error should not happen. This is likely a bug in the Nickel interpreter. Please consider \
  reporting it at https://github.com/tweag/nickel/issues with the above error message.";
 
 /// A trait for converting an error to a diagnostic.
diff --git a/core/src/error/report.rs b/core/src/error/report.rs
index 501f032b..0ad25fd6 100644
--- a/core/src/error/report.rs
+++ b/core/src/error/report.rs
@@ -78,6 +78,30 @@ pub fn report<E: IntoDiagnostics<FileId>>(
     )
 }
 
+/// Pretty-print an error on stdout.
+///
+/// # Arguments
+///
+/// - `cache` is the file cache used during the evaluation, which is required by the reporting
+/// infrastructure to point at specific locations and print snippets when needed.
+pub fn report_to_stdout<E: IntoDiagnostics<FileId>>(
+    cache: &mut Cache,
+    error: E,
+    format: ErrorFormat,
+    color_opt: ColorOpt,
+) {
+    use std::io::{stdout, IsTerminal};
+
+    let stdlib_ids = cache.get_all_stdlib_modules_file_id();
+    report_with(
+        &mut StandardStream::stdout(color_opt.for_terminal(stdout().is_terminal())).lock(),
+        cache.files_mut(),
+        stdlib_ids.as_ref(),
+        error,
+        format,
+    )
+}
+
 /// Report an error on `stderr`, provided a file database and a list of stdlib file ids.
 pub fn report_with<E: IntoDiagnostics<FileId>>(
     writer: &mut dyn WriteColor,
diff --git a/core/src/eval/mod.rs b/core/src/eval/mod.rs
index e5a52e91..48907e75 100644
--- a/core/src/eval/mod.rs
+++ b/core/src/eval/mod.rs
@@ -1022,6 +1022,11 @@ impl<C: Cache> VirtualMachine<ImportCache, C> {
             type_ctxt,
         } = self.import_resolver.prepare_stdlib(&mut self.cache)?;
         self.import_resolver.prepare(main_id, &type_ctxt)?;
+        // Unwrap: closurization only fails if the input wasn't parsed, and we just
+        // parsed it.
+        self.import_resolver
+            .closurize(main_id, &mut self.cache)
+            .unwrap();
         self.initial_env = eval_env;
         Ok(self.import_resolver().get(main_id).unwrap())
     }
diff --git a/core/src/program.rs b/core/src/program.rs
index 63e93ad4..8c932bd9 100644
--- a/core/src/program.rs
+++ b/core/src/program.rs
@@ -22,8 +22,9 @@
 //! Each such value is added to the initial environment before the evaluation of the program.
 use crate::{
     cache::*,
+    closurize::Closurize as _,
     error::{
-        report::{report, ColorOpt, ErrorFormat},
+        report::{report, report_to_stdout, report_with, ColorOpt, ErrorFormat},
         Error, EvalError, IOError, IntoDiagnostics, ParseError,
     },
     eval::{cache::Cache as EvalCache, Closure, VirtualMachine},
@@ -31,18 +32,21 @@ use crate::{
     label::Label,
     metrics::increment,
     term::{
-        make as mk_term, make::builder, record::Field, BinaryOp, MergePriority, RichTerm, Term,
+        make::{self as mk_term, builder},
+        record::Field,
+        BinaryOp, MergePriority, RichTerm, Term,
     },
 };
 
+use clap::ColorChoice;
 use codespan::FileId;
-use codespan_reporting::term::termcolor::Ansi;
+use codespan_reporting::term::termcolor::{Ansi, NoColor, WriteColor};
 use std::path::PathBuf;
 
 use std::{
     ffi::OsString,
     fmt,
-    io::{self, Cursor, Read, Write},
+    io::{self, Read, Write},
     result::Result,
 };
 
@@ -389,6 +393,23 @@ impl<EC: EvalCache> Program<EC> {
             .clone())
     }
 
+    /// Applies a custom transformation to the main term, assuming that it has been parsed but not
+    /// yet transformed.
+    ///
+    /// The term is left in whatever state it started in.
+    ///
+    /// This state-management isn't great, as it breaks the usual linear order of state changes.
+    /// In particular, there's no protection against double-applying the same transformation, and no
+    /// protection against applying it to a term that's in an unexpected state.
+    pub fn custom_transform<E, F>(&mut self, mut transform: F) -> Result<(), CacheError<E>>
+    where
+        F: FnMut(&mut Cache, RichTerm) -> Result<RichTerm, E>,
+    {
+        self.vm
+            .import_resolver_mut()
+            .custom_transform(self.main_id, &mut transform)
+    }
+
     /// Retrieve the parsed term, typecheck it, and generate a fresh initial environment. If
     /// `self.overrides` isn't empty, generate the required merge parts and return a merge
     /// expression including the overrides. Extract the field corresponding to `self.field`, if not
@@ -456,6 +477,15 @@ impl<EC: EvalCache> Program<EC> {
         Ok(self.vm.eval_closure(prepared)?.body)
     }
 
+    /// Evaluate a closure using the same virtual machine (and import resolver)
+    /// as the main term. The closure should already have been prepared for
+    /// evaluation, with imports resolved and any necessary transformations
+    /// applied.
+    pub fn eval_closure(&mut self, closure: Closure) -> Result<RichTerm, EvalError> {
+        self.vm.reset();
+        Ok(self.vm.eval_closure(closure)?.body)
+    }
+
     /// Same as `eval`, but proceeds to a full evaluation.
     pub fn eval_full(&mut self) -> Result<RichTerm, Error> {
         let prepared = self.prepare_eval()?;
@@ -533,6 +563,14 @@ impl<EC: EvalCache> Program<EC> {
         report(self.vm.import_resolver_mut(), error, format, self.color_opt)
     }
 
+    /// Wrapper for [`report_to_stdout`].
+    pub fn report_to_stdout<E>(&mut self, error: E, format: ErrorFormat)
+    where
+        E: IntoDiagnostics<FileId>,
+    {
+        report_to_stdout(self.vm.import_resolver_mut(), error, format, self.color_opt)
+    }
+
     /// Build an error report as a string and return it.
     pub fn report_as_str<E>(&mut self, error: E) -> String
     where
@@ -540,19 +578,27 @@ impl<EC: EvalCache> Program<EC> {
     {
         let cache = self.vm.import_resolver_mut();
         let stdlib_ids = cache.get_all_stdlib_modules_file_id();
-        let diagnostics = error.into_diagnostics(cache.files_mut(), stdlib_ids.as_ref());
-        let mut buffer = Ansi::new(Cursor::new(Vec::new()));
-        let config = codespan_reporting::term::Config::default();
-        // write to `buffer`
-        diagnostics
-            .iter()
-            .try_for_each(|d| {
-                codespan_reporting::term::emit(&mut buffer, &config, cache.files_mut(), d)
-            })
-            // safe because writing to a cursor in memory
-            .unwrap();
-        // unwrap(): emit() should only print valid utf8 to the the buffer
-        String::from_utf8(buffer.into_inner().into_inner()).unwrap()
+
+        let mut buffer = Vec::new();
+        let mut with_color;
+        let mut no_color;
+        let writer: &mut dyn WriteColor = if self.color_opt.0 == ColorChoice::Never {
+            no_color = NoColor::new(&mut buffer);
+            &mut no_color
+        } else {
+            with_color = Ansi::new(&mut buffer);
+            &mut with_color
+        };
+
+        report_with(
+            writer,
+            cache.files_mut(),
+            stdlib_ids.as_ref(),
+            error,
+            ErrorFormat::Text,
+        );
+        // unwrap(): report_with() should only print valid utf8 to the the buffer
+        String::from_utf8(buffer).unwrap()
     }
 
     /// Evaluate a program into a record spine, a form suitable for extracting the general
@@ -601,6 +647,33 @@ impl<EC: EvalCache> Program<EC> {
     /// [crate::error::EvalError::MissingFieldDef] errors are _ignored_: if this is encountered
     /// when evaluating a field, this field is just left as it is and the evaluation proceeds.
     pub fn eval_record_spine(&mut self) -> Result<RichTerm, Error> {
+        self.maybe_closurized_eval_record_spine(false)
+    }
+
+    /// Evaluate a program into a record spine, while closurizing all the
+    /// non-record "leaves" in the spine.
+    ///
+    /// To understand the difference between this function and
+    /// [`Program::eval_record_spine`], consider a term like
+    ///
+    /// ```nickel
+    /// let foo = 1 in { bar = [foo] }
+    /// ```
+    ///
+    /// `eval_record_spine` will evaluate this into a record containing the
+    /// field `bar`, and the value of that field will be a `Term::Array`
+    /// containing a `Term::Var("foo")`. In contrast, `eval_closurized` will
+    /// still evaluate the term into a record contining `bar`, but the value of
+    /// that field will be a `Term::Closure` containing that same `Term::Array`,
+    /// together with an `Environment` defining the variable "foo". In
+    /// particular, the closurized version is more useful if you intend to
+    /// further evaluate any record fields, while the non-closurized version is
+    /// more useful if you intend to do further static analysis.
+    pub fn eval_closurized_record_spine(&mut self) -> Result<RichTerm, Error> {
+        self.maybe_closurized_eval_record_spine(true)
+    }
+
+    fn maybe_closurized_eval_record_spine(&mut self, closurize: bool) -> Result<RichTerm, Error> {
         use crate::eval::Environment;
         use crate::match_sharedterm;
         use crate::term::{record::RecordData, RuntimeContract};
@@ -613,12 +686,13 @@ impl<EC: EvalCache> Program<EC> {
             vm: &mut VirtualMachine<Cache, EC>,
             mut pending_contracts: Vec<RuntimeContract>,
             current_env: Environment,
+            closurize: bool,
         ) -> Result<Vec<RuntimeContract>, Error> {
             vm.reset();
 
             for ctr in pending_contracts.iter_mut() {
                 let rt = ctr.contract.clone();
-                ctr.contract = do_eval(vm, rt, current_env.clone())?;
+                ctr.contract = do_eval(vm, rt, current_env.clone(), closurize)?;
             }
 
             Ok(pending_contracts)
@@ -628,6 +702,7 @@ impl<EC: EvalCache> Program<EC> {
             vm: &mut VirtualMachine<Cache, EC>,
             term: RichTerm,
             env: Environment,
+            closurize: bool,
         ) -> Result<RichTerm, Error> {
             vm.reset();
             let result = vm.eval_closure(Closure {
@@ -660,12 +735,15 @@ impl<EC: EvalCache> Program<EC> {
                                 Field {
                                     value: field
                                         .value
-                                        .map(|value| do_eval(vm, value, result.env.clone()))
+                                        .map(|value| {
+                                            do_eval(vm, value, result.env.clone(), closurize)
+                                        })
                                         .transpose()?,
                                     pending_contracts: eval_contracts(
                                         vm,
                                         field.pending_contracts,
                                         result.env.clone(),
+                                        closurize,
                                     )?,
                                     ..field
                                 },
@@ -678,11 +756,16 @@ impl<EC: EvalCache> Program<EC> {
                         result.body.pos,
                     ))
                 }
-                _ => Ok(result.body),
+                _ =>
+                    if closurize {
+                        Ok(result.body.closurize(&mut vm.cache, result.env))
+                    } else {
+                        Ok(result.body)
+                    },
             })
         }
 
-        do_eval(&mut self.vm, prepared.body, prepared.env)
+        do_eval(&mut self.vm, prepared.body, prepared.env, closurize)
     }
 
     /// Extract documentation from the program
@@ -872,6 +955,31 @@ mod doc {
                 }
             }
         }
+
+        pub fn docstrings(&self) -> Vec<(Vec<&str>, &str)> {
+            fn collect<'a>(
+                slf: &'a ExtractedDocumentation,
+                path: &[&'a str],
+                acc: &mut Vec<(Vec<&'a str>, &'a str)>,
+            ) {
+                for (name, field) in &slf.fields {
+                    let mut path = path.to_owned();
+                    path.push(name);
+
+                    if let Some(fields) = &field.fields {
+                        collect(fields, &path, acc);
+                    }
+
+                    if let Some(doc) = &field.documentation {
+                        acc.push((path, doc));
+                    }
+                }
+            }
+
+            let mut ret = Vec::new();
+            collect(self, &[], &mut ret);
+            ret
+        }
     }
 
     /// Parses a string into markdown and increases any headers in the markdown by the specified
diff --git a/core/src/typecheck/mod.rs b/core/src/typecheck/mod.rs
index f8f1d06f..5088f8e8 100644
--- a/core/src/typecheck/mod.rs
+++ b/core/src/typecheck/mod.rs
@@ -1372,7 +1372,7 @@ pub struct TypeTables {
 /// Typecheck a term.
 ///
 /// Return the inferred type in case of success. This is just a wrapper that calls
-/// `type_check_linearize` with a blanket implementation for the visitor.
+/// `type_check_with_visitor` with a blanket implementation for the visitor.
 ///
 /// Note that this function doesn't recursively typecheck imports (anymore), but just the current
 /// file. It however still needs the resolver to get the apparent type of imports.
diff --git a/core/stdlib/std.ncl b/core/stdlib/std.ncl
index 4be3ef33..aee41af5 100644
--- a/core/stdlib/std.ncl
+++ b/core/stdlib/std.ncl
@@ -52,11 +52,12 @@
 
           # Examples
 
-          ```nickel
-          ([] | std.array.NonEmpty) =>
-            error
-          ([ 1 ] | std.array.NonEmpty) =>
-            [ 1 ]
+          ```nickel multiline
+          ([] | std.array.NonEmpty)
+          # => error: contract broken by a value
+
+          ([ 1 ] | std.array.NonEmpty)
+          # => [ 1 ]
           ```
         "%
       =
@@ -79,8 +80,8 @@
           # Examples
 
           ```nickel
-          std.array.first [ "this is the head", "this is not" ] =>
-            "this is the head"
+          std.array.first [ "this is the head", "this is not" ]
+          # => "this is the head"
           ```
         "%
       = fun array => %array/at% array 0,
@@ -94,8 +95,8 @@
           # Examples
 
           ```nickel
-          std.array.last [ "this is the head", "this is not" ] =>
-            "this is not"
+          std.array.last [ "this is the head", "this is not" ]
+          # => "this is not"
           ```
         "%
       = fun array => %array/at% array (%array/length% array - 1),
@@ -109,8 +110,8 @@
           # Examples
 
           ```nickel
-          std.array.drop_first [ 1, 2, 3 ] =>
-            [ 2, 3 ]
+          std.array.drop_first [ 1, 2, 3 ]
+          # => [ 2, 3 ]
           ```
         "%
       = fun array => %array/slice% 1 (%array/length% array) array,
@@ -124,8 +125,8 @@
           # Examples
 
           ```nickel
-          std.array.drop_last [ 1, 2, 3 ] =>
-            [ 1, 2 ]
+          std.array.drop_last [ 1, 2, 3 ]
+          # => [ 1, 2 ]
           ```
         "%
       = fun array => %array/slice% 0 (%array/length% array - 1) array,
@@ -138,8 +139,8 @@
           # Examples
 
           ```nickel
-          std.array.length [ "Hello,", " World!" ] =>
-            2
+          std.array.length [ "Hello,", " World!" ]
+          # => 2
           ```
         "%
       = fun l => %array/length% l,
@@ -153,8 +154,8 @@
           # Examples
 
           ```nickel
-          std.array.map (fun x => x + 1) [ 1, 2, 3 ] =>
-            [ 2, 3, 4 ]
+          std.array.map (fun x => x + 1) [ 1, 2, 3 ]
+          # => [ 2, 3, 4 ]
           ```
         "%
       = fun f l => %array/map% l f,
@@ -168,8 +169,8 @@
           # Examples
 
           ```nickel
-          std.array.at 3 [ "zero", "one", "two", "three", "four" ] =>
-            "three"
+          std.array.at 3 [ "zero", "one", "two", "three", "four" ]
+          # => "three"
           ```
         "%
       = fun n l => %array/at% l n,
@@ -184,12 +185,12 @@
 
           # Examples
 
-          ```nickel
-          std.array.at_or 3 "default" [ "zero", "one", "two", "three" ] =>
-            "three"
+          ```nickel multiline
+          std.array.at_or 3 "default" [ "zero", "one", "two", "three" ]
+          # => "three"
 
-          std.array.at_or 3 "default" [ "zero", "one" ] =>
-            "default"
+          std.array.at_or 3 "default" [ "zero", "one" ]
+          # => "default"
           ```
         "%
       = fun n default_value array =>
@@ -206,8 +207,8 @@
           # Examples
 
           ```nickel
-            std.array.concat [ 1, 2, 3 ] [ 4, 5, 6 ] =>
-              [ 1, 2, 3, 4, 5, 6 ]
+            std.array.concat [ 1, 2, 3 ] [ 4, 5, 6 ]
+            # => [ 1, 2, 3, 4, 5, 6 ]
           ```
         "%
       = fun l1 l2 => l1 @ l2,
@@ -236,9 +237,12 @@
 
             ```nickel
             std.array.fold_right (-) 0 [1, 2, 3, 4]
-             => -2
+            # => -2
+            ```
+
+            ```nickel
             std.array.fold_left (-) 0 [1, 2, 3, 4]
-             => -10
+            # => -10
             ```
           - If the folded function is associative, both `fold_right` and
             `fold_left` return the same result. In that case, **`fold_left` is
@@ -272,9 +276,9 @@
           # Examples
 
           ```nickel
-          fold_left (fun acc e => acc + e) 0 [ 1, 2, 3 ] =>
-            (((0 + 1) + 2) 3) =>
-            6
+          fold_left (fun acc e => acc + e) 0 [ 1, 2, 3 ]
+          # => (((0 + 1) + 2) + 3)
+          # => 6
           ```
         "%
       = fun f acc array =>
@@ -323,8 +327,8 @@
               }
           in
           let even = fun x => x % 2 == 0 in
-          find_first even [1, 3, 4, 5, 2] =>
-            'Some 4
+          find_first even [1, 3, 4, 5, 2]
+          # => 'Some 4
           ```
         "%
       = fun f acc array =>
@@ -366,9 +370,9 @@
           # Examples
 
           ```nickel
-          std.array.fold_right (fun e acc => acc @ [e]) [] [ 1, 2, 3 ] =>
-            ((([] @ [3]) @ [2]) @ [1]) =>
-            [ 3, 2, 1 ]
+          std.array.fold_right (fun e acc => acc @ [e]) [] [ 1, 2, 3 ]
+          # => ((([] @ [3]) @ [2]) @ [1])
+          # => [ 3, 2, 1 ]
           ```
         "%
       = fun f fst l =>
@@ -389,8 +393,8 @@
           # Examples
 
           ```nickel
-          std.array.prepend 1 [ 2, 3 ] =>
-            [ 1, 2, 3 ]
+          std.array.prepend 1 [ 2, 3 ]
+          # => [ 1, 2, 3 ]
           ```
         "%
       = fun x l => [x] @ l,
@@ -403,8 +407,8 @@
           # Examples
 
           ```nickel
-          std.array.append 3 [ 1, 2 ] =>
-            [ 1, 2, 3 ]
+          std.array.append 3 [ 1, 2 ]
+          # => [ 1, 2, 3 ]
           ```
         "%
       = fun x l => l @ [x],
@@ -417,8 +421,8 @@
           # Examples
 
           ```nickel
-          std.array.reverse [ 1, 2, 3 ] =>
-            [ 3, 2, 1 ]
+          std.array.reverse [ 1, 2, 3 ]
+          # => [ 3, 2, 1 ]
           ```
         "%
       = fun l => fold_left (fun acc e => [e] @ acc) [] l,
@@ -431,8 +435,8 @@
           # Examples
 
           ```nickel
-          std.array.filter (fun x => x <= 3) [ 4, 3, 2, 5, 1 ] =>
-            [ 3, 2, 1 ]
+          std.array.filter (fun x => x <= 3) [ 4, 3, 2, 5, 1 ]
+          # => [ 3, 2, 1 ]
           ```
         "%
       = fun pred l => fold_left (fun acc x => if pred x then acc @ [x] else acc) [] l,
@@ -445,8 +449,8 @@
           # Examples
 
           ```nickel
-          std.array.flatten [[1, 2], [3, 4]] =>
-            [1, 2, 3, 4]
+          std.array.flatten [[1, 2], [3, 4]]
+          # => [1, 2, 3, 4]
           ```
         "%
       = fun l => fold_right (fun l acc => l @ acc) [] l,
@@ -459,11 +463,12 @@
 
           # Examples
 
-          ```nickel
-          std.array.all (fun x => x < 3) [ 1, 2 ] =>
-            true
-          std.array.all (fun x => x < 3) [ 1, 2, 3 ] =>
-            false
+          ```nickel multiline
+          std.array.all (fun x => x < 3) [ 1, 2 ]
+          # => true
+
+          std.array.all (fun x => x < 3) [ 1, 2, 3 ]
+          # => false
           ```
         "%
       = fun pred l => fold_right (fun x acc => if pred x then acc else false) true l,
@@ -476,11 +481,12 @@
 
           # Examples
 
-          ```nickel
-          std.array.any (fun x => x < 3) [ 1, 2, 3, 4 ] =>
-            true
-          std.array.any (fun x => x < 3) [ 5, 6, 7, 8 ] =>
-            false
+          ```nickel multiline
+          std.array.any (fun x => x < 3) [ 1, 2, 3, 4 ]
+          # => true
+
+          std.array.any (fun x => x < 3) [ 5, 6, 7, 8 ]
+          # => false
           ```
         "%
       = fun pred l => fold_right (fun x acc => if pred x then true else acc) false l,
@@ -505,8 +511,8 @@
           # Examples
 
           ```nickel
-          std.array.elem 3 [ 1, 2, 3, 4, 5 ] =>
-            true
+          std.array.elem 3 [ 1, 2, 3, 4, 5 ]
+          # => true
           ```
         "%
       = fun elt => any (fun x => x == elt),
@@ -521,8 +527,8 @@
           # Examples
 
           ```nickel
-          std.array.partition (fun x => x < 5) [ 2, 4, 5, 3, 7, 8, 6 ] =>
-            { right = [ 2, 4, 3 ], wrong = [ 5, 7, 8, 6 ] }
+          std.array.partition (fun x => x < 5) [ 2, 4, 5, 3, 7, 8, 6 ]
+          # => { right = [ 2, 4, 3 ], wrong = [ 5, 7, 8, 6 ] }
           ```
         "%
       = fun pred l =>
@@ -545,8 +551,8 @@
           # Examples
 
           ```nickel
-          std.array.generate (fun x => x * x) 4 =>
-            [ 0, 1, 4, 9 ]
+          std.array.generate (fun x => x * x) 4
+          # => [ 0, 1, 4, 9 ]
           ```
         "%
       = fun f n => %array/generate% n f,
@@ -559,11 +565,12 @@
 
           # Examples
 
-          ```nickel
-          std.array.compare std.number.compare [3, 1, 4] [6, 2, 8] =>
-            'Lesser
-          std.array.compare std.string.compare ["hello", "world"] ["hello"] =>
-            'Greater
+          ```nickel multiline
+          std.array.compare std.number.compare [3, 1, 4] [6, 2, 8]
+          # => 'Lesser
+
+          std.array.compare std.string.compare ["hello", "world"] ["hello"]
+          # => 'Greater
           ```
         "%
       =
@@ -595,7 +602,7 @@
             else
               'Greater)
             [ 4, 5, 1, 2 ]
-          => [ 1, 2, 4, 5 ]
+          # => [ 1, 2, 4, 5 ]
           ```
         "%
       #TODO: maybe inline partition to avoid contract checks?
@@ -619,7 +626,7 @@
 
           ```nickel
           std.array.flat_map (fun x => [x, x]) [1, 2, 3]
-            => [1, 1, 2, 2, 3, 3]
+          # => [1, 1, 2, 2, 3, 3]
           ```
         "%
       = fun f xs => map f xs |> flatten,
@@ -631,13 +638,15 @@
 
           # Examples
 
-          ```nickel
+          ```nickel multiline
           std.array.intersperse ", " [ "Hello", "wonderful", "world!" ]
-            => [ "Hello", ", ", "wonderful", ", ", "world!" ]
+          # => [ "Hello", ", ", "wonderful", ", ", "world!" ]
+
           std.array.intersperse ", " [ "Hello" ]
-            => [ "Hello" ]
+          # => [ "Hello" ]
+
           std.array.intersperse ", " []
-            => []
+          # => []
           ```
         "%
       = fun v array =>
@@ -663,13 +672,15 @@
 
           # Examples
 
-          ```nickel
+          ```nickel multiline
           std.array.slice 1 3 [ 0, 1, 2, 3, 4, 5]
-            => [ 1, 2 ]
+          # => [ 1, 2 ]
+
           std.array.slice 0 3 [ "Hello", "world", "!" ]
-            => [ "Hello", "world", "!" ]
+          # => [ "Hello", "world", "!" ]
+
           std.array.slice 2 3 [ "Hello", "world", "!" ]
-            => [ "!" ]
+          # => [ "!" ]
            ```
         "%
       = fun start end value => %array/slice% start end value,
@@ -689,13 +700,15 @@
 
           # Examples
 
-          ```nickel
+          ```nickel multiline
           std.array.split_at 2 [ 0, 1, 2, 3, 4, 5]
-            => { left = [ 0, 1 ], right = [ 2, 3, 4, 5 ] }
+          # => { left = [ 0, 1 ], right = [ 2, 3, 4, 5 ] }
+
           std.array.split_at 0 [ "Hello", "world", "!" ]
-            => { left = [  ], right = [ "Hello", "world", "!" ] }
+          # => { left = [  ], right = [ "Hello", "world", "!" ] }
+
           std.array.split_at 3 [ "Hello", "world", "!" ]
-            => { left = [ "Hello", "world", "!" ], right = [  ] }
+          # => { left = [ "Hello", "world", "!" ], right = [  ] }
           ```
         "%
       = fun index value =>
@@ -716,11 +729,12 @@
 
           # Examples
 
-          ```nickel
+          ```nickel multiline
           std.array.replicate 0 false
-            => [ ]
+          # => [ ]
+
           std.array.replicate 5 "x"
-            => [ "x", "x", "x", "x", "x" ]
+          # => [ "x", "x", "x", "x", "x" ]
           ```
         "%
       = fun n x => %array/generate% n (fun _i => x),
@@ -742,7 +756,7 @@
 
           ```nickel
           std.array.range_step (-1.5) 2 0.5
-           => [ -1.5, -1, -0.5, 0, 0.5, 1, 1.5 ]
+           # => [ -1.5, -1, -0.5, 0, 0.5, 1, 1.5 ]
           ```
         "%
       = fun start end step =>
@@ -769,7 +783,7 @@
 
           ```nickel
           std.array.range 0 5
-           => [ 0, 1, 2, 3, 4 ]
+          # => [ 0, 1, 2, 3, 4 ]
           ```
         "%
       = fun start end => range_step start end 1,
@@ -799,13 +813,14 @@
 
           # Examples
 
-          ```nickel
+          ```nickel multiline
           std.array.reduce_left (@) [ [1, 2], [3], [4,5] ]
-            => (([1, 2] @ [3]) @ [4,5])
-            => [ 1, 2, 4, 5 ]
+          # => (([1, 2] @ [3]) @ [4,5])
+          # => [ 1, 2, 3, 4, 5 ]
+
           std.array.reduce_left (-) [ 1, 2, 3, 4]
-            => ((1 - 2) - 3) - 4
-            => -8
+          # => ((1 - 2) - 3) - 4
+          # => -8
           ```
         "%
       = fun f array =>
@@ -839,13 +854,14 @@
 
           # Examples
 
-          ```nickel
+          ```nickel multiline
           std.array.reduce_right (@) [ [1, 2], [3], [4,5] ]
-            => [1, 2] @ ([3] @ [4,5])
-            => [ 1, 2, 4, 5 ]
+          # => [1, 2] @ ([3] @ [4,5])
+          # => [ 1, 2, 3, 4, 5 ]
+
           std.array.reduce_right (-) [ 1, 2, 3, 4]
-            => 1 - (2 - (3 - 4))
-            => -2
+          # => 1 - (2 - (3 - 4))
+          # => -2
           ```
         "%
       = fun f array =>
@@ -863,13 +879,15 @@
 
           # Examples
 
-          ```nickel
+          ```nickel multiline
           std.array.zip_with (+) [1, 2, 3] [4, 5, 6]
-            => [5, 7, 9]
+          # => [5, 7, 9]
+
           std.array.zip_with (*) [1, 2] [4, 5, 6]
-            => [4, 10]
+          # => [4, 10]
+
           std.array.zip_with (-) [1, 2, 3] [4, 5]
-            => [-3, -3]
+          # => [-3, -3]
           ```
         "%
       = fun f xs ys =>
@@ -887,8 +905,8 @@
           # Examples
 
           ```nickel
-          std.array.map_with_index (fun i x => i + x + 1) [ 1, 2, 3 ] =>
-            [ 2, 4, 6 ]
+          std.array.map_with_index (fun i x => i + x + 1) [ 1, 2, 3 ]
+          # => [ 2, 4, 6 ]
           ```
         "%
       = fun f xs =>
@@ -910,11 +928,12 @@
 
         # Example
 
-        ```nickel
+        ```nickel multiline
         1 + 4 | std.contract.Equal 5
-         => 5
+        # => 5
+
         4 | std.contract.Equal 5
-         => error: contract broken by a value
+        # => error: contract broken by a value
         ```
       "%
       =
@@ -1021,11 +1040,13 @@
           # Examples
 
           ```nickel
-          IsZero = fun label value =>
+          let IsZero = fun label value =>
             if value == 0 then
               value
             else
               std.contract.blame label
+          in
+          0 | IsZero
           ```
         "%
       = fun label => %blame% label,
@@ -1106,7 +1127,7 @@
           in
 
           null | Nullable Number
-            => null
+          # => null
           ```
         "%
       = fun contract => %contract/custom% contract,
@@ -1228,26 +1249,26 @@
 
           # Examples
 
-          ```nickel
+          ```nickel ignore
           x | std.contract.Sequence [ Foo, Bar ]
           ```
 
           is equivalent to
 
-          ```nickel
+          ```nickel ignore
           (x | Foo) | Bar
           ```
 
           This is useful in positions where one cannot apply multiple contracts,
           such as an argument to another contract:
 
-          ```nickel
+          ```nickel ignore
           x | Array (std.contract.Sequence [ Foo, Bar ])
           ```
 
           Or stored in a variable:
 
-          ```nickel
+          ```nickel ignore
           let C = std.contract.Sequence [ Foo, Bar ] in
           x | C
           ```
@@ -1338,7 +1359,7 @@
               in
 
               { foo = 4, hello = "world" } | FooIsEven
-                => { foo = 4, hello = "world" }
+              # => { foo = 4, hello = "world" }
               ```
             "%
           = fun message label => %label/with_message% message label,
@@ -1379,6 +1400,8 @@
               in
 
               null | AlwaysFailWithNotes
+
+              # => error
               ```
             "%
           # the %label/with_notes% operator expects an array of strings which is
@@ -1408,6 +1431,7 @@
               in
 
               null | AlwaysFailWithNotes
+              # => error
               ```
             "%
           = fun note label => %label/append_note% note label,
@@ -1466,17 +1490,16 @@
           applications.
 
           ```nickel
-          let FooOfNumber = fun Contract =>
+          let FooOfNumber =
             std.contract.custom
               (fun label =>
                 match {
-                  'Foo arg => 'Foo (std.contract.apply Contract label value),
+                  'Foo arg => 'Ok ('Foo (std.contract.apply Number label arg)),
                   _ => 'Error {},
                 }
               )
           in
-
-          { foo = 1 } | Nullable {foo | Number}
+          'Foo 3 | FooOfNumber
           ```
 
           # Diagnostics stack
@@ -1507,6 +1530,7 @@
           in
 
           null | ParentContract
+          # => error: contract broken by a value
           ```
 
           This example will print two diagnostics: the main one, using the
@@ -1684,7 +1708,7 @@
           ```nickel
           let NotNumber = std.contract.not Number in
           "a" | NotNumber
-            => "a"
+          # => "a"
           ```
         "%
       = fun Contract =>
@@ -1720,7 +1744,7 @@
               That is, `some_function | DependentFun Foo Bar` performs the
               same checks as
 
-              ```nickel
+              ```nickel ignore
               fun arg =>
                 let arg_checked = (arg | Foo) in
                 (some_function arg_checked | (Bar arg_checked))
@@ -1985,6 +2009,7 @@
               let f | HasField Dyn = fun field record => record."%{field}" in
               (f "foo" { foo = 1, bar = 2 })
               + (f "baz" { foo = 1, bar = 2 })
+              # => error: missing field
               ```
 
               In this example, the first call to `f` won't blame, but the second
@@ -2020,13 +2045,15 @@
 
         # Examples
 
-        ```nickel
-        ('foo | std.enum.Tag) =>
-          'foo
-        ('FooBar | std.enum.Tag) =>
-          'FooBar
-        ("tag" | std.enum.Tag) =>
-          error
+        ```nickel multiline
+        ('foo | std.enum.Tag)
+        # => 'foo
+
+        ('FooBar | std.enum.Tag)
+        # => 'FooBar
+
+        ("tag" | std.enum.Tag)
+        # => error
         ```
       "%
       = std.contract.from_predicate is_enum_tag,
@@ -2037,13 +2064,15 @@
 
         # Examples
 
-        ```nickel
-        ('Foo | std.enum.Enum) =>
-          'Foo
-        ('Bar 5 | std.enum.Enum) =>
-          'Bar 5
-        ("tag" | std.enum.Enum) =>
-          error
+        ```nickel multiline
+        ('Foo | std.enum.Enum)
+        # => 'Foo
+
+        ('Bar 5 | std.enum.Enum)
+        # => 'Bar 5
+
+        ("tag" | std.enum.Enum)
+        # => error
         "%
       =
         %contract/custom% (fun _label value =>
@@ -2111,15 +2140,18 @@
 
         # Examples
 
-        ```nickel
+        ```nickel multiline
         std.enum.is_enum_tag 'foo
-          => true
+        # => true
+
         std.enum.is_enum_tag 'FooBar
-          => true
+        # => true
+
         std.enum.is_enum_tag "tag"
-          => false
+        # => false
+
         std.enum.is_enum_tag ('Foo "arg")
-          => false
+        # => false
         ```
       "%
       = fun value => std.is_enum value && !(%enum/is_variant% value),
@@ -2132,15 +2164,18 @@
 
         # Examples
 
-        ```nickel
+        ```nickel multiline
         std.enum.is_enum_variant ('Foo "arg")
-          => true
+        # => true
+
         std.enum.is_enum_variant ('Http {version = "1.1"})
-          => true
+        # => true
+
         std.enum.is_enum_variant 'foo
-          => false
+        # => false
+
         std.enum.is_enum_variant [1, 2, 3]
-          => false
+        # => false
         ```
       "%
       = fun value => %enum/is_variant% value,
@@ -2156,11 +2191,12 @@
 
         # Examples
 
-        ```nickel
-        std.enum.to_tag_and_arg ('Foo "arg") =>
-          { tag = "Foo", arg = "arg" }
+        ```nickel multiline
+        std.enum.to_tag_and_arg ('Foo "arg")
+        # => { tag = "Foo", arg = "arg" }
+
         std.enum.to_tag_and_arg 'http
-          => { tag = "http" }
+        # => { tag = "http" }
         ```
         "%
       = fun enum_value =>
@@ -2184,11 +2220,12 @@
 
         # Examples
 
-        ```nickel
+        ```nickel multiline
         std.enum.from_tag_and_arg { tag = "Foo", arg = "arg" }
-          => ('Foo "arg")
+        # => ('Foo "arg")
+
         std.enum.from_tag_and_arg { tag = "http" }
-          => 'http
+        # => 'http
         ```
         "%
       = fun enum_data =>
@@ -2205,11 +2242,12 @@
 
           # Examples
 
-          ```nickel
+          ```nickel multiline
           std.enum.map ((+) 1) ('Foo 42)
-            => 'Foo 43
-          std.enum.map f 'Bar
-            => 'Bar
+          # => 'Foo 43
+
+          std.enum.map (fun x => 1) 'Bar
+          # => 'Bar
           ```
         "%
       = fun f enum_value =>
@@ -2230,11 +2268,12 @@
 
         # Examples
 
-        ```nickel
+        ```nickel multiline
         std.function.id null
-          => null
+        # => null
+
         (std.function.id (fun x => x + 1)) 0
-          => 1
+        # => 1
         ```
       "%
       = fun x => x,
@@ -2251,8 +2290,8 @@
         let f = std.function.compose (fun x => x + 1) (fun x => x / 2)
         in
         f 10
-          => (10 / 2) + 1
-          => 6
+        # => (10 / 2) + 1
+        # => 6
         ```
       "%
       = fun g f x => x |> f |> g,
@@ -2266,7 +2305,7 @@
 
         ```nickel
         std.function.flip (fun x y => "%{x} %{y}") "world!" "Hello,"
-          => "Hello, world!"
+        # => "Hello, world!"
         ```
       "%%
       = fun f x y => f y x,
@@ -2282,7 +2321,7 @@
         ```nickel
         let f = std.function.const 5 in
         f 7
-          => 5
+        # => 5
         ```
       "%
       = fun x y => x,
@@ -2297,7 +2336,7 @@
 
         ```nickel
         std.function.first 5 7
-          => 5
+        # => 5
         ```
       "%
       = fun x y => x,
@@ -2311,7 +2350,7 @@
 
         ```nickel
         std.function.second 5 7
-          => 7
+        # => 7
         ```
       "%
       = fun x y => y,
@@ -2323,11 +2362,12 @@
 
         # Examples
 
-        ```nickel
+        ```nickel multiline
         std.function.pipe 2 [ (+) 2, (+) 3 ]
-          => 7
+        # => 7
+
         std.function.pipe 'World [ std.string.from, fun s => "Hello, %{s}!" ]
-          => "Hello, World!"
+        # => "Hello, World!"
         ```
       "%%
       = fun x fs => std.array.fold_left (|>) x fs,
@@ -2340,11 +2380,12 @@
 
         # Examples
 
-        ```nickel
-        (1.5 | Integer) =>
-          error
-        (42 | Integer) =>
-          42
+        ```nickel multiline
+        (1.5 | Integer)
+        # => error
+
+        (42 | Integer)
+        # => 42
         ```
       "%
       =
@@ -2364,13 +2405,15 @@
 
         # Examples
 
-        ```nickel
-        (42 | std.number.Nat) =>
-          42
-        (0 | std.number.Nat) =>
-          0
-        (-4 | std.number.Nat) =>
-          error
+        ```nickel multiline
+        (42 | std.number.Nat)
+        # => 42
+
+        (0 | std.number.Nat)
+        # => 0
+
+        (-4 | std.number.Nat)
+        # => error
         ```
       "%
       =
@@ -2390,13 +2433,15 @@
 
         # Examples
 
-        ```nickel
-        (42 | std.number.PosNat) =>
-          42
-        (0 | std.number.PosNat) =>
-          error
-        (-4 | std.number.PosNat) =>
-          error
+        ```nickel multiline
+        (42 | std.number.PosNat)
+        # => 42
+
+        (0 | std.number.PosNat)
+        # => error
+
+        (-4 | std.number.PosNat)
+        # => error
         ```
       "%
       =
@@ -2416,11 +2461,12 @@
 
         # Examples
 
-        ```nickel
-        (1 | std.number.NonZero) =>
-          1
-        (0.0 | std.number.NonZero) =>
-          error
+        ```nickel multiline
+        (1 | std.number.NonZero)
+        # => 1
+
+        (0.0 | std.number.NonZero)
+        # => error
         ```
       "%
       =
@@ -2441,11 +2487,12 @@
 
         # Examples
 
-        ```nickel
-        std.number.is_integer 42 =>
-          true
-        std.number.is_integer 1.5 =>
-          false
+        ```nickel multiline
+        std.number.is_integer 42
+        # => true
+
+        std.number.is_integer 1.5
+        # => false
         ```
       "%
       = fun x => x % 1 == 0,
@@ -2457,8 +2504,8 @@
 
         # Examples
         ```nickel
-        std.number.compare 1 2 =>
-          'Lesser
+        std.number.compare 1 2
+        # => 'Lesser
         ```
       "%
       = fun x y =>
@@ -2477,8 +2524,8 @@
         # Examples
 
         ```nickel
-        std.number.min (-1337) 42 =>
-          -1337
+        std.number.min (-1337) 42
+        # => -1337
         ```
       "%
       = fun x y => if x <= y then x else y,
@@ -2491,8 +2538,8 @@
         # Examples
 
         ```nickel
-        std.number.max (-1337) 42 =>
-          42
+        std.number.max (-1337) 42
+        # => 42
         ```
       "%
       = fun x y => if x >= y then x else y,
@@ -2504,11 +2551,12 @@
 
         # Examples
 
-        ```nickel
-        std.number.floor 42.5 =>
-          42
-        std.number.floor (-42.5) =>
-          -43
+        ```nickel multiline
+        std.number.floor 42.5
+        # => 42
+
+        std.number.floor (-42.5)
+        # => -43
         ```
       "%
       = fun x =>
@@ -2524,11 +2572,12 @@
 
         # Examples
 
-        ```nickel
-        std.number.abs (-5) =>
-          5
-        std.number.abs 42 =>
-          42
+        ```nickel multiline
+        std.number.abs (-5)
+        # => 5
+
+        std.number.abs 42
+        # => 42
         ```
       "%
       = fun x => if x < 0 then -x else x,
@@ -2540,11 +2589,12 @@
 
         # Examples
 
-        ```nickel
-        std.number.fract 13.37 =>
-          0.37
-        std.number.fract 42 =>
-          0
+        ```nickel multiline
+        std.number.fract 13.37
+        # => 0.37
+
+        std.number.fract 42
+        # => 0
         ```
       "%
       = fun x => x % 1,
@@ -2556,11 +2606,12 @@
 
         # Examples
 
-        ```nickel
-        std.number.truncate (-13.37) =>
-          -13
-        std.number.truncate 42.5 =>
-          42
+        ```nickel multiline
+        std.number.truncate (-13.37)
+        # => -13
+
+        std.number.truncate 42.5
+        # => 42
         ```
       "%
       = fun x => x - (x % 1),
@@ -2573,8 +2624,8 @@
         # Examples
 
         ```nickel
-        std.number.pow 2 8 =>
-          256
+        std.number.pow 2 8
+        # => 256
         ```
 
         # Precision
@@ -2598,13 +2649,15 @@
         `log x base` returns the logarithm of x with respect to the given base.
 
         # Examples
-        ```nickel
-        std.number.log 2 1024 =>
-          10
-        std.number.log 100 10 =>
-          2
-        std.number.log 10 std.number.e =>
-          2.302585
+        ```nickel ignore
+        std.number.log 2 1024
+        # => 10
+
+        std.number.log 100 10
+        # => 2
+
+        std.number.log 10 std.number.e
+        # => 2.302585
         ```
         "%
       = fun x b => %number/log% x b,
@@ -2616,9 +2669,9 @@
 
         # Examples
 
-        ```nickel
-        std.number.cos (std.number.pi / 4) =>
-          0.70710678118
+        ```nickel ignore
+        std.number.cos (std.number.pi / 4)
+        # => 0.70710678118
         ```
       "%
       = fun x => %number/cos% x,
@@ -2630,9 +2683,9 @@
 
         # Examples
 
-        ```nickel
-        std.number.sin (std.number.pi / 2) =>
-          1
+        ```nickel ignore
+        std.number.sin (std.number.pi / 2)
+        # => 1
         ```
       "%
       = fun x => %number/sin% x,
@@ -2644,9 +2697,9 @@
 
         # Examples
 
-        ```nickel
-        std.number.tan (std.number.pi / 4) =>
-          1
+        ```nickel ignore
+        std.number.tan (std.number.pi / 4)
+        # => 1
         ```
       "%
       = fun x => %number/tan% x,
@@ -2658,11 +2711,12 @@
 
         # Examples
 
-        ```nickel
-        std.number.arccos 0 =>
-          1.5707963
-        std.number.arccos 1 =>
-          0
+        ```nickel ignore
+        std.number.arccos 0
+        # => 1.570796326794897
+
+        std.number.arccos 1
+        # => 0
         ```
         "%
       = fun x => %number/arccos% x,
@@ -2674,11 +2728,12 @@
 
         # Examples
 
-        ```nickel
-        std.number.arcsin 0 =>
-          0
-        std.number.arcsin 1 =>
-          1.5707963
+        ```nickel ignore
+        std.number.arcsin 0
+        # => 0
+
+        std.number.arcsin 1
+        # => 1.570796326794897
         ```
         "%
       = fun x => %number/arcsin% x,
@@ -2690,9 +2745,9 @@
 
         # Examples
 
-        ```nickel
-        std.number.arctan 1 =>
-          0.78539816
+        ```nickel ignore
+        std.number.arctan 1
+        # => 0.7853981633974482
         ```
         "%
       = fun x => %number/arctan% x,
@@ -2704,11 +2759,12 @@
 
       # Examples
 
-      ```nickel
-      std.number.arctan2 1 0 =>
-        1.5707963
-      std.number.arctan2 (-0.5) (-0.5) =>
-        -0.7853981633974483
+      ```nickel ignore
+      std.number.arctan2 1 0
+      # => 1.570796326794897
+
+      std.number.arctan2 (-0.5) (-0.5)
+      # => -2.356194490192345
       ```
       "%
       = fun y x => %number/arctan2% y x,
@@ -2721,8 +2777,8 @@
         # Examples
 
         ```nickel
-        std.number.sqrt 4 =>
-          2
+        std.number.sqrt 4
+        # => 2
         ```
       "%
       = fun x => pow x (1 / 2),
@@ -2751,11 +2807,12 @@
 
         # Examples
 
-        ```nickel
-        std.record.map (fun s x => s) { hi = 2 } =>
-          { hi = "hi" }
-        std.record.map (fun s x => x + 1) { hello = 1, world = 2 } =>
-          { hello = 2, world = 3 }
+        ```nickel multiline
+        std.record.map (fun s x => s) { hi = 2 }
+        # => { hi = "hi" }
+
+        std.record.map (fun s x => x + 1) { hello = 1, world = 2 }
+        # => { hello = 2, world = 3 }
         ```
       "%
       = fun f r => %record/map% r f,
@@ -2775,11 +2832,12 @@
 
         # Examples
 
-        ```nickel
-        std.record.fields { one = 1, two = 2 } =>
-          [ "one", "two" ]
-        std.record.fields { one = 1, two = 2, three_opt | optional } =>
-          [ "one", "two" ]
+        ```nickel multiline
+        std.record.fields { one = 1, two = 2 }
+        # => [ "one", "two" ]
+
+        std.record.fields { one = 1, two = 2, three_opt | optional }
+        # => [ "one", "two" ]
         ```
       "%
       = fun r => %record/fields% r,
@@ -2793,11 +2851,12 @@
 
         # Examples
 
-        ```nickel
-        std.record.fields_with_opts { one = 1, two = 2 } =>
-          [ "one", "two" ]
-        std.record.fields_with_opts { one = 1, two = 2, three_opt | optional } =>
-          [ "one", "two", "three_opt" ]
+        ```nickel multiline
+        std.record.fields_with_opts { one = 1, two = 2 }
+        # => [ "one", "two" ]
+
+        std.record.fields_with_opts { one = 1, two = 2, three_opt | optional }
+        # => [ "one", "two", "three_opt" ]
         ```
       "%
       = fun r => %record/fields_with_opts% r,
@@ -2810,8 +2869,8 @@
         # Examples
 
         ```nickel
-        std.record.values { one = 1, world = "world" } =>
-          [ 1, "world" ]
+        std.record.values { one = 1, world = "world" }
+        # => [ 1, "world" ]
         ```
       "%
       = fun r => %record/values% r,
@@ -2830,16 +2889,19 @@
 
         # Examples
 
-        ```nickel
+        ```nickel multiline
         std.record.has_field "hello" { one = 1, two = 2 }
-          => false
+        # => false
+
         std.record.has_field "one" { one = 1, two = 2 }
-          => true
+        # => true
+
         std.record.has_field "two_opt" { one, two_opt | optional }
-          => false
+        # => false
+
         ({ one = 1 } | {one, two_opt | optional })
         |> std.record.has_field "two_opt"
-          => false
+        # => false
         ```
       "%
       = fun field r => %record/has_field% field r,
@@ -2854,16 +2916,19 @@
 
         # Examples
 
-        ```nickel
+        ```nickel multiline
         std.record.has_field_with_opts "hello" { one = 1, two = 2 }
-          => false
+        # => false
+
         std.record.has_field_with_opts "one" { one = 1, two = 2 }
-          => true
+        # => true
+
         std.record.has_field_with_opts "two_opt" { one, two_opt | optional }
-          => true
+        # => true
+
         ({ one = 1 } | {one, two_opt | optional })
         |> std.record.has_field_with_opts "two_opt"
-          => true
+        # => true
       "%
       = fun field r => %record/has_field_with_opts% field r,
 
@@ -2885,15 +2950,16 @@
 
         # Examples
 
-        ```nickel
-        std.record.get "one" { one = 1, two = 2 } =>
-          1
+        ```nickel multiline
+        std.record.get "one" { one = 1, two = 2 }
+        # => 1
+
         { one = 1, two = 2, string = "three"}
         |> std.record.to_array
         |> std.array.filter (fun { field, value } => std.is_number value)
         |> std.record.from_array
         |> std.record.get "two"
-          => 2
+        # => 2
         ```
       "%%
       = fun field r => r."%{field}",
@@ -2907,13 +2973,15 @@
 
         # Examples
 
-        ```nickel
+        ```nickel multiline
         std.record.get_or "tree" 3 { one = 1, two = 2 }
-          => 3
+        # => 3
+
         std.record.get_or "one" 11 { one = 1, two = 2 }
-          => 1
+        # => 1
+
         std.record.get_or "value" "default" { tag = 'Hello, value }
-          => "default"
+        # => "default"
         ```
       "%%
       = fun field default_value record =>
@@ -2946,17 +3014,20 @@
 
           # Examples
 
-          ```nickel
+          ```nickel multiline
           std.record.insert "foo" 5 { bar = "bar" }
-            => { foo = 5, bar = "bar }
+          # => { foo = 5, bar = "bar" }
+
           {}
           |> std.record.insert "file.txt" "data/text"
           |> std.record.insert "length" (10*1000)
-            => {"file.txt" = "data/text", "length" = 10000}
+          # => {"file.txt" = "data/text", "length" = 10000}
+
           std.record.insert "already_there_opt" 0 {already_there_opt | optional}
-            => {already_there_optional = 0}
+          # => {already_there_opt = 0}
+
           std.record.insert "already_there" 0 {already_there = 1}
-            => error
+          # => error
           ```
         "%%
       = fun field content r => %record/insert% field r content,
@@ -2977,17 +3048,20 @@
 
           # Examples
 
-          ```nickel
+          ```nickel multiline
           std.record.insert_with_opts "foo" 5 { bar = "bar" }
-            => { foo = 5, bar = "bar }
+          # => { foo = 5, bar = "bar" }
+
           {}
           |> std.record.insert_with_opts "file.txt" "data/text"
           |> std.record.insert_with_opts "length" (10*1000)
-            => {"file.txt" = "data/text", "length" = 10000}
+          # => {"file.txt" = "data/text", "length" = 10000}
+
           std.record.insert_with_opts "already_there_optional" 0 {already_there | optional}
-            => error
+          # => { already_there_optional = 0 }
+
           std.record.insert_with_opts "already_there" 0 {already_there = 1}
-            => error
+          # => error
           ```
         "%%
       = fun field content r => %record/insert_with_opts% field r content,
@@ -3014,13 +3088,15 @@
 
           # Examples
 
-          ```nickel
+          ```nickel multiline
           std.record.remove "foo" { foo = "foo", bar = "bar" }
-            => { bar = "bar" }
+          # => { bar = "bar" }
+
           std.record.remove "foo_opt" {foo_opt | optional}
-            => error
+          # => error
+
           std.record.remove "foo" { bar = "bar" }
-            => error
+          # => error
           ```
         "%
       = fun field r => %record/remove% field r,
@@ -3040,13 +3116,15 @@
 
           # Examples
 
-          ```nickel
+          ```nickel multiline
           std.record.remove_with_opts "foo" { foo = "foo", bar = "bar" }
-            => { bar = "bar" }
+          # => { bar = "bar" }
+
           std.record.remove_with_opts "foo_opt" {foo_opt | optional}
-            => {}
+          # => {}
+
           std.record.remove_with_opts "foo" { bar = "bar" }
-            => error
+          # => error
           ```
         "%
       = fun field r => %record/remove_with_opts% field r,
@@ -3066,13 +3144,15 @@
 
           # Examples
 
-          ```nickel
-          std.record.update "foo" 5 { foo = "foo", bar = "bar" } =>
-            { foo = 5, bar = "bar" }
-          std.record.update "foo" 5 { bar = "bar" } =>
-            { foo = 5, bar = "bar" }
-          std.record.update "foo_opt" 5 {foo_opt | optional} =>
-            {foo_opt = 5}
+          ```nickel multiline
+          std.record.update "foo" 5 { foo = "foo", bar = "bar" }
+          # => { foo = 5, bar = "bar" }
+
+          std.record.update "foo" 5 { bar = "bar" }
+          # => { foo = 5, bar = "bar" }
+
+          std.record.update "foo_opt" 5 {foo_opt | optional}
+          # => {foo_opt = 5}
           ```
 
           # Overriding
@@ -3081,11 +3161,12 @@
           will only change the specified field and won't automatically update the other
           fields which depend on it:
 
-          ```nickel
-          { foo = bar + 1, bar | default = 0 } & { bar = 1 } =>
-            { foo = 2, bar = 1 }
-          std.record.update "bar" 1 {foo = bar + 1, bar | default = 0 } =>
-            { foo = 1, bar = 1 }
+          ```nickel multiline
+          { foo = bar + 1, bar | default = 0 } & { bar = 1 }
+          # => { foo = 2, bar = 1 }
+
+          std.record.update "bar" 1 {foo = bar + 1, bar | default = 0 }
+          # => { foo = 1, bar = 1 }
           ```
         "%
       = fun field content r =>
@@ -3105,11 +3186,12 @@
 
         # Examples
 
-        ```nickel
+        ```nickel multiline
         std.record.map_values (fun x => x + 1) { hi = 2 }
-          => { hi = 3 }
+        # => { hi = 3 }
+
         std.record.map_values (fun x => x + 1) { hello = 1, world = 2 }
-          => { hello = 2, world = 3 }
+        # => { hello = 2, world = 3 }
         ```
       "%
       = fun f => map (fun _field => f),
@@ -3123,10 +3205,7 @@
 
         ```nickel
         std.record.to_array { hello = "world", foo = "bar" }
-          => [
-            { field = "hello", value = "world" },
-            { field = "foo", value = "bar" },
-          ]
+        # => [ { field = "hello", value = "world" }, { field = "foo", value = "bar" } ]
         ```
       "%
       = fun record =>
@@ -3147,7 +3226,7 @@
           { field = "hello", value = "world" },
           { field = "foo", value = "bar" }
         ]
-          => { hello = "world", foo = "bar" }
+        # => { hello = "world", foo = "bar" }
         ```
       "%
       = fun bindings =>
@@ -3161,11 +3240,12 @@
 
         # Examples
 
-        ```nickel
+        ```nickel multiline
         std.record.is_empty {}
-          => true
+        # => true
+
         std.record.is_empty { foo = 1 }
-          => false
+        # => false
         ```
       "%
       = (==) {},
@@ -3179,7 +3259,7 @@
 
         ```nickel
         std.record.merge_all [ { foo = 1 }, { bar = 2 } ]
-          => { foo = 1, bar = 2 }
+        # => { foo = 1, bar = 2 }
         ```
       "%
       = fun rs => (std.array.fold_left (&) {} (rs | Array Dyn)) | { _ : Dyn },
@@ -3195,7 +3275,7 @@
 
         ```nickel
         std.record.filter (fun _name x => x % 2 == 0) { even = 2, odd = 3 }
-          => { even = 2 }
+        # => { even = 2 }
         ```
       "%
       = fun f record =>
@@ -3212,11 +3292,12 @@
 
         # Examples
 
-        ```nickel
-        std.record.apply_on "name" std.string.compare { name = "Alice", age = 27 } { name = "Bob", age = 23 } =>
-          'Lesser
-        std.record.apply_on "age" std.number.compare { name = "Alice", age = 27 } { name = "Bob", age = 23 } =>
-          'Greater
+        ```nickel multiline
+        std.record.apply_on "name" std.string.compare { name = "Alice", age = 27 } { name = "Bob", age = 23 }
+        # => 'Lesser
+
+        std.record.apply_on "age" std.number.compare { name = "Alice", age = 27 } { name = "Bob", age = 23 }
+        # => 'Greater
         ```
         "%
       = fun field f a b => f a."%{field}" b."%{field}",
@@ -3244,13 +3325,15 @@
 
         # Examples
 
-        ```nickel
-        ("true" | std.string.BoolLiteral) =>
-          "true"
-        ("hello" | std.string.BoolLiteral) =>
-          error
-        (true | std.string.BoolLiteral) =>
-          error
+        ```nickel multiline
+        ("true" | std.string.BoolLiteral)
+        # => "true"
+
+        ("hello" | std.string.BoolLiteral)
+        # => error
+
+        (true | std.string.BoolLiteral)
+        # => error
         ```
       "%
       = fun l s =>
@@ -3270,13 +3353,15 @@
 
         # Examples
 
-        ```nickel
-        ("+1.2" | std.string.NumberLiteral) =>
-          "+1.2"
-        ("5" | std.string.NumberLiteral) =>
-          "5"
-        (42 | std.string.NumberLiteral) =>
-          error
+        ```nickel multiline
+        ("+1.2" | std.string.NumberLiteral)
+        # => "+1.2"
+
+        ("5" | std.string.NumberLiteral)
+        # => "5"
+
+        (42 | std.string.NumberLiteral)
+        # => error
         ```
       "%
       =
@@ -3299,15 +3384,18 @@
 
         # Examples
 
-        ```nickel
+        ```nickel multiline
         ("e" | std.string.Character)
-          => "e"
+        # => "e"
+
         ("#" | std.string.Character)
-          => "#"
+        # => "#"
+
         ("" | std.string.Character)
-          => error
+        # => error
+
         (1 | std.string.Character)
-          => error
+        # => error
         ```
       "%
       =
@@ -3336,15 +3424,18 @@
 
         # Examples
 
-        ```nickel
+        ```nickel multiline
         ('Foo | std.string.Stringable)
-          => 'Foo
+        # => 'Foo
+
         (false | std.string.Stringable)
-          => false
+        # => false
+
         ("bar" ++ "foo" | std.string.Stringable)
-          => "barfoo"
+        # => "barfoo"
+
         ({foo = "baz"} | std.string.Stringable)
-          => error
+        # => error
         ```
       "%
       =
@@ -3369,13 +3460,15 @@
 
         # Examples
 
-        ```nickel
+        ```nickel multiline
         ("" | std.string.NonEmpty)
-          => error
+        # => error
+
         ("hi!" | std.string.NonEmpty)
-          => "hi!"
+        # => "hi!"
+
         (42 | std.string.NonEmpty)
-          => error
+        # => error
         ```
       "%
       =
@@ -3396,13 +3489,15 @@
 
         # Examples
 
-        ```nickel
+        ```nickel multiline
         std.string.join ", " [ "Hello", "World!" ]
-          => "Hello, World!"
+        # => "Hello, World!"
+
         std.string.join ";" ["I'm alone"]
-          => "I'm alone"
+        # => "I'm alone"
+
         std.string.join ", " []
-          => ""
+        # => ""
         ```
       "%
       = fun sep fragments =>
@@ -3427,11 +3522,12 @@
 
         # Examples
 
-        ```nickel
+        ```nickel multiline
         std.string.split "," "1,2,3"
-          => [ "1", "2", "3" ]
+        # => [ "1", "2", "3" ]
+
         std.string.split "." "1,2,3"
-          => [ "1,2,3" ]
+        # => [ "1,2,3" ]
         ```
       "%
       = fun sep s => %string/split% s sep,
@@ -3443,11 +3539,12 @@
 
         # Examples
 
-        ```nickel
+        ```nickel multiline
         std.string.trim " hi  "
-          => "hi"
+        # => "hi"
+
         std.string.trim "1   2   3   "
-          => "1   2   3"
+        # => "1   2   3"
         ```
       "%
       = fun s => %string/trim% s,
@@ -3461,7 +3558,7 @@
 
         ```nickel
         std.string.characters "Hello"
-          => [ "H", "e", "l", "l", "o" ]
+        # => [ "H", "e", "l", "l", "o" ]
         ```
       "%
       = fun s => %string/chars% s,
@@ -3474,13 +3571,15 @@
 
         # Examples
 
-        ```nickel
+        ```nickel multiline
         std.string.uppercase "a"
-          => "A"
+        # => "A"
+
         std.string.uppercase "æ"
-          => "Æ"
+        # => "Æ"
+
         std.string.uppercase "hello.world"
-          => "HELLO.WORLD"
+        # => "HELLO.WORLD"
         ```
       "%
       = fun s => %string/uppercase% s,
@@ -3494,13 +3593,15 @@
 
         # Examples
 
-        ```nickel
+        ```nickel multiline
         std.string.lowercase "A"
-          => "a"
+        # => "a"
+
         std.string.lowercase "Æ"
-          => "æ"
+        # => "æ"
+
         std.string.lowercase "HELLO.WORLD"
-          => "hello.world"
+        # => "hello.world"
         ```
       "%
       = fun s => %string/lowercase% s,
@@ -3515,13 +3616,15 @@
 
         # Examples
 
-        ```nickel
+        ```nickel multiline
         std.string.contains "cde" "abcdef"
-          => true
+        # => true
+
         std.string.contains "" "abcdef"
-          => true
+        # => true
+
         std.string.contains "ghj" "abcdef"
-          => false
+        # => false
         ```
       "%
       = fun subs s => %string/contains% s subs,
@@ -3533,13 +3636,15 @@
 
         # Examples
 
-        ```nickel
+        ```nickel multiline
         std.string.compare "abc" "def"
-          => 'Lesser
+        # => 'Lesser
+
         std.string.compare "a" "a"
-          => 'Equal
+        # => 'Equal
+
         std.string.compare "world" "hello"
-          => 'Greater
+        # => 'Greater
         ```
       "%
       = fun a b => %string/compare% a b,
@@ -3554,11 +3659,12 @@
 
         # Examples
 
-        ```nickel
+        ```nickel multiline
         std.string.replace "cd" "   " "abcdef"
-          => "ab   ef"
+        # => "ab   ef"
+
         std.string.replace "" "A" "abcdef"
-          => "AaAbAcAdAeAfA"
+        # => "AaAbAcAdAeAfA"
         ```
       "%
       = fun pattern replace s =>
@@ -3576,11 +3682,12 @@
 
         # Examples
 
-        ```nickel
+        ```nickel multiline
         std.string.replace_regex "l+." "j" "Hello!"
-          => "Hej!"
+        # => "Hej!"
+
         std.string.replace_regex "\\d+" "\"a\" is not" "This 37 is a number."
-          "This \"a\" is not a number."
+        # "This \"a\" is not a number."
       ```
       "%
       = fun pattern replace s =>
@@ -3599,11 +3706,12 @@
 
         # Examples
 
-        ```nickel
+        ```nickel multiline
         std.string.is_match "^\\d+$" "123"
-          => true
+        # => true
+
         std.string.is_match "\\d{4}" "123"
-          => false
+        # => false
         ```
 
         # Performance
@@ -3623,7 +3731,7 @@
         in
         ["0", "42", "0.5"]
         |> std.array.all is_number
-          => true
+        # => true
         ```
 
         On the other hand, in the version below, the partial application of
@@ -3635,7 +3743,7 @@
         let is_number' = std.string.is_match "[0-9]*\\.?[0-9]+" in
         ["0", "42", "0.5"]
         |> std.array.all is_number'
-          => true
+        # => true
         ```
       "%
       = fun regex => %string/is_match% regex,
@@ -3657,11 +3765,12 @@
 
         # Examples
 
-        ```nickel
+        ```nickel multiline
         std.string.find "^(\\d).*(\\d).*(\\d).*$" "5 apples, 6 pears and 0 grapes"
-          => { matched = "5 apples, 6 pears and 0 grapes", index = 0, groups = [ "5", "6", "0" ] }
+        # => { matched = "5 apples, 6 pears and 0 grapes", index = 0, groups = [ "5", "6", "0" ] }
+
         std.string.find "3" "01234"
-          => { matched = "3", index = 3, groups = [ ] }
+        # => { matched = "3", index = 3, groups = [ ] }
         ```
 
         # Performance
@@ -3690,19 +3799,20 @@
 
         # Examples
 
-        ```nickel
+        ```nickel multiline
         std.string.find_all "(\\d) (\\w+)" "5 apples, 6 pears and 0 grapes"
-          => [
-            { groups = [ "5", "apples" ], index = 0, matched = "5 apples", },
-            { groups = [ "6", "pears" ], index = 10, matched = "6 pears", },
-            { groups = [ "0", "grapes" ], index = 22, matched = "0 grapes", }
-          ]
+        # => [
+        #   { groups = [ "5", "apples" ], index = 0, matched = "5 apples", },
+        #   { groups = [ "6", "pears" ], index = 10, matched = "6 pears", },
+        #   { groups = [ "0", "grapes" ], index = 22, matched = "0 grapes", }
+        # ]
+
         std.string.find_all "2" "123 123 123"
-          => [
-            { groups = [  ], index = 1, matched = "2", },
-            { groups = [  ], index = 5, matched = "2", },
-            { groups = [  ], index = 9, matched = "2", }
-          ]
+        # => [
+        #   { groups = [  ], index = 1, matched = "2", },
+        #   { groups = [  ], index = 5, matched = "2", },
+        #   { groups = [  ], index = 9, matched = "2", }
+        # ]
         ```
 
         # Performance
@@ -3729,15 +3839,18 @@
 
         # Examples
 
-        ```nickel
-        std.string.length "" =>
-          => 0
-        std.string.length "hi" =>
-          => 2
-        std.string.length "四字熟語" =>
-          => 4
-        std.string.length "👨🏾‍❤️‍💋‍👨🏻" =>
-          => 1
+        ```nickel multiline
+        std.string.length ""
+        # => 0
+
+        std.string.length "hi"
+        # => 2
+
+        std.string.length "四字熟語"
+        # => 4
+
+        std.string.length "👨🏾‍❤️‍💋‍👨🏻"
+        # => 1
         ```
       "%
       = fun s => %string/length% s,
@@ -3757,13 +3870,15 @@
 
         # Examples
 
-        ```nickel
-        std.string.substring 3 5 "abcdef" =>
-          "de"
-        std.string.substring 3 10 "abcdef" =>
-          error
-        std.string.substring (-3) 4 "abcdef" =>
-          error
+        ```nickel multiline
+        std.string.substring 3 5 "abcdef"
+        # => "de"
+
+        std.string.substring 3 10 "abcdef"
+        # => error
+
+        std.string.substring (-3) 4 "abcdef"
+        # => error
         ```
       "%
       = fun start end s => %string/substr% s start end,
@@ -3776,15 +3891,18 @@
 
         # Examples
 
-        ```nickel
+        ```nickel multiline
         std.string.from 42
-          => "42"
+        # => "42"
+
         std.string.from 'Foo
-          => "Foo"
+        # => "Foo"
+
         std.string.from null
-          => "null"
+        # => "null"
+
         std.string.from {value = 0}
-          => error
+        # => error
         ```
       "%
       = fun x => %to_string% x,
@@ -3798,7 +3916,7 @@
 
         ```nickel
         std.string.from_number 42
-          => "42"
+        # => "42"
         ```
       "%
       = from,
@@ -3813,7 +3931,7 @@
 
         ```nickel
         std.string.from_enum 'MyEnum
-          => "MyEnum"
+        # => "MyEnum"
         ```
       "%
       = from,
@@ -3827,7 +3945,7 @@
 
         ```nickel
         std.string.from_bool true
-          => "true"
+        # => "true"
         ```
       "%
       = from,
@@ -3842,7 +3960,7 @@
 
         ```nickel
         std.string.to_number "123"
-          => 123
+        # => 123
         ```
       "%
       = fun s => %number/from_string% s,
@@ -3855,11 +3973,12 @@
 
         # Examples
 
-        ```nickel
+        ```nickel multiline
         std.string.to_bool "true"
-          => true
+        # => true
+
         std.string.to_bool "false"
-          => false
+        # => false
         ```
       "%
       # because of the contract on the argument, `s` can only be `"true"` or
@@ -3873,11 +3992,12 @@
 
         # Examples
 
-        ```nickel
+        ```nickel multiline
         std.string.to_enum "Hello"
-          => 'Hello
+        # => 'Hello
+
         std.string.to_enum "hey,there!"
-          => '"hey,there!"
+        # => '"hey,there!"
         ```
       "%
       = fun s => %enum/from_string% s,
@@ -3890,12 +4010,12 @@
 
         # Examples
 
-        ```nickel
+        ```nickel multiline
         1 == 2 | std.test.Assert
-         => error: contract broken by a value
+        # => error: contract broken by a value
 
         1 == 1 | std.test.Assert
-         => true
+        # => true
         ```
         "%
       =
@@ -3919,7 +4039,7 @@
 
         ```nickel
         [ (1 == 2), (1 == 1), (1 == 3) ] |> std.test.assert_all
-         => error: contract broken by a value
+        # => error: contract broken by the caller
         ```
         "%
       # We mostly rely on the contracts to do the work here. We just need to
@@ -3934,11 +4054,12 @@
 
       # Examples
 
-      ```nickel
+      ```nickel multiline
       std.is_number 1
-        => true
+      # => true
+
       std.is_number "Hello, World!"
-        => false
+      # => false
       ```
     "%
     = fun x => %typeof% x == 'Number,
@@ -3950,11 +4071,12 @@
 
       # Examples
 
-      ```nickel
+      ```nickel multiline
       std.is_bool false
-        => true
+      # => true
+
       std.is_bool 42
-        => false
+      # => false
       ```
     "%
     = fun x => %typeof% x == 'Bool,
@@ -3966,11 +4088,12 @@
 
       # Examples
 
-      ```nickel
+      ```nickel multiline
       std.is_string true
-        => false
+      # => false
+
       std.is_string "Hello, World!"
-        => true
+      # => true
       ```
     "%
     = fun x => %typeof% x == 'String,
@@ -3982,11 +4105,12 @@
 
       # Examples
 
-      ```nickel
+      ```nickel multiline
       std.is_enum true
-        => false
-      std.is_enum `false
-        => true
+      # => false
+
+      std.is_enum 'false
+      # => true
       ```
     "%
     = fun x => %typeof% x == 'Enum,
@@ -3998,11 +4122,12 @@
 
       # Examples
 
-      ```nickel
+      ```nickel multiline
       std.is_function (fun x => x)
-        => true
+      # => true
+
       std.is_function 42
-        => false
+      # => false
       ```
     "%
     = fun x => %typeof% x == 'Function,
@@ -4014,11 +4139,12 @@
 
       # Examples
 
-      ```nickel
+      ```nickel multiline
       std.is_array [ 1, 2 ]
-        => true
+      # => true
+
       std.is_array 42
-        => false
+      # => false
       ```
     "%
     = fun x => %typeof% x == 'Array,
@@ -4030,11 +4156,12 @@
 
       # Examples
 
-      ```nickel
+      ```nickel multiline
       std.is_record [ 1, 2 ]
-        => false
+      # => false
+
       std.is_record { hello = "Hello", world = "World" }
-        => true
+      # => true
       ```
     "%
     = fun x => %typeof% x == 'Record,
@@ -4059,11 +4186,12 @@
 
       # Examples
 
-      ```nickel
+      ```nickel multiline
       std.typeof [ 1, 2 ]
-        => 'Array
+      # => 'Array
+
       std.typeof (fun x => x)
-        => 'Function
+      # => 'Function
       ```
     "%
     = fun x => %typeof% x,
@@ -4088,13 +4216,15 @@
 
       # Examples
 
-      ```nickel
+      ```nickel multiline
       std.seq (42 / 0) 37
-        => error
+      # => error
+
       std.seq (42 / 2) 37
-        => 37
+      # => 37
+
       std.seq { too_far = 42 / 0 } 37
-        => 37
+      # => 37
       ```
     "%
     = fun x y => %seq% x y,
@@ -4108,13 +4238,15 @@
 
       # Examples
 
-      ```nickel
+      ```nickel multiline
       std.deep_seq (42 / 0) 37
-        => error
+      # => error
+
       std.deep_seq (42 / 2) 37
-        => 37
+      # => 37
+
       std.deep_seq [1+1, { not_too_far = [42 / 0] }] 37
-        => error
+      # => error
       ```
     "%
     = fun x y => %deep_seq% x y,
@@ -4128,7 +4260,7 @@
 
       ```nickel
       std.hash 'Md5 "hunter2"
-        => "2ab96390c7dbe3439de74d0c9b0b1767"
+      # => "2ab96390c7dbe3439de74d0c9b0b1767"
       ```
     "%
     = fun type s => %hash% type s,
@@ -4141,11 +4273,8 @@
       # Examples
 
       ```nickel
-      serialize 'Json { hello = "Hello", world = "World" } =>
-        "{
-          \"hello\": \"Hello\",
-          \"world\": \"World\"
-        }"
+      serialize 'Json { hello = "Hello", world = "World" }
+      # => "{\n  \"hello\": \"Hello\",\n  \"world\": \"World\"\n}"
       ```
     "%
     = fun format x => %serialize% format (%force% x),
@@ -4159,7 +4288,7 @@
 
       ```nickel
       deserialize 'Json "{ \"hello\": \"Hello\", \"world\": \"World\" }"
-        { hello = "Hello", world = "World" }
+      # => { hello = "Hello", world = "World" }
       ```
     "%
     = fun format x => %deserialize% format x,
@@ -4172,13 +4301,15 @@
 
       # Examples
 
-      ```nickel
-      std.to_string 42 =>
-        "42"
-      std.to_string 'Foo =>
-        "Foo"
-      std.to_string null =>
-        "null"
+      ```nickel multiline
+      std.to_string 42
+      # => "42"
+
+      std.to_string 'Foo
+      # => "Foo"
+
+      std.to_string null
+      # => "null"
       ```
     "%
     = fun x => %to_string% x,
@@ -4193,8 +4324,8 @@
 
       ```nickel
       std.trace "Hello, world!" true
-        std.trace: Hello, world!
-        => true
+      # std.trace: Hello, world!
+      # => true
       ```
     "%
     = fun msg x => %trace% msg x,
@@ -4207,7 +4338,7 @@
 
       ```nickel
       1 | std.FailWith "message"
-        => error: contract broken by a value: message
+      # => error: message
       ```
     "%
     = fun msg =>
@@ -4223,7 +4354,7 @@
 
       ```nickel
       std.fail_with "message"
-        => error: contract broken by a value: message
+      # => error: message
       ```
     "%
     = fun msg => null | FailWith msg,
diff --git a/doc/manual/cookbook.md b/doc/manual/cookbook.md
index 83237dcf..6c21c855 100644
--- a/doc/manual/cookbook.md
+++ b/doc/manual/cookbook.md
@@ -44,3 +44,198 @@ record level. It makes code more navigable and `query`-friendly, but at the
 expense of repetition and duplicated contract checks. It is also currently
 required for polymorphic functions because of [the following
 bug](https://github.com/tweag/nickel/issues/360).
+
+## Unit/documentation tests
+
+With the `nickel test` command, nickel supports using documentation examples as
+tests. This command extracts markdown blocks in documentation metadata, and runs
+them as nickel snippets. For example, if the file "main.ncl" contains
+
+````nickel
+{
+  foo
+    | Number
+    | doc m%"
+    This is my field named foo.
+
+    ## Examples
+
+    ```nickel
+      1 + "2"
+    ```
+    "%
+}
+````
+
+then running `nickel test main.ncl` will fail with the error message
+
+```console
+testing foo/0...FAILED
+test foo/0 failed
+error: dynamic type error
+  ┌─ [..]/test.ncl:1:7
+  │
+1 │   1 + "2"
+  │       ^^^ this expression has type String, but Number was expected
+  │
+  = (+) expects its 2nd argument to be a Number
+
+1 failures
+error: tests failed
+```
+
+Only code blocks with the "nickel" tag are tested.
+
+### Testing for expected output
+
+In order to check that a test evaluates to a given value, terminate its
+code block with a comment like `# => <expected output>`. This is equivalent
+to applying a `std.contract.Equal <expected output>` contract, but might
+look better in the rendered documentation. For example, running
+`nickel test` on
+
+````nickel
+{
+  foo
+    | Number
+    | doc m%"
+    This is my field named foo.
+
+    ## Examples
+
+    ```nickel
+      1 + 1
+      # => 3
+    ```
+    "%
+}
+````
+
+will output
+
+```console
+testing foo/0...FAILED
+test foo/0 failed
+error: contract broken by a value
+  ┌─ <unknown> (generated by evaluation):1:1
+  │
+1 │ std.contract.Equal 3
+  │ -------------------- expected type
+  │
+  ┌─ /home/jneeman/tweag/nickel/test.ncl:1:3
+  │
+1 │   1 + 1
+  │   ^^^^^ applied to this expression
+  │
+  ┌─ <unknown> (generated by evaluation):1:1
+  │
+1 │ 2
+  │ - evaluated to this value
+
+1 failures
+error: tests failed
+```
+
+### Checking for errors
+
+Sometimes you want a test case to ensure that errors are raised when
+appropriate. You can check for this by terminating a code block with
+a comment like `# => error: <expected error text>`, and the test runner
+will check that the example raises and error, and that the error message
+contains "\<expected error text\>" as a substring. To test for an error
+without checking the error message, terminate a code block with `# => error`.
+
+For example,
+
+````nickel
+{
+  foo
+    | Number
+    | doc m%"
+    This is my field named foo.
+
+    ## Examples
+
+    ```nickel
+      1 + "2"
+      # => error: has type String, but Number was expected
+    ```
+    "%
+}
+````
+
+### Ignoring tests
+
+To ignore a test while still keeping the code block tagged as nickel
+source, add the "ignore" label, like
+
+````text
+```nickel ignore
+1 | String
+```
+````
+
+### Multiline tests
+
+If you have many short examples, you might prefer not to wrap each
+one in a separate code block. In this case, you can use the "multiline"
+label to create multiple blank-line-separated tests in a single code block.
+For example,
+
+````nickel
+{
+  foo
+    | Number
+    | doc m%"
+    This is my field named foo.
+
+    ## Examples
+
+    ```nickel multiline
+      1 + "2"
+      # => error: has type String, but Number was expected
+
+      1 + 1
+      # => 2
+
+      1 + 2
+      # => 3
+    ```
+    "%
+}
+````
+
+### The test expression's environment
+
+Each test expression will be evaluated in the same environment as the field it's
+documenting: the name of the field will be in scope, along with the names of all
+the sibling fields.
+
+For example, the following tests will succeed:
+
+````nickel
+{
+  bar
+    | Number
+    = 2,
+
+  foo
+    | Number
+    | doc m%"
+    This is my field named foo.
+
+    ## Examples
+
+    ```nickel
+      foo
+      # => 1
+    ```
+
+    ```nickel
+      foo + bar
+      # => 3
+    ```
+    "%
+    = 1,
+}
+````
diff --git a/doc/manual/typing.md b/doc/manual/typing.md
index b42fbd6a..977973e9 100644
--- a/doc/manual/typing.md
+++ b/doc/manual/typing.md
@@ -688,9 +688,9 @@ calling to the statically typed `std.array.filter` from dynamically typed code:
 ```nickel #repl
 > std.array.filter (fun x => if x % 2 == 0 then x else null) [1,2,3,4,5,6]
 error: contract broken by the caller of `filter`
-    ┌─ <stdlib/std.ncl>:427:25
+    ┌─ <stdlib/std.ncl>:431:25
     │
-427 │       : forall a. (a -> Bool) -> Array a -> Array a
+431 │       : forall a. (a -> Bool) -> Array a -> Array a
     │                         ---- expected return type of a function provided by the caller
     │
     ┌─ <repl-input-6>:1:55


Test patch:
diff --git a/cli/src/doctest.rs b/cli/src/doctest.rs
new file mode 100644
index 00000000..0bde75a7
--- /dev/null
+++ b/cli/src/doctest.rs
@@ -0,0 +1,482 @@
+//! The `nickel test` command.
+//!
+//! Extracts tests from docstrings and evaluates them, printing out any failures.
+
+use std::{collections::HashMap, io::Write as _, path::PathBuf, rc::Rc};
+
+use comrak::{arena_tree::NodeEdge, nodes::AstNode, Arena, ComrakOptions};
+use nickel_lang_core::{
+    cache::{Cache, ImportResolver, InputFormat, SourcePath},
+    error::{Error as CoreError, EvalError},
+    eval::{cache::CacheImpl, Closure, Environment},
+    identifier::{Ident, LocIdent},
+    label::Label,
+    match_sharedterm, mk_app, mk_fun,
+    program::Program,
+    term::{
+        make, record::RecordData, LabeledType, RichTerm, Term, Traverse as _, TraverseOrder,
+        TypeAnnotation,
+    },
+    typ::{Type, TypeF},
+};
+use once_cell::sync::Lazy;
+use regex::Regex;
+
+use crate::{
+    cli::GlobalOptions,
+    customize::ExtractFieldOnly,
+    error::CliResult,
+    input::{InputOptions, Prepare},
+};
+
+#[derive(clap::Parser, Debug)]
+pub struct TestCommand {
+    #[command(flatten)]
+    pub input: InputOptions<ExtractFieldOnly>,
+}
+
+/// The expected outcome of a test.
+#[derive(Debug)]
+enum Expected {
+    /// The test is expected to evaluate (without errors) to a specific value.
+    ///
+    /// The string here will be parsed into a nickel term, and then wrapped in a `std.contract.Equal`
+    /// contract to provide a nice error message.
+    Value(String),
+    /// The test is expected to raise an error, and the error message is expected to contain
+    /// this string as a substring.
+    Error(String),
+    /// The test is expected to evaluate without errors, but we don't care what it evaluates to.
+    None,
+}
+
+impl Expected {
+    /// Parse out an expected outcome from a doctest.
+    ///
+    /// After ignoring whitespace-only trailing lines, we look for the last comment block in the doctest.
+    /// If that comment block has a line starting (modulo whitespace) with "=>", everything following
+    /// the "=>" is the expected value of the doctest.
+    ///
+    /// There are two special cases for tests that are expected to fail:
+    /// - if the "=>" line looks like "=> error: some text", the test is expected to exit with an error,
+    ///   and the error message is supposed to contain "some text".
+    /// - if the "=>" line looks like "=> error", the test is expected to exit with an error (but we
+    ///   don't care what the message is.
+    fn extract(doctest: &str) -> Self {
+        let mut lines: Vec<&str> = doctest.lines().collect();
+
+        // Throw away trailing empty lines.
+        let last_non_empty = lines
+            .iter()
+            .rposition(|line| !line.trim().is_empty())
+            .unwrap_or(0);
+        lines.truncate(last_non_empty + 1);
+
+        let mut expected = Vec::new();
+        for line in lines.iter().rev() {
+            // If we encounter an uncommented line before we find a "=>", there's no expected value
+            // for this test.
+            let Some(commented) = line.trim_start().strip_prefix('#') else {
+                break;
+            };
+
+            if let Some(arrowed) = commented.trim_start().strip_prefix("=>") {
+                // We've found an expected value for the test.
+                if let Some(msg) = arrowed.trim_start().strip_prefix("error:") {
+                    expected.push(msg.trim());
+                    expected.reverse();
+                    return Expected::Error(expected.join("\n"));
+                } else if arrowed.trim() == "error" {
+                    return Expected::Error(String::new());
+                } else {
+                    expected.push(arrowed);
+                    expected.reverse();
+                    return Expected::Value(expected.join("\n"));
+                }
+            } else {
+                expected.push(commented);
+            }
+        }
+
+        Expected::None
+    }
+
+    fn error(&self) -> Option<String> {
+        match self {
+            Expected::Error(s) => Some(s.clone()),
+            _ => None,
+        }
+    }
+}
+
+#[derive(Debug)]
+struct DocTest {
+    input: String,
+    expected: Expected,
+}
+
+struct TestEntry {
+    expected_error: Option<String>,
+    field_name: LocIdent,
+    test_idx: usize,
+}
+
+#[derive(Default)]
+struct TestRegistry {
+    tests: HashMap<Ident, TestEntry>,
+}
+
+impl DocTest {
+    fn new(input: String) -> Self {
+        let expected = Expected::extract(&input);
+        DocTest { input, expected }
+    }
+}
+
+struct Error {
+    /// The record path to the field whose doctest triggered this error.
+    path: Vec<LocIdent>,
+    /// The field whose doctest triggered this error might have multiple tests in its
+    /// doc metadata. This is the index of the failing test.
+    idx: usize,
+    kind: ErrorKind,
+}
+
+enum ErrorKind {
+    /// A doctest was expected to succeed, but it failed.
+    UnexpectedFailure { error: EvalError },
+    /// A doctest was expected to fail, but instead it succeeded.
+    UnexpectedSuccess { result: RichTerm },
+    /// A doctest failed with an unexpected message.
+    WrongTestFailure { message: String, expected: String },
+}
+
+// Go through the record spine, running tests one-by-one.
+//
+// `spine` is the already-evaluated record spine. It was previously transformed
+// with [`doctest_transform`], so all the tests are present in the record spine.
+// They've already been closurized with the correct environment.
+fn run_tests(
+    path: &mut Vec<LocIdent>,
+    prog: &mut Program<CacheImpl>,
+    errors: &mut Vec<Error>,
+    registry: &TestRegistry,
+    spine: &RichTerm,
+) {
+    match spine.as_ref() {
+        Term::Record(data) | Term::RecRecord(data, ..) => {
+            for (id, field) in &data.fields {
+                if let Some(entry) = registry.tests.get(&id.ident()) {
+                    let Some(val) = field.value.as_ref() else {
+                        continue;
+                    };
+
+                    path.push(entry.field_name);
+                    let path_display: Vec<_> = path.iter().map(|id| id.label()).collect();
+
+                    print!("testing {}/{}...", path_display.join("."), entry.test_idx);
+                    let _ = std::io::stdout().flush();
+
+                    // Undo the test's lazy wrapper.
+                    let result = prog.eval_closure(Closure {
+                        body: mk_app!(val.clone(), Term::Null),
+                        env: Environment::new(),
+                    });
+
+                    let err = match result {
+                        Ok(v) => {
+                            if entry.expected_error.is_some() {
+                                Some(ErrorKind::UnexpectedSuccess { result: v })
+                            } else {
+                                None
+                            }
+                        }
+                        Err(e) => {
+                            if let Some(expected) = &entry.expected_error {
+                                let message = prog.report_as_str(e);
+                                if !message.contains(expected) {
+                                    Some(ErrorKind::WrongTestFailure {
+                                        message,
+                                        expected: expected.clone(),
+                                    })
+                                } else {
+                                    None
+                                }
+                            } else {
+                                Some(ErrorKind::UnexpectedFailure { error: e })
+                            }
+                        }
+                    };
+                    if let Some(err) = err {
+                        println!("FAILED");
+                        errors.push(Error {
+                            kind: err,
+                            path: path.clone(),
+                            idx: entry.test_idx,
+                        });
+                    } else {
+                        println!("ok");
+                    }
+                    path.pop();
+                } else if let Some(val) = field.value.as_ref() {
+                    path.push(*id);
+                    run_tests(path, prog, errors, registry, val);
+                    path.pop();
+                }
+            }
+        }
+        _ => {}
+    }
+}
+
+impl TestCommand {
+    pub fn run(self, global: GlobalOptions) -> CliResult<()> {
+        let mut program = self.input.prepare(&global)?;
+
+        let (spine, registry) = match self.prepare_tests(&mut program) {
+            Ok(x) => x,
+            Err(error) => return Err(crate::error::Error::Program { program, error }),
+        };
+
+        let mut path = Vec::new();
+        let mut errors = Vec::new();
+        run_tests(&mut path, &mut program, &mut errors, &registry, &spine);
+
+        let num_errors = errors.len();
+        for e in errors {
+            let path_display: Vec<_> = e.path.iter().map(|id| id.label()).collect();
+            let path_display = path_display.join(".");
+            match e.kind {
+                ErrorKind::UnexpectedSuccess { result } => {
+                    println!(
+                        "test {}/{} succeeded (evaluated to {result}), but it should have failed",
+                        path_display, e.idx
+                    );
+                }
+                ErrorKind::WrongTestFailure { message, expected } => {
+                    println!(
+                        "test {}/{} failed, but the error didn't contain \"{expected}\". Actual error:\n{}",
+                        path_display, e.idx, message,
+                    );
+                }
+                ErrorKind::UnexpectedFailure { error } => {
+                    println!("test {}/{} failed", path_display, e.idx);
+                    program.report_to_stdout(
+                        error,
+                        nickel_lang_core::error::report::ErrorFormat::Text,
+                    );
+                }
+            }
+        }
+
+        if num_errors > 0 {
+            eprintln!("{num_errors} failures");
+            Err(crate::error::Error::FailedTests)
+        } else {
+            Ok(())
+        }
+    }
+
+    fn prepare_tests(
+        self,
+        program: &mut Program<CacheImpl>,
+    ) -> Result<(RichTerm, TestRegistry), CoreError> {
+        let mut registry = TestRegistry::default();
+        program.typecheck()?;
+        program
+            .custom_transform(|cache, rt| doctest_transform(cache, &mut registry, rt))
+            .map_err(|e| e.unwrap_error("transforming doctest"))?;
+        Ok((program.eval_closurized_record_spine()?, registry))
+    }
+}
+
+/// Extract all the nickel code blocks from a single doc comment.
+fn nickel_code_blocks<'a>(document: &'a AstNode<'a>) -> Vec<DocTest> {
+    use comrak::arena_tree::Node;
+    use comrak::nodes::{Ast, NodeCodeBlock, NodeValue};
+    document
+        .traverse()
+        .flat_map(|ne| match ne {
+            // Question: can we extract enough location information so that
+            // we can munge the parsed AST to point into the doc comment?
+            NodeEdge::Start(Node { data, .. }) => match &*data.borrow() {
+                Ast {
+                    value: NodeValue::CodeBlock(NodeCodeBlock { info, literal, .. }),
+                    ..
+                } => info
+                    .strip_prefix("nickel")
+                    .map(|tag| match tag.trim() {
+                        "ignore" => Vec::new(),
+                        "multiline" => {
+                            static BLANK_LINE: Lazy<Regex> =
+                                Lazy::new(|| Regex::new("\n\\s*\n").unwrap());
+                            BLANK_LINE
+                                .split(literal)
+                                .filter_map(|chunk| {
+                                    if !chunk.trim().is_empty() {
+                                        Some(DocTest::new(chunk.to_owned()))
+                                    } else {
+                                        None
+                                    }
+                                })
+                                .collect()
+                        }
+                        _ => vec![DocTest::new(literal.to_owned())],
+                    })
+                    .unwrap_or_default(),
+                _ => vec![],
+            },
+            _ => vec![],
+        })
+        .collect()
+}
+
+// Transform a term by taking all its doctests and inserting them into the record next
+// to the field that they're annotating.
+//
+// For example,
+// {
+//   field | doc m%"
+//     ```nickel
+//       1 + 1
+//     ```
+//   "%
+// }
+// becomes
+// {
+//   field | doc m%"
+//     ```nickel
+//       1 + 1
+//     ```
+//   "%,
+//   %0 = fun %1 => 1 + 1,
+// }
+//
+// The idea is for the test to be evaluated in the same environment as the
+// field that declares it. We wrap the test in a function so that it doesn't get
+// evaluated too soon.
+//
+// The generated test field ids (i.e. `%0` in the example above) are collected
+// in `registry` so that a later pass can go through and evaluate them.
+//
+// One disadvantage with this traversal approach is that any parse errors in
+// the test will be encountered as soon as we explore the record spine. We might
+// prefer to delay parsing the tests until it's time to evaluate them.
+// The main advantage of this approach is that it makes it easy to have the test
+// evaluated in the right environment.
+fn doctest_transform(
+    cache: &mut Cache,
+    registry: &mut TestRegistry,
+    rt: RichTerm,
+) -> Result<RichTerm, CoreError> {
+    // Get the path that of the current term, so we can pretend that test snippets
+    // came from the same path. This allows imports to work.
+    let path = rt
+        .pos
+        .as_opt_ref()
+        .and_then(|sp| cache.get_path(sp.src_id))
+        .map(PathBuf::from);
+
+    let source_path = match path {
+        Some(p) => SourcePath::Snippet(p),
+        None => SourcePath::Generated("test".to_owned()),
+    };
+
+    // Prepare a test snippet. Skips typechecking and transformations, because
+    // the returned term will get inserted into a bigger term that will be
+    // typechecked and transformed.
+    fn prepare(
+        cache: &mut Cache,
+        input: &str,
+        source_path: &SourcePath,
+    ) -> Result<RichTerm, CoreError> {
+        let src_id = cache.add_string(source_path.clone(), input.to_owned());
+        cache.parse(src_id, InputFormat::Nickel)?;
+        // We could probably skip import resolution here also, but `Cache::get` insists
+        // that imports be resolved.
+        cache
+            .resolve_imports(src_id)
+            .map_err(|e| e.unwrap_error("test snippet"))?;
+        // unwrap: we just populated it
+        Ok(cache.get(src_id).unwrap())
+    }
+
+    let mut record_with_doctests =
+        |mut record_data: RecordData, dyn_fields, pos| -> Result<_, CoreError> {
+            let mut doc_fields: Vec<(Ident, RichTerm)> = Vec::new();
+            for (id, field) in &record_data.fields {
+                if let Some(doc) = &field.metadata.doc {
+                    let arena = Arena::new();
+                    let snippets = nickel_code_blocks(comrak::parse_document(
+                        &arena,
+                        doc,
+                        &ComrakOptions::default(),
+                    ));
+
+                    for (i, snippet) in snippets.iter().enumerate() {
+                        let mut test_term = prepare(cache, &snippet.input, &source_path)?;
+
+                        if let Expected::Value(s) = &snippet.expected {
+                            // Create the contract `std.contract.Equal <expected>` and apply it to the
+                            // test term.
+                            let expected_term = prepare(cache, s, &source_path)?;
+                            // unwrap: we just parsed it, so it will have a span
+                            let expected_span = expected_term.pos.into_opt().unwrap();
+
+                            let eq = make::static_access(
+                                RichTerm::from(Term::Var("std".into())),
+                                ["contract", "Equal"],
+                            );
+                            let eq = mk_app!(eq, expected_term);
+                            let eq_ty = Type::from(TypeF::Flat(eq));
+                            test_term = Term::Annotated(
+                                TypeAnnotation {
+                                    typ: None,
+                                    contracts: vec![LabeledType {
+                                        typ: eq_ty.clone(),
+                                        label: Label {
+                                            typ: Rc::new(eq_ty),
+                                            span: expected_span,
+                                            ..Default::default()
+                                        },
+                                    }],
+                                },
+                                test_term,
+                            )
+                            .into();
+                        }
+
+                        // Make the test term lazy, so that the tests don't automatically get evaluated
+                        // just by evaluating the record spine.
+                        let test_term = mk_fun!(LocIdent::fresh(), test_term);
+                        let test_id = LocIdent::fresh().ident();
+                        let entry = TestEntry {
+                            expected_error: snippet.expected.error(),
+                            field_name: *id,
+                            test_idx: i,
+                        };
+                        registry.tests.insert(test_id, entry);
+                        doc_fields.push((test_id, test_term));
+                    }
+                }
+            }
+            for (id, term) in doc_fields {
+                record_data.fields.insert(id.into(), term.into());
+            }
+            Ok(RichTerm::from(Term::RecRecord(record_data, dyn_fields, None)).with_pos(pos))
+        };
+
+    let mut traversal = |rt: RichTerm| -> Result<RichTerm, CoreError> {
+        let term = match_sharedterm!(match (rt.term) {
+            Term::RecRecord(record_data, dyn_fields, _deps) => {
+                record_with_doctests(record_data, dyn_fields, rt.pos)?
+            }
+            Term::Record(record_data) => {
+                record_with_doctests(record_data, Vec::new(), rt.pos)?
+            }
+            _ => rt,
+        });
+        Ok(term)
+    };
+    rt.traverse(&mut traversal, TraverseOrder::TopDown)
+}
diff --git a/cli/tests/snapshot/inputs/doctest/fail_expected_error.ncl b/cli/tests/snapshot/inputs/doctest/fail_expected_error.ncl
new file mode 100644
index 00000000..7eea6bef
--- /dev/null
+++ b/cli/tests/snapshot/inputs/doctest/fail_expected_error.ncl
@@ -0,0 +1,27 @@
+# capture = 'all'
+# command = ['test', '--color', 'never']
+{
+  foo
+    | doc m%"
+    ```nickel
+    foo
+    # => error
+    ```
+
+    ```nickel
+    foo + "1"
+    # => error: wrong message
+    ```
+
+    There can be multiple tests in a single code block
+
+    ```nickel multiline
+    foo
+    # => error
+
+    1
+    # => error
+    ```
+    "%
+    = 1,
+}
diff --git a/cli/tests/snapshot/inputs/doctest/fail_unexpected_error.ncl b/cli/tests/snapshot/inputs/doctest/fail_unexpected_error.ncl
new file mode 100644
index 00000000..20ca85c3
--- /dev/null
+++ b/cli/tests/snapshot/inputs/doctest/fail_unexpected_error.ncl
@@ -0,0 +1,16 @@
+# capture = 'all'
+# command = ['test']
+{
+  foo
+    | doc m%"
+    ```nickel
+    foo + "1"
+    ```
+
+    ```nickel
+    foo + "1"
+    # => 2
+    ```
+    "%
+    = 1,
+}
diff --git a/cli/tests/snapshot/inputs/doctest/fail_wrong_output.ncl b/cli/tests/snapshot/inputs/doctest/fail_wrong_output.ncl
new file mode 100644
index 00000000..cb299bcb
--- /dev/null
+++ b/cli/tests/snapshot/inputs/doctest/fail_wrong_output.ncl
@@ -0,0 +1,31 @@
+# capture = 'all'
+# command = ['test']
+{
+  foo
+    | doc m%"
+    ```nickel
+    foo
+    # => 2
+    ```
+
+    The output block can be split over multiple lines.
+
+    ```nickel
+    foo
+    # => (
+    # 2
+    # )
+    ```
+
+    There can be multiple tests in a code block.
+
+    ```nickel multiline
+    foo
+    # => 2
+
+    foo + 1
+    # => 3
+    ```
+    "%
+    = 1,
+}
diff --git a/cli/tests/snapshot/inputs/doctest/pass.ncl b/cli/tests/snapshot/inputs/doctest/pass.ncl
new file mode 100644
index 00000000..869c5616
--- /dev/null
+++ b/cli/tests/snapshot/inputs/doctest/pass.ncl
@@ -0,0 +1,50 @@
+# capture = 'all'
+# command = ['test']
+{
+  foo
+    | doc m%"
+    Here is some documentation with a test.
+
+    ```nickel
+    foo
+    ```
+
+    The next test actually checks the value.
+    ```nickel
+    foo
+    # => 1
+    ```
+
+    The trailing block can have multiple lines.
+    ```nickel
+    foo
+    # => (
+    # 1
+    # )
+    ```
+
+    There can be multiple tests in a single code block
+    ```nickel multiline
+    foo
+    # => 1
+
+    1
+    # => 1
+    ```
+    "%
+    = 1,
+
+  bar
+    | doc m%"
+    ```nickel
+    foo + "1"
+    # => error: this expression has type String, but Number was expected
+    ```
+
+    We can also test for an error without specifying the message.
+    ```nickel
+    foo + "1"
+    # => error
+    ```
+    "%
+}
diff --git a/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_at_empty_array.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_at_empty_array.ncl.snap
index ab6e249b..65b7d36f 100644
--- a/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_at_empty_array.ncl.snap
+++ b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_at_empty_array.ncl.snap
@@ -4,9 +4,9 @@ expression: err
 ---
 error: contract broken by the caller of `at`
        invalid array indexing
-    ┌─ <stdlib/std.ncl>:164:9
+    ┌─ <stdlib/std.ncl>:165:9
     │
-164 │       | std.contract.unstable.IndexedArrayFun 'Index
+165 │       | std.contract.unstable.IndexedArrayFun 'Index
     │         -------------------------------------------- expected type
     │
     ┌─ [INPUTS_PATH]/errors/array_at_empty_array.ncl:3:16
diff --git a/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_at_out_of_bound.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_at_out_of_bound.ncl.snap
index 1f42c95a..454d46b5 100644
--- a/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_at_out_of_bound.ncl.snap
+++ b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_at_out_of_bound.ncl.snap
@@ -4,9 +4,9 @@ expression: err
 ---
 error: contract broken by the caller of `at`
        invalid array indexing
-    ┌─ <stdlib/std.ncl>:164:9
+    ┌─ <stdlib/std.ncl>:165:9
     │
-164 │       | std.contract.unstable.IndexedArrayFun 'Index
+165 │       | std.contract.unstable.IndexedArrayFun 'Index
     │         -------------------------------------------- expected type
     │
     ┌─ [INPUTS_PATH]/errors/array_at_out_of_bound.ncl:3:16
diff --git a/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_range_reversed_indices.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_range_reversed_indices.ncl.snap
index a06c9f12..7408ee1e 100644
--- a/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_range_reversed_indices.ncl.snap
+++ b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_range_reversed_indices.ncl.snap
@@ -4,9 +4,9 @@ expression: err
 ---
 error: contract broken by the caller of `range`
        invalid range
-    ┌─ <stdlib/std.ncl>:755:9
+    ┌─ <stdlib/std.ncl>:769:9
     │
-755 │       | std.contract.unstable.RangeFun Dyn
+769 │       | std.contract.unstable.RangeFun Dyn
     │         ---------------------------------- expected type
     │
     ┌─ [INPUTS_PATH]/errors/array_range_reversed_indices.ncl:3:19
diff --git a/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_range_step_negative_step.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_range_step_negative_step.ncl.snap
index a0750a16..ad589a24 100644
--- a/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_range_step_negative_step.ncl.snap
+++ b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_array_range_step_negative_step.ncl.snap
@@ -4,9 +4,9 @@ expression: err
 ---
 error: contract broken by the caller of `range_step`
        invalid range step
-    ┌─ <stdlib/std.ncl>:730:9
+    ┌─ <stdlib/std.ncl>:744:9
     │
-730 │       | std.contract.unstable.RangeFun (std.contract.unstable.RangeStep -> Dyn)
+744 │       | std.contract.unstable.RangeFun (std.contract.unstable.RangeStep -> Dyn)
     │         ----------------------------------------------------------------------- expected type
     │
     ┌─ [INPUTS_PATH]/errors/array_range_step_negative_step.ncl:3:27
diff --git a/cli/tests/snapshot/snapshots/snapshot__eval_stderr_caller_contract_violation.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_caller_contract_violation.ncl.snap
index e16b46d0..8a26cdc5 100644
--- a/cli/tests/snapshot/snapshots/snapshot__eval_stderr_caller_contract_violation.ncl.snap
+++ b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_caller_contract_violation.ncl.snap
@@ -4,9 +4,9 @@ expression: err
 ---
 error: contract broken by the caller of `map`
        expected an array
-    ┌─ <stdlib/std.ncl>:148:33
+    ┌─ <stdlib/std.ncl>:149:33
     │
-148 │       : forall a b. (a -> b) -> Array a -> Array b
+149 │       : forall a b. (a -> b) -> Array a -> Array b
     │                                 ------- expected type of the argument provided by the caller
     │
     ┌─ [INPUTS_PATH]/errors/caller_contract_violation.ncl:3:31
diff --git a/cli/tests/snapshot/snapshots/snapshot__test_stderr_fail_expected_error.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__test_stderr_fail_expected_error.ncl.snap
new file mode 100644
index 00000000..20fd1565
--- /dev/null
+++ b/cli/tests/snapshot/snapshots/snapshot__test_stderr_fail_expected_error.ncl.snap
@@ -0,0 +1,6 @@
+---
+source: cli/tests/snapshot/main.rs
+expression: err
+---
+4 failures
+error: tests failed
diff --git a/cli/tests/snapshot/snapshots/snapshot__test_stderr_fail_unexpected_error.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__test_stderr_fail_unexpected_error.ncl.snap
new file mode 100644
index 00000000..51be7e10
--- /dev/null
+++ b/cli/tests/snapshot/snapshots/snapshot__test_stderr_fail_unexpected_error.ncl.snap
@@ -0,0 +1,6 @@
+---
+source: cli/tests/snapshot/main.rs
+expression: err
+---
+2 failures
+error: tests failed
diff --git a/cli/tests/snapshot/snapshots/snapshot__test_stderr_fail_wrong_output.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__test_stderr_fail_wrong_output.ncl.snap
new file mode 100644
index 00000000..20fd1565
--- /dev/null
+++ b/cli/tests/snapshot/snapshots/snapshot__test_stderr_fail_wrong_output.ncl.snap
@@ -0,0 +1,6 @@
+---
+source: cli/tests/snapshot/main.rs
+expression: err
+---
+4 failures
+error: tests failed
diff --git a/cli/tests/snapshot/snapshots/snapshot__test_stderr_pass.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__test_stderr_pass.ncl.snap
new file mode 100644
index 00000000..7e3d9f24
--- /dev/null
+++ b/cli/tests/snapshot/snapshots/snapshot__test_stderr_pass.ncl.snap
@@ -0,0 +1,5 @@
+---
+source: cli/tests/snapshot/main.rs
+expression: err
+---
+
diff --git a/cli/tests/snapshot/snapshots/snapshot__test_stdout_fail_expected_error.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__test_stdout_fail_expected_error.ncl.snap
new file mode 100644
index 00000000..c35cb20a
--- /dev/null
+++ b/cli/tests/snapshot/snapshots/snapshot__test_stdout_fail_expected_error.ncl.snap
@@ -0,0 +1,21 @@
+---
+source: cli/tests/snapshot/main.rs
+expression: out
+---
+testing foo/0...FAILED
+testing foo/1...FAILED
+testing foo/2...FAILED
+testing foo/3...FAILED
+test foo/0 succeeded (evaluated to 1), but it should have failed
+test foo/1 failed, but the error didn't contain "wrong message". Actual error:
+error: dynamic type error
+  ┌─ [INPUTS_PATH]/doctest/fail_expected_error.ncl:1:7
+  │
+1 │ foo + "1"
+  │       ^^^ this expression has type String, but Number was expected
+  │
+  = (+) expects its 2nd argument to be a Number
+
+
+test foo/2 succeeded (evaluated to 1), but it should have failed
+test foo/3 succeeded (evaluated to 1), but it should have failed
diff --git a/cli/tests/snapshot/snapshots/snapshot__test_stdout_fail_unexpected_error.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__test_stdout_fail_unexpected_error.ncl.snap
new file mode 100644
index 00000000..077f041d
--- /dev/null
+++ b/cli/tests/snapshot/snapshots/snapshot__test_stdout_fail_unexpected_error.ncl.snap
@@ -0,0 +1,23 @@
+---
+source: cli/tests/snapshot/main.rs
+expression: out
+---
+testing foo/0...FAILED
+testing foo/1...FAILED
+test foo/0 failed
+error: dynamic type error
+  ┌─ [INPUTS_PATH]/doctest/fail_unexpected_error.ncl:1:7
+  │
+1 │ foo + "1"
+  │       ^^^ this expression has type String, but Number was expected
+  │
+  = (+) expects its 2nd argument to be a Number
+
+test foo/1 failed
+error: dynamic type error
+  ┌─ [INPUTS_PATH]/doctest/fail_unexpected_error.ncl:1:7
+  │
+1 │ foo + "1"
+  │       ^^^ this expression has type String, but Number was expected
+  │
+  = (+) expects its 2nd argument to be a Number
diff --git a/cli/tests/snapshot/snapshots/snapshot__test_stdout_fail_wrong_output.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__test_stdout_fail_wrong_output.ncl.snap
new file mode 100644
index 00000000..a25de89d
--- /dev/null
+++ b/cli/tests/snapshot/snapshots/snapshot__test_stdout_fail_wrong_output.ncl.snap
@@ -0,0 +1,75 @@
+---
+source: cli/tests/snapshot/main.rs
+expression: out
+---
+testing foo/0...FAILED
+testing foo/1...FAILED
+testing foo/2...FAILED
+testing foo/3...FAILED
+test foo/0 failed
+error: contract broken by a value
+   ┌─ <unknown> (generated by evaluation):1:1
+   │
+ 1 │ std.contract.Equal 2
+   │ -------------------- expected type
+   │
+   ┌─ [INPUTS_PATH]/doctest/fail_wrong_output.ncl:1:1
+   │
+ 1 │ foo
+   │ ^^^ applied to this expression
+   │
+   ┌─ [INPUTS_PATH]/doctest/fail_wrong_output.ncl:30:7
+   │
+30 │     = 1,
+   │       - evaluated to this expression
+
+test foo/1 failed
+error: contract broken by a value
+   ┌─ <unknown> (generated by evaluation):1:1
+   │
+ 1 │ std.contract.Equal 2
+   │ -------------------- expected type
+   │
+   ┌─ [INPUTS_PATH]/doctest/fail_wrong_output.ncl:1:1
+   │
+ 1 │ foo
+   │ ^^^ applied to this expression
+   │
+   ┌─ [INPUTS_PATH]/doctest/fail_wrong_output.ncl:30:7
+   │
+30 │     = 1,
+   │       - evaluated to this expression
+
+test foo/2 failed
+error: contract broken by a value
+   ┌─ <unknown> (generated by evaluation):1:1
+   │
+ 1 │ std.contract.Equal 2
+   │ -------------------- expected type
+   │
+   ┌─ [INPUTS_PATH]/doctest/fail_wrong_output.ncl:1:1
+   │
+ 1 │ foo
+   │ ^^^ applied to this expression
+   │
+   ┌─ [INPUTS_PATH]/doctest/fail_wrong_output.ncl:30:7
+   │
+30 │     = 1,
+   │       - evaluated to this expression
+
+test foo/3 failed
+error: contract broken by a value
+  ┌─ <unknown> (generated by evaluation):1:1
+  │
+1 │ std.contract.Equal 3
+  │ -------------------- expected type
+  │
+  ┌─ [INPUTS_PATH]/doctest/fail_wrong_output.ncl:1:1
+  │
+1 │ foo + 1
+  │ ^^^^^^^ applied to this expression
+  │
+  ┌─ <unknown> (generated by evaluation):1:1
+  │
+1 │ 2
+  │ - evaluated to this value
diff --git a/cli/tests/snapshot/snapshots/snapshot__test_stdout_pass.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__test_stdout_pass.ncl.snap
new file mode 100644
index 00000000..2cdfce27
--- /dev/null
+++ b/cli/tests/snapshot/snapshots/snapshot__test_stdout_pass.ncl.snap
@@ -0,0 +1,11 @@
+---
+source: cli/tests/snapshot/main.rs
+expression: out
+---
+testing foo/0...ok
+testing foo/1...ok
+testing foo/2...ok
+testing foo/3...ok
+testing foo/4...ok
+testing bar/0...ok
+testing bar/1...ok
diff --git a/core/tests/integration/inputs/imports/recursive.ncl b/core/tests/integration/inputs/imports/recursive.ncl
new file mode 100644
index 00000000..15dce7d0
--- /dev/null
+++ b/core/tests/integration/inputs/imports/recursive.ncl
@@ -0,0 +1,5 @@
+# test.type = 'error'
+#
+# [test.metadata]
+# error = 'EvalError::InfiniteRecursion'
+let x = import "./recursive.ncl" in x
diff --git a/lsp/nls/tests/snapshots/main__lsp__nls__tests__inputs__hover-stdlib-type.ncl.snap b/lsp/nls/tests/snapshots/main__lsp__nls__tests__inputs__hover-stdlib-type.ncl.snap
index 5b7d5aad..c20eb1c5 100644
--- a/lsp/nls/tests/snapshots/main__lsp__nls__tests__inputs__hover-stdlib-type.ncl.snap
+++ b/lsp/nls/tests/snapshots/main__lsp__nls__tests__inputs__hover-stdlib-type.ncl.snap
@@ -11,7 +11,7 @@ String -> Number
 
 ```nickel
 std.string.to_number "123"
-  => 123
+# => 123
 ```, ```nickel
 NumberLiteral -> Dyn
 ```, ```nickel
diff --git a/lsp/nls/tests/snapshots/main__lsp__nls__tests__inputs__hover_field_typed_block_regression_1574.ncl.snap b/lsp/nls/tests/snapshots/main__lsp__nls__tests__inputs__hover_field_typed_block_regression_1574.ncl.snap
index 2e9e5234..710e6c9c 100644
--- a/lsp/nls/tests/snapshots/main__lsp__nls__tests__inputs__hover_field_typed_block_regression_1574.ncl.snap
+++ b/lsp/nls/tests/snapshots/main__lsp__nls__tests__inputs__hover_field_typed_block_regression_1574.ncl.snap
@@ -10,8 +10,8 @@ expression: output
 # Examples
 
 ```nickel
-std.array.map (fun x => x + 1) [ 1, 2, 3 ] =>
-  [ 2, 3, 4 ]
+std.array.map (fun x => x + 1) [ 1, 2, 3 ]
+# => [ 2, 3, 4 ]
 ```, ```nickel
 forall a b. (a -> b) -> Array a -> Array b
 ```]