Repository: tweag/nickel
Pull request #1191 (2023-03-20 15:55:50)

Fix LSP crash when a resolved import term fails to typecheck
Currently, when we open a Nickel file and some of content of one or more imports don't typecheck, the LSP crashes. 

This pull request fixes that.


Patch:
diff --git a/.github/workflows/update-flake-lock.yml b/.github/workflows/update-flake-lock.yml
index f2299672..f3fc5744 100644
--- a/.github/workflows/update-flake-lock.yml
+++ b/.github/workflows/update-flake-lock.yml
@@ -16,7 +16,7 @@ jobs:
           extra_nix_config: |
             access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
       - name: Update flake.lock
-        uses: DeterminateSystems/update-flake-lock@v17
+        uses: DeterminateSystems/update-flake-lock@v18
         with:
           # Providing a secret token is necessary for the PRs to run CI
           # See https://github.com/DeterminateSystems/update-flake-lock#with-a-personal-authentication-token
diff --git a/Cargo.lock b/Cargo.lock
index 9d3580e8..ef1d4a3e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1041,6 +1041,12 @@ version = "1.0.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "bfa799dd5ed20a7e349f3b4639aa80d74549c81716d9ec4f994c9b5815598306"
 
+[[package]]
+name = "indoc"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f2cb48b81b1dc9f39676bf99f5499babfec7cd8fe14307f7b3d747208fb5690"
+
 [[package]]
 name = "inferno"
 version = "0.11.15"
@@ -1424,6 +1430,7 @@ dependencies = [
  "comrak",
  "criterion 0.4.0",
  "directories",
+ "indoc 2.0.1",
  "insta",
  "js-sys",
  "lalrpop",
@@ -1868,7 +1875,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "268be0c73583c183f2b14052337465768c07726936a260f480f0857cb95ba543"
 dependencies = [
  "cfg-if",
- "indoc",
+ "indoc 1.0.9",
  "libc",
  "memoffset 0.6.5",
  "parking_lot",
diff --git a/Cargo.toml b/Cargo.toml
index 0c2f247f..7738f93d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -46,6 +46,7 @@ sha2 = "0.10.6"
 md-5 = "0.10.5"
 directories = "4.0.1"
 unicode-segmentation = "1.10.1"
+indoc = "2"
 
 termimad = { version = "0.23.0", optional = true }
 ansi_term = { version = "0.12", optional = true }
diff --git a/flake.lock b/flake.lock
index 4d13156d..c90f6b7d 100644
--- a/flake.lock
+++ b/flake.lock
@@ -1,5 +1,21 @@
 {
   "nodes": {
+    "advisory-db": {
+      "flake": false,
+      "locked": {
+        "lastModified": 1678924180,
+        "narHash": "sha256-5bwage/7JRiPiDY4wY3+OBiT8abY5f83hss6pQBklz8=",
+        "owner": "rustsec",
+        "repo": "advisory-db",
+        "rev": "0888b44843e3c86db9fd56334c7f5261ea00dc19",
+        "type": "github"
+      },
+      "original": {
+        "owner": "rustsec",
+        "repo": "advisory-db",
+        "type": "github"
+      }
+    },
     "crane": {
       "inputs": {
         "flake-compat": "flake-compat",
@@ -23,6 +39,30 @@
         "type": "github"
       }
     },
+    "crane_2": {
+      "inputs": {
+        "flake-compat": "flake-compat_3",
+        "flake-utils": "flake-utils_3",
+        "nixpkgs": [
+          "topiary",
+          "nixpkgs"
+        ],
+        "rust-overlay": "rust-overlay_3"
+      },
+      "locked": {
+        "lastModified": 1678152261,
+        "narHash": "sha256-cPRDxwygVMleiSEGELrvAiq9vYAN4c3KK/K4UEO13vU=",
+        "owner": "ipetkov",
+        "repo": "crane",
+        "rev": "5291dd0aa7a52d607fc952763ef60714e4c881d4",
+        "type": "github"
+      },
+      "original": {
+        "owner": "ipetkov",
+        "repo": "crane",
+        "type": "github"
+      }
+    },
     "flake-compat": {
       "flake": false,
       "locked": {
@@ -55,6 +95,22 @@
         "type": "github"
       }
     },
+    "flake-compat_3": {
+      "flake": false,
+      "locked": {
+        "lastModified": 1673956053,
+        "narHash": "sha256-4gtG9iQuiKITOjNQQeQIpoIB6b16fm+504Ch3sNKLd8=",
+        "owner": "edolstra",
+        "repo": "flake-compat",
+        "rev": "35bb57c0c8d8b62bbfd284272c928ceb64ddbde9",
+        "type": "github"
+      },
+      "original": {
+        "owner": "edolstra",
+        "repo": "flake-compat",
+        "type": "github"
+      }
+    },
     "flake-utils": {
       "locked": {
         "lastModified": 1676283394,
@@ -85,6 +141,51 @@
         "type": "github"
       }
     },
+    "flake-utils_3": {
+      "locked": {
+        "lastModified": 1676283394,
+        "narHash": "sha256-XX2f9c3iySLCw54rJ/CZs+ZK6IQy7GXNY4nSOyu2QG4=",
+        "owner": "numtide",
+        "repo": "flake-utils",
+        "rev": "3db36a8b464d0c4532ba1c7dda728f4576d6d073",
+        "type": "github"
+      },
+      "original": {
+        "owner": "numtide",
+        "repo": "flake-utils",
+        "type": "github"
+      }
+    },
+    "flake-utils_4": {
+      "locked": {
+        "lastModified": 1678901627,
+        "narHash": "sha256-U02riOqrKKzwjsxc/400XnElV+UtPUQWpANPlyazjH0=",
+        "owner": "numtide",
+        "repo": "flake-utils",
+        "rev": "93a2b84fc4b70d9e089d029deacc3583435c2ed6",
+        "type": "github"
+      },
+      "original": {
+        "owner": "numtide",
+        "repo": "flake-utils",
+        "type": "github"
+      }
+    },
+    "flake-utils_5": {
+      "locked": {
+        "lastModified": 1659877975,
+        "narHash": "sha256-zllb8aq3YO3h8B/U0/J1WBgAL8EX5yWf5pMj3G0NAmc=",
+        "owner": "numtide",
+        "repo": "flake-utils",
+        "rev": "c0e246b9b83f637f4681389ecabcb2681b4f3af0",
+        "type": "github"
+      },
+      "original": {
+        "owner": "numtide",
+        "repo": "flake-utils",
+        "type": "github"
+      }
+    },
     "gitignore": {
       "inputs": {
         "nixpkgs": [
@@ -106,6 +207,21 @@
         "type": "github"
       }
     },
+    "nix-filter": {
+      "locked": {
+        "lastModified": 1678109515,
+        "narHash": "sha256-C2X+qC80K2C1TOYZT8nabgo05Dw2HST/pSn6s+n6BO8=",
+        "owner": "numtide",
+        "repo": "nix-filter",
+        "rev": "aa9ff6ce4a7f19af6415fb3721eaa513ea6c763c",
+        "type": "github"
+      },
+      "original": {
+        "owner": "numtide",
+        "repo": "nix-filter",
+        "type": "github"
+      }
+    },
     "nixpkgs": {
       "locked": {
         "lastModified": 1678898370,
@@ -137,6 +253,22 @@
         "type": "github"
       }
     },
+    "nixpkgs_2": {
+      "locked": {
+        "lastModified": 1665296151,
+        "narHash": "sha256-uOB0oxqxN9K7XGF1hcnY+PQnlQJ+3bP2vCn/+Ru/bbc=",
+        "owner": "NixOS",
+        "repo": "nixpkgs",
+        "rev": "14ccaaedd95a488dd7ae142757884d8e125b3363",
+        "type": "github"
+      },
+      "original": {
+        "owner": "NixOS",
+        "ref": "nixpkgs-unstable",
+        "repo": "nixpkgs",
+        "type": "github"
+      }
+    },
     "pre-commit-hooks": {
       "inputs": {
         "flake-compat": "flake-compat_2",
@@ -169,7 +301,8 @@
         "flake-utils": "flake-utils_2",
         "nixpkgs": "nixpkgs",
         "pre-commit-hooks": "pre-commit-hooks",
-        "rust-overlay": "rust-overlay_2"
+        "rust-overlay": "rust-overlay_2",
+        "topiary": "topiary"
       }
     },
     "rust-overlay": {
@@ -219,6 +352,77 @@
         "repo": "rust-overlay",
         "type": "github"
       }
+    },
+    "rust-overlay_3": {
+      "inputs": {
+        "flake-utils": [
+          "topiary",
+          "crane",
+          "flake-utils"
+        ],
+        "nixpkgs": [
+          "topiary",
+          "crane",
+          "nixpkgs"
+        ]
+      },
+      "locked": {
+        "lastModified": 1677812689,
+        "narHash": "sha256-EakqhgRnjVeYJv5+BJx/NZ7/eFTMBxc4AhICUNquhUg=",
+        "owner": "oxalica",
+        "repo": "rust-overlay",
+        "rev": "e53e8853aa7b0688bc270e9e6a681d22e01cf299",
+        "type": "github"
+      },
+      "original": {
+        "owner": "oxalica",
+        "repo": "rust-overlay",
+        "type": "github"
+      }
+    },
+    "rust-overlay_4": {
+      "inputs": {
+        "flake-utils": "flake-utils_5",
+        "nixpkgs": "nixpkgs_2"
+      },
+      "locked": {
+        "lastModified": 1679106165,
+        "narHash": "sha256-03Opt2yu4E/AIFjvlgib0/nhMn6B4B/t/nvwS2bzOGw=",
+        "owner": "oxalica",
+        "repo": "rust-overlay",
+        "rev": "7313c06ac334d6262ddfe30a38b3abc3da6bd565",
+        "type": "github"
+      },
+      "original": {
+        "owner": "oxalica",
+        "repo": "rust-overlay",
+        "type": "github"
+      }
+    },
+    "topiary": {
+      "inputs": {
+        "advisory-db": "advisory-db",
+        "crane": "crane_2",
+        "flake-utils": "flake-utils_4",
+        "nix-filter": "nix-filter",
+        "nixpkgs": [
+          "nixpkgs"
+        ],
+        "rust-overlay": "rust-overlay_4"
+      },
+      "locked": {
+        "lastModified": 1680007860,
+        "narHash": "sha256-H36jb8pjR3vUl2xKVnazM6Hn+S5dsUmtN8xk9FwhzJo=",
+        "owner": "tweag",
+        "repo": "topiary",
+        "rev": "399d59415eef6193e4959b8774b285a2615ab692",
+        "type": "github"
+      },
+      "original": {
+        "owner": "tweag",
+        "repo": "topiary",
+        "type": "github"
+      }
     }
   },
   "root": "root",
diff --git a/flake.nix b/flake.nix
index 64621227..d82d42c8 100644
--- a/flake.nix
+++ b/flake.nix
@@ -16,6 +16,10 @@
       url = "github:ipetkov/crane";
       inputs.nixpkgs.follows = "nixpkgs";
     };
+    topiary = {
+      url = "github:tweag/topiary";
+      inputs.nixpkgs.follows = "nixpkgs";
+    };
   };
 
   nixConfig = {
@@ -30,6 +34,7 @@
     , pre-commit-hooks
     , rust-overlay
     , crane
+    , topiary
     }:
     let
       SYSTEMS = [
@@ -264,6 +269,7 @@
           pkgs.node2nix
           pkgs.nodePackages.markdownlint-cli
           pkgs.python3
+          topiary.packages.${system}.default
         ];
 
         shellHook = (pre-commit-builder { inherit rust; checkFormat = true; }).shellHook + ''
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/deserialize.rs b/src/deserialize.rs
index ae55d13e..52f09994 100644
--- a/src/deserialize.rs
+++ b/src/deserialize.rs
@@ -55,7 +55,7 @@ impl<'de> serde::Deserializer<'de> for RichTerm {
             Term::Null => visitor.visit_unit(),
             Term::Bool(v) => visitor.visit_bool(v),
             Term::Num(v) => visitor.visit_f64(f64::rounding_from(v, RoundingMode::Nearest)),
-            Term::Str(v) => visitor.visit_string(v),
+            Term::Str(v) => visitor.visit_string(v.into_inner()),
             Term::Enum(v) => visitor.visit_enum(EnumDeserializer {
                 variant: v.into_label(),
                 rich_term: None,
@@ -186,7 +186,7 @@ impl<'de> serde::Deserializer<'de> for RichTerm {
         V: Visitor<'de>,
     {
         match unwrap_term(self)? {
-            Term::Str(v) => visitor.visit_string(v),
+            Term::Str(v) => visitor.visit_string(v.into_inner()),
             other => Err(RustDeserializationError::InvalidType {
                 expected: "Str".to_string(),
                 occurred: other.type_of().unwrap_or_else(|| "Other".to_string()),
@@ -208,7 +208,7 @@ impl<'de> serde::Deserializer<'de> for RichTerm {
         V: Visitor<'de>,
     {
         match unwrap_term(self)? {
-            Term::Str(v) => visitor.visit_string(v),
+            Term::Str(v) => visitor.visit_string(v.into_inner()),
             Term::Array(v, _) => visit_array(v, visitor),
             other => Err(RustDeserializationError::InvalidType {
                 expected: "Str or Array".to_string(),
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/mod.rs b/src/eval/mod.rs
index c2022dc5..2da43ff3 100644
--- a/src/eval/mod.rs
+++ b/src/eval/mod.rs
@@ -74,6 +74,7 @@
 //! something to consider at some point.
 
 use crate::term::record::FieldMetadata;
+use crate::term::string::NickelString;
 use crate::{
     cache::{Cache as ImportCache, Envs, ImportResolver},
     environment::Environment as GenericEnvironment,
@@ -189,7 +190,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
         rt: RichTerm,
         initial_env: &Environment,
     ) -> Result<(RichTerm, Environment), EvalError> {
-        let wrapper = mk_term::op1(UnaryOp::Force(None), rt);
+        let wrapper = mk_term::op1(UnaryOp::Force(), rt);
         self.eval_closure(Closure::atomic_closure(wrapper), initial_env)
     }
 
@@ -441,15 +442,6 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                         pos,
                     );
 
-                    // Special casing (hopefully temporary) due to the need for `DeepSeq` to produce
-                    // acceptable error message for missing field definition occurring when sequencing
-                    // a record. See the definition of `UnaryOp::DeepSeq`.
-                    if let UnaryOp::DeepSeq(Some(stack_elem)) | UnaryOp::Force(Some(stack_elem)) =
-                        op
-                    {
-                        self.call_stack.0.push(stack_elem.clone());
-                    }
-
                     Closure {
                         body: t.clone(),
                         env,
@@ -507,12 +499,12 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                     let mut chunks_iter = chunks.iter();
                     match chunks_iter.next_back() {
                         None => Closure {
-                            body: Term::Str(String::new()).into(),
+                            body: Term::Str(NickelString::new()).into(),
                             env: Environment::new(),
                         },
                         Some(chunk) => {
                             let (arg, indent) = match chunk {
-                                StrChunk::Literal(s) => (Term::Str(s.clone()).into(), 0),
+                                StrChunk::Literal(s) => (Term::Str(s.into()).into(), 0),
                                 StrChunk::Expr(e, indent) => (e.clone(), *indent),
                             };
 
diff --git a/src/eval/operation.rs b/src/eval/operation.rs
index c7b6de3d..9ede1dca 100644
--- a/src/eval/operation.rs
+++ b/src/eval/operation.rs
@@ -7,8 +7,7 @@
 //! On the other hand, the functions `process_unary_operation` and `process_binary_operation`
 //! receive evaluated operands and implement the actual semantics of operators.
 use super::{
-    callstack, merge,
-    merge::{merge, MergeMode},
+    merge::{self, MergeMode},
     stack::StrAccData,
     subst, Cache, Closure, Environment, ImportResolver, VirtualMachine,
 };
@@ -26,7 +25,8 @@ 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,
     },
@@ -36,7 +36,7 @@ use crate::{
 use malachite::{
     num::{
         arithmetic::traits::Pow,
-        basic::traits::{One, Zero},
+        basic::traits::Zero,
         conversion::traits::{RoundingFrom, ToSci},
     },
     rounding_modes::RoundingMode,
@@ -718,35 +718,29 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                 .pop_arg(&self.cache)
                 .map(|(next, ..)| next)
                 .ok_or_else(|| EvalError::NotEnoughArgs(2, String::from("seq"), pos_op)),
-            UnaryOp::DeepSeq(_) => {
-                /// Build a RichTerm that forces a given list of terms, and at the end resumes the
-                /// evaluation of the argument on the top of the stack. The argument must iterate over
-                /// a tuple, which first element is an optional call stack element to add to the
-                /// callstack before starting evaluation. This is a temporary fix to have reasonable
-                /// missing definition error when deepsequing a record.
-                ///
-                /// Requires its first argument to be non-empty.
+            UnaryOp::DeepSeq() => {
+                // Build a `RichTerm` that forces a given list of terms, and at the end resumes the
+                // evaluation of the argument on the top of the stack.
+                //
+                // Requires its first argument to be non-empty.
                 fn seq_terms<I>(mut it: I, pos_op_inh: TermPos) -> RichTerm
                 where
-                    I: Iterator<Item = (Option<callstack::StackElem>, RichTerm)>,
+                    I: Iterator<Item = RichTerm>,
                 {
-                    let (first_elem, first) = it
+                    let first = it
                         .next()
                         .expect("expected the argument to be a non-empty iterator");
 
                     it.fold(
-                        mk_term::op1(UnaryOp::DeepSeq(first_elem), first).with_pos(pos_op_inh),
-                        |acc, (elem, t)| {
-                            mk_app!(mk_term::op1(UnaryOp::DeepSeq(elem), t), acc)
-                                .with_pos(pos_op_inh)
+                        mk_term::op1(UnaryOp::DeepSeq(), first).with_pos(pos_op_inh),
+                        |acc, t| {
+                            mk_app!(mk_term::op1(UnaryOp::DeepSeq(), t), acc).with_pos(pos_op_inh)
                         },
                     )
                 }
 
                 match t.into_owned() {
                     Term::Record(record) if !record.fields.is_empty() => {
-                        let pos_record = pos;
-                        let pos_access = pos_op;
                         let defined = record
                             // into_iter_without_opts applies pending contracts as well
                             .into_iter_without_opts()
@@ -755,17 +749,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                                 missing_def_err.into_eval_err(pos, pos_op)
                             })?;
 
-                        let terms = defined.into_iter().map(|(id, value)| {
-                            (
-                                Some(callstack::StackElem::Field {
-                                    id,
-                                    pos_record,
-                                    pos_field: value.pos,
-                                    pos_access,
-                                }),
-                                value,
-                            )
-                        });
+                        let terms = defined.into_iter().map(|(_, field)| field);
 
                         Ok(Closure {
                             body: seq_terms(terms, pos_op),
@@ -783,7 +767,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                                         pos.into_inherited(),
                                     )
                                     .closurize(&mut self.cache, &mut shared_env, env.clone());
-                                    (None, t_with_ctr)
+                                    t_with_ctr
                                 }),
                                 pos_op,
                             );
@@ -831,7 +815,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                         let indent_str: String = std::iter::once('\n')
                             .chain((0..indent).map(|_| ' '))
                             .collect();
-                        s.replace('\n', &indent_str)
+                        s.as_str().replace('\n', &indent_str).into()
                     } else {
                         s.clone()
                     };
@@ -860,7 +844,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                         })
                     } else {
                         Ok(Closure::atomic_closure(RichTerm::new(
-                            Term::Str(acc),
+                            Term::Str(acc.into()),
                             pos_op_inh,
                         )))
                     }
@@ -878,7 +862,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
             UnaryOp::StrTrim() => {
                 if let Term::Str(s) = &*t {
                     Ok(Closure::atomic_closure(RichTerm::new(
-                        Term::Str(String::from(s.trim())),
+                        Term::Str(s.trim().into()),
                         pos_op_inh,
                     )))
                 } else {
@@ -892,11 +876,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
             }
             UnaryOp::StrChars() => {
                 if let Term::Str(s) = &*t {
-                    let ts = s
-                        .chars()
-                        .map(|c| RichTerm::from(Term::Str(c.to_string())))
-                        .collect();
-
+                    let ts = s.characters();
                     Ok(Closure::atomic_closure(RichTerm::new(
                         Term::Array(ts, ArrayAttrs::new().closurized()),
                         pos_op_inh,
@@ -910,59 +890,10 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                     ))
                 }
             }
-            UnaryOp::CharCode() => {
-                if let Term::Str(s) = &*t {
-                    if s.len() == 1 {
-                        let code = s.chars().next().unwrap() as u32;
-                        Ok(Closure::atomic_closure(RichTerm::new(
-                            Term::Num(code.into()),
-                            pos_op_inh,
-                        )))
-                    } else {
-                        Err(EvalError::Other(
-                            format!("charCode: expected 1-char string, got `{}`", s.len()),
-                            pos,
-                        ))
-                    }
-                } else {
-                    Err(EvalError::TypeError(
-                        String::from("Str"),
-                        String::from("charCode"),
-                        arg_pos,
-                        RichTerm { term: t, pos },
-                    ))
-                }
-            }
-            UnaryOp::CharFromCode() => {
-                let Term::Num(ref code) = *t else {
-                    return Err(EvalError::TypeError(
-                        String::from("Num"),
-                        String::from("charFromCode"),
-                        arg_pos,
-                        RichTerm { term: t, pos },
-                    ))
-                };
-
-                let Ok(code_as_u32) = u32::try_from(code) else {
-                   return Err(EvalError::Other(format!("charFromCode: expected the argument to be a positive integer smaller than {}, got {code}", u32::MAX), pos_op));
-                };
-
-                if let Some(car) = std::char::from_u32(code_as_u32) {
-                    Ok(Closure::atomic_closure(RichTerm::new(
-                        Term::Str(String::from(car)),
-                        pos_op_inh,
-                    )))
-                } else {
-                    Err(EvalError::Other(
-                        format!("charFromCode: invalid character code {code}"),
-                        pos_op,
-                    ))
-                }
-            }
             UnaryOp::StrUppercase() => {
                 if let Term::Str(s) = &*t {
                     Ok(Closure::atomic_closure(RichTerm::new(
-                        Term::Str(s.to_uppercase()),
+                        Term::Str(s.to_uppercase().into()),
                         pos_op_inh,
                     )))
                 } else {
@@ -977,7 +908,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
             UnaryOp::StrLowercase() => {
                 if let Term::Str(s) = &*t {
                     Ok(Closure::atomic_closure(RichTerm::new(
-                        Term::Str(s.to_lowercase()),
+                        Term::Str(s.to_lowercase().into()),
                         pos_op_inh,
                     )))
                 } else {
@@ -1007,11 +938,11 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
             }
             UnaryOp::ToStr() => {
                 let result = match_sharedterm! {t, with {
-                    Term::Num(n) => Ok(Term::Str(format!("{}", n.to_sci()))),
+                    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())),
-                    Term::Enum(id) => Ok(Term::Str(id.to_string())),
-                    Term::Null => Ok(Term::Str(String::from("null"))),
+                    Term::Bool(b) => Ok(Term::Str(b.to_string().into())),
+                    Term::Enum(id) => Ok(Term::Str(id.into())),
+                    Term::Null => Ok(Term::Str("null".into())),
                 } else {
                     Err(EvalError::Other(
                         format!(
@@ -1027,7 +958,10 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
             UnaryOp::NumFromStr() => {
                 if let Term::Str(s) = &*t {
                     let n = parse_number(s).map_err(|_| {
-                        EvalError::Other(format!("num_from_string: invalid num literal `{s}`"), pos)
+                        EvalError::Other(
+                            format!("num_from_string: invalid num literal `{}`", s.as_str()),
+                            pos,
+                        )
                     })?;
                     Ok(Closure::atomic_closure(RichTerm::new(
                         Term::Num(n),
@@ -1045,7 +979,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
             UnaryOp::EnumFromStr() => {
                 if let Term::Str(s) = &*t {
                     Ok(Closure::atomic_closure(RichTerm::new(
-                        Term::Enum(s.into()),
+                        Term::Enum(Ident::from(s)),
                         pos_op_inh,
                     )))
                 } else {
@@ -1135,12 +1069,12 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                             .iter()
                             .skip(1)
                             .filter_map(|s_opt| {
-                                s_opt.map(|s| RichTerm::from(Term::Str(String::from(s.as_str()))))
+                                s_opt.map(|s| RichTerm::from(Term::Str(s.as_str().into())))
                             })
                             .collect();
 
                         mk_record!(
-                            ("matched", Term::Str(String::from(first_match.as_str()))),
+                            ("matched", Term::Str(first_match.as_str().into())),
                             ("index", Term::Num(first_match.start().into())),
                             (
                                 "groups",
@@ -1150,7 +1084,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                     } else {
                         //FIXME: what should we return when there's no match?
                         mk_record!(
-                            ("matched", Term::Str(String::new())),
+                            ("matched", Term::Str(NickelString::new())),
                             ("index", Term::Num(Number::from(-1))),
                             (
                                 "groups",
@@ -1169,7 +1103,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                     ))
                 }
             }
-            UnaryOp::Force(_) => {
+            UnaryOp::Force() => {
                 /// `Seq` the `terms` iterator and then resume evaluating the `cont` continuation.
                 fn seq_terms<I>(terms: I, pos: TermPos, cont: RichTerm) -> RichTerm
                 where
@@ -1185,15 +1119,8 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                         Term::Record(record) if !record.fields.is_empty() => {
                             let mut shared_env = Environment::new();
 
-                            let record = record.map_values_closurize(&mut self.cache, &mut shared_env, &env, |id, t| {
-                                let stack_elem = Some(callstack::StackElem::Field {
-                                    id,
-                                    pos_record: pos,
-                                    pos_field: t.pos,
-                                    pos_access: pos_op,
-                                });
-
-                                mk_term::op1(UnaryOp::Force(stack_elem), t)
+                            let record = record.map_values_closurize(&mut self.cache, &mut shared_env, &env, |_, t| {
+                                mk_term::op1(UnaryOp::Force(), t)
                             }).map_err(|missing_field_err| missing_field_err.into_eval_err(pos, pos_op))?;
 
                             // unwrap: the call to map_fields_without_optionals must ensure that
@@ -1212,7 +1139,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                                 .into_iter()
                                 .map(|t| {
                                     mk_term::op1(
-                                        UnaryOp::Force(None),
+                                        UnaryOp::Force(),
                                         PendingContract::apply_all(
                                             t,
                                             attrs.pending_contracts.iter().cloned(),
@@ -1602,7 +1529,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                     if let Term::Str(s2) = &*t2 {
                         let ss: [&str; 2] = [s1, s2];
                         Ok(Closure::atomic_closure(RichTerm::new(
-                            Term::Str(ss.concat()),
+                            Term::Str(ss.concat().into()),
                             pos_op_inh,
                         )))
                     } else {
@@ -1906,7 +1833,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                     Term::Str(field) => match_sharedterm! {t2, with {
                             Term::Lbl(l) => {
                                 let mut l = l;
-                                l.path.push(ty_path::Elem::Field(Ident::from(field)));
+                                l.path.push(ty_path::Elem::Field(field.into_inner().into()));
                                 Ok(Closure::atomic_closure(RichTerm::new(
                                     Term::Lbl(l),
                                     pos_op_inh,
@@ -1944,16 +1871,17 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                             // and again, which is not optimal. The same thing happens with array
                             // contracts. There are several way to improve this, but this is left
                             // as future work.
-                            match record.get_value_with_ctrs(&Ident::from(&id)).map_err(|missing_field_err| missing_field_err.into_eval_err(pos2, pos_op))? {
+                            let ident = Ident::from(&id);
+                            match record.get_value_with_ctrs(&ident).map_err(|missing_field_err| missing_field_err.into_eval_err(pos2, pos_op))? {
                                 Some(value) => {
-                                    self.call_stack.enter_field(Ident::from(id), pos2, value.pos, pos_op);
+                                    self.call_stack.enter_field(ident, pos2, value.pos, pos_op);
                                     Ok(Closure {
                                         body: value,
                                         env: env2,
                                     })
                                 }
                                 None => Err(EvalError::FieldMissing(
-                                    id,
+                                    id.into_inner(),
                                     String::from("(.$)"),
                                     RichTerm {
                                         term: t2,
@@ -2059,7 +1987,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                                       }) =>
                                     {
                                         Err(EvalError::FieldMissing(
-                                            id,
+                                            id.into_inner(),
                                             String::from("remove"),
                                             RichTerm::new(
                                                 Term::Record(RecordData { fields, ..record }),
@@ -2106,7 +2034,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                     Term::Str(id) => {
                         if let Term::Record(record) = &*t2 {
                             Ok(Closure::atomic_closure(RichTerm::new(
-                                Term::Bool(matches!(record.fields.get(&Ident::from(id)), Some(field) if !field.is_empty_optional())),
+                                Term::Bool(matches!(record.fields.get(&Ident::from(id.into_inner())), Some(field) if !field.is_empty_optional())),
                                 pos_op_inh,
                             )))
                         } else {
@@ -2261,7 +2189,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                     },
                 )),
             },
-            BinaryOp::Merge() => merge(
+            BinaryOp::Merge() => merge::merge(
                 &mut self.cache,
                 RichTerm {
                     term: t1,
@@ -2295,29 +2223,29 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                         let result = match id.as_ref() {
                             "Md5" => {
                                 let mut hasher = md5::Md5::new();
-                                hasher.update(s);
+                                hasher.update(s.as_ref());
                                 format!("{:x}", hasher.finalize())
                             }
                             "Sha1" => {
                                 let mut hasher = sha1::Sha1::new();
-                                hasher.update(s);
+                                hasher.update(s.as_ref());
                                 format!("{:x}", hasher.finalize())
                             }
                             "Sha256" => {
                                 let mut hasher = sha2::Sha256::new();
-                                hasher.update(s);
+                                hasher.update(s.as_ref());
                                 format!("{:x}", hasher.finalize())
                             }
                             "Sha512" => {
                                 let mut hasher = sha2::Sha512::new();
-                                hasher.update(s);
+                                hasher.update(s.as_ref());
                                 format!("{:x}", hasher.finalize())
                             }
                             _ => return mk_err_fst(t1),
                         };
 
                         Ok(Closure::atomic_closure(RichTerm::new(
-                            Term::Str(result),
+                            Term::Str(result.into()),
                             pos_op_inh,
                         )))
                     } else {
@@ -2370,7 +2298,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
 
                     serialize::validate(format, &rt2)?;
                     Ok(Closure::atomic_closure(RichTerm::new(
-                        Term::Str(serialize::to_string(format, &rt2)?),
+                        Term::Str(serialize::to_string(format, &rt2)?.into()),
                         pos_op_inh,
                     )))
                 } else {
@@ -2434,14 +2362,10 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                 }
             }
             BinaryOp::StrSplit() => match (&*t1, &*t2) {
-                (Term::Str(s1), Term::Str(s2)) => {
-                    let array = s1
-                        .split(s2)
-                        .map(|s| Term::Str(String::from(s)).into())
-                        .collect();
-
+                (Term::Str(input), Term::Str(separator)) => {
+                    let result = input.split(separator);
                     Ok(Closure::atomic_closure(RichTerm::new(
-                        Term::Array(array, ArrayAttrs::new().closurized()),
+                        Term::Array(result, ArrayAttrs::new().closurized()),
                         pos_op_inh,
                     )))
                 }
@@ -2465,10 +2389,13 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                 )),
             },
             BinaryOp::StrContains() => match (&*t1, &*t2) {
-                (Term::Str(s1), Term::Str(s2)) => Ok(Closure::atomic_closure(RichTerm::new(
-                    Term::Bool(s1.contains(s2)),
-                    pos_op_inh,
-                ))),
+                (Term::Str(s1), Term::Str(s2)) => {
+                    let result = s1.contains(s2.as_str());
+                    Ok(Closure::atomic_closure(RichTerm::new(
+                        Term::Bool(result),
+                        pos_op_inh,
+                    )))
+                }
                 (Term::Str(_), _) => Err(EvalError::TypeError(
                     String::from("Str"),
                     String::from("strContains, 2nd argument"),
@@ -2539,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,
@@ -2556,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,
@@ -2575,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"),
@@ -2665,7 +2577,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                 };
 
                 Ok(Closure::atomic_closure(RichTerm::new(
-                    Term::Lbl(label.with_diagnostic_message(message)),
+                    Term::Lbl(label.with_diagnostic_message(message.into_inner())),
                     pos_op_inh,
                 )))
             }
@@ -2704,7 +2616,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                         let term = element.term.into_owned();
 
                         if let Term::Str(s) = term {
-                            Ok(s)
+                            Ok(s.into_inner())
                         } else {
                             Err(EvalError::TypeError(
                                 String::from("Str"),
@@ -2765,7 +2677,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                 };
 
                 Ok(Closure::atomic_closure(RichTerm::new(
-                    Term::Lbl(label.append_diagnostic_note(note)),
+                    Term::Lbl(label.append_diagnostic_note(note.into_inner())),
                     pos2.into_inherited(),
                 )))
             }
@@ -2832,12 +2744,12 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                 match (&*fst, &*snd, &*thd) {
                     (Term::Str(s), Term::Str(from), Term::Str(to)) => {
                         let result = if let NAryOp::StrReplace() = n_op {
-                            str::replace(s, from, to)
+                            s.replace(from.as_str(), to.as_str())
                         } else {
                             let re = regex::Regex::new(from)
                                 .map_err(|err| EvalError::Other(err.to_string(), pos_op))?;
 
-                            re.replace_all(s, to.as_str()).into_owned()
+                            re.replace_all(s, to.as_str()).into()
                         };
 
                         Ok(Closure::atomic_closure(RichTerm::new(
@@ -2884,28 +2796,12 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
                 debug_assert!(args_wo_env.next().is_none());
 
                 match (&*fst, &*snd, &*thd) {
-                    (Term::Str(s), Term::Num(start), Term::Num(end)) => {
-                        let Ok(start_as_usize) = usize::try_from(start) else {
-                            return Err(EvalError::Other(format!("substring: expected the 2nd argument (start) to be a positive integer smaller than {}, got {start}", usize::MAX), pos_op));
-                        };
-
-                        let Ok(end_as_usize) = usize::try_from(end) else {
-                            return Err(EvalError::Other(format!("substring: expected the 3nd argument (end) to be a positive integer smaller than {}, got the floating-point value {end}", usize::MAX), pos_op));
-                        };
-
-                        if !s.is_char_boundary(start_as_usize) {
-                            return Err(EvalError::Other(format!("substring: index out of bounds. Expected the 2nd argument (start) to be between 0 and {}, got {}", s.len(), start), pos_op));
-                        }
-
-                        if end <= start || !s.is_char_boundary(end_as_usize) {
-                            return Err(EvalError::Other(format!("substring: index out of bounds. Expected the 3rd argument (end) to be between {} and {}, got {}", start + Number::ONE, s.len(), end), pos_op));
-                        };
-
-                        Ok(Closure::atomic_closure(RichTerm::new(
-                            Term::Str(s[start_as_usize..end_as_usize].to_owned()),
-                            pos_op_inh,
-                        )))
-                    }
+                    (Term::Str(s), Term::Num(start), Term::Num(end)) => s
+                        .substring(start, end)
+                        .map(|substr| {
+                            Closure::atomic_closure(RichTerm::new(Term::Str(substr), pos_op_inh))
+                        })
+                        .map_err(|e| EvalError::Other(format!("{}", e), pos_op)),
                     (Term::Str(_), Term::Num(_), _) => Err(EvalError::TypeError(
                         String::from("Str"),
                         String::from("strReplace, 3rd argument"),
@@ -2970,7 +2866,7 @@ impl<R: ImportResolver, C: Cache> VirtualMachine<R, C> {
 
                 match_sharedterm! {t1, with {
                         Term::Lbl(lbl) => {
-                            merge(
+                            merge::merge(
                                 &mut self.cache,
                                 RichTerm {
                                     term: t2,
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 19a8e382..1ae6dc4e 100644
--- a/src/parser/grammar.lalrpop
+++ b/src/parser/grammar.lalrpop
@@ -551,7 +551,7 @@ StrChunks: RichTerm = {
 
         if let StringStartDelimiter::Symbolic(prefix) = start {
             let terms = chunks.into_iter().map(|chunk| match chunk {
-                StrChunk::Literal(l) => Term::Str(l).into(),
+                StrChunk::Literal(l) => Term::Str(l.into()).into(),
                 StrChunk::Expr(e, _) => e,
             }).collect();
 
@@ -652,15 +652,13 @@ UOp: UnaryOp = {
     "generate" => UnaryOp::ArrayGen(),
     "record_map" => UnaryOp::RecordMap(),
     "seq" => UnaryOp::Seq(),
-    "deep_seq" => UnaryOp::DeepSeq(None),
-    "op force" => UnaryOp::Force(None),
+    "deep_seq" => UnaryOp::DeepSeq(),
+    "op force" => UnaryOp::Force(),
     "length" => UnaryOp::ArrayLength(),
     "fields" => UnaryOp::FieldsOf(),
     "values" => UnaryOp::ValuesOf(),
     "str_trim" => UnaryOp::StrTrim(),
     "str_chars" => UnaryOp::StrChars(),
-    "char_code" => UnaryOp::CharCode(),
-    "char_from_code" => UnaryOp::CharFromCode(),
     "str_uppercase" => UnaryOp::StrUppercase(),
     "str_lowercase" => UnaryOp::StrLowercase(),
     "str_length" => UnaryOp::StrLength(),
@@ -842,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(),
@@ -1003,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),
@@ -1054,8 +1052,6 @@ extern {
         "str_split" => Token::Normal(NormalToken::StrSplit),
         "str_trim" => Token::Normal(NormalToken::StrTrim),
         "str_chars" => Token::Normal(NormalToken::StrChars),
-        "char_code" => Token::Normal(NormalToken::CharCode),
-        "char_from_code" => Token::Normal(NormalToken::CharFromCode),
         "str_uppercase" => Token::Normal(NormalToken::StrUppercase),
         "str_lowercase" => Token::Normal(NormalToken::StrLowercase),
         "str_contains" => Token::Normal(NormalToken::StrContains),
diff --git a/src/parser/lexer.rs b/src/parser/lexer.rs
index 099ba731..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%")]
@@ -286,10 +286,6 @@ pub enum NormalToken<'input> {
     StrTrim,
     #[token("%str_chars%")]
     StrChars,
-    #[token("%char_code%")]
-    CharCode,
-    #[token("%char_from_code%")]
-    CharFromCode,
     #[token("%str_uppercase%")]
     StrUppercase,
     #[token("%str_lowercase%")]
diff --git a/src/pretty.rs b/src/pretty.rs
index 259258e2..e24d2bca 100644
--- a/src/pretty.rs
+++ b/src/pretty.rs
@@ -300,7 +300,7 @@ where
             ArrayGen() => allocator.text("%gen%").append(allocator.space()),
             ArrayMap() => allocator.text("%map%").append(allocator.space()),
             ArrayLength() => allocator.text("%length%").append(allocator.space()),
-            DeepSeq(_) => allocator.text("%deep_seq%").append(allocator.space()),
+            DeepSeq() => allocator.text("%deep_seq%").append(allocator.space()),
             Typeof() => allocator.text("%typeof%").append(allocator.space()),
             BoolNot() => allocator.text("!"),
             BoolAnd() => allocator.space().append(allocator.text("&&")),
@@ -311,7 +311,7 @@ where
                 .append(allocator.space())
                 .append(allocator.as_string(id))
                 .append(allocator.space()),
-            Force(_) => allocator.text("%force%").append(allocator.space()),
+            Force() => allocator.text("%force%").append(allocator.space()),
             op => allocator
                 .text(format!("%{op:?}%").to_lowercase())
                 .append(allocator.space()),
diff --git a/src/program.rs b/src/program.rs
index cb58a8d3..6be45e78 100644
--- a/src/program.rs
+++ b/src/program.rs
@@ -569,7 +569,7 @@ mod tests {
         // [2, "ab", [1, [3]]]
         let expd = mk_array!(
             mk_term::integer(2),
-            Term::Str(String::from("ab")),
+            Term::Str("ab".into()),
             mk_array!(
                 mk_term::integer(1),
                 mk_array!(mk_term::integer(3); ArrayAttrs::new().closurized());
diff --git a/src/stdlib.rs b/src/stdlib.rs
index 72881c13..179b0d03 100644
--- a/src/stdlib.rs
+++ b/src/stdlib.rs
@@ -110,8 +110,8 @@ macro_rules! generate_accessor {
     };
 }
 
-/// Accessors to the builtin contracts.
-pub mod contract {
+/// Accessors to the builtin contracts and other internals that aren't accessible from user code.
+pub mod internals {
     use super::*;
 
     // `dyn` is a reserved keyword in rust
@@ -135,10 +135,7 @@ pub mod contract {
     generate_accessor!(forall_tail);
     generate_accessor!(dyn_tail);
     generate_accessor!(empty_tail);
-}
-
-pub mod internals {
-    use super::*;
+    generate_accessor!(contract_equal);
 
     generate_accessor!(rec_default);
     generate_accessor!(rec_force);
diff --git a/src/term/mod.rs b/src/term/mod.rs
index ca5ae766..1908a26b 100644
--- a/src/term/mod.rs
+++ b/src/term/mod.rs
@@ -19,9 +19,11 @@
 
 pub mod array;
 pub mod record;
+pub mod string;
 
 use array::{Array, ArrayAttrs};
 use record::{Field, FieldDeps, FieldMetadata, RecordData, RecordDeps};
+use string::NickelString;
 
 use crate::{
     destructuring::RecordPattern,
@@ -71,7 +73,7 @@ pub enum Term {
     #[serde(deserialize_with = "crate::serialize::deserialize_num")]
     Num(Number),
     /// A literal string.
-    Str(String),
+    Str(NickelString),
     /// A string containing interpolated expressions, represented as a list of either literals or
     /// expressions.
     ///
@@ -1013,11 +1015,7 @@ pub enum UnaryOp {
     ///
     /// Recursive here means that the evaluation does not stop at a WHNF, but the content of arrays
     /// and records is also recursively forced.
-    ///
-    /// The parameter is a fix used for error reporting of missing field definitions ocurring
-    /// during the deep sequencing of a record. This is temporary and should be stored somewhere
-    /// else ideally (like on the stack).
-    DeepSeq(Option<crate::eval::callstack::StackElem>),
+    DeepSeq(),
 
     /// Return the length of an array.
     ArrayLength(),
@@ -1039,10 +1037,6 @@ pub enum UnaryOp {
     StrTrim(),
     /// Return the array of characters of a string.
     StrChars(),
-    /// Return the code of a character (givne as a string of length 1).
-    CharCode(),
-    /// Return the character corresponding to a code.
-    CharFromCode(),
     /// Transform a string to uppercase.
     StrUppercase(),
     /// Transform a string to lowercase.
@@ -1083,7 +1077,7 @@ pub enum UnaryOp {
     ///
     /// It's also worth noting that [`UnaryOp::DeepSeq`] should be, in principle, more efficient that [`UnaryOp::Force`]
     /// as it does less cloning.
-    Force(Option<crate::eval::callstack::StackElem>),
+    Force(),
     /// Recursive default priority operator. Recursively propagates a default priority through a
     /// record, stopping whenever a field isn't a record anymore to then turn into a simple
     /// `default`.
@@ -1290,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(),
@@ -1939,7 +1927,7 @@ pub mod make {
 
     pub fn string<S>(s: S) -> RichTerm
     where
-        S: Into<String>,
+        S: Into<NickelString>,
     {
         Term::Str(s.into()).into()
     }
diff --git a/src/term/string.rs b/src/term/string.rs
new file mode 100644
index 00000000..eea1e20d
--- /dev/null
+++ b/src/term/string.rs
@@ -0,0 +1,424 @@
+use std::ops::{Deref, DerefMut};
+
+use malachite::Rational;
+use serde::{Deserialize, Serialize};
+use unicode_segmentation::UnicodeSegmentation;
+
+use super::{array::Array, Number, Term};
+
+/// A Nickel string is really just a Rust `String`, overlayed with some
+/// methods implementing custom logic (in particular, functions which
+/// avoid ever breaking up Unicode extended grapheme clusters.)
+#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
+pub struct NickelString(String);
+
+// Conversions to & from String-like things.
+
+impl<S> From<S> for NickelString
+where
+    String: From<S>,
+{
+    fn from(inner: S) -> Self {
+        Self(inner.into())
+    }
+}
+
+impl From<&NickelString> for String {
+    fn from(s: &NickelString) -> Self {
+        s.clone().into_inner()
+    }
+}
+
+// The below impls broadly allow `NclString`s to be treated just like
+// Rust `String`s.
+
+impl Deref for NickelString {
+    type Target = String;
+
+    fn deref(&self) -> &String {
+        &self.0
+    }
+}
+
+impl DerefMut for NickelString {
+    fn deref_mut(&mut self) -> &mut String {
+        &mut self.0
+    }
+}
+
+impl std::fmt::Display for NickelString {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "{}", self.0)
+    }
+}
+
+impl AsRef<str> for NickelString {
+    fn as_ref(&self) -> &str {
+        self.0.as_str()
+    }
+}
+
+impl NickelString {
+    /// Creates a new empty `NclString`. Which is to say, it creates a new,
+    /// empty `String`.
+    pub fn new() -> NickelString {
+        String::new().into()
+    }
+
+    /// The number of Unicode extended grapheme clusters the string contains.
+    ///
+    /// This method has `O(self.len())` time complexity.
+    pub fn length(&self) -> usize {
+        self.grapheme_cluster_count()
+    }
+
+    /// Returns the number of Unicode extended grapheme clusters the string
+    /// contains.
+    ///
+    /// This method has `O(self.len())` time complexity.
+    #[inline]
+    fn grapheme_cluster_count(&self) -> usize {
+        self.graphemes(true).count()
+    }
+
+    /// Returns an [`Array`] of Nickel strings, each one
+    /// containing a single Unicode extended grapheme cluster.
+    ///
+    /// This method has `O(self.len())` time complexity.
+    pub fn characters(&self) -> Array {
+        self.grapheme_clusters()
+    }
+
+    /// Returns an [`Array`] of Nickel strings, each one
+    /// containing a single Unicode extended grapheme cluster.
+    ///
+    /// This method has `O(self.len())` time complexity.
+    #[inline]
+    fn grapheme_clusters(&self) -> Array {
+        self.graphemes(true)
+            .map(|g| Term::Str(g.into()).into())
+            .collect()
+    }
+
+    /// Splits the string on `separator`, returning an [`Array`] of Nickel
+    /// strings.
+    ///
+    /// This method has `O(self.len() * separator.len())` time complexity when
+    /// the separator is non-empty, or `O(self.len())` otherwise.
+    pub fn split(&self, separator: &str) -> Array {
+        if separator.is_empty() {
+            // If there's no separator, we split the string between
+            // each grapheme cluster.
+            self.grapheme_clusters()
+        } else {
+            use grapheme_cluster_preservation::SearchEvent;
+
+            let mut result = Vec::new();
+
+            // We build our result vec by searching through the string for the separator.
+            grapheme_cluster_preservation::search(self, separator).for_each(|e| match e {
+                SearchEvent::Match {
+                    // If we hit a match, then everything we saw between the previous
+                    // match and now is a split...
+                    since_last_match: split,
+                } // ...then we do the same with whatever's left at the end.
+                | SearchEvent::LastNonMatch { non_match: split } => {
+                    result.push(Term::Str(split.into()).into())
+                }
+            });
+
+            Array::from_iter(result.into_iter())
+        }
+    }
+
+    /// Returns `true` if `needle` is contained in `self`, and `false` otherwise.
+    ///
+    /// Note that in contrast to Rust `String`'s `contains` method, this method
+    /// will not consider a Unicode codepoint to be contained in a string if
+    /// it exists as part of a larger extended grapheme cluster.
+    ///
+    /// The time complexity of this method is `O(self.len() * needle.len())`.
+    pub fn contains(&self, needle: &str) -> bool {
+        if needle.is_empty() {
+            true
+        } else {
+            use grapheme_cluster_preservation::SearchEvent;
+
+            grapheme_cluster_preservation::search(self, needle)
+                .any(|e| matches!(e, SearchEvent::Match { .. }))
+        }
+    }
+
+    /// Returns a new `NclString` replacing every occurence of `from` with `to`.
+    ///
+    /// This method has time complexity `O(self.len() * from.len())`.
+    pub fn replace(&self, from: &str, to: &str) -> NickelString {
+        let mut result = String::with_capacity(self.capacity());
+
+        if from.is_empty() {
+            // If `from` is empty then we:
+            //   1. insert `to` at the beginning.
+            result.push_str(to);
+            //   2. insert `to` after each cluster.
+            self.graphemes(true)
+                .flat_map(|grapheme| [grapheme, to])
+                .for_each(|s| result.push_str(s))
+        } else {
+            use grapheme_cluster_preservation::SearchEvent;
+
+            grapheme_cluster_preservation::search(self, from).for_each(|e| match e {
+                // If we hit a match...
+                SearchEvent::Match { since_last_match } => {
+                    // ...we write everything we've seen since the last match...
+                    result.push_str(since_last_match);
+                    // ...and then we replace the matched `from` with `to`.
+                    result.push_str(to);
+                }
+                // We also need to write anything remaining after the last match.
+                SearchEvent::LastNonMatch { non_match } => result.push_str(non_match),
+            });
+        }
+
+        result.into()
+    }
+
+    /// Returns the substring of `self` between `start` and `end`.
+    ///
+    /// Returns an error if:
+    ///    - either start or end is not a fraction
+    ///    - start < 0
+    ///    - start > end
+    ///
+    /// The time complexity of this method is `O(self.len())`.
+    pub fn substring(&self, start: &Number, end: &Number) -> Result<NickelString, SubstringError> {
+        let Ok(start_usize) = usize::try_from(start) else {
+            return Err(SubstringError::NonIntStart(start.clone()));
+        };
+
+        let Ok(end_usize) = usize::try_from(end) else {
+            return Err(SubstringError::NonIntEnd(end.clone()));
+        };
+
+        let mut from_start = self.graphemes(true).skip(start_usize).peekable();
+
+        // If `from_start` is `None` then we skipped past the end of `self`.
+        if from_start.peek().is_none() {
+            Err(SubstringError::StartOutOfBounds {
+                start: start.clone(),
+                str_len: self.grapheme_cluster_count(),
+            })
+        } else if end_usize < start_usize {
+            Err(SubstringError::EndOutOfBounds {
+                start: start.clone(),
+                end: end.clone(),
+                str_len: self.grapheme_cluster_count(),
+            })
+        } else {
+            let wanted_substr_len = end_usize - start_usize;
+            let substr: String = from_start.take(wanted_substr_len).collect();
+            let substr: NickelString = substr.into();
+            // If `substr.grapheme_cluster_count()` is smaller than we wanted
+            // then end was larger than the length of the string.
+            if substr.grapheme_cluster_count() != wanted_substr_len {
+                Err(SubstringError::EndOutOfBounds {
+                    start: start.clone(),
+                    end: end.clone(),
+                    str_len: self.grapheme_cluster_count(),
+                })
+            } else {
+                Ok(substr)
+            }
+        }
+    }
+
+    /// Consumes `self`, returning the Rust `String`.
+    pub fn into_inner(self) -> String {
+        self.0
+    }
+}
+
+/// Errors returned by `NclString`'s `substring` method.
+pub enum SubstringError {
+    /// The start index was not an int
+    NonIntStart(Rational),
+    /// The end index was not an int
+    NonIntEnd(Rational),
+    /// The start index was not within the bounds of the string
+    StartOutOfBounds { start: Rational, str_len: usize },
+    EndOutOfBounds {
+        start: Rational,
+        end: Rational,
+        str_len: usize,
+    },
+}
+
+impl std::fmt::Display for SubstringError {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        use SubstringError::*;
+
+        write!(f, "substring: ")?;
+
+        match self {
+            NonIntStart(start) => write!(
+                f,
+                "expected 2nd argument (start) to be a positive integer smaller than {}, got {start}",
+                usize::MAX
+            ),
+            NonIntEnd(end) => write!(
+                f,
+                "expected 3rd argument (end) to be a positive integer smaller than {}, got {end}",
+                usize::MAX
+            ),
+            StartOutOfBounds { start, str_len } => write!(
+                f,
+                "index out of bounds. \
+                 Expected 2nd argument (start) to be between 0 and {str_len}, got {start}"
+            ),
+            EndOutOfBounds {
+                start,
+                end,
+                str_len,
+            } => write!(
+                f,
+                "index out of bounds. \
+                Expected 3rd argument (end) to be between {start} and {str_len}, got {end}"
+            ),
+        }
+    }
+}
+
+/// Types and functions designed to make it as easy as possible not to accidentally
+/// break up Unicode extended grapheme clusters in string operations.
+mod grapheme_cluster_preservation {
+    use unicode_segmentation::GraphemeCursor;
+
+    /// Returns a [`SearchIter`], which is an `Iterator` for iterating through
+    /// `haystack` in order to find `needle`.
+    ///
+    /// [`SearchIter`] produces [`SearchEvent`]s which can be used to
+    /// implement a variety of different behaviours.
+    pub fn search<'a>(haystack: &'a str, needle: &'a str) -> SearchIter<'a> {
+        SearchIter {
+            haystack,
+            needle,
+            cursor: GraphemeCursor::new(0, haystack.len(), true),
+            current_split_start: 0,
+            last_boundary: 0,
+        }
+    }
+
+    /// An event emitted by [`SearchIter`].
+    pub enum SearchEvent<'a> {
+        /// The `needle` was found within `haystack`.
+        /// Since the caller already has a reference to `needle`, we don't
+        /// include it in this event. However, we do include every part of
+        /// `haystack` which was traversed up until the match happened.
+        Match {
+            /// The slice of the string from the end of the last match
+            /// up to this one.
+            since_last_match: &'a str,
+        },
+        /// Iteration through `haystack` has finished, but there was
+        /// an unconsumed (non-matching) slice which the caller may wish
+        /// to do something with.
+        LastNonMatch {
+            /// Any non-matching text from the end of the string.
+            non_match: &'a str,
+        },
+    }
+
+    /// Our base algorithm for implementing a variety of grapheme cluster
+    /// preserving methods on [`NclString`]. As the name suggests, it is an
+    /// `Iterator`. However, rather than iterating through grapheme clusters
+    /// directly, its `Item` is a [`SearchEvent`].
+    pub struct SearchIter<'a> {
+        haystack: &'a str,
+        needle: &'a str,
+        cursor: GraphemeCursor,
+        current_split_start: usize,
+        last_boundary: usize,
+    }
+
+    impl<'a> Iterator for SearchIter<'a> {
+        type Item = SearchEvent<'a>;
+
+        fn next(&mut self) -> Option<Self::Item> {
+            use indoc::indoc;
+
+            let unwrap_explanation = indoc! {"
+                None of the GraphemeIncomplete errors are possible here:
+                    - PreContext and PrevChunk only happen if chunk_start is nonzero.
+                    - NextChunk only happens if the chunk is smaller than the cursor's len parameter
+                      but we pass `haystack` and `hackstack.len()`` respectively.
+                    - InvalidOffset can't happen because we check that `haystack` contains `needle`
+                      in the range (last_boundary, last_boundary + needle.len())
+            "};
+
+            loop {
+                let nxt = self
+                    .cursor
+                    .next_boundary(self.haystack, 0)
+                    .expect(unwrap_explanation);
+                match nxt {
+                    Some(next_boundary) => {
+                        // To check whether a match has occured, we'll first check whether
+                        // the slice of `haystack` starting at the `last_boundary` begins
+                        // with `needle`. If it does, we *also* need to check that this
+                        // instance of `needle` doesn't end in the middle of a cluster.
+                        // This is to avoid the situtaion where we have a string like
+                        // `"a🧑‍🤝‍🧑"` and we get a match by searching for `"a🧑"`.
+                        let does_match_intersect_cluster = || {
+                            let mut tmp_cursor = self.cursor.clone();
+                            tmp_cursor.set_cursor(self.last_boundary + self.needle.len());
+                            !tmp_cursor
+                                .is_boundary(self.haystack, 0)
+                                .expect(unwrap_explanation)
+                        };
+
+                        if self.haystack[self.last_boundary..].starts_with(self.needle)
+                            && !does_match_intersect_cluster()
+                        {
+                            // Yay, we found a match! We start by grabbing the slice of
+                            // the string we traversed while looking for the match.
+                            let since_last_match =
+                                &self.haystack[self.current_split_start..self.last_boundary];
+                            // Now we move the cursor to after the match, to avoid needlessly
+                            // traversing that part of the string again.
+                            // Note that in particular this means that if we seach for "aa" in
+                            // a string like "aaaa" we will get two matches: the first pair,
+                            // and then the second.
+                            let match_end = self.last_boundary + self.needle.len();
+                            self.cursor.set_cursor(match_end);
+                            self.current_split_start = match_end;
+                            self.last_boundary = match_end;
+                            // Finally we return the part of the string we traversed
+                            // to get to this match back to the caller.
+                            break Some(SearchEvent::Match { since_last_match });
+                        } else {
+                            // This is the only codepath which doesn't break
+                            // the loop. This is because we didn't match `needle`
+                            // so we're just going to move on to the next
+                            // cluster boundary.
+                            self.last_boundary = next_boundary;
+                        }
+                    }
+                    None => {
+                        // We finished iterating through the grapheme clusters, but
+                        // there could still be a final chunk of the string that we
+                        // haven't told the caller about yet.
+                        if self.current_split_start < self.haystack.len() {
+                            // We pass that slice back here & advance the `current_split_start`
+                            // pointer to the end of the string to ensure we don't hit this
+                            // condition again & will correctly return None on the next call.
+                            let non_match = &self.haystack[self.current_split_start..];
+                            self.current_split_start = self.haystack.len();
+                            break Some(SearchEvent::LastNonMatch { non_match });
+                        } else {
+                            break None;
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
diff --git a/src/transform/desugar_destructuring.rs b/src/transform/desugar_destructuring.rs
index 86d508c7..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()), x)
+                op2(DynRemove(), Term::Str((*i).into()), x)
             }
         }),
         body,
diff --git a/src/typecheck/operation.rs b/src/typecheck/operation.rs
index cff23972..ac1ad205 100644
--- a/src/typecheck/operation.rs
+++ b/src/typecheck/operation.rs
@@ -109,7 +109,7 @@ pub fn get_uop_type(
             )
         }
         // forall a b. a -> b -> b
-        UnaryOp::Seq() | UnaryOp::DeepSeq(_) => {
+        UnaryOp::Seq() | UnaryOp::DeepSeq() => {
             let fst = UnifType::UnifVar(state.table.fresh_type_var_id());
             let snd = UnifType::UnifVar(state.table.fresh_type_var_id());
 
@@ -144,10 +144,6 @@ pub fn get_uop_type(
         UnaryOp::StrTrim() => (mk_uniftype::str(), mk_uniftype::str()),
         // Str -> Array Str
         UnaryOp::StrChars() => (mk_uniftype::str(), mk_uniftype::array(mk_uniftype::str())),
-        // Str -> Num
-        UnaryOp::CharCode() => (mk_uniftype::str(), mk_uniftype::num()),
-        // Num -> Str
-        UnaryOp::CharFromCode() => (mk_uniftype::num(), mk_uniftype::str()),
         // Str -> Str
         UnaryOp::StrUppercase() => (mk_uniftype::str(), mk_uniftype::str()),
         // Str -> Str
@@ -192,7 +188,7 @@ pub fn get_uop_type(
             ),
         ),
         // Dyn -> Dyn
-        UnaryOp::Force(_) => (mk_uniftype::dynamic(), mk_uniftype::dynamic()),
+        UnaryOp::Force() => (mk_uniftype::dynamic(), mk_uniftype::dynamic()),
         // forall a. a -> a
         UnaryOp::RecDefault() => {
             let ty = state.table.fresh_type_uvar();
@@ -386,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/src/types.rs b/src/types.rs
index e52aa0c2..e5c2bad3 100644
--- a/src/types.rs
+++ b/src/types.rs
@@ -709,7 +709,7 @@ fn get_var_contract(
 
 impl EnumRows {
     fn subcontract(&self) -> Result<RichTerm, UnboundTypeVariableError> {
-        use crate::stdlib::contract;
+        use crate::stdlib::internals;
 
         let mut cases = HashMap::new();
         let mut has_tail = false;
@@ -752,14 +752,14 @@ impl EnumRows {
             mk_app!(
                 Term::Match {
                     cases,
-                    default: Some(mk_app!(contract::enum_fail(), mk_term::var(label_arg))),
+                    default: Some(mk_app!(internals::enum_fail(), mk_term::var(label_arg))),
                 },
                 mk_term::var(value_arg)
             )
         };
         let case = mk_fun!(label_arg, value_arg, case_body);
 
-        Ok(mk_app!(contract::enums(), case))
+        Ok(mk_app!(internals::enums(), case))
     }
 
     pub fn iter(&self) -> EnumRowsIterator<EnumRows> {
@@ -775,7 +775,7 @@ impl RecordRows {
         pol: Polarity,
         sy: &mut i32,
     ) -> Result<RichTerm, UnboundTypeVariableError> {
-        use crate::stdlib::contract;
+        use crate::stdlib::internals;
 
         // We begin by building a record whose arguments are contracts
         // derived from the types of the statically known fields.
@@ -794,8 +794,8 @@ impl RecordRows {
         // Now that we've dealt with the row extends, we just need to
         // work out the tail.
         let tail = match &rrows.0 {
-            RecordRowsF::Empty => contract::empty_tail(),
-            RecordRowsF::TailDyn => contract::dyn_tail(),
+            RecordRowsF::Empty => internals::empty_tail(),
+            RecordRowsF::TailDyn => internals::dyn_tail(),
             RecordRowsF::TailVar(id) => get_var_contract(&vars, id)?,
             // Safety: the while above excludes that `tail` can have the form `Extend`.
             RecordRowsF::Extend { .. } => unreachable!(),
@@ -803,7 +803,7 @@ impl RecordRows {
 
         let rec = RichTerm::from(Term::Record(RecordData::with_field_values(fcs)));
 
-        Ok(mk_app!(contract::record(), rec, tail))
+        Ok(mk_app!(internals::record(), rec, tail))
     }
 
     /// Find a nested binding in a record row type. The nested field is given as a list of
@@ -903,19 +903,19 @@ impl Types {
         pol: Polarity,
         sy: &mut i32,
     ) -> Result<RichTerm, UnboundTypeVariableError> {
-        use crate::stdlib::contract;
+        use crate::stdlib::internals;
 
         let ctr = match self.types {
-            TypeF::Dyn => contract::dynamic(),
-            TypeF::Number => contract::num(),
-            TypeF::Bool => contract::bool(),
-            TypeF::String => contract::string(),
+            TypeF::Dyn => internals::dynamic(),
+            TypeF::Number => internals::num(),
+            TypeF::Bool => internals::bool(),
+            TypeF::String => internals::string(),
             //TODO: optimization: have a specialized contract for `Array Dyn`, to avoid mapping an
             //always successful contract on each element.
-            TypeF::Array(ref ty) => mk_app!(contract::array(), ty.subcontract(vars, pol, sy)?),
+            TypeF::Array(ref ty) => mk_app!(internals::array(), ty.subcontract(vars, pol, sy)?),
             TypeF::Symbol => panic!("Are you trying to check a Sym at runtime?"),
             TypeF::Arrow(ref s, ref t) => mk_app!(
-                contract::func(),
+                internals::func(),
                 s.subcontract(vars.clone(), pol.flip(), sy)?,
                 t.subcontract(vars, pol, sy)?
             ),
@@ -930,14 +930,14 @@ impl Types {
 
                 let sealing_key = Term::SealingKey(*sy);
                 let contract = match var_kind {
-                    Type => mk_app!(contract::forall_var(), sealing_key.clone()),
-                    EnumRows | RecordRows => mk_app!(contract::forall_tail(), sealing_key.clone()),
+                    Type => mk_app!(internals::forall_var(), sealing_key.clone()),
+                    EnumRows | RecordRows => mk_app!(internals::forall_tail(), sealing_key.clone()),
                 };
                 vars.insert(*var, contract);
 
                 *sy += 1;
                 mk_app!(
-                    contract::forall(),
+                    internals::forall(),
                     sealing_key,
                     Term::from(pol),
                     body.subcontract(vars, pol, sy)?
@@ -946,9 +946,9 @@ impl Types {
             TypeF::Enum(ref erows) => erows.subcontract()?,
             TypeF::Record(ref rrows) => rrows.subcontract(vars, pol, sy)?,
             TypeF::Dict(ref ty) => {
-                mk_app!(contract::dyn_record(), ty.subcontract(vars, pol, sy)?)
+                mk_app!(internals::dyn_record(), ty.subcontract(vars, pol, sy)?)
             }
-            TypeF::Wildcard(_) => contract::dynamic(),
+            TypeF::Wildcard(_) => internals::dynamic(),
         };
 
         Ok(ctr)
diff --git a/stdlib/contract.ncl b/stdlib/contract.ncl
index 2671d703..faba8940 100644
--- a/stdlib/contract.ncl
+++ b/stdlib/contract.ncl
@@ -1,5 +1,54 @@
 {
   contract = {
+    Equal
+      | doc m%"
+        `Equal` is a contract enforcing equality to a given constant.
+
+        `Equal` is lazy over arrays and records. When checking such values,
+        `Equal` doesn't test for equality of elements right away (it just tests
+        that the size is the same for arrays and that fields are the same for
+        records), but returns a new value where an equality contract has been
+        pushed down each element.
+
+        # Example
+
+        ```nickel
+        1 + 4 | Equal 5
+         => 5
+        4 | Equal 5
+         => ERROR (contract broken by a value)
+        ```
+      "%
+    = fun constant =>
+      %typeof% constant
+      |> match {
+        `Record =>
+          #TODO: lazy version for records. The difficulty is not to make it lazy
+          #right now, but to make it preserve recursivity as well. We might need
+          #something like a recursivity-preserving map, or a primop for
+          # per-field contract application, like %array_lazy_assume%.
+          from_predicate ((==) constant),
+        `Array =>
+          fun label' value =>
+            if %typeof% value == `Array then
+              let value_length = %length% value in
+              if value_length == %length% constant then
+                %generate% value_length (fun i =>
+                  contract.apply (Equal (%elem_at% constant i)) label' value)
+              else
+                label'
+                |> label.with_message "array length mismatch (expected `%{%to_str% (%length% constant)}`, got `%{%to_str% value_length}`)"
+                |> label.append_note "`Equal some_array` requires that the checked value is equal to the array `some_array`, but their lengths differ."
+                |> blame
+            else
+              label'
+                |> label.with_message "expected Array, got %{%to_str% (%typeof% value)}"
+                |> label.append_note "`Equal some_array` requires that the checked value is equal to the array `some_array`, but the provided value isn't an array."
+                |> blame,
+        # Outside of lazy data structure, we just use (==)
+        _ => from_predicate ((==) constant),
+      },
+
     blame
       | doc m%"
         Raise blame for a given label.
diff --git a/stdlib/internals.ncl b/stdlib/internals.ncl
index 7a7dabf4..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),
 
@@ -146,4 +146,11 @@
 
   "$rec_force" = fun value => %rec_force% (%force% value),
   "$rec_default" = fun value => %rec_default% (%force% value),
+
+  # Provide access to contract.Equal within the initial environement. Merging
+  # makes use of `contract.Equal`, but it can't blindly substitute such an
+  # expression, because `contract` might have been redefined locally. Putting it
+  # in an internal value prefixed with `$` makes it accessible from the initial
+  # environment and prevents it from being shadowed.
+  "$stdlib_contract_equal" = contract.Equal,
 }
diff --git a/stdlib/record.ncl b/stdlib/record.ncl
index f698ca85..38c8d450 100644
--- a/stdlib/record.ncl
+++ b/stdlib/record.ncl
@@ -195,5 +195,19 @@
       |> to_array
       |> array.filter (fun { field, value } => f field value)
       |> from_array,
+
+    length
+      : forall a. {_: a} -> Number
+      | doc m%"
+          Returns the number of fields in a record. This count doesn't include
+          fields both marked as optional and without a definition.
+
+          Because of the need to filter empty optional fields, the cost of
+          `length` is linear in the size of the record.
+        "%
+      = fun record =>
+        record
+        |> fields
+        |> array.length,
   }
 }
diff --git a/stdlib/string.ncl b/stdlib/string.ncl
index 1c072a51..a6589bb9 100644
--- a/stdlib/string.ncl
+++ b/stdlib/string.ncl
@@ -156,7 +156,7 @@
 
     split : String -> String -> Array String
     | doc m%"
-      Splits a string based on a separator string. The separator string is not included in any string.
+      Splits a string based on a separator string.
 
       For example:
       ```nickel
@@ -165,6 +165,9 @@
       split "." "1,2,3" =>
         [ "1,2,3" ]
       ```
+
+      Note that this function never splits up Unicode extended grapheme
+      clusters, even in cases where the sought value exists within one.
       "%
     = fun sep s => %str_split% s sep,
 
@@ -184,7 +187,7 @@
 
     characters : String -> Array String
     | doc m%"
-      Separates a string into its individual characters.
+      Separates a string into its individual Unicode extended grapheme clusters.
 
       For example:
       ```nickel
@@ -194,38 +197,6 @@
       "%
     = fun s => %str_chars% s,
 
-    codepoint | Character -> Number
-    | doc m%%"
-      Results in the unicode codepoint of the given character if it fits into a single codepoint.
-
-      For example:
-      ```nickel
-        code "A" =>
-          65
-        code "%" =>
-          37
-        code "å" =>
-          error
-      ```
-      "%%
-    = fun s => %char_code% s,
-
-    from_codepoint | Number -> Character
-    | doc m%%"
-      Results in the character for a given unicode codepoint.
-
-      For example:
-      ```nickel
-        from_code 65 =>
-          "A"
-        from_code 37 =>
-          "%"
-        from_code 128 =>
-          error
-      ```
-      "%%
-    = fun s => %char_from_code% s,
-
     uppercase : String -> String
     | doc m%"
       Results in the uppercase version of the given character (including non-ascii characters) if it exists, the same
@@ -273,12 +244,15 @@
         contains "ghj" "abcdef" =>
           false
       ```
+
+      Note that this function returns false if the sought string exists inside
+      a Unicode extended grapheme cluster.
       "%
     = fun subs s => %str_contains% s subs,
 
     replace: String -> String -> String -> String
     | doc m%"
-      `replace sub repl String` replaces every occurrence of `sub` in `String` with `repl`.
+      `replace sub repl str` replaces every occurrence of `sub` in `str` with `repl`.
 
       For example:
       ```nickel
@@ -287,6 +261,9 @@
         replace "" "A" "abcdef" =>
           "AaAbAcAdAeAfA"
       ```
+
+      Note that this function will not replace `sub` if it exists within a
+      larger unicode extended grapheme cluster.
       "%
     = fun pattern replace s =>
        %str_replace% s pattern replace,
@@ -385,6 +362,9 @@
     | doc m%"
       Takes a slice from the string. Errors if either index is out of range.
 
+      The index arguments are the indices of Unicode extended grapheme clusters
+      rather than codepoints.
+
       For example:
       ```nickel
         substring 3 5 "abcdef" =>
@@ -425,7 +405,6 @@
       "%
     = from,
 
-    # from_enum | < | Dyn> -> String = fun tag => %to_str% tag,
     from_enum | enum.Tag -> String
     | doc m%"
       Converts an enum variant to its string representation.
@@ -478,7 +457,6 @@
       "%
     = fun s => s == "true",
 
-    # to_enum | String -> < | Dyn> = fun s => %enum_from_str% s,
     to_enum | String -> enum.Tag
     | doc m%"
       Converts any string that represents an enum variant to that enum variant.


Test patch:
diff --git a/src/eval/tests.rs b/src/eval/tests.rs
index 24720787..87ba1043 100644
--- a/src/eval/tests.rs
+++ b/src/eval/tests.rs
@@ -213,7 +213,7 @@ fn interpolation_simple() {
     let t: RichTerm = Term::StrChunks(chunks).into();
     assert_eq!(
         eval_no_import(t),
-        Ok(Term::Str(String::from("Hello, World! How are you?")))
+        Ok(Term::Str("Hello, World! How are you?".into()))
     );
 }
 
@@ -247,7 +247,7 @@ fn interpolation_nested() {
     let t: RichTerm = Term::StrChunks(chunks).into();
     assert_eq!(
         eval_no_import(t),
-        Ok(Term::Str(String::from("Hello, World! How are you?")))
+        Ok(Term::Str("Hello, World! How are you?".into()))
     );
 }
 
diff --git a/src/parser/tests.rs b/src/parser/tests.rs
index 60d4c842..e8021bff 100644
--- a/src/parser/tests.rs
+++ b/src/parser/tests.rs
@@ -56,7 +56,7 @@ fn mk_symbolic_single_chunk(prefix: &str, s: &str) -> RichTerm {
             (
                 FieldPathElem::Ident("fragments".into()),
                 Field::from(RichTerm::from(Array(
-                    Array::new(Rc::new([Str(String::from(s)).into()])),
+                    Array::new(Rc::new([Str(s.into()).into()])),
                     Default::default(),
                 ))),
             ),
diff --git a/tests/integration/pass/stdlib_string.ncl b/tests/integration/pass/stdlib_string.ncl
deleted file mode 100644
index c994178c..00000000
--- a/tests/integration/pass/stdlib_string.ncl
+++ /dev/null
@@ -1,131 +0,0 @@
-let {check, ..} = import "lib/assert.ncl" in
-
-[
-  # happy path for all stdlib functions
-  # string.BoolLiteral
-  ("True" | string.BoolLiteral) == "true",
-  ("False" | string.BoolLiteral) == "false",
-  ("true" | string.BoolLiteral) == "true",
-  ("false" | string.BoolLiteral) == "false",
-  # string.NumLiteral
-  ("+1.2" | string.NumLiteral) == "+1.2",
-  ("-1.2" | string.NumLiteral) == "-1.2",
-  ("9001" | string.NumLiteral) == "9001",
-  # string.CharLiteral
-  ("e" | string.Character) == "e",
-  # enum.Tag
-  (`Foo | enum.Tag) == `Foo,
-  # string.Stringable
-  (`Foo | string.Stringable) == `Foo,
-  (true | string.Stringable) == true,
-  (1 | string.Stringable) == 1,
-  ("_" | string.NonEmpty) == "_",
-  # string.join
-  string.join " " ["Hello", "世界"] == "Hello 世界",
-  string.join "anything" [] == "",
-  string.join "a" ["b", "n", "n", ""] == "banana",
-  # string.split
-  string.split "," "1,2,3" == ["1", "2", "3"],
-  string.split "" "123" == ["", "1", "2", "3", ""],
-  string.split "❤" "👩‍❤️‍💋‍👨" == [ "👩‍", "️‍💋‍👨" ],
-  string.split "‍" "😶‍🌫️" == ["😶", "🌫️"],
-  string.split "" "" == ["", ""],
-  # string.trim
-  string.trim "  ひげ  " == "ひげ",
-  string.trim "   " == "",
-  string.trim "" == "",
-  string.trim "​ ​ ​ a ​ ​ ​" == "​ ​ ​ a ​ ​ ​", # zero width spaces aren't whitespace
-  string.trim "   \n   new\nline\n" == "new\nline",
-  string.trim "　   	  " == "",
-  # string.characters
-  string.characters "漢字" == ["漢", "字"],
-  string.characters "👨‍❤️‍💋‍👨" == ["👨", "‍", "❤", "️", "‍", "💋", "‍", "👨"], # arguably wrong, splits the ZWJ combined emoji
-  string.characters "ab_cd" == ["a", "b", "_", "c", "d"],
-  string.characters "" == [],
-  # string.code
-  string.codepoint "A" == 65,
-  string.codepoint "%" == 37,
-  # string.from_code
-  string.from_codepoint 65 == "A",
-  string.from_codepoint 37 == "%",
-  # code/from_code round trip
-  (array.all (fun code => (string.codepoint (string.from_codepoint code)) == code) (array.generate function.id 127)),
-  # string.uppercase
-  string.uppercase "abcd" == "ABCD",
-  string.uppercase "子供" == "子供",
-  string.uppercase "アαφ" == "アΑΦ",
-  string.uppercase "" == "",
-  # string.lowercase
-  string.lowercase "ABCD" == "abcd",
-  string.lowercase "子供" == "子供",
-  string.lowercase "アΑΦ" == "アαφ",
-  string.lowercase "" == "",
-  # string.contains
-  string.contains "" "",
-  string.contains "_" "__",
-  string.contains "\n" "\n\n\n",
-  !(string.contains "\n" "\r"),
-  !(string.contains "_" "no underscores"),
-  !(string.contains "👨‍❤️‍💋‍👨" "❤"),
-  # string.replace
-  string.replace "" "a" "bnn" == "abanana",
-  string.replace "the present" "the future" "the present is now" == "the future is now",
-  string.replace "nickel" "iron" "iron man" == "iron man",
-  # string.replace_regex
-  string.replace_regex "\\d" "_" "1,234,1 1" == "_,___,_ _",
-  string.replace_regex "\\d+" "_" "1,234,1 1" == "_,_,_ _",
-  string.replace_regex "\\d" "_" "１２３" == "___",
-  string.replace_regex "\\d" "_" "一二三" == "一二三",
-  # string.is_match
-  string.is_match "^___$" "___",
-  string.is_match "___" "___)",
-  !(string.is_match "^___$" "___)"),
-  # string.find
-  string.find "([0-9]{1,3}\\.){3}([0-9]{1,3})" "1.2.3.4" == { matched = "1.2.3.4", index = 0, groups = ["3.", "4"]},
-  string.find "([0-9]{1,3})\\.([0-9]{1,3})\\.([0-9]{1,3})\\.([0-9]{1,3})" "ip: 192.168.1.4, sorry, what's ipv6?" == { matched = "192.168.1.4", index = 4, groups = ["192", "168", "1", "4"]},
-  string.find "\\d" "no numeral" == { matched = "", index = -1, groups = []},
-
-  # string.length
-  string.length "" == 0,
-  string.length " " == 1,
-  string.length "nickel" == 6,
-  # Hungarian `ő` character.
-  string.length "unicőde" == 7,
-  # Vietnamese `ế ` character.
-  string.length "nickếl" == 6,
-  string.length "四字熟語" == 4,
-  string.length "👩🏿‍❤️‍💋‍👩🏼" == 1,
-  string.length "👨‍❤️‍💋‍👨" == 1,
-
-  # string.substring
-  string.substring 3 5 "abcdef" == "de",
-  # string.from
-  string.from true == "true",
-  string.from 1 == "1",
-  string.from "asdf" == "asdf",
-  string.from `Foo == "Foo",
-  # string.from_number
-  string.from_number (-1) == "-1",
-  string.from_number 1 == "1",
-  string.from_number 0 == "0",
-  string.from_number 9009 == "9009",
-  string.from_number 1.2 == "1.2",
-  # string.from_enum
-  string.from_enum `Enum == "Enum",
-  # string.to_number
-  string.to_number "1" == 1,
-  string.to_number "-1" == (-1),
-  string.to_number "-1.1" == (-1.1),
-  # string.to_bool
-  string.to_bool "True",
-  string.to_bool "true",
-  string.to_bool "false" == false,
-  string.to_bool "False" == false,
-  # string.to_enum
-  string.to_enum "" == `"",
-  string.to_enum "x" == `x,
-  string.to_enum "X" == `X,
-  string.to_enum "X" == string.to_enum "X",
-  string.to_enum "タグ" == `"タグ",
-]
-|> check
diff --git a/tests/integration/pass/stdlib_string_contains_find_replace.ncl b/tests/integration/pass/stdlib_string_contains_find_replace.ncl
new file mode 100644
index 00000000..e945d03f
--- /dev/null
+++ b/tests/integration/pass/stdlib_string_contains_find_replace.ncl
@@ -0,0 +1,37 @@
+let {check, ..} = import "lib/assert.ncl" in
+
+[
+  # string.contains
+  string.contains "" "",
+  string.contains "_" "__",
+  string.contains "\n" "\n\n\n",
+  !(string.contains "\n" "\r"),
+  !(string.contains "_" "no underscores"),
+  !(string.contains "❤" "👨‍❤️‍💋‍👨"),
+  !(string.contains "words👨" "words👨‍❤️‍💋‍👨"),
+
+  # string.replace
+  string.replace "" "a" "bnn" == "abanana",
+  string.replace "the present" "the future" "the present is now" == "the future is now",
+  string.replace "iron" "nickel" "iron man" == "nickel man",
+  string.replace "nickel" "iron" "iron man" == "iron man",
+  string.replace "❤" "egg" "👨‍❤️‍💋‍👨" == "👨‍❤️‍💋‍👨",
+  string.replace "has" "had" "this has multiple matches, has it?" == "this had multiple matches, had it?",
+  string.replace "hello👨" "goodbye" "hello👨‍❤️‍💋‍👨" == "hello👨‍❤️‍💋‍👨",
+
+  # string.replace_regex
+  string.replace_regex "\\d" "_" "1,234,1 1" == "_,___,_ _",
+  string.replace_regex "\\d+" "_" "1,234,1 1" == "_,_,_ _",
+  string.replace_regex "\\d" "_" "１２３" == "___",
+  string.replace_regex "\\d" "_" "一二三" == "一二三",
+
+  # string.is_match
+  string.is_match "^___$" "___",
+  string.is_match "___" "___)",
+  !(string.is_match "^___$" "___)"),
+
+  # string.find
+  string.find "([0-9]{1,3}\\.){3}([0-9]{1,3})" "1.2.3.4" == { matched = "1.2.3.4", index = 0, groups = ["3.", "4"]},
+  string.find "([0-9]{1,3})\\.([0-9]{1,3})\\.([0-9]{1,3})\\.([0-9]{1,3})" "ip: 192.168.1.4, sorry, what's ipv6?" == { matched = "192.168.1.4", index = 4, groups = ["192", "168", "1", "4"]},
+  string.find "\\d" "no numeral" == { matched = "", index = -1, groups = []},
+] |> check
\ No newline at end of file
diff --git a/tests/integration/pass/stdlib_string_contracts.ncl b/tests/integration/pass/stdlib_string_contracts.ncl
new file mode 100644
index 00000000..e2d59244
--- /dev/null
+++ b/tests/integration/pass/stdlib_string_contracts.ncl
@@ -0,0 +1,40 @@
+let {check, ..} = import "lib/assert.ncl" in
+
+[
+  # string.BoolLiteral
+  ("True" | string.BoolLiteral) == "true",
+  ("False" | string.BoolLiteral) == "false",
+  ("true" | string.BoolLiteral) == "true",
+  ("false" | string.BoolLiteral) == "false",
+
+  # string.NumLiteral
+  ("+1.2" | string.NumLiteral) == "+1.2",
+  ("-1.2" | string.NumLiteral) == "-1.2",
+  ("9001" | string.NumLiteral) == "9001",
+
+  # string.Character
+  # single ascii chars
+  ("e" | string.Character) == "e",
+  ("x" | string.Character) == "x",
+  # single unicode codepoints
+  ("😀" | string.Character) == "😀",
+  ("😶" | string.Character) == "😶",
+  # an extended grapheme cluster is also a "character"
+  ("ő" | string.Character) == "ő",
+  ("👩🏿‍❤️‍💋‍👩🏼" | string.Character) == "👩🏿‍❤️‍💋‍👩🏼",
+
+  # string.EnumTag
+  (`Foo | enum.Tag) == `Foo,
+  (`Barr | enum.Tag) == `Barr,
+
+  # string.Stringable
+  (`Foo | string.Stringable) == `Foo,
+  (true | string.Stringable) == true,
+  (1 | string.Stringable) == 1,
+  ("" | string.Stringable) == "",
+
+  # string.NonEmpty
+  ("_" | string.NonEmpty) == "_",
+  ("a" | string.NonEmpty) == "a",
+  (" " | string.NonEmpty) == " ",
+] |> check
\ No newline at end of file
diff --git a/tests/integration/pass/stdlib_string_conversions.ncl b/tests/integration/pass/stdlib_string_conversions.ncl
new file mode 100644
index 00000000..64c27717
--- /dev/null
+++ b/tests/integration/pass/stdlib_string_conversions.ncl
@@ -0,0 +1,38 @@
+let {check, ..} = import "lib/assert.ncl" in
+
+[
+  # string.from
+  string.from true == "true",
+  string.from 1 == "1",
+  string.from "asdf" == "asdf",
+  string.from `Foo == "Foo",
+
+  # string.from_number
+  string.from_number (-1) == "-1",
+  string.from_number 1 == "1",
+  string.from_number 0 == "0",
+  string.from_number 9009 == "9009",
+  string.from_number 1.2 == "1.2",
+
+  # string.from_enum
+  string.from_enum `Enum == "Enum",
+  string.from_enum `Magick == "Magick",
+
+  # string.to_number
+  string.to_number "1" == 1,
+  string.to_number "-1" == (-1),
+  string.to_number "-1.1" == (-1.1),
+
+  # string.to_bool
+  string.to_bool "True",
+  string.to_bool "true",
+  string.to_bool "false" == false,
+  string.to_bool "False" == false,
+
+  # string.to_enum
+  string.to_enum "" == `"",
+  string.to_enum "x" == `x,
+  string.to_enum "X" == `X,
+  string.to_enum "X" == string.to_enum "X",
+  string.to_enum "タグ" == `"タグ",
+] |> check
\ No newline at end of file
diff --git a/tests/integration/pass/stdlib_string_primitives.ncl b/tests/integration/pass/stdlib_string_primitives.ncl
new file mode 100644
index 00000000..068e7b47
--- /dev/null
+++ b/tests/integration/pass/stdlib_string_primitives.ncl
@@ -0,0 +1,35 @@
+let {check, ..} = import "lib/assert.ncl" in
+
+[
+  # string.characters
+  string.characters "漢字" == ["漢", "字"],
+  string.characters "👨‍❤️‍💋‍👨" == ["👨‍❤️‍💋‍👨"],
+  string.characters "ab_cd" == ["a", "b", "_", "c", "d"],
+  string.characters "" == [],
+  string.characters "😭👻👀" == ["😭", "👻", "👀"],
+  string.characters "👩🏾‍🦲👩🏻‍🦲👩🏿‍🦲" == ["👩🏾‍🦲", "👩🏻‍🦲", "👩🏿‍🦲"],
+  string.characters "unicőde" == ["u", "n", "i", "c", "ő", "d", "e"],
+
+  # string.length
+  string.length "" == 0,
+  string.length " " == 1,
+  string.length "nickel" == 6,
+  string.length m%"
+      a multiline string
+      stretched over lines
+      what length should it be?"% == 65,
+  # Hungarian `ő` character.
+  string.length "unicőde" == 7,
+  # Vietnamese `ế ` character.
+  string.length "nickếl" == 6,
+  string.length "四字熟語" == 4,
+  string.length "👩🏿‍❤️‍💋‍👩🏼" == 1,
+  string.length "👨‍❤️‍💋‍👨" == 1,
+
+  # string.substring
+  string.substring 3 5 "abcdef" == "de",
+  string.substring 0 6 "nickel is great" == "nickel",
+  string.substring 0 1 "👩🏻‍🦲" == "👩🏻‍🦲",
+  string.substring 1 6 "👨‍❤️‍💋‍👨hello" == "hello",
+  string.substring 2 4 "ab👨‍❤️‍💋‍👨👨‍❤️‍💋‍👨cd" == "👨‍❤️‍💋‍👨👨‍❤️‍💋‍👨",
+] |> check
diff --git a/tests/integration/pass/stdlib_string_split_join.ncl b/tests/integration/pass/stdlib_string_split_join.ncl
new file mode 100644
index 00000000..e695e827
--- /dev/null
+++ b/tests/integration/pass/stdlib_string_split_join.ncl
@@ -0,0 +1,32 @@
+let {check, ..} = import "lib/assert.ncl" in
+
+[
+  # string.join
+  string.join " " ["Hello", "世界"] == "Hello 世界",
+  string.join "anything" [] == "",
+  string.join "a" ["b", "n", "n", ""] == "banana",
+
+  # string.split
+  string.split "," "1,2,3" == ["1", "2", "3"],
+  string.split "" "123" == ["1", "2", "3"],
+  string.split "❤" "👩‍❤️‍💋‍👨" == ["👩‍❤️‍💋‍👨"],
+  string.split "‍" "😶‍🌫️" == ["😶‍🌫️"],
+  string.split "😶‍🌫️" "👩‍❤️‍💋‍👨😶‍🌫️👩‍❤️‍💋‍👨" == ["👩‍❤️‍💋‍👨", "👩‍❤️‍💋‍👨"],
+  string.split "" "" == [],
+  string.split "x" "abc" == ["abc"],
+  string.split "❤" "i❤u" == ["i", "u"],
+  string.split "abc" "xabcyabcz" == ["x", "y", "z"],
+  string.split "❤❤" "x❤❤y❤z" == ["x", "y❤z"],
+  string.split "a" "abacus" == ["", "b", "cus"],
+  string.split "a" "banana" == ["b", "n", "n"],
+  string.split "a👨" "a👨‍❤️‍💋‍👨" == ["a👨‍❤️‍💋‍👨"],
+
+  # string.split/string.join roundtrip
+  array.all (fun {sep, str} => str == string.join sep (string.split sep str)) [
+    { sep = ",", str = "a,b,c,d,e,f,g" },
+    { sep = ":", str = "1:2:3:4:5" },
+    { sep = "--", str = "yes--no--maybe--x--y--z" },
+    { sep = " ", str = "this is a sentence."},
+    { sep = "👩‍❤️‍💋‍👨", str = "-👩‍❤️‍💋‍👨👩‍❤️‍💋‍👨👩‍❤️‍💋‍👨-" },
+  ],
+] |> check
\ No newline at end of file
diff --git a/tests/integration/pass/stdlib_string_trim.ncl b/tests/integration/pass/stdlib_string_trim.ncl
new file mode 100644
index 00000000..9191574a
--- /dev/null
+++ b/tests/integration/pass/stdlib_string_trim.ncl
@@ -0,0 +1,20 @@
+let {check, ..} = import "lib/assert.ncl" in
+
+[
+  # string.trim
+  string.trim "  ひげ  " == "ひげ",
+  string.trim "   " == "",
+  string.trim "" == "",
+  # zero width spaces aren't whitespace
+  string.trim "​ ​ ​ a ​ ​ ​" == "​ ​ ​ a ​ ​ ​",
+  string.trim "   \n   new\nline\n" == "new\nline",
+  string.trim "　   	  " == "",
+  string.trim m%"
+  
+        a multiline string
+
+
+
+
+  "% == "a multiline string",
+] |> check
\ No newline at end of file
diff --git a/tests/integration/pass/stdlib_string_uppercase_lowercase.ncl b/tests/integration/pass/stdlib_string_uppercase_lowercase.ncl
new file mode 100644
index 00000000..917e50ea
--- /dev/null
+++ b/tests/integration/pass/stdlib_string_uppercase_lowercase.ncl
@@ -0,0 +1,33 @@
+let {check, ..} = import "lib/assert.ncl" in
+
+[
+    # string.uppercase
+  string.uppercase "abcd" == "ABCD",
+  string.uppercase "子供" == "子供",
+  string.uppercase "アαφ" == "アΑΦ",
+  string.uppercase "" == "",
+  string.uppercase "👨‍❤️‍💋‍👨" == "👨‍❤️‍💋‍👨",
+
+  # string.lowercase
+  string.lowercase "ABCD" == "abcd",
+  string.lowercase "子供" == "子供",
+  string.lowercase "アΑΦ" == "アαφ",
+  string.lowercase "" == "",
+  string.lowercase "👨‍❤️‍💋‍👨" == "👨‍❤️‍💋‍👨",
+
+  # string.uppercase/string.lowercase roundtrip
+  array.all (fun s => s == string.lowercase (string.uppercase s)) [
+    "abacus",
+    "abcd",
+    "xxxxyyyyzzzz",
+    "this is a test"
+  ],
+
+  # string.lowercase/string.uppercase roundtrip
+  array.all (fun s => s == string.uppercase (string.lowercase s)) [
+    "ABACUS",
+    "ABCD",
+    "XXXXYYYYZZZZ",
+    "THIS IS A TEST"
+  ],
+] |> check
\ No newline at end of file
diff --git a/tests/integration/pretty.rs b/tests/integration/pretty.rs
index 2a552b46..23ed0e33 100644
--- a/tests/integration/pretty.rs
+++ b/tests/integration/pretty.rs
@@ -106,7 +106,17 @@ fn string_interpolation() {
 
 #[test]
 fn stdlib_string() {
-    check_file("stdlib_string.ncl")
+    for f in [
+        "contains_find_replace",
+        "contracts",
+        "conversions",
+        "primitives",
+        "split_join",
+        "trim",
+        "uppercase_lowercase",
+    ] {
+        check_file(format!("stdlib_string_{}.ncl", f).as_str())
+    }
 }
 
 #[test]
@@ -143,7 +153,7 @@ fn overriding() {
 #[test]
 fn str_vs_strchunks() {
     assert_eq!(
-        pretty(&Term::Str("string".to_string()).into()),
+        pretty(&Term::Str("string".into()).into()),
         pretty(&Term::StrChunks(vec![StrChunk::Literal("string".to_string())]).into())
     );
 }