Repository: tweag/nickel
Pull request #1212 (2023-03-29 17:01:49)

Add grapheme cluster warnings to stdlib.string regex function docs
See the discussion in #1007 for context.

Feel free to merge if/when this is approved (or make any required changes), as I'll be away from my laptop for the next week.


Patch:
diff --git a/Cargo.lock b/Cargo.lock
index ef1d4a3e..14fb76ac 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -365,16 +365,14 @@ dependencies = [
 
 [[package]]
 name = "comrak"
-version = "0.16.0"
+version = "0.17.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "784836d0812dade01579cc0cc9b1684847044e716fd7aa6bffbc172e42199500"
+checksum = "54b9638d96f93f8bf7b050f7505ba0d82b8267f601965b08faa0eb4882a9bba9"
 dependencies = [
  "clap 4.1.8",
  "entities",
  "memchr",
  "once_cell",
- "pest",
- "pest_derive",
  "regex",
  "shell-words",
  "slug",
@@ -1642,50 +1640,6 @@ version = "2.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "478c572c3d73181ff3c2539045f6eb99e5491218eae919370993b890cdbdd98e"
 
-[[package]]
-name = "pest"
-version = "2.5.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8cbd939b234e95d72bc393d51788aec68aeeb5d51e748ca08ff3aad58cb722f7"
-dependencies = [
- "thiserror",
- "ucd-trie",
-]
-
-[[package]]
-name = "pest_derive"
-version = "2.5.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a81186863f3d0a27340815be8f2078dd8050b14cd71913db9fbda795e5f707d7"
-dependencies = [
- "pest",
- "pest_generator",
-]
-
-[[package]]
-name = "pest_generator"
-version = "2.5.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "75a1ef20bf3193c15ac345acb32e26b3dc3223aff4d77ae4fc5359567683796b"
-dependencies = [
- "pest",
- "pest_meta",
- "proc-macro2 1.0.52",
- "quote 1.0.24",
- "syn 1.0.109",
-]
-
-[[package]]
-name = "pest_meta"
-version = "2.5.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5e3b284b1f13a20dc5ebc90aff59a51b8d7137c221131b52a7260c08cbc1cc80"
-dependencies = [
- "once_cell",
- "pest",
- "sha2",
-]
-
 [[package]]
 name = "petgraph"
 version = "0.6.3"
@@ -2732,12 +2686,6 @@ version = "1.16.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "497961ef93d974e23eb6f433eb5fe1b7930b659f06d12dec6fc44a8f554c0bba"
 
-[[package]]
-name = "ucd-trie"
-version = "0.1.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9e79c4d996edb816c91e4308506774452e55e95c3c9de07b6729e17e15a5ef81"
-
 [[package]]
 name = "unicode-bidi"
 version = "0.3.11"
diff --git a/Cargo.toml b/Cargo.toml
index 7738f93d..10973e61 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -62,7 +62,7 @@ js-sys = { version = "0.3", optional = true }
 serde_repr = { version = "0.1", optional = true }
 pretty = "0.11.3"
 
-comrak = { version = "0.16.0", optional = true, features = [] }
+comrak = { version = "0.17.0", optional = true, features = [] }
 once_cell = "1.17.1"
 typed-arena = "2.0.2"
 malachite = {version = "0.3.2", features = ["enable_serde"] }
diff --git a/flake.nix b/flake.nix
index d82d42c8..e0400a59 100644
--- a/flake.nix
+++ b/flake.nix
@@ -380,20 +380,27 @@
         '';
       };
 
-      # Generate the stdlib documentation from `nickel doc`.
-      stdlibDoc = pkgs.stdenv.mkDerivation {
-        name = "nickel-stdlib-doc-${version}";
-        src = ./stdlib;
-        installPhase = ''
-          mkdir -p $out
-          for file in *
-          do
-            module=$(basename $file .ncl)
-            ${self.packages."${system}".default}/bin/nickel doc -f "$module.ncl" \
-              --output "$out/$module.md"
-          done
-        '';
-      };
+      # Generate the stdlib documentation from `nickel doc` as `format`.
+      stdlibDoc = format:
+        let
+          extension =
+            {
+              "markdown" = "md";
+            }."${format}" or format;
+        in
+        pkgs.stdenv.mkDerivation {
+          name = "nickel-stdlib-doc-${format}-${version}";
+          src = ./stdlib;
+          installPhase = ''
+            mkdir -p $out
+            for file in $(ls *.ncl | grep -v 'internals.ncl')
+            do
+              module=$(basename $file .ncl)
+              ${self.packages."${system}".default}/bin/nickel doc --format "${format}" -f "$module.ncl" \
+                --output "$out/$module.${extension}"
+            done
+          '';
+        };
 
     in
     rec {
@@ -410,7 +417,8 @@
         dockerImage = buildDocker packages.nickel; # TODO: docker image should be a passthru
         inherit vscodeExtension;
         inherit userManual;
-        inherit stdlibDoc;
+        stdlibMarkdown = stdlibDoc "markdown";
+        stdlibJson = stdlibDoc "json";
       };
 
       apps = {
diff --git a/lsp/nls/src/cache.rs b/lsp/nls/src/cache.rs
index 8a5f0d24..e3ec3cbb 100644
--- a/lsp/nls/src/cache.rs
+++ b/lsp/nls/src/cache.rs
@@ -48,8 +48,8 @@ impl CacheExt for Cache {
         if let Ok(CacheOp::Done((ids, errors))) = self.resolve_imports(file_id) {
             import_errors = errors;
             for id in ids {
-                self.typecheck_with_analysis(id, initial_ctxt, initial_env, lin_cache)
-                    .unwrap();
+                // Don't crash when an import doens't type check
+                let _ = self.typecheck_with_analysis(id, initial_ctxt, initial_env, lin_cache);
             }
         }
 
diff --git a/src/bin/nickel.rs b/src/bin/nickel.rs
index 07151d37..7b0c8383 100644
--- a/src/bin/nickel.rs
+++ b/src/bin/nickel.rs
@@ -1,4 +1,5 @@
 //! Entry point of the program.
+use core::fmt;
 use nickel_lang::error::{Error, IOError};
 use nickel_lang::eval::cache::CacheImpl;
 use nickel_lang::program::{ColorOpt, Program};
@@ -81,14 +82,67 @@ enum Command {
     /// Generates the documentation files for the specified nickel file
     #[cfg(feature = "doc")]
     Doc {
-        /// Specify the path of the generated documentation file. Default to
+        /// The path of the generated documentation file. Default to
         /// `~/.nickel/doc/<input-file>.md` for input `<input-file>.ncl`, or to
         /// `~/.nickel/doc/out.md` if the input is read from stdin.
         #[structopt(short = "o", long, parse(from_os_str))]
         output: Option<PathBuf>,
+        /// Write documentation to stdout. Takes precedence over `output`
+        #[structopt(long)]
+        stdout: bool,
+        /// The output format for the generated documentation. Possible values:
+        /// markdown, json
+        #[structopt(long, default_value = "markdown")]
+        format: DocFormat,
     },
 }
 
+#[derive(Copy, Clone, Eq, PartialEq, Debug, Default)]
+pub enum DocFormat {
+    Json,
+    #[default]
+    Markdown,
+}
+
+impl DocFormat {
+    pub fn extension(&self) -> &'static str {
+        match self {
+            Self::Json => "json",
+            Self::Markdown => "md",
+        }
+    }
+}
+
+impl fmt::Display for DocFormat {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        match self {
+            Self::Json => write!(f, "json"),
+            Self::Markdown => write!(f, "markdown"),
+        }
+    }
+}
+
+#[derive(Clone, Eq, PartialEq, Debug)]
+pub struct ParseFormatError(String);
+
+impl fmt::Display for ParseFormatError {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        write!(f, "unsupported export format {}", self.0)
+    }
+}
+
+impl std::str::FromStr for DocFormat {
+    type Err = ParseFormatError;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        match s.to_lowercase().as_ref() {
+            "json" => Ok(DocFormat::Json),
+            "markdown" => Ok(DocFormat::Markdown),
+            _ => Err(ParseFormatError(String::from(s))),
+        }
+    }
+}
+
 fn main() {
     let opts = Opt::from_args();
 
@@ -160,7 +214,56 @@ fn main() {
             Some(Command::Typecheck) => program.typecheck(),
             Some(Command::Repl { .. }) => unreachable!(),
             #[cfg(feature = "doc")]
-            Some(Command::Doc { ref output }) => output
+            Some(Command::Doc {
+                output,
+                stdout,
+                format,
+            }) => export_doc(&mut program, opts.file.as_ref(), output, stdout, format),
+            None => program
+                .eval_full()
+                .map(|t| println!("{}", Term::from(t).deep_repr())),
+        };
+
+        if let Err(err) = result {
+            program.report(err);
+            process::exit(1)
+        }
+    }
+}
+
+fn export(
+    program: &mut Program<CacheImpl>,
+    format: Option<ExportFormat>,
+    output: Option<PathBuf>,
+) -> Result<(), Error> {
+    let rt = program.eval_full().map(RichTerm::from)?;
+    let format = format.unwrap_or_default();
+
+    serialize::validate(format, &rt)?;
+
+    if let Some(file) = output {
+        let file = fs::File::create(file).map_err(IOError::from)?;
+        serialize::to_writer(file, format, &rt)?;
+    } else {
+        serialize::to_writer(std::io::stdout(), format, &rt)?;
+    }
+
+    Ok(())
+}
+
+#[cfg(feature = "doc")]
+fn export_doc(
+    program: &mut Program<CacheImpl>,
+    file: Option<&PathBuf>,
+    output: Option<PathBuf>,
+    stdout: bool,
+    format: DocFormat,
+) -> Result<(), Error> {
+    let mut out: Box<dyn std::io::Write> = if stdout {
+        Box::new(std::io::stdout())
+    } else {
+        Box::new(
+            output
                 .as_ref()
                 .map(|output| {
                     fs::File::create(output.clone()).map_err(|e| {
@@ -180,59 +283,35 @@ fn main() {
                             e
                         )))
                     })?;
-                    let mut markdown_file = docpath.to_path_buf();
+                    let mut output_file = docpath.to_path_buf();
 
                     let mut has_file_name = false;
 
-                    if let Some(path) = opts.file {
+                    if let Some(path) = file {
                         if let Some(file_stem) = path.file_stem() {
-                            markdown_file.push(file_stem);
+                            output_file.push(file_stem);
                             has_file_name = true;
                         }
                     }
 
                     if !has_file_name {
-                        markdown_file.push("out");
+                        output_file.push("out");
                     }
 
-                    markdown_file.set_extension("md");
-                    File::create(markdown_file.clone().into_os_string()).map_err(|e| {
+                    output_file.set_extension(format.extension());
+                    File::create(output_file.clone().into_os_string()).map_err(|e| {
                         Error::IOError(IOError(format!(
                             "when opening or creating output file `{}`: {}",
-                            markdown_file.to_string_lossy(),
+                            output_file.to_string_lossy(),
                             e
                         )))
                     })
-                })
-                .and_then(|mut out| program.output_doc(&mut out)),
-            None => program
-                .eval_full()
-                .map(|t| println!("{}", Term::from(t).deep_repr())),
-        };
-
-        if let Err(err) = result {
-            program.report(err);
-            process::exit(1)
-        }
+                })?,
+        )
+    };
+    let doc = program.extract_doc()?;
+    match format {
+        DocFormat::Json => doc.write_json(&mut out),
+        DocFormat::Markdown => doc.write_markdown(&mut out),
     }
 }
-
-fn export(
-    program: &mut Program<CacheImpl>,
-    format: Option<ExportFormat>,
-    output: Option<PathBuf>,
-) -> Result<(), Error> {
-    let rt = program.eval_full().map(RichTerm::from)?;
-    let format = format.unwrap_or_default();
-
-    serialize::validate(format, &rt)?;
-
-    if let Some(file) = output {
-        let file = fs::File::create(file).map_err(IOError::from)?;
-        serialize::to_writer(file, format, &rt)?;
-    } else {
-        serialize::to_writer(std::io::stdout(), format, &rt)?;
-    }
-
-    Ok(())
-}
diff --git a/src/error.rs b/src/error.rs
index 4fcdd269..2a4a64a3 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -33,7 +33,7 @@ pub enum Error {
     TypecheckError(TypecheckError),
     ParseErrors(ParseErrors),
     ImportError(ImportError),
-    SerializationError(SerializationError),
+    ExportError(ExportError),
     IOError(IOError),
     ReplError(ReplError),
 }
@@ -98,7 +98,7 @@ pub enum EvalError {
     /// An element in the evaluation Cache was entered during its own update.
     InfiniteRecursion(CallStack, TermPos),
     /// A serialization error occurred during a call to the builtin `serialize`.
-    SerializationError(SerializationError),
+    SerializationError(ExportError),
     /// A parse error occurred during a call to the builtin `deserialize`.
     DeserializationError(
         String,  /* format */
@@ -384,13 +384,15 @@ pub enum ImportError {
 
 /// An error occurred during serialization.
 #[derive(Debug, PartialEq, Clone)]
-pub enum SerializationError {
+pub enum ExportError {
     /// Encountered a null value for a format that doesn't support them.
     UnsupportedNull(ExportFormat, RichTerm),
     /// Tried exporting something else than a `Str` to raw format.
     NotAString(RichTerm),
     /// A term contains constructs that cannot be serialized.
     NonSerializable(RichTerm),
+    /// No exportable documentation was found when requested.
+    NoDocumentation(RichTerm),
     Other(String),
 }
 
@@ -441,9 +443,9 @@ impl From<ImportError> for Error {
     }
 }
 
-impl From<SerializationError> for Error {
-    fn from(error: SerializationError) -> Error {
-        Error::SerializationError(error)
+impl From<ExportError> for Error {
+    fn from(error: ExportError) -> Error {
+        Error::ExportError(error)
     }
 }
 
@@ -459,8 +461,8 @@ impl From<std::io::Error> for IOError {
     }
 }
 
-impl From<SerializationError> for EvalError {
-    fn from(error: SerializationError) -> EvalError {
+impl From<ExportError> for EvalError {
+    fn from(error: ExportError) -> EvalError {
         EvalError::SerializationError(error)
     }
 }
@@ -754,7 +756,7 @@ impl IntoDiagnostics<FileId> for Error {
             Error::TypecheckError(err) => err.into_diagnostics(files, stdlib_ids),
             Error::EvalError(err) => err.into_diagnostics(files, stdlib_ids),
             Error::ImportError(err) => err.into_diagnostics(files, stdlib_ids),
-            Error::SerializationError(err) => err.into_diagnostics(files, stdlib_ids),
+            Error::ExportError(err) => err.into_diagnostics(files, stdlib_ids),
             Error::IOError(err) => err.into_diagnostics(files, stdlib_ids),
             Error::ReplError(err) => err.into_diagnostics(files, stdlib_ids),
         }
@@ -1754,14 +1756,14 @@ impl IntoDiagnostics<FileId> for ImportError {
     }
 }
 
-impl IntoDiagnostics<FileId> for SerializationError {
+impl IntoDiagnostics<FileId> for ExportError {
     fn into_diagnostics(
         self,
         files: &mut Files<String>,
         _stdlib_ids: Option<&Vec<FileId>>,
     ) -> Vec<Diagnostic<FileId>> {
         match self {
-            SerializationError::NotAString(rt) => vec![Diagnostic::error()
+            ExportError::NotAString(rt) => vec![Diagnostic::error()
                 .with_message(format!(
                     "raw export only supports `Str`, got {}",
                     rt.as_ref()
@@ -1769,13 +1771,19 @@ impl IntoDiagnostics<FileId> for SerializationError {
                         .unwrap_or_else(|| String::from("<unevaluated>"))
                 ))
                 .with_labels(vec![primary_term(&rt, files)])],
-            SerializationError::UnsupportedNull(format, rt) => vec![Diagnostic::error()
+            ExportError::UnsupportedNull(format, rt) => vec![Diagnostic::error()
                 .with_message(format!("{format} doesn't support null values"))
                 .with_labels(vec![primary_term(&rt, files)])],
-            SerializationError::NonSerializable(rt) => vec![Diagnostic::error()
+            ExportError::NonSerializable(rt) => vec![Diagnostic::error()
                 .with_message("non serializable term")
                 .with_labels(vec![primary_term(&rt, files)])],
-            SerializationError::Other(msg) => vec![Diagnostic::error()
+            ExportError::NoDocumentation(rt) => vec![Diagnostic::error()
+                .with_message("no documentation found")
+                .with_labels(vec![primary_term(&rt, files)])
+                .with_notes(vec![
+                    "documentation can only be collected from records".to_owned()
+                ])],
+            ExportError::Other(msg) => vec![Diagnostic::error()
                 .with_message("error during serialization")
                 .with_notes(vec![msg])],
         }
diff --git a/src/eval/fixpoint.rs b/src/eval/fixpoint.rs
index 62ea0451..f664e986 100644
--- a/src/eval/fixpoint.rs
+++ b/src/eval/fixpoint.rs
@@ -1,5 +1,5 @@
 //! Compute the fixpoint of a recursive record.
-use super::*;
+use super::{merge::RevertClosurize, *};
 use crate::{label::Label, position::TermPos};
 
 // Update the environment of a term by extending it with a recursive environment. In the general
@@ -203,3 +203,41 @@ pub fn patch_field<C: Cache>(
 
     Ok(())
 }
+
+/// Revert an evaluated record (`Record`) back to a recursive record (`RecRecord`). The fixpoint
+/// will be recomputed again at evaluation.
+///
+/// `revert` is used when an operation (excluding `merge`, which has its own reverting logic) might
+/// have changed the value of fields (e.g. a lazy contract application on a record), requiring to
+/// update recursive dependencies.
+///
+/// # Parameters
+///
+/// `env` and `local_env` are similar to the parameters of
+/// `[crate::transform::Closurize::closurize]`.
+///
+/// - `cache`: the evaluation cache
+/// - `record_data`: the data of the record to revert
+/// - `env`: the final environment in which the fields of the result will be closurized
+/// - `local_env`: the environment of the record represented by `record_data`
+pub fn revert<C: Cache>(
+    cache: &mut C,
+    record_data: RecordData,
+    env: &mut Environment,
+    local_env: &Environment,
+) -> Term {
+    let fields = record_data
+        .fields
+        .into_iter()
+        .map(|(id, field)| (id, field.revert_closurize(cache, env, local_env.clone())))
+        .collect();
+
+    let record_data = RecordData {
+        fields,
+        ..record_data
+    };
+
+    // At run-time, we don't care about `RecordDeps`, because this information is already stored in
+    // the cache (formerly thunks in call-by-need). We set it to `None`.
+    Term::RecRecord(record_data, Vec::new(), None)
+}
diff --git a/src/eval/merge.rs b/src/eval/merge.rs
index 295a737f..14d1c037 100644
--- a/src/eval/merge.rs
+++ b/src/eval/merge.rs
@@ -562,7 +562,7 @@ fn fields_merge_closurize<'a, I: DoubleEndedIterator<Item = &'a Ident> + Clone,
 }
 
 /// Same as [Closurizable], but also revert the element if the term is a variable.
-trait RevertClosurize {
+pub(super) trait RevertClosurize {
     /// Revert the element at the index inside the term (if any), and closurize the result inside `env`.
     fn revert_closurize<C: Cache>(
         self,
diff --git a/src/eval/operation.rs b/src/eval/operation.rs
index 0be5fe76..9ede1dca 100644
--- a/src/eval/operation.rs
+++ b/src/eval/operation.rs
@@ -25,7 +25,7 @@ use crate::{
     term::{
         array::{Array, ArrayAttrs},
         make as mk_term,
-        record::{self, Field, FieldMetadata, RecordAttrs, RecordData},
+        record::{self, Field, FieldMetadata, RecordData},
         string::NickelString,
         BinaryOp, MergePriority, NAryOp, Number, PendingContract, RecordExtKind, RichTerm,
         SharedTerm, StrChunk, Term, UnaryOp,
@@ -941,7 +941,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                     Term::Num(n) => Ok(Term::Str(format!("{}", n.to_sci()).into())),
                     Term::Str(s) => Ok(Term::Str(s)),
                     Term::Bool(b) => Ok(Term::Str(b.to_string().into())),
-                    Term::Enum(id) => Ok(Term::Str(id.to_string().into())),
+                    Term::Enum(id) => Ok(Term::Str(id.into())),
                     Term::Null => Ok(Term::Str("null".into())),
                 } else {
                     Err(EvalError::Other(
@@ -2466,16 +2466,9 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                     ))
                 }
             }
-            // This implementation of `%dictionary_assume%` internally creates
-            // essentially a copy of the record that is being checked, albeit
-            // without values for the fields. If the record in question is
-            // particularly huge, this could have performance implications.
-            //
-            // An alternative implementation would be to duplicate some of the
-            // logic from `eval::merge` in here. We decided not to do that for
-            // simplicity and to keep all the magic operations on revertible
-            // thunks contained in their own module.
-            BinaryOp::DictionaryAssume() => {
+            BinaryOp::RecordLazyAssume() => {
+                // The contract is expected to be of type `String -> Contract`: it takes the name
+                // of the field as a parameter, and returns a contract.
                 let (
                     Closure {
                         body: contract_term,
@@ -2483,14 +2476,14 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                     },
                     _,
                 ) = self.stack.pop_arg(&self.cache).ok_or_else(|| {
-                    EvalError::NotEnoughArgs(3, String::from("dictionary_assume"), pos_op)
+                    EvalError::NotEnoughArgs(3, String::from("record_lazy_assume"), pos_op)
                 })?;
 
-                let lbl = match_sharedterm! {t1, with {
-                        Term::Lbl(lbl) => lbl
+                let label = match_sharedterm! {t1, with {
+                        Term::Lbl(label) => label
                     } else return Err(EvalError::TypeError(
-                        String::from("Lbl"),
-                        String::from("dictionary_assume, 2nd argument"),
+                        String::from("Label"),
+                        String::from("record_lazy_assume, 2nd argument"),
                         fst_pos,
                         RichTerm {
                             term: t1,
@@ -2502,55 +2495,47 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                 match_sharedterm! {t2,
                     with {
                         Term::Record(record_data) => {
-                            let contract = contract_term.closurize(&mut self.cache, &mut env2, contract_env);
-                            let record_contract = Term::Record(RecordData {
-                                fields: record_data
-                                    .fields
-                                    .iter()
-                                    .map(|(key, value)| {
-                                        (
-                                            *key,
-                                            Field {
-                                                value: None,
-                                                pending_contracts: vec![PendingContract::new(
-                                                    contract.clone(),
-                                                    lbl.clone(),
-                                                )],
-                                                // This is intentionally not written as
-                                                // `{ opt: value.metadata.opt, ..Default::default() }`
-                                                //
-                                                // When `FieldMetadata` is changed in the future, we will need to consider
-                                                // which values should be inherited from `value.metadata`.
-                                                //
-                                                // The `metadata` field here must be chosen such that, upon merging it
-                                                // with `value.metadata`, the result is identical to the original metadata
-                                                // `value.metadata` of the field.
-                                                metadata: FieldMetadata {
-                                                    opt: value.metadata.opt,
-
-                                                    doc: None,
-                                                    annotation: Default::default(),
-                                                    not_exported: false,
-                                                    priority: Default::default(),
-                                                },
-                                            },
-                                        )
-                                    })
-                                    .collect(),
-                                attrs: RecordAttrs { open: true },
-                                ..Default::default()
-                            });
-
-                            let merge_term = Closure {
-                                body: mk_opn!(
-                                    NAryOp::MergeContract(),
-                                    Term::Lbl(lbl),
-                                    Term::Record(record_data),
-                                    record_contract
-                                ),
-                                env: env2,
+                            // due to a limitation of `match_sharedterm`: see the macro's
+                            // documentation
+                            let mut record_data = record_data;
+
+                            let mut contract_at_field = |id: Ident| {
+                                let pos = contract_term.pos;
+                                mk_app!(
+                                    contract_term.clone(),
+                                    RichTerm::new(Term::Str(id.into()), id.pos))
+                                        .with_pos(pos)
+                                        .closurize(&mut self.cache, &mut env2, contract_env.clone(),
+                                )
                             };
-                            Ok(merge_term)
+
+                            for (id, field) in record_data.fields.iter_mut() {
+                                field.pending_contracts.push(PendingContract {
+                                    contract: contract_at_field(*id),
+                                    label: label.clone(),
+                                });
+                            }
+
+                            // IMPORTANT: here, we revert the record back to a `RecRecord`. The
+                            // reason is that applying a contract over fields might change the
+                            // value of said fields (the typical example is adding a value to a
+                            // subrecord via the default value of a contract).
+                            //
+                            // We want recursive occurrences of fields to pick this new value as
+                            // well: hence, we need to recompute the fixpoint, which is done by
+                            // `fixpoint::revert`.
+                            let mut env = Environment::new();
+                            let reverted = super::fixpoint::revert(
+                                &mut self.cache,
+                                record_data,
+                                &mut env,
+                                &env2
+                            );
+
+                            Ok(Closure {
+                                body: RichTerm::new(reverted, pos2),
+                                env,
+                            })
                         }
                     } else Err(EvalError::TypeError(
                         String::from("Record"),
diff --git a/src/identifier.rs b/src/identifier.rs
index 6bdbcf16..e9d3ddfd 100644
--- a/src/identifier.rs
+++ b/src/identifier.rs
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
 use std::fmt::{self, Debug};
 use std::hash::Hash;
 
-use crate::position::TermPos;
+use crate::{position::TermPos, term::string::NickelString};
 
 simple_counter::generate_counter!(GeneratedCounter, usize);
 static INTERNER: Lazy<interner::Interner> = Lazy::new(interner::Interner::new);
@@ -109,6 +109,12 @@ impl Into<String> for Ident {
     }
 }
 
+impl From<Ident> for NickelString {
+    fn from(id: Ident) -> Self {
+        id.to_string().into()
+    }
+}
+
 impl Ident {
     pub fn is_generated(&self) -> bool {
         self.generated
diff --git a/src/parser/grammar.lalrpop b/src/parser/grammar.lalrpop
index 8fca23f6..1ae6dc4e 100644
--- a/src/parser/grammar.lalrpop
+++ b/src/parser/grammar.lalrpop
@@ -840,7 +840,7 @@ InfixExpr: UniTerm = {
 BOpPre: BinaryOp = {
     "assume" => BinaryOp::Assume(),
     "array_lazy_assume" => BinaryOp::ArrayLazyAssume(),
-    "dictionary_assume" => BinaryOp::DictionaryAssume(),
+    "record_lazy_assume" => BinaryOp::RecordLazyAssume(),
     "unseal" => BinaryOp::Unseal(),
     "seal" => BinaryOp::Seal(),
     "go_field" => BinaryOp::GoField(),
@@ -1001,7 +1001,7 @@ extern {
         "typeof" => Token::Normal(NormalToken::Typeof),
         "assume" => Token::Normal(NormalToken::Assume),
         "array_lazy_assume" => Token::Normal(NormalToken::ArrayLazyAssume),
-        "dictionary_assume" => Token::Normal(NormalToken::DictionaryAssume),
+        "record_lazy_assume" => Token::Normal(NormalToken::RecordLazyAssume),
         "op force" => Token::Normal(NormalToken::OpForce),
         "blame" => Token::Normal(NormalToken::Blame),
         "chng_pol" => Token::Normal(NormalToken::ChangePol),
diff --git a/src/parser/lexer.rs b/src/parser/lexer.rs
index 75e35391..f45b5f7b 100644
--- a/src/parser/lexer.rs
+++ b/src/parser/lexer.rs
@@ -186,8 +186,8 @@ pub enum NormalToken<'input> {
     Assume,
     #[token("%array_lazy_assume%")]
     ArrayLazyAssume,
-    #[token("%dictionary_assume%")]
-    DictionaryAssume,
+    #[token("%record_lazy_assume%")]
+    RecordLazyAssume,
     #[token("%blame%")]
     Blame,
     #[token("%chng_pol%")]
diff --git a/src/program.rs b/src/program.rs
index 6be45e78..7e7ffa00 100644
--- a/src/program.rs
+++ b/src/program.rs
@@ -304,10 +304,20 @@ impl<EC: EvalCache> Program<EC> {
         String::from_utf8(buffer.into_inner().into_inner()).unwrap()
     }
 
-    /// Create a markdown file with documentation for the specified program in `.nickel/doc/program_main_file_name.md`
+    /// Extract documentation from the program
     #[cfg(feature = "doc")]
-    pub fn output_doc(&mut self, out: &mut dyn std::io::Write) -> Result<(), Error> {
-        doc::output_doc(self.vm.import_resolver_mut(), self.main_id, out)
+    pub fn extract_doc(&mut self) -> Result<doc::ExtractedDocumentation, Error> {
+        use crate::error::ExportError;
+
+        self.vm.import_resolver_mut().parse(self.main_id)?;
+        let term = self
+            .vm
+            .import_resolver()
+            .get_ref(self.main_id)
+            .expect("The file has been parsed and must therefore be in the cache");
+        doc::ExtractedDocumentation::extract_from_term(term).ok_or(Error::ExportError(
+            ExportError::NoDocumentation(term.clone()),
+        ))
     }
 
     #[cfg(debug_assertions)]
@@ -403,75 +413,139 @@ impl From<ColorOpt> for ColorChoice {
 
 #[cfg(feature = "doc")]
 mod doc {
-    use crate::cache::Cache;
-    use crate::error::{Error, IOError};
+    use crate::error::{Error, ExportError, IOError};
     use crate::term::{RichTerm, Term};
-    use codespan::FileId;
     use comrak::arena_tree::NodeEdge;
-    use comrak::nodes::{Ast, AstNode, NodeCode, NodeHeading, NodeValue};
+    use comrak::nodes::{
+        Ast, AstNode, ListDelimType, ListType, NodeCode, NodeHeading, NodeList, NodeValue,
+    };
     use comrak::{format_commonmark, parse_document, Arena, ComrakOptions};
+    use serde::{Deserialize, Serialize};
+    use std::collections::HashMap;
     use std::io::Write;
 
-    /// Create a markdown file with documentation for the specified FileId.
-    pub fn output_doc(
-        cache: &mut Cache,
-        file_id: FileId,
-        out: &mut dyn Write,
-    ) -> Result<(), Error> {
-        cache.parse(file_id)?;
-        // unwrap(): at this point the term was correctly parsed and should exist in cache
-        let term = cache.get_ref(file_id).unwrap();
-        let document = AstNode::from(NodeValue::Document);
+    #[derive(Clone, Debug, Serialize, Deserialize)]
+    #[serde(transparent)]
+    pub struct ExtractedDocumentation {
+        fields: HashMap<String, DocumentationField>,
+    }
 
-        // Our nodes in the Markdown document are owned by this arena
-        let arena = Arena::new();
+    #[derive(Clone, Debug, Serialize, Deserialize)]
+    struct DocumentationField {
+        /// Field value [`ExtractedDocumentation`], if any
+        fields: Option<ExtractedDocumentation>,
+        /// Rendered type annotation, if any
+        types: Option<String>,
+        /// Rendered contract annotations
+        contracts: Vec<String>,
+        /// Rendered documentation, if any
+        documentation: Option<String>,
+    }
 
-        // The default ComrakOptions disables all extensions (essentially reducing to CommonMark)
-        let options = ComrakOptions::default();
+    impl ExtractedDocumentation {
+        pub fn extract_from_term(rt: &RichTerm) -> Option<Self> {
+            match rt.term.as_ref() {
+                Term::Record(record) | Term::RecRecord(record, _, _) => {
+                    let fields = record
+                        .fields
+                        .iter()
+                        .map(|(ident, field)| {
+                            let fields = field.value.as_ref().and_then(Self::extract_from_term);
+
+                            let types = field
+                                .metadata
+                                .annotation
+                                .types
+                                .as_ref()
+                                .map(|lt| lt.types.to_string());
+
+                            let contracts = field
+                                .metadata
+                                .annotation
+                                .contracts
+                                .iter()
+                                .map(|lt| lt.types.to_string())
+                                .collect();
+
+                            let documentation = field.metadata.doc.clone();
+
+                            (
+                                ident.label().to_owned(),
+                                DocumentationField {
+                                    fields,
+                                    types,
+                                    contracts,
+                                    documentation,
+                                },
+                            )
+                        })
+                        .collect();
+                    Some(Self { fields })
+                }
+                _ => None,
+            }
+        }
 
-        to_markdown(term, 0, &arena, &document, &options)?;
-        format_commonmark(&document, &options, out)
-            .map_err(|e| Error::IOError(IOError(e.to_string())))?;
+        pub fn write_json(&self, out: &mut dyn Write) -> Result<(), Error> {
+            serde_json::to_writer(out, self)
+                .map_err(|e| Error::ExportError(ExportError::Other(e.to_string())))
+        }
 
-        Ok(())
-    }
+        pub fn write_markdown(&self, out: &mut dyn Write) -> Result<(), Error> {
+            let document = AstNode::from(NodeValue::Document);
 
-    /// Recursively walk the given richterm, recursing into fields of record, looking for documentation.
-    /// This documentation is then added to the provided document.
-    fn to_markdown<'a>(
-        rt: &'a RichTerm,
-        header_level: u8,
-        arena: &'a Arena<AstNode<'a>>,
-        document: &'a AstNode<'a>,
-        options: &ComrakOptions,
-    ) -> Result<(), Error> {
-        match rt.term.as_ref() {
-            Term::Record(record) | Term::RecRecord(record, _, _) => {
-                // Sorting fields for a deterministic output
-                let mut entries: Vec<(_, _)> = record.fields.iter().collect();
-                entries.sort_by_key(|(k, _)| *k);
-
-                for (ident, field) in entries {
-                    let header = mk_header(ident.label(), header_level + 1, arena);
-                    document.append(header);
-
-                    if let Some(ref doc) = field.metadata.doc {
-                        document.append(parse_documentation(header_level, arena, doc, options));
-                    }
+            // Our nodes in the Markdown document are owned by this arena
+            let arena = Arena::new();
 
-                    if let Some(ref value) = field.value {
-                        to_markdown(value, header_level + 1, arena, document, options)?;
-                    }
+            // The default ComrakOptions disables all extensions (essentially reducing to CommonMark)
+            let options = ComrakOptions::default();
+
+            self.markdown_append(0, &arena, &document, &options);
+            format_commonmark(&document, &options, out)
+                .map_err(|e| Error::IOError(IOError(e.to_string())))?;
+
+            Ok(())
+        }
+
+        /// Recursively walk the given `DocOutput`, recursing into fields, looking for documentation.
+        /// This documentation is then appended to the provided document.
+        fn markdown_append<'a>(
+            &'a self,
+            header_level: u8,
+            arena: &'a Arena<AstNode<'a>>,
+            document: &'a AstNode<'a>,
+            options: &ComrakOptions,
+        ) {
+            let mut entries: Vec<(_, _)> = self.fields.iter().collect();
+            entries.sort_by_key(|(k, _)| *k);
+
+            for (ident, field) in entries {
+                let header = mk_header(ident, header_level + 1, arena);
+                document.append(header);
+
+                if field.types.is_some() || !field.contracts.is_empty() {
+                    document.append(mk_types_and_contracts(
+                        ident,
+                        arena,
+                        field.types.as_deref(),
+                        field.contracts.as_ref(),
+                    ))
+                }
+
+                if let Some(ref doc) = field.documentation {
+                    document.append(parse_markdown_string(header_level + 1, arena, doc, options));
+                }
+
+                if let Some(ref subfields) = field.fields {
+                    subfields.markdown_append(header_level + 1, arena, document, options);
                 }
             }
-            _ => (),
         }
-        Ok(())
     }
 
     /// Parses a string into markdown and increases any headers in the markdown by the specified level.
     /// This allows having headers in documentation without clashing with the structure of the document.
-    fn parse_documentation<'a>(
+    fn parse_markdown_string<'a>(
         header_level: u8,
         arena: &'a Arena<AstNode<'a>>,
         md: &str,
@@ -512,13 +586,64 @@ mod doc {
 
         let code = arena.alloc(AstNode::from(NodeValue::Code(NodeCode {
             num_backticks: 1,
-            literal: ident.bytes().collect(),
+            literal: ident.into(),
         })));
 
         res.append(code);
 
         res
     }
+
+    fn mk_types_and_contracts<'a>(
+        ident: &str,
+        arena: &'a Arena<AstNode<'a>>,
+        types: Option<&'a str>,
+        contracts: &'a [String],
+    ) -> &'a AstNode<'a> {
+        let list = arena.alloc(AstNode::from(NodeValue::List(NodeList {
+            list_type: ListType::Bullet,
+            marker_offset: 1,
+            padding: 0,
+            start: 0,
+            delimiter: ListDelimType::Period,
+            bullet_char: b'*',
+            tight: true,
+        })));
+
+        if let Some(t) = types {
+            list.append(mk_type(ident, ':', t, arena));
+        }
+
+        for contract in contracts {
+            list.append(mk_type(ident, '|', contract, arena));
+        }
+
+        list
+    }
+
+    fn mk_type<'a>(
+        ident: &str,
+        separator: char,
+        types: &str,
+        arena: &'a Arena<AstNode<'a>>,
+    ) -> &'a AstNode<'a> {
+        let list_item = arena.alloc(AstNode::from(NodeValue::Item(NodeList {
+            list_type: ListType::Bullet,
+            marker_offset: 1,
+            padding: 0,
+            start: 0,
+            delimiter: ListDelimType::Period,
+            bullet_char: b'*',
+            tight: true,
+        })));
+
+        list_item.append(arena.alloc(AstNode::from(NodeValue::Code(NodeCode {
+            literal: format!("{ident} {separator} {types}"),
+            num_backticks: 1,
+        }))));
+
+        list_item
+    }
 }
 
 #[cfg(test)]
diff --git a/src/serialize.rs b/src/serialize.rs
index 7741a049..49cbd553 100644
--- a/src/serialize.rs
+++ b/src/serialize.rs
@@ -2,7 +2,7 @@
 use malachite::{num::conversion::traits::RoundingFrom, rounding_modes::RoundingMode};
 
 use crate::{
-    error::SerializationError,
+    error::ExportError,
     term::{
         array::{Array, ArrayAttrs},
         record::RecordData,
@@ -199,20 +199,20 @@ impl<'de> Deserialize<'de> for RichTerm {
 
 /// Check that a term is serializable. Serializable terms are booleans, numbers, strings, enum,
 /// arrays of serializable terms or records of serializable terms.
-pub fn validate(format: ExportFormat, t: &RichTerm) -> Result<(), SerializationError> {
+pub fn validate(format: ExportFormat, t: &RichTerm) -> Result<(), ExportError> {
     use Term::*;
 
     if format == ExportFormat::Raw {
         if let Term::Str(_) = t.term.as_ref() {
             Ok(())
         } else {
-            Err(SerializationError::NotAString(t.clone()))
+            Err(ExportError::NotAString(t.clone()))
         }
     } else {
         match t.term.as_ref() {
             // TOML doesn't support null values
             Null if format == ExportFormat::Json || format == ExportFormat::Yaml => Ok(()),
-            Null => Err(SerializationError::UnsupportedNull(format, t.clone())),
+            Null => Err(ExportError::UnsupportedNull(format, t.clone())),
             Bool(_) | Num(_) | Str(_) | Enum(_) => Ok(()),
             Record(record) => {
                 record.iter_serializable().try_for_each(|binding| {
@@ -227,36 +227,33 @@ pub fn validate(format: ExportFormat, t: &RichTerm) -> Result<(), SerializationE
                 array.iter().try_for_each(|t| validate(format, t))?;
                 Ok(())
             }
-            _ => Err(SerializationError::NonSerializable(t.clone())),
+            _ => Err(ExportError::NonSerializable(t.clone())),
         }
     }
 }
 
-pub fn to_writer<W>(
-    mut writer: W,
-    format: ExportFormat,
-    rt: &RichTerm,
-) -> Result<(), SerializationError>
+pub fn to_writer<W>(mut writer: W, format: ExportFormat, rt: &RichTerm) -> Result<(), ExportError>
 where
     W: io::Write,
 {
     match format {
         ExportFormat::Json => serde_json::to_writer_pretty(writer, &rt)
-            .map_err(|err| SerializationError::Other(err.to_string())),
-        ExportFormat::Yaml => serde_yaml::to_writer(writer, &rt)
-            .map_err(|err| SerializationError::Other(err.to_string())),
+            .map_err(|err| ExportError::Other(err.to_string())),
+        ExportFormat::Yaml => {
+            serde_yaml::to_writer(writer, &rt).map_err(|err| ExportError::Other(err.to_string()))
+        }
         ExportFormat::Toml => toml::to_string_pretty(rt)
-            .map_err(|err| SerializationError::Other(err.to_string()))
+            .map_err(|err| ExportError::Other(err.to_string()))
             .and_then(|s| {
                 writer
                     .write_all(s.as_bytes())
-                    .map_err(|err| SerializationError::Other(err.to_string()))
+                    .map_err(|err| ExportError::Other(err.to_string()))
             }),
         ExportFormat::Raw => match rt.as_ref() {
             Term::Str(s) => writer
                 .write_all(s.as_bytes())
-                .map_err(|err| SerializationError::Other(err.to_string())),
-            t => Err(SerializationError::Other(format!(
+                .map_err(|err| ExportError::Other(err.to_string())),
+            t => Err(ExportError::Other(format!(
                 "raw export requires a `String`, got {}",
                 // unwrap(): terms must be fully evaluated before serialization,
                 // and fully evaluated terms have a definite type.
@@ -266,7 +263,7 @@ where
     }
 }
 
-pub fn to_string(format: ExportFormat, rt: &RichTerm) -> Result<String, SerializationError> {
+pub fn to_string(format: ExportFormat, rt: &RichTerm) -> Result<String, ExportError> {
     let mut buffer: Vec<u8> = Vec::new();
     to_writer(&mut buffer, format, rt)?;
 
diff --git a/src/term/mod.rs b/src/term/mod.rs
index f74ccb04..1908a26b 100644
--- a/src/term/mod.rs
+++ b/src/term/mod.rs
@@ -1284,15 +1284,9 @@ pub enum BinaryOp {
     /// This simply inserts a contract into the array attributes.
     ArrayLazyAssume(),
 
-    /// Apply a dictionary contract to a record. The arguments are a label
-    /// and the contract contained in the dictionary type. Results in a function from
-    /// records to records that applies the dictionary contract to its argument.
-    ///
-    /// When used with a dictionary contract `{_: C}` and a record `R`,
-    /// synthesizes a record contract whose fields are the fields of `R` with the
-    /// contract `C` attached to each of them. Then uses `NAryOp::MergeContract` to
-    /// apply this record contract to `R`.
-    DictionaryAssume(),
+    /// Lazily map contracts over a record. The arguments are a label and a function which takes
+    /// the name of the field as a parameter and returns the corresponding contract.
+    RecordLazyAssume(),
 
     /// Set the message of the current diagnostic of a label.
     LabelWithMessage(),
diff --git a/src/transform/desugar_destructuring.rs b/src/transform/desugar_destructuring.rs
index 101729da..f64807d9 100644
--- a/src/transform/desugar_destructuring.rs
+++ b/src/transform/desugar_destructuring.rs
@@ -154,7 +154,7 @@ fn bind_open_field(x: Ident, pat: &RecordPattern, body: RichTerm) -> RichTerm {
         var,
         matches.iter().fold(Term::Var(x).into(), |x, m| match m {
             Match::Simple(i, _) | Match::Assign(i, _, _) => {
-                op2(DynRemove(), Term::Str(i.to_string().into()), x)
+                op2(DynRemove(), Term::Str((*i).into()), x)
             }
         }),
         body,
diff --git a/src/typecheck/operation.rs b/src/typecheck/operation.rs
index b50d88c8..ac1ad205 100644
--- a/src/typecheck/operation.rs
+++ b/src/typecheck/operation.rs
@@ -382,7 +382,7 @@ pub fn get_bop_type(
         }
         // The first argument is a label, the third is a contract.
         // forall a. Dyn -> {_: a} -> Dyn -> {_: a}
-        BinaryOp::DictionaryAssume() => {
+        BinaryOp::RecordLazyAssume() => {
             let ty_field = UnifType::UnifVar(state.table.fresh_type_var_id());
             let ty_dict = mk_uniftype::dyn_record(ty_field);
             (
diff --git a/stdlib/internals.ncl b/stdlib/internals.ncl
index 51796a1b..b0031ff0 100644
--- a/stdlib/internals.ncl
+++ b/stdlib/internals.ncl
@@ -100,7 +100,7 @@
 
   "$dyn_record" = fun contract label value =>
     if %typeof% value == `Record then
-      %dictionary_assume% (%go_dict% label) value contract
+      %record_lazy_assume% (%go_dict% label) value (fun _field => contract)
     else
       %blame% (%label_with_message% "not a record" label),
 
diff --git a/stdlib/string.ncl b/stdlib/string.ncl
index a6589bb9..20da1819 100644
--- a/stdlib/string.ncl
+++ b/stdlib/string.ncl
@@ -270,7 +270,7 @@
 
     replace_regex: String -> String -> String -> String
     | doc m%"
-      `replace_regex regex repl String` replaces every match of `regex` in `String` with `repl`.
+      `replace_regex regex repl str` replaces every match of `regex` in `str` with `repl`.
 
       For example:
       ```nickel
@@ -279,6 +279,10 @@
         replace_regex "\\d+" "\"a\" is not" "This 37 is a number." =>
           "This \"a\" is not a number."
       ```
+
+      **WARNING**: this function will currently break up Unicode extended
+      grapheme clusters if the regex matches a codepoint that exists within
+      one. This behaviour is expected to change in a future version.
       "%
     = fun pattern replace s =>
        %str_replace_regex% s pattern replace,
@@ -315,13 +319,17 @@
       ["0", "42", "0.5"] |> array.all is_number' =>
         true
       ```
+
+      **WARNING**: this function will currently return `true` even if the
+      regex match overlaps, or happens entirely within, with a Unicode extended
+      grapheme cluster. This behaviour is expected to change in a future version.
       "%
     = fun regex => %str_is_match% regex,
 
     find : String -> String -> {matched: String, index: Number, groups: Array String}
     | doc m%"
-      `find regex String` matches `String` given `regex`. Results in the part of `String` that matched, the index of the
-      first character that was part of the match in `String`, and a arrays of all capture groups if any.
+      `find regex str` matches `str` given `regex`. Results in the part of `str` that matched, the index of the
+      first character that was part of the match in `str`, and a arrays of all capture groups if any.
 
       For example:
       ```nickel
@@ -334,6 +342,11 @@
       Note that this function may perform better by sharing its partial application between multiple calls,
       because in this case the underlying regular expression will only be compiled once (See the documentation
       of `string.is_match` for more details).
+
+      WARNING: this function will currently return incorrect indices if `str`
+      contains Unicode extended grapheme clusters. The indices returned will
+      be the location of the matching codepoints, rather than the matching
+      grapheme clusters.
       "%
     = fun regex => %str_find% regex,
 


Test patch:
diff --git a/tests/snapshot/inputs/docs/function.ncl b/tests/snapshot/inputs/docs/function.ncl
new file mode 100644
index 00000000..a554af3b
--- /dev/null
+++ b/tests/snapshot/inputs/docs/function.ncl
@@ -0,0 +1,1 @@
+fun x => x
diff --git a/tests/snapshot/inputs/docs/record.ncl b/tests/snapshot/inputs/docs/record.ncl
new file mode 100644
index 00000000..c521e998
--- /dev/null
+++ b/tests/snapshot/inputs/docs/record.ncl
@@ -0,0 +1,4 @@
+{
+  Hello
+    | doc "World!"
+}
diff --git a/tests/snapshot/inputs/docs/types.ncl b/tests/snapshot/inputs/docs/types.ncl
new file mode 100644
index 00000000..369343ce
--- /dev/null
+++ b/tests/snapshot/inputs/docs/types.ncl
@@ -0,0 +1,6 @@
+{
+  Hello
+    : Number
+    | string.Stringable
+    | doc "World!"
+}
diff --git a/tests/snapshot/main.rs b/tests/snapshot/main.rs
index 0bcc27b4..854ba777 100644
--- a/tests/snapshot/main.rs
+++ b/tests/snapshot/main.rs
@@ -5,6 +5,20 @@ use std::{
 };
 use test_generator::test_resources;
 
+macro_rules! assert_snapshot_filtered {
+    { $name:expr, $snapshot:expr } => {
+        insta::with_settings!({filters => vec![
+            // Since error output includes fully-qualified paths to the source file
+            // we need to replace those with something static to avoid snapshots
+            // differing across machines.
+            (r"(?:/.+/tests/snapshot/inputs)", "[INPUTS_PATH]")
+        ]},
+        {
+            insta::assert_snapshot!($name, $snapshot);
+        })
+    }
+}
+
 #[test_resources("tests/snapshot/inputs/pretty/*.ncl")]
 fn check_pretty_print_snapshots(file: &str) {
     let file = TestFile::from_project_path(file);
@@ -14,7 +28,7 @@ fn check_pretty_print_snapshots(file: &str) {
         .file(&file)
         .snapshot_stdout();
 
-    insta::assert_snapshot!(file.prefixed_test_name("pretty"), snapshot)
+    insta::assert_snapshot!(file.prefixed_test_name("pretty"), snapshot);
 }
 
 #[test_resources("tests/snapshot/inputs/export/*.ncl")]
@@ -46,16 +60,33 @@ fn check_error_snapshots(file: &str) {
     let file = TestFile::from_project_path(file);
 
     let snapshot = NickelInvocation::new().file(&file).snapshot_stderr();
+    assert_snapshot_filtered!(file.prefixed_test_name("error"), snapshot);
+}
 
-    insta::with_settings!({filters => vec![
-        // Since error output includes fully-qualified paths to the source file
-        // we need to replace those with something static to avoid snapshots
-        // differing across machines.
-        (r"(?:/.+/tests/snapshot/inputs)", "[INPUTS_PATH]")
-    ]},
-    {
-        insta::assert_snapshot!(file.prefixed_test_name("error"), snapshot)
-    });
+#[test_resources("tests/snapshot/inputs/docs/*.ncl")]
+fn check_doc_stdout_snapshots(file: &str) {
+    let file = TestFile::from_project_path(file);
+
+    let snapshot = NickelInvocation::new()
+        .subcommand("doc")
+        .file(&file)
+        .args(["--stdout"])
+        .snapshot_stdout();
+
+    insta::assert_snapshot!(file.prefixed_test_name("doc_stdout"), snapshot);
+}
+
+#[test_resources("tests/snapshot/inputs/docs/*.ncl")]
+fn check_doc_stderr_snapshots(file: &str) {
+    let file = TestFile::from_project_path(file);
+
+    let snapshot = NickelInvocation::new()
+        .subcommand("doc")
+        .file(&file)
+        .args(["--stdout"])
+        .snapshot_stderr();
+
+    assert_snapshot_filtered!(file.prefixed_test_name("doc_stderr"), snapshot);
 }
 
 struct TestFile {
@@ -108,6 +139,15 @@ impl NickelInvocation {
         self
     }
 
+    fn args<I, S>(&mut self, args: I) -> &mut Self
+    where
+        I: IntoIterator<Item = S>,
+        S: AsRef<OsStr>,
+    {
+        self.cmd.args(args);
+        self
+    }
+
     fn run(&mut self) -> Output {
         self.cmd.output().expect("Should be able to capture output")
     }
diff --git a/tests/snapshot/snapshots/snapshot__doc_stderr_function.ncl.snap b/tests/snapshot/snapshots/snapshot__doc_stderr_function.ncl.snap
new file mode 100644
index 00000000..fb5c57d5
--- /dev/null
+++ b/tests/snapshot/snapshots/snapshot__doc_stderr_function.ncl.snap
@@ -0,0 +1,13 @@
+---
+source: tests/snapshot/main.rs
+expression: snapshot
+---
+error: no documentation found
+  ┌─ [INPUTS_PATH]/docs/function.ncl:1:1
+  │
+1 │ fun x => x
+  │ ^^^^^^^^^^
+  │
+  = documentation can only be collected from records
+
+
diff --git a/tests/snapshot/snapshots/snapshot__doc_stderr_record.ncl.snap b/tests/snapshot/snapshots/snapshot__doc_stderr_record.ncl.snap
new file mode 100644
index 00000000..b5a2b9aa
--- /dev/null
+++ b/tests/snapshot/snapshots/snapshot__doc_stderr_record.ncl.snap
@@ -0,0 +1,5 @@
+---
+source: tests/snapshot/main.rs
+expression: snapshot
+---
+
diff --git a/tests/snapshot/snapshots/snapshot__doc_stderr_types.ncl.snap b/tests/snapshot/snapshots/snapshot__doc_stderr_types.ncl.snap
new file mode 100644
index 00000000..b5a2b9aa
--- /dev/null
+++ b/tests/snapshot/snapshots/snapshot__doc_stderr_types.ncl.snap
@@ -0,0 +1,5 @@
+---
+source: tests/snapshot/main.rs
+expression: snapshot
+---
+
diff --git a/tests/snapshot/snapshots/snapshot__doc_stdout_function.ncl.snap b/tests/snapshot/snapshots/snapshot__doc_stdout_function.ncl.snap
new file mode 100644
index 00000000..b5a2b9aa
--- /dev/null
+++ b/tests/snapshot/snapshots/snapshot__doc_stdout_function.ncl.snap
@@ -0,0 +1,5 @@
+---
+source: tests/snapshot/main.rs
+expression: snapshot
+---
+
diff --git a/tests/snapshot/snapshots/snapshot__doc_stdout_record.ncl.snap b/tests/snapshot/snapshots/snapshot__doc_stdout_record.ncl.snap
new file mode 100644
index 00000000..8fee3707
--- /dev/null
+++ b/tests/snapshot/snapshots/snapshot__doc_stdout_record.ncl.snap
@@ -0,0 +1,8 @@
+---
+source: tests/snapshot/main.rs
+expression: snapshot
+---
+# `Hello`
+
+World\!
+
diff --git a/tests/snapshot/snapshots/snapshot__doc_stdout_types.ncl.snap b/tests/snapshot/snapshots/snapshot__doc_stdout_types.ncl.snap
new file mode 100644
index 00000000..0f4db2a9
--- /dev/null
+++ b/tests/snapshot/snapshots/snapshot__doc_stdout_types.ncl.snap
@@ -0,0 +1,10 @@
+---
+source: tests/snapshot/main.rs
+expression: snapshot
+---
+# `Hello`
+
+- `Hello : Number`
+- `Hello | string.Stringable`
+World\!
+