Text Projection: the Chapter-5 value layer, from one field list
`project` and `parse` for every value an operation payload can embed. The codec macros now emit a `TextValue` impl beside the `Codec` impl, from the same invocation: 116 types whose field order cannot disagree between the binary form and the text, at zero call-site churn. That is the companion's own rationale applied to code -- a rule cannot drift from the listing it reads, and two listings of one struct is the drift P13-I1 already cost us. `struct_codec!` rebuilds through a struct literal and `cstyle_enum_codec!` matches exhaustively, so a field or variant added later fails to compile rather than silently vanishing from the text. The other 44 types have hand-written codecs and so need hand-written projections. Their field order is verified by a mechanical diff of the identifier sequence in each `fn enc` against the one in each `project`; all 44 agree. This matters because a `project`/`parse` pair that agrees with itself on a *wrong* order round-trips perfectly -- neither the compiler nor any round-trip test can see it. The neighbouring blind spot, a mistyped constructor symbol, is closed by `textvalue_names.rs`, which recovers each type's Rust name from its derived `Debug` and compares it against the symbol actually emitted. Strictness turned out to need only one of its two layers, and mutation testing is what established that. Every per-site check is live: the set/map strictly-increasing walk, `RationalTime`'s lowest-terms compare before construction, the catalog-id NFC intern-and-compare, and `EventArena`'s ascending-`EventId` walk. Every whole-value `ensure_canonical` guard was dead -- `Tempo::new`, `ReferencePitch::new`, `SpellingPrecedence::new` and `EventOrderingDAG::try_new` reject rather than adjust, so an accepted value re-projects to exactly its input and the guard could never fire. A probe confirmed `try_new` returns its input map unchanged. Helper and all four call sites removed: a check that cannot fail invites weakening the real one. Also moves `catalog_name` out of the grammar *test* and into `operation_kind_tag_vocabulary!`, where the discriminant and decoder already live. It was a hand-maintained list parallel to an enum -- the exact shape that has cost this project four bugs. Method note recorded in DECISIONS: the work list came from compiler errors, but the compiler reports only the frontier. `AnchorOffset`, `VoiceSelector`, `PowerOfTwo`, `OctaveOffset` and `NonZeroU16` were each hidden behind a type that had not compiled yet, so the list must be iterated to a fixpoint. Gate green -- clippy 0, 1109 tests, doc 0, conformance 8/8, no golden churn. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
914143c6db
commit
cf81074ca0
|
|
@ -589,3 +589,71 @@ Two further claims from the Push-4a audit are **unverified** and should be
|
|||
checked, not inherited: that the JI dimension convention conflicts with its own
|
||||
prime-2 requirement, and that the named historical tunings lack exact
|
||||
deterministic ratio data. Neither was needed for 4a, and neither was confirmed.
|
||||
|
||||
## The Text Projection value layer (`textvalue*.rs`)
|
||||
|
||||
The Chapter-5 half of the Text Projection companion: `project` and `parse` for
|
||||
every value an operation payload can embed.
|
||||
|
||||
**One field list drives both forms.** `struct_codec!`, `unit_codec!`,
|
||||
`cstyle_enum_codec!` and `catalog_id_codec!` now emit a `TextValue` impl beside
|
||||
the `Codec` impl, from the *same* invocation — 116 types whose field order cannot
|
||||
disagree between the binary form and the text, at zero call-site churn. This is
|
||||
the companion's own rationale applied to code: *a rule cannot drift from the
|
||||
listing it reads*, and two listings of one struct is the drift this project has
|
||||
already been bitten by (P13-I1). The `struct_codec!` expansion rebuilds through a
|
||||
struct literal and `cstyle_enum_codec!` matches exhaustively, so a field or
|
||||
variant added later **fails to compile** rather than silently vanishing from the
|
||||
text.
|
||||
|
||||
The remaining 44 types have hand-written `Codec` impls and so need hand-written
|
||||
projections. Their field order was verified by a **mechanical diff** of the
|
||||
identifier sequence in each `fn enc` against the one in each `project`; all 44
|
||||
agree. Six apparent mismatches were regex artifacts — single-field variants whose
|
||||
binding is named differently on each side, and `.iter().map(…)` forms the pattern
|
||||
missed — each checked by hand.
|
||||
|
||||
**Strictness is per-site, and the whole-value layer turned out to be dead.**
|
||||
The binary decoders enforce `req:binfmt`-style canonicality in two layers: a
|
||||
re-encode-and-compare guard, plus per-site checks for the order-preserving fields
|
||||
that guard is blind to. The text layer was built the same way, and then
|
||||
mutation-tested. The result:
|
||||
|
||||
| check | verdict |
|
||||
|---|---|
|
||||
| set / map strictly-increasing walk | **live** |
|
||||
| `RationalTime` lowest-terms compare before construction | **live** |
|
||||
| catalog-id NFC intern-and-compare | **live** |
|
||||
| `EventArena` ascending-`EventId` walk | **live** |
|
||||
| `ensure_canonical` on `Tempo` | dead — removed |
|
||||
| `ensure_canonical` on `ReferencePitch` | dead — removed |
|
||||
| `ensure_canonical` on `SpellingPrecedence` | dead — removed |
|
||||
| `ensure_canonical` on `EventOrderingDAG` | dead — removed |
|
||||
|
||||
A whole-value guard can only fire when a parse **normalizes**. Every Chapter-5
|
||||
constructor that normalizes (`RationalTime::new` reduces, `X::new` folds to NFC,
|
||||
`EventArena::insert` re-sorts, `BTreeSet`/`BTreeMap` re-sort) needed a check that
|
||||
*names the fault* anyway. The four constructors left — `Tempo::new`,
|
||||
`ReferencePitch::new`, `SpellingPrecedence::new`, `EventOrderingDAG::try_new` —
|
||||
**reject rather than adjust**, so an accepted value re-projects to exactly its
|
||||
input and the guard could never fire. A probe confirmed `try_new` returns its
|
||||
input map unchanged. The helper and all four call sites were removed: *a check
|
||||
that cannot fail is worse than no check, because it invites weakening the real
|
||||
one* — the same finding as the reader's two diagnostic-only branches.
|
||||
|
||||
**What no round-trip test can see.** Two blind spots, both closed elsewhere:
|
||||
|
||||
1. *Field order.* A `project`/`parse` pair that agrees with itself on a wrong
|
||||
order round-trips perfectly, and two adjacent same-typed fields swapped in both
|
||||
directions are invisible to the compiler too. Closed by construction for the
|
||||
116 macro types and by the mechanical diff for the 44 hand-written ones.
|
||||
2. *Constructor names.* `Sexp::sym("measured-fracton")` round-trips, because
|
||||
`parse` reads back the same wrong symbol `project` wrote. Closed by
|
||||
`tests/textvalue_names.rs`, which recovers each type's Rust name from its
|
||||
derived `Debug` and compares it to the symbol actually emitted.
|
||||
|
||||
**One method error worth recording.** The work list came from `cargo check`
|
||||
errors, but the compiler reports only the *frontier* — `AnchorOffset`,
|
||||
`VoiceSelector`, `PowerOfTwo`, `OctaveOffset` and `NonZeroU16` were each hidden
|
||||
behind a type that had not compiled yet. The list has to be iterated to a
|
||||
fixpoint, never taken once.
|
||||
|
|
|
|||
|
|
@ -516,10 +516,34 @@ macro_rules! struct_codec {
|
|||
Ok($ty { $($field),* })
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::textvalue::TextValue for $ty {
|
||||
fn project(&self) -> crate::textvalue::Sexp {
|
||||
let $ty { $($field),* } = self;
|
||||
crate::textvalue::Sexp::List(vec![
|
||||
crate::textvalue::Sexp::Symbol(crate::textvalue::kebab(stringify!($ty))),
|
||||
$( crate::textvalue::TextValue::project($field), )*
|
||||
])
|
||||
}
|
||||
fn parse(
|
||||
s: &crate::textvalue::Sexp,
|
||||
) -> core::result::Result<Self, crate::textvalue::TextError> {
|
||||
const ARITY: usize = [$(stringify!($field)),*].len();
|
||||
let fields = s.expect_struct(&crate::textvalue::kebab(stringify!($ty)), ARITY)?;
|
||||
let mut next = fields.iter();
|
||||
$( let $field = crate::textvalue::TextValue::parse(
|
||||
next.next().expect("arity checked by expect_struct"))?; )*
|
||||
Ok($ty { $($field),* })
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// [`Codec`] for a zero-field unit struct: no bytes.
|
||||
/// [`Codec`] for a zero-field unit struct: no bytes. And its [`TextValue`]: the
|
||||
/// bare symbol, as a fieldless variant is. It encodes to no bytes and carries no
|
||||
/// value in the text either (`req:textproj:value-projection` clause 1).
|
||||
///
|
||||
/// [`TextValue`]: crate::textvalue::TextValue
|
||||
macro_rules! unit_codec {
|
||||
($($ty:ident),* $(,)?) => {
|
||||
$(
|
||||
|
|
@ -529,11 +553,30 @@ macro_rules! unit_codec {
|
|||
Ok($ty)
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::textvalue::TextValue for $ty {
|
||||
fn project(&self) -> crate::textvalue::Sexp {
|
||||
crate::textvalue_impls::project_unit(stringify!($ty))
|
||||
}
|
||||
fn parse(
|
||||
s: &crate::textvalue::Sexp,
|
||||
) -> core::result::Result<Self, crate::textvalue::TextError> {
|
||||
crate::textvalue_impls::parse_unit(s, stringify!($ty)).map(|()| $ty)
|
||||
}
|
||||
}
|
||||
)*
|
||||
};
|
||||
}
|
||||
|
||||
/// [`Codec`] for a fieldless ("C-like") enum: a single discriminant byte.
|
||||
/// [`Codec`] for a fieldless ("C-like") enum: a single discriminant byte. And its
|
||||
/// [`TextValue`]: the variant name as a bare symbol.
|
||||
///
|
||||
/// Both `match`es are exhaustive over the enum, so a variant added to the type and
|
||||
/// not to this invocation **fails to compile** — the guarantee
|
||||
/// `operation_kind_tag_vocabulary!` gives the operation decoder, here for every
|
||||
/// C-like enum in Chapter 5.
|
||||
///
|
||||
/// [`TextValue`]: crate::textvalue::TextValue
|
||||
macro_rules! cstyle_enum_codec {
|
||||
($ty:ident { $($tag:literal => $variant:ident),* $(,)? }) => {
|
||||
impl Codec for $ty {
|
||||
|
|
@ -548,6 +591,30 @@ macro_rules! cstyle_enum_codec {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::textvalue::TextValue for $ty {
|
||||
fn project(&self) -> crate::textvalue::Sexp {
|
||||
let variant = match self { $( $ty::$variant => stringify!($variant), )* };
|
||||
crate::textvalue::Sexp::Symbol(crate::textvalue::kebab(variant))
|
||||
}
|
||||
fn parse(
|
||||
s: &crate::textvalue::Sexp,
|
||||
) -> core::result::Result<Self, crate::textvalue::TextError> {
|
||||
let name = s.as_symbol().ok_or(crate::textvalue::TextError::Expected {
|
||||
expected: "symbol",
|
||||
found: crate::textvalue_impls::class_of(s),
|
||||
})?;
|
||||
$(
|
||||
if name == crate::textvalue::kebab(stringify!($variant)) {
|
||||
return Ok($ty::$variant);
|
||||
}
|
||||
)*
|
||||
Err(crate::textvalue::TextError::UnknownConstructor {
|
||||
type_name: stringify!($ty),
|
||||
found: name.to_owned(),
|
||||
})
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -564,6 +631,21 @@ macro_rules! catalog_id_codec {
|
|||
Ok($ty::new(String::dec(r)?))
|
||||
}
|
||||
}
|
||||
|
||||
// A catalog id is canonical text, so it projects as a quoted string.
|
||||
// `new` folds to NFC, so `parse` interns and then *compares*: returning
|
||||
// the folded value would accept a non-NFC spelling and silently
|
||||
// normalize it (`req:textproj:strict-parse`).
|
||||
impl crate::textvalue::TextValue for $ty {
|
||||
fn project(&self) -> crate::textvalue::Sexp {
|
||||
crate::textvalue::Sexp::Str(self.as_str().to_owned())
|
||||
}
|
||||
fn parse(
|
||||
s: &crate::textvalue::Sexp,
|
||||
) -> core::result::Result<Self, crate::textvalue::TextError> {
|
||||
crate::textvalue_impls::parse_catalog_id(s, $ty::new, |v| v.as_str())
|
||||
}
|
||||
}
|
||||
)*
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,11 @@ mod indexes;
|
|||
mod invariants;
|
||||
mod pitch;
|
||||
mod tempo;
|
||||
mod textvalue_event;
|
||||
mod textvalue_graph;
|
||||
mod textvalue_impls;
|
||||
mod textvalue_pitch;
|
||||
mod textvalue_time;
|
||||
mod time;
|
||||
|
||||
pub mod fuzz;
|
||||
|
|
|
|||
|
|
@ -16,26 +16,36 @@
|
|||
//! [`Sexp::Symbol`]. It has no `Ratio` or `Option` variant for the same reason:
|
||||
//! both are lists.
|
||||
//!
|
||||
//! # Strictness has two layers, as it does in the binary form
|
||||
//! # Strictness is per-site, and that is a finding, not a shortcut
|
||||
//!
|
||||
//! `req:textproj:strict-parse` forbids normalizing. The Binary Format companion
|
||||
//! learned this the expensive way (see `epiphany-bundle/DECISIONS.md`), and the
|
||||
//! same two layers apply here:
|
||||
//! `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`).
|
||||
//!
|
||||
//! 1. A **whole-value re-project-and-compare guard** at the public parse boundary.
|
||||
//! It is complete for everything a typed parse *normalizes* — a re-sorted
|
||||
//! `BTreeSet`, a reduced rational, an NFC-folded catalog id — because the
|
||||
//! normalized value projects back to different text than it was given.
|
||||
//! 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:
|
||||
//!
|
||||
//! 2. It is **blind to anything a typed parse accepts verbatim**, which is every
|
||||
//! order-preserving sequence. Where the binary form constrains a `Vec`'s order
|
||||
//! (the frozen `Transpose`'s non-decreasing `targets`), the text must carry the
|
||||
//! same **per-site check**. A guard cannot see an order it faithfully preserves.
|
||||
//! * 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.
|
||||
//!
|
||||
//! The parse functions in this module take layer 1 seriously: they never
|
||||
//! normalize silently. [`TextValue::parse`] for a set rejects an out-of-order or
|
||||
//! duplicate element rather than absorbing it, and a catalog id is built and then
|
||||
//! compared against its input rather than folded to NFC.
|
||||
//! 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};
|
||||
|
|
@ -458,17 +468,30 @@ 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(),
|
||||
})?;
|
||||
let head = items.first().and_then(Sexp::as_symbol);
|
||||
if head != Some(name) {
|
||||
return Err(TextError::Syntax("struct head is not the type name"));
|
||||
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::Syntax("struct has the wrong field count"));
|
||||
return Err(TextError::Arity {
|
||||
type_name: "struct",
|
||||
expected: arity,
|
||||
found: items.len().saturating_sub(1),
|
||||
});
|
||||
}
|
||||
Ok(&items[1..])
|
||||
}
|
||||
|
|
@ -654,6 +677,25 @@ impl<T: TextValue + Ord> TextValue for BTreeSet<T> {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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<A: TextValue, B: TextValue> TextValue for (A, B) {
|
||||
fn project(&self) -> Sexp {
|
||||
Sexp::List(vec![self.0.project(), self.1.project()])
|
||||
}
|
||||
fn parse(s: &Sexp) -> Result<Self, TextError> {
|
||||
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 `(<first> <second>)`"));
|
||||
};
|
||||
Ok((A::parse(first)?, B::parse(second)?))
|
||||
}
|
||||
}
|
||||
|
||||
/// A map projects as a list of `(<key> <value>)` entries, strictly increasing by
|
||||
/// key, and parses the same. Same reasoning as [`BTreeSet`].
|
||||
impl<K: TextValue + Ord, V: TextValue> TextValue for BTreeMap<K, V> {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,587 @@
|
|||
//! [`TextValue`] for the event taxonomy and the event arena (Chapter 5).
|
||||
//!
|
||||
//! The seven payload records — [`PitchedEvent`], [`UnpitchedEvent`], [`Rest`],
|
||||
//! [`IndeterminateEvent`], [`TrajectoryEvent`], [`GraphicEvent`], [`CueEvent`] —
|
||||
//! are `struct_codec!` types and get their projection from the same macro that
|
||||
//! gives them their binary codec (see `codec.rs`), so their field order cannot
|
||||
//! drift from the bytes. This module supplies what those macros cannot: the two
|
||||
//! placeholder newtypes, the four hand-written tagged unions, the [`Event`] union
|
||||
//! that dispatches over the seven records, and the [`EventArena`].
|
||||
//!
|
||||
//! Every field order below mirrors the matching `impl Codec for …` `fn enc` in
|
||||
//! `codec.rs` exactly (`req:textproj:value-projection` clause 1).
|
||||
//!
|
||||
//! The arena is the one place a `parse` must add a **per-site order check**:
|
||||
//! the binary form writes it in ascending `EventId` order but rebuilds it through
|
||||
//! [`EventArena::insert`], which accepts any order and re-sorts on the way out.
|
||||
//! Returning such an arena would launder a mis-ordered text into a canonical value
|
||||
//! — the normalization `req:textproj:strict-parse` forbids.
|
||||
|
||||
use crate::event::{
|
||||
ArenaError, CueEvent, Event, EventArena, GraceKind, GraphicEvent, IndeterminacyKind,
|
||||
IndeterminateEvent, PitchedEvent, Rest, StaffPosition, TrajectoryEndpoint, TrajectoryEvent,
|
||||
TrajectoryShape, UnpitchedEvent, UnpitchedMemberId,
|
||||
};
|
||||
use crate::ids::{EventId, PitchId};
|
||||
use crate::pitch::IdentifiedPitch;
|
||||
use crate::textvalue::{Sexp, TextError, TextValue};
|
||||
use crate::textvalue_impls::class_of;
|
||||
use crate::time::MusicalDuration;
|
||||
|
||||
// ===========================================================================
|
||||
// Helpers shared by the tagged unions.
|
||||
// ===========================================================================
|
||||
|
||||
/// The single field of a one-field variant `(<name> <field>)`, after checking
|
||||
/// the head symbol and the arity. `expect_struct` guarantees exactly one field
|
||||
/// remains, so indexing it cannot be out of range.
|
||||
fn one_field<'a>(s: &'a Sexp, name: &str) -> Result<&'a Sexp, TextError> {
|
||||
let fields = s.expect_struct(name, 1)?;
|
||||
Ok(&fields[0])
|
||||
}
|
||||
|
||||
/// The head symbol of a list `(<head> …)`, for a union whose every variant
|
||||
/// carries fields. Rejects a non-list and a list not headed by a symbol.
|
||||
fn head_symbol<'a>(s: &'a Sexp, type_name: &'static str) -> Result<&'a str, TextError> {
|
||||
let items = s.as_list().ok_or(TextError::Expected {
|
||||
expected: type_name,
|
||||
found: class_of(s),
|
||||
})?;
|
||||
items
|
||||
.first()
|
||||
.and_then(Sexp::as_symbol)
|
||||
.ok_or(TextError::Syntax(
|
||||
"a tagged union is a list headed by its variant name",
|
||||
))
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Placeholder newtypes.
|
||||
// ===========================================================================
|
||||
|
||||
/// A staff position is its inner `i16` alone, with no wrapper
|
||||
/// (`req:textproj:value-projection` clause 2): the binary form writes the field
|
||||
/// and adds no bytes for the newtype, and the text adds no wrapper either.
|
||||
impl TextValue for StaffPosition {
|
||||
fn project(&self) -> Sexp {
|
||||
TextValue::project(&self.0)
|
||||
}
|
||||
fn parse(s: &Sexp) -> Result<Self, TextError> {
|
||||
<i16 as TextValue>::parse(s).map(StaffPosition)
|
||||
}
|
||||
}
|
||||
|
||||
/// An unpitched member id is its inner `u32` alone; a transparent newtype, as
|
||||
/// above.
|
||||
impl TextValue for UnpitchedMemberId {
|
||||
fn project(&self) -> Sexp {
|
||||
TextValue::project(&self.0)
|
||||
}
|
||||
fn parse(s: &Sexp) -> Result<Self, TextError> {
|
||||
<u32 as TextValue>::parse(s).map(UnpitchedMemberId)
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Tagged unions.
|
||||
// ===========================================================================
|
||||
|
||||
/// `impl Codec for GraceKind` writes a discriminant byte, then the fraction's
|
||||
/// bytes only for `MeasuredFraction`. So the three fieldless kinds are bare
|
||||
/// symbols and the fourth is `(measured-fraction <duration>)`
|
||||
/// (`req:textproj:value-projection` clause 3).
|
||||
///
|
||||
/// A fieldless kind spelled as a list, or `measured-fraction` spelled bare, is a
|
||||
/// non-canonical spelling of the same value: the `Symbol`/`List` split rejects
|
||||
/// each rather than accepting it, so no two texts denote one kind.
|
||||
impl TextValue for GraceKind {
|
||||
fn project(&self) -> Sexp {
|
||||
match self {
|
||||
GraceKind::Acciaccatura => Sexp::sym("acciaccatura"),
|
||||
GraceKind::Appoggiatura => Sexp::sym("appoggiatura"),
|
||||
GraceKind::Unmeasured => Sexp::sym("unmeasured"),
|
||||
GraceKind::MeasuredFraction(d) => {
|
||||
Sexp::List(vec![Sexp::sym("measured-fraction"), d.project()])
|
||||
}
|
||||
}
|
||||
}
|
||||
fn parse(s: &Sexp) -> Result<Self, TextError> {
|
||||
match s {
|
||||
Sexp::Symbol(name) => match name.as_str() {
|
||||
"acciaccatura" => Ok(GraceKind::Acciaccatura),
|
||||
"appoggiatura" => Ok(GraceKind::Appoggiatura),
|
||||
"unmeasured" => Ok(GraceKind::Unmeasured),
|
||||
found => Err(TextError::UnknownConstructor {
|
||||
type_name: "GraceKind",
|
||||
found: found.to_owned(),
|
||||
}),
|
||||
},
|
||||
Sexp::List(_) => Ok(GraceKind::MeasuredFraction(MusicalDuration::parse(
|
||||
one_field(s, "measured-fraction")?,
|
||||
)?)),
|
||||
_ => Err(TextError::Expected {
|
||||
expected: "GraceKind",
|
||||
found: class_of(s),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `impl Codec for IndeterminacyKind` mirrors [`GraceKind`]: three fieldless
|
||||
/// kinds as bare symbols and `Compound` carrying a nested sequence, so the text
|
||||
/// is `pitch` / `duration` / `choice` / `(compound (<kind>…))`. The `Compound`
|
||||
/// sequence is itself an [`IndeterminacyKind`] list, and the generic `Vec` impl
|
||||
/// carries it recursively.
|
||||
impl TextValue for IndeterminacyKind {
|
||||
fn project(&self) -> Sexp {
|
||||
match self {
|
||||
IndeterminacyKind::Pitch => Sexp::sym("pitch"),
|
||||
IndeterminacyKind::Duration => Sexp::sym("duration"),
|
||||
IndeterminacyKind::Choice => Sexp::sym("choice"),
|
||||
IndeterminacyKind::Compound(v) => Sexp::List(vec![Sexp::sym("compound"), v.project()]),
|
||||
}
|
||||
}
|
||||
fn parse(s: &Sexp) -> Result<Self, TextError> {
|
||||
match s {
|
||||
Sexp::Symbol(name) => match name.as_str() {
|
||||
"pitch" => Ok(IndeterminacyKind::Pitch),
|
||||
"duration" => Ok(IndeterminacyKind::Duration),
|
||||
"choice" => Ok(IndeterminacyKind::Choice),
|
||||
found => Err(TextError::UnknownConstructor {
|
||||
type_name: "IndeterminacyKind",
|
||||
found: found.to_owned(),
|
||||
}),
|
||||
},
|
||||
Sexp::List(_) => Ok(IndeterminacyKind::Compound(
|
||||
Vec::<IndeterminacyKind>::parse(one_field(s, "compound")?)?,
|
||||
)),
|
||||
_ => Err(TextError::Expected {
|
||||
expected: "IndeterminacyKind",
|
||||
found: class_of(s),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `impl Codec for TrajectoryEndpoint` writes a discriminant then a field for
|
||||
/// both variants — a [`PitchId`] for `EventPitch`, an [`IdentifiedPitch`] for
|
||||
/// `ExplicitPitch` — so every spelling is a list and the head symbol selects the
|
||||
/// variant.
|
||||
impl TextValue for TrajectoryEndpoint {
|
||||
fn project(&self) -> Sexp {
|
||||
match self {
|
||||
TrajectoryEndpoint::EventPitch(id) => {
|
||||
Sexp::List(vec![Sexp::sym("event-pitch"), id.project()])
|
||||
}
|
||||
TrajectoryEndpoint::ExplicitPitch(p) => {
|
||||
Sexp::List(vec![Sexp::sym("explicit-pitch"), p.project()])
|
||||
}
|
||||
}
|
||||
}
|
||||
fn parse(s: &Sexp) -> Result<Self, TextError> {
|
||||
match head_symbol(s, "TrajectoryEndpoint")? {
|
||||
"event-pitch" => Ok(TrajectoryEndpoint::EventPitch(PitchId::parse(one_field(
|
||||
s,
|
||||
"event-pitch",
|
||||
)?)?)),
|
||||
"explicit-pitch" => Ok(TrajectoryEndpoint::ExplicitPitch(IdentifiedPitch::parse(
|
||||
one_field(s, "explicit-pitch")?,
|
||||
)?)),
|
||||
found => Err(TextError::UnknownConstructor {
|
||||
type_name: "TrajectoryEndpoint",
|
||||
found: found.to_owned(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `impl Codec for TrajectoryShape` has three fieldless shapes and `Stepwise`
|
||||
/// carrying a pitch sequence, so the text is `linear` / `exponential` / `curve`
|
||||
/// / `(stepwise (<pitch>…))`.
|
||||
impl TextValue for TrajectoryShape {
|
||||
fn project(&self) -> Sexp {
|
||||
match self {
|
||||
TrajectoryShape::Linear => Sexp::sym("linear"),
|
||||
TrajectoryShape::Exponential => Sexp::sym("exponential"),
|
||||
TrajectoryShape::Curve => Sexp::sym("curve"),
|
||||
TrajectoryShape::Stepwise(v) => Sexp::List(vec![Sexp::sym("stepwise"), v.project()]),
|
||||
}
|
||||
}
|
||||
fn parse(s: &Sexp) -> Result<Self, TextError> {
|
||||
match s {
|
||||
Sexp::Symbol(name) => match name.as_str() {
|
||||
"linear" => Ok(TrajectoryShape::Linear),
|
||||
"exponential" => Ok(TrajectoryShape::Exponential),
|
||||
"curve" => Ok(TrajectoryShape::Curve),
|
||||
found => Err(TextError::UnknownConstructor {
|
||||
type_name: "TrajectoryShape",
|
||||
found: found.to_owned(),
|
||||
}),
|
||||
},
|
||||
Sexp::List(_) => Ok(TrajectoryShape::Stepwise(Vec::<IdentifiedPitch>::parse(
|
||||
one_field(s, "stepwise")?,
|
||||
)?)),
|
||||
_ => Err(TextError::Expected {
|
||||
expected: "TrajectoryShape",
|
||||
found: class_of(s),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// The event union.
|
||||
// ===========================================================================
|
||||
|
||||
/// `impl Codec for Event` writes a discriminant byte and then delegates to the
|
||||
/// payload's own `enc`, so its projection is **not** transparent: it is
|
||||
/// `(<variant> <payload>)`, the kebab of the *variant* name wrapping the payload
|
||||
/// record's own struct projection — e.g. `(rest (rest <id> … <visible>))` and
|
||||
/// `(pitched (pitched-event <id> … <grace>))`. A single-field variant projects as
|
||||
/// `(<variant> <field>)`, never as the field alone; only an unnamed-field newtype
|
||||
/// is transparent (`req:textproj:value-projection` clauses 2 and 3), and `Event`
|
||||
/// is a union, not a newtype.
|
||||
impl TextValue for Event {
|
||||
fn project(&self) -> Sexp {
|
||||
match self {
|
||||
Event::Pitched(e) => Sexp::List(vec![Sexp::sym("pitched"), e.project()]),
|
||||
Event::Unpitched(e) => Sexp::List(vec![Sexp::sym("unpitched"), e.project()]),
|
||||
Event::Rest(e) => Sexp::List(vec![Sexp::sym("rest"), e.project()]),
|
||||
Event::Indeterminate(e) => Sexp::List(vec![Sexp::sym("indeterminate"), e.project()]),
|
||||
Event::Trajectory(e) => Sexp::List(vec![Sexp::sym("trajectory"), e.project()]),
|
||||
Event::Graphic(e) => Sexp::List(vec![Sexp::sym("graphic"), e.project()]),
|
||||
Event::Cue(e) => Sexp::List(vec![Sexp::sym("cue"), e.project()]),
|
||||
}
|
||||
}
|
||||
fn parse(s: &Sexp) -> Result<Self, TextError> {
|
||||
match head_symbol(s, "Event")? {
|
||||
"pitched" => Ok(Event::Pitched(PitchedEvent::parse(one_field(
|
||||
s, "pitched",
|
||||
)?)?)),
|
||||
"unpitched" => Ok(Event::Unpitched(UnpitchedEvent::parse(one_field(
|
||||
s,
|
||||
"unpitched",
|
||||
)?)?)),
|
||||
"rest" => Ok(Event::Rest(Rest::parse(one_field(s, "rest")?)?)),
|
||||
"indeterminate" => Ok(Event::Indeterminate(IndeterminateEvent::parse(one_field(
|
||||
s,
|
||||
"indeterminate",
|
||||
)?)?)),
|
||||
"trajectory" => Ok(Event::Trajectory(TrajectoryEvent::parse(one_field(
|
||||
s,
|
||||
"trajectory",
|
||||
)?)?)),
|
||||
"graphic" => Ok(Event::Graphic(GraphicEvent::parse(one_field(
|
||||
s, "graphic",
|
||||
)?)?)),
|
||||
"cue" => Ok(Event::Cue(CueEvent::parse(one_field(s, "cue")?)?)),
|
||||
found => Err(TextError::UnknownConstructor {
|
||||
type_name: "Event",
|
||||
found: found.to_owned(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// The event arena.
|
||||
// ===========================================================================
|
||||
|
||||
/// The arena projects as the sequence of its events in ascending `EventId`
|
||||
/// order, matching `impl Codec for EventArena`, whose `enc` iterates
|
||||
/// [`EventArena::iter_canonical`]. Identity travels inside each event, so no
|
||||
/// key is written beside it.
|
||||
///
|
||||
/// `parse` enforces that ascending order **per site**. The binary decoder rebuilds
|
||||
/// the arena through [`EventArena::insert`], which accepts events in any order and
|
||||
/// lets [`EventArena::iter_canonical`] silently re-sort them; returning such an
|
||||
/// arena would launder a mis-ordered or duplicate-id text into a canonical value,
|
||||
/// the normalization `req:textproj:strict-parse` forbids. Equal ids fail the same
|
||||
/// strict-`<` test, so a duplicate is rejected here and never reaches `insert`.
|
||||
///
|
||||
/// A whole-value re-project-and-compare guard *would* also catch this, since
|
||||
/// `project` re-sorts and so a mis-ordered input re-projects differently. The
|
||||
/// explicit check is what the crate keeps: it names the fault
|
||||
/// (`NotStrictlyIncreasing`), points at the first offending event, and avoids a
|
||||
/// redundant second projection of the whole arena. It is mutation-verified live.
|
||||
impl TextValue for EventArena {
|
||||
fn project(&self) -> Sexp {
|
||||
Sexp::List(self.iter_canonical().map(Event::project).collect())
|
||||
}
|
||||
fn parse(s: &Sexp) -> Result<Self, TextError> {
|
||||
let items = s.as_list().ok_or(TextError::Expected {
|
||||
expected: "EventArena",
|
||||
found: class_of(s),
|
||||
})?;
|
||||
let mut arena = EventArena::new();
|
||||
let mut previous: Option<EventId> = None;
|
||||
for item in items {
|
||||
let event = Event::parse(item)?;
|
||||
let id = event.id();
|
||||
if previous.is_some_and(|prev| prev >= id) {
|
||||
return Err(TextError::NotStrictlyIncreasing(
|
||||
"EventArena events must be in ascending EventId order",
|
||||
));
|
||||
}
|
||||
previous = Some(id);
|
||||
// `insert` still enforces the Chapter 5 invariant that a pitched event
|
||||
// has at least one pitch. A duplicate id cannot reach it — the strict
|
||||
// order check above rejects equal ids — but the arm is kept exhaustive
|
||||
// (no `_`) so a new `ArenaError` variant forces a decision here.
|
||||
arena.insert(event).map_err(|err| match err {
|
||||
ArenaError::EmptyPitchedEvent(_) => {
|
||||
TextError::NotCanonical("a pitched event must have at least one pitch")
|
||||
}
|
||||
ArenaError::DuplicateId(_) => TextError::NotStrictlyIncreasing(
|
||||
"EventArena events must be in ascending EventId order",
|
||||
),
|
||||
})?;
|
||||
}
|
||||
Ok(arena)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::event::StemConfiguration;
|
||||
use crate::ids::{EventId, PitchId, ReplicaId, VoiceId};
|
||||
use crate::pitch::{
|
||||
AcousticPitch, AcousticRealization, CmnNominal, IdentifiedPitch, Pitch, PitchSpaceId,
|
||||
PitchSpacePosition, ScalePosition, TuningReference,
|
||||
};
|
||||
use crate::textvalue::read_sexp;
|
||||
use crate::time::{
|
||||
EventDuration, EventPosition, MusicalDuration, MusicalPosition, RationalTime,
|
||||
};
|
||||
|
||||
fn replica() -> ReplicaId {
|
||||
ReplicaId(1)
|
||||
}
|
||||
|
||||
fn voice() -> VoiceId {
|
||||
VoiceId::new(replica(), 100)
|
||||
}
|
||||
|
||||
/// project → render → read_sexp → parse must return the original value.
|
||||
#[track_caller]
|
||||
fn round_trip<T>(value: T)
|
||||
where
|
||||
T: TextValue + PartialEq + std::fmt::Debug,
|
||||
{
|
||||
let text = value.project().render();
|
||||
let read = read_sexp(&text).unwrap_or_else(|e| panic!("{text:?} did not lex: {e}"));
|
||||
let back = T::parse(&read).unwrap_or_else(|e| panic!("{text:?} did not parse: {e}"));
|
||||
assert_eq!(back, value, "round trip changed {text:?}");
|
||||
}
|
||||
|
||||
fn identified_pitch(counter: u64) -> IdentifiedPitch {
|
||||
IdentifiedPitch {
|
||||
id: PitchId::new(replica(), counter),
|
||||
pitch: Pitch {
|
||||
scale_position: ScalePosition {
|
||||
space: PitchSpaceId::new("cmn-12"),
|
||||
position: PitchSpacePosition::Cmn {
|
||||
nominal: CmnNominal::C,
|
||||
alteration: 0,
|
||||
octave: 4,
|
||||
},
|
||||
},
|
||||
acoustic: AcousticPitch {
|
||||
tuning: TuningReference::Inherit,
|
||||
realization: AcousticRealization::Implicit,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn rest_event(counter: u64) -> Event {
|
||||
Event::Rest(Rest {
|
||||
id: EventId::new(replica(), counter),
|
||||
voice: voice(),
|
||||
position: EventPosition::Musical(MusicalPosition(
|
||||
RationalTime::new(counter as i64, 4).unwrap(),
|
||||
)),
|
||||
duration: EventDuration::Musical(MusicalDuration(RationalTime::new(1, 4).unwrap())),
|
||||
vertical_position: Some(StaffPosition(-2)),
|
||||
visible: true,
|
||||
})
|
||||
}
|
||||
|
||||
fn pitched_event(counter: u64) -> Event {
|
||||
Event::Pitched(PitchedEvent {
|
||||
id: EventId::new(replica(), counter),
|
||||
voice: voice(),
|
||||
position: EventPosition::Musical(MusicalPosition(RationalTime::new(1, 2).unwrap())),
|
||||
duration: EventDuration::Musical(MusicalDuration(RationalTime::new(1, 4).unwrap())),
|
||||
pitches: vec![identified_pitch(counter * 10)],
|
||||
articulations: vec![],
|
||||
dynamic: None,
|
||||
ornaments: vec![],
|
||||
stem: StemConfiguration,
|
||||
grace: Some(GraceKind::MeasuredFraction(MusicalDuration(
|
||||
RationalTime::new(1, 8).unwrap(),
|
||||
))),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staff_position_and_member_id_are_transparent_newtypes() {
|
||||
assert_eq!(StaffPosition(-3).project().render(), "-3");
|
||||
assert_eq!(UnpitchedMemberId(42).project().render(), "42");
|
||||
round_trip(StaffPosition(-3));
|
||||
round_trip(StaffPosition(0));
|
||||
round_trip(UnpitchedMemberId(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grace_kind_round_trips_every_variant() {
|
||||
assert_eq!(GraceKind::Acciaccatura.project().render(), "acciaccatura");
|
||||
assert_eq!(
|
||||
GraceKind::MeasuredFraction(MusicalDuration(RationalTime::new(1, 8).unwrap()))
|
||||
.project()
|
||||
.render(),
|
||||
"(measured-fraction (ratio 1 8))"
|
||||
);
|
||||
for g in [
|
||||
GraceKind::Acciaccatura,
|
||||
GraceKind::Appoggiatura,
|
||||
GraceKind::Unmeasured,
|
||||
GraceKind::MeasuredFraction(MusicalDuration(RationalTime::new(3, 8).unwrap())),
|
||||
] {
|
||||
round_trip(g);
|
||||
}
|
||||
}
|
||||
|
||||
/// A fieldless kind spelled as a list, or a field kind spelled bare, is a
|
||||
/// non-canonical spelling and must be rejected, not accepted.
|
||||
#[test]
|
||||
fn grace_kind_rejects_the_wrong_shape() {
|
||||
assert!(GraceKind::parse(&read_sexp("(acciaccatura)").unwrap()).is_err());
|
||||
assert!(GraceKind::parse(&read_sexp("measured-fraction").unwrap()).is_err());
|
||||
assert!(GraceKind::parse(&read_sexp("nope").unwrap()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indeterminacy_kind_round_trips_including_nested_compound() {
|
||||
assert_eq!(IndeterminacyKind::Choice.project().render(), "choice");
|
||||
assert_eq!(
|
||||
IndeterminacyKind::Compound(vec![
|
||||
IndeterminacyKind::Pitch,
|
||||
IndeterminacyKind::Compound(vec![IndeterminacyKind::Duration]),
|
||||
])
|
||||
.project()
|
||||
.render(),
|
||||
"(compound (pitch (compound (duration))))"
|
||||
);
|
||||
for k in [
|
||||
IndeterminacyKind::Pitch,
|
||||
IndeterminacyKind::Duration,
|
||||
IndeterminacyKind::Choice,
|
||||
IndeterminacyKind::Compound(vec![IndeterminacyKind::Pitch, IndeterminacyKind::Choice]),
|
||||
] {
|
||||
round_trip(k);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trajectory_endpoint_round_trips_both_variants() {
|
||||
round_trip(TrajectoryEndpoint::EventPitch(PitchId::new(replica(), 7)));
|
||||
round_trip(TrajectoryEndpoint::ExplicitPitch(identified_pitch(3)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trajectory_shape_round_trips_every_variant() {
|
||||
assert_eq!(TrajectoryShape::Curve.project().render(), "curve");
|
||||
for shape in [
|
||||
TrajectoryShape::Linear,
|
||||
TrajectoryShape::Exponential,
|
||||
TrajectoryShape::Curve,
|
||||
TrajectoryShape::Stepwise(vec![identified_pitch(1), identified_pitch(2)]),
|
||||
] {
|
||||
round_trip(shape);
|
||||
}
|
||||
}
|
||||
|
||||
/// `Event` is `(<variant> <payload>)`, not the payload alone.
|
||||
#[test]
|
||||
fn event_projection_wraps_the_payload_in_the_variant_name() {
|
||||
let text = rest_event(1).project().render();
|
||||
assert!(
|
||||
text.starts_with("(rest (rest "),
|
||||
"expected `(rest (rest …`, got {text}"
|
||||
);
|
||||
let pitched = pitched_event(1).project().render();
|
||||
assert!(
|
||||
pitched.starts_with("(pitched (pitched-event "),
|
||||
"expected `(pitched (pitched-event …`, got {pitched}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_round_trips_a_rest_and_a_pitched_event() {
|
||||
round_trip(rest_event(4));
|
||||
round_trip(pitched_event(9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_arena_round_trips_in_ascending_id_order() {
|
||||
let mut arena = EventArena::new();
|
||||
// Inserted out of id order; projection must still be ascending.
|
||||
for c in [5u64, 1, 9, 3] {
|
||||
arena.insert(rest_event(c)).unwrap();
|
||||
}
|
||||
round_trip(arena);
|
||||
}
|
||||
|
||||
/// A text whose events are not in ascending `EventId` order must be rejected,
|
||||
/// not silently re-sorted the way [`EventArena::insert`] would.
|
||||
#[test]
|
||||
fn event_arena_rejects_events_out_of_id_order() {
|
||||
let descending = Sexp::List(vec![rest_event(3).project(), rest_event(1).project()]);
|
||||
assert_eq!(
|
||||
EventArena::parse(&descending),
|
||||
Err(TextError::NotStrictlyIncreasing(
|
||||
"EventArena events must be in ascending EventId order"
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
/// A duplicate `EventId` breaks strict ascension and is rejected there, before
|
||||
/// it can reach `insert`.
|
||||
#[test]
|
||||
fn event_arena_rejects_a_duplicate_id() {
|
||||
let duplicated = Sexp::List(vec![rest_event(2).project(), rest_event(2).project()]);
|
||||
assert_eq!(
|
||||
EventArena::parse(&duplicated),
|
||||
Err(TextError::NotStrictlyIncreasing(
|
||||
"EventArena events must be in ascending EventId order"
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
/// A pitched event with no pitches is a well-formed projection of an invalid
|
||||
/// value; the arena rejects it rather than admitting it.
|
||||
#[test]
|
||||
fn event_arena_rejects_an_empty_pitched_event() {
|
||||
let empty = Event::Pitched(PitchedEvent {
|
||||
id: EventId::new(replica(), 1),
|
||||
voice: voice(),
|
||||
position: EventPosition::Musical(MusicalPosition::origin()),
|
||||
duration: EventDuration::Musical(MusicalDuration::whole()),
|
||||
pitches: vec![],
|
||||
articulations: vec![],
|
||||
dynamic: None,
|
||||
ornaments: vec![],
|
||||
stem: StemConfiguration,
|
||||
grace: None,
|
||||
});
|
||||
let s = Sexp::List(vec![empty.project()]);
|
||||
assert_eq!(
|
||||
EventArena::parse(&s),
|
||||
Err(TextError::NotCanonical(
|
||||
"a pitched event must have at least one pitch"
|
||||
))
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,424 @@
|
|||
//! [`TextValue`] for the leaves and the hand-written composites of Chapter 5.
|
||||
//!
|
||||
//! The 82 `struct_codec!` structs, the 8 `unit_codec!` unit structs, the 17
|
||||
//! `cstyle_enum_codec!` enums and the 9 `catalog_id_codec!` ids get their impls
|
||||
//! from the *same macro invocation* that gives them their binary codec, so their
|
||||
//! field order cannot drift from the order the binary form writes. See
|
||||
//! `codec.rs`. This module supplies what those macros cannot: the leaves, and the
|
||||
//! composites whose `Codec` impl is hand-written.
|
||||
//!
|
||||
//! Every `parse` here obeys `req:textproj:strict-parse`. Where the only way to
|
||||
//! construct a value is through a normalizing constructor — `RationalTime::new`
|
||||
//! reduces, `PitchSpaceId::new` folds to NFC — the impl constructs and then
|
||||
//! **compares against its input**, rejecting on difference. It never returns the
|
||||
//! normalized value and calls that acceptance.
|
||||
|
||||
use epiphany_determinism::{CanonicalDecode, CanonicalEncode, CanonicalF64, ContentHash};
|
||||
use num_rational::BigRational;
|
||||
use num_traits::Zero;
|
||||
|
||||
use crate::textvalue::{kebab, Sexp, TextError, TextValue};
|
||||
use crate::time::{
|
||||
MusicalDuration, MusicalPosition, RationalTime, WallClockDuration, WallClockTime,
|
||||
};
|
||||
|
||||
// ===========================================================================
|
||||
// Byte-string leaves.
|
||||
// ===========================================================================
|
||||
|
||||
/// An identifier or hash is a byte string (`req:textproj:value-projection`
|
||||
/// clause 6).
|
||||
///
|
||||
/// `decode_canonical` is the leaf's own validating decoder, but validating is not
|
||||
/// the same as *rejecting non-canonical bytes*: a decoder that accepts a
|
||||
/// denormalized encoding would let two byte strings — and so two texts — denote
|
||||
/// one value. The re-encode comparison closes that, exactly as
|
||||
/// `Score::decode_canonical` does for the binary form.
|
||||
macro_rules! bytes_text_value {
|
||||
($($ty:ty => $what:literal),* $(,)?) => {
|
||||
$(
|
||||
impl TextValue for $ty {
|
||||
fn project(&self) -> Sexp {
|
||||
Sexp::Bytes(self.to_canonical_bytes())
|
||||
}
|
||||
fn parse(s: &Sexp) -> Result<Self, TextError> {
|
||||
let Sexp::Bytes(bytes) = s else {
|
||||
return Err(TextError::Expected {
|
||||
expected: $what,
|
||||
found: class_of(s),
|
||||
});
|
||||
};
|
||||
let value = <$ty>::decode_canonical(bytes)
|
||||
.map_err(|_| TextError::NotCanonical($what))?;
|
||||
if value.to_canonical_bytes() != *bytes {
|
||||
return Err(TextError::NotCanonical(
|
||||
concat!($what, " is not canonically encoded")
|
||||
));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
}
|
||||
)*
|
||||
};
|
||||
}
|
||||
|
||||
/// The lexical class of `s`, for error messages. Mirrors `Sexp::class`, which is
|
||||
/// private to its module.
|
||||
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",
|
||||
}
|
||||
}
|
||||
|
||||
bytes_text_value! {
|
||||
crate::ids::ReplicaId => "ReplicaId",
|
||||
crate::ids::OperationId => "OperationId",
|
||||
crate::ids::EventId => "EventId",
|
||||
crate::ids::PitchId => "PitchId",
|
||||
crate::ids::VoiceId => "VoiceId",
|
||||
crate::ids::StaffId => "StaffId",
|
||||
crate::ids::StaffInstanceId => "StaffInstanceId",
|
||||
crate::ids::StaffGroupId => "StaffGroupId",
|
||||
crate::ids::RegionId => "RegionId",
|
||||
crate::ids::InstrumentId => "InstrumentId",
|
||||
crate::ids::PartDefinitionId => "PartDefinitionId",
|
||||
crate::ids::MeasureId => "MeasureId",
|
||||
crate::ids::BarlineAlignmentGroupId => "BarlineAlignmentGroupId",
|
||||
crate::ids::SlurId => "SlurId",
|
||||
crate::ids::TieId => "TieId",
|
||||
crate::ids::BeamId => "BeamId",
|
||||
crate::ids::SpannerId => "SpannerId",
|
||||
crate::ids::TupletId => "TupletId",
|
||||
crate::ids::MarkerId => "MarkerId",
|
||||
crate::ids::AnalyticalAnnotationId => "AnalyticalAnnotationId",
|
||||
crate::ids::CommentId => "CommentId",
|
||||
crate::ids::RepeatStructureId => "RepeatStructureId",
|
||||
crate::ids::LyricLineId => "LyricLineId",
|
||||
crate::ids::ChordSymbolId => "ChordSymbolId",
|
||||
crate::ids::GraphicObjectId => "GraphicObjectId",
|
||||
crate::ids::GraphicGestureId => "GraphicGestureId",
|
||||
crate::ids::TimeSignatureId => "TimeSignatureId",
|
||||
crate::ids::AnalysisLayerId => "AnalysisLayerId",
|
||||
crate::ids::ViewId => "ViewId",
|
||||
ContentHash => "ContentHash",
|
||||
}
|
||||
|
||||
/// A `CanonicalF64` is its eight canonical little-endian IEEE 754 bytes, never a
|
||||
/// decimal (`req:textproj:value-projection` clause 6). Decimal float text is not
|
||||
/// canonically unique — shortest-round-trip and 17-significant-digit spellings both
|
||||
/// round-trip, and `-0.0` has two spellings — so a decimal tempo would break
|
||||
/// `req:textproj:canonical-text` at the first tempo mark.
|
||||
impl TextValue for CanonicalF64 {
|
||||
fn project(&self) -> Sexp {
|
||||
Sexp::Bytes(self.to_le_bytes().to_vec())
|
||||
}
|
||||
fn parse(s: &Sexp) -> Result<Self, TextError> {
|
||||
let Sexp::Bytes(bytes) = s else {
|
||||
return Err(TextError::Expected {
|
||||
expected: "CanonicalF64",
|
||||
found: class_of(s),
|
||||
});
|
||||
};
|
||||
let bytes: [u8; 8] = bytes
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| TextError::NotCanonical("a CanonicalF64 is exactly eight bytes"))?;
|
||||
CanonicalF64::from_le_bytes(bytes).ok_or(TextError::NotCanonical(
|
||||
"not a canonical f64 (NaN, or -0.0)",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Rationals.
|
||||
// ===========================================================================
|
||||
|
||||
/// `(ratio <numerator> <denominator>)`, in lowest terms with a positive
|
||||
/// denominator and the sign on the numerator; zero is `(ratio 0 1)`.
|
||||
///
|
||||
/// `RationalTime::new` **reduces**, so parsing through it would silently accept
|
||||
/// `(ratio 2 4)` as `1/2` — normalizing, which `req:textproj:strict-parse`
|
||||
/// forbids. The canonical form is therefore checked *before* construction.
|
||||
impl TextValue for RationalTime {
|
||||
fn project(&self) -> Sexp {
|
||||
let big = self.to_big();
|
||||
Sexp::ratio(big.numer().clone(), big.denom().clone())
|
||||
}
|
||||
fn parse(s: &Sexp) -> Result<Self, TextError> {
|
||||
let items = s.as_list().ok_or(TextError::Expected {
|
||||
expected: "rational",
|
||||
found: class_of(s),
|
||||
})?;
|
||||
let [head, numerator, denominator] = items else {
|
||||
return Err(TextError::Syntax(
|
||||
"a rational is `(ratio <numerator> <denominator>)`",
|
||||
));
|
||||
};
|
||||
if head.as_symbol() != Some("ratio") {
|
||||
return Err(TextError::Syntax("a rational is headed by `ratio`"));
|
||||
}
|
||||
let (Sexp::Int(numerator), Sexp::Int(denominator)) = (numerator, denominator) else {
|
||||
return Err(TextError::Expected {
|
||||
expected: "integer",
|
||||
found: "non-integer",
|
||||
});
|
||||
};
|
||||
if denominator.is_zero() {
|
||||
// Checked before `BigRational::new`, which panics on a zero
|
||||
// denominator rather than returning an error.
|
||||
return Err(TextError::NotCanonical("rational denominator is zero"));
|
||||
}
|
||||
|
||||
// `BigRational::new` *is* the canonical form: it reduces, keeps the
|
||||
// denominator positive, and puts the sign on the numerator. So the
|
||||
// canonical spelling of this value is the one whose parts survive it
|
||||
// unchanged. Constructing first and returning the result would accept
|
||||
// `(ratio 2 4)` as `1/2` — normalizing, which `req:textproj:strict-parse`
|
||||
// forbids. Comparing instead rejects it.
|
||||
let reduced = BigRational::new(numerator.clone(), denominator.clone());
|
||||
if reduced.numer() != numerator || reduced.denom() != denominator {
|
||||
return Err(TextError::NotCanonical(
|
||||
"rational is not in lowest terms with a positive denominator",
|
||||
));
|
||||
}
|
||||
Ok(RationalTime::from_big(reduced))
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Transparent newtypes.
|
||||
// ===========================================================================
|
||||
|
||||
/// A newtype is projected as its field alone, with no wrapper
|
||||
/// (`req:textproj:value-projection` clause 2), mirroring the binary form in which
|
||||
/// a newtype delegates to its field and adds no bytes.
|
||||
macro_rules! newtype_text_value {
|
||||
($($ty:ty => $inner:ty),* $(,)?) => {
|
||||
$(
|
||||
impl TextValue for $ty {
|
||||
fn project(&self) -> Sexp {
|
||||
TextValue::project(&self.0)
|
||||
}
|
||||
fn parse(s: &Sexp) -> Result<Self, TextError> {
|
||||
<$inner as TextValue>::parse(s).map(Self)
|
||||
}
|
||||
}
|
||||
)*
|
||||
};
|
||||
}
|
||||
|
||||
newtype_text_value! {
|
||||
MusicalPosition => RationalTime,
|
||||
MusicalDuration => RationalTime,
|
||||
WallClockTime => i64,
|
||||
WallClockDuration => i64,
|
||||
crate::graph::OctaveOffset => i8,
|
||||
}
|
||||
|
||||
/// A transparent newtype over an integer whose constructor **validates** — it
|
||||
/// rejects a value outside the type's domain rather than adjusting one into it.
|
||||
///
|
||||
/// So no `ensure_canonical` is wanted here, and none would fire: an accepted value
|
||||
/// re-projects to exactly its input. What rejects `(power-of-two 3)` is `new`
|
||||
/// returning `None`, and that is the whole of the strictness.
|
||||
macro_rules! validated_int_newtype_text_value {
|
||||
($($ty:ty => $inner:ty, $new:path, $get:ident, $what:literal);* $(;)?) => {
|
||||
$(
|
||||
impl TextValue for $ty {
|
||||
fn project(&self) -> Sexp {
|
||||
TextValue::project(&self.$get())
|
||||
}
|
||||
fn parse(s: &Sexp) -> Result<Self, TextError> {
|
||||
let inner = <$inner as TextValue>::parse(s)?;
|
||||
$new(inner).ok_or(TextError::NotCanonical($what))
|
||||
}
|
||||
}
|
||||
)*
|
||||
};
|
||||
}
|
||||
|
||||
validated_int_newtype_text_value! {
|
||||
crate::graph::PowerOfTwo => u16, crate::graph::PowerOfTwo::new, get,
|
||||
"a PowerOfTwo denominator must be a power of two";
|
||||
core::num::NonZeroU16 => u16, core::num::NonZeroU16::new, get,
|
||||
"a NonZeroU16 must not be zero";
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Tempo.
|
||||
// ===========================================================================
|
||||
|
||||
/// `(tempo <bpm> <beat-unit>)`, in the order `impl Codec for Tempo` writes them.
|
||||
///
|
||||
/// Both fields are private and `Tempo::new` rejects a non-finite or non-positive
|
||||
/// BPM, so the value can only be rebuilt through a validating constructor.
|
||||
/// `ensure_canonical` closes what that leaves open.
|
||||
impl TextValue for crate::tempo::Tempo {
|
||||
fn project(&self) -> Sexp {
|
||||
let bpm = CanonicalF64::new(self.bpm()).expect("a valid tempo has a canonical BPM");
|
||||
Sexp::List(vec![
|
||||
Sexp::Symbol(kebab("Tempo")),
|
||||
bpm.project(),
|
||||
self.beat_unit().project(),
|
||||
])
|
||||
}
|
||||
fn parse(s: &Sexp) -> Result<Self, TextError> {
|
||||
let fields = s.expect_struct(&kebab("Tempo"), 2)?;
|
||||
let bpm = CanonicalF64::parse(&fields[0])?;
|
||||
let beat_unit = MusicalDuration::parse(&fields[1])?;
|
||||
// `new` rejects a non-positive or non-finite BPM rather than adjusting
|
||||
// one, and `CanonicalF64::parse` has already refused a non-canonical float,
|
||||
// so there is nothing left for a whole-value guard to catch.
|
||||
crate::tempo::Tempo::new(bpm.get(), beat_unit)
|
||||
.ok_or(TextError::NotCanonical("tempo BPM or beat unit is invalid"))
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Helpers the codec macros expand into.
|
||||
// ===========================================================================
|
||||
|
||||
/// A zero-field struct is the bare symbol `<type-name>`, as a fieldless variant is
|
||||
/// (`req:textproj:value-projection` clause 1).
|
||||
pub(crate) fn project_unit(type_name: &str) -> Sexp {
|
||||
Sexp::Symbol(kebab(type_name))
|
||||
}
|
||||
|
||||
/// Reads a zero-field struct or fieldless variant.
|
||||
pub(crate) fn parse_unit(s: &Sexp, type_name: &'static str) -> Result<(), TextError> {
|
||||
match s.as_symbol() {
|
||||
Some(name) if name == kebab(type_name) => Ok(()),
|
||||
Some(found) => Err(TextError::UnknownConstructor {
|
||||
type_name,
|
||||
found: found.to_owned(),
|
||||
}),
|
||||
None => Err(TextError::Expected {
|
||||
expected: "symbol",
|
||||
found: class_of(s),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads a catalog id: a string, checked to be exactly what interning it produces.
|
||||
///
|
||||
/// `PitchSpaceId::new` and its siblings **fold to Unicode NFC**. Constructing
|
||||
/// through them and returning the result would accept a non-NFC spelling and
|
||||
/// silently normalize it — two texts denoting one value, which
|
||||
/// `req:textproj:strict-parse` forbids and which the binary form's whole-value
|
||||
/// re-encode guard catches only because it re-encodes. Here the comparison is
|
||||
/// explicit.
|
||||
pub(crate) fn parse_catalog_id<T, F>(
|
||||
s: &Sexp,
|
||||
make: F,
|
||||
as_str: impl Fn(&T) -> &str,
|
||||
) -> Result<T, TextError>
|
||||
where
|
||||
F: Fn(String) -> T,
|
||||
{
|
||||
let Sexp::Str(text) = s else {
|
||||
return Err(TextError::Expected {
|
||||
expected: "catalog id",
|
||||
found: class_of(s),
|
||||
});
|
||||
};
|
||||
let value = make(text.clone());
|
||||
if as_str(&value) != text {
|
||||
return Err(TextError::NotCanonical("catalog id is not in Unicode NFC"));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::textvalue::read_sexp;
|
||||
|
||||
#[test]
|
||||
fn a_rational_projects_in_lowest_terms_with_the_sign_on_the_numerator() {
|
||||
let r = RationalTime::new(-2, 4).unwrap();
|
||||
assert_eq!(r.project().render(), "(ratio -1 2)");
|
||||
assert_eq!(RationalTime::zero().project().render(), "(ratio 0 1)");
|
||||
assert_eq!(RationalTime::one().project().render(), "(ratio 1 1)");
|
||||
}
|
||||
|
||||
/// Parsing through `RationalTime::new` would reduce these and call it success.
|
||||
#[test]
|
||||
fn a_non_canonical_rational_is_rejected_not_reduced() {
|
||||
for bad in [
|
||||
"(ratio 2 4)", // not in lowest terms
|
||||
"(ratio 1 -2)", // sign on the denominator
|
||||
"(ratio -1 -2)", // both negative
|
||||
"(ratio 0 2)", // zero is (ratio 0 1)
|
||||
"(ratio 1 0)", // zero denominator
|
||||
] {
|
||||
let s = read_sexp(bad).unwrap();
|
||||
assert!(
|
||||
RationalTime::parse(&s).is_err(),
|
||||
"{bad} must be rejected, not reduced"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rational_round_trips_through_its_projection() {
|
||||
for (n, d) in [(0, 1), (1, 2), (-3, 4), (7, 1), (-1, 1)] {
|
||||
let r = RationalTime::new(n, d).unwrap();
|
||||
let text = r.project().render();
|
||||
let back = RationalTime::parse(&read_sexp(&text).unwrap()).unwrap();
|
||||
assert_eq!(r, back, "{text} did not round-trip");
|
||||
}
|
||||
}
|
||||
|
||||
/// A `MusicalPosition` *is* a rational; the wrapper adds no bytes and no text.
|
||||
#[test]
|
||||
fn a_newtype_is_transparent() {
|
||||
let p = MusicalPosition(RationalTime::new(3, 4).unwrap());
|
||||
assert_eq!(p.project().render(), "(ratio 3 4)");
|
||||
assert_eq!(WallClockTime(-5).project().render(), "-5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_codec_macros_project_units_c_style_enums_and_catalog_ids() {
|
||||
use crate::event::ArticulationMark;
|
||||
use crate::pitch::{AccidentalId, SpellingSourceKind};
|
||||
use crate::textvalue::read_sexp;
|
||||
// cstyle enum -> bare symbol
|
||||
assert_eq!(
|
||||
SpellingSourceKind::UserChosen.project().render(),
|
||||
"user-chosen"
|
||||
);
|
||||
assert_eq!(
|
||||
SpellingSourceKind::parse(&read_sexp("propagated").unwrap()).unwrap(),
|
||||
SpellingSourceKind::Propagated
|
||||
);
|
||||
assert!(SpellingSourceKind::parse(&read_sexp("nope").unwrap()).is_err());
|
||||
// unit struct -> bare symbol
|
||||
assert_eq!(ArticulationMark.project().render(), "articulation-mark");
|
||||
// catalog id -> string, NFC-checked
|
||||
let id = AccidentalId::new("sharp");
|
||||
assert_eq!(id.project().render(), "\"sharp\"");
|
||||
assert_eq!(
|
||||
AccidentalId::parse(&read_sexp("\"sharp\"").unwrap()).unwrap(),
|
||||
id
|
||||
);
|
||||
// A non-NFC spelling must be rejected, not folded: "e" + combining acute.
|
||||
let decomposed = read_sexp("\"e\u{0301}\"").unwrap();
|
||||
assert!(
|
||||
AccidentalId::parse(&decomposed).is_err(),
|
||||
"non-NFC catalog id must be rejected, not normalized"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_canonical_f64_is_eight_bytes_never_a_decimal() {
|
||||
let f = CanonicalF64::new(1.5).unwrap();
|
||||
assert_eq!(f.project().render(), "#x000000000000f83f");
|
||||
let back = CanonicalF64::parse(&read_sexp("#x000000000000f83f").unwrap()).unwrap();
|
||||
assert_eq!(f, back);
|
||||
assert!(CanonicalF64::parse(&read_sexp("#x00").unwrap()).is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,724 @@
|
|||
//! [`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>) -> 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<Self, TextError> {
|
||||
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 <id>)`.
|
||||
/// 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<Self, TextError> {
|
||||
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
|
||||
/// <f64>)`, or `(absolute-hz <f64>)`. 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<Self, TextError> {
|
||||
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 <nominal>)`, `(integer <i>)`, or
|
||||
/// `(registered <id>)`. 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<Self, TextError> {
|
||||
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<Self, TextError> {
|
||||
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 <voice-id>…)`. 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<Self, TextError> {
|
||||
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 <id>)`, or `(range <start>
|
||||
/// <end> <voices>)`. 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<Self, TextError> {
|
||||
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 <pitch-spelling>)`, or `(rule <spelling-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<Self, TextError> {
|
||||
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 <position> <frequency>)`, 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<Self, TextError> {
|
||||
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>)`, `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<Self, TextError> {
|
||||
let fields = s.expect_struct(&kebab("SpellingPrecedence"), 1)?;
|
||||
let [order] = fields else {
|
||||
return Err(arity("SpellingPrecedence", 1, fields.len()));
|
||||
};
|
||||
let order: Vec<SpellingSourceKind> = 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<T: TextValue + PartialEq + std::fmt::Debug>(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());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,603 @@
|
|||
//! [`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))");
|
||||
}
|
||||
}
|
||||
|
|
@ -108,7 +108,7 @@ impl RationalTime {
|
|||
/// [`RationalTime::Small`] when the normalized value fits the inline range.
|
||||
/// This is the single chokepoint that maintains the canonical-form
|
||||
/// invariant.
|
||||
fn from_big(value: BigRational) -> Self {
|
||||
pub(crate) fn from_big(value: BigRational) -> Self {
|
||||
// `BigRational` keeps the denominator positive and the fraction
|
||||
// reduced, so the sign lives on the numerator.
|
||||
let numer = value.numer();
|
||||
|
|
@ -126,7 +126,7 @@ impl RationalTime {
|
|||
|
||||
/// The value as a [`BigRational`] (allocates for the inline case; used on
|
||||
/// the slow arithmetic path and for canonical encoding).
|
||||
fn to_big(&self) -> BigRational {
|
||||
pub(crate) fn to_big(&self) -> BigRational {
|
||||
match self {
|
||||
RationalTime::Small(s) => {
|
||||
BigRational::new(BigInt::from(s.numerator), BigInt::from(s.denominator.get()))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,120 @@
|
|||
//! The constructor symbol of every hand-written projection must be the kebab of
|
||||
//! the Rust name it stands for.
|
||||
//!
|
||||
//! # Why this exists
|
||||
//!
|
||||
//! The 116 macro-generated impls take their constructor symbol from
|
||||
//! `kebab(stringify!($ty))`, so it cannot be wrong. The hand-written impls spell
|
||||
//! it as a string literal — `Sexp::sym("measured-fraction")` — and a typo there is
|
||||
//! **invisible to every other test in the suite**. `project` and `parse` would
|
||||
//! agree with each other on `"measured-fracton"`, the value would round-trip, the
|
||||
//! text would be self-consistent, and it would still not be the projection
|
||||
//! `req:textproj:value-projection` clause 1 and 3 specify.
|
||||
//!
|
||||
//! `Debug` is derived on all of these, and its output begins with the variant or
|
||||
//! struct name. So the name is recoverable at runtime and can be compared against
|
||||
//! the symbol the projection actually emits. That closes the gap.
|
||||
//!
|
||||
//! What it does **not** close: field *order* inside a variant. See
|
||||
//! `textvalue_roundtrip.rs`.
|
||||
|
||||
use epiphany_core::textvalue::{kebab, Sexp, TextValue};
|
||||
|
||||
/// Asserts that `value`'s projection is headed by the kebab of its Rust name.
|
||||
///
|
||||
/// Applies to a fieldless variant or zero-field struct (a bare symbol) and to a
|
||||
/// struct or a variant with fields (a list headed by a symbol). It does **not**
|
||||
/// apply to a transparent newtype, which by clause 2 emits no name at all.
|
||||
#[track_caller]
|
||||
fn projects_under_its_own_name<T: TextValue + std::fmt::Debug>(value: &T) {
|
||||
let debug = format!("{value:?}");
|
||||
let rust_name = debug
|
||||
.split(['(', ' ', '{'])
|
||||
.next()
|
||||
.expect("Debug output is non-empty");
|
||||
let expected = kebab(rust_name);
|
||||
|
||||
let projected = value.project();
|
||||
let head = match &projected {
|
||||
Sexp::Symbol(name) => name.clone(),
|
||||
Sexp::List(items) => items
|
||||
.first()
|
||||
.and_then(Sexp::as_symbol)
|
||||
.unwrap_or_else(|| {
|
||||
panic!("{rust_name} projects to a list not headed by a symbol: {projected:?}")
|
||||
})
|
||||
.to_owned(),
|
||||
other => panic!("{rust_name} projects to {other:?}, which carries no constructor name"),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
head, expected,
|
||||
"{rust_name} projects under the name `{head}`, but its Rust name kebabs to \
|
||||
`{expected}`. A round-trip test cannot see this: `parse` reads the same \
|
||||
wrong symbol `project` wrote."
|
||||
);
|
||||
}
|
||||
|
||||
mod event {
|
||||
use super::projects_under_its_own_name;
|
||||
use epiphany_core::{
|
||||
Event, EventDuration, EventPosition, GraceKind, IndeterminacyKind, MusicalDuration,
|
||||
MusicalPosition, PitchId, RationalTime, ReplicaId, Rest, StaffPosition, TrajectoryEndpoint,
|
||||
TrajectoryShape,
|
||||
};
|
||||
|
||||
fn replica() -> ReplicaId {
|
||||
ReplicaId::from_entropy([1; 8]).expect("a non-system-derived replica")
|
||||
}
|
||||
|
||||
fn a_rest() -> Rest {
|
||||
Rest {
|
||||
id: epiphany_core::EventId::new(replica(), 1),
|
||||
voice: epiphany_core::VoiceId::new(replica(), 1),
|
||||
position: EventPosition::Musical(MusicalPosition(RationalTime::zero())),
|
||||
duration: EventDuration::Musical(MusicalDuration(
|
||||
RationalTime::new(1, 4).expect("a valid quarter"),
|
||||
)),
|
||||
vertical_position: Some(StaffPosition(-2)),
|
||||
visible: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grace_kind_variants_project_under_their_own_names() {
|
||||
for value in [
|
||||
GraceKind::Acciaccatura,
|
||||
GraceKind::Appoggiatura,
|
||||
GraceKind::Unmeasured,
|
||||
] {
|
||||
projects_under_its_own_name(&value);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indeterminacy_kind_variants_project_under_their_own_names() {
|
||||
for value in [
|
||||
IndeterminacyKind::Pitch,
|
||||
IndeterminacyKind::Duration,
|
||||
IndeterminacyKind::Choice,
|
||||
] {
|
||||
projects_under_its_own_name(&value);
|
||||
}
|
||||
projects_under_its_own_name(&IndeterminacyKind::Compound(vec![IndeterminacyKind::Pitch]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trajectory_variants_project_under_their_own_names() {
|
||||
projects_under_its_own_name(&TrajectoryShape::Linear);
|
||||
projects_under_its_own_name(&TrajectoryEndpoint::EventPitch(PitchId::new(replica(), 1)));
|
||||
}
|
||||
|
||||
/// Also covers a `struct_codec!` struct (`Rest`), whose head comes from the
|
||||
/// macro, and the `Event` variant that wraps it, whose head is hand-spelled.
|
||||
#[test]
|
||||
fn event_variants_project_under_their_own_names() {
|
||||
let rest = a_rest();
|
||||
projects_under_its_own_name(&rest);
|
||||
projects_under_its_own_name(&Event::Rest(rest));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
//! The text projection's own injectivity, exercised over generated scores.
|
||||
//!
|
||||
//! `req:textproj:roundtrip` states two equations. The one a test can check with
|
||||
//! byte equality is the *text's* injectivity:
|
||||
//!
|
||||
//! ```text
|
||||
//! project(serialize(parse(T))) = T
|
||||
//! ```
|
||||
//!
|
||||
//! For a single value that reads: rendering a value, reading it back, and
|
||||
//! rendering again must give the same bytes; and the value that comes back must
|
||||
//! equal the value that went in. Both directions are asserted here for every
|
||||
//! `Event` of every score `epiphany_core::generators` can build, which reaches the
|
||||
//! whole of the Chapter-5 value graph that operations embed.
|
||||
//!
|
||||
//! # What this test cannot see
|
||||
//!
|
||||
//! A `project`/`parse` pair that agrees with *itself* on a wrong field order round
|
||||
//! trips perfectly. Two adjacent fields of the same type, swapped in both
|
||||
//! directions, are invisible here and to the compiler both. Order is pinned
|
||||
//! elsewhere:
|
||||
//!
|
||||
//! * for the 82 `struct_codec!` structs, by construction — the same field list
|
||||
//! generates the binary codec and the projection, so they cannot disagree;
|
||||
//! * for the 44 hand-written impls, by a mechanical diff of the identifier
|
||||
//! sequence in `impl Codec for T`'s `fn enc` against the one in `project`.
|
||||
//! `enc`'s order *is* the ratified declaration order, and all 44 agreed.
|
||||
//!
|
||||
//! Saying so is the point. A green round-trip is not evidence of correct order,
|
||||
//! and this file must not be read as if it were.
|
||||
//!
|
||||
//! `textvalue_names.rs` covers the neighbouring blind spot: a mistyped
|
||||
//! constructor symbol, which is equally invisible to a round trip because `parse`
|
||||
//! reads back whatever `project` wrote.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use epiphany_core::generators::{arbitrary_graph_corpus, valid_score_rich};
|
||||
use epiphany_core::textvalue::{read_sexp, TextValue};
|
||||
use epiphany_core::Score;
|
||||
|
||||
/// Renders `value`, reads it back, and asserts both the value and its rendering
|
||||
/// survive unchanged.
|
||||
#[track_caller]
|
||||
fn round_trips<T: TextValue + PartialEq + std::fmt::Debug>(value: &T) {
|
||||
let text = value.project().render();
|
||||
let sexp = read_sexp(&text).unwrap_or_else(|e| panic!("rejected its own output: {text}: {e}"));
|
||||
let back = T::parse(&sexp).unwrap_or_else(|e| panic!("rejected its own output: {text}: {e}"));
|
||||
assert_eq!(*value, back, "value changed across the projection");
|
||||
assert_eq!(
|
||||
back.project().render(),
|
||||
text,
|
||||
"re-projection is not byte-identical"
|
||||
);
|
||||
}
|
||||
|
||||
/// Every event of every generated score, through text and back.
|
||||
#[test]
|
||||
fn every_generated_event_round_trips_through_its_projection() {
|
||||
let mut events = 0usize;
|
||||
for score in arbitrary_graph_corpus(24, 0xF0F0_1234) {
|
||||
for event in score.events.iter_canonical() {
|
||||
round_trips(event);
|
||||
events += 1;
|
||||
}
|
||||
}
|
||||
// A round-trip test that round-trips nothing is a test that passes for the
|
||||
// wrong reason. The corpus must actually carry events.
|
||||
assert!(
|
||||
events > 100,
|
||||
"the corpus reached only {events} events; it proves almost nothing"
|
||||
);
|
||||
}
|
||||
|
||||
/// The arena projects as a sequence, so it exercises the ordering rule as well as
|
||||
/// every event.
|
||||
#[test]
|
||||
fn a_whole_event_arena_round_trips() {
|
||||
let score = valid_score_rich(7);
|
||||
assert!(
|
||||
score.events.iter_canonical().count() > 1,
|
||||
"need at least two events to exercise the arena's ordering"
|
||||
);
|
||||
round_trips(&score.events);
|
||||
}
|
||||
|
||||
/// Distinct values must render to distinct text: the projection determines the
|
||||
/// document, so two documents may not share a projection
|
||||
/// (`req:textproj:canonical-text`).
|
||||
#[test]
|
||||
fn distinct_scores_project_to_distinct_text() {
|
||||
let mut seen: BTreeSet<String> = BTreeSet::new();
|
||||
let mut arenas = 0usize;
|
||||
for score in arbitrary_graph_corpus(16, 0x5EED) {
|
||||
let text = score.events.project().render();
|
||||
if !seen.insert(text) {
|
||||
// Two generated scores may legitimately share an event arena; only a
|
||||
// *different* arena rendering to the same text would be a defect. Check
|
||||
// that directly rather than assuming distinctness.
|
||||
continue;
|
||||
}
|
||||
arenas += 1;
|
||||
}
|
||||
assert!(
|
||||
arenas > 1,
|
||||
"the corpus produced only {arenas} distinct arenas"
|
||||
);
|
||||
|
||||
// The real statement: equal text implies equal value.
|
||||
let corpus: Vec<Score> = arbitrary_graph_corpus(16, 0x5EED).collect();
|
||||
for a in &corpus {
|
||||
for b in &corpus {
|
||||
let same_text = a.events.project().render() == b.events.project().render();
|
||||
assert_eq!(
|
||||
same_text,
|
||||
a.events.iter_canonical().eq(b.events.iter_canonical()),
|
||||
"text equality and value equality disagree"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -438,7 +438,7 @@ pub const REGISTERED_TAG_DISCRIMINANT: u8 = 16;
|
|||
/// separate hand-maintained lists — two of them asserting the tag was *unknown*
|
||||
/// — stayed green (Push 5 / P4).
|
||||
macro_rules! operation_kind_tag_vocabulary {
|
||||
($($variant:ident = $disc:literal),+ $(,)?) => {
|
||||
($($variant:ident = $disc:literal => $catalog:literal),+ $(,)?) => {
|
||||
impl OperationKindTag {
|
||||
/// Every payload-free tag, in discriminant order. [`Registered`]
|
||||
/// is excluded: it carries an id and has no bare encoding.
|
||||
|
|
@ -465,41 +465,54 @@ macro_rules! operation_kind_tag_vocabulary {
|
|||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The name this kind carries in the Text Projection, which is the
|
||||
/// **Operation Catalog's** section name, not this enum's variant
|
||||
/// name. The tag space renamed three pairs (`InsertRegion`,
|
||||
/// `InsertStaff`, `InsertStaffInstance`); the projection keeps the
|
||||
/// catalog's `create-*`, because that is what the specification a
|
||||
/// reader holds calls them.
|
||||
pub fn catalog_name(&self) -> &'static str {
|
||||
match self {
|
||||
$(OperationKindTag::$variant => $catalog,)+
|
||||
OperationKindTag::Registered(_) => "registered",
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
operation_kind_tag_vocabulary! {
|
||||
InsertEvent = 0,
|
||||
DeleteEvent = 1,
|
||||
ModifyEvent = 2,
|
||||
RespellPitch = 3,
|
||||
Transpose = 4,
|
||||
CreateCrossCutting = 5,
|
||||
DeleteCrossCutting = 6,
|
||||
ModifyCrossCutting = 7,
|
||||
ChangeRegionTimeModel = 8,
|
||||
InsertRegion = 9,
|
||||
DeleteRegion = 10,
|
||||
InsertStaffInstance = 11,
|
||||
DeleteStaffInstance = 12,
|
||||
SetUserSystemBreak = 13,
|
||||
SetUserPageBreak = 14,
|
||||
DeclareTransaction = 15,
|
||||
InsertIdentifiedPitch = 17,
|
||||
DeleteIdentifiedPitch = 18,
|
||||
ModifyIdentifiedPitch = 19,
|
||||
CreateVoice = 20,
|
||||
DeleteVoice = 21,
|
||||
SetMetadata = 22,
|
||||
SetMetricGrid = 23,
|
||||
InsertStaff = 24,
|
||||
SetTimeSignature = 25,
|
||||
SetTempoSegment = 26,
|
||||
SetStaffLayout = 27,
|
||||
CreateRepeatStructure = 28,
|
||||
DeleteRepeatStructure = 29,
|
||||
TransposeInterval = 30,
|
||||
InsertEvent = 0 => "insert-event",
|
||||
DeleteEvent = 1 => "delete-event",
|
||||
ModifyEvent = 2 => "modify-event",
|
||||
RespellPitch = 3 => "respell-pitch",
|
||||
Transpose = 4 => "transpose",
|
||||
CreateCrossCutting = 5 => "create-cross-cutting",
|
||||
DeleteCrossCutting = 6 => "delete-cross-cutting",
|
||||
ModifyCrossCutting = 7 => "modify-cross-cutting",
|
||||
ChangeRegionTimeModel = 8 => "change-region-time-model",
|
||||
InsertRegion = 9 => "create-region",
|
||||
DeleteRegion = 10 => "delete-region",
|
||||
InsertStaffInstance = 11 => "create-staff-instance",
|
||||
DeleteStaffInstance = 12 => "delete-staff-instance",
|
||||
SetUserSystemBreak = 13 => "set-user-system-break",
|
||||
SetUserPageBreak = 14 => "set-user-page-break",
|
||||
DeclareTransaction = 15 => "declare-transaction",
|
||||
InsertIdentifiedPitch = 17 => "insert-identified-pitch",
|
||||
DeleteIdentifiedPitch = 18 => "delete-identified-pitch",
|
||||
ModifyIdentifiedPitch = 19 => "modify-identified-pitch",
|
||||
CreateVoice = 20 => "create-voice",
|
||||
DeleteVoice = 21 => "delete-voice",
|
||||
SetMetadata = 22 => "set-metadata",
|
||||
SetMetricGrid = 23 => "set-metric-grid",
|
||||
InsertStaff = 24 => "create-staff",
|
||||
SetTimeSignature = 25 => "set-time-signature",
|
||||
SetTempoSegment = 26 => "set-tempo-segment",
|
||||
SetStaffLayout = 27 => "set-staff-layout",
|
||||
CreateRepeatStructure = 28 => "create-repeat-structure",
|
||||
DeleteRepeatStructure = 29 => "delete-repeat-structure",
|
||||
TransposeInterval = 30 => "transpose-interval",
|
||||
}
|
||||
|
||||
impl CanonicalEncode for OperationKindTag {
|
||||
|
|
|
|||
|
|
@ -11,10 +11,11 @@
|
|||
//! 1. Every nonterminal the grammar references is defined, and every nonterminal
|
||||
//! it defines is reachable from `projection`.
|
||||
//! 2. The `kind` alternatives are **exactly** the operation vocabulary, *derived*
|
||||
//! from `OperationKindTag` rather than transcribed. Adding a tag without
|
||||
//! adding its production fails here, and the `match` below fails to compile
|
||||
//! without a name for it — the same two-layer guarantee
|
||||
//! `operation_kind_tag_vocabulary!` gives the decoder.
|
||||
//! from `OperationKindTag::catalog_name` rather than transcribed. That name is
|
||||
//! generated by the same `operation_kind_tag_vocabulary!` list as the
|
||||
//! discriminant and the decoder, so a tag added to the enum and not to the
|
||||
//! vocabulary fails to compile, and one added to the vocabulary but not the
|
||||
//! grammar fails here.
|
||||
//! 3. The `chunk-kind` alternatives are exactly the `ChunkKind` vocabulary.
|
||||
//! 4. The escape rule excludes the four codepoints
|
||||
//! `req:textproj:string-escapes` requires a writer to escape — the
|
||||
|
|
@ -202,49 +203,6 @@ fn every_nonterminal_is_defined_and_reachable() {
|
|||
);
|
||||
}
|
||||
|
||||
/// The Operation Catalog's section name, hyphenated, for each tag.
|
||||
///
|
||||
/// Exhaustive over `OperationKindTag`, so a new operation kind cannot be added
|
||||
/// without naming it here — and `the_kind_productions_are_the_operation_vocabulary`
|
||||
/// then fails until the grammar carries it. The tag names are *not* the catalog
|
||||
/// names: the tag space renamed three pairs (`InsertRegion`, `InsertStaff`,
|
||||
/// `InsertStaffInstance`), and the projection follows the semantics.
|
||||
fn catalog_name(tag: OperationKindTag) -> &'static str {
|
||||
match tag {
|
||||
OperationKindTag::InsertEvent => "insert-event",
|
||||
OperationKindTag::DeleteEvent => "delete-event",
|
||||
OperationKindTag::ModifyEvent => "modify-event",
|
||||
OperationKindTag::RespellPitch => "respell-pitch",
|
||||
OperationKindTag::Transpose => "transpose",
|
||||
OperationKindTag::TransposeInterval => "transpose-interval",
|
||||
OperationKindTag::CreateCrossCutting => "create-cross-cutting",
|
||||
OperationKindTag::DeleteCrossCutting => "delete-cross-cutting",
|
||||
OperationKindTag::ModifyCrossCutting => "modify-cross-cutting",
|
||||
OperationKindTag::ChangeRegionTimeModel => "change-region-time-model",
|
||||
OperationKindTag::InsertRegion => "create-region",
|
||||
OperationKindTag::DeleteRegion => "delete-region",
|
||||
OperationKindTag::InsertStaffInstance => "create-staff-instance",
|
||||
OperationKindTag::DeleteStaffInstance => "delete-staff-instance",
|
||||
OperationKindTag::SetUserSystemBreak => "set-user-system-break",
|
||||
OperationKindTag::SetUserPageBreak => "set-user-page-break",
|
||||
OperationKindTag::DeclareTransaction => "declare-transaction",
|
||||
OperationKindTag::Registered(_) => "registered",
|
||||
OperationKindTag::InsertIdentifiedPitch => "insert-identified-pitch",
|
||||
OperationKindTag::DeleteIdentifiedPitch => "delete-identified-pitch",
|
||||
OperationKindTag::ModifyIdentifiedPitch => "modify-identified-pitch",
|
||||
OperationKindTag::CreateVoice => "create-voice",
|
||||
OperationKindTag::DeleteVoice => "delete-voice",
|
||||
OperationKindTag::SetMetadata => "set-metadata",
|
||||
OperationKindTag::SetMetricGrid => "set-metric-grid",
|
||||
OperationKindTag::InsertStaff => "create-staff",
|
||||
OperationKindTag::SetTimeSignature => "set-time-signature",
|
||||
OperationKindTag::SetTempoSegment => "set-tempo-segment",
|
||||
OperationKindTag::SetStaffLayout => "set-staff-layout",
|
||||
OperationKindTag::CreateRepeatStructure => "create-repeat-structure",
|
||||
OperationKindTag::DeleteRepeatStructure => "delete-repeat-structure",
|
||||
}
|
||||
}
|
||||
|
||||
/// The right-hand side of `production`, spanning its continuation lines.
|
||||
///
|
||||
/// Located by name, not by column: a grammar reflow must not silently turn a
|
||||
|
|
@ -314,7 +272,12 @@ fn the_kind_productions_are_the_operation_vocabulary() {
|
|||
.chain(std::iter::once(OperationKindTag::Registered(
|
||||
epiphany_ops::OperationKindRegistryId(0),
|
||||
)))
|
||||
.map(|t| catalog_name(t).to_string())
|
||||
// `catalog_name` is production code, generated by the same
|
||||
// `operation_kind_tag_vocabulary!` list that generates the discriminant and
|
||||
// the decoder. It was a test-local `match` until 0.5.0 — a hand-maintained
|
||||
// list parallel to an enum, which is the exact shape that cost this project
|
||||
// four bugs.
|
||||
.map(|t| t.catalog_name().to_string())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
expected.len(),
|
||||
|
|
|
|||
Loading…
Reference in New Issue