Repository: tweag/nickel
Pull request #2020 (2024-08-01 05:47:07)

Adds a `nickel test` subcommand for testing examples in docs.
This adds a `nickel test` subcommand that extracts all nickel code blocks contained in doc fields, and runs them to make sure they work. The examples are run in the environment of the record they're contained in, so in
````nickel
{
  foo = 3,
  bar | doc m%"
  ```nickel
    foo + 1
  ```
  "%
}
````
the "foo" variable in the doc test will evaluate to `3`.

There is rudimentary support for tests that are expected to error, and tests that are expected to evaluate to a specific value: a test ending in
```
# => [1, 2]
```
will be wrapped in `(...) | std.contract.Equal [1, 2]`, while a test ending in
```
# => error: foo
```
will be expected to error, with the main diagnostic message containing the string "foo".

Tests can be ignored, by starting them with ` ```nickel ignore`

This mostly conforms to how the examples in the standard library are formatted, but a few changes are required:
- each fenced code block can only be used to test a single example
- the `=> expected` bit needs to be on the last line, and in a comment

I converted the doctests in `std.array` to the new format, and discovered a couple of typos in the process. If the format is ok, I'll do the rest of the stdlib.

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>Thu, August  1, 2024 at 05:54:13 UTC</td></tr><tr><td>Project</td><td><a href="https://bencher.dev/perf/nickel">nickel</a></td></tr><tr><td>Branch</td><td>2020/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=014b091c-0a07-4a5f-8654-bfc77e6d7b22&testbeds=66263ed0-9556-4385-9f30-26466ee26211&benchmarks=3cfaf19e-c9c5-4341-95cd-703456d0fc9f&measures=2b5b9371-696f-422d-b1a5-262ce8157b6b&start_time=1719899653000&end_time=1722491653000">view plot</a>)</td><td>497,840.00</td></tr><tr><td>pidigits 100</td><td>➖ (<a href="https://bencher.dev/perf/nickel?branches=014b091c-0a07-4a5f-8654-bfc77e6d7b22&testbeds=66263ed0-9556-4385-9f30-26466ee26211&benchmarks=5be25c57-d46f-4b48-b6f0-dee01b84220a&measures=2b5b9371-696f-422d-b1a5-262ce8157b6b&start_time=1719899653000&end_time=1722491653000">view plot</a>)</td><td>3,137,700.00</td></tr><tr><td>product 30</td><td>➖ (<a href="https://bencher.dev/perf/nickel?branches=014b091c-0a07-4a5f-8654-bfc77e6d7b22&testbeds=66263ed0-9556-4385-9f30-26466ee26211&benchmarks=2f5f7f6a-4e54-4542-aa09-da5671953fbb&measures=2b5b9371-696f-422d-b1a5-262ce8157b6b&start_time=1719899653000&end_time=1722491653000">view plot</a>)</td><td>792,030.00</td></tr><tr><td>scalar 10</td><td>➖ (<a href="https://bencher.dev/perf/nickel?branches=014b091c-0a07-4a5f-8654-bfc77e6d7b22&testbeds=66263ed0-9556-4385-9f30-26466ee26211&benchmarks=211bb66a-58f8-488b-97e4-7ea032870038&measures=2b5b9371-696f-422d-b1a5-262ce8157b6b&start_time=1719899653000&end_time=1722491653000">view plot</a>)</td><td>1,470,600.00</td></tr><tr><td>sum 30</td><td>➖ (<a href="https://bencher.dev/perf/nickel?branches=014b091c-0a07-4a5f-8654-bfc77e6d7b22&testbeds=66263ed0-9556-4385-9f30-26466ee26211&benchmarks=3f1710c5-ce75-4dfb-aadb-1435fed8a093&measures=2b5b9371-696f-422d-b1a5-262ce8157b6b&start_time=1719899653000&end_time=1722491653000">view plot</a>)</td><td>782,100.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/2020-merge/ubuntu-latest/rust_criterion"></div>
yannham: Idea: we could do as in the manual, and use a modifier such as `nickel #repl` or `nickel #multilines` (I don't know if the hash was a good idea, I think nowadays GH uses just a space for those kind of "language modifiers", but you get the spirit) for multiline examples, using a new line as a delimiter? It's basic and has a few gotcha when your example is long enough so that you want to have newlines in it, but it's been working rather well until now for the manual.

I fear a sequence of four or five 2-line fenced code blocks are going to be less readable and quite noisy in the source or in the markdown rendered version. What do you think?
jneem: I gave ` ```nickel multiline` a try and it seems pretty reasonable. I don't think `#parse` or `#repl` make much sense in doctests.

One possible issue with modifiers is that typos can give confusing errors. I accidentally wrote `multline` instead of `multiline` just now and the result was a little confusing. One possibility would be to error on unknown modifiers, but that might limit the ability to postprocess the documentation with other tools that look for modifiers on code blocks...
jneem: Ok, I've cleaned up the implementation and I think it's ready for review.
jneem: A couple caveats:
- I'd like to track the locations better, so that errors in the doctest will point to spans in the original files. This will require tracking the `doc` spans better (including tracking how much it got un-indented).
- I've disabled the numerical tests because of precision issues (the error message printing out the actual value truncates it to `f64`, so copy-pasting that value back into the test doesn't work)
jneem: Thanks for the review, I think this is in a better state now. It's still missing tests and user-facing documentation. I'll try to get to that tomorrow.

Patch:
diff --git a/.github/workflows/benchmark-master.yaml b/.github/workflows/benchmark-master.yaml
index 7e317448..9ad8053b 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@V27
+      - uses: cachix/install-nix-action@V28
         name: "Installing Nix"
         with:
           extra_nix_config: |
diff --git a/.github/workflows/benchmark-pr.yaml b/.github/workflows/benchmark-pr.yaml
index 37add156..e73e6180 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@V27
+      - uses: cachix/install-nix-action@V28
         name: "Installing Nix"
         with:
           extra_nix_config: |
diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml
index 0b962b6f..1cb3dc86 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@V27
+      uses: cachix/install-nix-action@V28
       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 b9eb3e7d..1eea0323 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@V27
+      - uses: cachix/install-nix-action@V28
         name: "Installing Nix"
         with:
           extra_nix_config: |
diff --git a/.github/workflows/update-flake-lock.yml b/.github/workflows/update-flake-lock.yml
index 59b89d59..a3ae724c 100644
--- a/.github/workflows/update-flake-lock.yml
+++ b/.github/workflows/update-flake-lock.yml
@@ -11,12 +11,12 @@ jobs:
       - name: Checkout repository
         uses: actions/checkout@v4
       - name: Install Nix
-        uses: cachix/install-nix-action@V27
+        uses: cachix/install-nix-action@V28
         with:
           extra_nix_config: |
             access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
       - name: Update flake.lock
-        uses: DeterminateSystems/update-flake-lock@v23
+        uses: DeterminateSystems/update-flake-lock@v24
         with:
           # Providing a secret token is necessary for the PRs to run CI
           # See https://github.com/DeterminateSystems/update-flake-lock#with-a-personal-authentication-token
diff --git a/Cargo.lock b/Cargo.lock
index 9debad4c..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,7 +1744,10 @@ dependencies = [
  "metrics-util",
  "nickel-lang-core",
  "nickel-lang-utils",
+ "once_cell",
+ "regex",
  "serde",
+ "serde_json",
  "tempfile",
  "test-generator",
 ]
@@ -1789,6 +1793,7 @@ dependencies = [
  "sha2",
  "similar",
  "simple-counter",
+ "smallvec",
  "strip-ansi-escapes",
  "strsim 0.10.0",
  "termimad",
@@ -2724,9 +2729,9 @@ dependencies = [
 
 [[package]]
 name = "smallvec"
-version = "1.13.1"
+version = "1.13.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e6ecd384b10a64542d77071bd64bd7b231f4ed5940fba55e98c3de13824cf3d7"
+checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67"
 
 [[package]]
 name = "stable_deref_trait"
@@ -3017,9 +3022,9 @@ dependencies = [
 
 [[package]]
 name = "tinyvec"
-version = "1.6.0"
+version = "1.8.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50"
+checksum = "445e881f4f6d382d5f27c034e25eb92edd7c784ceab92a0937db7f2e9471b938"
 dependencies = [
  "tinyvec_macros",
 ]
diff --git a/Cargo.toml b/Cargo.toml
index 17380468..25d94871 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -87,6 +87,7 @@ sha-1 = "0.10.0"
 sha2 = "0.10.6"
 similar = "2.2.1"
 simple-counter = "0.1.0"
+smallvec = "1.13.2"
 strip-ansi-escapes = "0.2.0"
 termimad = "0.23.1"
 test-generator = "0.3.1"
diff --git a/cli/Cargo.toml b/cli/Cargo.toml
index 5304f182..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"]
@@ -28,6 +28,7 @@ nickel-lang-core = { workspace = true, features = [ "markdown" ], default-featur
 
 clap = { workspace = true, features = ["derive", "string"] }
 serde = { workspace = true, features = ["derive"] }
+serde_json.workspace = true
 directories.workspace = true
 
 tempfile = { workspace = true, optional = true }
@@ -38,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/cli/src/query.rs b/cli/src/query.rs
index f25b03ac..fdf28c75 100644
--- a/cli/src/query.rs
+++ b/cli/src/query.rs
@@ -1,4 +1,14 @@
-use nickel_lang_core::repl::query_print;
+use std::{fs, io::Write, path::PathBuf};
+
+use nickel_lang_core::{
+    error::{Error, IOError},
+    identifier::{Ident, LocIdent},
+    pretty::PrettyPrintCap,
+    repl::query_print,
+    serialize::{self, MetadataExportFormat},
+    term::{record::Field, LabeledType, MergePriority, Term},
+};
+use serde::Serialize;
 
 use crate::{
     cli::GlobalOptions,
@@ -7,6 +17,8 @@ use crate::{
     input::{InputOptions, Prepare},
 };
 
+const VALUE_EXPORT_MAX_WIDTH: usize = 80;
+
 #[derive(clap::Parser, Debug)]
 pub struct QueryCommand {
     #[arg(long)]
@@ -24,10 +36,77 @@ pub struct QueryCommand {
     #[arg(long)]
     pub value: bool,
 
+    /// Export the value and all metadata of selected field in the specified format.
+    /// Value is exported in its string representation, capped at 80 characters.
+    ///
+    /// This flag cannot be used along with the following flags: --doc, --contract, --type, --default, --value
+    #[arg(long, short, value_enum, default_value_t, conflicts_with_all(["doc", "contract", "typ", "default", "value"]))]
+    pub format: MetadataExportFormat,
+
+    /// Output file. Standard output by default
+    #[arg(short, long, conflicts_with_all(["doc", "contract", "typ", "default", "value"]))]
+    pub output: Option<PathBuf>,
+
     #[command(flatten)]
     pub inputs: InputOptions<ExtractFieldOnly>,
 }
 
+#[derive(Clone, Debug, Serialize)]
+struct QueryResult {
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub doc: Option<String>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub typ: Option<LabeledType>,
+    #[serde(skip_serializing_if = "Vec::is_empty")]
+    pub contracts: Vec<LabeledType>,
+    pub optional: bool,
+    pub not_exported: bool,
+    pub priority: MergePriority,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub sub_fields: Option<Vec<Ident>>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub value: Option<String>,
+}
+
+impl From<Field> for QueryResult {
+    fn from(field: Field) -> Self {
+        let sub_fields = match field.value {
+            Some(ref val) => match val.as_ref() {
+                Term::Record(record) if !record.fields.is_empty() => {
+                    let mut fields: Vec<_> = record.fields.keys().collect();
+                    fields.sort();
+                    Some(fields.into_iter().map(LocIdent::ident).collect())
+                }
+                Term::RecRecord(record, dyn_fields, ..) if !record.fields.is_empty() => {
+                    let mut fields: Vec<_> = record.fields.keys().map(LocIdent::ident).collect();
+                    fields.sort();
+                    let dynamic = Ident::from("<dynamic>");
+                    fields.extend(dyn_fields.iter().map(|_| dynamic));
+                    Some(fields)
+                }
+                // Empty record has empty sub_fields
+                Term::Record(..) | Term::RecRecord(..) => Some(Vec::new()),
+                // Non-record has no concept of sub-field
+                _ => None,
+            },
+            None => None,
+        };
+
+        QueryResult {
+            doc: field.metadata.doc,
+            typ: field.metadata.annotation.typ,
+            contracts: field.metadata.annotation.contracts,
+            optional: field.metadata.opt,
+            not_exported: field.metadata.not_exported,
+            priority: field.metadata.priority,
+            sub_fields,
+            value: field
+                .value
+                .map(|v| v.pretty_print_cap(VALUE_EXPORT_MAX_WIDTH)),
+        }
+    }
+}
+
 impl QueryCommand {
     fn attributes_specified(&self) -> bool {
         self.doc || self.contract || self.typ || self.default || self.value
@@ -48,6 +127,34 @@ impl QueryCommand {
         }
     }
 
+    fn export<T>(self, res: T, format: MetadataExportFormat) -> Result<(), Error>
+    where
+        T: Serialize,
+    {
+        // This is a near-verbatim copy of ExportCommand::export
+
+        // We only add a trailing newline for JSON exports. Both YAML and TOML
+        // exporters already append a trailing newline by default.
+        let trailing_newline = format == MetadataExportFormat::Json;
+
+        if let Some(file) = self.output {
+            let mut file = fs::File::create(file).map_err(IOError::from)?;
+            serialize::to_writer_metadata(&mut file, format, &res)?;
+
+            if trailing_newline {
+                writeln!(file).map_err(IOError::from)?;
+            }
+        } else {
+            serialize::to_writer_metadata(std::io::stdout(), format, &res)?;
+
+            if trailing_newline {
+                println!();
+            }
+        }
+
+        Ok(())
+    }
+
     pub fn run(self, global: GlobalOptions) -> CliResult<()> {
         let mut program = self.inputs.prepare(&global)?;
 
@@ -55,22 +162,37 @@ impl QueryCommand {
             program.report(Warning::EmptyQueryPath, global.error_format);
         }
 
-        let found = program
-            .query()
-            .map(|field| {
-                query_print::write_query_result(
-                    &mut std::io::stdout(),
-                    &field,
-                    self.query_attributes(),
-                )
-                .unwrap()
-            })
-            .report_with_program(program)?;
-
-        if !found {
-            eprintln!("No metadata found for this field.")
-        }
+        use MetadataExportFormat::*;
+        match self.format {
+            Markdown => {
+                if self.output.is_some() {
+                    eprintln!("Output query result in markdown format to a file is currently not supported.")
+                } else {
+                    let found = program
+                        .query()
+                        .map(|field| {
+                            query_print::write_query_result(
+                                &mut std::io::stdout(),
+                                &field,
+                                self.query_attributes(),
+                            )
+                            .unwrap()
+                        })
+                        .report_with_program(program)?;
 
+                    if !found {
+                        eprintln!("No metadata found for this field.")
+                    }
+                }
+            }
+            format @ (Json | Toml | Yaml) => {
+                let _ = &program
+                    .query()
+                    .map(QueryResult::from)
+                    .map(|res| self.export(res, format))
+                    .report_with_program(program)?;
+            }
+        }
         Ok(())
     }
 }
diff --git a/core/Cargo.toml b/core/Cargo.toml
index 8ef2e297..1661c252 100644
--- a/core/Cargo.toml
+++ b/core/Cargo.toml
@@ -43,6 +43,7 @@ codespan.workspace = true
 codespan-reporting.workspace = true
 cxx = { workspace = true, optional = true }
 logos.workspace = true
+smallvec.workspace = true
 serde = { workspace = true, features = ["derive"] }
 serde_json.workspace = true
 serde_yaml.workspace = true
diff --git a/core/src/cache.rs b/core/src/cache.rs
index e5f962f3..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;
@@ -32,7 +33,7 @@ use std::time::SystemTime;
 use void::Void;
 
 /// Supported input formats.
-#[derive(Default, Clone, Copy, Eq, Debug, PartialEq)]
+#[derive(Default, Clone, Copy, Eq, Debug, PartialEq, Hash)]
 pub enum InputFormat {
     #[default]
     Nickel,
@@ -58,10 +59,36 @@ impl InputFormat {
             _ => None,
         }
     }
-    /// Renturns an [InputFormat] based on the extension of a source path.
+
+    pub fn from_tag(tag: &str) -> Option<InputFormat> {
+        Some(match tag {
+            "Json" => InputFormat::Json,
+            "Nickel" => InputFormat::Nickel,
+            "Raw" => InputFormat::Raw,
+            "Yaml" => InputFormat::Yaml,
+            "Toml" => InputFormat::Toml,
+            #[cfg(feature = "nix-experimental")]
+            "Nix" => InputFormat::Nix,
+            _ => return None,
+        })
+    }
+
+    pub fn to_tag(&self) -> &'static str {
+        match self {
+            InputFormat::Nickel => "Nickel",
+            InputFormat::Json => "Json",
+            InputFormat::Yaml => "Yaml",
+            InputFormat::Toml => "Toml",
+            InputFormat::Raw => "Raw",
+            #[cfg(feature = "nix-experimental")]
+            InputFormat::Nix => "Nix",
+        }
+    }
+
+    /// Extracts format embedded in SourcePath
     pub fn from_source_path(source_path: &SourcePath) -> Option<InputFormat> {
-        if let SourcePath::Path(p) = source_path {
-            Self::from_path(p)
+        if let SourcePath::Path(_p, fmt) = source_path {
+            Some(*fmt)
         } else {
             None
         }
@@ -205,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 {}
@@ -269,7 +298,7 @@ pub enum SourcePath {
     ///
     /// This is the only `SourcePath` variant that can be resolved as the target
     /// of an import statement.
-    Path(PathBuf),
+    Path(PathBuf, InputFormat),
     /// A subrange of a file at the given path.
     ///
     /// This is used by nls to analyze small parts of files that don't fully parse. The
@@ -290,7 +319,7 @@ impl<'a> TryFrom<&'a SourcePath> for &'a OsStr {
 
     fn try_from(value: &'a SourcePath) -> Result<Self, Self::Error> {
         match value {
-            SourcePath::Path(p) | SourcePath::Snippet(p) => Ok(p.as_os_str()),
+            SourcePath::Path(p, _) | SourcePath::Snippet(p) => Ok(p.as_os_str()),
             _ => Err(()),
         }
     }
@@ -302,7 +331,7 @@ impl<'a> TryFrom<&'a SourcePath> for &'a OsStr {
 impl From<SourcePath> for OsString {
     fn from(source_path: SourcePath) -> Self {
         match source_path {
-            SourcePath::Path(p) | SourcePath::Snippet(p) => p.into(),
+            SourcePath::Path(p, _) | SourcePath::Snippet(p) => p.into(),
             SourcePath::Std(StdlibModule::Std) => "<stdlib/std.ncl>".into(),
             SourcePath::Std(StdlibModule::Internals) => "<stdlib/internals.ncl>".into(),
             SourcePath::Query => "<query>".into(),
@@ -364,13 +393,18 @@ impl Cache {
 
     /// Same as [Self::add_file], but assume that the path is already normalized, and take the
     /// timestamp as a parameter.
-    fn add_file_(&mut self, path: PathBuf, timestamp: SystemTime) -> io::Result<FileId> {
+    fn add_file_(
+        &mut self,
+        path: PathBuf,
+        format: InputFormat,
+        timestamp: SystemTime,
+    ) -> io::Result<FileId> {
         let contents = std::fs::read_to_string(&path)?;
         let file_id = self.files.add(&path, contents);
         self.file_paths
-            .insert(file_id, SourcePath::Path(path.clone()));
+            .insert(file_id, SourcePath::Path(path.clone(), format));
         self.file_ids.insert(
-            SourcePath::Path(path),
+            SourcePath::Path(path, format),
             NameIdEntry {
                 id: file_id,
                 source: SourceKind::Filesystem(timestamp),
@@ -383,24 +417,32 @@ impl Cache {
     ///
     /// Uses the normalized path and the *modified at* timestamp as the name-id table entry.
     /// Overrides any existing entry with the same name.
-    pub fn add_file(&mut self, path: impl Into<OsString>) -> io::Result<FileId> {
+    pub fn add_file(
+        &mut self,
+        path: impl Into<OsString>,
+        format: InputFormat,
+    ) -> io::Result<FileId> {
         let path = path.into();
         let timestamp = timestamp(&path)?;
         let normalized = normalize_path(&path)?;
-        self.add_file_(normalized, timestamp)
+        self.add_file_(normalized, format, timestamp)
     }
 
     /// Try to retrieve the id of a file from the cache.
     ///
     /// If it was not in cache, try to read it from the filesystem and add it as a new entry.
-    pub fn get_or_add_file(&mut self, path: impl Into<OsString>) -> io::Result<CacheOp<FileId>> {
+    pub fn get_or_add_file(
+        &mut self,
+        path: impl Into<OsString>,
+        format: InputFormat,
+    ) -> io::Result<CacheOp<FileId>> {
         let path = path.into();
         let normalized = normalize_path(&path)?;
-        match self.id_or_new_timestamp_of(path.as_ref())? {
+        match self.id_or_new_timestamp_of(path.as_ref(), format)? {
             SourceState::UpToDate(id) => Ok(CacheOp::Cached(id)),
-            SourceState::Stale(timestamp) => {
-                self.add_file_(normalized, timestamp).map(CacheOp::Done)
-            }
+            SourceState::Stale(timestamp) => self
+                .add_file_(normalized, format, timestamp)
+                .map(CacheOp::Done),
         }
     }
 
@@ -666,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
@@ -772,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.
@@ -856,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),
         }
@@ -954,7 +1075,7 @@ impl Cache {
     /// [normalize_path]).
     pub fn id_of(&self, name: &SourcePath) -> Option<FileId> {
         match name {
-            SourcePath::Path(p) => match self.id_or_new_timestamp_of(p).ok()? {
+            SourcePath::Path(p, fmt) => match self.id_or_new_timestamp_of(p, *fmt).ok()? {
                 SourceState::UpToDate(id) => Some(id),
                 SourceState::Stale(_) => None,
             },
@@ -970,8 +1091,11 @@ impl Cache {
     ///
     /// The main point of this awkward signature is to minimize I/O operations: if we accessed
     /// the timestamp, keep it around.
-    fn id_or_new_timestamp_of(&self, name: &Path) -> io::Result<SourceState> {
-        match self.file_ids.get(&SourcePath::Path(name.to_owned())) {
+    fn id_or_new_timestamp_of(&self, name: &Path, format: InputFormat) -> io::Result<SourceState> {
+        match self
+            .file_ids
+            .get(&SourcePath::Path(name.to_owned(), format))
+        {
             None => Ok(SourceState::Stale(timestamp(name)?)),
             Some(NameIdEntry {
                 id,
@@ -1319,6 +1443,7 @@ pub trait ImportResolver {
     fn resolve(
         &mut self,
         path: &OsStr,
+        format: InputFormat,
         parent: Option<FileId>,
         pos: &TermPos,
     ) -> Result<(ResolvedTerm, FileId), ImportError>;
@@ -1333,6 +1458,7 @@ impl ImportResolver for Cache {
     fn resolve(
         &mut self,
         path: &OsStr,
+        format: InputFormat,
         parent: Option<FileId>,
         pos: &TermPos,
     ) -> Result<(ResolvedTerm, FileId), ImportError> {
@@ -1353,7 +1479,9 @@ impl ImportResolver for Cache {
             .find_map(|parent| {
                 let mut path_buf = parent.clone();
                 path_buf.push(path);
-                self.get_or_add_file(&path_buf).ok().map(|x| (x, path_buf))
+                self.get_or_add_file(&path_buf, format)
+                    .ok()
+                    .map(|x| (x, path_buf))
             })
             .ok_or_else(|| {
                 let parents = possible_parents
@@ -1367,7 +1495,6 @@ impl ImportResolver for Cache {
                 )
             })?;
 
-        let format = InputFormat::from_path(&path_buf).unwrap_or_default();
         let (result, file_id) = match id_op {
             CacheOp::Cached(id) => (ResolvedTerm::FromCache, id),
             CacheOp::Done(id) => (ResolvedTerm::FromFile { path: path_buf }, id),
@@ -1467,6 +1594,7 @@ pub mod resolvers {
         fn resolve(
             &mut self,
             _path: &OsStr,
+            _format: InputFormat,
             _parent: Option<FileId>,
             _pos: &TermPos,
         ) -> Result<(ResolvedTerm, FileId), ImportError> {
@@ -1508,6 +1636,7 @@ pub mod resolvers {
         fn resolve(
             &mut self,
             path: &OsStr,
+            _format: InputFormat,
             _parent: Option<FileId>,
             pos: &TermPos,
         ) -> Result<(ResolvedTerm, FileId), ImportError> {
diff --git a/core/src/error/mod.rs b/core/src/error/mod.rs
index 17ec9a57..0d952e1e 100644
--- a/core/src/error/mod.rs
+++ b/core/src/error/mod.rs
@@ -27,7 +27,7 @@ use crate::{
     position::{RawSpan, TermPos},
     repl,
     serialize::{ExportFormat, NickelPointer},
-    term::{record::FieldMetadata, Number, RichTerm, Term},
+    term::{pattern::Pattern, record::FieldMetadata, Number, RichTerm, Term},
     typ::{EnumRow, RecordRow, Type, TypeF, VarKindDiscriminant},
 };
 
@@ -168,6 +168,12 @@ pub enum EvalError {
         /// The position of the `match` expression
         pos: TermPos,
     },
+    FailedDestructuring {
+        /// The original term matched.
+        value: RichTerm,
+        /// The pattern that failed to match.
+        pattern: Pattern,
+    },
     /// Tried to query a field of something that wasn't a record.
     QueryNonRecord {
         /// Position of the original unevaluated expression.
@@ -520,6 +526,8 @@ pub enum ParseError {
     /// A recursive let pattern was encountered. They are not currently supported because we
     /// decided it was too involved to implement them.
     RecursiveLetPattern(RawSpan),
+    /// Let blocks can currently only contain plain bindings, not pattern bindings.
+    PatternInLetBlock(RawSpan),
     /// A type variable is used in ways that imply it has muiltiple different kinds.
     ///
     /// This can happen in several situations, for example:
@@ -561,6 +569,13 @@ pub enum ParseError {
         /// The previous instance of the duplicated identifier.
         prev_ident: LocIdent,
     },
+    /// A duplicate binding was encountered in a let block.
+    DuplicateIdentInLetBlock {
+        /// The duplicate identifier.
+        ident: LocIdent,
+        /// The previous instance of the duplicated identifier.
+        prev_ident: LocIdent,
+    },
     /// There was an attempt to use a feature that hasn't been enabled.
     DisabledFeature { feature: String, span: RawSpan },
     /// A term was used as a contract in type position, but this term has no chance to make any
@@ -568,6 +583,8 @@ pub enum ParseError {
     /// time, there are a set of expressions that can be excluded syntactically. Currently, it's
     /// mostly constants.
     InvalidContract(RawSpan),
+    /// Unrecognized explicit import format tag
+    InvalidImportFormat { span: RawSpan },
 }
 
 /// An error occurring during the resolution of an import.
@@ -773,6 +790,7 @@ impl ParseError {
                 InternalParseError::RecursiveLetPattern(pos) => {
                     ParseError::RecursiveLetPattern(pos)
                 }
+                InternalParseError::PatternInLetBlock(pos) => ParseError::PatternInLetBlock(pos),
                 InternalParseError::TypeVariableKindMismatch { ty_var, span } => {
                     ParseError::TypeVariableKindMismatch { ty_var, span }
                 }
@@ -786,6 +804,9 @@ impl ParseError {
                 InternalParseError::DuplicateIdentInRecordPattern { ident, prev_ident } => {
                     ParseError::DuplicateIdentInRecordPattern { ident, prev_ident }
                 }
+                InternalParseError::DuplicateIdentInLetBlock { ident, prev_ident } => {
+                    ParseError::DuplicateIdentInLetBlock { ident, prev_ident }
+                }
                 InternalParseError::DisabledFeature { feature, span } => {
                     ParseError::DisabledFeature { feature, span }
                 }
@@ -796,6 +817,9 @@ impl ParseError {
                     }
                 }
                 InternalParseError::InvalidContract(span) => ParseError::InvalidContract(span),
+                InternalParseError::InvalidImportFormat { span } => {
+                    ParseError::InvalidImportFormat { span }
+                }
             },
         }
     }
@@ -870,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.
@@ -1455,6 +1479,20 @@ impl IntoDiagnostics<FileId> for EvalError {
                     .with_message("unmatched pattern")
                     .with_labels(labels)]
             }
+            EvalError::FailedDestructuring { value, pattern } => {
+                let mut labels = Vec::new();
+
+                if let Some(span) = pattern.pos.into_opt() {
+                    labels.push(primary(&span).with_message("this pattern"));
+                }
+
+                labels
+                    .push(secondary_term(&value, files).with_message("this value failed to match"));
+
+                vec![Diagnostic::error()
+                    .with_message("destructuring failed")
+                    .with_labels(labels)]
+            }
             EvalError::IllegalPolymorphicTailAccess {
                 action,
                 label: contract_label,
@@ -1993,6 +2031,10 @@ impl IntoDiagnostics<FileId> for ParseError {
                         from within a field, so you might not need the recursive let."
                         .into(),
                 ]),
+            ParseError::PatternInLetBlock(span) => Diagnostic::error()
+                .with_message("destructuring patterns are not currently permitted in let blocks")
+                .with_labels(vec![primary(&span)])
+                .with_notes(vec!["Try re-writing your let block as nested `let ... in` expressions.".into()]),
             ParseError::TypeVariableKindMismatch { ty_var, span } => Diagnostic::error()
                 .with_message(format!(
                     "the type variable `{ty_var}` is used in conflicting ways"
@@ -2049,6 +2091,15 @@ impl IntoDiagnostics<FileId> for ParseError {
                     secondary(&prev_ident.pos.unwrap()).with_message("previous binding here"),
                     primary(&ident.pos.unwrap()).with_message("duplicated binding here"),
                 ]),
+            ParseError::DuplicateIdentInLetBlock { ident, prev_ident } => Diagnostic::error()
+                .with_message(format!(
+                    "duplicated binding `{}` in let block",
+                    ident.label()
+                ))
+                .with_labels(vec![
+                    secondary(&prev_ident.pos.unwrap()).with_message("previous binding here"),
+                    primary(&ident.pos.unwrap()).with_message("duplicated binding here"),
+                ]),
             ParseError::DisabledFeature { feature, span } => Diagnostic::error()
                 .with_message("interpreter compiled without required features")
                 .with_labels(vec![primary(&span).with_message(format!(
@@ -2067,6 +2118,13 @@ impl IntoDiagnostics<FileId> for ParseError {
                         .to_owned(),
                     "Only functions and records might be valid contracts".to_owned(),
                 ]),
+            ParseError::InvalidImportFormat{span} => Diagnostic::error()
+                .with_message("unknown import format tag")
+                .with_labels(vec![primary(&span)])
+                .with_notes(vec![
+                    "Examples of valid format tags: 'Nickel 'Json 'Yaml 'Toml 'Raw"
+                        .to_owned()
+                ]),
         };
 
         vec![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 93c0da04..48907e75 100644
--- a/core/src/eval/mod.rs
+++ b/core/src/eval/mod.rs
@@ -569,21 +569,30 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                     );
                     Closure { body: t1, env }
                 }
-                Term::Let(x, bound, body, LetAttrs { binding_type, rec }) => {
-                    let bound_closure: Closure = Closure {
-                        body: bound,
-                        env: env.clone(),
-                    };
+                Term::Let(bindings, body, LetAttrs { binding_type, rec }) => {
+                    let mut indices = Vec::new();
+                    let init_env = env.clone();
+
+                    for (x, bound) in bindings {
+                        let bound_closure: Closure = Closure {
+                            body: bound,
+                            env: init_env.clone(),
+                        };
 
-                    let idx = self.cache.add(bound_closure, binding_type);
+                        let idx = self.cache.add(bound_closure, binding_type.clone());
+
+                        // Patch the environment with the (x <- closure) binding
+                        if rec {
+                            indices.push(idx.clone());
+                        }
 
-                    // Patch the environment with the (x <- closure) binding
-                    if rec {
-                        self.cache
-                            .patch(idx.clone(), |cl| cl.env.insert(x.ident(), idx.clone()));
+                        env.insert(x.ident(), idx);
                     }
 
-                    env.insert(x.ident(), idx);
+                    let names = env.iter_elems().map(|(k, _v)| k).collect::<Vec<_>>();
+                    for idx in indices {
+                        self.cache.patch(idx, |cl| cl.env = env.clone());
+                    }
 
                     Closure { body, env }
                 }
@@ -787,7 +796,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                         ));
                     }
                 }
-                Term::Import(path) => {
+                Term::Import { path, .. } => {
                     return Err(EvalError::InternalError(
                         format!("Unresolved import ({})", path.to_string_lossy()),
                         pos,
@@ -1013,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())
     }
@@ -1160,7 +1174,7 @@ pub fn subst<C: Cache>(
         | v @ Term::ForeignId(_)
         | v @ Term::SealingKey(_)
         | v @ Term::Enum(_)
-        | v @ Term::Import(_)
+        | v @ Term::Import{..}
         | v @ Term::ResolvedImport(_)
         // We could recurse here, because types can contain terms which would then be subject to
         // substitution. Not recursing should be fine, though, because a type in term position
@@ -1171,11 +1185,11 @@ pub fn subst<C: Cache>(
 
             RichTerm::new(Term::EnumVariant { tag, arg, attrs }, pos)
         }
-        Term::Let(id, t1, t2, attrs) => {
-            let t1 = subst(cache, t1, initial_env, env);
-            let t2 = subst(cache, t2, initial_env, env);
+        Term::Let(bindings, body, attrs) => {
+            let bindings = bindings.into_iter().map(|(key, val)| (key, subst(cache, val, initial_env, env))).collect();
+            let body = subst(cache, body, initial_env, env);
 
-            RichTerm::new(Term::Let(id, t1, t2, attrs), pos)
+            RichTerm::new(Term::Let(bindings, body, attrs), pos)
         }
         p @ Term::LetPattern(..) => panic!(
             "Pattern {p:?} has not been transformed before evaluation"
diff --git a/core/src/parser/error.rs b/core/src/parser/error.rs
index ca6f91ce..817460d8 100644
--- a/core/src/parser/error.rs
+++ b/core/src/parser/error.rs
@@ -99,6 +99,8 @@ pub enum ParseError {
     /// A recursive let pattern was encountered. They are not currently supported because we
     /// decided it was too involved to implement them.
     RecursiveLetPattern(RawSpan),
+    /// Let blocks can currently only contain plain bindings, not pattern bindings.
+    PatternInLetBlock(RawSpan),
     /// A duplicate binding was encountered in a record destructuring pattern.
     DuplicateIdentInRecordPattern {
         /// The duplicate identifier.
@@ -106,6 +108,13 @@ pub enum ParseError {
         /// The previous instance of the duplicated identifier.
         prev_ident: LocIdent,
     },
+    /// A duplicate binding was encountered in a let block.
+    DuplicateIdentInLetBlock {
+        /// The duplicate identifier.
+        ident: LocIdent,
+        /// The previous instance of the duplicated identifier.
+        prev_ident: LocIdent,
+    },
     /// A type variable is used in ways that imply it has multiple different kinds.
     ///
     /// This can happen in several situations, for example:
@@ -144,4 +153,6 @@ pub enum ParseError {
     /// time, there are a set of expressions that can be excluded syntactically. Currently, it's
     /// mostly constants.
     InvalidContract(RawSpan),
+    /// Unrecognized explicit import format tag
+    InvalidImportFormat { span: RawSpan },
 }
diff --git a/core/src/parser/grammar.lalrpop b/core/src/parser/grammar.lalrpop
index 8faf22d1..1f504d90 100644
--- a/core/src/parser/grammar.lalrpop
+++ b/core/src/parser/grammar.lalrpop
@@ -221,19 +221,22 @@ pub ExtendedTerm: ExtendedTerm = {
     Term => ExtendedTerm::RichTerm(<>),
 };
 
+LetBinding: LetBinding = {
+    <pattern:Pattern> <annot:LetAnnot<FixedType>?> "=" <value:Term> => {
+        LetBinding { pattern, annot, value }
+    }
+}
+
 // A general uniterm. The root of the grammar.
 UniTerm: UniTerm = {
     InfixExpr,
     AnnotatedInfixExpr,
     AsUniTerm<Forall>,
-    "let" <l: @L> <recursive:"rec"?> <r: @R> <pat:Pattern> <ann: LetAnnot<FixedType>?>
-        "=" <mut t1: Term>
-        "in" <t2: Term> =>? {
-        if let Some(ann) = ann {
-            t1 = ann.annotation.attach_term(t1);
-        }
-
-        Ok(UniTerm::from(mk_let(recursive.is_some(), pat, t1, t2, mk_span(src_id, l, r))?))
+    "let" <recursive:"rec"?>
+        <mut bindings:(<LetBinding> ",")*> <last:LetBinding> ","?
+        "in" <body: Term> =>? {
+        bindings.push(last);
+        Ok(UniTerm::from(mk_let(recursive.is_some(), bindings, body)?))
     },
     <l: @L> "fun" <pats: PatternFun+> "=>" <t: Term> <r: @R> => {
         let pos = mk_pos(src_id, l, r);
@@ -249,7 +252,12 @@ UniTerm: UniTerm = {
     <err: Error> => {
         UniTerm::from(err)
     },
-    "import" <s: StandardStaticString> => UniTerm::from(Term::Import(OsString::from(s))),
+    "import" <l: @L> <s: StandardStaticString> <r: @R> =>? {
+        Ok(UniTerm::from(mk_import_based_on_filename(s, mk_span(src_id, l, r))?))
+    },
+    "import" <l: @L> <t: EnumTag> <r: @R> <s: StandardStaticString> =>? {
+        Ok(UniTerm::from(mk_import_explicit(s, t, mk_span(src_id, l, r))?))
+    },
 };
 
 AnnotatedInfixExpr: UniTerm = {
diff --git a/core/src/parser/utils.rs b/core/src/parser/utils.rs
index 57d71e2e..1db0114f 100644
--- a/core/src/parser/utils.rs
+++ b/core/src/parser/utils.rs
@@ -1,13 +1,17 @@
 //! Various helpers and companion code for the parser are put here to keep the grammar definition
 //! uncluttered.
 use indexmap::map::Entry;
-use std::fmt::Debug;
+use std::ffi::OsString;
 use std::rc::Rc;
+use std::{collections::HashSet, fmt::Debug};
 
 use codespan::FileId;
 
+use self::pattern::bindings::Bindings as _;
+
 use super::error::ParseError;
 
+use crate::cache::InputFormat;
 use crate::{
     combine::Combine,
     eval::{
@@ -216,6 +220,14 @@ pub enum RecordLastField {
     Ellipsis,
 }
 
+/// A single binding in a let block.
+#[derive(Clone, Debug)]
+pub struct LetBinding {
+    pub pattern: Pattern,
+    pub annot: Option<LetMetadata>,
+    pub value: RichTerm,
+}
+
 /// An infix operator that is not applied. Used for the curried operator syntax (e.g `(==)`)
 pub enum InfixOp {
     Unary(UnaryOp),
@@ -619,21 +631,63 @@ pub fn mk_merge_label(src_id: FileId, l: usize, r: usize) -> MergeLabel {
     }
 }
 
-/// Generate a `Let` or a `LetPattern` (depending on whether `assgn` has a record pattern) from
-/// the parsing of a let definition. This function fails if the definition has both a pattern
-/// and is recursive because recursive let-patterns are currently not supported.
+/// Generate a `Let` or a `LetPattern` (depending on whether there's a binding
+/// with a record pattern) from the parsing of a let definition.
 pub fn mk_let(
     rec: bool,
-    pat: Pattern,
-    t1: RichTerm,
-    t2: RichTerm,
-    span: RawSpan,
+    bindings: Vec<LetBinding>,
+    body: RichTerm,
 ) -> Result<RichTerm, ParseError> {
-    match pat.data {
-        PatternData::Any(id) if rec => Ok(mk_term::let_rec_in(id, t1, t2)),
-        PatternData::Any(id) => Ok(mk_term::let_in(id, t1, t2)),
-        _ if rec => Err(ParseError::RecursiveLetPattern(span)),
-        _ => Ok(mk_term::let_pat(pat, t1, t2)),
+    let all_simple = bindings
+        .iter()
+        .all(|b| matches!(b.pattern.data, PatternData::Any(_)));
+
+    // Check for duplicate names across the different bindings. We
+    // don't check for duplicate names within a single binding because
+    // there are backwards-compatibility constraints (e.g., see
+    // `RecordPattern::check_dup`).
+    let mut seen_bindings: HashSet<LocIdent> = HashSet::new();
+    for b in &bindings {
+        let new_bindings = b.pattern.bindings();
+        for (_path, id, _field) in &new_bindings {
+            if let Some(old) = seen_bindings.get(id) {
+                return Err(ParseError::DuplicateIdentInLetBlock {
+                    ident: *id,
+                    prev_ident: *old,
+                });
+            }
+        }
+
+        seen_bindings.extend(new_bindings.into_iter().map(|(_path, id, _field)| id));
+    }
+
+    if all_simple {
+        Ok(mk_term::let_in(
+            rec,
+            bindings.into_iter().map(|mut b| {
+                let PatternData::Any(id) = b.pattern.data else {
+                    // unreachable: we checked for `all_simple`, meaning that
+                    // all bindings are just Any(_).
+                    unreachable!()
+                };
+                if let Some(ann) = b.annot {
+                    b.value = ann.annotation.attach_term(b.value);
+                }
+                (id, b.value)
+            }),
+            body,
+        ))
+    } else {
+        Ok(mk_term::let_pat_in(
+            rec,
+            bindings.into_iter().map(|mut b| {
+                if let Some(ann) = b.annot {
+                    b.value = ann.annotation.attach_term(b.value);
+                }
+                (b.pattern, b.value)
+            }),
+            body,
+        ))
     }
 }
 
@@ -647,6 +701,29 @@ pub fn mk_fun(pat: Pattern, body: RichTerm) -> Term {
     }
 }
 
+pub fn mk_import_based_on_filename(path: String, _span: RawSpan) -> Result<Term, ParseError> {
+    let path = OsString::from(path);
+    let format: Option<InputFormat> =
+        InputFormat::from_path(std::path::Path::new(path.as_os_str()));
+
+    // Fall back to InputFormat::Nickel in case of unknown filename extension for backwards compatiblilty.
+    let format = format.unwrap_or_default();
+
+    Ok(Term::Import { path, format })
+}
+
+pub fn mk_import_explicit(
+    path: String,
+    format: LocIdent,
+    span: RawSpan,
+) -> Result<Term, ParseError> {
+    let path = OsString::from(path);
+    let Some(format) = InputFormat::from_tag(format.label()) else {
+        return Err(ParseError::InvalidImportFormat { span });
+    };
+    Ok(Term::Import { path, format })
+}
+
 /// Determine the minimal level of indentation of a multi-line string.
 ///
 /// The result is determined by computing the minimum indentation level among all lines, where the
diff --git a/core/src/pretty.rs b/core/src/pretty.rs
index 67d7820f..c1795740 100644
--- a/core/src/pretty.rs
+++ b/core/src/pretty.rs
@@ -1,5 +1,6 @@
 use std::fmt;
 
+use crate::cache::InputFormat;
 use crate::identifier::LocIdent;
 use crate::parser::lexer::KEYWORDS;
 use crate::term::{
@@ -143,7 +144,7 @@ fn needs_parens_in_type_pos(typ: &Type) -> bool {
                 | Term::Let(..)
                 | Term::LetPattern(..)
                 | Term::Op1(UnaryOp::IfThenElse, _)
-                | Term::Import(..)
+                | Term::Import { .. }
                 | Term::ResolvedImport(..)
         )
     } else {
@@ -252,6 +253,25 @@ where
             .enclose(start_delimiter, end_delimiter)
     }
 
+    fn binding(&'a self, lhs: impl Pretty<'a, Self, A>, rt: RichTerm) -> DocBuilder<'a, Self, A> {
+        docs![
+            self,
+            lhs,
+            if let Term::Annotated(annot, _) = rt.as_ref() {
+                annot.pretty(self)
+            } else {
+                self.nil()
+            },
+            self.line(),
+            "= ",
+            if let Term::Annotated(_, inner) = rt.as_ref() {
+                inner.pretty(self)
+            } else {
+                rt.pretty(self)
+            },
+        ]
+    }
+
     /// Print a function, which can have several parameters (represented as nested functions), and
     /// where each layer might be a normal function, a pattern matching function or a custom
     /// contract. [function] automatically unwrap any of those nested layers to print the function
@@ -470,6 +490,17 @@ impl<'a, D: DocAllocator<'a, A>, A> NickelDocBuilderExt<'a, D, A> for DocBuilder
     }
 }
 
+impl<'a, D, A> Pretty<'a, D, A> for LocIdent
+where
+    D: NickelAllocatorExt<'a, A>,
+    D::Doc: Clone,
+    A: Clone + 'a,
+{
+    fn pretty(self, allocator: &'a D) -> DocBuilder<'a, D, A> {
+        allocator.text(self.into_label())
+    }
+}
+
 impl<'a, D, A> Pretty<'a, D, A> for RecursivePriority
 where
     D: NickelAllocatorExt<'a, A>,
@@ -832,27 +863,21 @@ where
             FunPattern(pat, body) => allocator.function(allocator.pat_with_parens(pat), body),
             // Format this as the application `std.contract.from_predicate <pred>`.
             Lbl(_lbl) => allocator.text("%<label>").append(allocator.line()),
-            Let(id, rt, body, attrs) => docs![
+            Let(bindings, body, attrs) => docs![
                 allocator,
-                "let ",
+                "let",
+                allocator.space(),
                 if attrs.rec {
-                    allocator.text("rec ")
+                    docs![allocator, "rec", allocator.space()]
                 } else {
                     allocator.nil()
                 },
-                id.to_string(),
-                if let Annotated(annot, _) = rt.as_ref() {
-                    annot.pretty(allocator)
-                } else {
-                    allocator.nil()
-                },
-                allocator.line(),
-                "= ",
-                if let Annotated(_, inner) = rt.as_ref() {
-                    inner.pretty(allocator)
-                } else {
-                    rt.pretty(allocator)
-                },
+                allocator.intersperse(
+                    bindings
+                        .iter()
+                        .map(|(k, v)| allocator.binding(*k, v.clone())),
+                    docs![allocator, ",", allocator.line()]
+                ),
                 allocator.line(),
                 "in",
             ]
@@ -860,22 +885,21 @@ where
             .append(allocator.line())
             .append(body.pretty(allocator).nest(2))
             .group(),
-            LetPattern(pattern, rt, body) => docs![
+            LetPattern(bindings, body, attrs) => docs![
                 allocator,
-                "let ",
-                pattern,
-                if let Annotated(annot, _) = rt.as_ref() {
-                    annot.pretty(allocator)
+                "let",
+                allocator.space(),
+                if attrs.rec {
+                    docs![allocator, "rec", allocator.space()]
                 } else {
                     allocator.nil()
                 },
-                allocator.line(),
-                "= ",
-                if let Annotated(_, inner) = rt.as_ref() {
-                    inner
-                } else {
-                    rt
-                },
+                allocator.intersperse(
+                    bindings
+                        .iter()
+                        .map(|(k, v)| allocator.binding(k, v.clone())),
+                    docs![allocator, ",", allocator.line()]
+                ),
                 allocator.line(),
                 "in",
             ]
@@ -1033,9 +1057,20 @@ where
             SealingKey(sym) => allocator.text(format!("%<sealing key: {sym}>")),
             Sealed(_i, _rt, _lbl) => allocator.text("%<sealed>"),
             Annotated(annot, rt) => allocator.atom(rt).append(annot.pretty(allocator)),
-            Import(f) => allocator
-                .text("import ")
-                .append(allocator.as_string(f.to_string_lossy()).double_quotes()),
+            Import { path, format } => {
+                docs![
+                    allocator,
+                    "import",
+                    if Some(*format)
+                        != InputFormat::from_path(std::path::Path::new(path.as_os_str()))
+                    {
+                        docs![allocator, "'", format.to_tag(), allocator.space()]
+                    } else {
+                        allocator.space()
+                    },
+                    allocator.as_string(path.to_string_lossy()).double_quotes()
+                ]
+            }
             ResolvedImport(id) => allocator.text(format!("import <file_id: {id:?}>")),
             // This type is in term position, so we don't need to add parentheses.
             Type { typ, contract: _ } => typ.pretty(allocator),
diff --git a/core/src/program.rs b/core/src/program.rs
index 34005b7d..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,
 };
 
@@ -220,10 +224,10 @@ impl<EC: EvalCache> Program<EC> {
         let mut cache = Cache::new(ErrorTolerance::Strict);
 
         let main_id = match input {
-            Input::Path(path) => cache.add_file(path)?,
+            Input::Path(path) => cache.add_file(path, InputFormat::Nickel)?,
             Input::Source(source, name) => {
                 let path = PathBuf::from(name.into());
-                cache.add_source(SourcePath::Path(path), source)?
+                cache.add_source(SourcePath::Path(path, InputFormat::Nickel), source)?
             }
         };
 
@@ -252,13 +256,19 @@ impl<EC: EvalCache> Program<EC> {
         let merge_term = inputs
             .into_iter()
             .map(|input| match input {
-                Input::Path(path) => RichTerm::from(Term::Import(path.into())),
+                Input::Path(path) => RichTerm::from(Term::Import {
+                    path: path.into(),
+                    format: InputFormat::Nickel,
+                }),
                 Input::Source(source, name) => {
                     let path = PathBuf::from(name.into());
                     cache
-                        .add_source(SourcePath::Path(path.clone()), source)
+                        .add_source(SourcePath::Path(path.clone(), InputFormat::Nickel), source)
                         .unwrap();
-                    RichTerm::from(Term::Import(path.into()))
+                    RichTerm::from(Term::Import {
+                        path: path.into(),
+                        format: InputFormat::Nickel,
+                    })
                 }
             })
             .reduce(|acc, f| mk_term::op2(BinaryOp::Merge(Label::default().into()), acc, f))
@@ -383,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
@@ -450,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()?;
@@ -527,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
@@ -534,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
@@ -595,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};
@@ -607,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)
@@ -622,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 {
@@ -654,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
                                 },
@@ -672,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
@@ -866,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/repl/mod.rs b/core/src/repl/mod.rs
index 1e376a10..28ef952f 100644
--- a/core/src/repl/mod.rs
+++ b/core/src/repl/mod.rs
@@ -230,7 +230,7 @@ impl<EC: EvalCache> Repl for ReplImpl<EC> {
         let file_id = self
             .vm
             .import_resolver_mut()
-            .add_file(OsString::from(path.as_ref()))
+            .add_file(OsString::from(path.as_ref()), InputFormat::Nickel)
             .map_err(IOError::from)?;
         self.vm
             .import_resolver_mut()
diff --git a/core/src/repl/query_print.rs b/core/src/repl/query_print.rs
index e2b92cdb..39c068b8 100644
--- a/core/src/repl/query_print.rs
+++ b/core/src/repl/query_print.rs
@@ -1,4 +1,6 @@
 //! Rendering of the results of a metadata query.
+use serde::Serialize;
+
 use crate::{
     identifier::{Ident, LocIdent},
     pretty::PrettyPrintCap,
@@ -153,7 +155,7 @@ impl QueryPrinter for MarkdownRenderer {
 }
 
 /// Represent which metadata attributes are requested by a query.
-#[derive(Clone, Copy, Eq, PartialEq)]
+#[derive(Clone, Copy, Eq, PartialEq, Serialize)]
 pub struct Attributes {
     pub doc: bool,
     pub contract: bool,
diff --git a/core/src/serialize.rs b/core/src/serialize.rs
index 16eafe02..aa0b3043 100644
--- a/core/src/serialize.rs
+++ b/core/src/serialize.rs
@@ -26,7 +26,6 @@ use once_cell::sync::Lazy;
 use std::{fmt, io, rc::Rc};
 
 /// Available export formats.
-// If you add or remove variants, remember to update the CLI docs in `src/bin/nickel.rs'
 #[derive(Copy, Clone, Eq, PartialEq, Debug, Default, clap::ValueEnum)]
 pub enum ExportFormat {
     Raw,
@@ -47,8 +46,27 @@ impl fmt::Display for ExportFormat {
     }
 }
 
-// TODO: This type is publicly exposed, but never constructed.
-#[allow(dead_code)]
+/// Available metadata export formats.
+#[derive(Copy, Clone, Eq, PartialEq, Debug, Default, clap::ValueEnum)]
+pub enum MetadataExportFormat {
+    #[default]
+    Markdown,
+    Json,
+    Yaml,
+    Toml,
+}
+
+impl fmt::Display for MetadataExportFormat {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        match self {
+            Self::Markdown => write!(f, "markdown"),
+            Self::Json => write!(f, "json"),
+            Self::Yaml => write!(f, "yaml"),
+            Self::Toml => write!(f, "toml"),
+        }
+    }
+}
+
 #[derive(Clone, Eq, PartialEq, Debug)]
 pub struct ParseFormatError(String);
 
@@ -370,6 +388,34 @@ pub fn validate(format: ExportFormat, t: &RichTerm) -> Result<(), ExportError> {
     }
 }
 
+pub fn to_writer_metadata<W, T>(
+    mut writer: W,
+    format: MetadataExportFormat,
+    item: &T,
+) -> Result<(), ExportError>
+where
+    W: io::Write,
+    T: ?Sized + Serialize,
+{
+    // This is a near-verbatim copy of `to_writer`
+    match format {
+        MetadataExportFormat::Markdown => unimplemented!(),
+        MetadataExportFormat::Json => serde_json::to_writer_pretty(writer, &item)
+            .map_err(|err| ExportErrorData::Other(err.to_string())),
+        MetadataExportFormat::Yaml => serde_yaml::to_writer(writer, &item)
+            .map_err(|err| ExportErrorData::Other(err.to_string())),
+        MetadataExportFormat::Toml => toml::to_string_pretty(item)
+            .map_err(|err| ExportErrorData::Other(err.to_string()))
+            .and_then(|s| {
+                writer
+                    .write_all(s.as_bytes())
+                    .map_err(|err| ExportErrorData::Other(err.to_string()))
+            }),
+    }?;
+
+    Ok(())
+}
+
 pub fn to_writer<W>(mut writer: W, format: ExportFormat, rt: &RichTerm) -> Result<(), ExportError>
 where
     W: io::Write,
diff --git a/core/src/term/mod.rs b/core/src/term/mod.rs
index 3e50f8fb..d7eb929c 100644
--- a/core/src/term/mod.rs
+++ b/core/src/term/mod.rs
@@ -17,9 +17,11 @@ pub mod string;
 use array::{Array, ArrayAttrs};
 use pattern::Pattern;
 use record::{Field, FieldDeps, FieldMetadata, RecordData, RecordDeps};
+use smallvec::SmallVec;
 use string::NickelString;
 
 use crate::{
+    cache::InputFormat,
     error::{EvalError, ParseError},
     eval::{cache::CacheIndex, Environment},
     identifier::LocIdent,
@@ -45,7 +47,7 @@ pub use malachite::{
     Integer, Rational,
 };
 
-use serde::{Deserialize, Serialize};
+use serde::{Deserialize, Serialize, Serializer};
 
 // Because we use `IndexMap` for recors, consumer of Nickel (as a library) might have to
 // manipulate values of this type, so we re-export this type.
@@ -109,11 +111,11 @@ pub enum Term {
 
     /// A let binding.
     #[serde(skip)]
-    Let(LocIdent, RichTerm, RichTerm, LetAttrs),
+    Let(SmallVec<[(LocIdent, RichTerm); 4]>, RichTerm, LetAttrs),
 
     /// A destructuring let-binding.
     #[serde(skip)]
-    LetPattern(Pattern, RichTerm, RichTerm),
+    LetPattern(SmallVec<[(Pattern, RichTerm); 1]>, RichTerm, LetAttrs),
 
     /// An application.
     #[serde(skip)]
@@ -206,7 +208,7 @@ pub enum Term {
 
     /// An unresolved import.
     #[serde(skip)]
-    Import(OsString),
+    Import { path: OsString, format: InputFormat },
 
     /// A resolved import (which has already been loaded and parsed).
     #[serde(skip)]
@@ -343,9 +345,7 @@ impl PartialEq for Term {
             (Self::Fun(l0, l1), Self::Fun(r0, r1)) => l0 == r0 && l1 == r1,
             (Self::FunPattern(l0, l1), Self::FunPattern(r0, r1)) => l0 == r0 && l1 == r1,
             (Self::Lbl(l0), Self::Lbl(r0)) => l0 == r0,
-            (Self::Let(l0, l1, l2, l3), Self::Let(r0, r1, r2, r3)) => {
-                l0 == r0 && l1 == r1 && l2 == r2 && l3 == r3
-            }
+            (Self::Let(l0, l1, l2), Self::Let(r0, r1, r2)) => l0 == r0 && l1 == r1 && l2 == r2,
             (Self::LetPattern(l0, l1, l2), Self::LetPattern(r0, r1, r2)) => {
                 l0 == r0 && l1 == r1 && l2 == r2
             }
@@ -366,7 +366,16 @@ impl PartialEq for Term {
                 l0 == r0 && l1 == r1 && l2 == r2
             }
             (Self::Annotated(l0, l1), Self::Annotated(r0, r1)) => l0 == r0 && l1 == r1,
-            (Self::Import(l0), Self::Import(r0)) => l0 == r0,
+            (
+                Self::Import {
+                    path: l0,
+                    format: l1,
+                },
+                Self::Import {
+                    path: r0,
+                    format: r1,
+                },
+            ) => l0 == r0 && l1 == r1,
             (Self::ResolvedImport(l0), Self::ResolvedImport(r0)) => l0 == r0,
             (
                 Self::Type {
@@ -681,6 +690,15 @@ impl fmt::Display for MergePriority {
     }
 }
 
+impl Serialize for MergePriority {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: Serializer,
+    {
+        serializer.serialize_str(&self.to_string())
+    }
+}
+
 /// A branch of a match expression.
 #[derive(Debug, PartialEq, Clone)]
 pub struct MatchBranch {
@@ -731,6 +749,15 @@ impl LabeledType {
     }
 }
 
+impl Serialize for LabeledType {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: Serializer,
+    {
+        serializer.serialize_str(&self.label.typ.to_string())
+    }
+}
+
 impl Traverse<RichTerm> for LabeledType {
     // Note that this function doesn't traverse the label, which is most often what you want. The
     // terms that may hide in a label are mostly types used for error reporting, but are never
@@ -974,7 +1001,7 @@ impl Term {
             | Term::Op1(_, _)
             | Term::Op2(_, _, _)
             | Term::OpN(..)
-            | Term::Import(_)
+            | Term::Import { .. }
             | Term::ResolvedImport(_)
             | Term::StrChunks(_)
             | Term::ParseError(_)
@@ -1027,7 +1054,7 @@ impl Term {
             | Term::OpN(..)
             | Term::Sealed(..)
             | Term::Annotated(..)
-            | Term::Import(_)
+            | Term::Import{..}
             | Term::ResolvedImport(_)
             | Term::StrChunks(_)
             | Term::RecRecord(..)
@@ -1089,7 +1116,7 @@ impl Term {
             | Term::OpN(..)
             | Term::Sealed(..)
             | Term::Annotated(..)
-            | Term::Import(_)
+            | Term::Import { .. }
             | Term::ResolvedImport(_)
             | Term::StrChunks(_)
             | Term::RecRecord(..)
@@ -1145,7 +1172,7 @@ impl Term {
             | Term::OpN(..)
             | Term::Sealed(..)
             | Term::Annotated(..)
-            | Term::Import(..)
+            | Term::Import{..}
             | Term::ResolvedImport(..)
             | Term::Closure(_)
             | Term::ParseError(_)
@@ -2200,15 +2227,21 @@ impl Traverse<RichTerm> for RichTerm {
                 let t = t.traverse(f, order)?;
                 RichTerm::new(Term::CustomContract(t), pos)
             }
-            Term::Let(id, t1, t2, attrs) => {
-                let t1 = t1.traverse(f, order)?;
-                let t2 = t2.traverse(f, order)?;
-                RichTerm::new(Term::Let(id, t1, t2, attrs), pos)
+            Term::Let(bindings, body, attrs) => {
+                let bindings = bindings
+                    .into_iter()
+                    .map(|(key, val)| Ok((key, val.traverse(f, order)?)))
+                    .collect::<Result<_, E>>()?;
+                let body = body.traverse(f, order)?;
+                RichTerm::new(Term::Let(bindings, body, attrs), pos)
             }
-            Term::LetPattern(pat, t1, t2) => {
-                let t1 = t1.traverse(f, order)?;
-                let t2 = t2.traverse(f, order)?;
-                RichTerm::new(Term::LetPattern(pat, t1, t2), pos)
+            Term::LetPattern(bindings, body, attrs) => {
+                let bindings = bindings
+                    .into_iter()
+                    .map(|(key, val)| Ok((key, val.traverse(f, order)?)))
+                    .collect::<Result<_, E>>()?;
+                let body = body.traverse(f, order)?;
+                RichTerm::new(Term::LetPattern(bindings, body, attrs), pos)
             }
             Term::App(t1, t2) => {
                 let t1 = t1.traverse(f, order)?;
@@ -2378,7 +2411,7 @@ impl Traverse<RichTerm> for RichTerm {
             | Term::Var(_)
             | Term::Closure(_)
             | Term::Enum(_)
-            | Term::Import(_)
+            | Term::Import { .. }
             | Term::ResolvedImport(_)
             | Term::SealingKey(_)
             | Term::ForeignId(_)
@@ -2397,10 +2430,15 @@ impl Traverse<RichTerm> for RichTerm {
             | Term::Op1(_, t)
             | Term::Sealed(_, t, _)
             | Term::CustomContract(t) => t.traverse_ref(f, state),
-            Term::Let(_, t1, t2, _)
-            | Term::LetPattern(_, t1, t2)
-            | Term::App(t1, t2)
-            | Term::Op2(_, t1, t2) => t1
+            Term::Let(bindings, body, _) => bindings
+                .iter()
+                .find_map(|(_id, t)| t.traverse_ref(f, state))
+                .or_else(|| body.traverse_ref(f, state)),
+            Term::LetPattern(bindings, body, _) => bindings
+                .iter()
+                .find_map(|(_pat, t)| t.traverse_ref(f, state))
+                .or_else(|| body.traverse_ref(f, state)),
+            Term::App(t1, t2) | Term::Op2(_, t1, t2) => t1
                 .traverse_ref(f, state)
                 .or_else(|| t2.traverse_ref(f, state)),
             Term::Record(data) => data
@@ -2706,44 +2744,80 @@ pub mod make {
         Term::Var(v.into()).into()
     }
 
-    fn let_in_<I, T1, T2>(rec: bool, id: I, t1: T1, t2: T2) -> RichTerm
+    pub fn let_in<I, T1, T2, Iter>(rec: bool, bindings: Iter, t2: T2) -> RichTerm
     where
         T1: Into<RichTerm>,
         T2: Into<RichTerm>,
         I: Into<LocIdent>,
+        Iter: IntoIterator<Item = (I, T1)>,
     {
         let attrs = LetAttrs {
             binding_type: BindingType::Normal,
             rec,
         };
-        Term::Let(id.into(), t1.into(), t2.into(), attrs).into()
+        Term::Let(
+            bindings
+                .into_iter()
+                .map(|(id, t)| (id.into(), t.into()))
+                .collect(),
+            t2.into(),
+            attrs,
+        )
+        .into()
     }
 
-    pub fn let_in<I, T1, T2>(id: I, t1: T1, t2: T2) -> RichTerm
+    pub fn let_one_in<I, T1, T2>(id: I, t1: T1, t2: T2) -> RichTerm
     where
         T1: Into<RichTerm>,
         T2: Into<RichTerm>,
         I: Into<LocIdent>,
     {
-        let_in_(false, id, t1, t2)
+        let_in(false, std::iter::once((id, t1)), t2)
     }
 
-    pub fn let_rec_in<I, T1, T2>(id: I, t1: T1, t2: T2) -> RichTerm
+    pub fn let_one_rec_in<I, T1, T2>(id: I, t1: T1, t2: T2) -> RichTerm
     where
         T1: Into<RichTerm>,
         T2: Into<RichTerm>,
         I: Into<LocIdent>,
     {
-        let_in_(true, id, t1, t2)
+        let_in(true, std::iter::once((id, t1)), t2)
     }
 
-    pub fn let_pat<D, T1, T2>(pat: D, t1: T1, t2: T2) -> RichTerm
+    pub fn let_one_pat<D, T1, T2>(pat: D, t1: T1, t2: T2) -> RichTerm
     where
         T1: Into<RichTerm>,
         T2: Into<RichTerm>,
         D: Into<Pattern>,
     {
-        Term::LetPattern(pat.into(), t1.into(), t2.into()).into()
+        Term::LetPattern(
+            std::iter::once((pat.into(), t1.into())).collect(),
+            t2.into(),
+            LetAttrs::default(),
+        )
+        .into()
+    }
+
+    pub fn let_pat_in<D, T1, T2, Iter>(rec: bool, bindings: Iter, body: T2) -> RichTerm
+    where
+        T1: Into<RichTerm>,
+        T2: Into<RichTerm>,
+        D: Into<Pattern>,
+        Iter: IntoIterator<Item = (D, T1)>,
+    {
+        let attrs = LetAttrs {
+            binding_type: BindingType::Normal,
+            rec,
+        };
+        Term::LetPattern(
+            bindings
+                .into_iter()
+                .map(|(pat, t)| (pat.into(), t.into()))
+                .collect(),
+            body.into(),
+            attrs,
+        )
+        .into()
     }
 
     pub fn if_then_else<T1, T2, T3>(cond: T1, t1: T2, t2: T3) -> RichTerm
@@ -2806,11 +2880,15 @@ pub mod make {
         mk_fun!("x", var("x"))
     }
 
-    pub fn import<S>(path: S) -> RichTerm
+    pub fn import<S>(path: S, format: InputFormat) -> RichTerm
     where
         S: Into<OsString>,
     {
-        Term::Import(path.into()).into()
+        Term::Import {
+            path: path.into(),
+            format,
+        }
+        .into()
     }
 
     pub fn integer(n: impl Into<i64>) -> RichTerm {
diff --git a/lsp/nls/src/pattern.rs b/core/src/term/pattern/bindings.rs
similarity index 99%
rename from lsp/nls/src/pattern.rs
rename to core/src/term/pattern/bindings.rs
index 1bdf88fd..637b0bfc 100644
--- a/lsp/nls/src/pattern.rs
+++ b/core/src/term/pattern/bindings.rs
@@ -1,11 +1,11 @@
 //! Pattern analysis.
 
-use nickel_lang_core::{
+use crate::{
     identifier::LocIdent,
     term::{pattern::*, record::Field},
 };
 
-pub(super) trait Bindings {
+pub trait Bindings {
     /// Returns a list of all variables bound by this pattern, together with the path to the field
     /// they match and the associated extra annotations.
     ///
diff --git a/core/src/term/pattern/compile.rs b/core/src/term/pattern/compile.rs
index bb4b645a..c78319e6 100644
--- a/core/src/term/pattern/compile.rs
+++ b/core/src/term/pattern/compile.rs
@@ -239,7 +239,7 @@ impl CompilePart for Pattern {
         //
         // if `alias` is set, or just `continuation` otherwise.
         if let Some(alias) = self.alias {
-            make::let_in(
+            make::let_one_in(
                 bindings_id,
                 insert_binding(alias, value_id, bindings_id),
                 continuation,
@@ -340,7 +340,7 @@ impl CompilePart for OrPattern {
                     pattern.compile_part(value_id, bindings_id),
                 );
 
-                make::let_in(prev_bindings, cont, if_block)
+                make::let_one_in(prev_bindings, cont, if_block)
             })
     }
 }
@@ -459,7 +459,7 @@ impl CompilePart for RecordPattern {
 
             // let bindings_id = <remove_from_rest(field, local_bindings_id)> in
             // <field.compile_part(local_value_id, local_bindings_id)>
-            let updated_bindings_let = make::let_in(
+            let updated_bindings_let = make::let_one_in(
                 local_bindings_id,
                 remove_from_rest(rest_field, field, local_bindings_id),
                 field_pat
@@ -478,7 +478,7 @@ impl CompilePart for RecordPattern {
 
             // let local_value_id = <extracted_value> in <updated_bindings_let>
             let inner_else_block =
-                make::let_in(local_value_id, extracted_value, updated_bindings_let);
+                make::let_one_in(local_value_id, extracted_value, updated_bindings_let);
 
             // The innermost if:
             //
@@ -493,13 +493,13 @@ impl CompilePart for RecordPattern {
             );
 
             // let local_bindings_id = cont in <value_let>
-            let binding_cont_let = make::let_in(local_bindings_id, cont, inner_if);
+            let binding_cont_let = make::let_one_in(local_bindings_id, cont, inner_if);
 
             // <if !field.annotation.is_empty()>
             //   let value_id = <update_with_merge...> in <binding_cont_let>
             // <end if>
             let optional_merge = if !field_pat.annotation.is_empty() {
-                make::let_in(
+                make::let_one_in(
                     value_id,
                     update_with_merge(
                         value_id,
@@ -529,7 +529,7 @@ impl CompilePart for RecordPattern {
             //   let value_id = <with_default_value value_id field default> in
             // <end if>
             if let Some(default) = field_pat.default.as_ref() {
-                make::let_in(
+                make::let_one_in(
                     value_id,
                     with_default_value(value_id, field, default.clone()),
                     enclosing_if,
@@ -614,7 +614,7 @@ impl CompilePart for RecordPattern {
 
         // The let enclosing the fold block and the final block:
         // let final_bindings_id = <fold_block> in <tail_block>
-        let outer_let = make::let_in(final_bindings_id, fold_block, guard_tail_block);
+        let outer_let = make::let_one_in(final_bindings_id, fold_block, guard_tail_block);
 
         // if <is_record> then <outer_let> else null
         make::if_then_else(is_record, outer_let, Term::Null)
@@ -696,7 +696,7 @@ impl CompilePart for ArrayPattern {
 
                 // let local_value_id = <extracted_value> in <updated_bindings_let>
                 let inner_else_block =
-                    make::let_in(local_value_id, extracted_value, updated_bindings_let);
+                    make::let_one_in(local_value_id, extracted_value, updated_bindings_let);
 
                 // The innermost if:
                 //
@@ -711,7 +711,7 @@ impl CompilePart for ArrayPattern {
                 );
 
                 // let local_bindings_id = cont in <inner_if>
-                make::let_in(local_bindings_id, cont, inner_if)
+                make::let_one_in(local_bindings_id, cont, inner_if)
             },
         );
 
@@ -771,13 +771,13 @@ impl CompilePart for ArrayPattern {
 
         // The let enclosing the fold block and the let binding `final_bindings_id`:
         // let final_bindings_id = <fold_block> in <tail_block>
-        let outer_let = make::let_in(final_bindings_id, fold_block, guard_tail_block);
+        let outer_let = make::let_one_in(final_bindings_id, fold_block, guard_tail_block);
 
         // if <outer_check> then <outer_let> else null
         let outer_if = make::if_then_else(outer_check, outer_let, Term::Null);
 
         // finally, we need to bind `value_len_id` to the length of the array
-        make::let_in(
+        make::let_one_in(
             value_len_id,
             make::op1(UnaryOp::ArrayLength, Term::Var(value_id)),
             outer_if,
@@ -812,7 +812,7 @@ impl CompilePart for EnumPattern {
 
             make::if_then_else(
                 if_condition,
-                make::let_in(
+                make::let_one_in(
                     value_id,
                     make::op1(UnaryOp::EnumGetArg, Term::Var(value_id)),
                     pat.compile_part(value_id, bindings_id),
@@ -1011,10 +1011,10 @@ impl Compile for MatchData {
                 // let init_bindings_id = {} in
                 // let bindings_id = <pattern.compile_part(value_id, init_bindings)> in
                 // <inner>
-                make::let_in(
+                make::let_one_in(
                     init_bindings_id,
                     Term::Record(RecordData::empty()),
-                    make::let_in(
+                    make::let_one_in(
                         bindings_id,
                         branch.pattern.compile_part(value_id, init_bindings_id),
                         inner,
@@ -1023,7 +1023,7 @@ impl Compile for MatchData {
             });
 
         // let value_id = value in <fold_block>
-        make::let_in(value_id, value, fold_block)
+        make::let_one_in(value_id, value, fold_block)
     }
 }
 
diff --git a/core/src/term/pattern/mod.rs b/core/src/term/pattern/mod.rs
index b99c46af..d1880321 100644
--- a/core/src/term/pattern/mod.rs
+++ b/core/src/term/pattern/mod.rs
@@ -11,6 +11,7 @@ use crate::{
     position::TermPos,
 };
 
+pub mod bindings;
 pub mod compile;
 
 /// A small helper to generate a
diff --git a/core/src/transform/desugar_destructuring.rs b/core/src/transform/desugar_destructuring.rs
index 69639b21..404189dd 100644
--- a/core/src/transform/desugar_destructuring.rs
+++ b/core/src/transform/desugar_destructuring.rs
@@ -1,18 +1,17 @@
 //! Destructuring desugaring
 //!
-//! Replace a let-binding with destructuring by simpler constructs.
-//!
-//! A destructuring `let pat> = <destr> in <body>` is rewritten to the pattern matching
-//! `<destr> |> match { <pat> => <body> }`.
-//!
-//! A destructuring function `fun <pat> => <body>` is rewritten to
-//! `fun x => let <pat> = x in <body>`, and then the inner let is recursively desugared.
+//! Replace a let and function bindings with destructuring by simpler constructs.
+use smallvec::SmallVec;
+
 use crate::{
+    error::EvalError,
     identifier::LocIdent,
     match_sharedterm,
-    term::{pattern::*, MatchBranch, MatchData, RichTerm, Term},
+    term::{make, pattern::*, record::RecordData, BinaryOp, BindingType, LetAttrs, RichTerm, Term},
 };
 
+use self::{bindings::Bindings, compile::CompilePart};
+
 /// Entry point of the destructuring desugaring transformation.
 ///
 /// As other `transform_one` variants, this transformation is not recursive and only desugars the
@@ -20,7 +19,8 @@ use crate::{
 /// destructuring patterns to be desugared in children nodes.
 pub fn transform_one(rt: RichTerm) -> RichTerm {
     match_sharedterm!(match (rt.term) {
-        Term::LetPattern(pat, bound, body) => RichTerm::new(desugar_let(pat, bound, body), rt.pos),
+        Term::LetPattern(bindings, body, attrs) =>
+            RichTerm::new(desugar_let(bindings, body, attrs.rec), rt.pos),
         Term::FunPattern(pat, body) => RichTerm::new(desugar_fun(pat, body), rt.pos),
         _ => rt,
     })
@@ -38,7 +38,11 @@ pub fn desugar_fun(mut pat: Pattern, body: RichTerm) -> Term {
     Term::Fun(
         id,
         RichTerm::new(
-            Term::LetPattern(pat, Term::Var(id).into(), body),
+            Term::LetPattern(
+                std::iter::once((pat, Term::Var(id).into())).collect(),
+                body,
+                LetAttrs::default(),
+            ),
             // TODO: should we use rt.pos?
             pos_body,
         ),
@@ -47,24 +51,116 @@ pub fn desugar_fun(mut pat: Pattern, body: RichTerm) -> Term {
 
 /// Desugar a destructuring let-binding.
 ///
-/// A let-binding `let <pat> = bound in body` is desugared to `<bound> |> match { <pat> => body }`.
-pub fn desugar_let(pattern: Pattern, bound: RichTerm, body: RichTerm) -> Term {
-    // the position of the match expression is used during error reporting, so we try to provide a
-    // sensible one.
-    let match_expr_pos = pattern.pos.fuse(bound.pos);
+/// A let-binding
+///
+/// ```text
+/// let
+///   <pat1> = <bound1>,
+///   <pat2> = <bound2>
+/// in body
+/// ```
+///
+/// is desugared to
+///
+/// ```text
+/// let
+///   %b1 = <bound1>,
+///   %b2 = <bound2>,
+///   %empty_record = {},
+/// in
+/// let
+///   %r1 = <pat1.compile_part(%b1, %empty_record)>,
+///   %r2 = <pat2.compile_part(%b2, %empty_record)>,
+/// in
+/// let
+///   foo = %r1.foo,
+///   ...
+///   baz = %r2.baz,
+/// in
+///   if %r1 == null then <error1> else if %r2 == null then <error2> else body
+/// ```
+/// where `foo` and `baz` are names bound in `<pat1>` and `<pat2>`.
+///
+/// There's some ambiguity about where to put the error-checking. It might be natural
+/// to put it before trying to access `%r1.foo`, but that would only raise the error
+/// if someone tries to evaluate `foo`. Putting it in the body as above raises
+/// an error, for example, in `let 'Foo = 'Bar in true`.
+///
+/// A recursive let-binding is desugared almost the same way, except that everything is
+/// shoved into a single let-rec block instead of three nested blocks.
+pub fn desugar_let(
+    bindings: SmallVec<[(Pattern, RichTerm); 1]>,
+    body: RichTerm,
+    rec: bool,
+) -> Term {
+    // Outer bindings are the ones we called %b1 and %b2, and %empty_record_id in the doc above.
+    let mut outer_bindings = SmallVec::new();
+    // Mid bindings are the ones we called %r1 and %r2 above.
+    let mut mid_bindings = SmallVec::new();
+    // Inner bindings are the ones that bind the actual variables defined in the patterns.
+    let mut inner_bindings = SmallVec::new();
+    let mut error_tests = Vec::new();
 
-    // `(match { <pat> => <body> }) <bound>`
-    Term::App(
-        RichTerm::new(
-            Term::Match(MatchData {
-                branches: vec![MatchBranch {
-                    pattern,
-                    guard: None,
-                    body,
-                }],
+    let empty_record_id = LocIdent::fresh();
+    outer_bindings.push((empty_record_id, Term::Record(RecordData::empty()).into()));
+    for (pat, rhs) in bindings {
+        let pos = pat.pos.fuse(rhs.pos);
+        let outer_id = LocIdent::fresh();
+        outer_bindings.push((outer_id, rhs.clone()));
+
+        let mid_id = LocIdent::fresh();
+        mid_bindings.push((mid_id, pat.compile_part(outer_id, empty_record_id)));
+
+        let error_case = RichTerm::new(
+            Term::RuntimeError(EvalError::FailedDestructuring {
+                value: rhs.clone(),
+                pattern: pat.clone(),
             }),
-            match_expr_pos,
-        ),
-        bound,
-    )
+            pos,
+        );
+
+        let is_record_null = make::op2(BinaryOp::Eq, Term::Var(mid_id), Term::Null);
+        error_tests.push((is_record_null, error_case));
+
+        for (_path, id, _field) in pat.bindings() {
+            inner_bindings.push((
+                id,
+                make::static_access(Term::Var(mid_id), std::iter::once(id)),
+            ));
+        }
+    }
+
+    let checked_body = error_tests
+        .into_iter()
+        .rev()
+        .fold(body, |acc, (check, error)| {
+            make::if_then_else(check, error, acc)
+        });
+
+    let attrs = LetAttrs {
+        binding_type: BindingType::Normal,
+        rec,
+    };
+    if rec {
+        Term::Let(
+            outer_bindings
+                .into_iter()
+                .chain(mid_bindings)
+                .chain(inner_bindings)
+                .collect(),
+            checked_body,
+            attrs,
+        )
+    } else {
+        Term::Let(
+            outer_bindings,
+            Term::Let(
+                mid_bindings,
+                Term::Let(inner_bindings, checked_body, attrs.clone()).into(),
+                attrs.clone(),
+            )
+            .into(),
+            attrs,
+        )
+    }
 }
diff --git a/core/src/transform/free_vars.rs b/core/src/transform/free_vars.rs
index e4ab64d3..b91395a9 100644
--- a/core/src/transform/free_vars.rs
+++ b/core/src/transform/free_vars.rs
@@ -42,7 +42,7 @@ impl CollectFreeVars for RichTerm {
             | Term::ForeignId(_)
             | Term::SealingKey(_)
             | Term::Enum(_)
-            | Term::Import(_)
+            | Term::Import { .. }
             | Term::ResolvedImport(_) => (),
             Term::Fun(id, t) => {
                 let mut fresh = HashSet::new();
@@ -60,26 +60,39 @@ impl CollectFreeVars for RichTerm {
 
                 free_vars.extend(fresh);
             }
-            Term::Let(id, t1, t2, attrs) => {
+            Term::Let(bindings, body, attrs) => {
                 let mut fresh = HashSet::new();
 
-                if attrs.rec {
-                    t1.collect_free_vars(&mut fresh);
-                } else {
-                    t1.collect_free_vars(free_vars);
+                for (_id, rt) in bindings.iter_mut() {
+                    if attrs.rec {
+                        rt.collect_free_vars(&mut fresh);
+                    } else {
+                        rt.collect_free_vars(free_vars);
+                    }
                 }
 
-                t2.collect_free_vars(&mut fresh);
-                fresh.remove(&id.ident());
+                body.collect_free_vars(&mut fresh);
+                for (id, _rt) in bindings {
+                    fresh.remove(&id.ident());
+                }
 
                 free_vars.extend(fresh);
             }
-            Term::LetPattern(pat, t1, t2) => {
+            Term::LetPattern(bindings, body, attrs) => {
                 let mut fresh = HashSet::new();
 
-                t1.collect_free_vars(free_vars);
-                t2.collect_free_vars(&mut fresh);
-                pat.remove_bindings(&mut fresh);
+                for (_pat, rt) in bindings.iter_mut() {
+                    if attrs.rec {
+                        rt.collect_free_vars(&mut fresh);
+                    } else {
+                        rt.collect_free_vars(free_vars);
+                    }
+                }
+
+                body.collect_free_vars(&mut fresh);
+                for (pat, _rt) in bindings {
+                    pat.remove_bindings(&mut fresh);
+                }
 
                 free_vars.extend(fresh);
             }
diff --git a/core/src/transform/import_resolution.rs b/core/src/transform/import_resolution.rs
index f5497504..6bf9d24c 100644
--- a/core/src/transform/import_resolution.rs
+++ b/core/src/transform/import_resolution.rs
@@ -144,7 +144,8 @@ pub mod tolerant {
     {
         let term = rt.as_ref();
         match term {
-            Term::Import(path) => match resolver.resolve(path, parent, &rt.pos) {
+            Term::Import { path, format } => match resolver.resolve(path, *format, parent, &rt.pos)
+            {
                 Ok((_, file_id)) => (RichTerm::new(Term::ResolvedImport(file_id), rt.pos), None),
                 Err(err) => (rt, Some(err)),
             },
diff --git a/core/src/typecheck/eq.rs b/core/src/typecheck/eq.rs
index 0cceb9e3..e9835b28 100644
--- a/core/src/typecheck/eq.rs
+++ b/core/src/typecheck/eq.rs
@@ -548,6 +548,11 @@ fn erows_as_map<E: TermEnvironment>(
 
 /// Check for contract equality between record fields. Fields are equal if they are both without a
 /// definition, or are both defined and their values are equal.
+///
+/// The attached metadata must be equal as well: most record contracts are written as field with
+/// metadata but without definition. For example, take `{ foo | {bar | Number}}` and `{foo | {bar |
+/// String}}`. Those two record contracts are obviously not equal, but to know that, we have to
+/// look at the contracts of each bar field.
 fn contract_eq_fields<E: TermEnvironment>(
     state: &mut State,
     field1: &Field,
@@ -555,13 +560,57 @@ fn contract_eq_fields<E: TermEnvironment>(
     field2: &Field,
     env2: &E,
 ) -> bool {
-    match (&field1.value, &field2.value) {
+    // Check that the pending contracts are equal.
+    //
+    // [^contract-eq-ignore-label]: We mostly ignore the label here, which doesn't impact the fact
+    // that a contract blame or not. Different labels might lead to different error messages,
+    // though. Note that there is one important exception: the field `type_environment` does impact
+    // the evaluation of the contract. Fortunately, it's a simple datastructure that is easy to
+    // compare, so we do check for equality here.
+    //
+    // Otherwise, comparing the rest of the labels seem rather clumsy (as labels store a wide
+    // variety of static and runtime data) and not very meaningful.
+    let pending_contracts_eq = field1
+        .pending_contracts
+        .iter()
+        .zip(field2.pending_contracts.iter())
+        .all(|(c1, c2)| {
+            c1.label.type_environment == c2.label.type_environment
+                && contract_eq_bounded(state, &c1.contract, env1, &c2.contract, env2)
+        });
+
+    // Check that the type and contrat annotations are equal. [^contract-eq-ignore-label] applies
+    // here as well.
+    let annotations_eq = field1
+        .metadata
+        .annotation
+        .iter()
+        .zip(field2.metadata.annotation.iter())
+        .all(|(t1, t2)| {
+            t1.label.type_environment == t2.label.type_environment
+                && type_eq_bounded(
+                    state,
+                    &GenericUnifType::from_type(t1.typ.clone(), env1),
+                    env1,
+                    &GenericUnifType::from_type(t2.typ.clone(), env2),
+                    env2,
+                )
+        });
+
+    // Check that "scalar" metadata (simple values) are equals
+    let scalar_metadata_eq = field1.metadata.opt == field2.metadata.opt
+        && field1.metadata.not_exported == field2.metadata.not_exported
+        && field1.metadata.priority == field2.metadata.priority;
+
+    let value_eq = match (&field1.value, &field2.value) {
         (Some(ref value1), Some(ref value2)) => {
             contract_eq_bounded(state, value1, env1, value2, env2)
         }
         (None, None) => true,
         _ => false,
-    }
+    };
+
+    pending_contracts_eq && annotations_eq && scalar_metadata_eq && value_eq
 }
 
 /// Perform the type equality comparison on types. Structurally recurse into type constructors and
diff --git a/core/src/typecheck/mod.rs b/core/src/typecheck/mod.rs
index 72918472..5088f8e8 100644
--- a/core/src/typecheck/mod.rs
+++ b/core/src/typecheck/mod.rs
@@ -58,13 +58,12 @@ use crate::{
     environment::Environment as GenericEnvironment,
     error::TypecheckError,
     identifier::{Ident, LocIdent},
-    stdlib as nickel_stdlib,
+    mk_uty_arrow, mk_uty_enum, mk_uty_record, mk_uty_record_row, stdlib as nickel_stdlib,
     term::{
-        record::Field, LabeledType, MatchBranch, RichTerm, StrChunk, Term, Traverse, TraverseOrder,
-        TypeAnnotation,
+        pattern::bindings::Bindings as _, record::Field, LabeledType, MatchBranch, RichTerm,
+        StrChunk, Term, Traverse, TraverseOrder, TypeAnnotation,
     },
     typ::*,
-    {mk_uty_arrow, mk_uty_enum, mk_uty_record, mk_uty_record_row},
 };
 
 use std::{
@@ -1373,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.
@@ -1450,7 +1449,7 @@ fn walk<V: TypecheckVisitor>(
         | Term::SealingKey(_)
         // This function doesn't recursively typecheck imports: this is the responsibility of the
         // caller.
-        | Term::Import(_)
+        | Term::Import{..}
         | Term::ResolvedImport(_) => Ok(()),
         Term::Var(x) => ctxt.type_env
             .get(&x.ident())
@@ -1486,52 +1485,84 @@ fn walk<V: TypecheckVisitor>(
             .try_for_each(|t| -> Result<(), TypecheckError> {
                 walk(state, ctxt.clone(), visitor, t)
             }),
-        Term::Let(x, re, rt, attrs) => {
-            let ty_let = binding_type(state, re.as_ref(), &ctxt, false);
+        Term::Let(bindings, rt, attrs) => {
+            // For a recursive let block, shadow all the names we're about to bind, so
+            // we aren't influenced by variables defined in an outer scope.
+            if attrs.rec {
+                for (x, _re) in bindings {
+                    ctxt.type_env
+                        .insert(x.ident(), state.table.fresh_type_uvar(ctxt.var_level));
+                }
+            }
 
-            // We don't support recursive binding when checking for contract equality.
-            //
-            // This would quickly lead to cycles, which are hard to deal with without leaking
-            // memory. In order to deal with recursive bindings, the best way is probably to
-            // allocate all the term environments inside an arena, local to each statically typed
-            // block, and use bare references to represent cycles. Then everything would be cleaned
-            // at the end of the block.
-            ctxt.term_env.0.insert(x.ident(), (re.clone(), ctxt.term_env.clone()));
+            let start_ctxt = ctxt.clone();
+            for (x, re) in bindings {
+                let ty_let = binding_type(state, re.as_ref(), &start_ctxt, false);
+
+                // We don't support recursive binding when checking for contract equality.
+                //
+                // This would quickly lead to cycles, which are hard to deal with without leaking
+                // memory. In order to deal with recursive bindings, the best way is probably to
+                // allocate all the term environments inside an arena, local to each statically typed
+                // block, and use bare references to represent cycles. Then everything would be cleaned
+                // at the end of the block.
+                ctxt.term_env
+                    .0
+                    .insert(x.ident(), (re.clone(), ctxt.term_env.clone()));
 
-            if attrs.rec {
                 ctxt.type_env.insert(x.ident(), ty_let.clone());
+                visitor.visit_ident(x, ty_let.clone());
             }
 
-            visitor.visit_ident(x, ty_let.clone());
-            walk(state, ctxt.clone(), visitor, re)?;
-
-            if !attrs.rec {
-                ctxt.type_env.insert(x.ident(), ty_let);
+            let re_ctxt = if attrs.rec { ctxt.clone() } else { start_ctxt.clone() };
+            for (_x, re) in bindings {
+                walk(state, re_ctxt.clone(), visitor, re)?;
             }
 
             walk(state, ctxt, visitor, rt)
         }
-        Term::LetPattern(pat, re, rt) => {
-            let ty_let = binding_type(state, re.as_ref(), &ctxt, false);
+        Term::LetPattern(bindings, rt, attrs) => {
+            // For a recursive let block, shadow all the names we're about to bind, so
+            // we aren't influenced by variables defined in an outer scope.
+            if attrs.rec {
+                for (pat, _re) in bindings {
+                    for (_path, id, _fld) in pat.bindings() {
+                        ctxt.type_env
+                            .insert(id.ident(), state.table.fresh_type_uvar(ctxt.var_level));
+                    }
+                }
+            }
 
-            walk(state, ctxt.clone(), visitor, re)?;
+            let start_ctxt = ctxt.clone();
 
-            // In the case of a let-binding, we want to guess a better type than `Dyn` when we can
-            // do so cheaply for the whole pattern.
-            if let Some(alias) = &pat.alias {
-                visitor.visit_ident(alias, ty_let.clone());
-                ctxt.type_env.insert(alias.ident(), ty_let);
-            }
+            for (pat, re) in bindings {
+                let ty_let = binding_type(state, re.as_ref(), &start_ctxt, false);
 
-            // [^separate-alias-treatment]: Note that we call `pattern_types` on the inner pattern
-            // data, which doesn't take into account the potential heading alias `x @ <pattern>`.
-            // This is on purpose, as the alias has been treated separately, so we don't want to
-            // shadow it with a less precise type.
-            let PatternTypeData {bindings: pat_bindings, ..} = pat.data.pattern_types(state, &ctxt, pattern::TypecheckMode::Walk)?;
+                // In the case of a let-binding, we want to guess a better type than `Dyn` when we can
+                // do so cheaply for the whole pattern.
+                if let Some(alias) = &pat.alias {
+                    visitor.visit_ident(alias, ty_let.clone());
+                    ctxt.type_env.insert(alias.ident(), ty_let);
+                }
 
-            for (id, typ) in pat_bindings {
-                visitor.visit_ident(&id, typ.clone());
-                ctxt.type_env.insert(id.ident(), typ);
+                // [^separate-alias-treatment]: Note that we call `pattern_types` on the inner pattern
+                // data, which doesn't take into account the potential heading alias `x @ <pattern>`.
+                // This is on purpose, as the alias has been treated separately, so we don't want to
+                // shadow it with a less precise type.
+                //
+                // The use of start_ctxt here looks like it might be wrong for let rec, but in fact
+                // it's unused in TypecheckMode::Walk anyway.
+                let PatternTypeData {bindings: pat_bindings, ..} = pat.data.pattern_types(state, &start_ctxt, pattern::TypecheckMode::Walk)?;
+
+                for (id, typ) in pat_bindings {
+                    visitor.visit_ident(&id, typ.clone());
+                    ctxt.type_env.insert(id.ident(), typ);
+                }
+            }
+
+            let re_ctxt = if attrs.rec { ctxt.clone() } else { start_ctxt.clone() };
+            for (_pat, re) in bindings {
+                walk(state, re_ctxt.clone(), visitor, re)?;
             }
 
             walk(state, ctxt, visitor, rt)
@@ -1951,53 +1982,90 @@ fn check<V: TypecheckVisitor>(
             ty.unify(mk_uniftype::dynamic(), state, &ctxt)
                 .map_err(|err| err.into_typecheck_err(state, rt.pos))
         }
-        Term::Let(x, re, rt, attrs) => {
-            let ty_let = binding_type(state, re.as_ref(), &ctxt, true);
+        Term::Let(bindings, rt, attrs) => {
+            // For a recursive let block, shadow all the names we're about to bind, so
+            // we aren't influenced by variables defined in an outer scope.
+            if attrs.rec {
+                for (x, _re) in bindings {
+                    ctxt.type_env
+                        .insert(x.ident(), state.table.fresh_type_uvar(ctxt.var_level));
+                }
+            }
 
-            // We don't support recursive binding when checking for contract equality. See the
-            // `Let` case in `walk`.
-            ctxt.term_env
-                .0
-                .insert(x.ident(), (re.clone(), ctxt.term_env.clone()));
+            let mut tys = Vec::new();
+            let start_ctxt = ctxt.clone();
+            for (x, re) in bindings {
+                let ty_let = binding_type(state, re.as_ref(), &start_ctxt, true);
+
+                // We don't support recursive binding when checking for contract equality. See the
+                // `Let` case in `walk`.
+                ctxt.term_env
+                    .0
+                    .insert(x.ident(), (re.clone(), ctxt.term_env.clone()));
 
-            if attrs.rec {
                 ctxt.type_env.insert(x.ident(), ty_let.clone());
+                visitor.visit_ident(x, ty_let.clone());
+                tys.push((re, ty_let));
             }
 
-            visitor.visit_ident(x, ty_let.clone());
-            check(state, ctxt.clone(), visitor, re, ty_let.clone())?;
-
-            if !attrs.rec {
-                ctxt.type_env.insert(x.ident(), ty_let);
+            let re_ctxt = if attrs.rec { &ctxt } else { &start_ctxt };
+            for (re, ty_let) in tys {
+                check(state, re_ctxt.clone(), visitor, re, ty_let)?;
             }
-            check(state, ctxt, visitor, rt, ty)
+
+            // FIXME: if we're recursive, do we need to do unify the fresh
+            // type variables with the (modified by the recursive check)
+            // binding_type? I feel like we should, but it doesn't seem to make
+            // a difference.
+
+            check(state, ctxt.clone(), visitor, rt, ty)
         }
-        Term::LetPattern(pat, re, rt) => {
-            // See [^separate-alias-treatment].
-            let pat_types = pat.pattern_types(state, &ctxt, pattern::TypecheckMode::Enforce)?;
+        Term::LetPattern(bindings, rt, attrs) => {
+            // For a recursive let block, shadow all the names we're about to bind, so
+            // we aren't influenced by variables defined in an outer scope.
+            if attrs.rec {
+                for (pat, _re) in bindings {
+                    for (_path, id, _fld) in pat.bindings() {
+                        ctxt.type_env
+                            .insert(id.ident(), state.table.fresh_type_uvar(ctxt.var_level));
+                    }
+                }
+            }
 
-            // In the destructuring case, there's no alternative pattern, and we must thus
-            // immediatly close all the row types.
-            pattern::close_all_enums(pat_types.enum_open_tails, state);
+            let mut tys = Vec::new();
+            let start_ctxt = ctxt.clone();
+            for (pat, re) in bindings {
+                // See [^separate-alias-treatment].
+                let pat_types =
+                    pat.pattern_types(state, &start_ctxt, pattern::TypecheckMode::Enforce)?;
+
+                // In the destructuring case, there's no alternative pattern, and we must thus
+                // immediatly close all the row types.
+                pattern::close_all_enums(pat_types.enum_open_tails, state);
 
-            // The inferred type of the expr being bound
-            let ty_let = binding_type(state, re.as_ref(), &ctxt, true);
+                // The inferred type of the expr being bound
+                let ty_let = binding_type(state, re.as_ref(), &start_ctxt, true);
 
-            pat_types
-                .typ
-                .unify(ty_let.clone(), state, &ctxt)
-                .map_err(|e| e.into_typecheck_err(state, re.pos))?;
+                pat_types
+                    .typ
+                    .unify(ty_let.clone(), state, &start_ctxt)
+                    .map_err(|e| e.into_typecheck_err(state, re.pos))?;
 
-            check(state, ctxt.clone(), visitor, re, ty_let.clone())?;
+                if let Some(alias) = &pat.alias {
+                    visitor.visit_ident(alias, ty_let.clone());
+                    ctxt.type_env.insert(alias.ident(), ty_let.clone());
+                }
 
-            if let Some(alias) = &pat.alias {
-                visitor.visit_ident(alias, ty_let.clone());
-                ctxt.type_env.insert(alias.ident(), ty_let);
+                for (id, typ) in pat_types.bindings {
+                    visitor.visit_ident(&id, typ.clone());
+                    ctxt.type_env.insert(id.ident(), typ);
+                }
+                tys.push((re, ty_let));
             }
 
-            for (id, typ) in pat_types.bindings {
-                visitor.visit_ident(&id, typ.clone());
-                ctxt.type_env.insert(id.ident(), typ);
+            let re_ctxt = if attrs.rec { &ctxt } else { &start_ctxt };
+            for (re, ty_let) in tys {
+                check(state, re_ctxt.clone(), visitor, re, ty_let)?;
             }
 
             check(state, ctxt, visitor, rt, ty)
@@ -2339,7 +2407,7 @@ fn check<V: TypecheckVisitor>(
             .unify(mk_uniftype::sym(), state, &ctxt)
             .map_err(|err| err.into_typecheck_err(state, rt.pos)),
         Term::Sealed(_, t, _) => check(state, ctxt, visitor, t, ty),
-        Term::Import(_) => ty
+        Term::Import { .. } => ty
             .unify(mk_uniftype::dynamic(), state, &ctxt)
             .map_err(|err| err.into_typecheck_err(state, rt.pos)),
         // We use the apparent type of the import for checking. This function doesn't recursively
@@ -2736,7 +2804,7 @@ impl From<ApparentType> for Type {
     }
 }
 
-/// Return the apparent type of a field, by first looking at the type annotation, if any, the at
+/// Return the apparent type of a field, by first looking at the type annotation, if any, then at
 /// the contracts annotation, and if there is none, fall back to the apparent type of the value. If
 /// there is no value, `Approximated(Dyn)` is returned.
 fn field_apparent_type(
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/syntax.md b/doc/manual/syntax.md
index 6c026673..d8d7d6d5 100644
--- a/doc/manual/syntax.md
+++ b/doc/manual/syntax.md
@@ -559,11 +559,11 @@ Here are some valid conditional expressions in Nickel:
 
 ### Let-In
 
-A `let` binds an expression to a variable. It is used like `let <rec?>
-<ident> = <expr> in <expr>`. The `rec` keyword makes the binding recursive,
-enabling the use of `<ident>` within the bound
-expression `<expr>`.
-Currently, only a single variable can be bound per let binding.
+A `let` binds expressions to variables. It is used like `let <rec?> <ident> =
+<expr> in <expr>`, or with multiple comma-separated bindings like
+`let <rec?> <ident> = <expr>, <ident> = <expr> in <expr>`. The `rec` keyword
+makes the binding recursive, enabling the use of `<ident>` within the bound
+expression(s) `<expr>`.
 
 Here are some examples of let bindings in Nickel:
 
@@ -574,7 +574,10 @@ Here are some examples of let bindings in Nickel:
 > let inner = { inside = true } in let outer = { outside = inner.inside } in outer.outside
 true
 
-> let a = 1 in let b = 2 in a + b
+> let rec inner = { inside = true }, outer = { outside = inner.inside } in outer.outside
+true
+
+> let a = 1, b = 2 in a + b
 3
 
 > let rec f = fun n => if n == 0 then n else n + f (n - 1) in f 10
@@ -934,7 +937,7 @@ Examples:
 [ 2, 3 ]
 
 > let 'Invalid x = {} in x
-error: unmatched pattern
+error: destructuring failed
 [...]
 ```
 
@@ -1113,7 +1116,7 @@ true
 
 > {foo = 1, bar = "string"} : {_ : Number}
 error: incompatible types
-  ┌─ <repl-input-96>:1:18
+  ┌─ <repl-input-97>:1:18
   │
 1 │  {foo = 1, bar = "string"} : {_ : Number}
   │                  ^^^^^^^^ this expression
@@ -1173,7 +1176,7 @@ annotation but no value are forbidden outside of types.
 ```nickel #repl
 > {foo = 1, bar = "foo" } : {foo : Number, bar : String | optional}
 error: statically typed field without a definition
-  ┌─ <repl-input-100>:1:29
+  ┌─ <repl-input-101>:1:29
   │
 1 │  {foo = 1, bar = "foo" } : {foo : Number, bar : String | optional}
   │                             ^^^   ------ but it has a type annotation
@@ -1302,4 +1305,26 @@ record is serialized. This includes the output of the `nickel export` command:
 "{\n  \"foo\": 1\n}"
 ```
 
+## Imports
+
+There is special keyword `import`, which can be followed by either a
+string literal or an enum tag and a string literal.
+
+This causes Nickel to read, evaluate and return the specified file.
+
+The file is searched in directories specified by `NICKEL_IMPORT_PATH`
+environment variable or similar command line option, with default being
+the current directory.
+
+One-argument import, like `import "myfile.ncl"`, uses filename extension
+to determine the file format. Nickel embeds a short list of known filename
+extensions: `ncl`, `json`, `yml`, `yaml`, `toml`, `txt` and
+`nix` with a fallback to a Nickel file if there is no extetnsion
+or the extension is unknown.
+
+Two-argument import, like `import 'Raw "test.html"` uses a special enum
+tag to determine the format. Currently the tags are `'Nickel`, `'Json`,
+`'Yaml`, `'Toml`, `'Raw` and `'Nix`. Some of the formats may be unavailable
+depending on compilation options of the Nickel interpreter.
+
 [nix-string-context]: https://shealevy.com/blog/2018/08/05/understanding-nixs-string-context/
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
diff --git a/lsp/nls/src/background.rs b/lsp/nls/src/background.rs
index 1a7b10a3..e58b6419 100644
--- a/lsp/nls/src/background.rs
+++ b/lsp/nls/src/background.rs
@@ -10,7 +10,7 @@ use crossbeam::channel::{bounded, Receiver, RecvTimeoutError, Sender};
 use log::warn;
 use lsp_types::Url;
 use nickel_lang_core::{
-    cache::SourcePath,
+    cache::{InputFormat, SourcePath},
     eval::{cache::CacheImpl, VirtualMachine},
 };
 use serde::{Deserialize, Serialize};
@@ -93,7 +93,10 @@ pub fn worker_main() -> anyhow::Result<()> {
         anyhow::bail!("skipping invalid uri {}", eval.eval);
     };
 
-    if let Some(file_id) = world.cache.id_of(&SourcePath::Path(path.clone())) {
+    if let Some(file_id) = world
+        .cache
+        .id_of(&SourcePath::Path(path.clone(), InputFormat::Nickel))
+    {
         let mut diagnostics = world.parse_and_typecheck(file_id);
 
         // Evaluation diagnostics (but only if there were no parse/type errors).
diff --git a/lsp/nls/src/cache.rs b/lsp/nls/src/cache.rs
index 1f7a1d71..5b3c788f 100644
--- a/lsp/nls/src/cache.rs
+++ b/lsp/nls/src/cache.rs
@@ -1,5 +1,6 @@
 use codespan::{ByteIndex, FileId};
 use lsp_types::{TextDocumentPositionParams, Url};
+use nickel_lang_core::cache::InputFormat;
 use nickel_lang_core::term::{RichTerm, Term, Traverse};
 use nickel_lang_core::{
     cache::{Cache, CacheError, CacheOp, EntryState, SourcePath, TermEntry},
@@ -112,7 +113,7 @@ impl CacheExt for Cache {
         let path = uri
             .to_file_path()
             .map_err(|_| crate::error::Error::FileNotFound(uri.clone()))?;
-        Ok(self.id_of(&SourcePath::Path(path)))
+        Ok(self.id_of(&SourcePath::Path(path, InputFormat::Nickel)))
     }
 
     fn position(
diff --git a/lsp/nls/src/field_walker.rs b/lsp/nls/src/field_walker.rs
index bf5674b8..a8359ad9 100644
--- a/lsp/nls/src/field_walker.rs
+++ b/lsp/nls/src/field_walker.rs
@@ -432,7 +432,7 @@ impl<'a> FieldResolver<'a> {
             Term::Op2(BinaryOp::Merge(_), t1, t2) => {
                 combine(self.resolve_container(t1), self.resolve_container(t2))
             }
-            Term::Let(_, _, body, _) | Term::LetPattern(_, _, body) => self.resolve_container(body),
+            Term::Let(_, body, _) | Term::LetPattern(_, body, _) => self.resolve_container(body),
             Term::Op1(UnaryOp::RecordAccess(id), term) => {
                 self.containers_at_path(term, std::iter::once(id.ident()))
             }
diff --git a/lsp/nls/src/main.rs b/lsp/nls/src/main.rs
index 39380d03..57f162da 100644
--- a/lsp/nls/src/main.rs
+++ b/lsp/nls/src/main.rs
@@ -23,7 +23,6 @@ mod position;
 mod requests;
 mod server;
 use server::Server;
-mod pattern;
 mod term;
 mod trace;
 mod usage;
diff --git a/lsp/nls/src/position.rs b/lsp/nls/src/position.rs
index 290b868e..332c91b4 100644
--- a/lsp/nls/src/position.rs
+++ b/lsp/nls/src/position.rs
@@ -3,10 +3,10 @@ use std::ops::Range;
 use codespan::ByteIndex;
 use nickel_lang_core::{
     position::TermPos,
-    term::{RichTerm, Term, Traverse, TraverseControl},
+    term::{pattern::bindings::Bindings, RichTerm, Term, Traverse, TraverseControl},
 };
 
-use crate::{identifier::LocIdent, pattern::Bindings, term::RichTermPtr};
+use crate::{identifier::LocIdent, term::RichTermPtr};
 
 /// Turn a collection of "nested" ranges into a collection of disjoint ranges.
 ///
@@ -122,8 +122,17 @@ impl PositionLookup {
             }
 
             match term.as_ref() {
-                Term::Fun(id, _) | Term::Let(id, _, _, _) => idents.push(*id),
-                Term::FunPattern(pat, _) | Term::LetPattern(pat, _, _) => {
+                Term::Fun(id, _) => idents.push(*id),
+                Term::Let(bindings, _, _) => {
+                    idents.extend(bindings.iter().map(|(id, _)| *id));
+                }
+                Term::LetPattern(bindings, _, _) => {
+                    for (pat, _) in bindings {
+                        let ids = pat.bindings().into_iter().map(|(_path, id, _)| id);
+                        idents.extend(ids);
+                    }
+                }
+                Term::FunPattern(pat, _) => {
                     let ids = pat.bindings().into_iter().map(|(_path, id, _)| id);
                     idents.extend(ids);
                 }
diff --git a/lsp/nls/src/requests/completion.rs b/lsp/nls/src/requests/completion.rs
index 4c26ed57..e1a748a5 100644
--- a/lsp/nls/src/requests/completion.rs
+++ b/lsp/nls/src/requests/completion.rs
@@ -229,7 +229,7 @@ pub fn handle_completion(
     let term = server.world.lookup_term_by_position(pos)?.cloned();
     let ident = server.world.lookup_ident_by_position(pos)?;
 
-    if let Some(Term::Import(import)) = term.as_ref().map(|t| t.term.as_ref()) {
+    if let Some(Term::Import { path: import, .. }) = term.as_ref().map(|t| t.term.as_ref()) {
         // Don't respond with anything if trigger is a `.`, as that may be the
         // start of a relative file path `./`, or the start of a file extension
         if !matches!(trigger, Some(".")) {
diff --git a/lsp/nls/src/requests/formatting.rs b/lsp/nls/src/requests/formatting.rs
index b5f0058f..4c8d1726 100644
--- a/lsp/nls/src/requests/formatting.rs
+++ b/lsp/nls/src/requests/formatting.rs
@@ -1,6 +1,6 @@
 use lsp_server::{RequestId, Response, ResponseError};
 use lsp_types::{DocumentFormattingParams, Position, Range, TextEdit};
-use nickel_lang_core::cache::SourcePath;
+use nickel_lang_core::cache::{InputFormat, SourcePath};
 
 use crate::{error::Error, files::uri_to_path, server::Server};
 
@@ -13,7 +13,11 @@ pub fn handle_format_document(
     server: &mut Server,
 ) -> Result<(), ResponseError> {
     let path = uri_to_path(&params.text_document.uri)?;
-    let file_id = server.world.cache.id_of(&SourcePath::Path(path)).unwrap();
+    let file_id = server
+        .world
+        .cache
+        .id_of(&SourcePath::Path(path, InputFormat::Nickel))
+        .unwrap();
     let text = server.world.cache.files().source(file_id).clone();
     let document_length = text.lines().count() as u32;
 
diff --git a/lsp/nls/src/usage.rs b/lsp/nls/src/usage.rs
index 4e455abf..69463c63 100644
--- a/lsp/nls/src/usage.rs
+++ b/lsp/nls/src/usage.rs
@@ -4,10 +4,10 @@ use nickel_lang_core::{
     environment::Environment as GenericEnvironment,
     identifier::Ident,
     position::RawSpan,
-    term::{MatchData, RichTerm, Term, Traverse, TraverseControl},
+    term::{pattern::bindings::Bindings, MatchData, RichTerm, Term, Traverse, TraverseControl},
 };
 
-use crate::{field_walker::Def, identifier::LocIdent, pattern::Bindings};
+use crate::{field_walker::Def, identifier::LocIdent};
 
 pub type Environment = GenericEnvironment<Ident, Def>;
 
@@ -153,36 +153,47 @@ impl UsageLookup {
 
                         TraverseControl::ContinueWithScope(new_env)
                     }
-                    Term::Let(id, val, body, attrs) => {
+                    Term::Let(bindings, body, attrs) => {
                         let mut new_env = env.clone();
-                        let def = Def::Let {
-                            ident: LocIdent::from(*id),
-                            value: val.clone(),
-                            path: Vec::new(),
-                        };
-                        new_env.insert_def(def.clone());
-                        self.add_sym(def);
-
-                        self.fill(val, if attrs.rec { &new_env } else { env });
+                        for (id, val) in bindings {
+                            let def = Def::Let {
+                                ident: LocIdent::from(*id),
+                                value: val.clone(),
+                                path: Vec::new(),
+                            };
+                            new_env.insert_def(def.clone());
+                            self.add_sym(def);
+                        }
+
+                        for (_, val) in bindings {
+                            self.fill(val, if attrs.rec { &new_env } else { env });
+                        }
                         self.fill(body, &new_env);
 
                         TraverseControl::SkipBranch
                     }
-                    Term::LetPattern(pat, val, _body) => {
+                    Term::LetPattern(bindings, body, attrs) => {
                         let mut new_env = env.clone();
 
-                        for (path, id, _field) in pat.bindings() {
-                            let path = path.iter().map(|i| i.ident()).collect();
-                            let def = Def::Let {
-                                ident: LocIdent::from(id),
-                                value: val.clone(),
-                                path,
-                            };
-                            new_env.insert_def(def.clone());
-                            self.add_sym(def);
+                        for (pat, val) in bindings {
+                            for (path, id, _field) in pat.bindings() {
+                                let path = path.iter().map(|i| i.ident()).collect();
+                                let def = Def::Let {
+                                    ident: LocIdent::from(id),
+                                    value: val.clone(),
+                                    path,
+                                };
+                                new_env.insert_def(def.clone());
+                                self.add_sym(def);
+                            }
                         }
 
-                        TraverseControl::ContinueWithScope(new_env)
+                        for (_, val) in bindings {
+                            self.fill(val, if attrs.rec { &new_env } else { env });
+                        }
+                        self.fill(body, &new_env);
+
+                        TraverseControl::SkipBranch
                     }
                     Term::RecRecord(data, ..) | Term::Record(data) => {
                         let mut new_env = env.clone();
diff --git a/lsp/nls/src/world.rs b/lsp/nls/src/world.rs
index ec50ec00..5847e522 100644
--- a/lsp/nls/src/world.rs
+++ b/lsp/nls/src/world.rs
@@ -12,7 +12,7 @@ use nickel_lang_core::{
     cache::{Cache, CacheError, ErrorTolerance, InputFormat, SourcePath},
     error::{ImportError, IntoDiagnostics},
     position::{RawPos, RawSpan},
-    term::{record::FieldMetadata, RichTerm, Term, UnaryOp},
+    term::{pattern::bindings::Bindings, record::FieldMetadata, RichTerm, Term, UnaryOp},
     typecheck::Context,
 };
 
@@ -23,7 +23,6 @@ use crate::{
     field_walker::{Def, FieldResolver},
     files::uri_to_path,
     identifier::LocIdent,
-    pattern::Bindings as _,
 };
 
 /// All the state associated with the files we know about.
@@ -92,7 +91,9 @@ impl World {
 
         // Replace the path (as opposed to adding it): we may already have this file in the
         // cache if it was imported by an already-open file.
-        let file_id = self.cache.replace_string(SourcePath::Path(path), contents);
+        let file_id = self
+            .cache
+            .replace_string(SourcePath::Path(path, InputFormat::Nickel), contents);
 
         // The cache automatically invalidates reverse-dependencies; we also need
         // to track them, so that we can clear our own analysis.
@@ -121,7 +122,9 @@ impl World {
         contents: String,
     ) -> anyhow::Result<(FileId, Vec<FileId>)> {
         let path = uri_to_path(&uri)?;
-        let file_id = self.cache.replace_string(SourcePath::Path(path), contents);
+        let file_id = self
+            .cache
+            .replace_string(SourcePath::Path(path, InputFormat::Nickel), contents);
 
         let invalid = self.cache.invalidate_cache(file_id);
         for f in &invalid {
@@ -270,23 +273,29 @@ impl World {
                         })
                         .collect()
                 }
-                (Term::LetPattern(pat, value, _), Some(hovered_id)) => {
-                    let (path, _, _) = pat
-                        .bindings()
-                        .into_iter()
-                        .find(|(_path, bound_id, _)| bound_id.ident() == hovered_id.ident)?;
-
-                    let (last, path) = path.split_last()?;
-                    let path: Vec<_> = path.iter().map(|id| id.ident()).collect();
-                    let parents = resolver.resolve_path(value, path.iter().copied());
-                    parents
-                        .iter()
-                        .filter_map(|parent| {
-                            parent
-                                .field_loc(last.ident())
-                                .and_then(|def| def.pos.into_opt())
-                        })
-                        .collect()
+                (Term::LetPattern(bindings, _, _), Some(hovered_id)) => {
+                    let mut spans = Vec::new();
+                    for (pat, value) in bindings {
+                        if let Some((path, _, _)) = pat
+                            .bindings()
+                            .into_iter()
+                            .find(|(_path, bound_id, _)| bound_id.ident() == hovered_id.ident)
+                        {
+                            let (last, path) = path.split_last()?;
+                            let path: Vec<_> = path.iter().map(|id| id.ident()).collect();
+                            let parents = resolver.resolve_path(value, path.iter().copied());
+                            spans = parents
+                                .iter()
+                                .filter_map(|parent| {
+                                    parent
+                                        .field_loc(last.ident())
+                                        .and_then(|def| def.pos.into_opt())
+                                })
+                                .collect();
+                            break;
+                        }
+                    }
+                    spans
                 }
                 (Term::ResolvedImport(file), _) => {
                     let pos = world.cache.terms().get(file)?.term.pos;
diff --git a/scripts/release.sh b/scripts/release.sh
index f061335f..1945fac6 100755
--- a/scripts/release.sh
+++ b/scripts/release.sh
@@ -388,36 +388,35 @@ cat <<EOF
 EOF
 
 crates_to_publish=(core cli lsp/nls)
+# The crates below aren't published on crates.io because they are only
+# dev-dependencies of the crates to publish (used for tests and benchmarks).
+#
+# What's more, there are circular dependencies between them and the crates to
+# publish above, which would make it difficult to publish them anyhow.
+#
+# Because crates.io require that all dependencies are published, we need to
+# clean them from the various `Cargo.toml` files before publishing.
+crates_to_remove=(nickel-lang-utils lsp-harness)
 
-report_progress "Removing 'nickel-lang-utils' and 'lsp-test-harness' from dev-dependencies..."
-
-for crate in "${crates_to_publish[@]}"; do
-    # Remove `nickel-lang-utils` from `dev-dependencies` of released crates.
-    # Indeed, `nickel-lang-utils` is only used for testing or benchmarking and
-    # it creates a circular dependency. We just don't publish it and cut it off
-    # from dev-dependencies (which aren't required for proper publication on
-    # crates.io)
-    #
-    # see [^tomlq-sed]
-    sed -i '/^nickel-lang-utils\.workspace\s*=\s*true\s*$/d' "$crate/Cargo.toml"
-    sed -i '/^lsp-harness\.workspace\s*=\s*true\s*$/d' "$crate/Cargo.toml"
-    cleanup_actions+=('git restore '"$crate/Cargo.toml")
-done
-
-report_progress "Removing 'lsp-harness' from dev-dependencies..."
+report_progress "Removing " "${crates_to_remove[@]}" "from dev-dependencies..."
 
 for crate in "${crates_to_publish[@]}"; do
-    # Remove `lsp-harness` from `dev-dependencies` of released crates.
-    # `lsp-harness` is only used for testing the LSP and isn't published on
-    # crates.io, so we cut it off as well
-    #
-    # see [^tomlq-sed]
-    sed -i '/^lsp-harness\.workspace\s*=\s*true$/d' "$crate/Cargo.toml"
-    cleanup_actions+=('git restore '"$crate/Cargo.toml")
-done
+    for to_remove in "${crates_to_remove[@]}"; do
+        # see [^tomlq-sed]
+        sed -i '/^\s*'"$to_remove"'\.workspace\s*=\s*true\s*$/d' "$crate/Cargo.toml"
+        # Some of  the dependencies have the form
+        #
+        # ```
+        # deps = { workspace = true, ..etc }
+        # ```
+        #
+        # This case is handled by the following sed command. The `workspace =
+        # true` is most of the time the first attribute of the object, but we
+        # play safe here and allow arbitrary content before it.
+        sed -i '/^\s*'"$to_remove"'\s*=\s*{.*workspace\s*=\s*true/d' "$crate/Cargo.toml"
+    done
 
-for crate in "${crates_to_publish[@]}"; do
-    # Stage the modifications of the previous processing steps
+    # Stage the modifications
     git add "$crate/Cargo.toml"
     cleanup_actions+=('git reset -- '"$crate/Cargo.toml")
 done
diff --git a/utils/src/bench.rs b/utils/src/bench.rs
index 6179e072..da40fd9a 100644
--- a/utils/src/bench.rs
+++ b/utils/src/bench.rs
@@ -1,6 +1,6 @@
 use criterion::Criterion;
 use nickel_lang_core::{
-    cache::{Cache, Envs, ErrorTolerance},
+    cache::{Cache, Envs, ErrorTolerance, InputFormat},
     eval::{
         cache::{Cache as EvalCache, CacheImpl},
         VirtualMachine,
@@ -112,7 +112,7 @@ pub fn bench_terms<'r>(rts: Vec<Bench<'r>>) -> Box<dyn Fn(&mut Criterion) + 'r>
                 b.iter_batched(
                     || {
                         let mut cache = cache.clone();
-                        let id = cache.add_file(bench.path()).unwrap();
+                        let id = cache.add_file(bench.path(), InputFormat::Nickel).unwrap();
                         let t = import_resolution::strict::resolve_imports(t.clone(), &mut cache)
                             .unwrap()
                             .transformed_term;
@@ -202,7 +202,7 @@ macro_rules! ncl_bench_group {
                     b.iter_batched(
                         || {
                             let mut cache = cache.clone();
-                            let id = cache.add_file(bench.path()).unwrap();
+                            let id = cache.add_file(bench.path(), InputFormat::Nickel).unwrap();
                             let t = resolve_imports(t.clone(), &mut cache)
                                 .unwrap()
                                 .transformed_term;


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/inputs/errors/let_block_duplicate_identifier.ncl b/cli/tests/snapshot/inputs/errors/let_block_duplicate_identifier.ncl
new file mode 100644
index 00000000..0f6d0080
--- /dev/null
+++ b/cli/tests/snapshot/inputs/errors/let_block_duplicate_identifier.ncl
@@ -0,0 +1,7 @@
+# capture = 'stderr'
+# command = ['eval']
+let
+  { x, y } = { x = 1, y = 2 },
+  x = 3
+in
+  x
diff --git a/cli/tests/snapshot/inputs/errors/let_block_not_rec.ncl b/cli/tests/snapshot/inputs/errors/let_block_not_rec.ncl
new file mode 100644
index 00000000..6725d816
--- /dev/null
+++ b/cli/tests/snapshot/inputs/errors/let_block_not_rec.ncl
@@ -0,0 +1,7 @@
+# capture = 'stderr'
+# command = ['eval']
+let
+  a = 2,
+  b = a
+in
+b
diff --git a/cli/tests/snapshot/inputs/pretty/let_block.ncl b/cli/tests/snapshot/inputs/pretty/let_block.ncl
new file mode 100644
index 00000000..cec4111a
--- /dev/null
+++ b/cli/tests/snapshot/inputs/pretty/let_block.ncl
@@ -0,0 +1,10 @@
+# capture = 'stdout'
+# command = ['pprint-ast']
+let rec
+  fib = fun n =>
+    if n == 0 || n == 1 then
+      1
+    else
+      fib (n - 1) + fib (n - 2),
+  fib2 = fun n => fib (fib n)
+in fib2 3
diff --git a/cli/tests/snapshot/inputs/pretty/let_block_with_pat.ncl b/cli/tests/snapshot/inputs/pretty/let_block_with_pat.ncl
new file mode 100644
index 00000000..0ff43e4a
--- /dev/null
+++ b/cli/tests/snapshot/inputs/pretty/let_block_with_pat.ncl
@@ -0,0 +1,6 @@
+# capture = 'stdout'
+# command = ['pprint-ast']
+let
+  a = 1,
+  d@{ b, c } = { b = "b", c = 2 },
+in a + d.c
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__eval_stderr_destructuring_closed_fail.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_destructuring_closed_fail.ncl.snap
index eb3c6adb..0fd4c245 100644
--- a/cli/tests/snapshot/snapshots/snapshot__eval_stderr_destructuring_closed_fail.ncl.snap
+++ b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_destructuring_closed_fail.ncl.snap
@@ -2,11 +2,10 @@
 source: cli/tests/snapshot/main.rs
 expression: err
 ---
-error: unmatched pattern
+error: destructuring failed
   ┌─ [INPUTS_PATH]/errors/destructuring_closed_fail.ncl:3:5
   │
 3 │ let {a} = {a=1, b=2}
-  │     ^^^^^^^^^^^^^^^^
-  │     │     │
-  │     │     this value doesn't match any branch
-  │     in this match expression
+  │     ^^^   ---------- this value failed to match
+  │     │      
+  │     this pattern
diff --git a/cli/tests/snapshot/snapshots/snapshot__eval_stderr_let_block_duplicate_identifier.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_let_block_duplicate_identifier.ncl.snap
new file mode 100644
index 00000000..bbb0b337
--- /dev/null
+++ b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_let_block_duplicate_identifier.ncl.snap
@@ -0,0 +1,11 @@
+---
+source: cli/tests/snapshot/main.rs
+expression: err
+---
+error: duplicated binding `x` in let block
+  ┌─ [INPUTS_PATH]/errors/let_block_duplicate_identifier.ncl:5:3
+  │
+4 │   { x, y } = { x = 1, y = 2 },
+  │     - previous binding here
+5 │   x = 3
+  │   ^ duplicated binding here
diff --git a/cli/tests/snapshot/snapshots/snapshot__eval_stderr_let_block_not_rec.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_let_block_not_rec.ncl.snap
new file mode 100644
index 00000000..41909e02
--- /dev/null
+++ b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_let_block_not_rec.ncl.snap
@@ -0,0 +1,9 @@
+---
+source: cli/tests/snapshot/main.rs
+expression: err
+---
+error: unbound identifier `a`
+  ┌─ [INPUTS_PATH]/errors/let_block_not_rec.ncl:5:7
+  │
+5 │   b = a
+  │       ^ this identifier is unbound
diff --git a/cli/tests/snapshot/snapshots/snapshot__eval_stdout_let_block_rec.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stdout_let_block_rec.ncl.snap
new file mode 100644
index 00000000..6287ff81
--- /dev/null
+++ b/cli/tests/snapshot/snapshots/snapshot__eval_stdout_let_block_rec.ncl.snap
@@ -0,0 +1,5 @@
+---
+source: cli/tests/snapshot/main.rs
+expression: out
+---
+4
diff --git a/cli/tests/snapshot/snapshots/snapshot__pprint-ast_stdout_let_block.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__pprint-ast_stdout_let_block.ncl.snap
new file mode 100644
index 00000000..ca1f78c2
--- /dev/null
+++ b/cli/tests/snapshot/snapshots/snapshot__pprint-ast_stdout_let_block.ncl.snap
@@ -0,0 +1,10 @@
+---
+source: cli/tests/snapshot/main.rs
+expression: out
+---
+let rec fib
+  = fun n => if (n == 0) || (n == 1) then 1 else (fib (n - 1)) + (fib (n - 2)),
+  fib2
+  = fun n => fib (fib n)
+  in
+fib2 3
diff --git a/cli/tests/snapshot/snapshots/snapshot__pprint-ast_stdout_let_block_with_pat.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__pprint-ast_stdout_let_block_with_pat.ncl.snap
new file mode 100644
index 00000000..6369cc6b
--- /dev/null
+++ b/cli/tests/snapshot/snapshots/snapshot__pprint-ast_stdout_let_block_with_pat.ncl.snap
@@ -0,0 +1,5 @@
+---
+source: cli/tests/snapshot/main.rs
+expression: out
+---
+let a = 1, d @ { b, c, } = { b = "b", c = 2, } in a + d.c
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/src/eval/tests.rs b/core/src/eval/tests.rs
index 5ebfd80d..ea3c9f0d 100644
--- a/core/src/eval/tests.rs
+++ b/core/src/eval/tests.rs
@@ -82,7 +82,7 @@ fn simple_app() {
 
 #[test]
 fn simple_let() {
-    let t = mk_term::let_in("x", mk_term::integer(5), mk_term::var("x"));
+    let t = mk_term::let_one_in("x", mk_term::integer(5), mk_term::var("x"));
     assert_eq!(Ok(Term::Num(Number::from(5))), eval_no_import(t));
 }
 
@@ -131,15 +131,15 @@ fn imports() {
         .add_source(String::from("bad"), String::from("^$*/.23ab 0°@"));
     vm.import_resolver_mut().add_source(
         String::from("nested"),
-        String::from("let x = import \"two\" in x + 1"),
+        String::from("let x = import 'Nickel \"two\" in x + 1"),
     );
     vm.import_resolver_mut().add_source(
         String::from("cycle"),
-        String::from("let x = import \"cycle_b\" in {a = 1, b = x.a}"),
+        String::from("let x = import 'Nickel \"cycle_b\" in {a = 1, b = x.a}"),
     );
     vm.import_resolver_mut().add_source(
         String::from("cycle_b"),
-        String::from("let x = import \"cycle\" in {a = x.a}"),
+        String::from("let x = import 'Nickel \"cycle\" in {a = x.a}"),
     );
 
     fn mk_import<R>(
@@ -152,7 +152,11 @@ fn imports() {
         R: ImportResolver,
     {
         resolve_imports(
-            mk_term::let_in(var, mk_term::import(import), body),
+            mk_term::let_one_in(
+                var,
+                mk_term::import(import, crate::cache::InputFormat::Nickel),
+                body,
+            ),
             vm.import_resolver_mut(),
         )
         .map(|resolve_result| resolve_result.transformed_term)
@@ -267,7 +271,7 @@ fn initial_env() {
         ),
     );
 
-    let t = mk_term::let_in("x", mk_term::integer(2), mk_term::var("x"));
+    let t = mk_term::let_one_in("x", mk_term::integer(2), mk_term::var("x"));
     assert_eq!(
         VirtualMachine::new_with_cache(DummyResolver {}, eval_cache.clone(), std::io::sink())
             .with_initial_env(initial_env.clone())
@@ -276,7 +280,7 @@ fn initial_env() {
         Ok(mk_term::integer(2))
     );
 
-    let t = mk_term::let_in("x", mk_term::integer(2), mk_term::var("g"));
+    let t = mk_term::let_one_in("x", mk_term::integer(2), mk_term::var("g"));
     assert_eq!(
         VirtualMachine::new_with_cache(DummyResolver {}, eval_cache.clone(), std::io::sink())
             .with_initial_env(initial_env.clone())
@@ -286,7 +290,7 @@ fn initial_env() {
     );
 
     // Shadowing of the initial environment
-    let t = mk_term::let_in("g", mk_term::integer(2), mk_term::var("g"));
+    let t = mk_term::let_one_in("g", mk_term::integer(2), mk_term::var("g"));
     assert_eq!(
         VirtualMachine::new_with_cache(DummyResolver {}, eval_cache.clone(), std::io::sink())
             .with_initial_env(initial_env.clone())
diff --git a/core/src/parser/tests.rs b/core/src/parser/tests.rs
index 5d0783ae..ef1e8270 100644
--- a/core/src/parser/tests.rs
+++ b/core/src/parser/tests.rs
@@ -555,7 +555,7 @@ fn ty_var_kind_mismatch() {
 fn import() {
     assert_eq!(
         parse_without_pos("import \"file.ncl\""),
-        mk_term::import("file.ncl")
+        mk_term::import("file.ncl", crate::cache::InputFormat::Nickel)
     );
     assert_matches!(
         parse("import \"file.ncl\" some args"),
@@ -564,7 +564,7 @@ fn import() {
     assert_eq!(
         parse_without_pos("(import \"file.ncl\") some args"),
         mk_app!(
-            mk_term::import("file.ncl"),
+            mk_term::import("file.ncl", crate::cache::InputFormat::Nickel),
             mk_term::var("some"),
             mk_term::var("args")
         )
diff --git a/core/tests/integration/inputs/contracts/unsound_dedup_record_contract.ncl b/core/tests/integration/inputs/contracts/unsound_dedup_record_contract.ncl
new file mode 100644
index 00000000..beb669e0
--- /dev/null
+++ b/core/tests/integration/inputs/contracts/unsound_dedup_record_contract.ncl
@@ -0,0 +1,49 @@
+# test.type = 'error'
+# eval = 'full'
+#
+# [test.metadata]
+# error = 'EvalError::BlameError'
+
+# Regression test for https://github.com/tweag/nickel/issues/1700
+let outer = {
+  spec
+    | {
+      hostAliases | Number,
+      containers | Number,
+    },
+}
+in
+
+let inner = {
+  spec
+    | {
+      hostAliases,
+      containers
+        | Array {
+          ..
+        },
+    },
+}
+in
+
+std.deep_seq
+  (
+    {
+      spec = {
+        hostAliases = "this should fail",
+        containers = [
+          {
+            name = "test",
+            image = "nginx",
+            ports = [
+              {
+                containerPort = 80,
+                name = "http"
+              }
+            ],
+          }
+        ]
+      }
+    } | inner | outer
+  )
+  true
diff --git a/core/tests/integration/inputs/core/let_block_rec.ncl b/core/tests/integration/inputs/core/let_block_rec.ncl
new file mode 100644
index 00000000..127b3518
--- /dev/null
+++ b/core/tests/integration/inputs/core/let_block_rec.ncl
@@ -0,0 +1,12 @@
+# test.type = 'pass'
+let rec
+  b = a,
+  a = 2,
+  c = b
+in
+[
+  a == 2,
+  b == 2,
+  c == 2,
+]
+|> std.test.assert_all
diff --git a/core/tests/integration/inputs/core/let_rec_block_with_pat.ncl b/core/tests/integration/inputs/core/let_rec_block_with_pat.ncl
new file mode 100644
index 00000000..6ea18913
--- /dev/null
+++ b/core/tests/integration/inputs/core/let_rec_block_with_pat.ncl
@@ -0,0 +1,6 @@
+# test.type = 'pass'
+let rec
+  a = 2,
+  { b } = { b = a }
+in
+b == 2
diff --git a/core/tests/integration/inputs/destructuring/adt_enum_tag_tag_mismatch.ncl b/core/tests/integration/inputs/destructuring/adt_enum_tag_tag_mismatch.ncl
index 09ab2159..bd909315 100644
--- a/core/tests/integration/inputs/destructuring/adt_enum_tag_tag_mismatch.ncl
+++ b/core/tests/integration/inputs/destructuring/adt_enum_tag_tag_mismatch.ncl
@@ -1,6 +1,6 @@
 # test.type = 'error'
 #
 # [test.metadata]
-# error = 'EvalError::NonExhaustiveEnumMatch'
+# error = 'EvalError::FailedDestructuring'
 let 'Foo = 'Bar in
 true
diff --git a/core/tests/integration/inputs/destructuring/adt_extra_arg.ncl b/core/tests/integration/inputs/destructuring/adt_extra_arg.ncl
index facfe98c..657a8c94 100644
--- a/core/tests/integration/inputs/destructuring/adt_extra_arg.ncl
+++ b/core/tests/integration/inputs/destructuring/adt_extra_arg.ncl
@@ -1,6 +1,6 @@
 # test.type = 'error'
 #
 # [test.metadata]
-# error = 'EvalError::NAryPrimopTypeError'
+# error = 'EvalError::FailedDestructuring'
 let 'Foo = 'Foo 5 in
 true
diff --git a/core/tests/integration/inputs/destructuring/adt_missing_arg.ncl b/core/tests/integration/inputs/destructuring/adt_missing_arg.ncl
index 8e844917..6c77f8d8 100644
--- a/core/tests/integration/inputs/destructuring/adt_missing_arg.ncl
+++ b/core/tests/integration/inputs/destructuring/adt_missing_arg.ncl
@@ -1,6 +1,6 @@
 # test.type = 'error'
 #
 # [test.metadata]
-# error = 'EvalError::NonExhaustiveMatch'
+# error = 'EvalError::FailedDestructuring'
 let 'Foo x = 'Foo in
 x
diff --git a/core/tests/integration/inputs/destructuring/adt_wrong_tag.ncl b/core/tests/integration/inputs/destructuring/adt_wrong_tag.ncl
index b7e49ae7..5c6cd7c7 100644
--- a/core/tests/integration/inputs/destructuring/adt_wrong_tag.ncl
+++ b/core/tests/integration/inputs/destructuring/adt_wrong_tag.ncl
@@ -1,6 +1,6 @@
 # test.type = 'error'
 #
 # [test.metadata]
-# error = 'EvalError::NonExhaustiveMatch'
+# error = 'EvalError::FailedDestructuring'
 let 'Foo x = 'Bar 5  in
 x
diff --git a/core/tests/integration/inputs/destructuring/adt_wrong_type.ncl b/core/tests/integration/inputs/destructuring/adt_wrong_type.ncl
index 85188777..119b2e86 100644
--- a/core/tests/integration/inputs/destructuring/adt_wrong_type.ncl
+++ b/core/tests/integration/inputs/destructuring/adt_wrong_type.ncl
@@ -1,6 +1,6 @@
 # test.type = 'error'
 #
 # [test.metadata]
-# error = 'EvalError::NonExhaustiveMatch'
+# error = 'EvalError::FailedDestructuring'
 let 'Foo x = {bar = 1}  in
 x
diff --git a/core/tests/integration/inputs/destructuring/closed_fail.ncl b/core/tests/integration/inputs/destructuring/closed_fail.ncl
index b04e2a38..83475c8e 100644
--- a/core/tests/integration/inputs/destructuring/closed_fail.ncl
+++ b/core/tests/integration/inputs/destructuring/closed_fail.ncl
@@ -1,6 +1,6 @@
 # test.type = 'error'
 #
 # [test.metadata]
-# error = 'EvalError::NonExhaustiveMatch'
+# error = 'EvalError::FailedDestructuring'
 let {a} = {a=1, b=2}
 in a == 1
diff --git a/core/tests/integration/inputs/destructuring/constant_bool_fail.ncl b/core/tests/integration/inputs/destructuring/constant_bool_fail.ncl
index 78d64cd2..6e6966ec 100644
--- a/core/tests/integration/inputs/destructuring/constant_bool_fail.ncl
+++ b/core/tests/integration/inputs/destructuring/constant_bool_fail.ncl
@@ -1,5 +1,5 @@
 # test.type = 'error'
 #
 # [test.metadata]
-# error = 'EvalError::NonExhaustiveMatch'
+# error = 'EvalError::FailedDestructuring'
 let true = false in true
diff --git a/core/tests/integration/inputs/destructuring/constant_null_fail.ncl b/core/tests/integration/inputs/destructuring/constant_null_fail.ncl
index 1cb75351..202b9a20 100644
--- a/core/tests/integration/inputs/destructuring/constant_null_fail.ncl
+++ b/core/tests/integration/inputs/destructuring/constant_null_fail.ncl
@@ -1,5 +1,5 @@
 # test.type = 'error'
 #
 # [test.metadata]
-# error = 'EvalError::NonExhaustiveMatch'
+# error = 'EvalError::FailedDestructuring'
 let null = 0 in true
diff --git a/core/tests/integration/inputs/destructuring/constant_number_fail.ncl b/core/tests/integration/inputs/destructuring/constant_number_fail.ncl
index bdf99a42..baaba35f 100644
--- a/core/tests/integration/inputs/destructuring/constant_number_fail.ncl
+++ b/core/tests/integration/inputs/destructuring/constant_number_fail.ncl
@@ -1,5 +1,5 @@
 # test.type = 'error'
 #
 # [test.metadata]
-# error = 'EvalError::NonExhaustiveMatch'
+# error = 'EvalError::FailedDestructuring'
 let 1 = 1 + 1 in true
diff --git a/core/tests/integration/inputs/destructuring/constant_string_fail.ncl b/core/tests/integration/inputs/destructuring/constant_string_fail.ncl
index 7a6760ea..f4164b2d 100644
--- a/core/tests/integration/inputs/destructuring/constant_string_fail.ncl
+++ b/core/tests/integration/inputs/destructuring/constant_string_fail.ncl
@@ -1,5 +1,5 @@
 # test.type = 'error'
 #
 # [test.metadata]
-# error = 'EvalError::NonExhaustiveMatch'
+# error = 'EvalError::FailedDestructuring'
 let "ab" = "a" ++ "b" ++ "c" in true
diff --git a/core/tests/integration/inputs/imports/explicit.ncl b/core/tests/integration/inputs/imports/explicit.ncl
new file mode 100644
index 00000000..dfa8be93
--- /dev/null
+++ b/core/tests/integration/inputs/imports/explicit.ncl
@@ -0,0 +1,11 @@
+# test.type = 'pass'
+
+[
+  (import 'Nickel "imported/file_without_extension") == 1234,
+  (import 'Raw    "imported/file_without_extension") |> std.string.is_match "^1200\\+34\\s*$",
+  (import 'Nickel "imported/file_with_unknown_extension.tst") == 1234,
+  (import 'Raw    "imported/file_with_unknown_extension.tst") |> std.string.is_match "^34\\+1200\\s*$",
+  (import 'Raw    "imported/empty.yaml") == "",
+  (import 'Raw    "imported/two.ncl") |> std.string.is_match "^\\s*\\#",
+]
+|> std.test.assert_all
diff --git a/core/tests/integration/inputs/imports/explicit_unknowntag.ncl b/core/tests/integration/inputs/imports/explicit_unknowntag.ncl
new file mode 100644
index 00000000..23102f5c
--- /dev/null
+++ b/core/tests/integration/inputs/imports/explicit_unknowntag.ncl
@@ -0,0 +1,6 @@
+# test.type = 'error'
+#
+# [test.metadata]
+# error = 'ParseError'
+
+import 'Qqq "imported/empty.yaml"
diff --git a/core/tests/integration/inputs/imports/fallback.ncl b/core/tests/integration/inputs/imports/fallback.ncl
new file mode 100644
index 00000000..674b62c0
--- /dev/null
+++ b/core/tests/integration/inputs/imports/fallback.ncl
@@ -0,0 +1,8 @@
+# test.type = 'pass'
+
+# Testing that importing files with unknown extension interprets them as Nickel source code
+[
+  (import "imported/file_without_extension") == 1234,
+  (import "imported/file_with_unknown_extension.tst") == 1234,
+]
+|> std.test.assert_all
diff --git a/core/tests/integration/inputs/imports/imported/file_with_unknown_extension.tst b/core/tests/integration/inputs/imports/imported/file_with_unknown_extension.tst
new file mode 100644
index 00000000..a4a447b7
--- /dev/null
+++ b/core/tests/integration/inputs/imports/imported/file_with_unknown_extension.tst
@@ -0,0 +1,1 @@
+34+1200
diff --git a/core/tests/integration/inputs/imports/imported/file_without_extension b/core/tests/integration/inputs/imports/imported/file_without_extension
new file mode 100644
index 00000000..8ea46cc4
--- /dev/null
+++ b/core/tests/integration/inputs/imports/imported/file_without_extension
@@ -0,0 +1,1 @@
+1200+34
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/core/tests/integration/inputs/typecheck/let_block_not_rec.ncl b/core/tests/integration/inputs/typecheck/let_block_not_rec.ncl
new file mode 100644
index 00000000..57e57c85
--- /dev/null
+++ b/core/tests/integration/inputs/typecheck/let_block_not_rec.ncl
@@ -0,0 +1,13 @@
+# test.type = 'error'
+# eval = 'typecheck'
+#
+# [test.metadata]
+# error = 'TypecheckError::UnboundIdentifier'
+#
+# [test.metadata.expectation]
+# identifier = "x"
+(let
+  x = 1,
+  y = x,
+in
+true): _
diff --git a/core/tests/integration/inputs/typecheck/let_blocks.ncl b/core/tests/integration/inputs/typecheck/let_blocks.ncl
new file mode 100644
index 00000000..87ef432d
--- /dev/null
+++ b/core/tests/integration/inputs/typecheck/let_blocks.ncl
@@ -0,0 +1,38 @@
+# test.type = 'pass'
+(let typecheck = [
+  let
+    x = 1,
+    y = 2,
+  in x + y,
+
+  let rec
+    x = 1,
+    y = x,
+  in x + y,
+
+  let x = 1 in
+  let
+    x = "hi",
+    y = x,
+    z = 2,
+  in y + z,
+
+  let x = "hi" in
+  let rec
+    x = 1,
+    y = x,
+    z = 2,
+  in y + z,
+
+  let
+    { x, y } = { x = 1, y = x },
+    z = 2,
+  in y + z,
+
+  let rec
+    a = 2,
+    { b } = { b = a }
+  in b,
+]
+in
+true): _
diff --git a/core/tests/integration/inputs/typecheck/let_rec_outer_scope.ncl b/core/tests/integration/inputs/typecheck/let_rec_outer_scope.ncl
new file mode 100644
index 00000000..887bd0e1
--- /dev/null
+++ b/core/tests/integration/inputs/typecheck/let_rec_outer_scope.ncl
@@ -0,0 +1,15 @@
+# test.type = 'error'
+# eval = 'typecheck'
+#
+# [test.metadata]
+# error = 'TypecheckError::TypeMismatch'
+#
+# [test.metadata.expectation]
+# expected = "Number"
+# inferred = "String"
+(let x = 1 in
+let rec
+  y = x,
+  x = "1",
+in
+y + 1): _
diff --git a/core/tests/integration/main.rs b/core/tests/integration/main.rs
index 4764f4cb..6aa5b8c4 100644
--- a/core/tests/integration/main.rs
+++ b/core/tests/integration/main.rs
@@ -171,6 +171,8 @@ enum ErrorExpectation {
     EvalNonExhaustiveMatch,
     #[serde(rename = "EvalError::NonExhaustiveEnumMatch")]
     EvalNonExhaustiveEnumMatch,
+    #[serde(rename = "EvalError::FailedDestructuring")]
+    EvalFailedDestructuring,
     #[serde(rename = "TypecheckError::UnboundIdentifier")]
     TypecheckUnboundIdentifier { identifier: String },
     #[serde(rename = "TypecheckError::UnboundTypeVariable")]
@@ -247,6 +249,7 @@ impl PartialEq<Error> for ErrorExpectation {
                 EvalNonExhaustiveEnumMatch,
                 Error::EvalError(EvalError::NonExhaustiveEnumMatch { .. }),
             )
+            | (EvalFailedDestructuring, Error::EvalError(EvalError::FailedDestructuring { .. }))
             | (
                 TypecheckRecordRowMismatch,
                 Error::TypecheckError(TypecheckError::RecordRowMismatch { .. }),
@@ -410,6 +413,7 @@ impl std::fmt::Display for ErrorExpectation {
             }
             EvalNonExhaustiveMatch => "EvalError::NonExhaustiveMatch".to_owned(),
             EvalNonExhaustiveEnumMatch => "EvalError::NonExhaustiveEnumMatch".to_owned(),
+            EvalFailedDestructuring => "EvalError::FailedDestructuring".to_owned(),
             TypecheckUnboundIdentifier { identifier } => {
                 format!("TypecheckError::UnboundIdentifier({identifier})")
             }
diff --git a/lsp/nls/tests/inputs/completion-patterns-rec.ncl b/lsp/nls/tests/inputs/completion-patterns-rec.ncl
new file mode 100644
index 00000000..56bc8be0
--- /dev/null
+++ b/lsp/nls/tests/inputs/completion-patterns-rec.ncl
@@ -0,0 +1,88 @@
+### /input.ncl
+let rec outer@{ foo = inner@{ bar = innermost, .. }, .. } = {
+  extra = 1,
+  foo = {
+    more = 1,
+    bar = {
+      most = 1,
+      baz = 1,
+    }
+  },
+}
+in
+[
+  outer.foo,
+  outer.foo.bar,
+  outer.foo.bar.baz,
+  inner.bar,
+  inner.bar.baz,
+  innermost.baz,
+]
+### [[request]]
+### type = "Completion"
+### textDocument.uri = "file:///input.ncl"
+### position = { line = 12, character = 10 }
+###
+### [[request]]
+### type = "Completion"
+### textDocument.uri = "file:///input.ncl"
+### position = { line = 13, character = 14 }
+###
+### [[request]]
+### type = "Completion"
+### textDocument.uri = "file:///input.ncl"
+### position = { line = 14, character = 18 }
+###
+### [[request]]
+### type = "Completion"
+### textDocument.uri = "file:///input.ncl"
+### position = { line = 15, character = 10 }
+###
+### [[request]]
+### type = "Completion"
+### textDocument.uri = "file:///input.ncl"
+### position = { line = 16, character = 14 }
+###
+### [[request]]
+### type = "Completion"
+### textDocument.uri = "file:///input.ncl"
+### position = { line = 17, character = 14 }
+###
+### # Record field completions
+###
+### [[request]]
+### type = "Completion"
+### textDocument.uri = "file:///input.ncl"
+### position = { line = 1, character = 3 }
+###
+### [[request]]
+### type = "Completion"
+### textDocument.uri = "file:///input.ncl"
+### position = { line = 3, character = 5 }
+###
+### [[request]]
+### type = "Completion"
+### textDocument.uri = "file:///input.ncl"
+### position = { line = 5, character = 7 }
+###
+### # Env completions
+###
+### [[request]]
+### type = "Completion"
+### textDocument.uri = "file:///input.ncl"
+### position = { line = 1, character = 10 }
+###
+### [[request]]
+### type = "Completion"
+### textDocument.uri = "file:///input.ncl"
+### position = { line = 3, character = 11 }
+###
+### [[request]]
+### type = "Completion"
+### textDocument.uri = "file:///input.ncl"
+### position = { line = 5, character = 13 }
+###
+### [[request]]
+### type = "Completion"
+### textDocument.uri = "file:///input.ncl"
+### position = { line = 11, character = 1 }
diff --git a/lsp/nls/tests/inputs/completion-patterns.ncl b/lsp/nls/tests/inputs/completion-patterns.ncl
index 6a53aa4d..31c944b2 100644
--- a/lsp/nls/tests/inputs/completion-patterns.ncl
+++ b/lsp/nls/tests/inputs/completion-patterns.ncl
@@ -81,3 +81,8 @@ in
 ### type = "Completion"
 ### textDocument.uri = "file:///input.ncl"
 ### position = { line = 5, character = 13 }
+###
+### [[request]]
+### type = "Completion"
+### textDocument.uri = "file:///input.ncl"
+### position = { line = 11, character = 1 }
diff --git a/lsp/nls/tests/inputs/goto-let-block.ncl b/lsp/nls/tests/inputs/goto-let-block.ncl
new file mode 100644
index 00000000..c10f480f
--- /dev/null
+++ b/lsp/nls/tests/inputs/goto-let-block.ncl
@@ -0,0 +1,25 @@
+### /goto.ncl
+let x = 2 in x +
+(let
+  y = x,
+  x = 1,
+in x + y)
+### [[request]]
+### type = "GotoDefinition"
+### textDocument.uri = "file:///goto.ncl"
+### position = { line = 0, character = 13 }
+###
+### [[request]]
+### type = "GotoDefinition"
+### textDocument.uri = "file:///goto.ncl"
+### position = { line = 2, character = 6 }
+###
+### [[request]]
+### type = "GotoDefinition"
+### textDocument.uri = "file:///goto.ncl"
+### position = { line = 4, character = 3 }
+###
+### [[request]]
+### type = "GotoDefinition"
+### textDocument.uri = "file:///goto.ncl"
+### position = { line = 4, character = 7 }
diff --git a/lsp/nls/tests/inputs/goto-let-rec.ncl b/lsp/nls/tests/inputs/goto-let-rec.ncl
new file mode 100644
index 00000000..fe713da3
--- /dev/null
+++ b/lsp/nls/tests/inputs/goto-let-rec.ncl
@@ -0,0 +1,25 @@
+### /goto.ncl
+let x = 2 in x +
+(let rec
+  y = x,
+  x = 1,
+in x + y)
+### [[request]]
+### type = "GotoDefinition"
+### textDocument.uri = "file:///goto.ncl"
+### position = { line = 0, character = 13 }
+###
+### [[request]]
+### type = "GotoDefinition"
+### textDocument.uri = "file:///goto.ncl"
+### position = { line = 2, character = 6 }
+###
+### [[request]]
+### type = "GotoDefinition"
+### textDocument.uri = "file:///goto.ncl"
+### position = { line = 4, character = 3 }
+###
+### [[request]]
+### type = "GotoDefinition"
+### textDocument.uri = "file:///goto.ncl"
+### position = { line = 4, character = 7 }
diff --git a/lsp/nls/tests/snapshots/main__lsp__nls__tests__inputs__completion-patterns-rec.ncl.snap b/lsp/nls/tests/snapshots/main__lsp__nls__tests__inputs__completion-patterns-rec.ncl.snap
new file mode 100644
index 00000000..7b0360c9
--- /dev/null
+++ b/lsp/nls/tests/snapshots/main__lsp__nls__tests__inputs__completion-patterns-rec.ncl.snap
@@ -0,0 +1,17 @@
+---
+source: lsp/nls/tests/main.rs
+expression: output
+---
+[extra, foo]
+[bar, more]
+[baz, most]
+[bar (Dyn), more]
+[baz, most]
+[baz, most]
+[foo]
+[bar]
+[baz]
+[inner, innermost, outer, std]
+[extra, foo, inner, innermost, outer, std]
+[bar, extra, foo, inner, innermost, more, outer, std]
+[inner, innermost, outer, std]
diff --git a/lsp/nls/tests/snapshots/main__lsp__nls__tests__inputs__completion-patterns.ncl.snap b/lsp/nls/tests/snapshots/main__lsp__nls__tests__inputs__completion-patterns.ncl.snap
index 8c28c32e..e34b3a13 100644
--- a/lsp/nls/tests/snapshots/main__lsp__nls__tests__inputs__completion-patterns.ncl.snap
+++ b/lsp/nls/tests/snapshots/main__lsp__nls__tests__inputs__completion-patterns.ncl.snap
@@ -11,6 +11,7 @@ expression: output
 [foo]
 [bar]
 [baz]
+[std]
+[extra, foo, std]
+[bar, extra, foo, more, std]
 [inner, innermost, outer, std]
-[extra, foo, inner, innermost, outer, std]
-[bar, extra, foo, inner, innermost, more, outer, std]
diff --git a/lsp/nls/tests/snapshots/main__lsp__nls__tests__inputs__goto-let-block.ncl.snap b/lsp/nls/tests/snapshots/main__lsp__nls__tests__inputs__goto-let-block.ncl.snap
new file mode 100644
index 00000000..411dd5dd
--- /dev/null
+++ b/lsp/nls/tests/snapshots/main__lsp__nls__tests__inputs__goto-let-block.ncl.snap
@@ -0,0 +1,8 @@
+---
+source: lsp/nls/tests/main.rs
+expression: output
+---
+file:///goto.ncl:0:4-0:5
+file:///goto.ncl:0:4-0:5
+file:///goto.ncl:3:2-3:3
+file:///goto.ncl:2:2-2:3
diff --git a/lsp/nls/tests/snapshots/main__lsp__nls__tests__inputs__goto-let-rec.ncl.snap b/lsp/nls/tests/snapshots/main__lsp__nls__tests__inputs__goto-let-rec.ncl.snap
new file mode 100644
index 00000000..30a3da47
--- /dev/null
+++ b/lsp/nls/tests/snapshots/main__lsp__nls__tests__inputs__goto-let-rec.ncl.snap
@@ -0,0 +1,8 @@
+---
+source: lsp/nls/tests/main.rs
+expression: output
+---
+file:///goto.ncl:0:4-0:5
+file:///goto.ncl:3:2-3:3
+file:///goto.ncl:3:2-3:3
+file:///goto.ncl:2:2-2:3
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
 ```]