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

Add a JSON documentation export option
This is to facilitate better standard library documentation formatting on the website.


Patch:
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..e4a04e33 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,128 @@ 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::{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 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,
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),
 


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/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_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\!
+