croma-core 1.0.0

Library-first ABC to MusicXML exporter
Documentation
pub mod barline;
pub mod decoration;
pub mod directive;
pub mod field;
pub mod lyric;
pub mod music;
pub mod note;

use crate::diagnostic::{Diagnostic, RecoveryNote, Severity, Span, SpecReference};
use crate::error::{CromaError, Result};
use crate::lower::{ScoreModelInput, build_score_model, lower_tune_music};
use crate::model::{Score, TextLine, Tune};
use crate::options::ParseOptions;
use crate::parse::field::{ParsedAbcFields, ParsedFieldKind, parse_fields};
use crate::parse::music::parse_music_document;
use crate::source::SourceText;
use crate::syntax::ParsedMusicDocument;
use crate::syntax::tune::{SurfaceMap, analyze_source};

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParseReport<T> {
    pub value: T,
    pub diagnostics: Vec<Diagnostic>,
}

impl<T> ParseReport<T> {
    pub fn new(value: T, diagnostics: Vec<Diagnostic>) -> Self {
        Self { value, diagnostics }
    }

    pub fn has_errors(&self) -> bool {
        self.diagnostics
            .iter()
            .any(|diagnostic| diagnostic.severity == Severity::Error)
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AbcDocument {
    pub source: SourceText,
    pub options: ParseOptions,
    pub surface: SurfaceMap,
    pub fields: ParsedAbcFields,
    pub music: ParsedMusicDocument,
}

pub fn parse_document(source: &str, options: ParseOptions) -> ParseReport<AbcDocument> {
    parse_document_source(SourceText::new(source), options)
}

pub fn parse_document_source(
    source: SourceText,
    options: ParseOptions,
) -> ParseReport<AbcDocument> {
    let mut diagnostics = source_level_diagnostics(&source);
    let surface = analyze_source(&source);
    let (fields, field_diagnostics) = parse_fields(&source, &surface, options);
    diagnostics.extend(field_diagnostics);
    let ParseReport {
        value: music,
        diagnostics: music_diagnostics,
    } = parse_music_document(&source, &surface, &fields);
    diagnostics.extend(music_diagnostics);

    ParseReport::new(
        AbcDocument {
            source,
            options,
            surface,
            fields,
            music,
        },
        diagnostics,
    )
}

pub fn parse_tune(
    source: &SourceText,
    _surface: &SurfaceMap,
    _options: ParseOptions,
) -> Result<Tune> {
    let report = parse_tune_report(source, _surface, _options);
    report
        .value
        .ok_or_else(|| CromaError::from_diagnostics(report.diagnostics))
}

pub fn parse_tune_report(
    source: &SourceText,
    surface: &SurfaceMap,
    options: ParseOptions,
) -> ParseReport<Option<Tune>> {
    if source.content().trim().is_empty() {
        return ParseReport::new(None, source_level_diagnostics(source));
    }

    let fallback_surface;
    let surface = if surface.line_map.lines.is_empty() && source.line_count() > 0 {
        fallback_surface = analyze_source(source);
        &fallback_surface
    } else {
        surface
    };

    let (fields, mut diagnostics) = parse_fields(source, surface, options);
    let music_report = parse_music_document(source, surface, &fields);
    diagnostics.extend(music_report.diagnostics);
    parse_tune_report_with_fields(
        source,
        surface,
        &fields,
        Some(&music_report.value),
        diagnostics,
    )
}

pub fn parse_tune_report_from_document(document: &AbcDocument) -> ParseReport<Option<Tune>> {
    parse_tune_report_with_fields(
        &document.source,
        &document.surface,
        &document.fields,
        Some(&document.music),
        Vec::new(),
    )
}

fn parse_tune_report_with_fields(
    source: &SourceText,
    surface: &SurfaceMap,
    fields: &ParsedAbcFields,
    music: Option<&ParsedMusicDocument>,
    mut diagnostics: Vec<Diagnostic>,
) -> ParseReport<Option<Tune>> {
    if source.content().trim().is_empty() {
        return ParseReport::new(None, source_level_diagnostics(source));
    }

    let mut reference = String::new();
    let mut reference_line = TextLine {
        text: String::new(),
        span: Span::new(0, 0),
    };
    let mut title = String::new();
    let mut title_line = None;
    let mut composers = Vec::new();
    let mut tempo_line = None;
    let mut meter = String::from("4/4");
    let mut key = String::new();
    let mut has_key = false;
    let mut events = Vec::new();
    let mut divisions = 8;
    let mut voices = Vec::new();
    let mut score_directives = Vec::new();
    let mut preserved_directives = Vec::new();
    let mut post_tune_lyrics = Vec::new();
    let mut score = None;
    let mut body_start = None;
    let mut tune_field_state = None;

    let Some(tune) = surface.line_map.tunes.first() else {
        diagnostics.push(missing_key_diagnostic(source, surface.line_map.tunes.len()));
        return ParseReport::new(None, diagnostics);
    };

    if let Some(tune_fields) = fields.tune(tune.index) {
        if let Some(parsed_meter) = tune_fields.header.meter.as_ref() {
            meter = parsed_meter.value.raw.clone();
        }
        tune_field_state = Some(&tune_fields.header);

        for field_index in &tune_fields.header_field_indices {
            let Some(field) = fields.field(*field_index) else {
                continue;
            };
            match &field.kind {
                ParsedFieldKind::Reference(value) => {
                    reference = value.value.clone();
                    reference_line = TextLine {
                        text: value.value.clone(),
                        span: value.span,
                    };
                }
                ParsedFieldKind::Title(value) if title.is_empty() => {
                    title = value.value.clone();
                    title_line = Some(TextLine {
                        text: value.value.clone(),
                        span: value.span,
                    });
                }
                ParsedFieldKind::TextMetadata(metadata) if metadata.code == 'C' => {
                    composers.push(TextLine {
                        text: metadata.value.value.clone(),
                        span: metadata.value.span,
                    });
                }
                ParsedFieldKind::Tempo(value) => {
                    tempo_line = Some(TextLine {
                        text: value.value.clone(),
                        span: value.span,
                    });
                }
                ParsedFieldKind::Key(value) => {
                    has_key = true;
                    key = if value.value.raw.is_empty() {
                        String::from("none")
                    } else {
                        value.value.raw.clone()
                    };
                    body_start = Some(field.line_span.end);
                }
                _ => {}
            }
        }
    }

    if let (Some(tune_music), Some(field_state)) = (
        music.and_then(|music| music.tune(tune.index)),
        tune_field_state,
    ) {
        let lower_report = lower_tune_music(tune_music, field_state);
        diagnostics.extend(lower_report.diagnostics);
        events = lower_report.value.events;
        divisions = lower_report.value.divisions;
        voices = lower_report.value.voices;
        score_directives = lower_report.value.score_directives;
        preserved_directives = lower_report.value.preserved_directives;
        post_tune_lyrics = lower_report.value.post_tune_lyrics;
        score = Some(build_score_model(ScoreModelInput {
            reference: reference_line.clone(),
            title: title_line.clone(),
            composers: composers.clone(),
            tempo: tempo_line.clone(),
            source_span: tune.span,
            field_state,
            voices: &voices,
            score_directives: &score_directives,
            preserved_directives: &preserved_directives,
            post_tune_lyrics: &post_tune_lyrics,
            diagnostics: &diagnostics,
            divisions,
        }));
    }

    if !has_key {
        diagnostics.push(missing_key_diagnostic(source, surface.line_map.tunes.len()));
        return ParseReport::new(None, diagnostics);
    }
    if events.iter().all(|event| !event.is_time_bearing()) {
        diagnostics.push(no_music_diagnostic(source, body_start));
        return ParseReport::new(None, diagnostics);
    }

    ParseReport::new(
        Some(Tune {
            reference,
            title,
            meter,
            key,
            divisions,
            events,
            voices,
            score_directives,
            preserved_directives,
            post_tune_lyrics,
            score: score.unwrap_or_else(|| empty_score(tune.span, diagnostics.clone())),
        }),
        diagnostics,
    )
}

fn empty_score(span: Span, diagnostics: Vec<Diagnostic>) -> Score {
    Score {
        metadata: crate::model::ScoreMetadata {
            reference: TextLine {
                text: String::new(),
                span,
            },
            title: None,
            composers: Vec::new(),
            tempo: None,
            tempo_model: None,
            meter: None,
            key: None,
            directives: Vec::new(),
            preserved_directives: Vec::new(),
            post_tune_lyrics: Vec::new(),
            source_span: span,
        },
        parts: Vec::new(),
        diagnostics,
        divisions: 1,
        source_span: span,
        accidental_policy: crate::model::AccidentalPolicy {
            preserve_explicit_accidentals: true,
            reset_at_barlines: true,
            scope: crate::model::AccidentalScope::PitchAndOctave,
            source_span: span,
        },
    }
}

fn source_level_diagnostics(source: &SourceText) -> Vec<Diagnostic> {
    if source.content().trim().is_empty() {
        return vec![
            Diagnostic::new(
                Severity::Error,
                "abc.file.empty",
                "ABC source is empty",
                Span::new(source.content_start(), source.content_start()),
            )
            .with_spec_reference(abc_file_structure_reference()),
        ];
    }

    let has_non_comment_content = (0..source.line_count()).any(|index| {
        source
            .line_text(index)
            .map(|line| {
                let trimmed = line.trim_start();
                !trimmed.trim_end().is_empty() && !trimmed.starts_with('%')
            })
            .unwrap_or(false)
    });

    if has_non_comment_content {
        Vec::new()
    } else {
        vec![
            Diagnostic::new(
                Severity::Error,
                "abc.file.no_tune",
                "ABC source contains comments only and no tune content",
                source.content_span(),
            )
            .with_spec_reference(abc_file_structure_reference()),
        ]
    }
}

fn abc_file_structure_reference() -> SpecReference {
    SpecReference::new("ABC 2.1 file structure")
        .with_url("https://abcnotation.com/wiki/abc:standard:v2.1")
}

fn missing_key_diagnostic(source: &SourceText, tune_count: usize) -> Diagnostic {
    let diagnostic = Diagnostic::new(
        Severity::Error,
        "abc.file.missing_k",
        "ABC source is missing a K: field",
        Span::new(source.len(), source.len()),
    )
    .with_spec_reference(abc_file_structure_reference());
    if tune_count > 1 {
        // Clarify the otherwise-confusing message when K: is plainly present on a
        // LATER tune: croma processes a single tune per file, and the first one
        // (the one missing K:) was selected (ABC 2.1 §2.2 separates tunes by blank
        // lines — two adjacent `X:` with no blank line is malformed).
        diagnostic.with_recovery_note(RecoveryNote::new(
            "This file holds multiple tunes; croma processes only the first, which has no K: field. Separate tunes with a blank line.",
        ))
    } else {
        diagnostic
    }
}

fn no_music_diagnostic(source: &SourceText, body_start: Option<usize>) -> Diagnostic {
    Diagnostic::new(
        Severity::Error,
        "abc.file.no_music",
        "ABC source does not contain body music",
        Span::new(body_start.unwrap_or_else(|| source.len()), source.len()),
    )
    .with_spec_reference(abc_file_structure_reference())
}

#[cfg(test)]
#[path = "mod_tests.rs"]
mod tests;