Skip to main content

use_note/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7pub mod prelude {
8    pub use crate::{
9        Accidental, EnharmonicSpelling, NaturalNote, NoteClass, NoteError, NoteLetter, NoteName,
10        NoteSpelling, Octave, ScientificPitchNotation,
11    };
12}
13
14#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
15pub enum NoteLetter {
16    A,
17    B,
18    C,
19    D,
20    E,
21    F,
22    G,
23}
24
25impl NoteLetter {
26    pub const ALL: &'static [Self] = &[
27        Self::A,
28        Self::B,
29        Self::C,
30        Self::D,
31        Self::E,
32        Self::F,
33        Self::G,
34    ];
35
36    pub const fn as_str(self) -> &'static str {
37        match self {
38            Self::A => "A",
39            Self::B => "B",
40            Self::C => "C",
41            Self::D => "D",
42            Self::E => "E",
43            Self::F => "F",
44            Self::G => "G",
45        }
46    }
47}
48
49impl fmt::Display for NoteLetter {
50    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
51        formatter.write_str(self.as_str())
52    }
53}
54
55impl FromStr for NoteLetter {
56    type Err = NoteError;
57
58    fn from_str(value: &str) -> Result<Self, Self::Err> {
59        match value.trim().to_ascii_uppercase().as_str() {
60            "A" => Ok(Self::A),
61            "B" => Ok(Self::B),
62            "C" => Ok(Self::C),
63            "D" => Ok(Self::D),
64            "E" => Ok(Self::E),
65            "F" => Ok(Self::F),
66            "G" => Ok(Self::G),
67            _ => Err(NoteError::InvalidFormat),
68        }
69    }
70}
71
72#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
73pub enum Accidental {
74    Natural,
75    Sharp,
76    Flat,
77    DoubleSharp,
78    DoubleFlat,
79}
80
81impl Accidental {
82    pub const ALL: &'static [Self] = &[
83        Self::Natural,
84        Self::Sharp,
85        Self::Flat,
86        Self::DoubleSharp,
87        Self::DoubleFlat,
88    ];
89
90    pub const fn as_str(self) -> &'static str {
91        match self {
92            Self::Natural => "",
93            Self::Sharp => "#",
94            Self::Flat => "b",
95            Self::DoubleSharp => "##",
96            Self::DoubleFlat => "bb",
97        }
98    }
99}
100
101impl fmt::Display for Accidental {
102    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
103        formatter.write_str(self.as_str())
104    }
105}
106
107#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
108pub struct Octave(i8);
109
110impl Octave {
111    pub const MIN: i8 = -1;
112    pub const MAX: i8 = 10;
113
114    pub fn new(value: i8) -> Result<Self, NoteError> {
115        if !(Self::MIN..=Self::MAX).contains(&value) {
116            return Err(NoteError::OutOfRange);
117        }
118
119        Ok(Self(value))
120    }
121
122    pub const fn value(self) -> i8 {
123        self.0
124    }
125}
126
127impl fmt::Display for Octave {
128    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
129        self.0.fmt(formatter)
130    }
131}
132
133impl FromStr for Octave {
134    type Err = NoteError;
135
136    fn from_str(value: &str) -> Result<Self, Self::Err> {
137        let parsed = value
138            .trim()
139            .parse::<i8>()
140            .map_err(|_| NoteError::InvalidFormat)?;
141        Self::new(parsed)
142    }
143}
144
145impl TryFrom<i8> for Octave {
146    type Error = NoteError;
147
148    fn try_from(value: i8) -> Result<Self, Self::Error> {
149        Self::new(value)
150    }
151}
152
153#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
154pub struct NoteName {
155    value: String,
156    letter: NoteLetter,
157    accidental: Accidental,
158    octave: Option<Octave>,
159}
160
161pub type NoteSpelling = NoteName;
162pub type ScientificPitchNotation = NoteName;
163pub type NaturalNote = NoteLetter;
164
165#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
166pub struct EnharmonicSpelling {
167    preferred: NoteName,
168    alternate: NoteName,
169}
170
171impl EnharmonicSpelling {
172    pub const fn new(preferred: NoteName, alternate: NoteName) -> Self {
173        Self {
174            preferred,
175            alternate,
176        }
177    }
178
179    pub const fn preferred(&self) -> &NoteName {
180        &self.preferred
181    }
182
183    pub const fn alternate(&self) -> &NoteName {
184        &self.alternate
185    }
186}
187
188impl NoteName {
189    pub fn new(value: impl AsRef<str>) -> Result<Self, NoteError> {
190        value.as_ref().parse()
191    }
192
193    pub fn as_str(&self) -> &str {
194        &self.value
195    }
196
197    pub fn value(&self) -> &str {
198        self.as_str()
199    }
200
201    pub const fn letter(&self) -> NoteLetter {
202        self.letter
203    }
204
205    pub const fn accidental(&self) -> Accidental {
206        self.accidental
207    }
208
209    pub const fn octave(&self) -> Option<Octave> {
210        self.octave
211    }
212
213    pub const fn note_class(&self) -> NoteClass {
214        match self.accidental {
215            Accidental::Natural => NoteClass::Natural,
216            Accidental::Sharp | Accidental::Flat => NoteClass::Chromatic,
217            Accidental::DoubleSharp | Accidental::DoubleFlat => NoteClass::Enharmonic,
218        }
219    }
220}
221
222impl AsRef<str> for NoteName {
223    fn as_ref(&self) -> &str {
224        self.as_str()
225    }
226}
227
228impl fmt::Display for NoteName {
229    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
230        formatter.write_str(self.as_str())
231    }
232}
233
234impl FromStr for NoteName {
235    type Err = NoteError;
236
237    fn from_str(value: &str) -> Result<Self, Self::Err> {
238        let trimmed = value.trim();
239        if trimmed.is_empty() {
240            return Err(NoteError::Empty);
241        }
242
243        let mut chars = trimmed.chars();
244        let letter_char = chars.next().ok_or(NoteError::Empty)?;
245        let letter = NoteLetter::from_str(&letter_char.to_string())?;
246        let rest: String = chars.collect();
247        let (accidental_text, octave_text) = split_accidental_and_octave(&rest)?;
248        let accidental = parse_accidental(accidental_text)?;
249        let octave = if octave_text.is_empty() {
250            None
251        } else {
252            Some(octave_text.parse::<Octave>()?)
253        };
254
255        Ok(Self {
256            value: format!(
257                "{}{}{}",
258                letter,
259                accidental,
260                octave.map_or_else(String::new, |value| value.to_string())
261            ),
262            letter,
263            accidental,
264            octave,
265        })
266    }
267}
268
269impl TryFrom<&str> for NoteName {
270    type Error = NoteError;
271
272    fn try_from(value: &str) -> Result<Self, Self::Error> {
273        value.parse()
274    }
275}
276
277#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
278pub enum NoteClass {
279    Natural,
280    Chromatic,
281    Enharmonic,
282}
283
284fn split_accidental_and_octave(value: &str) -> Result<(&str, &str), NoteError> {
285    let octave_start = value
286        .char_indices()
287        .find_map(|(index, character)| {
288            (character == '-' || character.is_ascii_digit()).then_some(index)
289        })
290        .unwrap_or(value.len());
291    let accidental = &value[..octave_start];
292    let octave = &value[octave_start..];
293
294    if !octave.is_empty() && octave == "-" {
295        return Err(NoteError::InvalidFormat);
296    }
297
298    Ok((accidental, octave))
299}
300
301fn parse_accidental(value: &str) -> Result<Accidental, NoteError> {
302    match value {
303        "" => Ok(Accidental::Natural),
304        "#" => Ok(Accidental::Sharp),
305        "b" => Ok(Accidental::Flat),
306        "##" | "x" => Ok(Accidental::DoubleSharp),
307        "bb" => Ok(Accidental::DoubleFlat),
308        _ => Err(NoteError::InvalidFormat),
309    }
310}
311
312#[derive(Clone, Copy, Debug, Eq, PartialEq)]
313pub enum NoteError {
314    Empty,
315    InvalidFormat,
316    OutOfRange,
317}
318
319impl fmt::Display for NoteError {
320    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
321        match self {
322            Self::Empty => formatter.write_str("note spelling cannot be empty"),
323            Self::InvalidFormat => formatter.write_str("note spelling has an invalid format"),
324            Self::OutOfRange => formatter.write_str("octave is out of range"),
325        }
326    }
327}
328
329impl Error for NoteError {}
330
331#[cfg(test)]
332#[allow(
333    unused_imports,
334    clippy::unnecessary_wraps,
335    clippy::assertions_on_constants
336)]
337mod tests {
338    use super::{Accidental, NoteClass, NoteError, NoteLetter, NoteName, Octave};
339
340    #[test]
341    fn parses_common_note_spellings() -> Result<(), NoteError> {
342        for spelling in ["C", "C#", "Db", "F##", "Gbb", "A4", "C#4"] {
343            assert_eq!(spelling.parse::<NoteName>()?.to_string(), spelling);
344        }
345
346        let note = NoteName::new(" C#4 ")?;
347        assert_eq!(note.letter(), NoteLetter::C);
348        assert_eq!(note.accidental(), Accidental::Sharp);
349        assert_eq!(note.octave(), Some(Octave::new(4)?));
350        assert_eq!(note.note_class(), NoteClass::Chromatic);
351        Ok(())
352    }
353
354    #[test]
355    fn validates_octave_range() {
356        assert_eq!(Octave::new(-1).map(Octave::value), Ok(-1));
357        assert_eq!(Octave::new(10).map(Octave::value), Ok(10));
358        assert_eq!(Octave::new(-2), Err(NoteError::OutOfRange));
359        assert_eq!(Octave::new(11), Err(NoteError::OutOfRange));
360    }
361
362    #[test]
363    fn rejects_invalid_spellings() {
364        assert_eq!(NoteName::new(""), Err(NoteError::Empty));
365        assert_eq!(NoteName::new("H"), Err(NoteError::InvalidFormat));
366        assert_eq!(NoteName::new("C###"), Err(NoteError::InvalidFormat));
367        assert_eq!(NoteName::new("C11"), Err(NoteError::OutOfRange));
368    }
369}