use crate::inline_markup::TextSpan;
#[derive(Debug, Clone, PartialEq)]
pub struct Song {
pub metadata: Metadata,
pub lines: Vec<Line>,
}
impl Song {
#[must_use]
pub fn new() -> Self {
Self {
metadata: Metadata::new(),
lines: Vec::new(),
}
}
#[must_use]
pub fn config_overrides(&self) -> Vec<(&str, &str)> {
let mut overrides = Vec::new();
for line in &self.lines {
if let Line::Directive(directive) = line {
if let DirectiveKind::ConfigOverride(ref key) = directive.kind {
if let Some(ref value) = directive.value {
overrides.push((key.as_str(), value.as_str()));
}
}
}
}
overrides
}
pub fn apply_define_displays(&mut self) {
let mut define_map: Vec<(String, Option<String>, Option<String>)> = Vec::new();
for line in &self.lines {
if let Line::Directive(directive) = line {
if directive.kind == DirectiveKind::Define {
if let Some(ref value) = directive.value {
let def = ChordDefinition::parse_value(value);
if def.display.is_some() || def.format.is_some() {
if let Some(entry) =
define_map.iter_mut().find(|(n, _, _)| *n == def.name)
{
entry.1 = def.display;
entry.2 = def.format;
} else {
define_map.push((def.name, def.display, def.format));
}
}
}
}
}
}
if define_map.is_empty() {
return;
}
for line in &mut self.lines {
if let Line::Lyrics(lyrics_line) = line {
for segment in &mut lyrics_line.segments {
if let Some(ref mut chord) = segment.chord {
if chord.display.is_none() {
if let Some((_, display, format)) =
define_map.iter().find(|(n, _, _)| *n == chord.name)
{
if let Some(d) = display {
chord.display = Some(d.clone());
} else if let Some(f) = format {
if let Some(expanded) = chord.expand_format(f) {
chord.display = Some(expanded);
}
}
}
}
}
}
}
}
}
#[must_use]
pub fn fretted_defines(&self) -> Vec<(String, String)> {
let mut result: Vec<(String, String)> = Vec::new();
for line in &self.lines {
if let Line::Directive(directive) = line {
if directive.kind == DirectiveKind::Define
|| directive.kind == DirectiveKind::ChordDirective
{
if let Some(ref value) = directive.value {
let def = ChordDefinition::parse_value(value);
if let Some(raw) = def.raw {
if let Some(pos) = result.iter().position(|(n, _)| *n == def.name) {
result[pos].1 = raw;
} else {
result.push((def.name, raw));
}
}
}
}
}
}
result
}
#[must_use]
pub fn keyboard_defines(&self) -> Vec<(String, Vec<i32>)> {
let mut result: Vec<(String, Vec<i32>)> = Vec::new();
for line in &self.lines {
if let Line::Directive(directive) = line {
if directive.kind == DirectiveKind::Define
|| directive.kind == DirectiveKind::ChordDirective
{
if let Some(ref value) = directive.value {
let def = ChordDefinition::parse_value(value);
if let Some(keys) = def.keys {
if let Some(pos) = result.iter().position(|(n, _)| *n == def.name) {
result[pos].1 = keys;
} else {
result.push((def.name, keys));
}
}
}
}
}
}
result
}
#[must_use]
pub fn used_chord_names(&self) -> Vec<String> {
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut result: Vec<String> = Vec::new();
for line in &self.lines {
if let Line::Lyrics(lyrics) = line {
for seg in &lyrics.segments {
if let Some(ref chord) = seg.chord {
if seen.insert(chord.name.clone()) {
result.push(chord.name.clone());
}
}
}
}
}
result
}
}
impl Default for Song {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Metadata {
pub title: Option<String>,
pub subtitles: Vec<String>,
pub artists: Vec<String>,
pub composers: Vec<String>,
pub lyricists: Vec<String>,
pub album: Option<String>,
pub year: Option<String>,
pub key: Option<String>,
pub keys: Vec<String>,
pub tempo: Option<String>,
pub tempos: Vec<String>,
pub time: Option<String>,
pub times: Vec<String>,
pub capo: Option<String>,
pub sort_title: Option<String>,
pub sort_artist: Option<String>,
pub arrangers: Vec<String>,
pub copyright: Option<String>,
pub duration: Option<String>,
pub tags: Vec<String>,
pub custom: Vec<(String, String)>,
}
impl Metadata {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn capo_validated(&self) -> CapoValidation {
let Some(raw) = self.capo.as_deref() else {
return CapoValidation::Unset;
};
let trimmed = raw.trim();
match trimmed.parse::<u32>() {
Ok(n) if (1..=24).contains(&n) => {
#[allow(clippy::cast_possible_truncation)]
CapoValidation::Valid(n as u8)
}
Ok(n) => CapoValidation::OutOfRange(n),
Err(_) => CapoValidation::NotInteger(trimmed.to_string()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CapoValidation {
Unset,
Valid(u8),
OutOfRange(u32),
NotInteger(String),
}
#[derive(Debug, Clone, PartialEq)]
pub enum Line {
Lyrics(LyricsLine),
Directive(Directive),
Comment(CommentStyle, String),
Empty,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum CommentStyle {
Normal,
Italic,
Boxed,
Highlight,
}
#[derive(Debug, Clone, PartialEq)]
pub struct LyricsLine {
pub segments: Vec<LyricsSegment>,
}
impl LyricsLine {
#[must_use]
pub fn new() -> Self {
Self {
segments: Vec::new(),
}
}
#[must_use]
pub fn text(&self) -> String {
self.segments.iter().map(|s| s.text.as_str()).collect()
}
#[must_use]
pub fn has_chords(&self) -> bool {
self.segments.iter().any(|s| s.chord.is_some())
}
}
impl Default for LyricsLine {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct LyricsSegment {
pub chord: Option<Chord>,
pub text: String,
pub spans: Vec<TextSpan>,
}
impl LyricsSegment {
#[must_use]
pub fn new(chord: Option<Chord>, text: impl Into<String>) -> Self {
Self {
chord,
text: text.into(),
spans: Vec::new(),
}
}
#[must_use]
pub fn text_only(text: impl Into<String>) -> Self {
Self {
chord: None,
text: text.into(),
spans: Vec::new(),
}
}
#[must_use]
pub fn chord_only(chord: Chord) -> Self {
Self {
chord: Some(chord),
text: String::new(),
spans: Vec::new(),
}
}
#[must_use]
pub fn with_spans(chord: Option<Chord>, text: impl Into<String>, spans: Vec<TextSpan>) -> Self {
Self {
chord,
text: text.into(),
spans,
}
}
#[must_use]
pub fn has_markup(&self) -> bool {
!self.spans.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Chord {
pub name: String,
pub detail: Option<crate::chord::ChordDetail>,
pub display: Option<String>,
}
impl Chord {
#[must_use]
pub fn new(name: impl Into<String>) -> Self {
let name = name.into();
let detail = crate::chord::parse_chord(&name);
Self {
name,
detail,
display: None,
}
}
#[must_use]
pub fn display_name(&self) -> &str {
self.display.as_deref().unwrap_or(&self.name)
}
#[must_use]
pub fn expand_format(&self, pattern: &str) -> Option<String> {
let detail = self.detail.as_ref()?;
let root = {
let mut s = detail.root.to_string();
if let Some(ref acc) = detail.root_accidental {
s.push_str(&acc.to_string());
}
s
};
let quality = detail.quality.to_string();
let ext = detail.extension.as_deref().unwrap_or("");
let bass = detail
.bass_note
.as_ref()
.map_or(String::new(), |(note, acc)| {
let mut s = note.to_string();
if let Some(a) = acc {
s.push_str(&a.to_string());
}
s
});
let result = pattern
.replace("%{root}", &root)
.replace("%{quality}", &quality)
.replace("%{ext}", ext)
.replace("%{bass}", &bass);
Some(result)
}
}
impl core::fmt::Display for Chord {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(self.display_name())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
pub struct ImageAttributes {
pub src: String,
pub width: Option<String>,
pub height: Option<String>,
pub scale: Option<String>,
pub title: Option<String>,
pub anchor: Option<String>,
}
impl ImageAttributes {
#[must_use]
pub fn new(src: impl Into<String>) -> Self {
Self {
src: src.into(),
..Self::default()
}
}
#[must_use]
pub fn has_src(&self) -> bool {
!self.src.is_empty()
}
}
fn extract_attribute(s: &mut String, key: &str) -> Option<String> {
let needle = format!("{key}=");
let match_pos = s
.match_indices(&needle)
.find(|&(pos, _)| pos == 0 || s.as_bytes()[pos - 1].is_ascii_whitespace());
let (pos, _) = match_pos?;
let after = &s[pos + needle.len()..];
let (val, token_end) = if let Some(stripped) = after.strip_prefix('"') {
let close = stripped.find('"').unwrap_or(stripped.len());
let v = stripped[..close].to_string();
let has_close = close < stripped.len();
(
Some(v),
pos + needle.len() + 1 + close + usize::from(has_close),
)
} else {
match after.split_whitespace().next() {
Some(t) if !t.contains('=') => (Some(t.to_string()), pos + needle.len() + t.len()),
Some(_) | None => (Some(String::new()), pos + needle.len()),
}
};
let before = s[..pos].trim_end();
let after_token = s[token_end..].trim_start();
*s = if before.is_empty() {
after_token.to_string()
} else if after_token.is_empty() {
before.to_string()
} else {
format!("{before} {after_token}")
};
val
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChordDefinition {
pub name: String,
pub keys: Option<Vec<i32>>,
pub copy: Option<String>,
pub copyall: Option<String>,
pub display: Option<String>,
pub format: Option<String>,
pub raw: Option<String>,
pub transposable: bool,
}
impl ChordDefinition {
#[must_use]
pub fn parse_value(value: &str) -> Self {
let value = value.trim();
let mut parts = value.splitn(2, char::is_whitespace);
let mut name = parts
.next()
.expect("splitn always yields at least one element")
.to_string();
let rest = parts.next().unwrap_or("").trim();
if name.len() >= 2 && name.starts_with('[') && name.ends_with(']') {
name = name
.strip_prefix('[')
.unwrap()
.strip_suffix(']')
.unwrap()
.to_string();
return Self {
name,
keys: None,
copy: None,
copyall: None,
display: None,
format: None,
raw: None,
transposable: true,
};
}
let mut def = Self {
name,
keys: None,
copy: None,
copyall: None,
display: None,
format: None,
raw: None,
transposable: false,
};
if rest.is_empty() {
return def;
}
let mut remaining = rest.to_string();
def.display = extract_attribute(&mut remaining, "display");
def.format = extract_attribute(&mut remaining, "format");
let remaining = remaining.trim();
if remaining.is_empty() {
return def;
}
if let Some(keys_str) = remaining.strip_prefix("keys").and_then(|rest| {
if rest.is_empty() || rest.starts_with(|c: char| c.is_ascii_whitespace()) {
Some(rest)
} else {
None
}
}) {
let keys: Vec<i32> = keys_str
.split_whitespace()
.filter_map(|s| s.parse::<i32>().ok())
.filter(|&v| (0..=127).contains(&v))
.collect();
def.keys = if keys.is_empty() { None } else { Some(keys) };
return def;
}
if let Some(rest) = remaining.strip_prefix("copyall").and_then(|r| {
if r.is_empty() || r.starts_with(|c: char| c.is_ascii_whitespace()) {
Some(r)
} else {
None
}
}) {
let name = rest.split_whitespace().next().unwrap_or("").trim();
if !name.is_empty() {
def.copyall = Some(name.to_string());
}
return def;
}
if let Some(rest) = remaining.strip_prefix("copy").and_then(|r| {
if r.is_empty() || r.starts_with(|c: char| c.is_ascii_whitespace()) {
Some(r)
} else {
None
}
}) {
let name = rest.split_whitespace().next().unwrap_or("").trim();
if !name.is_empty() {
def.copy = Some(name.to_string());
}
return def;
}
def.raw = if remaining.is_empty() {
None
} else {
Some(remaining.to_string())
};
def
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum DirectiveKind {
Title,
Subtitle,
Artist,
Composer,
Lyricist,
Album,
Year,
Key,
Tempo,
Time,
Capo,
SortTitle,
SortArtist,
Arranger,
Copyright,
Duration,
Tag,
Transpose,
Comment,
CommentItalic,
CommentBox,
Highlight,
StartOfChorus,
EndOfChorus,
StartOfVerse,
EndOfVerse,
StartOfBridge,
EndOfBridge,
StartOfTab,
EndOfTab,
StartOfGrid,
EndOfGrid,
TextFont,
TextSize,
TextColour,
ChordFont,
ChordSize,
ChordColour,
TabFont,
TabSize,
TabColour,
Chorus,
NewPage,
NewPhysicalPage,
ColumnBreak,
Columns,
Pagetype,
TitleFont,
TitleSize,
TitleColour,
ChorusFont,
ChorusSize,
ChorusColour,
FooterFont,
FooterSize,
FooterColour,
HeaderFont,
HeaderSize,
HeaderColour,
LabelFont,
LabelSize,
LabelColour,
GridFont,
GridSize,
GridColour,
TocFont,
TocSize,
TocColour,
NewSong,
Define,
ChordDirective,
StartOfAbc,
EndOfAbc,
StartOfLy,
EndOfLy,
StartOfSvg,
EndOfSvg,
StartOfTextblock,
EndOfTextblock,
StartOfMusicxml,
EndOfMusicxml,
StartOfSection(String),
EndOfSection(String),
Meta(String),
Diagrams,
NoDiagrams,
Image(ImageAttributes),
ConfigOverride(String),
Unknown(String),
}
impl DirectiveKind {
#[must_use]
pub fn from_name(name: &str) -> Self {
match name.to_ascii_lowercase().as_str() {
"title" | "t" => Self::Title,
"subtitle" | "st" => Self::Subtitle,
"artist" => Self::Artist,
"composer" => Self::Composer,
"lyricist" => Self::Lyricist,
"album" => Self::Album,
"year" => Self::Year,
"key" => Self::Key,
"tempo" => Self::Tempo,
"time" => Self::Time,
"capo" => Self::Capo,
"sorttitle" => Self::SortTitle,
"sortartist" => Self::SortArtist,
"arranger" => Self::Arranger,
"copyright" => Self::Copyright,
"duration" => Self::Duration,
"tag" => Self::Tag,
"transpose" => Self::Transpose,
"new_song" | "ns" => Self::NewSong,
"comment" | "c" => Self::Comment,
"comment_italic" | "ci" => Self::CommentItalic,
"comment_box" | "cb" => Self::CommentBox,
"highlight" => Self::Highlight,
"start_of_chorus" | "soc" => Self::StartOfChorus,
"end_of_chorus" | "eoc" => Self::EndOfChorus,
"start_of_verse" | "sov" => Self::StartOfVerse,
"end_of_verse" | "eov" => Self::EndOfVerse,
"start_of_bridge" | "sob" => Self::StartOfBridge,
"end_of_bridge" | "eob" => Self::EndOfBridge,
"start_of_tab" | "sot" => Self::StartOfTab,
"end_of_tab" | "eot" => Self::EndOfTab,
"start_of_grid" | "sog" => Self::StartOfGrid,
"end_of_grid" | "eog" => Self::EndOfGrid,
"textfont" | "tf" => Self::TextFont,
"textsize" | "ts" => Self::TextSize,
"textcolour" | "textcolor" | "tc" => Self::TextColour,
"chordfont" | "cf" => Self::ChordFont,
"chordsize" | "cs" => Self::ChordSize,
"chordcolour" | "chordcolor" | "cc" => Self::ChordColour,
"tabfont" => Self::TabFont,
"tabsize" => Self::TabSize,
"tabcolour" | "tabcolor" => Self::TabColour,
"start_of_abc" => Self::StartOfAbc,
"end_of_abc" => Self::EndOfAbc,
"start_of_ly" => Self::StartOfLy,
"end_of_ly" => Self::EndOfLy,
"start_of_svg" => Self::StartOfSvg,
"end_of_svg" => Self::EndOfSvg,
"start_of_textblock" => Self::StartOfTextblock,
"end_of_textblock" => Self::EndOfTextblock,
"start_of_musicxml" => Self::StartOfMusicxml,
"end_of_musicxml" => Self::EndOfMusicxml,
"chorus" => Self::Chorus,
"new_page" | "np" => Self::NewPage,
"new_physical_page" | "npp" => Self::NewPhysicalPage,
"column_break" | "colb" => Self::ColumnBreak,
"columns" | "col" => Self::Columns,
"pagetype" => Self::Pagetype,
"titlefont" => Self::TitleFont,
"titlesize" => Self::TitleSize,
"titlecolour" | "titlecolor" => Self::TitleColour,
"chorusfont" => Self::ChorusFont,
"chorussize" => Self::ChorusSize,
"choruscolour" | "choruscolor" => Self::ChorusColour,
"footerfont" => Self::FooterFont,
"footersize" => Self::FooterSize,
"footercolour" | "footercolor" => Self::FooterColour,
"headerfont" => Self::HeaderFont,
"headersize" => Self::HeaderSize,
"headercolour" | "headercolor" => Self::HeaderColour,
"labelfont" => Self::LabelFont,
"labelsize" => Self::LabelSize,
"labelcolour" | "labelcolor" => Self::LabelColour,
"gridfont" => Self::GridFont,
"gridsize" => Self::GridSize,
"gridcolour" | "gridcolor" => Self::GridColour,
"tocfont" => Self::TocFont,
"tocsize" => Self::TocSize,
"toccolour" | "toccolor" => Self::TocColour,
"define" => Self::Define,
"chord" => Self::ChordDirective,
"diagrams" => Self::Diagrams,
"no_diagrams" | "nodiagrams" => Self::NoDiagrams,
"meta" => Self::Meta(String::new()),
"image" => Self::Image(ImageAttributes::default()),
other => {
if let Some(key) = other.strip_prefix("+config.") {
if !key.is_empty() {
return Self::ConfigOverride(key.to_string());
}
}
if let Some(section) = other.strip_prefix("start_of_") {
if !section.is_empty() {
return Self::StartOfSection(section.to_string());
}
}
if let Some(section) = other.strip_prefix("end_of_") {
if !section.is_empty() {
return Self::EndOfSection(section.to_string());
}
}
Self::Unknown(other.to_string())
}
}
}
#[must_use]
pub fn resolve_with_selector(name: &str) -> (Self, Option<String>) {
let kind = Self::from_name(name);
let is_known = !matches!(
kind,
Self::Unknown(_) | Self::StartOfSection(_) | Self::EndOfSection(_)
);
if is_known {
return (kind, None);
}
if let Some(last_hyphen) = name.rfind('-') {
let prefix = &name[..last_hyphen];
let suffix = &name[last_hyphen + 1..];
if !prefix.is_empty() && !suffix.is_empty() {
let prefix_kind = Self::from_name(prefix);
if !matches!(prefix_kind, Self::Unknown(_)) {
return (prefix_kind, Some(suffix.to_ascii_lowercase()));
}
}
}
(kind, None)
}
#[must_use]
pub fn canonical_name(&self) -> &str {
match self {
Self::Title => "title",
Self::Subtitle => "subtitle",
Self::Artist => "artist",
Self::Composer => "composer",
Self::Lyricist => "lyricist",
Self::Album => "album",
Self::Year => "year",
Self::Key => "key",
Self::Tempo => "tempo",
Self::Time => "time",
Self::Capo => "capo",
Self::SortTitle => "sorttitle",
Self::SortArtist => "sortartist",
Self::Arranger => "arranger",
Self::Copyright => "copyright",
Self::Duration => "duration",
Self::Tag => "tag",
Self::Transpose => "transpose",
Self::NewSong => "new_song",
Self::Comment => "comment",
Self::CommentItalic => "comment_italic",
Self::CommentBox => "comment_box",
Self::Highlight => "highlight",
Self::StartOfChorus => "start_of_chorus",
Self::EndOfChorus => "end_of_chorus",
Self::StartOfVerse => "start_of_verse",
Self::EndOfVerse => "end_of_verse",
Self::StartOfBridge => "start_of_bridge",
Self::EndOfBridge => "end_of_bridge",
Self::StartOfTab => "start_of_tab",
Self::EndOfTab => "end_of_tab",
Self::StartOfGrid => "start_of_grid",
Self::EndOfGrid => "end_of_grid",
Self::TextFont => "textfont",
Self::TextSize => "textsize",
Self::TextColour => "textcolour",
Self::ChordFont => "chordfont",
Self::ChordSize => "chordsize",
Self::ChordColour => "chordcolour",
Self::TabFont => "tabfont",
Self::TabSize => "tabsize",
Self::TabColour => "tabcolour",
Self::TitleFont => "titlefont",
Self::TitleSize => "titlesize",
Self::TitleColour => "titlecolour",
Self::ChorusFont => "chorusfont",
Self::ChorusSize => "chorussize",
Self::ChorusColour => "choruscolour",
Self::FooterFont => "footerfont",
Self::FooterSize => "footersize",
Self::FooterColour => "footercolour",
Self::HeaderFont => "headerfont",
Self::HeaderSize => "headersize",
Self::HeaderColour => "headercolour",
Self::LabelFont => "labelfont",
Self::LabelSize => "labelsize",
Self::LabelColour => "labelcolour",
Self::GridFont => "gridfont",
Self::GridSize => "gridsize",
Self::GridColour => "gridcolour",
Self::TocFont => "tocfont",
Self::TocSize => "tocsize",
Self::TocColour => "toccolour",
Self::StartOfAbc => "start_of_abc",
Self::EndOfAbc => "end_of_abc",
Self::StartOfLy => "start_of_ly",
Self::EndOfLy => "end_of_ly",
Self::StartOfSvg => "start_of_svg",
Self::EndOfSvg => "end_of_svg",
Self::StartOfTextblock => "start_of_textblock",
Self::EndOfTextblock => "end_of_textblock",
Self::StartOfMusicxml => "start_of_musicxml",
Self::EndOfMusicxml => "end_of_musicxml",
Self::Chorus => "chorus",
Self::NewPage => "new_page",
Self::NewPhysicalPage => "new_physical_page",
Self::ColumnBreak => "column_break",
Self::Columns => "columns",
Self::Pagetype => "pagetype",
Self::Define => "define",
Self::ChordDirective => "chord",
Self::Diagrams => "diagrams",
Self::NoDiagrams => "no_diagrams",
Self::Meta(_) => "meta",
Self::Image(_) => "image",
Self::ConfigOverride(key) => key.as_str(),
Self::StartOfSection(name) | Self::EndOfSection(name) | Self::Unknown(name) => {
name.as_str()
}
}
}
#[must_use]
pub fn full_canonical_name(&self) -> String {
match self {
Self::StartOfSection(name) => format!("start_of_{name}"),
Self::EndOfSection(name) => format!("end_of_{name}"),
Self::ConfigOverride(key) => format!("+config.{key}"),
_ => self.canonical_name().to_string(),
}
}
#[must_use]
pub fn is_metadata(&self) -> bool {
matches!(
self,
Self::Title
| Self::Subtitle
| Self::Artist
| Self::Composer
| Self::Lyricist
| Self::Album
| Self::Year
| Self::Key
| Self::Tempo
| Self::Time
| Self::Capo
| Self::SortTitle
| Self::SortArtist
| Self::Arranger
| Self::Copyright
| Self::Duration
| Self::Tag
| Self::Meta(_)
)
}
#[must_use]
pub fn is_comment(&self) -> bool {
matches!(
self,
Self::Comment | Self::CommentItalic | Self::CommentBox | Self::Highlight
)
}
#[must_use]
pub fn is_font_size_color(&self) -> bool {
matches!(
self,
Self::TextFont
| Self::TextSize
| Self::TextColour
| Self::ChordFont
| Self::ChordSize
| Self::ChordColour
| Self::TabFont
| Self::TabSize
| Self::TabColour
| Self::TitleFont
| Self::TitleSize
| Self::TitleColour
| Self::ChorusFont
| Self::ChorusSize
| Self::ChorusColour
| Self::FooterFont
| Self::FooterSize
| Self::FooterColour
| Self::HeaderFont
| Self::HeaderSize
| Self::HeaderColour
| Self::LabelFont
| Self::LabelSize
| Self::LabelColour
| Self::GridFont
| Self::GridSize
| Self::GridColour
| Self::TocFont
| Self::TocSize
| Self::TocColour
)
}
#[must_use]
pub fn is_section_start(&self) -> bool {
matches!(
self,
Self::StartOfChorus
| Self::StartOfVerse
| Self::StartOfBridge
| Self::StartOfTab
| Self::StartOfGrid
| Self::StartOfAbc
| Self::StartOfLy
| Self::StartOfSvg
| Self::StartOfTextblock
| Self::StartOfMusicxml
| Self::StartOfSection(_)
)
}
#[must_use]
pub fn is_section_end(&self) -> bool {
matches!(
self,
Self::EndOfChorus
| Self::EndOfVerse
| Self::EndOfBridge
| Self::EndOfTab
| Self::EndOfGrid
| Self::EndOfAbc
| Self::EndOfLy
| Self::EndOfSvg
| Self::EndOfTextblock
| Self::EndOfMusicxml
| Self::EndOfSection(_)
)
}
#[must_use]
pub fn is_environment(&self) -> bool {
self.is_section_start() || self.is_section_end()
}
#[must_use]
pub fn is_image(&self) -> bool {
matches!(self, Self::Image(_))
}
#[must_use]
pub fn is_page_control(&self) -> bool {
matches!(
self,
Self::NewPage | Self::NewPhysicalPage | Self::ColumnBreak | Self::Columns
)
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Directive {
pub name: String,
pub value: Option<String>,
pub kind: DirectiveKind,
pub selector: Option<String>,
}
impl Directive {
#[must_use]
pub fn with_value(name: impl Into<String>, value: impl Into<String>) -> Self {
let name_str = name.into();
let kind = DirectiveKind::from_name(&name_str);
let canonical = kind.full_canonical_name();
Self {
name: canonical,
value: Some(value.into()),
kind,
selector: None,
}
}
#[must_use]
pub fn name_only(name: impl Into<String>) -> Self {
let name_str = name.into();
let kind = DirectiveKind::from_name(&name_str);
let canonical = kind.full_canonical_name();
Self {
name: canonical,
value: None,
kind,
selector: None,
}
}
#[must_use]
pub fn with_selector(
name: impl Into<String>,
value: Option<String>,
selector: impl Into<String>,
) -> Self {
let name_str = name.into();
let kind = DirectiveKind::from_name(&name_str);
let canonical = kind.full_canonical_name();
Self {
name: canonical,
value,
kind,
selector: Some(selector.into().to_ascii_lowercase()),
}
}
#[must_use]
pub fn is_section_start(&self) -> bool {
self.kind.is_section_start()
}
#[must_use]
pub fn is_section_end(&self) -> bool {
self.kind.is_section_end()
}
#[must_use]
pub fn section_name(&self) -> Option<&str> {
if let Some(suffix) = self.name.strip_prefix("start_of_") {
Some(suffix)
} else if let Some(suffix) = self.name.strip_prefix("end_of_") {
Some(suffix)
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chord::{Accidental, ChordQuality, Note};
#[test]
fn song_new_is_empty() {
let song = Song::new();
assert!(song.lines.is_empty());
assert_eq!(song.metadata.title, None);
}
#[test]
fn song_default_equals_new() {
assert_eq!(Song::default(), Song::new());
}
#[test]
fn used_chord_names_empty() {
let song = crate::parse("{title: Test}").unwrap();
assert!(song.used_chord_names().is_empty());
}
#[test]
fn used_chord_names_order_and_dedup() {
let song = crate::parse("[Am]one [G]two [Am]three [C]four").unwrap();
assert_eq!(song.used_chord_names(), vec!["Am", "G", "C"]);
}
#[test]
fn fretted_defines_empty() {
let song = crate::parse("{title: Test}").unwrap();
assert!(song.fretted_defines().is_empty());
}
#[test]
fn fretted_defines_returns_raw_only() {
let input = "{define: Am base-fret 1 frets x 0 2 2 1 0}\n{define: G keys 0 4 7}";
let song = crate::parse(input).unwrap();
let defs = song.fretted_defines();
assert_eq!(defs.len(), 1);
assert_eq!(defs[0].0, "Am");
}
#[test]
fn fretted_defines_later_overrides_earlier() {
let input = "{define: Am base-fret 1 frets x 0 2 2 1 0}\n{define: Am base-fret 1 frets x 0 2 2 0 0}";
let song = crate::parse(input).unwrap();
let defs = song.fretted_defines();
assert_eq!(defs.len(), 1);
assert!(
defs[0].1.contains("0 0"),
"later define should override earlier"
);
}
#[test]
fn fretted_defines_chord_directive_alias() {
let input_chord = "{chord: Am base-fret 1 frets x 0 2 2 1 0}";
let input_define = "{define: Am base-fret 1 frets x 0 2 2 1 0}";
let defs_chord = crate::parse(input_chord).unwrap().fretted_defines();
let defs_define = crate::parse(input_define).unwrap().fretted_defines();
assert_eq!(
defs_chord.len(),
1,
"{{chord:}} must appear in fretted_defines"
);
assert_eq!(defs_chord[0].0, defs_define[0].0, "chord names must match");
assert_eq!(defs_chord[0].1, defs_define[0].1, "raw values must match");
}
#[test]
fn song_with_lines() {
let mut song = Song::new();
song.metadata.title = Some("My Song".to_string());
song.lines.push(Line::Empty);
song.lines
.push(Line::Comment(CommentStyle::Normal, "A comment".to_string()));
assert_eq!(song.lines.len(), 2);
assert_eq!(song.metadata.title.as_deref(), Some("My Song"));
}
#[test]
fn metadata_default_is_empty() {
let meta = Metadata::new();
assert_eq!(meta.title, None);
assert!(meta.subtitles.is_empty());
assert!(meta.artists.is_empty());
assert!(meta.composers.is_empty());
assert!(meta.lyricists.is_empty());
assert_eq!(meta.album, None);
assert_eq!(meta.year, None);
assert_eq!(meta.key, None);
assert_eq!(meta.tempo, None);
assert_eq!(meta.time, None);
assert_eq!(meta.capo, None);
assert_eq!(meta.sort_title, None);
assert_eq!(meta.sort_artist, None);
assert!(meta.arrangers.is_empty());
assert_eq!(meta.copyright, None);
assert_eq!(meta.duration, None);
assert!(meta.tags.is_empty());
assert!(meta.custom.is_empty());
}
#[test]
fn lyrics_line_text_concatenation() {
let line = LyricsLine {
segments: vec![
LyricsSegment::new(Some(Chord::new("Am")), "Hello "),
LyricsSegment::new(Some(Chord::new("G")), "world"),
],
};
assert_eq!(line.text(), "Hello world");
}
#[test]
fn lyrics_line_has_chords() {
let with_chords = LyricsLine {
segments: vec![LyricsSegment::new(Some(Chord::new("C")), "text")],
};
assert!(with_chords.has_chords());
let without_chords = LyricsLine {
segments: vec![LyricsSegment::text_only("just text")],
};
assert!(!without_chords.has_chords());
}
#[test]
fn lyrics_line_empty_default() {
let line = LyricsLine::new();
assert!(line.segments.is_empty());
assert_eq!(line.text(), "");
assert!(!line.has_chords());
}
#[test]
fn segment_text_only() {
let seg = LyricsSegment::text_only("hello");
assert_eq!(seg.chord, None);
assert_eq!(seg.text, "hello");
}
#[test]
fn segment_chord_only() {
let seg = LyricsSegment::chord_only(Chord::new("Dm"));
assert_eq!(seg.chord, Some(Chord::new("Dm")));
assert!(seg.text.is_empty());
}
#[test]
fn segment_with_chord_and_text() {
let seg = LyricsSegment::new(Some(Chord::new("E7")), "lyrics");
assert_eq!(seg.chord.as_ref().map(|c| c.name.as_str()), Some("E7"));
assert_eq!(seg.text, "lyrics");
}
#[test]
fn chord_display() {
let chord = Chord::new("F#m7");
assert_eq!(format!("{chord}"), "F#m7");
}
#[test]
fn chord_equality() {
assert_eq!(Chord::new("Am"), Chord::new("Am"));
assert_ne!(Chord::new("Am"), Chord::new("Bm"));
}
#[test]
fn chord_detail_parsed() {
let chord = Chord::new("C#m7");
let detail = chord.detail.as_ref().expect("should have detail");
assert_eq!(detail.root, Note::C);
assert_eq!(detail.root_accidental, Some(Accidental::Sharp));
assert_eq!(detail.quality, ChordQuality::Minor);
assert_eq!(detail.extension.as_deref(), Some("7"));
}
#[test]
fn chord_detail_slash_chord() {
let chord = Chord::new("G/B");
let detail = chord.detail.as_ref().expect("should have detail");
assert_eq!(detail.root, Note::G);
assert_eq!(detail.bass_note, Some((Note::B, None)));
}
#[test]
fn chord_detail_unparseable() {
let chord = Chord::new("");
assert!(chord.detail.is_none());
assert_eq!(chord.name, "");
}
#[test]
fn chord_detail_invalid_notation() {
let chord = Chord::new("xyz");
assert!(chord.detail.is_none());
assert_eq!(chord.name, "xyz");
}
#[test]
fn directive_kind_from_name_metadata() {
assert_eq!(DirectiveKind::from_name("title"), DirectiveKind::Title);
assert_eq!(DirectiveKind::from_name("t"), DirectiveKind::Title);
assert_eq!(DirectiveKind::from_name("TITLE"), DirectiveKind::Title);
assert_eq!(DirectiveKind::from_name("Title"), DirectiveKind::Title);
assert_eq!(
DirectiveKind::from_name("subtitle"),
DirectiveKind::Subtitle
);
assert_eq!(DirectiveKind::from_name("st"), DirectiveKind::Subtitle);
assert_eq!(DirectiveKind::from_name("artist"), DirectiveKind::Artist);
assert_eq!(
DirectiveKind::from_name("composer"),
DirectiveKind::Composer
);
assert_eq!(
DirectiveKind::from_name("lyricist"),
DirectiveKind::Lyricist
);
assert_eq!(DirectiveKind::from_name("album"), DirectiveKind::Album);
assert_eq!(DirectiveKind::from_name("year"), DirectiveKind::Year);
assert_eq!(DirectiveKind::from_name("key"), DirectiveKind::Key);
assert_eq!(DirectiveKind::from_name("tempo"), DirectiveKind::Tempo);
assert_eq!(DirectiveKind::from_name("time"), DirectiveKind::Time);
assert_eq!(DirectiveKind::from_name("capo"), DirectiveKind::Capo);
assert_eq!(
DirectiveKind::from_name("sorttitle"),
DirectiveKind::SortTitle
);
assert_eq!(
DirectiveKind::from_name("SORTTITLE"),
DirectiveKind::SortTitle
);
assert_eq!(
DirectiveKind::from_name("sortartist"),
DirectiveKind::SortArtist
);
assert_eq!(
DirectiveKind::from_name("arranger"),
DirectiveKind::Arranger
);
assert_eq!(
DirectiveKind::from_name("copyright"),
DirectiveKind::Copyright
);
assert_eq!(
DirectiveKind::from_name("duration"),
DirectiveKind::Duration
);
assert_eq!(DirectiveKind::from_name("tag"), DirectiveKind::Tag);
}
#[test]
fn directive_kind_from_name_comment() {
assert_eq!(DirectiveKind::from_name("comment"), DirectiveKind::Comment);
assert_eq!(DirectiveKind::from_name("c"), DirectiveKind::Comment);
assert_eq!(
DirectiveKind::from_name("comment_italic"),
DirectiveKind::CommentItalic
);
assert_eq!(DirectiveKind::from_name("ci"), DirectiveKind::CommentItalic);
assert_eq!(
DirectiveKind::from_name("comment_box"),
DirectiveKind::CommentBox
);
assert_eq!(DirectiveKind::from_name("cb"), DirectiveKind::CommentBox);
assert_eq!(
DirectiveKind::from_name("highlight"),
DirectiveKind::Highlight
);
assert!(DirectiveKind::Highlight.is_comment());
}
#[test]
fn directive_kind_from_name_pagetype() {
assert_eq!(
DirectiveKind::from_name("pagetype"),
DirectiveKind::Pagetype
);
assert_eq!(DirectiveKind::Pagetype.canonical_name(), "pagetype");
assert!(!DirectiveKind::Pagetype.is_comment());
assert!(!DirectiveKind::Pagetype.is_metadata());
assert!(!DirectiveKind::Pagetype.is_environment());
}
#[test]
fn directive_kind_from_name_environment() {
assert_eq!(
DirectiveKind::from_name("start_of_chorus"),
DirectiveKind::StartOfChorus
);
assert_eq!(
DirectiveKind::from_name("soc"),
DirectiveKind::StartOfChorus
);
assert_eq!(
DirectiveKind::from_name("end_of_chorus"),
DirectiveKind::EndOfChorus
);
assert_eq!(DirectiveKind::from_name("eoc"), DirectiveKind::EndOfChorus);
assert_eq!(
DirectiveKind::from_name("start_of_verse"),
DirectiveKind::StartOfVerse
);
assert_eq!(DirectiveKind::from_name("sov"), DirectiveKind::StartOfVerse);
assert_eq!(
DirectiveKind::from_name("end_of_verse"),
DirectiveKind::EndOfVerse
);
assert_eq!(DirectiveKind::from_name("eov"), DirectiveKind::EndOfVerse);
assert_eq!(
DirectiveKind::from_name("start_of_bridge"),
DirectiveKind::StartOfBridge
);
assert_eq!(
DirectiveKind::from_name("sob"),
DirectiveKind::StartOfBridge
);
assert_eq!(
DirectiveKind::from_name("end_of_bridge"),
DirectiveKind::EndOfBridge
);
assert_eq!(DirectiveKind::from_name("eob"), DirectiveKind::EndOfBridge);
assert_eq!(
DirectiveKind::from_name("start_of_tab"),
DirectiveKind::StartOfTab
);
assert_eq!(DirectiveKind::from_name("sot"), DirectiveKind::StartOfTab);
assert_eq!(
DirectiveKind::from_name("end_of_tab"),
DirectiveKind::EndOfTab
);
assert_eq!(DirectiveKind::from_name("eot"), DirectiveKind::EndOfTab);
}
#[test]
fn directive_kind_from_name_page_control() {
assert_eq!(DirectiveKind::from_name("new_page"), DirectiveKind::NewPage);
assert_eq!(DirectiveKind::from_name("np"), DirectiveKind::NewPage);
assert_eq!(
DirectiveKind::from_name("new_physical_page"),
DirectiveKind::NewPhysicalPage
);
assert_eq!(
DirectiveKind::from_name("npp"),
DirectiveKind::NewPhysicalPage
);
assert_eq!(
DirectiveKind::from_name("column_break"),
DirectiveKind::ColumnBreak
);
assert_eq!(DirectiveKind::from_name("colb"), DirectiveKind::ColumnBreak);
assert_eq!(DirectiveKind::from_name("columns"), DirectiveKind::Columns);
assert_eq!(DirectiveKind::from_name("col"), DirectiveKind::Columns);
}
#[test]
fn directive_kind_from_name_unknown() {
let kind = DirectiveKind::from_name("custom_thing");
assert_eq!(kind, DirectiveKind::Unknown("custom_thing".to_string()));
}
#[test]
fn directive_kind_case_insensitive() {
assert_eq!(DirectiveKind::from_name("TITLE"), DirectiveKind::Title);
assert_eq!(DirectiveKind::from_name("Title"), DirectiveKind::Title);
assert_eq!(
DirectiveKind::from_name("START_OF_CHORUS"),
DirectiveKind::StartOfChorus
);
assert_eq!(
DirectiveKind::from_name("Comment_Italic"),
DirectiveKind::CommentItalic
);
assert_eq!(DirectiveKind::from_name("NEW_PAGE"), DirectiveKind::NewPage);
assert_eq!(
DirectiveKind::from_name("Column_Break"),
DirectiveKind::ColumnBreak
);
}
#[test]
fn directive_kind_canonical_name() {
assert_eq!(DirectiveKind::Title.canonical_name(), "title");
assert_eq!(
DirectiveKind::StartOfChorus.canonical_name(),
"start_of_chorus"
);
assert_eq!(DirectiveKind::Comment.canonical_name(), "comment");
assert_eq!(
DirectiveKind::Unknown("foo".to_string()).canonical_name(),
"foo"
);
assert_eq!(DirectiveKind::SortTitle.canonical_name(), "sorttitle");
assert_eq!(DirectiveKind::SortArtist.canonical_name(), "sortartist");
assert_eq!(DirectiveKind::Arranger.canonical_name(), "arranger");
assert_eq!(DirectiveKind::Copyright.canonical_name(), "copyright");
assert_eq!(DirectiveKind::Duration.canonical_name(), "duration");
assert_eq!(DirectiveKind::Tag.canonical_name(), "tag");
assert_eq!(DirectiveKind::NewPage.canonical_name(), "new_page");
assert_eq!(
DirectiveKind::NewPhysicalPage.canonical_name(),
"new_physical_page"
);
assert_eq!(DirectiveKind::ColumnBreak.canonical_name(), "column_break");
assert_eq!(DirectiveKind::Columns.canonical_name(), "columns");
}
#[test]
fn directive_kind_category_checks() {
assert!(DirectiveKind::Title.is_metadata());
assert!(!DirectiveKind::Title.is_comment());
assert!(!DirectiveKind::Title.is_environment());
assert!(DirectiveKind::Comment.is_comment());
assert!(!DirectiveKind::Comment.is_metadata());
assert!(DirectiveKind::StartOfChorus.is_section_start());
assert!(DirectiveKind::StartOfChorus.is_environment());
assert!(!DirectiveKind::StartOfChorus.is_section_end());
assert!(DirectiveKind::EndOfChorus.is_section_end());
assert!(DirectiveKind::EndOfChorus.is_environment());
assert!(!DirectiveKind::EndOfChorus.is_section_start());
let unknown = DirectiveKind::Unknown("x".to_string());
assert!(!unknown.is_metadata());
assert!(!unknown.is_comment());
assert!(!unknown.is_environment());
assert!(DirectiveKind::NewPage.is_page_control());
assert!(DirectiveKind::NewPhysicalPage.is_page_control());
assert!(DirectiveKind::ColumnBreak.is_page_control());
assert!(DirectiveKind::Columns.is_page_control());
assert!(!DirectiveKind::NewPage.is_metadata());
assert!(!DirectiveKind::NewPage.is_comment());
assert!(!DirectiveKind::NewPage.is_environment());
assert!(!DirectiveKind::Title.is_page_control());
assert!(!unknown.is_page_control());
}
#[test]
fn directive_with_value() {
let d = Directive::with_value("title", "My Song");
assert_eq!(d.name, "title");
assert_eq!(d.value.as_deref(), Some("My Song"));
assert_eq!(d.kind, DirectiveKind::Title);
}
#[test]
fn directive_name_only() {
let d = Directive::name_only("start_of_chorus");
assert_eq!(d.name, "start_of_chorus");
assert!(d.value.is_none());
assert_eq!(d.kind, DirectiveKind::StartOfChorus);
}
#[test]
fn directive_short_alias_resolution() {
let d = Directive::with_value("t", "My Song");
assert_eq!(d.name, "title");
assert_eq!(d.kind, DirectiveKind::Title);
let d = Directive::name_only("soc");
assert_eq!(d.name, "start_of_chorus");
assert_eq!(d.kind, DirectiveKind::StartOfChorus);
let d = Directive::with_value("st", "Alternate Title");
assert_eq!(d.name, "subtitle");
assert_eq!(d.kind, DirectiveKind::Subtitle);
}
#[test]
fn directive_case_insensitive_resolution() {
let d = Directive::with_value("TITLE", "My Song");
assert_eq!(d.name, "title");
assert_eq!(d.kind, DirectiveKind::Title);
let d = Directive::name_only("SOC");
assert_eq!(d.name, "start_of_chorus");
assert_eq!(d.kind, DirectiveKind::StartOfChorus);
}
#[test]
fn directive_unknown_preserves_name() {
let d = Directive::with_value("my_custom", "value");
assert_eq!(d.name, "my_custom");
assert_eq!(d.kind, DirectiveKind::Unknown("my_custom".to_string()));
}
#[test]
fn directive_section_detection() {
let soc = Directive::name_only("start_of_chorus");
assert!(soc.is_section_start());
assert!(!soc.is_section_end());
assert_eq!(soc.section_name(), Some("chorus"));
let eoc = Directive::name_only("end_of_chorus");
assert!(!eoc.is_section_start());
assert!(eoc.is_section_end());
assert_eq!(eoc.section_name(), Some("chorus"));
let title = Directive::with_value("title", "Test");
assert!(!title.is_section_start());
assert!(!title.is_section_end());
assert_eq!(title.section_name(), None);
}
#[test]
fn directive_section_name_variants() {
let sov = Directive::name_only("start_of_verse");
assert_eq!(sov.section_name(), Some("verse"));
let eob = Directive::name_only("end_of_bridge");
assert_eq!(eob.section_name(), Some("bridge"));
}
#[test]
fn directive_section_detection_via_short_alias() {
let soc = Directive::name_only("soc");
assert!(soc.is_section_start());
assert_eq!(soc.section_name(), Some("chorus"));
let eot = Directive::name_only("eot");
assert!(eot.is_section_end());
assert_eq!(eot.section_name(), Some("tab"));
}
#[test]
fn directive_kind_start_of_custom_section() {
let kind = DirectiveKind::from_name("start_of_intro");
assert_eq!(kind, DirectiveKind::StartOfSection("intro".to_string()));
assert!(kind.is_section_start());
assert!(!kind.is_section_end());
assert!(kind.is_environment());
}
#[test]
fn directive_kind_end_of_custom_section() {
let kind = DirectiveKind::from_name("end_of_intro");
assert_eq!(kind, DirectiveKind::EndOfSection("intro".to_string()));
assert!(kind.is_section_end());
assert!(!kind.is_section_start());
assert!(kind.is_environment());
}
#[test]
fn directive_kind_custom_section_case_insensitive() {
let kind = DirectiveKind::from_name("Start_Of_Intro");
assert_eq!(kind, DirectiveKind::StartOfSection("intro".to_string()));
}
#[test]
fn directive_kind_custom_section_various_names() {
assert_eq!(
DirectiveKind::from_name("start_of_outro"),
DirectiveKind::StartOfSection("outro".to_string())
);
assert_eq!(
DirectiveKind::from_name("start_of_solo"),
DirectiveKind::StartOfSection("solo".to_string())
);
assert_eq!(
DirectiveKind::from_name("end_of_solo"),
DirectiveKind::EndOfSection("solo".to_string())
);
assert_eq!(
DirectiveKind::from_name("start_of_interlude"),
DirectiveKind::StartOfSection("interlude".to_string())
);
}
#[test]
fn directive_kind_from_name_font_size_color() {
assert_eq!(
DirectiveKind::from_name("textfont"),
DirectiveKind::TextFont
);
assert_eq!(DirectiveKind::from_name("tf"), DirectiveKind::TextFont);
assert_eq!(
DirectiveKind::from_name("TEXTFONT"),
DirectiveKind::TextFont
);
assert_eq!(
DirectiveKind::from_name("textsize"),
DirectiveKind::TextSize
);
assert_eq!(DirectiveKind::from_name("ts"), DirectiveKind::TextSize);
assert_eq!(
DirectiveKind::from_name("textcolour"),
DirectiveKind::TextColour
);
assert_eq!(
DirectiveKind::from_name("textcolor"),
DirectiveKind::TextColour
);
assert_eq!(DirectiveKind::from_name("tc"), DirectiveKind::TextColour);
assert_eq!(
DirectiveKind::from_name("chordfont"),
DirectiveKind::ChordFont
);
assert_eq!(DirectiveKind::from_name("cf"), DirectiveKind::ChordFont);
assert_eq!(
DirectiveKind::from_name("chordsize"),
DirectiveKind::ChordSize
);
assert_eq!(DirectiveKind::from_name("cs"), DirectiveKind::ChordSize);
assert_eq!(
DirectiveKind::from_name("chordcolour"),
DirectiveKind::ChordColour
);
assert_eq!(
DirectiveKind::from_name("chordcolor"),
DirectiveKind::ChordColour
);
assert_eq!(DirectiveKind::from_name("cc"), DirectiveKind::ChordColour);
assert_eq!(DirectiveKind::from_name("tabfont"), DirectiveKind::TabFont);
assert_eq!(DirectiveKind::from_name("tabsize"), DirectiveKind::TabSize);
assert_eq!(
DirectiveKind::from_name("tabcolour"),
DirectiveKind::TabColour
);
assert_eq!(
DirectiveKind::from_name("tabcolor"),
DirectiveKind::TabColour
);
}
#[test]
fn directive_custom_section_full_canonical_name() {
let kind = DirectiveKind::StartOfSection("intro".to_string());
assert_eq!(kind.full_canonical_name(), "start_of_intro");
let kind = DirectiveKind::EndOfSection("outro".to_string());
assert_eq!(kind.full_canonical_name(), "end_of_outro");
}
#[test]
fn directive_custom_section_name_only() {
let d = Directive::name_only("start_of_intro");
assert_eq!(d.name, "start_of_intro");
assert!(d.value.is_none());
assert_eq!(d.kind, DirectiveKind::StartOfSection("intro".to_string()));
assert!(d.is_section_start());
assert_eq!(d.section_name(), Some("intro"));
}
#[test]
fn directive_custom_section_with_label() {
let d = Directive::with_value("start_of_intro", "Guitar Intro");
assert_eq!(d.name, "start_of_intro");
assert_eq!(d.value.as_deref(), Some("Guitar Intro"));
assert_eq!(d.kind, DirectiveKind::StartOfSection("intro".to_string()));
}
#[test]
fn directive_end_custom_section() {
let d = Directive::name_only("end_of_intro");
assert_eq!(d.name, "end_of_intro");
assert!(d.is_section_end());
assert_eq!(d.section_name(), Some("intro"));
}
#[test]
fn directive_known_sections_not_custom() {
assert_eq!(
DirectiveKind::from_name("start_of_chorus"),
DirectiveKind::StartOfChorus
);
assert_eq!(
DirectiveKind::from_name("start_of_verse"),
DirectiveKind::StartOfVerse
);
assert_eq!(
DirectiveKind::from_name("start_of_bridge"),
DirectiveKind::StartOfBridge
);
assert_eq!(
DirectiveKind::from_name("start_of_tab"),
DirectiveKind::StartOfTab
);
}
#[test]
fn directive_kind_font_size_color_canonical_names() {
assert_eq!(DirectiveKind::TextFont.canonical_name(), "textfont");
assert_eq!(DirectiveKind::TextSize.canonical_name(), "textsize");
assert_eq!(DirectiveKind::TextColour.canonical_name(), "textcolour");
assert_eq!(DirectiveKind::ChordFont.canonical_name(), "chordfont");
assert_eq!(DirectiveKind::ChordSize.canonical_name(), "chordsize");
assert_eq!(DirectiveKind::ChordColour.canonical_name(), "chordcolour");
assert_eq!(DirectiveKind::TabFont.canonical_name(), "tabfont");
assert_eq!(DirectiveKind::TabSize.canonical_name(), "tabsize");
assert_eq!(DirectiveKind::TabColour.canonical_name(), "tabcolour");
}
#[test]
fn directive_kind_font_size_color_category_checks() {
let font_kinds = [
DirectiveKind::TextFont,
DirectiveKind::TextSize,
DirectiveKind::TextColour,
DirectiveKind::ChordFont,
DirectiveKind::ChordSize,
DirectiveKind::ChordColour,
DirectiveKind::TabFont,
DirectiveKind::TabSize,
DirectiveKind::TabColour,
];
for kind in &font_kinds {
assert!(
kind.is_font_size_color(),
"{kind:?} should be font_size_color"
);
assert!(!kind.is_metadata(), "{kind:?} should not be metadata");
assert!(!kind.is_comment(), "{kind:?} should not be comment");
assert!(!kind.is_environment(), "{kind:?} should not be environment");
}
}
#[test]
fn directive_font_alias_resolution() {
let d = Directive::with_value("tf", "Times");
assert_eq!(d.name, "textfont");
assert_eq!(d.kind, DirectiveKind::TextFont);
assert_eq!(d.value.as_deref(), Some("Times"));
let d = Directive::with_value("cc", "#FF0000");
assert_eq!(d.name, "chordcolour");
assert_eq!(d.kind, DirectiveKind::ChordColour);
let d = Directive::with_value("textcolor", "blue");
assert_eq!(d.name, "textcolour");
assert_eq!(d.kind, DirectiveKind::TextColour);
}
#[test]
fn comment_style_variants() {
let normal = Line::Comment(CommentStyle::Normal, "text".to_string());
let italic = Line::Comment(CommentStyle::Italic, "text".to_string());
let boxed = Line::Comment(CommentStyle::Boxed, "text".to_string());
assert!(matches!(normal, Line::Comment(CommentStyle::Normal, _)));
assert!(matches!(italic, Line::Comment(CommentStyle::Italic, _)));
assert!(matches!(boxed, Line::Comment(CommentStyle::Boxed, _)));
}
#[test]
fn line_enum_variants() {
let lyrics = Line::Lyrics(LyricsLine::new());
let directive = Line::Directive(Directive::name_only("soc"));
let comment = Line::Comment(CommentStyle::Normal, "test".to_string());
let empty = Line::Empty;
assert!(matches!(lyrics, Line::Lyrics(_)));
assert!(matches!(directive, Line::Directive(_)));
assert!(matches!(comment, Line::Comment(..)));
assert!(matches!(empty, Line::Empty));
}
#[test]
fn line_clone_and_eq() {
let line = Line::Lyrics(LyricsLine {
segments: vec![LyricsSegment::new(Some(Chord::new("C")), "hello")],
});
let cloned = line.clone();
assert_eq!(line, cloned);
}
#[test]
fn directive_with_selector_constructor() {
let d = Directive::with_selector("title", Some("My Song".to_string()), "piano");
assert_eq!(d.name, "title");
assert_eq!(d.value.as_deref(), Some("My Song"));
assert_eq!(d.kind, DirectiveKind::Title);
assert_eq!(d.selector.as_deref(), Some("piano"));
}
#[test]
fn directive_with_value_has_no_selector() {
let d = Directive::with_value("title", "My Song");
assert_eq!(d.selector, None);
}
#[test]
fn directive_name_only_has_no_selector() {
let d = Directive::name_only("start_of_chorus");
assert_eq!(d.selector, None);
}
#[test]
fn resolve_with_selector_plain_directive() {
let (kind, sel) = DirectiveKind::resolve_with_selector("title");
assert_eq!(kind, DirectiveKind::Title);
assert_eq!(sel, None);
}
#[test]
fn resolve_with_selector_with_suffix() {
let (kind, sel) = DirectiveKind::resolve_with_selector("title-piano");
assert_eq!(kind, DirectiveKind::Title);
assert_eq!(sel.as_deref(), Some("piano"));
}
#[test]
fn resolve_with_selector_comment() {
let (kind, sel) = DirectiveKind::resolve_with_selector("comment-bass");
assert_eq!(kind, DirectiveKind::Comment);
assert_eq!(sel.as_deref(), Some("bass"));
}
#[test]
fn resolve_with_selector_comment_italic() {
let (kind, sel) = DirectiveKind::resolve_with_selector("comment_italic-guitar");
assert_eq!(kind, DirectiveKind::CommentItalic);
assert_eq!(sel.as_deref(), Some("guitar"));
}
#[test]
fn resolve_with_selector_environment() {
let (kind, sel) = DirectiveKind::resolve_with_selector("start_of_chorus-piano");
assert_eq!(kind, DirectiveKind::StartOfChorus);
assert_eq!(sel.as_deref(), Some("piano"));
}
#[test]
fn resolve_with_selector_end_of_tab() {
let (kind, sel) = DirectiveKind::resolve_with_selector("end_of_tab-guitar");
assert_eq!(kind, DirectiveKind::EndOfTab);
assert_eq!(sel.as_deref(), Some("guitar"));
}
#[test]
fn resolve_with_selector_custom_section_no_selector() {
let (kind, sel) = DirectiveKind::resolve_with_selector("start_of_intro");
assert_eq!(kind, DirectiveKind::StartOfSection("intro".to_string()));
assert_eq!(sel, None);
}
#[test]
fn resolve_with_selector_custom_section_with_selector() {
let (kind, sel) = DirectiveKind::resolve_with_selector("start_of_intro-piano");
assert_eq!(kind, DirectiveKind::StartOfSection("intro".to_string()));
assert_eq!(sel.as_deref(), Some("piano"));
}
#[test]
fn resolve_with_selector_unknown_no_hyphen() {
let (kind, sel) = DirectiveKind::resolve_with_selector("mything");
assert_eq!(kind, DirectiveKind::Unknown("mything".to_string()));
assert_eq!(sel, None);
}
#[test]
fn resolve_with_selector_unknown_with_hyphen() {
let (kind, sel) = DirectiveKind::resolve_with_selector("my-thing");
assert_eq!(kind, DirectiveKind::Unknown("my-thing".to_string()));
assert_eq!(sel, None);
}
#[test]
fn resolve_with_selector_case_insensitive() {
let (kind, sel) = DirectiveKind::resolve_with_selector("Title-Piano");
assert_eq!(kind, DirectiveKind::Title);
assert_eq!(sel.as_deref(), Some("piano"));
}
#[test]
fn resolve_with_selector_short_alias_with_suffix() {
let (kind, sel) = DirectiveKind::resolve_with_selector("t-guitar");
assert_eq!(kind, DirectiveKind::Title);
assert_eq!(sel.as_deref(), Some("guitar"));
}
#[test]
fn full_song_construction() {
let mut song = Song::new();
song.metadata.title = Some("Amazing Grace".to_string());
song.metadata.key = Some("G".to_string());
song.metadata.artists.push("John Newton".to_string());
song.lines
.push(Line::Directive(Directive::name_only("start_of_verse")));
song.lines.push(Line::Lyrics(LyricsLine {
segments: vec![
LyricsSegment::new(Some(Chord::new("G")), "Amazing "),
LyricsSegment::new(Some(Chord::new("G7")), "grace, how "),
LyricsSegment::new(Some(Chord::new("C")), "sweet the "),
LyricsSegment::new(Some(Chord::new("G")), "sound"),
],
}));
song.lines
.push(Line::Directive(Directive::name_only("end_of_verse")));
assert_eq!(song.lines.len(), 3);
if let Line::Lyrics(ref lyrics) = song.lines[1] {
assert_eq!(lyrics.text(), "Amazing grace, how sweet the sound");
assert!(lyrics.has_chords());
assert_eq!(lyrics.segments.len(), 4);
} else {
panic!("Expected Line::Lyrics");
}
}
#[test]
fn directive_kind_grid_from_name() {
assert_eq!(
DirectiveKind::from_name("start_of_grid"),
DirectiveKind::StartOfGrid
);
assert_eq!(
DirectiveKind::from_name("end_of_grid"),
DirectiveKind::EndOfGrid
);
assert_eq!(DirectiveKind::from_name("sog"), DirectiveKind::StartOfGrid);
assert_eq!(DirectiveKind::from_name("eog"), DirectiveKind::EndOfGrid);
}
#[test]
fn directive_kind_grid_canonical_name() {
assert_eq!(DirectiveKind::StartOfGrid.canonical_name(), "start_of_grid");
assert_eq!(DirectiveKind::EndOfGrid.canonical_name(), "end_of_grid");
}
#[test]
fn directive_kind_grid_is_section() {
assert!(DirectiveKind::StartOfGrid.is_section_start());
assert!(!DirectiveKind::StartOfGrid.is_section_end());
assert!(DirectiveKind::EndOfGrid.is_section_end());
assert!(!DirectiveKind::EndOfGrid.is_section_start());
assert!(DirectiveKind::StartOfGrid.is_environment());
assert!(DirectiveKind::EndOfGrid.is_environment());
}
#[test]
fn directive_grid_section_name() {
let sog = Directive::name_only("sog");
assert!(sog.is_section_start());
assert_eq!(sog.section_name(), Some("grid"));
let eog = Directive::name_only("eog");
assert!(eog.is_section_end());
assert_eq!(eog.section_name(), Some("grid"));
}
#[test]
fn directive_kind_from_name_title_font_size_color() {
assert_eq!(
DirectiveKind::from_name("titlefont"),
DirectiveKind::TitleFont
);
assert_eq!(
DirectiveKind::from_name("TITLEFONT"),
DirectiveKind::TitleFont
);
assert_eq!(
DirectiveKind::from_name("titlesize"),
DirectiveKind::TitleSize
);
assert_eq!(
DirectiveKind::from_name("titlecolour"),
DirectiveKind::TitleColour
);
assert_eq!(
DirectiveKind::from_name("titlecolor"),
DirectiveKind::TitleColour
);
}
#[test]
fn directive_kind_from_name_chorus_font_size_color() {
assert_eq!(
DirectiveKind::from_name("chorusfont"),
DirectiveKind::ChorusFont
);
assert_eq!(
DirectiveKind::from_name("chorussize"),
DirectiveKind::ChorusSize
);
assert_eq!(
DirectiveKind::from_name("choruscolour"),
DirectiveKind::ChorusColour
);
assert_eq!(
DirectiveKind::from_name("choruscolor"),
DirectiveKind::ChorusColour
);
}
#[test]
fn directive_kind_from_name_footer_header_label() {
assert_eq!(
DirectiveKind::from_name("footerfont"),
DirectiveKind::FooterFont
);
assert_eq!(
DirectiveKind::from_name("footersize"),
DirectiveKind::FooterSize
);
assert_eq!(
DirectiveKind::from_name("footercolour"),
DirectiveKind::FooterColour
);
assert_eq!(
DirectiveKind::from_name("footercolor"),
DirectiveKind::FooterColour
);
assert_eq!(
DirectiveKind::from_name("headerfont"),
DirectiveKind::HeaderFont
);
assert_eq!(
DirectiveKind::from_name("headersize"),
DirectiveKind::HeaderSize
);
assert_eq!(
DirectiveKind::from_name("headercolour"),
DirectiveKind::HeaderColour
);
assert_eq!(
DirectiveKind::from_name("headercolor"),
DirectiveKind::HeaderColour
);
assert_eq!(
DirectiveKind::from_name("labelfont"),
DirectiveKind::LabelFont
);
assert_eq!(
DirectiveKind::from_name("labelsize"),
DirectiveKind::LabelSize
);
assert_eq!(
DirectiveKind::from_name("labelcolour"),
DirectiveKind::LabelColour
);
assert_eq!(
DirectiveKind::from_name("labelcolor"),
DirectiveKind::LabelColour
);
}
#[test]
fn directive_kind_from_name_grid_toc() {
assert_eq!(
DirectiveKind::from_name("gridfont"),
DirectiveKind::GridFont
);
assert_eq!(
DirectiveKind::from_name("gridsize"),
DirectiveKind::GridSize
);
assert_eq!(
DirectiveKind::from_name("gridcolour"),
DirectiveKind::GridColour
);
assert_eq!(
DirectiveKind::from_name("gridcolor"),
DirectiveKind::GridColour
);
assert_eq!(DirectiveKind::from_name("tocfont"), DirectiveKind::TocFont);
assert_eq!(DirectiveKind::from_name("tocsize"), DirectiveKind::TocSize);
assert_eq!(
DirectiveKind::from_name("toccolour"),
DirectiveKind::TocColour
);
assert_eq!(
DirectiveKind::from_name("toccolor"),
DirectiveKind::TocColour
);
}
#[test]
fn directive_kind_extra_font_size_color_canonical_names() {
assert_eq!(DirectiveKind::TitleFont.canonical_name(), "titlefont");
assert_eq!(DirectiveKind::TitleSize.canonical_name(), "titlesize");
assert_eq!(DirectiveKind::TitleColour.canonical_name(), "titlecolour");
assert_eq!(DirectiveKind::ChorusFont.canonical_name(), "chorusfont");
assert_eq!(DirectiveKind::ChorusSize.canonical_name(), "chorussize");
assert_eq!(DirectiveKind::ChorusColour.canonical_name(), "choruscolour");
assert_eq!(DirectiveKind::FooterFont.canonical_name(), "footerfont");
assert_eq!(DirectiveKind::FooterSize.canonical_name(), "footersize");
assert_eq!(DirectiveKind::FooterColour.canonical_name(), "footercolour");
assert_eq!(DirectiveKind::HeaderFont.canonical_name(), "headerfont");
assert_eq!(DirectiveKind::HeaderSize.canonical_name(), "headersize");
assert_eq!(DirectiveKind::HeaderColour.canonical_name(), "headercolour");
assert_eq!(DirectiveKind::LabelFont.canonical_name(), "labelfont");
assert_eq!(DirectiveKind::LabelSize.canonical_name(), "labelsize");
assert_eq!(DirectiveKind::LabelColour.canonical_name(), "labelcolour");
assert_eq!(DirectiveKind::GridFont.canonical_name(), "gridfont");
assert_eq!(DirectiveKind::GridSize.canonical_name(), "gridsize");
assert_eq!(DirectiveKind::GridColour.canonical_name(), "gridcolour");
assert_eq!(DirectiveKind::TocFont.canonical_name(), "tocfont");
assert_eq!(DirectiveKind::TocSize.canonical_name(), "tocsize");
assert_eq!(DirectiveKind::TocColour.canonical_name(), "toccolour");
}
#[test]
fn directive_kind_extra_font_size_color_category_checks() {
let font_kinds = [
DirectiveKind::TitleFont,
DirectiveKind::TitleSize,
DirectiveKind::TitleColour,
DirectiveKind::ChorusFont,
DirectiveKind::ChorusSize,
DirectiveKind::ChorusColour,
DirectiveKind::FooterFont,
DirectiveKind::FooterSize,
DirectiveKind::FooterColour,
DirectiveKind::HeaderFont,
DirectiveKind::HeaderSize,
DirectiveKind::HeaderColour,
DirectiveKind::LabelFont,
DirectiveKind::LabelSize,
DirectiveKind::LabelColour,
DirectiveKind::GridFont,
DirectiveKind::GridSize,
DirectiveKind::GridColour,
DirectiveKind::TocFont,
DirectiveKind::TocSize,
DirectiveKind::TocColour,
];
for kind in &font_kinds {
assert!(
kind.is_font_size_color(),
"{kind:?} should be font_size_color"
);
assert!(!kind.is_metadata(), "{kind:?} should not be metadata");
assert!(!kind.is_comment(), "{kind:?} should not be comment");
assert!(!kind.is_environment(), "{kind:?} should not be environment");
}
}
#[test]
fn directive_font_size_color_alias_resolution() {
let d = Directive::with_value("titlefont", "Times");
assert_eq!(d.name, "titlefont");
assert_eq!(d.kind, DirectiveKind::TitleFont);
assert_eq!(d.value.as_deref(), Some("Times"));
let d = Directive::with_value("choruscolor", "#FF0000");
assert_eq!(d.name, "choruscolour");
assert_eq!(d.kind, DirectiveKind::ChorusColour);
let d = Directive::with_value("titlecolor", "blue");
assert_eq!(d.name, "titlecolour");
assert_eq!(d.kind, DirectiveKind::TitleColour);
let d = Directive::with_value("gridsize", "12");
assert_eq!(d.name, "gridsize");
assert_eq!(d.kind, DirectiveKind::GridSize);
assert_eq!(d.value.as_deref(), Some("12"));
}
}
#[cfg(test)]
mod delegate_tests {
use super::*;
#[test]
fn directive_kind_from_name_delegate_abc() {
assert_eq!(
DirectiveKind::from_name("start_of_abc"),
DirectiveKind::StartOfAbc
);
assert_eq!(
DirectiveKind::from_name("end_of_abc"),
DirectiveKind::EndOfAbc
);
}
#[test]
fn directive_kind_from_name_delegate_ly() {
assert_eq!(
DirectiveKind::from_name("start_of_ly"),
DirectiveKind::StartOfLy
);
assert_eq!(
DirectiveKind::from_name("end_of_ly"),
DirectiveKind::EndOfLy
);
}
#[test]
fn directive_kind_from_name_delegate_svg() {
assert_eq!(
DirectiveKind::from_name("start_of_svg"),
DirectiveKind::StartOfSvg
);
assert_eq!(
DirectiveKind::from_name("end_of_svg"),
DirectiveKind::EndOfSvg
);
}
#[test]
fn directive_kind_from_name_delegate_textblock() {
assert_eq!(
DirectiveKind::from_name("start_of_textblock"),
DirectiveKind::StartOfTextblock
);
assert_eq!(
DirectiveKind::from_name("end_of_textblock"),
DirectiveKind::EndOfTextblock
);
}
#[test]
fn delegate_environments_case_insensitive() {
assert_eq!(
DirectiveKind::from_name("START_OF_ABC"),
DirectiveKind::StartOfAbc
);
assert_eq!(
DirectiveKind::from_name("End_Of_Ly"),
DirectiveKind::EndOfLy
);
assert_eq!(
DirectiveKind::from_name("START_OF_SVG"),
DirectiveKind::StartOfSvg
);
assert_eq!(
DirectiveKind::from_name("End_Of_Textblock"),
DirectiveKind::EndOfTextblock
);
}
#[test]
fn delegate_environments_are_section_start() {
assert!(DirectiveKind::StartOfAbc.is_section_start());
assert!(DirectiveKind::StartOfLy.is_section_start());
assert!(DirectiveKind::StartOfSvg.is_section_start());
assert!(DirectiveKind::StartOfTextblock.is_section_start());
}
#[test]
fn delegate_environments_are_section_end() {
assert!(DirectiveKind::EndOfAbc.is_section_end());
assert!(DirectiveKind::EndOfLy.is_section_end());
assert!(DirectiveKind::EndOfSvg.is_section_end());
assert!(DirectiveKind::EndOfTextblock.is_section_end());
}
#[test]
fn delegate_environments_are_environments() {
assert!(DirectiveKind::StartOfAbc.is_environment());
assert!(DirectiveKind::EndOfAbc.is_environment());
assert!(DirectiveKind::StartOfLy.is_environment());
assert!(DirectiveKind::EndOfLy.is_environment());
assert!(DirectiveKind::StartOfSvg.is_environment());
assert!(DirectiveKind::EndOfSvg.is_environment());
assert!(DirectiveKind::StartOfTextblock.is_environment());
assert!(DirectiveKind::EndOfTextblock.is_environment());
}
#[test]
fn delegate_environments_canonical_names() {
assert_eq!(DirectiveKind::StartOfAbc.canonical_name(), "start_of_abc");
assert_eq!(DirectiveKind::EndOfAbc.canonical_name(), "end_of_abc");
assert_eq!(DirectiveKind::StartOfLy.canonical_name(), "start_of_ly");
assert_eq!(DirectiveKind::EndOfLy.canonical_name(), "end_of_ly");
assert_eq!(DirectiveKind::StartOfSvg.canonical_name(), "start_of_svg");
assert_eq!(DirectiveKind::EndOfSvg.canonical_name(), "end_of_svg");
assert_eq!(
DirectiveKind::StartOfTextblock.canonical_name(),
"start_of_textblock"
);
assert_eq!(
DirectiveKind::EndOfTextblock.canonical_name(),
"end_of_textblock"
);
}
#[test]
fn delegate_not_metadata() {
assert!(!DirectiveKind::StartOfAbc.is_metadata());
assert!(!DirectiveKind::EndOfLy.is_metadata());
assert!(!DirectiveKind::StartOfSvg.is_metadata());
assert!(!DirectiveKind::EndOfTextblock.is_metadata());
}
#[test]
fn delegate_not_comment() {
assert!(!DirectiveKind::StartOfAbc.is_comment());
assert!(!DirectiveKind::EndOfLy.is_comment());
}
#[test]
fn delegate_directive_section_name() {
let d = Directive::name_only("start_of_abc");
assert_eq!(d.section_name(), Some("abc"));
let d = Directive::name_only("end_of_ly");
assert_eq!(d.section_name(), Some("ly"));
let d = Directive::name_only("start_of_svg");
assert_eq!(d.section_name(), Some("svg"));
let d = Directive::name_only("end_of_textblock");
assert_eq!(d.section_name(), Some("textblock"));
}
#[test]
fn delegate_directive_with_label() {
let d = Directive::with_value("start_of_abc", "Melody");
assert_eq!(d.name, "start_of_abc");
assert_eq!(d.value.as_deref(), Some("Melody"));
assert_eq!(d.kind, DirectiveKind::StartOfAbc);
}
#[test]
fn delegate_sections_not_custom() {
assert!(!matches!(
DirectiveKind::from_name("start_of_abc"),
DirectiveKind::StartOfSection(_)
));
assert!(!matches!(
DirectiveKind::from_name("start_of_ly"),
DirectiveKind::StartOfSection(_)
));
assert!(!matches!(
DirectiveKind::from_name("start_of_svg"),
DirectiveKind::StartOfSection(_)
));
assert!(!matches!(
DirectiveKind::from_name("start_of_textblock"),
DirectiveKind::StartOfSection(_)
));
}
}
#[cfg(test)]
mod chord_definition_tests {
use super::*;
#[test]
fn test_parse_keyboard_definition() {
let def = ChordDefinition::parse_value("Am keys 0 3 7");
assert_eq!(def.name, "Am");
assert_eq!(def.keys, Some(vec![0, 3, 7]));
assert!(def.copy.is_none());
}
#[test]
fn test_parse_keyboard_empty_keys() {
let def = ChordDefinition::parse_value("Am keys");
assert_eq!(def.name, "Am");
assert_eq!(def.keys, None);
}
#[test]
fn test_parse_keyboard_keys_midi_range() {
let def = ChordDefinition::parse_value("Am keys 0 60 127");
assert_eq!(def.keys, Some(vec![0, 60, 127]));
}
#[test]
fn test_parse_keyboard_keys_out_of_range_dropped() {
let def = ChordDefinition::parse_value("Am keys -1 0 128 60");
assert_eq!(def.keys, Some(vec![0, 60]));
}
#[test]
fn test_parse_keyboard_keys_all_invalid() {
let def = ChordDefinition::parse_value("Am keys abc def");
assert_eq!(def.keys, None);
}
#[test]
fn test_parse_keyboard_keys_non_numeric_dropped() {
let def = ChordDefinition::parse_value("Am keys 0 abc 7 xyz 12");
assert_eq!(def.keys, Some(vec![0, 7, 12]));
}
#[test]
fn test_parse_copy() {
let def = ChordDefinition::parse_value("Am copy Amin");
assert_eq!(def.name, "Am");
assert_eq!(def.copy, Some("Amin".to_string()));
assert!(def.keys.is_none());
}
#[test]
fn test_parse_copyall() {
let def = ChordDefinition::parse_value("Am copyall Amin");
assert_eq!(def.name, "Am");
assert_eq!(def.copyall, Some("Amin".to_string()));
}
#[test]
fn test_parse_copy_first_token_only() {
let def = ChordDefinition::parse_value("Am copy Amin extra stuff");
assert_eq!(def.copy, Some("Amin".to_string()));
}
#[test]
fn test_parse_copyall_first_token_only() {
let def = ChordDefinition::parse_value("Am copyall Amin extra stuff");
assert_eq!(def.copyall, Some("Amin".to_string()));
}
#[test]
fn test_parse_copy_with_display() {
let def = ChordDefinition::parse_value("Am copy Bm display=\"Alt\"");
assert_eq!(def.copy, Some("Bm".to_string()));
assert_eq!(def.display, Some("Alt".to_string()));
}
#[test]
fn test_parse_copyall_with_display() {
let def = ChordDefinition::parse_value("Am copyall Bm display=\"Alt\"");
assert_eq!(def.copyall, Some("Bm".to_string()));
assert_eq!(def.display, Some("Alt".to_string()));
}
#[test]
fn test_parse_copy_with_format() {
let def = ChordDefinition::parse_value("Am copy Bm format=\"%{root}m\"");
assert_eq!(def.copy, Some("Bm".to_string()));
assert_eq!(def.format, Some("%{root}m".to_string()));
}
#[test]
fn test_parse_keys_with_display() {
let def = ChordDefinition::parse_value("Am keys 0 3 7 display=\"A minor\"");
assert_eq!(def.keys, Some(vec![0, 3, 7]));
assert_eq!(def.display, Some("A minor".to_string()));
}
#[test]
fn test_parse_keys_with_format() {
let def = ChordDefinition::parse_value("Am keys 0 3 7 format=\"%{root}m\"");
assert_eq!(def.keys, Some(vec![0, 3, 7]));
assert_eq!(def.format, Some("%{root}m".to_string()));
}
#[test]
fn test_parse_fretted_definition() {
let def = ChordDefinition::parse_value("Am base-fret 1 frets x 0 2 2 1 0");
assert_eq!(def.name, "Am");
assert!(def.raw.is_some());
assert!(def.raw.unwrap().contains("base-fret"));
}
#[test]
fn test_parse_name_only() {
let def = ChordDefinition::parse_value("Am");
assert_eq!(def.name, "Am");
assert!(def.keys.is_none());
assert!(def.copy.is_none());
assert!(def.raw.is_none());
}
#[test]
fn test_parse_display_attribute() {
let def =
ChordDefinition::parse_value("Am base-fret 1 frets x 0 2 2 1 0 display=\"A minor\"");
assert_eq!(def.name, "Am");
assert_eq!(def.display, Some("A minor".to_string()));
let raw = def.raw.unwrap();
assert!(
!raw.contains("display="),
"display= should be stripped from raw, got: {raw}"
);
assert!(raw.contains("base-fret"));
}
#[test]
fn test_parse_display_attribute_at_start() {
let def =
ChordDefinition::parse_value("Am display=\"A minor\" base-fret 1 frets x 0 2 2 1 0");
assert_eq!(def.display, Some("A minor".to_string()));
let raw = def.raw.unwrap();
assert!(!raw.contains("display="));
assert!(raw.contains("base-fret"));
}
#[test]
fn test_parse_display_attribute_middle() {
let def =
ChordDefinition::parse_value("Am base-fret 1 display=\"A minor\" frets x 0 2 2 1 0");
assert_eq!(def.display, Some("A minor".to_string()));
let raw = def.raw.unwrap();
assert!(!raw.contains("display="));
assert!(raw.contains("base-fret"));
assert!(raw.contains("frets"));
}
#[test]
fn test_parse_display_unquoted() {
let def = ChordDefinition::parse_value("Am base-fret 1 frets x 0 2 2 1 0 display=Aminor");
assert_eq!(def.display, Some("Aminor".to_string()));
let raw = def.raw.unwrap();
assert!(!raw.contains("display="));
}
#[test]
fn test_parse_display_no_false_match() {
let def = ChordDefinition::parse_value("Am undisplay=foo base-fret 1 frets x 0 2 2 1 0");
assert_eq!(def.display, None);
let raw = def.raw.unwrap();
assert!(raw.contains("undisplay=foo"));
}
#[test]
fn test_parse_display_only() {
let def = ChordDefinition::parse_value("Am display=\"A minor\"");
assert_eq!(def.display, Some("A minor".to_string()));
assert!(def.raw.is_none());
}
#[test]
fn test_parse_format_attribute() {
let def = ChordDefinition::parse_value(
"Am base-fret 1 frets x 0 2 2 1 0 format=\"%{root}%{quality}\"",
);
assert_eq!(def.format, Some("%{root}%{quality}".to_string()));
let raw = def.raw.unwrap();
assert!(!raw.contains("format="));
assert!(raw.contains("base-fret"));
}
#[test]
fn test_parse_both_display_and_format() {
let def = ChordDefinition::parse_value(
"Am display=\"A minor\" format=\"%{root}%{quality}\" base-fret 1 frets x 0 2 2 1 0",
);
assert_eq!(def.display, Some("A minor".to_string()));
assert_eq!(def.format, Some("%{root}%{quality}".to_string()));
let raw = def.raw.unwrap();
assert!(!raw.contains("display="));
assert!(!raw.contains("format="));
}
#[test]
fn test_parse_format_only() {
let def = ChordDefinition::parse_value("Am format=\"%{root}-%{quality}\"");
assert_eq!(def.format, Some("%{root}-%{quality}".to_string()));
assert!(def.raw.is_none());
}
#[test]
fn test_parse_format_unclosed_quote_no_panic() {
let def = ChordDefinition::parse_value("Am display=\"unclosed");
assert_eq!(def.display, Some("unclosed".to_string()));
assert!(def.raw.is_none());
}
#[test]
fn test_parse_format_unclosed_quote_format_attr() {
let def = ChordDefinition::parse_value("Am format=\"%{root}%{quality}");
assert_eq!(def.format, Some("%{root}%{quality}".to_string()));
}
#[test]
fn test_parse_keyboard_negative_keys_dropped() {
let def = ChordDefinition::parse_value("Cm keys -1 0 3 7");
assert_eq!(def.keys, Some(vec![0, 3, 7]));
}
#[test]
fn test_parse_copy_tab_delimiter() {
let def = ChordDefinition::parse_value("Am copy\tAmin");
assert_eq!(def.copy, Some("Amin".to_string()));
}
#[test]
fn test_parse_copyall_tab_delimiter() {
let def = ChordDefinition::parse_value("Am copyall\tAmin");
assert_eq!(def.copyall, Some("Amin".to_string()));
}
#[test]
fn test_parse_copy_multiple_spaces() {
let def = ChordDefinition::parse_value("Am copy Amin");
assert_eq!(def.copy, Some("Amin".to_string()));
}
#[test]
fn test_parse_copyall_multiple_spaces() {
let def = ChordDefinition::parse_value("Am copyall Amin");
assert_eq!(def.copyall, Some("Amin".to_string()));
}
#[test]
fn test_parse_copy_mixed_whitespace() {
let def = ChordDefinition::parse_value("Am copy \t Amin");
assert_eq!(def.copy, Some("Amin".to_string()));
}
#[test]
fn test_parse_copyall_mixed_whitespace() {
let def = ChordDefinition::parse_value("Am copyall \t Amin");
assert_eq!(def.copyall, Some("Amin".to_string()));
}
#[test]
fn test_parse_trailing_display_equals_no_value() {
let def = ChordDefinition::parse_value("Am base-fret 1 frets x 0 2 2 1 0 display=");
assert_eq!(def.display, Some(String::new()));
assert_eq!(def.raw, Some("base-fret 1 frets x 0 2 2 1 0".to_string()));
}
#[test]
fn test_unquoted_empty_display_with_format() {
let def = ChordDefinition::parse_value("Am display= format=\"test\"");
assert_eq!(def.display, Some(String::new()));
assert_eq!(def.format, Some("test".to_string()));
}
#[test]
fn test_unquoted_empty_format_with_display() {
let def = ChordDefinition::parse_value("Am format= display=\"A minor\"");
assert_eq!(def.format, Some(String::new()));
assert_eq!(def.display, Some("A minor".to_string()));
}
#[test]
fn test_unquoted_value_with_equals_treated_as_empty() {
let def = ChordDefinition::parse_value("Am display=val=ue");
assert_eq!(
def.display,
Some(String::new()),
"unquoted value with '=' should be treated as empty"
);
}
#[test]
fn test_quoted_value_with_equals_preserved() {
let def = ChordDefinition::parse_value("Am display=\"val=ue\"");
assert_eq!(def.display, Some("val=ue".to_string()));
}
#[test]
fn test_define_after_usage_still_applies() {
let mut song = Song::new();
let mut lyrics = LyricsLine::new();
lyrics
.segments
.push(LyricsSegment::new(Some(Chord::new("Am")), "word "));
song.lines.push(Line::Lyrics(lyrics));
song.lines.push(Line::Directive(Directive::with_value(
"define",
"Am display=\"A minor\"",
)));
song.apply_define_displays();
if let Line::Lyrics(ref lyrics) = song.lines[0] {
assert_eq!(
lyrics.segments[0].chord.as_ref().unwrap().display_name(),
"A minor"
);
} else {
panic!("expected lyrics line");
}
}
#[test]
fn test_parse_keys_multiple_spaces() {
let def = ChordDefinition::parse_value("Am keys 0 3 7");
assert_eq!(def.keys, Some(vec![0, 3, 7]));
}
#[test]
fn test_parse_keys_tab_separator() {
let def = ChordDefinition::parse_value("Am keys\t0 3 7");
assert_eq!(def.keys, Some(vec![0, 3, 7]));
}
#[test]
fn test_parse_keys_only_keyword() {
let def = ChordDefinition::parse_value("Am keys");
assert!(def.keys.is_none());
}
#[test]
fn has_src_returns_true_for_non_empty() {
let attrs = ImageAttributes::new("photo.jpg");
assert!(attrs.has_src());
}
#[test]
fn has_src_returns_false_for_empty() {
let attrs = ImageAttributes::default();
assert!(!attrs.has_src());
}
#[test]
fn has_src_returns_false_for_explicit_empty_string() {
let attrs = ImageAttributes::new("");
assert!(!attrs.has_src());
}
#[test]
fn parse_value_detects_bracket_form() {
let def = ChordDefinition::parse_value("[A]");
assert_eq!(def.name, "A");
assert!(def.transposable);
assert!(def.raw.is_none());
}
#[test]
fn parse_value_bracket_form_drops_attrs() {
let def = ChordDefinition::parse_value("[A] frets 0 2 2 1 0 0");
assert_eq!(def.name, "A");
assert!(def.transposable);
assert!(def.raw.is_none());
assert!(def.keys.is_none());
assert!(def.copy.is_none());
assert!(def.copyall.is_none());
assert!(def.display.is_none());
assert!(def.format.is_none());
}
#[test]
fn parse_value_bracket_form_drops_display_and_format() {
let def = ChordDefinition::parse_value("[Am] display=\"X\" format=\"%{root}%{quality}\"");
assert_eq!(def.name, "Am");
assert!(def.transposable);
assert!(def.display.is_none());
assert!(def.format.is_none());
}
#[test]
fn parse_value_no_brackets_is_not_transposable() {
let def = ChordDefinition::parse_value("A frets 0 2 2 1 0 0");
assert_eq!(def.name, "A");
assert!(!def.transposable);
assert!(def.raw.is_some());
}
#[test]
fn parse_value_extension_chord_in_brackets() {
let def = ChordDefinition::parse_value("[A#m7]");
assert_eq!(def.name, "A#m7");
assert!(def.transposable);
}
#[test]
fn parse_value_bracket_only_no_other_token() {
let def = ChordDefinition::parse_value("[]");
assert_eq!(def.name, "");
assert!(def.transposable);
}
#[test]
fn parse_value_unmatched_open_bracket_is_not_transposable() {
let def = ChordDefinition::parse_value("[A");
assert_eq!(def.name, "[A");
assert!(!def.transposable);
}
#[test]
fn parse_value_unmatched_close_bracket_is_not_transposable() {
let def = ChordDefinition::parse_value("A]");
assert_eq!(def.name, "A]");
assert!(!def.transposable);
}
}
#[cfg(test)]
mod apply_define_displays_tests {
use super::*;
fn make_song_with_define_and_chords(define_value: &str, chord_names: &[&str]) -> Song {
let mut song = Song::new();
song.lines.push(Line::Directive(Directive::with_value(
"define",
define_value,
)));
let mut lyrics = LyricsLine::new();
for name in chord_names {
lyrics
.segments
.push(LyricsSegment::new(Some(Chord::new(*name)), "word "));
}
song.lines.push(Line::Lyrics(lyrics));
song
}
#[test]
fn applies_display_to_matching_chords() {
let mut song = make_song_with_define_and_chords(
"Am base-fret 1 frets x 0 2 2 1 0 display=\"A minor\"",
&["Am", "G", "Am"],
);
song.apply_define_displays();
if let Line::Lyrics(ref lyrics) = song.lines[1] {
assert_eq!(
lyrics.segments[0].chord.as_ref().unwrap().display_name(),
"A minor"
);
assert_eq!(
lyrics.segments[1].chord.as_ref().unwrap().display_name(),
"G"
);
assert_eq!(
lyrics.segments[2].chord.as_ref().unwrap().display_name(),
"A minor"
);
} else {
panic!("expected lyrics line");
}
}
#[test]
fn no_display_when_not_defined() {
let mut song =
make_song_with_define_and_chords("Am base-fret 1 frets x 0 2 2 1 0", &["Am"]);
song.apply_define_displays();
if let Line::Lyrics(ref lyrics) = song.lines[1] {
assert_eq!(lyrics.segments[0].chord.as_ref().unwrap().display, None);
} else {
panic!("expected lyrics line");
}
}
#[test]
fn later_define_overrides_earlier() {
let mut song = Song::new();
song.lines.push(Line::Directive(Directive::with_value(
"define",
"Am display=\"first\"",
)));
song.lines.push(Line::Directive(Directive::with_value(
"define",
"Am display=\"second\"",
)));
let mut lyrics = LyricsLine::new();
lyrics
.segments
.push(LyricsSegment::new(Some(Chord::new("Am")), "text"));
song.lines.push(Line::Lyrics(lyrics));
song.apply_define_displays();
if let Line::Lyrics(ref lyrics) = song.lines[2] {
assert_eq!(
lyrics.segments[0].chord.as_ref().unwrap().display_name(),
"second"
);
} else {
panic!("expected lyrics line");
}
}
#[test]
fn does_not_overwrite_existing_display() {
let mut song = Song::new();
song.lines.push(Line::Directive(Directive::with_value(
"define",
"Am display=\"from define\"",
)));
let mut lyrics = LyricsLine::new();
let mut chord = Chord::new("Am");
chord.display = Some("already set".to_string());
lyrics
.segments
.push(LyricsSegment::new(Some(chord), "text"));
song.lines.push(Line::Lyrics(lyrics));
song.apply_define_displays();
if let Line::Lyrics(ref lyrics) = song.lines[1] {
assert_eq!(
lyrics.segments[0].chord.as_ref().unwrap().display_name(),
"already set"
);
} else {
panic!("expected lyrics line");
}
}
#[test]
fn format_expands_chord_components() {
let mut song =
make_song_with_define_and_chords("Am format=\"%{root} %{quality}\"", &["Am"]);
song.apply_define_displays();
if let Line::Lyrics(ref lyrics) = song.lines[1] {
assert_eq!(
lyrics.segments[0].chord.as_ref().unwrap().display_name(),
"A m"
);
} else {
panic!("expected lyrics line");
}
}
#[test]
fn format_with_extension() {
let mut song =
make_song_with_define_and_chords("Am7 format=\"%{root}%{quality}%{ext}\"", &["Am7"]);
song.apply_define_displays();
if let Line::Lyrics(ref lyrics) = song.lines[1] {
assert_eq!(
lyrics.segments[0].chord.as_ref().unwrap().display_name(),
"Am7"
);
} else {
panic!("expected lyrics line");
}
}
#[test]
fn format_with_bass_note() {
let mut song = make_song_with_define_and_chords("G/B format=\"%{root}/%{bass}\"", &["G/B"]);
song.apply_define_displays();
if let Line::Lyrics(ref lyrics) = song.lines[1] {
assert_eq!(
lyrics.segments[0].chord.as_ref().unwrap().display_name(),
"G/B"
);
} else {
panic!("expected lyrics line");
}
}
#[test]
fn display_takes_precedence_over_format() {
let mut song = make_song_with_define_and_chords(
"Am display=\"A minor\" format=\"%{root}%{quality}\"",
&["Am"],
);
song.apply_define_displays();
if let Line::Lyrics(ref lyrics) = song.lines[1] {
assert_eq!(
lyrics.segments[0].chord.as_ref().unwrap().display_name(),
"A minor"
);
} else {
panic!("expected lyrics line");
}
}
}
#[cfg(test)]
mod expand_format_tests {
use super::*;
#[test]
fn basic_root_quality() {
let chord = Chord::new("Am");
assert_eq!(
chord.expand_format("%{root}%{quality}"),
Some("Am".to_string())
);
}
#[test]
fn with_accidental() {
let chord = Chord::new("Bb");
assert_eq!(chord.expand_format("%{root}"), Some("Bb".to_string()));
}
#[test]
fn with_extension() {
let chord = Chord::new("Cmaj7");
let result = chord.expand_format("%{root}%{quality}%{ext}");
assert_eq!(result, Some("Cmaj7".to_string()));
}
#[test]
fn with_bass() {
let chord = Chord::new("Am/G");
let result = chord.expand_format("%{root}%{quality}/%{bass}");
assert_eq!(result, Some("Am/G".to_string()));
}
#[test]
fn custom_format() {
let chord = Chord::new("Am");
let result = chord.expand_format("[%{root} minor]");
assert_eq!(result, Some("[A minor]".to_string()));
}
#[test]
fn returns_none_for_unparsed_chord() {
let chord = Chord {
name: "???".to_string(),
detail: None,
display: None,
};
assert_eq!(chord.expand_format("%{root}"), None);
}
#[test]
fn unknown_placeholder_passes_through() {
let chord = Chord::new("Am");
let result = chord.expand_format("%{root}%{unknown}");
assert_eq!(result, Some("A%{unknown}".to_string()));
}
#[test]
fn empty_format_string() {
let chord = Chord::new("Am");
assert_eq!(chord.expand_format(""), Some(String::new()));
}
#[test]
fn slash_chord_bass_with_accidental() {
let chord = Chord::new("G/Bb");
let result = chord.expand_format("%{root}/%{bass}");
assert_eq!(result, Some("G/Bb".to_string()));
}
#[test]
fn no_bass_produces_empty_string() {
let chord = Chord::new("Am");
let result = chord.expand_format("%{root}%{quality} (bass: %{bass})");
assert_eq!(result, Some("Am (bass: )".to_string()));
}
#[test]
fn all_placeholders_combined() {
let chord = Chord::new("Bbm7/Eb");
let result = chord.expand_format("%{root}%{quality}%{ext}/%{bass}");
assert_eq!(result, Some("Bbm7/Eb".to_string()));
}
#[test]
fn literal_text_with_no_placeholders() {
let chord = Chord::new("Am");
let result = chord.expand_format("just text");
assert_eq!(result, Some("just text".to_string()));
}
}