Skip to main content

chordsketch_core/
ast.rs

1//! Abstract Syntax Tree (AST) definitions for the ChordPro format.
2//!
3//! This module defines the data structures that represent a parsed ChordPro
4//! document. The root node is [`Song`], which contains metadata and a sequence
5//! of [`Line`] nodes.
6//!
7//! # Design
8//!
9//! The AST is intentionally simple and flat. A [`Song`] is a list of lines,
10//! where each line is one of several variants (lyrics, directive, comment, or
11//! empty). Section boundaries are represented as directives — the parser may
12//! later group them, but the AST itself does not enforce nesting.
13//!
14//! Chord annotations are stored inline within lyrics lines using a segment
15//! model: each [`LyricsSegment`] pairs an optional chord with the lyric text
16//! that follows it. This preserves the chord-text relationship without
17//! requiring offset arithmetic during rendering.
18//!
19//! # Directive Classification
20//!
21//! Directives carry a [`DirectiveKind`] that classifies them into metadata,
22//! formatting, environment (section), or unknown categories. The parser
23//! resolves short aliases (e.g., `t` → `title`) and performs case-insensitive
24//! matching per the ChordPro specification.
25
26use crate::inline_markup::TextSpan;
27
28// ---------------------------------------------------------------------------
29// Song (root node)
30// ---------------------------------------------------------------------------
31
32/// The root AST node representing a complete ChordPro song.
33///
34/// A song consists of optional metadata (populated from directives such as
35/// `{title}`, `{subtitle}`, `{artist}`, etc.) and a sequence of lines that
36/// make up the song body.
37///
38/// # Examples
39///
40/// ```
41/// use chordsketch_core::ast::{Song, Metadata};
42///
43/// let song = Song::new();
44/// assert!(song.lines.is_empty());
45/// assert_eq!(song.metadata.title, None);
46/// ```
47#[derive(Debug, Clone, PartialEq)]
48pub struct Song {
49    /// Metadata extracted from directives (title, subtitle, artist, etc.).
50    pub metadata: Metadata,
51    /// The ordered sequence of lines that make up the song body.
52    pub lines: Vec<Line>,
53}
54
55impl Song {
56    /// Creates a new empty song with no metadata and no lines.
57    #[must_use]
58    pub fn new() -> Self {
59        Self {
60            metadata: Metadata::new(),
61            lines: Vec::new(),
62        }
63    }
64
65    /// Extracts `{+config.KEY: VALUE}` overrides from this song's directives.
66    ///
67    /// Returns a list of `(key, value)` pairs in directive order. The key is
68    /// the dot-separated config path (e.g., `"pdf.chorus.indent"`), and the
69    /// value is the raw string from the directive.
70    ///
71    /// These overrides are scoped to this song and should not leak to other
72    /// songs in a multi-song file.
73    #[must_use]
74    pub fn config_overrides(&self) -> Vec<(&str, &str)> {
75        let mut overrides = Vec::new();
76        for line in &self.lines {
77            if let Line::Directive(directive) = line {
78                if let DirectiveKind::ConfigOverride(ref key) = directive.kind {
79                    if let Some(ref value) = directive.value {
80                        overrides.push((key.as_str(), value.as_str()));
81                    }
82                }
83            }
84        }
85        overrides
86    }
87
88    /// Resolves `{define}` display and format attributes to matching chords.
89    ///
90    /// Scans all `{define}` directives in the song, collecting `display` and
91    /// `format` overrides. Then walks every chord in the song and sets
92    /// `Chord::display`:
93    /// - `display` takes precedence (used as-is).
94    /// - `format` is expanded using the chord's parsed components.
95    ///
96    /// Later definitions override earlier ones for the same chord name.
97    pub fn apply_define_displays(&mut self) {
98        // First pass: collect chord name -> (display, format) mappings.
99        let mut define_map: Vec<(String, Option<String>, Option<String>)> = Vec::new();
100        for line in &self.lines {
101            if let Line::Directive(directive) = line {
102                if directive.kind == DirectiveKind::Define {
103                    if let Some(ref value) = directive.value {
104                        let def = ChordDefinition::parse_value(value);
105                        if def.display.is_some() || def.format.is_some() {
106                            if let Some(entry) =
107                                define_map.iter_mut().find(|(n, _, _)| *n == def.name)
108                            {
109                                entry.1 = def.display;
110                                entry.2 = def.format;
111                            } else {
112                                define_map.push((def.name, def.display, def.format));
113                            }
114                        }
115                    }
116                }
117            }
118        }
119
120        if define_map.is_empty() {
121            return;
122        }
123
124        // Second pass: apply display/format overrides to matching chords.
125        for line in &mut self.lines {
126            if let Line::Lyrics(lyrics_line) = line {
127                for segment in &mut lyrics_line.segments {
128                    if let Some(ref mut chord) = segment.chord {
129                        if chord.display.is_none() {
130                            if let Some((_, display, format)) =
131                                define_map.iter().find(|(n, _, _)| *n == chord.name)
132                            {
133                                if let Some(d) = display {
134                                    // display= takes precedence over format=
135                                    chord.display = Some(d.clone());
136                                } else if let Some(f) = format {
137                                    // Expand format pattern using chord components
138                                    if let Some(expanded) = chord.expand_format(f) {
139                                        chord.display = Some(expanded);
140                                    }
141                                }
142                            }
143                        }
144                    }
145                }
146            }
147        }
148    }
149
150    /// Returns `(name, raw)` pairs for all fretted `{define}` and `{chord}` directives
151    /// in the song.
152    ///
153    /// `{chord}` is a ChordPro alias for `{define}` and is treated identically here.
154    /// Only returns definitions that contain fret data (i.e., `base-fret … frets …`).
155    /// Keyboard (`keys`), copy, and display-only definitions are excluded.
156    /// Later definitions override earlier ones for the same chord name.
157    #[must_use]
158    pub fn fretted_defines(&self) -> Vec<(String, String)> {
159        let mut result: Vec<(String, String)> = Vec::new();
160        for line in &self.lines {
161            if let Line::Directive(directive) = line {
162                if directive.kind == DirectiveKind::Define
163                    || directive.kind == DirectiveKind::ChordDirective
164                {
165                    if let Some(ref value) = directive.value {
166                        let def = ChordDefinition::parse_value(value);
167                        if let Some(raw) = def.raw {
168                            if let Some(pos) = result.iter().position(|(n, _)| *n == def.name) {
169                                result[pos].1 = raw;
170                            } else {
171                                result.push((def.name, raw));
172                            }
173                        }
174                    }
175                }
176            }
177        }
178        result
179    }
180
181    /// Returns keyboard chord definitions as `(name, keys)` pairs.
182    ///
183    /// Scans `{define}` / `{chord}` directives for entries that use the `keys`
184    /// attribute (e.g., `{define: Am keys 0 3 7}`). Fretted, copy, and
185    /// display-only definitions are excluded.  Later definitions override
186    /// earlier ones for the same chord name.
187    ///
188    /// The `keys` values are the raw integers from the directive — typically
189    /// MIDI note numbers (0–127) or semitone offsets.
190    #[must_use]
191    pub fn keyboard_defines(&self) -> Vec<(String, Vec<i32>)> {
192        let mut result: Vec<(String, Vec<i32>)> = Vec::new();
193        for line in &self.lines {
194            if let Line::Directive(directive) = line {
195                if directive.kind == DirectiveKind::Define
196                    || directive.kind == DirectiveKind::ChordDirective
197                {
198                    if let Some(ref value) = directive.value {
199                        let def = ChordDefinition::parse_value(value);
200                        if let Some(keys) = def.keys {
201                            if let Some(pos) = result.iter().position(|(n, _)| *n == def.name) {
202                                result[pos].1 = keys;
203                            } else {
204                                result.push((def.name, keys));
205                            }
206                        }
207                    }
208                }
209            }
210        }
211        result
212    }
213
214    /// Returns the unique chord names used in the song, in order of first appearance.
215    ///
216    /// Scans every [`LyricsSegment`] in every [`LyricsLine`] in the song. The
217    /// returned names are the raw chord strings as they appear in the source
218    /// (e.g., `"Am"`, `"C#m7"`).  Each name appears at most once; names are
219    /// returned in the order they are first encountered.
220    #[must_use]
221    pub fn used_chord_names(&self) -> Vec<String> {
222        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
223        let mut result: Vec<String> = Vec::new();
224        for line in &self.lines {
225            if let Line::Lyrics(lyrics) = line {
226                for seg in &lyrics.segments {
227                    if let Some(ref chord) = seg.chord {
228                        if seen.insert(chord.name.clone()) {
229                            result.push(chord.name.clone());
230                        }
231                    }
232                }
233            }
234        }
235        result
236    }
237}
238
239impl Default for Song {
240    fn default() -> Self {
241        Self::new()
242    }
243}
244
245// ---------------------------------------------------------------------------
246// Metadata
247// ---------------------------------------------------------------------------
248
249/// Metadata extracted from well-known ChordPro directives.
250///
251/// These fields correspond to standard ChordPro meta-directives such as
252/// `{title}`, `{subtitle}`, `{artist}`, `{composer}`, `{album}`, `{year}`,
253/// `{key}`, `{tempo}`, `{time}`, and `{capo}`.
254///
255/// Fields that can logically appear multiple times (e.g., `subtitle`, `artist`,
256/// `composer`) are stored as `Vec<String>`. Fields that are expected to appear
257/// at most once are stored as `Option<String>`.
258///
259/// Any meta-directive not covered by these fields can be stored in the
260/// `custom` vector as key-value pairs.
261#[derive(Debug, Clone, PartialEq, Default)]
262pub struct Metadata {
263    /// The song title, from `{title}` / `{t}`.
264    pub title: Option<String>,
265    /// Subtitles, from `{subtitle}` / `{st}`. May appear multiple times.
266    pub subtitles: Vec<String>,
267    /// Artist names, from `{artist}`.
268    pub artists: Vec<String>,
269    /// Composer names, from `{composer}`.
270    pub composers: Vec<String>,
271    /// Lyricist names, from `{lyricist}`.
272    pub lyricists: Vec<String>,
273    /// Album name, from `{album}`.
274    pub album: Option<String>,
275    /// Year or date, from `{year}`.
276    pub year: Option<String>,
277    /// Musical key, from `{key}`.
278    pub key: Option<String>,
279    /// Tempo indication, from `{tempo}`.
280    pub tempo: Option<String>,
281    /// Time signature, from `{time}`.
282    pub time: Option<String>,
283    /// Capo position, from `{capo}`.
284    pub capo: Option<String>,
285    /// Sortable title, from `{sorttitle}`.
286    pub sort_title: Option<String>,
287    /// Sortable artist name, from `{sortartist}`.
288    pub sort_artist: Option<String>,
289    /// Arranger names, from `{arranger}`. May appear multiple times.
290    pub arrangers: Vec<String>,
291    /// Copyright notice, from `{copyright}`.
292    pub copyright: Option<String>,
293    /// Song duration, from `{duration}`.
294    pub duration: Option<String>,
295    /// Tags for categorization, from `{tag}`. May appear multiple times.
296    pub tags: Vec<String>,
297    /// Custom metadata directives not covered by the standard fields.
298    /// Each entry is a `(name, value)` pair.
299    ///
300    /// All custom entries (from both unknown directives and unrecognized
301    /// `{meta}` keys) share a single size cap. Filling the vec with one
302    /// key prevents other keys from being stored.
303    pub custom: Vec<(String, String)>,
304}
305
306impl Metadata {
307    /// Creates a new empty metadata set with all fields at their defaults.
308    #[must_use]
309    pub fn new() -> Self {
310        Self::default()
311    }
312}
313
314// ---------------------------------------------------------------------------
315// Line
316// ---------------------------------------------------------------------------
317
318/// A single line in the song body.
319///
320/// ChordPro documents are processed line-by-line. Each line is classified
321/// into one of these variants by the parser.
322#[derive(Debug, Clone, PartialEq)]
323pub enum Line {
324    /// A lyrics line, possibly containing chord annotations interspersed
325    /// with text. An empty lyrics line (no text, no chords) is represented
326    /// by [`Line::Empty`] instead.
327    Lyrics(LyricsLine),
328
329    /// A directive such as `{title: My Song}` or `{start_of_chorus}`.
330    Directive(Directive),
331
332    /// A comment line from a comment directive (`{comment}`, `{comment_italic}`,
333    /// `{comment_box}`) or a file-level `#` comment. The [`CommentStyle`]
334    /// distinguishes the rendering intent.
335    Comment(CommentStyle, String),
336
337    /// An empty line, typically used to separate paragraphs or sections.
338    Empty,
339}
340
341// ---------------------------------------------------------------------------
342// CommentStyle
343// ---------------------------------------------------------------------------
344
345/// The visual style of a comment, determined by the directive that produced it.
346///
347/// ChordPro supports three comment directives, each with a different rendering
348/// intent:
349///
350/// - `{comment}` / `{c}` — normal comment (typically highlighted or boxed)
351/// - `{comment_italic}` / `{ci}` — italic comment
352/// - `{comment_box}` / `{cb}` — boxed comment
353///
354/// File-level `#` comments use [`CommentStyle::Normal`] as a default.
355#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
356pub enum CommentStyle {
357    /// A normal comment, from `{comment}` / `{c}` or file-level `#`.
358    Normal,
359    /// An italic comment, from `{comment_italic}` / `{ci}`.
360    Italic,
361    /// A boxed comment, from `{comment_box}` / `{cb}`.
362    Boxed,
363}
364
365// ---------------------------------------------------------------------------
366// LyricsLine
367// ---------------------------------------------------------------------------
368
369/// A lyrics line containing text with optional chord annotations.
370///
371/// In ChordPro format, chords are written inline with the lyrics:
372/// ```text
373/// [Am]Hello [G]world
374/// ```
375///
376/// The AST splits this into a sequence of [`LyricsSegment`] items, each
377/// optionally preceded by a chord. This preserves the exact relationship
378/// between chords and the lyric text they annotate.
379///
380/// # Examples
381///
382/// The input `[Am]Hello [G]world` produces:
383///
384/// ```
385/// use chordsketch_core::ast::{LyricsLine, LyricsSegment, Chord};
386///
387/// let line = LyricsLine {
388///     segments: vec![
389///         LyricsSegment {
390///             chord: Some(Chord::new("Am")),
391///             text: "Hello ".to_string(),
392///             spans: vec![],
393///         },
394///         LyricsSegment {
395///             chord: Some(Chord::new("G")),
396///             text: "world".to_string(),
397///             spans: vec![],
398///         },
399///     ],
400/// };
401/// ```
402#[derive(Debug, Clone, PartialEq)]
403pub struct LyricsLine {
404    /// The ordered sequence of segments that make up this lyrics line.
405    pub segments: Vec<LyricsSegment>,
406}
407
408impl LyricsLine {
409    /// Creates a new empty lyrics line with no segments.
410    #[must_use]
411    pub fn new() -> Self {
412        Self {
413            segments: Vec::new(),
414        }
415    }
416
417    /// Returns the full lyric text with all chord annotations removed.
418    #[must_use]
419    pub fn text(&self) -> String {
420        self.segments.iter().map(|s| s.text.as_str()).collect()
421    }
422
423    /// Returns `true` if this lyrics line contains at least one chord.
424    #[must_use]
425    pub fn has_chords(&self) -> bool {
426        self.segments.iter().any(|s| s.chord.is_some())
427    }
428}
429
430impl Default for LyricsLine {
431    fn default() -> Self {
432        Self::new()
433    }
434}
435
436// ---------------------------------------------------------------------------
437// LyricsSegment
438// ---------------------------------------------------------------------------
439
440/// A segment within a lyrics line: an optional chord followed by text.
441///
442/// Every lyrics line is decomposed into a sequence of segments. Each segment
443/// may have a chord placed above the start of its text. A segment with no
444/// chord and non-empty text represents plain lyrics. A segment with a chord
445/// and empty text represents a chord placed at the end of the line (or
446/// between two consecutive chords with no intervening text).
447#[derive(Debug, Clone, PartialEq)]
448pub struct LyricsSegment {
449    /// The chord annotation, if any, placed above the start of `text`.
450    pub chord: Option<Chord>,
451    /// The lyric text following the chord (may be empty).
452    ///
453    /// When inline markup is present, this field contains the plain text
454    /// content with all markup tags stripped. Renderers that do not support
455    /// markup can always use this field directly.
456    pub text: String,
457    /// Inline markup spans parsed from the text.
458    ///
459    /// When the text contains inline markup tags (e.g., `<b>`, `<i>`,
460    /// `<highlight>`, `<comment>`), this field holds the parsed span tree.
461    /// When no markup is present, this vector is empty and renderers should
462    /// use the `text` field instead.
463    pub spans: Vec<TextSpan>,
464}
465
466impl LyricsSegment {
467    /// Creates a new segment with the given chord and text.
468    #[must_use]
469    pub fn new(chord: Option<Chord>, text: impl Into<String>) -> Self {
470        Self {
471            chord,
472            text: text.into(),
473            spans: Vec::new(),
474        }
475    }
476
477    /// Creates a text-only segment with no chord.
478    #[must_use]
479    pub fn text_only(text: impl Into<String>) -> Self {
480        Self {
481            chord: None,
482            text: text.into(),
483            spans: Vec::new(),
484        }
485    }
486
487    /// Creates a chord-only segment with no text.
488    #[must_use]
489    pub fn chord_only(chord: Chord) -> Self {
490        Self {
491            chord: Some(chord),
492            text: String::new(),
493            spans: Vec::new(),
494        }
495    }
496
497    /// Creates a new segment with chord, text, and inline markup spans.
498    #[must_use]
499    pub fn with_spans(chord: Option<Chord>, text: impl Into<String>, spans: Vec<TextSpan>) -> Self {
500        Self {
501            chord,
502            text: text.into(),
503            spans,
504        }
505    }
506
507    /// Returns `true` if this segment has inline markup spans.
508    #[must_use]
509    pub fn has_markup(&self) -> bool {
510        !self.spans.is_empty()
511    }
512}
513
514// ---------------------------------------------------------------------------
515// Chord
516// ---------------------------------------------------------------------------
517
518/// A chord annotation such as `Am`, `G7`, `Cmaj7`, or `F#m`.
519///
520/// The chord stores both the raw string as it appeared in the source and,
521/// when parsing succeeds, a structured [`ChordDetail`] with the individual
522/// components (root, accidental, quality, extension, bass note).
523///
524/// If the chord notation cannot be parsed structurally, `detail` is `None`
525/// and the raw `name` is still available for display or round-tripping.
526///
527/// [`ChordDetail`]: crate::chord::ChordDetail
528#[derive(Debug, Clone, PartialEq, Eq, Hash)]
529pub struct Chord {
530    /// The raw chord string as written in the source (e.g., `"Am"`, `"G7"`).
531    pub name: String,
532    /// The parsed chord components, if the chord notation was recognized.
533    pub detail: Option<crate::chord::ChordDetail>,
534    /// An alternative display name set by `{define}` with `display` attribute.
535    ///
536    /// When present, renderers should show this instead of `name`.
537    pub display: Option<String>,
538}
539
540impl Chord {
541    /// Creates a new chord from the given name string.
542    ///
543    /// The chord notation is automatically parsed into structured components.
544    /// If parsing fails (e.g., the chord string is not valid notation), the
545    /// `detail` field is `None` but the raw `name` is preserved.
546    #[must_use]
547    pub fn new(name: impl Into<String>) -> Self {
548        let name = name.into();
549        let detail = crate::chord::parse_chord(&name);
550        Self {
551            name,
552            detail,
553            display: None,
554        }
555    }
556
557    /// Returns the display name for this chord.
558    ///
559    /// If a `display` attribute was set (via `{define}`), returns that.
560    /// Otherwise returns the raw `name`.
561    #[must_use]
562    pub fn display_name(&self) -> &str {
563        self.display.as_deref().unwrap_or(&self.name)
564    }
565
566    /// Expand a format pattern using this chord's parsed components.
567    ///
568    /// Replaces placeholders in the pattern string with chord detail fields:
569    /// - `%{root}` — root note with accidental (e.g., `"A"`, `"Bb"`, `"F#"`)
570    /// - `%{quality}` — quality string (e.g., `""`, `"m"`, `"dim"`, `"aug"`)
571    /// - `%{ext}` — extension (e.g., `"7"`, `"maj7"`, `"sus4"`)
572    /// - `%{bass}` — bass note for slash chords (e.g., `"B"`, `"Eb"`)
573    ///
574    /// Returns `None` if the chord has no parsed `detail`.
575    #[must_use]
576    pub fn expand_format(&self, pattern: &str) -> Option<String> {
577        let detail = self.detail.as_ref()?;
578
579        let root = {
580            let mut s = detail.root.to_string();
581            if let Some(ref acc) = detail.root_accidental {
582                s.push_str(&acc.to_string());
583            }
584            s
585        };
586        let quality = detail.quality.to_string();
587        let ext = detail.extension.as_deref().unwrap_or("");
588        let bass = detail
589            .bass_note
590            .as_ref()
591            .map_or(String::new(), |(note, acc)| {
592                let mut s = note.to_string();
593                if let Some(a) = acc {
594                    s.push_str(&a.to_string());
595                }
596                s
597            });
598
599        let result = pattern
600            .replace("%{root}", &root)
601            .replace("%{quality}", &quality)
602            .replace("%{ext}", ext)
603            .replace("%{bass}", &bass);
604
605        Some(result)
606    }
607}
608
609impl core::fmt::Display for Chord {
610    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
611        f.write_str(self.display_name())
612    }
613}
614
615// ---------------------------------------------------------------------------
616// ImageAttributes
617// ---------------------------------------------------------------------------
618
619/// Attributes for the `{image}` directive.
620///
621/// The `{image}` directive embeds an image in the song. The `src` attribute
622/// is required; all other attributes are optional.
623///
624/// Format support is renderer-specific: the PDF renderer supports JPEG only
625/// (`.jpg` / `.jpeg`), while the HTML renderer delegates to the browser and
626/// can display any web-supported format.
627///
628/// # Examples
629///
630/// ```
631/// use chordsketch_core::ast::ImageAttributes;
632///
633/// let attrs = ImageAttributes::new("photo.jpg");
634/// assert_eq!(attrs.src, "photo.jpg");
635/// assert!(attrs.width.is_none());
636/// ```
637#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
638pub struct ImageAttributes {
639    /// The image file path (required).
640    pub src: String,
641    /// Display width (e.g., "100", "50%").
642    pub width: Option<String>,
643    /// Display height (e.g., "200", "75%").
644    pub height: Option<String>,
645    /// Scale factor (e.g., "0.5").
646    pub scale: Option<String>,
647    /// Image title or alt text.
648    pub title: Option<String>,
649    /// Positioning anchor.
650    pub anchor: Option<String>,
651}
652
653impl ImageAttributes {
654    /// Creates a new `ImageAttributes` with the given source path and all
655    /// optional fields set to `None`.
656    #[must_use]
657    pub fn new(src: impl Into<String>) -> Self {
658        Self {
659            src: src.into(),
660            ..Self::default()
661        }
662    }
663
664    /// Returns `true` if a non-empty `src` path is present.
665    ///
666    /// Renderers can use this to skip rendering when no image source was
667    /// provided, avoiding duplicated empty-string checks.
668    #[must_use]
669    pub fn has_src(&self) -> bool {
670        !self.src.is_empty()
671    }
672}
673
674// ---------------------------------------------------------------------------
675// ChordDefinition
676// ---------------------------------------------------------------------------
677
678/// Extract a `key=value` attribute from a string, removing it in-place.
679///
680/// Matches `key=` at word boundaries (preceded by whitespace or at start of
681/// string). Handles both quoted values (`key="value with spaces"`) and
682/// unquoted values (`key=value`).
683///
684/// Unquoted values may not contain `=`. If the next token after `key=`
685/// contains `=`, it is treated as a separate attribute and the current
686/// attribute is returned as empty-valued. Use quoted values for values
687/// containing `=`.
688///
689/// Returns `Some(value)` if found (and mutates `s` to remove the attribute),
690/// or `None` if not found.
691fn extract_attribute(s: &mut String, key: &str) -> Option<String> {
692    let needle = format!("{key}=");
693    let match_pos = s
694        .match_indices(&needle)
695        .find(|&(pos, _)| pos == 0 || s.as_bytes()[pos - 1].is_ascii_whitespace());
696
697    let (pos, _) = match_pos?;
698    let after = &s[pos + needle.len()..];
699
700    let (val, token_end) = if let Some(stripped) = after.strip_prefix('"') {
701        // Find the closing quote, if present.
702        let close = stripped.find('"').unwrap_or(stripped.len());
703        let v = stripped[..close].to_string();
704        // Only count the closing quote in token_end when it actually exists.
705        let has_close = close < stripped.len();
706        (
707            Some(v),
708            pos + needle.len() + 1 + close + usize::from(has_close),
709        )
710    } else {
711        match after.split_whitespace().next() {
712            Some(t) if !t.contains('=') => (Some(t.to_string()), pos + needle.len() + t.len()),
713            // Token contains '=' — likely the next attribute (e.g., "format=...").
714            // Treat current attribute as empty-valued rather than consuming the
715            // next attribute's token.
716            Some(_) | None => (Some(String::new()), pos + needle.len()),
717        }
718    };
719
720    // Remove the attribute token from the string.
721    let before = s[..pos].trim_end();
722    let after_token = s[token_end..].trim_start();
723    *s = if before.is_empty() {
724        after_token.to_string()
725    } else if after_token.is_empty() {
726        before.to_string()
727    } else {
728        format!("{before} {after_token}")
729    };
730
731    val
732}
733
734/// A parsed chord definition from `{define}` directives.
735///
736/// Supports three types:
737/// - **Fretted**: `{define: Am base-fret 1 frets x 0 2 2 1 0}` (stored as raw value)
738/// - **Keyboard**: `{define: Am keys 0 3 7}` (MIDI key offsets)
739/// - **Copy**: `{define: Am copy Amin}` or `{define: Am copyall Amin}`
740///
741/// # Examples
742///
743/// ```
744/// use chordsketch_core::ast::ChordDefinition;
745///
746/// let def = ChordDefinition::parse_value("Am keys 0 3 7");
747/// assert_eq!(def.name, "Am");
748/// assert!(def.keys.is_some());
749/// ```
750#[derive(Debug, Clone, PartialEq, Eq)]
751pub struct ChordDefinition {
752    /// The chord name being defined.
753    pub name: String,
754    /// Keyboard keys (MIDI note numbers, 0-127) for keyboard instrument definitions.
755    ///
756    /// Values outside 0-127 and non-numeric tokens are silently dropped during parsing.
757    /// `None` when no valid key values are provided.
758    pub keys: Option<Vec<i32>>,
759    /// Source chord name for `copy` definitions.
760    pub copy: Option<String>,
761    /// Source chord name for `copyall` definitions.
762    pub copyall: Option<String>,
763    /// Display name override.
764    pub display: Option<String>,
765    /// Format pattern for chord name rendering (e.g., `"%{root}%{quality}"`).
766    ///
767    /// When present, the pattern is expanded using the chord's parsed
768    /// components. Supported placeholders: `%{root}`, `%{quality}`,
769    /// `%{ext}`, `%{bass}`.
770    pub format: Option<String>,
771    /// The raw definition value (for fretted definitions not yet parsed).
772    pub raw: Option<String>,
773}
774
775impl ChordDefinition {
776    /// Parse a define directive value string.
777    ///
778    /// Recognizes `keys`, `copy`, `copyall`, and `display` tokens.
779    /// Everything else is stored in `raw` for fretted chord definitions.
780    #[must_use]
781    pub fn parse_value(value: &str) -> Self {
782        let value = value.trim();
783        let mut parts = value.splitn(2, char::is_whitespace);
784        // splitn(2, ..) on any string always yields at least one element, so
785        // next() is infallible here. If value is empty or whitespace-only after
786        // trim(), name will be "" — the callers check def.display/format/raw
787        // before using def.name, so an empty name is a harmless no-op.
788        let name = parts
789            .next()
790            .expect("splitn always yields at least one element")
791            .to_string();
792        // rest is None for single-word values (no whitespace); "" is the correct
793        // default (no rest tokens to process).
794        let rest = parts.next().unwrap_or("").trim();
795
796        let mut def = Self {
797            name,
798            keys: None,
799            copy: None,
800            copyall: None,
801            display: None,
802            format: None,
803            raw: None,
804        };
805
806        if rest.is_empty() {
807            return def;
808        }
809
810        // Extract known attributes (display=, format=) from the value first,
811        // so they work with all definition variants (fretted, keys, copy, copyall).
812        let mut remaining = rest.to_string();
813        def.display = extract_attribute(&mut remaining, "display");
814        def.format = extract_attribute(&mut remaining, "format");
815        let remaining = remaining.trim();
816
817        if remaining.is_empty() {
818            return def;
819        }
820
821        // Check for "keys <n1> <n2> ..."
822        // Key values are MIDI note numbers (0-127). Non-numeric and
823        // out-of-range values are silently dropped.
824        // Handles arbitrary whitespace after "keys" (space, tab, multiple).
825        if let Some(keys_str) = remaining.strip_prefix("keys").and_then(|rest| {
826            if rest.is_empty() || rest.starts_with(|c: char| c.is_ascii_whitespace()) {
827                Some(rest)
828            } else {
829                None
830            }
831        }) {
832            let keys: Vec<i32> = keys_str
833                .split_whitespace()
834                .filter_map(|s| s.parse::<i32>().ok())
835                .filter(|&v| (0..=127).contains(&v))
836                .collect();
837            // Treat empty keys (all values invalid) as no keys defined.
838            def.keys = if keys.is_empty() { None } else { Some(keys) };
839            return def;
840        }
841
842        // Check for "copy <source>" or "copyall <source>"
843        // Only the first token after the prefix is used as the source name.
844        // Handles arbitrary whitespace (spaces, tabs, multiple) after keyword.
845        if let Some(rest) = remaining.strip_prefix("copyall").and_then(|r| {
846            if r.is_empty() || r.starts_with(|c: char| c.is_ascii_whitespace()) {
847                Some(r)
848            } else {
849                None
850            }
851        }) {
852            let name = rest.split_whitespace().next().unwrap_or("").trim();
853            if !name.is_empty() {
854                def.copyall = Some(name.to_string());
855            }
856            return def;
857        }
858        if let Some(rest) = remaining.strip_prefix("copy").and_then(|r| {
859            if r.is_empty() || r.starts_with(|c: char| c.is_ascii_whitespace()) {
860                Some(r)
861            } else {
862                None
863            }
864        }) {
865            let name = rest.split_whitespace().next().unwrap_or("").trim();
866            if !name.is_empty() {
867                def.copy = Some(name.to_string());
868            }
869            return def;
870        }
871
872        def.raw = if remaining.is_empty() {
873            None
874        } else {
875            Some(remaining.to_string())
876        };
877
878        def
879    }
880}
881
882// ---------------------------------------------------------------------------
883// DirectiveKind
884// ---------------------------------------------------------------------------
885
886/// Classification of a ChordPro directive.
887///
888/// The parser examines each directive's name (case-insensitively, resolving
889/// short aliases) and assigns a `DirectiveKind` to indicate its semantic
890/// category. Renderers and downstream consumers can match on this enum
891/// rather than performing string comparisons.
892///
893/// # Categories
894///
895/// - **Metadata** — directives that set song metadata (`title`, `subtitle`,
896///   `artist`, `album`, `year`, `key`, `tempo`, `time`, `capo`, etc.).
897/// - **Formatting** — comment directives (`comment`, `comment_italic`,
898///   `comment_box`).
899/// - **Font/size/color** — legacy rendering directives (`titlefont`,
900///   `titlesize`, `titlecolour`, `chorusfont`, etc.).
901/// - **Environment** — section start/end directives (`start_of_chorus`,
902///   `end_of_chorus`, `start_of_verse`, etc.).
903/// - **Unknown** — any directive not recognized as a standard directive.
904#[derive(Debug, Clone, PartialEq, Eq, Hash)]
905pub enum DirectiveKind {
906    // -- Metadata directives ------------------------------------------------
907    /// `{title}` / `{t}` — the song title.
908    Title,
909    /// `{subtitle}` / `{st}` — a subtitle.
910    Subtitle,
911    /// `{artist}` — the artist name.
912    Artist,
913    /// `{composer}` — the composer name.
914    Composer,
915    /// `{lyricist}` — the lyricist name.
916    Lyricist,
917    /// `{album}` — the album name.
918    Album,
919    /// `{year}` — the year or date.
920    Year,
921    /// `{key}` — the musical key.
922    Key,
923    /// `{tempo}` — the tempo.
924    Tempo,
925    /// `{time}` — the time signature.
926    Time,
927    /// `{capo}` — the capo position.
928    Capo,
929    /// `{sorttitle}` — a sortable title.
930    SortTitle,
931    /// `{sortartist}` — a sortable artist name.
932    SortArtist,
933    /// `{arranger}` — the arranger name.
934    Arranger,
935    /// `{copyright}` — the copyright notice.
936    Copyright,
937    /// `{duration}` — the song duration.
938    Duration,
939    /// `{tag}` — a tag for categorization.
940    Tag,
941
942    // -- Transpose directive ------------------------------------------------
943    /// `{transpose}` — in-file transposition offset in semitones.
944    Transpose,
945
946    // -- Formatting directives (comment) ------------------------------------
947    /// `{comment}` / `{c}` — a normal comment.
948    Comment,
949    /// `{comment_italic}` / `{ci}` — an italic comment.
950    CommentItalic,
951    /// `{comment_box}` / `{cb}` — a boxed comment.
952    CommentBox,
953
954    // -- Environment (section) directives -----------------------------------
955    /// `{start_of_chorus}` / `{soc}` — begins a chorus section.
956    StartOfChorus,
957    /// `{end_of_chorus}` / `{eoc}` — ends a chorus section.
958    EndOfChorus,
959    /// `{start_of_verse}` / `{sov}` — begins a verse section.
960    StartOfVerse,
961    /// `{end_of_verse}` / `{eov}` — ends a verse section.
962    EndOfVerse,
963    /// `{start_of_bridge}` / `{sob}` — begins a bridge section.
964    StartOfBridge,
965    /// `{end_of_bridge}` / `{eob}` — ends a bridge section.
966    EndOfBridge,
967    /// `{start_of_tab}` / `{sot}` — begins a tab section.
968    StartOfTab,
969    /// `{end_of_tab}` / `{eot}` — ends a tab section.
970    EndOfTab,
971    /// `{start_of_grid}` / `{sog}` — begins a grid section.
972    StartOfGrid,
973    /// `{end_of_grid}` / `{eog}` — ends a grid section.
974    EndOfGrid,
975
976    // -- Font, size, and color directives -----------------------------------
977    /// `{textfont}` / `{tf}` — sets the font for lyrics text.
978    TextFont,
979    /// `{textsize}` / `{ts}` — sets the font size for lyrics text.
980    TextSize,
981    /// `{textcolour}` / `{textcolor}` / `{tc}` — sets the color for lyrics text.
982    TextColour,
983    /// `{chordfont}` / `{cf}` — sets the font for chord names.
984    ChordFont,
985    /// `{chordsize}` / `{cs}` — sets the font size for chord names.
986    ChordSize,
987    /// `{chordcolour}` / `{chordcolor}` / `{cc}` — sets the color for chord names.
988    ChordColour,
989    /// `{tabfont}` — sets the font for tab sections.
990    TabFont,
991    /// `{tabsize}` — sets the font size for tab sections.
992    TabSize,
993    /// `{tabcolour}` / `{tabcolor}` — sets the color for tab sections.
994    TabColour,
995
996    // -- Recall directives ---------------------------------------------------
997    /// `{chorus}` — recalls (repeats) the most recently defined chorus section.
998    ///
999    /// An optional label may override the default "Chorus" heading.
1000    Chorus,
1001
1002    // -- Page control directives ----------------------------------------------
1003    /// `{new_page}` / `{np}` — forces a page break.
1004    NewPage,
1005    /// `{new_physical_page}` / `{npp}` — forces a physical page break (for duplex printing).
1006    NewPhysicalPage,
1007    /// `{column_break}` / `{colb}` — forces a column break.
1008    ColumnBreak,
1009    /// `{columns}` / `{col}` — sets the number of columns.
1010    Columns,
1011
1012    // -- Extended font, size, and color directives --------------------------
1013    /// `{titlefont}` — sets the font for song titles.
1014    TitleFont,
1015    /// `{titlesize}` — sets the font size for song titles.
1016    TitleSize,
1017    /// `{titlecolour}` / `{titlecolor}` — sets the color for song titles.
1018    TitleColour,
1019    /// `{chorusfont}` — sets the font for chorus sections.
1020    ChorusFont,
1021    /// `{chorussize}` — sets the font size for chorus sections.
1022    ChorusSize,
1023    /// `{choruscolour}` / `{choruscolor}` — sets the color for chorus sections.
1024    ChorusColour,
1025    /// `{footerfont}` — sets the font for footer text.
1026    FooterFont,
1027    /// `{footersize}` — sets the font size for footer text.
1028    FooterSize,
1029    /// `{footercolour}` / `{footercolor}` — sets the color for footer text.
1030    FooterColour,
1031    /// `{headerfont}` — sets the font for header text.
1032    HeaderFont,
1033    /// `{headersize}` — sets the font size for header text.
1034    HeaderSize,
1035    /// `{headercolour}` / `{headercolor}` — sets the color for header text.
1036    HeaderColour,
1037    /// `{labelfont}` — sets the font for labels.
1038    LabelFont,
1039    /// `{labelsize}` — sets the font size for labels.
1040    LabelSize,
1041    /// `{labelcolour}` / `{labelcolor}` — sets the color for labels.
1042    LabelColour,
1043    /// `{gridfont}` — sets the font for grid sections.
1044    GridFont,
1045    /// `{gridsize}` — sets the font size for grid sections.
1046    GridSize,
1047    /// `{gridcolour}` / `{gridcolor}` — sets the color for grid sections.
1048    GridColour,
1049    /// `{tocfont}` — sets the font for table of contents.
1050    TocFont,
1051    /// `{tocsize}` — sets the font size for table of contents.
1052    TocSize,
1053    /// `{toccolour}` / `{toccolor}` — sets the color for table of contents.
1054    TocColour,
1055
1056    // -- Song boundary directives --------------------------------------------
1057    /// `{new_song}` / `{ns}` — marks the start of a new song in a multi-song file.
1058    NewSong,
1059
1060    // -- Chord definition directives ----------------------------------------
1061    /// `{define}` — defines a custom chord fingering.
1062    Define,
1063    /// `{chord}` — references a custom chord.
1064    ChordDirective,
1065
1066    // -- Delegate environment directives -------------------------------------
1067    /// `{start_of_abc}` — begins an ABC music notation section.
1068    /// Content is treated as verbatim text (no chord parsing).
1069    StartOfAbc,
1070    /// `{end_of_abc}` — ends an ABC music notation section.
1071    EndOfAbc,
1072    /// `{start_of_ly}` — begins a Lilypond notation section.
1073    /// Content is treated as verbatim text (no chord parsing).
1074    StartOfLy,
1075    /// `{end_of_ly}` — ends a Lilypond notation section.
1076    EndOfLy,
1077    /// `{start_of_svg}` — begins an SVG graphics section.
1078    /// Content is treated as verbatim text (no chord parsing).
1079    StartOfSvg,
1080    /// `{end_of_svg}` — ends an SVG graphics section.
1081    EndOfSvg,
1082    /// `{start_of_textblock}` — begins a preformatted text block section.
1083    /// Content is treated as verbatim text (no chord parsing).
1084    StartOfTextblock,
1085    /// `{end_of_textblock}` — ends a preformatted text block section.
1086    EndOfTextblock,
1087    /// `{start_of_musicxml}` — begins a MusicXML notation section.
1088    /// Content is treated as verbatim text (no chord parsing).
1089    StartOfMusicxml,
1090    /// `{end_of_musicxml}` — ends a MusicXML notation section.
1091    EndOfMusicxml,
1092
1093    // -- Custom section directives -------------------------------------------
1094    /// `{start_of_X}` — begins a custom section (e.g., intro, outro, solo).
1095    /// The contained `String` is the section type name (e.g., `"intro"`).
1096    StartOfSection(String),
1097    /// `{end_of_X}` — ends a custom section.
1098    /// The contained `String` is the section type name (e.g., `"intro"`).
1099    EndOfSection(String),
1100
1101    // -- Generic metadata directive -----------------------------------------
1102    /// `{meta: key value}` — a generic metadata directive.
1103    ///
1104    /// The first word of the value is the metadata key name (e.g., `"artist"`),
1105    /// and the remainder is the metadata value. This allows setting any metadata
1106    /// field using the generic `{meta}` directive syntax.
1107    Meta(String),
1108
1109    // -- Chord diagram control ------------------------------------------------
1110    /// `{diagrams}` / `{diagrams: on}` / `{diagrams: off}` — control chord
1111    /// diagram visibility. When set to "off", renderers suppress automatic
1112    /// chord diagrams for the current song.
1113    Diagrams,
1114    /// `{no_diagrams}` — suppresses the auto-generated diagram block for
1115    /// this song. Equivalent to `{diagrams: off}`.
1116    NoDiagrams,
1117
1118    // -- Image directive ----------------------------------------------------
1119    /// `{image: src=filename}` — embeds an image with optional attributes.
1120    Image(ImageAttributes),
1121
1122    // -- Config override directives -----------------------------------------
1123    /// `{+config.KEY: VALUE}` — overrides a configuration value for this song.
1124    ///
1125    /// The contained `String` is the dot-separated config key path
1126    /// (e.g., `"pdf.chorus.indent"`). The directive value holds the new
1127    /// setting (e.g., `"20"`).
1128    ConfigOverride(String),
1129
1130    // -- Unknown ------------------------------------------------------------
1131    /// A directive not recognized as a standard ChordPro directive.
1132    /// The original directive name (lowercased) is preserved.
1133    Unknown(String),
1134}
1135
1136impl DirectiveKind {
1137    /// Resolves a directive name to its [`DirectiveKind`].
1138    ///
1139    /// The lookup is case-insensitive and recognizes standard short aliases
1140    /// (e.g., `t` for `title`, `soc` for `start_of_chorus`).
1141    #[must_use]
1142    pub fn from_name(name: &str) -> Self {
1143        match name.to_ascii_lowercase().as_str() {
1144            // Metadata
1145            "title" | "t" => Self::Title,
1146            "subtitle" | "st" => Self::Subtitle,
1147            "artist" => Self::Artist,
1148            "composer" => Self::Composer,
1149            "lyricist" => Self::Lyricist,
1150            "album" => Self::Album,
1151            "year" => Self::Year,
1152            "key" => Self::Key,
1153            "tempo" => Self::Tempo,
1154            "time" => Self::Time,
1155            "capo" => Self::Capo,
1156            "sorttitle" => Self::SortTitle,
1157            "sortartist" => Self::SortArtist,
1158            "arranger" => Self::Arranger,
1159            "copyright" => Self::Copyright,
1160            "duration" => Self::Duration,
1161            "tag" => Self::Tag,
1162
1163            // Transpose
1164            "transpose" => Self::Transpose,
1165
1166            // Song boundary
1167            "new_song" | "ns" => Self::NewSong,
1168
1169            // Formatting (comments)
1170            "comment" | "c" => Self::Comment,
1171            "comment_italic" | "ci" => Self::CommentItalic,
1172            "comment_box" | "cb" => Self::CommentBox,
1173
1174            // Environment (sections)
1175            "start_of_chorus" | "soc" => Self::StartOfChorus,
1176            "end_of_chorus" | "eoc" => Self::EndOfChorus,
1177            "start_of_verse" | "sov" => Self::StartOfVerse,
1178            "end_of_verse" | "eov" => Self::EndOfVerse,
1179            "start_of_bridge" | "sob" => Self::StartOfBridge,
1180            "end_of_bridge" | "eob" => Self::EndOfBridge,
1181            "start_of_tab" | "sot" => Self::StartOfTab,
1182            "end_of_tab" | "eot" => Self::EndOfTab,
1183            "start_of_grid" | "sog" => Self::StartOfGrid,
1184            "end_of_grid" | "eog" => Self::EndOfGrid,
1185
1186            // Font, size, and color
1187            "textfont" | "tf" => Self::TextFont,
1188            "textsize" | "ts" => Self::TextSize,
1189            "textcolour" | "textcolor" | "tc" => Self::TextColour,
1190            "chordfont" | "cf" => Self::ChordFont,
1191            "chordsize" | "cs" => Self::ChordSize,
1192            "chordcolour" | "chordcolor" | "cc" => Self::ChordColour,
1193            "tabfont" => Self::TabFont,
1194            "tabsize" => Self::TabSize,
1195            "tabcolour" | "tabcolor" => Self::TabColour,
1196
1197            // Delegate environments (verbatim sections)
1198            "start_of_abc" => Self::StartOfAbc,
1199            "end_of_abc" => Self::EndOfAbc,
1200            "start_of_ly" => Self::StartOfLy,
1201            "end_of_ly" => Self::EndOfLy,
1202            "start_of_svg" => Self::StartOfSvg,
1203            "end_of_svg" => Self::EndOfSvg,
1204            "start_of_textblock" => Self::StartOfTextblock,
1205            "end_of_textblock" => Self::EndOfTextblock,
1206            "start_of_musicxml" => Self::StartOfMusicxml,
1207            "end_of_musicxml" => Self::EndOfMusicxml,
1208
1209            // Recall
1210            "chorus" => Self::Chorus,
1211
1212            // Page control
1213            "new_page" | "np" => Self::NewPage,
1214            "new_physical_page" | "npp" => Self::NewPhysicalPage,
1215            "column_break" | "colb" => Self::ColumnBreak,
1216            "columns" | "col" => Self::Columns,
1217
1218            // Font, size, and color
1219            "titlefont" => Self::TitleFont,
1220            "titlesize" => Self::TitleSize,
1221            "titlecolour" | "titlecolor" => Self::TitleColour,
1222            "chorusfont" => Self::ChorusFont,
1223            "chorussize" => Self::ChorusSize,
1224            "choruscolour" | "choruscolor" => Self::ChorusColour,
1225            "footerfont" => Self::FooterFont,
1226            "footersize" => Self::FooterSize,
1227            "footercolour" | "footercolor" => Self::FooterColour,
1228            "headerfont" => Self::HeaderFont,
1229            "headersize" => Self::HeaderSize,
1230            "headercolour" | "headercolor" => Self::HeaderColour,
1231            "labelfont" => Self::LabelFont,
1232            "labelsize" => Self::LabelSize,
1233            "labelcolour" | "labelcolor" => Self::LabelColour,
1234            "gridfont" => Self::GridFont,
1235            "gridsize" => Self::GridSize,
1236            "gridcolour" | "gridcolor" => Self::GridColour,
1237            "tocfont" => Self::TocFont,
1238            "tocsize" => Self::TocSize,
1239            "toccolour" | "toccolor" => Self::TocColour,
1240
1241            // Chord definitions and diagrams
1242            "define" => Self::Define,
1243            "chord" => Self::ChordDirective,
1244            "diagrams" => Self::Diagrams,
1245            "no_diagrams" | "nodiagrams" => Self::NoDiagrams,
1246
1247            // Generic metadata
1248            "meta" => Self::Meta(String::new()),
1249
1250            // Image — recognized but requires attribute parsing by the parser.
1251            // from_name returns a placeholder; the parser replaces it.
1252            "image" => Self::Image(ImageAttributes::default()),
1253
1254            // Custom sections (start_of_X / end_of_X)
1255            other => {
1256                // Config override: {+config.KEY: VALUE}
1257                if let Some(key) = other.strip_prefix("+config.") {
1258                    if !key.is_empty() {
1259                        return Self::ConfigOverride(key.to_string());
1260                    }
1261                }
1262                if let Some(section) = other.strip_prefix("start_of_") {
1263                    if !section.is_empty() {
1264                        return Self::StartOfSection(section.to_string());
1265                    }
1266                }
1267                if let Some(section) = other.strip_prefix("end_of_") {
1268                    if !section.is_empty() {
1269                        return Self::EndOfSection(section.to_string());
1270                    }
1271                }
1272                Self::Unknown(other.to_string())
1273            }
1274        }
1275    }
1276
1277    /// Resolves a directive name to a ([`DirectiveKind`], optional selector) pair.
1278    ///
1279    /// The algorithm works as follows:
1280    ///
1281    /// 1. First try to match the full name as a known directive (via
1282    ///    [`from_name`](Self::from_name)). If it resolves to a **known,
1283    ///    non-`Unknown`, non-custom-section** directive, return it with no
1284    ///    selector.
1285    /// 2. Otherwise, split at the **last** hyphen. Re-resolve the prefix
1286    ///    and, if it matches a known non-`Unknown` directive, treat the
1287    ///    suffix as the selector.
1288    /// 3. If neither approach yields a known directive, return the full name
1289    ///    as an `Unknown` directive with no selector.
1290    ///
1291    /// Custom section directives (`StartOfSection`, `EndOfSection`) are
1292    /// special-cased: `{start_of_chorus-piano}` must resolve as
1293    /// `StartOfChorus` with selector `"piano"`, not as
1294    /// `StartOfSection("chorus-piano")`.
1295    ///
1296    /// The lookup is case-insensitive, matching the behavior of
1297    /// [`from_name`](Self::from_name).
1298    #[must_use]
1299    pub fn resolve_with_selector(name: &str) -> (Self, Option<String>) {
1300        let kind = Self::from_name(name);
1301
1302        // If it resolves to a known directive that is NOT Unknown and NOT
1303        // a custom section, return it directly — no selector.
1304        let is_known = !matches!(
1305            kind,
1306            Self::Unknown(_) | Self::StartOfSection(_) | Self::EndOfSection(_)
1307        );
1308        if is_known {
1309            return (kind, None);
1310        }
1311
1312        // Try splitting at the last hyphen.
1313        if let Some(last_hyphen) = name.rfind('-') {
1314            let prefix = &name[..last_hyphen];
1315            let suffix = &name[last_hyphen + 1..];
1316
1317            if !prefix.is_empty() && !suffix.is_empty() {
1318                let prefix_kind = Self::from_name(prefix);
1319                if !matches!(prefix_kind, Self::Unknown(_)) {
1320                    return (prefix_kind, Some(suffix.to_ascii_lowercase()));
1321                }
1322            }
1323        }
1324
1325        // Fall back to the original resolution (Unknown or custom section
1326        // without a selector).
1327        (kind, None)
1328    }
1329
1330    /// Returns the canonical (long-form) directive name for known directives.
1331    ///
1332    /// For [`DirectiveKind::Unknown`], returns the stored name.
1333    #[must_use]
1334    pub fn canonical_name(&self) -> &str {
1335        match self {
1336            Self::Title => "title",
1337            Self::Subtitle => "subtitle",
1338            Self::Artist => "artist",
1339            Self::Composer => "composer",
1340            Self::Lyricist => "lyricist",
1341            Self::Album => "album",
1342            Self::Year => "year",
1343            Self::Key => "key",
1344            Self::Tempo => "tempo",
1345            Self::Time => "time",
1346            Self::Capo => "capo",
1347            Self::SortTitle => "sorttitle",
1348            Self::SortArtist => "sortartist",
1349            Self::Arranger => "arranger",
1350            Self::Copyright => "copyright",
1351            Self::Duration => "duration",
1352            Self::Tag => "tag",
1353            Self::Transpose => "transpose",
1354            Self::NewSong => "new_song",
1355            Self::Comment => "comment",
1356            Self::CommentItalic => "comment_italic",
1357            Self::CommentBox => "comment_box",
1358            Self::StartOfChorus => "start_of_chorus",
1359            Self::EndOfChorus => "end_of_chorus",
1360            Self::StartOfVerse => "start_of_verse",
1361            Self::EndOfVerse => "end_of_verse",
1362            Self::StartOfBridge => "start_of_bridge",
1363            Self::EndOfBridge => "end_of_bridge",
1364            Self::StartOfTab => "start_of_tab",
1365            Self::EndOfTab => "end_of_tab",
1366            Self::StartOfGrid => "start_of_grid",
1367            Self::EndOfGrid => "end_of_grid",
1368
1369            Self::TextFont => "textfont",
1370            Self::TextSize => "textsize",
1371            Self::TextColour => "textcolour",
1372            Self::ChordFont => "chordfont",
1373            Self::ChordSize => "chordsize",
1374            Self::ChordColour => "chordcolour",
1375            Self::TabFont => "tabfont",
1376            Self::TabSize => "tabsize",
1377            Self::TabColour => "tabcolour",
1378            Self::TitleFont => "titlefont",
1379            Self::TitleSize => "titlesize",
1380            Self::TitleColour => "titlecolour",
1381            Self::ChorusFont => "chorusfont",
1382            Self::ChorusSize => "chorussize",
1383            Self::ChorusColour => "choruscolour",
1384            Self::FooterFont => "footerfont",
1385            Self::FooterSize => "footersize",
1386            Self::FooterColour => "footercolour",
1387            Self::HeaderFont => "headerfont",
1388            Self::HeaderSize => "headersize",
1389            Self::HeaderColour => "headercolour",
1390            Self::LabelFont => "labelfont",
1391            Self::LabelSize => "labelsize",
1392            Self::LabelColour => "labelcolour",
1393            Self::GridFont => "gridfont",
1394            Self::GridSize => "gridsize",
1395            Self::GridColour => "gridcolour",
1396            Self::TocFont => "tocfont",
1397            Self::TocSize => "tocsize",
1398            Self::TocColour => "toccolour",
1399            Self::StartOfAbc => "start_of_abc",
1400            Self::EndOfAbc => "end_of_abc",
1401            Self::StartOfLy => "start_of_ly",
1402            Self::EndOfLy => "end_of_ly",
1403            Self::StartOfSvg => "start_of_svg",
1404            Self::EndOfSvg => "end_of_svg",
1405            Self::StartOfTextblock => "start_of_textblock",
1406            Self::EndOfTextblock => "end_of_textblock",
1407            Self::StartOfMusicxml => "start_of_musicxml",
1408            Self::EndOfMusicxml => "end_of_musicxml",
1409            Self::Chorus => "chorus",
1410            Self::NewPage => "new_page",
1411            Self::NewPhysicalPage => "new_physical_page",
1412            Self::ColumnBreak => "column_break",
1413            Self::Columns => "columns",
1414            Self::Define => "define",
1415            Self::ChordDirective => "chord",
1416            Self::Diagrams => "diagrams",
1417            Self::NoDiagrams => "no_diagrams",
1418            Self::Meta(_) => "meta",
1419
1420            Self::Image(_) => "image",
1421            Self::ConfigOverride(key) => key.as_str(),
1422            Self::StartOfSection(name) | Self::EndOfSection(name) | Self::Unknown(name) => {
1423                name.as_str()
1424            }
1425        }
1426    }
1427
1428    /// Returns the full canonical directive name as an owned `String`.
1429    ///
1430    /// For most directives this is the same as [`canonical_name`](Self::canonical_name).
1431    /// For custom section directives, the section name is prefixed with
1432    /// `start_of_` or `end_of_` to form the complete directive name.
1433    #[must_use]
1434    pub fn full_canonical_name(&self) -> String {
1435        match self {
1436            Self::StartOfSection(name) => format!("start_of_{name}"),
1437            Self::EndOfSection(name) => format!("end_of_{name}"),
1438            Self::ConfigOverride(key) => format!("+config.{key}"),
1439            _ => self.canonical_name().to_string(),
1440        }
1441    }
1442
1443    /// Returns `true` if this is a metadata directive.
1444    #[must_use]
1445    pub fn is_metadata(&self) -> bool {
1446        matches!(
1447            self,
1448            Self::Title
1449                | Self::Subtitle
1450                | Self::Artist
1451                | Self::Composer
1452                | Self::Lyricist
1453                | Self::Album
1454                | Self::Year
1455                | Self::Key
1456                | Self::Tempo
1457                | Self::Time
1458                | Self::Capo
1459                | Self::SortTitle
1460                | Self::SortArtist
1461                | Self::Arranger
1462                | Self::Copyright
1463                | Self::Duration
1464                | Self::Tag
1465                | Self::Meta(_)
1466        )
1467    }
1468
1469    /// Returns `true` if this is a comment/formatting directive.
1470    #[must_use]
1471    pub fn is_comment(&self) -> bool {
1472        matches!(self, Self::Comment | Self::CommentItalic | Self::CommentBox)
1473    }
1474
1475    /// Returns `true` if this is a font, size, or color formatting directive.
1476    #[must_use]
1477    pub fn is_font_size_color(&self) -> bool {
1478        matches!(
1479            self,
1480            Self::TextFont
1481                | Self::TextSize
1482                | Self::TextColour
1483                | Self::ChordFont
1484                | Self::ChordSize
1485                | Self::ChordColour
1486                | Self::TabFont
1487                | Self::TabSize
1488                | Self::TabColour
1489                | Self::TitleFont
1490                | Self::TitleSize
1491                | Self::TitleColour
1492                | Self::ChorusFont
1493                | Self::ChorusSize
1494                | Self::ChorusColour
1495                | Self::FooterFont
1496                | Self::FooterSize
1497                | Self::FooterColour
1498                | Self::HeaderFont
1499                | Self::HeaderSize
1500                | Self::HeaderColour
1501                | Self::LabelFont
1502                | Self::LabelSize
1503                | Self::LabelColour
1504                | Self::GridFont
1505                | Self::GridSize
1506                | Self::GridColour
1507                | Self::TocFont
1508                | Self::TocSize
1509                | Self::TocColour
1510        )
1511    }
1512
1513    /// Returns `true` if this is a section start directive.
1514    #[must_use]
1515    pub fn is_section_start(&self) -> bool {
1516        matches!(
1517            self,
1518            Self::StartOfChorus
1519                | Self::StartOfVerse
1520                | Self::StartOfBridge
1521                | Self::StartOfTab
1522                | Self::StartOfGrid
1523                | Self::StartOfAbc
1524                | Self::StartOfLy
1525                | Self::StartOfSvg
1526                | Self::StartOfTextblock
1527                | Self::StartOfMusicxml
1528                | Self::StartOfSection(_)
1529        )
1530    }
1531
1532    /// Returns `true` if this is a section end directive.
1533    #[must_use]
1534    pub fn is_section_end(&self) -> bool {
1535        matches!(
1536            self,
1537            Self::EndOfChorus
1538                | Self::EndOfVerse
1539                | Self::EndOfBridge
1540                | Self::EndOfTab
1541                | Self::EndOfGrid
1542                | Self::EndOfAbc
1543                | Self::EndOfLy
1544                | Self::EndOfSvg
1545                | Self::EndOfTextblock
1546                | Self::EndOfMusicxml
1547                | Self::EndOfSection(_)
1548        )
1549    }
1550
1551    /// Returns `true` if this is an environment (section start or end) directive.
1552    #[must_use]
1553    pub fn is_environment(&self) -> bool {
1554        self.is_section_start() || self.is_section_end()
1555    }
1556
1557    /// Returns `true` if this is the image directive.
1558    #[must_use]
1559    pub fn is_image(&self) -> bool {
1560        matches!(self, Self::Image(_))
1561    }
1562
1563    /// Returns `true` if this is a page control directive.
1564    #[must_use]
1565    pub fn is_page_control(&self) -> bool {
1566        matches!(
1567            self,
1568            Self::NewPage | Self::NewPhysicalPage | Self::ColumnBreak | Self::Columns
1569        )
1570    }
1571}
1572
1573// ---------------------------------------------------------------------------
1574// Directive
1575// ---------------------------------------------------------------------------
1576
1577/// A ChordPro directive such as `{title: My Song}` or `{start_of_chorus}`.
1578///
1579/// Directives are enclosed in curly braces and consist of a name and an
1580/// optional value separated by a colon. Some directives have standard short
1581/// aliases (e.g., `t` for `title`, `st` for `subtitle`).
1582///
1583/// The `name` field stores the **canonical** (long-form, lowercase) name after
1584/// alias resolution. The `kind` field provides a typed classification for
1585/// pattern matching.
1586///
1587/// # Selector Suffixes
1588///
1589/// Directives may carry an optional **selector suffix** that targets a specific
1590/// instrument or user. The selector is separated from the directive name by a
1591/// hyphen (e.g., `{textfont-piano: Courier}` has selector `"piano"`). The
1592/// parser splits the raw directive name at the **last** hyphen to detect
1593/// selectors: if the prefix resolves to a known directive, the suffix is
1594/// stored in `selector`; otherwise the entire name is treated as a single
1595/// (possibly unknown) directive with no selector.
1596///
1597/// # Examples
1598///
1599/// ```
1600/// use chordsketch_core::ast::{Directive, DirectiveKind};
1601///
1602/// // {title: My Song}
1603/// let d = Directive::with_value("title", "My Song");
1604/// assert_eq!(d.name, "title");
1605/// assert_eq!(d.value.as_deref(), Some("My Song"));
1606/// assert_eq!(d.kind, DirectiveKind::Title);
1607/// assert_eq!(d.selector, None);
1608///
1609/// // {start_of_chorus}
1610/// let d = Directive::name_only("start_of_chorus");
1611/// assert_eq!(d.name, "start_of_chorus");
1612/// assert!(d.value.is_none());
1613/// assert_eq!(d.kind, DirectiveKind::StartOfChorus);
1614/// assert_eq!(d.selector, None);
1615/// ```
1616#[derive(Debug, Clone, PartialEq)]
1617pub struct Directive {
1618    /// The canonical directive name (e.g., `"title"`, `"start_of_chorus"`).
1619    pub name: String,
1620    /// The optional value after the colon (e.g., `"My Song"` in `{title: My Song}`).
1621    pub value: Option<String>,
1622    /// The classified kind of this directive.
1623    pub kind: DirectiveKind,
1624    /// An optional selector suffix for instrument/user targeting.
1625    ///
1626    /// For example, `{textfont-piano: Courier}` has `selector` = `Some("piano")`.
1627    /// When no selector suffix is present, this is `None`.
1628    pub selector: Option<String>,
1629}
1630
1631impl Directive {
1632    /// Creates a directive with both a name and a value.
1633    ///
1634    /// The name is resolved to its canonical form and the [`DirectiveKind`]
1635    /// is determined automatically.
1636    #[must_use]
1637    pub fn with_value(name: impl Into<String>, value: impl Into<String>) -> Self {
1638        let name_str = name.into();
1639        let kind = DirectiveKind::from_name(&name_str);
1640        let canonical = kind.full_canonical_name();
1641        Self {
1642            name: canonical,
1643            value: Some(value.into()),
1644            kind,
1645            selector: None,
1646        }
1647    }
1648
1649    /// Creates a directive with only a name and no value.
1650    ///
1651    /// The name is resolved to its canonical form and the [`DirectiveKind`]
1652    /// is determined automatically.
1653    #[must_use]
1654    pub fn name_only(name: impl Into<String>) -> Self {
1655        let name_str = name.into();
1656        let kind = DirectiveKind::from_name(&name_str);
1657        let canonical = kind.full_canonical_name();
1658        Self {
1659            name: canonical,
1660            value: None,
1661            kind,
1662            selector: None,
1663        }
1664    }
1665
1666    /// Creates a directive with a name, value, and selector suffix.
1667    ///
1668    /// The name is resolved to its canonical form and the [`DirectiveKind`]
1669    /// is determined automatically.
1670    #[must_use]
1671    pub fn with_selector(
1672        name: impl Into<String>,
1673        value: Option<String>,
1674        selector: impl Into<String>,
1675    ) -> Self {
1676        let name_str = name.into();
1677        let kind = DirectiveKind::from_name(&name_str);
1678        let canonical = kind.full_canonical_name();
1679        Self {
1680            name: canonical,
1681            value,
1682            kind,
1683            selector: Some(selector.into().to_ascii_lowercase()),
1684        }
1685    }
1686
1687    /// Returns `true` if this directive marks the start of a section
1688    /// (e.g., `start_of_chorus`, `start_of_verse`, etc.).
1689    #[must_use]
1690    pub fn is_section_start(&self) -> bool {
1691        self.kind.is_section_start()
1692    }
1693
1694    /// Returns `true` if this directive marks the end of a section
1695    /// (e.g., `end_of_chorus`, `end_of_verse`, etc.).
1696    #[must_use]
1697    pub fn is_section_end(&self) -> bool {
1698        self.kind.is_section_end()
1699    }
1700
1701    /// If this is a section start or end directive, returns the section name
1702    /// (e.g., `"chorus"` from `"start_of_chorus"`).
1703    #[must_use]
1704    pub fn section_name(&self) -> Option<&str> {
1705        if let Some(suffix) = self.name.strip_prefix("start_of_") {
1706            Some(suffix)
1707        } else if let Some(suffix) = self.name.strip_prefix("end_of_") {
1708            Some(suffix)
1709        } else {
1710            None
1711        }
1712    }
1713}
1714
1715// ---------------------------------------------------------------------------
1716// Tests
1717// ---------------------------------------------------------------------------
1718
1719#[cfg(test)]
1720mod tests {
1721    use super::*;
1722    use crate::chord::{Accidental, ChordQuality, Note};
1723
1724    // -- Song ---------------------------------------------------------------
1725
1726    #[test]
1727    fn song_new_is_empty() {
1728        let song = Song::new();
1729        assert!(song.lines.is_empty());
1730        assert_eq!(song.metadata.title, None);
1731    }
1732
1733    #[test]
1734    fn song_default_equals_new() {
1735        assert_eq!(Song::default(), Song::new());
1736    }
1737
1738    #[test]
1739    fn used_chord_names_empty() {
1740        let song = crate::parse("{title: Test}").unwrap();
1741        assert!(song.used_chord_names().is_empty());
1742    }
1743
1744    #[test]
1745    fn used_chord_names_order_and_dedup() {
1746        let song = crate::parse("[Am]one [G]two [Am]three [C]four").unwrap();
1747        assert_eq!(song.used_chord_names(), vec!["Am", "G", "C"]);
1748    }
1749
1750    #[test]
1751    fn fretted_defines_empty() {
1752        let song = crate::parse("{title: Test}").unwrap();
1753        assert!(song.fretted_defines().is_empty());
1754    }
1755
1756    #[test]
1757    fn fretted_defines_returns_raw_only() {
1758        let input = "{define: Am base-fret 1 frets x 0 2 2 1 0}\n{define: G keys 0 4 7}";
1759        let song = crate::parse(input).unwrap();
1760        let defs = song.fretted_defines();
1761        assert_eq!(defs.len(), 1);
1762        assert_eq!(defs[0].0, "Am");
1763    }
1764
1765    #[test]
1766    fn fretted_defines_later_overrides_earlier() {
1767        let input = "{define: Am base-fret 1 frets x 0 2 2 1 0}\n{define: Am base-fret 1 frets x 0 2 2 0 0}";
1768        let song = crate::parse(input).unwrap();
1769        let defs = song.fretted_defines();
1770        assert_eq!(defs.len(), 1);
1771        assert!(
1772            defs[0].1.contains("0 0"),
1773            "later define should override earlier"
1774        );
1775    }
1776
1777    #[test]
1778    fn fretted_defines_chord_directive_alias() {
1779        // {chord:} is a ChordPro alias for {define:} and must be included.
1780        let input_chord = "{chord: Am base-fret 1 frets x 0 2 2 1 0}";
1781        let input_define = "{define: Am base-fret 1 frets x 0 2 2 1 0}";
1782        let defs_chord = crate::parse(input_chord).unwrap().fretted_defines();
1783        let defs_define = crate::parse(input_define).unwrap().fretted_defines();
1784        assert_eq!(
1785            defs_chord.len(),
1786            1,
1787            "{{chord:}} must appear in fretted_defines"
1788        );
1789        assert_eq!(defs_chord[0].0, defs_define[0].0, "chord names must match");
1790        assert_eq!(defs_chord[0].1, defs_define[0].1, "raw values must match");
1791    }
1792
1793    #[test]
1794    fn song_with_lines() {
1795        let mut song = Song::new();
1796        song.metadata.title = Some("My Song".to_string());
1797        song.lines.push(Line::Empty);
1798        song.lines
1799            .push(Line::Comment(CommentStyle::Normal, "A comment".to_string()));
1800        assert_eq!(song.lines.len(), 2);
1801        assert_eq!(song.metadata.title.as_deref(), Some("My Song"));
1802    }
1803
1804    // -- Metadata -----------------------------------------------------------
1805
1806    #[test]
1807    fn metadata_default_is_empty() {
1808        let meta = Metadata::new();
1809        assert_eq!(meta.title, None);
1810        assert!(meta.subtitles.is_empty());
1811        assert!(meta.artists.is_empty());
1812        assert!(meta.composers.is_empty());
1813        assert!(meta.lyricists.is_empty());
1814        assert_eq!(meta.album, None);
1815        assert_eq!(meta.year, None);
1816        assert_eq!(meta.key, None);
1817        assert_eq!(meta.tempo, None);
1818        assert_eq!(meta.time, None);
1819        assert_eq!(meta.capo, None);
1820        assert_eq!(meta.sort_title, None);
1821        assert_eq!(meta.sort_artist, None);
1822        assert!(meta.arrangers.is_empty());
1823        assert_eq!(meta.copyright, None);
1824        assert_eq!(meta.duration, None);
1825        assert!(meta.tags.is_empty());
1826        assert!(meta.custom.is_empty());
1827    }
1828
1829    // -- LyricsLine ---------------------------------------------------------
1830
1831    #[test]
1832    fn lyrics_line_text_concatenation() {
1833        let line = LyricsLine {
1834            segments: vec![
1835                LyricsSegment::new(Some(Chord::new("Am")), "Hello "),
1836                LyricsSegment::new(Some(Chord::new("G")), "world"),
1837            ],
1838        };
1839        assert_eq!(line.text(), "Hello world");
1840    }
1841
1842    #[test]
1843    fn lyrics_line_has_chords() {
1844        let with_chords = LyricsLine {
1845            segments: vec![LyricsSegment::new(Some(Chord::new("C")), "text")],
1846        };
1847        assert!(with_chords.has_chords());
1848
1849        let without_chords = LyricsLine {
1850            segments: vec![LyricsSegment::text_only("just text")],
1851        };
1852        assert!(!without_chords.has_chords());
1853    }
1854
1855    #[test]
1856    fn lyrics_line_empty_default() {
1857        let line = LyricsLine::new();
1858        assert!(line.segments.is_empty());
1859        assert_eq!(line.text(), "");
1860        assert!(!line.has_chords());
1861    }
1862
1863    // -- LyricsSegment ------------------------------------------------------
1864
1865    #[test]
1866    fn segment_text_only() {
1867        let seg = LyricsSegment::text_only("hello");
1868        assert_eq!(seg.chord, None);
1869        assert_eq!(seg.text, "hello");
1870    }
1871
1872    #[test]
1873    fn segment_chord_only() {
1874        let seg = LyricsSegment::chord_only(Chord::new("Dm"));
1875        assert_eq!(seg.chord, Some(Chord::new("Dm")));
1876        assert!(seg.text.is_empty());
1877    }
1878
1879    #[test]
1880    fn segment_with_chord_and_text() {
1881        let seg = LyricsSegment::new(Some(Chord::new("E7")), "lyrics");
1882        assert_eq!(seg.chord.as_ref().map(|c| c.name.as_str()), Some("E7"));
1883        assert_eq!(seg.text, "lyrics");
1884    }
1885
1886    // -- Chord --------------------------------------------------------------
1887
1888    #[test]
1889    fn chord_display() {
1890        let chord = Chord::new("F#m7");
1891        assert_eq!(format!("{chord}"), "F#m7");
1892    }
1893
1894    #[test]
1895    fn chord_equality() {
1896        assert_eq!(Chord::new("Am"), Chord::new("Am"));
1897        assert_ne!(Chord::new("Am"), Chord::new("Bm"));
1898    }
1899
1900    #[test]
1901    fn chord_detail_parsed() {
1902        let chord = Chord::new("C#m7");
1903        let detail = chord.detail.as_ref().expect("should have detail");
1904        assert_eq!(detail.root, Note::C);
1905        assert_eq!(detail.root_accidental, Some(Accidental::Sharp));
1906        assert_eq!(detail.quality, ChordQuality::Minor);
1907        assert_eq!(detail.extension.as_deref(), Some("7"));
1908    }
1909
1910    #[test]
1911    fn chord_detail_slash_chord() {
1912        let chord = Chord::new("G/B");
1913        let detail = chord.detail.as_ref().expect("should have detail");
1914        assert_eq!(detail.root, Note::G);
1915        assert_eq!(detail.bass_note, Some((Note::B, None)));
1916    }
1917
1918    #[test]
1919    fn chord_detail_unparseable() {
1920        let chord = Chord::new("");
1921        assert!(chord.detail.is_none());
1922        assert_eq!(chord.name, "");
1923    }
1924
1925    #[test]
1926    fn chord_detail_invalid_notation() {
1927        let chord = Chord::new("xyz");
1928        assert!(chord.detail.is_none());
1929        assert_eq!(chord.name, "xyz");
1930    }
1931
1932    // -- DirectiveKind ------------------------------------------------------
1933
1934    #[test]
1935    fn directive_kind_from_name_metadata() {
1936        assert_eq!(DirectiveKind::from_name("title"), DirectiveKind::Title);
1937        assert_eq!(DirectiveKind::from_name("t"), DirectiveKind::Title);
1938        assert_eq!(DirectiveKind::from_name("TITLE"), DirectiveKind::Title);
1939        assert_eq!(DirectiveKind::from_name("Title"), DirectiveKind::Title);
1940        assert_eq!(
1941            DirectiveKind::from_name("subtitle"),
1942            DirectiveKind::Subtitle
1943        );
1944        assert_eq!(DirectiveKind::from_name("st"), DirectiveKind::Subtitle);
1945        assert_eq!(DirectiveKind::from_name("artist"), DirectiveKind::Artist);
1946        assert_eq!(
1947            DirectiveKind::from_name("composer"),
1948            DirectiveKind::Composer
1949        );
1950        assert_eq!(
1951            DirectiveKind::from_name("lyricist"),
1952            DirectiveKind::Lyricist
1953        );
1954        assert_eq!(DirectiveKind::from_name("album"), DirectiveKind::Album);
1955        assert_eq!(DirectiveKind::from_name("year"), DirectiveKind::Year);
1956        assert_eq!(DirectiveKind::from_name("key"), DirectiveKind::Key);
1957        assert_eq!(DirectiveKind::from_name("tempo"), DirectiveKind::Tempo);
1958        assert_eq!(DirectiveKind::from_name("time"), DirectiveKind::Time);
1959        assert_eq!(DirectiveKind::from_name("capo"), DirectiveKind::Capo);
1960        assert_eq!(
1961            DirectiveKind::from_name("sorttitle"),
1962            DirectiveKind::SortTitle
1963        );
1964        assert_eq!(
1965            DirectiveKind::from_name("SORTTITLE"),
1966            DirectiveKind::SortTitle
1967        );
1968        assert_eq!(
1969            DirectiveKind::from_name("sortartist"),
1970            DirectiveKind::SortArtist
1971        );
1972        assert_eq!(
1973            DirectiveKind::from_name("arranger"),
1974            DirectiveKind::Arranger
1975        );
1976        assert_eq!(
1977            DirectiveKind::from_name("copyright"),
1978            DirectiveKind::Copyright
1979        );
1980        assert_eq!(
1981            DirectiveKind::from_name("duration"),
1982            DirectiveKind::Duration
1983        );
1984        assert_eq!(DirectiveKind::from_name("tag"), DirectiveKind::Tag);
1985    }
1986
1987    #[test]
1988    fn directive_kind_from_name_comment() {
1989        assert_eq!(DirectiveKind::from_name("comment"), DirectiveKind::Comment);
1990        assert_eq!(DirectiveKind::from_name("c"), DirectiveKind::Comment);
1991        assert_eq!(
1992            DirectiveKind::from_name("comment_italic"),
1993            DirectiveKind::CommentItalic
1994        );
1995        assert_eq!(DirectiveKind::from_name("ci"), DirectiveKind::CommentItalic);
1996        assert_eq!(
1997            DirectiveKind::from_name("comment_box"),
1998            DirectiveKind::CommentBox
1999        );
2000        assert_eq!(DirectiveKind::from_name("cb"), DirectiveKind::CommentBox);
2001    }
2002
2003    #[test]
2004    fn directive_kind_from_name_environment() {
2005        assert_eq!(
2006            DirectiveKind::from_name("start_of_chorus"),
2007            DirectiveKind::StartOfChorus
2008        );
2009        assert_eq!(
2010            DirectiveKind::from_name("soc"),
2011            DirectiveKind::StartOfChorus
2012        );
2013        assert_eq!(
2014            DirectiveKind::from_name("end_of_chorus"),
2015            DirectiveKind::EndOfChorus
2016        );
2017        assert_eq!(DirectiveKind::from_name("eoc"), DirectiveKind::EndOfChorus);
2018        assert_eq!(
2019            DirectiveKind::from_name("start_of_verse"),
2020            DirectiveKind::StartOfVerse
2021        );
2022        assert_eq!(DirectiveKind::from_name("sov"), DirectiveKind::StartOfVerse);
2023        assert_eq!(
2024            DirectiveKind::from_name("end_of_verse"),
2025            DirectiveKind::EndOfVerse
2026        );
2027        assert_eq!(DirectiveKind::from_name("eov"), DirectiveKind::EndOfVerse);
2028        assert_eq!(
2029            DirectiveKind::from_name("start_of_bridge"),
2030            DirectiveKind::StartOfBridge
2031        );
2032        assert_eq!(
2033            DirectiveKind::from_name("sob"),
2034            DirectiveKind::StartOfBridge
2035        );
2036        assert_eq!(
2037            DirectiveKind::from_name("end_of_bridge"),
2038            DirectiveKind::EndOfBridge
2039        );
2040        assert_eq!(DirectiveKind::from_name("eob"), DirectiveKind::EndOfBridge);
2041        assert_eq!(
2042            DirectiveKind::from_name("start_of_tab"),
2043            DirectiveKind::StartOfTab
2044        );
2045        assert_eq!(DirectiveKind::from_name("sot"), DirectiveKind::StartOfTab);
2046        assert_eq!(
2047            DirectiveKind::from_name("end_of_tab"),
2048            DirectiveKind::EndOfTab
2049        );
2050        assert_eq!(DirectiveKind::from_name("eot"), DirectiveKind::EndOfTab);
2051    }
2052
2053    #[test]
2054    fn directive_kind_from_name_page_control() {
2055        assert_eq!(DirectiveKind::from_name("new_page"), DirectiveKind::NewPage);
2056        assert_eq!(DirectiveKind::from_name("np"), DirectiveKind::NewPage);
2057        assert_eq!(
2058            DirectiveKind::from_name("new_physical_page"),
2059            DirectiveKind::NewPhysicalPage
2060        );
2061        assert_eq!(
2062            DirectiveKind::from_name("npp"),
2063            DirectiveKind::NewPhysicalPage
2064        );
2065        assert_eq!(
2066            DirectiveKind::from_name("column_break"),
2067            DirectiveKind::ColumnBreak
2068        );
2069        assert_eq!(DirectiveKind::from_name("colb"), DirectiveKind::ColumnBreak);
2070        assert_eq!(DirectiveKind::from_name("columns"), DirectiveKind::Columns);
2071        assert_eq!(DirectiveKind::from_name("col"), DirectiveKind::Columns);
2072    }
2073    #[test]
2074    fn directive_kind_from_name_unknown() {
2075        let kind = DirectiveKind::from_name("custom_thing");
2076        assert_eq!(kind, DirectiveKind::Unknown("custom_thing".to_string()));
2077    }
2078
2079    #[test]
2080    fn directive_kind_case_insensitive() {
2081        assert_eq!(DirectiveKind::from_name("TITLE"), DirectiveKind::Title);
2082        assert_eq!(DirectiveKind::from_name("Title"), DirectiveKind::Title);
2083        assert_eq!(
2084            DirectiveKind::from_name("START_OF_CHORUS"),
2085            DirectiveKind::StartOfChorus
2086        );
2087        assert_eq!(
2088            DirectiveKind::from_name("Comment_Italic"),
2089            DirectiveKind::CommentItalic
2090        );
2091        assert_eq!(DirectiveKind::from_name("NEW_PAGE"), DirectiveKind::NewPage);
2092        assert_eq!(
2093            DirectiveKind::from_name("Column_Break"),
2094            DirectiveKind::ColumnBreak
2095        );
2096    }
2097
2098    #[test]
2099    fn directive_kind_canonical_name() {
2100        assert_eq!(DirectiveKind::Title.canonical_name(), "title");
2101        assert_eq!(
2102            DirectiveKind::StartOfChorus.canonical_name(),
2103            "start_of_chorus"
2104        );
2105        assert_eq!(DirectiveKind::Comment.canonical_name(), "comment");
2106        assert_eq!(
2107            DirectiveKind::Unknown("foo".to_string()).canonical_name(),
2108            "foo"
2109        );
2110        assert_eq!(DirectiveKind::SortTitle.canonical_name(), "sorttitle");
2111        assert_eq!(DirectiveKind::SortArtist.canonical_name(), "sortartist");
2112        assert_eq!(DirectiveKind::Arranger.canonical_name(), "arranger");
2113        assert_eq!(DirectiveKind::Copyright.canonical_name(), "copyright");
2114        assert_eq!(DirectiveKind::Duration.canonical_name(), "duration");
2115        assert_eq!(DirectiveKind::Tag.canonical_name(), "tag");
2116        assert_eq!(DirectiveKind::NewPage.canonical_name(), "new_page");
2117        assert_eq!(
2118            DirectiveKind::NewPhysicalPage.canonical_name(),
2119            "new_physical_page"
2120        );
2121        assert_eq!(DirectiveKind::ColumnBreak.canonical_name(), "column_break");
2122        assert_eq!(DirectiveKind::Columns.canonical_name(), "columns");
2123    }
2124
2125    #[test]
2126    fn directive_kind_category_checks() {
2127        assert!(DirectiveKind::Title.is_metadata());
2128        assert!(!DirectiveKind::Title.is_comment());
2129        assert!(!DirectiveKind::Title.is_environment());
2130
2131        assert!(DirectiveKind::Comment.is_comment());
2132        assert!(!DirectiveKind::Comment.is_metadata());
2133
2134        assert!(DirectiveKind::StartOfChorus.is_section_start());
2135        assert!(DirectiveKind::StartOfChorus.is_environment());
2136        assert!(!DirectiveKind::StartOfChorus.is_section_end());
2137
2138        assert!(DirectiveKind::EndOfChorus.is_section_end());
2139        assert!(DirectiveKind::EndOfChorus.is_environment());
2140        assert!(!DirectiveKind::EndOfChorus.is_section_start());
2141
2142        let unknown = DirectiveKind::Unknown("x".to_string());
2143        assert!(!unknown.is_metadata());
2144        assert!(!unknown.is_comment());
2145        assert!(!unknown.is_environment());
2146
2147        assert!(DirectiveKind::NewPage.is_page_control());
2148        assert!(DirectiveKind::NewPhysicalPage.is_page_control());
2149        assert!(DirectiveKind::ColumnBreak.is_page_control());
2150        assert!(DirectiveKind::Columns.is_page_control());
2151        assert!(!DirectiveKind::NewPage.is_metadata());
2152        assert!(!DirectiveKind::NewPage.is_comment());
2153        assert!(!DirectiveKind::NewPage.is_environment());
2154        assert!(!DirectiveKind::Title.is_page_control());
2155        assert!(!unknown.is_page_control());
2156    }
2157
2158    // -- Directive ----------------------------------------------------------
2159
2160    #[test]
2161    fn directive_with_value() {
2162        let d = Directive::with_value("title", "My Song");
2163        assert_eq!(d.name, "title");
2164        assert_eq!(d.value.as_deref(), Some("My Song"));
2165        assert_eq!(d.kind, DirectiveKind::Title);
2166    }
2167
2168    #[test]
2169    fn directive_name_only() {
2170        let d = Directive::name_only("start_of_chorus");
2171        assert_eq!(d.name, "start_of_chorus");
2172        assert!(d.value.is_none());
2173        assert_eq!(d.kind, DirectiveKind::StartOfChorus);
2174    }
2175
2176    #[test]
2177    fn directive_short_alias_resolution() {
2178        let d = Directive::with_value("t", "My Song");
2179        assert_eq!(d.name, "title");
2180        assert_eq!(d.kind, DirectiveKind::Title);
2181
2182        let d = Directive::name_only("soc");
2183        assert_eq!(d.name, "start_of_chorus");
2184        assert_eq!(d.kind, DirectiveKind::StartOfChorus);
2185
2186        let d = Directive::with_value("st", "Alternate Title");
2187        assert_eq!(d.name, "subtitle");
2188        assert_eq!(d.kind, DirectiveKind::Subtitle);
2189    }
2190
2191    #[test]
2192    fn directive_case_insensitive_resolution() {
2193        let d = Directive::with_value("TITLE", "My Song");
2194        assert_eq!(d.name, "title");
2195        assert_eq!(d.kind, DirectiveKind::Title);
2196
2197        let d = Directive::name_only("SOC");
2198        assert_eq!(d.name, "start_of_chorus");
2199        assert_eq!(d.kind, DirectiveKind::StartOfChorus);
2200    }
2201
2202    #[test]
2203    fn directive_unknown_preserves_name() {
2204        let d = Directive::with_value("my_custom", "value");
2205        assert_eq!(d.name, "my_custom");
2206        assert_eq!(d.kind, DirectiveKind::Unknown("my_custom".to_string()));
2207    }
2208
2209    #[test]
2210    fn directive_section_detection() {
2211        let soc = Directive::name_only("start_of_chorus");
2212        assert!(soc.is_section_start());
2213        assert!(!soc.is_section_end());
2214        assert_eq!(soc.section_name(), Some("chorus"));
2215
2216        let eoc = Directive::name_only("end_of_chorus");
2217        assert!(!eoc.is_section_start());
2218        assert!(eoc.is_section_end());
2219        assert_eq!(eoc.section_name(), Some("chorus"));
2220
2221        let title = Directive::with_value("title", "Test");
2222        assert!(!title.is_section_start());
2223        assert!(!title.is_section_end());
2224        assert_eq!(title.section_name(), None);
2225    }
2226
2227    #[test]
2228    fn directive_section_name_variants() {
2229        let sov = Directive::name_only("start_of_verse");
2230        assert_eq!(sov.section_name(), Some("verse"));
2231
2232        let eob = Directive::name_only("end_of_bridge");
2233        assert_eq!(eob.section_name(), Some("bridge"));
2234    }
2235
2236    #[test]
2237    fn directive_section_detection_via_short_alias() {
2238        let soc = Directive::name_only("soc");
2239        assert!(soc.is_section_start());
2240        assert_eq!(soc.section_name(), Some("chorus"));
2241
2242        let eot = Directive::name_only("eot");
2243        assert!(eot.is_section_end());
2244        assert_eq!(eot.section_name(), Some("tab"));
2245    }
2246
2247    // -- Custom section directives ------------------------------------------
2248
2249    #[test]
2250    fn directive_kind_start_of_custom_section() {
2251        let kind = DirectiveKind::from_name("start_of_intro");
2252        assert_eq!(kind, DirectiveKind::StartOfSection("intro".to_string()));
2253        assert!(kind.is_section_start());
2254        assert!(!kind.is_section_end());
2255        assert!(kind.is_environment());
2256    }
2257
2258    #[test]
2259    fn directive_kind_end_of_custom_section() {
2260        let kind = DirectiveKind::from_name("end_of_intro");
2261        assert_eq!(kind, DirectiveKind::EndOfSection("intro".to_string()));
2262        assert!(kind.is_section_end());
2263        assert!(!kind.is_section_start());
2264        assert!(kind.is_environment());
2265    }
2266
2267    #[test]
2268    fn directive_kind_custom_section_case_insensitive() {
2269        let kind = DirectiveKind::from_name("Start_Of_Intro");
2270        assert_eq!(kind, DirectiveKind::StartOfSection("intro".to_string()));
2271    }
2272
2273    #[test]
2274    fn directive_kind_custom_section_various_names() {
2275        assert_eq!(
2276            DirectiveKind::from_name("start_of_outro"),
2277            DirectiveKind::StartOfSection("outro".to_string())
2278        );
2279        assert_eq!(
2280            DirectiveKind::from_name("start_of_solo"),
2281            DirectiveKind::StartOfSection("solo".to_string())
2282        );
2283        assert_eq!(
2284            DirectiveKind::from_name("end_of_solo"),
2285            DirectiveKind::EndOfSection("solo".to_string())
2286        );
2287        assert_eq!(
2288            DirectiveKind::from_name("start_of_interlude"),
2289            DirectiveKind::StartOfSection("interlude".to_string())
2290        );
2291    }
2292
2293    // -- Font, size, and color directives -----------------------------------
2294
2295    #[test]
2296    fn directive_kind_from_name_font_size_color() {
2297        // Text font directives
2298        assert_eq!(
2299            DirectiveKind::from_name("textfont"),
2300            DirectiveKind::TextFont
2301        );
2302        assert_eq!(DirectiveKind::from_name("tf"), DirectiveKind::TextFont);
2303        assert_eq!(
2304            DirectiveKind::from_name("TEXTFONT"),
2305            DirectiveKind::TextFont
2306        );
2307        assert_eq!(
2308            DirectiveKind::from_name("textsize"),
2309            DirectiveKind::TextSize
2310        );
2311        assert_eq!(DirectiveKind::from_name("ts"), DirectiveKind::TextSize);
2312        assert_eq!(
2313            DirectiveKind::from_name("textcolour"),
2314            DirectiveKind::TextColour
2315        );
2316        assert_eq!(
2317            DirectiveKind::from_name("textcolor"),
2318            DirectiveKind::TextColour
2319        );
2320        assert_eq!(DirectiveKind::from_name("tc"), DirectiveKind::TextColour);
2321
2322        // Chord font directives
2323        assert_eq!(
2324            DirectiveKind::from_name("chordfont"),
2325            DirectiveKind::ChordFont
2326        );
2327        assert_eq!(DirectiveKind::from_name("cf"), DirectiveKind::ChordFont);
2328        assert_eq!(
2329            DirectiveKind::from_name("chordsize"),
2330            DirectiveKind::ChordSize
2331        );
2332        assert_eq!(DirectiveKind::from_name("cs"), DirectiveKind::ChordSize);
2333        assert_eq!(
2334            DirectiveKind::from_name("chordcolour"),
2335            DirectiveKind::ChordColour
2336        );
2337        assert_eq!(
2338            DirectiveKind::from_name("chordcolor"),
2339            DirectiveKind::ChordColour
2340        );
2341        assert_eq!(DirectiveKind::from_name("cc"), DirectiveKind::ChordColour);
2342
2343        // Tab font directives
2344        assert_eq!(DirectiveKind::from_name("tabfont"), DirectiveKind::TabFont);
2345        assert_eq!(DirectiveKind::from_name("tabsize"), DirectiveKind::TabSize);
2346        assert_eq!(
2347            DirectiveKind::from_name("tabcolour"),
2348            DirectiveKind::TabColour
2349        );
2350        assert_eq!(
2351            DirectiveKind::from_name("tabcolor"),
2352            DirectiveKind::TabColour
2353        );
2354    }
2355
2356    #[test]
2357    fn directive_custom_section_full_canonical_name() {
2358        let kind = DirectiveKind::StartOfSection("intro".to_string());
2359        assert_eq!(kind.full_canonical_name(), "start_of_intro");
2360
2361        let kind = DirectiveKind::EndOfSection("outro".to_string());
2362        assert_eq!(kind.full_canonical_name(), "end_of_outro");
2363    }
2364
2365    #[test]
2366    fn directive_custom_section_name_only() {
2367        let d = Directive::name_only("start_of_intro");
2368        assert_eq!(d.name, "start_of_intro");
2369        assert!(d.value.is_none());
2370        assert_eq!(d.kind, DirectiveKind::StartOfSection("intro".to_string()));
2371        assert!(d.is_section_start());
2372        assert_eq!(d.section_name(), Some("intro"));
2373    }
2374
2375    #[test]
2376    fn directive_custom_section_with_label() {
2377        let d = Directive::with_value("start_of_intro", "Guitar Intro");
2378        assert_eq!(d.name, "start_of_intro");
2379        assert_eq!(d.value.as_deref(), Some("Guitar Intro"));
2380        assert_eq!(d.kind, DirectiveKind::StartOfSection("intro".to_string()));
2381    }
2382
2383    #[test]
2384    fn directive_end_custom_section() {
2385        let d = Directive::name_only("end_of_intro");
2386        assert_eq!(d.name, "end_of_intro");
2387        assert!(d.is_section_end());
2388        assert_eq!(d.section_name(), Some("intro"));
2389    }
2390
2391    #[test]
2392    fn directive_known_sections_not_custom() {
2393        // Built-in sections should NOT produce StartOfSection/EndOfSection
2394        assert_eq!(
2395            DirectiveKind::from_name("start_of_chorus"),
2396            DirectiveKind::StartOfChorus
2397        );
2398        assert_eq!(
2399            DirectiveKind::from_name("start_of_verse"),
2400            DirectiveKind::StartOfVerse
2401        );
2402        assert_eq!(
2403            DirectiveKind::from_name("start_of_bridge"),
2404            DirectiveKind::StartOfBridge
2405        );
2406        assert_eq!(
2407            DirectiveKind::from_name("start_of_tab"),
2408            DirectiveKind::StartOfTab
2409        );
2410    }
2411
2412    #[test]
2413    fn directive_kind_font_size_color_canonical_names() {
2414        assert_eq!(DirectiveKind::TextFont.canonical_name(), "textfont");
2415        assert_eq!(DirectiveKind::TextSize.canonical_name(), "textsize");
2416        assert_eq!(DirectiveKind::TextColour.canonical_name(), "textcolour");
2417        assert_eq!(DirectiveKind::ChordFont.canonical_name(), "chordfont");
2418        assert_eq!(DirectiveKind::ChordSize.canonical_name(), "chordsize");
2419        assert_eq!(DirectiveKind::ChordColour.canonical_name(), "chordcolour");
2420        assert_eq!(DirectiveKind::TabFont.canonical_name(), "tabfont");
2421        assert_eq!(DirectiveKind::TabSize.canonical_name(), "tabsize");
2422        assert_eq!(DirectiveKind::TabColour.canonical_name(), "tabcolour");
2423    }
2424
2425    #[test]
2426    fn directive_kind_font_size_color_category_checks() {
2427        let font_kinds = [
2428            DirectiveKind::TextFont,
2429            DirectiveKind::TextSize,
2430            DirectiveKind::TextColour,
2431            DirectiveKind::ChordFont,
2432            DirectiveKind::ChordSize,
2433            DirectiveKind::ChordColour,
2434            DirectiveKind::TabFont,
2435            DirectiveKind::TabSize,
2436            DirectiveKind::TabColour,
2437        ];
2438        for kind in &font_kinds {
2439            assert!(
2440                kind.is_font_size_color(),
2441                "{kind:?} should be font_size_color"
2442            );
2443            assert!(!kind.is_metadata(), "{kind:?} should not be metadata");
2444            assert!(!kind.is_comment(), "{kind:?} should not be comment");
2445            assert!(!kind.is_environment(), "{kind:?} should not be environment");
2446        }
2447    }
2448
2449    #[test]
2450    fn directive_font_alias_resolution() {
2451        let d = Directive::with_value("tf", "Times");
2452        assert_eq!(d.name, "textfont");
2453        assert_eq!(d.kind, DirectiveKind::TextFont);
2454        assert_eq!(d.value.as_deref(), Some("Times"));
2455
2456        let d = Directive::with_value("cc", "#FF0000");
2457        assert_eq!(d.name, "chordcolour");
2458        assert_eq!(d.kind, DirectiveKind::ChordColour);
2459
2460        let d = Directive::with_value("textcolor", "blue");
2461        assert_eq!(d.name, "textcolour");
2462        assert_eq!(d.kind, DirectiveKind::TextColour);
2463    }
2464
2465    // -- CommentStyle -------------------------------------------------------
2466
2467    #[test]
2468    fn comment_style_variants() {
2469        let normal = Line::Comment(CommentStyle::Normal, "text".to_string());
2470        let italic = Line::Comment(CommentStyle::Italic, "text".to_string());
2471        let boxed = Line::Comment(CommentStyle::Boxed, "text".to_string());
2472
2473        assert!(matches!(normal, Line::Comment(CommentStyle::Normal, _)));
2474        assert!(matches!(italic, Line::Comment(CommentStyle::Italic, _)));
2475        assert!(matches!(boxed, Line::Comment(CommentStyle::Boxed, _)));
2476    }
2477
2478    // -- Line enum ----------------------------------------------------------
2479
2480    #[test]
2481    fn line_enum_variants() {
2482        let lyrics = Line::Lyrics(LyricsLine::new());
2483        let directive = Line::Directive(Directive::name_only("soc"));
2484        let comment = Line::Comment(CommentStyle::Normal, "test".to_string());
2485        let empty = Line::Empty;
2486
2487        // Ensure they are all distinct variants via pattern matching
2488        assert!(matches!(lyrics, Line::Lyrics(_)));
2489        assert!(matches!(directive, Line::Directive(_)));
2490        assert!(matches!(comment, Line::Comment(..)));
2491        assert!(matches!(empty, Line::Empty));
2492    }
2493
2494    #[test]
2495    fn line_clone_and_eq() {
2496        let line = Line::Lyrics(LyricsLine {
2497            segments: vec![LyricsSegment::new(Some(Chord::new("C")), "hello")],
2498        });
2499        let cloned = line.clone();
2500        assert_eq!(line, cloned);
2501    }
2502
2503    // -- Directive selector -------------------------------------------------
2504
2505    #[test]
2506    fn directive_with_selector_constructor() {
2507        let d = Directive::with_selector("title", Some("My Song".to_string()), "piano");
2508        assert_eq!(d.name, "title");
2509        assert_eq!(d.value.as_deref(), Some("My Song"));
2510        assert_eq!(d.kind, DirectiveKind::Title);
2511        assert_eq!(d.selector.as_deref(), Some("piano"));
2512    }
2513
2514    #[test]
2515    fn directive_with_value_has_no_selector() {
2516        let d = Directive::with_value("title", "My Song");
2517        assert_eq!(d.selector, None);
2518    }
2519
2520    #[test]
2521    fn directive_name_only_has_no_selector() {
2522        let d = Directive::name_only("start_of_chorus");
2523        assert_eq!(d.selector, None);
2524    }
2525
2526    #[test]
2527    fn resolve_with_selector_plain_directive() {
2528        let (kind, sel) = DirectiveKind::resolve_with_selector("title");
2529        assert_eq!(kind, DirectiveKind::Title);
2530        assert_eq!(sel, None);
2531    }
2532
2533    #[test]
2534    fn resolve_with_selector_with_suffix() {
2535        let (kind, sel) = DirectiveKind::resolve_with_selector("title-piano");
2536        assert_eq!(kind, DirectiveKind::Title);
2537        assert_eq!(sel.as_deref(), Some("piano"));
2538    }
2539
2540    #[test]
2541    fn resolve_with_selector_comment() {
2542        let (kind, sel) = DirectiveKind::resolve_with_selector("comment-bass");
2543        assert_eq!(kind, DirectiveKind::Comment);
2544        assert_eq!(sel.as_deref(), Some("bass"));
2545    }
2546
2547    #[test]
2548    fn resolve_with_selector_comment_italic() {
2549        let (kind, sel) = DirectiveKind::resolve_with_selector("comment_italic-guitar");
2550        assert_eq!(kind, DirectiveKind::CommentItalic);
2551        assert_eq!(sel.as_deref(), Some("guitar"));
2552    }
2553
2554    #[test]
2555    fn resolve_with_selector_environment() {
2556        let (kind, sel) = DirectiveKind::resolve_with_selector("start_of_chorus-piano");
2557        assert_eq!(kind, DirectiveKind::StartOfChorus);
2558        assert_eq!(sel.as_deref(), Some("piano"));
2559    }
2560
2561    #[test]
2562    fn resolve_with_selector_end_of_tab() {
2563        let (kind, sel) = DirectiveKind::resolve_with_selector("end_of_tab-guitar");
2564        assert_eq!(kind, DirectiveKind::EndOfTab);
2565        assert_eq!(sel.as_deref(), Some("guitar"));
2566    }
2567
2568    #[test]
2569    fn resolve_with_selector_custom_section_no_selector() {
2570        let (kind, sel) = DirectiveKind::resolve_with_selector("start_of_intro");
2571        assert_eq!(kind, DirectiveKind::StartOfSection("intro".to_string()));
2572        assert_eq!(sel, None);
2573    }
2574
2575    #[test]
2576    fn resolve_with_selector_custom_section_with_selector() {
2577        // start_of_intro-piano: "start_of_intro" resolves to StartOfSection("intro"),
2578        // which is a custom section. The last hyphen splits into "start_of_intro" + "piano".
2579        // "start_of_intro" is NOT Unknown (it's StartOfSection), so we split successfully.
2580        let (kind, sel) = DirectiveKind::resolve_with_selector("start_of_intro-piano");
2581        assert_eq!(kind, DirectiveKind::StartOfSection("intro".to_string()));
2582        assert_eq!(sel.as_deref(), Some("piano"));
2583    }
2584
2585    #[test]
2586    fn resolve_with_selector_unknown_no_hyphen() {
2587        let (kind, sel) = DirectiveKind::resolve_with_selector("mything");
2588        assert_eq!(kind, DirectiveKind::Unknown("mything".to_string()));
2589        assert_eq!(sel, None);
2590    }
2591
2592    #[test]
2593    fn resolve_with_selector_unknown_with_hyphen() {
2594        // "my-thing" -> prefix "my" is Unknown, so no selector is detected.
2595        let (kind, sel) = DirectiveKind::resolve_with_selector("my-thing");
2596        assert_eq!(kind, DirectiveKind::Unknown("my-thing".to_string()));
2597        assert_eq!(sel, None);
2598    }
2599
2600    #[test]
2601    fn resolve_with_selector_case_insensitive() {
2602        let (kind, sel) = DirectiveKind::resolve_with_selector("Title-Piano");
2603        assert_eq!(kind, DirectiveKind::Title);
2604        assert_eq!(sel.as_deref(), Some("piano"));
2605    }
2606
2607    #[test]
2608    fn resolve_with_selector_short_alias_with_suffix() {
2609        let (kind, sel) = DirectiveKind::resolve_with_selector("t-guitar");
2610        assert_eq!(kind, DirectiveKind::Title);
2611        assert_eq!(sel.as_deref(), Some("guitar"));
2612    }
2613
2614    // -- Integration: full song construction --------------------------------
2615
2616    #[test]
2617    fn full_song_construction() {
2618        let mut song = Song::new();
2619        song.metadata.title = Some("Amazing Grace".to_string());
2620        song.metadata.key = Some("G".to_string());
2621        song.metadata.artists.push("John Newton".to_string());
2622
2623        // {start_of_verse}
2624        song.lines
2625            .push(Line::Directive(Directive::name_only("start_of_verse")));
2626
2627        // [G]Amazing [G7]grace, how [C]sweet the [G]sound
2628        song.lines.push(Line::Lyrics(LyricsLine {
2629            segments: vec![
2630                LyricsSegment::new(Some(Chord::new("G")), "Amazing "),
2631                LyricsSegment::new(Some(Chord::new("G7")), "grace, how "),
2632                LyricsSegment::new(Some(Chord::new("C")), "sweet the "),
2633                LyricsSegment::new(Some(Chord::new("G")), "sound"),
2634            ],
2635        }));
2636
2637        // {end_of_verse}
2638        song.lines
2639            .push(Line::Directive(Directive::name_only("end_of_verse")));
2640
2641        assert_eq!(song.lines.len(), 3);
2642        if let Line::Lyrics(ref lyrics) = song.lines[1] {
2643            assert_eq!(lyrics.text(), "Amazing grace, how sweet the sound");
2644            assert!(lyrics.has_chords());
2645            assert_eq!(lyrics.segments.len(), 4);
2646        } else {
2647            panic!("Expected Line::Lyrics");
2648        }
2649    }
2650
2651    #[test]
2652    fn directive_kind_grid_from_name() {
2653        assert_eq!(
2654            DirectiveKind::from_name("start_of_grid"),
2655            DirectiveKind::StartOfGrid
2656        );
2657        assert_eq!(
2658            DirectiveKind::from_name("end_of_grid"),
2659            DirectiveKind::EndOfGrid
2660        );
2661        assert_eq!(DirectiveKind::from_name("sog"), DirectiveKind::StartOfGrid);
2662        assert_eq!(DirectiveKind::from_name("eog"), DirectiveKind::EndOfGrid);
2663    }
2664
2665    #[test]
2666    fn directive_kind_grid_canonical_name() {
2667        assert_eq!(DirectiveKind::StartOfGrid.canonical_name(), "start_of_grid");
2668        assert_eq!(DirectiveKind::EndOfGrid.canonical_name(), "end_of_grid");
2669    }
2670
2671    #[test]
2672    fn directive_kind_grid_is_section() {
2673        assert!(DirectiveKind::StartOfGrid.is_section_start());
2674        assert!(!DirectiveKind::StartOfGrid.is_section_end());
2675        assert!(DirectiveKind::EndOfGrid.is_section_end());
2676        assert!(!DirectiveKind::EndOfGrid.is_section_start());
2677        assert!(DirectiveKind::StartOfGrid.is_environment());
2678        assert!(DirectiveKind::EndOfGrid.is_environment());
2679    }
2680
2681    #[test]
2682    fn directive_grid_section_name() {
2683        let sog = Directive::name_only("sog");
2684        assert!(sog.is_section_start());
2685        assert_eq!(sog.section_name(), Some("grid"));
2686
2687        let eog = Directive::name_only("eog");
2688        assert!(eog.is_section_end());
2689        assert_eq!(eog.section_name(), Some("grid"));
2690    }
2691
2692    // -- Font, size, and color directives -----------------------------------
2693
2694    #[test]
2695    fn directive_kind_from_name_title_font_size_color() {
2696        assert_eq!(
2697            DirectiveKind::from_name("titlefont"),
2698            DirectiveKind::TitleFont
2699        );
2700        assert_eq!(
2701            DirectiveKind::from_name("TITLEFONT"),
2702            DirectiveKind::TitleFont
2703        );
2704        assert_eq!(
2705            DirectiveKind::from_name("titlesize"),
2706            DirectiveKind::TitleSize
2707        );
2708        assert_eq!(
2709            DirectiveKind::from_name("titlecolour"),
2710            DirectiveKind::TitleColour
2711        );
2712        assert_eq!(
2713            DirectiveKind::from_name("titlecolor"),
2714            DirectiveKind::TitleColour
2715        );
2716    }
2717
2718    #[test]
2719    fn directive_kind_from_name_chorus_font_size_color() {
2720        assert_eq!(
2721            DirectiveKind::from_name("chorusfont"),
2722            DirectiveKind::ChorusFont
2723        );
2724        assert_eq!(
2725            DirectiveKind::from_name("chorussize"),
2726            DirectiveKind::ChorusSize
2727        );
2728        assert_eq!(
2729            DirectiveKind::from_name("choruscolour"),
2730            DirectiveKind::ChorusColour
2731        );
2732        assert_eq!(
2733            DirectiveKind::from_name("choruscolor"),
2734            DirectiveKind::ChorusColour
2735        );
2736    }
2737
2738    #[test]
2739    fn directive_kind_from_name_footer_header_label() {
2740        assert_eq!(
2741            DirectiveKind::from_name("footerfont"),
2742            DirectiveKind::FooterFont
2743        );
2744        assert_eq!(
2745            DirectiveKind::from_name("footersize"),
2746            DirectiveKind::FooterSize
2747        );
2748        assert_eq!(
2749            DirectiveKind::from_name("footercolour"),
2750            DirectiveKind::FooterColour
2751        );
2752        assert_eq!(
2753            DirectiveKind::from_name("footercolor"),
2754            DirectiveKind::FooterColour
2755        );
2756        assert_eq!(
2757            DirectiveKind::from_name("headerfont"),
2758            DirectiveKind::HeaderFont
2759        );
2760        assert_eq!(
2761            DirectiveKind::from_name("headersize"),
2762            DirectiveKind::HeaderSize
2763        );
2764        assert_eq!(
2765            DirectiveKind::from_name("headercolour"),
2766            DirectiveKind::HeaderColour
2767        );
2768        assert_eq!(
2769            DirectiveKind::from_name("headercolor"),
2770            DirectiveKind::HeaderColour
2771        );
2772        assert_eq!(
2773            DirectiveKind::from_name("labelfont"),
2774            DirectiveKind::LabelFont
2775        );
2776        assert_eq!(
2777            DirectiveKind::from_name("labelsize"),
2778            DirectiveKind::LabelSize
2779        );
2780        assert_eq!(
2781            DirectiveKind::from_name("labelcolour"),
2782            DirectiveKind::LabelColour
2783        );
2784        assert_eq!(
2785            DirectiveKind::from_name("labelcolor"),
2786            DirectiveKind::LabelColour
2787        );
2788    }
2789
2790    #[test]
2791    fn directive_kind_from_name_grid_toc() {
2792        assert_eq!(
2793            DirectiveKind::from_name("gridfont"),
2794            DirectiveKind::GridFont
2795        );
2796        assert_eq!(
2797            DirectiveKind::from_name("gridsize"),
2798            DirectiveKind::GridSize
2799        );
2800        assert_eq!(
2801            DirectiveKind::from_name("gridcolour"),
2802            DirectiveKind::GridColour
2803        );
2804        assert_eq!(
2805            DirectiveKind::from_name("gridcolor"),
2806            DirectiveKind::GridColour
2807        );
2808        assert_eq!(DirectiveKind::from_name("tocfont"), DirectiveKind::TocFont);
2809        assert_eq!(DirectiveKind::from_name("tocsize"), DirectiveKind::TocSize);
2810        assert_eq!(
2811            DirectiveKind::from_name("toccolour"),
2812            DirectiveKind::TocColour
2813        );
2814        assert_eq!(
2815            DirectiveKind::from_name("toccolor"),
2816            DirectiveKind::TocColour
2817        );
2818    }
2819
2820    #[test]
2821    fn directive_kind_extra_font_size_color_canonical_names() {
2822        assert_eq!(DirectiveKind::TitleFont.canonical_name(), "titlefont");
2823        assert_eq!(DirectiveKind::TitleSize.canonical_name(), "titlesize");
2824        assert_eq!(DirectiveKind::TitleColour.canonical_name(), "titlecolour");
2825        assert_eq!(DirectiveKind::ChorusFont.canonical_name(), "chorusfont");
2826        assert_eq!(DirectiveKind::ChorusSize.canonical_name(), "chorussize");
2827        assert_eq!(DirectiveKind::ChorusColour.canonical_name(), "choruscolour");
2828        assert_eq!(DirectiveKind::FooterFont.canonical_name(), "footerfont");
2829        assert_eq!(DirectiveKind::FooterSize.canonical_name(), "footersize");
2830        assert_eq!(DirectiveKind::FooterColour.canonical_name(), "footercolour");
2831        assert_eq!(DirectiveKind::HeaderFont.canonical_name(), "headerfont");
2832        assert_eq!(DirectiveKind::HeaderSize.canonical_name(), "headersize");
2833        assert_eq!(DirectiveKind::HeaderColour.canonical_name(), "headercolour");
2834        assert_eq!(DirectiveKind::LabelFont.canonical_name(), "labelfont");
2835        assert_eq!(DirectiveKind::LabelSize.canonical_name(), "labelsize");
2836        assert_eq!(DirectiveKind::LabelColour.canonical_name(), "labelcolour");
2837        assert_eq!(DirectiveKind::GridFont.canonical_name(), "gridfont");
2838        assert_eq!(DirectiveKind::GridSize.canonical_name(), "gridsize");
2839        assert_eq!(DirectiveKind::GridColour.canonical_name(), "gridcolour");
2840        assert_eq!(DirectiveKind::TocFont.canonical_name(), "tocfont");
2841        assert_eq!(DirectiveKind::TocSize.canonical_name(), "tocsize");
2842        assert_eq!(DirectiveKind::TocColour.canonical_name(), "toccolour");
2843    }
2844
2845    #[test]
2846    fn directive_kind_extra_font_size_color_category_checks() {
2847        let font_kinds = [
2848            DirectiveKind::TitleFont,
2849            DirectiveKind::TitleSize,
2850            DirectiveKind::TitleColour,
2851            DirectiveKind::ChorusFont,
2852            DirectiveKind::ChorusSize,
2853            DirectiveKind::ChorusColour,
2854            DirectiveKind::FooterFont,
2855            DirectiveKind::FooterSize,
2856            DirectiveKind::FooterColour,
2857            DirectiveKind::HeaderFont,
2858            DirectiveKind::HeaderSize,
2859            DirectiveKind::HeaderColour,
2860            DirectiveKind::LabelFont,
2861            DirectiveKind::LabelSize,
2862            DirectiveKind::LabelColour,
2863            DirectiveKind::GridFont,
2864            DirectiveKind::GridSize,
2865            DirectiveKind::GridColour,
2866            DirectiveKind::TocFont,
2867            DirectiveKind::TocSize,
2868            DirectiveKind::TocColour,
2869        ];
2870        for kind in &font_kinds {
2871            assert!(
2872                kind.is_font_size_color(),
2873                "{kind:?} should be font_size_color"
2874            );
2875            assert!(!kind.is_metadata(), "{kind:?} should not be metadata");
2876            assert!(!kind.is_comment(), "{kind:?} should not be comment");
2877            assert!(!kind.is_environment(), "{kind:?} should not be environment");
2878        }
2879    }
2880
2881    #[test]
2882    fn directive_font_size_color_alias_resolution() {
2883        let d = Directive::with_value("titlefont", "Times");
2884        assert_eq!(d.name, "titlefont");
2885        assert_eq!(d.kind, DirectiveKind::TitleFont);
2886        assert_eq!(d.value.as_deref(), Some("Times"));
2887
2888        let d = Directive::with_value("choruscolor", "#FF0000");
2889        assert_eq!(d.name, "choruscolour");
2890        assert_eq!(d.kind, DirectiveKind::ChorusColour);
2891
2892        let d = Directive::with_value("titlecolor", "blue");
2893        assert_eq!(d.name, "titlecolour");
2894        assert_eq!(d.kind, DirectiveKind::TitleColour);
2895
2896        let d = Directive::with_value("gridsize", "12");
2897        assert_eq!(d.name, "gridsize");
2898        assert_eq!(d.kind, DirectiveKind::GridSize);
2899        assert_eq!(d.value.as_deref(), Some("12"));
2900    }
2901}
2902
2903#[cfg(test)]
2904mod delegate_tests {
2905    use super::*;
2906
2907    #[test]
2908    fn directive_kind_from_name_delegate_abc() {
2909        assert_eq!(
2910            DirectiveKind::from_name("start_of_abc"),
2911            DirectiveKind::StartOfAbc
2912        );
2913        assert_eq!(
2914            DirectiveKind::from_name("end_of_abc"),
2915            DirectiveKind::EndOfAbc
2916        );
2917    }
2918
2919    #[test]
2920    fn directive_kind_from_name_delegate_ly() {
2921        assert_eq!(
2922            DirectiveKind::from_name("start_of_ly"),
2923            DirectiveKind::StartOfLy
2924        );
2925        assert_eq!(
2926            DirectiveKind::from_name("end_of_ly"),
2927            DirectiveKind::EndOfLy
2928        );
2929    }
2930
2931    #[test]
2932    fn directive_kind_from_name_delegate_svg() {
2933        assert_eq!(
2934            DirectiveKind::from_name("start_of_svg"),
2935            DirectiveKind::StartOfSvg
2936        );
2937        assert_eq!(
2938            DirectiveKind::from_name("end_of_svg"),
2939            DirectiveKind::EndOfSvg
2940        );
2941    }
2942
2943    #[test]
2944    fn directive_kind_from_name_delegate_textblock() {
2945        assert_eq!(
2946            DirectiveKind::from_name("start_of_textblock"),
2947            DirectiveKind::StartOfTextblock
2948        );
2949        assert_eq!(
2950            DirectiveKind::from_name("end_of_textblock"),
2951            DirectiveKind::EndOfTextblock
2952        );
2953    }
2954
2955    #[test]
2956    fn delegate_environments_case_insensitive() {
2957        assert_eq!(
2958            DirectiveKind::from_name("START_OF_ABC"),
2959            DirectiveKind::StartOfAbc
2960        );
2961        assert_eq!(
2962            DirectiveKind::from_name("End_Of_Ly"),
2963            DirectiveKind::EndOfLy
2964        );
2965        assert_eq!(
2966            DirectiveKind::from_name("START_OF_SVG"),
2967            DirectiveKind::StartOfSvg
2968        );
2969        assert_eq!(
2970            DirectiveKind::from_name("End_Of_Textblock"),
2971            DirectiveKind::EndOfTextblock
2972        );
2973    }
2974
2975    #[test]
2976    fn delegate_environments_are_section_start() {
2977        assert!(DirectiveKind::StartOfAbc.is_section_start());
2978        assert!(DirectiveKind::StartOfLy.is_section_start());
2979        assert!(DirectiveKind::StartOfSvg.is_section_start());
2980        assert!(DirectiveKind::StartOfTextblock.is_section_start());
2981    }
2982
2983    #[test]
2984    fn delegate_environments_are_section_end() {
2985        assert!(DirectiveKind::EndOfAbc.is_section_end());
2986        assert!(DirectiveKind::EndOfLy.is_section_end());
2987        assert!(DirectiveKind::EndOfSvg.is_section_end());
2988        assert!(DirectiveKind::EndOfTextblock.is_section_end());
2989    }
2990
2991    #[test]
2992    fn delegate_environments_are_environments() {
2993        assert!(DirectiveKind::StartOfAbc.is_environment());
2994        assert!(DirectiveKind::EndOfAbc.is_environment());
2995        assert!(DirectiveKind::StartOfLy.is_environment());
2996        assert!(DirectiveKind::EndOfLy.is_environment());
2997        assert!(DirectiveKind::StartOfSvg.is_environment());
2998        assert!(DirectiveKind::EndOfSvg.is_environment());
2999        assert!(DirectiveKind::StartOfTextblock.is_environment());
3000        assert!(DirectiveKind::EndOfTextblock.is_environment());
3001    }
3002
3003    #[test]
3004    fn delegate_environments_canonical_names() {
3005        assert_eq!(DirectiveKind::StartOfAbc.canonical_name(), "start_of_abc");
3006        assert_eq!(DirectiveKind::EndOfAbc.canonical_name(), "end_of_abc");
3007        assert_eq!(DirectiveKind::StartOfLy.canonical_name(), "start_of_ly");
3008        assert_eq!(DirectiveKind::EndOfLy.canonical_name(), "end_of_ly");
3009        assert_eq!(DirectiveKind::StartOfSvg.canonical_name(), "start_of_svg");
3010        assert_eq!(DirectiveKind::EndOfSvg.canonical_name(), "end_of_svg");
3011        assert_eq!(
3012            DirectiveKind::StartOfTextblock.canonical_name(),
3013            "start_of_textblock"
3014        );
3015        assert_eq!(
3016            DirectiveKind::EndOfTextblock.canonical_name(),
3017            "end_of_textblock"
3018        );
3019    }
3020
3021    #[test]
3022    fn delegate_not_metadata() {
3023        assert!(!DirectiveKind::StartOfAbc.is_metadata());
3024        assert!(!DirectiveKind::EndOfLy.is_metadata());
3025        assert!(!DirectiveKind::StartOfSvg.is_metadata());
3026        assert!(!DirectiveKind::EndOfTextblock.is_metadata());
3027    }
3028
3029    #[test]
3030    fn delegate_not_comment() {
3031        assert!(!DirectiveKind::StartOfAbc.is_comment());
3032        assert!(!DirectiveKind::EndOfLy.is_comment());
3033    }
3034
3035    #[test]
3036    fn delegate_directive_section_name() {
3037        let d = Directive::name_only("start_of_abc");
3038        assert_eq!(d.section_name(), Some("abc"));
3039
3040        let d = Directive::name_only("end_of_ly");
3041        assert_eq!(d.section_name(), Some("ly"));
3042
3043        let d = Directive::name_only("start_of_svg");
3044        assert_eq!(d.section_name(), Some("svg"));
3045
3046        let d = Directive::name_only("end_of_textblock");
3047        assert_eq!(d.section_name(), Some("textblock"));
3048    }
3049
3050    #[test]
3051    fn delegate_directive_with_label() {
3052        let d = Directive::with_value("start_of_abc", "Melody");
3053        assert_eq!(d.name, "start_of_abc");
3054        assert_eq!(d.value.as_deref(), Some("Melody"));
3055        assert_eq!(d.kind, DirectiveKind::StartOfAbc);
3056    }
3057
3058    #[test]
3059    fn delegate_sections_not_custom() {
3060        // Delegate section names must NOT produce StartOfSection variants
3061        assert!(!matches!(
3062            DirectiveKind::from_name("start_of_abc"),
3063            DirectiveKind::StartOfSection(_)
3064        ));
3065        assert!(!matches!(
3066            DirectiveKind::from_name("start_of_ly"),
3067            DirectiveKind::StartOfSection(_)
3068        ));
3069        assert!(!matches!(
3070            DirectiveKind::from_name("start_of_svg"),
3071            DirectiveKind::StartOfSection(_)
3072        ));
3073        assert!(!matches!(
3074            DirectiveKind::from_name("start_of_textblock"),
3075            DirectiveKind::StartOfSection(_)
3076        ));
3077    }
3078}
3079
3080#[cfg(test)]
3081mod chord_definition_tests {
3082    use super::*;
3083
3084    #[test]
3085    fn test_parse_keyboard_definition() {
3086        let def = ChordDefinition::parse_value("Am keys 0 3 7");
3087        assert_eq!(def.name, "Am");
3088        assert_eq!(def.keys, Some(vec![0, 3, 7]));
3089        assert!(def.copy.is_none());
3090    }
3091
3092    #[test]
3093    fn test_parse_keyboard_empty_keys() {
3094        // {define: Am keys} with no values produces None (no valid keys).
3095        let def = ChordDefinition::parse_value("Am keys");
3096        assert_eq!(def.name, "Am");
3097        assert_eq!(def.keys, None);
3098    }
3099
3100    #[test]
3101    fn test_parse_keyboard_keys_midi_range() {
3102        // Values within MIDI range (0-127) are accepted.
3103        let def = ChordDefinition::parse_value("Am keys 0 60 127");
3104        assert_eq!(def.keys, Some(vec![0, 60, 127]));
3105    }
3106
3107    #[test]
3108    fn test_parse_keyboard_keys_out_of_range_dropped() {
3109        // Values outside 0-127 are silently dropped.
3110        let def = ChordDefinition::parse_value("Am keys -1 0 128 60");
3111        assert_eq!(def.keys, Some(vec![0, 60]));
3112    }
3113
3114    #[test]
3115    fn test_parse_keyboard_keys_all_invalid() {
3116        // All values invalid -> None (not Some(vec![])).
3117        let def = ChordDefinition::parse_value("Am keys abc def");
3118        assert_eq!(def.keys, None);
3119    }
3120
3121    #[test]
3122    fn test_parse_keyboard_keys_non_numeric_dropped() {
3123        // Non-numeric tokens are silently dropped.
3124        let def = ChordDefinition::parse_value("Am keys 0 abc 7 xyz 12");
3125        assert_eq!(def.keys, Some(vec![0, 7, 12]));
3126    }
3127
3128    #[test]
3129    fn test_parse_copy() {
3130        let def = ChordDefinition::parse_value("Am copy Amin");
3131        assert_eq!(def.name, "Am");
3132        assert_eq!(def.copy, Some("Amin".to_string()));
3133        assert!(def.keys.is_none());
3134    }
3135
3136    #[test]
3137    fn test_parse_copyall() {
3138        let def = ChordDefinition::parse_value("Am copyall Amin");
3139        assert_eq!(def.name, "Am");
3140        assert_eq!(def.copyall, Some("Amin".to_string()));
3141    }
3142
3143    #[test]
3144    fn test_parse_copy_first_token_only() {
3145        // Only the first token after "copy" is the source name (#607).
3146        let def = ChordDefinition::parse_value("Am copy Amin extra stuff");
3147        assert_eq!(def.copy, Some("Amin".to_string()));
3148    }
3149
3150    #[test]
3151    fn test_parse_copyall_first_token_only() {
3152        let def = ChordDefinition::parse_value("Am copyall Amin extra stuff");
3153        assert_eq!(def.copyall, Some("Amin".to_string()));
3154    }
3155
3156    #[test]
3157    fn test_parse_copy_with_display() {
3158        // display= should be extracted even on copy definitions (#601).
3159        let def = ChordDefinition::parse_value("Am copy Bm display=\"Alt\"");
3160        assert_eq!(def.copy, Some("Bm".to_string()));
3161        assert_eq!(def.display, Some("Alt".to_string()));
3162    }
3163
3164    #[test]
3165    fn test_parse_copyall_with_display() {
3166        let def = ChordDefinition::parse_value("Am copyall Bm display=\"Alt\"");
3167        assert_eq!(def.copyall, Some("Bm".to_string()));
3168        assert_eq!(def.display, Some("Alt".to_string()));
3169    }
3170
3171    #[test]
3172    fn test_parse_copy_with_format() {
3173        let def = ChordDefinition::parse_value("Am copy Bm format=\"%{root}m\"");
3174        assert_eq!(def.copy, Some("Bm".to_string()));
3175        assert_eq!(def.format, Some("%{root}m".to_string()));
3176    }
3177
3178    #[test]
3179    fn test_parse_keys_with_display() {
3180        // display= should be extracted even on keys definitions (#601).
3181        let def = ChordDefinition::parse_value("Am keys 0 3 7 display=\"A minor\"");
3182        assert_eq!(def.keys, Some(vec![0, 3, 7]));
3183        assert_eq!(def.display, Some("A minor".to_string()));
3184    }
3185
3186    #[test]
3187    fn test_parse_keys_with_format() {
3188        let def = ChordDefinition::parse_value("Am keys 0 3 7 format=\"%{root}m\"");
3189        assert_eq!(def.keys, Some(vec![0, 3, 7]));
3190        assert_eq!(def.format, Some("%{root}m".to_string()));
3191    }
3192
3193    #[test]
3194    fn test_parse_fretted_definition() {
3195        let def = ChordDefinition::parse_value("Am base-fret 1 frets x 0 2 2 1 0");
3196        assert_eq!(def.name, "Am");
3197        assert!(def.raw.is_some());
3198        assert!(def.raw.unwrap().contains("base-fret"));
3199    }
3200
3201    #[test]
3202    fn test_parse_name_only() {
3203        let def = ChordDefinition::parse_value("Am");
3204        assert_eq!(def.name, "Am");
3205        assert!(def.keys.is_none());
3206        assert!(def.copy.is_none());
3207        assert!(def.raw.is_none());
3208    }
3209
3210    #[test]
3211    fn test_parse_display_attribute() {
3212        let def =
3213            ChordDefinition::parse_value("Am base-fret 1 frets x 0 2 2 1 0 display=\"A minor\"");
3214        assert_eq!(def.name, "Am");
3215        assert_eq!(def.display, Some("A minor".to_string()));
3216        // display= should be stripped from raw
3217        let raw = def.raw.unwrap();
3218        assert!(
3219            !raw.contains("display="),
3220            "display= should be stripped from raw, got: {raw}"
3221        );
3222        assert!(raw.contains("base-fret"));
3223    }
3224
3225    #[test]
3226    fn test_parse_display_attribute_at_start() {
3227        let def =
3228            ChordDefinition::parse_value("Am display=\"A minor\" base-fret 1 frets x 0 2 2 1 0");
3229        assert_eq!(def.display, Some("A minor".to_string()));
3230        let raw = def.raw.unwrap();
3231        assert!(!raw.contains("display="));
3232        assert!(raw.contains("base-fret"));
3233    }
3234
3235    #[test]
3236    fn test_parse_display_attribute_middle() {
3237        let def =
3238            ChordDefinition::parse_value("Am base-fret 1 display=\"A minor\" frets x 0 2 2 1 0");
3239        assert_eq!(def.display, Some("A minor".to_string()));
3240        let raw = def.raw.unwrap();
3241        assert!(!raw.contains("display="));
3242        assert!(raw.contains("base-fret"));
3243        assert!(raw.contains("frets"));
3244    }
3245
3246    #[test]
3247    fn test_parse_display_unquoted() {
3248        let def = ChordDefinition::parse_value("Am base-fret 1 frets x 0 2 2 1 0 display=Aminor");
3249        assert_eq!(def.display, Some("Aminor".to_string()));
3250        let raw = def.raw.unwrap();
3251        assert!(!raw.contains("display="));
3252    }
3253
3254    #[test]
3255    fn test_parse_display_no_false_match() {
3256        // "undisplay=" should not match as "display="
3257        let def = ChordDefinition::parse_value("Am undisplay=foo base-fret 1 frets x 0 2 2 1 0");
3258        assert_eq!(def.display, None);
3259        let raw = def.raw.unwrap();
3260        assert!(raw.contains("undisplay=foo"));
3261    }
3262
3263    #[test]
3264    fn test_parse_display_only() {
3265        // display-only definition (no fret data)
3266        let def = ChordDefinition::parse_value("Am display=\"A minor\"");
3267        assert_eq!(def.display, Some("A minor".to_string()));
3268        assert!(def.raw.is_none());
3269    }
3270
3271    #[test]
3272    fn test_parse_format_attribute() {
3273        let def = ChordDefinition::parse_value(
3274            "Am base-fret 1 frets x 0 2 2 1 0 format=\"%{root}%{quality}\"",
3275        );
3276        assert_eq!(def.format, Some("%{root}%{quality}".to_string()));
3277        let raw = def.raw.unwrap();
3278        assert!(!raw.contains("format="));
3279        assert!(raw.contains("base-fret"));
3280    }
3281
3282    #[test]
3283    fn test_parse_both_display_and_format() {
3284        let def = ChordDefinition::parse_value(
3285            "Am display=\"A minor\" format=\"%{root}%{quality}\" base-fret 1 frets x 0 2 2 1 0",
3286        );
3287        assert_eq!(def.display, Some("A minor".to_string()));
3288        assert_eq!(def.format, Some("%{root}%{quality}".to_string()));
3289        let raw = def.raw.unwrap();
3290        assert!(!raw.contains("display="));
3291        assert!(!raw.contains("format="));
3292    }
3293
3294    #[test]
3295    fn test_parse_format_only() {
3296        let def = ChordDefinition::parse_value("Am format=\"%{root}-%{quality}\"");
3297        assert_eq!(def.format, Some("%{root}-%{quality}".to_string()));
3298        assert!(def.raw.is_none());
3299    }
3300
3301    #[test]
3302    fn test_parse_format_unclosed_quote_no_panic() {
3303        // Malformed input: missing closing quote must not panic.
3304        let def = ChordDefinition::parse_value("Am display=\"unclosed");
3305        assert_eq!(def.display, Some("unclosed".to_string()));
3306        assert!(def.raw.is_none());
3307    }
3308
3309    #[test]
3310    fn test_parse_format_unclosed_quote_format_attr() {
3311        let def = ChordDefinition::parse_value("Am format=\"%{root}%{quality}");
3312        assert_eq!(def.format, Some("%{root}%{quality}".to_string()));
3313    }
3314
3315    #[test]
3316    fn test_parse_keyboard_negative_keys_dropped() {
3317        // Negative values are outside MIDI range (0-127) and are dropped.
3318        let def = ChordDefinition::parse_value("Cm keys -1 0 3 7");
3319        assert_eq!(def.keys, Some(vec![0, 3, 7]));
3320    }
3321
3322    // --- Tab delimiter for copy/copyall (#649) ---
3323
3324    #[test]
3325    fn test_parse_copy_tab_delimiter() {
3326        let def = ChordDefinition::parse_value("Am copy\tAmin");
3327        assert_eq!(def.copy, Some("Amin".to_string()));
3328    }
3329
3330    #[test]
3331    fn test_parse_copyall_tab_delimiter() {
3332        let def = ChordDefinition::parse_value("Am copyall\tAmin");
3333        assert_eq!(def.copyall, Some("Amin".to_string()));
3334    }
3335
3336    #[test]
3337    fn test_parse_copy_multiple_spaces() {
3338        let def = ChordDefinition::parse_value("Am copy   Amin");
3339        assert_eq!(def.copy, Some("Amin".to_string()));
3340    }
3341
3342    #[test]
3343    fn test_parse_copyall_multiple_spaces() {
3344        let def = ChordDefinition::parse_value("Am copyall   Amin");
3345        assert_eq!(def.copyall, Some("Amin".to_string()));
3346    }
3347
3348    #[test]
3349    fn test_parse_copy_mixed_whitespace() {
3350        let def = ChordDefinition::parse_value("Am copy \t Amin");
3351        assert_eq!(def.copy, Some("Amin".to_string()));
3352    }
3353
3354    #[test]
3355    fn test_parse_copyall_mixed_whitespace() {
3356        let def = ChordDefinition::parse_value("Am copyall \t Amin");
3357        assert_eq!(def.copyall, Some("Amin".to_string()));
3358    }
3359
3360    // --- extract_attribute empty value (#650) ---
3361
3362    #[test]
3363    fn test_parse_trailing_display_equals_no_value() {
3364        // "display=" with no value should return Some("") and remove the
3365        // token from the string so it does not leak into raw fret data.
3366        let def = ChordDefinition::parse_value("Am base-fret 1 frets x 0 2 2 1 0 display=");
3367        assert_eq!(def.display, Some(String::new()));
3368        // display= must not appear in raw — only the fret portion remains.
3369        assert_eq!(def.raw, Some("base-fret 1 frets x 0 2 2 1 0".to_string()));
3370    }
3371
3372    // --- extract_attribute does not consume next attribute (#682) ---
3373
3374    #[test]
3375    fn test_unquoted_empty_display_with_format() {
3376        // "display=" (unquoted, empty) followed by format= should NOT
3377        // consume "format=..." as the display value.
3378        let def = ChordDefinition::parse_value("Am display= format=\"test\"");
3379        assert_eq!(def.display, Some(String::new()));
3380        assert_eq!(def.format, Some("test".to_string()));
3381    }
3382
3383    #[test]
3384    fn test_unquoted_empty_format_with_display() {
3385        // "format=" (unquoted, empty) followed by display= should NOT
3386        // consume "display=..." as the format value.
3387        let def = ChordDefinition::parse_value("Am format= display=\"A minor\"");
3388        assert_eq!(def.format, Some(String::new()));
3389        assert_eq!(def.display, Some("A minor".to_string()));
3390    }
3391
3392    // --- Unquoted value with embedded '=' (#688) ---
3393
3394    #[test]
3395    fn test_unquoted_value_with_equals_treated_as_empty() {
3396        // An unquoted value containing '=' is treated as a separate attribute,
3397        // so the current attribute becomes empty-valued. Users should quote
3398        // values that contain '='.
3399        let def = ChordDefinition::parse_value("Am display=val=ue");
3400        assert_eq!(
3401            def.display,
3402            Some(String::new()),
3403            "unquoted value with '=' should be treated as empty"
3404        );
3405    }
3406
3407    #[test]
3408    fn test_quoted_value_with_equals_preserved() {
3409        // Quoted values may contain '=' without issue.
3410        let def = ChordDefinition::parse_value("Am display=\"val=ue\"");
3411        assert_eq!(def.display, Some("val=ue".to_string()));
3412    }
3413
3414    // --- Forward-reference {define} (#657) ---
3415
3416    #[test]
3417    fn test_define_after_usage_still_applies() {
3418        // {define} appears after lyrics that use the chord.
3419        // apply_define_displays uses a two-pass approach and should
3420        // still apply the display override.
3421        let mut song = Song::new();
3422        let mut lyrics = LyricsLine::new();
3423        lyrics
3424            .segments
3425            .push(LyricsSegment::new(Some(Chord::new("Am")), "word "));
3426        song.lines.push(Line::Lyrics(lyrics));
3427        song.lines.push(Line::Directive(Directive::with_value(
3428            "define",
3429            "Am display=\"A minor\"",
3430        )));
3431        song.apply_define_displays();
3432
3433        if let Line::Lyrics(ref lyrics) = song.lines[0] {
3434            assert_eq!(
3435                lyrics.segments[0].chord.as_ref().unwrap().display_name(),
3436                "A minor"
3437            );
3438        } else {
3439            panic!("expected lyrics line");
3440        }
3441    }
3442
3443    // --- Multiple consecutive spaces in keys (#659) ---
3444
3445    #[test]
3446    fn test_parse_keys_multiple_spaces() {
3447        let def = ChordDefinition::parse_value("Am keys  0 3 7");
3448        assert_eq!(def.keys, Some(vec![0, 3, 7]));
3449    }
3450
3451    #[test]
3452    fn test_parse_keys_tab_separator() {
3453        let def = ChordDefinition::parse_value("Am keys\t0 3 7");
3454        assert_eq!(def.keys, Some(vec![0, 3, 7]));
3455    }
3456
3457    #[test]
3458    fn test_parse_keys_only_keyword() {
3459        // "keys" with no values should still work.
3460        let def = ChordDefinition::parse_value("Am keys");
3461        assert!(def.keys.is_none());
3462    }
3463
3464    // -- ImageAttributes ----------------------------------------------------
3465
3466    #[test]
3467    fn has_src_returns_true_for_non_empty() {
3468        let attrs = ImageAttributes::new("photo.jpg");
3469        assert!(attrs.has_src());
3470    }
3471
3472    #[test]
3473    fn has_src_returns_false_for_empty() {
3474        let attrs = ImageAttributes::default();
3475        assert!(!attrs.has_src());
3476    }
3477
3478    #[test]
3479    fn has_src_returns_false_for_explicit_empty_string() {
3480        let attrs = ImageAttributes::new("");
3481        assert!(!attrs.has_src());
3482    }
3483}
3484
3485#[cfg(test)]
3486mod apply_define_displays_tests {
3487    use super::*;
3488
3489    fn make_song_with_define_and_chords(define_value: &str, chord_names: &[&str]) -> Song {
3490        let mut song = Song::new();
3491
3492        // Add {define} directive
3493        song.lines.push(Line::Directive(Directive::with_value(
3494            "define",
3495            define_value,
3496        )));
3497
3498        // Add a lyrics line with the given chords
3499        let mut lyrics = LyricsLine::new();
3500        for name in chord_names {
3501            lyrics
3502                .segments
3503                .push(LyricsSegment::new(Some(Chord::new(*name)), "word "));
3504        }
3505        song.lines.push(Line::Lyrics(lyrics));
3506
3507        song
3508    }
3509
3510    #[test]
3511    fn applies_display_to_matching_chords() {
3512        let mut song = make_song_with_define_and_chords(
3513            "Am base-fret 1 frets x 0 2 2 1 0 display=\"A minor\"",
3514            &["Am", "G", "Am"],
3515        );
3516        song.apply_define_displays();
3517
3518        if let Line::Lyrics(ref lyrics) = song.lines[1] {
3519            assert_eq!(
3520                lyrics.segments[0].chord.as_ref().unwrap().display_name(),
3521                "A minor"
3522            );
3523            assert_eq!(
3524                lyrics.segments[1].chord.as_ref().unwrap().display_name(),
3525                "G"
3526            );
3527            assert_eq!(
3528                lyrics.segments[2].chord.as_ref().unwrap().display_name(),
3529                "A minor"
3530            );
3531        } else {
3532            panic!("expected lyrics line");
3533        }
3534    }
3535
3536    #[test]
3537    fn no_display_when_not_defined() {
3538        let mut song =
3539            make_song_with_define_and_chords("Am base-fret 1 frets x 0 2 2 1 0", &["Am"]);
3540        song.apply_define_displays();
3541
3542        if let Line::Lyrics(ref lyrics) = song.lines[1] {
3543            assert_eq!(lyrics.segments[0].chord.as_ref().unwrap().display, None);
3544        } else {
3545            panic!("expected lyrics line");
3546        }
3547    }
3548
3549    #[test]
3550    fn later_define_overrides_earlier() {
3551        let mut song = Song::new();
3552        song.lines.push(Line::Directive(Directive::with_value(
3553            "define",
3554            "Am display=\"first\"",
3555        )));
3556        song.lines.push(Line::Directive(Directive::with_value(
3557            "define",
3558            "Am display=\"second\"",
3559        )));
3560        let mut lyrics = LyricsLine::new();
3561        lyrics
3562            .segments
3563            .push(LyricsSegment::new(Some(Chord::new("Am")), "text"));
3564        song.lines.push(Line::Lyrics(lyrics));
3565
3566        song.apply_define_displays();
3567
3568        if let Line::Lyrics(ref lyrics) = song.lines[2] {
3569            assert_eq!(
3570                lyrics.segments[0].chord.as_ref().unwrap().display_name(),
3571                "second"
3572            );
3573        } else {
3574            panic!("expected lyrics line");
3575        }
3576    }
3577
3578    #[test]
3579    fn does_not_overwrite_existing_display() {
3580        let mut song = Song::new();
3581        song.lines.push(Line::Directive(Directive::with_value(
3582            "define",
3583            "Am display=\"from define\"",
3584        )));
3585        let mut lyrics = LyricsLine::new();
3586        let mut chord = Chord::new("Am");
3587        chord.display = Some("already set".to_string());
3588        lyrics
3589            .segments
3590            .push(LyricsSegment::new(Some(chord), "text"));
3591        song.lines.push(Line::Lyrics(lyrics));
3592
3593        song.apply_define_displays();
3594
3595        if let Line::Lyrics(ref lyrics) = song.lines[1] {
3596            assert_eq!(
3597                lyrics.segments[0].chord.as_ref().unwrap().display_name(),
3598                "already set"
3599            );
3600        } else {
3601            panic!("expected lyrics line");
3602        }
3603    }
3604
3605    #[test]
3606    fn format_expands_chord_components() {
3607        let mut song =
3608            make_song_with_define_and_chords("Am format=\"%{root} %{quality}\"", &["Am"]);
3609        song.apply_define_displays();
3610
3611        if let Line::Lyrics(ref lyrics) = song.lines[1] {
3612            assert_eq!(
3613                lyrics.segments[0].chord.as_ref().unwrap().display_name(),
3614                "A m"
3615            );
3616        } else {
3617            panic!("expected lyrics line");
3618        }
3619    }
3620
3621    #[test]
3622    fn format_with_extension() {
3623        let mut song =
3624            make_song_with_define_and_chords("Am7 format=\"%{root}%{quality}%{ext}\"", &["Am7"]);
3625        song.apply_define_displays();
3626
3627        if let Line::Lyrics(ref lyrics) = song.lines[1] {
3628            assert_eq!(
3629                lyrics.segments[0].chord.as_ref().unwrap().display_name(),
3630                "Am7"
3631            );
3632        } else {
3633            panic!("expected lyrics line");
3634        }
3635    }
3636
3637    #[test]
3638    fn format_with_bass_note() {
3639        let mut song = make_song_with_define_and_chords("G/B format=\"%{root}/%{bass}\"", &["G/B"]);
3640        song.apply_define_displays();
3641
3642        if let Line::Lyrics(ref lyrics) = song.lines[1] {
3643            assert_eq!(
3644                lyrics.segments[0].chord.as_ref().unwrap().display_name(),
3645                "G/B"
3646            );
3647        } else {
3648            panic!("expected lyrics line");
3649        }
3650    }
3651
3652    #[test]
3653    fn display_takes_precedence_over_format() {
3654        let mut song = make_song_with_define_and_chords(
3655            "Am display=\"A minor\" format=\"%{root}%{quality}\"",
3656            &["Am"],
3657        );
3658        song.apply_define_displays();
3659
3660        if let Line::Lyrics(ref lyrics) = song.lines[1] {
3661            assert_eq!(
3662                lyrics.segments[0].chord.as_ref().unwrap().display_name(),
3663                "A minor"
3664            );
3665        } else {
3666            panic!("expected lyrics line");
3667        }
3668    }
3669}
3670
3671#[cfg(test)]
3672mod expand_format_tests {
3673    use super::*;
3674
3675    #[test]
3676    fn basic_root_quality() {
3677        let chord = Chord::new("Am");
3678        assert_eq!(
3679            chord.expand_format("%{root}%{quality}"),
3680            Some("Am".to_string())
3681        );
3682    }
3683
3684    #[test]
3685    fn with_accidental() {
3686        let chord = Chord::new("Bb");
3687        assert_eq!(chord.expand_format("%{root}"), Some("Bb".to_string()));
3688    }
3689
3690    #[test]
3691    fn with_extension() {
3692        let chord = Chord::new("Cmaj7");
3693        let result = chord.expand_format("%{root}%{quality}%{ext}");
3694        assert_eq!(result, Some("Cmaj7".to_string()));
3695    }
3696
3697    #[test]
3698    fn with_bass() {
3699        let chord = Chord::new("Am/G");
3700        let result = chord.expand_format("%{root}%{quality}/%{bass}");
3701        assert_eq!(result, Some("Am/G".to_string()));
3702    }
3703
3704    #[test]
3705    fn custom_format() {
3706        let chord = Chord::new("Am");
3707        let result = chord.expand_format("[%{root} minor]");
3708        assert_eq!(result, Some("[A minor]".to_string()));
3709    }
3710
3711    #[test]
3712    fn returns_none_for_unparsed_chord() {
3713        let chord = Chord {
3714            name: "???".to_string(),
3715            detail: None,
3716            display: None,
3717        };
3718        assert_eq!(chord.expand_format("%{root}"), None);
3719    }
3720
3721    #[test]
3722    fn unknown_placeholder_passes_through() {
3723        let chord = Chord::new("Am");
3724        let result = chord.expand_format("%{root}%{unknown}");
3725        assert_eq!(result, Some("A%{unknown}".to_string()));
3726    }
3727
3728    #[test]
3729    fn empty_format_string() {
3730        let chord = Chord::new("Am");
3731        assert_eq!(chord.expand_format(""), Some(String::new()));
3732    }
3733
3734    #[test]
3735    fn slash_chord_bass_with_accidental() {
3736        let chord = Chord::new("G/Bb");
3737        let result = chord.expand_format("%{root}/%{bass}");
3738        assert_eq!(result, Some("G/Bb".to_string()));
3739    }
3740
3741    #[test]
3742    fn no_bass_produces_empty_string() {
3743        let chord = Chord::new("Am");
3744        let result = chord.expand_format("%{root}%{quality} (bass: %{bass})");
3745        assert_eq!(result, Some("Am (bass: )".to_string()));
3746    }
3747
3748    #[test]
3749    fn all_placeholders_combined() {
3750        let chord = Chord::new("Bbm7/Eb");
3751        let result = chord.expand_format("%{root}%{quality}%{ext}/%{bass}");
3752        assert_eq!(result, Some("Bbm7/Eb".to_string()));
3753    }
3754
3755    #[test]
3756    fn literal_text_with_no_placeholders() {
3757        let chord = Chord::new("Am");
3758        let result = chord.expand_format("just text");
3759        assert_eq!(result, Some("just text".to_string()));
3760    }
3761}