//! Line classification and surface tokenization for the tune surface AST.
//!
//! Produces the `LineMap` (classified source lines) and the flat `SurfaceToken`
//! stream consumed by `analyze_source` in the parent `tune` module.

use super::*;
use crate::Span;
use crate::source::SourceText;

pub(crate) fn classify_lines(source: &SourceText) -> LineMap {
    let mut builder = LineMapBuilder::new(source);
    builder.classify();
    builder.finish()
}

struct LineMapBuilder<'source> {
    source: &'source SourceText,
    lines: Vec<ClassifiedLine>,
    blocks: Vec<SourceBlock>,
    tunes: Vec<TuneBlock>,
    continuation_edges: Vec<ContinuationEdge>,
    non_note_items: Vec<NonNoteItem>,
    state: DocumentState,
    next_tune_index: usize,
    previous_field_line: Option<usize>,
    pending_music_continuation: Option<PendingMusicContinuation>,
    typeset_text_block_start: Option<usize>,
    in_begintext_block: bool,
}

impl<'source> LineMapBuilder<'source> {
    fn new(source: &'source SourceText) -> Self {
        Self {
            source,
            lines: Vec::new(),
            blocks: Vec::new(),
            tunes: Vec::new(),
            continuation_edges: Vec::new(),
            non_note_items: Vec::new(),
            state: DocumentState::Start,
            next_tune_index: 0,
            previous_field_line: None,
            pending_music_continuation: None,
            typeset_text_block_start: None,
            in_begintext_block: false,
        }
    }

    fn classify(&mut self) {
        for index in 0..self.source.line_count() {
            let Some(source_line) = self.source.line(index) else {
                continue;
            };
            let Some(text) = self.source.line_text(index) else {
                continue;
            };

            let raw_line = classify_raw_line(
                index,
                source_line.full_span(),
                source_line.text_span(),
                text,
                self.in_begintext_block,
            );

            let line = self.apply_structure(raw_line);
            self.track_typeset_block(&line);
            self.track_non_note_items(&line);
            self.track_field_continuation(&line);
            self.track_music_continuation(&line);
            self.update_begintext_state(&line, text);
            self.lines.push(line);
        }
    }

    fn apply_structure(&mut self, mut line: ClassifiedLine) -> ClassifiedLine {
        line.context = match self.state.clone() {
            DocumentState::Start => self.apply_start_structure(&mut line),
            DocumentState::FileHeader { start_line } => {
                self.apply_file_header_structure(&mut line, start_line)
            }
            DocumentState::BetweenBlocks => self.apply_between_blocks_structure(&mut line),
            DocumentState::FreeText { start_line } => {
                self.apply_free_text_structure(&mut line, start_line)
            }
            DocumentState::TuneHeader { tune } => self.apply_tune_header_structure(&mut line, tune),
            DocumentState::TuneBody { tune } => self.apply_tune_body_structure(&mut line, tune),
        };

        if line.kind != LineKind::MusicCode {
            line.score_line_break = ScoreLineBreak::NotApplicable;
        }

        line
    }

    fn apply_start_structure(&mut self, line: &mut ClassifiedLine) -> LineContext {
        match line.kind {
            LineKind::VersionLine | LineKind::EmptyLine | LineKind::Comment => {
                LineContext::Preamble
            }
            LineKind::InformationField if line.field_code() == Some('X') => self.start_tune(line),
            LineKind::InformationField
            | LineKind::FieldContinuation
            | LineKind::StylesheetDirective => {
                self.state = DocumentState::FileHeader {
                    start_line: line.index,
                };
                LineContext::FileHeader
            }
            LineKind::TypesetTextDirective => {
                self.state = DocumentState::BetweenBlocks;
                LineContext::TypesetText
            }
            LineKind::FreeText | LineKind::MusicCode => {
                line.kind = LineKind::FreeText;
                self.state = DocumentState::FreeText {
                    start_line: line.index,
                };
                LineContext::FreeText
            }
        }
    }

    fn apply_file_header_structure(
        &mut self,
        line: &mut ClassifiedLine,
        start_line: usize,
    ) -> LineContext {
        if line.kind == LineKind::EmptyLine && self.is_synthetic_final_empty_line(line) {
            self.close_file_header(start_line, line.index);
            self.state = DocumentState::BetweenBlocks;
            return LineContext::BetweenBlocks;
        }

        match line.kind {
            LineKind::EmptyLine => {
                self.close_file_header(start_line, line.index);
                self.state = DocumentState::BetweenBlocks;
                LineContext::BetweenBlocks
            }
            LineKind::InformationField if line.field_code() == Some('X') => {
                self.close_file_header(start_line, line.index);
                self.start_tune(line)
            }
            LineKind::InformationField
            | LineKind::FieldContinuation
            | LineKind::StylesheetDirective
            | LineKind::Comment => LineContext::FileHeader,
            LineKind::TypesetTextDirective => {
                self.close_file_header(start_line, line.index);
                self.state = DocumentState::BetweenBlocks;
                LineContext::TypesetText
            }
            LineKind::VersionLine => LineContext::FileHeader,
            LineKind::FreeText | LineKind::MusicCode => {
                self.close_file_header(start_line, line.index);
                line.kind = LineKind::FreeText;
                self.state = DocumentState::FreeText {
                    start_line: line.index,
                };
                LineContext::FreeText
            }
        }
    }

    fn apply_between_blocks_structure(&mut self, line: &mut ClassifiedLine) -> LineContext {
        match line.kind {
            LineKind::InformationField if line.field_code() == Some('X') => self.start_tune(line),
            LineKind::TypesetTextDirective => LineContext::TypesetText,
            LineKind::FreeText | LineKind::MusicCode => {
                line.kind = LineKind::FreeText;
                self.state = DocumentState::FreeText {
                    start_line: line.index,
                };
                LineContext::FreeText
            }
            LineKind::VersionLine
            | LineKind::EmptyLine
            | LineKind::Comment
            | LineKind::StylesheetDirective
            | LineKind::InformationField
            | LineKind::FieldContinuation => LineContext::BetweenBlocks,
        }
    }

    fn apply_free_text_structure(
        &mut self,
        line: &mut ClassifiedLine,
        start_line: usize,
    ) -> LineContext {
        if line.kind == LineKind::EmptyLine && self.is_synthetic_final_empty_line(line) {
            self.close_free_text_block(start_line, line.index);
            self.state = DocumentState::BetweenBlocks;
            return LineContext::BetweenBlocks;
        }

        match line.kind {
            LineKind::EmptyLine => {
                self.close_free_text_block(start_line, line.index);
                self.state = DocumentState::BetweenBlocks;
                LineContext::FreeText
            }
            LineKind::InformationField if line.field_code() == Some('X') => {
                self.close_free_text_block(start_line, line.index);
                self.start_tune(line)
            }
            LineKind::TypesetTextDirective => {
                self.close_free_text_block(start_line, line.index);
                self.state = DocumentState::BetweenBlocks;
                LineContext::TypesetText
            }
            LineKind::FreeText | LineKind::MusicCode => {
                line.kind = LineKind::FreeText;
                LineContext::FreeText
            }
            LineKind::VersionLine
            | LineKind::Comment
            | LineKind::StylesheetDirective
            | LineKind::InformationField
            | LineKind::FieldContinuation => LineContext::FreeText,
        }
    }

    fn apply_tune_header_structure(
        &mut self,
        line: &mut ClassifiedLine,
        mut tune: OpenTune,
    ) -> LineContext {
        if line.kind == LineKind::EmptyLine && self.is_synthetic_final_empty_line(line) {
            self.close_tune(tune, line.index, None);
            self.state = DocumentState::BetweenBlocks;
            return LineContext::BetweenBlocks;
        }

        match line.kind {
            LineKind::EmptyLine => {
                let tune_index = tune.index;
                self.close_tune(tune, line.index, Some(line.index));
                self.state = DocumentState::BetweenBlocks;
                LineContext::TuneTerminator { tune_index }
            }
            LineKind::InformationField
                if line.field_code() == Some('X') && line.index != tune.start_line =>
            {
                self.close_tune(tune, line.index, None);
                self.start_tune(line)
            }
            LineKind::InformationField if line.field_code() == Some('K') => {
                let tune_index = tune.index;
                tune.body_start_line = Some(line.index + 1);
                self.state = DocumentState::TuneBody { tune };
                LineContext::TuneHeader { tune_index }
            }
            LineKind::FreeText | LineKind::MusicCode => {
                line.kind = LineKind::FreeText;
                let tune_index = tune.index;
                self.state = DocumentState::TuneHeader { tune };
                LineContext::TuneHeader { tune_index }
            }
            LineKind::VersionLine
            | LineKind::Comment
            | LineKind::StylesheetDirective
            | LineKind::InformationField
            | LineKind::FieldContinuation
            | LineKind::TypesetTextDirective => {
                let tune_index = tune.index;
                self.state = DocumentState::TuneHeader { tune };
                LineContext::TuneHeader { tune_index }
            }
        }
    }

    fn apply_tune_body_structure(
        &mut self,
        line: &mut ClassifiedLine,
        tune: OpenTune,
    ) -> LineContext {
        if line.kind == LineKind::EmptyLine && self.is_synthetic_final_empty_line(line) {
            self.close_tune(tune, line.index, None);
            self.pending_music_continuation = None;
            self.state = DocumentState::BetweenBlocks;
            return LineContext::BetweenBlocks;
        }

        match line.kind {
            LineKind::EmptyLine => {
                let tune_index = tune.index;
                self.close_tune(tune, line.index, Some(line.index));
                self.pending_music_continuation = None;
                self.state = DocumentState::BetweenBlocks;
                LineContext::TuneTerminator { tune_index }
            }
            LineKind::FreeText | LineKind::MusicCode => {
                line.kind = LineKind::MusicCode;
                let tune_index = tune.index;
                self.state = DocumentState::TuneBody { tune };
                LineContext::TuneBody { tune_index }
            }
            LineKind::VersionLine
            | LineKind::Comment
            | LineKind::StylesheetDirective
            | LineKind::InformationField
            | LineKind::FieldContinuation
            | LineKind::TypesetTextDirective => {
                let tune_index = tune.index;
                self.state = DocumentState::TuneBody { tune };
                LineContext::TuneBody { tune_index }
            }
        }
    }

    fn start_tune(&mut self, line: &ClassifiedLine) -> LineContext {
        let tune_index = self.next_tune_index;
        self.next_tune_index += 1;
        self.pending_music_continuation = None;
        self.state = DocumentState::TuneHeader {
            tune: OpenTune {
                index: tune_index,
                start_line: line.index,
                body_start_line: None,
            },
        };
        LineContext::TuneHeader { tune_index }
    }

    fn close_file_header(&mut self, start_line: usize, end_line: usize) {
        if start_line >= end_line {
            return;
        }

        self.blocks.push(SourceBlock {
            kind: SourceBlockKind::FileHeader,
            span: self.line_range_span(start_line, end_line),
            line_start: start_line,
            line_end: end_line,
            tune_index: None,
        });
    }

    fn close_free_text_block(&mut self, start_line: usize, end_line: usize) {
        if start_line >= end_line {
            return;
        }

        self.blocks.push(SourceBlock {
            kind: SourceBlockKind::FreeText,
            span: self.line_range_span(start_line, end_line),
            line_start: start_line,
            line_end: end_line,
            tune_index: None,
        });
    }

    fn close_tune(&mut self, tune: OpenTune, end_line: usize, terminator_line: Option<usize>) {
        if tune.start_line >= end_line {
            return;
        }

        let body_start_line = tune.body_start_line.unwrap_or(end_line);
        let header_line_end = body_start_line.min(end_line);
        let body_line_start = body_start_line.min(end_line);
        let span = self.line_range_span(tune.start_line, end_line);
        let header_span = self.line_range_span(tune.start_line, header_line_end);
        let body_span = self.line_range_span(body_line_start, end_line);

        self.blocks.push(SourceBlock {
            kind: SourceBlockKind::Tune,
            span,
            line_start: tune.start_line,
            line_end: end_line,
            tune_index: Some(tune.index),
        });
        self.tunes.push(TuneBlock {
            index: tune.index,
            span,
            header_span,
            body_span,
            line_start: tune.start_line,
            line_end: end_line,
            header_line_start: tune.start_line,
            header_line_end,
            body_line_start,
            body_line_end: end_line,
            terminator_line,
        });
    }

    fn track_typeset_block(&mut self, line: &ClassifiedLine) {
        if line.kind == LineKind::TypesetTextDirective {
            if self.typeset_text_block_start.is_none() {
                self.typeset_text_block_start = Some(line.index);
            }
        } else if let Some(start_line) = self.typeset_text_block_start.take() {
            self.close_typeset_text_block(start_line, line.index);
        }
    }

    fn close_typeset_text_block(&mut self, start_line: usize, end_line: usize) {
        if start_line >= end_line {
            return;
        }

        self.blocks.push(SourceBlock {
            kind: SourceBlockKind::TypesetText,
            span: self.line_range_span(start_line, end_line),
            line_start: start_line,
            line_end: end_line,
            tune_index: tune_index_for_context(self.lines[start_line].context),
        });
    }

    fn track_non_note_items(&mut self, line: &ClassifiedLine) {
        if let Some(kind) = non_note_kind_for_line(line.kind) {
            self.non_note_items.push(NonNoteItem {
                kind,
                line_index: line.index,
                span: non_note_span(line),
            });
        }

        if let Some(span) = line.trailing_comment {
            self.non_note_items.push(NonNoteItem {
                kind: NonNoteKind::InlineComment,
                line_index: line.index,
                span,
            });
        }
    }

    fn track_field_continuation(&mut self, line: &ClassifiedLine) {
        if line.kind == LineKind::FieldContinuation {
            if let (Some(from_line), Some(marker_span)) =
                (self.previous_field_line, line.marker_span)
            {
                let from_end = self
                    .lines
                    .get(from_line)
                    .map(|line| line.text_span.end)
                    .unwrap_or(marker_span.start);
                self.continuation_edges.push(ContinuationEdge {
                    kind: ContinuationKind::FieldContinuation,
                    from_line,
                    to_line: line.index,
                    span: Span::new(from_end, marker_span.end),
                    marker_span,
                });
            }
            self.previous_field_line = Some(line.index);
            return;
        }

        if matches!(line.kind, LineKind::InformationField) {
            self.previous_field_line = Some(line.index);
        } else if !matches!(
            line.kind,
            LineKind::Comment | LineKind::StylesheetDirective | LineKind::TypesetTextDirective
        ) {
            self.previous_field_line = None;
        }
    }

    fn track_music_continuation(&mut self, line: &ClassifiedLine) {
        if line.kind == LineKind::MusicCode {
            if let Some(pending) = self.pending_music_continuation.take() {
                self.continuation_edges.push(ContinuationEdge {
                    kind: ContinuationKind::MusicBackslash,
                    from_line: pending.from_line,
                    to_line: line.index,
                    span: Span::new(pending.marker_span.start, line.content_span.start),
                    marker_span: pending.marker_span,
                });
            }

            self.pending_music_continuation = match line.score_line_break {
                ScoreLineBreak::Suppressed { marker_span } => Some(PendingMusicContinuation {
                    from_line: line.index,
                    marker_span,
                }),
                ScoreLineBreak::NotApplicable | ScoreLineBreak::Physical => None,
            };
        }
    }

    fn update_begintext_state(&mut self, line: &ClassifiedLine, text: &str) {
        if line.kind != LineKind::TypesetTextDirective {
            return;
        }

        let Some(name) = directive_name(text) else {
            return;
        };

        if name.eq_ignore_ascii_case("begintext") {
            self.in_begintext_block = true;
        } else if name.eq_ignore_ascii_case("endtext") {
            self.in_begintext_block = false;
        }
    }

    fn finish(mut self) -> LineMap {
        match self.state.clone() {
            DocumentState::FileHeader { start_line } => {
                self.close_file_header(start_line, self.source.line_count());
            }
            DocumentState::FreeText { start_line } => {
                self.close_free_text_block(start_line, self.source.line_count());
            }
            DocumentState::TuneHeader { tune } | DocumentState::TuneBody { tune } => {
                self.close_tune(tune, self.source.line_count(), None);
            }
            DocumentState::Start | DocumentState::BetweenBlocks => {}
        }

        if let Some(start_line) = self.typeset_text_block_start.take() {
            self.close_typeset_text_block(start_line, self.source.line_count());
        }

        LineMap {
            lines: self.lines,
            blocks: self.blocks,
            tunes: self.tunes,
            continuation_edges: self.continuation_edges,
            non_note_items: self.non_note_items,
        }
    }

    fn line_range_span(&self, start_line: usize, end_line: usize) -> Span {
        if start_line >= end_line {
            let offset = self
                .source
                .line(start_line)
                .map(|line| line.start())
                .unwrap_or_else(|| self.source.len());
            return Span::new(offset, offset);
        }

        let start = self
            .source
            .line(start_line)
            .map(|line| line.start())
            .unwrap_or_else(|| self.source.len());
        let end = self
            .source
            .line(end_line - 1)
            .map(|line| line.end())
            .unwrap_or(start);
        Span::new(start, end)
    }

    fn is_synthetic_final_empty_line(&self, line: &ClassifiedLine) -> bool {
        line.index + 1 == self.source.line_count()
            && line.text_span.is_empty()
            && line.span.is_empty()
            && line.text_span.start == self.source.len()
            && self.source.len() > self.source.content_start()
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
enum DocumentState {
    Start,
    FileHeader { start_line: usize },
    BetweenBlocks,
    FreeText { start_line: usize },
    TuneHeader { tune: OpenTune },
    TuneBody { tune: OpenTune },
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct OpenTune {
    index: usize,
    start_line: usize,
    body_start_line: Option<usize>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct PendingMusicContinuation {
    from_line: usize,
    marker_span: Span,
}

impl ClassifiedLine {
    pub fn field_code(&self) -> Option<char> {
        self.field.map(|field| field.code)
    }
}

fn classify_raw_line(
    index: usize,
    span: Span,
    text_span: Span,
    text: &str,
    in_begintext_block: bool,
) -> ClassifiedLine {
    let content_start = trimmed_start_offset(text);
    let content = &text[content_start..];
    let content_span = Span::new(text_span.start + content_start, text_span.end);
    let trailing_comment = trailing_comment_span(text, text_span.start);

    let mut line = ClassifiedLine {
        index,
        kind: LineKind::FreeText,
        context: LineContext::BetweenBlocks,
        span,
        text_span,
        content_span,
        marker_span: None,
        field: None,
        trailing_comment,
        score_line_break: ScoreLineBreak::NotApplicable,
    };

    if content.trim_end().is_empty() {
        line.kind = LineKind::EmptyLine;
        line.content_span = Span::new(text_span.end, text_span.end);
        line.trailing_comment = None;
        return line;
    }

    if in_begintext_block {
        line.kind = LineKind::TypesetTextDirective;
        line.marker_span = content
            .strip_prefix("%%")
            .map(|_| Span::new(line.content_span.start, line.content_span.start + 2));
        return line;
    }

    if is_version_line(content) {
        line.kind = LineKind::VersionLine;
        line.marker_span = Some(Span::new(
            line.content_span.start,
            line.content_span.start + 4,
        ));
        line.trailing_comment = None;
        return line;
    }

    if content.starts_with("%%") {
        line.marker_span = Some(Span::new(
            line.content_span.start,
            line.content_span.start + 2,
        ));
        line.kind = if is_typeset_text_directive(content) {
            LineKind::TypesetTextDirective
        } else {
            LineKind::StylesheetDirective
        };
        line.trailing_comment = None;
        return line;
    }

    if content.starts_with('%') {
        line.kind = LineKind::Comment;
        line.marker_span = Some(Span::new(
            line.content_span.start,
            line.content_span.start + 1,
        ));
        line.trailing_comment = None;
        return line;
    }

    if content.starts_with("+:") {
        line.kind = LineKind::FieldContinuation;
        line.marker_span = Some(Span::new(
            line.content_span.start,
            line.content_span.start + 2,
        ));
        return line;
    }

    if let Some(field) = parse_field_header(content, line.content_span.start, text_span.end) {
        line.kind = LineKind::InformationField;
        line.marker_span = Some(field.marker_span);
        line.field = Some(field);
        return line;
    }

    line.score_line_break = music_score_line_break(text, text_span.start);
    line
}

fn parse_field_header(
    content: &str,
    content_offset: usize,
    text_end: usize,
) -> Option<FieldHeader> {
    let mut chars = content.chars();
    let code = chars.next()?;
    if !code.is_ascii_alphabetic() || chars.next()? != ':' {
        return None;
    }

    let marker_end = content_offset + code.len_utf8() + 1;
    Some(FieldHeader {
        code,
        marker_span: Span::new(content_offset, marker_end),
        value_span: Span::new(marker_end, text_end),
    })
}

fn trimmed_start_offset(text: &str) -> usize {
    text.len() - text.trim_start().len()
}

fn trailing_comment_span(text: &str, line_offset: usize) -> Option<Span> {
    let comment_start = text.char_indices().find_map(|(offset, ch)| {
        if ch == '%' && !is_escaped(text, offset) {
            Some(offset)
        } else {
            None
        }
    })?;

    Some(Span::new(
        line_offset + comment_start,
        line_offset + text.len(),
    ))
}

fn music_score_line_break(text: &str, line_offset: usize) -> ScoreLineBreak {
    let code_end = trailing_comment_span(text, 0)
        .map(|span| span.start)
        .unwrap_or(text.len());
    let code = text[..code_end].trim_end();
    let Some(last) = code.strip_suffix('\\') else {
        return ScoreLineBreak::Physical;
    };
    let marker_start = line_offset + last.len();
    ScoreLineBreak::Suppressed {
        marker_span: Span::new(marker_start, marker_start + 1),
    }
}

fn is_escaped(text: &str, offset: usize) -> bool {
    let mut slash_count = 0;
    for byte in text[..offset].bytes().rev() {
        if byte == b'\\' {
            slash_count += 1;
        } else {
            break;
        }
    }
    slash_count % 2 == 1
}

fn directive_name(text: &str) -> Option<&str> {
    let content = text.trim_start();
    let rest = content.strip_prefix("%%")?;
    let name_end = rest
        .find(|ch: char| ch.is_whitespace())
        .unwrap_or(rest.len());
    Some(&rest[..name_end])
}

fn is_typeset_text_directive(content: &str) -> bool {
    let Some(name) = directive_name(content) else {
        return false;
    };

    matches!(
        name.to_ascii_lowercase().as_str(),
        "text" | "center" | "begintext" | "endtext"
    )
}

fn is_version_line(content: &str) -> bool {
    let Some(rest) = content.strip_prefix("%abc") else {
        return false;
    };
    rest.is_empty()
        || rest
            .chars()
            .next()
            .map(|ch| ch == '-' || ch.is_whitespace())
            .unwrap_or(true)
}

fn non_note_kind_for_line(kind: LineKind) -> Option<NonNoteKind> {
    match kind {
        LineKind::VersionLine => Some(NonNoteKind::VersionLine),
        LineKind::EmptyLine => Some(NonNoteKind::EmptyLine),
        LineKind::Comment => Some(NonNoteKind::Comment),
        LineKind::StylesheetDirective => Some(NonNoteKind::StylesheetDirective),
        LineKind::InformationField => Some(NonNoteKind::InformationField),
        LineKind::FieldContinuation => Some(NonNoteKind::FieldContinuation),
        LineKind::FreeText => Some(NonNoteKind::FreeText),
        LineKind::TypesetTextDirective => Some(NonNoteKind::TypesetTextDirective),
        LineKind::MusicCode => None,
    }
}

fn non_note_span(line: &ClassifiedLine) -> Span {
    if line.kind == LineKind::EmptyLine {
        line.text_span
    } else {
        line.content_span
    }
}

fn tune_index_for_context(context: LineContext) -> Option<usize> {
    match context {
        LineContext::TuneHeader { tune_index }
        | LineContext::TuneBody { tune_index }
        | LineContext::TuneTerminator { tune_index } => Some(tune_index),
        LineContext::Preamble
        | LineContext::FileHeader
        | LineContext::BetweenBlocks
        | LineContext::FreeText
        | LineContext::TypesetText => None,
    }
}

pub(crate) fn tokenize_surface(source: &SourceText, line_map: &LineMap) -> Vec<SurfaceToken> {
    let mut tokens = Vec::new();

    for line in &line_map.lines {
        match line.kind {
            LineKind::InformationField => {
                if let Some(field) = line.field {
                    tokens.push(SurfaceToken {
                        kind: SurfaceKind::Field,
                        span: field.marker_span,
                    });
                }
            }
            LineKind::FieldContinuation => {
                if let Some(marker_span) = line.marker_span {
                    tokens.push(SurfaceToken {
                        kind: SurfaceKind::FieldContinuation,
                        span: marker_span,
                    });
                }
            }
            LineKind::Comment => tokens.push(SurfaceToken {
                kind: SurfaceKind::Comment,
                span: line.content_span,
            }),
            LineKind::StylesheetDirective | LineKind::TypesetTextDirective => {
                tokens.push(SurfaceToken {
                    kind: SurfaceKind::Directive,
                    span: line.content_span,
                });
            }
            LineKind::FreeText => tokens.push(SurfaceToken {
                kind: SurfaceKind::FreeText,
                span: line.content_span,
            }),
            LineKind::MusicCode => {
                if let Some(text) = source.slice(line.text_span) {
                    tokenize_music_line(text, line.text_span.start, &mut tokens);
                }
            }
            LineKind::VersionLine | LineKind::EmptyLine => {}
        }

        if line.kind != LineKind::MusicCode
            && let Some(span) = line.trailing_comment
        {
            tokens.push(SurfaceToken {
                kind: SurfaceKind::Comment,
                span,
            });
        }
    }

    tokens
}

fn tokenize_music_line(line: &str, line_offset: usize, tokens: &mut Vec<SurfaceToken>) {
    for (column, ch) in line.char_indices() {
        let kind = match ch {
            '%' if !is_escaped(line, column) => {
                tokens.push(SurfaceToken {
                    kind: SurfaceKind::Comment,
                    span: Span::new(line_offset + column, line_offset + line.len()),
                });
                break;
            }
            '|' => SurfaceKind::Barline,
            'A'..='G' | 'a'..='g' => SurfaceKind::Note,
            'z' | 'x' => SurfaceKind::Rest,
            _ => SurfaceKind::Other,
        };
        if kind != SurfaceKind::Other {
            tokens.push(SurfaceToken {
                kind,
                span: Span::new(line_offset + column, line_offset + column + ch.len_utf8()),
            });
        }
    }
}
