use proptest::prelude::*;
use text_document::TextDocument;
#[derive(Debug, Clone)]
enum Inline {
Text(String),
Bold(String),
Italic(String),
Code(String),
Sup(String),
Sub(String),
Strike(String),
Underline(String),
Link(String, String),
}
#[derive(Debug, Clone, Default)]
struct BlockStyle {
alignment: Option<&'static str>,
line_height: Option<i64>,
direction: Option<&'static str>,
non_breakable_lines: Option<bool>,
background: Option<&'static str>,
}
impl BlockStyle {
fn emit(&self) -> String {
let mut pairs: Vec<String> = Vec::new();
if let Some(a) = self.alignment {
pairs.push(format!("alignment={a}"));
}
if let Some(lh) = self.line_height {
pairs.push(format!("line_height={lh}"));
}
if let Some(d) = self.direction {
pairs.push(format!("direction={d}"));
}
if let Some(n) = self.non_breakable_lines {
pairs.push(format!("non_breakable_lines={n}"));
}
if let Some(bg) = self.background {
pairs.push(format!("background_color=\"{bg}\""));
}
if pairs.is_empty() {
String::new()
} else {
format!("{{{}}}\n", pairs.join(" "))
}
}
}
#[derive(Debug, Clone)]
enum Block {
Para(BlockStyle, Vec<Inline>),
Heading(BlockStyle, u8, String),
Fenced(Option<String>, String),
Bullet(Vec<String>),
Ordered(Vec<String>),
Task(Vec<(bool, String)>),
Quote(String),
}
fn emit_inline(i: &Inline) -> String {
match i {
Inline::Text(s) => s.clone(),
Inline::Bold(s) => format!("*{s}*"),
Inline::Italic(s) => format!("_{s}_"),
Inline::Code(s) => format!("`{s}`"),
Inline::Sup(s) => format!("^{s}^"),
Inline::Sub(s) => format!("~{s}~"),
Inline::Strike(s) => format!("{{-{s}-}}"),
Inline::Underline(s) => format!("{{+{s}+}}"),
Inline::Link(t, u) => format!("[{t}]({u})"),
}
}
fn emit_block(b: &Block) -> String {
match b {
Block::Para(style, inlines) => format!(
"{}{}",
style.emit(),
inlines.iter().map(emit_inline).collect::<String>()
),
Block::Heading(style, level, s) => {
format!("{}{} {s}", style.emit(), "#".repeat(*level as usize))
}
Block::Fenced(lang, content) => {
format!("```{}\n{content}\n```", lang.as_deref().unwrap_or(""))
}
Block::Bullet(items) => items
.iter()
.map(|s| format!("- {s}"))
.collect::<Vec<_>>()
.join("\n\n"),
Block::Ordered(items) => items
.iter()
.enumerate()
.map(|(i, s)| format!("{}. {s}", i + 1))
.collect::<Vec<_>>()
.join("\n\n"),
Block::Task(items) => items
.iter()
.map(|(checked, s)| format!("- [{}] {s}", if *checked { 'x' } else { ' ' }))
.collect::<Vec<_>>()
.join("\n\n"),
Block::Quote(s) => format!("> {s}"),
}
}
fn emit(blocks: &[Block]) -> String {
blocks
.iter()
.map(emit_block)
.collect::<Vec<_>>()
.join("\n\n")
}
fn word() -> impl Strategy<Value = String> {
"[a-zA-Z0-9][a-zA-Z0-9 ]{0,10}[a-zA-Z0-9]".prop_map(|s| s.trim().to_string())
}
fn url() -> impl Strategy<Value = String> {
"https://[a-z]{2,8}\\.example/[a-z0-9_-]{0,10}"
}
fn plain_text() -> impl Strategy<Value = String> {
"[a-zA-Z][a-zA-Z0-9 .,!?*_~^]{0,24}".prop_map(|s| s.trim_end().to_string())
}
fn inline() -> impl Strategy<Value = Inline> {
prop_oneof![
plain_text().prop_map(Inline::Text),
word().prop_map(Inline::Bold),
word().prop_map(Inline::Italic),
"[a-zA-Z0-9 ]{1,12}".prop_map(Inline::Code),
word().prop_map(Inline::Sup),
word().prop_map(Inline::Sub),
word().prop_map(Inline::Strike),
word().prop_map(Inline::Underline),
(word(), url()).prop_map(|(t, u)| Inline::Link(t, u)),
]
}
fn block_style() -> impl Strategy<Value = BlockStyle> {
(
prop::option::of(prop_oneof![
Just("left"),
Just("right"),
Just("center"),
Just("justify")
]),
prop::option::of(1i64..3000),
prop::option::of(prop_oneof![Just("ltr"), Just("rtl")]),
prop::option::of(any::<bool>()),
prop::option::of(prop_oneof![
Just("#ff0000"),
Just("#00ff00"),
Just("yellow")
]),
)
.prop_map(
|(alignment, line_height, direction, non_breakable_lines, background)| BlockStyle {
alignment,
line_height,
direction,
non_breakable_lines,
background,
},
)
}
fn block() -> impl Strategy<Value = Block> {
prop_oneof![
(block_style(), prop::collection::vec(inline(), 1..5)).prop_map(|(s, i)| Block::Para(s, i)),
(block_style(), 1u8..=6, word()).prop_map(|(st, l, s)| Block::Heading(st, l, s)),
(
prop::option::of(prop_oneof![
Just("rust".to_string()),
Just("py".to_string())
]),
"[a-zA-Z0-9 ;=()]{0,30}",
)
.prop_map(|(lang, c)| Block::Fenced(lang, c)),
prop::collection::vec(word(), 1..4).prop_map(Block::Bullet),
prop::collection::vec(word(), 1..4).prop_map(Block::Ordered),
prop::collection::vec((any::<bool>(), word()), 1..4).prop_map(Block::Task),
word().prop_map(Block::Quote),
]
}
fn set_djot(doc: &TextDocument, src: &str) {
doc.set_djot(src).unwrap().wait().unwrap();
}
proptest! {
#![proptest_config(ProptestConfig { cases: 128, ..ProptestConfig::default() })]
#[test]
fn djot_roundtrip_is_a_fixpoint(blocks in prop::collection::vec(block(), 1..6)) {
let seed = emit(&blocks);
let doc1 = TextDocument::new();
set_djot(&doc1, &seed);
let t1 = doc1.to_djot().unwrap();
let doc2 = TextDocument::new();
set_djot(&doc2, &t1);
let t2 = doc2.to_djot().unwrap();
prop_assert_eq!(&t1, &t2, "export not a fixpoint\nseed={:?}\nt1={:?}\nt2={:?}", seed, t1, t2);
prop_assert_eq!(
doc1.to_plain_text().unwrap(),
doc2.to_plain_text().unwrap(),
"plain text diverged"
);
prop_assert_eq!(doc1.block_count(), doc2.block_count(), "block count diverged");
}
}