1use 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
17const MAX_CHORUS_RECALLS: usize = 1000;
20
21pub use chordsketch_chordpro::render_result::MAX_WARNINGS;
27
28#[must_use]
43pub fn render_song(song: &Song) -> String {
44 render_song_with_transpose(song, 0, &Config::defaults())
45}
46
47#[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#[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
79fn render_song_impl(
81 song: &Song,
82 cli_transpose: i8,
83 config: &Config,
84 warnings: &mut Vec<String>,
85) -> String {
86 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 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 let mut chorus_body: Vec<Line> = Vec::new();
110 let mut chorus_buf: Option<Vec<Line>> = None;
112 let mut chorus_recall_count: usize = 0;
113
114 let mut in_notation_block: Option<NotationKind> = None;
122
123 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 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 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 output.push(format!("[Key: {}]", unicode_accidentals(value)));
181 }
182 DirectiveKind::Tempo => {
183 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 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 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 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 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 if let Some(ref instrument) = auto_diagrams_instrument {
342 if instrument == "piano" {
343 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 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#[must_use]
413pub fn render_songs(songs: &[Song]) -> String {
414 render_songs_with_transpose(songs, 0, &Config::defaults())
415}
416
417#[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#[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 if let Some(last) = parts.last_mut() {
452 last.push('\n');
453 }
454 RenderResult::with_warnings(parts.join("\n\n"), warnings)
455}
456
457#[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#[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
488fn 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
505fn 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 chord_line.push_str(chord_name);
552
553 lyric_line.push_str(text);
555
556 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 && 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
580fn 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 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 DirectiveKind::StartOfTextblock => {
636 render_section_header("Textblock", &directive.value, output);
637 }
638 DirectiveKind::StartOfSection(section_name) => {
639 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 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 DirectiveKind::NewPage
668 | DirectiveKind::NewPhysicalPage
669 | DirectiveKind::ColumnBreak
670 | DirectiveKind::Columns => {}
671 _ => {}
673 }
674}
675
676fn 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
703fn 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
711fn 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#[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 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 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 #[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 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 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 #[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 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 #[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 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 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 #[test]
969 fn test_render_multibyte_lyrics_alignment() {
970 let input = "[Am]こんにちは [G]世界";
972 let output = render(input);
973 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 #[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 #[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 #[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 assert!(output.contains("\n\n"));
1092 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 assert!(output.ends_with("[Chorus]\nSecond chorus\n"));
1342 }
1343
1344 #[test]
1345 fn test_chorus_recall_applies_current_transpose() {
1346 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 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 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 let recall_count = output.matches("[Chorus]\nChorus").count() - 1; 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 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 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 let chorus_section_lines: Vec<&str> = output.lines().collect();
1412 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 #[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 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 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 assert!(
1520 !output.contains("<score-partwise"),
1521 "MusicXML body must not leak into text output; got:\n{output}"
1522 );
1523 }
1524
1525 #[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 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 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 #[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 #[test]
1624 fn test_render_fullwidth_cjk_alignment() {
1625 let input = "[C]日本語";
1627 let output = render(input);
1628 assert_eq!(output, "C\n日本語\n");
1630 }
1631
1632 #[test]
1633 fn test_render_mixed_width_alignment() {
1634 let input = "[Am]hello世界 [G]test";
1636 let output = render(input);
1637 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 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 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 #[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 #[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 #[test]
1799 fn test_max_warnings_truncates() {
1800 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 #[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 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 #[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 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 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 let input = "[Em]cafe\u{0301} [D]world";
1908 let output = render(input);
1909 assert_eq!(output, "Em D\ncafe\u{0301} world\n");
1912 }
1913}