|
| 1 | +//! Canonical level-2 text from a canonical `Score` — transport and master |
| 2 | +//! timeline (SWG-4A-03). |
| 3 | +//! |
| 4 | +//! The census (`docs/swang/exact-score-text.md`) decided every spelling |
| 5 | +//! here; this module only obeys it. Four of those decisions are load-bearing |
| 6 | +//! and easy to undo by accident, so each is marked at its site: |
| 7 | +//! |
| 8 | +//! - `MasterBar.index` is the **stored** value, never the vector position; |
| 9 | +//! - `master_bars` keeps **vector order** and is never sorted; |
| 10 | +//! - `Tempo` is written from its own reduced rational, including `7/1` and |
| 11 | +//! `100/7`, never reconstructed from a constructor's provenance; |
| 12 | +//! - `repeat` is omitted for `RepeatMarker::default()` and written |
| 13 | +//! literally for every other combination. |
| 14 | +
|
| 15 | +use griff_core::event::{TimeSignature, ValidationError}; |
| 16 | +use griff_core::score::{MasterBar, RepeatMarker, Score}; |
| 17 | +use griff_core::slice::TickRange; |
| 18 | + |
| 19 | +use super::ExactWriteError; |
| 20 | + |
| 21 | +/// The level-2 header, byte for byte (spec §1.1). |
| 22 | +const HEADER: &str = "swang 2\n"; |
| 23 | + |
| 24 | +/// Writes `score` as one canonical level-2 document. |
| 25 | +/// |
| 26 | +/// # Errors |
| 27 | +/// [`ExactWriteError::OutsideWriterDomain`] when the score violates an |
| 28 | +/// invariant `griff-core` declares, and [`ExactWriteError::NotYetWritten`] |
| 29 | +/// when it reaches a part of the tree this slice does not cover. Both are |
| 30 | +/// returned **before any byte is produced**: a partial document is not a |
| 31 | +/// thing this writer can emit. |
| 32 | +pub fn write_score(score: &Score) -> Result<String, ExactWriteError> { |
| 33 | + // Domain first, frontier second, and the order is deliberate. A score |
| 34 | + // that is both invalid and beyond this slice gets the permanent verdict, |
| 35 | + // not the temporary one: "the finished writer will refuse this too" is |
| 36 | + // strictly more informative than "not implemented yet", and reversing |
| 37 | + // the checks would hide the stronger statement behind the weaker. |
| 38 | + check_writer_domain(score)?; |
| 39 | + check_slice_frontier(score)?; |
| 40 | + |
| 41 | + let mut out = String::from(HEADER); |
| 42 | + out.push_str("\nscore {\n"); |
| 43 | + field( |
| 44 | + &mut out, |
| 45 | + " ", |
| 46 | + "ppqn", |
| 47 | + &score.ticks_per_quarter.to_string(), |
| 48 | + ); |
| 49 | + // Vector order, never sorted: the order is the semantics (§6.2 rule 2). |
| 50 | + for bar in &score.master_bars { |
| 51 | + write_master_bar(&mut out, bar); |
| 52 | + } |
| 53 | + out.push_str("}\n"); |
| 54 | + Ok(out) |
| 55 | +} |
| 56 | + |
| 57 | +/// One `<indent><word> <value>` line — the shape of every scalar field. |
| 58 | +/// |
| 59 | +/// Assembled by pushing parts rather than by `write!` or |
| 60 | +/// `push_str(&format!(…))`. The first returns a `Result` that cannot fail |
| 61 | +/// when the sink is a `String`, and pretending to handle it would be |
| 62 | +/// dishonest; the second is denied by `clippy::format_push_string`. |
| 63 | +fn field(out: &mut String, indent: &str, word: &str, value: &str) { |
| 64 | + out.push_str(indent); |
| 65 | + out.push_str(word); |
| 66 | + out.push(' '); |
| 67 | + out.push_str(value); |
| 68 | + out.push('\n'); |
| 69 | +} |
| 70 | + |
| 71 | +/// One `master_bar` block. |
| 72 | +fn write_master_bar(out: &mut String, bar: &MasterBar) { |
| 73 | + out.push_str("\n master_bar {\n"); |
| 74 | + |
| 75 | + // The *stored* index (H4). Deriving it from the enumeration position |
| 76 | + // would make a disagreeing import unrepresentable. |
| 77 | + field(out, " ", "index", &bar.index.to_string()); |
| 78 | + |
| 79 | + let ticks = format!("{}..{}", bar.tick_range.start.0, bar.tick_range.end.0); |
| 80 | + field(out, " ", "ticks", &ticks); |
| 81 | + |
| 82 | + let meter = format!( |
| 83 | + "{}/{}", |
| 84 | + bar.time_signature.numerator, bar.time_signature.denominator |
| 85 | + ); |
| 86 | + field(out, " ", "meter", &meter); |
| 87 | + |
| 88 | + // The reduced rational itself, denominator always written. `7/1` is a |
| 89 | + // tempo `from_bpm_integer` builds and `60_000_000 % 7 != 0` would |
| 90 | + // wrongly reject; writing the value rather than a reconstructed |
| 91 | + // provenance keeps that trap out of the writer entirely (H1). |
| 92 | + let tempo = format!( |
| 93 | + "{}/{}", |
| 94 | + bar.tempo.bpm_numerator(), |
| 95 | + bar.tempo.bpm_denominator() |
| 96 | + ); |
| 97 | + field(out, " ", "tempo", &tempo); |
| 98 | + |
| 99 | + // Omitted only for the model's own documented default, which |
| 100 | + // `RepeatMarker` itself defines as "no repeat barline". Every other |
| 101 | + // combination is written literally, including the degenerate |
| 102 | + // `play_count 1` that `closes()` calls false. |
| 103 | + if bar.repeat != RepeatMarker::default() { |
| 104 | + // Inline, as §6.1 spells it. A block small enough to read on one |
| 105 | + // line is written on one line; the layout is part of the canonical |
| 106 | + // text, not a formatting preference. |
| 107 | + out.push_str(" repeat { start "); |
| 108 | + out.push_str(&bar.repeat.start.to_string()); |
| 109 | + out.push_str(" play_count "); |
| 110 | + out.push_str(&bar.repeat.play_count.to_string()); |
| 111 | + out.push_str(" }\n"); |
| 112 | + } |
| 113 | + |
| 114 | + out.push_str(" }\n"); |
| 115 | +} |
| 116 | + |
| 117 | +// ── the writer domain (census §3) ────────────────────────────────────────── |
| 118 | + |
| 119 | +/// Re-checks the invariants `griff-core` already declares. |
| 120 | +/// |
| 121 | +/// Every clause delegates to the model's own checked constructor where one |
| 122 | +/// exists, so the predicate cannot drift away from the model's rules — and |
| 123 | +/// the error it carries is the model's [`ValidationError`], not a second |
| 124 | +/// small constitution written here. |
| 125 | +/// |
| 126 | +/// The constructors are advisory rather than enforced (H5: the fields are |
| 127 | +/// public), which is exactly why the writer must run them again on values |
| 128 | +/// that may never have passed through them. |
| 129 | +fn check_writer_domain(score: &Score) -> Result<(), ExactWriteError> { |
| 130 | + if score.ticks_per_quarter == 0 { |
| 131 | + return Err(ExactWriteError::OutsideWriterDomain { |
| 132 | + at: "score.ppqn", |
| 133 | + reason: ValidationError::InvalidTicksPerQuarter, |
| 134 | + }); |
| 135 | + } |
| 136 | + for bar in &score.master_bars { |
| 137 | + TickRange::new(bar.tick_range.start, bar.tick_range.end).map_err(|reason| { |
| 138 | + ExactWriteError::OutsideWriterDomain { |
| 139 | + at: "master_bar.ticks", |
| 140 | + reason, |
| 141 | + } |
| 142 | + })?; |
| 143 | + TimeSignature::new(bar.time_signature.numerator, bar.time_signature.denominator).map_err( |
| 144 | + |reason| ExactWriteError::OutsideWriterDomain { |
| 145 | + at: "master_bar.meter", |
| 146 | + reason, |
| 147 | + }, |
| 148 | + )?; |
| 149 | + } |
| 150 | + Ok(()) |
| 151 | +} |
| 152 | + |
| 153 | +// ── this slice's frontier ────────────────────────────────────────────────── |
| 154 | + |
| 155 | +/// Refuses the parts of the tree SWG-4A-03 does not write yet. |
| 156 | +/// |
| 157 | +/// Not a claim about representability: every state named here is spellable |
| 158 | +/// under the accepted grammar, and a later slice will spell it. |
| 159 | +const fn check_slice_frontier(score: &Score) -> Result<(), ExactWriteError> { |
| 160 | + if !score.tracks.is_empty() { |
| 161 | + return Err(ExactWriteError::NotYetWritten { |
| 162 | + what: "score.track", |
| 163 | + task: "SWG-4A-04", |
| 164 | + }); |
| 165 | + } |
| 166 | + if score.source_meta.is_some() { |
| 167 | + return Err(ExactWriteError::NotYetWritten { |
| 168 | + what: "score.source", |
| 169 | + task: "SWG-4A-05", |
| 170 | + }); |
| 171 | + } |
| 172 | + if !score.loss.is_clean() { |
| 173 | + return Err(ExactWriteError::NotYetWritten { |
| 174 | + what: "score.loss", |
| 175 | + task: "SWG-4A-05", |
| 176 | + }); |
| 177 | + } |
| 178 | + Ok(()) |
| 179 | +} |
0 commit comments