use std::sync::Arc;
use crate::{card::Card, parser::ParseError};
pub const BLANK: &str = "____";
pub const HIDDEN: &str = "[…]";
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Segment {
Text(String),
Hole(String),
}
pub fn parse_line(line: &str, lineno: usize) -> Result<Vec<Segment>, ParseError> {
let mut segments = Vec::new();
let mut text = String::new();
let mut hole: Option<String> = None;
let mut chars = line.chars().peekable();
while let Some(c) = chars.next() {
match c {
'\\' if matches!(chars.peek(), Some('{' | '}' | '\\')) => {
let escaped = chars.next().unwrap();
match &mut hole {
Some(h) => h.push(escaped),
None => text.push(escaped),
}
}
'{' if chars.peek() == Some(&'{') => {
chars.next();
if hole.is_some() {
return Err(ParseError::NestedClozeHole(lineno));
}
if !text.is_empty() {
segments.push(Segment::Text(std::mem::take(&mut text)));
}
hole = Some(String::new());
}
'}' if hole.is_some() && chars.peek() == Some(&'}') => {
chars.next();
let h = hole.take().unwrap();
if h.trim().is_empty() {
return Err(ParseError::EmptyClozeHole(lineno));
}
segments.push(Segment::Hole(h));
}
c => match &mut hole {
Some(h) => h.push(c),
None => text.push(c),
},
}
}
if hole.is_some() {
return Err(ParseError::UnclosedClozeHole(lineno));
}
if !text.is_empty() {
segments.push(Segment::Text(text));
}
Ok(segments)
}
fn hash_repr(segments: &[Segment]) -> String {
let mut out = String::new();
for segment in segments {
match segment {
Segment::Text(t) => out.push_str(t),
Segment::Hole(h) => {
out.push('\u{1f}');
out.push_str(h);
out.push('\u{1f}');
}
}
}
out
}
pub fn expand(
subject: &Arc<str>,
front: &str,
back: &[(usize, String)],
note: Option<&str>,
line: usize,
) -> Result<Vec<Card>, ParseError> {
let lines: Vec<Vec<Segment>> = back
.iter()
.map(|(lineno, text)| parse_line(text, *lineno))
.collect::<Result<_, _>>()?;
let total: usize = lines
.iter()
.flatten()
.filter(|s| matches!(s, Segment::Hole(_)))
.count();
if total == 0 {
return Err(ParseError::ClozeWithoutHoles(line));
}
if total == 1 {
let has_context = lines.iter().flatten().any(|seg| match seg {
Segment::Text(t) => t.chars().any(char::is_alphanumeric),
Segment::Hole(_) => false,
});
if !has_context {
return Err(ParseError::ClozeWithoutContext(line));
}
}
let structure: Vec<String> = lines.iter().map(|segments| hash_repr(segments)).collect();
let mut cards = Vec::with_capacity(total);
let holes = lines.iter().enumerate().flat_map(|(li, segments)| {
segments
.iter()
.enumerate()
.filter_map(move |(si, seg)| match seg {
Segment::Hole(h) => Some((li, si, h.clone())),
Segment::Text(_) => None,
})
});
for (hole_index, (target_line, target_seg, answer)) in holes.enumerate() {
let context: Vec<String> = lines
.iter()
.enumerate()
.map(|(li, segments)| {
let mut rendered = String::new();
for (si, seg) in segments.iter().enumerate() {
match seg {
Segment::Text(t) => rendered.push_str(t),
Segment::Hole(_) if li == target_line && si == target_seg => {
rendered.push_str(BLANK)
}
Segment::Hole(_) => rendered.push_str(HIDDEN),
}
}
rendered
})
.collect();
let front = front.to_string();
let mut hash_lines = structure.clone();
hash_lines.push(format!("#cloze:{hole_index}"));
cards.push(Card {
subject: Arc::clone(subject),
front,
context,
back: vec![answer],
note: note.map(String::from),
line,
hash_lines: Some(hash_lines),
reveal: None,
input: None,
direction: None,
image: None,
image_back: None,
at: None,
at_origin: None,
origin: None,
givens: Vec::new(),
display_back: None,
});
}
Ok(cards)
}
#[cfg(test)]
mod tests {
use super::*;
fn text(s: &str) -> Segment {
Segment::Text(s.to_string())
}
fn hole(s: &str) -> Segment {
Segment::Hole(s.to_string())
}
#[test]
fn parse_line_without_holes() {
assert_eq!(
vec![text("plain text")],
parse_line("plain text", 1).unwrap()
);
}
#[test]
fn parse_line_with_holes() {
assert_eq!(
vec![text("To "), hole("be"), text(" or not to "), hole("be")],
parse_line("To {{be}} or not to {{be}}", 1).unwrap()
);
}
#[test]
fn parse_line_hole_at_edges() {
assert_eq!(
vec![hole("a"), text(" mid "), hole("b")],
parse_line("{{a}} mid {{b}}", 1).unwrap()
);
}
#[test]
fn single_braces_are_literal() {
assert_eq!(
vec![text("fn main() {}")],
parse_line("fn main() {}", 1).unwrap()
);
assert_eq!(
vec![text("let p = Foo { x: "), hole("1"), text(" };")],
parse_line("let p = Foo { x: {{1}} };", 1).unwrap()
);
}
#[test]
fn escaped_double_brace_is_literal() {
assert_eq!(vec![text("a {{ b")], parse_line("a \\{\\{ b", 1).unwrap());
}
#[test]
fn stray_closing_brace_is_literal() {
assert_eq!(vec![text("end }")], parse_line("end }", 1).unwrap());
}
#[test]
fn parse_line_errors() {
assert_eq!(
Err(ParseError::UnclosedClozeHole(7)),
parse_line("oops {{unclosed", 7)
);
assert_eq!(
Err(ParseError::EmptyClozeHole(7)),
parse_line("an {{}} empty", 7)
);
assert_eq!(
Err(ParseError::EmptyClozeHole(7)),
parse_line("a {{ }} blank", 7)
);
assert_eq!(
Err(ParseError::NestedClozeHole(7)),
parse_line("a {{nested {{hole}}}}", 7)
);
}
fn subject() -> Arc<str> {
Arc::from("deck.txt")
}
#[test]
fn expand_single_hole() {
let back = vec![(2, "To be or not to {{be}}".to_string())];
let cards = expand(&subject(), "Complete the quote", &back, None, 1).unwrap();
assert_eq!(1, cards.len());
assert_eq!("Complete the quote", cards[0].front);
assert_eq!(vec!["To be or not to ____"], cards[0].context);
assert_eq!(vec!["be"], cards[0].back);
}
#[test]
fn expand_multiple_holes() {
let back = vec![(2, "To {{be}} or not to {{bee}}".to_string())];
let cards = expand(&subject(), "Quote", &back, Some("n"), 1).unwrap();
assert_eq!(2, cards.len());
assert_eq!("Quote", cards[0].front);
assert_eq!(vec!["To ____ or not to […]"], cards[0].context);
assert_eq!(vec!["be"], cards[0].back);
assert_eq!(Some("n".to_string()), cards[0].note);
assert_eq!("Quote", cards[1].front);
assert_eq!(vec!["To […] or not to ____"], cards[1].context);
assert_eq!(vec!["bee"], cards[1].back);
}
#[test]
fn sibling_hole_content_never_leaks() {
let back = vec![(2, "a {{alpha}} b {{beta}} c {{gamma}}".to_string())];
let cards = expand(&subject(), "f", &back, None, 1).unwrap();
for card in &cards {
let answer = &card.back[0];
for other in cards.iter().filter(|c| &c.back[0] != answer) {
assert!(
!card.context[0].contains(&other.back[0]),
"context {:?} leaks sibling answer {:?}",
card.context[0],
other.back[0]
);
}
}
}
#[test]
fn expand_across_lines() {
let back = vec![
(2, "first {{alpha}} line".to_string()),
(3, "second {{beta}} line".to_string()),
];
let cards = expand(&subject(), "f", &back, None, 1).unwrap();
assert_eq!(2, cards.len());
assert_eq!(vec!["first ____ line", "second […] line"], cards[0].context);
assert_eq!(vec!["first […] line", "second ____ line"], cards[1].context);
assert_eq!(vec!["beta"], cards[1].back);
}
#[test]
fn identical_hole_texts_get_distinct_ids() {
let back = vec![(2, "To {{be}} or not to {{be}}".to_string())];
let cards = expand(&subject(), "Quote", &back, None, 1).unwrap();
assert_eq!(2, cards.len());
assert_ne!(cards[0].id(), cards[1].id());
}
#[test]
fn ids_survive_front_rewording_but_not_text_changes() {
let back = vec![(2, "a {{b}} c".to_string())];
let v1 = expand(&subject(), "front one", &back, None, 1).unwrap();
let v2 = expand(&subject(), "front two", &back, None, 5).unwrap();
assert_eq!(v1[0].id(), v2[0].id());
let changed = vec![(2, "a {{b}} d".to_string())];
let v3 = expand(&subject(), "front one", &changed, None, 1).unwrap();
assert_ne!(v1[0].id(), v3[0].id());
}
#[test]
fn expand_without_holes_is_an_error() {
let back = vec![(2, "no holes here".to_string())];
assert_eq!(
Err(ParseError::ClozeWithoutHoles(1)),
expand(&subject(), "f", &back, None, 1)
);
}
#[test]
fn single_brace_answer_has_no_holes() {
let back = vec![(2, "the {old} style".to_string())];
assert_eq!(
Err(ParseError::ClozeWithoutHoles(1)),
expand(&subject(), "f", &back, None, 1)
);
}
#[test]
fn expand_whole_answer_hole_is_an_error() {
let back = vec![(2, "`{{IdentStr}}`".to_string())];
assert_eq!(
Err(ParseError::ClozeWithoutContext(1)),
expand(&subject(), "f", &back, None, 1)
);
}
#[test]
fn expand_single_hole_with_context_is_ok() {
let back = vec![(2, "the borrowed form is {{IdentStr}}".to_string())];
assert!(expand(&subject(), "f", &back, None, 1).is_ok());
}
#[test]
fn expand_multi_hole_without_literal_text_is_ok() {
let back = vec![(2, "{{red}} {{green}}".to_string())];
assert!(expand(&subject(), "f", &back, None, 1).is_ok());
}
}