epiphany/crates/epiphany-core/src/textvalue_time.rs

604 lines
25 KiB
Rust

//! [`TextValue`] for the hand-written tagged unions of `time.rs`.
//!
//! These are the Chapter-3/Chapter-5 time enums whose [`Codec`] is written by
//! hand rather than by a `struct_codec!`/`cstyle_enum_codec!` macro, so their
//! projection has to be written by hand too: [`TimeAnchor`], [`EventPosition`],
//! [`ConcreteDuration`], [`EventDuration`], [`TimeBounds`], and [`AnchorOffset`].
//!
//! [`Codec`]: crate::codec::Codec
//!
//! `AnchorOffset` is included because [`TimeAnchor`] embeds it and nothing else
//! projects it: it is a hand-written-codec union with no macro to generate a
//! [`TextValue`], and the C-style/struct siblings a `TimeAnchor` also touches
//! ([`MeasurePosition`], [`RegionEdge`], [`DurationBounds`]) get theirs from their
//! codec macros. Its omission would leave `TimeAnchor::project` with no way to
//! project its `offset`.
//!
//! [`MeasurePosition`]: crate::time::MeasurePosition
//! [`RegionEdge`]: crate::time::RegionEdge
//! [`DurationBounds`]: crate::time::DurationBounds
//!
//! # Why none of these needs [`ensure_canonical`]
//!
//! `req:textproj:strict-parse` forbids a parse that normalizes. A whole-value
//! [`ensure_canonical`] guard is needed only where a value can *only* be built
//! through a constructor that validates or normalizes. None of these unions has
//! one: each `parse` selects a variant by its head symbol and builds it with a
//! plain enum expression, and every field it then reads parses strictly on its
//! own — a byte-string id re-encode-checks, a `RationalTime` rejects a non-reduced
//! ratio, an `i64` range-checks, a [`DurationBounds`] parses its fields
//! positionally. So the guard would have nothing left to catch, and there is no
//! order-constrained `Vec` here for it to be blind to either. The strictness these
//! impls own is narrower and explicit: a fieldless variant is the bare symbol, so
//! its one-element list spelling (`(zero)`, `(unbounded)`) is rejected, not
//! silently accepted (`req:textproj:value-projection` clause 3).
//!
//! [`ensure_canonical`]: crate::textvalue::ensure_canonical
use crate::textvalue::{kebab, Sexp, TextError, TextValue};
use crate::textvalue_impls::{class_of, project_unit};
use crate::time::{
AnchorOffset, ConcreteDuration, EventDuration, EventPosition, TimeAnchor, TimeBounds,
};
// ===========================================================================
// Shared shape helpers.
// ===========================================================================
/// The projection of a variant that carries `fields`: a list headed by the
/// variant's kebab-case name (`req:textproj:value-projection` clause 3). A
/// fieldless variant is not built here; it is the bare symbol, via
/// [`project_unit`].
fn applied(variant: &str, fields: Vec<Sexp>) -> Sexp {
let mut items = Vec::with_capacity(fields.len() + 1);
items.push(Sexp::Symbol(kebab(variant)));
items.extend(fields);
Sexp::List(items)
}
/// A tagged-union projection split into its constructor and any fields, keeping
/// the distinction the binary discriminant keeps: a bare symbol names a fieldless
/// variant and can match *only* one; a list headed by a symbol names an applied
/// variant and can match *only* one. Collapsing the two would accept `(zero)` as
/// `zero`, which is exactly the normalization `req:textproj:strict-parse` forbids.
enum Variant<'a> {
/// A bare symbol: a fieldless variant's canonical spelling.
Nullary(&'a str),
/// A list `(<name> <field>…)`: an applied variant's canonical spelling.
Applied(&'a str, &'a [Sexp]),
}
/// Classifies `s` as a tagged-union projection, or reports why it is not one.
fn classify<'a>(s: &'a Sexp, type_name: &'static str) -> Result<Variant<'a>, TextError> {
match s {
Sexp::Symbol(name) => Ok(Variant::Nullary(name)),
Sexp::List(items) => {
let (head, fields) = items.split_first().ok_or(TextError::Syntax(
"a tagged-union list is headed by its variant name",
))?;
let head = head.as_symbol().ok_or(TextError::Syntax(
"a tagged-union list is headed by its variant name",
))?;
Ok(Variant::Applied(head, fields))
}
_ => Err(TextError::Expected {
expected: type_name,
found: class_of(s),
}),
}
}
/// The error for an applied variant read with the wrong number of fields.
fn arity(type_name: &'static str, expected: usize, found: usize) -> TextError {
TextError::Arity {
type_name,
expected,
found,
}
}
/// The error for a constructor symbol naming no variant of `type_name`.
fn unknown(type_name: &'static str, found: &str) -> TextError {
TextError::UnknownConstructor {
type_name,
found: found.to_owned(),
}
}
// ===========================================================================
// TimeAnchor.
// ===========================================================================
/// A stored time reference. The variant order and every struct-variant's field
/// order mirror `impl Codec for TimeAnchor` exactly: `Event { id, offset }`,
/// `Measure { id, position, offset }`, `Region { id, edge, offset }`,
/// `WallClock { time }`.
///
/// The match in `project` is exhaustive with no `_` arm, so a new variant will
/// not compile until it is projected here. `parse` builds each variant directly
/// and reads its fields with their own strict parses, so it needs no
/// whole-value guard.
impl TextValue for TimeAnchor {
fn project(&self) -> Sexp {
match self {
TimeAnchor::Event { id, offset } => applied(
"Event",
vec![TextValue::project(id), TextValue::project(offset)],
),
TimeAnchor::Measure {
id,
position,
offset,
} => applied(
"Measure",
vec![
TextValue::project(id),
TextValue::project(position),
TextValue::project(offset),
],
),
TimeAnchor::Region { id, edge, offset } => applied(
"Region",
vec![
TextValue::project(id),
TextValue::project(edge),
TextValue::project(offset),
],
),
TimeAnchor::WallClock { time } => applied("WallClock", vec![TextValue::project(time)]),
}
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
match classify(s, "TimeAnchor")? {
Variant::Applied(head, fields) if head == kebab("Event") => {
let [id, offset] = fields else {
return Err(arity("TimeAnchor", 2, fields.len()));
};
Ok(TimeAnchor::Event {
id: TextValue::parse(id)?,
offset: TextValue::parse(offset)?,
})
}
Variant::Applied(head, fields) if head == kebab("Measure") => {
let [id, position, offset] = fields else {
return Err(arity("TimeAnchor", 3, fields.len()));
};
Ok(TimeAnchor::Measure {
id: TextValue::parse(id)?,
position: TextValue::parse(position)?,
offset: TextValue::parse(offset)?,
})
}
Variant::Applied(head, fields) if head == kebab("Region") => {
let [id, edge, offset] = fields else {
return Err(arity("TimeAnchor", 3, fields.len()));
};
Ok(TimeAnchor::Region {
id: TextValue::parse(id)?,
edge: TextValue::parse(edge)?,
offset: TextValue::parse(offset)?,
})
}
Variant::Applied(head, fields) if head == kebab("WallClock") => {
let [time] = fields else {
return Err(arity("TimeAnchor", 1, fields.len()));
};
Ok(TimeAnchor::WallClock {
time: TextValue::parse(time)?,
})
}
Variant::Applied(head, _) | Variant::Nullary(head) => Err(unknown("TimeAnchor", head)),
}
}
}
// ===========================================================================
// EventPosition.
// ===========================================================================
/// An event's position, unioned over the two clocks. Variant order mirrors
/// `impl Codec for EventPosition`: `Musical(_)` then `WallClock(_)`.
impl TextValue for EventPosition {
fn project(&self) -> Sexp {
match self {
EventPosition::Musical(p) => applied("Musical", vec![TextValue::project(p)]),
EventPosition::WallClock(t) => applied("WallClock", vec![TextValue::project(t)]),
}
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
match classify(s, "EventPosition")? {
Variant::Applied(head, fields) if head == kebab("Musical") => {
let [p] = fields else {
return Err(arity("EventPosition", 1, fields.len()));
};
Ok(EventPosition::Musical(TextValue::parse(p)?))
}
Variant::Applied(head, fields) if head == kebab("WallClock") => {
let [t] = fields else {
return Err(arity("EventPosition", 1, fields.len()));
};
Ok(EventPosition::WallClock(TextValue::parse(t)?))
}
Variant::Applied(head, _) | Variant::Nullary(head) => {
Err(unknown("EventPosition", head))
}
}
}
}
// ===========================================================================
// ConcreteDuration.
// ===========================================================================
/// A determinate duration in one clock. Variant order mirrors
/// `impl Codec for ConcreteDuration`: `Musical(_)` then `WallClock(_)`.
impl TextValue for ConcreteDuration {
fn project(&self) -> Sexp {
match self {
ConcreteDuration::Musical(d) => applied("Musical", vec![TextValue::project(d)]),
ConcreteDuration::WallClock(d) => applied("WallClock", vec![TextValue::project(d)]),
}
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
match classify(s, "ConcreteDuration")? {
Variant::Applied(head, fields) if head == kebab("Musical") => {
let [d] = fields else {
return Err(arity("ConcreteDuration", 1, fields.len()));
};
Ok(ConcreteDuration::Musical(TextValue::parse(d)?))
}
Variant::Applied(head, fields) if head == kebab("WallClock") => {
let [d] = fields else {
return Err(arity("ConcreteDuration", 1, fields.len()));
};
Ok(ConcreteDuration::WallClock(TextValue::parse(d)?))
}
Variant::Applied(head, _) | Variant::Nullary(head) => {
Err(unknown("ConcreteDuration", head))
}
}
}
}
// ===========================================================================
// EventDuration.
// ===========================================================================
/// An event's duration, unioned over musical, wall-clock, and indeterminate
/// forms. Variant order mirrors `impl Codec for EventDuration`: `Musical(_)`,
/// `WallClock(_)`, then `Indeterminate(_)` whose payload is a
/// [`DurationBounds`](crate::time::DurationBounds) — itself a `struct_codec!`
/// struct whose projection and strict parse the macro supplies.
impl TextValue for EventDuration {
fn project(&self) -> Sexp {
match self {
EventDuration::Musical(d) => applied("Musical", vec![TextValue::project(d)]),
EventDuration::WallClock(d) => applied("WallClock", vec![TextValue::project(d)]),
EventDuration::Indeterminate(b) => {
applied("Indeterminate", vec![TextValue::project(b)])
}
}
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
match classify(s, "EventDuration")? {
Variant::Applied(head, fields) if head == kebab("Musical") => {
let [d] = fields else {
return Err(arity("EventDuration", 1, fields.len()));
};
Ok(EventDuration::Musical(TextValue::parse(d)?))
}
Variant::Applied(head, fields) if head == kebab("WallClock") => {
let [d] = fields else {
return Err(arity("EventDuration", 1, fields.len()));
};
Ok(EventDuration::WallClock(TextValue::parse(d)?))
}
Variant::Applied(head, fields) if head == kebab("Indeterminate") => {
let [b] = fields else {
return Err(arity("EventDuration", 1, fields.len()));
};
Ok(EventDuration::Indeterminate(TextValue::parse(b)?))
}
Variant::Applied(head, _) | Variant::Nullary(head) => {
Err(unknown("EventDuration", head))
}
}
}
}
// ===========================================================================
// TimeBounds.
// ===========================================================================
/// An aleatoric interval bound. Variant order and struct-variant field order
/// mirror `impl Codec for TimeBounds`: `MusicalRange { min, max }`,
/// `WallClockRange { min, max }`, then the fieldless `Unbounded`.
///
/// `Unbounded` is the bare symbol `unbounded`; the list `(unbounded)` is a
/// different spelling and is rejected below as an unknown *applied* constructor,
/// never accepted as `Unbounded`. The binary form imposes no `min <= max`
/// ordering and there is no validating constructor, so any `min`/`max` pair is
/// canonical and no per-site order check is owed.
impl TextValue for TimeBounds {
fn project(&self) -> Sexp {
match self {
TimeBounds::MusicalRange { min, max } => applied(
"MusicalRange",
vec![TextValue::project(min), TextValue::project(max)],
),
TimeBounds::WallClockRange { min, max } => applied(
"WallClockRange",
vec![TextValue::project(min), TextValue::project(max)],
),
TimeBounds::Unbounded => project_unit("Unbounded"),
}
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
match classify(s, "TimeBounds")? {
Variant::Nullary(head) if head == kebab("Unbounded") => Ok(TimeBounds::Unbounded),
Variant::Applied(head, fields) if head == kebab("MusicalRange") => {
let [min, max] = fields else {
return Err(arity("TimeBounds", 2, fields.len()));
};
Ok(TimeBounds::MusicalRange {
min: TextValue::parse(min)?,
max: TextValue::parse(max)?,
})
}
Variant::Applied(head, fields) if head == kebab("WallClockRange") => {
let [min, max] = fields else {
return Err(arity("TimeBounds", 2, fields.len()));
};
Ok(TimeBounds::WallClockRange {
min: TextValue::parse(min)?,
max: TextValue::parse(max)?,
})
}
Variant::Applied(head, _) | Variant::Nullary(head) => Err(unknown("TimeBounds", head)),
}
}
}
// ===========================================================================
// AnchorOffset.
// ===========================================================================
/// An offset applied to an anchor target, embedded in [`TimeAnchor`]. Variant
/// order mirrors `impl Codec for AnchorOffset`: `Musical(_)`, `WallClock(_)`,
/// then the fieldless `Zero`.
///
/// As with [`TimeBounds::Unbounded`], `Zero` is the bare symbol `zero` and its
/// list spelling `(zero)` is rejected rather than normalized.
impl TextValue for AnchorOffset {
fn project(&self) -> Sexp {
match self {
AnchorOffset::Musical(d) => applied("Musical", vec![TextValue::project(d)]),
AnchorOffset::WallClock(d) => applied("WallClock", vec![TextValue::project(d)]),
AnchorOffset::Zero => project_unit("Zero"),
}
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
match classify(s, "AnchorOffset")? {
Variant::Nullary(head) if head == kebab("Zero") => Ok(AnchorOffset::Zero),
Variant::Applied(head, fields) if head == kebab("Musical") => {
let [d] = fields else {
return Err(arity("AnchorOffset", 1, fields.len()));
};
Ok(AnchorOffset::Musical(TextValue::parse(d)?))
}
Variant::Applied(head, fields) if head == kebab("WallClock") => {
let [d] = fields else {
return Err(arity("AnchorOffset", 1, fields.len()));
};
Ok(AnchorOffset::WallClock(TextValue::parse(d)?))
}
Variant::Applied(head, _) | Variant::Nullary(head) => {
Err(unknown("AnchorOffset", head))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ids::{EventId, MeasureId, RegionId, ReplicaId};
use crate::textvalue::read_sexp;
use crate::time::{
DurationBounds, MeasurePosition, MusicalDuration, MusicalPosition, RationalTime,
RegionEdge, WallClockDuration, WallClockTime,
};
fn rt(n: i64, d: i64) -> RationalTime {
RationalTime::new(n, d).unwrap()
}
/// Build a value, project it, render it, read it back with the strict reader,
/// parse it, and require the result to equal the original.
#[track_caller]
fn round_trip<T: TextValue + PartialEq + std::fmt::Debug>(value: T) {
let text = value.project().render();
let read = read_sexp(&text)
.unwrap_or_else(|e| panic!("projection {text:?} was rejected by read_sexp: {e}"));
let back =
T::parse(&read).unwrap_or_else(|e| panic!("projection {text:?} did not parse: {e}"));
assert_eq!(value, back, "{text:?} did not round-trip");
}
/// The strict reader must accept `text`, and the typed parse must then reject
/// it — the projection is well-formed lexically but is not the canonical
/// projection of any value of `T`.
#[track_caller]
fn rejects<T: TextValue + std::fmt::Debug>(text: &str) {
let s = read_sexp(text).unwrap_or_else(|e| panic!("{text:?} was not lexable: {e}"));
assert!(
T::parse(&s).is_err(),
"{text:?} was accepted; strict parsing forbids it"
);
}
// -------------------------------------------------------------------
// TimeAnchor.
// -------------------------------------------------------------------
#[test]
fn time_anchor_round_trips_every_variant() {
round_trip(TimeAnchor::WallClock {
time: WallClockTime(42),
});
round_trip(TimeAnchor::Event {
id: EventId::new(ReplicaId(1), 2),
offset: AnchorOffset::Zero,
});
round_trip(TimeAnchor::Measure {
id: MeasureId::new(ReplicaId(3), 4),
position: MeasurePosition::Start,
offset: AnchorOffset::Musical(MusicalDuration(rt(1, 4))),
});
round_trip(TimeAnchor::Region {
id: RegionId::new(ReplicaId(5), 6),
edge: RegionEdge::End,
offset: AnchorOffset::WallClock(WallClockDuration(10)),
});
}
#[test]
fn time_anchor_projects_fields_in_codec_order() {
let anchor = TimeAnchor::Measure {
id: MeasureId::new(ReplicaId(0), 1),
position: MeasurePosition::End,
offset: AnchorOffset::Zero,
};
// id, then position, then offset — the order `impl Codec` writes.
assert_eq!(
anchor.project().render(),
"(measure #x00000000000000000000000000000001 end zero)"
);
}
#[test]
fn time_anchor_rejects_wrong_arity_and_unknown_constructor() {
// A one-field `WallClock` written with none, and a `Measure` with too few.
rejects::<TimeAnchor>("(wall-clock)");
rejects::<TimeAnchor>("(measure #x00000000000000000000000000000001 start)");
// No such variant.
rejects::<TimeAnchor>("(sometime 1)");
// A field-bearing union is never a bare symbol.
rejects::<TimeAnchor>("wall-clock");
}
// -------------------------------------------------------------------
// EventPosition.
// -------------------------------------------------------------------
#[test]
fn event_position_round_trips() {
round_trip(EventPosition::Musical(MusicalPosition(rt(3, 4))));
round_trip(EventPosition::WallClock(WallClockTime(-7)));
}
/// The inner rational is parsed strictly, so a non-reduced ratio is rejected
/// through the projection rather than reduced inside it.
#[test]
fn event_position_rejects_non_canonical_field_and_shape() {
rejects::<EventPosition>("(musical (ratio 2 4))");
rejects::<EventPosition>("(musical)");
rejects::<EventPosition>("(musical (ratio 1 2) (ratio 1 2))");
rejects::<EventPosition>("(bogus (ratio 1 2))");
}
// -------------------------------------------------------------------
// ConcreteDuration.
// -------------------------------------------------------------------
#[test]
fn concrete_duration_round_trips() {
round_trip(ConcreteDuration::Musical(MusicalDuration(rt(1, 4))));
round_trip(ConcreteDuration::WallClock(WallClockDuration(500)));
}
#[test]
fn concrete_duration_rejects_non_canonical_field_and_shape() {
rejects::<ConcreteDuration>("(musical (ratio 2 4))");
rejects::<ConcreteDuration>("(wall-clock 1 2)");
rejects::<ConcreteDuration>("(indeterminate 1)");
}
// -------------------------------------------------------------------
// EventDuration.
// -------------------------------------------------------------------
#[test]
fn event_duration_round_trips_including_indeterminate() {
round_trip(EventDuration::Musical(MusicalDuration(rt(1, 8))));
round_trip(EventDuration::WallClock(WallClockDuration(250)));
round_trip(EventDuration::Indeterminate(DurationBounds {
lower: Some(ConcreteDuration::Musical(MusicalDuration(rt(1, 4)))),
upper: Some(ConcreteDuration::WallClock(WallClockDuration(1000))),
}));
round_trip(EventDuration::Indeterminate(DurationBounds {
lower: None,
upper: None,
}));
}
#[test]
fn event_duration_rejects_wrong_arity_and_unknown_constructor() {
rejects::<EventDuration>("(indeterminate)");
rejects::<EventDuration>("(musical (ratio 2 4))");
rejects::<EventDuration>("(nope 1)");
}
// -------------------------------------------------------------------
// TimeBounds.
// -------------------------------------------------------------------
#[test]
fn time_bounds_round_trips_every_variant() {
round_trip(TimeBounds::MusicalRange {
min: MusicalPosition(rt(0, 1)),
max: MusicalPosition(rt(4, 1)),
});
round_trip(TimeBounds::WallClockRange {
min: WallClockTime(0),
max: WallClockTime(1000),
});
round_trip(TimeBounds::Unbounded);
}
/// `Unbounded` projects to the bare symbol, so the list spelling `(unbounded)`
/// is not its canonical projection and must be rejected — not normalized into
/// `Unbounded`. Symmetrically, a range variant is never a bare symbol.
#[test]
fn time_bounds_rejects_the_list_form_of_the_fieldless_variant() {
rejects::<TimeBounds>("(unbounded)");
rejects::<TimeBounds>("musical-range");
rejects::<TimeBounds>("(musical-range (ratio 1 2))");
rejects::<TimeBounds>("(musical-range (ratio 2 4) (ratio 1 2))");
}
// -------------------------------------------------------------------
// AnchorOffset (the dependency TimeAnchor embeds).
// -------------------------------------------------------------------
#[test]
fn anchor_offset_round_trips_every_variant() {
round_trip(AnchorOffset::Musical(MusicalDuration(rt(1, 16))));
round_trip(AnchorOffset::WallClock(WallClockDuration(-3)));
round_trip(AnchorOffset::Zero);
}
#[test]
fn anchor_offset_rejects_the_list_form_of_zero() {
rejects::<AnchorOffset>("(zero)");
rejects::<AnchorOffset>("musical");
rejects::<AnchorOffset>("(musical (ratio 2 4))");
}
}