use crate::diagnostic::Span;
use crate::model::{Fraction, TempoBeat, TempoBeatRole, TempoModel};
pub(crate) fn parse_tempo_model(
raw: &str,
span: Span,
unit_note_length: Fraction,
) -> Option<TempoModel> {
let closed_quote = has_closed_quote(raw);
let (text, remainder) = extract_quoted_text(raw);
let beat = parse_tempo_beat(&remainder, unit_note_length);
if beat.is_none() {
let is_clean_quoted = raw.trim_start().starts_with('"')
&& remainder.trim().is_empty()
&& closed_quote
&& text.is_some();
if !is_clean_quoted {
return None;
}
}
Some(TempoModel {
text,
beat,
beat_role: TempoBeatRole::PrintedMetronome,
source_span: span,
})
}
pub(crate) fn has_unterminated_quote(raw: &str) -> bool {
match raw.find('"') {
Some(open) => find_unescaped_quote(&raw[open + 1..]).is_none(),
None => false,
}
}
fn extract_quoted_text(raw: &str) -> (Option<String>, String) {
let Some(open) = raw.find('"') else {
return (None, raw.to_owned());
};
let rest = &raw[open + 1..];
let Some(close_rel) = find_unescaped_quote(rest) else {
return (
Some(unescape_quoted_text(rest).trim().to_owned()),
raw[..open].to_owned(),
);
};
let text = unescape_quoted_text(&rest[..close_rel]).trim().to_owned();
let mut remainder = raw[..open].to_owned();
remainder.push(' ');
remainder.push_str(&rest[close_rel + 1..]);
let text = (!text.is_empty()).then_some(text);
(text, remainder)
}
fn find_unescaped_quote(text: &str) -> Option<usize> {
let mut escaped = false;
for (index, ch) in text.char_indices() {
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == '"' {
return Some(index);
}
}
None
}
fn has_closed_quote(raw: &str) -> bool {
raw.find('"')
.and_then(|open| find_unescaped_quote(&raw[open + 1..]))
.is_some()
}
fn unescape_quoted_text(text: &str) -> String {
let mut out = String::new();
let mut chars = text.chars();
while let Some(ch) = chars.next() {
if ch != '\\' {
out.push(ch);
continue;
}
match chars.next() {
Some(escaped @ ('"' | '\\')) => out.push(escaped),
Some(other) => {
out.push('\\');
out.push(other);
}
None => out.push('\\'),
}
}
out
}
fn parse_tempo_beat(remainder: &str, unit_note_length: Fraction) -> Option<TempoBeat> {
let trimmed = remainder.trim();
if trimmed.is_empty() {
return None;
}
if let Some((beats, bpm_part)) = trimmed.split_once('=') {
let bpm = parse_u32(bpm_part)?;
let (mut num, mut den) = (0u32, 1u32);
let mut saw_fraction = false;
for token in beats.split_whitespace() {
if token.eq_ignore_ascii_case("C") {
let (a, b) = add_fractions(
num,
den,
unit_note_length.numerator,
unit_note_length.denominator,
);
num = a;
den = b;
saw_fraction = true;
continue;
}
let (fn_num, fn_den) = parse_fraction(token)?;
let (a, b) = add_fractions(num, den, fn_num, fn_den);
num = a;
den = b;
saw_fraction = true;
}
if !saw_fraction {
return None;
}
let (num, den) = reduce_fraction(num, den);
Some(TempoBeat {
beat_numerator: num,
beat_denominator: den,
bpm,
})
} else {
let bpm = parse_bare_tempo_bpm(trimmed)?;
Some(TempoBeat {
beat_numerator: unit_note_length.numerator,
beat_denominator: unit_note_length.denominator,
bpm,
})
}
}
fn parse_u32(text: &str) -> Option<u32> {
let value = text.trim().parse::<u32>().ok()?;
(value > 0).then_some(value)
}
fn parse_bare_tempo_bpm(trimmed: &str) -> Option<u32> {
parse_u32(trimmed)
}
fn parse_fraction(token: &str) -> Option<(u32, u32)> {
match token.split_once('/') {
Some((num, den)) => {
let num = num.trim().parse::<u32>().ok()?;
let den = den.trim().parse::<u32>().ok()?;
(num > 0 && den > 0).then_some((num, den))
}
None => {
let num = token.trim().parse::<u32>().ok()?;
(num > 0).then_some((num, 1))
}
}
}
fn add_fractions(a_num: u32, a_den: u32, b_num: u32, b_den: u32) -> (u32, u32) {
if a_den == 0 {
return (b_num, b_den);
}
let num = a_num * b_den + b_num * a_den;
let den = a_den * b_den;
reduce_fraction(num, den)
}
fn reduce_fraction(num: u32, den: u32) -> (u32, u32) {
if num == 0 || den == 0 {
return (num, den.max(1));
}
let divisor = gcd(num, den);
(num / divisor, den / divisor)
}
fn gcd(mut a: u32, mut b: u32) -> u32 {
while b != 0 {
let t = b;
b = a % b;
a = t;
}
a.max(1)
}