Repository: tweag/nickel
Pull request #1200 (2023-03-25 00:42:42)

Update stdlib string functions to work on Unicode extended grapheme clusters
This PR updates the standard library `string` module to function predominantly on Unicode [extended grapheme clusters](https://unicode.org/reports/tr29/).

In particular:
- `string.split`, `string.characters`, `string.replace`, `string.substring` and `string.contains` all avoid ever breaking up extended grapheme clusters,
- `string.codepoint` and `string.from_codepoint` have been removed,
- a lot of string functionality has been pulled out of `eval/operation.rs` and moved into a new `term/string.rs` module.

## What's left?

This PR doesn't touch any of the functions which take a regex argument. I'll make a follow-up PR when I get some time to work on it.

## Review tips

I'd recommend reviewing c72f490 and e197dab individually, but then skipping to 60132c0, as the commits in-between mostly reimplement the string operations in-place, whereas that commit refactors everything into the `string` module. It's likely simpler to just review the (heavily-commented) `string` module than each of the intermediate states.

Please let me know if you have any questions!


Patch:
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/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/mod.rs b/src/eval/mod.rs
index 35c82c37..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,
@@ -498,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 a0427d6e..0be5fe76 100644
--- a/src/eval/operation.rs
+++ b/src/eval/operation.rs
@@ -26,6 +26,7 @@ use crate::{
         array::{Array, ArrayAttrs},
         make as mk_term,
         record::{self, Field, FieldMetadata, RecordAttrs, RecordData},
+        string::NickelString,
         BinaryOp, MergePriority, NAryOp, Number, PendingContract, RecordExtKind, RichTerm,
         SharedTerm, StrChunk, Term, UnaryOp,
     },
@@ -35,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,
@@ -814,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()
                     };
@@ -843,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,
                         )))
                     }
@@ -861,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 {
@@ -875,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,
@@ -893,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 {
@@ -960,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 {
@@ -990,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.to_string().into())),
+                    Term::Null => Ok(Term::Str("null".into())),
                 } else {
                     Err(EvalError::Other(
                         format!(
@@ -1010,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),
@@ -1028,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 {
@@ -1118,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",
@@ -1133,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",
@@ -1578,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 {
@@ -1882,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,
@@ -1920,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,
@@ -2035,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 }),
@@ -2082,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 {
@@ -2271,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 {
@@ -2346,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 {
@@ -2410,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,
                     )))
                 }
@@ -2441,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"),
@@ -2641,7 +2592,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,
                 )))
             }
@@ -2680,7 +2631,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"),
@@ -2741,7 +2692,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(),
                 )))
             }
@@ -2808,12 +2759,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(
@@ -2860,28 +2811,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"),
diff --git a/src/parser/grammar.lalrpop b/src/parser/grammar.lalrpop
index 5c6f856a..8fca23f6 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();
 
@@ -659,8 +659,6 @@ UOp: UnaryOp = {
     "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(),
@@ -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..75e35391 100644
--- a/src/parser/lexer.rs
+++ b/src/parser/lexer.rs
@@ -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/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/term/mod.rs b/src/term/mod.rs
index ce933496..f74ccb04 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.
     ///
@@ -1035,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.
@@ -1935,7 +1933,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..101729da 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.to_string().into()), x)
             }
         }),
         body,
diff --git a/src/typecheck/operation.rs b/src/typecheck/operation.rs
index 7f6cc971..b50d88c8 100644
--- a/src/typecheck/operation.rs
+++ b/src/typecheck/operation.rs
@@ -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
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())
     );
 }