croma-core 1.4.0

Library-first ABC to MusicXML exporter
Documentation
//! Key-signature (`K:`) field parsing.

use super::misc::tokens_with_spans;
use super::voice::parse_voice_properties;
use super::*;
use crate::diagnostic::Span;

pub(crate) fn parse_key(value: &str, value_span: Span) -> KeySignature {
    let trimmed = value.trim();
    if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("none") {
        return KeySignature {
            raw: trimmed.to_owned(),
            tonic: None,
            mode: KeyMode::None,
            accidentals: Vec::new(),
            explicit: false,
            compact_accidentals_ignored: false,
            tonic_trailing_junk_ignored: false,
            properties: VoiceProperties::default(),
        };
    }

    if trimmed == "HP" || trimmed == "Hp" {
        return KeySignature {
            raw: trimmed.to_owned(),
            tonic: None,
            mode: if trimmed == "HP" {
                KeyMode::HighlandPipes
            } else {
                KeyMode::HighlandPipesMarked
            },
            accidentals: Vec::new(),
            explicit: false,
            compact_accidentals_ignored: false,
            tonic_trailing_junk_ignored: false,
            properties: VoiceProperties::default(),
        };
    }

    let (tokens, compact_accidentals_ignored) = key_tokens_with_spans(trimmed, value_span.start);
    let mut tonic = None;
    let mut mode = KeyMode::Major;
    let mut explicit = false;
    let mut tonic_trailing_junk_ignored = false;
    let mut token_start = 0;

    if let Some(first) = tokens.first()
        && let Some((parsed_tonic, inline_mode, junk_ignored)) = parse_tonic_token(&first.value)
    {
        tonic = Some(parsed_tonic);
        if let Some(inline_mode) = inline_mode {
            mode = inline_mode;
            explicit = mode == KeyMode::Explicit;
        }
        tonic_trailing_junk_ignored = junk_ignored;
        token_start = 1;
    }

    if let Some(token) = tokens.get(token_start)
        && let Some(parsed_mode) = parse_key_mode(&token.value)
    {
        explicit = parsed_mode == KeyMode::Explicit;
        mode = parsed_mode;
        token_start += 1;
    }

    let accidentals = tokens[token_start..]
        .iter()
        .flat_map(parse_key_accidentals)
        .collect();

    // ABC 2.1 §4.6: a K: field may also carry clef/octave/middle/transpose
    // modifiers (`K:C treble+8`, `K: Dm octave=1`). The tonic/mode tokens are
    // already consumed, so parse the remainder with the voice-property parser.
    // Accidental tokens (`^f`, `_B`) carry no `=` and match no clef shorthand,
    // so they fall harmlessly into `other`.
    let properties = if let Some(first_remaining) = tokens.get(token_start) {
        let remainder_start = first_remaining.span.start;
        let remainder_offset = remainder_start.saturating_sub(value_span.start);
        let remainder = trimmed.get(remainder_offset..).unwrap_or("");
        parse_voice_properties(&Spanned::new(remainder.to_owned(), {
            Span::new(remainder_start, value_span.end)
        }))
    } else {
        VoiceProperties::default()
    };

    KeySignature {
        raw: trimmed.to_owned(),
        tonic,
        mode,
        accidentals,
        explicit,
        compact_accidentals_ignored,
        tonic_trailing_junk_ignored,
        properties,
    }
}

fn key_tokens_with_spans(value: &str, offset: usize) -> (Vec<Spanned<String>>, bool) {
    let tokens = tokens_with_spans(value, offset);
    let Some(first) = tokens.first() else {
        return (tokens, false);
    };
    let Some(mut expanded_first) = split_compact_key_accidentals(first) else {
        return (tokens, false);
    };

    let mut expanded = Vec::with_capacity(expanded_first.len() + tokens.len().saturating_sub(1));
    expanded.append(&mut expanded_first);
    expanded.extend(tokens.into_iter().skip(1));
    (expanded, true)
}

/// Detect a tonic immediately followed — with NO space — by a run of accidental
/// tokens (`K:D^f`, `K:A=G`). ABC 2.1 §3.1.14 requires modifying accidentals to be
/// "separated by spaces" (`K:D ^f`, `K:D =c`); only the *mode* may be written
/// space-less (`K:F#Mix`). A space-less accidental run is therefore not a valid
/// accidental list: the strict parser keeps only the valid leading tonic (returned
/// as the single first token) and the caller flags the dropped run so lowering can
/// emit `abc.field.key.compact_accidentals_ignored`. Inserting the missing space
/// (`K:D^f` -> `K:D ^f`) is a job for `fmt --auto-fix`, not the strict parser.
///
/// Returns `None` when the first token is not this tonic+compact-accidental shape,
/// leaving the normal space-delimited tokens untouched.
fn split_compact_key_accidentals(token: &Spanned<String>) -> Option<Vec<Spanned<String>>> {
    let value = token.value.as_str();
    for (tail_start, _) in value.char_indices().skip(1) {
        if compact_key_accidental_len(&value[tail_start..]).is_none() {
            continue;
        }
        let tonic = &value[..tail_start];
        if parse_tonic_token(tonic).is_none() {
            continue;
        }
        if !is_compact_key_accidental_tail(&value[tail_start..]) {
            continue;
        }
        return Some(vec![Spanned::new(
            tonic.to_owned(),
            Span::new(token.span.start, token.span.start + tail_start),
        )]);
    }
    None
}

fn is_compact_key_accidental_tail(value: &str) -> bool {
    let mut start = 0;
    while start < value.len() {
        let Some(token_len) = compact_key_accidental_len(&value[start..]) else {
            return false;
        };
        start += token_len;
    }
    true
}

fn compact_key_accidental_len(value: &str) -> Option<usize> {
    let sign_len = if value.starts_with("__") || value.starts_with("^^") {
        2
    } else if value.starts_with('_') || value.starts_with('^') || value.starts_with('=') {
        1
    } else {
        return None;
    };
    let note = value[sign_len..].chars().next()?;
    if !matches!(note.to_ascii_uppercase(), 'A'..='G') {
        return None;
    }
    Some(sign_len + note.len_utf8())
}

/// Parse a leading key tonic. Returns the tonic, any inline mode suffix, and
/// whether trailing non-alphabetic junk was discarded to recover it (`K:Bb,`),
/// so the caller can flag that recovery.
fn parse_tonic_token(token: &str) -> Option<(KeyTonic, Option<KeyMode>, bool)> {
    let mut chars = token.char_indices();
    let (_, first) = chars.next()?;
    if !matches!(first.to_ascii_uppercase(), 'A'..='G') {
        return None;
    }

    let mut accidental = None;
    let mut mode_start = first.len_utf8();
    if let Some((offset, ch)) = chars.next() {
        match ch {
            '#' => {
                accidental = Some(KeyTonicAccidental::Sharp);
                mode_start = offset + ch.len_utf8();
            }
            'b' => {
                accidental = Some(KeyTonicAccidental::Flat);
                mode_start = offset + ch.len_utf8();
            }
            _ => {
                mode_start = offset;
            }
        }
    }

    // ABC 2.1 §3.1.14: a tonic is `A-G` plus an optional `#`/`b`. Classify
    // whatever follows the tonic *within the same whitespace-delimited token* by
    // one rule (not per-bug special cases):
    //   - a recognised mode suffix (`Cmaj`, `Ador`, `F#min`) -> that mode;
    //   - an *alphabetic* non-mode remainder -> the token is NOT a tonic but a
    //     word that merely starts with a note letter — a clef shorthand
    //     (`bass`, `alto`) or a property token — so reject it (`return None`);
    //   - a *non-alphabetic* remainder (stray punctuation, e.g. the comma in
    //     `K:Bb,`) -> it can be neither a mode nor a clef word, so keep the valid
    //     leading tonic and discard the junk. The joined form then parses like the
    //     already-valid spaced form (`K:Bb,` behaves as `K:Bb`).
    let mut junk_ignored = false;
    let mode = if mode_start < token.len() {
        let remainder = &token[mode_start..];
        match parse_key_mode(remainder) {
            Some(mode) => Some(mode),
            None if remainder.starts_with(|c: char| c.is_ascii_alphabetic()) => {
                return None;
            }
            None => {
                junk_ignored = true;
                None
            }
        }
    } else {
        None
    };

    Some((
        KeyTonic {
            step: first.to_ascii_uppercase(),
            accidental,
        },
        mode,
        junk_ignored,
    ))
}

fn parse_key_mode(value: &str) -> Option<KeyMode> {
    let lower = value.to_ascii_lowercase();
    if lower == "m" {
        return Some(KeyMode::Minor);
    }

    // `get` rather than slicing: a multi-byte letter can straddle byte 3, and
    // such a word is no mode anyway.
    let prefix = lower.get(..3).unwrap_or(&lower);
    match prefix {
        "maj" => Some(KeyMode::Major),
        "ion" => Some(KeyMode::Ionian),
        "min" => Some(KeyMode::Minor),
        "aeo" => Some(KeyMode::Aeolian),
        "mix" => Some(KeyMode::Mixolydian),
        "dor" => Some(KeyMode::Dorian),
        "phr" => Some(KeyMode::Phrygian),
        "lyd" => Some(KeyMode::Lydian),
        "loc" => Some(KeyMode::Locrian),
        "exp" => Some(KeyMode::Explicit),
        _ => None,
    }
}

/// Parse every accidental in a key token. The `exp`/global accidental list may be
/// written space-less (`_B^g`, `^f_B_e`), in which case it arrives as a single
/// token; each `<sign><note>` pair must be captured (ABC 2.1 §3.1.14), not just
/// the first — dropping the rest left those pitches un-altered. Tokens that are
/// not accidentals (clef/property tokens) yield an empty vec.
fn parse_key_accidentals(token: &Spanned<String>) -> Vec<KeyAccidental> {
    let value = token.value.as_str();
    let mut accidentals = Vec::new();
    let mut pos = 0;
    while pos < value.len() {
        let rest = &value[pos..];
        let (sign, sign_len) = if rest.starts_with("__") {
            (AccidentalSign::DoubleFlat, 2)
        } else if rest.starts_with("^^") {
            (AccidentalSign::DoubleSharp, 2)
        } else if rest.starts_with('_') {
            (AccidentalSign::Flat, 1)
        } else if rest.starts_with('^') {
            (AccidentalSign::Sharp, 1)
        } else if rest.starts_with('=') {
            (AccidentalSign::Natural, 1)
        } else {
            break;
        };
        let Some(note) = rest[sign_len..].chars().next() else {
            break;
        };
        if !matches!(note.to_ascii_uppercase(), 'A'..='G') {
            break;
        }
        let sign_start = token.span.start + pos;
        let note_start = sign_start + sign_len;
        let note_span = Span::new(note_start, note_start + note.len_utf8());
        accidentals.push(KeyAccidental {
            sign,
            note: Spanned::new(note, note_span),
            span: Span::new(sign_start, note_span.end),
        });
        pos += sign_len + note.len_utf8();
    }
    accidentals
}