use std::mem;
use std::str;
use comrak::{ComrakExtensionOptions, ComrakOptions, ComrakParseOptions, ComrakRenderOptions};
use comrak::nodes::{AstNode, NodeValue, ListType};
use lazy_static::lazy_static;
use regex::{Regex, Captures};
use crate::book::*;
use crate::util::{BStr, ByteSliceExt};
use crate::music::{self, Notation};
use crate::error::*;
type AstRef<'a> = &'a AstNode<'a>;
type Arena<'a> = comrak::Arena<AstNode<'a>>;
const FALLBACK_TITLE: &str = "[Untitled]";
lazy_static! {
static ref EXTENSION: Regex = Regex::new(r"(^|\s)(!+)(\S+)").unwrap();
}
#[derive(Debug)]
struct Extension {
num_excls: u32,
content: String,
prefix_space: bool,
}
impl<'a> From<Captures<'a>> for Extension {
fn from(caps: Captures<'a>) -> Self {
let prefix_space = caps.get(1).unwrap().as_str().chars().next().is_some();
let num_excls = caps.get(2).unwrap().as_str().len() as _;
let content = caps.get(3).unwrap().as_str().to_owned();
Self {
num_excls,
content,
prefix_space,
}
}
}
impl Extension {
fn try_parse_xpose(&self) -> Option<Transpose> {
if self.content.starts_with(&['+', '-'][..]) {
if let Ok(delta) = self.content.parse::<i32>() {
match self.num_excls {
1 => return Some(Transpose::Transpose(delta)),
2 => return Some(Transpose::AltTranspose(delta)),
_ => {}
}
}
}
if let Ok(notation) = self.content.parse::<Notation>() {
match self.num_excls {
1 => return Some(Transpose::Notation(notation)),
2 => return Some(Transpose::AltNotation(notation)),
_ => {}
}
}
None
}
fn try_parse_chorus_ref(&self) -> Option<ChorusRef> {
if self.num_excls == 1 && self.content.chars().all(|c| c == '>') {
let num = self.content.len() as _;
Some(ChorusRef::new(Some(num), self.prefix_space))
} else {
None
}
}
fn try_parse(&self) -> Option<Inline> {
if let Some(xpose) = self.try_parse_xpose() {
Some(Inline::Transpose(xpose))
} else if let Some(chorus_ref) = self.try_parse_chorus_ref() {
Some(Inline::ChorusRef(chorus_ref))
} else {
None
}
}
}
#[derive(Clone, Default, Debug)]
pub struct Transposition {
src_notation: Notation,
xpose: Option<i32>,
notation: Option<Notation>,
alt_xpose: Option<i32>,
alt_notation: Option<Notation>,
disabled: bool,
}
impl Transposition {
fn new(src_notation: Notation, disabled: bool) -> Self {
Self {
src_notation,
disabled,
..Default::default()
}
}
fn update(&mut self, xpose: Transpose) {
if self.disabled {
return;
}
match xpose {
Transpose::Transpose(d) => self.xpose = Some(d),
Transpose::Notation(nt) => self.notation = Some(nt),
Transpose::AltTranspose(d) => self.alt_xpose = Some(d),
Transpose::AltNotation(nt) => self.alt_notation = Some(nt),
}
}
fn is_some(&self) -> bool {
self.xpose.is_some()
|| self.notation.is_some()
|| self.alt_xpose.is_some()
|| self.alt_notation.is_some()
}
}
trait NodeExt<'a> {
fn is_block(&self) -> bool;
fn is_text(&self) -> bool;
fn is_h(&self, level: u32) -> bool;
fn is_p(&self) -> bool;
fn is_code(&self) -> bool;
fn is_break(&self) -> bool;
fn is_link(&self) -> bool;
fn is_item(&self) -> bool;
fn is_bq(&self) -> bool;
fn is_img(&self) -> bool;
fn ends_chord(&self) -> bool;
fn as_plaintext(&'a self) -> String;
fn split_at(&'a self, at_child: usize, arena: &'a Arena<'a>) -> AstRef<'a>;
fn preprocess(&'a self, arena: &'a Arena<'a>);
}
impl<'a> NodeExt<'a> for AstNode<'a> {
#[inline]
fn is_block(&self) -> bool {
self.data.borrow().value.block()
}
#[inline]
fn is_text(&self) -> bool {
self.data.borrow().value.text().is_some()
}
#[inline]
fn is_h(&self, level: u32) -> bool {
matches!(self.data.borrow().value,
NodeValue::Heading(h) if h.level == level
)
}
#[inline]
fn is_p(&self) -> bool {
matches!(self.data.borrow().value, NodeValue::Paragraph)
}
#[inline]
fn is_code(&self) -> bool {
matches!(self.data.borrow().value, NodeValue::Code(..))
}
#[inline]
fn is_break(&self) -> bool {
matches!(
self.data.borrow().value,
NodeValue::LineBreak | NodeValue::SoftBreak
)
}
#[inline]
fn is_link(&self) -> bool {
matches!(self.data.borrow().value, NodeValue::Link(..))
}
#[inline]
fn is_item(&self) -> bool {
matches!(self.data.borrow().value, NodeValue::Item(..))
}
#[inline]
fn is_bq(&self) -> bool {
matches!(self.data.borrow().value, NodeValue::BlockQuote)
}
#[inline]
fn is_img(&self) -> bool {
matches!(self.data.borrow().value, NodeValue::Image(..))
}
#[inline]
fn ends_chord(&self) -> bool {
self.is_break() || self.is_img()
}
fn as_plaintext(&'a self) -> String {
fn recurse<'a>(this: &'a AstNode<'a>, res: &mut String) {
let value = this.data.borrow();
let text_b = match &value.value {
NodeValue::Text(b) | NodeValue::Code(b) => Some(b),
_ => None,
};
if let Some(bytes) = text_b {
let utf8 = String::from_utf8_lossy(&bytes[..]);
res.push_str(&utf8);
} else {
for c in this.children() {
recurse(c, res);
}
}
}
let mut res = String::new();
recurse(self, &mut res);
res
}
fn split_at(&'a self, at_child: usize, arena: &'a Arena<'a>) -> AstRef<'a> {
let data2 = self.data.clone();
let node2 = arena.alloc(AstNode::new(data2));
self.insert_after(node2);
for child in self.children().skip(at_child) {
node2.append(child);
}
node2
}
fn preprocess(&'a self, arena: &'a Arena<'a>) {
self.children().for_each(|c| c.preprocess(arena));
if self.is_block() {
return;
}
if self.is_link() {
if self.children().count() == 1
&& self.children().next().map_or(false, NodeExt::is_text)
{
} else {
let plain = self.as_plaintext().into_bytes();
for c in self.children() {
c.detach();
}
let textnode = arena.alloc(AstNode::from(NodeValue::Text(plain)));
self.append(textnode);
}
return;
}
let mut start_node = Some(self);
while let Some(node) = start_node.take() {
if let Some((i, child)) = node
.children()
.enumerate()
.find(|(_, c)| c.is_code() || c.is_break() || c.is_img())
{
child.detach();
let node2 = node.split_at(i, arena);
node.insert_after(child);
start_node = Some(node2);
}
}
}
}
#[derive(Debug)]
struct ChordBuilder {
chord: BStr,
alt_chord: Option<BStr>,
inlines: Vec<Inline>,
line: u32,
}
impl ChordBuilder {
fn new(chord: BStr, line: u32) -> Self {
Self {
chord,
alt_chord: None,
inlines: vec![],
line,
}
}
fn inlines_mut<'s>(&'s mut self) -> &'s mut Vec<Inline> {
&mut self.inlines
}
fn transpose(&mut self, xp: &Transposition) -> Result<()> {
if xp.disabled {
return Ok(());
}
let src_nt = xp.src_notation;
let chord = music::Chord::parse(&self.chord, src_nt)
.with_context(|| format!("Unknown chord `{}` on line {}", self.chord, self.line))?;
if xp.xpose.is_some() || xp.notation.is_some() {
let delta = xp.xpose.unwrap_or(0);
let notation = xp.notation.unwrap_or(src_nt);
self.chord = chord.transposed(delta).to_string(notation).into();
}
if xp.alt_xpose.is_some() || xp.alt_notation.is_some() {
let delta = xp.alt_xpose.unwrap_or(0);
let notation = xp.alt_notation.unwrap_or(src_nt);
self.alt_chord = Some(chord.transposed(delta).to_string(notation).into());
}
Ok(())
}
fn finalize(self, inlines: &mut Vec<Inline>) {
let chord = Chord::new(self.chord, self.alt_chord, self.line);
let chord = Inline::Chord(Inlines {
data: chord,
inlines: self.inlines.into(),
});
inlines.push(chord);
}
}
#[derive(Debug)]
struct VerseBuilder<'a> {
label: VerseLabel,
paragraphs: Vec<Paragraph>,
xp: Transposition,
config: &'a ParserConfig,
}
impl<'a> VerseBuilder<'a> {
fn new(label: VerseLabel, xp: Transposition, config: &'a ParserConfig) -> Self {
Self {
label,
paragraphs: vec![],
xp,
config,
}
}
fn with_p_nodes<'n, I>(
label: VerseLabel, xp: Transposition, config: &'a ParserConfig, mut nodes: I,
) -> Result<Self>
where
I: Iterator<Item = AstRef<'a>>,
{
nodes.try_fold(Self::new(label, xp, config), |mut this, node| {
this.add_p_node(node)?;
Ok(this)
})
}
fn parse_text(&mut self, node: AstRef, target: &mut Vec<Inline>) {
let data = node.data.borrow();
let text = match &data.value {
NodeValue::Text(text) => text,
other => unreachable!("Unexpected element: {:?}", other),
};
let text = String::from_utf8_lossy(&*text);
let mut pos = 0;
for caps in EXTENSION.captures_iter(&*text) {
let hit = caps.get(0).unwrap();
let ext = Extension::from(caps);
if let Some(inline) = ext.try_parse() {
let preceding = &text[pos..hit.start()];
if !preceding.is_empty() {
let preceding = Inline::Text {
text: preceding.into(),
};
target.push(preceding);
}
if inline.is_xpose() && !self.xp.disabled {
self.xp.update(inline.unwrap_xpose());
if !ext.prefix_space && hit.end() < text.len() {
pos = hit.end() + 1;
} else {
pos = hit.end();
}
} else {
target.push(inline);
pos = hit.end();
}
}
}
let rest = &text[pos..];
if !rest.is_empty() {
let rest = Inline::Text { text: rest.into() };
target.push(rest);
}
}
fn collect_inlines(&mut self, node: AstRef) -> Result<Box<[Inline]>> {
node.children()
.try_fold(vec![], |mut vec, node| {
self.make_inlines(node, &mut vec)?;
Ok(vec)
})
.map(Into::into)
}
fn make_inlines(&mut self, node: AstRef, target: &mut Vec<Inline>) -> Result<()> {
assert!(!node.is_block());
let single = match &node.data.borrow().value {
NodeValue::Text(..) => {
self.parse_text(node, target);
return Ok(());
}
NodeValue::SoftBreak | NodeValue::LineBreak => Inline::Break,
NodeValue::HtmlInline(..) => return Ok(()),
NodeValue::Emph => Inline::Emph(Inlines::new(self.collect_inlines(node)?)),
NodeValue::Strong => Inline::Strong(Inlines::new(self.collect_inlines(node)?)),
NodeValue::Link(link) => {
let mut children = node.children();
let text = children.next().unwrap();
assert!(children.next().is_none());
assert!(text.is_text());
let text = text.as_plaintext().into();
let link = Link::new(link.url.into_bstr(), link.title.into_bstr(), text);
Inline::Link(link)
}
NodeValue::Image(link) => {
let img = Image::new(
link.url.into_bstr(),
node.as_plaintext().into(),
link.title.into_bstr(),
);
Inline::Image(img)
}
NodeValue::FootnoteReference(..) => return Ok(()),
other => {
unreachable!("Unexpected element: {:?}", other);
}
};
target.push(single);
Ok(())
}
fn add_p_inner(&mut self, node: AstRef) -> Result<()> {
assert!(node.is_p());
let mut para: Vec<Inline> = vec![];
let mut cb = None::<ChordBuilder>;
for c in node.children() {
let c_data = c.data.borrow();
if let NodeValue::Code(code) = &c_data.value {
if let Some(cb) = cb.take() {
cb.finalize(&mut para);
}
let mut new_cb = ChordBuilder::new(code.into_bstr(), c_data.start_line);
if self.xp.is_some() {
new_cb.transpose(&self.xp).with_context(|| {
format!(
"Failed to transpose: Uknown chord `{}` on line {}",
new_cb.chord, new_cb.line
)
})?;
}
cb = Some(new_cb);
} else if c.ends_chord() {
if let Some(cb) = cb.take() {
cb.finalize(&mut para);
}
self.make_inlines(c, &mut para)?;
} else {
if let Some(cb) = cb.as_mut() {
self.make_inlines(c, cb.inlines_mut())?;
} else {
self.make_inlines(c, &mut para)?;
}
}
}
if let Some(cb) = cb.take() {
cb.finalize(&mut para);
}
if !para.is_empty() {
self.paragraphs.push(para.into());
}
Ok(())
}
fn add_p_node(&mut self, node: AstRef) -> Result<()> {
match &node.data.borrow().value {
NodeValue::Paragraph => self.add_p_inner(node),
NodeValue::BlockQuote | NodeValue::List(..) | NodeValue::Item(..) => {
node.children().try_for_each(|c| self.add_p_node(c))
}
other => unreachable!("Unexpected element: {:?}", other),
}
}
fn finalize(self) -> (Verse, Transposition) {
let verse = Verse::new(self.label.into(), self.paragraphs);
(verse, self.xp)
}
}
#[derive(Debug)]
struct SongBuilder<'a> {
nodes: &'a [AstRef<'a>],
title: String,
subtitles: Vec<BStr>,
verse: Option<VerseBuilder<'a>>,
blocks: Vec<Block>,
xp: Transposition,
config: &'a ParserConfig,
}
impl<'a> SongBuilder<'a> {
fn new(nodes: &'a [AstRef<'a>], config: &'a ParserConfig) -> Self {
let (title, nodes) = match nodes.first() {
Some(n) if n.is_h(1) => (n.as_plaintext().into(), &nodes[1..]),
_ => (config.fallback_title.clone(), nodes),
};
let subtitles: Vec<_> = nodes
.iter()
.take_while(|node| node.is_h(2))
.map(|node| node.as_plaintext().into())
.collect();
let nodes = &nodes[subtitles.len()..];
Self {
nodes,
title,
subtitles,
verse: None,
blocks: vec![],
xp: Transposition::new(config.notation, config.xp_disabled),
config,
}
}
fn verse_mut<'s>(&'s mut self) -> &'s mut VerseBuilder<'a> {
if self.verse.is_none() {
self.verse = Some(VerseBuilder::new(
VerseLabel::None {},
self.xp.clone(),
&self.config,
));
}
self.verse.as_mut().unwrap()
}
fn verse_finalize(&mut self) {
if let Some(verse) = self.verse.take() {
let (verse, xp) = verse.finalize();
self.blocks.push(Block::Verse(verse));
self.xp = xp;
}
}
fn parse_bq(&mut self, bq: AstRef, level: u32) -> Result<()> {
assert!(bq.is_bq());
let mut prev_bq = false;
for c in bq.children() {
if c.is_bq() {
self.verse_finalize();
self.parse_bq(c, level + 1)?;
prev_bq = true;
} else {
if prev_bq {
self.verse_finalize();
prev_bq = false;
}
if !self.verse.is_some() {
let label = VerseLabel::Chorus(Some(level));
let verse = VerseBuilder::new(label, self.xp.clone(), &self.config);
self.verse = Some(verse);
}
self.verse_mut().add_p_node(c)?;
}
}
Ok(())
}
fn parse(&mut self) -> Result<()> {
for node in self.nodes.iter() {
if !node.is_p() {
self.verse_finalize();
}
match &node.data.borrow().value {
NodeValue::Paragraph => self.verse_mut().add_p_node(node)?,
NodeValue::List(list) if matches!(list.list_type, ListType::Ordered) => {
for (i, item) in node.children().enumerate() {
assert!(item.is_item());
self.verse_finalize();
let label = VerseLabel::Verse((list.start + i) as u32);
let verse = VerseBuilder::with_p_nodes(
label,
self.xp.clone(),
&self.config,
item.children(),
)?;
self.verse = Some(verse);
}
}
NodeValue::List(..) => {
let items: Vec<BStr> = node
.children()
.map(|item| item.as_plaintext().into())
.collect();
let list = BulletList {
items: items.into(),
};
self.blocks.push(Block::BulletList(list));
}
NodeValue::BlockQuote => self.parse_bq(node, 1)?,
NodeValue::Heading(h) if h.level >= 3 => {
let label = VerseLabel::Custom(node.as_plaintext().into());
self.verse = Some(VerseBuilder::new(label, self.xp.clone(), &self.config));
}
NodeValue::ThematicBreak => {
self.blocks.push(Block::HorizontalLine);
}
NodeValue::CodeBlock(cb) => self.blocks.push(Block::Pre {
text: cb.literal.into_bstr(),
}),
_ => {}
}
}
Ok(())
}
fn finalize(mut self) -> Song {
self.verse_finalize();
let max_chorus = self
.blocks
.iter()
.map(|b| b.chorus_num().unwrap_or(0))
.max()
.unwrap_or(0);
if max_chorus < 2 {
self.blocks.iter_mut().for_each(Block::remove_chorus_num);
}
let mut song = Song {
title: self.title.into(),
subtitles: self.subtitles.into(),
blocks: self.blocks.into(),
notation: self.xp.src_notation,
};
song.postprocess();
song
}
}
struct SongsIter<'s, 'a> {
slice: &'s [AstRef<'a>],
}
impl<'s, 'a> SongsIter<'s, 'a> {
fn new(slice: &'s [AstRef<'a>]) -> Self {
Self { slice }
}
fn find_next_h1(&self) -> Option<usize> {
self.slice[1..]
.iter()
.enumerate()
.find_map(|(i, node)| if node.is_h(1) { Some(i + 1) } else { None })
}
}
impl<'s, 'a> Iterator for SongsIter<'s, 'a> {
type Item = &'s [AstRef<'a>];
fn next(&mut self) -> Option<Self::Item> {
if self.slice.is_empty() {
return None;
}
if let Some(next_h1) = self.find_next_h1() {
let (ret, next_slice) = self.slice.split_at(next_h1);
self.slice = next_slice;
Some(ret)
} else {
Some(mem::replace(&mut self.slice, &[]))
}
}
}
#[derive(Debug)]
pub struct ParserConfig {
pub notation: Notation,
pub fallback_title: String,
pub xp_disabled: bool,
}
impl ParserConfig {
pub fn new(notation: Notation) -> Self {
Self {
notation,
fallback_title: FALLBACK_TITLE.into(),
xp_disabled: false,
}
}
}
impl Default for ParserConfig {
fn default() -> Self {
Self {
notation: Notation::default(),
fallback_title: FALLBACK_TITLE.into(),
xp_disabled: false,
}
}
}
#[derive(Debug)]
pub struct Parser<'i> {
input: &'i str,
config: ParserConfig,
}
impl<'i> Parser<'i> {
pub fn new(input: &'i str, config: ParserConfig) -> Self {
Self { input, config }
}
#[cfg(test)]
fn set_xp_disabled(&mut self, disabled: bool) {
self.config.xp_disabled = disabled;
}
fn comrak_config() -> ComrakOptions {
ComrakOptions {
extension: ComrakExtensionOptions {
strikethrough: false,
tagfilter: false,
table: false,
autolink: false,
tasklist: false,
superscript: false,
header_ids: None,
footnotes: false,
description_lists: false,
front_matter_delimiter: None,
},
parse: ComrakParseOptions {
smart: false,
default_info_string: None,
},
render: ComrakRenderOptions {
hardbreaks: false,
github_pre_lang: false,
width: 0,
unsafe_: false,
escape: false,
},
}
}
pub fn parse<'s>(&mut self, songs: &'s mut Vec<Song>) -> Result<&'s mut [Song]> {
let arena = Arena::new();
let root = comrak::parse_document(&arena, self.input, &Self::comrak_config());
let root_elems: Vec<_> = root.children().collect();
let orig_len = songs.len();
for song_nodes in SongsIter::new(&root_elems) {
song_nodes.iter().for_each(|node| node.preprocess(&arena));
let mut song = SongBuilder::new(song_nodes, &self.config);
song.parse()?;
songs.push(song.finalize());
}
Ok(&mut songs[orig_len..])
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
fn parse(input: &str, disable_xpose: bool) -> Vec<Song> {
let mut songs = vec![];
let mut parser = Parser::new(input, ParserConfig::default());
parser.set_xp_disabled(disable_xpose);
parser.parse(&mut songs).unwrap();
songs
}
fn parse_one(input: &str) -> Song {
let mut songs = parse(input, false);
assert_eq!(songs.len(), 1);
let song = songs.drain(..).next().unwrap();
song
}
fn parse_one_para(input: &str) -> Paragraph {
let blocks = parse_one(input).blocks;
let block = Vec::from(blocks).drain(..).next().unwrap();
match block {
Block::Verse(v) => Vec::from(v.paragraphs).drain(..).next().unwrap(),
_ => panic!("First block in this Song isn't a Verse"),
}
}
#[test]
fn songs_split() {
let input = r#"
No-heading lyrics
# Song 1
Lyrics lyrics...
# Song 2
Lyrics lyrics...
"#;
let songs = parse(&input, false);
assert_eq!(songs.len(), 3);
assert_eq!(&*songs[0].title, FALLBACK_TITLE);
assert_eq!(&*songs[1].title, "Song 1");
assert_eq!(&*songs[2].title, "Song 2");
}
#[test]
fn ast_split_at() {
let input = r#"_text **strong** `C`text2 **strong2**_"#;
let arena = Arena::new();
let options = ComrakOptions::default();
let root = comrak::parse_document(&arena, input, &options);
let para = root.children().next().unwrap();
let em = para.children().next().unwrap();
let code = em.split_at(3, &arena);
let em2 = code.split_at(1, &arena);
assert_eq!(em.children().count(), 3);
assert_eq!(em.as_plaintext(), "text strong ");
assert_eq!(code.children().count(), 1);
assert_eq!(code.as_plaintext(), "C");
assert_eq!(em2.children().count(), 2);
assert_eq!(em2.as_plaintext(), "text2 strong2");
}
#[test]
fn ast_preprocess() {
let input = r#"
Lyrics _em **strong `C` strong**
em_ lyrics
"#;
let arena = Arena::new();
let options = ComrakOptions::default();
let root = comrak::parse_document(&arena, input, &options);
let para = root.children().next().unwrap();
para.preprocess(&arena);
assert_eq!(para.children().count(), 7);
let code = para
.children()
.find(|c| c.is_code())
.unwrap()
.as_plaintext();
assert_eq!(code, "C");
para.children().find(|c| c.is_break()).unwrap();
}
#[test]
fn parse_verses_basic() {
let input = r#"
# Song
1. First verse.
Second paragraph of the first verse.
2. Second verse.
Second paragraph of the second verse.
3. Third verse.
4. Fourth verse.
> Chorus.
"#;
parse_one(input).assert_eq(json!({
"title": "Song",
"subtitles": [],
"notation": "english",
"blocks": [
{
"type": "b-verse",
"label": { "verse": 1 },
"paragraphs": [
[{ "type": "i-text", "text": "First verse." }],
[{ "type": "i-text", "text": "Second paragraph of the first verse." }],
],
},
{
"type": "b-verse",
"label": { "verse": 2 },
"paragraphs": [
[{ "type": "i-text", "text": "Second verse." }],
[{ "type": "i-text", "text": "Second paragraph of the second verse." }],
],
},
{
"type": "b-verse",
"label": { "verse": 3 },
"paragraphs": [[{ "type": "i-text", "text": "Third verse." }]],
},
{
"type": "b-verse",
"label": { "verse": 4 },
"paragraphs": [[{ "type": "i-text", "text": "Fourth verse." }]],
},
{
"type": "b-verse",
"label": { "chorus": null },
"paragraphs": [[{ "type": "i-text", "text": "Chorus." }]],
},
],
}));
}
#[test]
fn parse_verses_corners() {
let input = r#"
# Song
Verse without any label.
Next paragraph of that verse.
### Custom label
Lyrics Lyrics lyrics.
> Chorus 1.
>> Chorus 2.
>
> Chorus 1 again.
>
> More lyrics.
Yet more lyrics (these should go to the chorus as well actually).
>>> Chorus 3.
More lyrics to the chorus 3.
"#;
parse_one(input).assert_eq(json!({
"title": "Song",
"subtitles": [],
"notation": "english",
"blocks": [
{
"type": "b-verse",
"label": { "none": {} },
"paragraphs": [
[{ "type": "i-text", "text": "Verse without any label." }],
[{ "type": "i-text", "text": "Next paragraph of that verse." }],
],
},
{
"type": "b-verse",
"label": { "custom": "Custom label" },
"paragraphs": [
[{ "type": "i-text", "text": "Lyrics Lyrics lyrics." }],
],
},
{
"type": "b-verse",
"label": { "chorus": 1 },
"paragraphs": [
[{ "type": "i-text", "text": "Chorus 1." }],
],
},
{
"type": "b-verse",
"label": { "chorus": 2 },
"paragraphs": [
[{ "type": "i-text", "text": "Chorus 2." }],
],
},
{
"type": "b-verse",
"label": { "chorus": 1 },
"paragraphs": [
[{ "type": "i-text", "text": "Chorus 1 again." }],
[{ "type": "i-text", "text": "More lyrics." }],
[{ "type": "i-text", "text": "Yet more lyrics (these should go to the chorus as well actually)." }],
],
},
{
"type": "b-verse",
"label": { "chorus": 3 },
"paragraphs": [
[{ "type": "i-text", "text": "Chorus 3." }],
[{ "type": "i-text", "text": "More lyrics to the chorus 3." }],
],
},
],
}));
}
#[test]
fn parse_subtitles() {
let input = r#"
# Song
## Subtitle 1
## Subtitle 2
Some lyrics.
## This one should be ignored
"#;
let song = parse_one(input);
assert_eq!(&*song.subtitles, &[
"Subtitle 1".into(),
"Subtitle 2".into(),
]);
}
#[test]
fn parse_chords() {
let input = r#"
# Song
1. Sailing round `G`the ocean,
Sailing round the `D`sea.
"#;
parse_one_para(input).assert_eq(json!([
{ "type": "i-text", "text": "Sailing round " },
{
"type": "i-chord",
"chord": "G",
"alt_chord": null,
"inlines": [{ "type": "i-text", "text": "the ocean," }],
},
{ "type": "i-break" },
{ "type": "i-text", "text": "Sailing round the " },
{
"type": "i-chord",
"chord": "D",
"alt_chord": null,
"inlines": [{ "type": "i-text", "text": "sea." }],
},
]));
}
#[test]
fn parse_inlines() {
let input = r#"
# Song
1. Sailing **round `G`the _ocean,
Sailing_ round the `D`sea.**
"#;
parse_one_para(input).assert_eq(json!([
{ "type": "i-text", "text": "Sailing " },
{ "type": "i-strong", "inlines": [{ "type": "i-text", "text": "round " }] },
{
"type": "i-chord",
"chord": "G",
"alt_chord": null,
"inlines": [{
"type": "i-strong",
"inlines": [
{ "type": "i-text", "text": "the " },
{ "type": "i-emph", "inlines": [{ "type": "i-text", "text": "ocean," }] },
]
}],
},
{ "type": "i-break" },
{
"type": "i-strong",
"inlines": [
{ "type": "i-emph", "inlines": [{ "type": "i-text", "text": "Sailing" }] },
{ "type": "i-text", "text": " round the " },
],
},
{
"type": "i-chord",
"chord": "D",
"alt_chord": null,
"inlines": [{
"type": "i-strong",
"inlines": [{ "type": "i-text", "text": "sea." }]
}],
},
]));
}
#[test]
fn parse_extensions() {
let input = r#"
# Song
!+5
!!czech
> Chorus.
1. Lyrics !!> !!!english !+0
!+2 More lyrics !>
# Song two
> Chorus.
>> Chorus two.
1. Reference both: !> !>>
!> First on the line.
Mixed !>> in text.
"#;
let songs = parse(input, true);
songs[0].blocks.assert_eq(json!([
{
"type": "b-verse",
"label": { "none": {} },
"paragraphs": [
[
{ "type": "i-transpose", "t-transpose": 5 },
{ "type": "i-break" },
{ "type": "i-transpose", "t-alt-notation": "german" },
]
],
},
{
"type": "b-verse",
"label": { "chorus": null },
"paragraphs": [
[
{ "type": "i-text", "text": "Chorus." },
]
]
},
{
"type": "b-verse",
"label": { "verse": 1 },
"paragraphs": [
[
{ "type": "i-text", "text": "Lyrics !!> !!!english" },
{ "type": "i-transpose", "t-transpose": 0 },
{ "type": "i-break" },
{ "type": "i-transpose", "t-transpose": 2 },
{ "type": "i-text", "text": " More lyrics" },
{ "type": "i-chorus-ref", "num": null, "prefix_space": " " },
]
]
}
]
));
songs[1].blocks.assert_eq(json!([
{
"type": "b-verse",
"label": { "chorus": 1 },
"paragraphs": [
[
{ "type": "i-text", "text": "Chorus." },
]
]
},
{
"type": "b-verse",
"label": { "chorus": 2 },
"paragraphs": [
[
{ "type": "i-text", "text": "Chorus two." },
]
]
},
{
"type": "b-verse",
"label": { "verse": 1 },
"paragraphs": [
[
{ "type": "i-text", "text": "Reference both:" },
{ "type": "i-chorus-ref", "num": 1, "prefix_space": " "},
{ "type": "i-chorus-ref", "num": 2, "prefix_space": " "},
{ "type": "i-break" },
{ "type": "i-chorus-ref", "num": 1, "prefix_space": ""},
{ "type": "i-text", "text": " First on the line." },
{ "type": "i-break" },
{ "type": "i-text", "text": "Mixed" },
{ "type": "i-chorus-ref", "num": 2, "prefix_space": " "},
{ "type": "i-text", "text": " in text." },
]
]
}
]
));
}
#[test]
fn transposition() {
let input = r#"
# Song
!+5
!!czech
> 1. `Bm`Yippie yea `D`oh! !+0
!+0 Yippie yea `Bm`yay!
"#;
let song = parse_one(input);
song.blocks.assert_eq(json!([
{
"type": "b-verse",
"label": { "chorus": null },
"paragraphs": [
[
{
"type": "i-chord",
"chord": "Em",
"alt_chord": "Hm",
"inlines": [ { "type": "i-text", "text": "Yippie yea " } ],
},
{
"type": "i-chord",
"chord": "G",
"alt_chord": "D",
"inlines": [ { "type": "i-text", "text": "oh!" } ],
},
{ "type": "i-break" },
{ "type": "i-text", "text": "Yippie yea " },
{
"type": "i-chord",
"chord": "Bm",
"alt_chord": "Hm",
"inlines": [ { "type": "i-text", "text": "yay!" } ],
},
]
]
}
]));
}
}