Skip to main content

croma_core/syntax/
tune_classify.rs

1//! Line classification and surface tokenization for the tune surface AST.
2//!
3//! Produces the `LineMap` (classified source lines) and the flat `SurfaceToken`
4//! stream consumed by `analyze_source` in the parent `tune` module.
5
6use super::*;
7use crate::Span;
8use crate::escape::is_escaped;
9use crate::source::SourceText;
10
11pub(crate) fn classify_lines(source: &SourceText) -> LineMap {
12    let mut builder = LineMapBuilder::new(source);
13    builder.classify();
14    builder.finish()
15}
16
17struct LineMapBuilder<'source> {
18    source: &'source SourceText,
19    lines: Vec<ClassifiedLine>,
20    blocks: Vec<SourceBlock>,
21    tunes: Vec<TuneBlock>,
22    continuation_edges: Vec<ContinuationEdge>,
23    non_note_items: Vec<NonNoteItem>,
24    state: DocumentState,
25    next_tune_index: usize,
26    previous_field_line: Option<usize>,
27    pending_music_continuation: Option<PendingMusicContinuation>,
28    typeset_text_block_start: Option<usize>,
29    in_begintext_block: bool,
30}
31
32impl<'source> LineMapBuilder<'source> {
33    fn new(source: &'source SourceText) -> Self {
34        Self {
35            source,
36            lines: Vec::new(),
37            blocks: Vec::new(),
38            tunes: Vec::new(),
39            continuation_edges: Vec::new(),
40            non_note_items: Vec::new(),
41            state: DocumentState::Start,
42            next_tune_index: 0,
43            previous_field_line: None,
44            pending_music_continuation: None,
45            typeset_text_block_start: None,
46            in_begintext_block: false,
47        }
48    }
49
50    fn classify(&mut self) {
51        for index in 0..self.source.line_count() {
52            let Some(source_line) = self.source.line(index) else {
53                continue;
54            };
55            let Some(text) = self.source.line_text(index) else {
56                continue;
57            };
58
59            let raw_line = classify_raw_line(
60                index,
61                source_line.full_span(),
62                source_line.text_span(),
63                text,
64                self.in_begintext_block,
65            );
66
67            let line = self.apply_structure(raw_line);
68            self.track_typeset_block(&line);
69            self.track_non_note_items(&line);
70            self.track_field_continuation(&line);
71            self.track_music_continuation(&line);
72            self.update_begintext_state(&line, text);
73            self.lines.push(line);
74        }
75    }
76
77    fn apply_structure(&mut self, mut line: ClassifiedLine) -> ClassifiedLine {
78        line.context = match self.state.clone() {
79            DocumentState::Start => self.apply_start_structure(&mut line),
80            DocumentState::FileHeader { start_line } => {
81                self.apply_file_header_structure(&mut line, start_line)
82            }
83            DocumentState::BetweenBlocks => self.apply_between_blocks_structure(&mut line),
84            DocumentState::FreeText { start_line } => {
85                self.apply_free_text_structure(&mut line, start_line)
86            }
87            DocumentState::TuneHeader { tune } => self.apply_tune_header_structure(&mut line, tune),
88            DocumentState::TuneBody { tune } => self.apply_tune_body_structure(&mut line, tune),
89        };
90
91        if line.kind != LineKind::MusicCode {
92            line.score_line_break = ScoreLineBreak::NotApplicable;
93        }
94
95        line
96    }
97
98    fn apply_start_structure(&mut self, line: &mut ClassifiedLine) -> LineContext {
99        match line.kind {
100            LineKind::VersionLine | LineKind::EmptyLine | LineKind::Comment => {
101                LineContext::Preamble
102            }
103            LineKind::InformationField if line.field_code() == Some('X') => self.start_tune(line),
104            LineKind::InformationField
105            | LineKind::FieldContinuation
106            | LineKind::StylesheetDirective => {
107                self.state = DocumentState::FileHeader {
108                    start_line: line.index,
109                };
110                LineContext::FileHeader
111            }
112            LineKind::TypesetTextDirective => {
113                self.state = DocumentState::BetweenBlocks;
114                LineContext::TypesetText
115            }
116            LineKind::FreeText | LineKind::MusicCode => {
117                line.kind = LineKind::FreeText;
118                self.state = DocumentState::FreeText {
119                    start_line: line.index,
120                };
121                LineContext::FreeText
122            }
123        }
124    }
125
126    fn apply_file_header_structure(
127        &mut self,
128        line: &mut ClassifiedLine,
129        start_line: usize,
130    ) -> LineContext {
131        if line.kind == LineKind::EmptyLine && self.is_synthetic_final_empty_line(line) {
132            self.close_file_header(start_line, line.index);
133            self.state = DocumentState::BetweenBlocks;
134            return LineContext::BetweenBlocks;
135        }
136
137        match line.kind {
138            LineKind::EmptyLine => {
139                self.close_file_header(start_line, line.index);
140                self.state = DocumentState::BetweenBlocks;
141                LineContext::BetweenBlocks
142            }
143            LineKind::InformationField if line.field_code() == Some('X') => {
144                self.close_file_header(start_line, line.index);
145                self.start_tune(line)
146            }
147            LineKind::InformationField
148            | LineKind::FieldContinuation
149            | LineKind::StylesheetDirective
150            | LineKind::Comment => LineContext::FileHeader,
151            LineKind::TypesetTextDirective => {
152                self.close_file_header(start_line, line.index);
153                self.state = DocumentState::BetweenBlocks;
154                LineContext::TypesetText
155            }
156            LineKind::VersionLine => LineContext::FileHeader,
157            LineKind::FreeText | LineKind::MusicCode => {
158                self.close_file_header(start_line, line.index);
159                line.kind = LineKind::FreeText;
160                self.state = DocumentState::FreeText {
161                    start_line: line.index,
162                };
163                LineContext::FreeText
164            }
165        }
166    }
167
168    fn apply_between_blocks_structure(&mut self, line: &mut ClassifiedLine) -> LineContext {
169        match line.kind {
170            LineKind::InformationField if line.field_code() == Some('X') => self.start_tune(line),
171            LineKind::TypesetTextDirective => LineContext::TypesetText,
172            LineKind::FreeText | LineKind::MusicCode => {
173                line.kind = LineKind::FreeText;
174                self.state = DocumentState::FreeText {
175                    start_line: line.index,
176                };
177                LineContext::FreeText
178            }
179            LineKind::VersionLine
180            | LineKind::EmptyLine
181            | LineKind::Comment
182            | LineKind::StylesheetDirective
183            | LineKind::InformationField
184            | LineKind::FieldContinuation => LineContext::BetweenBlocks,
185        }
186    }
187
188    fn apply_free_text_structure(
189        &mut self,
190        line: &mut ClassifiedLine,
191        start_line: usize,
192    ) -> LineContext {
193        if line.kind == LineKind::EmptyLine && self.is_synthetic_final_empty_line(line) {
194            self.close_free_text_block(start_line, line.index);
195            self.state = DocumentState::BetweenBlocks;
196            return LineContext::BetweenBlocks;
197        }
198
199        match line.kind {
200            LineKind::EmptyLine => {
201                self.close_free_text_block(start_line, line.index);
202                self.state = DocumentState::BetweenBlocks;
203                LineContext::FreeText
204            }
205            LineKind::InformationField if line.field_code() == Some('X') => {
206                self.close_free_text_block(start_line, line.index);
207                self.start_tune(line)
208            }
209            LineKind::TypesetTextDirective => {
210                self.close_free_text_block(start_line, line.index);
211                self.state = DocumentState::BetweenBlocks;
212                LineContext::TypesetText
213            }
214            LineKind::FreeText | LineKind::MusicCode => {
215                line.kind = LineKind::FreeText;
216                LineContext::FreeText
217            }
218            LineKind::VersionLine
219            | LineKind::Comment
220            | LineKind::StylesheetDirective
221            | LineKind::InformationField
222            | LineKind::FieldContinuation => LineContext::FreeText,
223        }
224    }
225
226    fn apply_tune_header_structure(
227        &mut self,
228        line: &mut ClassifiedLine,
229        mut tune: OpenTune,
230    ) -> LineContext {
231        if line.kind == LineKind::EmptyLine && self.is_synthetic_final_empty_line(line) {
232            self.close_tune(tune, line.index, None);
233            self.state = DocumentState::BetweenBlocks;
234            return LineContext::BetweenBlocks;
235        }
236
237        match line.kind {
238            LineKind::EmptyLine => {
239                let tune_index = tune.index;
240                self.close_tune(tune, line.index, Some(line.index));
241                self.state = DocumentState::BetweenBlocks;
242                LineContext::TuneTerminator { tune_index }
243            }
244            LineKind::InformationField
245                if line.field_code() == Some('X') && line.index != tune.start_line =>
246            {
247                self.close_tune(tune, line.index, None);
248                self.start_tune(line)
249            }
250            LineKind::InformationField if line.field_code() == Some('K') => {
251                let tune_index = tune.index;
252                tune.body_start_line = Some(line.index + 1);
253                self.state = DocumentState::TuneBody { tune };
254                LineContext::TuneHeader { tune_index }
255            }
256            LineKind::FreeText | LineKind::MusicCode => {
257                line.kind = LineKind::FreeText;
258                let tune_index = tune.index;
259                self.state = DocumentState::TuneHeader { tune };
260                LineContext::TuneHeader { tune_index }
261            }
262            LineKind::VersionLine
263            | LineKind::Comment
264            | LineKind::StylesheetDirective
265            | LineKind::InformationField
266            | LineKind::FieldContinuation
267            | LineKind::TypesetTextDirective => {
268                let tune_index = tune.index;
269                self.state = DocumentState::TuneHeader { tune };
270                LineContext::TuneHeader { tune_index }
271            }
272        }
273    }
274
275    fn apply_tune_body_structure(
276        &mut self,
277        line: &mut ClassifiedLine,
278        tune: OpenTune,
279    ) -> LineContext {
280        if line.kind == LineKind::EmptyLine && self.is_synthetic_final_empty_line(line) {
281            self.close_tune(tune, line.index, None);
282            self.pending_music_continuation = None;
283            self.state = DocumentState::BetweenBlocks;
284            return LineContext::BetweenBlocks;
285        }
286
287        match line.kind {
288            LineKind::EmptyLine => {
289                let tune_index = tune.index;
290                self.close_tune(tune, line.index, Some(line.index));
291                self.pending_music_continuation = None;
292                self.state = DocumentState::BetweenBlocks;
293                LineContext::TuneTerminator { tune_index }
294            }
295            LineKind::FreeText | LineKind::MusicCode => {
296                line.kind = LineKind::MusicCode;
297                let tune_index = tune.index;
298                self.state = DocumentState::TuneBody { tune };
299                LineContext::TuneBody { tune_index }
300            }
301            LineKind::VersionLine
302            | LineKind::Comment
303            | LineKind::StylesheetDirective
304            | LineKind::InformationField
305            | LineKind::FieldContinuation
306            | LineKind::TypesetTextDirective => {
307                let tune_index = tune.index;
308                self.state = DocumentState::TuneBody { tune };
309                LineContext::TuneBody { tune_index }
310            }
311        }
312    }
313
314    fn start_tune(&mut self, line: &ClassifiedLine) -> LineContext {
315        let tune_index = self.next_tune_index;
316        self.next_tune_index += 1;
317        self.pending_music_continuation = None;
318        self.state = DocumentState::TuneHeader {
319            tune: OpenTune {
320                index: tune_index,
321                start_line: line.index,
322                body_start_line: None,
323            },
324        };
325        LineContext::TuneHeader { tune_index }
326    }
327
328    fn close_file_header(&mut self, start_line: usize, end_line: usize) {
329        if start_line >= end_line {
330            return;
331        }
332
333        self.blocks.push(SourceBlock {
334            kind: SourceBlockKind::FileHeader,
335            span: self.line_range_span(start_line, end_line),
336            line_start: start_line,
337            line_end: end_line,
338            tune_index: None,
339        });
340    }
341
342    fn close_free_text_block(&mut self, start_line: usize, end_line: usize) {
343        if start_line >= end_line {
344            return;
345        }
346
347        self.blocks.push(SourceBlock {
348            kind: SourceBlockKind::FreeText,
349            span: self.line_range_span(start_line, end_line),
350            line_start: start_line,
351            line_end: end_line,
352            tune_index: None,
353        });
354    }
355
356    fn close_tune(&mut self, tune: OpenTune, end_line: usize, terminator_line: Option<usize>) {
357        if tune.start_line >= end_line {
358            return;
359        }
360
361        let body_start_line = tune.body_start_line.unwrap_or(end_line);
362        let header_line_end = body_start_line.min(end_line);
363        let body_line_start = body_start_line.min(end_line);
364        let span = self.line_range_span(tune.start_line, end_line);
365        let header_span = self.line_range_span(tune.start_line, header_line_end);
366        let body_span = self.line_range_span(body_line_start, end_line);
367
368        self.blocks.push(SourceBlock {
369            kind: SourceBlockKind::Tune,
370            span,
371            line_start: tune.start_line,
372            line_end: end_line,
373            tune_index: Some(tune.index),
374        });
375        self.tunes.push(TuneBlock {
376            index: tune.index,
377            span,
378            header_span,
379            body_span,
380            line_start: tune.start_line,
381            line_end: end_line,
382            header_line_start: tune.start_line,
383            header_line_end,
384            body_line_start,
385            body_line_end: end_line,
386            terminator_line,
387        });
388    }
389
390    fn track_typeset_block(&mut self, line: &ClassifiedLine) {
391        if line.kind == LineKind::TypesetTextDirective {
392            if self.typeset_text_block_start.is_none() {
393                self.typeset_text_block_start = Some(line.index);
394            }
395        } else if let Some(start_line) = self.typeset_text_block_start.take() {
396            self.close_typeset_text_block(start_line, line.index);
397        }
398    }
399
400    fn close_typeset_text_block(&mut self, start_line: usize, end_line: usize) {
401        if start_line >= end_line {
402            return;
403        }
404
405        self.blocks.push(SourceBlock {
406            kind: SourceBlockKind::TypesetText,
407            span: self.line_range_span(start_line, end_line),
408            line_start: start_line,
409            line_end: end_line,
410            tune_index: tune_index_for_context(self.lines[start_line].context),
411        });
412    }
413
414    fn track_non_note_items(&mut self, line: &ClassifiedLine) {
415        if let Some(kind) = non_note_kind_for_line(line.kind) {
416            self.non_note_items.push(NonNoteItem {
417                kind,
418                line_index: line.index,
419                span: non_note_span(line),
420            });
421        }
422
423        if let Some(span) = line.trailing_comment {
424            self.non_note_items.push(NonNoteItem {
425                kind: NonNoteKind::InlineComment,
426                line_index: line.index,
427                span,
428            });
429        }
430    }
431
432    fn track_field_continuation(&mut self, line: &ClassifiedLine) {
433        if line.kind == LineKind::FieldContinuation {
434            if let (Some(from_line), Some(marker_span)) =
435                (self.previous_field_line, line.marker_span)
436            {
437                let from_end = self
438                    .lines
439                    .get(from_line)
440                    .map(|line| line.text_span.end)
441                    .unwrap_or(marker_span.start);
442                self.continuation_edges.push(ContinuationEdge {
443                    kind: ContinuationKind::FieldContinuation,
444                    from_line,
445                    to_line: line.index,
446                    span: Span::new(from_end, marker_span.end),
447                    marker_span,
448                });
449            }
450            self.previous_field_line = Some(line.index);
451            return;
452        }
453
454        if matches!(line.kind, LineKind::InformationField) {
455            self.previous_field_line = Some(line.index);
456        } else if !matches!(
457            line.kind,
458            LineKind::Comment | LineKind::StylesheetDirective | LineKind::TypesetTextDirective
459        ) {
460            self.previous_field_line = None;
461        }
462    }
463
464    fn track_music_continuation(&mut self, line: &ClassifiedLine) {
465        if line.kind == LineKind::MusicCode {
466            if let Some(pending) = self.pending_music_continuation.take() {
467                self.continuation_edges.push(ContinuationEdge {
468                    kind: ContinuationKind::MusicBackslash,
469                    from_line: pending.from_line,
470                    to_line: line.index,
471                    span: Span::new(pending.marker_span.start, line.content_span.start),
472                    marker_span: pending.marker_span,
473                });
474            }
475
476            self.pending_music_continuation = match line.score_line_break {
477                ScoreLineBreak::Suppressed { marker_span } => Some(PendingMusicContinuation {
478                    from_line: line.index,
479                    marker_span,
480                }),
481                ScoreLineBreak::NotApplicable | ScoreLineBreak::Physical => None,
482            };
483        }
484    }
485
486    fn update_begintext_state(&mut self, line: &ClassifiedLine, text: &str) {
487        if line.kind != LineKind::TypesetTextDirective {
488            return;
489        }
490
491        let Some(name) = directive_name(text) else {
492            return;
493        };
494
495        if name.eq_ignore_ascii_case("begintext") {
496            self.in_begintext_block = true;
497        } else if name.eq_ignore_ascii_case("endtext") {
498            self.in_begintext_block = false;
499        }
500    }
501
502    fn finish(mut self) -> LineMap {
503        match self.state.clone() {
504            DocumentState::FileHeader { start_line } => {
505                self.close_file_header(start_line, self.source.line_count());
506            }
507            DocumentState::FreeText { start_line } => {
508                self.close_free_text_block(start_line, self.source.line_count());
509            }
510            DocumentState::TuneHeader { tune } | DocumentState::TuneBody { tune } => {
511                self.close_tune(tune, self.source.line_count(), None);
512            }
513            DocumentState::Start | DocumentState::BetweenBlocks => {}
514        }
515
516        if let Some(start_line) = self.typeset_text_block_start.take() {
517            self.close_typeset_text_block(start_line, self.source.line_count());
518        }
519
520        LineMap {
521            lines: self.lines,
522            blocks: self.blocks,
523            tunes: self.tunes,
524            continuation_edges: self.continuation_edges,
525            non_note_items: self.non_note_items,
526        }
527    }
528
529    fn line_range_span(&self, start_line: usize, end_line: usize) -> Span {
530        if start_line >= end_line {
531            let offset = self
532                .source
533                .line(start_line)
534                .map(|line| line.start())
535                .unwrap_or_else(|| self.source.len());
536            return Span::new(offset, offset);
537        }
538
539        let start = self
540            .source
541            .line(start_line)
542            .map(|line| line.start())
543            .unwrap_or_else(|| self.source.len());
544        let end = self
545            .source
546            .line(end_line - 1)
547            .map(|line| line.end())
548            .unwrap_or(start);
549        Span::new(start, end)
550    }
551
552    fn is_synthetic_final_empty_line(&self, line: &ClassifiedLine) -> bool {
553        line.index + 1 == self.source.line_count()
554            && line.text_span.is_empty()
555            && line.span.is_empty()
556            && line.text_span.start == self.source.len()
557            && self.source.len() > self.source.content_start()
558    }
559}
560
561#[derive(Debug, Clone, PartialEq, Eq)]
562enum DocumentState {
563    Start,
564    FileHeader { start_line: usize },
565    BetweenBlocks,
566    FreeText { start_line: usize },
567    TuneHeader { tune: OpenTune },
568    TuneBody { tune: OpenTune },
569}
570
571#[derive(Debug, Clone, PartialEq, Eq)]
572struct OpenTune {
573    index: usize,
574    start_line: usize,
575    body_start_line: Option<usize>,
576}
577
578#[derive(Debug, Clone, Copy, PartialEq, Eq)]
579struct PendingMusicContinuation {
580    from_line: usize,
581    marker_span: Span,
582}
583
584impl ClassifiedLine {
585    pub fn field_code(&self) -> Option<char> {
586        self.field.map(|field| field.code)
587    }
588}
589
590fn classify_raw_line(
591    index: usize,
592    span: Span,
593    text_span: Span,
594    text: &str,
595    in_begintext_block: bool,
596) -> ClassifiedLine {
597    let content_start = trimmed_start_offset(text);
598    let content = &text[content_start..];
599    let content_span = Span::new(text_span.start + content_start, text_span.end);
600    let trailing_comment = trailing_comment_span(text, text_span.start);
601
602    let mut line = ClassifiedLine {
603        index,
604        kind: LineKind::FreeText,
605        context: LineContext::BetweenBlocks,
606        span,
607        text_span,
608        content_span,
609        marker_span: None,
610        field: None,
611        trailing_comment,
612        score_line_break: ScoreLineBreak::NotApplicable,
613    };
614
615    if content.trim_end().is_empty() {
616        line.kind = LineKind::EmptyLine;
617        line.content_span = Span::new(text_span.end, text_span.end);
618        line.trailing_comment = None;
619        return line;
620    }
621
622    if in_begintext_block {
623        line.kind = LineKind::TypesetTextDirective;
624        line.marker_span = content
625            .strip_prefix("%%")
626            .map(|_| Span::new(line.content_span.start, line.content_span.start + 2));
627        return line;
628    }
629
630    if is_version_line(content) {
631        line.kind = LineKind::VersionLine;
632        line.marker_span = Some(Span::new(
633            line.content_span.start,
634            line.content_span.start + 4,
635        ));
636        line.trailing_comment = None;
637        return line;
638    }
639
640    if content.starts_with("%%") {
641        line.marker_span = Some(Span::new(
642            line.content_span.start,
643            line.content_span.start + 2,
644        ));
645        line.kind = if is_typeset_text_directive(content) {
646            LineKind::TypesetTextDirective
647        } else {
648            LineKind::StylesheetDirective
649        };
650        line.trailing_comment = None;
651        return line;
652    }
653
654    if content.starts_with('%') {
655        line.kind = LineKind::Comment;
656        line.marker_span = Some(Span::new(
657            line.content_span.start,
658            line.content_span.start + 1,
659        ));
660        line.trailing_comment = None;
661        return line;
662    }
663
664    if content.starts_with("+:") {
665        line.kind = LineKind::FieldContinuation;
666        line.marker_span = Some(Span::new(
667            line.content_span.start,
668            line.content_span.start + 2,
669        ));
670        return line;
671    }
672
673    if let Some(field) = parse_field_header(content, line.content_span.start, text_span.end) {
674        line.kind = LineKind::InformationField;
675        line.marker_span = Some(field.marker_span);
676        line.field = Some(field);
677        return line;
678    }
679
680    line.score_line_break = music_score_line_break(text, text_span.start);
681    line
682}
683
684fn parse_field_header(
685    content: &str,
686    content_offset: usize,
687    text_end: usize,
688) -> Option<FieldHeader> {
689    let mut chars = content.chars();
690    let code = chars.next()?;
691    if !code.is_ascii_alphabetic() || chars.next()? != ':' {
692        return None;
693    }
694
695    let marker_end = content_offset + code.len_utf8() + 1;
696    Some(FieldHeader {
697        code,
698        marker_span: Span::new(content_offset, marker_end),
699        value_span: Span::new(marker_end, text_end),
700    })
701}
702
703fn trimmed_start_offset(text: &str) -> usize {
704    text.len() - text.trim_start().len()
705}
706
707fn trailing_comment_span(text: &str, line_offset: usize) -> Option<Span> {
708    let comment_start = text.char_indices().find_map(|(offset, ch)| {
709        if ch == '%' && !is_escaped(text, offset) {
710            Some(offset)
711        } else {
712            None
713        }
714    })?;
715
716    Some(Span::new(
717        line_offset + comment_start,
718        line_offset + text.len(),
719    ))
720}
721
722fn music_score_line_break(text: &str, line_offset: usize) -> ScoreLineBreak {
723    let code_end = trailing_comment_span(text, 0)
724        .map(|span| span.start)
725        .unwrap_or(text.len());
726    let code = text[..code_end].trim_end();
727    let Some(last) = code.strip_suffix('\\') else {
728        return ScoreLineBreak::Physical;
729    };
730    let marker_start = line_offset + last.len();
731    ScoreLineBreak::Suppressed {
732        marker_span: Span::new(marker_start, marker_start + 1),
733    }
734}
735
736fn directive_name(text: &str) -> Option<&str> {
737    let content = text.trim_start();
738    let rest = content.strip_prefix("%%")?;
739    let name_end = rest
740        .find(|ch: char| ch.is_whitespace())
741        .unwrap_or(rest.len());
742    Some(&rest[..name_end])
743}
744
745fn is_typeset_text_directive(content: &str) -> bool {
746    let Some(name) = directive_name(content) else {
747        return false;
748    };
749
750    matches!(
751        name.to_ascii_lowercase().as_str(),
752        "text" | "center" | "begintext" | "endtext"
753    )
754}
755
756fn is_version_line(content: &str) -> bool {
757    let Some(rest) = content.strip_prefix("%abc") else {
758        return false;
759    };
760    rest.is_empty()
761        || rest
762            .chars()
763            .next()
764            .map(|ch| ch == '-' || ch.is_whitespace())
765            .unwrap_or(true)
766}
767
768fn non_note_kind_for_line(kind: LineKind) -> Option<NonNoteKind> {
769    match kind {
770        LineKind::VersionLine => Some(NonNoteKind::VersionLine),
771        LineKind::EmptyLine => Some(NonNoteKind::EmptyLine),
772        LineKind::Comment => Some(NonNoteKind::Comment),
773        LineKind::StylesheetDirective => Some(NonNoteKind::StylesheetDirective),
774        LineKind::InformationField => Some(NonNoteKind::InformationField),
775        LineKind::FieldContinuation => Some(NonNoteKind::FieldContinuation),
776        LineKind::FreeText => Some(NonNoteKind::FreeText),
777        LineKind::TypesetTextDirective => Some(NonNoteKind::TypesetTextDirective),
778        LineKind::MusicCode => None,
779    }
780}
781
782fn non_note_span(line: &ClassifiedLine) -> Span {
783    if line.kind == LineKind::EmptyLine {
784        line.text_span
785    } else {
786        line.content_span
787    }
788}
789
790fn tune_index_for_context(context: LineContext) -> Option<usize> {
791    match context {
792        LineContext::TuneHeader { tune_index }
793        | LineContext::TuneBody { tune_index }
794        | LineContext::TuneTerminator { tune_index } => Some(tune_index),
795        LineContext::Preamble
796        | LineContext::FileHeader
797        | LineContext::BetweenBlocks
798        | LineContext::FreeText
799        | LineContext::TypesetText => None,
800    }
801}
802
803pub(crate) fn tokenize_surface(source: &SourceText, line_map: &LineMap) -> Vec<SurfaceToken> {
804    let mut tokens = Vec::new();
805
806    for line in &line_map.lines {
807        match line.kind {
808            LineKind::InformationField => {
809                if let Some(field) = line.field {
810                    tokens.push(SurfaceToken {
811                        kind: SurfaceKind::Field,
812                        span: field.marker_span,
813                    });
814                }
815            }
816            LineKind::FieldContinuation => {
817                if let Some(marker_span) = line.marker_span {
818                    tokens.push(SurfaceToken {
819                        kind: SurfaceKind::FieldContinuation,
820                        span: marker_span,
821                    });
822                }
823            }
824            LineKind::Comment => tokens.push(SurfaceToken {
825                kind: SurfaceKind::Comment,
826                span: line.content_span,
827            }),
828            LineKind::StylesheetDirective | LineKind::TypesetTextDirective => {
829                tokens.push(SurfaceToken {
830                    kind: SurfaceKind::Directive,
831                    span: line.content_span,
832                });
833            }
834            LineKind::FreeText => tokens.push(SurfaceToken {
835                kind: SurfaceKind::FreeText,
836                span: line.content_span,
837            }),
838            LineKind::MusicCode => {
839                if let Some(text) = source.slice(line.text_span) {
840                    tokenize_music_line(text, line.text_span.start, &mut tokens);
841                }
842            }
843            LineKind::VersionLine | LineKind::EmptyLine => {}
844        }
845
846        if line.kind != LineKind::MusicCode
847            && let Some(span) = line.trailing_comment
848        {
849            tokens.push(SurfaceToken {
850                kind: SurfaceKind::Comment,
851                span,
852            });
853        }
854    }
855
856    tokens
857}
858
859fn tokenize_music_line(line: &str, line_offset: usize, tokens: &mut Vec<SurfaceToken>) {
860    for (column, ch) in line.char_indices() {
861        let kind = match ch {
862            '%' if !is_escaped(line, column) => {
863                tokens.push(SurfaceToken {
864                    kind: SurfaceKind::Comment,
865                    span: Span::new(line_offset + column, line_offset + line.len()),
866                });
867                break;
868            }
869            '|' => SurfaceKind::Barline,
870            'A'..='G' | 'a'..='g' => SurfaceKind::Note,
871            'z' | 'x' => SurfaceKind::Rest,
872            _ => SurfaceKind::Other,
873        };
874        if kind != SurfaceKind::Other {
875            tokens.push(SurfaceToken {
876                kind,
877                span: Span::new(line_offset + column, line_offset + column + ch.len_utf8()),
878            });
879        }
880    }
881}