1use chordsketch_core::ast::{CommentStyle, DirectiveKind, Line, LyricsLine, Song};
7use chordsketch_core::config::Config;
8use chordsketch_core::render_result::RenderResult;
9use chordsketch_core::resolve_diagrams_instrument;
10use chordsketch_core::transpose::transpose_chord;
11use unicode_width::UnicodeWidthStr;
12
13const MAX_CHORUS_RECALLS: usize = 1000;
16
17#[must_use]
32pub fn render_song(song: &Song) -> String {
33 render_song_with_transpose(song, 0, &Config::defaults())
34}
35
36#[must_use]
44pub fn render_song_with_transpose(song: &Song, cli_transpose: i8, config: &Config) -> String {
45 let result = render_song_with_warnings(song, cli_transpose, config);
46 for w in &result.warnings {
47 eprintln!("warning: {w}");
48 }
49 result.output
50}
51
52#[must_use = "caller must check warnings in the returned RenderResult"]
58pub fn render_song_with_warnings(
59 song: &Song,
60 cli_transpose: i8,
61 config: &Config,
62) -> RenderResult<String> {
63 let mut warnings = Vec::new();
64 let output = render_song_impl(song, cli_transpose, config, &mut warnings);
65 RenderResult::with_warnings(output, warnings)
66}
67
68fn render_song_impl(
70 song: &Song,
71 cli_transpose: i8,
72 config: &Config,
73 warnings: &mut Vec<String>,
74) -> String {
75 let song_overrides = song.config_overrides();
80 let song_config;
81 let _config = if song_overrides.is_empty() {
82 config
83 } else {
84 song_config = config
85 .clone()
86 .with_song_overrides(&song_overrides, warnings);
87 &song_config
88 };
89 let song_transpose_delta = Config::song_transpose_delta(&song_overrides);
92 let mut output = Vec::new();
93 let (combined_transpose, _) =
94 chordsketch_core::transpose::combine_transpose(cli_transpose, song_transpose_delta);
95 let mut transpose_offset: i8 = combined_transpose;
96 let mut chorus_body: Vec<Line> = Vec::new();
99 let mut chorus_buf: Option<Vec<Line>> = None;
101 let mut chorus_recall_count: usize = 0;
102
103 let default_instrument = _config
106 .get_path("diagrams.instrument")
107 .as_str()
108 .map(str::to_ascii_lowercase)
109 .unwrap_or_else(|| "guitar".to_string());
110 let mut auto_diagrams_instrument: Option<String> = None;
111
112 render_metadata(&song.metadata, &mut output);
113
114 for line in &song.lines {
115 match line {
116 Line::Lyrics(lyrics_line) => {
117 if let Some(buf) = chorus_buf.as_mut() {
118 buf.push(line.clone());
119 }
120 render_lyrics(lyrics_line, transpose_offset, &mut output);
121 }
122 Line::Directive(directive) => {
123 if directive.kind.is_metadata() {
126 continue;
127 }
128 if directive.kind == DirectiveKind::Diagrams {
129 auto_diagrams_instrument = resolve_diagrams_instrument(
130 directive.value.as_deref(),
131 &default_instrument,
132 );
133 continue;
134 }
135 if directive.kind == DirectiveKind::NoDiagrams {
136 auto_diagrams_instrument = None;
137 continue;
138 }
139 if directive.kind == DirectiveKind::Transpose {
140 let file_offset: i8 = match directive.value.as_deref() {
144 None | Some("") => 0,
145 Some(raw) => match raw.parse() {
146 Ok(v) => v,
147 Err(_) => {
148 warnings.push(format!(
149 "{{transpose}} value {raw:?} cannot be \
150 parsed as i8, ignored (using 0)"
151 ));
152 0
153 }
154 },
155 };
156 let (combined, saturated) =
157 chordsketch_core::transpose::combine_transpose(file_offset, cli_transpose);
158 if saturated {
159 warnings.push(format!(
160 "transpose offset {file_offset} + {cli_transpose} \
161 exceeds i8 range, clamped to {combined}"
162 ));
163 }
164 transpose_offset = combined;
165 continue;
166 }
167 match &directive.kind {
168 DirectiveKind::StartOfChorus => {
169 render_section_header("Chorus", &directive.value, &mut output);
170 chorus_buf = Some(Vec::new());
172 }
173 DirectiveKind::EndOfChorus => {
174 if let Some(buf) = chorus_buf.take() {
175 chorus_body = buf;
176 }
177 }
178 DirectiveKind::Chorus => {
179 if chorus_recall_count < MAX_CHORUS_RECALLS {
180 render_chorus_recall(
181 &directive.value,
182 &chorus_body,
183 transpose_offset,
184 &mut output,
185 );
186 chorus_recall_count += 1;
187 } else if chorus_recall_count == MAX_CHORUS_RECALLS {
188 warnings.push(format!(
189 "chorus recall limit ({MAX_CHORUS_RECALLS}) reached, \
190 further recalls suppressed"
191 ));
192 chorus_recall_count += 1;
193 }
194 }
195 DirectiveKind::NewPage
200 | DirectiveKind::NewPhysicalPage
201 | DirectiveKind::ColumnBreak
202 | DirectiveKind::Columns => {}
203 _ => {
204 if let Some(buf) = chorus_buf.as_mut() {
205 buf.push(line.clone());
206 }
207 let mut target = Vec::new();
208 render_directive(directive, &mut target);
209 output.extend(target);
210 }
211 }
212 }
213 Line::Comment(style, text) => {
214 if let Some(buf) = chorus_buf.as_mut() {
215 buf.push(line.clone());
216 }
217 render_comment(*style, text, &mut output);
218 }
219 Line::Empty => {
220 if let Some(buf) = chorus_buf.as_mut() {
221 buf.push(line.clone());
222 }
223 output.push(String::new());
224 }
225 }
226 }
227
228 if let Some(ref instrument) = auto_diagrams_instrument {
230 if instrument == "piano" {
231 let kbd_defines = song.keyboard_defines();
234 let voicings: Vec<_> = song
235 .used_chord_names()
236 .into_iter()
237 .filter_map(|name| chordsketch_core::lookup_keyboard_voicing(&name, &kbd_defines))
238 .collect();
239 if !voicings.is_empty() {
240 output.push(String::new());
241 output.push("[Chord Diagrams]".to_string());
242 for voicing in &voicings {
243 output.push(format!(
244 " {}: keys {}",
245 voicing.title(),
246 voicing
247 .keys
248 .iter()
249 .map(|k| k.to_string())
250 .collect::<Vec<_>>()
251 .join(" ")
252 ));
253 }
254 }
255 } else {
256 let frets_shown = _config
257 .get_path("diagrams.frets")
258 .as_f64()
259 .map_or(chordsketch_core::chord_diagram::DEFAULT_FRETS_SHOWN, |n| {
260 (n as usize).max(1)
261 });
262 let defines = song.fretted_defines();
263 let diagrams: Vec<_> = song
264 .used_chord_names()
265 .into_iter()
266 .filter_map(|name| {
267 chordsketch_core::lookup_diagram(&name, &defines, instrument, frets_shown)
268 })
269 .collect();
270 if !diagrams.is_empty() {
271 output.push(String::new());
272 output.push("[Chord Diagrams]".to_string());
273 for diagram in &diagrams {
274 output.push(String::new());
275 for diagram_line in
276 chordsketch_core::chord_diagram::render_ascii(diagram).lines()
277 {
278 output.push(diagram_line.to_string());
279 }
280 }
281 }
282 }
283 }
284
285 while output.last().is_some_and(|l| l.is_empty()) {
287 output.pop();
288 }
289
290 if output.is_empty() {
291 return String::new();
292 }
293
294 let mut result = output.join("\n");
295 result.push('\n');
296 result
297}
298
299#[must_use]
301pub fn render_songs(songs: &[Song]) -> String {
302 render_songs_with_transpose(songs, 0, &Config::defaults())
303}
304
305#[must_use]
310pub fn render_songs_with_transpose(songs: &[Song], cli_transpose: i8, config: &Config) -> String {
311 let result = render_songs_with_warnings(songs, cli_transpose, config);
312 for w in &result.warnings {
313 eprintln!("warning: {w}");
314 }
315 result.output
316}
317
318#[must_use = "caller must check warnings in the returned RenderResult"]
324pub fn render_songs_with_warnings(
325 songs: &[Song],
326 cli_transpose: i8,
327 config: &Config,
328) -> RenderResult<String> {
329 let mut warnings = Vec::new();
330 let mut parts: Vec<String> = songs
331 .iter()
332 .map(|song| {
333 render_song_impl(song, cli_transpose, config, &mut warnings)
334 .trim_end()
335 .to_string()
336 })
337 .collect();
338 if let Some(last) = parts.last_mut() {
340 last.push('\n');
341 }
342 RenderResult::with_warnings(parts.join("\n\n"), warnings)
343}
344
345#[must_use = "parse errors should be handled"]
352pub fn try_render(input: &str) -> Result<String, chordsketch_core::ParseError> {
353 let song = chordsketch_core::parse(input)?;
354 Ok(render_song(&song))
355}
356
357#[must_use]
364pub fn render(input: &str) -> String {
365 match try_render(input) {
366 Ok(text) => text,
367 Err(e) => format!(
368 "Parse error at line {} column {}: {}\n",
369 e.line(),
370 e.column(),
371 e.message
372 ),
373 }
374}
375
376fn render_metadata(metadata: &chordsketch_core::ast::Metadata, output: &mut Vec<String>) {
385 if let Some(title) = &metadata.title {
386 output.push(title.clone());
387 }
388 for subtitle in &metadata.subtitles {
389 output.push(subtitle.clone());
390 }
391}
392
393fn render_lyrics(lyrics_line: &LyricsLine, transpose_offset: i8, output: &mut Vec<String>) {
408 if !lyrics_line.has_chords() {
409 output.push(lyrics_line.text());
410 return;
411 }
412
413 let mut chord_line = String::new();
414 let mut lyric_line = String::new();
415
416 for segment in &lyrics_line.segments {
417 let transposed;
418 let chord_name = if transpose_offset != 0 {
419 if let Some(chord) = &segment.chord {
420 transposed = transpose_chord(chord, transpose_offset);
421 transposed.display_name()
422 } else {
423 ""
424 }
425 } else {
426 segment.chord.as_ref().map_or("", |c| c.display_name())
427 };
428 let text = &segment.text;
429
430 let chord_len = UnicodeWidthStr::width(chord_name);
431 let text_len = UnicodeWidthStr::width(text.as_str());
432
433 chord_line.push_str(chord_name);
435
436 lyric_line.push_str(text);
438
439 if chord_len > 0 && chord_len >= text_len {
445 let padding = chord_len - text_len + 1;
446 lyric_line.extend(std::iter::repeat_n(' ', padding));
447 chord_line.push(' ');
448 } else if chord_len > 0 && text_len > chord_len {
449 let padding = text_len - chord_len;
450 chord_line.extend(std::iter::repeat_n(' ', padding));
451 }
452 if chord_len == 0 && text_len > 0 {
455 chord_line.extend(std::iter::repeat_n(' ', text_len));
456 }
457 }
458
459 output.push(chord_line.trim_end().to_string());
460 output.push(lyric_line.trim_end().to_string());
461}
462
463fn render_directive(directive: &chordsketch_core::ast::Directive, output: &mut Vec<String>) {
476 match &directive.kind {
477 DirectiveKind::StartOfChorus => {
478 render_section_header("Chorus", &directive.value, output);
479 }
480 DirectiveKind::StartOfVerse => {
481 render_section_header("Verse", &directive.value, output);
482 }
483 DirectiveKind::StartOfBridge => {
484 render_section_header("Bridge", &directive.value, output);
485 }
486 DirectiveKind::StartOfTab => {
487 render_section_header("Tab", &directive.value, output);
488 }
489 DirectiveKind::StartOfGrid => {
490 render_section_header("Grid", &directive.value, output);
491 }
492 DirectiveKind::StartOfAbc => {
493 render_section_header("ABC", &directive.value, output);
494 }
495 DirectiveKind::StartOfLy => {
496 render_section_header("Lilypond", &directive.value, output);
497 }
498 DirectiveKind::StartOfSvg => {
499 render_section_header("SVG", &directive.value, output);
500 }
501 DirectiveKind::StartOfTextblock => {
502 render_section_header("Textblock", &directive.value, output);
503 }
504 DirectiveKind::StartOfMusicxml => {
505 render_section_header("MusicXML", &directive.value, output);
506 }
507 DirectiveKind::StartOfSection(section_name) => {
508 let label = chordsketch_core::capitalize(section_name);
510 render_section_header(&label, &directive.value, output);
511 }
512 DirectiveKind::Image(attrs) if attrs.has_src() => {
513 output.push(format!("[Image: {}]", attrs.src));
514 }
515 DirectiveKind::Image(_) => {}
516 DirectiveKind::NewPage
521 | DirectiveKind::NewPhysicalPage
522 | DirectiveKind::ColumnBreak
523 | DirectiveKind::Columns => {}
524 _ => {}
526 }
527}
528
529fn render_chorus_recall(
535 value: &Option<String>,
536 chorus_body: &[Line],
537 transpose_offset: i8,
538 output: &mut Vec<String>,
539) {
540 render_section_header("Chorus", value, output);
541 for line in chorus_body {
542 match line {
543 Line::Lyrics(lyrics) => render_lyrics(lyrics, transpose_offset, output),
544 Line::Comment(style, text) => render_comment(*style, text, output),
545 Line::Empty => output.push(String::new()),
546 Line::Directive(d) if !d.kind.is_metadata() => render_directive(d, output),
547 _ => {}
548 }
549 }
550}
551
552fn render_section_header(label: &str, value: &Option<String>, output: &mut Vec<String>) {
554 match value {
555 Some(v) if !v.is_empty() => output.push(format!("[{label}: {v}]")),
556 _ => output.push(format!("[{label}]")),
557 }
558}
559
560fn render_comment(style: CommentStyle, text: &str, output: &mut Vec<String>) {
570 match style {
571 CommentStyle::Normal => output.push(format!("({text})")),
572 CommentStyle::Italic => output.push(format!("(*{text}*)")),
573 CommentStyle::Boxed => output.push(format!("[{text}]")),
574 }
575}
576
577#[cfg(test)]
582mod tests {
583 use super::*;
584
585 #[test]
586 fn test_render_empty() {
587 assert_eq!(render(""), "");
588 }
589
590 #[test]
591 fn test_render_title_only() {
592 let input = "{title: Amazing Grace}";
593 let output = render(input);
594 assert_eq!(output, "Amazing Grace\n");
595 }
596
597 #[test]
598 fn test_render_title_and_subtitle() {
599 let input = "{title: Amazing Grace}\n{subtitle: Traditional}";
600 let output = render(input);
601 assert_eq!(output, "Amazing Grace\nTraditional\n");
602 }
603
604 #[test]
605 fn test_render_plain_lyrics() {
606 let input = "Hello world\nSecond line";
607 let output = render(input);
608 assert_eq!(output, "Hello world\nSecond line\n");
609 }
610
611 #[test]
612 fn test_render_lyrics_with_chords() {
613 let input = "[Am]Hello [G]world";
614 let output = render(input);
615 assert_eq!(output, "Am G\nHello world\n");
616 }
617
618 #[test]
619 fn test_render_chord_longer_than_text() {
620 let input = "[Cmaj7]I [G]see";
622 let output = render(input);
623 assert_eq!(output, "Cmaj7 G\nI see\n");
624 }
625
626 #[test]
627 fn test_render_chorus_section() {
628 let input = "{start_of_chorus}\n[G]La la la\n{end_of_chorus}";
629 let output = render(input);
630 assert_eq!(output, "[Chorus]\nG\nLa la la\n");
631 }
632
633 #[test]
634 fn test_render_verse_with_label() {
635 let input = "{start_of_verse: Verse 1}\nSome lyrics\n{end_of_verse}";
636 let output = render(input);
637 assert_eq!(output, "[Verse: Verse 1]\nSome lyrics\n");
638 }
639
640 #[test]
641 fn test_render_comment_normal() {
642 let input = "{comment: This is a comment}";
643 let output = render(input);
644 assert_eq!(output, "(This is a comment)\n");
645 }
646
647 #[test]
648 fn test_render_comment_italic() {
649 let input = "{comment_italic: Softly}";
650 let output = render(input);
651 assert_eq!(output, "(*Softly*)\n");
652 }
653
654 #[test]
655 fn test_render_comment_box() {
656 let input = "{comment_box: Important}";
657 let output = render(input);
658 assert_eq!(output, "[Important]\n");
659 }
660
661 #[test]
662 fn test_render_empty_lines_preserved() {
663 let input = "Line one\n\nLine two";
664 let output = render(input);
665 assert_eq!(output, "Line one\n\nLine two\n");
666 }
667
668 #[test]
669 fn test_render_metadata_not_duplicated() {
670 let input = "{title: Test}\n{artist: Someone}\n{key: G}\nLyrics here";
672 let output = render(input);
673 assert_eq!(output, "Test\nLyrics here\n");
674 }
675
676 #[test]
677 fn test_render_full_song() {
678 let input = "\
679{title: Amazing Grace}
680{subtitle: Traditional}
681{key: G}
682
683{start_of_verse}
684[G]Amazing [G7]grace, how [C]sweet the [G]sound
685[G]That saved a [Em]wretch like [D]me
686{end_of_verse}
687
688{start_of_chorus}
689[G]I once was [G7]lost, but [C]now am [G]found
690{end_of_chorus}";
691 let output = render(input);
692 assert!(!output.is_empty());
694 assert!(output.contains("Amazing Grace"));
695 assert!(output.contains("[Verse]"));
696 assert!(output.contains("[Chorus]"));
697 }
698
699 #[test]
700 fn test_render_song_api() {
701 let song = chordsketch_core::parse("{title: Test}\n[Am]Hello").unwrap();
702 let output = render_song(&song);
703 assert!(output.contains("Test"));
704 assert!(output.contains("Am"));
705 assert!(output.contains("Hello"));
706 }
707
708 #[test]
709 fn test_render_chord_only_segment() {
710 let input = "[Am]Hello [G]";
712 let output = render(input);
713 assert!(output.contains("Am"));
714 assert!(output.contains("G"));
715 assert!(output.contains("Hello"));
716 }
717
718 #[test]
719 fn test_render_bridge_section() {
720 let input = "{start_of_bridge}\nBridge lyrics\n{end_of_bridge}";
721 let output = render(input);
722 assert_eq!(output, "[Bridge]\nBridge lyrics\n");
723 }
724
725 #[test]
726 fn test_render_tab_section() {
727 let input = "{start_of_tab}\ne|---0---|\n{end_of_tab}";
728 let output = render(input);
729 assert_eq!(output, "[Tab]\ne|---0---|\n");
730 }
731
732 #[test]
735 fn test_render_multibyte_lyrics_alignment() {
736 let input = "[Am]こんにちは [G]世界";
738 let output = render(input);
739 assert_eq!(output, "Am G\nこんにちは 世界\n");
742 }
743
744 #[test]
745 fn test_render_accented_lyrics_alignment() {
746 let input = "[Em]café [D]résumé";
747 let output = render(input);
748 assert_eq!(output, "Em D\ncafé résumé\n");
749 }
750
751 #[test]
754 fn test_render_text_before_first_chord() {
755 let input = "Hello [Am]world";
756 let output = render(input);
757 assert_eq!(output, " Am\nHello world\n");
758 }
759
760 #[test]
761 fn test_render_text_before_first_chord_multiple() {
762 let input = "I say [Am]hello [G]world";
763 let output = render(input);
764 assert_eq!(output, " Am G\nI say hello world\n");
765 }
766
767 #[test]
770 fn test_try_render_success() {
771 let result = try_render("{title: Test}\n[Am]Hello");
772 assert!(result.is_ok());
773 let text = result.unwrap();
774 assert!(text.contains("Test"));
775 assert!(text.contains("Am"));
776 }
777
778 #[test]
779 fn test_try_render_parse_error() {
780 let result = try_render("{title: unclosed");
781 assert!(result.is_err());
782 let err = result.unwrap_err();
783 assert_eq!(err.line(), 1);
784 }
785
786 #[test]
787 fn test_render_grid_section() {
788 let input = "{start_of_grid}\n| Am . | C . |\n{end_of_grid}";
789 let output = render(input);
790 assert_eq!(output, "[Grid]\n| Am . | C . |\n");
791 }
792
793 #[test]
794 fn test_render_grid_section_with_label() {
795 let input = "{start_of_grid: Intro}\n| Am . | C . |\n{end_of_grid}";
796 let output = render(input);
797 assert_eq!(output, "[Grid: Intro]\n| Am . | C . |\n");
798 }
799
800 #[test]
801 fn test_render_grid_short_alias() {
802 let input = "{sog}\n| G . | D . |\n{eog}";
803 let output = render(input);
804 assert_eq!(output, "[Grid]\n| G . | D . |\n");
805 }
806
807 #[test]
810 fn test_render_custom_section_intro() {
811 let input = "{start_of_intro}\n[Am]Da da da\n{end_of_intro}";
812 let output = render(input);
813 assert!(output.contains("[Intro]"));
814 assert!(output.contains("Am"));
815 assert!(output.contains("Da da da"));
816 }
817
818 #[test]
819 fn test_render_custom_section_with_label() {
820 let input = "{start_of_intro: Guitar}\nSome notes\n{end_of_intro}";
821 let output = render(input);
822 assert_eq!(output, "[Intro: Guitar]\nSome notes\n");
823 }
824
825 #[test]
826 fn test_render_custom_section_outro() {
827 let input = "{start_of_outro}\nFinal notes\n{end_of_outro}";
828 let output = render(input);
829 assert!(output.contains("[Outro]"));
830 }
831
832 #[test]
833 fn test_render_custom_section_solo() {
834 let input = "{start_of_solo}\n[Em]Solo line\n{end_of_solo}";
835 let output = render(input);
836 assert!(output.contains("[Solo]"));
837 }
838}
839
840#[cfg(test)]
841mod multi_song_tests {
842 use super::*;
843
844 #[test]
845 fn test_render_songs_two_songs() {
846 let songs = chordsketch_core::parse_multi(
847 "{title: Song One}\n[Am]Hello\n{new_song}\n{title: Song Two}\n[G]World",
848 )
849 .unwrap();
850 let output = render_songs(&songs);
851 assert!(output.contains("Song One"));
852 assert!(output.contains("Am"));
853 assert!(output.contains("Hello"));
854 assert!(output.contains("Song Two"));
855 assert!(output.contains("G\nWorld"));
856 assert!(output.contains("\n\n"));
858 assert!(
860 !output.contains("\n\n\n"),
861 "Should not have triple newline between songs"
862 );
863 }
864
865 #[test]
866 fn test_render_songs_single_song() {
867 let songs = chordsketch_core::parse_multi("{title: Only One}\nLyrics").unwrap();
868 let output = render_songs(&songs);
869 assert_eq!(output, render_song(&songs[0]));
870 }
871
872 #[test]
873 fn test_render_songs_with_transpose() {
874 let songs =
875 chordsketch_core::parse_multi("{title: S1}\n[C]Do\n{new_song}\n{title: S2}\n[G]Re")
876 .unwrap();
877 let output = render_songs_with_transpose(&songs, 2, &Config::defaults());
878 assert!(output.contains("D\nDo"));
880 assert!(output.contains("A\nRe"));
881 }
882}
883
884#[cfg(test)]
885mod transpose_tests {
886 use super::*;
887
888 #[test]
889 fn test_transpose_directive_up_2() {
890 let input = "{transpose: 2}\n[G]Hello [C]world";
891 let song = chordsketch_core::parse(input).unwrap();
892 let output = render_song(&song);
893 assert_eq!(output, "A D\nHello world\n");
895 }
896
897 #[test]
898 fn test_transpose_directive_down_3() {
899 let input = "{transpose: -3}\n[Am]Hello [Em]world";
900 let song = chordsketch_core::parse(input).unwrap();
901 let output = render_song(&song);
902 assert_eq!(output, "F#m C#m\nHello world\n");
904 }
905
906 #[test]
907 fn test_transpose_directive_replaces_previous() {
908 let input = "{transpose: 2}\n[G]First\n{transpose: -1}\n[G]Second";
909 let song = chordsketch_core::parse(input).unwrap();
910 let output = render_song(&song);
911 assert!(output.contains("A\nFirst"));
913 assert!(output.contains("F#\nSecond"));
914 }
915
916 #[test]
917 fn test_transpose_directive_zero_resets() {
918 let input = "{transpose: 5}\n[C]Up\n{transpose: 0}\n[C]Normal";
919 let song = chordsketch_core::parse(input).unwrap();
920 let output = render_song(&song);
921 assert!(output.contains("F\nUp"));
923 assert!(output.contains("C\nNormal"));
924 }
925
926 #[test]
927 fn test_transpose_directive_with_cli_offset() {
928 let input = "{transpose: 2}\n[C]Hello";
929 let song = chordsketch_core::parse(input).unwrap();
930 let output = render_song_with_transpose(&song, 3, &Config::defaults());
931 assert!(output.contains("F\nHello"));
933 }
934
935 #[test]
936 fn test_cli_transpose_without_directive() {
937 let input = "[G]Hello [C]world";
938 let song = chordsketch_core::parse(input).unwrap();
939 let output = render_song_with_transpose(&song, 2, &Config::defaults());
940 assert_eq!(output, "A D\nHello world\n");
942 }
943
944 #[test]
945 fn test_transpose_directive_replaces_with_cli_additive() {
946 let input = "{transpose: 2}\n[C]First\n{transpose: -1}\n[C]Second";
947 let song = chordsketch_core::parse(input).unwrap();
948 let output = render_song_with_transpose(&song, 1, &Config::defaults());
949 assert!(output.contains("D#\nFirst"));
951 assert!(output.contains("C\nSecond"));
952 }
953
954 #[test]
955 fn test_transpose_no_chord_lyrics_unaffected() {
956 let input = "{transpose: 5}\nPlain lyrics no chords";
957 let song = chordsketch_core::parse(input).unwrap();
958 let output = render_song(&song);
959 assert_eq!(output, "Plain lyrics no chords\n");
960 }
961
962 #[test]
963 fn test_transpose_invalid_value_treated_as_zero() {
964 let input = "{transpose: abc}\n[G]Hello";
965 let song = chordsketch_core::parse(input).unwrap();
966 let result =
967 render_song_with_warnings(&song, 0, &chordsketch_core::config::Config::defaults());
968 assert!(result.output.contains("G\nHello"));
970 assert!(
971 result.warnings.iter().any(|w| w.contains("\"abc\"")),
972 "expected warning about unparseable value, got: {:?}",
973 result.warnings
974 );
975 }
976
977 #[test]
978 fn test_transpose_out_of_i8_range_emits_warning() {
979 let input = "{transpose: 999}\n[G]Hello";
981 let song = chordsketch_core::parse(input).unwrap();
982 let result =
983 render_song_with_warnings(&song, 0, &chordsketch_core::config::Config::defaults());
984 assert!(
985 result.output.contains("G\nHello"),
986 "chord should be untransposed"
987 );
988 assert!(
989 result.warnings.iter().any(|w| w.contains("\"999\"")),
990 "expected warning about out-of-range value, got: {:?}",
991 result.warnings
992 );
993 }
994
995 #[test]
996 fn test_transpose_no_value_treated_as_zero() {
997 let input = "{transpose}\n[G]Hello";
998 let song = chordsketch_core::parse(input).unwrap();
999 let result =
1000 render_song_with_warnings(&song, 0, &chordsketch_core::config::Config::defaults());
1001 assert!(result.output.contains("G\nHello"));
1003 assert!(
1004 result.warnings.is_empty(),
1005 "missing {{transpose}} value should not emit a warning; got: {:?}",
1006 result.warnings
1007 );
1008 }
1009
1010 #[test]
1011 fn test_transpose_whitespace_value_treated_as_zero() {
1012 let input = "{transpose: }\n[G]Hello";
1016 let song = chordsketch_core::parse(input).unwrap();
1017 let result =
1018 render_song_with_warnings(&song, 0, &chordsketch_core::config::Config::defaults());
1019 assert!(
1020 result.output.contains("G\nHello"),
1021 "chord should be untransposed"
1022 );
1023 assert!(
1024 result.warnings.is_empty(),
1025 "whitespace-only {{transpose}} value should not emit a warning; got: {:?}",
1026 result.warnings
1027 );
1028 }
1029
1030 #[test]
1033 fn test_render_chorus_recall_basic() {
1034 let input = "\
1035{start_of_chorus}
1036[G]La la la
1037{end_of_chorus}
1038
1039{start_of_verse}
1040Some verse
1041{end_of_verse}
1042
1043{chorus}";
1044 let output = render(input);
1045 assert_eq!(
1047 output,
1048 "[Chorus]\nG\nLa la la\n\n[Verse]\nSome verse\n\n[Chorus]\nG\nLa la la\n"
1049 );
1050 }
1051
1052 #[test]
1053 fn test_render_chorus_recall_with_label() {
1054 let input = "\
1055{start_of_chorus}
1056Sing along
1057{end_of_chorus}
1058
1059{chorus: Repeat}";
1060 let output = render(input);
1061 assert!(output.contains("[Chorus: Repeat]"));
1062 assert_eq!(
1063 output,
1064 "[Chorus]\nSing along\n\n[Chorus: Repeat]\nSing along\n"
1065 );
1066 }
1067
1068 #[test]
1069 fn test_render_chorus_recall_no_chorus_defined() {
1070 let input = "{chorus}";
1072 let output = render(input);
1073 assert_eq!(output, "[Chorus]\n");
1074 }
1075
1076 #[test]
1077 fn test_render_chorus_recall_multiple() {
1078 let input = "\
1079{start_of_chorus}
1080Chorus line
1081{end_of_chorus}
1082{chorus}
1083{chorus}";
1084 let output = render(input);
1085 assert_eq!(
1087 output,
1088 "[Chorus]\nChorus line\n[Chorus]\nChorus line\n[Chorus]\nChorus line\n"
1089 );
1090 }
1091
1092 #[test]
1093 fn test_render_chorus_recall_uses_latest() {
1094 let input = "\
1096{start_of_chorus}
1097First chorus
1098{end_of_chorus}
1099
1100{start_of_chorus}
1101Second chorus
1102{end_of_chorus}
1103
1104{chorus}";
1105 let output = render(input);
1106 assert!(output.ends_with("[Chorus]\nSecond chorus\n"));
1108 }
1109
1110 #[test]
1111 fn test_chorus_recall_applies_current_transpose() {
1112 let input = "\
1115{start_of_chorus}
1116[G]La la
1117{end_of_chorus}
1118{transpose: 2}
1119{chorus}";
1120 let output = render(input);
1121 let recall_idx = output.rfind("[Chorus]").expect("should have recall");
1125 let recall_section = &output[recall_idx..];
1126 assert!(
1127 recall_section.contains('A') && !recall_section.contains('G'),
1128 "recalled chorus should have transposed chord A (not G), got:\n{recall_section}"
1129 );
1130 }
1131
1132 #[test]
1133 fn test_chorus_recall_limit_exceeded() {
1134 let mut input = String::from("{start_of_chorus}\nChorus\n{end_of_chorus}\n");
1136 for _ in 0..1005 {
1137 input.push_str("{chorus}\n");
1138 }
1139 let output = render(&input);
1140 let recall_count = output.matches("[Chorus]\nChorus").count() - 1; assert_eq!(
1143 recall_count,
1144 super::MAX_CHORUS_RECALLS,
1145 "should stop at MAX_CHORUS_RECALLS"
1146 );
1147 }
1148
1149 #[test]
1150 fn test_page_control_not_replayed_in_chorus_recall() {
1151 let input = "\
1155{start_of_chorus}
1156[G]Chorus line
1157{new_page}
1158{column_break}
1159{columns: 2}
1160{end_of_chorus}
1161{chorus}";
1162 let output = render(input);
1163 assert!(
1165 output.contains("G"),
1166 "chord from chorus must appear in recall: {output}"
1167 );
1168 assert!(
1169 output.contains("Chorus line"),
1170 "lyric from chorus must appear in recall: {output}"
1171 );
1172 let chorus_section_lines: Vec<&str> = output.lines().collect();
1178 let non_empty_count = chorus_section_lines
1183 .iter()
1184 .filter(|l| !l.is_empty())
1185 .count();
1186 assert_eq!(
1187 non_empty_count, 6,
1188 "expected 6 non-empty output lines (3 original + 3 recall), got {non_empty_count}; \
1189 output:\n{output}"
1190 );
1191 }
1192}
1193
1194#[cfg(test)]
1195mod delegate_tests {
1196 use super::*;
1197
1198 #[test]
1199 fn test_render_abc_section() {
1200 let input = "{start_of_abc}\nX:1\nK:G\n{end_of_abc}";
1201 let output = render(input);
1202 assert!(output.contains("[ABC]"));
1203 assert!(output.contains("X:1"));
1204 }
1205
1206 #[test]
1207 fn test_render_abc_section_with_label() {
1208 let input = "{start_of_abc: Melody}\nX:1\n{end_of_abc}";
1209 let output = render(input);
1210 assert_eq!(output, "[ABC: Melody]\nX:1\n");
1211 }
1212
1213 #[test]
1214 fn test_render_ly_section() {
1215 let input = "{start_of_ly}\nnotes\n{end_of_ly}";
1216 let output = render(input);
1217 assert!(output.contains("[Lilypond]"));
1218 }
1219
1220 #[test]
1221 fn test_render_svg_section() {
1222 let input = "{start_of_svg}\n<svg/>\n{end_of_svg}";
1223 let output = render(input);
1224 assert!(output.contains("[SVG]"));
1225 }
1226
1227 #[test]
1228 fn test_render_textblock_section() {
1229 let input = "{start_of_textblock}\nPreformatted text\n{end_of_textblock}";
1230 let output = render(input);
1231 assert!(output.contains("[Textblock]"));
1232 assert!(output.contains("Preformatted text"));
1233 }
1234
1235 #[test]
1236 fn test_render_musicxml_section() {
1237 let input = "{start_of_musicxml}\n<score-partwise/>\n{end_of_musicxml}";
1238 let output = render(input);
1239 assert!(output.contains("[MusicXML]"));
1240 }
1241
1242 #[test]
1243 fn test_render_musicxml_section_with_label() {
1244 let input = "{start_of_musicxml: Score}\n<score-partwise/>\n{end_of_musicxml}";
1245 let output = render(input);
1246 assert!(output.contains("[MusicXML: Score]"));
1247 }
1248
1249 #[test]
1250 fn test_delegate_verbatim_no_chords() {
1251 let input = "{start_of_textblock}\n[Am]Not a chord\n{end_of_textblock}";
1252 let output = render(input);
1253 assert!(output.contains("[Am]Not a chord"));
1254 }
1255
1256 #[test]
1259 fn test_markup_stripped_in_text_output() {
1260 let output = render("Hello <b>bold</b> world");
1261 assert!(output.contains("Hello bold world"));
1262 assert!(!output.contains("<b>"));
1263 assert!(!output.contains("</b>"));
1264 }
1265
1266 #[test]
1267 fn test_markup_stripped_with_chord() {
1268 let output = render("[Am]Hello <b>bold</b> world");
1269 assert!(output.contains("Am"));
1270 assert!(output.contains("Hello bold world"));
1271 assert!(!output.contains("<b>"));
1272 }
1273
1274 #[test]
1275 fn test_span_markup_stripped_in_text_output() {
1276 let output = render(r#"<span foreground="red">red text</span>"#);
1277 assert!(output.contains("red text"));
1278 assert!(!output.contains("<span"));
1279 assert!(!output.contains("foreground"));
1280 }
1281
1282 #[test]
1285 fn test_render_fullwidth_cjk_alignment() {
1286 let input = "[C]日本語";
1288 let output = render(input);
1289 assert_eq!(output, "C\n日本語\n");
1291 }
1292
1293 #[test]
1294 fn test_render_mixed_width_alignment() {
1295 let input = "[Am]hello世界 [G]test";
1297 let output = render(input);
1298 assert_eq!(output, "Am G\nhello世界 test\n");
1301 }
1302
1303 #[test]
1304 fn test_render_image_placeholder() {
1305 let input = "{image: src=photo.jpg}";
1306 let output = render(input);
1307 assert!(output.contains("[Image: photo.jpg]"));
1308 }
1309
1310 #[test]
1311 fn test_render_image_placeholder_with_path() {
1312 let input = "{image: src=images/cover.png width=200}";
1313 let output = render(input);
1314 assert!(output.contains("[Image: images/cover.png]"));
1315 }
1316
1317 #[test]
1318 fn test_render_image_empty_src_suppressed() {
1319 let input = "{image}";
1320 let output = render(input);
1321 assert!(!output.contains("[Image"));
1322 }
1323
1324 #[test]
1325 fn test_render_image_empty_src_with_other_attrs_suppressed() {
1326 let input = "{image: width=200 height=100}";
1327 let output = render(input);
1328 assert!(!output.contains("[Image"));
1329 }
1330
1331 #[test]
1334 fn test_selector_filtering_removes_non_matching_directive() {
1335 let input = "{title: Song}\n{textfont-piano: Courier}\n[Am]Hello";
1336 let song = chordsketch_core::parse(input).unwrap();
1337 let ctx = chordsketch_core::selector::SelectorContext::new(Some("guitar"), None);
1338 let filtered = ctx.filter_song(&song);
1339 let has_textfont = filtered.lines.iter().any(|l| {
1341 matches!(l, chordsketch_core::ast::Line::Directive(d) if d.kind == chordsketch_core::ast::DirectiveKind::TextFont)
1342 });
1343 assert!(
1344 !has_textfont,
1345 "piano textfont directive should be removed for guitar context"
1346 );
1347 let output = render_song(&filtered);
1348 assert!(output.contains("Hello"), "lyrics should survive filtering");
1349 }
1350
1351 #[test]
1352 fn test_selector_filtering_removes_section_with_contents() {
1353 let input = "{title: Song}\n{start_of_chorus-piano}\n[C]Piano only\n{end_of_chorus-piano}\n[Am]Guitar verse";
1354 let song = chordsketch_core::parse(input).unwrap();
1355 let ctx = chordsketch_core::selector::SelectorContext::new(Some("guitar"), None);
1356 let filtered = ctx.filter_song(&song);
1357 let output = render_song(&filtered);
1358 assert!(
1359 !output.contains("Piano only"),
1360 "piano chorus should be removed for guitar context"
1361 );
1362 assert!(
1363 output.contains("Guitar verse"),
1364 "unselectored content should remain"
1365 );
1366 }
1367
1368 #[test]
1371 fn test_diagrams_auto_inject_text() {
1372 let output = render("{diagrams}\n[Am]Hello");
1373 assert!(
1374 output.contains("[Chord Diagrams]"),
1375 "text output should include Chord Diagrams header"
1376 );
1377 assert!(output.contains("Am"), "Am ASCII diagram expected");
1378 assert!(output.contains("x o"), "Am fret pattern expected");
1380 }
1381
1382 #[test]
1383 fn test_no_diagrams_suppresses_text_inject() {
1384 let output = render("{no_diagrams}\n[Am]Hello");
1385 assert!(
1386 !output.contains("[Chord Diagrams]"),
1387 "{{no_diagrams}} should suppress ASCII diagram block"
1388 );
1389 }
1390
1391 #[test]
1392 fn test_diagrams_off_suppresses_text_inject() {
1393 let output = render("{diagrams: off}\n[Am]Hello");
1394 assert!(
1395 !output.contains("[Chord Diagrams]"),
1396 "{{diagrams: off}} should suppress ASCII diagram block"
1397 );
1398 }
1399
1400 #[test]
1401 fn test_diagrams_piano_auto_inject_text() {
1402 let output = render("{diagrams: piano}\n[Am]Hello [C]world");
1403 assert!(
1404 output.contains("[Chord Diagrams]"),
1405 "piano instrument should include Chord Diagrams header"
1406 );
1407 assert!(output.contains("Am:"), "Am entry expected");
1409 assert!(output.contains("C:"), "C entry expected");
1410 assert!(output.contains("keys"), "key list label expected");
1411 }
1412
1413 #[test]
1414 fn test_render_decomposed_diacritics_alignment() {
1415 let input = "[Em]cafe\u{0301} [D]world";
1420 let output = render(input);
1421 assert_eq!(output, "Em D\ncafe\u{0301} world\n");
1424 }
1425}