use crate::Lexer;
use crate::ast::{
Chord, CommentStyle, Directive, DirectiveKind, ImageAttributes, Line, LyricsLine,
LyricsSegment, Song,
};
use crate::inline_markup;
use crate::token::{Position, Span, Token, TokenKind};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParseError {
pub message: String,
pub span: Span,
}
impl ParseError {
fn new(message: impl Into<String>, span: Span) -> Self {
Self {
message: message.into(),
span,
}
}
#[must_use]
pub fn line(&self) -> usize {
self.span.start.line
}
#[must_use]
pub fn column(&self) -> usize {
self.span.start.column
}
}
impl core::fmt::Display for ParseError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(
f,
"parse error at line {}, column {}: {}",
self.span.start.line, self.span.start.column, self.message
)
}
}
impl std::error::Error for ParseError {}
#[derive(Debug, Clone)]
pub struct ParseResult {
pub song: Song,
pub errors: Vec<ParseError>,
}
impl ParseResult {
#[must_use]
pub fn is_ok(&self) -> bool {
self.errors.is_empty()
}
#[must_use]
pub fn has_errors(&self) -> bool {
!self.errors.is_empty()
}
}
pub struct Parser {
tokens: Vec<Token>,
pos: usize,
verbatim_end: Option<String>,
}
impl Parser {
#[must_use]
pub fn new(tokens: Vec<Token>) -> Self {
Self::try_new(tokens).expect("token list must contain at least an Eof token")
}
#[must_use = "callers must handle the empty-token error"]
pub fn try_new(tokens: Vec<Token>) -> Result<Self, ParseError> {
if tokens.is_empty() {
return Err(ParseError::new(
"empty token list — expected at least an Eof token",
Span::new(Position::new(1, 1), Position::new(1, 1)),
));
}
Ok(Self {
tokens,
pos: 0,
verbatim_end: None,
})
}
#[must_use = "callers must handle the parse result"]
pub fn parse(mut self) -> Result<Song, ParseError> {
let mut song = Song::new();
while !self.is_at_end() {
let line = self.parse_line()?;
if let Line::Directive(ref directive) = line {
if directive.selector.is_none() {
Self::populate_metadata(&mut song.metadata, directive);
}
}
song.lines.push(line);
}
song.apply_define_displays();
Ok(song)
}
#[must_use = "callers must handle the parse result"]
pub fn parse_lenient(self) -> ParseResult {
self.parse_lenient_limited(0)
}
#[must_use = "callers must handle the parse result"]
pub fn parse_lenient_limited(mut self, max_errors: usize) -> ParseResult {
let mut song = Song::new();
let mut errors = Vec::new();
while !self.is_at_end() {
match self.parse_line() {
Ok(line) => {
if let Line::Directive(ref directive) = line {
if directive.selector.is_none() {
Self::populate_metadata(&mut song.metadata, directive);
}
}
song.lines.push(line);
}
Err(e) => {
if max_errors == 0 || errors.len() < max_errors {
errors.push(e);
}
self.skip_to_next_line();
}
}
}
song.apply_define_displays();
ParseResult { song, errors }
}
fn skip_to_next_line(&mut self) {
while !self.is_at_end() {
if self.peek_kind() == &TokenKind::Newline {
self.advance();
return;
}
self.advance();
}
}
const MAX_METADATA_ENTRIES: usize = 1000;
fn push_if_under_cap<T>(vec: &mut Vec<T>, value: T) {
if vec.len() < Self::MAX_METADATA_ENTRIES {
vec.push(value);
}
}
pub fn populate_metadata(metadata: &mut crate::ast::Metadata, directive: &Directive) {
let value = match directive.value.as_deref() {
Some(v) => v.to_string(),
None => return, };
match directive.kind {
DirectiveKind::Title => {
metadata.title = Some(value);
}
DirectiveKind::Subtitle => {
Self::push_if_under_cap(&mut metadata.subtitles, value);
}
DirectiveKind::Artist => {
Self::push_if_under_cap(&mut metadata.artists, value);
}
DirectiveKind::Composer => {
Self::push_if_under_cap(&mut metadata.composers, value);
}
DirectiveKind::Lyricist => {
Self::push_if_under_cap(&mut metadata.lyricists, value);
}
DirectiveKind::Album => {
metadata.album = Some(value);
}
DirectiveKind::Year => {
metadata.year = Some(value);
}
DirectiveKind::Key => {
Self::push_if_under_cap(&mut metadata.keys, value.clone());
metadata.key = Some(value);
}
DirectiveKind::Tempo => {
Self::push_if_under_cap(&mut metadata.tempos, value.clone());
metadata.tempo = Some(value);
}
DirectiveKind::Time => {
Self::push_if_under_cap(&mut metadata.times, value.clone());
metadata.time = Some(value);
}
DirectiveKind::Capo => {
metadata.capo = Some(value);
}
DirectiveKind::SortTitle => {
metadata.sort_title = Some(value);
}
DirectiveKind::SortArtist => {
metadata.sort_artist = Some(value);
}
DirectiveKind::Arranger => {
Self::push_if_under_cap(&mut metadata.arrangers, value);
}
DirectiveKind::Copyright => {
metadata.copyright = Some(value);
}
DirectiveKind::Duration => {
metadata.duration = Some(value);
}
DirectiveKind::Tag => {
Self::push_if_under_cap(&mut metadata.tags, value);
}
DirectiveKind::Meta(ref key) => match key.to_ascii_lowercase().as_str() {
"title" | "t" => metadata.title = Some(value),
"subtitle" | "st" => Self::push_if_under_cap(&mut metadata.subtitles, value),
"artist" => Self::push_if_under_cap(&mut metadata.artists, value),
"composer" => Self::push_if_under_cap(&mut metadata.composers, value),
"lyricist" => Self::push_if_under_cap(&mut metadata.lyricists, value),
"album" => metadata.album = Some(value),
"year" => metadata.year = Some(value),
"key" => {
Self::push_if_under_cap(&mut metadata.keys, value.clone());
metadata.key = Some(value);
}
"tempo" => {
Self::push_if_under_cap(&mut metadata.tempos, value.clone());
metadata.tempo = Some(value);
}
"time" => {
Self::push_if_under_cap(&mut metadata.times, value.clone());
metadata.time = Some(value);
}
"capo" => metadata.capo = Some(value),
"sorttitle" => metadata.sort_title = Some(value),
"sortartist" => metadata.sort_artist = Some(value),
"arranger" => Self::push_if_under_cap(&mut metadata.arrangers, value),
"copyright" => metadata.copyright = Some(value),
"duration" => metadata.duration = Some(value),
"tag" => Self::push_if_under_cap(&mut metadata.tags, value),
_ => Self::push_if_under_cap(&mut metadata.custom, (key.clone(), value)),
},
DirectiveKind::Unknown(ref name) => {
Self::push_if_under_cap(&mut metadata.custom, (name.clone(), value));
}
_ => {}
}
}
fn is_at_end(&self) -> bool {
self.pos >= self.tokens.len() || self.peek_kind() == &TokenKind::Eof
}
fn peek_kind(&self) -> &TokenKind {
self.tokens
.get(self.pos)
.map(|t| &t.kind)
.unwrap_or(&TokenKind::Eof)
}
fn peek(&self) -> &Token {
&self.tokens[self.pos]
}
fn advance(&mut self) -> &Token {
let tok = &self.tokens[self.pos];
self.pos += 1;
tok
}
fn parse_line(&mut self) -> Result<Line, ParseError> {
let in_verbatim = self.verbatim_end.is_some();
match self.peek_kind() {
TokenKind::Newline => {
self.advance();
Ok(Line::Empty)
}
TokenKind::DirectiveOpen => {
if in_verbatim && !self.is_verbatim_end_ahead() {
return self.parse_verbatim_line();
}
let line = self.parse_directive_line()?;
if let Line::Directive(ref d) = line {
if let Some(end_name) = Self::verbatim_end_for(&d.kind) {
self.verbatim_end = Some(end_name);
} else if d.kind.is_section_end() && in_verbatim {
self.verbatim_end = None;
}
}
Ok(line)
}
_ if in_verbatim => self.parse_verbatim_line(),
TokenKind::Text(t) if t.starts_with('#') => self.parse_hash_comment_line(),
_ => self.parse_lyrics_line(),
}
}
fn verbatim_end_for(kind: &DirectiveKind) -> Option<String> {
match kind {
DirectiveKind::StartOfTab => Some("end_of_tab".to_string()),
DirectiveKind::StartOfGrid => Some("end_of_grid".to_string()),
DirectiveKind::StartOfAbc => Some("end_of_abc".to_string()),
DirectiveKind::StartOfLy => Some("end_of_ly".to_string()),
DirectiveKind::StartOfSvg => Some("end_of_svg".to_string()),
DirectiveKind::StartOfTextblock => Some("end_of_textblock".to_string()),
DirectiveKind::StartOfMusicxml => Some("end_of_musicxml".to_string()),
_ => None,
}
}
fn is_verbatim_end_ahead(&self) -> bool {
if let Some(ref end_name) = self.verbatim_end {
if self.pos + 1 < self.tokens.len() {
if let TokenKind::Text(ref text) = self.tokens[self.pos + 1].kind {
let trimmed = text.trim().to_ascii_lowercase();
if trimmed == *end_name {
return true;
}
return match end_name.as_str() {
"end_of_tab" => trimmed == "eot",
"end_of_grid" => trimmed == "eog",
_ => false,
};
}
}
}
false
}
fn parse_verbatim_line(&mut self) -> Result<Line, ParseError> {
let text = self.collect_raw_line();
if self.peek_kind() == &TokenKind::Newline {
self.advance();
}
if text.is_empty() {
Ok(Line::Empty)
} else {
Ok(Line::Lyrics(LyricsLine {
segments: vec![LyricsSegment::text_only(text)],
}))
}
}
fn parse_hash_comment_line(&mut self) -> Result<Line, ParseError> {
let raw = self.collect_raw_line();
if self.peek_kind() == &TokenKind::Newline {
self.advance();
}
debug_assert!(
raw.starts_with('#'),
"parse_hash_comment_line called without '#' prefix"
);
let after_hash = raw.strip_prefix('#').unwrap_or(raw.as_str());
let text = after_hash.strip_prefix(' ').unwrap_or(after_hash);
Ok(Line::Comment(CommentStyle::Normal, text.to_string()))
}
fn collect_raw_line(&mut self) -> String {
let mut raw = String::new();
loop {
match self.peek_kind() {
TokenKind::Newline | TokenKind::Eof => break,
TokenKind::Text(t) => {
raw.push_str(t);
self.advance();
}
TokenKind::ChordOpen => {
raw.push('[');
self.advance();
}
TokenKind::ChordClose => {
raw.push(']');
self.advance();
}
TokenKind::DirectiveOpen => {
raw.push('{');
self.advance();
}
TokenKind::DirectiveClose => {
raw.push('}');
self.advance();
}
TokenKind::Colon => {
raw.push(':');
self.advance();
}
}
}
raw
}
fn parse_directive_line(&mut self) -> Result<Line, ParseError> {
let open_span = self.peek().span;
self.advance();
let name = self.parse_directive_name(&open_span)?;
let value = if self.peek_kind() == &TokenKind::Colon {
self.advance(); Some(self.parse_directive_value())
} else {
None
};
if self.peek_kind() != &TokenKind::DirectiveClose {
let span = self.peek().span;
return Err(ParseError::new("unclosed directive: expected `}`", span));
}
self.advance();
if self.peek_kind() == &TokenKind::Newline {
self.advance();
}
let raw_name = name.trim().to_string();
let mut value = value.map(|v| v.trim().to_string());
let name = match raw_name.find(|c: char| c.is_whitespace()) {
Some(idx) => {
let prefix = &raw_name[..idx];
let attrs = raw_name[idx..].trim().to_string();
let (prefix_kind, _) = DirectiveKind::resolve_with_selector(prefix);
let is_attribute_bearing = !matches!(
prefix_kind,
DirectiveKind::Unknown(_)
| DirectiveKind::StartOfSection(_)
| DirectiveKind::EndOfSection(_)
);
if is_attribute_bearing && !attrs.is_empty() {
if value.is_none() {
value = Some(attrs);
}
prefix.to_string()
} else {
raw_name
}
}
None => raw_name,
};
let (kind, selector) = DirectiveKind::resolve_with_selector(&name);
if kind.is_comment() && selector.is_none() {
let style = match kind {
DirectiveKind::Comment => CommentStyle::Normal,
DirectiveKind::CommentItalic => CommentStyle::Italic,
DirectiveKind::CommentBox => CommentStyle::Boxed,
DirectiveKind::Highlight => CommentStyle::Highlight,
_ => CommentStyle::Normal,
};
let text = value.unwrap_or_default();
return Ok(Line::Comment(style, text));
}
if matches!(kind, DirectiveKind::Meta(_)) {
if let Some(ref val) = value {
let trimmed = val.trim();
if let Some(pos) = trimmed.find(|c: char| c.is_whitespace()) {
let meta_key = trimmed[..pos].to_string();
let meta_value = trimmed[pos..].trim().to_string();
let kind = DirectiveKind::Meta(meta_key.clone());
let directive = Directive {
name: "meta".to_string(),
value: if meta_value.is_empty() {
None
} else {
Some(meta_value)
},
kind,
selector,
};
return Ok(Line::Directive(directive));
} else if !trimmed.is_empty() {
let meta_key = trimmed.to_string();
let kind = DirectiveKind::Meta(meta_key);
let directive = Directive {
name: "meta".to_string(),
value: None,
kind,
selector,
};
return Ok(Line::Directive(directive));
}
}
let directive = Directive {
name: "meta".to_string(),
value: None,
kind: DirectiveKind::Unknown("meta".to_string()),
selector,
};
return Ok(Line::Directive(directive));
}
if kind.is_image() {
let attrs = match &value {
Some(v) => parse_image_attributes(v),
None => ImageAttributes::default(),
};
let kind = DirectiveKind::Image(attrs);
let canonical = kind.canonical_name().to_string();
let directive = Directive {
name: canonical,
value,
kind,
selector,
};
return Ok(Line::Directive(directive));
}
let canonical = kind.full_canonical_name();
let directive = Directive {
name: canonical,
value,
kind,
selector,
};
Ok(Line::Directive(directive))
}
fn parse_directive_name(&mut self, open_span: &Span) -> Result<String, ParseError> {
let mut name = String::new();
loop {
match self.peek_kind() {
TokenKind::Text(text) => {
name.push_str(text);
self.advance();
}
TokenKind::Colon | TokenKind::DirectiveClose => break,
TokenKind::Eof | TokenKind::Newline => {
return Err(ParseError::new(
"unclosed directive: expected `}`",
*open_span,
));
}
_ => {
let tok = self.peek();
return Err(ParseError::new(
format!("unexpected {:?} in directive name", tok.kind),
tok.span,
));
}
}
}
if name.trim().is_empty() {
return Err(ParseError::new("empty directive name", *open_span));
}
Ok(name)
}
fn parse_directive_value(&mut self) -> String {
let mut value = String::new();
loop {
match self.peek_kind() {
TokenKind::Text(text) => {
value.push_str(text);
self.advance();
}
TokenKind::DirectiveClose | TokenKind::Eof | TokenKind::Newline => break,
TokenKind::Colon => {
value.push(':');
self.advance();
}
TokenKind::ChordOpen => {
value.push('[');
self.advance();
}
TokenKind::ChordClose => {
value.push(']');
self.advance();
}
TokenKind::DirectiveOpen => {
value.push('{');
self.advance();
}
}
}
value
}
fn parse_lyrics_line(&mut self) -> Result<Line, ParseError> {
let mut segments: Vec<LyricsSegment> = Vec::new();
let mut current_chord: Option<Chord> = None;
let mut current_text = String::new();
loop {
match self.peek_kind() {
TokenKind::Newline | TokenKind::Eof => {
break;
}
TokenKind::ChordOpen => {
if current_chord.is_some() || !current_text.is_empty() {
segments.push(LyricsSegment::new(
current_chord.take(),
core::mem::take(&mut current_text),
));
}
current_chord = Some(self.parse_chord()?);
}
TokenKind::Text(text) => {
current_text.push_str(text);
self.advance();
}
TokenKind::DirectiveOpen => {
current_text.push('{');
self.advance();
}
TokenKind::DirectiveClose => {
current_text.push('}');
self.advance();
}
TokenKind::ChordClose => {
current_text.push(']');
self.advance();
}
TokenKind::Colon => {
current_text.push(':');
self.advance();
}
}
}
if current_chord.is_some() || !current_text.is_empty() {
segments.push(LyricsSegment::new(current_chord, current_text));
}
if self.peek_kind() == &TokenKind::Newline {
self.advance();
}
if segments.is_empty() {
Ok(Line::Empty)
} else {
let segments = segments
.into_iter()
.map(Self::apply_inline_markup)
.collect();
Ok(Line::Lyrics(LyricsLine { segments }))
}
}
fn apply_inline_markup(mut segment: LyricsSegment) -> LyricsSegment {
if inline_markup::has_inline_markup(&segment.text) {
let spans = inline_markup::parse_inline_markup(&segment.text);
if !spans.is_empty() {
segment.text = inline_markup::spans_to_plain_text(&spans);
segment.spans = spans;
}
}
segment
}
fn parse_chord(&mut self) -> Result<Chord, ParseError> {
let open_span = self.peek().span;
self.advance();
let mut name = String::new();
loop {
match self.peek_kind() {
TokenKind::Text(text) => {
name.push_str(text);
self.advance();
}
TokenKind::ChordClose => {
self.advance(); break;
}
TokenKind::Newline | TokenKind::Eof => {
return Err(ParseError::new("unclosed chord: expected `]`", open_span));
}
_ => {
let tok = self.peek();
return Err(ParseError::new(
format!("unexpected {:?} inside chord", tok.kind),
tok.span,
));
}
}
}
Ok(Chord::new(name))
}
}
#[must_use = "callers must handle the parse error"]
pub fn parse(input: &str) -> Result<Song, ParseError> {
parse_with_options(input, &ParseOptions::default())
}
#[derive(Debug, Clone)]
pub struct ParseOptions {
pub max_input_size: usize,
pub max_errors: usize,
}
impl Default for ParseOptions {
fn default() -> Self {
Self {
max_input_size: 10 * 1024 * 1024, max_errors: 1000,
}
}
}
#[must_use = "callers must handle the parse error"]
pub fn parse_with_options(input: &str, options: &ParseOptions) -> Result<Song, ParseError> {
if options.max_input_size > 0 && input.len() > options.max_input_size {
return Err(ParseError::new(
format!(
"input size ({} bytes) exceeds maximum ({} bytes)",
input.len(),
options.max_input_size
),
Span::new(
crate::token::Position::new(1, 1),
crate::token::Position::new(1, 1),
),
));
}
let tokens = Lexer::new(input).tokenize();
Parser::new(tokens).parse()
}
#[must_use]
pub fn parse_lenient(input: &str) -> ParseResult {
parse_lenient_with_options(input, &ParseOptions::default())
}
#[must_use]
pub fn parse_lenient_with_options(input: &str, options: &ParseOptions) -> ParseResult {
if options.max_input_size > 0 && input.len() > options.max_input_size {
return ParseResult {
song: Song::new(),
errors: vec![ParseError::new(
format!(
"input size ({} bytes) exceeds maximum ({} bytes)",
input.len(),
options.max_input_size
),
Span::new(
crate::token::Position::new(1, 1),
crate::token::Position::new(1, 1),
),
)],
};
}
let tokens = Lexer::new(input).tokenize();
Parser::new(tokens).parse_lenient_limited(options.max_errors)
}
#[derive(Debug, Clone)]
pub struct MultiParseResult {
pub results: Vec<ParseResult>,
}
impl MultiParseResult {
#[must_use]
pub fn songs(&self) -> Vec<&Song> {
self.results.iter().map(|r| &r.song).collect()
}
#[must_use]
pub fn is_ok(&self) -> bool {
self.results.iter().all(|r| r.is_ok())
}
#[must_use]
pub fn has_errors(&self) -> bool {
self.results.iter().any(|r| r.has_errors())
}
#[must_use]
pub fn all_errors(&self) -> Vec<&ParseError> {
self.results.iter().flat_map(|r| r.errors.iter()).collect()
}
}
fn is_new_song_line(trimmed: &str) -> bool {
if !trimmed.starts_with('{') || !trimmed.ends_with('}') {
return false;
}
let inner = trimmed[1..trimmed.len() - 1].trim().to_ascii_lowercase();
let name = match inner.find(':') {
Some(pos) => inner[..pos].trim_end(),
None => inner.as_str(),
};
name == "new_song" || name == "ns"
}
fn split_at_new_song(input: &str) -> Vec<&str> {
let mut segments = Vec::new();
let mut seg_start = 0;
let bytes = input.as_bytes();
let len = bytes.len();
let mut pos = 0;
while pos < len {
let line_start = pos;
while pos < len && bytes[pos] != b'\r' && bytes[pos] != b'\n' {
pos += 1;
}
let line_end = pos;
let after_newline = if pos < len && bytes[pos] == b'\r' {
if pos + 1 < len && bytes[pos + 1] == b'\n' {
pos + 2
} else {
pos + 1 }
} else if pos < len && bytes[pos] == b'\n' {
pos + 1
} else {
pos
};
pos = after_newline;
let line = &input[line_start..line_end];
let trimmed = line.trim();
if is_new_song_line(trimmed) {
segments.push(&input[seg_start..line_start]);
seg_start = after_newline;
}
}
segments.push(&input[seg_start..]);
segments
}
#[must_use = "callers must handle the parse error"]
pub fn parse_multi(input: &str) -> Result<Vec<Song>, ParseError> {
parse_multi_with_options(input, &ParseOptions::default())
}
#[must_use = "callers must handle the parse error"]
pub fn parse_multi_with_options(
input: &str,
options: &ParseOptions,
) -> Result<Vec<Song>, ParseError> {
if options.max_input_size > 0 && input.len() > options.max_input_size {
return Err(ParseError::new(
format!(
"input size ({} bytes) exceeds maximum ({} bytes)",
input.len(),
options.max_input_size
),
Span::new(
crate::token::Position::new(1, 1),
crate::token::Position::new(1, 1),
),
));
}
let segments = split_at_new_song(input);
let mut songs = Vec::with_capacity(segments.len());
for segment in segments {
let tokens = Lexer::new(segment).tokenize();
let song = Parser::new(tokens).parse()?;
songs.push(song);
}
Ok(songs)
}
#[must_use]
pub fn parse_multi_lenient(input: &str) -> MultiParseResult {
parse_multi_lenient_with_options(input, &ParseOptions::default())
}
#[must_use]
pub fn parse_multi_lenient_with_options(input: &str, options: &ParseOptions) -> MultiParseResult {
if options.max_input_size > 0 && input.len() > options.max_input_size {
return MultiParseResult {
results: vec![ParseResult {
song: Song::new(),
errors: vec![ParseError::new(
format!(
"input size ({} bytes) exceeds maximum ({} bytes)",
input.len(),
options.max_input_size
),
Span::new(
crate::token::Position::new(1, 1),
crate::token::Position::new(1, 1),
),
)],
}],
};
}
let segments = split_at_new_song(input);
let results: Vec<ParseResult> = segments
.into_iter()
.map(|segment| {
let tokens = Lexer::new(segment).tokenize();
Parser::new(tokens).parse_lenient_limited(options.max_errors)
})
.collect();
MultiParseResult { results }
}
const IMAGE_SRC_MAX_BYTES: usize = 4096;
const IMAGE_ATTR_MAX_BYTES: usize = 1024;
fn truncate_string(s: String, max_bytes: usize) -> String {
if s.len() <= max_bytes {
return s;
}
let mut end = max_bytes;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
s[..end].to_string()
}
#[must_use]
pub fn parse_image_attributes(input: &str) -> ImageAttributes {
let mut attrs = ImageAttributes::default();
let pairs = split_key_value_pairs(input);
for (key, value) in pairs {
match key.to_ascii_lowercase().as_str() {
"src" => attrs.src = truncate_string(value, IMAGE_SRC_MAX_BYTES),
"width" => attrs.width = Some(truncate_string(value, IMAGE_ATTR_MAX_BYTES)),
"height" => attrs.height = Some(truncate_string(value, IMAGE_ATTR_MAX_BYTES)),
"scale" => attrs.scale = Some(truncate_string(value, IMAGE_ATTR_MAX_BYTES)),
"title" => attrs.title = Some(truncate_string(value, IMAGE_ATTR_MAX_BYTES)),
"anchor" => attrs.anchor = Some(truncate_string(value, IMAGE_ATTR_MAX_BYTES)),
_ => {
}
}
}
attrs
}
fn split_key_value_pairs(input: &str) -> Vec<(String, String)> {
let mut pairs = Vec::new();
let bytes = input.as_bytes();
let len = bytes.len();
let mut i = 0;
while i < len {
while i < len && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i >= len {
break;
}
let key_start = i;
while i < len && bytes[i] != b'=' && !bytes[i].is_ascii_whitespace() {
i += 1;
}
let key = &input[key_start..i];
if i >= len || bytes[i] != b'=' {
while i < len && !bytes[i].is_ascii_whitespace() {
i += 1;
}
continue;
}
i += 1;
let value = if i < len && bytes[i] == b'"' {
i += 1; let val_start = i;
while i < len && bytes[i] != b'"' {
i += 1;
}
let val = &input[val_start..i];
if i < len {
i += 1; }
val
} else {
let val_start = i;
while i < len && !bytes[i].is_ascii_whitespace() {
i += 1;
}
&input[val_start..i]
};
if !key.is_empty() {
pairs.push((key.to_string(), value.to_string()));
}
}
pairs
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ast::{
Chord, CommentStyle, Directive, DirectiveKind, Line, LyricsLine, LyricsSegment,
};
fn lines(input: &str) -> Vec<Line> {
parse(input).expect("parse failed").lines
}
#[test]
fn input_within_limit_succeeds() {
let opts = ParseOptions {
max_input_size: 100,
..Default::default()
};
let result = parse_with_options("{title: Test}", &opts);
assert!(result.is_ok());
}
#[test]
fn input_exceeding_limit_fails() {
let opts = ParseOptions {
max_input_size: 10,
..Default::default()
};
let result = parse_with_options("{title: This is too long}", &opts);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.message.contains("exceeds maximum"));
}
#[test]
fn zero_limit_disables_check() {
let opts = ParseOptions {
max_input_size: 0,
..Default::default()
};
let result = parse_with_options("{title: Any size is fine}", &opts);
assert!(result.is_ok());
}
#[test]
fn default_limit_is_10mb() {
let opts = ParseOptions::default();
assert_eq!(opts.max_input_size, 10 * 1024 * 1024);
assert_eq!(opts.max_errors, 1000);
}
#[test]
fn empty_input() {
let song = parse("").unwrap();
assert!(song.lines.is_empty());
}
#[test]
fn single_empty_line() {
let result = lines("\n");
assert_eq!(result, vec![Line::Empty]);
}
#[test]
fn multiple_empty_lines() {
let result = lines("\n\n\n");
assert_eq!(result, vec![Line::Empty, Line::Empty, Line::Empty]);
}
#[test]
fn plain_text_line() {
let result = lines("Hello world");
assert_eq!(
result,
vec![Line::Lyrics(LyricsLine {
segments: vec![LyricsSegment::text_only("Hello world")],
})]
);
}
#[test]
fn multiple_plain_text_lines() {
let result = lines("Line one\nLine two");
assert_eq!(
result,
vec![
Line::Lyrics(LyricsLine {
segments: vec![LyricsSegment::text_only("Line one")],
}),
Line::Lyrics(LyricsLine {
segments: vec![LyricsSegment::text_only("Line two")],
}),
]
);
}
#[test]
fn single_chord_with_text() {
let result = lines("[Am]Hello");
assert_eq!(
result,
vec![Line::Lyrics(LyricsLine {
segments: vec![LyricsSegment::new(Some(Chord::new("Am")), "Hello")],
})]
);
}
#[test]
fn multiple_chords_with_text() {
let result = lines("[Am]Hello [G]world");
assert_eq!(
result,
vec![Line::Lyrics(LyricsLine {
segments: vec![
LyricsSegment::new(Some(Chord::new("Am")), "Hello "),
LyricsSegment::new(Some(Chord::new("G")), "world"),
],
})]
);
}
#[test]
fn chord_only_no_text() {
let result = lines("[Am]");
assert_eq!(
result,
vec![Line::Lyrics(LyricsLine {
segments: vec![LyricsSegment::chord_only(Chord::new("Am"))],
})]
);
}
#[test]
fn consecutive_chords_no_text_between() {
let result = lines("[Am][G]");
assert_eq!(
result,
vec![Line::Lyrics(LyricsLine {
segments: vec![
LyricsSegment::chord_only(Chord::new("Am")),
LyricsSegment::chord_only(Chord::new("G")),
],
})]
);
}
#[test]
fn text_before_first_chord() {
let result = lines("Hello [Am]world");
assert_eq!(
result,
vec![Line::Lyrics(LyricsLine {
segments: vec![
LyricsSegment::text_only("Hello "),
LyricsSegment::new(Some(Chord::new("Am")), "world"),
],
})]
);
}
#[test]
fn chord_at_end_of_line() {
let result = lines("Hello [Am]");
assert_eq!(
result,
vec![Line::Lyrics(LyricsLine {
segments: vec![
LyricsSegment::text_only("Hello "),
LyricsSegment::chord_only(Chord::new("Am")),
],
})]
);
}
#[test]
fn empty_chord_name() {
let result = lines("[]text");
assert_eq!(
result,
vec![Line::Lyrics(LyricsLine {
segments: vec![LyricsSegment::new(Some(Chord::new("")), "text")],
})]
);
}
#[test]
fn directive_with_value() {
let result = lines("{title: My Song}");
assert_eq!(
result,
vec![Line::Directive(Directive::with_value("title", "My Song"))],
);
}
#[test]
fn directive_without_value() {
let result = lines("{start_of_chorus}");
assert_eq!(
result,
vec![Line::Directive(Directive::name_only("start_of_chorus"))],
);
}
#[test]
fn directive_value_trimmed() {
let result = lines("{title: Hello World }");
assert_eq!(
result,
vec![Line::Directive(Directive::with_value(
"title",
"Hello World"
))],
);
}
#[test]
fn directive_name_trimmed() {
let result = lines("{ title : value}");
assert_eq!(
result,
vec![Line::Directive(Directive::with_value("title", "value"))],
);
}
#[test]
fn directive_with_colon_in_value() {
let result = lines("{comment: time 10:30}");
assert_eq!(
result,
vec![Line::Comment(
CommentStyle::Normal,
"time 10:30".to_string()
)]
);
}
#[test]
fn directive_followed_by_lyrics() {
let result = lines("{title: Test}\n[Am]Hello");
assert_eq!(
result,
vec![
Line::Directive(Directive::with_value("title", "Test")),
Line::Lyrics(LyricsLine {
segments: vec![LyricsSegment::new(Some(Chord::new("Am")), "Hello")],
}),
]
);
}
#[test]
fn comment_directive_full_name() {
let result = lines("{comment: This is a comment}");
assert_eq!(
result,
vec![Line::Comment(
CommentStyle::Normal,
"This is a comment".to_string()
)],
);
}
#[test]
fn comment_directive_short_name() {
let result = lines("{c: Short comment}");
assert_eq!(
result,
vec![Line::Comment(
CommentStyle::Normal,
"Short comment".to_string()
)],
);
}
#[test]
fn comment_directive_no_value() {
let result = lines("{comment}");
assert_eq!(
result,
vec![Line::Comment(CommentStyle::Normal, String::new())]
);
}
#[test]
fn comment_italic_directive() {
let result = lines("{comment_italic: Softly}");
assert_eq!(
result,
vec![Line::Comment(CommentStyle::Italic, "Softly".to_string())],
);
}
#[test]
fn comment_italic_short_name() {
let result = lines("{ci: Softly}");
assert_eq!(
result,
vec![Line::Comment(CommentStyle::Italic, "Softly".to_string())],
);
}
#[test]
fn comment_box_directive() {
let result = lines("{comment_box: Important}");
assert_eq!(
result,
vec![Line::Comment(CommentStyle::Boxed, "Important".to_string())],
);
}
#[test]
fn comment_box_short_name() {
let result = lines("{cb: Important}");
assert_eq!(
result,
vec![Line::Comment(CommentStyle::Boxed, "Important".to_string())],
);
}
#[test]
fn hash_comment_basic() {
let result = lines("# This is a comment");
assert_eq!(
result,
vec![Line::Comment(
CommentStyle::Normal,
"This is a comment".to_string()
)],
);
}
#[test]
fn hash_comment_no_space_after_hash() {
let result = lines("#no space");
assert_eq!(
result,
vec![Line::Comment(CommentStyle::Normal, "no space".to_string())],
);
}
#[test]
fn hash_comment_standalone_hash() {
let result = lines("#");
assert_eq!(
result,
vec![Line::Comment(CommentStyle::Normal, "".to_string())],
);
}
#[test]
fn hash_comment_mixed_with_directives() {
let result = lines("# First\n{title: My Song}\n# Second");
assert_eq!(
result,
vec![
Line::Comment(CommentStyle::Normal, "First".to_string()),
Line::Directive(Directive::with_value("title", "My Song")),
Line::Comment(CommentStyle::Normal, "Second".to_string()),
],
);
}
#[test]
fn hash_comment_indented_is_lyrics_not_comment() {
let result = lines(" # indented");
assert!(
matches!(result[..], [Line::Lyrics(_)]),
"expected Lyrics, got {result:?}"
);
}
#[cfg(not(debug_assertions))]
#[test]
fn parse_hash_comment_line_is_resilient_to_missing_hash_prefix() {
let tokens = Lexer::new("no hash prefix\n").tokenize();
let mut parser = Parser::new(tokens);
let line = parser
.parse_hash_comment_line()
.expect("parse_hash_comment_line returned Err unexpectedly");
assert_eq!(
line,
Line::Comment(CommentStyle::Normal, "no hash prefix".to_string()),
);
}
#[cfg(debug_assertions)]
#[test]
#[should_panic(expected = "parse_hash_comment_line called without '#' prefix")]
fn parse_hash_comment_line_debug_asserts_missing_hash_prefix() {
let tokens = Lexer::new("no hash prefix\n").tokenize();
let mut parser = Parser::new(tokens);
let _ = parser.parse_hash_comment_line();
}
#[test]
fn directive_short_alias_title() {
let result = lines("{t: My Song}");
let expected = Directive::with_value("title", "My Song");
assert_eq!(result, vec![Line::Directive(expected)]);
}
#[test]
fn directive_short_alias_subtitle() {
let result = lines("{st: Alternate}");
let expected = Directive::with_value("subtitle", "Alternate");
assert_eq!(result, vec![Line::Directive(expected)]);
}
#[test]
fn directive_short_alias_soc() {
let result = lines("{soc}");
let expected = Directive::name_only("start_of_chorus");
assert_eq!(result, vec![Line::Directive(expected)]);
}
#[test]
fn directive_short_alias_eoc() {
let result = lines("{eoc}");
let expected = Directive::name_only("end_of_chorus");
assert_eq!(result, vec![Line::Directive(expected)]);
}
#[test]
fn directive_case_insensitive() {
let result = lines("{TITLE: Upper}");
let expected = Directive::with_value("title", "Upper");
assert_eq!(result, vec![Line::Directive(expected)]);
}
#[test]
fn directive_mixed_case() {
let result = lines("{Start_Of_Chorus}");
let expected = Directive::name_only("start_of_chorus");
assert_eq!(result, vec![Line::Directive(expected)]);
}
#[test]
fn directive_unknown_preserved() {
let result = lines("{my_custom: value}");
assert_eq!(
result,
vec![Line::Directive(Directive {
name: "my_custom".to_string(),
value: Some("value".to_string()),
kind: DirectiveKind::Unknown("my_custom".to_string()),
selector: None,
})],
);
}
#[test]
fn directive_kind_on_parsed_directive() {
let song = parse("{title: Test}").unwrap();
if let Line::Directive(ref d) = song.lines[0] {
assert_eq!(d.kind, DirectiveKind::Title);
assert_eq!(d.name, "title");
} else {
panic!("expected directive");
}
}
#[test]
fn environment_directives_long_form() {
let cases = vec![
(
"{start_of_chorus}",
"start_of_chorus",
DirectiveKind::StartOfChorus,
),
(
"{end_of_chorus}",
"end_of_chorus",
DirectiveKind::EndOfChorus,
),
(
"{start_of_verse}",
"start_of_verse",
DirectiveKind::StartOfVerse,
),
("{end_of_verse}", "end_of_verse", DirectiveKind::EndOfVerse),
(
"{start_of_bridge}",
"start_of_bridge",
DirectiveKind::StartOfBridge,
),
(
"{end_of_bridge}",
"end_of_bridge",
DirectiveKind::EndOfBridge,
),
("{start_of_tab}", "start_of_tab", DirectiveKind::StartOfTab),
("{end_of_tab}", "end_of_tab", DirectiveKind::EndOfTab),
];
for (input, expected_name, expected_kind) in cases {
let result = lines(input);
if let Line::Directive(ref d) = result[0] {
assert_eq!(d.name, expected_name, "failed for input: {input}");
assert_eq!(d.kind, expected_kind, "failed for input: {input}");
} else {
panic!("expected directive for input: {input}");
}
}
}
#[test]
fn environment_directives_short_form() {
let cases = vec![
("{soc}", "start_of_chorus", DirectiveKind::StartOfChorus),
("{eoc}", "end_of_chorus", DirectiveKind::EndOfChorus),
("{sov}", "start_of_verse", DirectiveKind::StartOfVerse),
("{eov}", "end_of_verse", DirectiveKind::EndOfVerse),
("{sob}", "start_of_bridge", DirectiveKind::StartOfBridge),
("{eob}", "end_of_bridge", DirectiveKind::EndOfBridge),
("{sot}", "start_of_tab", DirectiveKind::StartOfTab),
("{eot}", "end_of_tab", DirectiveKind::EndOfTab),
];
for (input, expected_name, expected_kind) in cases {
let result = lines(input);
if let Line::Directive(ref d) = result[0] {
assert_eq!(d.name, expected_name, "failed for input: {input}");
assert_eq!(d.kind, expected_kind, "failed for input: {input}");
} else {
panic!("expected directive for input: {input}");
}
}
}
#[test]
fn metadata_title_populated() {
let song = parse("{title: Amazing Grace}").unwrap();
assert_eq!(song.metadata.title.as_deref(), Some("Amazing Grace"));
}
#[test]
fn metadata_title_via_short_alias() {
let song = parse("{t: Amazing Grace}").unwrap();
assert_eq!(song.metadata.title.as_deref(), Some("Amazing Grace"));
}
#[test]
fn metadata_subtitle_populated() {
let song = parse("{subtitle: How sweet}\n{st: The sound}").unwrap();
assert_eq!(song.metadata.subtitles, vec!["How sweet", "The sound"]);
}
#[test]
fn metadata_artist_populated() {
let song = parse("{artist: John Newton}").unwrap();
assert_eq!(song.metadata.artists, vec!["John Newton"]);
}
#[test]
fn metadata_multiple_artists() {
let song = parse("{artist: John}\n{artist: Jane}").unwrap();
assert_eq!(song.metadata.artists, vec!["John", "Jane"]);
}
#[test]
fn metadata_composer_populated() {
let song = parse("{composer: Bach}").unwrap();
assert_eq!(song.metadata.composers, vec!["Bach"]);
}
#[test]
fn metadata_lyricist_populated() {
let song = parse("{lyricist: Someone}").unwrap();
assert_eq!(song.metadata.lyricists, vec!["Someone"]);
}
#[test]
fn metadata_album_populated() {
let song = parse("{album: Greatest Hits}").unwrap();
assert_eq!(song.metadata.album.as_deref(), Some("Greatest Hits"));
}
#[test]
fn metadata_year_populated() {
let song = parse("{year: 1779}").unwrap();
assert_eq!(song.metadata.year.as_deref(), Some("1779"));
}
#[test]
fn metadata_key_populated() {
let song = parse("{key: G}").unwrap();
assert_eq!(song.metadata.key.as_deref(), Some("G"));
}
#[test]
fn metadata_tempo_populated() {
let song = parse("{tempo: 120}").unwrap();
assert_eq!(song.metadata.tempo.as_deref(), Some("120"));
}
#[test]
fn metadata_time_populated() {
let song = parse("{time: 3/4}").unwrap();
assert_eq!(song.metadata.time.as_deref(), Some("3/4"));
}
#[test]
fn metadata_capo_populated() {
let song = parse("{capo: 2}").unwrap();
assert_eq!(song.metadata.capo.as_deref(), Some("2"));
}
#[test]
fn metadata_keys_accumulate_multi_value() {
let song = parse("{key: G}\n[G]hi\n{key: D}\n[D]ho").unwrap();
assert_eq!(song.metadata.keys, vec!["G".to_string(), "D".to_string()]);
assert_eq!(song.metadata.key.as_deref(), Some("D"));
}
#[test]
fn metadata_tempos_accumulate_multi_value() {
let song = parse("{tempo: 120}\n[G]a\n{tempo: 140}\n[D]b").unwrap();
assert_eq!(
song.metadata.tempos,
vec!["120".to_string(), "140".to_string()]
);
assert_eq!(song.metadata.tempo.as_deref(), Some("140"));
}
#[test]
fn metadata_times_accumulate_multi_value() {
let song = parse("{time: 4/4}\n[G]a\n{time: 6/8}\n[D]b").unwrap();
assert_eq!(
song.metadata.times,
vec!["4/4".to_string(), "6/8".to_string()]
);
assert_eq!(song.metadata.time.as_deref(), Some("6/8"));
}
#[test]
fn metadata_keys_accumulate_via_meta_long_form() {
let song = parse("{meta: key G}\n{meta: key D}").unwrap();
assert_eq!(song.metadata.keys, vec!["G".to_string(), "D".to_string()]);
}
#[test]
fn metadata_case_insensitive() {
let song = parse("{TITLE: Upper Case}").unwrap();
assert_eq!(song.metadata.title.as_deref(), Some("Upper Case"));
}
#[test]
fn metadata_not_populated_without_value() {
let song = parse("{title}").unwrap();
assert_eq!(song.metadata.title, None);
}
#[test]
fn metadata_all_fields_populated() {
let input = "\
{title: My Song}
{subtitle: A Sub}
{artist: An Artist}
{composer: A Composer}
{lyricist: A Lyricist}
{album: An Album}
{year: 2024}
{key: Am}
{tempo: 100}
{time: 4/4}
{capo: 3}";
let song = parse(input).unwrap();
assert_eq!(song.metadata.title.as_deref(), Some("My Song"));
assert_eq!(song.metadata.subtitles, vec!["A Sub"]);
assert_eq!(song.metadata.artists, vec!["An Artist"]);
assert_eq!(song.metadata.composers, vec!["A Composer"]);
assert_eq!(song.metadata.lyricists, vec!["A Lyricist"]);
assert_eq!(song.metadata.album.as_deref(), Some("An Album"));
assert_eq!(song.metadata.year.as_deref(), Some("2024"));
assert_eq!(song.metadata.key.as_deref(), Some("Am"));
assert_eq!(song.metadata.tempo.as_deref(), Some("100"));
assert_eq!(song.metadata.time.as_deref(), Some("4/4"));
assert_eq!(song.metadata.capo.as_deref(), Some("3"));
}
#[test]
fn metadata_custom_populated_for_unknown_directive() {
let song = parse("{x_my_custom: some value}").unwrap();
assert_eq!(
song.metadata.custom,
vec![("x_my_custom".to_string(), "some value".to_string())]
);
}
#[test]
fn metadata_custom_multiple_unknown_directives() {
let song = parse("{x_one: first}\n{x_two: second}").unwrap();
assert_eq!(
song.metadata.custom,
vec![
("x_one".to_string(), "first".to_string()),
("x_two".to_string(), "second".to_string()),
]
);
}
#[test]
fn metadata_custom_not_populated_without_value() {
let song = parse("{x_no_value}").unwrap();
assert!(song.metadata.custom.is_empty());
}
#[test]
fn metadata_custom_coexists_with_standard_metadata() {
let input = "{title: My Song}\n{x_custom: custom value}";
let song = parse(input).unwrap();
assert_eq!(song.metadata.title.as_deref(), Some("My Song"));
assert_eq!(
song.metadata.custom,
vec![("x_custom".to_string(), "custom value".to_string())]
);
}
#[test]
fn unclosed_directive() {
let err = parse("{title: oops").unwrap_err();
assert!(
err.message.contains("unclosed directive"),
"error message was: {}",
err.message
);
}
#[test]
fn unclosed_chord() {
let err = parse("[Am").unwrap_err();
assert!(
err.message.contains("unclosed chord"),
"error message was: {}",
err.message
);
}
#[test]
fn empty_directive_name() {
let err = parse("{}").unwrap_err();
assert!(
err.message.contains("empty directive name"),
"error message was: {}",
err.message
);
}
#[test]
fn empty_directive_with_colon() {
let err = parse("{: value}").unwrap_err();
assert!(
err.message.contains("empty directive name"),
"error message was: {}",
err.message
);
}
#[test]
fn unclosed_chord_at_newline() {
let err = parse("[Am\ntext").unwrap_err();
assert!(
err.message.contains("unclosed chord"),
"error message was: {}",
err.message
);
}
#[test]
fn parse_error_display() {
let err = parse("{title: no close").unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("parse error at line"));
assert!(msg.contains("unclosed directive"));
}
#[test]
fn full_song() {
let input = "\
{title: Amazing Grace}
{artist: John Newton}
[G]Amazing [G7]grace, how [C]sweet the [G]sound
[G]That saved a [Em]wretch like [D]me";
let song = parse(input).unwrap();
assert_eq!(song.lines.len(), 5);
assert_eq!(song.metadata.title.as_deref(), Some("Amazing Grace"));
assert_eq!(song.metadata.artists, vec!["John Newton"]);
assert_eq!(
song.lines[0],
Line::Directive(Directive::with_value("title", "Amazing Grace")),
);
assert_eq!(
song.lines[1],
Line::Directive(Directive::with_value("artist", "John Newton")),
);
assert_eq!(song.lines[2], Line::Empty);
if let Line::Lyrics(ref lyrics) = song.lines[3] {
assert_eq!(lyrics.text(), "Amazing grace, how sweet the sound");
assert!(lyrics.has_chords());
assert_eq!(lyrics.segments.len(), 4);
assert_eq!(lyrics.segments[0].chord.as_ref().unwrap().name, "G");
assert_eq!(lyrics.segments[0].text, "Amazing ");
assert_eq!(lyrics.segments[1].chord.as_ref().unwrap().name, "G7");
assert_eq!(lyrics.segments[1].text, "grace, how ");
assert_eq!(lyrics.segments[2].chord.as_ref().unwrap().name, "C");
assert_eq!(lyrics.segments[2].text, "sweet the ");
assert_eq!(lyrics.segments[3].chord.as_ref().unwrap().name, "G");
assert_eq!(lyrics.segments[3].text, "sound");
} else {
panic!("expected Line::Lyrics for line 4");
}
if let Line::Lyrics(ref lyrics) = song.lines[4] {
assert_eq!(lyrics.text(), "That saved a wretch like me");
assert_eq!(lyrics.segments.len(), 3);
} else {
panic!("expected Line::Lyrics for line 5");
}
}
#[test]
fn song_with_sections() {
let input = "\
{start_of_chorus}
[C]La la [G]la
{end_of_chorus}";
let song = parse(input).unwrap();
assert_eq!(song.lines.len(), 3);
assert!(matches!(song.lines[0], Line::Directive(_)));
assert!(matches!(song.lines[1], Line::Lyrics(_)));
assert!(matches!(song.lines[2], Line::Directive(_)));
}
#[test]
fn song_with_comments_and_empty_lines() {
let input = "\
{title: Test}
{comment: Intro}
[Am]Hello
";
let song = parse(input).unwrap();
assert_eq!(song.lines.len(), 4);
assert_eq!(
song.lines[0],
Line::Directive(Directive::with_value("title", "Test"))
);
assert_eq!(
song.lines[1],
Line::Comment(CommentStyle::Normal, "Intro".to_string())
);
assert_eq!(song.lines[2], Line::Empty);
assert!(matches!(song.lines[3], Line::Lyrics(_)));
}
#[test]
fn crlf_line_endings() {
let input = "{title: Test}\r\n[Am]Hello\r\n";
let song = parse(input).unwrap();
assert_eq!(song.lines.len(), 2);
assert_eq!(
song.lines[0],
Line::Directive(Directive::with_value("title", "Test")),
);
assert!(matches!(song.lines[1], Line::Lyrics(_)));
}
#[test]
fn stray_close_brace_in_lyrics() {
let result = lines("hello } world");
assert_eq!(
result,
vec![Line::Lyrics(LyricsLine {
segments: vec![LyricsSegment::text_only("hello } world")],
})]
);
}
#[test]
fn stray_close_bracket_in_lyrics() {
let result = lines("hello ] world");
assert_eq!(
result,
vec![Line::Lyrics(LyricsLine {
segments: vec![LyricsSegment::text_only("hello ] world")],
})]
);
}
#[test]
fn unicode_in_chords_and_lyrics() {
let result = lines("[Am]こんにちは [G]世界");
assert_eq!(
result,
vec![Line::Lyrics(LyricsLine {
segments: vec![
LyricsSegment::new(Some(Chord::new("Am")), "こんにちは "),
LyricsSegment::new(Some(Chord::new("G")), "世界"),
],
})]
);
}
#[test]
fn multiple_colons_in_directive_value() {
let result = lines("{meta: key:value:extra}");
assert_eq!(
result,
vec![Line::Directive(Directive {
name: "meta".to_string(),
value: None,
kind: DirectiveKind::Meta("key:value:extra".to_string()),
selector: None,
})],
);
let result = lines("{custom_dir: key:value:extra}");
assert_eq!(
result,
vec![Line::Directive(Directive {
name: "custom_dir".to_string(),
value: Some("key:value:extra".to_string()),
kind: DirectiveKind::Unknown("custom_dir".to_string()),
selector: None,
})],
);
}
#[test]
fn directive_only_whitespace_name() {
let err = parse("{ }").unwrap_err();
assert!(
err.message.contains("empty directive name"),
"error message was: {}",
err.message
);
}
#[test]
fn directive_with_brackets_in_value() {
let result = lines("{comment: play [Am] here}");
assert_eq!(
result,
vec![Line::Comment(
CommentStyle::Normal,
"play [Am] here".to_string()
)],
);
}
#[test]
fn chord_line_with_spaces() {
let result = lines("[Am] [G] [C]");
assert_eq!(
result,
vec![Line::Lyrics(LyricsLine {
segments: vec![
LyricsSegment::new(Some(Chord::new("Am")), " "),
LyricsSegment::new(Some(Chord::new("G")), " "),
LyricsSegment::chord_only(Chord::new("C")),
],
})]
);
}
#[test]
fn trailing_newline_produces_empty_line() {
let result = lines("text\n");
assert_eq!(
result,
vec![Line::Lyrics(LyricsLine {
segments: vec![LyricsSegment::text_only("text")],
})]
);
}
#[test]
fn parser_struct_directly() {
let tokens = Lexer::new("[C]Hello").tokenize();
let song = Parser::new(tokens).parse().unwrap();
assert_eq!(song.lines.len(), 1);
}
#[test]
fn full_song_with_all_directive_types() {
let input = "\
{t: Amazing Grace}
{st: A Hymn}
{artist: John Newton}
{key: G}
{tempo: 80}
{time: 3/4}
{capo: 2}
{comment: Verse 1}
{ci: Play softly}
{cb: Key change ahead}
{soc}
[G]Amazing [G7]grace
{eoc}";
let song = parse(input).unwrap();
assert_eq!(song.metadata.title.as_deref(), Some("Amazing Grace"));
assert_eq!(song.metadata.subtitles, vec!["A Hymn"]);
assert_eq!(song.metadata.artists, vec!["John Newton"]);
assert_eq!(song.metadata.key.as_deref(), Some("G"));
assert_eq!(song.metadata.tempo.as_deref(), Some("80"));
assert_eq!(song.metadata.time.as_deref(), Some("3/4"));
assert_eq!(song.metadata.capo.as_deref(), Some("2"));
assert_eq!(song.lines.len(), 13);
assert!(matches!(song.lines[0], Line::Directive(_))); assert!(matches!(song.lines[1], Line::Directive(_))); assert!(matches!(song.lines[2], Line::Directive(_))); assert!(matches!(song.lines[3], Line::Directive(_))); assert!(matches!(song.lines[4], Line::Directive(_))); assert!(matches!(song.lines[5], Line::Directive(_))); assert!(matches!(song.lines[6], Line::Directive(_))); assert_eq!(
song.lines[7],
Line::Comment(CommentStyle::Normal, "Verse 1".to_string())
);
assert_eq!(
song.lines[8],
Line::Comment(CommentStyle::Italic, "Play softly".to_string())
);
assert_eq!(
song.lines[9],
Line::Comment(CommentStyle::Boxed, "Key change ahead".to_string())
);
if let Line::Directive(ref d) = song.lines[10] {
assert_eq!(d.kind, DirectiveKind::StartOfChorus);
assert_eq!(d.name, "start_of_chorus");
} else {
panic!("expected directive");
}
assert!(matches!(song.lines[11], Line::Lyrics(_))); if let Line::Directive(ref d) = song.lines[12] {
assert_eq!(d.kind, DirectiveKind::EndOfChorus);
assert_eq!(d.name, "end_of_chorus");
} else {
panic!("expected directive");
}
}
#[test]
fn parse_error_implements_std_error() {
let err = parse("[Am").unwrap_err();
let _: &dyn std::error::Error = &err;
}
#[test]
fn parse_error_source_is_none() {
let err = parse("[Am").unwrap_err();
let err_ref: &dyn std::error::Error = &err;
assert!(err_ref.source().is_none());
}
#[test]
fn parse_error_line_column_accessors() {
let err = parse("[Am").unwrap_err();
assert_eq!(err.line(), 1);
assert_eq!(err.column(), 1);
}
#[test]
fn unclosed_chord_error_location() {
let err = parse("[Am").unwrap_err();
assert!(err.message.contains("unclosed chord"));
assert_eq!(err.span.start.line, 1);
assert_eq!(err.span.start.column, 1);
}
#[test]
fn unclosed_chord_on_second_line() {
let err = parse("Hello\n[Am").unwrap_err();
assert!(err.message.contains("unclosed chord"));
assert_eq!(err.span.start.line, 2);
assert_eq!(err.span.start.column, 1);
}
#[test]
fn unclosed_chord_mid_line() {
let err = parse("text [Am").unwrap_err();
assert!(err.message.contains("unclosed chord"));
assert_eq!(err.span.start.line, 1);
assert_eq!(err.span.start.column, 6);
}
#[test]
fn unclosed_directive_error_location() {
let err = parse("{title: oops").unwrap_err();
assert!(err.message.contains("unclosed directive"));
assert_eq!(err.span.start.line, 1);
assert_eq!(err.span.start.column, 13);
}
#[test]
fn unclosed_directive_on_third_line() {
let err = parse("line one\nline two\n{title: oops").unwrap_err();
assert!(err.message.contains("unclosed directive"));
assert_eq!(err.span.start.line, 3);
assert_eq!(err.span.start.column, 13);
}
#[test]
fn empty_directive_error_location() {
let err = parse("{}").unwrap_err();
assert!(err.message.contains("empty directive name"));
assert_eq!(err.span.start.line, 1);
assert_eq!(err.span.start.column, 1);
}
#[test]
fn empty_directive_with_colon_error_location() {
let err = parse("{: value}").unwrap_err();
assert!(err.message.contains("empty directive name"));
assert_eq!(err.span.start.line, 1);
assert_eq!(err.span.start.column, 1);
}
#[test]
fn error_display_format_with_line_column() {
let err = parse("first line\n{title: no close").unwrap_err();
let msg = format!("{err}");
assert!(
msg.starts_with("parse error at line 2, column 17:"),
"unexpected display format: {msg}"
);
}
#[test]
fn unclosed_chord_at_end_of_line_error_location() {
let err = parse("[Am\nmore text").unwrap_err();
assert!(err.message.contains("unclosed chord"));
assert_eq!(err.span.start.line, 1);
assert_eq!(err.span.start.column, 1);
}
#[test]
fn unclosed_directive_at_eof_error_location() {
let err = parse("{title").unwrap_err();
assert!(err.message.contains("unclosed directive"));
assert_eq!(err.span.start.line, 1);
assert_eq!(err.span.start.column, 1);
}
#[test]
fn whitespace_only_directive_name_error_location() {
let err = parse("{ : value}").unwrap_err();
assert!(err.message.contains("empty directive name"));
assert_eq!(err.span.start.line, 1);
assert_eq!(err.span.start.column, 1);
}
#[test]
fn error_after_valid_content() {
let input = "{title: Test}\n[Am]Hello\n[G";
let err = parse(input).unwrap_err();
assert!(err.message.contains("unclosed chord"));
assert_eq!(err.span.start.line, 3);
assert_eq!(err.span.start.column, 1);
}
#[test]
fn multiple_errors_first_is_reported() {
let err = parse("{title\n{another").unwrap_err();
assert!(err.message.contains("unclosed directive"));
assert_eq!(err.span.start.line, 1);
}
#[test]
fn tab_content_is_verbatim() {
let song = parse("{start_of_tab}\ne|---[0]---|\n{end_of_tab}").unwrap();
if let Line::Lyrics(ref l) = song.lines[1] {
assert_eq!(l.segments.len(), 1);
assert!(l.segments[0].chord.is_none());
assert_eq!(l.segments[0].text, "e|---[0]---|");
} else {
panic!("expected lyrics line for tab content");
}
}
#[test]
fn tab_content_preserves_braces() {
let song = parse("{sot}\n{some text}\n{eot}").unwrap();
if let Line::Lyrics(ref l) = song.lines[1] {
assert_eq!(l.segments[0].text, "{some text}");
} else {
panic!("expected lyrics line for tab content");
}
}
#[test]
fn chords_parsed_after_tab_ends() {
let song = parse("{sot}\ne|---|\n{eot}\n[Am]Hello").unwrap();
if let Line::Lyrics(ref l) = song.lines[3] {
assert!(l.segments[0].chord.is_some());
assert_eq!(l.segments[0].chord.as_ref().unwrap().name, "Am");
} else {
panic!("expected lyrics line with chord after tab section");
}
}
#[test]
fn grid_content_is_verbatim() {
let song = parse("{start_of_grid}\n| [Am] . | [C] . |\n{end_of_grid}").unwrap();
if let Line::Lyrics(ref l) = song.lines[1] {
assert_eq!(l.segments.len(), 1);
assert!(l.segments[0].chord.is_none());
assert_eq!(l.segments[0].text, "| [Am] . | [C] . |");
} else {
panic!("expected lyrics line for grid content");
}
}
#[test]
fn grid_content_preserves_braces() {
let song = parse("{sog}\n{some text}\n{eog}").unwrap();
if let Line::Lyrics(ref l) = song.lines[1] {
assert_eq!(l.segments[0].text, "{some text}");
} else {
panic!("expected lyrics line for grid content");
}
}
#[test]
fn chords_parsed_after_grid_ends() {
let song = parse("{sog}\n| Am . |\n{eog}\n[Am]Hello").unwrap();
if let Line::Lyrics(ref l) = song.lines[3] {
assert!(l.segments[0].chord.is_some());
assert_eq!(l.segments[0].chord.as_ref().unwrap().name, "Am");
} else {
panic!("expected lyrics line with chord after grid section");
}
}
#[test]
fn grid_inline_attribute_form_extracts_shape() {
let song = parse(r#"{start_of_grid shape="1+4x2+4"}\n| Am . |\n{end_of_grid}"#)
.unwrap_or_else(|e| panic!("parse failed: {e:?}"));
if let Line::Directive(ref d) = song.lines[0] {
assert_eq!(d.kind, DirectiveKind::StartOfGrid);
assert_eq!(d.name, "start_of_grid");
assert_eq!(d.value.as_deref(), Some(r#"shape="1+4x2+4""#));
} else {
panic!("expected start_of_grid directive");
}
}
#[test]
fn grid_inline_label_and_shape_attributes() {
let song =
parse(r#"{start_of_grid shape="4x4" label="Intro"}\n| G . |\n{end_of_grid}"#).unwrap();
if let Line::Directive(ref d) = song.lines[0] {
assert_eq!(d.kind, DirectiveKind::StartOfGrid);
assert!(d.value.as_deref().unwrap().contains(r#"label="Intro""#));
} else {
panic!("expected start_of_grid directive");
}
}
#[test]
fn custom_section_with_space_preserves_whole_name() {
let song = parse("{start_of_foo bar}\ntext\n{end_of_foo bar}").unwrap();
if let Line::Directive(ref d) = song.lines[0] {
assert_eq!(d.kind, DirectiveKind::StartOfSection("foo bar".to_string()));
assert_eq!(d.value, None);
} else {
panic!("expected StartOfSection");
}
}
#[test]
fn explicit_colon_value_wins_over_inline_attrs() {
let song = parse(r#"{start_of_grid shape="4x4": Verse}\n| G |\n{end_of_grid}"#).unwrap();
if let Line::Directive(ref d) = song.lines[0] {
assert_eq!(d.kind, DirectiveKind::StartOfGrid);
assert_eq!(d.value.as_deref(), Some("Verse"));
} else {
panic!("expected start_of_grid directive");
}
}
#[test]
fn grid_short_aliases_sog_eog() {
let song = parse("{sog}\n| Am |\n{eog}").unwrap();
if let Line::Directive(ref d) = song.lines[0] {
assert_eq!(d.kind, DirectiveKind::StartOfGrid);
assert_eq!(d.name, "start_of_grid");
} else {
panic!("expected start_of_grid directive");
}
if let Line::Directive(ref d) = song.lines[2] {
assert_eq!(d.kind, DirectiveKind::EndOfGrid);
assert_eq!(d.name, "end_of_grid");
} else {
panic!("expected end_of_grid directive");
}
}
#[test]
fn grid_with_label() {
let song = parse("{start_of_grid: Intro}\n| Am . | C . |\n{end_of_grid}").unwrap();
if let Line::Directive(ref d) = song.lines[0] {
assert_eq!(d.kind, DirectiveKind::StartOfGrid);
assert_eq!(d.value.as_deref(), Some("Intro"));
} else {
panic!("expected start_of_grid directive with label");
}
}
#[test]
fn define_directive_parsed() {
let song = parse("{define: Asus4 base-fret 1 frets x 0 2 2 3 0}").unwrap();
if let Line::Directive(ref d) = song.lines[0] {
assert_eq!(d.kind, DirectiveKind::Define);
assert_eq!(d.name, "define");
assert_eq!(
d.value.as_deref(),
Some("Asus4 base-fret 1 frets x 0 2 2 3 0")
);
} else {
panic!("expected define directive");
}
}
#[test]
fn chord_directive_parsed() {
let song = parse("{chord: Asus4}").unwrap();
if let Line::Directive(ref d) = song.lines[0] {
assert_eq!(d.kind, DirectiveKind::ChordDirective);
assert_eq!(d.value.as_deref(), Some("Asus4"));
} else {
panic!("expected chord directive");
}
}
#[test]
fn page_control_directives_long_form() {
let song = parse("{new_page}\n{new_physical_page}\n{column_break}\n{columns: 2}").unwrap();
if let Line::Directive(ref d) = song.lines[0] {
assert_eq!(d.kind, DirectiveKind::NewPage);
assert_eq!(d.name, "new_page");
assert!(d.value.is_none());
} else {
panic!("expected new_page directive");
}
if let Line::Directive(ref d) = song.lines[1] {
assert_eq!(d.kind, DirectiveKind::NewPhysicalPage);
assert_eq!(d.name, "new_physical_page");
assert!(d.value.is_none());
} else {
panic!("expected new_physical_page directive");
}
if let Line::Directive(ref d) = song.lines[2] {
assert_eq!(d.kind, DirectiveKind::ColumnBreak);
assert_eq!(d.name, "column_break");
assert!(d.value.is_none());
} else {
panic!("expected column_break directive");
}
if let Line::Directive(ref d) = song.lines[3] {
assert_eq!(d.kind, DirectiveKind::Columns);
assert_eq!(d.name, "columns");
assert_eq!(d.value.as_deref(), Some("2"));
} else {
panic!("expected columns directive");
}
}
#[test]
fn page_control_directives_short_form() {
let song = parse("{np}\n{npp}\n{colb}\n{col: 3}").unwrap();
if let Line::Directive(ref d) = song.lines[0] {
assert_eq!(d.kind, DirectiveKind::NewPage);
assert_eq!(d.name, "new_page");
} else {
panic!("expected new_page directive");
}
if let Line::Directive(ref d) = song.lines[1] {
assert_eq!(d.kind, DirectiveKind::NewPhysicalPage);
assert_eq!(d.name, "new_physical_page");
} else {
panic!("expected new_physical_page directive");
}
if let Line::Directive(ref d) = song.lines[2] {
assert_eq!(d.kind, DirectiveKind::ColumnBreak);
assert_eq!(d.name, "column_break");
} else {
panic!("expected column_break directive");
}
if let Line::Directive(ref d) = song.lines[3] {
assert_eq!(d.kind, DirectiveKind::Columns);
assert_eq!(d.name, "columns");
assert_eq!(d.value.as_deref(), Some("3"));
} else {
panic!("expected columns directive");
}
}
#[test]
fn page_control_not_metadata() {
let song = parse("{new_page}\n{columns: 2}").unwrap();
assert!(song.metadata.title.is_none());
assert!(song.metadata.custom.is_empty());
}
#[test]
fn parse_lenient_no_errors() {
let result = parse_lenient("{title: Test}\n[Am]Hello");
assert!(result.is_ok());
assert!(!result.has_errors());
assert_eq!(result.song.metadata.title.as_deref(), Some("Test"));
assert_eq!(result.song.lines.len(), 2);
}
#[test]
fn parse_lenient_collects_multiple_errors() {
let result = parse_lenient("{title\nHello world\n[Am");
assert!(result.has_errors());
assert_eq!(result.errors.len(), 2);
assert!(result.song.lines.iter().any(|l| {
if let Line::Lyrics(ll) = l {
ll.text() == "Hello world"
} else {
false
}
}));
}
#[test]
fn parse_lenient_partial_ast_with_metadata() {
let result = parse_lenient("{title: My Song}\n{bad\n[G]La la");
assert_eq!(result.errors.len(), 1);
assert_eq!(result.song.metadata.title.as_deref(), Some("My Song"));
assert!(result.song.lines.len() >= 2);
}
#[test]
fn parse_lenient_all_lines_bad() {
let result = parse_lenient("{unclosed\n[bad");
assert_eq!(result.errors.len(), 2);
assert!(result.song.lines.is_empty());
}
#[test]
fn parse_lenient_error_locations() {
let result = parse_lenient("{ok: fine}\n{bad\n[Am]Good\n{also bad");
assert_eq!(result.errors.len(), 2);
assert_eq!(result.errors[0].line(), 2);
assert_eq!(result.errors[1].line(), 4);
}
#[test]
fn parse_lenient_empty_input() {
let result = parse_lenient("");
assert!(result.is_ok());
assert!(result.song.lines.is_empty());
}
#[test]
fn parse_lenient_size_limit() {
let opts = ParseOptions {
max_input_size: 10,
..Default::default()
};
let result = parse_lenient_with_options("this input is too long", &opts);
assert!(result.has_errors());
assert_eq!(result.errors.len(), 1);
assert!(result.errors[0].message.contains("exceeds maximum"));
}
#[test]
fn parse_lenient_max_errors_limits_collection() {
let input: String = (0..100).map(|_| "{unclosed\n").collect();
let opts = ParseOptions {
max_errors: 5,
..Default::default()
};
let result = parse_lenient_with_options(&input, &opts);
assert!(result.has_errors());
assert_eq!(result.errors.len(), 5);
}
#[test]
fn parse_lenient_zero_max_errors_disables_limit() {
let input: String = (0..20).map(|_| "{unclosed\n").collect();
let opts = ParseOptions {
max_errors: 0,
..Default::default()
};
let result = parse_lenient_with_options(&input, &opts);
assert_eq!(result.errors.len(), 20);
}
#[test]
fn transpose_directive_parsed() {
let song = parse("{transpose: 2}").expect("parse failed");
assert_eq!(song.lines.len(), 1);
if let Line::Directive(ref d) = song.lines[0] {
assert_eq!(d.kind, DirectiveKind::Transpose);
assert_eq!(d.name, "transpose");
assert_eq!(d.value.as_deref(), Some("2"));
} else {
panic!("expected transpose directive");
}
}
#[test]
fn transpose_directive_negative_value() {
let song = parse("{transpose: -3}").expect("parse failed");
if let Line::Directive(ref d) = song.lines[0] {
assert_eq!(d.kind, DirectiveKind::Transpose);
assert_eq!(d.value.as_deref(), Some("-3"));
} else {
panic!("expected transpose directive");
}
}
#[test]
fn transpose_directive_no_value() {
let song = parse("{transpose}").expect("parse failed");
if let Line::Directive(ref d) = song.lines[0] {
assert_eq!(d.kind, DirectiveKind::Transpose);
assert!(d.value.is_none());
} else {
panic!("expected transpose directive");
}
}
#[test]
fn transpose_directive_is_not_metadata() {
let kind = DirectiveKind::Transpose;
assert!(!kind.is_metadata());
}
#[test]
fn transpose_directive_case_insensitive() {
let song = parse("{Transpose: 5}").expect("parse failed");
if let Line::Directive(ref d) = song.lines[0] {
assert_eq!(d.kind, DirectiveKind::Transpose);
assert_eq!(d.name, "transpose");
assert_eq!(d.value.as_deref(), Some("5"));
} else {
panic!("expected transpose directive");
}
}
#[test]
fn custom_section_start_parsed() {
let result = lines("{start_of_intro}");
if let Line::Directive(ref d) = result[0] {
assert_eq!(d.name, "start_of_intro");
assert_eq!(d.kind, DirectiveKind::StartOfSection("intro".to_string()));
assert!(d.is_section_start());
} else {
panic!("expected directive");
}
}
#[test]
fn custom_section_end_parsed() {
let result = lines("{end_of_intro}");
if let Line::Directive(ref d) = result[0] {
assert_eq!(d.name, "end_of_intro");
assert_eq!(d.kind, DirectiveKind::EndOfSection("intro".to_string()));
assert!(d.is_section_end());
} else {
panic!("expected directive");
}
}
#[test]
fn custom_section_with_label() {
let result = lines("{start_of_intro: Guitar Intro}");
if let Line::Directive(ref d) = result[0] {
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()));
} else {
panic!("expected directive");
}
}
#[test]
fn custom_section_lyrics_parsed_normally() {
let song = parse("{start_of_intro}\n[Am]Hello [G]world\n{end_of_intro}").unwrap();
assert_eq!(song.lines.len(), 3);
if let Line::Lyrics(ref l) = song.lines[1] {
assert!(l.has_chords());
assert_eq!(l.segments.len(), 2);
assert_eq!(l.segments[0].chord.as_ref().unwrap().name, "Am");
} else {
panic!("expected lyrics line inside custom section");
}
}
#[test]
fn custom_section_various_names() {
for name in &["outro", "solo", "interlude", "coda", "pre_chorus"] {
let input = format!("{{start_of_{name}}}");
let result = lines(&input);
if let Line::Directive(ref d) = result[0] {
assert_eq!(d.name, format!("start_of_{name}"));
assert!(d.is_section_start(), "should be section start for {name}");
} else {
panic!("expected directive for {name}");
}
}
}
#[test]
fn lyrics_with_bold_markup() {
use crate::inline_markup::TextSpan;
let result = lines("[Am]Hello <b>world</b>");
match &result[0] {
Line::Lyrics(lyrics) => {
assert_eq!(lyrics.segments.len(), 1);
let seg = &lyrics.segments[0];
assert_eq!(seg.text, "Hello world");
assert_eq!(
seg.spans,
vec![
TextSpan::Plain("Hello ".to_string()),
TextSpan::Bold(vec![TextSpan::Plain("world".to_string())]),
]
);
}
_ => panic!("expected lyrics line"),
}
}
#[test]
fn custom_section_case_insensitive() {
let result = lines("{Start_Of_Intro}");
if let Line::Directive(ref d) = result[0] {
assert_eq!(d.name, "start_of_intro");
assert_eq!(d.kind, DirectiveKind::StartOfSection("intro".to_string()));
} else {
panic!("expected directive");
}
}
#[test]
fn image_directive_basic() {
let song = parse("{image: src=photo.jpg}").unwrap();
if let Line::Directive(ref d) = song.lines[0] {
assert_eq!(d.name, "image");
if let DirectiveKind::Image(ref attrs) = d.kind {
assert_eq!(attrs.src, "photo.jpg");
assert!(attrs.width.is_none());
assert!(attrs.height.is_none());
assert!(attrs.scale.is_none());
assert!(attrs.title.is_none());
assert!(attrs.anchor.is_none());
} else {
panic!("expected Image directive kind");
}
} else {
panic!("expected directive");
}
}
#[test]
fn lyrics_without_markup_has_empty_spans() {
let result = lines("[Am]Hello world");
match &result[0] {
Line::Lyrics(lyrics) => {
assert_eq!(lyrics.segments[0].text, "Hello world");
assert!(lyrics.segments[0].spans.is_empty());
}
_ => panic!("expected lyrics line"),
}
}
#[test]
fn image_directive_all_attributes() {
let song =
parse(r#"{image: src=logo.png width=200 height=100 scale=0.5 title="Album Cover" anchor=top}"#)
.unwrap();
if let Line::Directive(ref d) = song.lines[0] {
if let DirectiveKind::Image(ref attrs) = d.kind {
assert_eq!(attrs.src, "logo.png");
assert_eq!(attrs.width.as_deref(), Some("200"));
assert_eq!(attrs.height.as_deref(), Some("100"));
assert_eq!(attrs.scale.as_deref(), Some("0.5"));
assert_eq!(attrs.title.as_deref(), Some("Album Cover"));
assert_eq!(attrs.anchor.as_deref(), Some("top"));
} else {
panic!("expected Image directive kind");
}
} else {
panic!("expected directive");
}
}
#[test]
fn lyrics_with_nested_markup() {
use crate::inline_markup::TextSpan;
let result = lines("<b><i>both</i></b>");
match &result[0] {
Line::Lyrics(lyrics) => {
assert_eq!(lyrics.segments[0].text, "both");
assert_eq!(
lyrics.segments[0].spans,
vec![TextSpan::Bold(vec![TextSpan::Italic(vec![
TextSpan::Plain("both".to_string())
])])]
);
}
_ => panic!("expected lyrics line"),
}
}
#[test]
fn image_directive_quoted_value_with_spaces() {
let song = parse(r#"{image: src=cover.jpg title="My Great Album"}"#).unwrap();
if let Line::Directive(ref d) = song.lines[0] {
if let DirectiveKind::Image(ref attrs) = d.kind {
assert_eq!(attrs.src, "cover.jpg");
assert_eq!(attrs.title.as_deref(), Some("My Great Album"));
} else {
panic!("expected Image directive kind");
}
} else {
panic!("expected directive");
}
}
#[test]
fn lyrics_markup_text_field_has_stripped_content() {
let result = lines("<b>bold</b> and <i>italic</i> text");
match &result[0] {
Line::Lyrics(lyrics) => {
assert_eq!(lyrics.segments[0].text, "bold and italic text");
assert!(!lyrics.segments[0].spans.is_empty());
}
_ => panic!("expected lyrics line"),
}
}
#[test]
fn image_directive_no_value() {
let song = parse("{image}").unwrap();
if let Line::Directive(ref d) = song.lines[0] {
assert_eq!(d.name, "image");
if let DirectiveKind::Image(ref attrs) = d.kind {
assert_eq!(attrs.src, "");
} else {
panic!("expected Image directive kind");
}
} else {
panic!("expected directive");
}
}
#[test]
fn image_directive_unknown_attributes_ignored() {
let song = parse("{image: src=pic.jpg unknown=foo bar}").unwrap();
if let Line::Directive(ref d) = song.lines[0] {
if let DirectiveKind::Image(ref attrs) = d.kind {
assert_eq!(attrs.src, "pic.jpg");
} else {
panic!("expected Image directive kind");
}
} else {
panic!("expected directive");
}
}
#[test]
fn image_directive_case_insensitive() {
let song = parse("{IMAGE: src=photo.jpg}").unwrap();
if let Line::Directive(ref d) = song.lines[0] {
assert_eq!(d.name, "image");
assert!(d.kind.is_image());
} else {
panic!("expected directive");
}
}
#[test]
fn image_directive_width_only() {
let song = parse("{image: src=img.png width=50%}").unwrap();
if let Line::Directive(ref d) = song.lines[0] {
if let DirectiveKind::Image(ref attrs) = d.kind {
assert_eq!(attrs.src, "img.png");
assert_eq!(attrs.width.as_deref(), Some("50%"));
assert!(attrs.height.is_none());
} else {
panic!("expected Image directive kind");
}
} else {
panic!("expected directive");
}
}
#[test]
fn image_directive_preserves_raw_value() {
let song = parse("{image: src=photo.jpg width=200}").unwrap();
if let Line::Directive(ref d) = song.lines[0] {
assert_eq!(d.value.as_deref(), Some("src=photo.jpg width=200"));
} else {
panic!("expected directive");
}
}
#[test]
fn parse_image_attributes_empty_input() {
let attrs = super::parse_image_attributes("");
assert_eq!(attrs.src, "");
assert!(attrs.width.is_none());
}
#[test]
fn parse_image_attributes_src_only() {
let attrs = super::parse_image_attributes("src=test.png");
assert_eq!(attrs.src, "test.png");
}
#[test]
fn parse_image_attributes_multiple() {
let attrs = super::parse_image_attributes("src=a.jpg width=100 height=200");
assert_eq!(attrs.src, "a.jpg");
assert_eq!(attrs.width.as_deref(), Some("100"));
assert_eq!(attrs.height.as_deref(), Some("200"));
}
#[test]
fn parse_image_attributes_quoted_value() {
let attrs = super::parse_image_attributes(r#"src=a.jpg title="Hello World""#);
assert_eq!(attrs.src, "a.jpg");
assert_eq!(attrs.title.as_deref(), Some("Hello World"));
}
#[test]
fn parse_image_attributes_extra_whitespace() {
let attrs = super::parse_image_attributes(" src=a.jpg width=100 ");
assert_eq!(attrs.src, "a.jpg");
assert_eq!(attrs.width.as_deref(), Some("100"));
}
#[test]
fn parse_image_attributes_case_insensitive_keys() {
let attrs = super::parse_image_attributes("SRC=photo.jpg WIDTH=200 Height=100");
assert_eq!(attrs.src, "photo.jpg");
assert_eq!(attrs.width.as_deref(), Some("200"));
assert_eq!(attrs.height.as_deref(), Some("100"));
}
#[test]
fn parse_image_attributes_mixed_case_keys() {
let attrs = super::parse_image_attributes("Src=a.jpg Scale=0.5 Title=test Anchor=column");
assert_eq!(attrs.src, "a.jpg");
assert_eq!(attrs.scale.as_deref(), Some("0.5"));
assert_eq!(attrs.title.as_deref(), Some("test"));
assert_eq!(attrs.anchor.as_deref(), Some("column"));
}
#[test]
fn parse_image_attributes_src_truncated_at_limit() {
let long_src = "a".repeat(5000);
let input = format!("src={long_src}");
let attrs = super::parse_image_attributes(&input);
assert_eq!(attrs.src.len(), super::IMAGE_SRC_MAX_BYTES);
}
#[test]
fn parse_image_attributes_other_attrs_truncated_at_limit() {
let long_title = "x".repeat(2000);
let input = format!("src=ok.jpg title=\"{long_title}\" width={long_title}");
let attrs = super::parse_image_attributes(&input);
assert_eq!(attrs.src, "ok.jpg");
assert_eq!(
attrs.title.as_deref().map(str::len),
Some(super::IMAGE_ATTR_MAX_BYTES)
);
assert_eq!(
attrs.width.as_deref().map(str::len),
Some(super::IMAGE_ATTR_MAX_BYTES)
);
}
#[test]
fn parse_image_attributes_truncation_respects_utf8_boundary() {
let cjk = "漢".repeat(342); let input = format!("title=\"{cjk}\"");
let attrs = super::parse_image_attributes(&input);
let title = attrs.title.unwrap();
assert!(title.len() <= super::IMAGE_ATTR_MAX_BYTES);
assert_eq!(title.len(), 1023); }
#[test]
fn parse_image_attributes_values_within_limit_unchanged() {
let title = "a".repeat(1024);
let input = format!("src=ok.jpg title=\"{title}\"");
let attrs = super::parse_image_attributes(&input);
assert_eq!(attrs.title.as_deref(), Some(title.as_str()));
}
#[test]
fn truncate_string_empty() {
assert_eq!(super::truncate_string(String::new(), 100), "");
}
#[test]
fn split_key_value_pairs_basic() {
let pairs = super::split_key_value_pairs("key=value");
assert_eq!(pairs, vec![("key".to_string(), "value".to_string())]);
}
#[test]
fn split_key_value_pairs_quoted() {
let pairs = super::split_key_value_pairs(r#"key="hello world""#);
assert_eq!(pairs, vec![("key".to_string(), "hello world".to_string())]);
}
#[test]
fn split_key_value_pairs_mixed() {
let pairs = super::split_key_value_pairs(r#"a=1 b="two three" c=4"#);
assert_eq!(pairs.len(), 3);
assert_eq!(pairs[0], ("a".to_string(), "1".to_string()));
assert_eq!(pairs[1], ("b".to_string(), "two three".to_string()));
assert_eq!(pairs[2], ("c".to_string(), "4".to_string()));
}
#[test]
fn split_key_value_pairs_no_equals() {
let pairs = super::split_key_value_pairs("bare_token");
assert!(pairs.is_empty());
}
#[test]
fn split_key_value_pairs_empty() {
let pairs = super::split_key_value_pairs("");
assert!(pairs.is_empty());
}
#[test]
fn split_key_value_pairs_unterminated_quote() {
let pairs = super::split_key_value_pairs(r#"key="hello world"#);
assert_eq!(pairs, vec![("key".to_string(), "hello world".to_string())]);
}
#[test]
fn parse_image_attributes_unterminated_quoted_title() {
let attrs = super::parse_image_attributes(r#"src=photo.jpg title="My Album"#);
assert_eq!(attrs.src, "photo.jpg");
assert_eq!(attrs.title.as_deref(), Some("My Album"));
}
#[test]
fn parse_image_attributes_unterminated_quote_with_trailing_attrs() {
let attrs = super::parse_image_attributes(r#"src=photo.jpg title="My Album width=100"#);
assert_eq!(attrs.src, "photo.jpg");
assert_eq!(attrs.title.as_deref(), Some("My Album width=100"));
assert!(attrs.width.is_none());
}
}
#[cfg(test)]
mod delegate_tests {
use super::*;
use crate::ast::{DirectiveKind, Line};
fn lines(input: &str) -> Vec<Line> {
parse(input).expect("parse failed").lines
}
#[test]
fn abc_content_is_verbatim() {
let song = parse("{start_of_abc}\nX:1\nK:G\n{end_of_abc}").unwrap();
assert_eq!(song.lines.len(), 4);
if let Line::Lyrics(ref l) = song.lines[1] {
assert_eq!(l.segments.len(), 1);
assert!(l.segments[0].chord.is_none());
assert_eq!(l.segments[0].text, "X:1");
} else {
panic!("expected lyrics line for ABC content");
}
}
#[test]
fn abc_preserves_brackets() {
let song = parse("{start_of_abc}\n|:GABc|[1d2d2:|[2d4d4:|\n{end_of_abc}").unwrap();
if let Line::Lyrics(ref l) = song.lines[1] {
assert_eq!(l.segments[0].text, "|:GABc|[1d2d2:|[2d4d4:|");
} else {
panic!("expected verbatim lyrics line");
}
}
#[test]
fn ly_content_is_verbatim() {
let song = parse("{start_of_ly}\n\\relative c' { c4 d e f }\n{end_of_ly}").unwrap();
assert_eq!(song.lines.len(), 3);
if let Line::Lyrics(ref l) = song.lines[1] {
assert!(l.segments[0].chord.is_none());
} else {
panic!("expected lyrics line for Lilypond content");
}
}
#[test]
fn svg_content_is_verbatim() {
let song = parse("{start_of_svg}\n<svg><rect/></svg>\n{end_of_svg}").unwrap();
assert_eq!(song.lines.len(), 3);
if let Line::Lyrics(ref l) = song.lines[1] {
assert!(l.segments[0].chord.is_none());
} else {
panic!("expected lyrics line for SVG content");
}
}
#[test]
fn textblock_content_is_verbatim() {
let song = parse("{start_of_textblock}\n[Am]Not a chord\n{end_of_textblock}").unwrap();
assert_eq!(song.lines.len(), 3);
if let Line::Lyrics(ref l) = song.lines[1] {
assert_eq!(l.segments.len(), 1);
assert!(l.segments[0].chord.is_none());
assert_eq!(l.segments[0].text, "[Am]Not a chord");
} else {
panic!("expected lyrics line for textblock content");
}
}
#[test]
fn textblock_preserves_braces() {
let song = parse("{start_of_textblock}\n{some directive}\n{end_of_textblock}").unwrap();
if let Line::Lyrics(ref l) = song.lines[1] {
assert_eq!(l.segments[0].text, "{some directive}");
} else {
panic!("expected verbatim lyrics line");
}
}
#[test]
fn chords_parsed_after_abc_ends() {
let song = parse("{start_of_abc}\nX:1\n{end_of_abc}\n[Am]Hello").unwrap();
if let Line::Lyrics(ref l) = song.lines[3] {
assert!(l.segments[0].chord.is_some());
assert_eq!(l.segments[0].chord.as_ref().unwrap().name, "Am");
} else {
panic!("expected lyrics line with chord after ABC section");
}
}
#[test]
fn chords_parsed_after_ly_ends() {
let song = parse("{start_of_ly}\nnotes\n{end_of_ly}\n[G]Hello").unwrap();
if let Line::Lyrics(ref l) = song.lines[3] {
assert!(l.segments[0].chord.is_some());
assert_eq!(l.segments[0].chord.as_ref().unwrap().name, "G");
} else {
panic!("expected lyrics line with chord after Lilypond section");
}
}
#[test]
fn chords_parsed_after_svg_ends() {
let song = parse("{start_of_svg}\n<svg/>\n{end_of_svg}\n[C]Hello").unwrap();
if let Line::Lyrics(ref l) = song.lines[3] {
assert!(l.segments[0].chord.is_some());
assert_eq!(l.segments[0].chord.as_ref().unwrap().name, "C");
} else {
panic!("expected lyrics line with chord after SVG section");
}
}
#[test]
fn chords_parsed_after_textblock_ends() {
let song = parse("{start_of_textblock}\ntext\n{end_of_textblock}\n[D]Hello").unwrap();
if let Line::Lyrics(ref l) = song.lines[3] {
assert!(l.segments[0].chord.is_some());
assert_eq!(l.segments[0].chord.as_ref().unwrap().name, "D");
} else {
panic!("expected lyrics line with chord after textblock section");
}
}
#[test]
fn abc_directive_with_label() {
let result = lines("{start_of_abc: My Melody}");
if let Line::Directive(ref d) = result[0] {
assert_eq!(d.kind, DirectiveKind::StartOfAbc);
assert_eq!(d.value.as_deref(), Some("My Melody"));
} else {
panic!("expected directive");
}
}
#[test]
fn selector_suffix_on_metadata_directive() {
let result = lines("{title-piano: My Song}");
if let Line::Directive(ref d) = result[0] {
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"));
} else {
panic!("expected directive");
}
}
#[test]
fn textblock_directive_with_label() {
let result = lines("{start_of_textblock: Notes}");
if let Line::Directive(ref d) = result[0] {
assert_eq!(d.kind, DirectiveKind::StartOfTextblock);
assert_eq!(d.value.as_deref(), Some("Notes"));
} else {
panic!("expected directive");
}
}
#[test]
fn selector_suffix_on_key_directive() {
let result = lines("{key-bass: E}");
if let Line::Directive(ref d) = result[0] {
assert_eq!(d.name, "key");
assert_eq!(d.value.as_deref(), Some("E"));
assert_eq!(d.kind, DirectiveKind::Key);
assert_eq!(d.selector.as_deref(), Some("bass"));
} else {
panic!("expected directive");
}
}
#[test]
fn delegate_sections_not_custom() {
assert_eq!(
DirectiveKind::from_name("start_of_abc"),
DirectiveKind::StartOfAbc
);
assert_eq!(
DirectiveKind::from_name("start_of_ly"),
DirectiveKind::StartOfLy
);
assert_eq!(
DirectiveKind::from_name("start_of_svg"),
DirectiveKind::StartOfSvg
);
assert_eq!(
DirectiveKind::from_name("start_of_textblock"),
DirectiveKind::StartOfTextblock
);
}
#[test]
fn lyrics_markup_preserves_backward_compat() {
let result = lines("[Am]Hello <b>bold</b> [G]world");
match &result[0] {
Line::Lyrics(lyrics) => {
assert_eq!(lyrics.text(), "Hello bold world");
}
_ => panic!("expected lyrics line"),
}
}
#[test]
fn new_song_directive_kind() {
assert_eq!(DirectiveKind::from_name("new_song"), DirectiveKind::NewSong);
assert_eq!(DirectiveKind::from_name("ns"), DirectiveKind::NewSong);
assert_eq!(DirectiveKind::from_name("NEW_SONG"), DirectiveKind::NewSong);
assert_eq!(DirectiveKind::from_name("Ns"), DirectiveKind::NewSong);
}
#[test]
fn new_song_canonical_name() {
assert_eq!(DirectiveKind::NewSong.canonical_name(), "new_song");
}
#[test]
fn new_song_parsed_as_directive() {
let result = lines("{new_song}");
assert_eq!(result.len(), 1);
if let Line::Directive(ref d) = result[0] {
assert_eq!(d.name, "new_song");
assert_eq!(d.kind, DirectiveKind::NewSong);
assert!(d.value.is_none());
} else {
panic!("expected directive");
}
}
#[test]
fn selector_suffix_on_comment_directive() {
let result = lines("{comment-piano: Play softly}");
if let Line::Directive(ref d) = result[0] {
assert_eq!(d.kind, DirectiveKind::Comment);
assert_eq!(d.value.as_deref(), Some("Play softly"));
assert_eq!(d.selector.as_deref(), Some("piano"));
} else {
panic!(
"expected directive for comment with selector, got {:?}",
result[0]
);
}
}
#[test]
fn ns_alias_parsed_as_directive() {
let result = lines("{ns}");
assert_eq!(result.len(), 1);
if let Line::Directive(ref d) = result[0] {
assert_eq!(d.name, "new_song");
assert_eq!(d.kind, DirectiveKind::NewSong);
} else {
panic!("expected directive");
}
}
#[test]
fn parse_multi_single_song() {
let input = "{title: Only Song}\n[G]Hello";
let songs = parse_multi(input).unwrap();
assert_eq!(songs.len(), 1);
assert_eq!(songs[0].metadata.title.as_deref(), Some("Only Song"));
}
#[test]
fn parse_multi_two_songs() {
let input = "{title: Song One}\nLyrics one\n{new_song}\n{title: Song Two}\nLyrics two";
let songs = parse_multi(input).unwrap();
assert_eq!(songs.len(), 2);
assert_eq!(songs[0].metadata.title.as_deref(), Some("Song One"));
assert_eq!(songs[1].metadata.title.as_deref(), Some("Song Two"));
}
#[test]
fn parse_multi_ns_alias() {
let input = "{title: First}\n{ns}\n{title: Second}";
let songs = parse_multi(input).unwrap();
assert_eq!(songs.len(), 2);
assert_eq!(songs[0].metadata.title.as_deref(), Some("First"));
assert_eq!(songs[1].metadata.title.as_deref(), Some("Second"));
}
#[test]
fn parse_multi_three_songs() {
let input = "{title: A}\n{new_song}\n{title: B}\n{new_song}\n{title: C}";
let songs = parse_multi(input).unwrap();
assert_eq!(songs.len(), 3);
assert_eq!(songs[0].metadata.title.as_deref(), Some("A"));
assert_eq!(songs[1].metadata.title.as_deref(), Some("B"));
assert_eq!(songs[2].metadata.title.as_deref(), Some("C"));
}
#[test]
fn parse_multi_empty_first_song() {
let input = "{new_song}\n{title: Second}";
let songs = parse_multi(input).unwrap();
assert_eq!(songs.len(), 2);
assert!(songs[0].metadata.title.is_none());
assert_eq!(songs[1].metadata.title.as_deref(), Some("Second"));
}
#[test]
fn parse_multi_case_insensitive() {
let input = "{title: A}\n{NEW_SONG}\n{title: B}";
let songs = parse_multi(input).unwrap();
assert_eq!(songs.len(), 2);
}
#[test]
fn parse_multi_with_whitespace() {
let input = "{title: A}\n{ new_song }\n{title: B}";
let songs = parse_multi(input).unwrap();
assert_eq!(songs.len(), 2);
}
#[test]
fn parse_multi_crlf_line_endings() {
let input = "{title: A}\r\n[G]Hello\r\n{new_song}\r\n{title: B}\r\n[Am]World\r\n";
let songs = parse_multi(input).unwrap();
assert_eq!(songs.len(), 2);
assert_eq!(songs[0].metadata.title, Some("A".to_string()));
assert_eq!(songs[1].metadata.title, Some("B".to_string()));
}
#[test]
fn parse_multi_lenient_collects_errors() {
let input = "{title: Good}\n[Am\n{new_song}\n{title: Also Good}\n[G]Hello";
let result = parse_multi_lenient(input);
assert_eq!(result.results.len(), 2);
assert!(result.results[0].has_errors()); assert!(result.results[1].is_ok());
assert_eq!(
result.results[1].song.metadata.title.as_deref(),
Some("Also Good")
);
}
#[test]
fn comment_without_selector_still_becomes_line_comment() {
let result = lines("{comment: Normal comment}");
assert!(
matches!(result[0], Line::Comment(CommentStyle::Normal, _)),
"comment without selector should still be Line::Comment"
);
}
#[test]
fn parse_multi_songs_helper() {
let input = "{title: A}\n{new_song}\n{title: B}";
let result = parse_multi_lenient(input);
let songs = result.songs();
assert_eq!(songs.len(), 2);
assert_eq!(songs[0].metadata.title.as_deref(), Some("A"));
assert_eq!(songs[1].metadata.title.as_deref(), Some("B"));
}
#[test]
fn parse_multi_preserves_song_content() {
let input = "{title: Song One}
{artist: Artist One}
{start_of_chorus}
[G]La la [C]la
{end_of_chorus}
{new_song}
{title: Song Two}
{key: Am}
[Am]Hello [G]world";
let songs = parse_multi(input).unwrap();
assert_eq!(songs.len(), 2);
assert_eq!(songs[0].metadata.title.as_deref(), Some("Song One"));
assert_eq!(songs[0].metadata.artists, vec!["Artist One".to_string()]);
assert_eq!(songs[1].metadata.title.as_deref(), Some("Song Two"));
assert_eq!(songs[1].metadata.key.as_deref(), Some("Am"));
}
#[test]
fn is_new_song_line_detection() {
assert!(is_new_song_line("{new_song}"));
assert!(is_new_song_line("{ns}"));
assert!(is_new_song_line("{NEW_SONG}"));
assert!(is_new_song_line("{NS}"));
assert!(is_new_song_line("{ new_song }"));
assert!(is_new_song_line("{ ns }"));
assert!(is_new_song_line("{new_song: value}"));
assert!(is_new_song_line("{ns: tag}"));
assert!(is_new_song_line("{ new_song : tag }"));
assert!(!is_new_song_line("{title}"));
assert!(!is_new_song_line("new_song"));
assert!(!is_new_song_line(""));
assert!(!is_new_song_line("{new_songs}"));
}
#[test]
fn split_at_new_song_bare_cr() {
let input = "{title: A}\r{new_song}\r{title: B}";
let segments = split_at_new_song(input);
assert_eq!(segments.len(), 2);
assert!(segments[0].contains("title: A"));
assert!(segments[1].contains("title: B"));
}
#[test]
fn single_parse_ignores_new_song() {
let song = parse("{title: Test}\n{new_song}\n[G]Hello").unwrap();
assert_eq!(song.metadata.title.as_deref(), Some("Test"));
let has_new_song = song
.lines
.iter()
.any(|l| matches!(l, Line::Directive(d) if d.kind == DirectiveKind::NewSong));
assert!(has_new_song);
}
#[test]
fn selector_suffix_on_environment_directive() {
let result = lines("{start_of_chorus-piano}");
if let Line::Directive(ref d) = result[0] {
assert_eq!(d.name, "start_of_chorus");
assert_eq!(d.kind, DirectiveKind::StartOfChorus);
assert_eq!(d.selector.as_deref(), Some("piano"));
} else {
panic!("expected directive");
}
}
#[test]
fn selector_suffix_on_end_environment() {
let result = lines("{end_of_verse-guitar}");
if let Line::Directive(ref d) = result[0] {
assert_eq!(d.name, "end_of_verse");
assert_eq!(d.kind, DirectiveKind::EndOfVerse);
assert_eq!(d.selector.as_deref(), Some("guitar"));
} else {
panic!("expected directive");
}
}
#[test]
fn no_selector_on_plain_directive() {
let result = lines("{title: My Song}");
if let Line::Directive(ref d) = result[0] {
assert_eq!(d.selector, None);
} else {
panic!("expected directive");
}
}
#[test]
fn selector_suffix_case_insensitive() {
let result = lines("{Title-PIANO: My Song}");
if let Line::Directive(ref d) = result[0] {
assert_eq!(d.kind, DirectiveKind::Title);
assert_eq!(d.selector.as_deref(), Some("piano"));
} else {
panic!("expected directive");
}
}
#[test]
fn selector_with_short_alias() {
let result = lines("{t-guitar: My Song}");
if let Line::Directive(ref d) = result[0] {
assert_eq!(d.name, "title");
assert_eq!(d.kind, DirectiveKind::Title);
assert_eq!(d.selector.as_deref(), Some("guitar"));
} else {
panic!("expected directive");
}
}
#[test]
fn unknown_directive_with_hyphen_no_selector() {
let result = lines("{my-custom: value}");
if let Line::Directive(ref d) = result[0] {
assert_eq!(d.kind, DirectiveKind::Unknown("my-custom".to_string()));
assert_eq!(d.selector, None);
} else {
panic!("expected directive");
}
}
#[test]
fn custom_section_with_selector() {
let result = lines("{start_of_intro-piano}");
if let Line::Directive(ref d) = result[0] {
assert_eq!(d.kind, DirectiveKind::StartOfSection("intro".to_string()));
assert_eq!(d.selector.as_deref(), Some("piano"));
} else {
panic!("expected directive");
}
}
#[test]
#[should_panic(expected = "token list must contain at least an Eof token")]
fn parser_new_panics_on_empty_tokens() {
let _parser = Parser::new(Vec::new());
}
#[test]
fn parser_try_new_returns_err_on_empty_tokens() {
match Parser::try_new(Vec::new()) {
Ok(_) => panic!("try_new should reject an empty token list"),
Err(err) => {
assert!(
err.message.contains("empty token list"),
"unexpected message: {}",
err.message,
);
assert_eq!(err.line(), 1);
assert_eq!(err.column(), 1);
}
}
}
#[test]
fn parser_try_new_accepts_non_empty_tokens() {
let tokens = Lexer::new("[C]Hello").tokenize();
let song = Parser::try_new(tokens)
.expect("try_new should accept a non-empty token list")
.parse()
.expect("parse failed");
assert_eq!(song.lines.len(), 1);
}
#[test]
fn multi_song_oversized_input_rejected() {
let opts = ParseOptions {
max_input_size: 10,
..Default::default()
};
let input = "{title: A}\n{new_song}\n{title: B}";
let result = parse_multi_with_options(input, &opts);
assert!(result.is_err());
assert!(result.unwrap_err().message.contains("exceeds maximum"));
}
#[test]
fn multi_song_lenient_oversized_input_rejected() {
let opts = ParseOptions {
max_input_size: 10,
..Default::default()
};
let input = "{title: A}\n{new_song}\n{title: B}";
let result = parse_multi_lenient_with_options(input, &opts);
assert_eq!(result.results.len(), 1);
assert!(
result.results[0].errors[0]
.message
.contains("exceeds maximum")
);
}
#[test]
fn multi_song_within_limit_succeeds() {
let opts = ParseOptions {
max_input_size: 1000,
..Default::default()
};
let input = "{title: A}\n{new_song}\n{title: B}";
let result = parse_multi_with_options(input, &opts);
assert!(result.is_ok());
assert_eq!(result.unwrap().len(), 2);
}
#[test]
fn config_override_basic() {
let result = lines("{+config.pdf.margins.top: 100}");
if let Line::Directive(ref d) = result[0] {
assert_eq!(
d.kind,
DirectiveKind::ConfigOverride("pdf.margins.top".to_string())
);
assert_eq!(d.value.as_deref(), Some("100"));
assert_eq!(d.name, "+config.pdf.margins.top");
} else {
panic!("expected directive");
}
}
#[test]
fn config_override_string_value() {
let result = lines("{+config.pdf.theme.foreground: blue}");
if let Line::Directive(ref d) = result[0] {
assert_eq!(
d.kind,
DirectiveKind::ConfigOverride("pdf.theme.foreground".to_string())
);
assert_eq!(d.value.as_deref(), Some("blue"));
} else {
panic!("expected directive");
}
}
#[test]
fn config_override_no_value() {
let result = lines("{+config.settings.lyrics_only}");
if let Line::Directive(ref d) = result[0] {
assert_eq!(
d.kind,
DirectiveKind::ConfigOverride("settings.lyrics_only".to_string())
);
assert_eq!(d.value, None);
} else {
panic!("expected directive");
}
}
#[test]
fn config_override_case_insensitive() {
let result = lines("{+Config.PDF.Margins.Top: 50}");
if let Line::Directive(ref d) = result[0] {
assert_eq!(
d.kind,
DirectiveKind::ConfigOverride("pdf.margins.top".to_string())
);
} else {
panic!("expected directive");
}
}
#[test]
fn bare_plus_config_is_unknown() {
let result = lines("{+config: something}");
if let Line::Directive(ref d) = result[0] {
assert!(matches!(d.kind, DirectiveKind::Unknown(_)));
} else {
panic!("expected directive");
}
}
#[test]
fn config_overrides_extracted_from_song() {
let song = crate::parse(
"{title: Test}\n{+config.pdf.margins.top: 100}\n{+config.settings.transpose: 2}\n",
)
.unwrap();
let overrides = song.config_overrides();
assert_eq!(overrides.len(), 2);
assert_eq!(overrides[0], ("pdf.margins.top", "100"));
assert_eq!(overrides[1], ("settings.transpose", "2"));
}
#[test]
fn config_overrides_empty_when_none() {
let song = crate::parse("{title: Test}\n[G]Hello\n").unwrap();
assert!(song.config_overrides().is_empty());
}
#[test]
fn config_overrides_not_in_multi_song_leak() {
let songs = crate::parse_multi(
"{title: A}\n{+config.settings.transpose: 5}\n{new_song}\n{title: B}\n",
)
.unwrap();
assert_eq!(songs.len(), 2);
assert_eq!(songs[0].config_overrides().len(), 1);
assert!(songs[1].config_overrides().is_empty());
}
}
#[cfg(test)]
mod metadata_cap_tests {
use super::*;
#[test]
fn test_metadata_entries_capped_at_limit() {
let count = Parser::MAX_METADATA_ENTRIES + 100;
let mut input = String::new();
for i in 0..count {
input.push_str(&format!("{{subtitle: sub{i}}}\n"));
}
let song = parse(&input).unwrap();
assert_eq!(
song.metadata.subtitles.len(),
Parser::MAX_METADATA_ENTRIES,
"subtitles should be capped at MAX_METADATA_ENTRIES"
);
}
#[test]
fn test_metadata_cap_applies_per_field() {
let mut input = String::new();
for i in 0..Parser::MAX_METADATA_ENTRIES {
input.push_str(&format!("{{subtitle: s{i}}}\n"));
}
input.push_str("{artist: Alice}\n");
let song = parse(&input).unwrap();
assert_eq!(song.metadata.subtitles.len(), Parser::MAX_METADATA_ENTRIES);
assert_eq!(song.metadata.artists.len(), 1);
}
#[test]
fn test_metadata_cap_via_meta_directive() {
let count = Parser::MAX_METADATA_ENTRIES + 50;
let mut input = String::new();
for i in 0..count {
input.push_str(&format!("{{meta: subtitle s{i}}}\n"));
}
let song = parse(&input).unwrap();
assert_eq!(
song.metadata.subtitles.len(),
Parser::MAX_METADATA_ENTRIES,
"meta directive path should also cap subtitles"
);
}
}