use crate::diagnostic::Span;
use crate::model::{Fraction, TempoBeat, TempoBeatRole, TempoModel, gcd, gcd_u64};
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(crate::text::decode_text_string(rest).trim().to_owned()),
raw[..open].to_owned(),
);
};
let text = crate::text::decode_text_string(&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 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) -> Option<(u32, u32)> {
if a_den == 0 {
return Some((b_num, b_den));
}
let num = u64::from(a_num) * u64::from(b_den) + u64::from(b_num) * u64::from(a_den);
let den = u64::from(a_den) * u64::from(b_den);
let divisor = gcd_u64(num, den);
Some((
u32::try_from(num / divisor).ok()?,
u32::try_from(den / divisor).ok()?,
))
}
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)
}
#[cfg(test)]
#[path = "tempo_tests.rs"]
mod tests;