Skip to main content

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}