//! The s-expression value model of the Text Projection companion, and the //! [`TextValue`] trait that projects a canonical value into it and parses one //! back. //! //! # The projection is schema-directed //! //! `req:textproj:schema-directed`. Shape does not determine type. `()` is the //! empty sequence *and* the absent option; a bare symbol is a fieldless variant //! *and* a zero-field struct; a parenthesised list headed by a symbol is a struct //! *and* a sequence whose first element is a symbol. A reader resolves each by the //! type it expects, exactly as the binary decoder does. //! //! [`Sexp`] therefore carries only what the *lexer* can honestly know. It has no //! `Bool` variant, because `true` and `false` are spelled exactly like symbols and //! nothing but the expected type tells them apart; `bool` projects to //! [`Sexp::Symbol`]. It has no `Ratio` or `Option` variant for the same reason: //! both are lists. //! //! # Strictness is per-site, and that is a finding, not a shortcut //! //! `req:textproj:strict-parse` forbids normalizing. The binary decoders enforce //! the same rule in two layers: a whole-value re-encode-and-compare guard, plus //! per-site checks for the order-preserving fields that guard is blind to (see //! `epiphany-bundle/DECISIONS.md`). //! //! The text projection was built the same way and **the whole-value layer turned //! out to be dead here.** A re-project-and-compare guard can only fire when a //! parse *normalizes*, and in Chapter 5 every constructor that could normalize //! needed a check that names the fault anyway: //! //! * a set or map re-sorts and de-duplicates, so `parse` walks the elements and //! rejects the first that does not strictly increase; //! * `RationalTime::new` reduces, so `parse` compares against `BigRational::new`'s //! canonical form *before* constructing; //! * a catalog id folds to NFC, so `parse` interns and then compares; //! * `EventArena::insert` re-sorts, so `parse` checks ascending `EventId` itself. //! //! Every one of those is mutation-verified live. The remaining validating //! constructors — `Tempo::new`, `ReferencePitch::new`, `SpellingPrecedence::new`, //! `EventOrderingDAG::try_new` — *reject* rather than adjust, so an accepted value //! re-projects to exactly its input and a whole-value guard could never fire. Four //! such guards were written, mutation-tested, found dead, and removed. A check //! that cannot fail is worse than no check: it invites weakening the real one. //! //! The blind spot the binary form documents still applies and still needs naming: //! where a `Vec`'s order is constrained (the frozen `Transpose`'s non-decreasing //! `targets`), only a per-site check can see it. Nothing in Chapter 5 constrains a //! `Vec` that way; the operation payloads do. use core::fmt; use std::collections::{BTreeMap, BTreeSet}; use num_bigint::BigInt; // =========================================================================== // The value model. // =========================================================================== /// One node of a projected value. /// /// The five variants are exactly the lexical classes a reader can distinguish /// without knowing the expected type: a parenthesised list, a symbol (`[a-z]` /// then `[a-z0-9-]*`), an integer, a byte string (`#x` then lowercase hex pairs), /// and a quoted string. /// /// There is deliberately no `Bool`: `true` is lexically a symbol, and only the /// expected type says otherwise. Likewise a rational is a `List`, and so is an /// option. #[derive(Clone, PartialEq, Eq, Debug)] pub enum Sexp { /// A parenthesised list: a struct, a sequence, a set, a map, an option, or a /// rational, according to the expected type. List(Vec), /// A symbol: a type name, a variant name, or a boolean. Symbol(String), /// An integer of arbitrary precision. `RationalTime` promotes to a /// `BigRational`, so the text's integers cannot be bounded by `i64`. Int(BigInt), /// A byte string. Rendered `#x` followed by an even number of lowercase hex /// digits; the empty byte string is `#x`. Bytes(Vec), /// A quoted, escaped string. Str(String), } impl Sexp { /// The symbol `name`. pub fn sym(name: &str) -> Sexp { Sexp::Symbol(name.to_owned()) } /// An integer. pub fn int(value: impl Into) -> Sexp { Sexp::Int(value.into()) } /// The absent option, `()`. Identical to the empty sequence; see /// `req:textproj:schema-directed`. pub fn none() -> Sexp { Sexp::List(Vec::new()) } /// The present option, `(some )`. pub fn some(value: Sexp) -> Sexp { Sexp::List(vec![Sexp::sym("some"), value]) } /// `(ratio )`. pub fn ratio(numerator: BigInt, denominator: BigInt) -> Sexp { Sexp::List(vec![ Sexp::sym("ratio"), Sexp::Int(numerator), Sexp::Int(denominator), ]) } /// The canonical rendering. Total, and injective by construction: every /// choice a writer could make is made here, once. pub fn render(&self) -> String { let mut out = String::new(); self.write(&mut out); out } /// Appends the canonical rendering to `out`. pub fn write(&self, out: &mut String) { match self { Sexp::List(items) => { out.push('('); for (i, item) in items.iter().enumerate() { if i > 0 { out.push(' '); } item.write(out); } out.push(')'); } Sexp::Symbol(s) => out.push_str(s), Sexp::Int(n) => out.push_str(&n.to_string()), Sexp::Bytes(b) => { out.push_str("#x"); for byte in b { out.push(HEX[(byte >> 4) as usize]); out.push(HEX[(byte & 0x0f) as usize]); } } Sexp::Str(s) => { out.push('"'); for c in s.chars() { match c { '"' => out.push_str("\\\""), '\\' => out.push_str("\\\\"), '\n' => out.push_str("\\n"), '\t' => out.push_str("\\t"), _ => out.push(c), } } out.push('"'); } } } /// The list's elements, or `None` if this is not a list. pub fn as_list(&self) -> Option<&[Sexp]> { match self { Sexp::List(items) => Some(items), _ => None, } } /// The symbol's name, or `None` if this is not a symbol. pub fn as_symbol(&self) -> Option<&str> { match self { Sexp::Symbol(s) => Some(s), _ => None, } } /// A short description of this node's lexical class, for error messages. fn class(&self) -> &'static str { match self { Sexp::List(_) => "list", Sexp::Symbol(_) => "symbol", Sexp::Int(_) => "integer", Sexp::Bytes(_) => "byte string", Sexp::Str(_) => "string", } } } const HEX: [char; 16] = [ '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', ]; // =========================================================================== // The strict reader. // =========================================================================== /// Reads exactly one s-expression from `line`, which must be the whole of `line`. /// /// Strict in the sense of `req:textproj:strict-parse`: it rejects every spelling /// that is not the one [`Sexp::write`] produces. There is no normalization here /// and no leniency to be exploited — a leading zero, an upper-case hex digit, a /// doubled space, a space after `(`, an unknown escape and a trailing character /// are all rejections. /// /// It does **not** know types, so it cannot reject a value that is well-formed but /// wrong for its position; that is [`TextValue::parse`]'s job. Together the two /// give the layer-1 guarantee described in this module's header. pub fn read_sexp(line: &str) -> Result { let mut reader = Reader { text: line, bytes: line.as_bytes(), at: 0, }; let value = reader.value()?; if reader.at != line.len() { return Err(TextError::Syntax("trailing input after the s-expression")); } Ok(value) } struct Reader<'a> { text: &'a str, bytes: &'a [u8], at: usize, } impl Reader<'_> { fn peek(&self) -> Option { self.bytes.get(self.at).copied() } fn value(&mut self) -> Result { match self.peek() { Some(b'(') => self.list(), Some(b'"') => self.string(), Some(b'#') => self.byte_string(), Some(c) if c == b'-' || c.is_ascii_digit() => self.integer(), Some(c) if c.is_ascii_lowercase() => self.symbol(), Some(_) => Err(TextError::Syntax("no value begins with this character")), None => Err(TextError::Syntax("unexpected end of input")), } } /// `"(" (value (" " value)*)? ")"`. Exactly one space between elements, none /// after `(` and none before `)`. fn list(&mut self) -> Result { self.at += 1; // '(' let mut items = Vec::new(); if self.peek() == Some(b')') { self.at += 1; return Ok(Sexp::List(items)); } loop { // Diagnostic only, as above: a space here is already rejected by // `value`, which knows no value beginning with U+0020. Kept so the // message says what is wrong, and tested so it keeps saying it. if self.peek() == Some(b' ') { return Err(TextError::Syntax( "a list element may not be preceded by a space", )); } items.push(self.value()?); match self.peek() { Some(b' ') => self.at += 1, Some(b')') => { self.at += 1; return Ok(Sexp::List(items)); } Some(_) => return Err(TextError::Syntax("expected a space or `)`")), None => return Err(TextError::Syntax("unclosed list")), } } } fn symbol(&mut self) -> Result { let start = self.at; // `symbol ::= [a-z] [a-z0-9-]*` self.at += 1; while let Some(c) = self.peek() { if c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-' { self.at += 1; } else { break; } } Ok(Sexp::Symbol( std::str::from_utf8(&self.bytes[start..self.at]) .expect("ascii") .to_owned(), )) } /// `integer ::= "-"? digit+`, with no leading zeros and no `-0`. fn integer(&mut self) -> Result { let start = self.at; if self.peek() == Some(b'-') { self.at += 1; } let digits_start = self.at; while self.peek().is_some_and(|c| c.is_ascii_digit()) { self.at += 1; } let digits = &self.bytes[digits_start..self.at]; if digits.is_empty() { return Err(TextError::Syntax("an integer needs at least one digit")); } if digits.len() > 1 && digits[0] == b'0' { return Err(TextError::NotCanonical("integer has a leading zero")); } if digits == b"0" && start != digits_start { return Err(TextError::NotCanonical("negative zero")); } let text = std::str::from_utf8(&self.bytes[start..self.at]).expect("ascii"); text.parse::() .map(Sexp::Int) .map_err(|_| TextError::Syntax("malformed integer")) } /// `bytes ::= "#x" hexdigit*`, an even number of lower-case hex digits. fn byte_string(&mut self) -> Result { self.at += 1; // '#' if self.peek() != Some(b'x') { return Err(TextError::Syntax("a byte string begins `#x`")); } self.at += 1; let start = self.at; while self .peek() .is_some_and(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)) { self.at += 1; } // Diagnostic only, and known to be so: an upper-case hex digit merely stops // the scan, so it is *already* rejected downstream — by the odd-digit count // (`#x0A`) or by the trailing-input check (`#xAA`). Deleting this branch // changes no verdict. It is here to name the real problem, and // `the_reader_names_the_real_problem` holds it to that. if self.peek().is_some_and(|c| c.is_ascii_uppercase()) { return Err(TextError::NotCanonical("byte string uses upper-case hex")); } let digits = &self.bytes[start..self.at]; if digits.len() % 2 != 0 { return Err(TextError::Syntax("byte string has an odd hex digit count")); } let mut out = Vec::with_capacity(digits.len() / 2); for pair in digits.chunks_exact(2) { let hi = (pair[0] as char).to_digit(16).expect("checked") as u8; let lo = (pair[1] as char).to_digit(16).expect("checked") as u8; out.push((hi << 4) | lo); } Ok(Sexp::Bytes(out)) } /// `string ::= '"' schar* '"'`, where `schar` is an unescaped scalar other /// than U+0022, U+005C, U+000A, U+0009, or one of exactly four escapes. fn string(&mut self) -> Result { self.at += 1; // '"' let mut out = String::new(); loop { let rest = &self.bytes[self.at..]; let Some(&first) = rest.first() else { return Err(TextError::Syntax("unterminated string")); }; match first { b'"' => { self.at += 1; return Ok(Sexp::Str(out)); } b'\\' => { let Some(&escaped) = rest.get(1) else { return Err(TextError::Syntax("unterminated escape")); }; out.push(match escaped { b'"' => '"', b'\\' => '\\', b'n' => '\n', b't' => '\t', _ => return Err(TextError::NotCanonical("unknown escape sequence")), }); self.at += 2; } b'\n' | b'\t' => { return Err(TextError::NotCanonical( "U+000A and U+0009 must be escaped in a string", )) } _ => { // One whole UTF-8 scalar. `self.at` is always a char boundary: // every advance above is by an ASCII byte or by `len_utf8`. let c = self.text[self.at..].chars().next().expect("non-empty"); out.push(c); self.at += c.len_utf8(); } } } } } // =========================================================================== // Errors. // =========================================================================== /// Why a projection could not be read. #[derive(Clone, PartialEq, Eq, Debug)] pub enum TextError { /// A node of the wrong lexical class: expected `expected`, found `found`. Expected { /// What the schema expected at this position. expected: &'static str, /// The lexical class actually present. found: &'static str, }, /// A list of the wrong length for the type expected at this position. Arity { /// The type expected. type_name: &'static str, /// How many elements the type has. expected: usize, /// How many were present. found: usize, }, /// A constructor symbol that names no variant of the expected type. UnknownConstructor { /// The type expected. type_name: &'static str, /// The constructor found. found: String, }, /// A leaf whose text is not the canonical spelling of any value: a /// non-reduced rational, an integer with a leading zero, an out-of-range /// value, a non-NFC catalog id. NotCanonical(&'static str), /// A set or map whose elements are not strictly increasing, or a sequence the /// binary form constrains and this text violates. Never normalized. NotStrictlyIncreasing(&'static str), /// The text is not well-formed: bad escape, odd hex digit count, stray /// whitespace, unbalanced parenthesis, trailing input. Syntax(&'static str), } impl fmt::Display for TextError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { TextError::Expected { expected, found } => { write!(f, "expected {expected}, found {found}") } TextError::Arity { type_name, expected, found, } => write!(f, "{type_name} has {expected} fields, found {found}"), TextError::UnknownConstructor { type_name, found } => { write!(f, "`{found}` names no variant of {type_name}") } TextError::NotCanonical(what) => write!(f, "not canonical: {what}"), TextError::NotStrictlyIncreasing(what) => { write!(f, "not strictly increasing: {what}") } TextError::Syntax(what) => write!(f, "syntax: {what}"), } } } impl std::error::Error for TextError {} impl Sexp { /// Asserts this node is a list of exactly `arity` elements headed by the /// symbol `name`, and returns the fields after the head. /// /// A struct with zero fields never reaches here: it is the bare symbol /// `name`, as a fieldless variant is. pub fn expect_struct(&self, name: &str, arity: usize) -> Result<&[Sexp], TextError> { let items = self.as_list().ok_or(TextError::Expected { expected: "struct", found: self.class(), })?; match items.first().and_then(Sexp::as_symbol) { Some(head) if head == name => {} Some(head) => { return Err(TextError::UnknownConstructor { type_name: "struct", found: head.to_owned(), }) } None => return Err(TextError::Syntax("a struct is headed by its type name")), } if items.len() != arity + 1 { return Err(TextError::Arity { type_name: "struct", expected: arity, found: items.len().saturating_sub(1), }); } Ok(&items[1..]) } } // =========================================================================== // Names. // =========================================================================== /// A Rust type or variant name in the projection's lower-case hyphenated form: /// `PitchedEvent` becomes `pitched-event`, `EventOrderingDAG` becomes /// `event-ordering-dag`, `CanonicalF64` becomes `canonical-f64`. /// /// A hyphen is inserted before an upper-case letter that follows a lower-case /// letter or a digit, and before the last upper-case letter of a run when a /// lower-case letter follows it (so `DAGNode` becomes `dag-node`). pub fn kebab(name: &str) -> String { let chars: Vec = name.chars().collect(); let mut out = String::with_capacity(name.len() + 4); for (i, &c) in chars.iter().enumerate() { if c.is_ascii_uppercase() && i > 0 { let prev = chars[i - 1]; let next_is_lower = chars.get(i + 1).is_some_and(|n| n.is_ascii_lowercase()); if prev.is_ascii_lowercase() || prev.is_ascii_digit() || (prev.is_ascii_uppercase() && next_is_lower) { out.push('-'); } } out.push(c.to_ascii_lowercase()); } out } // =========================================================================== // The trait. // =========================================================================== /// A canonical value that projects to text and parses back. /// /// `parse` **must not normalize** (`req:textproj:strict-parse`). Where an impl /// cannot avoid constructing through a normalizing constructor, it constructs and /// then compares against its input, rejecting on difference. pub trait TextValue: Sized { /// This value's canonical projection. fn project(&self) -> Sexp; /// Reads a value of this type from its projection, rejecting any text that is /// not the canonical projection of the value it denotes. fn parse(s: &Sexp) -> Result; } // =========================================================================== // Leaf impls. // =========================================================================== impl TextValue for bool { fn project(&self) -> Sexp { Sexp::sym(if *self { "true" } else { "false" }) } fn parse(s: &Sexp) -> Result { match s.as_symbol() { Some("true") => Ok(true), Some("false") => Ok(false), Some(_) => Err(TextError::NotCanonical("a boolean is `true` or `false`")), None => Err(TextError::Expected { expected: "boolean", found: s.class(), }), } } } impl TextValue for String { fn project(&self) -> Sexp { Sexp::Str(self.clone()) } fn parse(s: &Sexp) -> Result { match s { Sexp::Str(v) => Ok(v.clone()), _ => Err(TextError::Expected { expected: "string", found: s.class(), }), } } } /// Integers project as integers, and parse back only within their own range. The /// text carries no width, so the expected type supplies it — schema-directed /// again. macro_rules! int_text_value { ($($ty:ty),* $(,)?) => { $( impl TextValue for $ty { fn project(&self) -> Sexp { Sexp::Int(BigInt::from(*self)) } fn parse(s: &Sexp) -> Result { match s { Sexp::Int(n) => <$ty>::try_from(n.clone()).map_err(|_| { TextError::NotCanonical(concat!( "integer out of range for ", stringify!($ty) )) }), _ => Err(TextError::Expected { expected: "integer", found: s.class(), }), } } } )* }; } int_text_value!(u8, u16, u32, u64, u128, i8, i16, i32, i64); // =========================================================================== // Collection impls. // =========================================================================== impl TextValue for Option { fn project(&self) -> Sexp { match self { None => Sexp::none(), Some(v) => Sexp::some(v.project()), } } fn parse(s: &Sexp) -> Result { let items = s.as_list().ok_or(TextError::Expected { expected: "option", found: s.class(), })?; match items { [] => Ok(None), [head, value] if head.as_symbol() == Some("some") => Ok(Some(T::parse(value)?)), _ => Err(TextError::Syntax("an option is `()` or `(some )`")), } } } impl TextValue for Vec { fn project(&self) -> Sexp { Sexp::List(self.iter().map(T::project).collect()) } fn parse(s: &Sexp) -> Result { let items = s.as_list().ok_or(TextError::Expected { expected: "sequence", found: s.class(), })?; items.iter().map(T::parse).collect() } } /// A set projects strictly increasing and parses strictly increasing. /// /// Absorbing a duplicate into the set, or letting `BTreeSet::insert` silently /// re-sort, would be **normalizing** — the thing `req:textproj:strict-parse` /// forbids. The check is per-element and explicit. impl TextValue for BTreeSet { fn project(&self) -> Sexp { Sexp::List(self.iter().map(T::project).collect()) } fn parse(s: &Sexp) -> Result { let items = s.as_list().ok_or(TextError::Expected { expected: "set", found: s.class(), })?; let mut out = BTreeSet::new(); for item in items { let value = T::parse(item)?; // Only ever-increasing values are inserted, so the set's maximum is // the previously read element. if out.last().is_some_and(|previous| *previous >= value) { return Err(TextError::NotStrictlyIncreasing("set elements")); } out.insert(value); } Ok(out) } } /// A pair projects as a two-element list, exactly as a map entry does — the /// binary form writes its two components in order and adds nothing, so neither /// does the text. impl TextValue for (A, B) { fn project(&self) -> Sexp { Sexp::List(vec![self.0.project(), self.1.project()]) } fn parse(s: &Sexp) -> Result { let items = s.as_list().ok_or(TextError::Expected { expected: "pair", found: s.class(), })?; let [first, second] = items else { return Err(TextError::Syntax("a pair is `( )`")); }; Ok((A::parse(first)?, B::parse(second)?)) } } /// A map projects as a list of `( )` entries, strictly increasing by /// key, and parses the same. Same reasoning as [`BTreeSet`]. impl TextValue for BTreeMap { fn project(&self) -> Sexp { Sexp::List( self.iter() .map(|(k, v)| Sexp::List(vec![k.project(), v.project()])) .collect(), ) } fn parse(s: &Sexp) -> Result { let items = s.as_list().ok_or(TextError::Expected { expected: "map", found: s.class(), })?; let mut out = BTreeMap::new(); for item in items { let entry = item.as_list().ok_or(TextError::Expected { expected: "map entry", found: item.class(), })?; let [key, value] = entry else { return Err(TextError::Syntax("a map entry is `( )`")); }; let key = K::parse(key)?; // As for a set: keys only increase, so the map's maximum key is the // previously read one. if out .last_key_value() .is_some_and(|(previous, _)| *previous >= key) { return Err(TextError::NotStrictlyIncreasing("map keys")); } let value = V::parse(value)?; out.insert(key, value); } Ok(out) } } // =========================================================================== // Big integers. // =========================================================================== impl TextValue for BigInt { fn project(&self) -> Sexp { Sexp::Int(self.clone()) } fn parse(s: &Sexp) -> Result { match s { Sexp::Int(n) => Ok(n.clone()), _ => Err(TextError::Expected { expected: "integer", found: s.class(), }), } } } #[cfg(test)] mod tests { use super::*; #[test] fn kebab_handles_acronyms_digits_and_runs() { assert_eq!(kebab("PitchedEvent"), "pitched-event"); assert_eq!(kebab("Pitch"), "pitch"); assert_eq!(kebab("EventOrderingDAG"), "event-ordering-dag"); assert_eq!(kebab("CanonicalF64"), "canonical-f64"); assert_eq!(kebab("DAGNode"), "dag-node"); assert_eq!(kebab("TimeSignatureDisplay"), "time-signature-display"); assert_eq!(kebab("ArticulationMark"), "articulation-mark"); } #[test] fn rendering_is_canonical() { assert_eq!(Sexp::Bytes(vec![0x0a, 0xff]).render(), "#x0aff"); assert_eq!(Sexp::Bytes(vec![]).render(), "#x"); assert_eq!(Sexp::none().render(), "()"); assert_eq!(Sexp::some(Sexp::sym("x")).render(), "(some x)"); assert_eq!(true.project().render(), "true"); assert_eq!(Sexp::int(-5i32).render(), "-5"); assert_eq!(Sexp::int(0i32).render(), "0"); } #[test] fn strings_escape_exactly_the_four_characters() { let s = "a\"b\\c\nd\te\u{00e9}".to_string(); assert_eq!(s.project().render(), "\"a\\\"b\\\\c\\nd\\te\u{00e9}\""); } /// Whatever `write` emits, `read_sexp` must accept and return unchanged. #[track_caller] fn round_trips(text: &str) { let value = read_sexp(text).unwrap_or_else(|e| panic!("{text:?} rejected: {e}")); assert_eq!(value.render(), text, "re-rendering {text:?} changed it"); } /// `read_sexp` must reject; the message is not part of the contract. #[track_caller] fn rejected(text: &str) { assert!( read_sexp(text).is_err(), "{text:?} was accepted; strict parsing forbids it" ); } #[test] fn the_reader_accepts_exactly_what_the_writer_emits() { for text in [ "()", "(some x)", "#x", "#x0aff", "0", "-5", "12345678901234567890123456789", "true", "false", "(ratio -3 4)", "\"\"", "\"a\\\"b\\\\c\\nd\\te\"", "(pitched-event #x0a (articulation-mark) () stem-configuration)", "((a b) (c d))", ] { round_trips(text); } } #[test] fn the_reader_rejects_every_non_canonical_spelling() { // Whitespace has nowhere to hide (`req:textproj:envelope-per-line`). rejected("( a)"); rejected("(a )"); rejected("(a b)"); rejected("(a\tb)"); rejected(" ()"); rejected("() "); // Integers: no leading zeros, no negative zero, no `+`. rejected("007"); rejected("-0"); rejected("-01"); rejected("+1"); rejected("-"); // Byte strings: lower-case hex, even count, `#x` prefix. rejected("#x0A"); rejected("#xAA"); rejected("#x0"); rejected("#y00"); rejected("#"); // Strings: exactly four escapes; the four characters never appear raw. rejected("\"\\q\""); rejected("\"\\\""); rejected("\"a"); rejected("\"a\tb\""); rejected("\"a\nb\""); // Structure. rejected("("); rejected(")"); rejected("(a"); rejected("(a))"); rejected("(some(x))"); rejected(""); rejected("A"); rejected("-a"); } /// Two branches of the reader change no verdict — an upper-case hex digit and /// a stray space are rejected downstream anyway. Mutation testing found both /// surviving the rejection suite above, which proves only that *something* /// rejects them. Their real contract is the diagnostic, so that is what is /// asserted here; without this test, deleting either branch is invisible. #[test] fn the_reader_names_the_real_problem() { assert_eq!( read_sexp("#xAA"), Err(TextError::NotCanonical("byte string uses upper-case hex")) ); assert_eq!( read_sexp("#x0A"), Err(TextError::NotCanonical("byte string uses upper-case hex")) ); assert_eq!( read_sexp("( a)"), Err(TextError::Syntax( "a list element may not be preceded by a space" )) ); assert_eq!( read_sexp("(a b)"), Err(TextError::Syntax( "a list element may not be preceded by a space" )) ); } #[test] fn a_set_parses_only_strictly_increasing_elements() { let ok = read_sexp("(1 2 3)").unwrap(); assert_eq!( BTreeSet::::parse(&ok).unwrap(), BTreeSet::from([1, 2, 3]) ); // Absorbing either of these into the set would be normalizing. for bad in ["(1 1 2)", "(3 2 1)", "(1 3 2)"] { let s = read_sexp(bad).unwrap(); assert_eq!( BTreeSet::::parse(&s), Err(TextError::NotStrictlyIncreasing("set elements")), "{bad} must be rejected, not sorted" ); } } #[test] fn a_map_parses_only_strictly_increasing_keys() { let ok = read_sexp("((1 true) (2 false))").unwrap(); assert_eq!( BTreeMap::::parse(&ok).unwrap(), BTreeMap::from([(1, true), (2, false)]) ); for bad in ["((2 true) (1 false))", "((1 true) (1 false))"] { let s = read_sexp(bad).unwrap(); assert_eq!( BTreeMap::::parse(&s), Err(TextError::NotStrictlyIncreasing("map keys")), "{bad} must be rejected" ); } let malformed = read_sexp("((1))").unwrap(); assert!(BTreeMap::::parse(&malformed).is_err()); } /// `()` is the empty sequence *and* the absent option; only the expected type /// tells them apart. That is `req:textproj:schema-directed`, and here it is. #[test] fn the_empty_list_is_both_an_empty_sequence_and_an_absent_option() { let empty = read_sexp("()").unwrap(); assert_eq!(Option::::parse(&empty).unwrap(), None); assert_eq!(Vec::::parse(&empty).unwrap(), Vec::::new()); assert_eq!(BTreeSet::::parse(&empty).unwrap(), BTreeSet::new()); assert_eq!(None::.project(), Vec::::new().project()); } #[test] fn an_integer_parses_only_within_the_expected_type_s_range() { let big = read_sexp("256").unwrap(); assert!(u8::parse(&big).is_err()); assert_eq!(u16::parse(&big).unwrap(), 256); let negative = read_sexp("-1").unwrap(); assert!(u8::parse(&negative).is_err()); assert_eq!(i8::parse(&negative).unwrap(), -1); } }