use crate::ast::{Chord, Directive, Line, LyricsLine, LyricsSegment, Song};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InputFormat {
ChordPro,
PlainChordLyrics,
Abc,
Unknown,
}
#[derive(Debug, Clone)]
pub struct PlainTextImporter {
pub chord_threshold: f64,
pub min_chord_tokens: usize,
}
impl Default for PlainTextImporter {
fn default() -> Self {
Self {
chord_threshold: 0.5,
min_chord_tokens: 2,
}
}
}
impl PlainTextImporter {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use = "this `Result` should be handled; use `.unwrap()` or `?` to obtain the configured importer"]
pub fn with_thresholds(chord_threshold: f64, min_chord_tokens: usize) -> Result<Self, String> {
if !(0.0..=1.0).contains(&chord_threshold) {
return Err(format!(
"chord_threshold must be in [0.0, 1.0], got {chord_threshold}"
));
}
if min_chord_tokens == 0 {
return Err("min_chord_tokens must be >= 1".to_string());
}
Ok(Self {
chord_threshold,
min_chord_tokens,
})
}
fn is_chord_line(&self, line: &str) -> bool {
if line.contains('.') || line.contains('?') || line.contains('!') {
return false;
}
let tokens: Vec<&str> = line.split_whitespace().collect();
if tokens.is_empty() {
return false;
}
let chord_count = tokens.iter().filter(|t| is_chord_token(t)).count();
chord_count >= self.min_chord_tokens
&& chord_count as f64 / tokens.len() as f64 >= self.chord_threshold
}
#[must_use]
pub fn detect_format(&self, input: &str) -> InputFormat {
let lines: Vec<&str> = input.lines().collect();
let has_directives = lines.iter().any(|l| {
let t = l.trim();
t.starts_with('{') && t.ends_with('}')
});
if has_directives {
return InputFormat::ChordPro;
}
let has_inline_chords = lines.iter().any(|l| {
let trimmed = l.trim();
if trimmed.starts_with('[')
&& trimmed.ends_with(']')
&& trimmed.len() >= 3
&& !trimmed[1..trimmed.len() - 1].contains('[')
{
return false;
}
let mut rest: &str = l;
while let Some(open) = rest.find('[') {
let after = &rest[open + 1..];
let Some(close) = after.find(']') else { break };
let content = &after[..close];
if is_chord_token(content) {
return true;
}
rest = &after[close + 1..];
}
false
});
if has_inline_chords {
return InputFormat::ChordPro;
}
let has_abc_header = lines.iter().any(|l| {
let t = l.trim_start();
if let Some(rest) = t.strip_prefix("X:") {
rest.trim_start()
.chars()
.next()
.is_some_and(|c| c.is_ascii_digit())
} else {
false
}
});
if has_abc_header {
return InputFormat::Abc;
}
let chord_line_count = lines.iter().filter(|l| self.is_chord_line(l)).count();
if chord_line_count >= 2 {
InputFormat::PlainChordLyrics
} else if chord_line_count == 1 && lines.len() <= 5 {
InputFormat::PlainChordLyrics
} else {
InputFormat::Unknown
}
}
#[must_use]
pub fn convert(&self, input: &str) -> Song {
let raw_lines: Vec<&str> = input.lines().collect();
let classes: Vec<LineKind<'_>> = raw_lines
.iter()
.map(|l| classify_line(l, |line| self.is_chord_line(line)))
.collect();
let mut song = Song::new();
let mut i = 0;
let mut current_section: Option<String> = None;
while i < classes.len() {
match &classes[i] {
LineKind::Blank => {
song.lines.push(Line::Empty);
i += 1;
}
LineKind::SectionHeader(label) => {
let label = label.clone();
if let Some(ref sec) = current_section {
song.lines
.push(Line::Directive(end_directive_for_section(sec)));
}
let (start_dir, canonical) = start_directive_for_section(&label);
song.lines.push(Line::Directive(start_dir));
current_section = Some(canonical);
i += 1;
}
LineKind::ChordLine(positions) => {
let j = i + 1;
if j < classes.len() {
if let LineKind::Lyric(lyric) = &classes[j] {
let paired = pair_chords_with_lyric(positions, lyric);
song.lines.push(Line::Lyrics(paired));
i += 2;
continue;
}
}
let paired = pair_chords_with_lyric(positions, "");
song.lines.push(Line::Lyrics(paired));
i += 1;
}
LineKind::Lyric(text) => {
song.lines.push(Line::Lyrics(LyricsLine {
segments: vec![LyricsSegment {
chord: None,
text: (*text).to_string(),
spans: vec![],
}],
}));
i += 1;
}
}
}
if let Some(ref sec) = current_section {
song.lines
.push(Line::Directive(end_directive_for_section(sec)));
}
song
}
}
#[must_use]
pub fn detect_format(input: &str) -> InputFormat {
PlainTextImporter::default().detect_format(input)
}
#[must_use]
pub fn convert_plain_text(input: &str) -> Song {
PlainTextImporter::default().convert(input)
}
#[derive(Debug)]
enum LineKind<'a> {
Blank,
SectionHeader(String),
ChordLine(Vec<(usize, String)>),
Lyric(&'a str),
}
fn classify_line<'a, F>(line: &'a str, is_chord_line: F) -> LineKind<'a>
where
F: Fn(&str) -> bool,
{
if line.trim().is_empty() {
return LineKind::Blank;
}
if let Some(label) = parse_section_header(line) {
return LineKind::SectionHeader(label);
}
if is_chord_line(line) {
return LineKind::ChordLine(chord_positions(line));
}
LineKind::Lyric(line)
}
fn is_chord_token(token: &str) -> bool {
if token.is_empty() || token.len() > 16 {
return false;
}
let bytes = token.as_bytes();
if !matches!(bytes[0], b'A'..=b'G') {
return false;
}
let (body, bass) = match token.find('/') {
Some(i) => (&token[..i], Some(&token[i + 1..])),
None => (token, None),
};
if let Some(bass) = bass {
if bass.is_empty() {
return false;
}
let b = bass.as_bytes();
if !matches!(b[0], b'A'..=b'G') {
return false;
}
if b.len() > 1 && b[1] != b'#' && b[1] != b'b' {
return false;
}
if b.len() > 2 {
return false;
}
}
let body_bytes = body.as_bytes();
let mut pos = 1usize;
if pos < body_bytes.len() && (body_bytes[pos] == b'#' || body_bytes[pos] == b'b') {
pos += 1;
}
let quality_ext = &body[pos..];
is_valid_quality_ext(quality_ext)
}
fn consume_numeric(s: &str) -> &str {
let bytes = s.as_bytes();
let mut i = 0;
if bytes.len() >= 2 && (bytes[0] == b'#' || bytes[0] == b'b') && bytes[1].is_ascii_digit() {
i = 1;
}
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
&s[i..]
}
fn is_valid_quality_ext(s: &str) -> bool {
let mut rest = s;
loop {
if rest.is_empty() {
return true;
}
let after_kw: Option<&str> = None
.or_else(|| rest.strip_prefix("maj"))
.or_else(|| rest.strip_prefix("min"))
.or_else(|| rest.strip_prefix("dim"))
.or_else(|| rest.strip_prefix("aug"))
.or_else(|| rest.strip_prefix("sus"))
.or_else(|| rest.strip_prefix("add"))
.or_else(|| rest.strip_prefix('m'))
.or_else(|| rest.strip_prefix('+'))
.or_else(|| rest.strip_prefix('°'));
if let Some(after) = after_kw {
rest = consume_numeric(after);
} else {
let next = consume_numeric(rest);
if next.len() == rest.len() {
return false;
}
rest = next;
}
}
}
fn chord_positions(line: &str) -> Vec<(usize, String)> {
let mut result = Vec::new();
let bytes = line.as_bytes();
let len = bytes.len();
let mut i = 0;
while i < len {
if bytes[i].is_ascii_whitespace() {
i += 1;
continue;
}
let start = i;
while i < len && !bytes[i].is_ascii_whitespace() {
i += 1;
}
let token = &line[start..i];
if is_chord_token(token) {
result.push((start, token.to_string()));
}
}
result
}
fn parse_section_header(line: &str) -> Option<String> {
let trimmed = line.trim();
fn is_safe_label(label: &str) -> bool {
!label.is_empty() && !label.contains('{') && !label.contains('}')
}
if trimmed.starts_with('[') && trimmed.ends_with(']') && trimmed.len() >= 3 {
let inner = trimmed[1..trimmed.len() - 1].trim();
if !inner.contains('[') && is_safe_label(inner) {
return Some(inner.to_string());
}
}
if trimmed.starts_with('(') && trimmed.ends_with(')') && trimmed.len() >= 3 {
let inner = trimmed[1..trimmed.len() - 1].trim();
if !inner.contains('(') && is_safe_label(inner) {
return Some(inner.to_string());
}
}
if let Some(label) = trimmed.strip_suffix(':') {
if label
.chars()
.all(|c| c.is_alphabetic() || c == ' ' || c == '-')
&& is_safe_label(label.trim())
{
return Some(label.trim().to_string());
}
}
for delim in &["--", "==", "**", "##"] {
if trimmed.starts_with(delim) && trimmed.ends_with(delim) && trimmed.len() > 2 * delim.len()
{
let inner = trimmed[delim.len()..trimmed.len() - delim.len()].trim();
if is_safe_label(inner) {
return Some(inner.to_string());
}
}
}
None
}
fn canonical_section(label: &str) -> &'static str {
let lower = label.to_lowercase();
let lower = lower.trim();
if lower.starts_with("chorus") || lower.starts_with("refrain") {
"chorus"
} else if lower.starts_with("bridge") {
"bridge"
} else {
"verse"
}
}
fn start_directive_for_section(label: &str) -> (Directive, String) {
let canonical = canonical_section(label);
let dir_name = format!("start_of_{canonical}");
let lower_label = label.trim().to_lowercase();
let dir = if lower_label == canonical {
Directive::name_only(dir_name)
} else {
Directive::with_value(dir_name, label.trim().to_string())
};
(dir, canonical.to_string())
}
fn end_directive_for_section(canonical: &str) -> Directive {
let dir_name = format!("end_of_{canonical}");
Directive::name_only(dir_name)
}
fn pair_chords_with_lyric(positions: &[(usize, String)], lyric: &str) -> LyricsLine {
if positions.is_empty() {
return LyricsLine {
segments: vec![LyricsSegment {
chord: None,
text: lyric.to_string(),
spans: vec![],
}],
};
}
let lyric_char_offsets: Vec<usize> = lyric.char_indices().map(|(b, _)| b).collect();
let lyric_len = lyric.len();
let clamp_to_lyric = |col: usize| -> usize {
if col >= lyric_len {
return lyric_len;
}
lyric_char_offsets
.iter()
.copied()
.rfind(|&b| b <= col)
.unwrap_or(0)
};
let mut segments: Vec<LyricsSegment> = Vec::new();
let mut cursor = 0usize;
for (i, (col, chord_name)) in positions.iter().enumerate() {
let text_start = clamp_to_lyric(*col);
let text_end = if let Some((next_col, _)) = positions.get(i + 1) {
clamp_to_lyric(*next_col)
} else {
lyric_len };
if text_start > cursor {
segments.push(LyricsSegment {
chord: None,
text: lyric[cursor..text_start].to_string(),
spans: vec![],
});
}
let text = lyric[text_start..text_end.min(lyric_len)].to_string();
segments.push(LyricsSegment {
chord: Some(Chord::new(chord_name.as_str())),
text,
spans: vec![],
});
cursor = text_end.min(lyric_len);
}
LyricsLine { segments }
}
fn sanitize_directive_token(s: &str) -> std::borrow::Cow<'_, str> {
if s.as_bytes()
.iter()
.any(|&b| matches!(b, b'{' | b'}' | b'\n' | b'\r'))
{
std::borrow::Cow::Owned(s.replace(['{', '}', '\n', '\r'], ""))
} else {
std::borrow::Cow::Borrowed(s)
}
}
fn sanitize_lyric_text(s: &str) -> std::borrow::Cow<'_, str> {
if s.as_bytes()
.iter()
.any(|&b| matches!(b, b'{' | b'}' | b'[' | b']' | b'\n' | b'\r'))
{
std::borrow::Cow::Owned(s.replace(['{', '}', '[', ']', '\n', '\r'], ""))
} else {
std::borrow::Cow::Borrowed(s)
}
}
fn sanitize_chord_name(s: &str) -> std::borrow::Cow<'_, str> {
if s.as_bytes()
.iter()
.any(|&b| matches!(b, b'{' | b'}' | b'[' | b']' | b'\n' | b'\r'))
{
std::borrow::Cow::Owned(s.replace(['{', '}', '[', ']', '\n', '\r'], ""))
} else {
std::borrow::Cow::Borrowed(s)
}
}
#[must_use]
pub fn song_to_chordpro(song: &Song) -> String {
use crate::ast::{CommentStyle, Line};
let mut out = String::new();
if let Some(ref title) = song.metadata.title {
out.push_str(&format!("{{title: {}}}\n", sanitize_directive_token(title)));
}
if let Some(artist) = song.metadata.artists.first() {
out.push_str(&format!(
"{{artist: {}}}\n",
sanitize_directive_token(artist)
));
}
for line in &song.lines {
match line {
Line::Empty => out.push('\n'),
Line::Comment(style, text) => {
let t = sanitize_directive_token(text);
match style {
CommentStyle::Normal => out.push_str(&format!("{{comment: {t}}}\n")),
CommentStyle::Italic => out.push_str(&format!("{{comment_italic: {t}}}\n")),
CommentStyle::Boxed => out.push_str(&format!("{{comment_box: {t}}}\n")),
CommentStyle::Highlight => out.push_str(&format!("{{highlight: {t}}}\n")),
}
}
Line::Directive(dir) => {
let name = sanitize_directive_token(&dir.name);
if let Some(ref value) = dir.value {
out.push_str(&format!(
"{{{}: {}}}\n",
name,
sanitize_directive_token(value)
));
} else {
out.push_str(&format!("{{{}}}\n", name));
}
}
Line::Lyrics(lyrics) => {
for seg in &lyrics.segments {
if let Some(ref chord) = seg.chord {
out.push('[');
out.push_str(&sanitize_chord_name(&chord.name));
out.push(']');
}
out.push_str(&sanitize_lyric_text(&seg.text));
}
out.push('\n');
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn chord_names(line: &LyricsLine) -> Vec<Option<String>> {
line.segments
.iter()
.map(|s| s.chord.as_ref().map(|c| c.name.clone()))
.collect()
}
#[test]
fn detects_chordpro_from_directives() {
assert_eq!(
detect_format("{title: Hello}\n{soc}\n[Am]Hello\n{eoc}"),
InputFormat::ChordPro
);
}
#[test]
fn detects_chordpro_from_inline_chords() {
assert_eq!(detect_format("[Am]Hello [G]world"), InputFormat::ChordPro);
}
#[test]
fn detects_plain_chord_lyrics() {
let input = "Am G C Em\nHello beautiful world tonight\nG C\nOnce more";
assert_eq!(detect_format(input), InputFormat::PlainChordLyrics);
}
#[test]
fn detects_plain_chord_lyrics_with_section_labels() {
let input = "[Verse]\nG D\nHere I am\nEm C\nWondering\n\n[Chorus]\nC G\nLala\n";
assert_eq!(detect_format(input), InputFormat::PlainChordLyrics);
}
#[test]
fn detects_single_letter_section_label_not_chordpro() {
let input = "[C]\nG D\nHere I am\nEm C\nWondering\n";
assert_eq!(detect_format(input), InputFormat::PlainChordLyrics);
let input_am = "[Am]\nG D Em\nHello world again now\n";
assert_eq!(detect_format(input_am), InputFormat::PlainChordLyrics);
}
#[test]
fn detects_inline_chord_in_line_still_chordpro() {
assert_eq!(detect_format("[C]Hello world"), InputFormat::ChordPro);
assert_eq!(detect_format("Hello [Am]world"), InputFormat::ChordPro);
}
#[test]
fn detects_multi_bracket_line_as_chordpro() {
assert_eq!(detect_format("[Am][G]"), InputFormat::ChordPro);
assert_eq!(detect_format("[C][G][Am]"), InputFormat::ChordPro);
}
#[test]
fn detects_mixed_section_label_and_inline_chord_as_chordpro() {
let input = "[Verse]\nHello [Am]world\n";
assert_eq!(detect_format(input), InputFormat::ChordPro);
}
#[test]
fn known_limitation_whole_line_chord_only_chordpro_returns_unknown() {
let input = "[Am]\nThis is a lyric line\n[G]\nAnother lyric line\n";
assert_eq!(detect_format(input), InputFormat::Unknown);
}
#[test]
fn detects_unknown_for_pure_lyrics() {
let input = "Hello beautiful world\nOnce upon a time\nSomething happened here";
assert_eq!(detect_format(input), InputFormat::Unknown);
}
#[test]
fn detects_unknown_for_empty() {
assert_eq!(detect_format(""), InputFormat::Unknown);
}
#[test]
fn chord_token_rejects_section_labels() {
assert!(!is_chord_token("Chorus"));
assert!(!is_chord_token("Bridge"));
assert!(!is_chord_token("Em7add9sus2extended"));
}
#[test]
fn chord_token_rejects_chord_like_prefix_in_lyric_words() {
assert!(!is_chord_token("Amazing"));
assert!(!is_chord_token("Empty"));
assert!(!is_chord_token("Get"));
assert!(!is_chord_token("Bad"));
assert!(!is_chord_token("Broken"));
assert!(!is_chord_token("Cold"));
assert!(!is_chord_token("Dance"));
assert!(!is_chord_token("Edge"));
assert!(!is_chord_token("Father"));
assert!(!is_chord_token("Furniture")); assert!(!is_chord_token("Gone"));
assert!(!is_chord_token("Amber")); assert!(!is_chord_token("Emerald")); assert!(!is_chord_token("Dmitri")); }
#[test]
fn chord_token_accepts_bare_dm_but_collisions_are_line_level() {
assert!(is_chord_token("Dm"));
}
#[test]
fn chord_token_accepts_valid_chords() {
assert!(is_chord_token("Am"));
assert!(is_chord_token("C"));
assert!(is_chord_token("G7"));
assert!(is_chord_token("Cmaj7"));
assert!(is_chord_token("D/F#"));
assert!(is_chord_token("Bb"));
assert!(is_chord_token("F#m7"));
assert!(is_chord_token("Gsus4"));
assert!(is_chord_token("Em"));
}
#[test]
fn chord_token_accepts_multicomponent_extensions() {
assert!(is_chord_token("Am7add11"));
assert!(is_chord_token("Cmaj7sus4"));
assert!(is_chord_token("G7b5"));
assert!(is_chord_token("Fmaj7add9"));
assert!(is_chord_token("Dm7add11"));
assert!(is_chord_token("G7#9"));
assert!(is_chord_token("Cmaj9"));
assert!(!is_chord_token("Chorus"));
assert!(!is_chord_token("Bridge"));
assert!(!is_chord_token("Cmaj7e"));
assert!(is_chord_token("Cmaj7sus4add9")); }
#[test]
fn chord_line_typical() {
let imp = PlainTextImporter::new();
assert!(imp.is_chord_line("Am F C G"));
}
#[test]
fn chord_line_with_slash_chords() {
let imp = PlainTextImporter::new();
assert!(imp.is_chord_line("G D/F# Em C"));
}
#[test]
fn not_chord_line_all_lyrics() {
let imp = PlainTextImporter::new();
assert!(!imp.is_chord_line("There's a lady who's sure."));
}
#[test]
fn not_chord_line_sentence_punctuation() {
let imp = PlainTextImporter::new();
assert!(!imp.is_chord_line("A song for G."));
}
#[test]
fn not_chord_line_too_few_chords() {
let imp = PlainTextImporter::new();
assert!(!imp.is_chord_line("Am something else here now"));
}
#[test]
fn section_square_brackets() {
assert_eq!(parse_section_header("[Verse]"), Some("Verse".to_string()));
assert_eq!(
parse_section_header("[Chorus 2]"),
Some("Chorus 2".to_string())
);
}
#[test]
fn section_parens() {
assert_eq!(parse_section_header("(Bridge)"), Some("Bridge".to_string()));
}
#[test]
fn section_colon() {
assert_eq!(parse_section_header("VERSE:"), Some("VERSE".to_string()));
assert_eq!(parse_section_header("Chorus:"), Some("Chorus".to_string()));
}
#[test]
fn section_dash_decorated() {
assert_eq!(
parse_section_header("-- Chorus --"),
Some("Chorus".to_string())
);
}
#[test]
fn section_not_matched() {
assert_eq!(parse_section_header("Hello world"), None);
assert_eq!(parse_section_header("Am G C Em"), None);
}
#[test]
fn section_rejects_brace_in_label() {
assert_eq!(parse_section_header("[Verse}]"), None);
assert_eq!(parse_section_header("[{Chorus}]"), None);
assert_eq!(parse_section_header("Verse}:"), None);
}
#[test]
fn pair_basic() {
let positions = vec![(0, "Am".to_string()), (4, "F".to_string())];
let line = pair_chords_with_lyric(&positions, "Hello world");
let chords = chord_names(&line);
assert_eq!(chords, vec![Some("Am".to_string()), Some("F".to_string())]);
assert_eq!(line.segments[0].text, "Hell");
assert_eq!(line.segments[1].text, "o world");
}
#[test]
fn pair_lyric_shorter_than_chord_line() {
let positions = vec![(0, "Am".to_string()), (10, "G".to_string())];
let line = pair_chords_with_lyric(&positions, "Hi");
assert_eq!(
line.segments[0].chord.as_ref().map(|c| c.name.as_str()),
Some("Am")
);
assert_eq!(line.segments[0].text, "Hi");
assert_eq!(
line.segments[1].chord.as_ref().map(|c| c.name.as_str()),
Some("G")
);
assert_eq!(line.segments[1].text, "");
}
#[test]
fn pair_no_chords_returns_plain_lyric() {
let positions: Vec<(usize, String)> = vec![];
let line = pair_chords_with_lyric(&positions, "Hello world");
assert_eq!(line.segments.len(), 1);
assert!(line.segments[0].chord.is_none());
assert_eq!(line.segments[0].text, "Hello world");
}
#[test]
fn convert_simple_verse() {
let input = "[Verse]\nAm G C\nHello world today\n";
let song = convert_plain_text(input);
assert!(song.lines.iter().any(|l| matches!(l, Line::Directive(_))));
assert!(song.lines.iter().any(|l| matches!(l, Line::Lyrics(_))));
}
#[test]
fn convert_chordless_lyric_passthrough() {
let input = "Am G C Em\nThere is a song\nThis line has no preceding chord line\n";
let song = convert_plain_text(input);
let has_plain_lyric = song.lines.iter().any(|l| {
if let Line::Lyrics(ll) = l {
ll.segments.len() == 1
&& ll.segments[0].chord.is_none()
&& ll.segments[0].text.contains("no preceding")
} else {
false
}
});
assert!(has_plain_lyric);
}
#[test]
fn convert_section_labels_to_directives() {
let input = "[Chorus]\nG C G D\nLala lala lala\n[Verse]\nAm F C G\nHello world\n";
let song = convert_plain_text(input);
use crate::ast::DirectiveKind;
let kinds: Vec<&DirectiveKind> = song
.lines
.iter()
.filter_map(|l| {
if let Line::Directive(d) = l {
Some(&d.kind)
} else {
None
}
})
.collect();
assert!(kinds.iter().any(|k| **k == DirectiveKind::StartOfChorus));
assert!(kinds.iter().any(|k| **k == DirectiveKind::StartOfVerse));
}
#[test]
fn convert_multiple_sections_close_properly() {
let input = "[Verse]\nAm G\nHello world\n[Chorus]\nC G\nYeah yeah\n";
let song = convert_plain_text(input);
use crate::ast::DirectiveKind;
let kinds: Vec<&DirectiveKind> = song
.lines
.iter()
.filter_map(|l| {
if let Line::Directive(d) = l {
Some(&d.kind)
} else {
None
}
})
.collect();
assert!(kinds.iter().any(|k| **k == DirectiveKind::EndOfVerse));
assert!(kinds.iter().any(|k| **k == DirectiveKind::EndOfChorus));
}
#[test]
fn song_to_chordpro_strips_braces_in_title() {
let mut song = Song::default();
song.metadata.title = Some("Hello {World}".to_string());
let out = song_to_chordpro(&song);
assert_eq!(out, "{title: Hello World}\n");
}
#[test]
fn song_to_chordpro_strips_braces_in_artist() {
let mut song = Song::default();
song.metadata.artists.push("{Dodgy} Artist".to_string());
let out = song_to_chordpro(&song);
assert_eq!(out, "{artist: Dodgy Artist}\n");
}
#[test]
fn song_to_chordpro_strips_braces_in_comment() {
use crate::ast::{CommentStyle, Line};
let mut song = Song::default();
song.lines.push(Line::Comment(
CommentStyle::Normal,
"See {note}".to_string(),
));
let out = song_to_chordpro(&song);
assert_eq!(out, "{comment: See note}\n");
}
#[test]
fn song_to_chordpro_strips_braces_in_directive_name_and_value() {
use crate::ast::{Directive, Line};
let mut dir = Directive::name_only("start_of_{section}".to_string());
dir.value = Some("{custom}".to_string());
let mut song = Song::default();
song.lines.push(Line::Directive(dir));
let out = song_to_chordpro(&song);
assert_eq!(out, "{start_of_section: custom}\n");
}
#[test]
fn song_to_chordpro_strips_embedded_newline_in_title() {
let mut song = Song::default();
song.metadata.title = Some("Foo\nBar".to_string());
let out = song_to_chordpro(&song);
assert_eq!(out, "{title: FooBar}\n");
}
#[test]
fn song_to_chordpro_strips_braces_in_lyric_text() {
use crate::ast::{Line, LyricsLine, LyricsSegment};
let mut song = Song::default();
song.lines.push(Line::Lyrics(LyricsLine {
segments: vec![LyricsSegment::text_only("see {title: HACKED} here")],
}));
let out = song_to_chordpro(&song);
assert!(
!out.contains('{') && !out.contains('}'),
"braces must not appear in serialized lyric output: {out:?}"
);
assert!(out.contains("see title: HACKED here"));
}
#[test]
fn song_to_chordpro_strips_brackets_in_lyric_text() {
use crate::ast::{Line, LyricsLine, LyricsSegment};
let mut song = Song::default();
song.lines.push(Line::Lyrics(LyricsLine {
segments: vec![LyricsSegment::text_only("pre [Cmaj7] mid")],
}));
let out = song_to_chordpro(&song);
assert!(
!out.contains('[') && !out.contains(']'),
"square brackets must not appear in serialized lyric output: {out:?}"
);
assert!(out.contains("pre Cmaj7 mid"));
}
#[test]
fn song_to_chordpro_strips_closing_bracket_in_chord_name() {
use crate::ast::{Chord, Line, LyricsLine, LyricsSegment};
let mut song = Song::default();
let chord = Chord {
name: "C]{title: HACKED}".to_string(),
detail: None,
display: None,
};
song.lines.push(Line::Lyrics(LyricsLine {
segments: vec![LyricsSegment::new(Some(chord), "hello")],
}));
let out = song_to_chordpro(&song);
assert!(
!out.contains('{') && !out.contains('}'),
"injected directive braces must be stripped: {out:?}"
);
assert_eq!(out, "[Ctitle: HACKED]hello\n");
}
#[test]
fn song_to_chordpro_strips_embedded_newline_in_lyric_text() {
use crate::ast::{Line, LyricsLine, LyricsSegment};
let mut song = Song::default();
song.lines.push(Line::Lyrics(LyricsLine {
segments: vec![LyricsSegment::text_only("Hello\n{title: HACKED}")],
}));
let out = song_to_chordpro(&song);
assert_eq!(out, "Hellotitle: HACKED\n");
}
#[test]
fn song_to_chordpro_strips_embedded_carriage_return_in_lyric_text() {
use crate::ast::{Line, LyricsLine, LyricsSegment};
let mut song = Song::default();
song.lines.push(Line::Lyrics(LyricsLine {
segments: vec![LyricsSegment::text_only("a\rb")],
}));
let out = song_to_chordpro(&song);
assert_eq!(out, "ab\n");
}
#[test]
fn song_to_chordpro_strips_embedded_newline_in_chord_name() {
use crate::ast::{Chord, Line, LyricsLine, LyricsSegment};
let mut song = Song::default();
let chord = Chord {
name: "C\nD".to_string(),
detail: None,
display: None,
};
song.lines.push(Line::Lyrics(LyricsLine {
segments: vec![LyricsSegment::new(Some(chord), "x")],
}));
let out = song_to_chordpro(&song);
assert_eq!(out, "[CD]x\n");
}
}