Repository: tweag/nickel
Pull request #1949 (2024-06-10 16:27:43)

Add span information for TOML imports
This PR uses a feature of the `toml` crate allowing to embed span information into the deserialized data (imported from Nickel). This makes it possible to give nice error messages and point to within a TOML file when the application of a Nickel contract to an existing TOML configuration fails, or in case of non-mergeable values.

Doing so requires a bit of boilerplate and brings an additional crate in (although small). It can also impact deserialization performance, because we need to first deserialize in an intermediate TOML-like datastructure. For those reasons, spanned deserialization has been gated by the `spanned-deser` feature, which is enabled by default, but can be disabled if this isn't useful.


Patch:
diff --git a/Cargo.lock b/Cargo.lock
index 6bfb67e1..bc35061b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -873,6 +873,16 @@ version = "1.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5"
 
+[[package]]
+name = "erased-serde"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24e2389d65ab4fab27dc2a5de7b191e1f6617d1f1c8855c0dc569c94a4cbb18d"
+dependencies = [
+ "serde",
+ "typeid",
+]
+
 [[package]]
 name = "errno"
 version = "0.3.8"
@@ -1649,7 +1659,7 @@ dependencies = [
 
 [[package]]
 name = "nickel-lang-cli"
-version = "1.6.0"
+version = "1.7.0"
 dependencies = [
  "clap 4.5.2",
  "clap_complete",
@@ -1667,7 +1677,7 @@ dependencies = [
 
 [[package]]
 name = "nickel-lang-core"
-version = "0.7.0"
+version = "0.8.0"
 dependencies = [
  "ansi_term",
  "assert_matches",
@@ -1698,6 +1708,7 @@ dependencies = [
  "rustyline",
  "rustyline-derive",
  "serde",
+ "serde-untagged",
  "serde-wasm-bindgen",
  "serde_json",
  "serde_repr",
@@ -1722,7 +1733,7 @@ dependencies = [
 
 [[package]]
 name = "nickel-lang-lsp"
-version = "1.6.0"
+version = "1.7.0"
 dependencies = [
  "anyhow",
  "assert_cmd",
@@ -1770,7 +1781,7 @@ dependencies = [
 
 [[package]]
 name = "nickel-wasm-repl"
-version = "0.7.0"
+version = "0.8.0"
 dependencies = [
  "nickel-lang-core",
 ]
@@ -2121,7 +2132,7 @@ dependencies = [
 
 [[package]]
 name = "pyckel"
-version = "1.6.0"
+version = "1.7.0"
 dependencies = [
  "codespan-reporting",
  "nickel-lang-core",
@@ -2460,6 +2471,17 @@ dependencies = [
  "serde_derive",
 ]
 
+[[package]]
+name = "serde-untagged"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2676ba99bd82f75cae5cbd2c8eda6fa0b8760f18978ea840e980dd5567b5c5b6"
+dependencies = [
+ "erased-serde",
+ "serde",
+ "typeid",
+]
+
 [[package]]
 name = "serde-wasm-bindgen"
 version = "0.5.0"
@@ -2506,9 +2528,9 @@ dependencies = [
 
 [[package]]
 name = "serde_spanned"
-version = "0.6.5"
+version = "0.6.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eb3622f419d1296904700073ea6cc23ad690adbd66f13ea683df73298736f0c1"
+checksum = "79e674e01f999af37c49f70a6ede167a8a60b2503e56c5599532a65baa5969a0"
 dependencies = [
  "serde",
 ]
@@ -2983,9 +3005,9 @@ dependencies = [
 
 [[package]]
 name = "toml"
-version = "0.7.8"
+version = "0.8.14"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dd79e69d3b627db300ff956027cc6c3798cef26d22526befdfcd12feeb6d2257"
+checksum = "6f49eb2ab21d2f26bd6db7bf383edc527a7ebaee412d17af4d40fdccd442f335"
 dependencies = [
  "serde",
  "serde_spanned",
@@ -2995,18 +3017,18 @@ dependencies = [
 
 [[package]]
 name = "toml_datetime"
-version = "0.6.5"
+version = "0.6.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3550f4e9685620ac18a50ed434eb3aec30db8ba93b0287467bca5826ea25baf1"
+checksum = "4badfd56924ae69bcc9039335b2e017639ce3f9b001c393c1b2d1ef846ce2cbf"
 dependencies = [
  "serde",
 ]
 
 [[package]]
 name = "toml_edit"
-version = "0.19.15"
+version = "0.22.14"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
+checksum = "f21c7aaf97f1bd9ca9d4f9e73b0a6c74bd5afef56f2bc931943a6e1c37e04e38"
 dependencies = [
  "indexmap 2.2.5",
  "serde",
@@ -3091,6 +3113,12 @@ version = "2.0.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a"
 
+[[package]]
+name = "typeid"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "059d83cc991e7a42fc37bd50941885db0888e34209f8cfd9aab07ddec03bc9cf"
+
 [[package]]
 name = "typenum"
 version = "1.17.0"
@@ -3484,9 +3512,9 @@ checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8"
 
 [[package]]
 name = "winnow"
-version = "0.5.40"
+version = "0.6.11"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876"
+checksum = "56c52728401e1dc672a56e81e593e912aa54c78f40246869f78359a2bf24d29d"
 dependencies = [
  "memchr",
 ]
diff --git a/Cargo.toml b/Cargo.toml
index c3e07dc9..e53f1fb3 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -11,7 +11,7 @@ members = [
 resolver = "2"
 
 [workspace.package]
-version = "1.6.0"
+version = "1.7.0"
 authors = ["The Nickel Team <nickel-lang@protonmail.com>"]
 license = "MIT"
 edition = "2021"
@@ -21,7 +21,7 @@ homepage = "https://nickel-lang.org"
 readme = "README.md"
 
 [workspace.dependencies]
-nickel-lang-core = { version = "0.7.0", path = "./core", default-features = false }
+nickel-lang-core = { version = "0.8.0", path = "./core", default-features = false }
 nickel-lang-utils = { version = "0.1.0", path = "./utils" }
 lsp-harness = { version = "0.1.0", path = "./lsp/lsp-harness" }
 
@@ -82,6 +82,7 @@ serde_json = "1.0.96"
 serde_repr = "0.1"
 serde-wasm-bindgen = "0.5.0"
 serde_yaml = "0.9.19"
+serde-untagged = "0.1"
 sha-1 = "0.10.0"
 sha2 = "0.10.6"
 similar = "2.2.1"
@@ -90,7 +91,7 @@ strip-ansi-escapes = "0.2.0"
 termimad = "0.23.1"
 test-generator = "0.3.1"
 thiserror = "1.0.44"
-toml = "0.7.2"
+toml = "0.8"
 typed-arena = "2.0.2"
 unicode-segmentation = "1.10.1"
 void = "1"
diff --git a/RELEASES.md b/RELEASES.md
index 01868cba..9d194724 100644
--- a/RELEASES.md
+++ b/RELEASES.md
@@ -1,3 +1,85 @@
+Version 1.7 (2024-06-11)
+========================
+
+Nickel 1.7 ships with several extensions to pattern matching which was
+introduced in Nickel 1.5 but had a number of limitations. See below for further details.
+
+Additionally, the publication of [Topiary](https://topiary.tweag.io/) to the
+crates registry makes it so that all versions of Nickel from 1.7 and onward,
+however they are built, ship with `nickel format` and the language server
+formatting command working out of the box without needing a separate
+installation of Topiary (which was previously required when installing from
+crates.io using `cargo install nickel-lang-cli` or `cargo install
+nickel-lang-lsp`).
+
+Breaking changes
+----------------
+
+* Although primitive operators are internal and aren't officially part of any
+    stability guarantee, some libraries sometimes need to resort to using them,
+    when there's no equivalent in the stdlib. Nickel 1.7 had a big primop
+    refactoring, amounting mostly to renaming. If you're using primops, please
+    look at the corresponding [pull
+    request](https://github.com/tweag/nickel/pull/1937)
+* The behavior of destructuring has been changed to match exactly the behavior
+    of pattern matching. While there should be no difference on well-behaving
+    programs, this change makes destructuring stricter and can lead to some
+    programs that were running fine before 1.7 to now fail with `unmatched
+    pattern`. The typical example is when destructuring a record with a field
+    that is not present: `let {x,y, ..} = import "lib.ncl" in`. If `x` is absent
+    from the `lib.ncl` but is never used anywhere, this used to work fine before
+    1.7 (the error would only trigger upon usage of `x`) but will now fail eagerly.
+
+    If you need to fix a large codebase with long import destructuring chains
+    and you don't know which fields are the offending ones, one possible
+    technique is to open the importing file in an editor and use the `goto
+    definition` command of the NLS on each field of a faulty
+    pattern. If it works, the field is present, but if it doesn't, the field
+    might be faulty.
+
+Core language
+-------------
+
+* Patterns now support constant values (`1`, `null`, `"a"`, etc.), array
+    patterns (`[fst, snd, ..tail]`), pattern guards (`'Foo x if std.is_number
+    x`), wildcard patterns (`_` placeholder) and or-patterns (`('Foo x) or ('Bar
+    x) or ('Baz x)`): (see the syntax section on of the manual for more details)
+    * Implement wildcard patterns by @yannham in https://github.com/tweag/nickel/pull/1904
+    * Implement constant patterns by @yannham in https://github.com/tweag/nickel/pull/1897
+    * Implement pattern guards by @yannham in https://github.com/tweag/nickel/pull/1910
+    * Implement array patterns by @yannham in https://github.com/tweag/nickel/pull/1912
+    * Implement or-patterns by @yannham in https://github.com/tweag/nickel/pull/1916
+* Uniformize destruct and pattern matching logic by @yannham in https://github.com/tweag/nickel/pull/1907
+* Opaque values by @jneem in https://github.com/tweag/nickel/pull/1913
+
+Stdlib
+------
+
+* Add `record.get_or` to get value from record supplying default value by @olorin37 in https://github.com/tweag/nickel/pull/1920
+* [Fix] Avoid `record.get_or` failing on field without definition by @yannham in https://github.com/tweag/nickel/pull/1946
+* Add stdlib function `array.at_or` by @olorin37 in https://github.com/tweag/nickel/pull/1927
+* Add `std.enum.(from/to)_tag_and_arg` and `std.enum.map` to dynamically decompose and recompose an enum by @yannham in https://github.com/tweag/nickel/pull/1939
+
+Documentation
+-------------
+
+* Fix typo in BlameError documentation by @ErinvanderVeen in https://github.com/tweag/nickel/pull/1899
+* Update README.md for nix profile install by @cloudyluna in https://github.com/tweag/nickel/pull/1918
+
+LSP
+---
+
+* Tell NLS about variable bindings in match statements by @jneem in https://github.com/tweag/nickel/pull/1926
+* Add --version support to NLS and fix feature unification issues by @yannham in https://github.com/tweag/nickel/pull/1936
+* Fix NLS crash and better refresh diagnostics by @jneem in https://github.com/tweag/nickel/pull/1944
+
+Tooling
+-------
+
+* Include field path in non serializable error by @yannham in https://github.com/tweag/nickel/pull/1905
+* Allow single input to `nickel xxx` command to be JSON, YAML or TOML as well by @olorin37 in https://github.com/tweag/nickel/pull/1902
+* Use Topiary's published crates over git to always enable formatting by @ErinvanderVeen in https://github.com/tweag/nickel/pull/1919
+
 Version 1.6 (2024-04-25)
 ========================
 
diff --git a/cli/Cargo.toml b/cli/Cargo.toml
index d0c639b6..5304f182 100644
--- a/cli/Cargo.toml
+++ b/cli/Cargo.toml
@@ -16,10 +16,11 @@ path = "src/main.rs"
 bench = false
 
 [features]
-default = ["repl", "doc", "format"]
+default = ["repl", "doc", "format", "spanned-deser"]
 repl = ["nickel-lang-core/repl"]
 doc = ["nickel-lang-core/doc"]
 format = ["nickel-lang-core/format", "dep:tempfile"]
+spanned-deser = ["nickel-lang-core/spanned-deser"]
 metrics = ["dep:metrics", "dep:metrics-util", "nickel-lang-core/metrics"]
 
 [dependencies]
diff --git a/core/Cargo.toml b/core/Cargo.toml
index 806dfc02..de696370 100644
--- a/core/Cargo.toml
+++ b/core/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "nickel-lang-core"
-version = "0.7.0"
+version = "0.8.0"
 description = "Programmable configuration files."
 authors.workspace = true
 edition.workspace = true
@@ -19,7 +19,7 @@ links = "nix"
 bench = false
 
 [features]
-default = ["markdown", "repl", "doc", "format"]
+default = ["markdown", "repl", "doc", "format", "spanned-deser"]
 markdown = ["dep:termimad"]
 repl = ["dep:rustyline", "dep:rustyline-derive", "dep:ansi_term"]
 repl-wasm = ["dep:wasm-bindgen", "dep:js-sys", "dep:serde_repr"]
@@ -27,6 +27,7 @@ doc = ["dep:comrak"]
 format = ["dep:topiary-core", "dep:topiary-queries", "dep:tree-sitter-nickel"]
 metrics = ["dep:metrics"]
 nix-experimental = [ "dep:cxx", "dep:cxx-build", "dep:pkg-config" ]
+spanned-deser = ["dep:serde-untagged"]
 
 [build-dependencies]
 lalrpop.workspace = true
@@ -45,6 +46,7 @@ logos.workspace = true
 serde = { workspace = true, features = ["derive"] }
 serde_json.workspace = true
 serde_yaml.workspace = true
+serde-untagged = { workspace = true, optional = true }
 toml = { workspace = true, features = ["parse"] }
 void.workspace = true
 sha-1.workspace = true
diff --git a/core/src/cache.rs b/core/src/cache.rs
index b406127d..e5f962f3 100644
--- a/core/src/cache.rs
+++ b/core/src/cache.rs
@@ -575,9 +575,11 @@ impl Cache {
                     ))
                 }
             }
-            InputFormat::Toml => toml::from_str(self.files.source(file_id))
-                .map(|t| (attach_pos(t), ParseErrors::default()))
-                .map_err(|err| (ParseError::from_toml(err, file_id))),
+            InputFormat::Toml => {
+                crate::serialize::toml_deser::from_str(self.files.source(file_id), file_id)
+                    .map(|t| (attach_pos(t), ParseErrors::default()))
+                    .map_err(|err| (ParseError::from_toml(err, file_id)))
+            }
             #[cfg(feature = "nix-experimental")]
             InputFormat::Nix => {
                 let json = nix_ffi::eval_to_json(self.files.source(file_id))
diff --git a/core/src/position.rs b/core/src/position.rs
index 843cc129..499787a2 100644
--- a/core/src/position.rs
+++ b/core/src/position.rs
@@ -53,6 +53,19 @@ impl RawSpan {
         }
     }
 
+    /// Create a span from a numeric range. If either start or end is too large to be represented,
+    /// `u32::MAX` is used instead.
+    pub fn from_range<T>(src_id: FileId, range: std::ops::Range<T>) -> Self
+    where
+        u32: TryFrom<T>,
+    {
+        RawSpan {
+            src_id,
+            start: ByteIndex(u32::try_from(range.start).unwrap_or(u32::MAX)),
+            end: ByteIndex(u32::try_from(range.end).unwrap_or(u32::MAX)),
+        }
+    }
+
     /// Return the start of this range.
     pub fn start_pos(&self) -> RawPos {
         RawPos {
diff --git a/core/src/serialize.rs b/core/src/serialize.rs
index c56bfdfa..ee147858 100644
--- a/core/src/serialize.rs
+++ b/core/src/serialize.rs
@@ -15,7 +15,10 @@ use serde::{
 };
 
 use malachite::{
-    num::conversion::traits::{IsInteger, RoundingFrom},
+    num::{
+        basic::floats::PrimitiveFloat,
+        conversion::traits::{IsInteger, RoundingFrom},
+    },
     rounding_modes::RoundingMode,
 };
 use once_cell::sync::Lazy;
@@ -82,17 +85,29 @@ where
         .serialize(serializer)
 }
 
-/// Deserialize a Nickel number. As for parsing, we convert the number from a 64bits float
+/// Helper function to convert a serialized primitive float to a Nickel number. Return a deserialize error if
+/// the floating point value is either NaN or infinity.
+fn number_from_float<F: PrimitiveFloat, E: serde::de::Error>(float_value: F) -> Result<Number, E>
+where
+    Number: TryFrom<
+        F,
+        Error = malachite_q::conversion::from_primitive_float::RationalFromPrimitiveFloatError,
+    >,
+{
+    Number::try_from_float_simplest(float_value).map_err(|_| {
+        E::custom(format!(
+            "couldn't convert {float_value} to a Nickel number: Nickel doesn't support NaN nor infinity"
+        ))
+    })
+}
+
+/// Deserialize a Nickel number. As for parsing, we convert the number from a 64bits float.
 pub fn deserialize_num<'de, D>(deserializer: D) -> Result<Number, D::Error>
 where
     D: Deserializer<'de>,
 {
     let as_f64 = f64::deserialize(deserializer)?;
-    Number::try_from_float_simplest(as_f64).map_err(|_| {
-        serde::de::Error::custom(format!(
-            "couldn't convert {as_f64} to a Nickel number: Nickel doesn't support NaN nor infinity"
-        ))
-    })
+    number_from_float::<f64, D::Error>(as_f64)
 }
 
 /// Serializer for annotated values.
@@ -392,6 +407,186 @@ pub fn to_string(format: ExportFormat, rt: &RichTerm) -> Result<String, ExportEr
     Ok(String::from_utf8_lossy(&buffer).into_owned())
 }
 
+/// TOML deserialization wrappers. Depending on the `spanned-deser` feature being
+/// enabled, [::toml::from_str] will either simply call [toml::from_str] or a custom
+/// deserializer that preserves span information.
+pub mod toml_deser {
+    use super::PrimitiveFloat;
+    use crate::term::RichTerm;
+    use codespan::FileId;
+
+    /// Utility module for spanned deserialization which preserves position information for some
+    /// parsers that support it. Although we haven't measured the effect, it's likely that
+    /// including span information affects performance and binary size (by pulling in alternate
+    /// parsers supporting spanned deserialization or using YAML parser for JSON deserialization),
+    /// so we gate this functionality behind the `spanned-deser` feature flag. It's still
+    /// enabled by default, being useful for error reporting, but can be disabled when building a
+    /// lightweight version of the interpreter e.g. to be embedded in a production environment.
+    #[cfg(feature = "spanned-deser")]
+    pub mod spanned {
+        use super::*;
+
+        use crate::{
+            identifier::LocIdent,
+            position::RawSpan,
+            term::{record::RecordData, string::NickelString, Number, Term},
+        };
+
+        use serde::{
+            de::{Deserializer, MapAccess},
+            Deserialize,
+        };
+        use serde_untagged::UntaggedEnumVisitor;
+
+        use toml::Spanned;
+
+        /// The inner data of a TOML value with span information. Similar to
+        /// `toml::Spanned<toml::Value>`, but where atoms use Nickel types instead (`NickelString`,
+        /// `Number`, etc)
+        #[derive(Debug, Clone)]
+        pub enum SpannedValueData {
+            String(NickelString),
+            Number(Number),
+            Boolean(bool),
+            Array(Vec<SpannedValue>),
+            /// Since we are going to iterate through the map and collect it in a new data
+            /// structure to fit Nickel's record representation anyway, we store maps as vectors of
+            /// entries instead of building an actual map.
+            Map(Vec<(Spanned<String>, SpannedValue)>),
+        }
+
+        /// A TOML value with span information.
+        ///
+        /// After some experimentation, it seems that in order to properly trigger spanned
+        /// deserialization in the `toml` crate, we need to write the deserializers for both
+        /// [SpannedValueData] and [SpannedValue] manually, although they are pretty mechanical and
+        /// should in principle be similar to what `derive(Deserialize)` would generate. However,
+        /// the latter doesn't work in practice (giving "data did not match any variant of untagged
+        /// enum" errors when importing TOML).
+        #[derive(Debug, Clone)]
+        pub struct SpannedValue(Spanned<SpannedValueData>);
+
+        impl<'de> Deserialize<'de> for SpannedValue {
+            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+            where
+                D: Deserializer<'de>,
+            {
+                let spanned: Spanned<SpannedValueData> = Deserialize::deserialize(deserializer)?;
+                Ok(SpannedValue(spanned))
+            }
+        }
+
+        impl<'de> Deserialize<'de> for SpannedValueData {
+            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+            where
+                D: Deserializer<'de>,
+            {
+                fn deser_int<I: Into<Number>, E>(n: I) -> Result<SpannedValueData, E> {
+                    Ok(SpannedValueData::Number(n.into()))
+                }
+
+                fn deser_float<F : PrimitiveFloat>(f: F) -> Result<SpannedValueData, serde_untagged::de::Error>
+                where Number: TryFrom<F, Error = malachite_q::conversion::from_primitive_float::RationalFromPrimitiveFloatError>{
+                    let as_number =
+                        crate::serialize::number_from_float::<F, serde_untagged::de::Error>(f)?;
+                    Ok(SpannedValueData::Number(as_number))
+                }
+
+                let data = UntaggedEnumVisitor::new()
+                    .string(|str| Ok(SpannedValueData::String(str.into())))
+                    .i8(deser_int)
+                    .i16(deser_int)
+                    .i32(deser_int)
+                    .i64(deser_int)
+                    .i128(deser_int)
+                    .u8(deser_int)
+                    .u16(deser_int)
+                    .u32(deser_int)
+                    .u64(deser_int)
+                    .u128(deser_int)
+                    .f32(deser_float)
+                    .f64(deser_float)
+                    .bool(|b| Ok(SpannedValueData::Boolean(b)))
+                    .seq(|seq| {
+                        let v: Result<Vec<SpannedValue>, serde_untagged::de::Error> =
+                            seq.deserialize();
+                        Ok(SpannedValueData::Array(v?))
+                    })
+                    .map(|mut map| {
+                        let mut result = if let Some(hint) = map.size_hint() {
+                            Vec::with_capacity(hint)
+                        } else {
+                            Vec::new()
+                        };
+
+                        while let Some((k, v)) =
+                            map.next_entry::<Spanned<String>, SpannedValue>()?
+                        {
+                            result.push((k, v));
+                        }
+
+                        Ok(SpannedValueData::Map(result))
+                    })
+                    .deserialize(deserializer)?;
+
+                Ok(data)
+            }
+        }
+
+        impl SpannedValue {
+            /// Take the id of the current file and convert a spanned value to a Nickel term with
+            /// position information properly set.
+            pub fn into_term(self, file_id: FileId) -> RichTerm {
+                let span = self.0.span();
+
+                let term = match self.0.into_inner() {
+                    SpannedValueData::String(s) => Term::Str(s),
+                    SpannedValueData::Number(n) => Term::Num(n),
+                    SpannedValueData::Boolean(b) => Term::Bool(b),
+                    SpannedValueData::Array(v) => Term::Array(
+                        v.into_iter().map(|v| v.into_term(file_id)).collect(),
+                        Default::default(),
+                    ),
+                    SpannedValueData::Map(m) => {
+                        let data = m
+                            .into_iter()
+                            .map(|(k, v)| {
+                                let key_span = k.span();
+                                let ident = LocIdent::new_with_pos(
+                                    k.into_inner(),
+                                    RawSpan::from_range(file_id, key_span).into(),
+                                );
+
+                                (ident, v.into_term(file_id).into())
+                            })
+                            .collect();
+
+                        Term::Record(RecordData {
+                            fields: data,
+                            ..Default::default()
+                        })
+                    }
+                };
+
+                RichTerm::new(term, RawSpan::from_range(file_id, span).into())
+            }
+        }
+    }
+
+    /// Deserialize a Nickel term with position information from a TOML source provided as a
+    /// string and the file id of this source.
+    #[cfg(feature = "spanned-deser")]
+    pub fn from_str(s: &str, file_id: FileId) -> Result<RichTerm, toml::de::Error> {
+        let spanned: spanned::SpannedValue = toml::from_str(s)?;
+        Ok(spanned.into_term(file_id))
+    }
+
+    #[cfg(not(feature = "spanned-deser"))]
+    pub fn from_str(s: &str, _file_id: FileId) -> Result<RichTerm, toml::de::Error> {
+        toml::from_str(s)
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
diff --git a/lsp/nls/Cargo.toml b/lsp/nls/Cargo.toml
index 7abf9236..8ddee0d9 100644
--- a/lsp/nls/Cargo.toml
+++ b/lsp/nls/Cargo.toml
@@ -40,7 +40,12 @@ lazy_static.workspace = true
 log.workspace = true
 lsp-server.workspace = true
 lsp-types.workspace = true
-nickel-lang-core = {workspace = true, default-features = false}
+# We enable the `spanned-deser` feature, anticipating the fact that NLS can be
+# used to open standard JSON or YAML files with a convention to know which
+# contract they should follow, e.g. with an environment variable or just the
+# filesystem layout, in which case we will be able to show Nickel diagnostics
+# directly inside a TOML or a YAML file. It's not used as of yet, though.
+nickel-lang-core = {workspace = true, default-features = false, features = ["spanned-deser"]}
 regex.workspace = true
 scopeguard.workspace = true
 serde = { workspace = true, features = ["derive"] }
diff --git a/lsp/vscode-extension/yarn.lock b/lsp/vscode-extension/yarn.lock
index d39ce84a..8e8bd1e9 100644
--- a/lsp/vscode-extension/yarn.lock
+++ b/lsp/vscode-extension/yarn.lock
@@ -483,11 +483,11 @@ brace-expansion@^2.0.1:
     balanced-match "^1.0.0"
 
 braces@^3.0.2, braces@~3.0.2:
-  version "3.0.2"
-  resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
-  integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789"
+  integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==
   dependencies:
-    fill-range "^7.0.1"
+    fill-range "^7.1.1"
 
 browser-stdout@1.3.1:
   version "1.3.1"
@@ -1012,10 +1012,10 @@ file-entry-cache@^6.0.1:
   dependencies:
     flat-cache "^3.0.4"
 
-fill-range@^7.0.1:
-  version "7.0.1"
-  resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40"
-  integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==
+fill-range@^7.1.1:
+  version "7.1.1"
+  resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292"
+  integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==
   dependencies:
     to-regex-range "^5.0.1"
 
diff --git a/scripts/release.sh b/scripts/release.sh
index e235dffb..f061335f 100755
--- a/scripts/release.sh
+++ b/scripts/release.sh
@@ -416,10 +416,16 @@ for crate in "${crates_to_publish[@]}"; do
     cleanup_actions+=('git restore '"$crate/Cargo.toml")
 done
 
+for crate in "${crates_to_publish[@]}"; do
+    # Stage the modifications of the previous processing steps
+    git add "$crate/Cargo.toml"
+    cleanup_actions+=('git reset -- '"$crate/Cargo.toml")
+done
+
 # Cargo requires to commit changes, but the last changes are temporary
 # work-arounds for the crates.io release that aren't supposed to stay. we'll
 # reset them later.
-git commit -m "[release.sh][tmp] remove nickel-lang-utils from deps"
+git commit -m "[release.sh][tmp] clean unpublished crates from dev-dependencies"
 cleanup_actions+=("git reset --hard HEAD~")
 
 # We have had reproducibility issues before due to the fact that when installing
@@ -442,24 +448,16 @@ cargo install --force --path ./lsp/nls
 
 git restore ./Cargo.lock
 
-report_progress "Successfully installed locally. Trying a dry run of cargo publish 'nickel-lang-core'"
-
-cargo publish -p nickel-lang-core --dry-run
-confirm_proceed "  -- Dry run successful. Proceed with actual publication of 'nickel-lang-core' to crates.io ?"
+report_progress "Successfully installed locally."
+confirm_proceed "Proceed with publication of 'nickel-lang-core' to crates.io ?"
 cargo publish -p nickel-lang-core
 
 report_progress "'nickel-lang-core' published successfully"
-report_progress "Trying a dry run of cargo publish 'nickel-lang-cli'"
-
-cargo publish -p nickel-lang-cli --dry-run
-confirm_proceed "  -- Dry run successful. Proceed with actual publication of 'nickel-lang-cli' to crates.io ?"
+confirm_proceed "Proceed with publication of 'nickel-lang-cli' to crates.io ?"
 cargo publish -p nickel-lang-cli
 
 report_progress "'nickel-lang-cli' published successfully"
-report_progress "Trying a dry run of cargo publish 'nickel-lang-lsp'"
-
-cargo publish -p nickel-lang-lsp --dry-run
-confirm_proceed "  -- Dry run successful. Proceed with actual publication of 'nickel-lang-lsp' to crates.io ?"
+confirm_proceed "Proceed with publication of 'nickel-lang-lsp' to crates.io ?"
 cargo publish -p nickel-lang-lsp
 
 report_progress "'nickel-lang-lsp' published successfully"
diff --git a/wasm-repl/Cargo.toml b/wasm-repl/Cargo.toml
index 8f88adc3..e277d4f7 100644
--- a/wasm-repl/Cargo.toml
+++ b/wasm-repl/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "nickel-wasm-repl"
-version = "0.7.0"
+version = "0.8.0"
 description = "WebAssembly REPL for the Nickel programming language."
 authors.workspace = true
 edition.workspace = true


Test patch:
diff --git a/cli/tests/snapshot/README.md b/cli/tests/snapshot/README.md
index 33a042e1..399f2a9f 100644
--- a/cli/tests/snapshot/README.md
+++ b/cli/tests/snapshot/README.md
@@ -4,6 +4,10 @@ Nickel snapshot tests are run by invoking the most recently built Nickel binary
 (located via the `CARGO_BIN_EXE_nickel` environment variable) with the Nickel
 files located in subdirectories of the `inputs` directory.
 
+Because each file of the `inputs` directory is supposed to be a snapshot test,
+the `imports` directory is used for auxiliary files that are imported by a
+snapshot test but aren't themselves standalone snapshot tests.
+
 Each subdirectory corresponds to the type of output we're snapshotting.
 Currently we have `errors`, which tests error descriptions written to STDERR,
 `export`, which tests the output when exporting JSON, and `pretty` which tests
diff --git a/cli/tests/snapshot/imports/package.toml b/cli/tests/snapshot/imports/package.toml
new file mode 100644
index 00000000..08d354ba
--- /dev/null
+++ b/cli/tests/snapshot/imports/package.toml
@@ -0,0 +1,2 @@
+[package]
+name = 5
diff --git a/cli/tests/snapshot/inputs/errors/spanned_toml.ncl b/cli/tests/snapshot/inputs/errors/spanned_toml.ncl
new file mode 100644
index 00000000..7635065f
--- /dev/null
+++ b/cli/tests/snapshot/inputs/errors/spanned_toml.ncl
@@ -0,0 +1,6 @@
+# capture = 'stderr'
+# command = ['eval']
+let test = import "../../imports/package.toml" in
+test | {
+  package.name | String,
+}
diff --git a/cli/tests/snapshot/main.rs b/cli/tests/snapshot/main.rs
index aa22f87f..3c17bbcb 100644
--- a/cli/tests/snapshot/main.rs
+++ b/cli/tests/snapshot/main.rs
@@ -16,7 +16,8 @@ macro_rules! assert_snapshot_filtered {
             // 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]")
+            (r"(?:/.+/tests/snapshot/inputs)", "[INPUTS_PATH]"),
+            (r"(?:/.+/tests/snapshot/imports)", "[IMPORTS_PATH]")
         ]},
         {
             insta::assert_snapshot!($name, $snapshot);
diff --git a/cli/tests/snapshot/snapshots/snapshot__eval_stderr_spanned_toml.ncl.snap b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_spanned_toml.ncl.snap
new file mode 100644
index 00000000..df0e816d
--- /dev/null
+++ b/cli/tests/snapshot/snapshots/snapshot__eval_stderr_spanned_toml.ncl.snap
@@ -0,0 +1,14 @@
+---
+source: cli/tests/snapshot/main.rs
+expression: err
+---
+error: contract broken by the value of `name`
+  ┌─ [INPUTS_PATH]/errors/spanned_toml.ncl:5:18
+  │
+5 │   package.name | String,
+  │                  ------ expected type
+  │
+  ┌─ [IMPORTS_PATH]/package.toml:2:8
+  │
+2 │ name = 5
+  │        ^ applied to this expression