From 92f7d5b05b310f1746c818a879846af72c379bb1 Mon Sep 17 00:00:00 2001 From: Martin Geisler Date: Sat, 17 Jan 2026 13:40:07 +0100 Subject: [PATCH 1/3] test: add fuzz test to check for idempotency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This introduces a fuzz test which looks for cases where the Markdown formatter is not reaching a fix point in it’s first attempt. The idea is that a well-formatted Markdown file should not be changed if formatted again. Running QUICKCHECK_TESTS=1000000 cargo test --test fuzz_test check -- --ignored will typically find a failure case. I’ve added a few failing tests already. --- Cargo.lock | 79 ++++++++++++++++++++++++++++++++++++++++++++-- Cargo.toml | 2 ++ tests/fuzz_test.rs | 79 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 tests/fuzz_test.rs 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/tests/fuzz_test.rs b/tests/fuzz_test.rs new file mode 100644 index 0000000..57c3e46 --- /dev/null +++ b/tests/fuzz_test.rs @@ -0,0 +1,79 @@ +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] +#[should_panic] +fn test_nul_tab() { + assert!(is_idempotent("\0\t\0")); +} + +#[test] +#[should_panic] +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 + } + } +} From 483fca8444f2df14cf8886778e57441529145102 Mon Sep 17 00:00:00 2001 From: Martin Geisler Date: Sat, 17 Jan 2026 16:04:22 +0100 Subject: [PATCH 2/3] fix: formatting text with null characters or vertical tabs Previously, the plugin would panic when encountering certain control characters like null bytes or vertical tabs within the text. This was because these characters were being sent raw to `dprint-core`, which expects only specific whitespace characters or valid text content, raising a debug panic for unexpected characters to prevent column tracking issues. This change updates `TextBuilder` to treat these characters (specifically `\t`, `\r`, `\n`, `\u{000b}`, `\u{000c}`) as whitespace. They will now be handled correctly by the whitespace collapsing logic or emitted as appropriate whitespace signals/items, preventing the panic and ensuring robust handling of arbitrary input. --- src/generation/generate.rs | 2 +- src/generation/utils.rs | 10 ++++++++++ tests/fuzz_test.rs | 1 - 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/generation/generate.rs b/src/generation/generate.rs index 227fe4b..e0e4f52 100644 --- a/src/generation/generate.rs +++ b/src/generation/generate.rs @@ -488,7 +488,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 index 57c3e46..c07ce94 100644 --- a/tests/fuzz_test.rs +++ b/tests/fuzz_test.rs @@ -37,7 +37,6 @@ fn test_vertical_tab() { } #[test] -#[should_panic] fn test_nul_tab() { assert!(is_idempotent("\0\t\0")); } From 83638962962b471e99c70854a153eecdcae58ab3 Mon Sep 17 00:00:00 2001 From: Martin Geisler Date: Sat, 17 Jan 2026 16:06:28 +0100 Subject: [PATCH 3/3] fix: empty block quotes not printing in output This ensures that block quotes with no children (e.g. '>') are still printed. --- src/generation/generate.rs | 21 +++++++++++++- tests/fuzz_test.rs | 1 - tests/specs/BlockQuotes/Empty.txt | 46 +++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 tests/specs/BlockQuotes/Empty.txt diff --git a/src/generation/generate.rs b/src/generation/generate.rs index e0e4f52..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( diff --git a/tests/fuzz_test.rs b/tests/fuzz_test.rs index c07ce94..584effa 100644 --- a/tests/fuzz_test.rs +++ b/tests/fuzz_test.rs @@ -42,7 +42,6 @@ fn test_nul_tab() { } #[test] -#[should_panic] fn test_paragraph_blockquote() { assert!(is_idempotent("Lorem ipsum.\n>")); } 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 + +>