sim_lib_music_notation/model.rs
1use sim_kernel::{Diagnostic, Severity, SourceId, Span, Symbol};
2use sim_lib_music_core::{MusicError, Score};
3use thiserror::Error;
4
5/// Resource limits applied before and during MusicXML profile import.
6///
7/// The defaults match the public runtime example while bounding every
8/// independently amplifiable dimension of the accepted tree.
9#[derive(Copy, Clone, Debug, PartialEq, Eq)]
10pub struct MusicXmlLimits {
11 /// Maximum UTF-8 source size.
12 pub bytes: usize,
13 /// Maximum XML nodes, including text nodes.
14 pub nodes: usize,
15 /// Maximum element nesting depth.
16 pub depth: usize,
17 /// Maximum aggregate text-node bytes.
18 pub text: usize,
19 /// Maximum score parts.
20 pub parts: usize,
21 /// Maximum note/rest events across all parts.
22 pub events: usize,
23}
24
25impl Default for MusicXmlLimits {
26 fn default() -> Self {
27 Self {
28 bytes: 4_000_000,
29 nodes: 200_000,
30 depth: 64,
31 text: 1_000_000,
32 parts: 256,
33 events: 1_000_000,
34 }
35 }
36}
37
38/// Kind of stable MusicXML identity retained by an exchange report.
39#[derive(Copy, Clone, Debug, PartialEq, Eq)]
40pub enum NotationIdentityKind {
41 /// A MusicXML `part` identity.
42 Part,
43 /// A MusicXML `note` or rest-event identity.
44 Event,
45}
46
47/// Stable MusicXML identity associated with a canonical structural path.
48///
49/// `Score` remains the one music model. This record is exchange sidecar
50/// evidence used to reproduce source identifiers on a later export.
51#[derive(Clone, Debug, PartialEq, Eq)]
52pub struct NotationIdentity {
53 /// Kind of identified object.
54 pub kind: NotationIdentityKind,
55 /// Canonical zero-based path such as `part/0/event/3`.
56 pub canonical_path: String,
57 /// XML identifier retained from import or deterministically allocated by export.
58 pub xml_id: String,
59}
60
61/// Machine-readable kind of information not carried by canonical `Score`.
62#[derive(Copy, Clone, Debug, PartialEq, Eq)]
63pub enum NotationLossKind {
64 /// Clef/layout metadata has no effect on canonical score semantics.
65 Clef,
66 /// A single-part display name is not carried by a melody score body.
67 PartName,
68 /// Enharmonic source spelling is not carried by canonical chromatic pitch.
69 PitchSpelling,
70 /// A missing tempo was replaced by the profile default.
71 DefaultedTempo,
72 /// A missing meter was replaced by the profile default.
73 DefaultedTimeSignature,
74 /// A note velocity cannot be represented by the bounded MusicXML subset.
75 Velocity,
76 /// A MIDI channel cannot be represented by the bounded MusicXML subset.
77 Channel,
78}
79
80/// One explicit piece of notation information outside canonical `Score`.
81#[derive(Clone, Debug, PartialEq, Eq)]
82pub struct NotationLoss {
83 /// Stable loss classification.
84 pub kind: NotationLossKind,
85 /// Closest stable exchange path, when applicable.
86 pub canonical_path: Option<String>,
87 /// Human-readable exact reason.
88 pub detail: String,
89}
90
91/// Result of a notation operation paired with any diagnostics produced.
92///
93/// Carries the converted `value` alongside the diagnostics gathered while
94/// importing or exporting, so callers can inspect warnings without losing the
95/// successful result.
96#[derive(Clone, Debug, PartialEq, Eq)]
97pub struct NotationReport<T> {
98 /// Converted value (an exported string or an imported `Score`).
99 pub value: T,
100 /// Diagnostics gathered during the operation.
101 pub diagnostics: Vec<Diagnostic>,
102 /// Stable exchange identities retained outside the canonical score.
103 pub identities: Vec<NotationIdentity>,
104 /// Every accepted-but-unrepresentable notation fact.
105 pub losses: Vec<NotationLoss>,
106}
107
108/// Error raised while importing or exporting LilyPond-subset notation.
109#[derive(Debug, Error, Clone, PartialEq, Eq)]
110pub enum NotationError {
111 /// A duration could not be expressed in the supported note-value set.
112 #[error("unsupported duration {0}")]
113 UnsupportedDuration(String),
114 /// A music object kind has no LilyPond-subset rendering.
115 #[error("unsupported music object {0}")]
116 UnsupportedMusicObject(&'static str),
117 /// A key signature string could not be parsed.
118 #[error("invalid key signature {0}")]
119 InvalidKey(String),
120 /// The LilyPond source used syntax outside the supported subset.
121 #[error("unsupported lilypond syntax")]
122 UnsupportedSyntax {
123 /// Diagnostics describing the offending syntax.
124 diagnostics: Vec<Diagnostic>,
125 },
126 /// MusicXML used markup outside the declared partwise profile.
127 #[error("unsupported musicxml-partwise profile input")]
128 UnsupportedMusicXml {
129 /// Diagnostics describing the first rejected construct.
130 diagnostics: Vec<Diagnostic>,
131 },
132 /// A bounded MusicXML resource dimension exceeded its configured maximum.
133 #[error("musicxml {limit} limit exceeded: {actual} > {maximum}")]
134 MusicXmlLimit {
135 /// Resource dimension that exceeded its limit.
136 limit: &'static str,
137 /// Observed resource count.
138 actual: usize,
139 /// Configured maximum.
140 maximum: usize,
141 },
142 /// MusicXML bytes were not valid UTF-8.
143 #[error("musicxml source is not valid UTF-8")]
144 InvalidMusicXmlUtf8,
145 /// The reused XML parser rejected malformed input.
146 #[error("invalid musicxml: {0}")]
147 InvalidMusicXml(String),
148 /// An error surfaced from the underlying music-core model.
149 #[error(transparent)]
150 Music(#[from] MusicError),
151}
152
153/// Codec converting between a `Score` and its LilyPond-subset text rendering.
154///
155/// Acts as the stateless entry point for the notation surface; each method
156/// delegates to the import or export pipeline.
157#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
158pub struct NotationCodec;
159
160impl NotationCodec {
161 /// Exports a score to LilyPond text, returning the rendering with diagnostics.
162 pub fn export_lilypond_report(
163 &self,
164 score: &Score,
165 ) -> Result<NotationReport<String>, NotationError> {
166 crate::export::export_lilypond_report(score)
167 }
168
169 /// Exports a score to LilyPond text, discarding diagnostics.
170 pub fn export_lilypond(&self, score: &Score) -> Result<String, NotationError> {
171 crate::export::export_lilypond(score)
172 }
173
174 /// Imports a score from LilyPond text, returning the score with diagnostics.
175 pub fn import_lilypond_report(
176 &self,
177 source: &str,
178 ) -> Result<NotationReport<Score>, NotationError> {
179 crate::import::import_lilypond_report(source)
180 }
181
182 /// Imports a score from LilyPond text, discarding diagnostics.
183 pub fn import_lilypond(&self, source: &str) -> Result<Score, NotationError> {
184 crate::import::import_lilypond(source)
185 }
186
187 /// Imports the bounded MusicXML partwise profile with explicit limits.
188 pub fn import_musicxml_partwise_report(
189 &self,
190 source: &[u8],
191 limits: MusicXmlLimits,
192 ) -> Result<NotationReport<Score>, NotationError> {
193 crate::musicxml::import_musicxml_partwise_report(source, limits)
194 }
195
196 /// Exports a score through the bounded MusicXML partwise profile.
197 ///
198 /// `identities` may be the sidecar returned by a prior import; matching
199 /// canonical paths reproduce the original stable XML identifiers.
200 pub fn export_musicxml_partwise_report(
201 &self,
202 score: &Score,
203 identities: &[NotationIdentity],
204 ) -> Result<NotationReport<String>, NotationError> {
205 crate::musicxml::export_musicxml_partwise_report(score, identities)
206 }
207}
208
209pub(crate) fn error_at(message: impl Into<String>, span: Span) -> NotationError {
210 NotationError::UnsupportedSyntax {
211 diagnostics: vec![Diagnostic {
212 severity: Severity::Error,
213 message: message.into(),
214 source: Some(SourceId("notation:lilypond".to_owned())),
215 span: Some(span),
216 code: None,
217 related: Vec::new(),
218 }],
219 }
220}
221
222pub(crate) fn musicxml_error(message: impl Into<String>, span: Option<Span>) -> NotationError {
223 NotationError::UnsupportedMusicXml {
224 diagnostics: vec![Diagnostic {
225 severity: Severity::Error,
226 message: message.into(),
227 source: Some(SourceId("notation:musicxml-partwise".to_owned())),
228 span,
229 code: Some(Symbol::qualified("musicxml", "profile")),
230 related: Vec::new(),
231 }],
232 }
233}
234
235pub(crate) fn loss_diagnostic(loss: &NotationLoss) -> Diagnostic {
236 Diagnostic {
237 severity: Severity::Warning,
238 message: loss.detail.clone(),
239 source: Some(SourceId("notation:musicxml-partwise".to_owned())),
240 span: None,
241 code: Some(Symbol::qualified("musicxml", "loss")),
242 related: Vec::new(),
243 }
244}