use std::{path::PathBuf, sync::Arc};
use clap::ValueEnum;
use thiserror::Error;
use crate::{
answer::Mode,
card::{Card, Direction, Frontend},
cloze,
};
const MARKUP_FRONT: char = '#';
const MARKUP_CLOZE: char = '?';
const MARKUP_COMMENT: char = '%';
const MARKUP_NOTE: char = '!';
const MARKUP_ESCAPE: char = '\\';
const MARKUP: [char; 3] = [MARKUP_FRONT, MARKUP_COMMENT, MARKUP_NOTE];
#[derive(Debug, Error, PartialEq, Eq)]
pub enum ParseError {
#[error("line {0}: answer line appears before any '#' card front")]
BackBeforeFront(usize),
#[error("line {0}: note appears before any '#' card front")]
NoteBeforeFront(usize),
#[error("line {0}: note must come after the card's answer lines")]
NoteBeforeBack(usize),
#[error("line {0}: answer lines are not allowed after a note")]
BackAfterNote(usize),
#[error("line {0}: card front without an answer")]
FrontWithoutBack(usize),
#[error("line {0}: card front is empty")]
EmptyFront(usize),
#[error("line {0}: cloze card ('#?') has no {{{{...}}}} holes in its answer")]
ClozeWithoutHoles(usize),
#[error("line {0}: empty cloze hole '{{{{}}}}'")]
EmptyClozeHole(usize),
#[error("line {0}: unclosed cloze hole (missing the closing '}}}}')")]
UnclosedClozeHole(usize),
#[error("line {0}: nested cloze hole")]
NestedClozeHole(usize),
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum State {
Init,
Front,
Back,
Note,
}
struct PartialCard {
front: String,
back: Vec<(usize, String)>,
note: Option<String>,
line: usize,
cloze: bool,
mode: Option<Mode>,
direction: Option<Direction>,
image: Option<String>,
image_back: Option<String>,
frontend: Option<Frontend>,
at: Option<String>,
givens: Vec<String>,
}
impl PartialCard {
fn build(self, subject: &Arc<str>, cards: &mut Vec<Card>) -> Result<(), ParseError> {
if self.cloze {
for mut card in cloze::expand(
subject,
&self.front,
&self.back,
self.note.as_deref(),
self.line,
)? {
card.mode = self.mode;
cards.push(card);
}
} else {
let mut card = Card::plain(
Arc::clone(subject),
self.front,
self.back.into_iter().map(|(_, text)| text).collect(),
self.note,
self.line,
);
card.mode = self.mode;
card.direction = self.direction;
card.image = self.image.map(PathBuf::from);
card.image_back = self.image_back.map(PathBuf::from);
card.frontend = self.frontend;
card.at = self.at;
card.givens = self.givens;
cards.push(card);
}
Ok(())
}
}
pub fn parse_links(text: &str) -> Vec<String> {
text.lines()
.filter_map(|raw| {
let line = raw.trim();
let rest = line.strip_prefix(MARKUP_COMMENT)?;
let url = rest.trim().strip_prefix("link:")?.trim();
(!url.is_empty()).then(|| url.to_string())
})
.collect()
}
pub fn parse_requires(text: &str) -> Vec<String> {
text.lines()
.filter_map(|raw| {
let rest = raw.trim().strip_prefix(MARKUP_COMMENT)?;
let dep = rest.trim().strip_prefix("requires:")?.trim();
(!dep.is_empty()).then(|| dep.to_string())
})
.collect()
}
pub fn parse_sources(text: &str) -> Vec<String> {
text.lines()
.filter_map(|raw| {
let rest = raw.trim().strip_prefix(MARKUP_COMMENT)?;
let src = rest.trim().strip_prefix("source:")?.trim();
(!src.is_empty()).then(|| src.to_string())
})
.collect()
}
pub fn parse_title(text: &str) -> Option<String> {
text.lines().find_map(|raw| {
let rest = raw.trim().strip_prefix(MARKUP_COMMENT)?;
let title = rest.trim().strip_prefix("title:")?.trim();
(!title.is_empty()).then(|| title.to_string())
})
}
pub fn parse_trace(text: &str) -> Option<String> {
text.lines().find_map(|raw| {
let rest = raw.trim().strip_prefix(MARKUP_COMMENT)?;
let desc = rest.trim().strip_prefix("trace:")?.trim();
(!desc.is_empty()).then(|| desc.to_string())
})
}
fn directive(raw: &str) -> Option<(String, String)> {
let rest = raw.trim().strip_prefix(MARKUP_COMMENT)?;
let (key, value) = rest.split_once(':')?;
let key = key.trim().to_ascii_lowercase();
if key.is_empty() || key.contains(char::is_whitespace) || key == "link" {
return None;
}
let value = value.trim();
(!value.is_empty()).then(|| (key, value.to_string()))
}
pub fn parse_directives(text: &str) -> Vec<(String, String)> {
text.lines()
.take_while(|raw| !raw.starts_with(MARKUP_FRONT))
.filter_map(directive)
.collect()
}
pub fn parse_str(subject: &str, text: &str) -> Result<Vec<Card>, ParseError> {
let subject: Arc<str> = Arc::from(subject);
let mut cards = Vec::new();
let mut state = State::Init;
let mut partial: Option<PartialCard> = None;
for (lineno, raw) in text.lines().enumerate() {
let lineno = lineno + 1;
let line = raw.trim();
if line.is_empty() {
continue;
}
let first = line.chars().next().unwrap();
let is_front = first == MARKUP_FRONT && raw.starts_with(MARKUP_FRONT);
match first {
MARKUP_FRONT if is_front => {
match state {
State::Front => {
return Err(ParseError::FrontWithoutBack(partial.unwrap().line));
}
State::Back | State::Note => {
partial.take().unwrap().build(&subject, &mut cards)?;
}
State::Init => {}
}
let rest = &line[MARKUP_FRONT.len_utf8()..];
let (cloze, rest) = match rest.strip_prefix(MARKUP_CLOZE) {
Some(rest) => (true, rest),
None => (false, rest),
};
let front = rest.trim();
if front.is_empty() {
return Err(ParseError::EmptyFront(lineno));
}
partial = Some(PartialCard {
front: front.to_string(),
back: Vec::new(),
note: None,
line: lineno,
cloze,
mode: None,
direction: None,
image: None,
image_back: None,
frontend: None,
at: None,
givens: Vec::new(),
});
state = State::Front;
}
MARKUP_NOTE => {
match state {
State::Init => return Err(ParseError::NoteBeforeFront(lineno)),
State::Front => return Err(ParseError::NoteBeforeBack(lineno)),
State::Back | State::Note => {}
}
let after = &line[MARKUP_NOTE.len_utf8()..];
let text = after.strip_prefix(' ').unwrap_or(after).trim_end();
let note = &mut partial.as_mut().unwrap().note;
match note {
Some(note) => {
note.push('\n');
note.push_str(text);
}
None => *note = Some(text.to_string()),
}
state = State::Note;
}
MARKUP_COMMENT => {
if state != State::Init
&& let Some((key, value)) = directive(line)
{
let partial = partial.as_mut().unwrap();
match key.as_str() {
"mode" => {
if let Ok(m) = Mode::from_str(&value, true) {
partial.mode = Some(m);
}
}
"direction" => {
if let Ok(d) = Direction::from_str(&value, true) {
partial.direction = Some(d);
}
}
"img" => partial.image = Some(value),
"img-back" => partial.image_back = Some(value),
"at" => partial.at = Some(value),
"given" => partial.givens.push(value),
"frontend" => {
if let Ok(f) = Frontend::from_str(&value, true) {
partial.frontend = Some(f);
}
}
_ => {}
}
}
}
_ => {
match state {
State::Init => return Err(ParseError::BackBeforeFront(lineno)),
State::Note => return Err(ParseError::BackAfterNote(lineno)),
State::Front | State::Back => {}
}
let second = line.chars().nth(1);
let back_line =
if first == MARKUP_ESCAPE && second.is_some_and(|c| MARKUP.contains(&c)) {
&line[MARKUP_ESCAPE.len_utf8()..]
} else {
line
};
partial
.as_mut()
.unwrap()
.back
.push((lineno, back_line.to_string()));
state = State::Back;
}
}
}
if state == State::Front {
return Err(ParseError::FrontWithoutBack(partial.unwrap().line));
}
if let Some(p) = partial.take() {
p.build(&subject, &mut cards)?;
}
Ok(cards)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn single_card() {
let cards = parse_str("s", "# front\n\tback").unwrap();
assert_eq!(1, cards.len());
assert_eq!("front", cards[0].front);
assert_eq!(vec!["back"], cards[0].back);
assert_eq!(None, cards[0].note);
assert_eq!(1, cards[0].line);
}
#[test]
fn multi_line_back_is_trimmed_per_line() {
let text = "# front\n\tvar (\n \t a = 1\n\t)\n";
let cards = parse_str("s", text).unwrap();
assert_eq!(vec!["var (", "a = 1", ")"], cards[0].back);
}
#[test]
fn note_after_back() {
let cards = parse_str("s", "# front\n\tback\n\t! a note").unwrap();
assert_eq!(Some("a note".to_string()), cards[0].note);
}
#[test]
fn comments_and_blank_lines_ignored() {
let text = "% header comment\n\n# front\n\nback\n\n% trailing\n";
let cards = parse_str("s", text).unwrap();
assert_eq!(1, cards.len());
assert_eq!(vec!["back"], cards[0].back);
}
#[test]
fn escaped_markup_in_back() {
let cards = parse_str("s", "# front\n\t\\%\n\t\\#\n\t\\!").unwrap();
assert_eq!(vec!["%", "#", "!"], cards[0].back);
}
#[test]
fn backslash_without_markup_kept_verbatim() {
let cards = parse_str("s", "# front\n\t\\n is a newline").unwrap();
assert_eq!(vec!["\\n is a newline"], cards[0].back);
}
#[test]
fn several_cards() {
let text = "# a\n1\n# b\n2\n3\n! note b\n# c\n4\n";
let cards = parse_str("s", text).unwrap();
assert_eq!(3, cards.len());
assert_eq!("a", cards[0].front);
assert_eq!(vec!["2", "3"], cards[1].back);
assert_eq!(Some("note b".to_string()), cards[1].note);
assert_eq!("c", cards[2].front);
assert_eq!(7, cards[2].line);
}
#[test]
fn error_back_before_front() {
assert_eq!(Err(ParseError::BackBeforeFront(1)), parse_str("s", "back"));
}
#[test]
fn error_front_without_back() {
assert_eq!(
Err(ParseError::FrontWithoutBack(1)),
parse_str("s", "# a\n# b\nback")
);
assert_eq!(
Err(ParseError::FrontWithoutBack(2)),
parse_str("s", "% x\n# a\n")
);
}
#[test]
fn error_note_placement() {
assert_eq!(
Err(ParseError::NoteBeforeFront(1)),
parse_str("s", "! note")
);
assert_eq!(
Err(ParseError::NoteBeforeBack(2)),
parse_str("s", "# a\n! note")
);
assert_eq!(
Err(ParseError::BackAfterNote(4)),
parse_str("s", "# a\nback\n! note\nmore back")
);
}
#[test]
fn links_are_collected_and_stay_comments() {
let text = "% link: https://docs.rs/tokio\n\
% a normal comment\n\
%link:https://example.org/spec\n\
# front\n\
\tback\n\
% link:\n";
assert_eq!(
vec!["https://docs.rs/tokio", "https://example.org/spec"],
parse_links(text)
);
let cards = parse_str("s", text).unwrap();
assert_eq!(1, cards.len());
assert_eq!(vec!["back"], cards[0].back);
}
#[test]
fn multi_line_note() {
let cards = parse_str("s", "# a\nback\n! one\n! two\n! three").unwrap();
assert_eq!(Some("one\ntwo\nthree".to_string()), cards[0].note);
}
#[test]
fn requires_are_collected() {
let text = "% requires: basics\n\
%requires:more.txt\n\
% link: https://example.org\n\
% a normal comment\n\
# front\n\tback\n";
assert_eq!(vec!["basics", "more.txt"], parse_requires(text));
}
#[test]
fn sources_are_collected_and_stay_comments() {
let text = "% source: https://doc.rust-lang.org/book/ch04.html\n\
%source:notes.md\n\
% requires: basics\n\
% a normal comment\n\
# front\n\tback\n\
% source:\n";
assert_eq!(
vec!["https://doc.rust-lang.org/book/ch04.html", "notes.md"],
parse_sources(text)
);
let cards = parse_str("s", text).unwrap();
assert_eq!(1, cards.len());
assert_eq!(vec!["back"], cards[0].back);
}
#[test]
fn directives_are_collected() {
let text = "% mode: line\n\
% Scheduler:SM2 \n\
% link: https://example.org\n\
% Then learn it with: alix\n\
% a plain comment\n\
# front\n\tback\n";
assert_eq!(
vec![
("mode".to_string(), "line".to_string()),
("scheduler".to_string(), "SM2".to_string()),
],
parse_directives(text)
);
let cards = parse_str("s", text).unwrap();
assert_eq!(1, cards.len());
}
#[test]
fn per_card_mode_directive_is_parsed() {
let text = "# a\n% mode: choice\n\tx\n# b\n\ty\n";
let cards = parse_str("s", text).unwrap();
assert_eq!(Some(Mode::Choice), cards[0].mode);
assert_eq!(None, cards[1].mode); }
#[test]
fn per_card_direction_directive_is_parsed() {
let cards = parse_str("s", "# a\n% direction: both\n\tx\n# b\n\ty\n").unwrap();
assert_eq!(Some(Direction::Both), cards[0].direction);
assert_eq!(None, cards[1].direction);
}
#[test]
fn per_card_image_and_frontend_directives_are_parsed() {
let text = "# q\n% img: moon.png\n% img-back: phase.png\n% frontend: web\n\tWaxing\n";
let cards = parse_str("s", text).unwrap();
assert_eq!(Some(PathBuf::from("moon.png")), cards[0].image);
assert_eq!(Some(PathBuf::from("phase.png")), cards[0].image_back);
assert_eq!(Some(Frontend::Web), cards[0].frontend);
}
#[test]
fn cloze_card_ignores_image_directive() {
let cards = parse_str("s", "#? f\n% img: x.png\n{{a}} b\n").unwrap();
assert_eq!(None, cards[0].image);
}
#[test]
fn per_card_at_locator_is_parsed_verbatim() {
let cards = parse_str("s", "# predict\n% at: src/card.rs:151-158\n\tpoint\n").unwrap();
assert_eq!(Some("src/card.rs:151-158".to_string()), cards[0].at);
let bare = parse_str("s", "# q\n\ta\n").unwrap();
assert_eq!(None, bare[0].at);
}
#[test]
fn per_card_given_lines_are_collected_in_order() {
let text = "# q\n\
% given: state — the parser position\n\
% given: partial — the card being assembled\n\
\tkey point\n";
let cards = parse_str("s", text).unwrap();
assert_eq!(
vec![
"state — the parser position".to_string(),
"partial — the card being assembled".to_string(),
],
cards[0].givens
);
assert!(parse_str("s", "# q\n\ta\n").unwrap()[0].givens.is_empty());
}
#[test]
fn trace_marks_a_deck_and_keeps_the_path() {
assert_eq!(
Some("how a keypress becomes a saved grade".to_string()),
parse_trace("% trace: how a keypress becomes a saved grade\n# f\n\tb\n")
);
assert_eq!(None, parse_trace("# f\n\tb\n"));
assert_eq!(None, parse_trace("% trace:\n# f\n\tb\n")); }
#[test]
fn title_is_parsed_keeping_spaces_and_colons() {
assert_eq!(
Some("Rust: The Book".to_string()),
parse_title("% title: Rust: The Book\n# f\n\tb\n")
);
assert_eq!(None, parse_title("# f\n\tb\n"));
assert_eq!(None, parse_title("% title:\n# f\n\tb\n")); }
#[test]
fn directives_are_header_only() {
assert!(parse_directives("# a\n% mode: choice\n\tx\n").is_empty());
assert_eq!(
vec![("mode".to_string(), "line".to_string())],
parse_directives("% mode: line\n# a\n\tx\n")
);
}
#[test]
fn note_preserves_interior_indentation() {
let text = "# a\nback\n! ```rust\n! fn main() {\n! let x = 1;\n! }\n! ```";
let cards = parse_str("s", text).unwrap();
assert_eq!(
Some("```rust\nfn main() {\n let x = 1;\n}\n```".to_string()),
cards[0].note
);
}
#[test]
fn note_lines_may_be_separated_by_blanks_and_comments() {
let text = "# a\nback\n! one\n\n% comment\n! two\n# b\nback b\n";
let cards = parse_str("s", text).unwrap();
assert_eq!(2, cards.len());
assert_eq!(Some("one\ntwo".to_string()), cards[0].note);
assert_eq!(None, cards[1].note);
}
#[test]
fn multi_line_note_on_cloze_cards() {
let cards = parse_str("s", "#? f\n{{a}} b\n! one\n! two").unwrap();
assert_eq!(Some("one\ntwo".to_string()), cards[0].note);
}
#[test]
fn error_empty_front() {
assert_eq!(Err(ParseError::EmptyFront(1)), parse_str("s", "#\nback"));
assert_eq!(Err(ParseError::EmptyFront(1)), parse_str("s", "#?\nback"));
}
#[test]
fn indented_hash_is_answer_content() {
let text = "# Dockerfile for a bin project\n\
\tFROM rust\n\
\t# Pre-built dependencies\n\
\tRUN cargo build\n\
# Which attribute exports an async fn?\n\
\t#[uniffi::export(async_runtime = \"tokio\")]\n";
let cards = parse_str("s", text).unwrap();
assert_eq!(2, cards.len());
assert_eq!(
vec!["FROM rust", "# Pre-built dependencies", "RUN cargo build"],
cards[0].back
);
assert_eq!(
vec!["#[uniffi::export(async_runtime = \"tokio\")]"],
cards[1].back
);
}
#[test]
fn indented_hash_before_any_front_is_an_error() {
assert_eq!(
Err(ParseError::BackBeforeFront(1)),
parse_str("s", "\t# comment\n")
);
}
#[test]
fn escaped_hash_and_indented_hash_yield_the_same_back_line() {
let escaped = parse_str("s", "# f\n\t\\# x\n").unwrap();
let indented = parse_str("s", "# f\n\t# x\n").unwrap();
assert_eq!(escaped[0].back, indented[0].back);
assert_eq!(escaped[0].id(), indented[0].id());
}
#[test]
fn indented_cloze_marker_is_answer_content() {
let cards = parse_str("s", "# f\n\t#? not a cloze front\n").unwrap();
assert_eq!(vec!["#? not a cloze front"], cards[0].back);
}
#[test]
fn cloze_front_expands_to_sub_cards() {
let text = "#? Complete the quote\n\tTo {{be}} or not to {{be}}\n\t! Hamlet\n";
let cards = parse_str("s", text).unwrap();
assert_eq!(2, cards.len());
assert_eq!("Complete the quote", cards[0].front);
assert_eq!(vec!["To ____ or not to […]"], cards[0].context);
assert_eq!(vec!["be"], cards[0].back);
assert_eq!(Some("Hamlet".to_string()), cards[0].note);
assert_eq!(vec!["To […] or not to ____"], cards[1].context);
assert_ne!(cards[0].id(), cards[1].id());
}
#[test]
fn cloze_marker_requires_no_space() {
let cards = parse_str("s", "# ? matches one char\n\tglob\n").unwrap();
assert_eq!(1, cards.len());
assert_eq!("? matches one char", cards[0].front);
assert!(cards[0].context.is_empty());
}
#[test]
fn braces_in_plain_cards_stay_literal() {
let cards = parse_str("s", "# main\n\tfunc main() {}\n\tx := T{ a, b }\n").unwrap();
assert_eq!(1, cards.len());
assert_eq!(vec!["func main() {}", "x := T{ a, b }"], cards[0].back);
assert!(cards[0].hash_lines.is_none());
}
#[test]
fn cloze_errors_carry_line_numbers() {
assert_eq!(
Err(ParseError::ClozeWithoutHoles(1)),
parse_str("s", "#? front\n\tno holes\n")
);
assert_eq!(
Err(ParseError::UnclosedClozeHole(3)),
parse_str("s", "#? front\n\tok {{fine}}\n\tbad {{oops\n")
);
assert_eq!(
Err(ParseError::EmptyClozeHole(2)),
parse_str("s", "#? front\n\tbad {{}} here\n")
);
}
}