croma-core 1.4.0

Library-first ABC to MusicXML exporter
Documentation
//! Tempo (`Q:`) field parsing into the semantic tempo model.

use crate::diagnostic::Span;
use crate::model::{Fraction, TempoBeat, TempoBeatRole, TempoModel, gcd, gcd_u64};

/// Parse an ABC `Q:` tempo field (ABC 2.1 §3.1.8) into a structured model.
///
/// Recognised forms:
/// - `beat=bpm` (e.g. `1/4=120`) — explicit beat unit and bpm.
/// - `bpm` (a bare number, e.g. `120`) — the unit note length `L:` is the beat.
/// - `"text"` with any of the above, in any order — the quoted text is kept.
/// - `"text"` alone — no numeric tempo (writer falls back to words).
///
/// Multiple summed beat fractions (`1/4 3/8=40`) are summed into a single beat
/// fraction, matching abc2xml. Returns `None` when the field carries no numeric
/// tempo and is not a clean single quoted string, so the writer keeps emitting
/// the raw field text verbatim as words (preserving prior behavior for free
/// text such as `Q:Fast`).
pub(crate) fn parse_tempo_model(
    raw: &str,
    span: Span,
    unit_note_length: Fraction,
) -> Option<TempoModel> {
    // Extract the first quoted string as the tempo text; tempo numerics live in
    // the unquoted remainder (in any position relative to the text).
    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() {
        // No numeric tempo: only treat the field as a structured tempo when it
        // is exactly one quoted string (`Q:"Andante"`). Anything else stays raw
        // words via the writer's fallback.
        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,
    })
}

/// Whether a `Q:` field has an opening `"` with no matching close — the same
/// recovery `extract_quoted_text` performs (keep the trailing text). The caller
/// warns so the lenient recovery is never silent.
pub(crate) fn has_unterminated_quote(raw: &str) -> bool {
    match raw.find('"') {
        Some(open) => find_unescaped_quote(&raw[open + 1..]).is_none(),
        None => false,
    }
}

/// Split out the first double-quoted run as tempo text, returning the text and
/// the source with that quoted run removed.
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 {
        // Unterminated quote: treat the remainder as text, leave nothing numeric.
        return (
            Some(crate::text::decode_text_string(rest).trim().to_owned()),
            raw[..open].to_owned(),
        );
    };
    // Tempo text is an ABC 2.1 §8.2 text string; decoding also undoes the
    // `\\` and `\"` escapes that keep a quote from closing it.
    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()
}

/// Parse the numeric portion of a `Q:` field into a [`TempoBeat`].
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('=') {
        // `beat[ beat...]=bpm`. `C`/`C=bpm` means the unit note length.
        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 {
        // Bare number: the unit note length is the beat unit.
        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)
}

/// Parse the bare-number tempo form (ABC 2.1 §10.1, deprecated `Q:120`). The
/// deprecated form the spec mandates accepting is a bare INTEGER; a decimal tail
/// (`400.`, `400.5`) or a legacy suffix letter (`320s`) is outside the grammar,
/// so the STRICT parser rejects it (the field then falls through to verbatim
/// words). Repairing those forms — `Q:400.` -> `Q:400`, `Q:320s` -> `Q:320` — is
/// the job of `croma fmt --auto-fix`, not the parser. Free text such as `Q:Fast`
/// or `Q:3 dancers` is likewise rejected (no clean integer).
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))
        }
    }
}

/// Sum two beat fractions, reduced. The cross products are taken in u64 (two
/// u32 products plus a sum cannot overflow it); `None` when the reduced sum
/// still does not fit u32, so the caller treats the field as non-numeric.
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;