diff --git a/Cargo.lock b/Cargo.lock index 797e316..18264af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -136,6 +136,8 @@ dependencies = [ "dprint-core-macros", "dprint-development", "pulldown-cmark", + "quickcheck", + "quickcheck_macros", "regex", "serde", "serde_json", @@ -148,6 +150,16 @@ version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" +[[package]] +name = "env_logger" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a19187fea3ac7e84da7dacf48de0c45d63c6a76f9490dae389aead16c243fce3" +dependencies = [ + "log", + "regex", +] + [[package]] name = "equivalent" version = "1.0.1" @@ -174,6 +186,17 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f" +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "hashbrown" version = "0.15.2" @@ -210,9 +233,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "libc" -version = "0.2.150" +version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89d92a4743f9a61002fae18374ed11e7973f530cb3a3255fb354818118b2203c" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" [[package]] name = "lock_api" @@ -224,6 +247,12 @@ dependencies = [ "scopeguard", ] +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + [[package]] name = "memchr" version = "2.7.2" @@ -279,6 +308,28 @@ dependencies = [ "unicase", ] +[[package]] +name = "quickcheck" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "588f6378e4dd99458b60ec275b4477add41ce4fa9f64dcba6f15adccb19b50d6" +dependencies = [ + "env_logger", + "log", + "rand", +] + +[[package]] +name = "quickcheck_macros" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f71ee38b42f8459a88d3362be6f9b841ad2d5421844f61eb1c59c11bff3ac14a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.60", +] + [[package]] name = "quote" version = "1.0.36" @@ -288,6 +339,24 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + [[package]] name = "redox_syscall" version = "0.5.1" @@ -472,6 +541,12 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "winapi-util" version = "0.1.8" diff --git a/Cargo.toml b/Cargo.toml index 3ee7059..f304efd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,4 +40,6 @@ unicode-width = "0.1.10" [dev-dependencies] dprint-development = "0.10.1" +quickcheck = "1.0.3" +quickcheck_macros = "1.1.0" serde_json = { version = "1.0" } diff --git a/src/generation/generate.rs b/src/generation/generate.rs index 227fe4b..76ab139 100644 --- a/src/generation/generate.rs +++ b/src/generation/generate.rs @@ -304,10 +304,29 @@ fn gen_block_quote(block_quote: &BlockQuote, context: &mut Context) -> PrintItem context.mark_in_block_quotes(|context, block_quote_count| { let mut items = PrintItems::new(); + let children_items = gen_nodes(&block_quote.children, context); + if children_items.is_empty() { + let newline_count = context.get_new_lines_in_range(block_quote.range.start, block_quote.range.end); + for i in 0..std::cmp::max(1, newline_count) { + if i > 0 { + items.push_signal(Signal::NewLine); + } + // We use a condition here primarily to prevent a parent + // `gen_block_quote` loop from wrapping this string with + // another layer of ">". + items.push_condition(if_true( + "angleBracketIfStartOfLine", + condition_resolvers::is_start_of_line(), + ir_helpers::gen_from_string(&">".repeat(block_quote_count)), + )); + } + return items; + } + // add a > for any string that is on the start of a line // Note: This is extremely hacky let mut indent_level = 0; - for print_item in gen_nodes(&block_quote.children, context).iter() { + for print_item in children_items.iter() { match print_item { PrintItem::String(text) => { items.push_condition(if_true( @@ -488,7 +507,7 @@ fn gen_str(text: &str, context: &mut Context) -> PrintItems { } pub fn add_char(&mut self, character: char) { - if character == '\n' || character == ' ' { + if utils::is_commonmark_whitespace(character) { if self.context.configuration.text_wrap == TextWrap::Maintain && character == '\n' { self.newline(); } else { diff --git a/src/generation/utils.rs b/src/generation/utils.rs index 7bfaeb7..e2903d5 100644 --- a/src/generation/utils.rs +++ b/src/generation/utils.rs @@ -110,6 +110,16 @@ fn is_space_tab_or_newline(c: char) -> bool { matches!(c, ' ' | '\t' | '\r' | '\n') } +/// Returns true if the character is a whitespace character according +/// to the CommonMark spec. +/// +/// See +/// +/// Note: This is stricter than Rust's `char::is_whitespace`. +pub fn is_commonmark_whitespace(c: char) -> bool { + matches!(c, ' ' | '\t' | '\r' | '\n' | '\u{0b}' | '\u{0c}') +} + #[cfg(test)] mod test { use super::*; diff --git a/tests/fuzz_test.rs b/tests/fuzz_test.rs new file mode 100644 index 0000000..584effa --- /dev/null +++ b/tests/fuzz_test.rs @@ -0,0 +1,77 @@ +use dprint_plugin_markdown::configuration::ConfigurationBuilder; +use dprint_plugin_markdown::format_text; +use quickcheck::TestResult; +use quickcheck_macros::quickcheck; + +#[quickcheck] +#[ignore] // Due to randomness, this can lead to sporadic errors. +fn check_idempotency(input: String) -> TestResult { + if !input.chars().all(|c| c.is_ascii_graphic() || c == '\n' || c == '\r') { + // Non-graphical characters (like NUL bytes) are known to break + // idempotency. + return TestResult::discard(); + } + + TestResult::from_bool(is_idempotent(&input)) +} + +#[test] +fn test_simple() { + assert!(is_idempotent("Lorem ipsum\n\n\nFoo bar")); +} + +#[test] +fn test_list() { + assert!(is_idempotent("* Lorem ipsum\n* Foo bar")); +} + +#[test] +fn test_tab() { + assert!(is_idempotent("\t")); +} + +#[test] +#[should_panic] +fn test_vertical_tab() { + assert!(is_idempotent("*\u{000b}")); +} + +#[test] +fn test_nul_tab() { + assert!(is_idempotent("\0\t\0")); +} + +#[test] +fn test_paragraph_blockquote() { + assert!(is_idempotent("Lorem ipsum.\n>")); +} + +/// Can `input` be format twice and give the same result? +fn is_idempotent(input: &str) -> bool { + let config = ConfigurationBuilder::new().build(); + + let format = |input| format_text(input, &config, |_, _, _| Ok(None)); + + let Ok(Some(text1)) = format(input) else { + // Input was already formatted, or we cannot format this input. + return true; + }; + + match format(&text1) { + Ok(None) => true, // Reached fix point => success. + Ok(Some(text2)) => { + // Second pass changed something => failure. + eprintln!("Input: {input:?}"); + eprintln!("Pass 1: {text1:?}"); + eprintln!("Pass 2: {text2:?}"); + false + } + Err(err) => { + // Second pass failed => failure. + eprintln!("Input: {input:?}"); + eprintln!("Pass 1: {text1:?}"); + eprintln!("Pass 2: {err}"); + false + } + } +} diff --git a/tests/specs/BlockQuotes/Empty.txt b/tests/specs/BlockQuotes/Empty.txt new file mode 100644 index 0000000..3bb67e9 --- /dev/null +++ b/tests/specs/BlockQuotes/Empty.txt @@ -0,0 +1,46 @@ +!! Empty blockquote +> + +[expect] +> + +!! Empty blockquote with newline +> +> + +[expect] +> +> + +!! Empty blockquote with trailing whitespace +> +> + +[expect] +> +> + +!! Nested empty blockquote +>> +>> + +[expect] +>> +>> + +!! Nested empty blockquote with space between quotes +> > +> > + +[expect] +>> +>> + +!! Empty blockquote after paragraph +Paragraph +> + +[expect] +Paragraph + +>