use std::collections::HashMap;
#[must_use]
pub fn convert_abc(input: &str) -> String {
let tunes = split_tunes(input);
let mut out = String::new();
for (i, tune) in tunes.iter().enumerate() {
if i > 0 {
out.push_str("{new_song}\n");
}
out.push_str(&convert_tune(tune));
}
out
}
fn split_tunes(input: &str) -> Vec<String> {
let mut tunes: Vec<String> = Vec::new();
let mut current = String::new();
for line in input.lines() {
if line.starts_with("X:") && !current.trim().is_empty() {
tunes.push(std::mem::take(&mut current));
}
current.push_str(line);
current.push('\n');
}
if !current.trim().is_empty() {
tunes.push(current);
}
if tunes.is_empty() && !input.trim().is_empty() {
tunes.push(input.to_string());
}
tunes
}
fn convert_tune(input: &str) -> String {
let mut out = String::new();
let mut title: Option<String> = None;
let mut composer: Option<String> = None;
let mut tempo: Option<String> = None;
let mut in_header = true;
let mut open_section = false;
let mut music_lines: Vec<String> = Vec::new();
let mut pending_w_lines: Vec<String> = Vec::new();
for line in input.lines() {
let trimmed = line.trim();
if trimmed.starts_with('%') {
continue;
}
if trimmed.is_empty() {
if !in_header {
flush_block(&music_lines, &pending_w_lines, &mut out);
music_lines.clear();
pending_w_lines.clear();
}
continue;
}
if !in_header && (trimmed.starts_with("w:") || trimmed.starts_with("W:")) {
let lyrics = trimmed[2..].trim_start().to_string();
pending_w_lines.push(lyrics);
continue;
}
if let Some((field, value)) = parse_field_line(trimmed) {
match field {
'X' => {
in_header = true;
}
'T' => title = Some(value.to_string()),
'C' => composer = Some(value.to_string()),
'Q' => tempo = extract_tempo_bpm(value),
'K' if in_header => {
emit_header_directives(
title.as_deref(),
composer.as_deref(),
tempo.as_deref(),
Some(value),
&mut out,
);
out.push('\n');
in_header = false;
}
'P' if !in_header => {
flush_block(&music_lines, &pending_w_lines, &mut out);
music_lines.clear();
pending_w_lines.clear();
if open_section {
out.push_str("{end_of_verse}\n");
}
out.push_str(&format!(
"{{start_of_verse: {}}}\n",
escape_directive_value(value)
));
open_section = true;
}
_ => {}
}
continue;
}
if in_header {
continue;
}
if !pending_w_lines.is_empty() {
flush_block(&music_lines, &pending_w_lines, &mut out);
music_lines.clear();
pending_w_lines.clear();
}
music_lines.push(trimmed.to_string());
}
if !music_lines.is_empty() || !pending_w_lines.is_empty() {
flush_block(&music_lines, &pending_w_lines, &mut out);
}
if open_section {
out.push_str("{end_of_verse}\n");
}
out
}
fn parse_field_line(line: &str) -> Option<(char, &str)> {
let mut chars = line.chars();
let field = chars.next()?;
if !field.is_ascii_alphabetic() {
return None;
}
let rest = chars.as_str();
if let Some(after_colon) = rest.strip_prefix(':') {
Some((field, after_colon.trim()))
} else {
None
}
}
fn extract_tempo_bpm(value: &str) -> Option<String> {
let candidate = if let Some(pos) = value.find('=') {
value[pos + 1..].split_whitespace().next().unwrap_or("")
} else {
value.split_whitespace().next().unwrap_or("")
};
if !candidate.is_empty() && candidate.bytes().all(|b| b.is_ascii_digit()) {
Some(candidate.to_string())
} else {
None
}
}
fn emit_header_directives(
title: Option<&str>,
composer: Option<&str>,
tempo: Option<&str>,
key: Option<&str>,
out: &mut String,
) {
if let Some(t) = title {
out.push_str(&format!("{{title: {}}}\n", escape_directive_value(t)));
}
if let Some(c) = composer {
out.push_str(&format!("{{composer: {}}}\n", escape_directive_value(c)));
}
if let Some(t) = tempo {
out.push_str(&format!("{{tempo: {}}}\n", escape_directive_value(t)));
}
if let Some(k) = key {
out.push_str(&format!("{{key: {}}}\n", escape_directive_value(k)));
}
}
fn escape_directive_value(s: &str) -> String {
let s = s.trim();
if s.contains('{') || s.contains('}') {
s.replace(['{', '}'], "")
} else {
s.to_string()
}
}
fn sanitize_lyric_text(s: &str) -> String {
if s.contains('{') || s.contains('}') {
s.replace(['{', '}'], "")
} else {
s.to_string()
}
}
fn sanitize_chord_name(chord: &str) -> String {
if chord.contains(['[', ']', '{', '}']) {
chord
.chars()
.filter(|&c| !matches!(c, '[' | ']' | '{' | '}'))
.collect()
} else {
chord.to_string()
}
}
fn flush_block(music_lines: &[String], w_lines: &[String], out: &mut String) {
if music_lines.is_empty() {
for w in w_lines {
let safe = sanitize_lyric_text(w);
if !safe.is_empty() {
out.push_str(&safe);
out.push('\n');
}
}
return;
}
let combined_music: String = music_lines.join(" ");
let (chord_at_note, note_count) = extract_chords_and_notes(&combined_music);
if w_lines.is_empty() {
let chord_line = build_chord_only_line(&chord_at_note, note_count);
if !chord_line.trim().is_empty() {
out.push_str(&chord_line);
out.push('\n');
}
return;
}
for w_line in w_lines {
let syllables = parse_abc_lyrics(w_line);
let line = build_chordpro_line(&chord_at_note, &syllables);
if !line.trim().is_empty() {
out.push_str(&line);
out.push('\n');
}
}
}
fn extract_chords_and_notes(line: &str) -> (HashMap<usize, String>, usize) {
let mut chord_at: HashMap<usize, String> = HashMap::new();
let mut note_idx: usize = 0;
let mut pending_chord: Option<String> = None;
let chars: Vec<char> = line.chars().collect();
let mut i = 0;
while i < chars.len() {
match chars[i] {
'%' => break,
'"' => {
i += 1; let start = i;
while i < chars.len() && chars[i] != '"' {
i += 1;
}
let chord: String = chars[start..i].iter().collect();
let chord = chord.trim().to_string();
if !chord.is_empty()
&& !matches!(chord.as_str(), "N.C." | "N.C" | "NC" | "n.c." | "n.c")
{
pending_chord = Some(chord);
}
if i < chars.len() {
i += 1; }
}
'z' | 'Z' | 'x' => {
assign_chord(&mut chord_at, &mut pending_chord, note_idx);
note_idx += 1;
i += 1;
skip_duration(&chars, &mut i);
}
'^' | '_' | '=' => {
let mut j = i + 1;
if j < chars.len() && (chars[j] == '^' || chars[j] == '_') {
j += 1;
}
if j < chars.len() && is_note_letter(chars[j]) {
assign_chord(&mut chord_at, &mut pending_chord, note_idx);
note_idx += 1;
i = j + 1;
skip_note_suffix(&chars, &mut i);
} else {
i += 1;
}
}
c if is_note_letter(c) => {
assign_chord(&mut chord_at, &mut pending_chord, note_idx);
note_idx += 1;
i += 1;
skip_note_suffix(&chars, &mut i);
}
'[' => {
i += 1;
if i < chars.len()
&& (chars[i].is_ascii_digit() || chars[i] == ':' || chars[i] == '|')
{
while i < chars.len() && chars[i] != ']' {
i += 1;
}
if i < chars.len() {
i += 1;
}
} else {
assign_chord(&mut chord_at, &mut pending_chord, note_idx);
note_idx += 1;
while i < chars.len() && chars[i] != ']' {
i += 1;
}
if i < chars.len() {
i += 1;
}
skip_duration(&chars, &mut i);
}
}
'{' => {
i += 1;
while i < chars.len() && chars[i] != '}' {
i += 1;
}
if i < chars.len() {
i += 1;
}
}
'!' | '+' => {
let closing = chars[i];
i += 1;
while i < chars.len() && chars[i] != closing {
i += 1;
}
if i < chars.len() {
i += 1;
}
}
'|' | ':' => {
i += 1;
while i < chars.len() && (chars[i] == '|' || chars[i] == ':') {
i += 1;
}
}
'(' => {
i += 1;
while i < chars.len() && chars[i].is_ascii_digit() {
i += 1;
}
}
_ => {
i += 1;
}
}
}
(chord_at, note_idx)
}
fn assign_chord(chord_at: &mut HashMap<usize, String>, pending: &mut Option<String>, idx: usize) {
if let Some(chord) = pending.take() {
chord_at.insert(idx, chord);
}
}
fn is_note_letter(c: char) -> bool {
matches!(c, 'A'..='G' | 'a'..='g')
}
fn skip_note_suffix(chars: &[char], i: &mut usize) {
while *i < chars.len() && (chars[*i] == '\'' || chars[*i] == ',') {
*i += 1;
}
skip_duration(chars, i);
}
fn skip_duration(chars: &[char], i: &mut usize) {
while *i < chars.len() && (chars[*i].is_ascii_digit() || chars[*i] == '/') {
*i += 1;
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum LyricToken {
Syllable(String),
Hold,
Skip,
Bar,
}
fn parse_abc_lyrics(w_line: &str) -> Vec<LyricToken> {
let mut tokens: Vec<LyricToken> = Vec::new();
let mut syllable = String::new();
let chars: Vec<char> = w_line.chars().collect();
let mut i = 0;
while i < chars.len() {
match chars[i] {
' ' | '\t' => {
if !syllable.is_empty() {
tokens.push(LyricToken::Syllable(syllable.clone()));
syllable.clear();
}
i += 1;
}
'-' => {
syllable.push('-');
tokens.push(LyricToken::Syllable(syllable.clone()));
syllable.clear();
i += 1;
}
'_' => {
if !syllable.is_empty() {
tokens.push(LyricToken::Syllable(syllable.clone()));
syllable.clear();
}
tokens.push(LyricToken::Hold);
i += 1;
}
'*' => {
if !syllable.is_empty() {
tokens.push(LyricToken::Syllable(syllable.clone()));
syllable.clear();
}
tokens.push(LyricToken::Skip);
i += 1;
}
'|' => {
if !syllable.is_empty() {
tokens.push(LyricToken::Syllable(syllable.clone()));
syllable.clear();
}
tokens.push(LyricToken::Bar);
i += 1;
}
'~' => {
syllable.push(' ');
i += 1;
}
'\\' if i + 1 < chars.len() && chars[i + 1] == 'n' => {
if !syllable.is_empty() {
tokens.push(LyricToken::Syllable(syllable.clone()));
syllable.clear();
}
i += 2;
}
c => {
syllable.push(c);
i += 1;
}
}
}
if !syllable.is_empty() {
tokens.push(LyricToken::Syllable(syllable));
}
tokens
}
fn build_chordpro_line(chord_at: &HashMap<usize, String>, syllables: &[LyricToken]) -> String {
let mut out = String::new();
let mut note_idx: usize = 0;
let mut need_space = false;
for token in syllables {
match token {
LyricToken::Bar => {
}
LyricToken::Hold => {
note_idx += 1;
}
LyricToken::Skip => {
out.push(' ');
need_space = false;
note_idx += 1;
}
LyricToken::Syllable(text) => {
if need_space {
out.push(' ');
}
if let Some(chord) = chord_at.get(¬e_idx) {
out.push('[');
out.push_str(&sanitize_chord_name(chord));
out.push(']');
}
let safe = sanitize_lyric_text(text);
out.push_str(&safe);
need_space = !safe.ends_with('-');
note_idx += 1;
}
}
}
if let Some(&last_chord_note) = chord_at.keys().max() {
while note_idx <= last_chord_note {
if let Some(chord) = chord_at.get(¬e_idx) {
let safe = sanitize_chord_name(chord);
out.push_str(&format!("[{safe}]"));
}
note_idx += 1;
}
}
out.trim_end().to_string()
}
fn build_chord_only_line(chord_at: &HashMap<usize, String>, note_count: usize) -> String {
if chord_at.is_empty() {
return String::new();
}
let mut parts: Vec<String> = Vec::new();
for i in 0..note_count.max(chord_at.keys().max().copied().unwrap_or(0) + 1) {
if let Some(chord) = chord_at.get(&i) {
parts.push(format!("[{}]", sanitize_chord_name(chord)));
}
}
parts.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_field_title() {
assert_eq!(parse_field_line("T:My Song"), Some(('T', "My Song")));
}
#[test]
fn parse_field_with_spaces() {
assert_eq!(
parse_field_line("T: My Song Title "),
Some(('T', "My Song Title"))
);
}
#[test]
fn parse_field_not_a_field() {
assert_eq!(parse_field_line("Hello world"), None);
assert_eq!(parse_field_line("|: CDEF |"), None);
}
#[test]
fn tempo_bare_number() {
assert_eq!(extract_tempo_bpm("120"), Some("120".to_string()));
}
#[test]
fn tempo_note_length() {
assert_eq!(extract_tempo_bpm("1/4=120"), Some("120".to_string()));
}
#[test]
fn tempo_with_label() {
assert_eq!(
extract_tempo_bpm("1/4=140 Allegro"),
Some("140".to_string())
);
}
#[test]
fn tempo_text_only_returns_none() {
assert_eq!(extract_tempo_bpm("Allegro"), None);
}
#[test]
fn tempo_note_length_text_only_returns_none() {
assert_eq!(extract_tempo_bpm("1/4=Allegro"), None);
}
#[test]
fn extract_chords_simple() {
let (map, count) = extract_chords_and_notes("\"Am\" CDEF \"G\" GABC");
assert_eq!(count, 8);
assert_eq!(map.get(&0), Some(&"Am".to_string()));
assert_eq!(map.get(&4), Some(&"G".to_string()));
assert_eq!(map.get(&1), None);
}
#[test]
fn extract_chords_with_rests() {
let (map, count) = extract_chords_and_notes("\"Am\" C z E");
assert_eq!(count, 3);
assert_eq!(map.get(&0), Some(&"Am".to_string()));
assert_eq!(map.get(&2), None);
}
#[test]
fn extract_chords_no_chord_nc() {
let (map, count) = extract_chords_and_notes("\"N.C.\" C D E");
assert_eq!(count, 3);
assert!(map.is_empty(), "N.C. should not produce a chord");
}
#[test]
fn extract_chords_lowercase_notes() {
let (map, count) = extract_chords_and_notes("\"C\" cdef");
assert_eq!(count, 4);
assert_eq!(map.get(&0), Some(&"C".to_string()));
}
#[test]
fn extract_chords_accidentals() {
let (map, count) = extract_chords_and_notes("\"F#m\" ^F G ^A B");
assert_eq!(count, 4);
assert_eq!(map.get(&0), Some(&"F#m".to_string()));
}
#[test]
fn extract_chords_grace_notes_not_counted() {
let (map, count) = extract_chords_and_notes("\"Am\" {cde} C D");
assert_eq!(count, 2);
assert_eq!(map.get(&0), Some(&"Am".to_string()));
}
#[test]
fn lyrics_basic() {
let tokens = parse_abc_lyrics("Hello world");
assert_eq!(
tokens,
vec![
LyricToken::Syllable("Hello".to_string()),
LyricToken::Syllable("world".to_string()),
]
);
}
#[test]
fn lyrics_hyphen_continuation() {
let tokens = parse_abc_lyrics("Hel-lo world");
assert_eq!(
tokens,
vec![
LyricToken::Syllable("Hel-".to_string()),
LyricToken::Syllable("lo".to_string()),
LyricToken::Syllable("world".to_string()),
]
);
}
#[test]
fn lyrics_hold_skip() {
let tokens = parse_abc_lyrics("Hello _ world * end");
assert_eq!(
tokens,
vec![
LyricToken::Syllable("Hello".to_string()),
LyricToken::Hold,
LyricToken::Syllable("world".to_string()),
LyricToken::Skip,
LyricToken::Syllable("end".to_string()),
]
);
}
#[test]
fn lyrics_bar_marker() {
let tokens = parse_abc_lyrics("Hello | world");
assert_eq!(
tokens,
vec![
LyricToken::Syllable("Hello".to_string()),
LyricToken::Bar,
LyricToken::Syllable("world".to_string()),
]
);
}
#[test]
fn build_line_basic() {
let mut map = HashMap::new();
map.insert(0, "Am".to_string());
map.insert(2, "G".to_string());
let tokens = vec![
LyricToken::Syllable("Hel-".to_string()),
LyricToken::Syllable("lo".to_string()),
LyricToken::Syllable("world".to_string()),
];
let result = build_chordpro_line(&map, &tokens);
assert_eq!(result, "[Am]Hel-lo [G]world");
}
#[test]
fn build_line_hold_advances_counter() {
let mut map = HashMap::new();
map.insert(2, "G".to_string());
let tokens = vec![
LyricToken::Syllable("Hello".to_string()),
LyricToken::Hold,
LyricToken::Syllable("world".to_string()),
];
let result = build_chordpro_line(&map, &tokens);
assert_eq!(result, "Hello [G]world");
}
#[test]
fn convert_basic_tune() {
let input = "X:1\nT:Test Song\nK:C\n\"C\" CDEF \"G\" GABC|\nw:Hel-lo world how are you\n";
let out = convert_abc(input);
assert!(out.contains("{title: Test Song}"), "should have title");
assert!(out.contains("{key: C}"), "should have key");
assert!(out.contains("[C]"), "should have C chord");
assert!(out.contains("[G]"), "should have G chord");
assert!(out.contains("Hel-"), "should have lyrics");
}
#[test]
fn convert_with_composer_and_tempo() {
let input = "X:1\nT:My Song\nC:John Doe\nQ:1/4=120\nK:Am\n\"Am\" CDEF|\nw:Hello world\n";
let out = convert_abc(input);
assert!(out.contains("{title: My Song}"));
assert!(out.contains("{composer: John Doe}"));
assert!(out.contains("{tempo: 120}"));
assert!(out.contains("{key: Am}"));
}
#[test]
fn convert_multi_tune() {
let input =
"X:1\nT:First\nK:C\n\"C\" CDEF|\nw:Hello\n\nX:2\nT:Second\nK:G\n\"G\" GABC|\nw:World\n";
let out = convert_abc(input);
assert!(
out.contains("{new_song}"),
"should separate tunes with new_song"
);
assert!(out.contains("{title: First}"));
assert!(out.contains("{title: Second}"));
}
#[test]
fn convert_no_lyrics_emits_chord_line() {
let input = "X:1\nT:Instrumental\nK:C\n\"C\" CDEF \"G\" GABC|\n";
let out = convert_abc(input);
assert!(
out.contains("[C]"),
"chord-only line should contain C chord"
);
assert!(
out.contains("[G]"),
"chord-only line should contain G chord"
);
}
#[test]
fn convert_part_labels() {
let input =
"X:1\nT:Song\nK:C\nP:A\n\"C\" CDEF|\nw:Hello world\nP:B\n\"G\" GABC|\nw:Bye world\n";
let out = convert_abc(input);
assert!(out.contains("{start_of_verse: A}"));
assert!(out.contains("{end_of_verse}"));
assert!(out.contains("{start_of_verse: B}"));
}
#[test]
fn convert_empty_abc_returns_empty() {
assert_eq!(convert_abc(""), "");
assert_eq!(convert_abc(" \n \n "), "");
}
#[test]
fn convert_abc_no_chord_nc_not_emitted() {
let input = "X:1\nT:T\nK:C\n\"N.C.\" C D E F|\nw:Hello world how are\n";
let out = convert_abc(input);
assert!(
!out.contains("[N.C.]"),
"N.C. should not appear as a chord marker"
);
assert!(out.contains("Hello"), "lyrics should still be present");
}
#[test]
fn convert_more_notes_than_syllables() {
let input = "X:1\nT:T\nK:C\n\"C\" C D E F G A B c|\nw:Hel-lo\n";
let out = convert_abc(input);
assert!(out.contains("[C]Hel-"));
}
#[test]
fn convert_slash_chord() {
let input = "X:1\nT:T\nK:C\n\"Am/E\" CDEF|\nw:Hello world\n";
let out = convert_abc(input);
assert!(out.contains("[Am/E]"), "slash chord should be preserved");
}
#[test]
fn escape_directive_value_strips_braces_not_whole_value() {
assert_eq!(escape_directive_value("Song {live}"), "Song live");
assert_eq!(escape_directive_value("{evil}"), "evil");
assert_eq!(escape_directive_value("Normal"), "Normal");
}
#[test]
fn convert_title_with_braces_strips_not_empties() {
let input = "X:1\nT:Song {live}\nK:C\n";
let out = convert_abc(input);
assert!(
out.contains("{title: Song live}"),
"braces in title should be stripped, got: {out}"
);
}
#[test]
fn sanitize_chord_name_strips_injection_chars() {
assert_eq!(sanitize_chord_name("Am"), "Am");
assert_eq!(sanitize_chord_name("Am][{title: evil}]["), "Amtitle: evil");
assert_eq!(sanitize_chord_name("G{7}"), "G7");
}
#[test]
fn convert_chord_with_injection_chars_sanitized() {
let input = "X:1\nT:T\nK:C\n\"Am][{title: evil}][\" C D|\nw:Hello world\n";
let out = convert_abc(input);
assert!(
!out.contains("{title: evil}"),
"injected directive must not appear in output, got: {out}"
);
assert!(
out.contains("[Amtitle: evil]"),
"sanitized chord name should be present, got: {out}"
);
}
#[test]
fn convert_tempo_text_only_omitted() {
let input = "X:1\nT:T\nQ:Allegro\nK:C\n";
let out = convert_abc(input);
assert!(
!out.contains("{tempo:"),
"non-numeric tempo must not produce a {{tempo}} directive, got: {out}"
);
}
#[test]
fn convert_tempo_note_length_text_omitted() {
let input = "X:1\nT:T\nQ:1/4=Allegro\nK:C\n";
let out = convert_abc(input);
assert!(
!out.contains("{tempo:"),
"non-numeric tempo must not produce a {{tempo}} directive, got: {out}"
);
}
#[test]
fn convert_multi_verse_emits_all_verses() {
let input = "X:1\nT:T\nK:C\n\"C\" C D E F|\nw:First verse here\nw:Second verse here\n";
let out = convert_abc(input);
assert!(
out.contains("First"),
"first verse must be present, got: {out}"
);
assert!(
out.contains("Second"),
"second verse must be present, got: {out}"
);
let chord_count = out.matches("[C]").count();
assert_eq!(
chord_count, 2,
"[C] chord must appear once per verse line (2 total), got {chord_count} in: {out}"
);
}
#[test]
fn convert_lyric_braces_sanitized() {
let input = "X:1\nT:T\nK:C\n\"C\" C D|\nw:Hello {world}\n";
let out = convert_abc(input);
assert!(
!out.contains("{world}"),
"brace injection must be stripped from lyrics, got: {out}"
);
assert!(
out.contains("Hello"),
"lyric text before braces must be preserved, got: {out}"
);
assert!(
out.contains("world"),
"lyric text inside braces must be preserved (braces stripped), got: {out}"
);
}
#[test]
fn orphaned_lyric_braces_sanitized() {
let input = "X:1\nT:T\nK:C\nw:Orphaned {inject} line\n";
let out = convert_abc(input);
assert!(
!out.contains("{inject}"),
"brace injection must be stripped from orphaned lyrics, got: {out}"
);
assert!(
out.contains("inject"),
"text inside braces must be preserved (braces stripped), got: {out}"
);
}
#[test]
fn orphaned_lyric_brace_only_emits_no_blank_line() {
let input = "X:1\nT:T\nK:C\nw:{}\n";
let out = convert_abc(input);
assert!(
!out.ends_with("\n\n\n"),
"brace-only orphaned lyric must not emit an extra blank line, got: {out:?}"
);
}
}