//! [`TextValue`] for the hand-written `Codec` composites of Chapter 2's pitch and //! spelling subsystem (`pitch.rs`). //! //! The macro-generated types of `pitch.rs` — the `struct_codec!` structs //! ([`ScalePosition`](crate::pitch::ScalePosition), [`Pitch`](crate::pitch::Pitch), //! [`PitchSpelling`](crate::pitch::PitchSpelling), …), the `cstyle_enum_codec!` //! [`CmnNominal`](crate::pitch::CmnNominal) / //! [`SpellingSourceKind`](crate::pitch::SpellingSourceKind), and the //! `catalog_id_codec!` ids — get their [`TextValue`] from the same macro that //! writes their bytes, so their projection cannot drift from the binary form. //! This module supplies the rest: the tagged unions and the two structs whose //! `Codec` is written out by hand. Each projection mirrors the field / variant //! order of the corresponding `impl Codec` in `codec.rs` exactly. //! //! Two of these parse through a **validating** constructor, so they obey //! `req:textproj:strict-parse` by re-projecting and comparing rather than //! returning a laundered value: //! //! * [`ReferencePitch`](crate::pitch::ReferencePitch) — the frequency field is //! private and reachable only through `ReferencePitch::new`, which rejects a //! non-positive frequency. //! * [`SpellingPrecedence`](crate::pitch::SpellingPrecedence) — the order is //! private and reachable only through `SpellingPrecedence::new`, which rejects //! any order that is not a total ranking (a kind missing or duplicated). A //! `Vec` parse preserves order and so cannot see a duplicate itself; `new` is //! what refuses it. //! //! Tagged-union parsing is strict about *shape* too: a fieldless variant projects //! as a bare symbol, so its one-element list spelling (`(inherit)`) is rejected, //! not accepted. use epiphany_determinism::CanonicalF64; use crate::pitch::{ AcousticRealization, PitchSpacePosition, ReferencePitch, SpellingDirective, SpellingNominal, SpellingPrecedence, SpellingScope, SpellingSource, SpellingSourceKind, TuningReference, VoiceSelector, }; use crate::textvalue::{kebab, Sexp, TextError, TextValue}; use crate::textvalue_impls::class_of; // =========================================================================== // Tagged-union helpers. // =========================================================================== /// Projects a tagged-union variant (`req:textproj:value-projection` clause 3): a /// fieldless variant is its bare kebab name; a variant with fields is a list of /// that name followed by the fields' projections. fn variant(name: &str, fields: Vec) -> Sexp { if fields.is_empty() { Sexp::Symbol(kebab(name)) } else { let mut items = Vec::with_capacity(fields.len() + 1); items.push(Sexp::Symbol(kebab(name))); items.extend(fields); Sexp::List(items) } } /// The constructor name of a tagged-union projection, and its field list when the /// projection is *applied* (a list). A bare symbol yields `None` for the fields — /// it is a fieldless variant — which is what lets a caller reject a fieldless /// variant miswritten as a one-element list, a spelling `project` never emits /// (`req:textproj:strict-parse`). fn constructor(s: &Sexp) -> Result<(&str, Option<&[Sexp]>), TextError> { match s { Sexp::Symbol(name) => Ok((name.as_str(), None)), Sexp::List(items) => { let (head, rest) = items.split_first().ok_or(TextError::Syntax( "a tagged-union variant is a symbol or a non-empty list", ))?; let name = head .as_symbol() .ok_or(TextError::Syntax("a variant constructor is a symbol"))?; Ok((name, Some(rest))) } _ => Err(TextError::Expected { expected: "tagged-union variant", found: class_of(s), }), } } /// The field count of a (possibly bare) variant, for arity diagnostics. fn field_count(fields: Option<&[Sexp]>) -> usize { fields.map_or(0, <[Sexp]>::len) } fn arity(type_name: &'static str, expected: usize, found: usize) -> TextError { TextError::Arity { type_name, expected, found, } } /// Rejects a fieldless variant spelled as a list. A fieldless variant projects as /// a bare symbol, so any field list — even the empty `(inherit)` — is not its /// canonical text and must be refused rather than absorbed /// (`req:textproj:strict-parse`). fn expect_fieldless(fields: Option<&[Sexp]>) -> Result<(), TextError> { match fields { None => Ok(()), Some(_) => Err(TextError::Syntax( "a fieldless variant projects as a bare symbol, not a list", )), } } // =========================================================================== // Tagged unions. // =========================================================================== /// A position within a pitch space (`req:textproj:value-projection` clause 3), /// mirroring `PitchSpacePosition`'s `Codec::enc` variant order: `Cmn`, `Integer`, /// `JiVector`, `Registered`. The `Cmn` fields project positionally in their /// declared order — nominal, alteration, octave — never by name. impl TextValue for PitchSpacePosition { fn project(&self) -> Sexp { match self { PitchSpacePosition::Cmn { nominal, alteration, octave, } => variant( "Cmn", vec![nominal.project(), alteration.project(), octave.project()], ), PitchSpacePosition::Integer { space_size, index } => { variant("Integer", vec![space_size.project(), index.project()]) } PitchSpacePosition::JiVector { components } => { variant("JiVector", vec![components.project()]) } PitchSpacePosition::Registered(id) => variant("Registered", vec![id.project()]), } } fn parse(s: &Sexp) -> Result { let (head, fields) = constructor(s)?; if head == kebab("Cmn") { let Some([nominal, alteration, octave]) = fields else { return Err(arity("PitchSpacePosition", 3, field_count(fields))); }; return Ok(PitchSpacePosition::Cmn { nominal: TextValue::parse(nominal)?, alteration: TextValue::parse(alteration)?, octave: TextValue::parse(octave)?, }); } if head == kebab("Integer") { let Some([space_size, index]) = fields else { return Err(arity("PitchSpacePosition", 2, field_count(fields))); }; return Ok(PitchSpacePosition::Integer { space_size: TextValue::parse(space_size)?, index: TextValue::parse(index)?, }); } if head == kebab("JiVector") { let Some([components]) = fields else { return Err(arity("PitchSpacePosition", 1, field_count(fields))); }; return Ok(PitchSpacePosition::JiVector { components: TextValue::parse(components)?, }); } if head == kebab("Registered") { let Some([id]) = fields else { return Err(arity("PitchSpacePosition", 1, field_count(fields))); }; return Ok(PitchSpacePosition::Registered(TextValue::parse(id)?)); } Err(TextError::UnknownConstructor { type_name: "PitchSpacePosition", found: head.to_owned(), }) } } /// The tuning reference governing a pitch: `inherit`, or `(explicit )`. /// Mirrors `TuningReference`'s `Codec::enc`. impl TextValue for TuningReference { fn project(&self) -> Sexp { match self { TuningReference::Inherit => variant("Inherit", vec![]), TuningReference::Explicit(id) => variant("Explicit", vec![id.project()]), } } fn parse(s: &Sexp) -> Result { let (head, fields) = constructor(s)?; if head == kebab("Inherit") { expect_fieldless(fields)?; return Ok(TuningReference::Inherit); } if head == kebab("Explicit") { let Some([id]) = fields else { return Err(arity("TuningReference", 1, field_count(fields))); }; return Ok(TuningReference::Explicit(TextValue::parse(id)?)); } Err(TextError::UnknownConstructor { type_name: "TuningReference", found: head.to_owned(), }) } } /// How the tuning system resolves to a frequency: `implicit`, `(cents-offset /// )`, or `(absolute-hz )`. Mirrors `AcousticRealization`'s /// `Codec::enc`. Each payload is a [`CanonicalF64`], so it projects as its eight /// canonical little-endian bytes — never a decimal, which would not be uniquely /// spellable (Appendix D §"Floating-Point Values"). impl TextValue for AcousticRealization { fn project(&self) -> Sexp { match self { AcousticRealization::Implicit => variant("Implicit", vec![]), AcousticRealization::CentsOffset(c) => variant("CentsOffset", vec![c.project()]), AcousticRealization::AbsoluteHz(c) => variant("AbsoluteHz", vec![c.project()]), } } fn parse(s: &Sexp) -> Result { let (head, fields) = constructor(s)?; if head == kebab("Implicit") { expect_fieldless(fields)?; return Ok(AcousticRealization::Implicit); } if head == kebab("CentsOffset") { let Some([c]) = fields else { return Err(arity("AcousticRealization", 1, field_count(fields))); }; return Ok(AcousticRealization::CentsOffset(TextValue::parse(c)?)); } if head == kebab("AbsoluteHz") { let Some([c]) = fields else { return Err(arity("AcousticRealization", 1, field_count(fields))); }; return Ok(AcousticRealization::AbsoluteHz(TextValue::parse(c)?)); } Err(TextError::UnknownConstructor { type_name: "AcousticRealization", found: head.to_owned(), }) } } /// The staff position a spelling draws on: `(cmn )`, `(integer )`, or /// `(registered )`. Mirrors `SpellingNominal`'s `Codec::enc`. impl TextValue for SpellingNominal { fn project(&self) -> Sexp { match self { SpellingNominal::Cmn(n) => variant("Cmn", vec![n.project()]), SpellingNominal::Integer(i) => variant("Integer", vec![i.project()]), SpellingNominal::Registered(id) => variant("Registered", vec![id.project()]), } } fn parse(s: &Sexp) -> Result { let (head, fields) = constructor(s)?; if head == kebab("Cmn") { let Some([n]) = fields else { return Err(arity("SpellingNominal", 1, field_count(fields))); }; return Ok(SpellingNominal::Cmn(TextValue::parse(n)?)); } if head == kebab("Integer") { let Some([i]) = fields else { return Err(arity("SpellingNominal", 1, field_count(fields))); }; return Ok(SpellingNominal::Integer(TextValue::parse(i)?)); } if head == kebab("Registered") { let Some([id]) = fields else { return Err(arity("SpellingNominal", 1, field_count(fields))); }; return Ok(SpellingNominal::Registered(TextValue::parse(id)?)); } Err(TextError::UnknownConstructor { type_name: "SpellingNominal", found: head.to_owned(), }) } } /// The provenance of a spelling attachment. Mirrors `SpellingSource`'s /// `Codec::enc` variant order: `UserChosen`, `Inferred`, `Imported`, /// `Propagated`, `Analytical`. (That order — with `Inferred` before `Imported` — /// is the type's declaration order and differs from /// [`SpellingSourceKind`](crate::pitch::SpellingSourceKind)'s discriminant order; /// the text carries variant *names*, not tags, so only the names matter here.) /// The single-field variants carry their named field positionally. impl TextValue for SpellingSource { fn project(&self) -> Sexp { match self { SpellingSource::UserChosen => variant("UserChosen", vec![]), SpellingSource::Inferred => variant("Inferred", vec![]), SpellingSource::Imported { format } => variant("Imported", vec![format.project()]), SpellingSource::Propagated { from } => variant("Propagated", vec![from.project()]), SpellingSource::Analytical => variant("Analytical", vec![]), } } fn parse(s: &Sexp) -> Result { let (head, fields) = constructor(s)?; if head == kebab("UserChosen") { expect_fieldless(fields)?; return Ok(SpellingSource::UserChosen); } if head == kebab("Inferred") { expect_fieldless(fields)?; return Ok(SpellingSource::Inferred); } if head == kebab("Imported") { let Some([format]) = fields else { return Err(arity("SpellingSource", 1, field_count(fields))); }; return Ok(SpellingSource::Imported { format: TextValue::parse(format)?, }); } if head == kebab("Propagated") { let Some([from]) = fields else { return Err(arity("SpellingSource", 1, field_count(fields))); }; return Ok(SpellingSource::Propagated { from: TextValue::parse(from)?, }); } if head == kebab("Analytical") { expect_fieldless(fields)?; return Ok(SpellingSource::Analytical); } Err(TextError::UnknownConstructor { type_name: "SpellingSource", found: head.to_owned(), }) } } /// A voice selector: `all`, or `(voices …)`. Mirrors `VoiceSelector`'s /// `Codec::enc`. Implemented here — though it is not one of the "spelling" types — /// because it is a `pitch.rs` composite with a hand-written `Codec` (the macros /// generate no [`TextValue`] for it) and [`SpellingScope::Range`] embeds it. impl TextValue for VoiceSelector { fn project(&self) -> Sexp { match self { VoiceSelector::All => variant("All", vec![]), VoiceSelector::Voices(voices) => variant("Voices", vec![voices.project()]), } } fn parse(s: &Sexp) -> Result { let (head, fields) = constructor(s)?; if head == kebab("All") { expect_fieldless(fields)?; return Ok(VoiceSelector::All); } if head == kebab("Voices") { let Some([voices]) = fields else { return Err(arity("VoiceSelector", 1, field_count(fields))); }; return Ok(VoiceSelector::Voices(TextValue::parse(voices)?)); } Err(TextError::UnknownConstructor { type_name: "VoiceSelector", found: head.to_owned(), }) } } /// What a spelling attachment applies to: `(pitch )`, or `(range /// )`. Mirrors `SpellingScope`'s `Codec::enc`, whose `Range` fields /// are start, end, voices in that order. impl TextValue for SpellingScope { fn project(&self) -> Sexp { match self { SpellingScope::Pitch(id) => variant("Pitch", vec![id.project()]), SpellingScope::Range { start, end, voices } => variant( "Range", vec![start.project(), end.project(), voices.project()], ), } } fn parse(s: &Sexp) -> Result { let (head, fields) = constructor(s)?; if head == kebab("Pitch") { let Some([id]) = fields else { return Err(arity("SpellingScope", 1, field_count(fields))); }; return Ok(SpellingScope::Pitch(TextValue::parse(id)?)); } if head == kebab("Range") { let Some([start, end, voices]) = fields else { return Err(arity("SpellingScope", 3, field_count(fields))); }; return Ok(SpellingScope::Range { start: TextValue::parse(start)?, end: TextValue::parse(end)?, voices: TextValue::parse(voices)?, }); } Err(TextError::UnknownConstructor { type_name: "SpellingScope", found: head.to_owned(), }) } } /// A spelling directive: `(explicit )`, or `(rule )`. /// Mirrors `SpellingDirective`'s `Codec::enc`. Both payloads are `struct_codec!` /// composites whose own [`TextValue`] is macro-generated. impl TextValue for SpellingDirective { fn project(&self) -> Sexp { match self { SpellingDirective::Explicit(spelling) => variant("Explicit", vec![spelling.project()]), SpellingDirective::Rule(rule) => variant("Rule", vec![rule.project()]), } } fn parse(s: &Sexp) -> Result { let (head, fields) = constructor(s)?; if head == kebab("Explicit") { let Some([spelling]) = fields else { return Err(arity("SpellingDirective", 1, field_count(fields))); }; return Ok(SpellingDirective::Explicit(TextValue::parse(spelling)?)); } if head == kebab("Rule") { let Some([rule]) = fields else { return Err(arity("SpellingDirective", 1, field_count(fields))); }; return Ok(SpellingDirective::Rule(TextValue::parse(rule)?)); } Err(TextError::UnknownConstructor { type_name: "SpellingDirective", found: head.to_owned(), }) } } // =========================================================================== // Structs with a private, validated field. // =========================================================================== /// `(reference-pitch )`, the frequency a [`CanonicalF64`]'s /// eight bytes, mirroring `ReferencePitch`'s `Codec::enc` (position then /// frequency). /// /// The frequency field is private and reachable only through /// [`ReferencePitch::new`], which *validates*: it rejects a non-positive or /// non-finite frequency (Chapter 4: "positive and finite"). Parsing routes /// through it — there is no other constructor — so a byte-legal frequency that is /// negative or zero is refused rather than stored. `new` does not normalize, so a /// value it accepts already projects back verbatim and a whole-value guard could /// never fire — the `None` is the whole of the strictness. impl TextValue for ReferencePitch { fn project(&self) -> Sexp { // `new` guaranteed a finite frequency, so re-wrapping cannot fail — the // same invariant `ReferencePitch`'s `Codec::enc` asserts. let hz = CanonicalF64::new(self.frequency_hz()) .expect("a constructed ReferencePitch has a finite frequency"); Sexp::List(vec![ Sexp::Symbol(kebab("ReferencePitch")), self.position.project(), hz.project(), ]) } fn parse(s: &Sexp) -> Result { let fields = s.expect_struct(&kebab("ReferencePitch"), 2)?; let [position, frequency] = fields else { return Err(arity("ReferencePitch", 2, fields.len())); }; let position = PitchSpacePosition::parse(position)?; let frequency: CanonicalF64 = TextValue::parse(frequency)?; // `new` *validates* — it refuses a non-positive or non-finite frequency — // and never adjusts one, so an accepted value re-projects to exactly its // input. A whole-value guard here could not fire; the `None` is the whole // of the strictness. ReferencePitch::new(position, frequency.get()).ok_or(TextError::NotCanonical( "a reference pitch frequency must be positive and finite", )) } } /// `(spelling-precedence )`, `order` the total ranking of source kinds, /// highest precedence first. Mirrors `SpellingPrecedence`'s `Codec::enc`, which /// writes the single `order` vector. /// /// The order is private and reachable only through [`SpellingPrecedence::new`], /// which *validates*: it rejects any order that is not a total ranking — the /// wrong length, or a source kind missing or duplicated. A `Vec` parse preserves /// order and cannot see a duplicate itself, so `new` is what refuses it, and /// parsing routes through `new` rather than laundering a malformed order into a /// value (`req:textproj:strict-parse`). Any *permutation* of the five kinds is a /// distinct, legitimate value, so `new` never reorders — it only accepts or /// rejects, and there is nothing left for a whole-value guard to catch. impl TextValue for SpellingPrecedence { fn project(&self) -> Sexp { let order = Sexp::List(self.order_ref().iter().map(TextValue::project).collect()); Sexp::List(vec![Sexp::Symbol(kebab("SpellingPrecedence")), order]) } fn parse(s: &Sexp) -> Result { let fields = s.expect_struct(&kebab("SpellingPrecedence"), 1)?; let [order] = fields else { return Err(arity("SpellingPrecedence", 1, fields.len())); }; let order: Vec = TextValue::parse(order)?; // `new` accepts only a permutation of the five source kinds and stores it // unchanged: every permutation is a distinct legitimate value, so it never // reorders. Validation, not normalization — the `None` rejects a duplicate // or a short list, and nothing downstream could catch what it misses. SpellingPrecedence::new(order).ok_or(TextError::NotCanonical( "spelling precedence must rank every source kind exactly once", )) } } #[cfg(test)] mod tests { use super::*; use crate::ids::{PitchId, ReplicaId, VoiceId}; use crate::pitch::{ CmnNominal, ForeignFormatId, NominalRegistryId, PitchSpelling, PositionRegistryId, SpellingRule, SpellingRuleSetId, TuningSystemId, }; use crate::textvalue::read_sexp; use crate::time::{TimeAnchor, WallClockTime}; /// Build a value, project it, render, read the text back, parse, and require /// equality — the full `project → render → read_sexp → parse` loop. #[track_caller] fn round_trip(value: T) { let text = value.project().render(); let sexp = read_sexp(&text).unwrap_or_else(|e| panic!("read_sexp rejected {text:?}: {e}")); let back = T::parse(&sexp).unwrap_or_else(|e| panic!("parse rejected {text:?}: {e}")); assert_eq!(value, back, "{text:?} did not round-trip"); } #[test] fn pitch_space_position_round_trips_every_variant() { round_trip(PitchSpacePosition::Cmn { nominal: CmnNominal::A, alteration: -1, octave: 4, }); round_trip(PitchSpacePosition::Integer { space_size: 31, index: -5, }); round_trip(PitchSpacePosition::JiVector { components: vec![1, -2, 3], }); round_trip(PitchSpacePosition::Registered(PositionRegistryId::new( "my-pos", ))); // The Cmn fields project positionally in declaration order. assert_eq!( PitchSpacePosition::Cmn { nominal: CmnNominal::C, alteration: 0, octave: 4, } .project() .render(), "(cmn c 0 4)" ); } #[test] fn tuning_reference_round_trips() { round_trip(TuningReference::Inherit); round_trip(TuningReference::Explicit(TuningSystemId::new("tet-12"))); assert_eq!(TuningReference::Inherit.project().render(), "inherit"); } #[test] fn acoustic_realization_round_trips() { round_trip(AcousticRealization::Implicit); round_trip(AcousticRealization::cents_offset(3.5).unwrap()); round_trip(AcousticRealization::absolute_hz(440.0).unwrap()); } #[test] fn reference_pitch_round_trips() { round_trip(ReferencePitch::a440()); round_trip( ReferencePitch::new( PitchSpacePosition::Cmn { nominal: CmnNominal::A, alteration: 0, octave: 4, }, 442.0, ) .unwrap(), ); } /// A negative or zero frequency is a valid `CanonicalF64` byte string, so the /// lexer and leaf parse accept it; only `ReferencePitch::new`'s validation /// rejects it — which is the point, the text is the projection of no reference /// pitch, and must not be laundered into one. #[test] fn a_non_positive_reference_frequency_is_rejected_not_accepted() { let position = PitchSpacePosition::Cmn { nominal: CmnNominal::A, alteration: 0, octave: 4, }; for bad_hz in [-440.0, 0.0] { let sexp = Sexp::List(vec![ Sexp::Symbol(kebab("ReferencePitch")), position.project(), CanonicalF64::new(bad_hz).unwrap().project(), ]); let text = sexp.render(); let read = read_sexp(&text).unwrap(); assert!( ReferencePitch::parse(&read).is_err(), "{text} must be rejected" ); } } #[test] fn spelling_nominal_round_trips() { round_trip(SpellingNominal::Cmn(CmnNominal::G)); round_trip(SpellingNominal::Integer(7)); round_trip(SpellingNominal::Registered(NominalRegistryId::new("nom"))); } #[test] fn spelling_source_round_trips() { let r = ReplicaId::SYSTEM_DERIVED; round_trip(SpellingSource::UserChosen); round_trip(SpellingSource::Inferred); round_trip(SpellingSource::Imported { format: ForeignFormatId::new("musicxml"), }); round_trip(SpellingSource::Propagated { from: PitchId::new(r, 7), }); round_trip(SpellingSource::Analytical); } /// A fieldless variant projects as a bare symbol, and a variant with fields as /// a list; the wrong shape is refused rather than accepted. #[test] fn a_source_of_the_wrong_shape_is_rejected() { // `user-chosen` is fieldless; `(user-chosen)` is a different text. let listed = read_sexp("(user-chosen)").unwrap(); assert!(SpellingSource::parse(&listed).is_err()); // `imported` carries a field; the bare symbol is missing it. let bare = read_sexp("imported").unwrap(); assert!(SpellingSource::parse(&bare).is_err()); } #[test] fn voice_selector_round_trips() { let r = ReplicaId::SYSTEM_DERIVED; round_trip(VoiceSelector::All); round_trip(VoiceSelector::Voices(vec![ VoiceId::new(r, 1), VoiceId::new(r, 2), ])); } #[test] fn spelling_scope_round_trips() { let r = ReplicaId::SYSTEM_DERIVED; round_trip(SpellingScope::Pitch(PitchId::new(r, 3))); round_trip(SpellingScope::Range { start: TimeAnchor::WallClock { time: WallClockTime(0), }, end: TimeAnchor::WallClock { time: WallClockTime(480), }, voices: VoiceSelector::All, }); } #[test] fn spelling_directive_round_trips() { round_trip(SpellingDirective::Explicit(PitchSpelling::cmn( CmnNominal::C, 4, ))); round_trip(SpellingDirective::Rule(SpellingRule { rule_set: SpellingRuleSetId::new("rs"), })); } #[test] fn spelling_precedence_round_trips() { round_trip(SpellingPrecedence::default()); // Any permutation is a distinct, legitimate value. round_trip( SpellingPrecedence::new(vec![ SpellingSourceKind::Analytical, SpellingSourceKind::Inferred, SpellingSourceKind::Propagated, SpellingSourceKind::Imported, SpellingSourceKind::UserChosen, ]) .unwrap(), ); assert_eq!( SpellingPrecedence::default().project().render(), "(spelling-precedence (user-chosen imported propagated inferred analytical))" ); } /// A `Vec` parse preserves order, so a duplicated or missing source kind is /// invisible to it; only `SpellingPrecedence::new` catches it. Parsing must /// reject, never silently repair, such an order. #[test] fn a_precedence_missing_or_duplicating_a_source_kind_is_rejected() { let duplicated = read_sexp( "(spelling-precedence (user-chosen user-chosen propagated inferred analytical))", ) .unwrap(); assert!(SpellingPrecedence::parse(&duplicated).is_err()); let missing = read_sexp("(spelling-precedence (user-chosen imported propagated inferred))").unwrap(); assert!(SpellingPrecedence::parse(&missing).is_err()); } }