Skip to main content

chordsketch_render_text/
lib.rs

1//! Plain text renderer for ChordPro documents.
2//!
3//! This crate converts a parsed ChordPro AST (from `chordsketch-core`) into
4//! formatted plain text with chords aligned above their corresponding lyrics.
5
6use chordsketch_core::ast::{CommentStyle, DirectiveKind, Line, LyricsLine, Song};
7use chordsketch_core::config::Config;
8use chordsketch_core::render_result::RenderResult;
9use chordsketch_core::resolve_diagrams_instrument;
10use chordsketch_core::transpose::transpose_chord;
11use unicode_width::UnicodeWidthStr;
12
13/// Maximum number of chorus recall directives allowed per song.
14/// Prevents output amplification from malicious inputs with many `{chorus}` lines.
15const MAX_CHORUS_RECALLS: usize = 1000;
16
17/// Render a [`Song`] AST to plain text.
18///
19/// The output format:
20/// - Title and subtitle are rendered as header lines.
21/// - Section markers (chorus, verse, bridge, tab) render as labeled headers.
22/// - Lyrics with chords produce two lines: chords above, lyrics below.
23/// - Lyrics without chords produce a single lyrics line.
24/// - Comments are rendered with style markers.
25/// - The `{chorus}` directive recalls (re-renders) the most recently defined
26///   chorus section. If no chorus has been defined yet, a `[Chorus]` marker
27///   is emitted instead.
28/// - Metadata directives (artist, key, capo, etc.) are silently consumed
29///   (they populate [`Song::metadata`] but do not appear in the text body).
30/// - Empty lines are preserved.
31#[must_use]
32pub fn render_song(song: &Song) -> String {
33    render_song_with_transpose(song, 0, &Config::defaults())
34}
35
36/// Render a [`Song`] AST to plain text with an additional CLI transposition offset.
37///
38/// The `cli_transpose` parameter is added to any in-file `{transpose}` directive
39/// values, allowing the CLI `--transpose` flag to combine with in-file directives.
40///
41/// Warnings are printed to stderr via `eprintln!`. Use
42/// [`render_song_with_warnings`] to capture them programmatically.
43#[must_use]
44pub fn render_song_with_transpose(song: &Song, cli_transpose: i8, config: &Config) -> String {
45    let result = render_song_with_warnings(song, cli_transpose, config);
46    for w in &result.warnings {
47        eprintln!("warning: {w}");
48    }
49    result.output
50}
51
52/// Render a [`Song`] AST to plain text, returning warnings programmatically.
53///
54/// This is the structured variant of [`render_song_with_transpose`]. Instead
55/// of printing warnings to stderr, they are collected into
56/// [`RenderResult::warnings`].
57#[must_use = "caller must check warnings in the returned RenderResult"]
58pub fn render_song_with_warnings(
59    song: &Song,
60    cli_transpose: i8,
61    config: &Config,
62) -> RenderResult<String> {
63    let mut warnings = Vec::new();
64    let output = render_song_impl(song, cli_transpose, config, &mut warnings);
65    RenderResult::with_warnings(output, warnings)
66}
67
68/// Internal implementation that renders a song and collects warnings.
69fn render_song_impl(
70    song: &Song,
71    cli_transpose: i8,
72    config: &Config,
73    warnings: &mut Vec<String>,
74) -> String {
75    // Apply song-level config overrides ({+config.KEY: VALUE} directives).
76    // The effective config is not yet consumed by the text renderer but is
77    // computed here for consistency with the HTML and PDF renderers, and will
78    // be used when text-specific config settings are added.
79    let song_overrides = song.config_overrides();
80    let song_config;
81    let _config = if song_overrides.is_empty() {
82        config
83    } else {
84        song_config = config
85            .clone()
86            .with_song_overrides(&song_overrides, warnings);
87        &song_config
88    };
89    // Extract song-level transpose delta from {+config.settings.transpose}.
90    // The base config transpose is already folded into cli_transpose by the caller.
91    let song_transpose_delta = Config::song_transpose_delta(&song_overrides);
92    let mut output = Vec::new();
93    let (combined_transpose, _) =
94        chordsketch_core::transpose::combine_transpose(cli_transpose, song_transpose_delta);
95    let mut transpose_offset: i8 = combined_transpose;
96    // Stores the AST lines of the most recently defined chorus body.
97    // Re-rendered at recall time so the current transpose offset is applied.
98    let mut chorus_body: Vec<Line> = Vec::new();
99    // Temporary buffer for collecting chorus AST lines.
100    let mut chorus_buf: Option<Vec<Line>> = None;
101    let mut chorus_recall_count: usize = 0;
102
103    // Instrument for the auto-inject ASCII diagram block.
104    // Set by {diagrams: guitar/ukulele/on}; cleared by {diagrams: off} / {no_diagrams}.
105    let default_instrument = _config
106        .get_path("diagrams.instrument")
107        .as_str()
108        .map(str::to_ascii_lowercase)
109        .unwrap_or_else(|| "guitar".to_string());
110    let mut auto_diagrams_instrument: Option<String> = None;
111
112    render_metadata(&song.metadata, &mut output);
113
114    for line in &song.lines {
115        match line {
116            Line::Lyrics(lyrics_line) => {
117                if let Some(buf) = chorus_buf.as_mut() {
118                    buf.push(line.clone());
119                }
120                render_lyrics(lyrics_line, transpose_offset, &mut output);
121            }
122            Line::Directive(directive) => {
123                // Metadata directives are already rendered via song.metadata;
124                // skip them in the body to avoid duplicate output.
125                if directive.kind.is_metadata() {
126                    continue;
127                }
128                if directive.kind == DirectiveKind::Diagrams {
129                    auto_diagrams_instrument = resolve_diagrams_instrument(
130                        directive.value.as_deref(),
131                        &default_instrument,
132                    );
133                    continue;
134                }
135                if directive.kind == DirectiveKind::NoDiagrams {
136                    auto_diagrams_instrument = None;
137                    continue;
138                }
139                if directive.kind == DirectiveKind::Transpose {
140                    // {transpose: N} sets the in-file transposition amount.
141                    // A missing or empty value silently resets to 0; only a
142                    // non-empty value that cannot be parsed as i8 emits a warning.
143                    let file_offset: i8 = match directive.value.as_deref() {
144                        None | Some("") => 0,
145                        Some(raw) => match raw.parse() {
146                            Ok(v) => v,
147                            Err(_) => {
148                                warnings.push(format!(
149                                    "{{transpose}} value {raw:?} cannot be \
150                                     parsed as i8, ignored (using 0)"
151                                ));
152                                0
153                            }
154                        },
155                    };
156                    let (combined, saturated) =
157                        chordsketch_core::transpose::combine_transpose(file_offset, cli_transpose);
158                    if saturated {
159                        warnings.push(format!(
160                            "transpose offset {file_offset} + {cli_transpose} \
161                             exceeds i8 range, clamped to {combined}"
162                        ));
163                    }
164                    transpose_offset = combined;
165                    continue;
166                }
167                match &directive.kind {
168                    DirectiveKind::StartOfChorus => {
169                        render_section_header("Chorus", &directive.value, &mut output);
170                        // Begin collecting chorus content lines.
171                        chorus_buf = Some(Vec::new());
172                    }
173                    DirectiveKind::EndOfChorus => {
174                        if let Some(buf) = chorus_buf.take() {
175                            chorus_body = buf;
176                        }
177                    }
178                    DirectiveKind::Chorus => {
179                        if chorus_recall_count < MAX_CHORUS_RECALLS {
180                            render_chorus_recall(
181                                &directive.value,
182                                &chorus_body,
183                                transpose_offset,
184                                &mut output,
185                            );
186                            chorus_recall_count += 1;
187                        } else if chorus_recall_count == MAX_CHORUS_RECALLS {
188                            warnings.push(format!(
189                                "chorus recall limit ({MAX_CHORUS_RECALLS}) reached, \
190                                 further recalls suppressed"
191                            ));
192                            chorus_recall_count += 1;
193                        }
194                    }
195                    // All page-layout directives are intentionally excluded from the
196                    // chorus buffer — they must not be replayed on chorus recall.
197                    // Plain-text rendering produces no output for these directives
198                    // (parity with HTML and PDF renderers which do the same exclusion).
199                    DirectiveKind::NewPage
200                    | DirectiveKind::NewPhysicalPage
201                    | DirectiveKind::ColumnBreak
202                    | DirectiveKind::Columns => {}
203                    _ => {
204                        if let Some(buf) = chorus_buf.as_mut() {
205                            buf.push(line.clone());
206                        }
207                        let mut target = Vec::new();
208                        render_directive(directive, &mut target);
209                        output.extend(target);
210                    }
211                }
212            }
213            Line::Comment(style, text) => {
214                if let Some(buf) = chorus_buf.as_mut() {
215                    buf.push(line.clone());
216                }
217                render_comment(*style, text, &mut output);
218            }
219            Line::Empty => {
220                if let Some(buf) = chorus_buf.as_mut() {
221                    buf.push(line.clone());
222                }
223                output.push(String::new());
224            }
225        }
226    }
227
228    // Auto-inject ASCII diagram block when {diagrams} (or {diagrams: guitar/ukulele/piano}) was seen.
229    if let Some(ref instrument) = auto_diagrams_instrument {
230        if instrument == "piano" {
231            // Plain-text rendering of keyboard diagrams is not supported; emit a note
232            // listing the chord names so the reader knows which chords are in use.
233            let kbd_defines = song.keyboard_defines();
234            let voicings: Vec<_> = song
235                .used_chord_names()
236                .into_iter()
237                .filter_map(|name| chordsketch_core::lookup_keyboard_voicing(&name, &kbd_defines))
238                .collect();
239            if !voicings.is_empty() {
240                output.push(String::new());
241                output.push("[Chord Diagrams]".to_string());
242                for voicing in &voicings {
243                    output.push(format!(
244                        "  {}: keys {}",
245                        voicing.title(),
246                        voicing
247                            .keys
248                            .iter()
249                            .map(|k| k.to_string())
250                            .collect::<Vec<_>>()
251                            .join(" ")
252                    ));
253                }
254            }
255        } else {
256            let frets_shown = _config
257                .get_path("diagrams.frets")
258                .as_f64()
259                .map_or(chordsketch_core::chord_diagram::DEFAULT_FRETS_SHOWN, |n| {
260                    (n as usize).max(1)
261                });
262            let defines = song.fretted_defines();
263            let diagrams: Vec<_> = song
264                .used_chord_names()
265                .into_iter()
266                .filter_map(|name| {
267                    chordsketch_core::lookup_diagram(&name, &defines, instrument, frets_shown)
268                })
269                .collect();
270            if !diagrams.is_empty() {
271                output.push(String::new());
272                output.push("[Chord Diagrams]".to_string());
273                for diagram in &diagrams {
274                    output.push(String::new());
275                    for diagram_line in
276                        chordsketch_core::chord_diagram::render_ascii(diagram).lines()
277                    {
278                        output.push(diagram_line.to_string());
279                    }
280                }
281            }
282        }
283    }
284
285    // Remove trailing empty lines, then add a final newline.
286    while output.last().is_some_and(|l| l.is_empty()) {
287        output.pop();
288    }
289
290    if output.is_empty() {
291        return String::new();
292    }
293
294    let mut result = output.join("\n");
295    result.push('\n');
296    result
297}
298
299/// Render multiple [`Song`]s to plain text, separated by a blank line.
300#[must_use]
301pub fn render_songs(songs: &[Song]) -> String {
302    render_songs_with_transpose(songs, 0, &Config::defaults())
303}
304
305/// Render multiple [`Song`]s to plain text with transposition.
306///
307/// Warnings are printed to stderr via `eprintln!`. Use
308/// [`render_songs_with_warnings`] to capture them programmatically.
309#[must_use]
310pub fn render_songs_with_transpose(songs: &[Song], cli_transpose: i8, config: &Config) -> String {
311    let result = render_songs_with_warnings(songs, cli_transpose, config);
312    for w in &result.warnings {
313        eprintln!("warning: {w}");
314    }
315    result.output
316}
317
318/// Render multiple [`Song`]s to plain text, returning warnings programmatically.
319///
320/// This is the structured variant of [`render_songs_with_transpose`]. Instead
321/// of printing warnings to stderr, they are collected into
322/// [`RenderResult::warnings`].
323#[must_use = "caller must check warnings in the returned RenderResult"]
324pub fn render_songs_with_warnings(
325    songs: &[Song],
326    cli_transpose: i8,
327    config: &Config,
328) -> RenderResult<String> {
329    let mut warnings = Vec::new();
330    let mut parts: Vec<String> = songs
331        .iter()
332        .map(|song| {
333            render_song_impl(song, cli_transpose, config, &mut warnings)
334                .trim_end()
335                .to_string()
336        })
337        .collect();
338    // Ensure the final output ends with a newline.
339    if let Some(last) = parts.last_mut() {
340        last.push('\n');
341    }
342    RenderResult::with_warnings(parts.join("\n\n"), warnings)
343}
344
345/// Parse a ChordPro source string and render it to plain text.
346///
347/// Returns `Ok(text)` on success, or the [`chordsketch_core::ParseError`] if
348/// the input cannot be parsed.
349///
350/// For pre-parsed input, use [`render_song`] directly.
351#[must_use = "parse errors should be handled"]
352pub fn try_render(input: &str) -> Result<String, chordsketch_core::ParseError> {
353    let song = chordsketch_core::parse(input)?;
354    Ok(render_song(&song))
355}
356
357/// Parse a ChordPro source string and render it to plain text.
358///
359/// This is a convenience wrapper around [`try_render`] that converts parse
360/// errors into a human-readable error string. Because success and failure
361/// both return a `String`, callers **cannot** distinguish between them
362/// programmatically — use [`try_render`] if you need error handling.
363#[must_use]
364pub fn render(input: &str) -> String {
365    match try_render(input) {
366        Ok(text) => text,
367        Err(e) => format!(
368            "Parse error at line {} column {}: {}\n",
369            e.line(),
370            e.column(),
371            e.message
372        ),
373    }
374}
375
376// ---------------------------------------------------------------------------
377// Metadata header
378// ---------------------------------------------------------------------------
379
380/// Render the song metadata (title, subtitle) as a header block.
381///
382/// No trailing blank line is added — the document's own empty lines
383/// provide spacing between the metadata header and the song body.
384fn render_metadata(metadata: &chordsketch_core::ast::Metadata, output: &mut Vec<String>) {
385    if let Some(title) = &metadata.title {
386        output.push(title.clone());
387    }
388    for subtitle in &metadata.subtitles {
389        output.push(subtitle.clone());
390    }
391}
392
393// ---------------------------------------------------------------------------
394// Lyrics rendering (chord-over-lyrics alignment)
395// ---------------------------------------------------------------------------
396
397/// Render a lyrics line with chords aligned above the lyrics.
398///
399/// If the line has chords, two lines are produced:
400///   1. A chord line with each chord positioned above its lyrics segment.
401///   2. The lyrics text.
402///
403/// If the line has no chords, only the lyrics text is emitted.
404///
405/// Alignment is based on Unicode display width (`UnicodeWidthStr::width()`),
406/// which correctly handles full-width CJK characters and other wide glyphs.
407fn render_lyrics(lyrics_line: &LyricsLine, transpose_offset: i8, output: &mut Vec<String>) {
408    if !lyrics_line.has_chords() {
409        output.push(lyrics_line.text());
410        return;
411    }
412
413    let mut chord_line = String::new();
414    let mut lyric_line = String::new();
415
416    for segment in &lyrics_line.segments {
417        let transposed;
418        let chord_name = if transpose_offset != 0 {
419            if let Some(chord) = &segment.chord {
420                transposed = transpose_chord(chord, transpose_offset);
421                transposed.display_name()
422            } else {
423                ""
424            }
425        } else {
426            segment.chord.as_ref().map_or("", |c| c.display_name())
427        };
428        let text = &segment.text;
429
430        let chord_len = UnicodeWidthStr::width(chord_name);
431        let text_len = UnicodeWidthStr::width(text.as_str());
432
433        // Write the chord (or equivalent spacing) on the chord line.
434        chord_line.push_str(chord_name);
435
436        // Write the text on the lyric line.
437        lyric_line.push_str(text);
438
439        // Ensure alignment: both lines must advance to at least the same column.
440        // If the chord is longer than the text, pad the lyric line.
441        // If the text is longer than the chord, pad the chord line.
442        // Add 1 space of padding after chord when chord >= text length,
443        // so chords don't run together.
444        if chord_len > 0 && chord_len >= text_len {
445            let padding = chord_len - text_len + 1;
446            lyric_line.extend(std::iter::repeat_n(' ', padding));
447            chord_line.push(' ');
448        } else if chord_len > 0 && text_len > chord_len {
449            let padding = text_len - chord_len;
450            chord_line.extend(std::iter::repeat_n(' ', padding));
451        }
452        // If chord_len == 0 (no chord), text just advances lyric_line naturally
453        // and chord_line needs to keep up.
454        if chord_len == 0 && text_len > 0 {
455            chord_line.extend(std::iter::repeat_n(' ', text_len));
456        }
457    }
458
459    output.push(chord_line.trim_end().to_string());
460    output.push(lyric_line.trim_end().to_string());
461}
462
463// ---------------------------------------------------------------------------
464// Directive rendering
465// ---------------------------------------------------------------------------
466
467/// Render a directive to text output.
468///
469/// - Section start directives produce a labeled header (e.g., "Chorus").
470/// - Section end directives are not rendered (they are structural markers).
471/// - Metadata directives are not rendered here (handled by `render_metadata`).
472/// - Page-layout directives (`{new_page}`, `{new_physical_page}`, `{column_break}`,
473///   `{columns}`) produce no output — plain text has no concept of pages or columns.
474/// - Unknown directives are silently ignored.
475fn render_directive(directive: &chordsketch_core::ast::Directive, output: &mut Vec<String>) {
476    match &directive.kind {
477        DirectiveKind::StartOfChorus => {
478            render_section_header("Chorus", &directive.value, output);
479        }
480        DirectiveKind::StartOfVerse => {
481            render_section_header("Verse", &directive.value, output);
482        }
483        DirectiveKind::StartOfBridge => {
484            render_section_header("Bridge", &directive.value, output);
485        }
486        DirectiveKind::StartOfTab => {
487            render_section_header("Tab", &directive.value, output);
488        }
489        DirectiveKind::StartOfGrid => {
490            render_section_header("Grid", &directive.value, output);
491        }
492        DirectiveKind::StartOfAbc => {
493            render_section_header("ABC", &directive.value, output);
494        }
495        DirectiveKind::StartOfLy => {
496            render_section_header("Lilypond", &directive.value, output);
497        }
498        DirectiveKind::StartOfSvg => {
499            render_section_header("SVG", &directive.value, output);
500        }
501        DirectiveKind::StartOfTextblock => {
502            render_section_header("Textblock", &directive.value, output);
503        }
504        DirectiveKind::StartOfMusicxml => {
505            render_section_header("MusicXML", &directive.value, output);
506        }
507        DirectiveKind::StartOfSection(section_name) => {
508            // Capitalize the first letter of the section name for display.
509            let label = chordsketch_core::capitalize(section_name);
510            render_section_header(&label, &directive.value, output);
511        }
512        DirectiveKind::Image(attrs) if attrs.has_src() => {
513            output.push(format!("[Image: {}]", attrs.src));
514        }
515        DirectiveKind::Image(_) => {}
516        // Page-layout directives are intentionally no-ops in plain-text output:
517        // plain text has no concept of pages, columns, or column breaks.
518        // Explicit arms here make the omission visible to future contributors
519        // (renderer-parity.md requires every directive to have an explicit arm).
520        DirectiveKind::NewPage
521        | DirectiveKind::NewPhysicalPage
522        | DirectiveKind::ColumnBreak
523        | DirectiveKind::Columns => {}
524        // End-of-section, metadata, and unknown directives produce no output.
525        _ => {}
526    }
527}
528
529/// Render a `{chorus}` recall directive.
530///
531/// Re-renders the stored chorus AST lines with the current transpose offset,
532/// so chords are transposed correctly even if `{transpose}` changed after
533/// the chorus was defined.
534fn render_chorus_recall(
535    value: &Option<String>,
536    chorus_body: &[Line],
537    transpose_offset: i8,
538    output: &mut Vec<String>,
539) {
540    render_section_header("Chorus", value, output);
541    for line in chorus_body {
542        match line {
543            Line::Lyrics(lyrics) => render_lyrics(lyrics, transpose_offset, output),
544            Line::Comment(style, text) => render_comment(*style, text, output),
545            Line::Empty => output.push(String::new()),
546            Line::Directive(d) if !d.kind.is_metadata() => render_directive(d, output),
547            _ => {}
548        }
549    }
550}
551
552/// Render a section header like "Chorus" or "Verse 1".
553fn render_section_header(label: &str, value: &Option<String>, output: &mut Vec<String>) {
554    match value {
555        Some(v) if !v.is_empty() => output.push(format!("[{label}: {v}]")),
556        _ => output.push(format!("[{label}]")),
557    }
558}
559
560// ---------------------------------------------------------------------------
561// Comment rendering
562// ---------------------------------------------------------------------------
563
564/// Render a comment with its style marker.
565///
566/// - Normal comments: `(comment text)`
567/// - Italic comments: `(*comment text*)`
568/// - Boxed comments:  `[comment text]`
569fn render_comment(style: CommentStyle, text: &str, output: &mut Vec<String>) {
570    match style {
571        CommentStyle::Normal => output.push(format!("({text})")),
572        CommentStyle::Italic => output.push(format!("(*{text}*)")),
573        CommentStyle::Boxed => output.push(format!("[{text}]")),
574    }
575}
576
577// ===========================================================================
578// Tests
579// ===========================================================================
580
581#[cfg(test)]
582mod tests {
583    use super::*;
584
585    #[test]
586    fn test_render_empty() {
587        assert_eq!(render(""), "");
588    }
589
590    #[test]
591    fn test_render_title_only() {
592        let input = "{title: Amazing Grace}";
593        let output = render(input);
594        assert_eq!(output, "Amazing Grace\n");
595    }
596
597    #[test]
598    fn test_render_title_and_subtitle() {
599        let input = "{title: Amazing Grace}\n{subtitle: Traditional}";
600        let output = render(input);
601        assert_eq!(output, "Amazing Grace\nTraditional\n");
602    }
603
604    #[test]
605    fn test_render_plain_lyrics() {
606        let input = "Hello world\nSecond line";
607        let output = render(input);
608        assert_eq!(output, "Hello world\nSecond line\n");
609    }
610
611    #[test]
612    fn test_render_lyrics_with_chords() {
613        let input = "[Am]Hello [G]world";
614        let output = render(input);
615        assert_eq!(output, "Am    G\nHello world\n");
616    }
617
618    #[test]
619    fn test_render_chord_longer_than_text() {
620        // Chord "Cmaj7" is 5 chars, text "I" is 1 char
621        let input = "[Cmaj7]I [G]see";
622        let output = render(input);
623        assert_eq!(output, "Cmaj7 G\nI     see\n");
624    }
625
626    #[test]
627    fn test_render_chorus_section() {
628        let input = "{start_of_chorus}\n[G]La la la\n{end_of_chorus}";
629        let output = render(input);
630        assert_eq!(output, "[Chorus]\nG\nLa la la\n");
631    }
632
633    #[test]
634    fn test_render_verse_with_label() {
635        let input = "{start_of_verse: Verse 1}\nSome lyrics\n{end_of_verse}";
636        let output = render(input);
637        assert_eq!(output, "[Verse: Verse 1]\nSome lyrics\n");
638    }
639
640    #[test]
641    fn test_render_comment_normal() {
642        let input = "{comment: This is a comment}";
643        let output = render(input);
644        assert_eq!(output, "(This is a comment)\n");
645    }
646
647    #[test]
648    fn test_render_comment_italic() {
649        let input = "{comment_italic: Softly}";
650        let output = render(input);
651        assert_eq!(output, "(*Softly*)\n");
652    }
653
654    #[test]
655    fn test_render_comment_box() {
656        let input = "{comment_box: Important}";
657        let output = render(input);
658        assert_eq!(output, "[Important]\n");
659    }
660
661    #[test]
662    fn test_render_empty_lines_preserved() {
663        let input = "Line one\n\nLine two";
664        let output = render(input);
665        assert_eq!(output, "Line one\n\nLine two\n");
666    }
667
668    #[test]
669    fn test_render_metadata_not_duplicated() {
670        // Metadata directives like {artist} should NOT appear in body text
671        let input = "{title: Test}\n{artist: Someone}\n{key: G}\nLyrics here";
672        let output = render(input);
673        assert_eq!(output, "Test\nLyrics here\n");
674    }
675
676    #[test]
677    fn test_render_full_song() {
678        let input = "\
679{title: Amazing Grace}
680{subtitle: Traditional}
681{key: G}
682
683{start_of_verse}
684[G]Amazing [G7]grace, how [C]sweet the [G]sound
685[G]That saved a [Em]wretch like [D]me
686{end_of_verse}
687
688{start_of_chorus}
689[G]I once was [G7]lost, but [C]now am [G]found
690{end_of_chorus}";
691        let output = render(input);
692        // Just verify it doesn't panic and produces non-empty output
693        assert!(!output.is_empty());
694        assert!(output.contains("Amazing Grace"));
695        assert!(output.contains("[Verse]"));
696        assert!(output.contains("[Chorus]"));
697    }
698
699    #[test]
700    fn test_render_song_api() {
701        let song = chordsketch_core::parse("{title: Test}\n[Am]Hello").unwrap();
702        let output = render_song(&song);
703        assert!(output.contains("Test"));
704        assert!(output.contains("Am"));
705        assert!(output.contains("Hello"));
706    }
707
708    #[test]
709    fn test_render_chord_only_segment() {
710        // A chord at end of line with no following text
711        let input = "[Am]Hello [G]";
712        let output = render(input);
713        assert!(output.contains("Am"));
714        assert!(output.contains("G"));
715        assert!(output.contains("Hello"));
716    }
717
718    #[test]
719    fn test_render_bridge_section() {
720        let input = "{start_of_bridge}\nBridge lyrics\n{end_of_bridge}";
721        let output = render(input);
722        assert_eq!(output, "[Bridge]\nBridge lyrics\n");
723    }
724
725    #[test]
726    fn test_render_tab_section() {
727        let input = "{start_of_tab}\ne|---0---|\n{end_of_tab}";
728        let output = render(input);
729        assert_eq!(output, "[Tab]\ne|---0---|\n");
730    }
731
732    // --- Issue #65: Unicode alignment ---
733
734    #[test]
735    fn test_render_multibyte_lyrics_alignment() {
736        // Japanese text: each char is 3 bytes, 1 code point, but 2 columns wide.
737        let input = "[Am]こんにちは [G]世界";
738        let output = render(input);
739        // "こんにちは " = 5×2 + 1 = 11 display columns, "Am" = 2 → pad chord by 9
740        // "世界" = 2×2 = 4 display columns, "G" = 1 → pad chord by 3
741        assert_eq!(output, "Am         G\nこんにちは 世界\n");
742    }
743
744    #[test]
745    fn test_render_accented_lyrics_alignment() {
746        let input = "[Em]café [D]résumé";
747        let output = render(input);
748        assert_eq!(output, "Em   D\ncafé résumé\n");
749    }
750
751    // --- Issue #66: Text before first chord ---
752
753    #[test]
754    fn test_render_text_before_first_chord() {
755        let input = "Hello [Am]world";
756        let output = render(input);
757        assert_eq!(output, "      Am\nHello world\n");
758    }
759
760    #[test]
761    fn test_render_text_before_first_chord_multiple() {
762        let input = "I say [Am]hello [G]world";
763        let output = render(input);
764        assert_eq!(output, "      Am    G\nI say hello world\n");
765    }
766
767    // --- Issue #67: try_render API ---
768
769    #[test]
770    fn test_try_render_success() {
771        let result = try_render("{title: Test}\n[Am]Hello");
772        assert!(result.is_ok());
773        let text = result.unwrap();
774        assert!(text.contains("Test"));
775        assert!(text.contains("Am"));
776    }
777
778    #[test]
779    fn test_try_render_parse_error() {
780        let result = try_render("{title: unclosed");
781        assert!(result.is_err());
782        let err = result.unwrap_err();
783        assert_eq!(err.line(), 1);
784    }
785
786    #[test]
787    fn test_render_grid_section() {
788        let input = "{start_of_grid}\n| Am . | C . |\n{end_of_grid}";
789        let output = render(input);
790        assert_eq!(output, "[Grid]\n| Am . | C . |\n");
791    }
792
793    #[test]
794    fn test_render_grid_section_with_label() {
795        let input = "{start_of_grid: Intro}\n| Am . | C . |\n{end_of_grid}";
796        let output = render(input);
797        assert_eq!(output, "[Grid: Intro]\n| Am . | C . |\n");
798    }
799
800    #[test]
801    fn test_render_grid_short_alias() {
802        let input = "{sog}\n| G . | D . |\n{eog}";
803        let output = render(input);
804        assert_eq!(output, "[Grid]\n| G . | D . |\n");
805    }
806
807    // --- Custom sections (#108) ---
808
809    #[test]
810    fn test_render_custom_section_intro() {
811        let input = "{start_of_intro}\n[Am]Da da da\n{end_of_intro}";
812        let output = render(input);
813        assert!(output.contains("[Intro]"));
814        assert!(output.contains("Am"));
815        assert!(output.contains("Da da da"));
816    }
817
818    #[test]
819    fn test_render_custom_section_with_label() {
820        let input = "{start_of_intro: Guitar}\nSome notes\n{end_of_intro}";
821        let output = render(input);
822        assert_eq!(output, "[Intro: Guitar]\nSome notes\n");
823    }
824
825    #[test]
826    fn test_render_custom_section_outro() {
827        let input = "{start_of_outro}\nFinal notes\n{end_of_outro}";
828        let output = render(input);
829        assert!(output.contains("[Outro]"));
830    }
831
832    #[test]
833    fn test_render_custom_section_solo() {
834        let input = "{start_of_solo}\n[Em]Solo line\n{end_of_solo}";
835        let output = render(input);
836        assert!(output.contains("[Solo]"));
837    }
838}
839
840#[cfg(test)]
841mod multi_song_tests {
842    use super::*;
843
844    #[test]
845    fn test_render_songs_two_songs() {
846        let songs = chordsketch_core::parse_multi(
847            "{title: Song One}\n[Am]Hello\n{new_song}\n{title: Song Two}\n[G]World",
848        )
849        .unwrap();
850        let output = render_songs(&songs);
851        assert!(output.contains("Song One"));
852        assert!(output.contains("Am"));
853        assert!(output.contains("Hello"));
854        assert!(output.contains("Song Two"));
855        assert!(output.contains("G\nWorld"));
856        // Two songs are separated by exactly one blank line (double newline).
857        assert!(output.contains("\n\n"));
858        // Must NOT contain triple newline.
859        assert!(
860            !output.contains("\n\n\n"),
861            "Should not have triple newline between songs"
862        );
863    }
864
865    #[test]
866    fn test_render_songs_single_song() {
867        let songs = chordsketch_core::parse_multi("{title: Only One}\nLyrics").unwrap();
868        let output = render_songs(&songs);
869        assert_eq!(output, render_song(&songs[0]));
870    }
871
872    #[test]
873    fn test_render_songs_with_transpose() {
874        let songs =
875            chordsketch_core::parse_multi("{title: S1}\n[C]Do\n{new_song}\n{title: S2}\n[G]Re")
876                .unwrap();
877        let output = render_songs_with_transpose(&songs, 2, &Config::defaults());
878        // C+2=D, G+2=A
879        assert!(output.contains("D\nDo"));
880        assert!(output.contains("A\nRe"));
881    }
882}
883
884#[cfg(test)]
885mod transpose_tests {
886    use super::*;
887
888    #[test]
889    fn test_transpose_directive_up_2() {
890        let input = "{transpose: 2}\n[G]Hello [C]world";
891        let song = chordsketch_core::parse(input).unwrap();
892        let output = render_song(&song);
893        // G+2=A, C+2=D
894        assert_eq!(output, "A     D\nHello world\n");
895    }
896
897    #[test]
898    fn test_transpose_directive_down_3() {
899        let input = "{transpose: -3}\n[Am]Hello [Em]world";
900        let song = chordsketch_core::parse(input).unwrap();
901        let output = render_song(&song);
902        // Am-3=F#m, Em-3=C#m
903        assert_eq!(output, "F#m   C#m\nHello world\n");
904    }
905
906    #[test]
907    fn test_transpose_directive_replaces_previous() {
908        let input = "{transpose: 2}\n[G]First\n{transpose: -1}\n[G]Second";
909        let song = chordsketch_core::parse(input).unwrap();
910        let output = render_song(&song);
911        // First line: G+2=A, Second line: G-1=F#
912        assert!(output.contains("A\nFirst"));
913        assert!(output.contains("F#\nSecond"));
914    }
915
916    #[test]
917    fn test_transpose_directive_zero_resets() {
918        let input = "{transpose: 5}\n[C]Up\n{transpose: 0}\n[C]Normal";
919        let song = chordsketch_core::parse(input).unwrap();
920        let output = render_song(&song);
921        // First line: C+5=F, Second line: C+0=C
922        assert!(output.contains("F\nUp"));
923        assert!(output.contains("C\nNormal"));
924    }
925
926    #[test]
927    fn test_transpose_directive_with_cli_offset() {
928        let input = "{transpose: 2}\n[C]Hello";
929        let song = chordsketch_core::parse(input).unwrap();
930        let output = render_song_with_transpose(&song, 3, &Config::defaults());
931        // In-file 2 + CLI 3 = 5 total. C+5=F
932        assert!(output.contains("F\nHello"));
933    }
934
935    #[test]
936    fn test_cli_transpose_without_directive() {
937        let input = "[G]Hello [C]world";
938        let song = chordsketch_core::parse(input).unwrap();
939        let output = render_song_with_transpose(&song, 2, &Config::defaults());
940        // CLI offset only: G+2=A, C+2=D
941        assert_eq!(output, "A     D\nHello world\n");
942    }
943
944    #[test]
945    fn test_transpose_directive_replaces_with_cli_additive() {
946        let input = "{transpose: 2}\n[C]First\n{transpose: -1}\n[C]Second";
947        let song = chordsketch_core::parse(input).unwrap();
948        let output = render_song_with_transpose(&song, 1, &Config::defaults());
949        // First: 2+1=3, C+3=D#. Second: -1+1=0, C+0=C
950        assert!(output.contains("D#\nFirst"));
951        assert!(output.contains("C\nSecond"));
952    }
953
954    #[test]
955    fn test_transpose_no_chord_lyrics_unaffected() {
956        let input = "{transpose: 5}\nPlain lyrics no chords";
957        let song = chordsketch_core::parse(input).unwrap();
958        let output = render_song(&song);
959        assert_eq!(output, "Plain lyrics no chords\n");
960    }
961
962    #[test]
963    fn test_transpose_invalid_value_treated_as_zero() {
964        let input = "{transpose: abc}\n[G]Hello";
965        let song = chordsketch_core::parse(input).unwrap();
966        let result =
967            render_song_with_warnings(&song, 0, &chordsketch_core::config::Config::defaults());
968        // Invalid value -> treated as 0
969        assert!(result.output.contains("G\nHello"));
970        assert!(
971            result.warnings.iter().any(|w| w.contains("\"abc\"")),
972            "expected warning about unparseable value, got: {:?}",
973            result.warnings
974        );
975    }
976
977    #[test]
978    fn test_transpose_out_of_i8_range_emits_warning() {
979        // 999 cannot be represented as i8; should fall back to 0 with a warning
980        let input = "{transpose: 999}\n[G]Hello";
981        let song = chordsketch_core::parse(input).unwrap();
982        let result =
983            render_song_with_warnings(&song, 0, &chordsketch_core::config::Config::defaults());
984        assert!(
985            result.output.contains("G\nHello"),
986            "chord should be untransposed"
987        );
988        assert!(
989            result.warnings.iter().any(|w| w.contains("\"999\"")),
990            "expected warning about out-of-range value, got: {:?}",
991            result.warnings
992        );
993    }
994
995    #[test]
996    fn test_transpose_no_value_treated_as_zero() {
997        let input = "{transpose}\n[G]Hello";
998        let song = chordsketch_core::parse(input).unwrap();
999        let result =
1000            render_song_with_warnings(&song, 0, &chordsketch_core::config::Config::defaults());
1001        // No value -> silently treated as 0, no warning emitted.
1002        assert!(result.output.contains("G\nHello"));
1003        assert!(
1004            result.warnings.is_empty(),
1005            "missing {{transpose}} value should not emit a warning; got: {:?}",
1006            result.warnings
1007        );
1008    }
1009
1010    #[test]
1011    fn test_transpose_whitespace_value_treated_as_zero() {
1012        // {transpose:   } with whitespace-only value should silently reset to 0,
1013        // no warning emitted. The parser trims whitespace → Some(""), which the
1014        // Some("") arm converts to 0.
1015        let input = "{transpose:   }\n[G]Hello";
1016        let song = chordsketch_core::parse(input).unwrap();
1017        let result =
1018            render_song_with_warnings(&song, 0, &chordsketch_core::config::Config::defaults());
1019        assert!(
1020            result.output.contains("G\nHello"),
1021            "chord should be untransposed"
1022        );
1023        assert!(
1024            result.warnings.is_empty(),
1025            "whitespace-only {{transpose}} value should not emit a warning; got: {:?}",
1026            result.warnings
1027        );
1028    }
1029
1030    // --- Issue #109: {chorus} recall ---
1031
1032    #[test]
1033    fn test_render_chorus_recall_basic() {
1034        let input = "\
1035{start_of_chorus}
1036[G]La la la
1037{end_of_chorus}
1038
1039{start_of_verse}
1040Some verse
1041{end_of_verse}
1042
1043{chorus}";
1044        let output = render(input);
1045        // The chorus content should appear twice: once in the original and once recalled
1046        assert_eq!(
1047            output,
1048            "[Chorus]\nG\nLa la la\n\n[Verse]\nSome verse\n\n[Chorus]\nG\nLa la la\n"
1049        );
1050    }
1051
1052    #[test]
1053    fn test_render_chorus_recall_with_label() {
1054        let input = "\
1055{start_of_chorus}
1056Sing along
1057{end_of_chorus}
1058
1059{chorus: Repeat}";
1060        let output = render(input);
1061        assert!(output.contains("[Chorus: Repeat]"));
1062        assert_eq!(
1063            output,
1064            "[Chorus]\nSing along\n\n[Chorus: Repeat]\nSing along\n"
1065        );
1066    }
1067
1068    #[test]
1069    fn test_render_chorus_recall_no_chorus_defined() {
1070        // When {chorus} is used before any chorus is defined, just show the header
1071        let input = "{chorus}";
1072        let output = render(input);
1073        assert_eq!(output, "[Chorus]\n");
1074    }
1075
1076    #[test]
1077    fn test_render_chorus_recall_multiple() {
1078        let input = "\
1079{start_of_chorus}
1080Chorus line
1081{end_of_chorus}
1082{chorus}
1083{chorus}";
1084        let output = render(input);
1085        // Original chorus + two recalls
1086        assert_eq!(
1087            output,
1088            "[Chorus]\nChorus line\n[Chorus]\nChorus line\n[Chorus]\nChorus line\n"
1089        );
1090    }
1091
1092    #[test]
1093    fn test_render_chorus_recall_uses_latest() {
1094        // If there are multiple chorus definitions, recall uses the latest
1095        let input = "\
1096{start_of_chorus}
1097First chorus
1098{end_of_chorus}
1099
1100{start_of_chorus}
1101Second chorus
1102{end_of_chorus}
1103
1104{chorus}";
1105        let output = render(input);
1106        // The recall should use "Second chorus", not "First chorus"
1107        assert!(output.ends_with("[Chorus]\nSecond chorus\n"));
1108    }
1109
1110    #[test]
1111    fn test_chorus_recall_applies_current_transpose() {
1112        // Chorus defined with no transpose, recalled after {transpose: 2}.
1113        // G should become A in the recalled chorus.
1114        let input = "\
1115{start_of_chorus}
1116[G]La la
1117{end_of_chorus}
1118{transpose: 2}
1119{chorus}";
1120        let output = render(input);
1121        // Original chorus has [G], recalled chorus should have [A].
1122        // The recalled chorus should show "A" (G+2) not "G".
1123        // The output has chord on one line and lyrics on next.
1124        let recall_idx = output.rfind("[Chorus]").expect("should have recall");
1125        let recall_section = &output[recall_idx..];
1126        assert!(
1127            recall_section.contains('A') && !recall_section.contains('G'),
1128            "recalled chorus should have transposed chord A (not G), got:\n{recall_section}"
1129        );
1130    }
1131
1132    #[test]
1133    fn test_chorus_recall_limit_exceeded() {
1134        // Generate input with more chorus recalls than the limit.
1135        let mut input = String::from("{start_of_chorus}\nChorus\n{end_of_chorus}\n");
1136        for _ in 0..1005 {
1137            input.push_str("{chorus}\n");
1138        }
1139        let output = render(&input);
1140        // Count occurrences of the chorus content (excluding the original).
1141        let recall_count = output.matches("[Chorus]\nChorus").count() - 1; // subtract original
1142        assert_eq!(
1143            recall_count,
1144            super::MAX_CHORUS_RECALLS,
1145            "should stop at MAX_CHORUS_RECALLS"
1146        );
1147    }
1148
1149    #[test]
1150    fn test_page_control_not_replayed_in_chorus_recall() {
1151        // Page-control directives inside a chorus definition must NOT appear in
1152        // {chorus} recall output. This mirrors the equivalent test in the HTML and
1153        // PDF renderers (renderer-parity.md).
1154        let input = "\
1155{start_of_chorus}
1156[G]Chorus line
1157{new_page}
1158{column_break}
1159{columns: 2}
1160{end_of_chorus}
1161{chorus}";
1162        let output = render(input);
1163        // The recalled chorus must contain the lyric content …
1164        assert!(
1165            output.contains("G"),
1166            "chord from chorus must appear in recall: {output}"
1167        );
1168        assert!(
1169            output.contains("Chorus line"),
1170            "lyric from chorus must appear in recall: {output}"
1171        );
1172        // … but must NOT contain any page-control directive text.
1173        // (Page-control directives produce no text output in the text renderer,
1174        //  so if they were erroneously replayed, the output would be unchanged;
1175        //  the key assertion is that the chorus body stored during collection
1176        //  does not include the directive AST nodes and cause extra empty lines.)
1177        let chorus_section_lines: Vec<&str> = output.lines().collect();
1178        // The definition renders: [Chorus] header + chord line + lyric line.
1179        // The recall renders: [Chorus] header + chord line + lyric line again.
1180        // Total: 6 non-empty lines (3 original + 3 recall). No extra blank lines
1181        // introduced by replayed page-control directives.
1182        let non_empty_count = chorus_section_lines
1183            .iter()
1184            .filter(|l| !l.is_empty())
1185            .count();
1186        assert_eq!(
1187            non_empty_count, 6,
1188            "expected 6 non-empty output lines (3 original + 3 recall), got {non_empty_count}; \
1189             output:\n{output}"
1190        );
1191    }
1192}
1193
1194#[cfg(test)]
1195mod delegate_tests {
1196    use super::*;
1197
1198    #[test]
1199    fn test_render_abc_section() {
1200        let input = "{start_of_abc}\nX:1\nK:G\n{end_of_abc}";
1201        let output = render(input);
1202        assert!(output.contains("[ABC]"));
1203        assert!(output.contains("X:1"));
1204    }
1205
1206    #[test]
1207    fn test_render_abc_section_with_label() {
1208        let input = "{start_of_abc: Melody}\nX:1\n{end_of_abc}";
1209        let output = render(input);
1210        assert_eq!(output, "[ABC: Melody]\nX:1\n");
1211    }
1212
1213    #[test]
1214    fn test_render_ly_section() {
1215        let input = "{start_of_ly}\nnotes\n{end_of_ly}";
1216        let output = render(input);
1217        assert!(output.contains("[Lilypond]"));
1218    }
1219
1220    #[test]
1221    fn test_render_svg_section() {
1222        let input = "{start_of_svg}\n<svg/>\n{end_of_svg}";
1223        let output = render(input);
1224        assert!(output.contains("[SVG]"));
1225    }
1226
1227    #[test]
1228    fn test_render_textblock_section() {
1229        let input = "{start_of_textblock}\nPreformatted text\n{end_of_textblock}";
1230        let output = render(input);
1231        assert!(output.contains("[Textblock]"));
1232        assert!(output.contains("Preformatted text"));
1233    }
1234
1235    #[test]
1236    fn test_render_musicxml_section() {
1237        let input = "{start_of_musicxml}\n<score-partwise/>\n{end_of_musicxml}";
1238        let output = render(input);
1239        assert!(output.contains("[MusicXML]"));
1240    }
1241
1242    #[test]
1243    fn test_render_musicxml_section_with_label() {
1244        let input = "{start_of_musicxml: Score}\n<score-partwise/>\n{end_of_musicxml}";
1245        let output = render(input);
1246        assert!(output.contains("[MusicXML: Score]"));
1247    }
1248
1249    #[test]
1250    fn test_delegate_verbatim_no_chords() {
1251        let input = "{start_of_textblock}\n[Am]Not a chord\n{end_of_textblock}";
1252        let output = render(input);
1253        assert!(output.contains("[Am]Not a chord"));
1254    }
1255
1256    // -- inline markup rendering (plain text strips all tags) ------------------
1257
1258    #[test]
1259    fn test_markup_stripped_in_text_output() {
1260        let output = render("Hello <b>bold</b> world");
1261        assert!(output.contains("Hello bold world"));
1262        assert!(!output.contains("<b>"));
1263        assert!(!output.contains("</b>"));
1264    }
1265
1266    #[test]
1267    fn test_markup_stripped_with_chord() {
1268        let output = render("[Am]Hello <b>bold</b> world");
1269        assert!(output.contains("Am"));
1270        assert!(output.contains("Hello bold world"));
1271        assert!(!output.contains("<b>"));
1272    }
1273
1274    #[test]
1275    fn test_span_markup_stripped_in_text_output() {
1276        let output = render(r#"<span foreground="red">red text</span>"#);
1277        assert!(output.contains("red text"));
1278        assert!(!output.contains("<span"));
1279        assert!(!output.contains("foreground"));
1280    }
1281
1282    // --- Unicode display width alignment ---
1283
1284    #[test]
1285    fn test_render_fullwidth_cjk_alignment() {
1286        // Full-width CJK characters are 2 columns wide
1287        let input = "[C]日本語";
1288        let output = render(input);
1289        // "日本語" = 3×2 = 6 display columns, "C" = 1 → pad chord by 5
1290        assert_eq!(output, "C\n日本語\n");
1291    }
1292
1293    #[test]
1294    fn test_render_mixed_width_alignment() {
1295        // Mix of ASCII (width 1) and CJK (width 2)
1296        let input = "[Am]hello世界 [G]test";
1297        let output = render(input);
1298        // "hello世界 " = 5 + 4 + 1 = 10, "Am" = 2 → pad chord by 8
1299        // "test" = 4, "G" = 1 → pad chord by 3
1300        assert_eq!(output, "Am        G\nhello世界 test\n");
1301    }
1302
1303    #[test]
1304    fn test_render_image_placeholder() {
1305        let input = "{image: src=photo.jpg}";
1306        let output = render(input);
1307        assert!(output.contains("[Image: photo.jpg]"));
1308    }
1309
1310    #[test]
1311    fn test_render_image_placeholder_with_path() {
1312        let input = "{image: src=images/cover.png width=200}";
1313        let output = render(input);
1314        assert!(output.contains("[Image: images/cover.png]"));
1315    }
1316
1317    #[test]
1318    fn test_render_image_empty_src_suppressed() {
1319        let input = "{image}";
1320        let output = render(input);
1321        assert!(!output.contains("[Image"));
1322    }
1323
1324    #[test]
1325    fn test_render_image_empty_src_with_other_attrs_suppressed() {
1326        let input = "{image: width=200 height=100}";
1327        let output = render(input);
1328        assert!(!output.contains("[Image"));
1329    }
1330
1331    // -- Selector filtering integration (#320) --
1332
1333    #[test]
1334    fn test_selector_filtering_removes_non_matching_directive() {
1335        let input = "{title: Song}\n{textfont-piano: Courier}\n[Am]Hello";
1336        let song = chordsketch_core::parse(input).unwrap();
1337        let ctx = chordsketch_core::selector::SelectorContext::new(Some("guitar"), None);
1338        let filtered = ctx.filter_song(&song);
1339        // The piano textfont directive should be absent from the filtered song.
1340        let has_textfont = filtered.lines.iter().any(|l| {
1341            matches!(l, chordsketch_core::ast::Line::Directive(d) if d.kind == chordsketch_core::ast::DirectiveKind::TextFont)
1342        });
1343        assert!(
1344            !has_textfont,
1345            "piano textfont directive should be removed for guitar context"
1346        );
1347        let output = render_song(&filtered);
1348        assert!(output.contains("Hello"), "lyrics should survive filtering");
1349    }
1350
1351    #[test]
1352    fn test_selector_filtering_removes_section_with_contents() {
1353        let input = "{title: Song}\n{start_of_chorus-piano}\n[C]Piano only\n{end_of_chorus-piano}\n[Am]Guitar verse";
1354        let song = chordsketch_core::parse(input).unwrap();
1355        let ctx = chordsketch_core::selector::SelectorContext::new(Some("guitar"), None);
1356        let filtered = ctx.filter_song(&song);
1357        let output = render_song(&filtered);
1358        assert!(
1359            !output.contains("Piano only"),
1360            "piano chorus should be removed for guitar context"
1361        );
1362        assert!(
1363            output.contains("Guitar verse"),
1364            "unselectored content should remain"
1365        );
1366    }
1367
1368    // -- auto-inject ASCII diagram block (issue #1140) ----------------------------
1369
1370    #[test]
1371    fn test_diagrams_auto_inject_text() {
1372        let output = render("{diagrams}\n[Am]Hello");
1373        assert!(
1374            output.contains("[Chord Diagrams]"),
1375            "text output should include Chord Diagrams header"
1376        );
1377        assert!(output.contains("Am"), "Am ASCII diagram expected");
1378        // Am = x o 2 2 1 o (guitar open position)
1379        assert!(output.contains("x o"), "Am fret pattern expected");
1380    }
1381
1382    #[test]
1383    fn test_no_diagrams_suppresses_text_inject() {
1384        let output = render("{no_diagrams}\n[Am]Hello");
1385        assert!(
1386            !output.contains("[Chord Diagrams]"),
1387            "{{no_diagrams}} should suppress ASCII diagram block"
1388        );
1389    }
1390
1391    #[test]
1392    fn test_diagrams_off_suppresses_text_inject() {
1393        let output = render("{diagrams: off}\n[Am]Hello");
1394        assert!(
1395            !output.contains("[Chord Diagrams]"),
1396            "{{diagrams: off}} should suppress ASCII diagram block"
1397        );
1398    }
1399
1400    #[test]
1401    fn test_diagrams_piano_auto_inject_text() {
1402        let output = render("{diagrams: piano}\n[Am]Hello [C]world");
1403        assert!(
1404            output.contains("[Chord Diagrams]"),
1405            "piano instrument should include Chord Diagrams header"
1406        );
1407        // Text renderer emits chord name + key numbers (no ASCII art for piano).
1408        assert!(output.contains("Am:"), "Am entry expected");
1409        assert!(output.contains("C:"), "C entry expected");
1410        assert!(output.contains("keys"), "key list label expected");
1411    }
1412
1413    #[test]
1414    fn test_render_decomposed_diacritics_alignment() {
1415        // "e\u{0301}" is a decomposed e-acute (U+0065 + U+0301 combining acute).
1416        // The combining character has zero display width, so the word "cafe\u{0301}"
1417        // should have the same display width (4 columns) as "cafe".
1418        // This should produce identical alignment to composed "café".
1419        let input = "[Em]cafe\u{0301} [D]world";
1420        let output = render(input);
1421        // "cafe\u{0301} " = 5 display columns (4 + 0 + 1), "Em" = 2 → pad chord by 3
1422        // Matches composed form: "[Em]café [D]résumé" → "Em   D"
1423        assert_eq!(output, "Em   D\ncafe\u{0301} world\n");
1424    }
1425}