1use super::*;
6
7impl RomanNumeral {
8 pub(super) fn raise_minor_sixth_and_seventh(&mut self, column: &mut String) -> Result<()> {
17 if !matches!(self.degree, 6 | 7) {
18 return Ok(());
19 }
20 if self.scale.is_some() {
25 return Ok(());
26 }
27 if !self.case_matters {
30 return Ok(());
31 }
32 let reading = if self.degree == 6 {
33 self.sixth_minor
34 } else {
35 self.seventh_minor
36 };
37 let adjusted = adjust_minor_vi_and_vii_by_quality(
38 &self.effective_key()?,
39 reading,
40 self.implied_quality,
41 self.accidental,
42 );
43 if adjusted != self.accidental {
44 self.accidental = adjusted;
45 sharpen_figure(column);
46 }
47 Ok(())
48 }
49}
50
51pub fn adjust_minor_vi_and_vii_by_quality(
63 key: &Key,
64 reading: Minor67Default,
65 quality: ImpliedQuality,
66 accidental: i8,
67) -> i8 {
68 if key.mode() != "minor" {
69 return accidental;
70 }
71 let wants_raised = matches!(
72 quality,
73 ImpliedQuality::Minor | ImpliedQuality::Diminished | ImpliedQuality::HalfDiminished
74 );
75 let raise = match reading {
76 Minor67Default::Flat => false,
77 Minor67Default::Sharp => true,
78 Minor67Default::Quality => wants_raised,
79 Minor67Default::Cautionary => match accidental {
80 0 => wants_raised,
81 sharps if sharps >= 1 => false,
82 _ => true,
83 },
84 };
85 if raise { accidental + 1 } else { accidental }
86}
87
88pub fn split_roman_accidental_prefix(value: &str) -> (i8, &str) {
91 let mut accidental = 0;
92 let mut end = 0;
93 for (idx, ch) in value.char_indices() {
94 match ch {
95 '#' => {
96 accidental += 1;
97 end = idx + ch.len_utf8();
98 }
99 'b' | '-' => {
100 accidental -= 1;
101 end = idx + ch.len_utf8();
102 }
103 _ => break,
104 }
105 }
106 (accidental, &value[end..])
107}
108
109pub(super) fn named_figure(figure: &str, key: &Key) -> String {
116 let tonic = if key.mode() == "minor" { "i" } else { "I" };
117 match figure {
118 "N" | "N6" => "bII6".to_string(),
119 "N53" => "bII".to_string(),
120 "Cad64" => format!("{tonic}64"),
121 other => other.to_string(),
122 }
123}
124
125pub(super) fn fold_figure_symbols(figure: &str) -> String {
132 let mut folded = String::with_capacity(figure.len());
133 let mut previous: Option<char> = None;
134 for letter in figure.chars() {
135 let fixed = match letter {
136 '0' if !previous.is_some_and(|before| before.is_ascii_digit()) => 'o',
137 '\u{00ba}' | '\u{00b0}' => 'o',
138 other => other,
139 };
140 folded.push(fixed);
141 previous = Some(letter);
142 }
143 folded.replace("/o", "\u{00f8}")
144}
145
146pub(super) fn validate_figure(figure: &str) -> Result<()> {
153 let ok = figure.chars().all(|letter| {
154 letter.is_alphanumeric()
155 || matches!(
156 letter,
157 '#' | '\u{00b0}' | '+' | '-' | '/' | '[' | ']' | '(' | ')' | ' '
158 )
159 });
160 if !ok
161 || figure
162 .chars()
163 .any(|letter| matches!(letter, 'x' | 'y' | 'z'))
164 {
165 return Err(Error::Chord(format!("Invalid figure: {figure}")));
166 }
167 Ok(())
168}
169
170pub(super) const FIGURES_IMPLYING_ROOT: &[&[u8]] = &[
175 &[6],
177 &[6, 3],
178 &[6, 4],
179 &[6, 5, 3],
181 &[6, 5],
182 &[6, 4, 3],
183 &[4, 3],
184 &[6, 4, 2],
185 &[4, 2],
186 &[2],
187 &[7, 6, 5, 3],
189 &[6, 5, 4, 3],
190 &[6, 4, 3, 2],
191 &[7, 5, 3, 2],
192 &[9, 7, 6, 5, 3],
194 &[7, 6, 5, 4, 3],
195 &[9, 6, 5, 4, 3],
196 &[9, 7, 6, 4, 3],
197 &[7, 6, 5, 4, 2],
198];
199
200pub fn split_secondary(figure: &str) -> (&str, Option<String>) {
207 for (index, letter) in figure.char_indices() {
208 if letter != '/' {
209 continue;
210 }
211 let rest = &figure[index + 1..];
212 let opens = rest.chars().next().is_some_and(|next| {
213 next == '#' || (next.is_ascii_alphabetic() && next != 'o' && next != 'O')
214 });
215 if opens {
216 return (&figure[..index], Some(rest.to_string()));
217 }
218 }
219 (figure, None)
220}
221
222pub fn take_omitted_steps(figure: &mut String) -> Vec<u8> {
227 let mut steps = Vec::new();
228 let mut kept = String::with_capacity(figure.len());
229 let mut remaining = figure.as_str();
230 while let Some(start) = remaining.find("[no") {
231 let Some(end) = remaining[start..].find(']') else {
232 break;
233 };
234 let group = &remaining[start + 1..start + end];
235 for part in group.split("no") {
236 if let Ok(step) = part.trim().parse::<u8>() {
237 steps.push(if step % 7 == 0 { 7 } else { step % 7 });
238 }
239 }
240 kept.push_str(&remaining[..start]);
241 remaining = remaining[start + end + 1..].trim_start();
242 }
243 kept.push_str(remaining);
244 *figure = kept;
245 steps
246}
247
248pub fn take_added_steps(figure: &mut String) -> Vec<(i8, u8)> {
251 take_bracket_groups(figure, "[add")
252}
253
254pub fn take_bracketed_alterations(figure: &mut String) -> Vec<(i8, u8)> {
256 take_bracket_groups(figure, "[")
257}
258
259pub(super) fn take_bracket_groups(figure: &mut String, opening: &str) -> Vec<(i8, u8)> {
262 let mut groups = Vec::new();
263 let mut kept = String::with_capacity(figure.len());
264 let mut remaining = figure.as_str();
265 while let Some(start) = remaining.find(opening) {
266 let Some(end) = remaining[start..].find(']') else {
267 break;
268 };
269 let body = &remaining[start + opening.len()..start + end];
270 let digits: String = body.chars().filter(char::is_ascii_digit).collect();
271 let well_formed = !digits.is_empty()
272 && body
273 .chars()
274 .all(|letter| matches!(letter, '#' | 'b' | '-') || letter.is_ascii_digit());
275 if !well_formed {
276 kept.push_str(&remaining[..start + end + 1]);
277 remaining = &remaining[start + end + 1..];
278 continue;
279 }
280 let alter: i8 = body
281 .chars()
282 .map(|letter| match letter {
283 '#' => 1,
284 'b' | '-' => -1,
285 _ => 0,
286 })
287 .sum();
288 if let Ok(step) = digits.parse::<u8>() {
289 groups.push((alter, step));
290 }
291 kept.push_str(&remaining[..start]);
292 remaining = remaining[start + end + 1..].trim_start();
293 }
294 kept.push_str(remaining);
295 *figure = kept;
296 groups
297}
298
299pub fn expand_shorthand(shorthand: &str) -> Vec<String> {
301 let mut shorthand = shorthand.replace('/', "");
302 if shorthand == "b" || shorthand == "-" {
304 shorthand.push('3');
305 }
306 let letters: Vec<char> = shorthand.chars().collect();
307 let mut tokens = Vec::new();
308 let mut index = 0;
309 while index < letters.len() {
310 let start = index;
311 for mark in ['#', '-', 'b', 'o'] {
312 while letters.get(index) == Some(&mark) {
313 index += 1;
314 }
315 }
316 let read = match letters.get(index) {
319 Some('1') if matches!(letters.get(index + 1), Some('1' | '3' | '5')) => {
320 index += 2;
321 true
322 }
323 Some(digit) if digit.is_ascii_digit() && *digit != '0' => {
324 index += 1;
325 true
326 }
327 _ => false,
328 };
329 if read {
330 tokens.push(letters[start..index].iter().collect::<String>());
331 } else {
332 index = start + 1;
333 }
334 }
335 if tokens.len() == 1 && tokens[0].trim_start_matches(['#', '-', 'b', 'o']) == "3" {
339 tokens.insert(0, "5".to_string());
340 }
341 tokens
342}
343
344pub fn secondary_key(
350 key: &Key,
351 secondary: &str,
352 sixth_minor: Minor67Default,
353 seventh_minor: Minor67Default,
354 case_matters: bool,
355) -> Result<Key> {
356 let numeral = RomanNumeral::with_options(
357 secondary.to_string(),
358 key.clone(),
359 sixth_minor,
360 seventh_minor,
361 case_matters,
362 )?;
363 let chord = numeral.to_chord()?;
364 let root = chord
365 .root()
366 .ok_or_else(|| Error::Chord(format!("no root for secondary numeral {secondary}")))?;
367 let mode = match numeral.implied_quality {
368 ImpliedQuality::Minor => "minor",
369 ImpliedQuality::Major => "major",
370 _ if chord.semitones_from_chord_step(3) == Some(3) => "minor",
371 _ => "major",
372 };
373 Key::from_tonic_mode(&root.name(), mode)
374}
375
376pub fn parse_numeral_alone(figure: &str) -> Result<NumeralAlone> {
383 if let Some(kind) = augmented_sixth_prefix(figure) {
384 let (name, default) = match kind {
385 AugmentedSixthKind::Italian => ("It", "6"),
386 AugmentedSixthKind::French => ("Fr", "43"),
387 AugmentedSixthKind::German => ("Ger", "65"),
388 AugmentedSixthKind::Swiss => ("Sw", "43"),
389 };
390 let (degree, alteration) = match kind {
391 AugmentedSixthKind::Italian | AugmentedSixthKind::German => (4, 1),
392 AugmentedSixthKind::French => (2, 0),
393 AugmentedSixthKind::Swiss => (2, 1),
394 };
395 let rest = figure[name.len()..].trim_start_matches('+');
396 let rest = unslash_inversion(rest);
398 let first = rest.chars().next();
399 let rest = if !first.is_some_and(|digit| digit.is_ascii_digit()) {
400 format!("{default}{rest}")
401 } else if first == Some('6')
402 && kind != AugmentedSixthKind::Italian
403 && !rest
404 .chars()
405 .nth(1)
406 .is_some_and(|next| next.is_ascii_digit())
407 {
408 format!("{default}{}", &rest[1..])
409 } else {
410 rest
411 };
412 let mut bracketed = Vec::new();
413 if kind != AugmentedSixthKind::French {
414 bracketed.push((1, 1));
415 }
416 if matches!(kind, AugmentedSixthKind::French | AugmentedSixthKind::Swiss) {
417 bracketed.push((1, 3));
418 }
419 return Ok(NumeralAlone {
420 numeral: name.to_string(),
421 rest,
422 degree,
423 alteration,
424 minor: true,
425 bracketed,
426 });
427 }
428
429 let (numeral, rest) = split_roman_prefix(figure)?;
430 Ok(NumeralAlone {
431 numeral: numeral.to_string(),
432 rest: rest.to_string(),
433 degree: roman_degree(numeral)?,
434 alteration: 0,
435 minor: false,
436 bracketed: Vec::new(),
437 })
438}
439
440pub(super) fn augmented_sixth_prefix(figure: &str) -> Option<AugmentedSixthKind> {
442 for (name, kind) in [
443 ("It", AugmentedSixthKind::Italian),
444 ("Ger", AugmentedSixthKind::German),
445 ("Fr", AugmentedSixthKind::French),
446 ("Sw", AugmentedSixthKind::Swiss),
447 ] {
448 if figure.starts_with(name) {
449 return Some(kind);
450 }
451 }
452 None
453}
454
455pub(super) fn unslash_inversion(figure: &str) -> String {
457 let letters: Vec<char> = figure.chars().collect();
458 let mut out = String::with_capacity(figure.len());
459 let mut index = 0;
460 while index < letters.len() {
461 if letters[index] == '/'
462 && index > 0
463 && letters[index - 1].is_ascii_digit()
464 && letters.get(index + 1).is_some_and(char::is_ascii_digit)
465 {
466 index += 1;
467 continue;
468 }
469 out.push(letters[index]);
470 index += 1;
471 }
472 out
473}
474
475pub fn bass_scale_degree_from_notation(degree: u8, numbers: &[u8]) -> Result<u8> {
484 bass_scale_degree_from_notation_in(degree, numbers, 7)
485}
486
487pub(super) fn bass_scale_degree_from_notation_in(
490 degree: u8,
491 numbers: &[u8],
492 cardinality: u8,
493) -> Result<u8> {
494 if !FIGURES_IMPLYING_ROOT.contains(&numbers) {
495 return Ok(degree);
496 }
497 let c3 = 22;
499 let mut pitches = vec![natural_at_diatonic_number(c3)?];
500 for number in numbers {
501 pitches.push(natural_at_diatonic_number(
502 c3 + IntegerType::from(*number) - 1,
503 )?);
504 }
505 let spelled = Chord::new(pitches.as_slice())?;
506 let root = spelled
507 .root()
508 .ok_or_else(|| Error::Chord("figured bass column has no root".to_string()))?;
509 let distance = root.diatonic_note_number() - c3;
510 let count = IntegerType::from(cardinality);
511 let bass = (IntegerType::from(degree) - distance).rem_euclid(count);
512 Ok(if bass == 0 { cardinality } else { bass as u8 })
513}
514
515pub(super) fn implied_quality_from_string(
521 roman: &str,
522 suffix: &str,
523 case_matters: bool,
524) -> (ImpliedQuality, String) {
525 if let Some(rest) = suffix.strip_prefix('o') {
526 return (ImpliedQuality::Diminished, rest.to_string());
527 }
528 if let Some(rest) = suffix.strip_prefix('\u{00f8}') {
529 return (ImpliedQuality::HalfDiminished, rest.to_string());
530 }
531 if let Some(rest) = suffix.strip_prefix('+') {
532 return (ImpliedQuality::Augmented, rest.to_string());
533 }
534 let lower = suffix.to_ascii_lowercase();
535 if lower.contains("m7b5") {
536 return (ImpliedQuality::HalfDiminished, suffix.to_string());
537 }
538 if lower.contains("dim") {
539 return (ImpliedQuality::Diminished, suffix.to_string());
540 }
541 if lower.contains("aug") {
542 return (ImpliedQuality::Augmented, suffix.to_string());
543 }
544 if let Some((leading, figure)) = suffix.rsplit_once('d')
547 && matches!(
548 figure,
549 "7" | "65" | "6/5" | "43" | "4/3" | "42" | "4/2" | "2"
550 )
551 {
552 return (
553 ImpliedQuality::DominantSeventh,
554 format!("{leading}{figure}"),
555 );
556 }
557 if !case_matters {
558 return (ImpliedQuality::Unstated, suffix.to_string());
559 }
560 let quality = if roman.chars().next().is_some_and(char::is_uppercase) {
561 ImpliedQuality::Major
562 } else {
563 ImpliedQuality::Minor
564 };
565 (quality, suffix.to_string())
566}
567
568pub(super) fn sharpen_figure(figure: &mut String) {
572 if figure.contains("##") {
573 *figure = figure.replace("##8", "#8");
574 } else if figure.contains("#2") {
575 *figure = figure.replace("#2", "2");
576 } else if figure.contains("#4") {
577 *figure = figure.replace("#4", "4");
578 } else if figure.contains("#6") {
579 *figure = figure.replace("#6", "6");
580 } else {
581 *figure = figure.replace("#8", "");
582 }
583}
584
585pub fn split_roman_prefix(value: &str) -> Result<(&str, &str)> {
589 let first = numeral_length(value);
590 if first == 0 {
591 return Err(Error::Chord(format!("No roman numeral found in '{value}'")));
592 }
593 let rest = &value[first..];
594 if rest.starts_with(['I', 'V', 'X', 'i', 'v']) {
595 return Err(Error::Chord(format!(
596 "No roman numeral for a scale degree found in '{value}'"
597 )));
598 }
599 Ok((&value[..first], rest))
600}
601
602fn numeral_length(text: &str) -> usize {
605 let bytes = text.as_bytes();
606 let count = |from: usize, letter: u8, most: usize| {
607 bytes[from..]
608 .iter()
609 .take(most)
610 .take_while(|byte| **byte == letter)
611 .count()
612 };
613 for (one, five) in [(b'I', b'V'), (b'i', b'v')] {
614 if bytes.starts_with(&[one, five]) {
615 return 2;
616 }
617 let ones = count(0, one, 3);
618 if ones > 0 {
619 return ones;
620 }
621 if bytes.first() == Some(&five) {
622 return 1 + count(1, one, 2);
623 }
624 }
625 0
626}
627
628pub(super) fn roman_degree(roman: &str) -> Result<u8> {
629 match roman.to_ascii_uppercase().as_str() {
630 "I" => Ok(1),
631 "II" => Ok(2),
632 "III" => Ok(3),
633 "IV" => Ok(4),
634 "V" => Ok(5),
635 "VI" => Ok(6),
636 "VII" => Ok(7),
637 _ => Err(Error::Chord(format!("unsupported roman numeral {roman:?}"))),
638 }
639}
640
641pub(super) fn suffix_has_seventh(suffix: &str) -> bool {
642 let suffix = strip_roman_addition_groups(suffix);
643 suffix.contains('7')
644 || suffix.contains('9')
645 || suffix.contains("11")
646 || suffix.contains("13")
647 || suffix.contains("65")
648 || suffix.contains("43")
649 || suffix.contains("42")
650}
651
652pub(super) const FIGURE_SHORTHANDS: [(&str, &str); 20] = [
659 ("53", ""),
660 ("3", ""),
661 ("63", "6"),
662 ("753", "7"),
663 ("75", "7"),
664 ("73", "7"),
665 ("9753", "9"),
666 ("975", "9"),
667 ("953", "9"),
668 ("97", "9"),
669 ("95", "9"),
670 ("93", "9"),
671 ("653", "65"),
672 ("6b53", "6b5"),
673 ("643", "43"),
674 ("642", "42"),
675 ("bb7b5b3", "o7"),
676 ("b7b5b3", "\u{00f8}7"),
677 ("bb7b53", "o7"),
678 ("b7b53", "\u{00f8}7"),
679];
680
681pub(super) fn normalize_figure(figure: &str) -> &str {
683 FIGURE_SHORTHANDS
684 .iter()
685 .find(|(full, _)| *full == figure)
686 .map_or(figure, |(_, short)| *short)
687}
688
689pub(super) fn parse_inversion(suffix: &str) -> u8 {
690 let suffix = strip_roman_addition_groups(suffix);
691 let digits: String = suffix.chars().filter(char::is_ascii_digit).collect();
694
695 match normalize_figure(&digits) {
696 "42" => 3,
697 "43" | "64" => 2,
698 "65" | "6" => 1,
699 _ => 0,
700 }
701}
702
703pub(super) fn strip_roman_addition_groups(suffix: &str) -> String {
704 let mut stripped = String::with_capacity(suffix.len());
705 let mut rest = suffix;
706 while let Some(index) = rest.find("add(") {
707 stripped.push_str(&rest[..index]);
708 let addition = &rest[index + 4..];
709 let Some(end) = addition.find(')') else {
710 rest = addition;
711 continue;
712 };
713 rest = &addition[end + 1..];
714 }
715 stripped.push_str(rest);
716 stripped
717}
718
719pub(crate) fn degree_to_roman(degree: u8) -> &'static str {
720 match degree {
721 1 => "I",
722 2 => "II",
723 3 => "III",
724 4 => "IV",
725 5 => "V",
726 6 => "VI",
727 7 => "VII",
728 _ => "I",
729 }
730}