Editor T2 W4a: the clipboard fragment projection and copy/paste
Ruling E made concrete: a versioned s-expression fragment — values only, reusing epiphany-core's own TextValue productions, no ids, no envelopes — with named untrusted-input caps (1 MiB / 4096 events / depth 64) checked before parsing and clean unknown-major rejection. copy_selection reports closure drops (boundary slurs/ties) on CopyOutcome; a partially-selected tuplet refuses; incidental rubber-band geometry (staff lines, stems) is skipped, mirroring alter_selection's precedent. paste_at and paste_over_selection mint fresh operations — InsertEvent + carried RespellPitch — per-voice-ordinal lanes, make-room overwrite, one atomic transaction; a second lane's refusal rolls the whole paste back byte-identically. Contained slur/tie capture lands in the format; replay on paste is W4b. Six worker mutations killed; coordinator review added a seventh test pinning the emitted stream (one DeclareTransaction descriptor plus members — descriptor-precedence at peers is canonical surface) after its absence let a per-op-commit mutation survive. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
f50f0ee52d
commit
1dfa8b4f3a
|
|
@ -130,3 +130,165 @@ one untransposable pitch (`AcousticRealization::AbsoluteHz`, pinned) leaves
|
||||||
*every* selected pitch — including the otherwise-transposable one — unmoved
|
*every* selected pitch — including the otherwise-transposable one — unmoved
|
||||||
(`graph_changed == false`), proving the reducer's own atomicity, not this
|
(`graph_changed == false`), proving the reducer's own atomicity, not this
|
||||||
method's plumbing, is what's carrying the guarantee.
|
method's plumbing, is what's carrying the guarantee.
|
||||||
|
|
||||||
|
## The clipboard fragment projection (2026-07-23, T2-W4a)
|
||||||
|
|
||||||
|
Dispatched under `spec/PLAN_EDITOR_APP.md` §Ruling E (granted 2026-07-23) and
|
||||||
|
`spec/CONTRACT_EDITOR_T2_SELECTION.md` §W4. New module `src/fragment.rs` (the
|
||||||
|
versioned s-expression format, private/`pub(crate)` types) plus three new
|
||||||
|
`EditorSession` intents in `lib.rs`: `copy_selection`, `paste_at`,
|
||||||
|
`paste_over_selection`. Blast radius: `epiphany-editor-core` only — the GUI
|
||||||
|
clipboard wiring is packet W4b.
|
||||||
|
|
||||||
|
**Grammar: reuse the Text Projection's leaf productions directly, not a
|
||||||
|
re-derived lookalike.** `Pitch`, `PitchSpelling`, `MusicalDuration`,
|
||||||
|
`SlurKind`, `TieClass`, `SpanStyle`, `CurvatureOverride`,
|
||||||
|
`ArticulationMark`/`DynamicMark`/`OrnamentMark`/`StemConfiguration`/`GraceKind`/`StaffPosition`
|
||||||
|
already implement `epiphany_core::textvalue::TextValue` — the same
|
||||||
|
`Sexp`/`TextValue`/`read_sexp` machinery `epiphany-textproj`/`epiphany-ops`'s
|
||||||
|
`textproj_leaf` build on, generated by `epiphany-core`'s `struct_codec!` /
|
||||||
|
`cstyle_enum_codec!` macros alongside their binary codec. The fragment format
|
||||||
|
calls `.project()`/`::parse()` on these directly rather than reinventing
|
||||||
|
their textual shape, and needs **no new dependency on `epiphany-textproj`**:
|
||||||
|
`epiphany_core::textvalue` is already public, and `epiphany-editor-core`
|
||||||
|
already depends on `epiphany-core`. What is bespoke is only the
|
||||||
|
document-shaped container Ruling E says the fragment must NOT borrow from
|
||||||
|
TP's document grammar: `(epiphany-fragment (0 1 0) VOICES SLURS TIES)`, with
|
||||||
|
`EventRef { voice: u32, event: u32 }` (a position in the fragment's own
|
||||||
|
per-voice lanes) as the sole way anything inside a fragment refers to
|
||||||
|
anything else — never a source `EventId`/`PitchId`/`SlurId`/`TieId`. Worked
|
||||||
|
example, captured live from `copy_paste_round_trip_preserves_values_with_fresh_ids`
|
||||||
|
(a two-note voice; the first note carries an authored spelling override, the
|
||||||
|
second does not — no cross-cutting structures span this range):
|
||||||
|
```
|
||||||
|
(epiphany-fragment (0 1 0) ((voice ((event (ratio 0 1) (ratio 1 4) (pitched ((pitch-entry (pitch (scale-position "cmn-12" (cmn g 0 4)) (acoustic-pitch inherit implicit)) (some (pitch-spelling (cmn d) () 4 (spelling-render-hints false false false false))))) () () () stem-configuration ())) (event (ratio 1 4) (ratio 1 4) (pitched ((pitch-entry (pitch (scale-position "cmn-12" (cmn g 0 4)) (acoustic-pitch inherit implicit)) ())) () () () stem-configuration ()))))) () ())
|
||||||
|
```
|
||||||
|
|
||||||
|
**API, as landed (diverges from the packet's indicative shape in two
|
||||||
|
places, both flagged there and re-flagged here):**
|
||||||
|
* `copy_selection(&self) -> Result<CopyOutcome, EditorError>` — `&self`, not
|
||||||
|
`&mut self` (it never mutates); `CopyOutcome { fragment: String, dropped:
|
||||||
|
Vec<DroppedItem> }`. The indicative shape had `copy_selection() ->
|
||||||
|
Result<String, EditorError>` with `dropped` living on `PasteOutcome`; the
|
||||||
|
required boundary-slur/tie test needs the dropped report at the point the
|
||||||
|
closure *decision* is made, which is copy time, against the source
|
||||||
|
selection — a fragment carries no memory of what it isn't, so paste could
|
||||||
|
never reconstruct this list. `PasteOutcome { outcome: EditOutcome,
|
||||||
|
events_inserted: usize }` therefore carries no `dropped`.
|
||||||
|
* `paste_at(&mut self, point: Point, grid: &GridResolution, fragment: &str)
|
||||||
|
-> Result<PasteOutcome, EditorError>`, `paste_over_selection(&mut self,
|
||||||
|
fragment: &str) -> Result<PasteOutcome, EditorError>` — match the
|
||||||
|
indicative shape.
|
||||||
|
* `fragment::{FragmentError, MAX_FRAGMENT_BYTES, MAX_FRAGMENT_EVENTS,
|
||||||
|
MAX_FRAGMENT_NESTING_DEPTH}` are re-exported at the crate root;
|
||||||
|
`EditorError::InvalidFragment(fragment::FragmentError)` folds decode
|
||||||
|
failures in via `From`. New `EditorError` variants:
|
||||||
|
`PartialTupletSelection { tuplet }` (copy-side closure refusal),
|
||||||
|
`InsufficientVoicesForFragment { needed, available }` (paste-side lane
|
||||||
|
policy, below), `EmptyFragment` (a decoded fragment naming no events).
|
||||||
|
|
||||||
|
**Closure v1, as implemented.** Tuplet refusal and the slur/tie
|
||||||
|
copy-or-drop-and-report decision are both evaluated in `copy_selection`
|
||||||
|
against the live score (`fragment.rs` never sees a tuplet, a `Score`, or a
|
||||||
|
selection — it only knows its own already-built `FragmentDocument`). A slur
|
||||||
|
whose `start_event`/`end_event` both resolve to fragment-local `EventRef`s
|
||||||
|
is carried into the fragment (`kind`/`curvature_override`/`style` copied
|
||||||
|
verbatim, since none are identities); exactly one resolving is a boundary
|
||||||
|
cut, reported via `DroppedItem::Slur`/`Tie`; neither resolving means the
|
||||||
|
slur/tie has nothing to do with this copy and is silently absent (not a
|
||||||
|
"drop" — it was never in scope). A partially-covered tuplet's members
|
||||||
|
short-circuit the whole copy before any fragment content is built.
|
||||||
|
|
||||||
|
**Points Ruling E left underdetermined, decided here (flagged per the
|
||||||
|
brief):**
|
||||||
|
1. **Event-kind scope.** Ruling E names no event-kind boundary for
|
||||||
|
"copies". `copy_selection` scopes to what `make_room` already treats as
|
||||||
|
copyable — a live, metric `Pitched`/`Rest` event — mirroring the crate's
|
||||||
|
one other place this exact line gets drawn, rather than inventing a
|
||||||
|
second boundary. Non-metric events and every other `Event` variant
|
||||||
|
(`Unpitched`/`Indeterminate`/`Trajectory`/`Graphic`/`Cue`) are out of v1.
|
||||||
|
2. **A selection member that is not a copyable pitch/event is *skipped*, not
|
||||||
|
refused — a deliberate departure from `delete_selection`'s hard-refusal
|
||||||
|
precedent, discovered while writing the required `select_within`-driven
|
||||||
|
tests.** A rubber-band `select_within` over a real staff routinely also
|
||||||
|
selects incidental geometry it visually crosses — staff lines, a note's
|
||||||
|
own stem — verified directly (a tight rect around two on-staff notes
|
||||||
|
still selected the staff line their noteheads sit on, plus both notes'
|
||||||
|
stems, five members total for two intended notes). A hard refusal
|
||||||
|
(`delete_selection`'s own rule: anything that is not a pitch/event errors
|
||||||
|
the whole call) would make geometric copy nearly unusable in practice.
|
||||||
|
`copy_selection` instead mirrors `alter_selection`'s established
|
||||||
|
"silently ignored" precedent (this file, above): a member that is not a
|
||||||
|
pitch/event, or a pitch/event that does not resolve to a live copyable
|
||||||
|
note/rest, is skipped; the whole copy still refuses
|
||||||
|
(`WrongSelection`) only if *nothing* usable remains. A note's own stem
|
||||||
|
(an `Event`-sourced region distinct from its `Pitch`-sourced notehead)
|
||||||
|
being incidentally selected alongside its notehead is harmless either
|
||||||
|
way — both resolve to the same `EventId` and collapse in the
|
||||||
|
`BTreeSet`.
|
||||||
|
3. **Multi-lane paste placement is resolved positionally, refusing rather
|
||||||
|
than guessing when short.** Ruling E specifies the destination staff/voice
|
||||||
|
for both placement forms but not a policy for a fragment naming more than
|
||||||
|
one voice lane. This session maps fragment lane `i` onto the destination
|
||||||
|
staff instance's `i`-th voice (`StaffInstance::voices[i]`) for both
|
||||||
|
`paste_at` (rooted at the clicked staff instance) and
|
||||||
|
`paste_over_selection` (rooted at the anchor's own staff instance);
|
||||||
|
`InsufficientVoicesForFragment` refuses cleanly when the destination has
|
||||||
|
fewer voices than the fragment has lanes, rather than collapsing lanes
|
||||||
|
onto one voice or dropping the extras silently.
|
||||||
|
4. **Slur/tie closure captures into the fragment *format* fully; paste does
|
||||||
|
not yet replay a captured one.** A fully-contained slur/tie is carried in
|
||||||
|
the fragment text (satisfying "copies" as a wire-format guarantee — the
|
||||||
|
data is not lost, and a future paste enhancement needs no format bump to
|
||||||
|
consume it), but Ruling E's own placement bullet names exactly
|
||||||
|
`InsertEvent` + `RespellPitch` as what paste mints, and no required test
|
||||||
|
exercises re-minting a pasted slur/tie (`CreateCrossCutting`, which would
|
||||||
|
need its own `SlurId`/`TieId` high-water-mark minter alongside
|
||||||
|
`mint_event_id`/`mint_pitch_id`). Filed here as a named follow-up, not a
|
||||||
|
silent gap: a fragment carrying a captured slur/tie today decodes and
|
||||||
|
pastes its notes correctly; the slur/tie itself is inert cargo until a
|
||||||
|
later packet wires the mint.
|
||||||
|
5. **A tie's explicit `pitch_pairing` is not carried; captured ties fall
|
||||||
|
back to `None`** (default enharmonic pairing on any future replay).
|
||||||
|
`pitch_pairing: Option<Vec<(PitchId, PitchId)>>` keys on source
|
||||||
|
`PitchId`s, which the fragment deliberately never carries (Ruling E: no
|
||||||
|
object ids); remapping specific pairings through per-pitch ordinals was
|
||||||
|
judged not worth the added grammar for a rare feature with no test
|
||||||
|
coverage requirement. `TieClass`/`SpanStyle` are still carried in full.
|
||||||
|
|
||||||
|
**Untrusted-input caps**, each a named public constant with its own
|
||||||
|
value-asserting rejection test in `fragment.rs`: `MAX_FRAGMENT_BYTES = 1 <<
|
||||||
|
20` (byte length, checked first — cheapest rejection); `MAX_FRAGMENT_EVENTS
|
||||||
|
= 4096` (checked after structural parse, since counting needs typed
|
||||||
|
voices); `MAX_FRAGMENT_NESTING_DEPTH = 64` (`(`-nesting, checked by one
|
||||||
|
linear, non-recursive scan over the raw text *before* `read_sexp`'s
|
||||||
|
recursive-descent reader ever sees adversarial input — the scan is
|
||||||
|
string-literal-aware, so a legitimate catalog-id string's own characters
|
||||||
|
can never spuriously trip it). An unrecognized major is rejected
|
||||||
|
immediately after the version triple is read and *before* `Vec<FragmentVoice>::parse`
|
||||||
|
or its siblings ever attempt to interpret the body — "never partially
|
||||||
|
parsed" verified directly (a major-1 fragment whose body would not even
|
||||||
|
parse under today's grammar still reports `UnsupportedVersion`, not a parse
|
||||||
|
error, proving the gate runs first).
|
||||||
|
|
||||||
|
**Paste atomicity.** `paste_document` builds the *entire* op list — every
|
||||||
|
lane's make-room clears plus every fragment event's `InsertEvent` (+
|
||||||
|
`RespellPitch` for a carried spelling override, insert-before-its-own-respell,
|
||||||
|
the same discipline `make_room_ops`'s split-tail loop already uses) — before
|
||||||
|
calling `apply`/`apply_transaction` even once; a refusal discovered while
|
||||||
|
building it (`make_room` overlapping a nested tuplet) therefore can never
|
||||||
|
leave a partial mutation, regardless of how the final application is shaped.
|
||||||
|
Because of that, the packet's own suggested atomicity scenario ("make-room
|
||||||
|
hits a refusal") cannot distinguish a transaction-committing implementation
|
||||||
|
from one that applies ops individually — both return `Err` before anything
|
||||||
|
would be applied either way (verified by construction, not asserted away:
|
||||||
|
`paste_refuses_cleanly_on_a_nested_tuplet_and_changes_nothing` keeps that
|
||||||
|
scenario as a real, useful test of a different property). The mutation that
|
||||||
|
*does* separate the two designs needs a refusal that a **second lane's**
|
||||||
|
make-room hits *after* a first lane's ops have already been built —
|
||||||
|
`paste_atomicity_rolls_back_mid_transaction_on_a_second_lanes_refusal` pastes
|
||||||
|
a two-lane fragment onto a destination whose voice 0 is clear and voice 1
|
||||||
|
carries a nested tuplet; mutating the commit to run per-lane inside the
|
||||||
|
loop (instead of once, over every lane's accumulated ops) lands voice 0's
|
||||||
|
insert before voice 1's refusal is discovered, changing `canonical_bytes`
|
||||||
|
even though the call still returns `Err` — killed, confirmed live.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,788 @@
|
||||||
|
//! The clipboard fragment projection (Ruling E, `spec/PLAN_EDITOR_APP.md`
|
||||||
|
//! §Ruling E, GRANTED 2026-07-23): a versioned, values-only s-expression
|
||||||
|
//! format for [`crate::EditorSession::copy_selection`] /
|
||||||
|
//! [`crate::EditorSession::paste_at`] / [`crate::EditorSession::paste_over_selection`].
|
||||||
|
//!
|
||||||
|
//! # Grammar
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! fragment ::= "(epiphany-fragment" version voices slurs ties ")"
|
||||||
|
//! version ::= "(" u32 " " u32 " " u32 ")" ; (major minor patch)
|
||||||
|
//! voices ::= "(" voice* ")"
|
||||||
|
//! voice ::= "(voice (" event* "))"
|
||||||
|
//! event ::= "(event" onset duration content ")"
|
||||||
|
//! onset ::= <MusicalDuration> ; relative to the fragment origin
|
||||||
|
//! duration ::= <MusicalDuration>
|
||||||
|
//! content ::= "rest" fields | "pitched" fields
|
||||||
|
//! pitches ::= "(" pitch-entry* ")"
|
||||||
|
//! pitch-entry ::= "(pitch-entry" <Pitch> spelling-override ")"
|
||||||
|
//! spelling-override ::= "()" | "(some" <PitchSpelling> ")"
|
||||||
|
//! slurs ::= "(" slur* ")"
|
||||||
|
//! slur ::= "(slur" event-ref event-ref <SlurKind> curvature-override <SpanStyle> ")"
|
||||||
|
//! ties ::= "(" tie* ")"
|
||||||
|
//! tie ::= "(tie" event-ref event-ref <TieClass> <SpanStyle> ")"
|
||||||
|
//! event-ref ::= "(event-ref" u32{voice} u32{event} ")"
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! `<Pitch>`, `<PitchSpelling>`, `<SlurKind>`, `<TieClass>`, `<SpanStyle>`,
|
||||||
|
//! `<CurvatureOverride>`, `<MusicalDuration>` are the Text Projection's own
|
||||||
|
//! ratified leaf/value productions — [`epiphany_core::textvalue::TextValue`]
|
||||||
|
//! impls this module reuses verbatim (`struct_codec!`/`cstyle_enum_codec!` in
|
||||||
|
//! `epiphany-core`), never re-derived. What is bespoke here is everything
|
||||||
|
//! *document*-shaped the Text Projection does not have an opinion on: no
|
||||||
|
//! document id, no envelopes, no causal contexts, and — the load-bearing
|
||||||
|
//! difference from every id in the rest of the codebase — **no object ids**.
|
||||||
|
//! An [`EventRef`] names an event by its position in the fragment's own
|
||||||
|
//! per-voice lanes (`voice` ordinal, `event` ordinal within that lane), never
|
||||||
|
//! a source `EventId`/`PitchId`/`SlurId`/`TieId`. Paste mints fresh ids from
|
||||||
|
//! the session's own minters; nothing in this module ever sees a source id.
|
||||||
|
//!
|
||||||
|
//! # Closure v1 (Ruling E, fail closed)
|
||||||
|
//!
|
||||||
|
//! This module only *represents* closure's outcome — a fragment either
|
||||||
|
//! carries a slur/tie (both its endpoints resolved to in-fragment
|
||||||
|
//! [`EventRef`]s) or it does not exist in the fragment at all. The decision
|
||||||
|
//! of *which* — and the "report dropped" bookkeeping for what closure
|
||||||
|
//! discarded — is [`crate::EditorSession::copy_selection`]'s job, against the
|
||||||
|
//! live selection and score; this module has no access to either. A
|
||||||
|
//! partially-selected tuplet's refusal is likewise a copy-time decision (this
|
||||||
|
//! module never sees a tuplet).
|
||||||
|
//!
|
||||||
|
//! # Untrusted input
|
||||||
|
//!
|
||||||
|
//! Fragments arrive from the OS clipboard. [`decode`] enforces three named
|
||||||
|
//! caps — [`MAX_FRAGMENT_BYTES`], [`MAX_FRAGMENT_EVENTS`],
|
||||||
|
//! [`MAX_FRAGMENT_NESTING_DEPTH`] — each checked, in that order, *before* the
|
||||||
|
//! more expensive check after it (byte length is a slice op; nesting depth is
|
||||||
|
//! one linear scan that never recurses, so it bounds stack depth *before*
|
||||||
|
//! [`read_sexp`]'s recursive-descent reader ever runs on attacker input; the
|
||||||
|
//! event count is checked only after a full structural parse, since it needs
|
||||||
|
//! typed voices to count). An unrecognized major version is rejected
|
||||||
|
//! immediately after the version triple is read and *before* any attempt to
|
||||||
|
//! interpret the body as today's voices/slurs/ties grammar — Ruling E: "never
|
||||||
|
//! partially parsed".
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
use epiphany_core::textvalue::{read_sexp, Sexp, TextError, TextValue};
|
||||||
|
use epiphany_core::{
|
||||||
|
ArticulationMark, CurvatureOverride, DynamicMark, GraceKind, MusicalDuration, OrnamentMark,
|
||||||
|
Pitch, PitchSpelling, SlurKind, SpanStyle, StaffPosition, StemConfiguration, TieClass,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The one fragment major version this build encodes and accepts. Ruling E:
|
||||||
|
/// starting `(0 1 0)`.
|
||||||
|
pub const FRAGMENT_VERSION: (u32, u32, u32) = (0, 1, 0);
|
||||||
|
|
||||||
|
/// Hard cap on a fragment's encoded byte length. Checked first, on the raw
|
||||||
|
/// text, before any parsing — the cheapest possible rejection of an
|
||||||
|
/// unreasonably large clipboard payload.
|
||||||
|
pub const MAX_FRAGMENT_BYTES: usize = 1 << 20; // 1 MiB
|
||||||
|
|
||||||
|
/// Hard cap on the total number of events a fragment may carry, summed over
|
||||||
|
/// every voice lane. Checked after structural parsing (counting typed events
|
||||||
|
/// needs the typed voices).
|
||||||
|
pub const MAX_FRAGMENT_EVENTS: usize = 4096;
|
||||||
|
|
||||||
|
/// Hard cap on `(`-nesting depth, checked by one linear, non-recursive scan
|
||||||
|
/// over the raw text *before* [`read_sexp`] — whose reader recurses one stack
|
||||||
|
/// frame per open paren — ever sees the input. Comfortably above this
|
||||||
|
/// grammar's legitimate worst case (a fully-populated pitched event with a
|
||||||
|
/// spelling override nests on the order of a dozen levels); its job is
|
||||||
|
/// bounding stack depth against adversarial input, not modeling this
|
||||||
|
/// grammar's real shape precisely.
|
||||||
|
pub const MAX_FRAGMENT_NESTING_DEPTH: usize = 64;
|
||||||
|
|
||||||
|
/// Why a clipboard fragment could not be decoded. Folded into
|
||||||
|
/// [`crate::EditorError::InvalidFragment`].
|
||||||
|
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||||
|
pub enum FragmentError {
|
||||||
|
/// The encoded text is over [`MAX_FRAGMENT_BYTES`].
|
||||||
|
TooManyBytes {
|
||||||
|
/// The byte cap.
|
||||||
|
limit: usize,
|
||||||
|
/// The text's actual length.
|
||||||
|
found: usize,
|
||||||
|
},
|
||||||
|
/// The text's `(`-nesting exceeds [`MAX_FRAGMENT_NESTING_DEPTH`].
|
||||||
|
NestingTooDeep {
|
||||||
|
/// The depth cap.
|
||||||
|
limit: usize,
|
||||||
|
},
|
||||||
|
/// The header named a major version this build does not read (only
|
||||||
|
/// `FRAGMENT_VERSION`'s major is accepted). Rejected before any attempt
|
||||||
|
/// to parse the body under today's grammar.
|
||||||
|
UnsupportedVersion {
|
||||||
|
/// The unrecognized major version.
|
||||||
|
major: u32,
|
||||||
|
},
|
||||||
|
/// The text is not a well-formed s-expression, or not a well-formed
|
||||||
|
/// fragment of a recognized major version.
|
||||||
|
Malformed(TextError),
|
||||||
|
/// The fragment's total event count (summed over every voice) is over
|
||||||
|
/// [`MAX_FRAGMENT_EVENTS`].
|
||||||
|
TooManyEvents {
|
||||||
|
/// The event-count cap.
|
||||||
|
limit: usize,
|
||||||
|
/// The fragment's actual total event count.
|
||||||
|
found: usize,
|
||||||
|
},
|
||||||
|
/// A slur or tie names an `EventRef` outside the fragment's own voices —
|
||||||
|
/// corrupt or adversarial input; a well-formed fragment produced by
|
||||||
|
/// `encode` never emits one.
|
||||||
|
DanglingEventRef,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for FragmentError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
FragmentError::TooManyBytes { limit, found } => {
|
||||||
|
write!(f, "fragment is {found} bytes, over the {limit}-byte cap")
|
||||||
|
}
|
||||||
|
FragmentError::NestingTooDeep { limit } => {
|
||||||
|
write!(f, "fragment nesting exceeds the {limit}-level cap")
|
||||||
|
}
|
||||||
|
FragmentError::UnsupportedVersion { major } => write!(
|
||||||
|
f,
|
||||||
|
"fragment major version {major} is not supported (this build reads major {})",
|
||||||
|
FRAGMENT_VERSION.0
|
||||||
|
),
|
||||||
|
FragmentError::Malformed(err) => write!(f, "malformed fragment: {err}"),
|
||||||
|
FragmentError::TooManyEvents { limit, found } => {
|
||||||
|
write!(f, "fragment has {found} events, over the {limit}-event cap")
|
||||||
|
}
|
||||||
|
FragmentError::DanglingEventRef => {
|
||||||
|
write!(f, "a slur or tie references an event outside the fragment")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for FragmentError {}
|
||||||
|
|
||||||
|
impl From<TextError> for FragmentError {
|
||||||
|
fn from(err: TextError) -> Self {
|
||||||
|
FragmentError::Malformed(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The lexical class of `s`, for error messages — mirrors `Sexp`'s own
|
||||||
|
/// private classifier (duplicated here the same way `epiphany-ops`'
|
||||||
|
/// `textproj_leaf`/`textvalue_event` each keep their own copy; `Sexp::class`
|
||||||
|
/// is private to its defining module).
|
||||||
|
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",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A reference to one event **within this fragment**, by its position in a
|
||||||
|
/// voice lane — never a source `EventId`/`PitchId` (Ruling E: "no object
|
||||||
|
/// ids"). `voice`/`event` are 0-based indices into [`FragmentDocument::voices`]
|
||||||
|
/// and that voice's `events`. [`decode`] validates every reference actually
|
||||||
|
/// resolves before returning a [`FragmentDocument`] — nothing downstream
|
||||||
|
/// needs to re-check.
|
||||||
|
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
||||||
|
pub(crate) struct EventRef {
|
||||||
|
pub(crate) voice: u32,
|
||||||
|
pub(crate) event: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextValue for EventRef {
|
||||||
|
fn project(&self) -> Sexp {
|
||||||
|
Sexp::List(vec![
|
||||||
|
Sexp::sym("event-ref"),
|
||||||
|
self.voice.project(),
|
||||||
|
self.event.project(),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
fn parse(s: &Sexp) -> Result<Self, TextError> {
|
||||||
|
let fields = s.expect_struct("event-ref", 2)?;
|
||||||
|
Ok(EventRef {
|
||||||
|
voice: u32::parse(&fields[0])?,
|
||||||
|
event: u32::parse(&fields[1])?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One note in a chord: its pitch **value** plus an authored spelling
|
||||||
|
/// override, if the source pitch carried one (an *inferred* spelling never
|
||||||
|
/// copies — Ruling E: derived state re-derives).
|
||||||
|
#[derive(Clone, PartialEq, Debug)]
|
||||||
|
pub(crate) struct FragmentPitch {
|
||||||
|
pub(crate) pitch: Pitch,
|
||||||
|
pub(crate) spelling_override: Option<PitchSpelling>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextValue for FragmentPitch {
|
||||||
|
fn project(&self) -> Sexp {
|
||||||
|
Sexp::List(vec![
|
||||||
|
Sexp::sym("pitch-entry"),
|
||||||
|
self.pitch.project(),
|
||||||
|
self.spelling_override.project(),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
fn parse(s: &Sexp) -> Result<Self, TextError> {
|
||||||
|
let fields = s.expect_struct("pitch-entry", 2)?;
|
||||||
|
Ok(FragmentPitch {
|
||||||
|
pitch: Pitch::parse(&fields[0])?,
|
||||||
|
spelling_override: Option::<PitchSpelling>::parse(&fields[1])?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An event's content: a rest, or a chord of one or more pitches with its
|
||||||
|
/// per-event attachments (Ruling E: "notes/rests with their per-event
|
||||||
|
/// attachments copy"). Scoped to what [`crate::EditorSession::make_room`]
|
||||||
|
/// itself already treats as copyable — a note or a rest — mirroring the
|
||||||
|
/// crate's established make-room boundary rather than inventing a new one;
|
||||||
|
/// Ruling E names no event-kind scope explicitly (flagged in the W4a
|
||||||
|
/// report).
|
||||||
|
#[derive(Clone, PartialEq, Debug)]
|
||||||
|
pub(crate) enum FragmentEventContent {
|
||||||
|
Rest {
|
||||||
|
vertical_position: Option<StaffPosition>,
|
||||||
|
visible: bool,
|
||||||
|
},
|
||||||
|
Pitched {
|
||||||
|
pitches: Vec<FragmentPitch>,
|
||||||
|
articulations: Vec<ArticulationMark>,
|
||||||
|
dynamic: Option<DynamicMark>,
|
||||||
|
ornaments: Vec<OrnamentMark>,
|
||||||
|
stem: StemConfiguration,
|
||||||
|
grace: Option<GraceKind>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextValue for FragmentEventContent {
|
||||||
|
fn project(&self) -> Sexp {
|
||||||
|
match self {
|
||||||
|
FragmentEventContent::Rest {
|
||||||
|
vertical_position,
|
||||||
|
visible,
|
||||||
|
} => Sexp::List(vec![
|
||||||
|
Sexp::sym("rest"),
|
||||||
|
vertical_position.project(),
|
||||||
|
visible.project(),
|
||||||
|
]),
|
||||||
|
FragmentEventContent::Pitched {
|
||||||
|
pitches,
|
||||||
|
articulations,
|
||||||
|
dynamic,
|
||||||
|
ornaments,
|
||||||
|
stem,
|
||||||
|
grace,
|
||||||
|
} => Sexp::List(vec![
|
||||||
|
Sexp::sym("pitched"),
|
||||||
|
pitches.project(),
|
||||||
|
articulations.project(),
|
||||||
|
dynamic.project(),
|
||||||
|
ornaments.project(),
|
||||||
|
stem.project(),
|
||||||
|
grace.project(),
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn parse(s: &Sexp) -> Result<Self, TextError> {
|
||||||
|
let items = s.as_list().ok_or(TextError::Expected {
|
||||||
|
expected: "FragmentEventContent",
|
||||||
|
found: class_of(s),
|
||||||
|
})?;
|
||||||
|
let head = items
|
||||||
|
.first()
|
||||||
|
.and_then(Sexp::as_symbol)
|
||||||
|
.ok_or(TextError::Syntax(
|
||||||
|
"a fragment event content is a list headed by its kind",
|
||||||
|
))?;
|
||||||
|
match head {
|
||||||
|
"rest" => {
|
||||||
|
let fields = s.expect_struct("rest", 2)?;
|
||||||
|
Ok(FragmentEventContent::Rest {
|
||||||
|
vertical_position: Option::<StaffPosition>::parse(&fields[0])?,
|
||||||
|
visible: bool::parse(&fields[1])?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
"pitched" => {
|
||||||
|
let fields = s.expect_struct("pitched", 6)?;
|
||||||
|
let pitches = Vec::<FragmentPitch>::parse(&fields[0])?;
|
||||||
|
if pitches.is_empty() {
|
||||||
|
return Err(TextError::NotCanonical(
|
||||||
|
"a pitched fragment event must have at least one pitch",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(FragmentEventContent::Pitched {
|
||||||
|
pitches,
|
||||||
|
articulations: Vec::<ArticulationMark>::parse(&fields[1])?,
|
||||||
|
dynamic: Option::<DynamicMark>::parse(&fields[2])?,
|
||||||
|
ornaments: Vec::<OrnamentMark>::parse(&fields[3])?,
|
||||||
|
stem: StemConfiguration::parse(&fields[4])?,
|
||||||
|
grace: Option::<GraceKind>::parse(&fields[5])?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
found => Err(TextError::UnknownConstructor {
|
||||||
|
type_name: "FragmentEventContent",
|
||||||
|
found: found.to_owned(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One event: a rational onset **relative to the fragment origin**, its
|
||||||
|
/// written duration, and its content (Ruling E).
|
||||||
|
#[derive(Clone, PartialEq, Debug)]
|
||||||
|
pub(crate) struct FragmentEvent {
|
||||||
|
pub(crate) onset: MusicalDuration,
|
||||||
|
pub(crate) duration: MusicalDuration,
|
||||||
|
pub(crate) content: FragmentEventContent,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextValue for FragmentEvent {
|
||||||
|
fn project(&self) -> Sexp {
|
||||||
|
Sexp::List(vec![
|
||||||
|
Sexp::sym("event"),
|
||||||
|
self.onset.project(),
|
||||||
|
self.duration.project(),
|
||||||
|
self.content.project(),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
fn parse(s: &Sexp) -> Result<Self, TextError> {
|
||||||
|
let fields = s.expect_struct("event", 3)?;
|
||||||
|
let onset = MusicalDuration::parse(&fields[0])?;
|
||||||
|
if onset.0.is_negative() {
|
||||||
|
return Err(TextError::NotCanonical(
|
||||||
|
"a fragment event's onset must be non-negative",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let duration = MusicalDuration::parse(&fields[1])?;
|
||||||
|
if !duration.is_positive() {
|
||||||
|
return Err(TextError::NotCanonical(
|
||||||
|
"a fragment event's duration must be positive",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let content = FragmentEventContent::parse(&fields[2])?;
|
||||||
|
Ok(FragmentEvent {
|
||||||
|
onset,
|
||||||
|
duration,
|
||||||
|
content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One **ordinal-keyed** voice lane (Ruling E: "in per-voice lanes keyed by
|
||||||
|
/// ordinal (not `VoiceId`)") — its ordinal is this voice's index in
|
||||||
|
/// [`FragmentDocument::voices`], not a field of this type.
|
||||||
|
#[derive(Clone, PartialEq, Debug, Default)]
|
||||||
|
pub(crate) struct FragmentVoice {
|
||||||
|
pub(crate) events: Vec<FragmentEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextValue for FragmentVoice {
|
||||||
|
fn project(&self) -> Sexp {
|
||||||
|
Sexp::List(vec![Sexp::sym("voice"), self.events.project()])
|
||||||
|
}
|
||||||
|
fn parse(s: &Sexp) -> Result<Self, TextError> {
|
||||||
|
let fields = s.expect_struct("voice", 1)?;
|
||||||
|
Ok(FragmentVoice {
|
||||||
|
events: Vec::<FragmentEvent>::parse(&fields[0])?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A slur fully inside the copied range — closure v1 carried it (both
|
||||||
|
/// endpoints resolved to in-fragment events); a boundary-cut slur never
|
||||||
|
/// reaches this type (it is dropped and reported at copy time instead, see
|
||||||
|
/// the module doc).
|
||||||
|
#[derive(Clone, PartialEq, Debug)]
|
||||||
|
pub(crate) struct FragmentSlur {
|
||||||
|
pub(crate) start: EventRef,
|
||||||
|
pub(crate) end: EventRef,
|
||||||
|
pub(crate) kind: SlurKind,
|
||||||
|
pub(crate) curvature_override: Option<CurvatureOverride>,
|
||||||
|
pub(crate) style: SpanStyle,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextValue for FragmentSlur {
|
||||||
|
fn project(&self) -> Sexp {
|
||||||
|
Sexp::List(vec![
|
||||||
|
Sexp::sym("slur"),
|
||||||
|
self.start.project(),
|
||||||
|
self.end.project(),
|
||||||
|
self.kind.project(),
|
||||||
|
self.curvature_override.project(),
|
||||||
|
self.style.project(),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
fn parse(s: &Sexp) -> Result<Self, TextError> {
|
||||||
|
let fields = s.expect_struct("slur", 5)?;
|
||||||
|
Ok(FragmentSlur {
|
||||||
|
start: EventRef::parse(&fields[0])?,
|
||||||
|
end: EventRef::parse(&fields[1])?,
|
||||||
|
kind: SlurKind::parse(&fields[2])?,
|
||||||
|
curvature_override: Option::<CurvatureOverride>::parse(&fields[3])?,
|
||||||
|
style: SpanStyle::parse(&fields[4])?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tie fully inside the copied range (see [`FragmentSlur`]'s doc — the
|
||||||
|
/// same closure rule). The source tie's explicit pitch pairing, if any, is
|
||||||
|
/// **not** carried (a decision this packet made explicitly, see
|
||||||
|
/// `DECISIONS.md`): pairing keys on source `PitchId`s, which the fragment
|
||||||
|
/// deliberately never carries, and remapping it through per-pitch ordinals
|
||||||
|
/// was judged not worth the added grammar for a v1 whose paste does not yet
|
||||||
|
/// re-mint cross-cutting structures at all (below). A pasted tie, when
|
||||||
|
/// pasting is extended to replay one, falls back to `None` — the default
|
||||||
|
/// enharmonic pairing.
|
||||||
|
#[derive(Clone, PartialEq, Debug)]
|
||||||
|
pub(crate) struct FragmentTie {
|
||||||
|
pub(crate) start: EventRef,
|
||||||
|
pub(crate) end: EventRef,
|
||||||
|
pub(crate) class: TieClass,
|
||||||
|
pub(crate) style: SpanStyle,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextValue for FragmentTie {
|
||||||
|
fn project(&self) -> Sexp {
|
||||||
|
Sexp::List(vec![
|
||||||
|
Sexp::sym("tie"),
|
||||||
|
self.start.project(),
|
||||||
|
self.end.project(),
|
||||||
|
self.class.project(),
|
||||||
|
self.style.project(),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
fn parse(s: &Sexp) -> Result<Self, TextError> {
|
||||||
|
let fields = s.expect_struct("tie", 4)?;
|
||||||
|
Ok(FragmentTie {
|
||||||
|
start: EventRef::parse(&fields[0])?,
|
||||||
|
end: EventRef::parse(&fields[1])?,
|
||||||
|
class: TieClass::parse(&fields[2])?,
|
||||||
|
style: SpanStyle::parse(&fields[3])?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A parsed, validated fragment body (everything after the version header).
|
||||||
|
/// **Values, never identities** (Ruling E): no document id, no envelopes, no
|
||||||
|
/// causal contexts, no object ids anywhere in this type or its fields.
|
||||||
|
#[derive(Clone, PartialEq, Debug, Default)]
|
||||||
|
pub(crate) struct FragmentDocument {
|
||||||
|
pub(crate) voices: Vec<FragmentVoice>,
|
||||||
|
pub(crate) slurs: Vec<FragmentSlur>,
|
||||||
|
pub(crate) ties: Vec<FragmentTie>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn project_version((major, minor, patch): (u32, u32, u32)) -> Sexp {
|
||||||
|
Sexp::List(vec![Sexp::int(major), Sexp::int(minor), Sexp::int(patch)])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_version(s: &Sexp) -> Result<(u32, u32, u32), FragmentError> {
|
||||||
|
let items = s.as_list().ok_or(TextError::Expected {
|
||||||
|
expected: "version triple",
|
||||||
|
found: class_of(s),
|
||||||
|
})?;
|
||||||
|
let [major, minor, patch] = items else {
|
||||||
|
return Err(TextError::Arity {
|
||||||
|
type_name: "version",
|
||||||
|
expected: 3,
|
||||||
|
found: items.len(),
|
||||||
|
}
|
||||||
|
.into());
|
||||||
|
};
|
||||||
|
Ok((u32::parse(major)?, u32::parse(minor)?, u32::parse(patch)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders `document` as fragment text: `(epiphany-fragment (0 1 0) …)`.
|
||||||
|
/// Never fails — it is built from a live, already-valid session selection,
|
||||||
|
/// not untrusted input (the caps in [`decode`] are a **read**-side
|
||||||
|
/// discipline; [`crate::EditorSession::copy_selection`] does not need them on
|
||||||
|
/// the way out).
|
||||||
|
pub(crate) fn encode(document: &FragmentDocument) -> String {
|
||||||
|
Sexp::List(vec![
|
||||||
|
Sexp::sym("epiphany-fragment"),
|
||||||
|
project_version(FRAGMENT_VERSION),
|
||||||
|
document.voices.project(),
|
||||||
|
document.slurs.project(),
|
||||||
|
document.ties.project(),
|
||||||
|
])
|
||||||
|
.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes and validates fragment text — untrusted input (Ruling E). See the
|
||||||
|
/// module doc for the cap ordering and the "never partially parsed" version
|
||||||
|
/// discipline.
|
||||||
|
pub(crate) fn decode(text: &str) -> Result<FragmentDocument, FragmentError> {
|
||||||
|
if text.len() > MAX_FRAGMENT_BYTES {
|
||||||
|
return Err(FragmentError::TooManyBytes {
|
||||||
|
limit: MAX_FRAGMENT_BYTES,
|
||||||
|
found: text.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
check_nesting_depth(text)?;
|
||||||
|
|
||||||
|
let sexp = read_sexp(text)?;
|
||||||
|
let items = sexp.as_list().ok_or(TextError::Expected {
|
||||||
|
expected: "epiphany-fragment",
|
||||||
|
found: class_of(&sexp),
|
||||||
|
})?;
|
||||||
|
let Some((head, rest)) = items.split_first() else {
|
||||||
|
return Err(TextError::Syntax("an empty fragment").into());
|
||||||
|
};
|
||||||
|
if head.as_symbol() != Some("epiphany-fragment") {
|
||||||
|
return Err(TextError::Syntax("a fragment begins `(epiphany-fragment …)`").into());
|
||||||
|
}
|
||||||
|
let [version, voices, slurs, ties] = rest else {
|
||||||
|
return Err(TextError::Arity {
|
||||||
|
type_name: "epiphany-fragment",
|
||||||
|
expected: 4,
|
||||||
|
found: rest.len(),
|
||||||
|
}
|
||||||
|
.into());
|
||||||
|
};
|
||||||
|
|
||||||
|
// The version gate: checked, and dispositioned, before any attempt to
|
||||||
|
// interpret the body under today's grammar — an unrecognized major is
|
||||||
|
// rejected cleanly, never partially parsed (Ruling E).
|
||||||
|
let (major, _minor, _patch) = parse_version(version)?;
|
||||||
|
if major != FRAGMENT_VERSION.0 {
|
||||||
|
return Err(FragmentError::UnsupportedVersion { major });
|
||||||
|
}
|
||||||
|
|
||||||
|
let voices = Vec::<FragmentVoice>::parse(voices)?;
|
||||||
|
let slurs = Vec::<FragmentSlur>::parse(slurs)?;
|
||||||
|
let ties = Vec::<FragmentTie>::parse(ties)?;
|
||||||
|
|
||||||
|
let total_events: usize = voices.iter().map(|v| v.events.len()).sum();
|
||||||
|
if total_events > MAX_FRAGMENT_EVENTS {
|
||||||
|
return Err(FragmentError::TooManyEvents {
|
||||||
|
limit: MAX_FRAGMENT_EVENTS,
|
||||||
|
found: total_events,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let ref_resolves = |r: &EventRef| {
|
||||||
|
voices
|
||||||
|
.get(r.voice as usize)
|
||||||
|
.is_some_and(|v| (r.event as usize) < v.events.len())
|
||||||
|
};
|
||||||
|
let dangling = slurs
|
||||||
|
.iter()
|
||||||
|
.any(|s| !ref_resolves(&s.start) || !ref_resolves(&s.end))
|
||||||
|
|| ties
|
||||||
|
.iter()
|
||||||
|
.any(|t| !ref_resolves(&t.start) || !ref_resolves(&t.end));
|
||||||
|
if dangling {
|
||||||
|
return Err(FragmentError::DanglingEventRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(FragmentDocument {
|
||||||
|
voices,
|
||||||
|
slurs,
|
||||||
|
ties,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A linear, non-recursive scan bounding `(`-nesting depth *before*
|
||||||
|
/// [`read_sexp`]'s recursive-descent reader runs — see [`MAX_FRAGMENT_NESTING_DEPTH`].
|
||||||
|
/// Parens inside a quoted string are not counted (a legitimate catalog-id
|
||||||
|
/// string, the only string-shaped leaf this grammar's leaf productions ever
|
||||||
|
/// emit, must not spuriously trip the cap); byte strings (`#x…`) never
|
||||||
|
/// contain parens by grammar, so they need no special handling.
|
||||||
|
fn check_nesting_depth(text: &str) -> Result<(), FragmentError> {
|
||||||
|
let mut depth: usize = 0;
|
||||||
|
let mut in_string = false;
|
||||||
|
let mut escaped = false;
|
||||||
|
for &b in text.as_bytes() {
|
||||||
|
if in_string {
|
||||||
|
if escaped {
|
||||||
|
escaped = false;
|
||||||
|
} else if b == b'\\' {
|
||||||
|
escaped = true;
|
||||||
|
} else if b == b'"' {
|
||||||
|
in_string = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match b {
|
||||||
|
b'"' => in_string = true,
|
||||||
|
b'(' => {
|
||||||
|
depth += 1;
|
||||||
|
if depth > MAX_FRAGMENT_NESTING_DEPTH {
|
||||||
|
return Err(FragmentError::NestingTooDeep {
|
||||||
|
limit: MAX_FRAGMENT_NESTING_DEPTH,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b')' => depth = depth.saturating_sub(1),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use epiphany_core::{AcousticPitch, AcousticRealization, TuningReference};
|
||||||
|
use epiphany_core::{
|
||||||
|
CmnNominal, PitchSpaceId, PitchSpacePosition, RationalTime, ScalePosition,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn a_pitch() -> 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 a_duration(n: i64, d: i64) -> MusicalDuration {
|
||||||
|
MusicalDuration(RationalTime::new(n, d).unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rest_event(onset: i64) -> FragmentEvent {
|
||||||
|
FragmentEvent {
|
||||||
|
onset: a_duration(onset, 4),
|
||||||
|
duration: a_duration(1, 4),
|
||||||
|
content: FragmentEventContent::Rest {
|
||||||
|
vertical_position: None,
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pitched_event(onset: i64) -> FragmentEvent {
|
||||||
|
FragmentEvent {
|
||||||
|
onset: a_duration(onset, 4),
|
||||||
|
duration: a_duration(1, 4),
|
||||||
|
content: FragmentEventContent::Pitched {
|
||||||
|
pitches: vec![FragmentPitch {
|
||||||
|
pitch: a_pitch(),
|
||||||
|
spelling_override: Some(PitchSpelling::cmn(CmnNominal::D, 4)),
|
||||||
|
}],
|
||||||
|
articulations: vec![],
|
||||||
|
dynamic: None,
|
||||||
|
ornaments: vec![],
|
||||||
|
stem: StemConfiguration,
|
||||||
|
grace: None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_small_document_round_trips_through_text() {
|
||||||
|
let document = FragmentDocument {
|
||||||
|
voices: vec![FragmentVoice {
|
||||||
|
events: vec![pitched_event(0), rest_event(1)],
|
||||||
|
}],
|
||||||
|
slurs: vec![FragmentSlur {
|
||||||
|
start: EventRef { voice: 0, event: 0 },
|
||||||
|
end: EventRef { voice: 0, event: 1 },
|
||||||
|
kind: SlurKind::Legato,
|
||||||
|
curvature_override: None,
|
||||||
|
style: SpanStyle::default(),
|
||||||
|
}],
|
||||||
|
ties: vec![FragmentTie {
|
||||||
|
start: EventRef { voice: 0, event: 0 },
|
||||||
|
end: EventRef { voice: 0, event: 1 },
|
||||||
|
class: TieClass::Standard,
|
||||||
|
style: SpanStyle::default(),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let text = encode(&document);
|
||||||
|
assert!(text.starts_with("(epiphany-fragment (0 1 0) "));
|
||||||
|
let decoded = decode(&text).expect("a well-formed fragment decodes");
|
||||||
|
assert_eq!(decoded, document);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_rejects_a_fragment_over_the_byte_cap() {
|
||||||
|
let text = "a".repeat(MAX_FRAGMENT_BYTES + 1);
|
||||||
|
assert_eq!(
|
||||||
|
decode(&text),
|
||||||
|
Err(FragmentError::TooManyBytes {
|
||||||
|
limit: MAX_FRAGMENT_BYTES,
|
||||||
|
found: text.len(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_rejects_a_fragment_over_the_event_cap() {
|
||||||
|
let document = FragmentDocument {
|
||||||
|
voices: vec![FragmentVoice {
|
||||||
|
events: (0..=MAX_FRAGMENT_EVENTS as i64).map(rest_event).collect(),
|
||||||
|
}],
|
||||||
|
slurs: vec![],
|
||||||
|
ties: vec![],
|
||||||
|
};
|
||||||
|
let text = encode(&document);
|
||||||
|
let found = MAX_FRAGMENT_EVENTS + 1;
|
||||||
|
assert_eq!(
|
||||||
|
decode(&text),
|
||||||
|
Err(FragmentError::TooManyEvents {
|
||||||
|
limit: MAX_FRAGMENT_EVENTS,
|
||||||
|
found,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_rejects_nesting_over_the_depth_cap_before_parsing() {
|
||||||
|
let text = format!(
|
||||||
|
"{}{}",
|
||||||
|
"(".repeat(MAX_FRAGMENT_NESTING_DEPTH + 1),
|
||||||
|
")".repeat(MAX_FRAGMENT_NESTING_DEPTH + 1)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
decode(&text),
|
||||||
|
Err(FragmentError::NestingTooDeep {
|
||||||
|
limit: MAX_FRAGMENT_NESTING_DEPTH,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_rejects_an_unrecognized_major_cleanly() {
|
||||||
|
// A major-1 header over a body that would not even parse under
|
||||||
|
// today's grammar (three atoms, not three lists) — proving the
|
||||||
|
// version gate rejects before any attempt to interpret the body.
|
||||||
|
let text = "(epiphany-fragment (1 0 0) x y z)";
|
||||||
|
assert_eq!(
|
||||||
|
decode(text),
|
||||||
|
Err(FragmentError::UnsupportedVersion { major: 1 })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_rejects_a_dangling_event_ref() {
|
||||||
|
let document = FragmentDocument {
|
||||||
|
voices: vec![FragmentVoice {
|
||||||
|
events: vec![rest_event(0)],
|
||||||
|
}],
|
||||||
|
slurs: vec![FragmentSlur {
|
||||||
|
start: EventRef { voice: 0, event: 0 },
|
||||||
|
end: EventRef { voice: 0, event: 5 }, // out of range
|
||||||
|
kind: SlurKind::Legato,
|
||||||
|
curvature_override: None,
|
||||||
|
style: SpanStyle::default(),
|
||||||
|
}],
|
||||||
|
ties: vec![],
|
||||||
|
};
|
||||||
|
let text = encode(&document);
|
||||||
|
assert_eq!(decode(&text), Err(FragmentError::DanglingEventRef));
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue