//! Grammar-directed text projections for operation-layer leaves and sub-vocabularies. use epiphany_core::textvalue::{Sexp, TextError, TextValue}; use epiphany_core::{Beam, EventId, MusicalPosition, Rest, Slur, Spanner, Tie, TupletId}; use epiphany_determinism::sorted_canonical; use crate::conflict::{ConflictId, ResolutionAction}; use crate::envelope::EnvelopeHash; use crate::payload::{ CrossCuttingValue, PositionRemapping, TransactionCategory, TupletCompensation, }; use crate::support::AuthorId; use crate::undo::UndoPolicy; /// The lexical class of `s`, used by impls in this module and by the registry-id /// declaration macro. This mirrors the core text layer's private diagnostic helper. pub(crate) fn class_of(s: &Sexp) -> &'static str { match s { Sexp::List(_) => "list", Sexp::Symbol(_) => "symbol", Sexp::Int(_) => "integer", Sexp::Bytes(_) => "byte string", Sexp::Str(_) => "string", } } fn constructor<'a>( s: &'a Sexp, type_name: &'static str, ) -> Result<(&'a str, &'a [Sexp]), TextError> { let items = s.as_list().ok_or(TextError::Expected { expected: type_name, found: class_of(s), })?; let Some(head) = items.first().and_then(Sexp::as_symbol) else { return Err(TextError::Syntax( "a constructor list is headed by a symbol", )); }; Ok((head, &items[1..])) } fn expect_arity<'a>( fields: &'a [Sexp], expected: usize, type_name: &'static str, ) -> Result<&'a [Sexp], TextError> { if fields.len() != expected { return Err(TextError::Arity { type_name, expected, found: fields.len(), }); } Ok(fields) } fn unknown(type_name: &'static str, found: &str) -> TextError { TextError::UnknownConstructor { type_name, found: found.to_owned(), } } fn parse_sorted_sequence(s: &Sexp, what: &'static str) -> Result, TextError> where T: TextValue + Ord, { let values = Vec::::parse(s)?; // These payload fields are Vecs whose binary encoders sort without removing // duplicates. Check before constructing the enum so parsing never normalizes // a descending input; equal neighbours remain legal in the frozen wire form. if values.windows(2).any(|pair| pair[0] > pair[1]) { return Err(TextError::NotCanonical(what)); } Ok(values) } fn parse_reassign_entries(s: &Sexp) -> Result, TextError> { let entries = Vec::<(EventId, MusicalPosition)>::parse(s)?; // The encoder sorts by EventId alone and its stable sort preserves the order // of duplicate keys. Compare only IDs: equal IDs with any positions are // therefore canonical, while a strict decrease would be normalized. if entries.windows(2).any(|pair| pair[0].0 > pair[1].0) { return Err(TextError::NotCanonical( "Reassign entries must be non-decreasing by EventId", )); } Ok(entries) } /// Projects an author identifier as its canonical 16 big-endian bytes. impl TextValue for AuthorId { fn project(&self) -> Sexp { Sexp::Bytes(self.canonical_bytes().to_vec()) } fn parse(s: &Sexp) -> Result { let Sexp::Bytes(bytes) = s else { return Err(TextError::Expected { expected: "AuthorId", found: class_of(s), }); }; let bytes: [u8; 16] = bytes .as_slice() .try_into() .map_err(|_| TextError::NotCanonical("an AuthorId is exactly 16 bytes"))?; Ok(Self(u128::from_be_bytes(bytes))) } } /// Projects a conflict identifier as its canonical 16 big-endian bytes. impl TextValue for ConflictId { fn project(&self) -> Sexp { Sexp::Bytes(self.canonical_bytes().to_vec()) } fn parse(s: &Sexp) -> Result { let Sexp::Bytes(bytes) = s else { return Err(TextError::Expected { expected: "ConflictId", found: class_of(s), }); }; let bytes: [u8; 16] = bytes .as_slice() .try_into() .map_err(|_| TextError::NotCanonical("a ConflictId is exactly 16 bytes"))?; Ok(Self(u128::from_be_bytes(bytes))) } } /// Projects an envelope hash as its canonical 32 bytes. impl TextValue for EnvelopeHash { fn project(&self) -> Sexp { Sexp::Bytes(self.0.to_vec()) } fn parse(s: &Sexp) -> Result { let Sexp::Bytes(bytes) = s else { return Err(TextError::Expected { expected: "EnvelopeHash", found: class_of(s), }); }; let bytes: [u8; 32] = bytes .as_slice() .try_into() .map_err(|_| TextError::NotCanonical("an EnvelopeHash is exactly 32 bytes"))?; Ok(Self(bytes)) } } /// Implements the grammar's `action` production in canonical discriminant order. impl TextValue for ResolutionAction { fn project(&self) -> Sexp { match self { ResolutionAction::AcceptLoser => Sexp::sym("accept-loser"), ResolutionAction::KeepWinner => Sexp::sym("keep-winner"), ResolutionAction::Override { override_operation } => { Sexp::List(vec![Sexp::sym("override"), override_operation.project()]) } ResolutionAction::Reanchor { new_target } => { Sexp::List(vec![Sexp::sym("reanchor"), new_target.project()]) } ResolutionAction::Dismiss => Sexp::sym("dismiss"), ResolutionAction::Registered(id) => { Sexp::List(vec![Sexp::sym("registered"), id.project()]) } } } fn parse(s: &Sexp) -> Result { if let Some(name) = s.as_symbol() { return match name { "accept-loser" => Ok(Self::AcceptLoser), "keep-winner" => Ok(Self::KeepWinner), "dismiss" => Ok(Self::Dismiss), _ => Err(unknown("ResolutionAction", name)), }; } let (name, fields) = constructor(s, "ResolutionAction")?; let fields = expect_arity(fields, 1, "ResolutionAction")?; match name { "override" => Ok(Self::Override { override_operation: TextValue::parse(&fields[0])?, }), "reanchor" => Ok(Self::Reanchor { new_target: TextValue::parse(&fields[0])?, }), "registered" => Ok(Self::Registered(TextValue::parse(&fields[0])?)), _ => Err(unknown("ResolutionAction", name)), } } } /// Implements the grammar's `policy` production. impl TextValue for UndoPolicy { fn project(&self) -> Sexp { match self { UndoPolicy::StrictInverse => Sexp::sym("strict-inverse"), UndoPolicy::BestEffort => Sexp::sym("best-effort"), UndoPolicy::Cascade => Sexp::sym("cascade"), } } fn parse(s: &Sexp) -> Result { match s.as_symbol() { Some("strict-inverse") => Ok(Self::StrictInverse), Some("best-effort") => Ok(Self::BestEffort), Some("cascade") => Ok(Self::Cascade), Some(name) => Err(unknown("UndoPolicy", name)), None => Err(TextError::Expected { expected: "UndoPolicy", found: class_of(s), }), } } } /// Implements the grammar's `tuplet-comp` production in canonical discriminant /// and payload-field order. impl TextValue for TupletCompensation { fn project(&self) -> Sexp { match self { TupletCompensation::NotInTuplet => Sexp::sym("not-in-tuplet"), TupletCompensation::ReplaceWithRest { rest } => { Sexp::List(vec![Sexp::sym("replace-with-rest"), rest.project()]) } TupletCompensation::RewriteTuplets { tuplets } => Sexp::List(vec![ Sexp::sym("rewrite-tuplets"), sorted_canonical(tuplets.clone()).project(), ]), TupletCompensation::CascadeDeleteTuplets { tuplets } => Sexp::List(vec![ Sexp::sym("cascade-delete-tuplets"), sorted_canonical(tuplets.clone()).project(), ]), } } fn parse(s: &Sexp) -> Result { if let Some(name) = s.as_symbol() { return match name { "not-in-tuplet" => Ok(Self::NotInTuplet), _ => Err(unknown("TupletCompensation", name)), }; } let (name, fields) = constructor(s, "TupletCompensation")?; let fields = expect_arity(fields, 1, "TupletCompensation")?; match name { "replace-with-rest" => Ok(Self::ReplaceWithRest { rest: Rest::parse(&fields[0])?, }), "rewrite-tuplets" => Ok(Self::RewriteTuplets { tuplets: parse_sorted_sequence::( &fields[0], "RewriteTuplets ids must be non-decreasing", )?, }), "cascade-delete-tuplets" => Ok(Self::CascadeDeleteTuplets { tuplets: parse_sorted_sequence::( &fields[0], "CascadeDeleteTuplets ids must be non-decreasing", )?, }), _ => Err(unknown("TupletCompensation", name)), } } } /// Implements the grammar's `cross-cutting` production, retaining the canonical /// discriminant order of `CrossCuttingValue::encode_canonical`. impl TextValue for CrossCuttingValue { fn project(&self) -> Sexp { match self { CrossCuttingValue::Tie(value) => Sexp::List(vec![Sexp::sym("tie"), value.project()]), CrossCuttingValue::Slur(value) => Sexp::List(vec![Sexp::sym("slur"), value.project()]), CrossCuttingValue::Beam(value) => Sexp::List(vec![Sexp::sym("beam"), value.project()]), CrossCuttingValue::Spanner(value) => { Sexp::List(vec![Sexp::sym("spanner"), value.project()]) } } } fn parse(s: &Sexp) -> Result { let (name, fields) = constructor(s, "CrossCuttingValue")?; let fields = expect_arity(fields, 1, "CrossCuttingValue")?; match name { "tie" => Ok(Self::Tie(Tie::parse(&fields[0])?)), "slur" => Ok(Self::Slur(Slur::parse(&fields[0])?)), "beam" => Ok(Self::Beam(Beam::parse(&fields[0])?)), "spanner" => Ok(Self::Spanner(Spanner::parse(&fields[0])?)), _ => Err(unknown("CrossCuttingValue", name)), } } } /// Implements the grammar's `remapping` production. Each reassign entry delegates /// to the core pair projection for `(EventId, MusicalPosition)`. impl TextValue for PositionRemapping { fn project(&self) -> Sexp { match self { PositionRemapping::PreserveTime => Sexp::sym("preserve-time"), PositionRemapping::Reassign(entries) => { let mut entries = entries.clone(); entries.sort_by_key(|(event, _)| event.canonical_bytes()); Sexp::List(vec![Sexp::sym("reassign"), entries.project()]) } } } fn parse(s: &Sexp) -> Result { if let Some(name) = s.as_symbol() { return match name { "preserve-time" => Ok(Self::PreserveTime), _ => Err(unknown("PositionRemapping", name)), }; } let (name, fields) = constructor(s, "PositionRemapping")?; if name != "reassign" { return Err(unknown("PositionRemapping", name)); } let fields = expect_arity(fields, 1, "PositionRemapping")?; Ok(Self::Reassign(parse_reassign_entries(&fields[0])?)) } } /// Implements the five-variant transaction-category sub-vocabulary in canonical /// discriminant order. impl TextValue for TransactionCategory { fn project(&self) -> Sexp { match self { TransactionCategory::NoteEntry => Sexp::sym("note-entry"), TransactionCategory::Structural => Sexp::sym("structural"), TransactionCategory::Layout => Sexp::sym("layout"), TransactionCategory::Import => Sexp::sym("import"), TransactionCategory::Registered(id) => { Sexp::List(vec![Sexp::sym("registered"), id.project()]) } } } fn parse(s: &Sexp) -> Result { if let Some(name) = s.as_symbol() { return match name { "note-entry" => Ok(Self::NoteEntry), "structural" => Ok(Self::Structural), "layout" => Ok(Self::Layout), "import" => Ok(Self::Import), _ => Err(unknown("TransactionCategory", name)), }; } let (name, fields) = constructor(s, "TransactionCategory")?; if name != "registered" { return Err(unknown("TransactionCategory", name)); } let fields = expect_arity(fields, 1, "TransactionCategory")?; Ok(Self::Registered(TextValue::parse(&fields[0])?)) } } #[cfg(test)] mod tests { use std::fmt::Debug; use epiphany_core::textvalue::read_sexp; use epiphany_core::{ AnchorOffset, BeamId, EventId, MusicalDuration, RationalTime, ReplicaId, SlurId, SpannerId, TieId, TimeAnchor, TupletId, VoiceId, }; use super::*; use crate::support::{ ConflictKindRegistryId, ExtensionPreconditionId, IntegrityAnomalyRegistryId, OperationKindRegistryId, PreconditionFailureRegistryId, ReanchorReasonRegistryId, RepairKindRegistryId, ReplicaAnomalyRegistryId, ResolutionRegistryId, }; fn round_trip(value: T) where T: TextValue + PartialEq + Debug, { let rendered = value.project().render(); let read = read_sexp(&rendered).expect("projected text is valid s-expression"); let parsed = T::parse(&read).expect("projected value parses"); assert_eq!(parsed, value); assert_eq!(parsed.project().render(), rendered); } fn event(counter: u64) -> EventId { EventId::new(ReplicaId(7), counter) } fn tuplet(counter: u64) -> TupletId { TupletId::new(ReplicaId(7), counter) } #[test] fn byte_leaves_round_trip() { round_trip(AuthorId(0x0011_2233_4455_6677_8899_aabb_ccdd_eeff)); round_trip(ConflictId(0xffee_ddcc_bbaa_9988_7766_5544_3322_1100)); round_trip(EnvelopeHash([0x5a; 32])); round_trip(OperationKindRegistryId(1)); round_trip(ConflictKindRegistryId(2)); round_trip(ResolutionRegistryId(3)); round_trip(RepairKindRegistryId(4)); round_trip(ReanchorReasonRegistryId(5)); round_trip(ReplicaAnomalyRegistryId(6)); round_trip(IntegrityAnomalyRegistryId(7)); round_trip(ExtensionPreconditionId(8)); round_trip(PreconditionFailureRegistryId(9)); } #[test] fn byte_leaves_reject_noncanonical_lengths() { let short = read_sexp("#x00").unwrap(); assert!(AuthorId::parse(&short).is_err()); assert!(ConflictId::parse(&short).is_err()); assert!(EnvelopeHash::parse(&short).is_err()); assert!(OperationKindRegistryId::parse(&short).is_err()); assert!(ConflictKindRegistryId::parse(&short).is_err()); assert!(ResolutionRegistryId::parse(&short).is_err()); assert!(RepairKindRegistryId::parse(&short).is_err()); assert!(ReanchorReasonRegistryId::parse(&short).is_err()); assert!(ReplicaAnomalyRegistryId::parse(&short).is_err()); assert!(IntegrityAnomalyRegistryId::parse(&short).is_err()); assert!(ExtensionPreconditionId::parse(&short).is_err()); assert!(PreconditionFailureRegistryId::parse(&short).is_err()); } #[test] fn action_round_trips_every_variant() { let values = [ ResolutionAction::AcceptLoser, ResolutionAction::KeepWinner, ResolutionAction::Override { override_operation: epiphany_core::OperationId::new(ReplicaId(1), 2), }, ResolutionAction::Reanchor { new_target: epiphany_core::TypedObjectId::Event(event(3)), }, ResolutionAction::Dismiss, ResolutionAction::Registered(ResolutionRegistryId(4)), ]; for value in values { round_trip(value); } } #[test] fn action_rejects_noncanonical_productions() { for bad in ["accept-winner", "(override)", "(reanchor #x00 #x01)"] { let sexp = read_sexp(bad).unwrap(); assert!(ResolutionAction::parse(&sexp).is_err(), "{bad}"); } } #[test] fn policy_round_trips_every_variant() { for value in [ UndoPolicy::StrictInverse, UndoPolicy::BestEffort, UndoPolicy::Cascade, ] { round_trip(value); } } #[test] fn policy_rejects_noncanonical_productions() { for bad in ["strict", "(cascade)"] { let sexp = read_sexp(bad).unwrap(); assert!(UndoPolicy::parse(&sexp).is_err(), "{bad}"); } } #[test] fn tuplet_comp_round_trips_every_variant() { let rest = crate::valuegen::rest_value( event(9), VoiceId::new(ReplicaId(7), 1), MusicalDuration( RationalTime::new(1, 4).expect("one quarter has a nonzero denominator"), ), ); for value in [ TupletCompensation::NotInTuplet, TupletCompensation::ReplaceWithRest { rest }, TupletCompensation::RewriteTuplets { tuplets: vec![tuplet(1), tuplet(2)], }, TupletCompensation::CascadeDeleteTuplets { tuplets: vec![tuplet(2), tuplet(2)], }, ] { round_trip(value); } } #[test] fn tuplet_comp_rejects_noncanonical_productions() { for bad in ["rewrite-tuplets", "(replace-with-rest)", "(unknown ())"] { let sexp = read_sexp(bad).unwrap(); assert!(TupletCompensation::parse(&sexp).is_err(), "{bad}"); } } #[test] fn tuplet_rewrite_rejects_descending_ids() { let sexp = read_sexp( "(rewrite-tuplets (#x00000000000000070000000000000002 #x00000000000000070000000000000001))", ) .unwrap(); assert!(TupletCompensation::parse(&sexp).is_err()); } #[test] fn tuplet_cascade_rejects_descending_ids() { let sexp = read_sexp( "(cascade-delete-tuplets (#x00000000000000070000000000000002 #x00000000000000070000000000000001))", ) .unwrap(); assert!(TupletCompensation::parse(&sexp).is_err()); } #[test] fn cross_cutting_round_trips_every_variant() { let start = event(1); let end = event(2); let spanner = Spanner { id: SpannerId::new(ReplicaId(7), 1), start: TimeAnchor::Event { id: start, offset: AnchorOffset::Musical(MusicalDuration::zero()), }, end: TimeAnchor::Event { id: end, offset: AnchorOffset::Musical(MusicalDuration::zero()), }, staves: Vec::new(), kind: Default::default(), style: Default::default(), }; let values = [ CrossCuttingValue::Tie(crate::valuegen::tie( TieId::new(ReplicaId(7), 1), start, end, )), CrossCuttingValue::Slur(crate::valuegen::slur( SlurId::new(ReplicaId(7), 1), start, end, )), CrossCuttingValue::Beam(crate::valuegen::beam( BeamId::new(ReplicaId(7), 1), vec![start, end], )), CrossCuttingValue::Spanner(spanner), ]; for value in values { round_trip(value); } } #[test] fn cross_cutting_rejects_noncanonical_productions() { for bad in ["tie", "(tie)", "(unknown #x00)"] { let sexp = read_sexp(bad).unwrap(); assert!(CrossCuttingValue::parse(&sexp).is_err(), "{bad}"); } } #[test] fn remapping_round_trips_every_variant() { round_trip(PositionRemapping::PreserveTime); round_trip(PositionRemapping::Reassign(vec![ (event(1), MusicalPosition::origin()), ( event(2), MusicalPosition( RationalTime::new(3, 4).expect("three quarters has a nonzero denominator"), ), ), (event(2), MusicalPosition::origin()), ])); } #[test] fn remapping_rejects_noncanonical_productions() { for bad in ["reassign", "(reassign)", "(unknown ())"] { let sexp = read_sexp(bad).unwrap(); assert!(PositionRemapping::parse(&sexp).is_err(), "{bad}"); } } #[test] fn remapping_rejects_descending_event_ids() { let sexp = read_sexp( "(reassign ((#x00000000000000070000000000000002 (ratio 0 1)) (#x00000000000000070000000000000001 (ratio 0 1))))", ) .unwrap(); assert!(PositionRemapping::parse(&sexp).is_err()); } #[test] fn transaction_category_round_trips_every_variant() { for value in [ TransactionCategory::NoteEntry, TransactionCategory::Structural, TransactionCategory::Layout, TransactionCategory::Import, TransactionCategory::Registered(OperationKindRegistryId(5)), ] { round_trip(value); } } #[test] fn transaction_category_rejects_noncanonical_productions() { for bad in ["noteentry", "registered", "(registered)", "(unknown #x00)"] { let sexp = read_sexp(bad).unwrap(); assert!(TransactionCategory::parse(&sexp).is_err(), "{bad}"); } } }