Skip to main content

chordsketch_core/
parser.rs

1//! Parser that transforms a token stream into a ChordPro AST.
2//!
3//! The parser accepts the flat token sequence produced by [`crate::Lexer`] and
4//! builds a [`Song`] AST. Each source line is classified as a directive, a
5//! lyrics line (with optional inline chord annotations), an empty line, or a
6//! comment (from `{comment}`, `{comment_italic}`, `{comment_box}` directives,
7//! or file-level `#` comment lines).
8//!
9//! # Directive Classification
10//!
11//! Directives are classified into typed variants via [`DirectiveKind`]. The
12//! parser resolves short aliases (e.g., `t` → `title`, `soc` →
13//! `start_of_chorus`) and normalizes names to their canonical lowercase form.
14//! Metadata directives automatically populate the [`Song::metadata`] fields.
15//!
16//! # Convenience Function
17//!
18//! The [`parse`] function combines lexing and parsing into a single step:
19//!
20//! ```
21//! use chordsketch_core::parser::parse;
22//!
23//! let song = parse("{title: Hello}\n[Am]World").unwrap();
24//! assert_eq!(song.metadata.title.as_deref(), Some("Hello"));
25//! assert_eq!(song.lines.len(), 2);
26//! ```
27//!
28//! # Error Handling
29//!
30//! The parser returns [`ParseError`] when the token stream contains structural
31//! problems such as unclosed directives, unclosed chords, or empty directives.
32
33use crate::Lexer;
34use crate::ast::{
35    Chord, CommentStyle, Directive, DirectiveKind, ImageAttributes, Line, LyricsLine,
36    LyricsSegment, Song,
37};
38use crate::inline_markup;
39use crate::token::{Span, Token, TokenKind};
40
41// ---------------------------------------------------------------------------
42// ParseError
43// ---------------------------------------------------------------------------
44
45/// An error encountered during parsing.
46///
47/// Each error carries a human-readable message and the [`Span`] in the source
48/// text where the problem was detected.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct ParseError {
51    /// A description of what went wrong.
52    pub message: String,
53    /// The location in the source text where the error was detected.
54    pub span: Span,
55}
56
57impl ParseError {
58    /// Creates a new `ParseError` with the given message and span.
59    fn new(message: impl Into<String>, span: Span) -> Self {
60        Self {
61            message: message.into(),
62            span,
63        }
64    }
65
66    /// Returns the 1-based line number where the error was detected.
67    #[must_use]
68    pub fn line(&self) -> usize {
69        self.span.start.line
70    }
71
72    /// Returns the 1-based column number where the error was detected.
73    #[must_use]
74    pub fn column(&self) -> usize {
75        self.span.start.column
76    }
77}
78
79impl core::fmt::Display for ParseError {
80    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
81        write!(
82            f,
83            "parse error at line {}, column {}: {}",
84            self.span.start.line, self.span.start.column, self.message
85        )
86    }
87}
88
89impl std::error::Error for ParseError {}
90
91// ---------------------------------------------------------------------------
92// ParseResult
93// ---------------------------------------------------------------------------
94
95/// The result of a lenient parse, containing a partial AST and any errors.
96///
97/// When using [`Parser::parse_lenient`] or [`parse_lenient`], the parser
98/// recovers from errors by skipping problematic lines and continuing.
99/// The `song` field contains all successfully parsed lines, and `errors`
100/// contains all problems encountered.
101///
102/// # Examples
103///
104/// ```
105/// use chordsketch_core::parser::parse_lenient;
106///
107/// let result = parse_lenient("{title: Test}\n[Am\nHello world");
108/// assert_eq!(result.song.metadata.title.as_deref(), Some("Test"));
109/// assert_eq!(result.errors.len(), 1); // unclosed chord on line 2
110/// assert_eq!(result.song.lines.len(), 2); // title directive + lyrics (error line skipped)
111/// ```
112#[derive(Debug, Clone)]
113pub struct ParseResult {
114    /// The partial AST with all successfully parsed lines.
115    pub song: Song,
116    /// All errors encountered during parsing.
117    pub errors: Vec<ParseError>,
118}
119
120impl ParseResult {
121    /// Returns `true` if no errors were encountered.
122    #[must_use]
123    pub fn is_ok(&self) -> bool {
124        self.errors.is_empty()
125    }
126
127    /// Returns `true` if any errors were encountered.
128    #[must_use]
129    pub fn has_errors(&self) -> bool {
130        !self.errors.is_empty()
131    }
132}
133
134// ---------------------------------------------------------------------------
135// Parser
136// ---------------------------------------------------------------------------
137
138/// A parser that transforms a token stream into a [`Song`] AST.
139///
140/// The parser is created from a `Vec<Token>` (typically produced by
141/// [`Lexer::tokenize`]) and consumes tokens one at a time, building up the
142/// AST line by line.
143pub struct Parser {
144    /// The token stream to consume.
145    tokens: Vec<Token>,
146    /// Current index into `tokens`.
147    pos: usize,
148    /// When inside a verbatim section (tab, grid, ABC, Lilypond, SVG, textblock),
149    /// this holds the end-directive name that will close the section.
150    /// Lines inside verbatim sections are treated as plain text (no chord parsing).
151    verbatim_end: Option<String>,
152}
153
154impl Parser {
155    /// Creates a new parser for the given token stream.
156    ///
157    /// # Panics
158    ///
159    /// Panics if `tokens` is empty. The lexer always appends an
160    /// [`Eof`](crate::token::TokenKind::Eof) token, so a well-formed
161    /// token stream is never empty.
162    #[must_use]
163    pub fn new(tokens: Vec<Token>) -> Self {
164        assert!(
165            !tokens.is_empty(),
166            "token list must contain at least an Eof token"
167        );
168        Self {
169            tokens,
170            pos: 0,
171            verbatim_end: None,
172        }
173    }
174
175    /// Parses the token stream and returns a [`Song`] AST.
176    ///
177    /// Metadata directives (`{title}`, `{artist}`, etc.) automatically
178    /// populate [`Song::metadata`]. Comment directives are converted to
179    /// [`Line::Comment`] with the appropriate [`CommentStyle`].
180    ///
181    /// Returns a [`ParseError`] on the first structural problem encountered
182    /// (e.g., unclosed directives or chords). Use [`parse_lenient`] to
183    /// collect all errors and obtain a partial AST.
184    #[must_use = "callers must handle the parse result"]
185    pub fn parse(mut self) -> Result<Song, ParseError> {
186        let mut song = Song::new();
187
188        while !self.is_at_end() {
189            let line = self.parse_line()?;
190
191            // If this is a metadata directive without a selector, populate
192            // the Song's metadata. Selector-bearing directives are deferred
193            // to filter_song(), which re-derives metadata after filtering.
194            if let Line::Directive(ref directive) = line {
195                if directive.selector.is_none() {
196                    Self::populate_metadata(&mut song.metadata, directive);
197                }
198            }
199
200            song.lines.push(line);
201        }
202
203        song.apply_define_displays();
204        Ok(song)
205    }
206
207    /// Parses the token stream leniently, collecting all errors.
208    ///
209    /// Unlike [`parse`], this method does not stop at the first error.
210    /// When a line cannot be parsed, the error is recorded and the parser
211    /// skips to the next line to continue. The returned [`ParseResult`]
212    /// contains the partial AST (all successfully parsed lines) and a
213    /// list of all errors encountered.
214    #[must_use = "callers must handle the parse result"]
215    pub fn parse_lenient(self) -> ParseResult {
216        self.parse_lenient_limited(0)
217    }
218
219    /// Like [`parse_lenient`](Self::parse_lenient), but stops collecting errors
220    /// after `max_errors` have been recorded. Set to `0` to disable the limit.
221    #[must_use = "callers must handle the parse result"]
222    pub fn parse_lenient_limited(mut self, max_errors: usize) -> ParseResult {
223        let mut song = Song::new();
224        let mut errors = Vec::new();
225
226        while !self.is_at_end() {
227            match self.parse_line() {
228                Ok(line) => {
229                    if let Line::Directive(ref directive) = line {
230                        if directive.selector.is_none() {
231                            Self::populate_metadata(&mut song.metadata, directive);
232                        }
233                    }
234                    song.lines.push(line);
235                }
236                Err(e) => {
237                    if max_errors == 0 || errors.len() < max_errors {
238                        errors.push(e);
239                    }
240                    // Skip to the next line to recover.
241                    self.skip_to_next_line();
242                }
243            }
244        }
245
246        song.apply_define_displays();
247        ParseResult { song, errors }
248    }
249
250    /// Advances past all tokens until the next Newline or Eof,
251    /// then consumes the Newline if present. Used for error recovery.
252    fn skip_to_next_line(&mut self) {
253        while !self.is_at_end() {
254            if self.peek_kind() == &TokenKind::Newline {
255                self.advance();
256                return;
257            }
258            self.advance();
259        }
260    }
261
262    // -- Metadata population ------------------------------------------------
263
264    /// Maximum number of entries per multi-value metadata field (e.g.,
265    /// subtitles, artists). Entries beyond this limit are silently dropped
266    /// to prevent resource exhaustion from maliciously crafted input.
267    const MAX_METADATA_ENTRIES: usize = 1000;
268
269    /// Push a value onto a multi-value metadata field if the cap has not
270    /// been reached.
271    fn push_if_under_cap<T>(vec: &mut Vec<T>, value: T) {
272        if vec.len() < Self::MAX_METADATA_ENTRIES {
273            vec.push(value);
274        }
275    }
276
277    /// Populate metadata fields from a directive's kind and value.
278    ///
279    /// This is called during parsing for unselectored directives, and again
280    /// by [`SelectorContext::filter_song`](crate::selector::SelectorContext::filter_song)
281    /// after filtering to re-derive metadata from matching selector-bearing directives.
282    pub fn populate_metadata(metadata: &mut crate::ast::Metadata, directive: &Directive) {
283        let value = match directive.value.as_deref() {
284            Some(v) => v.to_string(),
285            None => return, // Metadata directives without values are no-ops.
286        };
287
288        match directive.kind {
289            DirectiveKind::Title => {
290                metadata.title = Some(value);
291            }
292            DirectiveKind::Subtitle => {
293                Self::push_if_under_cap(&mut metadata.subtitles, value);
294            }
295            DirectiveKind::Artist => {
296                Self::push_if_under_cap(&mut metadata.artists, value);
297            }
298            DirectiveKind::Composer => {
299                Self::push_if_under_cap(&mut metadata.composers, value);
300            }
301            DirectiveKind::Lyricist => {
302                Self::push_if_under_cap(&mut metadata.lyricists, value);
303            }
304            DirectiveKind::Album => {
305                metadata.album = Some(value);
306            }
307            DirectiveKind::Year => {
308                metadata.year = Some(value);
309            }
310            DirectiveKind::Key => {
311                metadata.key = Some(value);
312            }
313            DirectiveKind::Tempo => {
314                metadata.tempo = Some(value);
315            }
316            DirectiveKind::Time => {
317                metadata.time = Some(value);
318            }
319            DirectiveKind::Capo => {
320                metadata.capo = Some(value);
321            }
322            DirectiveKind::SortTitle => {
323                metadata.sort_title = Some(value);
324            }
325            DirectiveKind::SortArtist => {
326                metadata.sort_artist = Some(value);
327            }
328            DirectiveKind::Arranger => {
329                Self::push_if_under_cap(&mut metadata.arrangers, value);
330            }
331            DirectiveKind::Copyright => {
332                metadata.copyright = Some(value);
333            }
334            DirectiveKind::Duration => {
335                metadata.duration = Some(value);
336            }
337            DirectiveKind::Tag => {
338                Self::push_if_under_cap(&mut metadata.tags, value);
339            }
340            DirectiveKind::Meta(ref key) => match key.to_ascii_lowercase().as_str() {
341                "title" | "t" => metadata.title = Some(value),
342                "subtitle" | "st" => Self::push_if_under_cap(&mut metadata.subtitles, value),
343                "artist" => Self::push_if_under_cap(&mut metadata.artists, value),
344                "composer" => Self::push_if_under_cap(&mut metadata.composers, value),
345                "lyricist" => Self::push_if_under_cap(&mut metadata.lyricists, value),
346                "album" => metadata.album = Some(value),
347                "year" => metadata.year = Some(value),
348                "key" => metadata.key = Some(value),
349                "tempo" => metadata.tempo = Some(value),
350                "time" => metadata.time = Some(value),
351                "capo" => metadata.capo = Some(value),
352                "sorttitle" => metadata.sort_title = Some(value),
353                "sortartist" => metadata.sort_artist = Some(value),
354                "arranger" => Self::push_if_under_cap(&mut metadata.arrangers, value),
355                "copyright" => metadata.copyright = Some(value),
356                "duration" => metadata.duration = Some(value),
357                "tag" => Self::push_if_under_cap(&mut metadata.tags, value),
358                _ => Self::push_if_under_cap(&mut metadata.custom, (key.clone(), value)),
359            },
360            DirectiveKind::Unknown(ref name) => {
361                Self::push_if_under_cap(&mut metadata.custom, (name.clone(), value));
362            }
363            _ => {}
364        }
365    }
366
367    // -- Token navigation ---------------------------------------------------
368
369    /// Returns `true` when all meaningful tokens have been consumed.
370    fn is_at_end(&self) -> bool {
371        self.pos >= self.tokens.len() || self.peek_kind() == &TokenKind::Eof
372    }
373
374    /// Returns a reference to the current token's kind without advancing.
375    fn peek_kind(&self) -> &TokenKind {
376        self.tokens
377            .get(self.pos)
378            .map(|t| &t.kind)
379            .unwrap_or(&TokenKind::Eof)
380    }
381
382    /// Returns a reference to the current token without advancing.
383    fn peek(&self) -> &Token {
384        // SAFETY: the caller ensures we are not past the end. The last token
385        // is always Eof, so indexing is safe as long as `pos < tokens.len()`.
386        &self.tokens[self.pos]
387    }
388
389    /// Advances past the current token and returns it.
390    fn advance(&mut self) -> &Token {
391        let tok = &self.tokens[self.pos];
392        self.pos += 1;
393        tok
394    }
395
396    // -- Line parsing -------------------------------------------------------
397
398    /// Parses a single line (up to and including the next Newline or Eof).
399    fn parse_line(&mut self) -> Result<Line, ParseError> {
400        let in_verbatim = self.verbatim_end.is_some();
401
402        match self.peek_kind() {
403            // An empty line: just a Newline token.
404            TokenKind::Newline => {
405                self.advance();
406                Ok(Line::Empty)
407            }
408            // A directive line: starts with `{`.
409            TokenKind::DirectiveOpen => {
410                // Inside a verbatim section: only the matching end directive
411                // is parsed; everything else is verbatim text.
412                if in_verbatim && !self.is_verbatim_end_ahead() {
413                    return self.parse_verbatim_line();
414                }
415                let line = self.parse_directive_line()?;
416                // Track verbatim section state.
417                if let Line::Directive(ref d) = line {
418                    if let Some(end_name) = Self::verbatim_end_for(&d.kind) {
419                        self.verbatim_end = Some(end_name);
420                    } else if d.kind.is_section_end() && in_verbatim {
421                        self.verbatim_end = None;
422                    }
423                }
424                Ok(line)
425            }
426            // Inside a verbatim section: treat as plain text (no chord parsing).
427            _ if in_verbatim => self.parse_verbatim_line(),
428            // File-level `#` comment: first text token starts with `#` at
429            // column 1 (no leading whitespace). The ChordPro spec says "a line
430            // starting with `#`", which means `#` must be the first character.
431            TokenKind::Text(t) if t.starts_with('#') => self.parse_hash_comment_line(),
432            // Anything else: a lyrics line.
433            _ => self.parse_lyrics_line(),
434        }
435    }
436
437    /// Returns the end-directive name for section types that use verbatim
438    /// content (tab, grid, ABC, Lilypond, SVG, textblock). Returns `None` for
439    /// sections that parse chords normally (chorus, verse, bridge, custom).
440    fn verbatim_end_for(kind: &DirectiveKind) -> Option<String> {
441        match kind {
442            DirectiveKind::StartOfTab => Some("end_of_tab".to_string()),
443            DirectiveKind::StartOfGrid => Some("end_of_grid".to_string()),
444            DirectiveKind::StartOfAbc => Some("end_of_abc".to_string()),
445            DirectiveKind::StartOfLy => Some("end_of_ly".to_string()),
446            DirectiveKind::StartOfSvg => Some("end_of_svg".to_string()),
447            DirectiveKind::StartOfTextblock => Some("end_of_textblock".to_string()),
448            DirectiveKind::StartOfMusicxml => Some("end_of_musicxml".to_string()),
449            _ => None,
450        }
451    }
452
453    /// Peeks ahead to check if the current `{` starts the end directive
454    /// that closes the current verbatim section. This allows the parser
455    /// to exit verbatim mode.
456    ///
457    /// Only checks the next token after `DirectiveOpen` for the directive
458    /// name text; the full directive structure (including `DirectiveClose`)
459    /// is validated later by `parse_directive_line`.
460    fn is_verbatim_end_ahead(&self) -> bool {
461        if let Some(ref end_name) = self.verbatim_end {
462            if self.pos + 1 < self.tokens.len() {
463                if let TokenKind::Text(ref text) = self.tokens[self.pos + 1].kind {
464                    let trimmed = text.trim().to_ascii_lowercase();
465                    // Check full name
466                    if trimmed == *end_name {
467                        return true;
468                    }
469                    // Check short aliases
470                    return match end_name.as_str() {
471                        "end_of_tab" => trimmed == "eot",
472                        "end_of_grid" => trimmed == "eog",
473                        _ => false,
474                    };
475                }
476            }
477        }
478        false
479    }
480
481    /// Parses a verbatim text line (used inside tab, grid, and delegate environment sections).
482    ///
483    /// All tokens until the next Newline/Eof are collected as plain text,
484    /// with no chord bracket interpretation. The result is a lyrics line
485    /// with a single text-only segment.
486    fn parse_verbatim_line(&mut self) -> Result<Line, ParseError> {
487        let text = self.collect_raw_line();
488
489        // Consume the newline.
490        if self.peek_kind() == &TokenKind::Newline {
491            self.advance();
492        }
493
494        if text.is_empty() {
495            Ok(Line::Empty)
496        } else {
497            Ok(Line::Lyrics(LyricsLine {
498                segments: vec![LyricsSegment::text_only(text)],
499            }))
500        }
501    }
502
503    /// Parses a file-level `#` comment line and emits
504    /// `Line::Comment(CommentStyle::Normal, text)`.
505    ///
506    /// The leading `#` is stripped; one immediately-following space is also
507    /// stripped so that `# My comment` produces `"My comment"` rather than
508    /// `" My comment"`. Inline chord brackets and directive delimiters are
509    /// consumed as literal characters (they lose their structural meaning inside
510    /// a source comment).
511    fn parse_hash_comment_line(&mut self) -> Result<Line, ParseError> {
512        let raw = self.collect_raw_line();
513
514        // Consume the trailing newline.
515        if self.peek_kind() == &TokenKind::Newline {
516            self.advance();
517        }
518
519        // The dispatch guard guarantees `raw` starts with `#` (no leading
520        // whitespace). Strip the `#` and one optional space.
521        let after_hash = raw
522            .strip_prefix('#')
523            .expect("dispatch guard guarantees raw starts with '#'");
524        let text = after_hash.strip_prefix(' ').unwrap_or(after_hash);
525
526        Ok(Line::Comment(CommentStyle::Normal, text.to_string()))
527    }
528
529    /// Collects all tokens on the current line (up to but not including the
530    /// trailing `Newline` or `Eof`) into a `String`, mapping structural tokens
531    /// (`[`, `]`, `{`, `}`, `:`) back to their literal characters.
532    ///
533    /// Does **not** consume the trailing `Newline`; the caller is responsible
534    /// for advancing past it.
535    fn collect_raw_line(&mut self) -> String {
536        let mut raw = String::new();
537        loop {
538            match self.peek_kind() {
539                TokenKind::Newline | TokenKind::Eof => break,
540                TokenKind::Text(t) => {
541                    raw.push_str(t);
542                    self.advance();
543                }
544                TokenKind::ChordOpen => {
545                    raw.push('[');
546                    self.advance();
547                }
548                TokenKind::ChordClose => {
549                    raw.push(']');
550                    self.advance();
551                }
552                TokenKind::DirectiveOpen => {
553                    raw.push('{');
554                    self.advance();
555                }
556                TokenKind::DirectiveClose => {
557                    raw.push('}');
558                    self.advance();
559                }
560                TokenKind::Colon => {
561                    raw.push(':');
562                    self.advance();
563                }
564            }
565        }
566        raw
567    }
568
569    // -- Directive parsing --------------------------------------------------
570
571    /// Parses a directive line: `{name}` or `{name: value}`.
572    ///
573    /// After parsing the directive itself, consumes the trailing Newline (or
574    /// verifies Eof). Comment directives (`comment`, `comment_italic`,
575    /// `comment_box`) are converted to [`Line::Comment`].
576    fn parse_directive_line(&mut self) -> Result<Line, ParseError> {
577        let open_span = self.peek().span;
578        self.advance(); // consume DirectiveOpen
579
580        // Collect the directive name.
581        let name = self.parse_directive_name(&open_span)?;
582
583        // Check for a colon (indicates a value follows).
584        let value = if self.peek_kind() == &TokenKind::Colon {
585            self.advance(); // consume Colon
586            Some(self.parse_directive_value())
587        } else {
588            None
589        };
590
591        // Expect the closing brace.
592        if self.peek_kind() != &TokenKind::DirectiveClose {
593            let span = self.peek().span;
594            return Err(ParseError::new("unclosed directive: expected `}`", span));
595        }
596        self.advance();
597
598        // Consume trailing newline if present.
599        if self.peek_kind() == &TokenKind::Newline {
600            self.advance();
601        }
602
603        // Trim whitespace from name and value.
604        let name = name.trim().to_string();
605        let value = value.map(|v| v.trim().to_string());
606
607        // Classify the directive, detecting any selector suffix.
608        let (kind, selector) = DirectiveKind::resolve_with_selector(&name);
609
610        // Comment directives without a selector → Line::Comment with appropriate style.
611        // Comment directives WITH a selector are kept as Line::Directive so
612        // the selector information is preserved for downstream filtering.
613        if kind.is_comment() && selector.is_none() {
614            let style = match kind {
615                DirectiveKind::Comment => CommentStyle::Normal,
616                DirectiveKind::CommentItalic => CommentStyle::Italic,
617                DirectiveKind::CommentBox => CommentStyle::Boxed,
618                _ => CommentStyle::Normal,
619            };
620            let text = value.unwrap_or_default();
621            return Ok(Line::Comment(style, text));
622        }
623
624        // Meta directive: split value into key + remaining value.
625        if matches!(kind, DirectiveKind::Meta(_)) {
626            if let Some(ref val) = value {
627                let trimmed = val.trim();
628                if let Some(pos) = trimmed.find(|c: char| c.is_whitespace()) {
629                    let meta_key = trimmed[..pos].to_string();
630                    let meta_value = trimmed[pos..].trim().to_string();
631                    let kind = DirectiveKind::Meta(meta_key.clone());
632                    let directive = Directive {
633                        name: "meta".to_string(),
634                        value: if meta_value.is_empty() {
635                            None
636                        } else {
637                            Some(meta_value)
638                        },
639                        kind,
640                        selector,
641                    };
642                    return Ok(Line::Directive(directive));
643                } else if !trimmed.is_empty() {
644                    // Only a key, no value
645                    let meta_key = trimmed.to_string();
646                    let kind = DirectiveKind::Meta(meta_key);
647                    let directive = Directive {
648                        name: "meta".to_string(),
649                        value: None,
650                        kind,
651                        selector,
652                    };
653                    return Ok(Line::Directive(directive));
654                }
655            }
656            // {meta} without value — treat as unknown
657            let directive = Directive {
658                name: "meta".to_string(),
659                value: None,
660                kind: DirectiveKind::Unknown("meta".to_string()),
661                selector,
662            };
663            return Ok(Line::Directive(directive));
664        }
665
666        // Image directive: parse key=value attributes from the value string.
667        if kind.is_image() {
668            let attrs = match &value {
669                Some(v) => parse_image_attributes(v),
670                None => ImageAttributes::default(),
671            };
672            let kind = DirectiveKind::Image(attrs);
673            let canonical = kind.canonical_name().to_string();
674            let directive = Directive {
675                name: canonical,
676                value,
677                kind,
678                selector,
679            };
680            return Ok(Line::Directive(directive));
681        }
682
683        // Build the directive with canonical name, kind, and optional selector.
684        let canonical = kind.full_canonical_name();
685        let directive = Directive {
686            name: canonical,
687            value,
688            kind,
689            selector,
690        };
691
692        Ok(Line::Directive(directive))
693    }
694
695    /// Parses the directive name (text between `{` and either `:` or `}`).
696    fn parse_directive_name(&mut self, open_span: &Span) -> Result<String, ParseError> {
697        let mut name = String::new();
698
699        loop {
700            match self.peek_kind() {
701                TokenKind::Text(text) => {
702                    name.push_str(text);
703                    self.advance();
704                }
705                TokenKind::Colon | TokenKind::DirectiveClose => break,
706                TokenKind::Eof | TokenKind::Newline => {
707                    return Err(ParseError::new(
708                        "unclosed directive: expected `}`",
709                        *open_span,
710                    ));
711                }
712                _ => {
713                    // Unexpected token inside directive name (e.g., ChordOpen).
714                    let tok = self.peek();
715                    return Err(ParseError::new(
716                        format!("unexpected {:?} in directive name", tok.kind),
717                        tok.span,
718                    ));
719                }
720            }
721        }
722
723        if name.trim().is_empty() {
724            return Err(ParseError::new("empty directive name", *open_span));
725        }
726
727        Ok(name)
728    }
729
730    /// Parses the directive value (everything between `:` and `}`).
731    ///
732    /// The value may contain text tokens and other tokens (like ChordOpen/Close)
733    /// that appear literally in the directive value. We collect all text content.
734    fn parse_directive_value(&mut self) -> String {
735        let mut value = String::new();
736
737        loop {
738            match self.peek_kind() {
739                TokenKind::Text(text) => {
740                    value.push_str(text);
741                    self.advance();
742                }
743                TokenKind::DirectiveClose | TokenKind::Eof | TokenKind::Newline => break,
744                TokenKind::Colon => {
745                    // Additional colons in value are literal text.
746                    value.push(':');
747                    self.advance();
748                }
749                TokenKind::ChordOpen => {
750                    value.push('[');
751                    self.advance();
752                }
753                TokenKind::ChordClose => {
754                    value.push(']');
755                    self.advance();
756                }
757                TokenKind::DirectiveOpen => {
758                    value.push('{');
759                    self.advance();
760                }
761            }
762        }
763
764        value
765    }
766
767    // -- Lyrics line parsing ------------------------------------------------
768
769    /// Parses a lyrics line containing text and optional chord annotations.
770    ///
771    /// The line is split into [`LyricsSegment`]s, each consisting of an
772    /// optional chord followed by lyric text.
773    fn parse_lyrics_line(&mut self) -> Result<Line, ParseError> {
774        let mut segments: Vec<LyricsSegment> = Vec::new();
775        let mut current_chord: Option<Chord> = None;
776        let mut current_text = String::new();
777
778        loop {
779            match self.peek_kind() {
780                TokenKind::Newline | TokenKind::Eof => {
781                    break;
782                }
783                TokenKind::ChordOpen => {
784                    // Flush the current segment before starting a new chord.
785                    if current_chord.is_some() || !current_text.is_empty() {
786                        segments.push(LyricsSegment::new(
787                            current_chord.take(),
788                            core::mem::take(&mut current_text),
789                        ));
790                    }
791
792                    current_chord = Some(self.parse_chord()?);
793                }
794                TokenKind::Text(text) => {
795                    current_text.push_str(text);
796                    self.advance();
797                }
798                TokenKind::DirectiveOpen => {
799                    // A directive starting mid-line is unexpected in well-formed
800                    // ChordPro, but we handle it gracefully by treating it as
801                    // the start of a directive line. First, flush the current
802                    // lyrics if any, then break and let the directive be parsed
803                    // on a subsequent call.
804                    //
805                    // However, per the task spec, directives always start at the
806                    // beginning of a line. If we see one mid-line, it is likely
807                    // a stray `{`. Treat the rest as text.
808                    current_text.push('{');
809                    self.advance();
810                }
811                TokenKind::DirectiveClose => {
812                    // A stray `}` outside a directive — include as literal text.
813                    current_text.push('}');
814                    self.advance();
815                }
816                TokenKind::ChordClose => {
817                    // A stray `]` outside a chord — include as literal text.
818                    current_text.push(']');
819                    self.advance();
820                }
821                TokenKind::Colon => {
822                    // Outside a directive, colons are text. The lexer only emits
823                    // Colon inside directives, so this shouldn't normally occur
824                    // here, but handle defensively.
825                    current_text.push(':');
826                    self.advance();
827                }
828            }
829        }
830
831        // Flush the last segment.
832        if current_chord.is_some() || !current_text.is_empty() {
833            segments.push(LyricsSegment::new(current_chord, current_text));
834        }
835
836        // Consume the trailing newline if present.
837        if self.peek_kind() == &TokenKind::Newline {
838            self.advance();
839        }
840
841        if segments.is_empty() {
842            Ok(Line::Empty)
843        } else {
844            // Parse inline markup for each segment's text.
845            let segments = segments
846                .into_iter()
847                .map(Self::apply_inline_markup)
848                .collect();
849            Ok(Line::Lyrics(LyricsLine { segments }))
850        }
851    }
852
853    /// Applies inline markup parsing to a lyrics segment.
854    ///
855    /// If the segment's text contains inline markup tags, the `spans` field is
856    /// populated with the parsed span tree and the `text` field is updated to
857    /// contain only the plain text (markup tags stripped). If no markup is found,
858    /// the segment is returned unchanged.
859    fn apply_inline_markup(mut segment: LyricsSegment) -> LyricsSegment {
860        if inline_markup::has_inline_markup(&segment.text) {
861            let spans = inline_markup::parse_inline_markup(&segment.text);
862            if !spans.is_empty() {
863                // Update text to be the plain-text version (tags stripped)
864                segment.text = inline_markup::spans_to_plain_text(&spans);
865                segment.spans = spans;
866            }
867        }
868        segment
869    }
870
871    /// Parses a chord annotation: `[` text `]`.
872    ///
873    /// The opening bracket has already been peeked; this method consumes it,
874    /// the chord text, and the closing bracket.
875    fn parse_chord(&mut self) -> Result<Chord, ParseError> {
876        let open_span = self.peek().span;
877        self.advance(); // consume ChordOpen
878
879        let mut name = String::new();
880
881        loop {
882            match self.peek_kind() {
883                TokenKind::Text(text) => {
884                    name.push_str(text);
885                    self.advance();
886                }
887                TokenKind::ChordClose => {
888                    self.advance(); // consume ChordClose
889                    break;
890                }
891                TokenKind::Newline | TokenKind::Eof => {
892                    return Err(ParseError::new("unclosed chord: expected `]`", open_span));
893                }
894                _ => {
895                    // Unexpected token inside a chord (e.g., DirectiveOpen).
896                    let tok = self.peek();
897                    return Err(ParseError::new(
898                        format!("unexpected {:?} inside chord", tok.kind),
899                        tok.span,
900                    ));
901                }
902            }
903        }
904
905        Ok(Chord::new(name))
906    }
907}
908
909// ---------------------------------------------------------------------------
910// Convenience function
911// ---------------------------------------------------------------------------
912
913/// Parses a ChordPro source string into a [`Song`] AST.
914///
915/// This is a convenience function that runs the lexer and parser in sequence.
916/// Metadata directives populate [`Song::metadata`] automatically.
917///
918/// # Errors
919///
920/// Returns a [`ParseError`] if the input contains structural problems.
921///
922/// # Examples
923///
924/// ```
925/// use chordsketch_core::parser::parse;
926///
927/// let song = parse("{title: Hello World}\n[Am]La la la").unwrap();
928/// assert_eq!(song.metadata.title.as_deref(), Some("Hello World"));
929/// assert_eq!(song.lines.len(), 2);
930/// ```
931#[must_use = "callers must handle the parse error"]
932pub fn parse(input: &str) -> Result<Song, ParseError> {
933    parse_with_options(input, &ParseOptions::default())
934}
935
936/// Options that control parser behavior.
937#[derive(Debug, Clone)]
938pub struct ParseOptions {
939    /// Maximum input size in bytes. Inputs exceeding this limit are rejected
940    /// with a [`ParseError`] before lexing begins. Set to `0` to disable.
941    ///
942    /// Default: 10 MB (10 × 1024 × 1024 bytes).
943    pub max_input_size: usize,
944
945    /// Maximum number of errors to collect in lenient parsing mode.
946    /// Once this limit is reached, additional errors are silently discarded
947    /// to prevent unbounded memory growth from highly malformed input.
948    /// Set to `0` to disable the limit.
949    ///
950    /// Default: 1000.
951    pub max_errors: usize,
952}
953
954impl Default for ParseOptions {
955    fn default() -> Self {
956        Self {
957            max_input_size: 10 * 1024 * 1024, // 10 MB
958            max_errors: 1000,
959        }
960    }
961}
962
963/// Parses a ChordPro source string into a [`Song`] AST with custom options.
964///
965/// See [`parse`] for details. This variant allows configuring parser behavior
966/// via [`ParseOptions`].
967///
968/// # Errors
969///
970/// Returns a [`ParseError`] if the input exceeds the configured size limit
971/// or contains structural problems.
972#[must_use = "callers must handle the parse error"]
973pub fn parse_with_options(input: &str, options: &ParseOptions) -> Result<Song, ParseError> {
974    if options.max_input_size > 0 && input.len() > options.max_input_size {
975        return Err(ParseError::new(
976            format!(
977                "input size ({} bytes) exceeds maximum ({} bytes)",
978                input.len(),
979                options.max_input_size
980            ),
981            Span::new(
982                crate::token::Position::new(1, 1),
983                crate::token::Position::new(1, 1),
984            ),
985        ));
986    }
987    let tokens = Lexer::new(input).tokenize();
988    Parser::new(tokens).parse()
989}
990
991/// Parses a ChordPro source string leniently, collecting all errors.
992///
993/// Unlike [`parse`], this function does not fail on the first error.
994/// It returns a [`ParseResult`] containing the partial AST and all
995/// errors encountered. The size limit from [`ParseOptions::default`]
996/// is enforced.
997///
998/// # Examples
999///
1000/// ```
1001/// use chordsketch_core::parser::parse_lenient;
1002///
1003/// let result = parse_lenient("{title: Test}\n{bad\n[G]Hello");
1004/// assert!(result.has_errors());
1005/// assert_eq!(result.song.metadata.title.as_deref(), Some("Test"));
1006/// // The valid lyrics line was still parsed.
1007/// assert!(result.song.lines.len() >= 2);
1008/// ```
1009#[must_use]
1010pub fn parse_lenient(input: &str) -> ParseResult {
1011    parse_lenient_with_options(input, &ParseOptions::default())
1012}
1013
1014/// Parses a ChordPro source string leniently with custom options.
1015///
1016/// See [`parse_lenient`] for details.
1017#[must_use]
1018pub fn parse_lenient_with_options(input: &str, options: &ParseOptions) -> ParseResult {
1019    if options.max_input_size > 0 && input.len() > options.max_input_size {
1020        return ParseResult {
1021            song: Song::new(),
1022            errors: vec![ParseError::new(
1023                format!(
1024                    "input size ({} bytes) exceeds maximum ({} bytes)",
1025                    input.len(),
1026                    options.max_input_size
1027                ),
1028                Span::new(
1029                    crate::token::Position::new(1, 1),
1030                    crate::token::Position::new(1, 1),
1031                ),
1032            )],
1033        };
1034    }
1035    let tokens = Lexer::new(input).tokenize();
1036    Parser::new(tokens).parse_lenient_limited(options.max_errors)
1037}
1038
1039// ---------------------------------------------------------------------------
1040// Multi-song result
1041// ---------------------------------------------------------------------------
1042
1043/// The result of a lenient multi-song parse.
1044///
1045/// When using [`parse_multi_lenient`], the parser splits the input at `{new_song}`
1046/// boundaries and parses each segment independently. Each entry in `results`
1047/// contains the lenient parse result for one song segment.
1048#[derive(Debug, Clone)]
1049pub struct MultiParseResult {
1050    /// The parsed songs, one per segment between `{new_song}` boundaries.
1051    /// Each entry is the lenient parse result for that song segment.
1052    pub results: Vec<ParseResult>,
1053}
1054
1055impl MultiParseResult {
1056    /// Returns all successfully parsed songs.
1057    #[must_use]
1058    pub fn songs(&self) -> Vec<&Song> {
1059        self.results.iter().map(|r| &r.song).collect()
1060    }
1061
1062    /// Returns `true` if no errors were encountered in any song.
1063    #[must_use]
1064    pub fn is_ok(&self) -> bool {
1065        self.results.iter().all(|r| r.is_ok())
1066    }
1067
1068    /// Returns `true` if any errors were encountered in any song.
1069    #[must_use]
1070    pub fn has_errors(&self) -> bool {
1071        self.results.iter().any(|r| r.has_errors())
1072    }
1073
1074    /// Returns all errors from all songs.
1075    #[must_use]
1076    pub fn all_errors(&self) -> Vec<&ParseError> {
1077        self.results.iter().flat_map(|r| r.errors.iter()).collect()
1078    }
1079}
1080
1081// ---------------------------------------------------------------------------
1082// Multi-song convenience functions
1083// ---------------------------------------------------------------------------
1084
1085/// Checks whether a trimmed line is a `{new_song}` or `{ns}` directive.
1086fn is_new_song_line(trimmed: &str) -> bool {
1087    // Match patterns like {new_song}, { new_song }, {ns}, { ns },
1088    // {new_song: value}, { ns : tag }, case-insensitive.
1089    if !trimmed.starts_with('{') || !trimmed.ends_with('}') {
1090        return false;
1091    }
1092    let inner = trimmed[1..trimmed.len() - 1].trim().to_ascii_lowercase();
1093    // Strip optional colon and value (e.g., "new_song: tag" → "new_song").
1094    let name = match inner.find(':') {
1095        Some(pos) => inner[..pos].trim_end(),
1096        None => inner.as_str(),
1097    };
1098    name == "new_song" || name == "ns"
1099}
1100
1101/// Splits input text at `{new_song}` / `{ns}` directive boundaries.
1102///
1103/// Returns a vector of string slices, where each element is the text of one
1104/// song. If the input contains no `{new_song}` directives, returns a
1105/// single-element vector containing the entire input.
1106fn split_at_new_song(input: &str) -> Vec<&str> {
1107    let mut segments = Vec::new();
1108    let mut seg_start = 0;
1109    let bytes = input.as_bytes();
1110    let len = bytes.len();
1111    let mut pos = 0;
1112
1113    while pos < len {
1114        let line_start = pos;
1115        // Advance to end of line content (stop at \r or \n).
1116        while pos < len && bytes[pos] != b'\r' && bytes[pos] != b'\n' {
1117            pos += 1;
1118        }
1119        let line_end = pos;
1120        // Consume line terminator: \r\n, \n, or bare \r.
1121        let after_newline = if pos < len && bytes[pos] == b'\r' {
1122            if pos + 1 < len && bytes[pos + 1] == b'\n' {
1123                pos + 2
1124            } else {
1125                pos + 1 // bare \r
1126            }
1127        } else if pos < len && bytes[pos] == b'\n' {
1128            pos + 1
1129        } else {
1130            pos
1131        };
1132        pos = after_newline;
1133
1134        let line = &input[line_start..line_end];
1135        let trimmed = line.trim();
1136        if is_new_song_line(trimmed) {
1137            segments.push(&input[seg_start..line_start]);
1138            seg_start = after_newline;
1139        }
1140    }
1141
1142    segments.push(&input[seg_start..]);
1143    segments
1144}
1145
1146/// Parses a multi-song ChordPro source string, splitting at `{new_song}` / `{ns}`
1147/// boundaries and parsing each segment as an independent [`Song`].
1148///
1149/// If the input contains no `{new_song}` directives, the result is a single-element
1150/// vector containing the entire input parsed as one song.
1151///
1152/// # Errors
1153///
1154/// Returns a [`ParseError`] if any song segment contains structural problems.
1155/// On error, parsing stops at the first problematic segment.
1156///
1157/// # Examples
1158///
1159/// ```
1160/// use chordsketch_core::parser::parse_multi;
1161///
1162/// let input = "{title: Song One}\nLyrics one\n{new_song}\n{title: Song Two}\nLyrics two";
1163/// let songs = parse_multi(input).unwrap();
1164/// assert_eq!(songs.len(), 2);
1165/// assert_eq!(songs[0].metadata.title.as_deref(), Some("Song One"));
1166/// assert_eq!(songs[1].metadata.title.as_deref(), Some("Song Two"));
1167/// ```
1168#[must_use = "callers must handle the parse error"]
1169pub fn parse_multi(input: &str) -> Result<Vec<Song>, ParseError> {
1170    parse_multi_with_options(input, &ParseOptions::default())
1171}
1172
1173/// Parses a multi-song ChordPro source string with custom options.
1174///
1175/// See [`parse_multi`] for details. This variant allows configuring parser
1176/// behavior via [`ParseOptions`]. The size limit applies to the entire input,
1177/// not individual song segments.
1178///
1179/// # Errors
1180///
1181/// Returns a [`ParseError`] if the input exceeds the configured size limit
1182/// or any song segment contains structural problems.
1183#[must_use = "callers must handle the parse error"]
1184pub fn parse_multi_with_options(
1185    input: &str,
1186    options: &ParseOptions,
1187) -> Result<Vec<Song>, ParseError> {
1188    if options.max_input_size > 0 && input.len() > options.max_input_size {
1189        return Err(ParseError::new(
1190            format!(
1191                "input size ({} bytes) exceeds maximum ({} bytes)",
1192                input.len(),
1193                options.max_input_size
1194            ),
1195            Span::new(
1196                crate::token::Position::new(1, 1),
1197                crate::token::Position::new(1, 1),
1198            ),
1199        ));
1200    }
1201
1202    let segments = split_at_new_song(input);
1203    let mut songs = Vec::with_capacity(segments.len());
1204
1205    for segment in segments {
1206        let tokens = Lexer::new(segment).tokenize();
1207        let song = Parser::new(tokens).parse()?;
1208        songs.push(song);
1209    }
1210
1211    Ok(songs)
1212}
1213
1214/// Parses a multi-song ChordPro source string leniently, collecting all errors.
1215///
1216/// Unlike [`parse_multi`], this function does not fail on the first error.
1217/// Each song segment is parsed independently with [`parse_lenient`], and all
1218/// errors are collected. The size limit from [`ParseOptions::default`] is
1219/// enforced on the entire input.
1220///
1221/// # Examples
1222///
1223/// ```
1224/// use chordsketch_core::parser::parse_multi_lenient;
1225///
1226/// let input = "{title: Song One}\n[Am\n{new_song}\n{title: Song Two}\n[G]Hello";
1227/// let result = parse_multi_lenient(input);
1228/// assert_eq!(result.results.len(), 2);
1229/// assert!(result.results[0].has_errors()); // unclosed chord
1230/// assert!(result.results[1].is_ok());
1231/// ```
1232#[must_use]
1233pub fn parse_multi_lenient(input: &str) -> MultiParseResult {
1234    parse_multi_lenient_with_options(input, &ParseOptions::default())
1235}
1236
1237/// Parses a multi-song ChordPro source string leniently with custom options.
1238///
1239/// See [`parse_multi_lenient`] for details.
1240#[must_use]
1241pub fn parse_multi_lenient_with_options(input: &str, options: &ParseOptions) -> MultiParseResult {
1242    if options.max_input_size > 0 && input.len() > options.max_input_size {
1243        return MultiParseResult {
1244            results: vec![ParseResult {
1245                song: Song::new(),
1246                errors: vec![ParseError::new(
1247                    format!(
1248                        "input size ({} bytes) exceeds maximum ({} bytes)",
1249                        input.len(),
1250                        options.max_input_size
1251                    ),
1252                    Span::new(
1253                        crate::token::Position::new(1, 1),
1254                        crate::token::Position::new(1, 1),
1255                    ),
1256                )],
1257            }],
1258        };
1259    }
1260
1261    let segments = split_at_new_song(input);
1262    let results: Vec<ParseResult> = segments
1263        .into_iter()
1264        .map(|segment| {
1265            let tokens = Lexer::new(segment).tokenize();
1266            Parser::new(tokens).parse_lenient_limited(options.max_errors)
1267        })
1268        .collect();
1269
1270    MultiParseResult { results }
1271}
1272
1273// ---------------------------------------------------------------------------
1274// Image attribute parsing
1275// ---------------------------------------------------------------------------
1276
1277/// Maximum byte length for the `src` attribute value.
1278const IMAGE_SRC_MAX_BYTES: usize = 4096;
1279
1280/// Maximum byte length for other image attribute values.
1281const IMAGE_ATTR_MAX_BYTES: usize = 1024;
1282
1283/// Truncates a string to the given maximum byte length at a valid UTF-8
1284/// character boundary.
1285fn truncate_string(s: String, max_bytes: usize) -> String {
1286    if s.len() <= max_bytes {
1287        return s;
1288    }
1289    let mut end = max_bytes;
1290    while end > 0 && !s.is_char_boundary(end) {
1291        end -= 1;
1292    }
1293    s[..end].to_string()
1294}
1295
1296/// Parses the value string of an `{image}` directive into [`ImageAttributes`].
1297///
1298/// The value string is expected to contain `key=value` pairs separated by
1299/// whitespace. Quoted values (e.g., `title="Album Cover"`) are supported.
1300/// Unknown keys are silently ignored.
1301///
1302/// # Examples
1303///
1304/// ```
1305/// # use chordsketch_core::parser::parse_image_attributes;
1306/// # use chordsketch_core::ast::ImageAttributes;
1307/// let attrs = parse_image_attributes("src=photo.jpg width=200");
1308/// assert_eq!(attrs.src, "photo.jpg");
1309/// assert_eq!(attrs.width.as_deref(), Some("200"));
1310/// ```
1311#[must_use]
1312pub fn parse_image_attributes(input: &str) -> ImageAttributes {
1313    let mut attrs = ImageAttributes::default();
1314    let pairs = split_key_value_pairs(input);
1315
1316    for (key, value) in pairs {
1317        match key.to_ascii_lowercase().as_str() {
1318            "src" => attrs.src = truncate_string(value, IMAGE_SRC_MAX_BYTES),
1319            "width" => attrs.width = Some(truncate_string(value, IMAGE_ATTR_MAX_BYTES)),
1320            "height" => attrs.height = Some(truncate_string(value, IMAGE_ATTR_MAX_BYTES)),
1321            "scale" => attrs.scale = Some(truncate_string(value, IMAGE_ATTR_MAX_BYTES)),
1322            "title" => attrs.title = Some(truncate_string(value, IMAGE_ATTR_MAX_BYTES)),
1323            "anchor" => attrs.anchor = Some(truncate_string(value, IMAGE_ATTR_MAX_BYTES)),
1324            _ => {
1325                // Unknown attributes are silently ignored per spec.
1326            }
1327        }
1328    }
1329
1330    attrs
1331}
1332
1333/// Splits an input string into `(key, value)` pairs from `key=value` tokens.
1334///
1335/// Handles both unquoted values (`key=value`) and quoted values
1336/// (`key="value with spaces"`). Tokens without an `=` sign are ignored.
1337fn split_key_value_pairs(input: &str) -> Vec<(String, String)> {
1338    let mut pairs = Vec::new();
1339    let bytes = input.as_bytes();
1340    let len = bytes.len();
1341    let mut i = 0;
1342
1343    while i < len {
1344        // Skip whitespace.
1345        while i < len && bytes[i].is_ascii_whitespace() {
1346            i += 1;
1347        }
1348        if i >= len {
1349            break;
1350        }
1351
1352        // Read key (up to '=' or whitespace).
1353        let key_start = i;
1354        while i < len && bytes[i] != b'=' && !bytes[i].is_ascii_whitespace() {
1355            i += 1;
1356        }
1357        let key = &input[key_start..i];
1358
1359        if i >= len || bytes[i] != b'=' {
1360            // No '=' found — skip this token.
1361            // Advance past any non-whitespace to avoid infinite loop.
1362            while i < len && !bytes[i].is_ascii_whitespace() {
1363                i += 1;
1364            }
1365            continue;
1366        }
1367
1368        // Skip '='.
1369        i += 1;
1370
1371        // Read value (possibly quoted).
1372        let value = if i < len && bytes[i] == b'"' {
1373            // Quoted value: read until closing '"'.
1374            i += 1; // skip opening quote
1375            let val_start = i;
1376            while i < len && bytes[i] != b'"' {
1377                i += 1;
1378            }
1379            let val = &input[val_start..i];
1380            if i < len {
1381                i += 1; // skip closing quote
1382            }
1383            val
1384        } else {
1385            // Unquoted value: read until whitespace.
1386            let val_start = i;
1387            while i < len && !bytes[i].is_ascii_whitespace() {
1388                i += 1;
1389            }
1390            &input[val_start..i]
1391        };
1392
1393        if !key.is_empty() {
1394            pairs.push((key.to_string(), value.to_string()));
1395        }
1396    }
1397
1398    pairs
1399}
1400
1401// ---------------------------------------------------------------------------
1402// Tests
1403// ---------------------------------------------------------------------------
1404
1405#[cfg(test)]
1406mod tests {
1407    use super::*;
1408    use crate::ast::{
1409        Chord, CommentStyle, Directive, DirectiveKind, Line, LyricsLine, LyricsSegment,
1410    };
1411
1412    // -- Helper -------------------------------------------------------------
1413
1414    /// Parses the input and returns the lines, panicking on error.
1415    fn lines(input: &str) -> Vec<Line> {
1416        parse(input).expect("parse failed").lines
1417    }
1418
1419    // -- Input size limits (#60) -----------------------------------------------
1420
1421    #[test]
1422    fn input_within_limit_succeeds() {
1423        let opts = ParseOptions {
1424            max_input_size: 100,
1425            ..Default::default()
1426        };
1427        let result = parse_with_options("{title: Test}", &opts);
1428        assert!(result.is_ok());
1429    }
1430
1431    #[test]
1432    fn input_exceeding_limit_fails() {
1433        let opts = ParseOptions {
1434            max_input_size: 10,
1435            ..Default::default()
1436        };
1437        let result = parse_with_options("{title: This is too long}", &opts);
1438        assert!(result.is_err());
1439        let err = result.unwrap_err();
1440        assert!(err.message.contains("exceeds maximum"));
1441    }
1442
1443    #[test]
1444    fn zero_limit_disables_check() {
1445        let opts = ParseOptions {
1446            max_input_size: 0,
1447            ..Default::default()
1448        };
1449        let result = parse_with_options("{title: Any size is fine}", &opts);
1450        assert!(result.is_ok());
1451    }
1452
1453    #[test]
1454    fn default_limit_is_10mb() {
1455        let opts = ParseOptions::default();
1456        assert_eq!(opts.max_input_size, 10 * 1024 * 1024);
1457        assert_eq!(opts.max_errors, 1000);
1458    }
1459
1460    // -- Empty input --------------------------------------------------------
1461
1462    #[test]
1463    fn empty_input() {
1464        let song = parse("").unwrap();
1465        assert!(song.lines.is_empty());
1466    }
1467
1468    // -- Empty lines --------------------------------------------------------
1469
1470    #[test]
1471    fn single_empty_line() {
1472        let result = lines("\n");
1473        assert_eq!(result, vec![Line::Empty]);
1474    }
1475
1476    #[test]
1477    fn multiple_empty_lines() {
1478        let result = lines("\n\n\n");
1479        assert_eq!(result, vec![Line::Empty, Line::Empty, Line::Empty]);
1480    }
1481
1482    // -- Plain text (lyrics without chords) ---------------------------------
1483
1484    #[test]
1485    fn plain_text_line() {
1486        let result = lines("Hello world");
1487        assert_eq!(
1488            result,
1489            vec![Line::Lyrics(LyricsLine {
1490                segments: vec![LyricsSegment::text_only("Hello world")],
1491            })]
1492        );
1493    }
1494
1495    #[test]
1496    fn multiple_plain_text_lines() {
1497        let result = lines("Line one\nLine two");
1498        assert_eq!(
1499            result,
1500            vec![
1501                Line::Lyrics(LyricsLine {
1502                    segments: vec![LyricsSegment::text_only("Line one")],
1503                }),
1504                Line::Lyrics(LyricsLine {
1505                    segments: vec![LyricsSegment::text_only("Line two")],
1506                }),
1507            ]
1508        );
1509    }
1510
1511    // -- Chord annotations --------------------------------------------------
1512
1513    #[test]
1514    fn single_chord_with_text() {
1515        let result = lines("[Am]Hello");
1516        assert_eq!(
1517            result,
1518            vec![Line::Lyrics(LyricsLine {
1519                segments: vec![LyricsSegment::new(Some(Chord::new("Am")), "Hello")],
1520            })]
1521        );
1522    }
1523
1524    #[test]
1525    fn multiple_chords_with_text() {
1526        let result = lines("[Am]Hello [G]world");
1527        assert_eq!(
1528            result,
1529            vec![Line::Lyrics(LyricsLine {
1530                segments: vec![
1531                    LyricsSegment::new(Some(Chord::new("Am")), "Hello "),
1532                    LyricsSegment::new(Some(Chord::new("G")), "world"),
1533                ],
1534            })]
1535        );
1536    }
1537
1538    #[test]
1539    fn chord_only_no_text() {
1540        let result = lines("[Am]");
1541        assert_eq!(
1542            result,
1543            vec![Line::Lyrics(LyricsLine {
1544                segments: vec![LyricsSegment::chord_only(Chord::new("Am"))],
1545            })]
1546        );
1547    }
1548
1549    #[test]
1550    fn consecutive_chords_no_text_between() {
1551        let result = lines("[Am][G]");
1552        assert_eq!(
1553            result,
1554            vec![Line::Lyrics(LyricsLine {
1555                segments: vec![
1556                    LyricsSegment::chord_only(Chord::new("Am")),
1557                    LyricsSegment::chord_only(Chord::new("G")),
1558                ],
1559            })]
1560        );
1561    }
1562
1563    #[test]
1564    fn text_before_first_chord() {
1565        let result = lines("Hello [Am]world");
1566        assert_eq!(
1567            result,
1568            vec![Line::Lyrics(LyricsLine {
1569                segments: vec![
1570                    LyricsSegment::text_only("Hello "),
1571                    LyricsSegment::new(Some(Chord::new("Am")), "world"),
1572                ],
1573            })]
1574        );
1575    }
1576
1577    #[test]
1578    fn chord_at_end_of_line() {
1579        let result = lines("Hello [Am]");
1580        assert_eq!(
1581            result,
1582            vec![Line::Lyrics(LyricsLine {
1583                segments: vec![
1584                    LyricsSegment::text_only("Hello "),
1585                    LyricsSegment::chord_only(Chord::new("Am")),
1586                ],
1587            })]
1588        );
1589    }
1590
1591    #[test]
1592    fn empty_chord_name() {
1593        // An empty chord `[]` is valid — chord name is an empty string.
1594        let result = lines("[]text");
1595        assert_eq!(
1596            result,
1597            vec![Line::Lyrics(LyricsLine {
1598                segments: vec![LyricsSegment::new(Some(Chord::new("")), "text")],
1599            })]
1600        );
1601    }
1602
1603    // -- Directives ---------------------------------------------------------
1604
1605    #[test]
1606    fn directive_with_value() {
1607        let result = lines("{title: My Song}");
1608        assert_eq!(
1609            result,
1610            vec![Line::Directive(Directive::with_value("title", "My Song"))],
1611        );
1612    }
1613
1614    #[test]
1615    fn directive_without_value() {
1616        let result = lines("{start_of_chorus}");
1617        assert_eq!(
1618            result,
1619            vec![Line::Directive(Directive::name_only("start_of_chorus"))],
1620        );
1621    }
1622
1623    #[test]
1624    fn directive_value_trimmed() {
1625        let result = lines("{title:  Hello World  }");
1626        assert_eq!(
1627            result,
1628            vec![Line::Directive(Directive::with_value(
1629                "title",
1630                "Hello World"
1631            ))],
1632        );
1633    }
1634
1635    #[test]
1636    fn directive_name_trimmed() {
1637        let result = lines("{  title  : value}");
1638        assert_eq!(
1639            result,
1640            vec![Line::Directive(Directive::with_value("title", "value"))],
1641        );
1642    }
1643
1644    #[test]
1645    fn directive_with_colon_in_value() {
1646        // The lexer emits multiple Colon tokens; the parser joins extra colons.
1647        let result = lines("{comment: time 10:30}");
1648        // This is the `comment` directive, so it becomes Line::Comment.
1649        assert_eq!(
1650            result,
1651            vec![Line::Comment(
1652                CommentStyle::Normal,
1653                "time 10:30".to_string()
1654            )]
1655        );
1656    }
1657
1658    #[test]
1659    fn directive_followed_by_lyrics() {
1660        let result = lines("{title: Test}\n[Am]Hello");
1661        assert_eq!(
1662            result,
1663            vec![
1664                Line::Directive(Directive::with_value("title", "Test")),
1665                Line::Lyrics(LyricsLine {
1666                    segments: vec![LyricsSegment::new(Some(Chord::new("Am")), "Hello")],
1667                }),
1668            ]
1669        );
1670    }
1671
1672    // -- Comment directive --------------------------------------------------
1673
1674    #[test]
1675    fn comment_directive_full_name() {
1676        let result = lines("{comment: This is a comment}");
1677        assert_eq!(
1678            result,
1679            vec![Line::Comment(
1680                CommentStyle::Normal,
1681                "This is a comment".to_string()
1682            )],
1683        );
1684    }
1685
1686    #[test]
1687    fn comment_directive_short_name() {
1688        let result = lines("{c: Short comment}");
1689        assert_eq!(
1690            result,
1691            vec![Line::Comment(
1692                CommentStyle::Normal,
1693                "Short comment".to_string()
1694            )],
1695        );
1696    }
1697
1698    #[test]
1699    fn comment_directive_no_value() {
1700        let result = lines("{comment}");
1701        assert_eq!(
1702            result,
1703            vec![Line::Comment(CommentStyle::Normal, String::new())]
1704        );
1705    }
1706
1707    #[test]
1708    fn comment_italic_directive() {
1709        let result = lines("{comment_italic: Softly}");
1710        assert_eq!(
1711            result,
1712            vec![Line::Comment(CommentStyle::Italic, "Softly".to_string())],
1713        );
1714    }
1715
1716    #[test]
1717    fn comment_italic_short_name() {
1718        let result = lines("{ci: Softly}");
1719        assert_eq!(
1720            result,
1721            vec![Line::Comment(CommentStyle::Italic, "Softly".to_string())],
1722        );
1723    }
1724
1725    #[test]
1726    fn comment_box_directive() {
1727        let result = lines("{comment_box: Important}");
1728        assert_eq!(
1729            result,
1730            vec![Line::Comment(CommentStyle::Boxed, "Important".to_string())],
1731        );
1732    }
1733
1734    #[test]
1735    fn comment_box_short_name() {
1736        let result = lines("{cb: Important}");
1737        assert_eq!(
1738            result,
1739            vec![Line::Comment(CommentStyle::Boxed, "Important".to_string())],
1740        );
1741    }
1742
1743    // -- File-level `#` comment lines --------------------------------------
1744
1745    #[test]
1746    fn hash_comment_basic() {
1747        let result = lines("# This is a comment");
1748        assert_eq!(
1749            result,
1750            vec![Line::Comment(
1751                CommentStyle::Normal,
1752                "This is a comment".to_string()
1753            )],
1754        );
1755    }
1756
1757    #[test]
1758    fn hash_comment_no_space_after_hash() {
1759        // `#text` (no space) — hash is stripped but no space to remove.
1760        let result = lines("#no space");
1761        assert_eq!(
1762            result,
1763            vec![Line::Comment(CommentStyle::Normal, "no space".to_string())],
1764        );
1765    }
1766
1767    #[test]
1768    fn hash_comment_standalone_hash() {
1769        // A bare `#` produces an empty comment text.
1770        let result = lines("#");
1771        assert_eq!(
1772            result,
1773            vec![Line::Comment(CommentStyle::Normal, "".to_string())],
1774        );
1775    }
1776
1777    #[test]
1778    fn hash_comment_mixed_with_directives() {
1779        // `#` comment before and after a directive — both become Comment(Normal).
1780        let result = lines("# First\n{title: My Song}\n# Second");
1781        assert_eq!(
1782            result,
1783            vec![
1784                Line::Comment(CommentStyle::Normal, "First".to_string()),
1785                Line::Directive(Directive::with_value("title", "My Song")),
1786                Line::Comment(CommentStyle::Normal, "Second".to_string()),
1787            ],
1788        );
1789    }
1790
1791    #[test]
1792    fn hash_comment_indented_is_lyrics_not_comment() {
1793        // `  # text` (leading spaces) — NOT a comment; treated as a lyrics line
1794        // because the ChordPro spec requires `#` at column 1.
1795        let result = lines("  # indented");
1796        assert!(
1797            matches!(result[..], [Line::Lyrics(_)]),
1798            "expected Lyrics, got {result:?}"
1799        );
1800    }
1801
1802    // -- Directive classification -------------------------------------------
1803
1804    #[test]
1805    fn directive_short_alias_title() {
1806        let result = lines("{t: My Song}");
1807        let expected = Directive::with_value("title", "My Song");
1808        assert_eq!(result, vec![Line::Directive(expected)]);
1809    }
1810
1811    #[test]
1812    fn directive_short_alias_subtitle() {
1813        let result = lines("{st: Alternate}");
1814        let expected = Directive::with_value("subtitle", "Alternate");
1815        assert_eq!(result, vec![Line::Directive(expected)]);
1816    }
1817
1818    #[test]
1819    fn directive_short_alias_soc() {
1820        let result = lines("{soc}");
1821        let expected = Directive::name_only("start_of_chorus");
1822        assert_eq!(result, vec![Line::Directive(expected)]);
1823    }
1824
1825    #[test]
1826    fn directive_short_alias_eoc() {
1827        let result = lines("{eoc}");
1828        let expected = Directive::name_only("end_of_chorus");
1829        assert_eq!(result, vec![Line::Directive(expected)]);
1830    }
1831
1832    #[test]
1833    fn directive_case_insensitive() {
1834        let result = lines("{TITLE: Upper}");
1835        let expected = Directive::with_value("title", "Upper");
1836        assert_eq!(result, vec![Line::Directive(expected)]);
1837    }
1838
1839    #[test]
1840    fn directive_mixed_case() {
1841        let result = lines("{Start_Of_Chorus}");
1842        let expected = Directive::name_only("start_of_chorus");
1843        assert_eq!(result, vec![Line::Directive(expected)]);
1844    }
1845
1846    #[test]
1847    fn directive_unknown_preserved() {
1848        let result = lines("{my_custom: value}");
1849        assert_eq!(
1850            result,
1851            vec![Line::Directive(Directive {
1852                name: "my_custom".to_string(),
1853                value: Some("value".to_string()),
1854                kind: DirectiveKind::Unknown("my_custom".to_string()),
1855                selector: None,
1856            })],
1857        );
1858    }
1859
1860    #[test]
1861    fn directive_kind_on_parsed_directive() {
1862        let song = parse("{title: Test}").unwrap();
1863        if let Line::Directive(ref d) = song.lines[0] {
1864            assert_eq!(d.kind, DirectiveKind::Title);
1865            assert_eq!(d.name, "title");
1866        } else {
1867            panic!("expected directive");
1868        }
1869    }
1870
1871    // -- Environment directives (all variants) ------------------------------
1872
1873    #[test]
1874    fn environment_directives_long_form() {
1875        let cases = vec![
1876            (
1877                "{start_of_chorus}",
1878                "start_of_chorus",
1879                DirectiveKind::StartOfChorus,
1880            ),
1881            (
1882                "{end_of_chorus}",
1883                "end_of_chorus",
1884                DirectiveKind::EndOfChorus,
1885            ),
1886            (
1887                "{start_of_verse}",
1888                "start_of_verse",
1889                DirectiveKind::StartOfVerse,
1890            ),
1891            ("{end_of_verse}", "end_of_verse", DirectiveKind::EndOfVerse),
1892            (
1893                "{start_of_bridge}",
1894                "start_of_bridge",
1895                DirectiveKind::StartOfBridge,
1896            ),
1897            (
1898                "{end_of_bridge}",
1899                "end_of_bridge",
1900                DirectiveKind::EndOfBridge,
1901            ),
1902            ("{start_of_tab}", "start_of_tab", DirectiveKind::StartOfTab),
1903            ("{end_of_tab}", "end_of_tab", DirectiveKind::EndOfTab),
1904        ];
1905
1906        for (input, expected_name, expected_kind) in cases {
1907            let result = lines(input);
1908            if let Line::Directive(ref d) = result[0] {
1909                assert_eq!(d.name, expected_name, "failed for input: {input}");
1910                assert_eq!(d.kind, expected_kind, "failed for input: {input}");
1911            } else {
1912                panic!("expected directive for input: {input}");
1913            }
1914        }
1915    }
1916
1917    #[test]
1918    fn environment_directives_short_form() {
1919        let cases = vec![
1920            ("{soc}", "start_of_chorus", DirectiveKind::StartOfChorus),
1921            ("{eoc}", "end_of_chorus", DirectiveKind::EndOfChorus),
1922            ("{sov}", "start_of_verse", DirectiveKind::StartOfVerse),
1923            ("{eov}", "end_of_verse", DirectiveKind::EndOfVerse),
1924            ("{sob}", "start_of_bridge", DirectiveKind::StartOfBridge),
1925            ("{eob}", "end_of_bridge", DirectiveKind::EndOfBridge),
1926            ("{sot}", "start_of_tab", DirectiveKind::StartOfTab),
1927            ("{eot}", "end_of_tab", DirectiveKind::EndOfTab),
1928        ];
1929
1930        for (input, expected_name, expected_kind) in cases {
1931            let result = lines(input);
1932            if let Line::Directive(ref d) = result[0] {
1933                assert_eq!(d.name, expected_name, "failed for input: {input}");
1934                assert_eq!(d.kind, expected_kind, "failed for input: {input}");
1935            } else {
1936                panic!("expected directive for input: {input}");
1937            }
1938        }
1939    }
1940
1941    // -- Metadata population ------------------------------------------------
1942
1943    #[test]
1944    fn metadata_title_populated() {
1945        let song = parse("{title: Amazing Grace}").unwrap();
1946        assert_eq!(song.metadata.title.as_deref(), Some("Amazing Grace"));
1947    }
1948
1949    #[test]
1950    fn metadata_title_via_short_alias() {
1951        let song = parse("{t: Amazing Grace}").unwrap();
1952        assert_eq!(song.metadata.title.as_deref(), Some("Amazing Grace"));
1953    }
1954
1955    #[test]
1956    fn metadata_subtitle_populated() {
1957        let song = parse("{subtitle: How sweet}\n{st: The sound}").unwrap();
1958        assert_eq!(song.metadata.subtitles, vec!["How sweet", "The sound"]);
1959    }
1960
1961    #[test]
1962    fn metadata_artist_populated() {
1963        let song = parse("{artist: John Newton}").unwrap();
1964        assert_eq!(song.metadata.artists, vec!["John Newton"]);
1965    }
1966
1967    #[test]
1968    fn metadata_multiple_artists() {
1969        let song = parse("{artist: John}\n{artist: Jane}").unwrap();
1970        assert_eq!(song.metadata.artists, vec!["John", "Jane"]);
1971    }
1972
1973    #[test]
1974    fn metadata_composer_populated() {
1975        let song = parse("{composer: Bach}").unwrap();
1976        assert_eq!(song.metadata.composers, vec!["Bach"]);
1977    }
1978
1979    #[test]
1980    fn metadata_lyricist_populated() {
1981        let song = parse("{lyricist: Someone}").unwrap();
1982        assert_eq!(song.metadata.lyricists, vec!["Someone"]);
1983    }
1984
1985    #[test]
1986    fn metadata_album_populated() {
1987        let song = parse("{album: Greatest Hits}").unwrap();
1988        assert_eq!(song.metadata.album.as_deref(), Some("Greatest Hits"));
1989    }
1990
1991    #[test]
1992    fn metadata_year_populated() {
1993        let song = parse("{year: 1779}").unwrap();
1994        assert_eq!(song.metadata.year.as_deref(), Some("1779"));
1995    }
1996
1997    #[test]
1998    fn metadata_key_populated() {
1999        let song = parse("{key: G}").unwrap();
2000        assert_eq!(song.metadata.key.as_deref(), Some("G"));
2001    }
2002
2003    #[test]
2004    fn metadata_tempo_populated() {
2005        let song = parse("{tempo: 120}").unwrap();
2006        assert_eq!(song.metadata.tempo.as_deref(), Some("120"));
2007    }
2008
2009    #[test]
2010    fn metadata_time_populated() {
2011        let song = parse("{time: 3/4}").unwrap();
2012        assert_eq!(song.metadata.time.as_deref(), Some("3/4"));
2013    }
2014
2015    #[test]
2016    fn metadata_capo_populated() {
2017        let song = parse("{capo: 2}").unwrap();
2018        assert_eq!(song.metadata.capo.as_deref(), Some("2"));
2019    }
2020
2021    #[test]
2022    fn metadata_case_insensitive() {
2023        let song = parse("{TITLE: Upper Case}").unwrap();
2024        assert_eq!(song.metadata.title.as_deref(), Some("Upper Case"));
2025    }
2026
2027    #[test]
2028    fn metadata_not_populated_without_value() {
2029        let song = parse("{title}").unwrap();
2030        assert_eq!(song.metadata.title, None);
2031    }
2032
2033    #[test]
2034    fn metadata_all_fields_populated() {
2035        let input = "\
2036{title: My Song}
2037{subtitle: A Sub}
2038{artist: An Artist}
2039{composer: A Composer}
2040{lyricist: A Lyricist}
2041{album: An Album}
2042{year: 2024}
2043{key: Am}
2044{tempo: 100}
2045{time: 4/4}
2046{capo: 3}";
2047
2048        let song = parse(input).unwrap();
2049        assert_eq!(song.metadata.title.as_deref(), Some("My Song"));
2050        assert_eq!(song.metadata.subtitles, vec!["A Sub"]);
2051        assert_eq!(song.metadata.artists, vec!["An Artist"]);
2052        assert_eq!(song.metadata.composers, vec!["A Composer"]);
2053        assert_eq!(song.metadata.lyricists, vec!["A Lyricist"]);
2054        assert_eq!(song.metadata.album.as_deref(), Some("An Album"));
2055        assert_eq!(song.metadata.year.as_deref(), Some("2024"));
2056        assert_eq!(song.metadata.key.as_deref(), Some("Am"));
2057        assert_eq!(song.metadata.tempo.as_deref(), Some("100"));
2058        assert_eq!(song.metadata.time.as_deref(), Some("4/4"));
2059        assert_eq!(song.metadata.capo.as_deref(), Some("3"));
2060    }
2061
2062    #[test]
2063    fn metadata_custom_populated_for_unknown_directive() {
2064        let song = parse("{x_my_custom: some value}").unwrap();
2065        assert_eq!(
2066            song.metadata.custom,
2067            vec![("x_my_custom".to_string(), "some value".to_string())]
2068        );
2069    }
2070
2071    #[test]
2072    fn metadata_custom_multiple_unknown_directives() {
2073        let song = parse("{x_one: first}\n{x_two: second}").unwrap();
2074        assert_eq!(
2075            song.metadata.custom,
2076            vec![
2077                ("x_one".to_string(), "first".to_string()),
2078                ("x_two".to_string(), "second".to_string()),
2079            ]
2080        );
2081    }
2082
2083    #[test]
2084    fn metadata_custom_not_populated_without_value() {
2085        let song = parse("{x_no_value}").unwrap();
2086        assert!(song.metadata.custom.is_empty());
2087    }
2088
2089    #[test]
2090    fn metadata_custom_coexists_with_standard_metadata() {
2091        let input = "{title: My Song}\n{x_custom: custom value}";
2092        let song = parse(input).unwrap();
2093        assert_eq!(song.metadata.title.as_deref(), Some("My Song"));
2094        assert_eq!(
2095            song.metadata.custom,
2096            vec![("x_custom".to_string(), "custom value".to_string())]
2097        );
2098    }
2099
2100    // -- Error cases --------------------------------------------------------
2101
2102    #[test]
2103    fn unclosed_directive() {
2104        let err = parse("{title: oops").unwrap_err();
2105        assert!(
2106            err.message.contains("unclosed directive"),
2107            "error message was: {}",
2108            err.message
2109        );
2110    }
2111
2112    #[test]
2113    fn unclosed_chord() {
2114        let err = parse("[Am").unwrap_err();
2115        assert!(
2116            err.message.contains("unclosed chord"),
2117            "error message was: {}",
2118            err.message
2119        );
2120    }
2121
2122    #[test]
2123    fn empty_directive_name() {
2124        let err = parse("{}").unwrap_err();
2125        assert!(
2126            err.message.contains("empty directive name"),
2127            "error message was: {}",
2128            err.message
2129        );
2130    }
2131
2132    #[test]
2133    fn empty_directive_with_colon() {
2134        let err = parse("{: value}").unwrap_err();
2135        assert!(
2136            err.message.contains("empty directive name"),
2137            "error message was: {}",
2138            err.message
2139        );
2140    }
2141
2142    #[test]
2143    fn unclosed_chord_at_newline() {
2144        let err = parse("[Am\ntext").unwrap_err();
2145        assert!(
2146            err.message.contains("unclosed chord"),
2147            "error message was: {}",
2148            err.message
2149        );
2150    }
2151
2152    #[test]
2153    fn parse_error_display() {
2154        let err = parse("{title: no close").unwrap_err();
2155        let msg = format!("{err}");
2156        assert!(msg.contains("parse error at line"));
2157        assert!(msg.contains("unclosed directive"));
2158    }
2159
2160    // -- Mixed content / integration ----------------------------------------
2161
2162    #[test]
2163    fn full_song() {
2164        let input = "\
2165{title: Amazing Grace}
2166{artist: John Newton}
2167
2168[G]Amazing [G7]grace, how [C]sweet the [G]sound
2169[G]That saved a [Em]wretch like [D]me";
2170
2171        let song = parse(input).unwrap();
2172        assert_eq!(song.lines.len(), 5);
2173
2174        // Metadata populated
2175        assert_eq!(song.metadata.title.as_deref(), Some("Amazing Grace"));
2176        assert_eq!(song.metadata.artists, vec!["John Newton"]);
2177
2178        // First line: title directive
2179        assert_eq!(
2180            song.lines[0],
2181            Line::Directive(Directive::with_value("title", "Amazing Grace")),
2182        );
2183
2184        // Second line: artist directive
2185        assert_eq!(
2186            song.lines[1],
2187            Line::Directive(Directive::with_value("artist", "John Newton")),
2188        );
2189
2190        // Third line: empty
2191        assert_eq!(song.lines[2], Line::Empty);
2192
2193        // Fourth line: lyrics with chords
2194        if let Line::Lyrics(ref lyrics) = song.lines[3] {
2195            assert_eq!(lyrics.text(), "Amazing grace, how sweet the sound");
2196            assert!(lyrics.has_chords());
2197            assert_eq!(lyrics.segments.len(), 4);
2198            assert_eq!(lyrics.segments[0].chord.as_ref().unwrap().name, "G");
2199            assert_eq!(lyrics.segments[0].text, "Amazing ");
2200            assert_eq!(lyrics.segments[1].chord.as_ref().unwrap().name, "G7");
2201            assert_eq!(lyrics.segments[1].text, "grace, how ");
2202            assert_eq!(lyrics.segments[2].chord.as_ref().unwrap().name, "C");
2203            assert_eq!(lyrics.segments[2].text, "sweet the ");
2204            assert_eq!(lyrics.segments[3].chord.as_ref().unwrap().name, "G");
2205            assert_eq!(lyrics.segments[3].text, "sound");
2206        } else {
2207            panic!("expected Line::Lyrics for line 4");
2208        }
2209
2210        // Fifth line: lyrics with chords
2211        if let Line::Lyrics(ref lyrics) = song.lines[4] {
2212            assert_eq!(lyrics.text(), "That saved a wretch like me");
2213            assert_eq!(lyrics.segments.len(), 3);
2214        } else {
2215            panic!("expected Line::Lyrics for line 5");
2216        }
2217    }
2218
2219    #[test]
2220    fn song_with_sections() {
2221        let input = "\
2222{start_of_chorus}
2223[C]La la [G]la
2224{end_of_chorus}";
2225
2226        let song = parse(input).unwrap();
2227        assert_eq!(song.lines.len(), 3);
2228        assert!(matches!(song.lines[0], Line::Directive(_)));
2229        assert!(matches!(song.lines[1], Line::Lyrics(_)));
2230        assert!(matches!(song.lines[2], Line::Directive(_)));
2231    }
2232
2233    #[test]
2234    fn song_with_comments_and_empty_lines() {
2235        let input = "\
2236{title: Test}
2237{comment: Intro}
2238
2239[Am]Hello
2240";
2241
2242        let song = parse(input).unwrap();
2243        assert_eq!(song.lines.len(), 4);
2244        assert_eq!(
2245            song.lines[0],
2246            Line::Directive(Directive::with_value("title", "Test"))
2247        );
2248        assert_eq!(
2249            song.lines[1],
2250            Line::Comment(CommentStyle::Normal, "Intro".to_string())
2251        );
2252        assert_eq!(song.lines[2], Line::Empty);
2253        assert!(matches!(song.lines[3], Line::Lyrics(_)));
2254    }
2255
2256    #[test]
2257    fn crlf_line_endings() {
2258        let input = "{title: Test}\r\n[Am]Hello\r\n";
2259        let song = parse(input).unwrap();
2260        assert_eq!(song.lines.len(), 2);
2261        assert_eq!(
2262            song.lines[0],
2263            Line::Directive(Directive::with_value("title", "Test")),
2264        );
2265        assert!(matches!(song.lines[1], Line::Lyrics(_)));
2266    }
2267
2268    #[test]
2269    fn stray_close_brace_in_lyrics() {
2270        // A stray `}` outside a directive is treated as literal text.
2271        let result = lines("hello } world");
2272        assert_eq!(
2273            result,
2274            vec![Line::Lyrics(LyricsLine {
2275                segments: vec![LyricsSegment::text_only("hello } world")],
2276            })]
2277        );
2278    }
2279
2280    #[test]
2281    fn stray_close_bracket_in_lyrics() {
2282        // A stray `]` outside a chord is treated as literal text.
2283        let result = lines("hello ] world");
2284        assert_eq!(
2285            result,
2286            vec![Line::Lyrics(LyricsLine {
2287                segments: vec![LyricsSegment::text_only("hello ] world")],
2288            })]
2289        );
2290    }
2291
2292    #[test]
2293    fn unicode_in_chords_and_lyrics() {
2294        let result = lines("[Am]こんにちは [G]世界");
2295        assert_eq!(
2296            result,
2297            vec![Line::Lyrics(LyricsLine {
2298                segments: vec![
2299                    LyricsSegment::new(Some(Chord::new("Am")), "こんにちは "),
2300                    LyricsSegment::new(Some(Chord::new("G")), "世界"),
2301                ],
2302            })]
2303        );
2304    }
2305
2306    #[test]
2307    fn multiple_colons_in_directive_value() {
2308        // Extra colons after the first are treated as part of the value.
2309        // When the directive is "meta", it is parsed as a Meta directive.
2310        // Since "key:value:extra" has no whitespace, the whole string
2311        // becomes the meta key with no value.
2312        let result = lines("{meta: key:value:extra}");
2313        assert_eq!(
2314            result,
2315            vec![Line::Directive(Directive {
2316                name: "meta".to_string(),
2317                value: None,
2318                kind: DirectiveKind::Meta("key:value:extra".to_string()),
2319                selector: None,
2320            })],
2321        );
2322
2323        // For non-meta directives, extra colons remain in the value.
2324        let result = lines("{custom_dir: key:value:extra}");
2325        assert_eq!(
2326            result,
2327            vec![Line::Directive(Directive {
2328                name: "custom_dir".to_string(),
2329                value: Some("key:value:extra".to_string()),
2330                kind: DirectiveKind::Unknown("custom_dir".to_string()),
2331                selector: None,
2332            })],
2333        );
2334    }
2335
2336    #[test]
2337    fn directive_only_whitespace_name() {
2338        let err = parse("{   }").unwrap_err();
2339        assert!(
2340            err.message.contains("empty directive name"),
2341            "error message was: {}",
2342            err.message
2343        );
2344    }
2345
2346    #[test]
2347    fn directive_with_brackets_in_value() {
2348        // Brackets inside a directive value are included literally.
2349        let result = lines("{comment: play [Am] here}");
2350        assert_eq!(
2351            result,
2352            vec![Line::Comment(
2353                CommentStyle::Normal,
2354                "play [Am] here".to_string()
2355            )],
2356        );
2357    }
2358
2359    #[test]
2360    fn chord_line_with_spaces() {
2361        let result = lines("[Am]  [G]  [C]");
2362        assert_eq!(
2363            result,
2364            vec![Line::Lyrics(LyricsLine {
2365                segments: vec![
2366                    LyricsSegment::new(Some(Chord::new("Am")), "  "),
2367                    LyricsSegment::new(Some(Chord::new("G")), "  "),
2368                    LyricsSegment::chord_only(Chord::new("C")),
2369                ],
2370            })]
2371        );
2372    }
2373
2374    #[test]
2375    fn trailing_newline_produces_empty_line() {
2376        let result = lines("text\n");
2377        assert_eq!(
2378            result,
2379            vec![Line::Lyrics(LyricsLine {
2380                segments: vec![LyricsSegment::text_only("text")],
2381            })]
2382        );
2383    }
2384
2385    #[test]
2386    fn parser_struct_directly() {
2387        // Test using Parser::new directly with tokens.
2388        let tokens = Lexer::new("[C]Hello").tokenize();
2389        let song = Parser::new(tokens).parse().unwrap();
2390        assert_eq!(song.lines.len(), 1);
2391    }
2392
2393    // -- Full song with all directive types ---------------------------------
2394
2395    #[test]
2396    fn full_song_with_all_directive_types() {
2397        let input = "\
2398{t: Amazing Grace}
2399{st: A Hymn}
2400{artist: John Newton}
2401{key: G}
2402{tempo: 80}
2403{time: 3/4}
2404{capo: 2}
2405{comment: Verse 1}
2406{ci: Play softly}
2407{cb: Key change ahead}
2408{soc}
2409[G]Amazing [G7]grace
2410{eoc}";
2411
2412        let song = parse(input).unwrap();
2413
2414        // Metadata checks
2415        assert_eq!(song.metadata.title.as_deref(), Some("Amazing Grace"));
2416        assert_eq!(song.metadata.subtitles, vec!["A Hymn"]);
2417        assert_eq!(song.metadata.artists, vec!["John Newton"]);
2418        assert_eq!(song.metadata.key.as_deref(), Some("G"));
2419        assert_eq!(song.metadata.tempo.as_deref(), Some("80"));
2420        assert_eq!(song.metadata.time.as_deref(), Some("3/4"));
2421        assert_eq!(song.metadata.capo.as_deref(), Some("2"));
2422
2423        // Line type checks
2424        assert_eq!(song.lines.len(), 13);
2425        assert!(matches!(song.lines[0], Line::Directive(_))); // title
2426        assert!(matches!(song.lines[1], Line::Directive(_))); // subtitle
2427        assert!(matches!(song.lines[2], Line::Directive(_))); // artist
2428        assert!(matches!(song.lines[3], Line::Directive(_))); // key
2429        assert!(matches!(song.lines[4], Line::Directive(_))); // tempo
2430        assert!(matches!(song.lines[5], Line::Directive(_))); // time
2431        assert!(matches!(song.lines[6], Line::Directive(_))); // capo
2432        assert_eq!(
2433            song.lines[7],
2434            Line::Comment(CommentStyle::Normal, "Verse 1".to_string())
2435        );
2436        assert_eq!(
2437            song.lines[8],
2438            Line::Comment(CommentStyle::Italic, "Play softly".to_string())
2439        );
2440        assert_eq!(
2441            song.lines[9],
2442            Line::Comment(CommentStyle::Boxed, "Key change ahead".to_string())
2443        );
2444        // soc
2445        if let Line::Directive(ref d) = song.lines[10] {
2446            assert_eq!(d.kind, DirectiveKind::StartOfChorus);
2447            assert_eq!(d.name, "start_of_chorus");
2448        } else {
2449            panic!("expected directive");
2450        }
2451        assert!(matches!(song.lines[11], Line::Lyrics(_))); // lyrics
2452        // eoc
2453        if let Line::Directive(ref d) = song.lines[12] {
2454            assert_eq!(d.kind, DirectiveKind::EndOfChorus);
2455            assert_eq!(d.name, "end_of_chorus");
2456        } else {
2457            panic!("expected directive");
2458        }
2459    }
2460
2461    // -- Error diagnostics (issue #25) --------------------------------------
2462
2463    #[test]
2464    fn parse_error_implements_std_error() {
2465        let err = parse("[Am").unwrap_err();
2466        // Verify that ParseError can be used as a std::error::Error trait object.
2467        let _: &dyn std::error::Error = &err;
2468    }
2469
2470    #[test]
2471    fn parse_error_source_is_none() {
2472        let err = parse("[Am").unwrap_err();
2473        let err_ref: &dyn std::error::Error = &err;
2474        assert!(err_ref.source().is_none());
2475    }
2476
2477    #[test]
2478    fn parse_error_line_column_accessors() {
2479        let err = parse("[Am").unwrap_err();
2480        assert_eq!(err.line(), 1);
2481        assert_eq!(err.column(), 1);
2482    }
2483
2484    #[test]
2485    fn unclosed_chord_error_location() {
2486        let err = parse("[Am").unwrap_err();
2487        assert!(err.message.contains("unclosed chord"));
2488        assert_eq!(err.span.start.line, 1);
2489        assert_eq!(err.span.start.column, 1);
2490    }
2491
2492    #[test]
2493    fn unclosed_chord_on_second_line() {
2494        let err = parse("Hello\n[Am").unwrap_err();
2495        assert!(err.message.contains("unclosed chord"));
2496        assert_eq!(err.span.start.line, 2);
2497        assert_eq!(err.span.start.column, 1);
2498    }
2499
2500    #[test]
2501    fn unclosed_chord_mid_line() {
2502        let err = parse("text [Am").unwrap_err();
2503        assert!(err.message.contains("unclosed chord"));
2504        assert_eq!(err.span.start.line, 1);
2505        assert_eq!(err.span.start.column, 6);
2506    }
2507
2508    #[test]
2509    fn unclosed_directive_error_location() {
2510        let err = parse("{title: oops").unwrap_err();
2511        assert!(err.message.contains("unclosed directive"));
2512        // Span points to EOF where the closing brace was expected.
2513        assert_eq!(err.span.start.line, 1);
2514        assert_eq!(err.span.start.column, 13);
2515    }
2516
2517    #[test]
2518    fn unclosed_directive_on_third_line() {
2519        let err = parse("line one\nline two\n{title: oops").unwrap_err();
2520        assert!(err.message.contains("unclosed directive"));
2521        // Span points to EOF where the closing brace was expected.
2522        assert_eq!(err.span.start.line, 3);
2523        assert_eq!(err.span.start.column, 13);
2524    }
2525
2526    #[test]
2527    fn empty_directive_error_location() {
2528        let err = parse("{}").unwrap_err();
2529        assert!(err.message.contains("empty directive name"));
2530        assert_eq!(err.span.start.line, 1);
2531        assert_eq!(err.span.start.column, 1);
2532    }
2533
2534    #[test]
2535    fn empty_directive_with_colon_error_location() {
2536        let err = parse("{: value}").unwrap_err();
2537        assert!(err.message.contains("empty directive name"));
2538        assert_eq!(err.span.start.line, 1);
2539        assert_eq!(err.span.start.column, 1);
2540    }
2541
2542    #[test]
2543    fn error_display_format_with_line_column() {
2544        let err = parse("first line\n{title: no close").unwrap_err();
2545        let msg = format!("{err}");
2546        // The error reports the position where the closing brace was expected.
2547        assert!(
2548            msg.starts_with("parse error at line 2, column 17:"),
2549            "unexpected display format: {msg}"
2550        );
2551    }
2552
2553    #[test]
2554    fn unclosed_chord_at_end_of_line_error_location() {
2555        // [Am at end followed by newline — error points to the opening bracket
2556        let err = parse("[Am\nmore text").unwrap_err();
2557        assert!(err.message.contains("unclosed chord"));
2558        assert_eq!(err.span.start.line, 1);
2559        assert_eq!(err.span.start.column, 1);
2560    }
2561
2562    #[test]
2563    fn unclosed_directive_at_eof_error_location() {
2564        let err = parse("{title").unwrap_err();
2565        assert!(err.message.contains("unclosed directive"));
2566        assert_eq!(err.span.start.line, 1);
2567        assert_eq!(err.span.start.column, 1);
2568    }
2569
2570    #[test]
2571    fn whitespace_only_directive_name_error_location() {
2572        let err = parse("{   : value}").unwrap_err();
2573        assert!(err.message.contains("empty directive name"));
2574        assert_eq!(err.span.start.line, 1);
2575        assert_eq!(err.span.start.column, 1);
2576    }
2577
2578    #[test]
2579    fn error_after_valid_content() {
2580        // Valid content followed by an error on a later line
2581        let input = "{title: Test}\n[Am]Hello\n[G";
2582        let err = parse(input).unwrap_err();
2583        assert!(err.message.contains("unclosed chord"));
2584        assert_eq!(err.span.start.line, 3);
2585        assert_eq!(err.span.start.column, 1);
2586    }
2587
2588    #[test]
2589    fn multiple_errors_first_is_reported() {
2590        // Parser stops at first error — verify it's the correct one.
2591        let err = parse("{title\n{another").unwrap_err();
2592        assert!(err.message.contains("unclosed directive"));
2593        assert_eq!(err.span.start.line, 1);
2594    }
2595
2596    // --- Tab verbatim (#59) ---
2597
2598    #[test]
2599    fn tab_content_is_verbatim() {
2600        // Brackets inside tab should NOT be parsed as chords.
2601        let song = parse("{start_of_tab}\ne|---[0]---|\n{end_of_tab}").unwrap();
2602        // Line 0: start_of_tab directive
2603        // Line 1: verbatim text line
2604        // Line 2: end_of_tab directive
2605        if let Line::Lyrics(ref l) = song.lines[1] {
2606            assert_eq!(l.segments.len(), 1);
2607            assert!(l.segments[0].chord.is_none());
2608            assert_eq!(l.segments[0].text, "e|---[0]---|");
2609        } else {
2610            panic!("expected lyrics line for tab content");
2611        }
2612    }
2613
2614    #[test]
2615    fn tab_content_preserves_braces() {
2616        let song = parse("{sot}\n{some text}\n{eot}").unwrap();
2617        if let Line::Lyrics(ref l) = song.lines[1] {
2618            assert_eq!(l.segments[0].text, "{some text}");
2619        } else {
2620            panic!("expected lyrics line for tab content");
2621        }
2622    }
2623
2624    #[test]
2625    fn chords_parsed_after_tab_ends() {
2626        // After end_of_tab, chord parsing should resume.
2627        let song = parse("{sot}\ne|---|\n{eot}\n[Am]Hello").unwrap();
2628        // Line 3 should be a lyrics line with a chord.
2629        if let Line::Lyrics(ref l) = song.lines[3] {
2630            assert!(l.segments[0].chord.is_some());
2631            assert_eq!(l.segments[0].chord.as_ref().unwrap().name, "Am");
2632        } else {
2633            panic!("expected lyrics line with chord after tab section");
2634        }
2635    }
2636
2637    // --- Grid verbatim (#107) ---
2638
2639    #[test]
2640    fn grid_content_is_verbatim() {
2641        // Brackets inside grid should NOT be parsed as chords.
2642        let song = parse("{start_of_grid}\n| [Am] . | [C] . |\n{end_of_grid}").unwrap();
2643        // Line 0: start_of_grid directive
2644        // Line 1: verbatim text line
2645        // Line 2: end_of_grid directive
2646        if let Line::Lyrics(ref l) = song.lines[1] {
2647            assert_eq!(l.segments.len(), 1);
2648            assert!(l.segments[0].chord.is_none());
2649            assert_eq!(l.segments[0].text, "| [Am] . | [C] . |");
2650        } else {
2651            panic!("expected lyrics line for grid content");
2652        }
2653    }
2654
2655    #[test]
2656    fn grid_content_preserves_braces() {
2657        let song = parse("{sog}\n{some text}\n{eog}").unwrap();
2658        if let Line::Lyrics(ref l) = song.lines[1] {
2659            assert_eq!(l.segments[0].text, "{some text}");
2660        } else {
2661            panic!("expected lyrics line for grid content");
2662        }
2663    }
2664
2665    #[test]
2666    fn chords_parsed_after_grid_ends() {
2667        // After end_of_grid, chord parsing should resume.
2668        let song = parse("{sog}\n| Am . |\n{eog}\n[Am]Hello").unwrap();
2669        // Line 3 should be a lyrics line with a chord.
2670        if let Line::Lyrics(ref l) = song.lines[3] {
2671            assert!(l.segments[0].chord.is_some());
2672            assert_eq!(l.segments[0].chord.as_ref().unwrap().name, "Am");
2673        } else {
2674            panic!("expected lyrics line with chord after grid section");
2675        }
2676    }
2677
2678    #[test]
2679    fn grid_short_aliases_sog_eog() {
2680        let song = parse("{sog}\n| Am |\n{eog}").unwrap();
2681        if let Line::Directive(ref d) = song.lines[0] {
2682            assert_eq!(d.kind, DirectiveKind::StartOfGrid);
2683            assert_eq!(d.name, "start_of_grid");
2684        } else {
2685            panic!("expected start_of_grid directive");
2686        }
2687        if let Line::Directive(ref d) = song.lines[2] {
2688            assert_eq!(d.kind, DirectiveKind::EndOfGrid);
2689            assert_eq!(d.name, "end_of_grid");
2690        } else {
2691            panic!("expected end_of_grid directive");
2692        }
2693    }
2694
2695    #[test]
2696    fn grid_with_label() {
2697        let song = parse("{start_of_grid: Intro}\n| Am . | C . |\n{end_of_grid}").unwrap();
2698        if let Line::Directive(ref d) = song.lines[0] {
2699            assert_eq!(d.kind, DirectiveKind::StartOfGrid);
2700            assert_eq!(d.value.as_deref(), Some("Intro"));
2701        } else {
2702            panic!("expected start_of_grid directive with label");
2703        }
2704    }
2705
2706    // --- Define directive (#37) ---
2707
2708    #[test]
2709    fn define_directive_parsed() {
2710        let song = parse("{define: Asus4 base-fret 1 frets x 0 2 2 3 0}").unwrap();
2711        if let Line::Directive(ref d) = song.lines[0] {
2712            assert_eq!(d.kind, DirectiveKind::Define);
2713            assert_eq!(d.name, "define");
2714            assert_eq!(
2715                d.value.as_deref(),
2716                Some("Asus4 base-fret 1 frets x 0 2 2 3 0")
2717            );
2718        } else {
2719            panic!("expected define directive");
2720        }
2721    }
2722
2723    #[test]
2724    fn chord_directive_parsed() {
2725        let song = parse("{chord: Asus4}").unwrap();
2726        if let Line::Directive(ref d) = song.lines[0] {
2727            assert_eq!(d.kind, DirectiveKind::ChordDirective);
2728            assert_eq!(d.value.as_deref(), Some("Asus4"));
2729        } else {
2730            panic!("expected chord directive");
2731        }
2732    }
2733
2734    #[test]
2735    fn page_control_directives_long_form() {
2736        let song = parse("{new_page}\n{new_physical_page}\n{column_break}\n{columns: 2}").unwrap();
2737        if let Line::Directive(ref d) = song.lines[0] {
2738            assert_eq!(d.kind, DirectiveKind::NewPage);
2739            assert_eq!(d.name, "new_page");
2740            assert!(d.value.is_none());
2741        } else {
2742            panic!("expected new_page directive");
2743        }
2744        if let Line::Directive(ref d) = song.lines[1] {
2745            assert_eq!(d.kind, DirectiveKind::NewPhysicalPage);
2746            assert_eq!(d.name, "new_physical_page");
2747            assert!(d.value.is_none());
2748        } else {
2749            panic!("expected new_physical_page directive");
2750        }
2751        if let Line::Directive(ref d) = song.lines[2] {
2752            assert_eq!(d.kind, DirectiveKind::ColumnBreak);
2753            assert_eq!(d.name, "column_break");
2754            assert!(d.value.is_none());
2755        } else {
2756            panic!("expected column_break directive");
2757        }
2758        if let Line::Directive(ref d) = song.lines[3] {
2759            assert_eq!(d.kind, DirectiveKind::Columns);
2760            assert_eq!(d.name, "columns");
2761            assert_eq!(d.value.as_deref(), Some("2"));
2762        } else {
2763            panic!("expected columns directive");
2764        }
2765    }
2766
2767    #[test]
2768    fn page_control_directives_short_form() {
2769        let song = parse("{np}\n{npp}\n{colb}\n{col: 3}").unwrap();
2770        if let Line::Directive(ref d) = song.lines[0] {
2771            assert_eq!(d.kind, DirectiveKind::NewPage);
2772            assert_eq!(d.name, "new_page");
2773        } else {
2774            panic!("expected new_page directive");
2775        }
2776        if let Line::Directive(ref d) = song.lines[1] {
2777            assert_eq!(d.kind, DirectiveKind::NewPhysicalPage);
2778            assert_eq!(d.name, "new_physical_page");
2779        } else {
2780            panic!("expected new_physical_page directive");
2781        }
2782        if let Line::Directive(ref d) = song.lines[2] {
2783            assert_eq!(d.kind, DirectiveKind::ColumnBreak);
2784            assert_eq!(d.name, "column_break");
2785        } else {
2786            panic!("expected column_break directive");
2787        }
2788        if let Line::Directive(ref d) = song.lines[3] {
2789            assert_eq!(d.kind, DirectiveKind::Columns);
2790            assert_eq!(d.name, "columns");
2791            assert_eq!(d.value.as_deref(), Some("3"));
2792        } else {
2793            panic!("expected columns directive");
2794        }
2795    }
2796
2797    #[test]
2798    fn page_control_not_metadata() {
2799        let song = parse("{new_page}\n{columns: 2}").unwrap();
2800        // Page control directives should not populate metadata
2801        assert!(song.metadata.title.is_none());
2802        assert!(song.metadata.custom.is_empty());
2803    }
2804
2805    // --- Lenient parsing / multi-error (#61) ---
2806
2807    #[test]
2808    fn parse_lenient_no_errors() {
2809        let result = parse_lenient("{title: Test}\n[Am]Hello");
2810        assert!(result.is_ok());
2811        assert!(!result.has_errors());
2812        assert_eq!(result.song.metadata.title.as_deref(), Some("Test"));
2813        assert_eq!(result.song.lines.len(), 2);
2814    }
2815
2816    #[test]
2817    fn parse_lenient_collects_multiple_errors() {
2818        // Two errors: unclosed directive on line 1, unclosed chord on line 3
2819        let result = parse_lenient("{title\nHello world\n[Am");
2820        assert!(result.has_errors());
2821        assert_eq!(result.errors.len(), 2);
2822        // The valid lyrics line in the middle should still be present.
2823        assert!(result.song.lines.iter().any(|l| {
2824            if let Line::Lyrics(ll) = l {
2825                ll.text() == "Hello world"
2826            } else {
2827                false
2828            }
2829        }));
2830    }
2831
2832    #[test]
2833    fn parse_lenient_partial_ast_with_metadata() {
2834        // Title parses successfully, then an error, then more content.
2835        let result = parse_lenient("{title: My Song}\n{bad\n[G]La la");
2836        assert_eq!(result.errors.len(), 1);
2837        assert_eq!(result.song.metadata.title.as_deref(), Some("My Song"));
2838        // Title directive + skipped error line + lyrics = at least 2 lines
2839        assert!(result.song.lines.len() >= 2);
2840    }
2841
2842    #[test]
2843    fn parse_lenient_all_lines_bad() {
2844        let result = parse_lenient("{unclosed\n[bad");
2845        assert_eq!(result.errors.len(), 2);
2846        assert!(result.song.lines.is_empty());
2847    }
2848
2849    #[test]
2850    fn parse_lenient_error_locations() {
2851        let result = parse_lenient("{ok: fine}\n{bad\n[Am]Good\n{also bad");
2852        assert_eq!(result.errors.len(), 2);
2853        assert_eq!(result.errors[0].line(), 2);
2854        assert_eq!(result.errors[1].line(), 4);
2855    }
2856
2857    #[test]
2858    fn parse_lenient_empty_input() {
2859        let result = parse_lenient("");
2860        assert!(result.is_ok());
2861        assert!(result.song.lines.is_empty());
2862    }
2863
2864    #[test]
2865    fn parse_lenient_size_limit() {
2866        let opts = ParseOptions {
2867            max_input_size: 10,
2868            ..Default::default()
2869        };
2870        let result = parse_lenient_with_options("this input is too long", &opts);
2871        assert!(result.has_errors());
2872        assert_eq!(result.errors.len(), 1);
2873        assert!(result.errors[0].message.contains("exceeds maximum"));
2874    }
2875
2876    #[test]
2877    fn parse_lenient_max_errors_limits_collection() {
2878        // Generate input with many errors (unclosed directives)
2879        let input: String = (0..100).map(|_| "{unclosed\n").collect();
2880        let opts = ParseOptions {
2881            max_errors: 5,
2882            ..Default::default()
2883        };
2884        let result = parse_lenient_with_options(&input, &opts);
2885        assert!(result.has_errors());
2886        assert_eq!(result.errors.len(), 5);
2887    }
2888
2889    #[test]
2890    fn parse_lenient_zero_max_errors_disables_limit() {
2891        let input: String = (0..20).map(|_| "{unclosed\n").collect();
2892        let opts = ParseOptions {
2893            max_errors: 0,
2894            ..Default::default()
2895        };
2896        let result = parse_lenient_with_options(&input, &opts);
2897        assert_eq!(result.errors.len(), 20);
2898    }
2899
2900    #[test]
2901    fn transpose_directive_parsed() {
2902        let song = parse("{transpose: 2}").expect("parse failed");
2903        assert_eq!(song.lines.len(), 1);
2904        if let Line::Directive(ref d) = song.lines[0] {
2905            assert_eq!(d.kind, DirectiveKind::Transpose);
2906            assert_eq!(d.name, "transpose");
2907            assert_eq!(d.value.as_deref(), Some("2"));
2908        } else {
2909            panic!("expected transpose directive");
2910        }
2911    }
2912
2913    #[test]
2914    fn transpose_directive_negative_value() {
2915        let song = parse("{transpose: -3}").expect("parse failed");
2916        if let Line::Directive(ref d) = song.lines[0] {
2917            assert_eq!(d.kind, DirectiveKind::Transpose);
2918            assert_eq!(d.value.as_deref(), Some("-3"));
2919        } else {
2920            panic!("expected transpose directive");
2921        }
2922    }
2923
2924    #[test]
2925    fn transpose_directive_no_value() {
2926        let song = parse("{transpose}").expect("parse failed");
2927        if let Line::Directive(ref d) = song.lines[0] {
2928            assert_eq!(d.kind, DirectiveKind::Transpose);
2929            assert!(d.value.is_none());
2930        } else {
2931            panic!("expected transpose directive");
2932        }
2933    }
2934
2935    #[test]
2936    fn transpose_directive_is_not_metadata() {
2937        let kind = DirectiveKind::Transpose;
2938        assert!(!kind.is_metadata());
2939    }
2940
2941    #[test]
2942    fn transpose_directive_case_insensitive() {
2943        let song = parse("{Transpose: 5}").expect("parse failed");
2944        if let Line::Directive(ref d) = song.lines[0] {
2945            assert_eq!(d.kind, DirectiveKind::Transpose);
2946            assert_eq!(d.name, "transpose");
2947            assert_eq!(d.value.as_deref(), Some("5"));
2948        } else {
2949            panic!("expected transpose directive");
2950        }
2951    }
2952
2953    // -- Custom section directives (#108) -----------------------------------
2954
2955    #[test]
2956    fn custom_section_start_parsed() {
2957        let result = lines("{start_of_intro}");
2958        if let Line::Directive(ref d) = result[0] {
2959            assert_eq!(d.name, "start_of_intro");
2960            assert_eq!(d.kind, DirectiveKind::StartOfSection("intro".to_string()));
2961            assert!(d.is_section_start());
2962        } else {
2963            panic!("expected directive");
2964        }
2965    }
2966
2967    #[test]
2968    fn custom_section_end_parsed() {
2969        let result = lines("{end_of_intro}");
2970        if let Line::Directive(ref d) = result[0] {
2971            assert_eq!(d.name, "end_of_intro");
2972            assert_eq!(d.kind, DirectiveKind::EndOfSection("intro".to_string()));
2973            assert!(d.is_section_end());
2974        } else {
2975            panic!("expected directive");
2976        }
2977    }
2978
2979    #[test]
2980    fn custom_section_with_label() {
2981        let result = lines("{start_of_intro: Guitar Intro}");
2982        if let Line::Directive(ref d) = result[0] {
2983            assert_eq!(d.name, "start_of_intro");
2984            assert_eq!(d.value.as_deref(), Some("Guitar Intro"));
2985            assert_eq!(d.kind, DirectiveKind::StartOfSection("intro".to_string()));
2986        } else {
2987            panic!("expected directive");
2988        }
2989    }
2990
2991    #[test]
2992    fn custom_section_lyrics_parsed_normally() {
2993        let song = parse("{start_of_intro}\n[Am]Hello [G]world\n{end_of_intro}").unwrap();
2994        // Lines: start_of_intro, lyrics, end_of_intro
2995        assert_eq!(song.lines.len(), 3);
2996        if let Line::Lyrics(ref l) = song.lines[1] {
2997            assert!(l.has_chords());
2998            assert_eq!(l.segments.len(), 2);
2999            assert_eq!(l.segments[0].chord.as_ref().unwrap().name, "Am");
3000        } else {
3001            panic!("expected lyrics line inside custom section");
3002        }
3003    }
3004
3005    #[test]
3006    fn custom_section_various_names() {
3007        for name in &["outro", "solo", "interlude", "coda", "pre_chorus"] {
3008            let input = format!("{{start_of_{name}}}");
3009            let result = lines(&input);
3010            if let Line::Directive(ref d) = result[0] {
3011                assert_eq!(d.name, format!("start_of_{name}"));
3012                assert!(d.is_section_start(), "should be section start for {name}");
3013            } else {
3014                panic!("expected directive for {name}");
3015            }
3016        }
3017    }
3018
3019    // -- Inline markup in lyrics -------------------------------------------
3020
3021    #[test]
3022    fn lyrics_with_bold_markup() {
3023        use crate::inline_markup::TextSpan;
3024
3025        let result = lines("[Am]Hello <b>world</b>");
3026        match &result[0] {
3027            Line::Lyrics(lyrics) => {
3028                assert_eq!(lyrics.segments.len(), 1);
3029                let seg = &lyrics.segments[0];
3030                assert_eq!(seg.text, "Hello world");
3031                assert_eq!(
3032                    seg.spans,
3033                    vec![
3034                        TextSpan::Plain("Hello ".to_string()),
3035                        TextSpan::Bold(vec![TextSpan::Plain("world".to_string())]),
3036                    ]
3037                );
3038            }
3039            _ => panic!("expected lyrics line"),
3040        }
3041    }
3042
3043    #[test]
3044    fn custom_section_case_insensitive() {
3045        let result = lines("{Start_Of_Intro}");
3046        if let Line::Directive(ref d) = result[0] {
3047            assert_eq!(d.name, "start_of_intro");
3048            assert_eq!(d.kind, DirectiveKind::StartOfSection("intro".to_string()));
3049        } else {
3050            panic!("expected directive");
3051        }
3052    }
3053
3054    // --- Image directive (#124) ---
3055
3056    #[test]
3057    fn image_directive_basic() {
3058        let song = parse("{image: src=photo.jpg}").unwrap();
3059        if let Line::Directive(ref d) = song.lines[0] {
3060            assert_eq!(d.name, "image");
3061            if let DirectiveKind::Image(ref attrs) = d.kind {
3062                assert_eq!(attrs.src, "photo.jpg");
3063                assert!(attrs.width.is_none());
3064                assert!(attrs.height.is_none());
3065                assert!(attrs.scale.is_none());
3066                assert!(attrs.title.is_none());
3067                assert!(attrs.anchor.is_none());
3068            } else {
3069                panic!("expected Image directive kind");
3070            }
3071        } else {
3072            panic!("expected directive");
3073        }
3074    }
3075
3076    // --- Inline markup (#112) ---
3077
3078    #[test]
3079    fn lyrics_without_markup_has_empty_spans() {
3080        let result = lines("[Am]Hello world");
3081        match &result[0] {
3082            Line::Lyrics(lyrics) => {
3083                assert_eq!(lyrics.segments[0].text, "Hello world");
3084                assert!(lyrics.segments[0].spans.is_empty());
3085            }
3086            _ => panic!("expected lyrics line"),
3087        }
3088    }
3089
3090    #[test]
3091    fn image_directive_all_attributes() {
3092        let song =
3093            parse(r#"{image: src=logo.png width=200 height=100 scale=0.5 title="Album Cover" anchor=top}"#)
3094                .unwrap();
3095        if let Line::Directive(ref d) = song.lines[0] {
3096            if let DirectiveKind::Image(ref attrs) = d.kind {
3097                assert_eq!(attrs.src, "logo.png");
3098                assert_eq!(attrs.width.as_deref(), Some("200"));
3099                assert_eq!(attrs.height.as_deref(), Some("100"));
3100                assert_eq!(attrs.scale.as_deref(), Some("0.5"));
3101                assert_eq!(attrs.title.as_deref(), Some("Album Cover"));
3102                assert_eq!(attrs.anchor.as_deref(), Some("top"));
3103            } else {
3104                panic!("expected Image directive kind");
3105            }
3106        } else {
3107            panic!("expected directive");
3108        }
3109    }
3110
3111    #[test]
3112    fn lyrics_with_nested_markup() {
3113        use crate::inline_markup::TextSpan;
3114
3115        let result = lines("<b><i>both</i></b>");
3116        match &result[0] {
3117            Line::Lyrics(lyrics) => {
3118                assert_eq!(lyrics.segments[0].text, "both");
3119                assert_eq!(
3120                    lyrics.segments[0].spans,
3121                    vec![TextSpan::Bold(vec![TextSpan::Italic(vec![
3122                        TextSpan::Plain("both".to_string())
3123                    ])])]
3124                );
3125            }
3126            _ => panic!("expected lyrics line"),
3127        }
3128    }
3129
3130    #[test]
3131    fn image_directive_quoted_value_with_spaces() {
3132        let song = parse(r#"{image: src=cover.jpg title="My Great Album"}"#).unwrap();
3133        if let Line::Directive(ref d) = song.lines[0] {
3134            if let DirectiveKind::Image(ref attrs) = d.kind {
3135                assert_eq!(attrs.src, "cover.jpg");
3136                assert_eq!(attrs.title.as_deref(), Some("My Great Album"));
3137            } else {
3138                panic!("expected Image directive kind");
3139            }
3140        } else {
3141            panic!("expected directive");
3142        }
3143    }
3144
3145    #[test]
3146    fn lyrics_markup_text_field_has_stripped_content() {
3147        let result = lines("<b>bold</b> and <i>italic</i> text");
3148        match &result[0] {
3149            Line::Lyrics(lyrics) => {
3150                // text field should have markup stripped
3151                assert_eq!(lyrics.segments[0].text, "bold and italic text");
3152                // spans should be populated
3153                assert!(!lyrics.segments[0].spans.is_empty());
3154            }
3155            _ => panic!("expected lyrics line"),
3156        }
3157    }
3158
3159    #[test]
3160    fn image_directive_no_value() {
3161        let song = parse("{image}").unwrap();
3162        if let Line::Directive(ref d) = song.lines[0] {
3163            assert_eq!(d.name, "image");
3164            if let DirectiveKind::Image(ref attrs) = d.kind {
3165                assert_eq!(attrs.src, "");
3166            } else {
3167                panic!("expected Image directive kind");
3168            }
3169        } else {
3170            panic!("expected directive");
3171        }
3172    }
3173
3174    #[test]
3175    fn image_directive_unknown_attributes_ignored() {
3176        let song = parse("{image: src=pic.jpg unknown=foo bar}").unwrap();
3177        if let Line::Directive(ref d) = song.lines[0] {
3178            if let DirectiveKind::Image(ref attrs) = d.kind {
3179                assert_eq!(attrs.src, "pic.jpg");
3180                // unknown attribute is silently ignored
3181            } else {
3182                panic!("expected Image directive kind");
3183            }
3184        } else {
3185            panic!("expected directive");
3186        }
3187    }
3188
3189    #[test]
3190    fn image_directive_case_insensitive() {
3191        let song = parse("{IMAGE: src=photo.jpg}").unwrap();
3192        if let Line::Directive(ref d) = song.lines[0] {
3193            assert_eq!(d.name, "image");
3194            assert!(d.kind.is_image());
3195        } else {
3196            panic!("expected directive");
3197        }
3198    }
3199
3200    #[test]
3201    fn image_directive_width_only() {
3202        let song = parse("{image: src=img.png width=50%}").unwrap();
3203        if let Line::Directive(ref d) = song.lines[0] {
3204            if let DirectiveKind::Image(ref attrs) = d.kind {
3205                assert_eq!(attrs.src, "img.png");
3206                assert_eq!(attrs.width.as_deref(), Some("50%"));
3207                assert!(attrs.height.is_none());
3208            } else {
3209                panic!("expected Image directive kind");
3210            }
3211        } else {
3212            panic!("expected directive");
3213        }
3214    }
3215
3216    #[test]
3217    fn image_directive_preserves_raw_value() {
3218        let song = parse("{image: src=photo.jpg width=200}").unwrap();
3219        if let Line::Directive(ref d) = song.lines[0] {
3220            // The raw value string is preserved.
3221            assert_eq!(d.value.as_deref(), Some("src=photo.jpg width=200"));
3222        } else {
3223            panic!("expected directive");
3224        }
3225    }
3226
3227    // --- parse_image_attributes unit tests ---
3228
3229    #[test]
3230    fn parse_image_attributes_empty_input() {
3231        let attrs = super::parse_image_attributes("");
3232        assert_eq!(attrs.src, "");
3233        assert!(attrs.width.is_none());
3234    }
3235
3236    #[test]
3237    fn parse_image_attributes_src_only() {
3238        let attrs = super::parse_image_attributes("src=test.png");
3239        assert_eq!(attrs.src, "test.png");
3240    }
3241
3242    #[test]
3243    fn parse_image_attributes_multiple() {
3244        let attrs = super::parse_image_attributes("src=a.jpg width=100 height=200");
3245        assert_eq!(attrs.src, "a.jpg");
3246        assert_eq!(attrs.width.as_deref(), Some("100"));
3247        assert_eq!(attrs.height.as_deref(), Some("200"));
3248    }
3249
3250    #[test]
3251    fn parse_image_attributes_quoted_value() {
3252        let attrs = super::parse_image_attributes(r#"src=a.jpg title="Hello World""#);
3253        assert_eq!(attrs.src, "a.jpg");
3254        assert_eq!(attrs.title.as_deref(), Some("Hello World"));
3255    }
3256
3257    #[test]
3258    fn parse_image_attributes_extra_whitespace() {
3259        let attrs = super::parse_image_attributes("  src=a.jpg   width=100  ");
3260        assert_eq!(attrs.src, "a.jpg");
3261        assert_eq!(attrs.width.as_deref(), Some("100"));
3262    }
3263
3264    #[test]
3265    fn parse_image_attributes_case_insensitive_keys() {
3266        let attrs = super::parse_image_attributes("SRC=photo.jpg WIDTH=200 Height=100");
3267        assert_eq!(attrs.src, "photo.jpg");
3268        assert_eq!(attrs.width.as_deref(), Some("200"));
3269        assert_eq!(attrs.height.as_deref(), Some("100"));
3270    }
3271
3272    #[test]
3273    fn parse_image_attributes_mixed_case_keys() {
3274        let attrs = super::parse_image_attributes("Src=a.jpg Scale=0.5 Title=test Anchor=column");
3275        assert_eq!(attrs.src, "a.jpg");
3276        assert_eq!(attrs.scale.as_deref(), Some("0.5"));
3277        assert_eq!(attrs.title.as_deref(), Some("test"));
3278        assert_eq!(attrs.anchor.as_deref(), Some("column"));
3279    }
3280
3281    #[test]
3282    fn parse_image_attributes_src_truncated_at_limit() {
3283        let long_src = "a".repeat(5000);
3284        let input = format!("src={long_src}");
3285        let attrs = super::parse_image_attributes(&input);
3286        assert_eq!(attrs.src.len(), super::IMAGE_SRC_MAX_BYTES);
3287    }
3288
3289    #[test]
3290    fn parse_image_attributes_other_attrs_truncated_at_limit() {
3291        let long_title = "x".repeat(2000);
3292        let input = format!("src=ok.jpg title=\"{long_title}\" width={long_title}");
3293        let attrs = super::parse_image_attributes(&input);
3294        assert_eq!(attrs.src, "ok.jpg");
3295        assert_eq!(
3296            attrs.title.as_deref().map(str::len),
3297            Some(super::IMAGE_ATTR_MAX_BYTES)
3298        );
3299        assert_eq!(
3300            attrs.width.as_deref().map(str::len),
3301            Some(super::IMAGE_ATTR_MAX_BYTES)
3302        );
3303    }
3304
3305    #[test]
3306    fn parse_image_attributes_truncation_respects_utf8_boundary() {
3307        // Each CJK character is 3 bytes. 341 chars = 1023 bytes, 342 = 1026.
3308        // With a 1024 limit the truncation must land on a char boundary.
3309        let cjk = "漢".repeat(342); // 1026 bytes
3310        let input = format!("title=\"{cjk}\"");
3311        let attrs = super::parse_image_attributes(&input);
3312        let title = attrs.title.unwrap();
3313        assert!(title.len() <= super::IMAGE_ATTR_MAX_BYTES);
3314        // Must be valid UTF-8 (String guarantees this, but verify length is
3315        // at a 3-byte boundary).
3316        assert_eq!(title.len(), 1023); // 341 * 3
3317    }
3318
3319    #[test]
3320    fn parse_image_attributes_values_within_limit_unchanged() {
3321        let title = "a".repeat(1024);
3322        let input = format!("src=ok.jpg title=\"{title}\"");
3323        let attrs = super::parse_image_attributes(&input);
3324        assert_eq!(attrs.title.as_deref(), Some(title.as_str()));
3325    }
3326
3327    #[test]
3328    fn truncate_string_empty() {
3329        assert_eq!(super::truncate_string(String::new(), 100), "");
3330    }
3331
3332    #[test]
3333    fn split_key_value_pairs_basic() {
3334        let pairs = super::split_key_value_pairs("key=value");
3335        assert_eq!(pairs, vec![("key".to_string(), "value".to_string())]);
3336    }
3337
3338    #[test]
3339    fn split_key_value_pairs_quoted() {
3340        let pairs = super::split_key_value_pairs(r#"key="hello world""#);
3341        assert_eq!(pairs, vec![("key".to_string(), "hello world".to_string())]);
3342    }
3343
3344    #[test]
3345    fn split_key_value_pairs_mixed() {
3346        let pairs = super::split_key_value_pairs(r#"a=1 b="two three" c=4"#);
3347        assert_eq!(pairs.len(), 3);
3348        assert_eq!(pairs[0], ("a".to_string(), "1".to_string()));
3349        assert_eq!(pairs[1], ("b".to_string(), "two three".to_string()));
3350        assert_eq!(pairs[2], ("c".to_string(), "4".to_string()));
3351    }
3352
3353    #[test]
3354    fn split_key_value_pairs_no_equals() {
3355        let pairs = super::split_key_value_pairs("bare_token");
3356        assert!(pairs.is_empty());
3357    }
3358
3359    #[test]
3360    fn split_key_value_pairs_empty() {
3361        let pairs = super::split_key_value_pairs("");
3362        assert!(pairs.is_empty());
3363    }
3364
3365    #[test]
3366    fn split_key_value_pairs_unterminated_quote() {
3367        // Unterminated quote: value should be everything after the opening quote.
3368        let pairs = super::split_key_value_pairs(r#"key="hello world"#);
3369        assert_eq!(pairs, vec![("key".to_string(), "hello world".to_string())]);
3370    }
3371
3372    #[test]
3373    fn parse_image_attributes_unterminated_quoted_title() {
3374        // From issue #288 (M5): unterminated quoted value should be accepted
3375        // gracefully — the value is everything from the opening quote to EOI.
3376        let attrs = super::parse_image_attributes(r#"src=photo.jpg title="My Album"#);
3377        assert_eq!(attrs.src, "photo.jpg");
3378        assert_eq!(attrs.title.as_deref(), Some("My Album"));
3379    }
3380
3381    #[test]
3382    fn parse_image_attributes_unterminated_quote_with_trailing_attrs() {
3383        // When a quote is not terminated, all remaining text becomes the value.
3384        let attrs = super::parse_image_attributes(r#"src=photo.jpg title="My Album width=100"#);
3385        assert_eq!(attrs.src, "photo.jpg");
3386        assert_eq!(attrs.title.as_deref(), Some("My Album width=100"));
3387        // width should NOT be parsed since it was consumed as part of the title.
3388        assert!(attrs.width.is_none());
3389    }
3390}
3391
3392#[cfg(test)]
3393mod delegate_tests {
3394    use super::*;
3395    use crate::ast::{DirectiveKind, Line};
3396
3397    fn lines(input: &str) -> Vec<Line> {
3398        parse(input).expect("parse failed").lines
3399    }
3400
3401    #[test]
3402    fn abc_content_is_verbatim() {
3403        let song = parse("{start_of_abc}\nX:1\nK:G\n{end_of_abc}").unwrap();
3404        assert_eq!(song.lines.len(), 4);
3405        if let Line::Lyrics(ref l) = song.lines[1] {
3406            assert_eq!(l.segments.len(), 1);
3407            assert!(l.segments[0].chord.is_none());
3408            assert_eq!(l.segments[0].text, "X:1");
3409        } else {
3410            panic!("expected lyrics line for ABC content");
3411        }
3412    }
3413
3414    #[test]
3415    fn abc_preserves_brackets() {
3416        let song = parse("{start_of_abc}\n|:GABc|[1d2d2:|[2d4d4:|\n{end_of_abc}").unwrap();
3417        if let Line::Lyrics(ref l) = song.lines[1] {
3418            assert_eq!(l.segments[0].text, "|:GABc|[1d2d2:|[2d4d4:|");
3419        } else {
3420            panic!("expected verbatim lyrics line");
3421        }
3422    }
3423
3424    #[test]
3425    fn ly_content_is_verbatim() {
3426        let song = parse("{start_of_ly}\n\\relative c' { c4 d e f }\n{end_of_ly}").unwrap();
3427        assert_eq!(song.lines.len(), 3);
3428        if let Line::Lyrics(ref l) = song.lines[1] {
3429            assert!(l.segments[0].chord.is_none());
3430        } else {
3431            panic!("expected lyrics line for Lilypond content");
3432        }
3433    }
3434
3435    #[test]
3436    fn svg_content_is_verbatim() {
3437        let song = parse("{start_of_svg}\n<svg><rect/></svg>\n{end_of_svg}").unwrap();
3438        assert_eq!(song.lines.len(), 3);
3439        if let Line::Lyrics(ref l) = song.lines[1] {
3440            assert!(l.segments[0].chord.is_none());
3441        } else {
3442            panic!("expected lyrics line for SVG content");
3443        }
3444    }
3445
3446    #[test]
3447    fn textblock_content_is_verbatim() {
3448        let song = parse("{start_of_textblock}\n[Am]Not a chord\n{end_of_textblock}").unwrap();
3449        assert_eq!(song.lines.len(), 3);
3450        if let Line::Lyrics(ref l) = song.lines[1] {
3451            assert_eq!(l.segments.len(), 1);
3452            assert!(l.segments[0].chord.is_none());
3453            assert_eq!(l.segments[0].text, "[Am]Not a chord");
3454        } else {
3455            panic!("expected lyrics line for textblock content");
3456        }
3457    }
3458
3459    #[test]
3460    fn textblock_preserves_braces() {
3461        let song = parse("{start_of_textblock}\n{some directive}\n{end_of_textblock}").unwrap();
3462        if let Line::Lyrics(ref l) = song.lines[1] {
3463            assert_eq!(l.segments[0].text, "{some directive}");
3464        } else {
3465            panic!("expected verbatim lyrics line");
3466        }
3467    }
3468
3469    #[test]
3470    fn chords_parsed_after_abc_ends() {
3471        let song = parse("{start_of_abc}\nX:1\n{end_of_abc}\n[Am]Hello").unwrap();
3472        if let Line::Lyrics(ref l) = song.lines[3] {
3473            assert!(l.segments[0].chord.is_some());
3474            assert_eq!(l.segments[0].chord.as_ref().unwrap().name, "Am");
3475        } else {
3476            panic!("expected lyrics line with chord after ABC section");
3477        }
3478    }
3479
3480    #[test]
3481    fn chords_parsed_after_ly_ends() {
3482        let song = parse("{start_of_ly}\nnotes\n{end_of_ly}\n[G]Hello").unwrap();
3483        if let Line::Lyrics(ref l) = song.lines[3] {
3484            assert!(l.segments[0].chord.is_some());
3485            assert_eq!(l.segments[0].chord.as_ref().unwrap().name, "G");
3486        } else {
3487            panic!("expected lyrics line with chord after Lilypond section");
3488        }
3489    }
3490
3491    #[test]
3492    fn chords_parsed_after_svg_ends() {
3493        let song = parse("{start_of_svg}\n<svg/>\n{end_of_svg}\n[C]Hello").unwrap();
3494        if let Line::Lyrics(ref l) = song.lines[3] {
3495            assert!(l.segments[0].chord.is_some());
3496            assert_eq!(l.segments[0].chord.as_ref().unwrap().name, "C");
3497        } else {
3498            panic!("expected lyrics line with chord after SVG section");
3499        }
3500    }
3501
3502    #[test]
3503    fn chords_parsed_after_textblock_ends() {
3504        let song = parse("{start_of_textblock}\ntext\n{end_of_textblock}\n[D]Hello").unwrap();
3505        if let Line::Lyrics(ref l) = song.lines[3] {
3506            assert!(l.segments[0].chord.is_some());
3507            assert_eq!(l.segments[0].chord.as_ref().unwrap().name, "D");
3508        } else {
3509            panic!("expected lyrics line with chord after textblock section");
3510        }
3511    }
3512
3513    #[test]
3514    fn abc_directive_with_label() {
3515        let result = lines("{start_of_abc: My Melody}");
3516        if let Line::Directive(ref d) = result[0] {
3517            assert_eq!(d.kind, DirectiveKind::StartOfAbc);
3518            assert_eq!(d.value.as_deref(), Some("My Melody"));
3519        } else {
3520            panic!("expected directive");
3521        }
3522    }
3523
3524    // -- Selector suffix parsing --------------------------------------------
3525
3526    #[test]
3527    fn selector_suffix_on_metadata_directive() {
3528        let result = lines("{title-piano: My Song}");
3529        if let Line::Directive(ref d) = result[0] {
3530            assert_eq!(d.name, "title");
3531            assert_eq!(d.value.as_deref(), Some("My Song"));
3532            assert_eq!(d.kind, DirectiveKind::Title);
3533            assert_eq!(d.selector.as_deref(), Some("piano"));
3534        } else {
3535            panic!("expected directive");
3536        }
3537    }
3538
3539    #[test]
3540    fn textblock_directive_with_label() {
3541        let result = lines("{start_of_textblock: Notes}");
3542        if let Line::Directive(ref d) = result[0] {
3543            assert_eq!(d.kind, DirectiveKind::StartOfTextblock);
3544            assert_eq!(d.value.as_deref(), Some("Notes"));
3545        } else {
3546            panic!("expected directive");
3547        }
3548    }
3549
3550    #[test]
3551    fn selector_suffix_on_key_directive() {
3552        let result = lines("{key-bass: E}");
3553        if let Line::Directive(ref d) = result[0] {
3554            assert_eq!(d.name, "key");
3555            assert_eq!(d.value.as_deref(), Some("E"));
3556            assert_eq!(d.kind, DirectiveKind::Key);
3557            assert_eq!(d.selector.as_deref(), Some("bass"));
3558        } else {
3559            panic!("expected directive");
3560        }
3561    }
3562
3563    #[test]
3564    fn delegate_sections_not_custom() {
3565        assert_eq!(
3566            DirectiveKind::from_name("start_of_abc"),
3567            DirectiveKind::StartOfAbc
3568        );
3569        assert_eq!(
3570            DirectiveKind::from_name("start_of_ly"),
3571            DirectiveKind::StartOfLy
3572        );
3573        assert_eq!(
3574            DirectiveKind::from_name("start_of_svg"),
3575            DirectiveKind::StartOfSvg
3576        );
3577        assert_eq!(
3578            DirectiveKind::from_name("start_of_textblock"),
3579            DirectiveKind::StartOfTextblock
3580        );
3581    }
3582
3583    #[test]
3584    fn lyrics_markup_preserves_backward_compat() {
3585        // The LyricsLine::text() method should return plain text
3586        let result = lines("[Am]Hello <b>bold</b> [G]world");
3587        match &result[0] {
3588            Line::Lyrics(lyrics) => {
3589                assert_eq!(lyrics.text(), "Hello bold world");
3590            }
3591            _ => panic!("expected lyrics line"),
3592        }
3593    }
3594
3595    // -- NewSong directive --------------------------------------------------
3596
3597    #[test]
3598    fn new_song_directive_kind() {
3599        assert_eq!(DirectiveKind::from_name("new_song"), DirectiveKind::NewSong);
3600        assert_eq!(DirectiveKind::from_name("ns"), DirectiveKind::NewSong);
3601        assert_eq!(DirectiveKind::from_name("NEW_SONG"), DirectiveKind::NewSong);
3602        assert_eq!(DirectiveKind::from_name("Ns"), DirectiveKind::NewSong);
3603    }
3604
3605    #[test]
3606    fn new_song_canonical_name() {
3607        assert_eq!(DirectiveKind::NewSong.canonical_name(), "new_song");
3608    }
3609
3610    #[test]
3611    fn new_song_parsed_as_directive() {
3612        let result = lines("{new_song}");
3613        assert_eq!(result.len(), 1);
3614        if let Line::Directive(ref d) = result[0] {
3615            assert_eq!(d.name, "new_song");
3616            assert_eq!(d.kind, DirectiveKind::NewSong);
3617            assert!(d.value.is_none());
3618        } else {
3619            panic!("expected directive");
3620        }
3621    }
3622
3623    #[test]
3624    fn selector_suffix_on_comment_directive() {
3625        // Comment directives with selectors are kept as Line::Directive
3626        // (not converted to Line::Comment) to preserve the selector.
3627        let result = lines("{comment-piano: Play softly}");
3628        if let Line::Directive(ref d) = result[0] {
3629            assert_eq!(d.kind, DirectiveKind::Comment);
3630            assert_eq!(d.value.as_deref(), Some("Play softly"));
3631            assert_eq!(d.selector.as_deref(), Some("piano"));
3632        } else {
3633            panic!(
3634                "expected directive for comment with selector, got {:?}",
3635                result[0]
3636            );
3637        }
3638    }
3639
3640    #[test]
3641    fn ns_alias_parsed_as_directive() {
3642        let result = lines("{ns}");
3643        assert_eq!(result.len(), 1);
3644        if let Line::Directive(ref d) = result[0] {
3645            assert_eq!(d.name, "new_song");
3646            assert_eq!(d.kind, DirectiveKind::NewSong);
3647        } else {
3648            panic!("expected directive");
3649        }
3650    }
3651
3652    // -- Multi-song parsing -------------------------------------------------
3653
3654    #[test]
3655    fn parse_multi_single_song() {
3656        let input = "{title: Only Song}\n[G]Hello";
3657        let songs = parse_multi(input).unwrap();
3658        assert_eq!(songs.len(), 1);
3659        assert_eq!(songs[0].metadata.title.as_deref(), Some("Only Song"));
3660    }
3661
3662    #[test]
3663    fn parse_multi_two_songs() {
3664        let input = "{title: Song One}\nLyrics one\n{new_song}\n{title: Song Two}\nLyrics two";
3665        let songs = parse_multi(input).unwrap();
3666        assert_eq!(songs.len(), 2);
3667        assert_eq!(songs[0].metadata.title.as_deref(), Some("Song One"));
3668        assert_eq!(songs[1].metadata.title.as_deref(), Some("Song Two"));
3669    }
3670
3671    #[test]
3672    fn parse_multi_ns_alias() {
3673        let input = "{title: First}\n{ns}\n{title: Second}";
3674        let songs = parse_multi(input).unwrap();
3675        assert_eq!(songs.len(), 2);
3676        assert_eq!(songs[0].metadata.title.as_deref(), Some("First"));
3677        assert_eq!(songs[1].metadata.title.as_deref(), Some("Second"));
3678    }
3679
3680    #[test]
3681    fn parse_multi_three_songs() {
3682        let input = "{title: A}\n{new_song}\n{title: B}\n{new_song}\n{title: C}";
3683        let songs = parse_multi(input).unwrap();
3684        assert_eq!(songs.len(), 3);
3685        assert_eq!(songs[0].metadata.title.as_deref(), Some("A"));
3686        assert_eq!(songs[1].metadata.title.as_deref(), Some("B"));
3687        assert_eq!(songs[2].metadata.title.as_deref(), Some("C"));
3688    }
3689
3690    #[test]
3691    fn parse_multi_empty_first_song() {
3692        // {new_song} at the very beginning means the first segment is empty
3693        let input = "{new_song}\n{title: Second}";
3694        let songs = parse_multi(input).unwrap();
3695        assert_eq!(songs.len(), 2);
3696        assert!(songs[0].metadata.title.is_none());
3697        assert_eq!(songs[1].metadata.title.as_deref(), Some("Second"));
3698    }
3699
3700    #[test]
3701    fn parse_multi_case_insensitive() {
3702        let input = "{title: A}\n{NEW_SONG}\n{title: B}";
3703        let songs = parse_multi(input).unwrap();
3704        assert_eq!(songs.len(), 2);
3705    }
3706
3707    #[test]
3708    fn parse_multi_with_whitespace() {
3709        let input = "{title: A}\n{ new_song }\n{title: B}";
3710        let songs = parse_multi(input).unwrap();
3711        assert_eq!(songs.len(), 2);
3712    }
3713
3714    #[test]
3715    fn parse_multi_crlf_line_endings() {
3716        let input = "{title: A}\r\n[G]Hello\r\n{new_song}\r\n{title: B}\r\n[Am]World\r\n";
3717        let songs = parse_multi(input).unwrap();
3718        assert_eq!(songs.len(), 2);
3719        assert_eq!(songs[0].metadata.title, Some("A".to_string()));
3720        assert_eq!(songs[1].metadata.title, Some("B".to_string()));
3721    }
3722
3723    #[test]
3724    fn parse_multi_lenient_collects_errors() {
3725        let input = "{title: Good}\n[Am\n{new_song}\n{title: Also Good}\n[G]Hello";
3726        let result = parse_multi_lenient(input);
3727        assert_eq!(result.results.len(), 2);
3728        assert!(result.results[0].has_errors()); // unclosed chord
3729        assert!(result.results[1].is_ok());
3730        assert_eq!(
3731            result.results[1].song.metadata.title.as_deref(),
3732            Some("Also Good")
3733        );
3734    }
3735
3736    #[test]
3737    fn comment_without_selector_still_becomes_line_comment() {
3738        let result = lines("{comment: Normal comment}");
3739        assert!(
3740            matches!(result[0], Line::Comment(CommentStyle::Normal, _)),
3741            "comment without selector should still be Line::Comment"
3742        );
3743    }
3744
3745    #[test]
3746    fn parse_multi_songs_helper() {
3747        let input = "{title: A}\n{new_song}\n{title: B}";
3748        let result = parse_multi_lenient(input);
3749        let songs = result.songs();
3750        assert_eq!(songs.len(), 2);
3751        assert_eq!(songs[0].metadata.title.as_deref(), Some("A"));
3752        assert_eq!(songs[1].metadata.title.as_deref(), Some("B"));
3753    }
3754
3755    #[test]
3756    fn parse_multi_preserves_song_content() {
3757        let input = "{title: Song One}
3758{artist: Artist One}
3759{start_of_chorus}
3760[G]La la [C]la
3761{end_of_chorus}
3762{new_song}
3763{title: Song Two}
3764{key: Am}
3765[Am]Hello [G]world";
3766        let songs = parse_multi(input).unwrap();
3767        assert_eq!(songs.len(), 2);
3768
3769        // First song
3770        assert_eq!(songs[0].metadata.title.as_deref(), Some("Song One"));
3771        assert_eq!(songs[0].metadata.artists, vec!["Artist One".to_string()]);
3772
3773        // Second song
3774        assert_eq!(songs[1].metadata.title.as_deref(), Some("Song Two"));
3775        assert_eq!(songs[1].metadata.key.as_deref(), Some("Am"));
3776    }
3777
3778    #[test]
3779    fn is_new_song_line_detection() {
3780        assert!(is_new_song_line("{new_song}"));
3781        assert!(is_new_song_line("{ns}"));
3782        assert!(is_new_song_line("{NEW_SONG}"));
3783        assert!(is_new_song_line("{NS}"));
3784        assert!(is_new_song_line("{ new_song }"));
3785        assert!(is_new_song_line("{ ns }"));
3786        // Directives with values should also be detected (#315).
3787        assert!(is_new_song_line("{new_song: value}"));
3788        assert!(is_new_song_line("{ns: tag}"));
3789        assert!(is_new_song_line("{ new_song : tag }"));
3790
3791        assert!(!is_new_song_line("{title}"));
3792        assert!(!is_new_song_line("new_song"));
3793        assert!(!is_new_song_line(""));
3794        assert!(!is_new_song_line("{new_songs}"));
3795    }
3796
3797    #[test]
3798    fn split_at_new_song_bare_cr() {
3799        // Bare \r (classic Mac line endings) should be handled correctly (#313).
3800        let input = "{title: A}\r{new_song}\r{title: B}";
3801        let segments = split_at_new_song(input);
3802        assert_eq!(segments.len(), 2);
3803        assert!(segments[0].contains("title: A"));
3804        assert!(segments[1].contains("title: B"));
3805    }
3806
3807    #[test]
3808    fn single_parse_ignores_new_song() {
3809        // The single-song parse() should treat {new_song} as a regular directive
3810        // and not fail.
3811        let song = parse("{title: Test}\n{new_song}\n[G]Hello").unwrap();
3812        assert_eq!(song.metadata.title.as_deref(), Some("Test"));
3813        // The {new_song} should appear as a Directive line
3814        let has_new_song = song
3815            .lines
3816            .iter()
3817            .any(|l| matches!(l, Line::Directive(d) if d.kind == DirectiveKind::NewSong));
3818        assert!(has_new_song);
3819    }
3820
3821    #[test]
3822    fn selector_suffix_on_environment_directive() {
3823        let result = lines("{start_of_chorus-piano}");
3824        if let Line::Directive(ref d) = result[0] {
3825            assert_eq!(d.name, "start_of_chorus");
3826            assert_eq!(d.kind, DirectiveKind::StartOfChorus);
3827            assert_eq!(d.selector.as_deref(), Some("piano"));
3828        } else {
3829            panic!("expected directive");
3830        }
3831    }
3832
3833    #[test]
3834    fn selector_suffix_on_end_environment() {
3835        let result = lines("{end_of_verse-guitar}");
3836        if let Line::Directive(ref d) = result[0] {
3837            assert_eq!(d.name, "end_of_verse");
3838            assert_eq!(d.kind, DirectiveKind::EndOfVerse);
3839            assert_eq!(d.selector.as_deref(), Some("guitar"));
3840        } else {
3841            panic!("expected directive");
3842        }
3843    }
3844
3845    #[test]
3846    fn no_selector_on_plain_directive() {
3847        let result = lines("{title: My Song}");
3848        if let Line::Directive(ref d) = result[0] {
3849            assert_eq!(d.selector, None);
3850        } else {
3851            panic!("expected directive");
3852        }
3853    }
3854
3855    #[test]
3856    fn selector_suffix_case_insensitive() {
3857        let result = lines("{Title-PIANO: My Song}");
3858        if let Line::Directive(ref d) = result[0] {
3859            assert_eq!(d.kind, DirectiveKind::Title);
3860            assert_eq!(d.selector.as_deref(), Some("piano"));
3861        } else {
3862            panic!("expected directive");
3863        }
3864    }
3865
3866    #[test]
3867    fn selector_with_short_alias() {
3868        let result = lines("{t-guitar: My Song}");
3869        if let Line::Directive(ref d) = result[0] {
3870            assert_eq!(d.name, "title");
3871            assert_eq!(d.kind, DirectiveKind::Title);
3872            assert_eq!(d.selector.as_deref(), Some("guitar"));
3873        } else {
3874            panic!("expected directive");
3875        }
3876    }
3877
3878    #[test]
3879    fn unknown_directive_with_hyphen_no_selector() {
3880        // "my-custom" -> "my" is Unknown, so the whole name is Unknown
3881        let result = lines("{my-custom: value}");
3882        if let Line::Directive(ref d) = result[0] {
3883            assert_eq!(d.kind, DirectiveKind::Unknown("my-custom".to_string()));
3884            assert_eq!(d.selector, None);
3885        } else {
3886            panic!("expected directive");
3887        }
3888    }
3889
3890    #[test]
3891    fn custom_section_with_selector() {
3892        let result = lines("{start_of_intro-piano}");
3893        if let Line::Directive(ref d) = result[0] {
3894            assert_eq!(d.kind, DirectiveKind::StartOfSection("intro".to_string()));
3895            assert_eq!(d.selector.as_deref(), Some("piano"));
3896        } else {
3897            panic!("expected directive");
3898        }
3899    }
3900
3901    #[test]
3902    #[should_panic(expected = "token list must contain at least an Eof token")]
3903    fn parser_new_panics_on_empty_tokens() {
3904        let _parser = Parser::new(Vec::new());
3905    }
3906
3907    // -- Multi-song input size limits -----------------------------------------
3908
3909    #[test]
3910    fn multi_song_oversized_input_rejected() {
3911        let opts = ParseOptions {
3912            max_input_size: 10,
3913            ..Default::default()
3914        };
3915        let input = "{title: A}\n{new_song}\n{title: B}";
3916        let result = parse_multi_with_options(input, &opts);
3917        assert!(result.is_err());
3918        assert!(result.unwrap_err().message.contains("exceeds maximum"));
3919    }
3920
3921    #[test]
3922    fn multi_song_lenient_oversized_input_rejected() {
3923        let opts = ParseOptions {
3924            max_input_size: 10,
3925            ..Default::default()
3926        };
3927        let input = "{title: A}\n{new_song}\n{title: B}";
3928        let result = parse_multi_lenient_with_options(input, &opts);
3929        assert_eq!(result.results.len(), 1);
3930        assert!(
3931            result.results[0].errors[0]
3932                .message
3933                .contains("exceeds maximum")
3934        );
3935    }
3936
3937    #[test]
3938    fn multi_song_within_limit_succeeds() {
3939        let opts = ParseOptions {
3940            max_input_size: 1000,
3941            ..Default::default()
3942        };
3943        let input = "{title: A}\n{new_song}\n{title: B}";
3944        let result = parse_multi_with_options(input, &opts);
3945        assert!(result.is_ok());
3946        assert_eq!(result.unwrap().len(), 2);
3947    }
3948
3949    // -- Config override directives -------------------------------------------
3950
3951    #[test]
3952    fn config_override_basic() {
3953        let result = lines("{+config.pdf.margins.top: 100}");
3954        if let Line::Directive(ref d) = result[0] {
3955            assert_eq!(
3956                d.kind,
3957                DirectiveKind::ConfigOverride("pdf.margins.top".to_string())
3958            );
3959            assert_eq!(d.value.as_deref(), Some("100"));
3960            assert_eq!(d.name, "+config.pdf.margins.top");
3961        } else {
3962            panic!("expected directive");
3963        }
3964    }
3965
3966    #[test]
3967    fn config_override_string_value() {
3968        let result = lines("{+config.pdf.theme.foreground: blue}");
3969        if let Line::Directive(ref d) = result[0] {
3970            assert_eq!(
3971                d.kind,
3972                DirectiveKind::ConfigOverride("pdf.theme.foreground".to_string())
3973            );
3974            assert_eq!(d.value.as_deref(), Some("blue"));
3975        } else {
3976            panic!("expected directive");
3977        }
3978    }
3979
3980    #[test]
3981    fn config_override_no_value() {
3982        let result = lines("{+config.settings.lyrics_only}");
3983        if let Line::Directive(ref d) = result[0] {
3984            assert_eq!(
3985                d.kind,
3986                DirectiveKind::ConfigOverride("settings.lyrics_only".to_string())
3987            );
3988            assert_eq!(d.value, None);
3989        } else {
3990            panic!("expected directive");
3991        }
3992    }
3993
3994    #[test]
3995    fn config_override_case_insensitive() {
3996        let result = lines("{+Config.PDF.Margins.Top: 50}");
3997        if let Line::Directive(ref d) = result[0] {
3998            assert_eq!(
3999                d.kind,
4000                DirectiveKind::ConfigOverride("pdf.margins.top".to_string())
4001            );
4002        } else {
4003            panic!("expected directive");
4004        }
4005    }
4006
4007    #[test]
4008    fn bare_plus_config_is_unknown() {
4009        // {+config} without a dot-separated key is Unknown
4010        let result = lines("{+config: something}");
4011        if let Line::Directive(ref d) = result[0] {
4012            assert!(matches!(d.kind, DirectiveKind::Unknown(_)));
4013        } else {
4014            panic!("expected directive");
4015        }
4016    }
4017
4018    #[test]
4019    fn config_overrides_extracted_from_song() {
4020        let song = crate::parse(
4021            "{title: Test}\n{+config.pdf.margins.top: 100}\n{+config.settings.transpose: 2}\n",
4022        )
4023        .unwrap();
4024        let overrides = song.config_overrides();
4025        assert_eq!(overrides.len(), 2);
4026        assert_eq!(overrides[0], ("pdf.margins.top", "100"));
4027        assert_eq!(overrides[1], ("settings.transpose", "2"));
4028    }
4029
4030    #[test]
4031    fn config_overrides_empty_when_none() {
4032        let song = crate::parse("{title: Test}\n[G]Hello\n").unwrap();
4033        assert!(song.config_overrides().is_empty());
4034    }
4035
4036    #[test]
4037    fn config_overrides_not_in_multi_song_leak() {
4038        let songs = crate::parse_multi(
4039            "{title: A}\n{+config.settings.transpose: 5}\n{new_song}\n{title: B}\n",
4040        )
4041        .unwrap();
4042        assert_eq!(songs.len(), 2);
4043        assert_eq!(songs[0].config_overrides().len(), 1);
4044        assert!(songs[1].config_overrides().is_empty());
4045    }
4046}
4047
4048#[cfg(test)]
4049mod metadata_cap_tests {
4050    use super::*;
4051
4052    #[test]
4053    fn test_metadata_entries_capped_at_limit() {
4054        // Generate more subtitles than the cap allows.
4055        let count = Parser::MAX_METADATA_ENTRIES + 100;
4056        let mut input = String::new();
4057        for i in 0..count {
4058            input.push_str(&format!("{{subtitle: sub{i}}}\n"));
4059        }
4060        let song = parse(&input).unwrap();
4061        assert_eq!(
4062            song.metadata.subtitles.len(),
4063            Parser::MAX_METADATA_ENTRIES,
4064            "subtitles should be capped at MAX_METADATA_ENTRIES"
4065        );
4066    }
4067
4068    #[test]
4069    fn test_metadata_cap_applies_per_field() {
4070        // Each field has its own cap — filling subtitles does not affect artists.
4071        let mut input = String::new();
4072        for i in 0..Parser::MAX_METADATA_ENTRIES {
4073            input.push_str(&format!("{{subtitle: s{i}}}\n"));
4074        }
4075        input.push_str("{artist: Alice}\n");
4076        let song = parse(&input).unwrap();
4077        assert_eq!(song.metadata.subtitles.len(), Parser::MAX_METADATA_ENTRIES);
4078        assert_eq!(song.metadata.artists.len(), 1);
4079    }
4080
4081    #[test]
4082    fn test_metadata_cap_via_meta_directive() {
4083        // The {meta: subtitle ...} path should also enforce the cap.
4084        let count = Parser::MAX_METADATA_ENTRIES + 50;
4085        let mut input = String::new();
4086        for i in 0..count {
4087            input.push_str(&format!("{{meta: subtitle s{i}}}\n"));
4088        }
4089        let song = parse(&input).unwrap();
4090        assert_eq!(
4091            song.metadata.subtitles.len(),
4092            Parser::MAX_METADATA_ENTRIES,
4093            "meta directive path should also cap subtitles"
4094        );
4095    }
4096}