1use crate::music::MusicError;
29use crate::music::key::Mode;
30
31#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
33pub enum Quality {
34 Major,
36 Minor,
38 Diminished,
40 HalfDiminished,
42 Augmented,
44}
45
46#[derive(Clone, Debug, PartialEq, Eq)]
48pub struct Numeral {
49 pub offset: u8,
51 pub quality: Quality,
53 pub extension: String,
55 pub of: Option<u8>,
57}
58
59#[derive(Clone, Debug, PartialEq, Eq)]
61pub struct Line {
62 pub numerals: Vec<Numeral>,
64 pub mode: Mode,
66 pub assumed: bool,
69}
70
71const MAJOR: [u8; 7] = [0, 2, 4, 5, 7, 9, 11];
72const MINOR: [u8; 7] = [0, 2, 3, 5, 7, 8, 10];
73const DEGREES: [&str; 7] = ["vii", "vi", "iv", "v", "iii", "ii", "i"];
74
75pub fn parse_line(text: &str, mode: Option<Mode>) -> Result<Line, MusicError> {
82 let parts: Vec<&str> = text
83 .split(|c: char| matches!(c, '-' | '–' | ',') || c.is_whitespace())
84 .filter(|part| !part.is_empty())
85 .collect();
86 if parts.is_empty() {
87 return Err(MusicError::UnknownNumeral(text.to_owned()));
88 }
89 let (mode, assumed) = match mode {
90 Some(mode) => (mode, false),
91 None if names_minor(&parts) => (Mode::Minor, false),
92 None => (Mode::Major, true),
93 };
94 let numerals = parts
95 .iter()
96 .map(|part| parse_numeral(part, mode))
97 .collect::<Result<_, _>>()?;
98 Ok(Line {
99 numerals,
100 mode,
101 assumed,
102 })
103}
104
105fn names_minor(parts: &[&str]) -> bool {
108 fn chord<'a>(part: &&'a str) -> Option<Parsed<'a>> {
109 read(part.split('/').next().unwrap_or(part))
110 }
111 let Some(first) = parts.first().and_then(chord) else {
112 return false;
113 };
114 if first.degree == 0 && first.accidental == 0 {
115 return first.lower;
116 }
117 parts.iter().filter_map(chord).any(|numeral| {
118 numeral.accidental == 0
119 && !numeral.lower
120 && numeral.sign.is_none()
121 && matches!(numeral.degree, 2 | 5 | 6)
122 })
123}
124
125pub fn parse_numeral(text: &str, mode: Mode) -> Result<Numeral, MusicError> {
131 let unknown = || MusicError::UnknownNumeral(text.to_owned());
132 if let Some((chord, target)) = text.split_once('/')
133 && read(target).is_some()
134 {
135 let target = parse_numeral(target, mode)?;
136 if target.of.is_some() {
137 return Err(unknown());
138 }
139 let chord = parse_numeral(chord, Mode::Major)?;
140 if chord.of.is_some() || chord.offset == 0 {
141 return Err(unknown());
142 }
143 return Ok(Numeral {
144 offset: (target.offset + chord.offset) % 12,
145 of: Some(target.offset),
146 ..chord
147 });
148 }
149 let parsed = read(text).ok_or_else(unknown)?;
150 let quality = match parsed.sign {
151 Some('°' | 'o') => Quality::Diminished,
152 Some('ø') => Quality::HalfDiminished,
153 Some('+') => Quality::Augmented,
154 _ if parsed.lower => Quality::Minor,
155 _ => Quality::Major,
156 };
157 let minor_line = mode == Mode::Minor;
158 let leading_tone = matches!(quality, Quality::Diminished | Quality::HalfDiminished);
159 let offset = match parsed.accidental {
160 0 if minor_line && parsed.degree == 6 && leading_tone => 11,
161 0 if minor_line => MINOR[parsed.degree],
162 step => {
163 let base = i16::from(MAJOR[parsed.degree]) + i16::from(step);
164 u8::try_from(base.rem_euclid(12)).unwrap_or(0)
165 }
166 };
167 Ok(Numeral {
168 offset,
169 quality,
170 extension: parsed.extension.to_owned(),
171 of: None,
172 })
173}
174
175struct Parsed<'a> {
176 accidental: i8,
177 degree: usize,
178 lower: bool,
179 sign: Option<char>,
180 extension: &'a str,
181}
182
183fn read(text: &str) -> Option<Parsed<'_>> {
184 let (accidental, rest) = match text.chars().next()? {
185 'b' | '♭' => (-1, &text[text.chars().next()?.len_utf8()..]),
186 '#' | '♯' => (1, &text[text.chars().next()?.len_utf8()..]),
187 _ => (0, text),
188 };
189 let lower_rest = rest.to_ascii_lowercase();
190 let (index, name) = DEGREES
191 .iter()
192 .enumerate()
193 .find(|(_, name)| lower_rest.starts_with(*name))?;
194 let numeral = &rest[..name.len()];
195 let lower = numeral.chars().all(|c| c.is_ascii_lowercase());
196 if !lower && !numeral.chars().all(|c| c.is_ascii_uppercase()) {
197 return None;
198 }
199 let after = &rest[name.len()..];
200 let sign = after
201 .chars()
202 .next()
203 .filter(|c| matches!(c, '°' | 'o' | 'ø' | '+'));
204 let extension = &after[sign.map_or(0, char::len_utf8)..];
205 let known_start = extension.is_empty()
206 || extension.starts_with(|c: char| c.is_ascii_digit() || matches!(c, '#' | 'b'))
207 || ["maj", "sus", "add"]
208 .iter()
209 .any(|word| extension.starts_with(word));
210 let known_chars = extension
213 .replace("6/9", "")
214 .chars()
215 .all(|c| c.is_ascii_alphanumeric() || c == '#');
216 if !known_start || !known_chars {
217 return None;
218 }
219 let degree = match index {
220 0 => 6,
221 1 => 5,
222 2 => 3,
223 3 => 4,
224 4 => 2,
225 5 => 1,
226 _ => 0,
227 };
228 Some(Parsed {
229 accidental,
230 degree,
231 lower,
232 sign,
233 extension,
234 })
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240
241 fn offsets(line: &str) -> Vec<u8> {
242 parse_line(line, None)
243 .unwrap_or_else(|error| panic!("{line}: {error}"))
244 .numerals
245 .iter()
246 .map(|numeral| numeral.offset)
247 .collect()
248 }
249
250 #[test]
251 fn reads_the_major_and_minor_degrees() {
252 assert_eq!(offsets("I ii iii IV V vi vii°"), [0, 2, 4, 5, 7, 9, 11]);
253 assert_eq!(offsets("i ii° III iv v VI VII"), [0, 2, 3, 5, 7, 8, 10]);
254 assert_eq!(offsets("bII bIII bVI bVII"), [1, 3, 8, 10]);
255 }
256
257 #[test]
258 fn a_line_is_minor_only_when_it_says_so() {
259 assert_eq!(offsets("vi-IV-I-V"), [9, 5, 0, 7]);
260 assert_eq!(offsets("iii-vi-ii-V-I"), [4, 9, 2, 7, 0]);
261 assert_eq!(offsets("vi-V-IV-V"), [9, 7, 5, 7]);
262 assert_eq!(offsets("IV-V-iii-vi"), [5, 7, 4, 9]);
263 assert_eq!(offsets("VI-VII-i-i"), [8, 10, 0, 0]);
264 assert_eq!(offsets("iv-VII-III-VI"), [5, 10, 3, 8]);
265 assert!(parse_line("vi-IV-I-V", None).unwrap().assumed);
266 for (line, offsets_read) in [
267 ("I-VI-ii-V", [0, 9, 2, 7]),
268 ("I-VI7-ii7-V7", [0, 9, 2, 7]),
269 ("I-V-VI-IV", [0, 7, 9, 5]),
270 ] {
271 assert_eq!(offsets(line), offsets_read, "{line}");
272 let parsed = parse_line(line, None).unwrap();
273 assert_eq!((parsed.mode, parsed.assumed), (Mode::Major, true), "{line}");
274 }
275 assert_eq!(offsets("I-III7-vi"), [0, 4, 9]);
276 assert!(parse_line("I-III7-vi", None).unwrap().assumed);
277 assert!(!parse_line("i-iv-v", None).unwrap().assumed);
278 let given = parse_line("vi-IV-I-V", Some(Mode::Minor)).unwrap();
279 assert_eq!((given.mode, given.assumed), (Mode::Minor, false));
280 assert_eq!(given.numerals[0].offset, 8);
281 }
282
283 #[test]
284 fn both_minor_spellings_name_the_same_chords() {
285 assert_eq!(offsets("i-VI-III-VII"), offsets("i-bVI-bIII-bVII"));
286 assert_eq!(offsets("I-bVII-IV"), [0, 10, 5]);
287 assert_eq!(offsets("bII-i"), [1, 0]);
288 assert_eq!(offsets("i-iv-V"), [0, 5, 7]);
289 assert_eq!(offsets("i vii°"), [0, 11]);
290 assert_eq!(offsets("i viiø7"), [0, 11]);
291 }
292
293 #[test]
294 fn reads_secondary_chords() {
295 let line = parse_line("I V7/V V", None).unwrap();
296 assert_eq!(line.numerals[1].offset, 2);
297 assert_eq!(line.numerals[1].of, Some(7));
298 assert_eq!(line.numerals[1].extension, "7");
299 assert_eq!(offsets("I V/vi vi"), [0, 4, 9]);
300 assert_eq!(offsets("I viiø7/V V"), [0, 6, 7]);
301 assert_eq!(
302 parse_line("I6/9", None).unwrap().numerals[0].extension,
303 "6/9"
304 );
305 for text in ["V7/X", "V/V/V", "I7/9", "V/", "I/V", "i/iv"] {
306 assert!(parse_line(text, None).is_err(), "{text}");
307 }
308 }
309
310 #[test]
311 fn keeps_quality_and_extension() {
312 let line = parse_line("ii7-V7-Imaj7 iiø7", None).unwrap();
313 let read: Vec<(u8, Quality, &str)> = line
314 .numerals
315 .iter()
316 .map(|numeral| (numeral.offset, numeral.quality, numeral.extension.as_str()))
317 .collect();
318 assert_eq!(
319 read,
320 [
321 (2, Quality::Minor, "7"),
322 (7, Quality::Major, "7"),
323 (0, Quality::Major, "maj7"),
324 (2, Quality::HalfDiminished, "7"),
325 ]
326 );
327 assert_eq!(
328 parse_line("III+", None).unwrap().numerals[0].quality,
329 Quality::Augmented
330 );
331 }
332
333 #[test]
334 fn rejects_text_that_is_no_numeral() {
335 for text in ["", "X", "Iv", "VIII", "IVx", "C-G-Am"] {
336 assert!(parse_line(text, None).is_err(), "{text}");
337 }
338 }
339}