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 middle_c = 22;
498 let mut pitches = vec![natural_at_diatonic_number(middle_c)?];
499 for number in numbers {
500 pitches.push(natural_at_diatonic_number(
501 middle_c + IntegerType::from(*number) - 1,
502 )?);
503 }
504 let spelled = Chord::new(pitches.as_slice())?;
505 let root = spelled
506 .root()
507 .ok_or_else(|| Error::Chord("figured bass column has no root".to_string()))?;
508 let distance = root.diatonic_note_number() - middle_c;
509 let count = IntegerType::from(cardinality);
510 let bass = (IntegerType::from(degree) - distance).rem_euclid(count);
511 Ok(if bass == 0 { cardinality } else { bass as u8 })
512}
513
514pub(super) fn implied_quality_from_string(
520 roman: &str,
521 suffix: &str,
522 case_matters: bool,
523) -> (ImpliedQuality, String) {
524 if let Some(rest) = suffix.strip_prefix('o') {
525 return (ImpliedQuality::Diminished, rest.to_string());
526 }
527 if let Some(rest) = suffix.strip_prefix('\u{00f8}') {
528 return (ImpliedQuality::HalfDiminished, rest.to_string());
529 }
530 if let Some(rest) = suffix.strip_prefix('+') {
531 return (ImpliedQuality::Augmented, rest.to_string());
532 }
533 let lower = suffix.to_ascii_lowercase();
534 if lower.contains("m7b5") {
535 return (ImpliedQuality::HalfDiminished, suffix.to_string());
536 }
537 if lower.contains("dim") {
538 return (ImpliedQuality::Diminished, suffix.to_string());
539 }
540 if lower.contains("aug") {
541 return (ImpliedQuality::Augmented, suffix.to_string());
542 }
543 if let Some((leading, figure)) = suffix.rsplit_once('d')
546 && matches!(
547 figure,
548 "7" | "65" | "6/5" | "43" | "4/3" | "42" | "4/2" | "2"
549 )
550 {
551 return (
552 ImpliedQuality::DominantSeventh,
553 format!("{leading}{figure}"),
554 );
555 }
556 if !case_matters {
557 return (ImpliedQuality::Unstated, suffix.to_string());
558 }
559 let quality = if roman.chars().next().is_some_and(char::is_uppercase) {
560 ImpliedQuality::Major
561 } else {
562 ImpliedQuality::Minor
563 };
564 (quality, suffix.to_string())
565}
566
567pub(super) fn sharpen_figure(figure: &mut String) {
571 if figure.contains("##") {
572 *figure = figure.replace("##8", "#8");
573 } else if figure.contains("#2") {
574 *figure = figure.replace("#2", "2");
575 } else if figure.contains("#4") {
576 *figure = figure.replace("#4", "4");
577 } else if figure.contains("#6") {
578 *figure = figure.replace("#6", "6");
579 } else {
580 *figure = figure.replace("#8", "");
581 }
582}
583
584pub fn split_roman_prefix(value: &str) -> Result<(&str, &str)> {
587 let end = value
588 .char_indices()
589 .find_map(|(idx, ch)| (!matches!(ch, 'I' | 'V' | 'X' | 'i' | 'v' | 'x')).then_some(idx))
590 .unwrap_or(value.len());
591
592 if end == 0 {
593 return Err(Error::Chord(format!("No roman numeral found in '{value}'")));
594 }
595
596 Ok((&value[..end], &value[end..]))
597}
598
599pub(super) fn roman_degree(roman: &str) -> Result<u8> {
600 match roman.to_ascii_uppercase().as_str() {
601 "I" => Ok(1),
602 "II" => Ok(2),
603 "III" => Ok(3),
604 "IV" => Ok(4),
605 "V" => Ok(5),
606 "VI" => Ok(6),
607 "VII" => Ok(7),
608 _ => Err(Error::Chord(format!("unsupported roman numeral {roman:?}"))),
609 }
610}
611
612pub(super) fn suffix_has_seventh(suffix: &str) -> bool {
613 let suffix = strip_roman_addition_groups(suffix);
614 suffix.contains('7')
615 || suffix.contains('9')
616 || suffix.contains("11")
617 || suffix.contains("13")
618 || suffix.contains("65")
619 || suffix.contains("43")
620 || suffix.contains("42")
621}
622
623pub(super) const FIGURE_SHORTHANDS: [(&str, &str); 20] = [
630 ("53", ""),
631 ("3", ""),
632 ("63", "6"),
633 ("753", "7"),
634 ("75", "7"),
635 ("73", "7"),
636 ("9753", "9"),
637 ("975", "9"),
638 ("953", "9"),
639 ("97", "9"),
640 ("95", "9"),
641 ("93", "9"),
642 ("653", "65"),
643 ("6b53", "6b5"),
644 ("643", "43"),
645 ("642", "42"),
646 ("bb7b5b3", "o7"),
647 ("b7b5b3", "\u{00f8}7"),
648 ("bb7b53", "o7"),
649 ("b7b53", "\u{00f8}7"),
650];
651
652pub(super) fn normalize_figure(figure: &str) -> &str {
654 FIGURE_SHORTHANDS
655 .iter()
656 .find(|(full, _)| *full == figure)
657 .map_or(figure, |(_, short)| *short)
658}
659
660pub(super) fn parse_inversion(suffix: &str) -> u8 {
661 let suffix = strip_roman_addition_groups(suffix);
662 let digits: String = suffix.chars().filter(char::is_ascii_digit).collect();
665
666 match normalize_figure(&digits) {
667 "42" => 3,
668 "43" | "64" => 2,
669 "65" | "6" => 1,
670 _ => 0,
671 }
672}
673
674pub(super) fn strip_roman_addition_groups(suffix: &str) -> String {
675 let mut stripped = String::with_capacity(suffix.len());
676 let mut rest = suffix;
677 while let Some(index) = rest.find("add(") {
678 stripped.push_str(&rest[..index]);
679 let addition = &rest[index + 4..];
680 let Some(end) = addition.find(')') else {
681 rest = addition;
682 continue;
683 };
684 rest = &addition[end + 1..];
685 }
686 stripped.push_str(rest);
687 stripped
688}
689
690pub(crate) fn degree_to_roman(degree: u8) -> &'static str {
691 match degree {
692 1 => "I",
693 2 => "II",
694 3 => "III",
695 4 => "IV",
696 5 => "V",
697 6 => "VI",
698 7 => "VII",
699 _ => "I",
700 }
701}