use std::error::Error;
use std::sync::OnceLock;
use crate::song::{LyricLanguage, Song, SongPart, SongPartContent, SongPartType};
pub fn import_from_ccli_string(content: &str) -> Result<Song, Box<dyn Error>> {
let normalised = content.replace("\r\n", "\n").replace('\r', "\n");
let (body, trailer) = split_off_trailer(&normalised);
let mut blocks = into_blocks(body);
if blocks.is_empty() {
return Err("not a CCLI SongSelect export: the file is empty".into());
}
let title_block = blocks.remove(0);
let mut song = Song::new(title_block[0]);
if title_block.len() > 1 {
song.set_tag("subtitle", &title_block[1..].join("\n"));
}
if blocks.is_empty() {
return Err("not a CCLI SongSelect export: the file contains no sections".into());
}
for block in blocks {
add_section(&mut song, &block);
}
if let Some(trailer) = trailer {
parse_trailer(&mut song, trailer);
}
song.add_guessed_part_order();
Ok(song)
}
fn split_off_trailer(content: &str) -> (&str, Option<&str>) {
let mut offset = 0;
for line in content.split_inclusive('\n') {
if is_ccli_reference(line) {
return (&content[..offset], Some(&content[offset..]));
}
offset += line.len();
}
(content, None)
}
fn is_ccli_reference(line: &str) -> bool {
line.to_uppercase().contains("CCLI") && line.chars().any(|c| c.is_ascii_digit())
}
fn into_blocks(body: &str) -> Vec<Vec<&str>> {
let mut blocks: Vec<Vec<&str>> = Vec::new();
let mut current: Vec<&str> = Vec::new();
for line in body.lines() {
let line = line.trim();
if line.is_empty() {
if !current.is_empty() {
blocks.push(std::mem::take(&mut current));
}
} else {
current.push(line);
}
}
if !current.is_empty() {
blocks.push(current);
}
blocks
}
fn add_section(song: &mut Song, block: &[&str]) {
let classified = classify_heading(block[0]);
let is_heading = block.len() > 1
|| (classified.is_some() && block[0].split_whitespace().count() <= 3);
let (heading, lyrics) = if is_heading {
(Some(block[0]), &block[1..])
} else {
(None, block)
};
let (part_type, number) = match classified {
Some(classified) => (classified.part_type, classified.number),
None => (SongPartType::Other, None),
};
let id = song.add_part_of_type(part_type, number);
let part = song.part_mut(&id).unwrap();
part.label = heading.map(|heading| heading.to_string());
if !lyrics.is_empty() {
part.add_content(SongPartContent::lyrics(
LyricLanguage::Default,
lyrics.join("\n"),
));
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct ClassifiedHeading {
pub part_type: SongPartType,
pub number: Option<u32>,
}
const HEADINGS: &[(SongPartType, &[&str])] = &[
(
SongPartType::PreChorus,
&[
"prechorus", "prerefrain", "vorrefrain", "prerefrao", "prerefren", "precoro", "preestribillo", "voorrefrein", ],
),
(
SongPartType::PostChorus,
&["postchorus", "postrefrain", "nachrefrain", "postcoro", "postrefrao"],
),
(
SongPartType::Chorus,
&[
"chorus", "refrain", "refrein", "refrao", "refren", "refrang", "omkvaed", "coro", "estribillo", "ritornello", "kertosae", "refren", "副歌", "후렴", "サビ", ],
),
(
SongPartType::Verse,
&[
"verse", "vers", "strophe", "strofe", "strofa", "verso", "estrofa", "couplet", "zwrotka", "sloka", "versszak", "sakeisto", "visa", "主歌", "절", ],
),
(
SongPartType::Bridge,
&[
"bridge", "brucke", "brug", "brygga", "bro", "silta", "puente", "ponte", "pont", "most", "hid", "桥段", ],
),
(
SongPartType::Intro,
&[
"intro",
"introduction",
"introduccion",
"introducao",
"einleitung",
"vorspiel",
"inledning",
"alkusoitto",
],
),
(
SongPartType::Outro,
&[
"outro",
"ending",
"finale",
"final",
"coda",
"schluss",
"nachspiel",
"avslutning",
"slutt",
],
),
(
SongPartType::Interlude,
&[
"interlude",
"interludio",
"interludium",
"zwischenspiel",
"mellanspel",
"valisoitto",
],
),
(
SongPartType::Instrumental,
&["instrumental", "instrumentaal", "instrumentell"],
),
(SongPartType::Solo, &["solo", "solistisch"]),
];
fn candidates() -> &'static [(SongPartType, &'static str)] {
static CANDIDATES: OnceLock<Vec<(SongPartType, &'static str)>> = OnceLock::new();
CANDIDATES.get_or_init(|| {
let mut candidates: Vec<(SongPartType, &'static str)> = HEADINGS
.iter()
.flat_map(|(part_type, names)| names.iter().map(move |name| (*part_type, *name)))
.collect();
candidates.sort_by_key(|(_, name)| std::cmp::Reverse(name.len()));
candidates
})
}
pub fn classify_heading(heading: &str) -> Option<ClassifiedHeading> {
let (text, number) = split_trailing_number(heading.trim());
let normalised = normalise(text);
if normalised.is_empty() {
return None;
}
for &(part_type, name) in candidates() {
if normalised.starts_with(name) {
return Some(ClassifiedHeading { part_type, number });
}
}
None
}
fn split_trailing_number(heading: &str) -> (&str, Option<u32>) {
let without_parens = match heading.find('(') {
Some(position) => heading[..position].trim_end(),
None => heading.trim_end(),
};
let boundary = without_parens
.char_indices()
.rev()
.take_while(|(_, c)| c.is_ascii_digit())
.last()
.map(|(index, _)| index);
match boundary {
Some(index) if index > 0 => match without_parens[index..].parse::<u32>() {
Ok(number) => (without_parens[..index].trim_end(), Some(number)),
Err(_) => (without_parens, None),
},
_ => (without_parens, None),
}
}
fn normalise(heading: &str) -> String {
heading
.chars()
.flat_map(fold_char)
.filter(|c| c.is_alphanumeric())
.collect()
}
fn fold_char(c: char) -> FoldedChar {
let folded: &'static [char] = match c {
'á' | 'à' | 'â' | 'ä' | 'ã' | 'å' | 'ą' | 'Á' | 'À' | 'Â' | 'Ä' | 'Ã' | 'Å' | 'Ą' => &['a'],
'é' | 'è' | 'ê' | 'ë' | 'ę' | 'É' | 'È' | 'Ê' | 'Ë' | 'Ę' => &['e'],
'í' | 'ì' | 'î' | 'ï' | 'Í' | 'Ì' | 'Î' | 'Ï' => &['i'],
'ó' | 'ò' | 'ô' | 'ö' | 'õ' | 'ø' | 'ő' | 'Ó' | 'Ò' | 'Ô' | 'Ö' | 'Õ' | 'Ø' | 'Ő' => &['o'],
'ú' | 'ù' | 'û' | 'ü' | 'ű' | 'Ú' | 'Ù' | 'Û' | 'Ü' | 'Ű' => &['u'],
'ý' | 'ÿ' | 'Ý' => &['y'],
'ñ' | 'Ñ' => &['n'],
'ç' | 'ć' | 'č' | 'Ç' | 'Ć' | 'Č' => &['c'],
'ł' | 'Ł' => &['l'],
'ś' | 'š' | 'ş' | 'Ś' | 'Š' | 'Ş' => &['s'],
'ź' | 'ż' | 'ž' | 'Ź' | 'Ż' | 'Ž' => &['z'],
'ř' | 'Ř' => &['r'],
'ť' | 'Ť' => &['t'],
'ď' | 'Ď' => &['d'],
'ň' | 'Ň' => &['n'],
'ß' => &['s', 's'],
'æ' | 'Æ' => &['a', 'e'],
other => return FoldedChar::Lowercase(other.to_lowercase()),
};
FoldedChar::Mapped(folded.iter().copied())
}
enum FoldedChar {
Mapped(std::iter::Copied<std::slice::Iter<'static, char>>),
Lowercase(std::char::ToLowercase),
}
impl Iterator for FoldedChar {
type Item = char;
fn next(&mut self) -> Option<char> {
match self {
FoldedChar::Mapped(iter) => iter.next(),
FoldedChar::Lowercase(iter) => iter.next(),
}
}
}
fn parse_trailer(song: &mut Song, trailer: &str) {
let lines: Vec<&str> = trailer
.lines()
.map(|line| line.trim())
.filter(|line| !line.is_empty())
.collect();
let mut reference_numbers: Vec<String> = Vec::new();
let mut authors: Vec<&str> = Vec::new();
let mut copyright: Vec<&str> = Vec::new();
let mut in_copyright = false;
for line in lines {
if is_ccli_reference(line) {
reference_numbers.push(extract_number(line));
in_copyright = false;
continue;
}
if starts_copyright(line) {
in_copyright = true;
}
if in_copyright {
copyright.push(line);
} else {
authors.push(line);
}
}
if let Some(first) = reference_numbers.first()
&& !first.is_empty() {
song.set_tag("ccli_song_number", first);
}
if reference_numbers.len() > 1 {
let last = reference_numbers.last().unwrap();
if !last.is_empty() {
song.set_tag("ccli_license_number", last);
}
}
if !authors.is_empty() {
let joined = authors.join(" | ");
let names: Vec<&str> = joined
.split('|')
.map(|name| name.trim())
.filter(|name| !name.is_empty())
.collect();
if !names.is_empty() {
song.set_tag("author", &names.join(", "));
}
}
if !copyright.is_empty() {
song.set_tag("copyright", ©right.join("\n"));
}
}
fn starts_copyright(line: &str) -> bool {
let lowered = line.to_lowercase();
line.starts_with('©')
|| lowered.starts_with("(c)")
|| lowered.starts_with("copyright")
|| lowered.starts_with("℗")
}
fn extract_number(line: &str) -> String {
let mut digits = String::new();
for c in line.chars().rev() {
if c.is_ascii_digit() {
digits.insert(0, c);
} else if !digits.is_empty() {
break;
}
}
digits
}
pub fn import_from_file(path: &std::path::Path) -> Result<Song, Box<dyn Error>> {
let content = std::fs::read_to_string(path)?;
let mut song = import_from_ccli_string(&content)?;
if song.title.trim().is_empty()
&& let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) {
song.title = stem.to_string();
}
Ok(song)
}
pub fn sections(song: &Song) -> Vec<(String, String)> {
song.ordered_parts()
.iter()
.map(|part: &&SongPart| {
let lyrics = part
.lyrics_for(None, song.default_language.as_deref())
.map(|content| content.content.clone())
.unwrap_or_default();
(part.display_label(), lyrics)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::song::SongPartId;
fn german_example() -> Song {
import_from_file(std::path::Path::new(
"tests/data/Weiß ich den Weg auch nicht.ccli",
))
.unwrap()
}
fn generic_example() -> Song {
import_from_file(std::path::Path::new("tests/data/ExampleCCLISong1.ccli")).unwrap()
}
#[test]
fn test_german_export_with_crlf_line_endings() {
let song = german_example();
assert_eq!(song.title, "Weiß ich den Weg auch nicht (Pax Dei)");
assert_eq!(song.part_count_of_type(SongPartType::Verse), 3);
assert_eq!(song.part_count(), 3);
let verse1 = song.part(&SongPartId::new(SongPartType::Verse, 1)).unwrap();
assert_eq!(verse1.label.as_deref(), Some("Vers 1"));
let lyrics = verse1.lyrics_for(None, None).unwrap();
assert!(lyrics.content.starts_with("Weiß ich den Weg auch nicht"));
assert_eq!(lyrics.content.lines().count(), 4);
assert!(!lyrics.content.contains('\r'));
let verse3 = song.part(&SongPartId::new(SongPartType::Verse, 3)).unwrap();
assert!(verse3
.lyrics_for(None, None)
.unwrap()
.content
.ends_with("das ist genug."));
}
#[test]
fn test_german_trailer() {
let song = german_example();
assert_eq!(song.tag("ccli_song_number").unwrap(), "5973691");
assert_eq!(song.tag("ccli_license_number").unwrap(), "0000000");
assert_eq!(
song.tag("author").unwrap(),
"Hedwig Von Redern, John Bacchus Dykes"
);
assert_eq!(
song.tag("copyright").unwrap(),
"© Words: Public Domain\nMusic: Public Domain"
);
for part in song.parts() {
let lyrics = part.lyrics_for(None, None).unwrap().content.clone();
assert!(!lyrics.contains("CCLI"), "trailer leaked into {}", part.id());
}
}
#[test]
fn test_mixed_section_types() {
let song = generic_example();
assert_eq!(song.title, "Example Song");
assert_eq!(song.part_count_of_type(SongPartType::Verse), 2);
assert_eq!(song.part_count_of_type(SongPartType::PreChorus), 1);
assert_eq!(song.part_count_of_type(SongPartType::Chorus), 1);
let prechorus = song
.part(&SongPartId::new(SongPartType::PreChorus, 1))
.unwrap();
assert_eq!(prechorus.label.as_deref(), Some("Pre-Chorus"));
assert_eq!(
prechorus.lyrics_for(None, None).unwrap().content,
"Missing Pre-Chorus\nMissing Pre-Chorus"
);
assert_eq!(song.tag("ccli_song_number").unwrap(), "000000");
assert_eq!(song.tag("ccli_license_number").unwrap(), "000000");
assert_eq!(song.tag("author").unwrap(), "Lorem Ipsum");
assert!(song.tag("copyright").is_none());
}
#[test]
fn test_singing_order_is_derived() {
let song = generic_example();
let sung: Vec<String> = song
.ordered_parts()
.iter()
.map(|part| part.id().to_string())
.collect();
assert_eq!(
sung,
[
"verse.1",
"prechorus.1",
"chorus.1",
"verse.2",
"prechorus.1",
"chorus.1"
]
);
}
#[test]
fn test_the_same_song_in_several_languages() {
let cases = [
("Verse 1", "Chorus", "Bridge", "CCLI Song # 1234"),
("Vers 1", "Refrain", "Brücke", "CCLI-Liednummer 1234"),
("Verso 1", "Coro", "Puente", "Número de Canción CCLI 1234"),
("Couplet 1", "Refrain", "Pont", "CCLI Chant N° 1234"),
("Verso 1", "Refrão", "Ponte", "Número da Música CCLI 1234"),
("Vers 1", "Refrein", "Brug", "CCLI-Liednummer 1234"),
("Zwrotka 1", "Refren", "Most", "Numer pieśni CCLI 1234"),
];
for (verse, chorus, bridge, reference) in cases {
let text = format!(
"A Song\n\n{verse}\nfirst line\n\n{chorus}\nsecond line\n\n{bridge}\nthird line\n\n{reference}\nAn Author\n"
);
let song = import_from_ccli_string(&text).unwrap();
assert_eq!(
song.part_count_of_type(SongPartType::Verse),
1,
"verse not recognised: {}",
verse
);
assert_eq!(
song.part_count_of_type(SongPartType::Chorus),
1,
"chorus not recognised: {}",
chorus
);
assert_eq!(
song.part_count_of_type(SongPartType::Bridge),
1,
"bridge not recognised: {}",
bridge
);
assert_eq!(
song.tag("ccli_song_number").unwrap(),
"1234",
"reference not recognised: {}",
reference
);
}
}
#[test]
fn test_accents_do_not_matter() {
for heading in ["Brücke", "Brucke", "BRÜCKE"] {
assert_eq!(
classify_heading(heading).unwrap().part_type,
SongPartType::Bridge,
"failed for {}",
heading
);
}
for heading in ["Refrão", "refrao", "Säkeistö"] {
assert!(classify_heading(heading).is_some(), "failed for {}", heading);
}
}
#[test]
fn test_prechorus_wins_over_chorus() {
assert_eq!(
classify_heading("Pre-Chorus").unwrap().part_type,
SongPartType::PreChorus
);
assert_eq!(
classify_heading("Post-Chorus").unwrap().part_type,
SongPartType::PostChorus
);
assert_eq!(
classify_heading("Chorus").unwrap().part_type,
SongPartType::Chorus
);
}
#[test]
fn test_section_numbers() {
assert_eq!(classify_heading("Verse 2").unwrap().number, Some(2));
assert_eq!(classify_heading("Verse").unwrap().number, None);
assert_eq!(classify_heading("Vers 12").unwrap().number, Some(12));
assert_eq!(classify_heading("Chorus (2x)").unwrap().number, None);
}
#[test]
fn test_unknown_heading_is_kept_verbatim() {
let text = "Lagu\n\nBagian Pertama\nbaris satu\nbaris dua\n\nCCLI Song # 42\nPenulis\n";
let song = import_from_ccli_string(text).unwrap();
assert_eq!(song.part_count(), 1);
let part = song.part_at(0).unwrap();
assert_eq!(part.part_type, SongPartType::Other);
assert_eq!(part.label.as_deref(), Some("Bagian Pertama"));
assert_eq!(
part.lyrics_for(None, None).unwrap().content,
"baris satu\nbaris dua"
);
assert_eq!(song.tag("ccli_song_number").unwrap(), "42");
}
#[test]
fn test_single_lyric_line_is_not_mistaken_for_a_heading() {
let song = import_from_ccli_string(
"Title\n\nVerse 1\nfirst\n\nSolo mit dir will ich gehen\n\nCCLI Song # 1\n",
)
.unwrap();
let part = song.part_at(1).unwrap();
assert_eq!(part.label, None);
assert_eq!(
part.lyrics_for(None, None).unwrap().content,
"Solo mit dir will ich gehen"
);
let song = import_from_ccli_string("Title\n\nSolo\n\nCCLI Song # 1\n").unwrap();
assert_eq!(song.part_at(0).unwrap().part_type, SongPartType::Solo);
}
#[test]
fn test_cjk_headings() {
assert_eq!(
classify_heading("主歌 1").unwrap().part_type,
SongPartType::Verse
);
assert_eq!(
classify_heading("副歌").unwrap().part_type,
SongPartType::Chorus
);
}
#[test]
fn test_file_without_a_trailer() {
let song =
import_from_ccli_string("Title\n\nVerse 1\nsome words\n").unwrap();
assert_eq!(song.title, "Title");
assert_eq!(song.part_count(), 1);
assert!(song.tag("ccli_song_number").is_none());
}
#[test]
fn test_single_reference_line_is_the_song_number() {
let song =
import_from_ccli_string("Title\n\nVerse 1\nwords\n\nCCLI Song # 77\n").unwrap();
assert_eq!(song.tag("ccli_song_number").unwrap(), "77");
assert!(song.tag("ccli_license_number").is_none());
}
#[test]
fn test_subtitle_line_is_kept() {
let song = import_from_ccli_string(
"The Title\nThe Artist\n\nVerse 1\nwords\n\nCCLI Song # 1\n",
)
.unwrap();
assert_eq!(song.title, "The Title");
assert_eq!(song.tag("subtitle").unwrap(), "The Artist");
}
#[test]
fn test_empty_input_is_an_error() {
assert!(import_from_ccli_string("").is_err());
assert!(import_from_ccli_string("Only a title\n").is_err());
}
#[test]
fn test_a_lyric_line_mentioning_ccli_needs_a_number() {
let song = import_from_ccli_string(
"Title\n\nVerse 1\nwe sing about CCLI today\n\nCCLI Song # 5\n",
)
.unwrap();
assert_eq!(
song.part_at(0).unwrap().lyrics_for(None, None).unwrap().content,
"we sing about CCLI today"
);
assert_eq!(song.tag("ccli_song_number").unwrap(), "5");
}
#[test]
fn test_sections_helper() {
let sections = sections(&german_example());
assert_eq!(sections.len(), 3);
assert_eq!(sections[0].0, "Vers 1");
assert!(sections[0].1.starts_with("Weiß ich"));
}
}