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-chordpro`) into
4//! formatted plain text with chords aligned above their corresponding lyrics.
5
6use chordsketch_chordpro::ast::{CommentStyle, DirectiveKind, Line, LyricsLine, Song};
7use chordsketch_chordpro::config::Config;
8use chordsketch_chordpro::notation::NotationKind;
9use chordsketch_chordpro::render_result::{
10    RenderResult, push_warning, validate_capo, validate_multiple_capo, validate_strict_key,
11};
12use chordsketch_chordpro::resolve_diagrams_instrument;
13use chordsketch_chordpro::transpose::{transpose_chord_with_style, transposed_key_prefers_flat};
14use chordsketch_chordpro::typography::{tempo_marking_for, unicode_accidentals};
15use unicode_width::UnicodeWidthStr;
16
17/// Maximum number of chorus recall directives allowed per song.
18/// Prevents output amplification from malicious inputs with many `{chorus}` lines.
19const MAX_CHORUS_RECALLS: usize = 1000;
20
21/// Maximum number of warnings the renderer will accumulate for a single
22/// render pass. Re-exported from the canonical location in
23/// `chordsketch-chordpro::render_result` so existing downstream callers can
24/// keep importing `chordsketch_render_text::MAX_WARNINGS` unchanged
25/// (issue #1874).
26pub use chordsketch_chordpro::render_result::MAX_WARNINGS;
27
28/// Render a [`Song`] AST to plain text.
29///
30/// The output format:
31/// - Title and subtitle are rendered as header lines.
32/// - Section markers (chorus, verse, bridge, tab) render as labeled headers.
33/// - Lyrics with chords produce two lines: chords above, lyrics below.
34/// - Lyrics without chords produce a single lyrics line.
35/// - Comments are rendered with style markers.
36/// - The `{chorus}` directive recalls (re-renders) the most recently defined
37///   chorus section. If no chorus has been defined yet, a `[Chorus]` marker
38///   is emitted instead.
39/// - Metadata directives (artist, key, capo, etc.) are silently consumed
40///   (they populate [`Song::metadata`] but do not appear in the text body).
41/// - Empty lines are preserved.
42#[must_use]
43pub fn render_song(song: &Song) -> String {
44    render_song_with_transpose(song, 0, &Config::defaults())
45}
46
47/// Render a [`Song`] AST to plain text with an additional CLI transposition offset.
48///
49/// The `cli_transpose` parameter is added to any in-file `{transpose}` directive
50/// values, allowing the CLI `--transpose` flag to combine with in-file directives.
51///
52/// Warnings are printed to stderr via `eprintln!`. Use
53/// [`render_song_with_warnings`] to capture them programmatically.
54#[must_use]
55pub fn render_song_with_transpose(song: &Song, cli_transpose: i8, config: &Config) -> String {
56    let result = render_song_with_warnings(song, cli_transpose, config);
57    for w in &result.warnings {
58        eprintln!("warning: {w}");
59    }
60    result.output
61}
62
63/// Render a [`Song`] AST to plain text, returning warnings programmatically.
64///
65/// This is the structured variant of [`render_song_with_transpose`]. Instead
66/// of printing warnings to stderr, they are collected into
67/// [`RenderResult::warnings`].
68#[must_use = "caller must check warnings in the returned RenderResult"]
69pub fn render_song_with_warnings(
70    song: &Song,
71    cli_transpose: i8,
72    config: &Config,
73) -> RenderResult<String> {
74    let mut warnings = Vec::new();
75    let output = render_song_impl(song, cli_transpose, config, &mut warnings);
76    RenderResult::with_warnings(output, warnings)
77}
78
79/// Internal implementation that renders a song and collects warnings.
80fn render_song_impl(
81    song: &Song,
82    cli_transpose: i8,
83    config: &Config,
84    warnings: &mut Vec<String>,
85) -> String {
86    // Apply song-level config overrides ({+config.KEY: VALUE} directives).
87    // The effective config is used for diagram instrument selection and
88    // strict-mode validation (validate_strict_key), consistent with the
89    // HTML and PDF renderers.
90    let song_overrides = song.config_overrides();
91    let song_config;
92    let _config = if song_overrides.is_empty() {
93        config
94    } else {
95        song_config = config
96            .clone()
97            .with_song_overrides(&song_overrides, warnings);
98        &song_config
99    };
100    // Extract song-level transpose delta from {+config.settings.transpose}.
101    // The base config transpose is already folded into cli_transpose by the caller.
102    let song_transpose_delta = Config::song_transpose_delta(&song_overrides);
103    let mut output = Vec::new();
104    let (combined_transpose, _) =
105        chordsketch_chordpro::transpose::combine_transpose(cli_transpose, song_transpose_delta);
106    let mut transpose_offset: i8 = combined_transpose;
107    // Stores the AST lines of the most recently defined chorus body.
108    // Re-rendered at recall time so the current transpose offset is applied.
109    let mut chorus_body: Vec<Line> = Vec::new();
110    // Temporary buffer for collecting chorus AST lines.
111    let mut chorus_buf: Option<Vec<Line>> = None;
112    let mut chorus_recall_count: usize = 0;
113
114    // #1971: parity with the PDF renderer (#1825). When inside a
115    // `{start_of_<tag>} … {end_of_<tag>}` pair for a notation block
116    // (ABC, Lilypond, MusicXML, SVG), discard every body line and
117    // emit a single structured warning at StartOf. Rendering the raw
118    // notation source as plain text (as this renderer did before)
119    // was just as unhelpful as in the PDF renderer — readers get a
120    // wall of `X:1` / `K:C` / `\relative c' {` with no context.
121    let mut in_notation_block: Option<NotationKind> = None;
122
123    // Instrument for the auto-inject ASCII diagram block.
124    // Set by {diagrams: guitar/ukulele/on}; cleared by {diagrams: off} / {no_diagrams}.
125    let default_instrument = _config
126        .get_path("diagrams.instrument")
127        .as_str()
128        .map(str::to_ascii_lowercase)
129        .unwrap_or_else(|| "guitar".to_string());
130    let mut auto_diagrams_instrument: Option<String> = None;
131
132    validate_capo(&song.metadata, warnings);
133    validate_multiple_capo(song, warnings);
134    validate_strict_key(&song.metadata, _config, warnings);
135    render_metadata(&song.metadata, &mut output);
136
137    for line in &song.lines {
138        // #1971: inside a notation block, discard every line until
139        // the matching EndOf directive. Mirrors the PDF renderer's
140        // skip-until-end window introduced in #1825/#1968.
141        if let Some(kind) = in_notation_block {
142            if let Line::Directive(d) = line {
143                if kind.is_end_directive(&d.kind) {
144                    in_notation_block = None;
145                }
146            }
147            continue;
148        }
149        match line {
150            Line::Lyrics(lyrics_line) => {
151                if let Some(buf) = chorus_buf.as_mut() {
152                    buf.push(line.clone());
153                }
154                let prefer_flat = transposed_key_prefers_flat(&song.metadata, transpose_offset);
155                render_lyrics(lyrics_line, transpose_offset, prefer_flat, &mut output);
156            }
157            Line::Directive(directive) => {
158                // Metadata directives are already rendered via song.metadata;
159                // skip them in the body to avoid duplicate output —
160                // except `{key}` / `{tempo}` / `{time}`, which are
161                // `[Nx] [Pos]` per spec (`chordpro.org/directives-
162                // key/`, `…tempo/`, `…time/`): every declaration
163                // applies forward from its position. Emit a positional
164                // marker so a reader can see *where* mid-song meta
165                // changes happen (Phase B of #2454; sister-site to
166                // `crates/render-html/src/lib.rs::render_song_body_into`
167                // and `packages/react/src/chordpro-jsx.tsx`).
168                if directive.kind.is_metadata() {
169                    if let Some(value) = directive
170                        .value
171                        .as_deref()
172                        .map(str::trim)
173                        .filter(|v| !v.is_empty())
174                    {
175                        match directive.kind {
176                            DirectiveKind::Key => {
177                                // Typeset `Bb` / `F#` with Unicode
178                                // accidentals — sister-site to the
179                                // React JSX walker + Rust HTML renderer.
180                                output.push(format!("[Key: {}]", unicode_accidentals(value)));
181                            }
182                            DirectiveKind::Tempo => {
183                                // Append the Italian tempo marking
184                                // (`Allegro`, `Andante`, …) when the
185                                // BPM matches a conventional band.
186                                let marking = value
187                                    .parse::<f32>()
188                                    .ok()
189                                    .and_then(tempo_marking_for)
190                                    .map(|m| format!(" ({m})"))
191                                    .unwrap_or_default();
192                                output.push(format!("[Tempo: {value} BPM{marking}]"));
193                            }
194                            DirectiveKind::Time => {
195                                output.push(format!("[Time: {value}]"));
196                            }
197                            _ => {}
198                        }
199                    }
200                    continue;
201                }
202                // #1971: handle notation block openers. Route the
203                // warning through `push_warning` (participates in the
204                // MAX_WARNINGS cap), emit an inline placeholder, and
205                // flip the skip window on. The section header still
206                // renders so readers see where the block was.
207                if let Some(kind) = NotationKind::from_start_directive(&directive.kind) {
208                    render_section_header(kind.label(), &directive.value, &mut output);
209                    let label = kind.label();
210                    let tag = kind.tag();
211                    push_warning(
212                        warnings,
213                        format!(
214                            "Text renderer does not support {label} blocks; body of the \
215                             `{{start_of_{tag}}} … {{end_of_{tag}}}` section has been \
216                             omitted. Use the HTML renderer for full {label} support.",
217                        ),
218                    );
219                    output.push(format!(
220                        "[{label} block omitted — use the HTML renderer to view it]",
221                    ));
222                    in_notation_block = Some(kind);
223                    continue;
224                }
225                if directive.kind == DirectiveKind::Diagrams {
226                    auto_diagrams_instrument = resolve_diagrams_instrument(
227                        directive.value.as_deref(),
228                        &default_instrument,
229                    );
230                    continue;
231                }
232                if directive.kind == DirectiveKind::NoDiagrams {
233                    auto_diagrams_instrument = None;
234                    continue;
235                }
236                if directive.kind == DirectiveKind::Transpose {
237                    // {transpose: N} sets the in-file transposition amount.
238                    // A missing or empty value silently resets to 0; only a
239                    // non-empty value that cannot be parsed as i8 emits a warning.
240                    let file_offset: i8 = match directive.value.as_deref() {
241                        None | Some("") => 0,
242                        Some(raw) => match raw.parse() {
243                            Ok(v) => v,
244                            Err(_) => {
245                                push_warning(
246                                    warnings,
247                                    format!(
248                                        "{{transpose}} value {raw:?} cannot be \
249                                         parsed as i8, ignored (using 0)"
250                                    ),
251                                );
252                                0
253                            }
254                        },
255                    };
256                    let (combined, saturated) = chordsketch_chordpro::transpose::combine_transpose(
257                        file_offset,
258                        cli_transpose,
259                    );
260                    if saturated {
261                        push_warning(
262                            warnings,
263                            format!(
264                                "transpose offset {file_offset} + {cli_transpose} \
265                                 exceeds i8 range, clamped to {combined}"
266                            ),
267                        );
268                    }
269                    transpose_offset = combined;
270                    continue;
271                }
272                match &directive.kind {
273                    DirectiveKind::StartOfChorus => {
274                        render_section_header("Chorus", &directive.value, &mut output);
275                        // Begin collecting chorus content lines.
276                        chorus_buf = Some(Vec::new());
277                    }
278                    DirectiveKind::EndOfChorus => {
279                        if let Some(buf) = chorus_buf.take() {
280                            chorus_body = buf;
281                        }
282                    }
283                    DirectiveKind::Chorus => {
284                        if chorus_recall_count < MAX_CHORUS_RECALLS {
285                            let prefer_flat =
286                                transposed_key_prefers_flat(&song.metadata, transpose_offset);
287                            render_chorus_recall(
288                                &directive.value,
289                                &chorus_body,
290                                transpose_offset,
291                                prefer_flat,
292                                &mut output,
293                                warnings,
294                            );
295                            chorus_recall_count += 1;
296                        } else if chorus_recall_count == MAX_CHORUS_RECALLS {
297                            push_warning(
298                                warnings,
299                                format!(
300                                    "chorus recall limit ({MAX_CHORUS_RECALLS}) reached, \
301                                     further recalls suppressed"
302                                ),
303                            );
304                            chorus_recall_count += 1;
305                        }
306                    }
307                    // All page-layout directives are intentionally excluded from the
308                    // chorus buffer — they must not be replayed on chorus recall.
309                    // Plain-text rendering produces no output for these directives
310                    // (parity with HTML and PDF renderers which do the same exclusion).
311                    DirectiveKind::NewPage
312                    | DirectiveKind::NewPhysicalPage
313                    | DirectiveKind::ColumnBreak
314                    | DirectiveKind::Columns => {}
315                    _ => {
316                        if let Some(buf) = chorus_buf.as_mut() {
317                            buf.push(line.clone());
318                        }
319                        let mut target = Vec::new();
320                        render_directive(directive, &mut target, warnings);
321                        output.extend(target);
322                    }
323                }
324            }
325            Line::Comment(style, text) => {
326                if let Some(buf) = chorus_buf.as_mut() {
327                    buf.push(line.clone());
328                }
329                render_comment(*style, text, &mut output);
330            }
331            Line::Empty => {
332                if let Some(buf) = chorus_buf.as_mut() {
333                    buf.push(line.clone());
334                }
335                output.push(String::new());
336            }
337        }
338    }
339
340    // Auto-inject ASCII diagram block when {diagrams} (or {diagrams: guitar/ukulele/piano}) was seen.
341    if let Some(ref instrument) = auto_diagrams_instrument {
342        if instrument == "piano" {
343            // Plain-text rendering of keyboard diagrams is not supported; emit a note
344            // listing the chord names so the reader knows which chords are in use.
345            let kbd_defines = song.keyboard_defines();
346            let voicings: Vec<_> = song
347                .used_chord_names()
348                .into_iter()
349                .filter_map(|name| {
350                    chordsketch_chordpro::lookup_keyboard_voicing(&name, &kbd_defines)
351                })
352                .collect();
353            if !voicings.is_empty() {
354                output.push(String::new());
355                output.push("[Chord Diagrams]".to_string());
356                for voicing in &voicings {
357                    output.push(format!(
358                        "  {}: keys {}",
359                        voicing.title(),
360                        voicing
361                            .keys
362                            .iter()
363                            .map(|k| k.to_string())
364                            .collect::<Vec<_>>()
365                            .join(" ")
366                    ));
367                }
368            }
369        } else {
370            let frets_shown = _config.get_path("diagrams.frets").as_f64().map_or(
371                chordsketch_chordpro::chord_diagram::DEFAULT_FRETS_SHOWN,
372                |n| (n as usize).max(1),
373            );
374            let defines = song.fretted_defines();
375            let diagrams: Vec<_> = song
376                .used_chord_names()
377                .into_iter()
378                .filter_map(|name| {
379                    chordsketch_chordpro::lookup_diagram(&name, &defines, instrument, frets_shown)
380                })
381                .collect();
382            if !diagrams.is_empty() {
383                output.push(String::new());
384                output.push("[Chord Diagrams]".to_string());
385                for diagram in &diagrams {
386                    output.push(String::new());
387                    for diagram_line in
388                        chordsketch_chordpro::chord_diagram::render_ascii(diagram).lines()
389                    {
390                        output.push(diagram_line.to_string());
391                    }
392                }
393            }
394        }
395    }
396
397    // Remove trailing empty lines, then add a final newline.
398    while output.last().is_some_and(|l| l.is_empty()) {
399        output.pop();
400    }
401
402    if output.is_empty() {
403        return String::new();
404    }
405
406    let mut result = output.join("\n");
407    result.push('\n');
408    result
409}
410
411/// Render multiple [`Song`]s to plain text, separated by a blank line.
412#[must_use]
413pub fn render_songs(songs: &[Song]) -> String {
414    render_songs_with_transpose(songs, 0, &Config::defaults())
415}
416
417/// Render multiple [`Song`]s to plain text with transposition.
418///
419/// Warnings are printed to stderr via `eprintln!`. Use
420/// [`render_songs_with_warnings`] to capture them programmatically.
421#[must_use]
422pub fn render_songs_with_transpose(songs: &[Song], cli_transpose: i8, config: &Config) -> String {
423    let result = render_songs_with_warnings(songs, cli_transpose, config);
424    for w in &result.warnings {
425        eprintln!("warning: {w}");
426    }
427    result.output
428}
429
430/// Render multiple [`Song`]s to plain text, returning warnings programmatically.
431///
432/// This is the structured variant of [`render_songs_with_transpose`]. Instead
433/// of printing warnings to stderr, they are collected into
434/// [`RenderResult::warnings`].
435#[must_use = "caller must check warnings in the returned RenderResult"]
436pub fn render_songs_with_warnings(
437    songs: &[Song],
438    cli_transpose: i8,
439    config: &Config,
440) -> RenderResult<String> {
441    let mut warnings = Vec::new();
442    let mut parts: Vec<String> = songs
443        .iter()
444        .map(|song| {
445            render_song_impl(song, cli_transpose, config, &mut warnings)
446                .trim_end()
447                .to_string()
448        })
449        .collect();
450    // Ensure the final output ends with a newline.
451    if let Some(last) = parts.last_mut() {
452        last.push('\n');
453    }
454    RenderResult::with_warnings(parts.join("\n\n"), warnings)
455}
456
457/// Parse a ChordPro source string and render it to plain text.
458///
459/// Returns `Ok(text)` on success, or the [`chordsketch_chordpro::ParseError`] if
460/// the input cannot be parsed.
461///
462/// For pre-parsed input, use [`render_song`] directly.
463#[must_use = "parse errors should be handled"]
464pub fn try_render(input: &str) -> Result<String, chordsketch_chordpro::ParseError> {
465    let song = chordsketch_chordpro::parse(input)?;
466    Ok(render_song(&song))
467}
468
469/// Parse a ChordPro source string and render it to plain text.
470///
471/// This is a convenience wrapper around [`try_render`] that converts parse
472/// errors into a human-readable error string. Because success and failure
473/// both return a `String`, callers **cannot** distinguish between them
474/// programmatically — use [`try_render`] if you need error handling.
475#[must_use]
476pub fn render(input: &str) -> String {
477    match try_render(input) {
478        Ok(text) => text,
479        Err(e) => format!(
480            "Parse error at line {} column {}: {}\n",
481            e.line(),
482            e.column(),
483            e.message
484        ),
485    }
486}
487
488// ---------------------------------------------------------------------------
489// Metadata header
490// ---------------------------------------------------------------------------
491
492/// Render the song metadata (title, subtitle) as a header block.
493///
494/// No trailing blank line is added — the document's own empty lines
495/// provide spacing between the metadata header and the song body.
496fn render_metadata(metadata: &chordsketch_chordpro::ast::Metadata, output: &mut Vec<String>) {
497    if let Some(title) = &metadata.title {
498        output.push(title.clone());
499    }
500    for subtitle in &metadata.subtitles {
501        output.push(subtitle.clone());
502    }
503}
504
505// ---------------------------------------------------------------------------
506// Lyrics rendering (chord-over-lyrics alignment)
507// ---------------------------------------------------------------------------
508
509/// Render a lyrics line with chords aligned above the lyrics.
510///
511/// If the line has chords, two lines are produced:
512///   1. A chord line with each chord positioned above its lyrics segment.
513///   2. The lyrics text.
514///
515/// If the line has no chords, only the lyrics text is emitted.
516///
517/// Alignment is based on Unicode display width (`UnicodeWidthStr::width()`),
518/// which correctly handles full-width CJK characters and other wide glyphs.
519fn render_lyrics(
520    lyrics_line: &LyricsLine,
521    transpose_offset: i8,
522    prefer_flat: bool,
523    output: &mut Vec<String>,
524) {
525    if !lyrics_line.has_chords() {
526        output.push(lyrics_line.text());
527        return;
528    }
529
530    let mut chord_line = String::new();
531    let mut lyric_line = String::new();
532
533    for segment in &lyrics_line.segments {
534        let transposed;
535        let chord_name = if transpose_offset != 0 {
536            if let Some(chord) = &segment.chord {
537                transposed = transpose_chord_with_style(chord, transpose_offset, prefer_flat);
538                transposed.display_name()
539            } else {
540                ""
541            }
542        } else {
543            segment.chord.as_ref().map_or("", |c| c.display_name())
544        };
545        let text = &segment.text;
546
547        let chord_len = UnicodeWidthStr::width(chord_name);
548        let text_len = UnicodeWidthStr::width(text.as_str());
549
550        // Write the chord (or equivalent spacing) on the chord line.
551        chord_line.push_str(chord_name);
552
553        // Write the text on the lyric line.
554        lyric_line.push_str(text);
555
556        // Ensure alignment: both lines must advance to at least the same column.
557        // If the chord is longer than the text, pad the lyric line.
558        // If the text is longer than the chord, pad the chord line.
559        // Add 1 space of padding after chord when chord >= text length,
560        // so chords don't run together.
561        if chord_len > 0 && chord_len >= text_len {
562            let padding = chord_len - text_len + 1;
563            lyric_line.extend(std::iter::repeat_n(' ', padding));
564            chord_line.push(' ');
565        } else if chord_len > 0 && text_len > chord_len {
566            let padding = text_len - chord_len;
567            chord_line.extend(std::iter::repeat_n(' ', padding));
568        }
569        // If chord_len == 0 (no chord), text just advances lyric_line naturally
570        // and chord_line needs to keep up.
571        if chord_len == 0 && text_len > 0 {
572            chord_line.extend(std::iter::repeat_n(' ', text_len));
573        }
574    }
575
576    output.push(chord_line.trim_end().to_string());
577    output.push(lyric_line.trim_end().to_string());
578}
579
580// ---------------------------------------------------------------------------
581// Directive rendering
582// ---------------------------------------------------------------------------
583
584/// Render a directive to text output.
585///
586/// - Section start directives produce a labeled header (e.g., "Chorus").
587/// - Section end directives are not rendered (they are structural markers).
588/// - Metadata directives are not rendered here (handled by `render_metadata`).
589/// - Page-layout directives (`{new_page}`, `{new_physical_page}`, `{column_break}`,
590///   `{columns}`) produce no output — plain text has no concept of pages or columns.
591/// - Unknown directives are silently ignored.
592fn render_directive(
593    directive: &chordsketch_chordpro::ast::Directive,
594    output: &mut Vec<String>,
595    warnings: &mut Vec<String>,
596) {
597    match &directive.kind {
598        DirectiveKind::StartOfChorus => {
599            render_section_header("Chorus", &directive.value, output);
600        }
601        DirectiveKind::StartOfVerse => {
602            render_section_header("Verse", &directive.value, output);
603        }
604        DirectiveKind::StartOfBridge => {
605            render_section_header("Bridge", &directive.value, output);
606        }
607        DirectiveKind::StartOfTab => {
608            render_section_header("Tab", &directive.value, output);
609        }
610        DirectiveKind::StartOfGrid => {
611            // `directive.value` may carry an attribute payload
612            // (`{start_of_grid shape="..." label="Intro"}`) or a
613            // legacy colon-form label (`{start_of_grid: Intro}`).
614            // Prefer the structured `label="..."` attribute when
615            // present, fall back to the colon-form value when it
616            // doesn't contain `=`, otherwise suppress entirely so
617            // the header reads `[Grid]` (not the raw attribute
618            // string). Sister-site to the HTML and PDF renderers.
619            let label_value = directive.value.as_ref().and_then(|v| {
620                if let Some(label) = chordsketch_chordpro::grid::extract_grid_label(v) {
621                    Some(label)
622                } else if !v.contains('=') {
623                    Some(v.clone())
624                } else {
625                    None
626                }
627            });
628            render_section_header("Grid", &label_value, output);
629        }
630        // Notation block openers (ABC / Lilypond / MusicXML / SVG) are
631        // handled in the main render loop's notation-block skip window
632        // so they never reach this function. The `{start_of_textblock}`
633        // directive is NOT a notation block — it's documented text so
634        // it renders its section header here like any other section.
635        DirectiveKind::StartOfTextblock => {
636            render_section_header("Textblock", &directive.value, output);
637        }
638        DirectiveKind::StartOfSection(section_name) => {
639            // Capitalize the first letter of the section name for display.
640            let label = chordsketch_chordpro::capitalize(section_name);
641            render_section_header(&label, &directive.value, output);
642        }
643        DirectiveKind::Image(attrs) if attrs.has_src() => {
644            // Validate the src for parity with the HTML renderer (#1832,
645            // renderer-parity.md §Validation Parity). Dangerous URI
646            // schemes such as `javascript:` and `file:///etc/passwd`
647            // never belong in rendered output — text output is not
648            // executed, but embedding such strings would mislead any
649            // tool or human that copies them downstream.
650            if !chordsketch_chordpro::image_path::is_safe_image_src(&attrs.src) {
651                push_warning(
652                    warnings,
653                    format!(
654                        "Image src {:?} rejected by sanitizer; omitted from text output",
655                        attrs.src
656                    ),
657                );
658            } else {
659                output.push(format!("[Image: {}]", attrs.src));
660            }
661        }
662        DirectiveKind::Image(_) => {}
663        // Page-layout directives are intentionally no-ops in plain-text output:
664        // plain text has no concept of pages, columns, or column breaks.
665        // Explicit arms here make the omission visible to future contributors
666        // (renderer-parity.md requires every directive to have an explicit arm).
667        DirectiveKind::NewPage
668        | DirectiveKind::NewPhysicalPage
669        | DirectiveKind::ColumnBreak
670        | DirectiveKind::Columns => {}
671        // End-of-section, metadata, and unknown directives produce no output.
672        _ => {}
673    }
674}
675
676/// Render a `{chorus}` recall directive.
677///
678/// Re-renders the stored chorus AST lines with the current transpose offset,
679/// so chords are transposed correctly even if `{transpose}` changed after
680/// the chorus was defined.
681fn render_chorus_recall(
682    value: &Option<String>,
683    chorus_body: &[Line],
684    transpose_offset: i8,
685    prefer_flat: bool,
686    output: &mut Vec<String>,
687    warnings: &mut Vec<String>,
688) {
689    render_section_header("Chorus", value, output);
690    for line in chorus_body {
691        match line {
692            Line::Lyrics(lyrics) => render_lyrics(lyrics, transpose_offset, prefer_flat, output),
693            Line::Comment(style, text) => render_comment(*style, text, output),
694            Line::Empty => output.push(String::new()),
695            Line::Directive(d) if !d.kind.is_metadata() => {
696                render_directive(d, output, warnings);
697            }
698            _ => {}
699        }
700    }
701}
702
703/// Render a section header like "Chorus" or "Verse 1".
704fn render_section_header(label: &str, value: &Option<String>, output: &mut Vec<String>) {
705    match value {
706        Some(v) if !v.is_empty() => output.push(format!("[{label}: {v}]")),
707        _ => output.push(format!("[{label}]")),
708    }
709}
710
711// ---------------------------------------------------------------------------
712// Comment rendering
713// ---------------------------------------------------------------------------
714
715/// Render a comment with its style marker.
716///
717/// - Normal comments:    `(comment text)`
718/// - Italic comments:    `(*comment text*)`
719/// - Boxed comments:     `[comment text]`
720/// - Highlight comments: `<<comment text>>`
721///
722/// `{highlight}` shares its text payload with `{comment}` per spec but
723/// renders with a distinct delimiter so the text-pipeline output is
724/// still able to round-trip the original directive choice. Sister-site
725/// to the HTML renderer's `comment--highlight` class and the PDF
726/// renderer's bold-weight variant.
727fn render_comment(style: CommentStyle, text: &str, output: &mut Vec<String>) {
728    match style {
729        CommentStyle::Normal => output.push(format!("({text})")),
730        CommentStyle::Italic => output.push(format!("(*{text}*)")),
731        CommentStyle::Boxed => output.push(format!("[{text}]")),
732        CommentStyle::Highlight => output.push(format!("<<{text}>>")),
733    }
734}
735
736// ===========================================================================
737// Tests
738// ===========================================================================
739
740#[cfg(test)]
741mod tests {
742    use super::*;
743
744    #[test]
745    fn test_render_empty() {
746        assert_eq!(render(""), "");
747    }
748
749    #[test]
750    fn test_render_title_only() {
751        let input = "{title: Amazing Grace}";
752        let output = render(input);
753        assert_eq!(output, "Amazing Grace\n");
754    }
755
756    #[test]
757    fn test_render_title_and_subtitle() {
758        let input = "{title: Amazing Grace}\n{subtitle: Traditional}";
759        let output = render(input);
760        assert_eq!(output, "Amazing Grace\nTraditional\n");
761    }
762
763    #[test]
764    fn test_render_plain_lyrics() {
765        let input = "Hello world\nSecond line";
766        let output = render(input);
767        assert_eq!(output, "Hello world\nSecond line\n");
768    }
769
770    #[test]
771    fn test_render_lyrics_with_chords() {
772        let input = "[Am]Hello [G]world";
773        let output = render(input);
774        assert_eq!(output, "Am    G\nHello world\n");
775    }
776
777    #[test]
778    fn test_render_chord_longer_than_text() {
779        // Chord "Cmaj7" is 5 chars, text "I" is 1 char
780        let input = "[Cmaj7]I [G]see";
781        let output = render(input);
782        assert_eq!(output, "Cmaj7 G\nI     see\n");
783    }
784
785    #[test]
786    fn test_render_chorus_section() {
787        let input = "{start_of_chorus}\n[G]La la la\n{end_of_chorus}";
788        let output = render(input);
789        assert_eq!(output, "[Chorus]\nG\nLa la la\n");
790    }
791
792    #[test]
793    fn test_render_verse_with_label() {
794        let input = "{start_of_verse: Verse 1}\nSome lyrics\n{end_of_verse}";
795        let output = render(input);
796        assert_eq!(output, "[Verse: Verse 1]\nSome lyrics\n");
797    }
798
799    #[test]
800    fn test_render_comment_normal() {
801        let input = "{comment: This is a comment}";
802        let output = render(input);
803        assert_eq!(output, "(This is a comment)\n");
804    }
805
806    #[test]
807    fn test_render_comment_italic() {
808        let input = "{comment_italic: Softly}";
809        let output = render(input);
810        assert_eq!(output, "(*Softly*)\n");
811    }
812
813    #[test]
814    fn test_render_comment_box() {
815        let input = "{comment_box: Important}";
816        let output = render(input);
817        assert_eq!(output, "[Important]\n");
818    }
819
820    #[test]
821    fn test_render_comment_highlight() {
822        // `{highlight}` is spec's stronger sibling of `{comment}` —
823        // distinct delimiter so round-trips can recover the directive
824        // choice. Sister to the HTML renderer's `comment--highlight`
825        // class and the PDF renderer's bold variant.
826        let input = "{highlight: Watch out}";
827        let output = render(input);
828        assert_eq!(output, "<<Watch out>>\n");
829    }
830
831    #[test]
832    fn test_render_empty_lines_preserved() {
833        let input = "Line one\n\nLine two";
834        let output = render(input);
835        assert_eq!(output, "Line one\n\nLine two\n");
836    }
837
838    /// Regression for the parallel-review High finding: render
839    /// surfaces must apply song-wide canonical chord spelling
840    /// (sister-site to the wasm path's `transpose(song, …)`),
841    /// not the legacy per-chord style preservation. C +3 → Eb
842    /// (flat side); a source-side `D#` chord becomes `Gb`.
843    #[test]
844    fn test_transposed_chord_uses_canonical_spelling() {
845        let song = chordsketch_chordpro::parse("{key: C}\n[D#]hi").unwrap();
846        let result =
847            render_song_with_warnings(&song, 3, &chordsketch_chordpro::config::Config::defaults());
848        // The flat-side spelling Gb should appear; the
849        // sharp-side F# should NOT (the source spelled D# but
850        // the song lands in Eb-major, so flats are canonical).
851        assert!(
852            result.output.contains("G\u{266D}") || result.output.contains("Gb"),
853            "expected flat-side `Gb` chord; got: {}",
854            result.output
855        );
856        assert!(
857            !result.output.contains("F#"),
858            "must not emit sharp-side `F#` for a flat-side song; got: {}",
859            result.output
860        );
861    }
862
863    #[test]
864    fn test_render_metadata_not_duplicated() {
865        // Header-only metadata (`{artist}`) is still suppressed from
866        // the body — only `{title}` / `{subtitle}` make it into the
867        // text-render header today (see `render_metadata`). `{key}`
868        // / `{tempo}` / `{time}` are now surfaced *inline* at their
869        // source position because the ChordPro spec marks them as
870        // `[Nx] [Pos]` — every declaration applies forward from
871        // where it appears. Phase B of #2454.
872        let input = "{title: Test}\n{artist: Someone}\n{key: G}\nLyrics here";
873        let output = render(input);
874        assert_eq!(output, "Test\n[Key: G]\nLyrics here\n");
875    }
876
877    /// Inline marker for every `[Pos]` metadata directive — sister-site
878    /// to the React JSX walker (`packages/react/src/chordpro-jsx.tsx`)
879    /// and the Rust HTML renderer (`crates/render-html/src/lib.rs`)
880    /// per `.claude/rules/renderer-parity.md`.
881    #[test]
882    fn test_inline_meta_markers_at_position() {
883        let input = "[G]first\n{tempo: 120}\n[C]middle\n{time: 6/8}\n[D]end";
884        let output = render(input);
885        assert!(
886            output.contains("[Tempo: 120 BPM (Allegro)]"),
887            "expected inline tempo marker with Italian marking; got:\n{output}"
888        );
889        assert!(
890            output.contains("[Time: 6/8]"),
891            "expected inline time marker; got:\n{output}"
892        );
893        // Markers appear in source order, not collapsed.
894        let tempo_idx = output.find("[Tempo:").unwrap();
895        let time_idx = output.find("[Time:").unwrap();
896        assert!(tempo_idx < time_idx, "markers must follow source order");
897    }
898
899    /// Empty / whitespace-only values silently drop the marker
900    /// instead of emitting a confusing "[Key: ]" empty bracket.
901    #[test]
902    fn test_inline_meta_marker_skipped_for_empty_value() {
903        let output = render("{key:}\n[G]hi");
904        assert!(
905            !output.contains("[Key:"),
906            "empty {{key}} must not produce a marker; got:\n{output}"
907        );
908    }
909
910    #[test]
911    fn test_render_full_song() {
912        let input = "\
913{title: Amazing Grace}
914{subtitle: Traditional}
915{key: G}
916
917{start_of_verse}
918[G]Amazing [G7]grace, how [C]sweet the [G]sound
919[G]That saved a [Em]wretch like [D]me
920{end_of_verse}
921
922{start_of_chorus}
923[G]I once was [G7]lost, but [C]now am [G]found
924{end_of_chorus}";
925        let output = render(input);
926        // Just verify it doesn't panic and produces non-empty output
927        assert!(!output.is_empty());
928        assert!(output.contains("Amazing Grace"));
929        assert!(output.contains("[Verse]"));
930        assert!(output.contains("[Chorus]"));
931    }
932
933    #[test]
934    fn test_render_song_api() {
935        let song = chordsketch_chordpro::parse("{title: Test}\n[Am]Hello").unwrap();
936        let output = render_song(&song);
937        assert!(output.contains("Test"));
938        assert!(output.contains("Am"));
939        assert!(output.contains("Hello"));
940    }
941
942    #[test]
943    fn test_render_chord_only_segment() {
944        // A chord at end of line with no following text
945        let input = "[Am]Hello [G]";
946        let output = render(input);
947        assert!(output.contains("Am"));
948        assert!(output.contains("G"));
949        assert!(output.contains("Hello"));
950    }
951
952    #[test]
953    fn test_render_bridge_section() {
954        let input = "{start_of_bridge}\nBridge lyrics\n{end_of_bridge}";
955        let output = render(input);
956        assert_eq!(output, "[Bridge]\nBridge lyrics\n");
957    }
958
959    #[test]
960    fn test_render_tab_section() {
961        let input = "{start_of_tab}\ne|---0---|\n{end_of_tab}";
962        let output = render(input);
963        assert_eq!(output, "[Tab]\ne|---0---|\n");
964    }
965
966    // --- Issue #65: Unicode alignment ---
967
968    #[test]
969    fn test_render_multibyte_lyrics_alignment() {
970        // Japanese text: each char is 3 bytes, 1 code point, but 2 columns wide.
971        let input = "[Am]こんにちは [G]世界";
972        let output = render(input);
973        // "こんにちは " = 5×2 + 1 = 11 display columns, "Am" = 2 → pad chord by 9
974        // "世界" = 2×2 = 4 display columns, "G" = 1 → pad chord by 3
975        assert_eq!(output, "Am         G\nこんにちは 世界\n");
976    }
977
978    #[test]
979    fn test_render_accented_lyrics_alignment() {
980        let input = "[Em]café [D]résumé";
981        let output = render(input);
982        assert_eq!(output, "Em   D\ncafé résumé\n");
983    }
984
985    // --- Issue #66: Text before first chord ---
986
987    #[test]
988    fn test_render_text_before_first_chord() {
989        let input = "Hello [Am]world";
990        let output = render(input);
991        assert_eq!(output, "      Am\nHello world\n");
992    }
993
994    #[test]
995    fn test_render_text_before_first_chord_multiple() {
996        let input = "I say [Am]hello [G]world";
997        let output = render(input);
998        assert_eq!(output, "      Am    G\nI say hello world\n");
999    }
1000
1001    // --- Issue #67: try_render API ---
1002
1003    #[test]
1004    fn test_try_render_success() {
1005        let result = try_render("{title: Test}\n[Am]Hello");
1006        assert!(result.is_ok());
1007        let text = result.unwrap();
1008        assert!(text.contains("Test"));
1009        assert!(text.contains("Am"));
1010    }
1011
1012    #[test]
1013    fn test_try_render_parse_error() {
1014        let result = try_render("{title: unclosed");
1015        assert!(result.is_err());
1016        let err = result.unwrap_err();
1017        assert_eq!(err.line(), 1);
1018    }
1019
1020    #[test]
1021    fn test_render_grid_section() {
1022        let input = "{start_of_grid}\n| Am . | C . |\n{end_of_grid}";
1023        let output = render(input);
1024        assert_eq!(output, "[Grid]\n| Am . | C . |\n");
1025    }
1026
1027    #[test]
1028    fn test_render_grid_section_with_label() {
1029        let input = "{start_of_grid: Intro}\n| Am . | C . |\n{end_of_grid}";
1030        let output = render(input);
1031        assert_eq!(output, "[Grid: Intro]\n| Am . | C . |\n");
1032    }
1033
1034    #[test]
1035    fn test_render_grid_short_alias() {
1036        let input = "{sog}\n| G . | D . |\n{eog}";
1037        let output = render(input);
1038        assert_eq!(output, "[Grid]\n| G . | D . |\n");
1039    }
1040
1041    // --- Custom sections (#108) ---
1042
1043    #[test]
1044    fn test_render_custom_section_intro() {
1045        let input = "{start_of_intro}\n[Am]Da da da\n{end_of_intro}";
1046        let output = render(input);
1047        assert!(output.contains("[Intro]"));
1048        assert!(output.contains("Am"));
1049        assert!(output.contains("Da da da"));
1050    }
1051
1052    #[test]
1053    fn test_render_custom_section_with_label() {
1054        let input = "{start_of_intro: Guitar}\nSome notes\n{end_of_intro}";
1055        let output = render(input);
1056        assert_eq!(output, "[Intro: Guitar]\nSome notes\n");
1057    }
1058
1059    #[test]
1060    fn test_render_custom_section_outro() {
1061        let input = "{start_of_outro}\nFinal notes\n{end_of_outro}";
1062        let output = render(input);
1063        assert!(output.contains("[Outro]"));
1064    }
1065
1066    #[test]
1067    fn test_render_custom_section_solo() {
1068        let input = "{start_of_solo}\n[Em]Solo line\n{end_of_solo}";
1069        let output = render(input);
1070        assert!(output.contains("[Solo]"));
1071    }
1072}
1073
1074#[cfg(test)]
1075mod multi_song_tests {
1076    use super::*;
1077
1078    #[test]
1079    fn test_render_songs_two_songs() {
1080        let songs = chordsketch_chordpro::parse_multi(
1081            "{title: Song One}\n[Am]Hello\n{new_song}\n{title: Song Two}\n[G]World",
1082        )
1083        .unwrap();
1084        let output = render_songs(&songs);
1085        assert!(output.contains("Song One"));
1086        assert!(output.contains("Am"));
1087        assert!(output.contains("Hello"));
1088        assert!(output.contains("Song Two"));
1089        assert!(output.contains("G\nWorld"));
1090        // Two songs are separated by exactly one blank line (double newline).
1091        assert!(output.contains("\n\n"));
1092        // Must NOT contain triple newline.
1093        assert!(
1094            !output.contains("\n\n\n"),
1095            "Should not have triple newline between songs"
1096        );
1097    }
1098
1099    #[test]
1100    fn test_render_songs_single_song() {
1101        let songs = chordsketch_chordpro::parse_multi("{title: Only One}\nLyrics").unwrap();
1102        let output = render_songs(&songs);
1103        assert_eq!(output, render_song(&songs[0]));
1104    }
1105
1106    #[test]
1107    fn test_render_songs_with_transpose() {
1108        let songs =
1109            chordsketch_chordpro::parse_multi("{title: S1}\n[C]Do\n{new_song}\n{title: S2}\n[G]Re")
1110                .unwrap();
1111        let output = render_songs_with_transpose(&songs, 2, &Config::defaults());
1112        // C+2=D, G+2=A
1113        assert!(output.contains("D\nDo"));
1114        assert!(output.contains("A\nRe"));
1115    }
1116}
1117
1118#[cfg(test)]
1119mod transpose_tests {
1120    use super::*;
1121
1122    #[test]
1123    fn test_transpose_directive_up_2() {
1124        let input = "{transpose: 2}\n[G]Hello [C]world";
1125        let song = chordsketch_chordpro::parse(input).unwrap();
1126        let output = render_song(&song);
1127        // G+2=A, C+2=D
1128        assert_eq!(output, "A     D\nHello world\n");
1129    }
1130
1131    #[test]
1132    fn test_transpose_directive_down_3() {
1133        let input = "{transpose: -3}\n[Am]Hello [Em]world";
1134        let song = chordsketch_chordpro::parse(input).unwrap();
1135        let output = render_song(&song);
1136        // Am-3=F#m, Em-3=C#m
1137        assert_eq!(output, "F#m   C#m\nHello world\n");
1138    }
1139
1140    #[test]
1141    fn test_transpose_directive_replaces_previous() {
1142        let input = "{transpose: 2}\n[G]First\n{transpose: -1}\n[G]Second";
1143        let song = chordsketch_chordpro::parse(input).unwrap();
1144        let output = render_song(&song);
1145        // First line: G+2=A, Second line: G-1=F#
1146        assert!(output.contains("A\nFirst"));
1147        assert!(output.contains("F#\nSecond"));
1148    }
1149
1150    #[test]
1151    fn test_transpose_directive_zero_resets() {
1152        let input = "{transpose: 5}\n[C]Up\n{transpose: 0}\n[C]Normal";
1153        let song = chordsketch_chordpro::parse(input).unwrap();
1154        let output = render_song(&song);
1155        // First line: C+5=F, Second line: C+0=C
1156        assert!(output.contains("F\nUp"));
1157        assert!(output.contains("C\nNormal"));
1158    }
1159
1160    #[test]
1161    fn test_transpose_directive_with_cli_offset() {
1162        let input = "{transpose: 2}\n[C]Hello";
1163        let song = chordsketch_chordpro::parse(input).unwrap();
1164        let output = render_song_with_transpose(&song, 3, &Config::defaults());
1165        // In-file 2 + CLI 3 = 5 total. C+5=F
1166        assert!(output.contains("F\nHello"));
1167    }
1168
1169    #[test]
1170    fn test_cli_transpose_without_directive() {
1171        let input = "[G]Hello [C]world";
1172        let song = chordsketch_chordpro::parse(input).unwrap();
1173        let output = render_song_with_transpose(&song, 2, &Config::defaults());
1174        // CLI offset only: G+2=A, C+2=D
1175        assert_eq!(output, "A     D\nHello world\n");
1176    }
1177
1178    #[test]
1179    fn test_transpose_directive_replaces_with_cli_additive() {
1180        let input = "{transpose: 2}\n[C]First\n{transpose: -1}\n[C]Second";
1181        let song = chordsketch_chordpro::parse(input).unwrap();
1182        let output = render_song_with_transpose(&song, 1, &Config::defaults());
1183        // First: 2+1=3, C+3=D#. Second: -1+1=0, C+0=C
1184        assert!(output.contains("D#\nFirst"));
1185        assert!(output.contains("C\nSecond"));
1186    }
1187
1188    #[test]
1189    fn test_transpose_no_chord_lyrics_unaffected() {
1190        let input = "{transpose: 5}\nPlain lyrics no chords";
1191        let song = chordsketch_chordpro::parse(input).unwrap();
1192        let output = render_song(&song);
1193        assert_eq!(output, "Plain lyrics no chords\n");
1194    }
1195
1196    #[test]
1197    fn test_transpose_invalid_value_treated_as_zero() {
1198        let input = "{transpose: abc}\n[G]Hello";
1199        let song = chordsketch_chordpro::parse(input).unwrap();
1200        let result =
1201            render_song_with_warnings(&song, 0, &chordsketch_chordpro::config::Config::defaults());
1202        // Invalid value -> treated as 0
1203        assert!(result.output.contains("G\nHello"));
1204        assert!(
1205            result.warnings.iter().any(|w| w.contains("\"abc\"")),
1206            "expected warning about unparseable value, got: {:?}",
1207            result.warnings
1208        );
1209    }
1210
1211    #[test]
1212    fn test_transpose_out_of_i8_range_emits_warning() {
1213        // 999 cannot be represented as i8; should fall back to 0 with a warning
1214        let input = "{transpose: 999}\n[G]Hello";
1215        let song = chordsketch_chordpro::parse(input).unwrap();
1216        let result =
1217            render_song_with_warnings(&song, 0, &chordsketch_chordpro::config::Config::defaults());
1218        assert!(
1219            result.output.contains("G\nHello"),
1220            "chord should be untransposed"
1221        );
1222        assert!(
1223            result.warnings.iter().any(|w| w.contains("\"999\"")),
1224            "expected warning about out-of-range value, got: {:?}",
1225            result.warnings
1226        );
1227    }
1228
1229    #[test]
1230    fn test_transpose_no_value_treated_as_zero() {
1231        let input = "{transpose}\n[G]Hello";
1232        let song = chordsketch_chordpro::parse(input).unwrap();
1233        let result =
1234            render_song_with_warnings(&song, 0, &chordsketch_chordpro::config::Config::defaults());
1235        // No value -> silently treated as 0, no warning emitted.
1236        assert!(result.output.contains("G\nHello"));
1237        assert!(
1238            result.warnings.is_empty(),
1239            "missing {{transpose}} value should not emit a warning; got: {:?}",
1240            result.warnings
1241        );
1242    }
1243
1244    #[test]
1245    fn test_transpose_whitespace_value_treated_as_zero() {
1246        // {transpose:   } with whitespace-only value should silently reset to 0,
1247        // no warning emitted. The parser trims whitespace → Some(""), which the
1248        // Some("") arm converts to 0.
1249        let input = "{transpose:   }\n[G]Hello";
1250        let song = chordsketch_chordpro::parse(input).unwrap();
1251        let result =
1252            render_song_with_warnings(&song, 0, &chordsketch_chordpro::config::Config::defaults());
1253        assert!(
1254            result.output.contains("G\nHello"),
1255            "chord should be untransposed"
1256        );
1257        assert!(
1258            result.warnings.is_empty(),
1259            "whitespace-only {{transpose}} value should not emit a warning; got: {:?}",
1260            result.warnings
1261        );
1262    }
1263
1264    // --- Issue #109: {chorus} recall ---
1265
1266    #[test]
1267    fn test_render_chorus_recall_basic() {
1268        let input = "\
1269{start_of_chorus}
1270[G]La la la
1271{end_of_chorus}
1272
1273{start_of_verse}
1274Some verse
1275{end_of_verse}
1276
1277{chorus}";
1278        let output = render(input);
1279        // The chorus content should appear twice: once in the original and once recalled
1280        assert_eq!(
1281            output,
1282            "[Chorus]\nG\nLa la la\n\n[Verse]\nSome verse\n\n[Chorus]\nG\nLa la la\n"
1283        );
1284    }
1285
1286    #[test]
1287    fn test_render_chorus_recall_with_label() {
1288        let input = "\
1289{start_of_chorus}
1290Sing along
1291{end_of_chorus}
1292
1293{chorus: Repeat}";
1294        let output = render(input);
1295        assert!(output.contains("[Chorus: Repeat]"));
1296        assert_eq!(
1297            output,
1298            "[Chorus]\nSing along\n\n[Chorus: Repeat]\nSing along\n"
1299        );
1300    }
1301
1302    #[test]
1303    fn test_render_chorus_recall_no_chorus_defined() {
1304        // When {chorus} is used before any chorus is defined, just show the header
1305        let input = "{chorus}";
1306        let output = render(input);
1307        assert_eq!(output, "[Chorus]\n");
1308    }
1309
1310    #[test]
1311    fn test_render_chorus_recall_multiple() {
1312        let input = "\
1313{start_of_chorus}
1314Chorus line
1315{end_of_chorus}
1316{chorus}
1317{chorus}";
1318        let output = render(input);
1319        // Original chorus + two recalls
1320        assert_eq!(
1321            output,
1322            "[Chorus]\nChorus line\n[Chorus]\nChorus line\n[Chorus]\nChorus line\n"
1323        );
1324    }
1325
1326    #[test]
1327    fn test_render_chorus_recall_uses_latest() {
1328        // If there are multiple chorus definitions, recall uses the latest
1329        let input = "\
1330{start_of_chorus}
1331First chorus
1332{end_of_chorus}
1333
1334{start_of_chorus}
1335Second chorus
1336{end_of_chorus}
1337
1338{chorus}";
1339        let output = render(input);
1340        // The recall should use "Second chorus", not "First chorus"
1341        assert!(output.ends_with("[Chorus]\nSecond chorus\n"));
1342    }
1343
1344    #[test]
1345    fn test_chorus_recall_applies_current_transpose() {
1346        // Chorus defined with no transpose, recalled after {transpose: 2}.
1347        // G should become A in the recalled chorus.
1348        let input = "\
1349{start_of_chorus}
1350[G]La la
1351{end_of_chorus}
1352{transpose: 2}
1353{chorus}";
1354        let output = render(input);
1355        // Original chorus has [G], recalled chorus should have [A].
1356        // The recalled chorus should show "A" (G+2) not "G".
1357        // The output has chord on one line and lyrics on next.
1358        let recall_idx = output.rfind("[Chorus]").expect("should have recall");
1359        let recall_section = &output[recall_idx..];
1360        assert!(
1361            recall_section.contains('A') && !recall_section.contains('G'),
1362            "recalled chorus should have transposed chord A (not G), got:\n{recall_section}"
1363        );
1364    }
1365
1366    #[test]
1367    fn test_chorus_recall_limit_exceeded() {
1368        // Generate input with more chorus recalls than the limit.
1369        let mut input = String::from("{start_of_chorus}\nChorus\n{end_of_chorus}\n");
1370        for _ in 0..1005 {
1371            input.push_str("{chorus}\n");
1372        }
1373        let output = render(&input);
1374        // Count occurrences of the chorus content (excluding the original).
1375        let recall_count = output.matches("[Chorus]\nChorus").count() - 1; // subtract original
1376        assert_eq!(
1377            recall_count,
1378            super::MAX_CHORUS_RECALLS,
1379            "should stop at MAX_CHORUS_RECALLS"
1380        );
1381    }
1382
1383    #[test]
1384    fn test_page_control_not_replayed_in_chorus_recall() {
1385        // Page-control directives inside a chorus definition must NOT appear in
1386        // {chorus} recall output. This mirrors the equivalent test in the HTML and
1387        // PDF renderers (renderer-parity.md).
1388        let input = "\
1389{start_of_chorus}
1390[G]Chorus line
1391{new_page}
1392{column_break}
1393{columns: 2}
1394{end_of_chorus}
1395{chorus}";
1396        let output = render(input);
1397        // The recalled chorus must contain the lyric content …
1398        assert!(
1399            output.contains("G"),
1400            "chord from chorus must appear in recall: {output}"
1401        );
1402        assert!(
1403            output.contains("Chorus line"),
1404            "lyric from chorus must appear in recall: {output}"
1405        );
1406        // … but must NOT contain any page-control directive text.
1407        // (Page-control directives produce no text output in the text renderer,
1408        //  so if they were erroneously replayed, the output would be unchanged;
1409        //  the key assertion is that the chorus body stored during collection
1410        //  does not include the directive AST nodes and cause extra empty lines.)
1411        let chorus_section_lines: Vec<&str> = output.lines().collect();
1412        // The definition renders: [Chorus] header + chord line + lyric line.
1413        // The recall renders: [Chorus] header + chord line + lyric line again.
1414        // Total: 6 non-empty lines (3 original + 3 recall). No extra blank lines
1415        // introduced by replayed page-control directives.
1416        let non_empty_count = chorus_section_lines
1417            .iter()
1418            .filter(|l| !l.is_empty())
1419            .count();
1420        assert_eq!(
1421            non_empty_count, 6,
1422            "expected 6 non-empty output lines (3 original + 3 recall), got {non_empty_count}; \
1423             output:\n{output}"
1424        );
1425    }
1426}
1427
1428#[cfg(test)]
1429mod delegate_tests {
1430    use super::*;
1431
1432    // -- Notation blocks (#1971): body is skipped, warning is captured,
1433    //    placeholder line renders. Parity with the PDF renderer's
1434    //    behaviour from #1825/#1968.
1435
1436    #[test]
1437    fn test_render_abc_section() {
1438        let input = "{start_of_abc}\nX:1\nK:G\n{end_of_abc}";
1439        let output = render(input);
1440        assert!(output.contains("[ABC]"));
1441        assert!(
1442            !output.contains("X:1"),
1443            "ABC body must not leak into text output; got:\n{output}"
1444        );
1445        assert!(output.contains("[ABC block omitted"));
1446    }
1447
1448    #[test]
1449    fn test_render_abc_section_with_label() {
1450        let input = "{start_of_abc: Melody}\nX:1\n{end_of_abc}";
1451        let output = render(input);
1452        // Section header + placeholder; body is gone.
1453        assert!(output.contains("[ABC: Melody]"));
1454        assert!(!output.contains("X:1"));
1455        assert!(output.contains("[ABC block omitted"));
1456    }
1457
1458    #[test]
1459    fn test_render_abc_section_emits_warning() {
1460        let input = "{start_of_abc}\nX:1\n{end_of_abc}";
1461        let song = chordsketch_chordpro::parse(input).unwrap();
1462        let result = render_song_with_warnings(&song, 0, &Config::defaults());
1463        assert!(
1464            result.warnings.iter().any(|w| w.contains("ABC")),
1465            "expected an ABC warning; got {:?}",
1466            result.warnings,
1467        );
1468    }
1469
1470    #[test]
1471    fn test_render_ly_section() {
1472        let input = "{start_of_ly}\n\\relative c' { c4 d }\n{end_of_ly}";
1473        let output = render(input);
1474        assert!(output.contains("[Lilypond]"));
1475        assert!(!output.contains("\\relative"));
1476        assert!(output.contains("[Lilypond block omitted"));
1477    }
1478
1479    #[test]
1480    fn test_render_svg_section() {
1481        let input = "{start_of_svg}\n<svg><circle/></svg>\n{end_of_svg}";
1482        let output = render(input);
1483        assert!(output.contains("[SVG]"));
1484        assert!(!output.contains("<circle"));
1485        assert!(output.contains("[SVG block omitted"));
1486    }
1487
1488    #[test]
1489    fn test_render_textblock_section() {
1490        // `{start_of_textblock}` is NOT a notation block — body must
1491        // continue to render as plain text. Guard against accidental
1492        // inclusion in the notation skip window.
1493        let input = "{start_of_textblock}\nPreformatted text\n{end_of_textblock}";
1494        let output = render(input);
1495        assert!(output.contains("[Textblock]"));
1496        assert!(output.contains("Preformatted text"));
1497    }
1498
1499    #[test]
1500    fn test_render_musicxml_section() {
1501        let input =
1502            "{start_of_musicxml}\n<score-partwise>notes</score-partwise>\n{end_of_musicxml}";
1503        let output = render(input);
1504        assert!(output.contains("[MusicXML]"));
1505        assert!(!output.contains("<score-partwise"));
1506        assert!(output.contains("[MusicXML block omitted"));
1507    }
1508
1509    #[test]
1510    fn test_render_musicxml_section_with_label() {
1511        let input = "{start_of_musicxml: Score}\n<score-partwise/>\n{end_of_musicxml}";
1512        let output = render(input);
1513        assert!(output.contains("[MusicXML: Score]"));
1514        assert!(output.contains("[MusicXML block omitted"));
1515        // Negative assertion — body must not leak even when the
1516        // section header carries a label. Mirrors the unlabelled
1517        // variant so a future regression touching either code path
1518        // fails here.
1519        assert!(
1520            !output.contains("<score-partwise"),
1521            "MusicXML body must not leak into text output; got:\n{output}"
1522        );
1523    }
1524
1525    // #1974 — edge-case coverage for the notation-block skip window.
1526    // Mirrors the tests added to the PDF renderer in #1969 so both
1527    // skip-and-warn implementations are guarded by the same set of
1528    // scenarios.
1529
1530    #[test]
1531    fn test_text_notation_block_inside_chorus_is_excluded_from_recall() {
1532        let input = "{start_of_chorus}\n\
1533                     [G]Sing along\n\
1534                     {start_of_abc}\n\
1535                     X:1\n\
1536                     {end_of_abc}\n\
1537                     [C]another line\n\
1538                     {end_of_chorus}\n\
1539                     {chorus}\n";
1540        let song = chordsketch_chordpro::parse(input).unwrap();
1541        let result = render_song_with_warnings(&song, 0, &Config::defaults());
1542        // One ABC block seen once → exactly one ABC warning. A recall
1543        // that re-emitted the placeholder would double this.
1544        let abc_warnings = result.warnings.iter().filter(|w| w.contains("ABC")).count();
1545        assert_eq!(
1546            abc_warnings, 1,
1547            "exactly one ABC warning expected (recall must not re-emit); got {:?}",
1548            result.warnings,
1549        );
1550        assert!(result.output.contains("Sing along"));
1551        assert!(result.output.contains("another line"));
1552    }
1553
1554    #[test]
1555    fn test_text_unterminated_notation_block_renders_without_panic() {
1556        let input = "[C]Before\n{start_of_abc}\nX:1\nK:C\n";
1557        let song = chordsketch_chordpro::parse(input).unwrap();
1558        let result = render_song_with_warnings(&song, 0, &Config::defaults());
1559        assert!(
1560            result.warnings.iter().any(|w| w.contains("ABC")),
1561            "unterminated ABC block should still emit the warning; got {:?}",
1562            result.warnings,
1563        );
1564        assert!(result.output.contains("Before"));
1565        assert!(result.output.contains("[ABC block omitted"));
1566        // Body must not leak even though no EndOf was seen.
1567        assert!(!result.output.contains("X:1"));
1568        assert!(!result.output.contains("K:C"));
1569    }
1570
1571    #[test]
1572    fn test_text_stray_end_of_notation_is_silently_ignored() {
1573        let input = "[C]Hello\n{end_of_abc}\n[D]World\n";
1574        let song = chordsketch_chordpro::parse(input).unwrap();
1575        let result = render_song_with_warnings(&song, 0, &Config::defaults());
1576        assert!(
1577            !result
1578                .warnings
1579                .iter()
1580                .any(|w| w.contains("ABC") && w.contains("omitted")),
1581            "stray `end_of_abc` must not trigger the notation-block warning; got {:?}",
1582            result.warnings,
1583        );
1584        assert!(result.output.contains("Hello"));
1585        assert!(result.output.contains("World"));
1586    }
1587
1588    #[test]
1589    fn test_delegate_verbatim_no_chords() {
1590        let input = "{start_of_textblock}\n[Am]Not a chord\n{end_of_textblock}";
1591        let output = render(input);
1592        assert!(output.contains("[Am]Not a chord"));
1593    }
1594
1595    // -- inline markup rendering (plain text strips all tags) ------------------
1596
1597    #[test]
1598    fn test_markup_stripped_in_text_output() {
1599        let output = render("Hello <b>bold</b> world");
1600        assert!(output.contains("Hello bold world"));
1601        assert!(!output.contains("<b>"));
1602        assert!(!output.contains("</b>"));
1603    }
1604
1605    #[test]
1606    fn test_markup_stripped_with_chord() {
1607        let output = render("[Am]Hello <b>bold</b> world");
1608        assert!(output.contains("Am"));
1609        assert!(output.contains("Hello bold world"));
1610        assert!(!output.contains("<b>"));
1611    }
1612
1613    #[test]
1614    fn test_span_markup_stripped_in_text_output() {
1615        let output = render(r#"<span foreground="red">red text</span>"#);
1616        assert!(output.contains("red text"));
1617        assert!(!output.contains("<span"));
1618        assert!(!output.contains("foreground"));
1619    }
1620
1621    // --- Unicode display width alignment ---
1622
1623    #[test]
1624    fn test_render_fullwidth_cjk_alignment() {
1625        // Full-width CJK characters are 2 columns wide
1626        let input = "[C]日本語";
1627        let output = render(input);
1628        // "日本語" = 3×2 = 6 display columns, "C" = 1 → pad chord by 5
1629        assert_eq!(output, "C\n日本語\n");
1630    }
1631
1632    #[test]
1633    fn test_render_mixed_width_alignment() {
1634        // Mix of ASCII (width 1) and CJK (width 2)
1635        let input = "[Am]hello世界 [G]test";
1636        let output = render(input);
1637        // "hello世界 " = 5 + 4 + 1 = 10, "Am" = 2 → pad chord by 8
1638        // "test" = 4, "G" = 1 → pad chord by 3
1639        assert_eq!(output, "Am        G\nhello世界 test\n");
1640    }
1641
1642    #[test]
1643    fn test_render_image_placeholder() {
1644        let input = "{image: src=photo.jpg}";
1645        let output = render(input);
1646        assert!(output.contains("[Image: photo.jpg]"));
1647    }
1648
1649    #[test]
1650    fn test_render_image_placeholder_with_path() {
1651        let input = "{image: src=images/cover.png width=200}";
1652        let output = render(input);
1653        assert!(output.contains("[Image: images/cover.png]"));
1654    }
1655
1656    #[test]
1657    fn test_render_image_empty_src_suppressed() {
1658        let input = "{image}";
1659        let output = render(input);
1660        assert!(!output.contains("[Image"));
1661    }
1662
1663    #[test]
1664    fn test_render_image_empty_src_with_other_attrs_suppressed() {
1665        let input = "{image: width=200 height=100}";
1666        let output = render(input);
1667        assert!(!output.contains("[Image"));
1668    }
1669
1670    #[test]
1671    fn test_render_image_dangerous_scheme_rejected() {
1672        // #1832: text renderer must reject the same URI schemes HTML does.
1673        let song = chordsketch_chordpro::parse("{image: src=\"javascript:alert(1)\"}").unwrap();
1674        let result = render_song_with_warnings(&song, 0, &Config::defaults());
1675        assert!(
1676            !result.output.contains("[Image"),
1677            "javascript: src must not reach text output: {}",
1678            result.output
1679        );
1680        assert!(
1681            result.warnings.iter().any(|w| w.contains("javascript")),
1682            "expected a warning mentioning the rejected src; got {:?}",
1683            result.warnings
1684        );
1685    }
1686
1687    #[test]
1688    fn test_render_image_file_uri_rejected() {
1689        let song = chordsketch_chordpro::parse("{image: src=\"file:///etc/passwd\"}").unwrap();
1690        let result = render_song_with_warnings(&song, 0, &Config::defaults());
1691        assert!(
1692            !result.output.contains("[Image"),
1693            "file: src must not reach text output"
1694        );
1695        // Sister-site parity with `test_render_image_dangerous_scheme_rejected`:
1696        // silent rejection is not enough — the renderer must surface a
1697        // warning so users know the `{image}` directive was dropped.
1698        assert!(
1699            result.warnings.iter().any(|w| w.contains("file")),
1700            "expected a warning mentioning the rejected src; got {:?}",
1701            result.warnings
1702        );
1703    }
1704
1705    // -- {capo} validation parity (#1834) ---------------------------------
1706
1707    #[test]
1708    fn test_capo_out_of_range_emits_warning() {
1709        let song = chordsketch_chordpro::parse("{title: T}\n{capo: 999}").unwrap();
1710        let result = render_song_with_warnings(&song, 0, &Config::defaults());
1711        assert!(
1712            result
1713                .warnings
1714                .iter()
1715                .any(|w| w.contains("capo") && w.contains("999")),
1716            "expected out-of-range {{capo}} warning; got {:?}",
1717            result.warnings
1718        );
1719    }
1720
1721    #[test]
1722    fn test_capo_non_numeric_emits_warning() {
1723        let song = chordsketch_chordpro::parse("{title: T}\n{capo: foo}").unwrap();
1724        let result = render_song_with_warnings(&song, 0, &Config::defaults());
1725        assert!(
1726            result
1727                .warnings
1728                .iter()
1729                .any(|w| w.contains("capo") && w.contains("foo")),
1730            "expected non-integer {{capo}} warning; got {:?}",
1731            result.warnings
1732        );
1733    }
1734
1735    #[test]
1736    fn test_capo_in_range_is_silent() {
1737        let song = chordsketch_chordpro::parse("{title: T}\n{capo: 5}").unwrap();
1738        let result = render_song_with_warnings(&song, 0, &Config::defaults());
1739        assert!(
1740            !result.warnings.iter().any(|w| w.contains("capo")),
1741            "valid {{capo: 5}} should not warn; got {:?}",
1742            result.warnings
1743        );
1744    }
1745
1746    // -- settings.strict missing-{key} warning (R6.100.0, #2291) ----------
1747
1748    #[test]
1749    fn test_strict_off_with_missing_key_is_silent() {
1750        let song = chordsketch_chordpro::parse("{title: T}").unwrap();
1751        let result = render_song_with_warnings(&song, 0, &Config::defaults());
1752        assert!(
1753            !result
1754                .warnings
1755                .iter()
1756                .any(|w| w.contains("settings.strict")),
1757            "default settings.strict=false must not warn on missing {{key}}; got {:?}",
1758            result.warnings
1759        );
1760    }
1761
1762    #[test]
1763    fn test_strict_on_with_missing_key_warns() {
1764        let song = chordsketch_chordpro::parse("{title: T}").unwrap();
1765        let cfg = Config::defaults()
1766            .with_define("settings.strict=true")
1767            .unwrap();
1768        let result = render_song_with_warnings(&song, 0, &cfg);
1769        assert!(
1770            result
1771                .warnings
1772                .iter()
1773                .any(|w| w.contains("{key}") && w.contains("settings.strict")),
1774            "expected missing-{{key}} warning under settings.strict; got {:?}",
1775            result.warnings
1776        );
1777    }
1778
1779    #[test]
1780    fn test_strict_on_with_present_key_is_silent() {
1781        let song = chordsketch_chordpro::parse("{title: T}\n{key: G}").unwrap();
1782        let cfg = Config::defaults()
1783            .with_define("settings.strict=true")
1784            .unwrap();
1785        let result = render_song_with_warnings(&song, 0, &cfg);
1786        assert!(
1787            !result
1788                .warnings
1789                .iter()
1790                .any(|w| w.contains("settings.strict")),
1791            "settings.strict warning must not fire when {{key}} is present; got {:?}",
1792            result.warnings
1793        );
1794    }
1795
1796    // -- MAX_WARNINGS cap (#1833) -----------------------------------------
1797
1798    #[test]
1799    fn test_max_warnings_truncates() {
1800        // Generate many bad {transpose} lines so every one emits a warning.
1801        let mut input = String::from("{title: T}\n");
1802        for _ in 0..(MAX_WARNINGS + 50) {
1803            input.push_str("{transpose: not-a-number}\n");
1804        }
1805        let song = chordsketch_chordpro::parse(&input).unwrap();
1806        let result = render_song_with_warnings(&song, 0, &Config::defaults());
1807        assert_eq!(
1808            result.warnings.len(),
1809            MAX_WARNINGS + 1,
1810            "expected exactly MAX_WARNINGS warnings plus one truncation marker"
1811        );
1812        assert!(
1813            result.warnings.last().unwrap().contains("MAX_WARNINGS"),
1814            "last entry must be the truncation marker; got {:?}",
1815            result.warnings.last()
1816        );
1817    }
1818
1819    // -- Selector filtering integration (#320) --
1820
1821    #[test]
1822    fn test_selector_filtering_removes_non_matching_directive() {
1823        let input = "{title: Song}\n{textfont-piano: Courier}\n[Am]Hello";
1824        let song = chordsketch_chordpro::parse(input).unwrap();
1825        let ctx = chordsketch_chordpro::selector::SelectorContext::new(Some("guitar"), None);
1826        let filtered = ctx.filter_song(&song);
1827        // The piano textfont directive should be absent from the filtered song.
1828        let has_textfont = filtered.lines.iter().any(|l| {
1829            matches!(l, chordsketch_chordpro::ast::Line::Directive(d) if d.kind == chordsketch_chordpro::ast::DirectiveKind::TextFont)
1830        });
1831        assert!(
1832            !has_textfont,
1833            "piano textfont directive should be removed for guitar context"
1834        );
1835        let output = render_song(&filtered);
1836        assert!(output.contains("Hello"), "lyrics should survive filtering");
1837    }
1838
1839    #[test]
1840    fn test_selector_filtering_removes_section_with_contents() {
1841        let input = "{title: Song}\n{start_of_chorus-piano}\n[C]Piano only\n{end_of_chorus-piano}\n[Am]Guitar verse";
1842        let song = chordsketch_chordpro::parse(input).unwrap();
1843        let ctx = chordsketch_chordpro::selector::SelectorContext::new(Some("guitar"), None);
1844        let filtered = ctx.filter_song(&song);
1845        let output = render_song(&filtered);
1846        assert!(
1847            !output.contains("Piano only"),
1848            "piano chorus should be removed for guitar context"
1849        );
1850        assert!(
1851            output.contains("Guitar verse"),
1852            "unselectored content should remain"
1853        );
1854    }
1855
1856    // -- auto-inject ASCII diagram block (issue #1140) ----------------------------
1857
1858    #[test]
1859    fn test_diagrams_auto_inject_text() {
1860        let output = render("{diagrams}\n[Am]Hello");
1861        assert!(
1862            output.contains("[Chord Diagrams]"),
1863            "text output should include Chord Diagrams header"
1864        );
1865        assert!(output.contains("Am"), "Am ASCII diagram expected");
1866        // Am = x o 2 2 1 o (guitar open position)
1867        assert!(output.contains("x o"), "Am fret pattern expected");
1868    }
1869
1870    #[test]
1871    fn test_no_diagrams_suppresses_text_inject() {
1872        let output = render("{no_diagrams}\n[Am]Hello");
1873        assert!(
1874            !output.contains("[Chord Diagrams]"),
1875            "{{no_diagrams}} should suppress ASCII diagram block"
1876        );
1877    }
1878
1879    #[test]
1880    fn test_diagrams_off_suppresses_text_inject() {
1881        let output = render("{diagrams: off}\n[Am]Hello");
1882        assert!(
1883            !output.contains("[Chord Diagrams]"),
1884            "{{diagrams: off}} should suppress ASCII diagram block"
1885        );
1886    }
1887
1888    #[test]
1889    fn test_diagrams_piano_auto_inject_text() {
1890        let output = render("{diagrams: piano}\n[Am]Hello [C]world");
1891        assert!(
1892            output.contains("[Chord Diagrams]"),
1893            "piano instrument should include Chord Diagrams header"
1894        );
1895        // Text renderer emits chord name + key numbers (no ASCII art for piano).
1896        assert!(output.contains("Am:"), "Am entry expected");
1897        assert!(output.contains("C:"), "C entry expected");
1898        assert!(output.contains("keys"), "key list label expected");
1899    }
1900
1901    #[test]
1902    fn test_render_decomposed_diacritics_alignment() {
1903        // "e\u{0301}" is a decomposed e-acute (U+0065 + U+0301 combining acute).
1904        // The combining character has zero display width, so the word "cafe\u{0301}"
1905        // should have the same display width (4 columns) as "cafe".
1906        // This should produce identical alignment to composed "café".
1907        let input = "[Em]cafe\u{0301} [D]world";
1908        let output = render(input);
1909        // "cafe\u{0301} " = 5 display columns (4 + 0 + 1), "Em" = 2 → pad chord by 3
1910        // Matches composed form: "[Em]café [D]résumé" → "Em   D"
1911        assert_eq!(output, "Em   D\ncafe\u{0301} world\n");
1912    }
1913}