From b5d4f0efbb91852185f358447c5ca5d9d97831ad Mon Sep 17 00:00:00 2001 From: Leynos Date: Sun, 20 Jul 2025 14:11:44 +0100 Subject: [PATCH 1/4] Refactor fence specifier handling and add tests --- README.md | 10 ++++------ src/fences.rs | 47 ++++++++++++++----------------------------- src/process.rs | 52 ++++++++++++++++++++++++++++++------------------ tests/cli.rs | 15 ++++++++++++++ tests/fences.rs | 53 +++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 120 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 9ba88a91..67898450 100644 --- a/README.md +++ b/README.md @@ -119,12 +119,10 @@ use std::path::Path; fn main() -> std::io::Result<()> { let lines = vec!["|A|B|".to_string(), "|1|2|".to_string()]; - let opts = Options { - wrap: true, - ellipsis: true, - fences: true, - footnotes: false, - }; + let opts = Options::default() + .with_wrap(true) + .with_ellipsis(true) + .with_fences(true); let fixed = process_stream_opts(&lines, opts); println!("{}", fixed.join("\n")); rewrite(Path::new("table.md"))?; diff --git a/src/fences.rs b/src/fences.rs index 13fbf50a..91454e53 100644 --- a/src/fences.rs +++ b/src/fences.rs @@ -66,48 +66,31 @@ pub fn compress_fences(lines: &[String]) -> Vec { /// ``` #[must_use] pub fn attach_orphan_specifiers(lines: &[String]) -> Vec { - let mut out = Vec::with_capacity(lines.len()); + let mut out: Vec = Vec::with_capacity(lines.len()); let mut in_fence = false; - let mut iter = lines.iter().peekable(); - - while let Some(line) = iter.next() { + for line in lines { let trimmed = line.trim(); - if !in_fence && ORPHAN_LANG_RE.is_match(trimmed) { - let mut peek_ahead = iter.clone(); - let mut found_fence = false; - - while let Some(next_line) = peek_ahead.peek() { - let next_trimmed = next_line.trim(); - if next_trimmed.is_empty() { - peek_ahead.next(); - } else if next_trimmed == "```" { - found_fence = true; - break; - } else { - break; + if trimmed.starts_with("```") { + if in_fence { + in_fence = false; + } else { + while matches!(out.last(), Some(l) if l.trim().is_empty()) { + out.pop(); } - } - - if found_fence { - while let Some(next_line) = iter.peek() { - if next_line.trim().is_empty() { - iter.next(); - } else { - break; + if let Some(prev) = out.last() { + let lang = prev.trim().to_string(); + if ORPHAN_LANG_RE.is_match(&lang) { + out.pop(); + out.push(format!("```{lang}")); + in_fence = true; + continue; } } - iter.next(); - out.push(format!("```{trimmed}")); in_fence = true; - continue; } } - if trimmed.starts_with("```") { - in_fence = !in_fence; - } - out.push(line.clone()); } out diff --git a/src/process.rs b/src/process.rs index bc391858..09be4b54 100644 --- a/src/process.rs +++ b/src/process.rs @@ -14,7 +14,7 @@ use crate::{ clippy::struct_excessive_bools, reason = "Options map directly to CLI flags" )] -#[derive(Clone, Copy)] +#[derive(Clone, Copy, Default)] pub struct Options { /// Enable paragraph wrapping pub wrap: bool, @@ -26,6 +26,36 @@ pub struct Options { pub footnotes: bool, } +impl Options { + /// Set `wrap` option and return the updated struct. + #[must_use] + pub fn with_wrap(mut self, wrap: bool) -> Self { + self.wrap = wrap; + self + } + + /// Set `ellipsis` option and return the updated struct. + #[must_use] + pub fn with_ellipsis(mut self, ellipsis: bool) -> Self { + self.ellipsis = ellipsis; + self + } + + /// Set `fences` option and return the updated struct. + #[must_use] + pub fn with_fences(mut self, fences: bool) -> Self { + self.fences = fences; + self + } + + /// Set `footnotes` option and return the updated struct. + #[must_use] + pub fn with_footnotes(mut self, footnotes: bool) -> Self { + self.footnotes = footnotes; + self + } +} + #[must_use] pub fn process_stream_inner(lines: &[String], opts: Options) -> Vec { let lines = if opts.fences { @@ -108,28 +138,12 @@ pub fn process_stream_inner(lines: &[String], opts: Options) -> Vec { #[must_use] pub fn process_stream(lines: &[String]) -> Vec { - process_stream_inner( - lines, - Options { - wrap: true, - ellipsis: false, - fences: false, - footnotes: false, - }, - ) + process_stream_inner(lines, Options::default().with_wrap(true)) } #[must_use] pub fn process_stream_no_wrap(lines: &[String]) -> Vec { - process_stream_inner( - lines, - Options { - wrap: false, - ellipsis: false, - fences: false, - footnotes: false, - }, - ) + process_stream_inner(lines, Options::default()) } #[must_use] diff --git a/tests/cli.rs b/tests/cli.rs index 597697f0..d67001b0 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -150,6 +150,21 @@ fn test_cli_fences_option() { ); } +#[test] +fn test_cli_fences_option_tilde() { + let output = Command::cargo_bin("mdtablefix") + .expect("Failed to create cargo command for mdtablefix") + .arg("--fences") + .write_stdin("~~~~rust\nfn main() {}\n~~~~\n") + .output() + .expect("Failed to execute mdtablefix command"); + assert!(output.status.success()); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + "```rust\nfn main() {}\n```\n" + ); +} + /// Ensures fence normalization runs before other processing. #[test] fn test_cli_fences_before_ellipsis() { diff --git a/tests/fences.rs b/tests/fences.rs index 7daff995..bffe9d16 100644 --- a/tests/fences.rs +++ b/tests/fences.rs @@ -25,6 +25,17 @@ fn compresses_tilde_fences() { assert_eq!(out, lines_vec!["```rust", "code", "```"]); } +#[test] +fn does_not_compress_mixed_fences() { + let input = lines_vec!["~~~rust", "code", "```"]; + let out = compress_fences(&input); + assert_eq!(out, lines_vec!["```rust", "code", "```"]); + + let input2 = lines_vec!["```rust", "code", "~~~"]; + let out2 = compress_fences(&input2); + assert_eq!(out2, lines_vec!["```rust", "code", "```"]); +} + #[test] fn leaves_other_lines_untouched() { let input = lines_vec!["~~", "``text``"]; @@ -59,3 +70,45 @@ fn fixes_orphaned_specifier_with_blank_line() { let out = attach_orphan_specifiers(&compress_fences(&input)); assert_eq!(out, lines_vec!["```Rust", "fn main() {}", "```"]); } + +#[test] +fn fixes_multiple_orphaned_specifiers() { + let input = lines_vec![ + "Rust", + "```", + "fn main() {}", + "```", + "Python", + "```", + "print('hi')", + "```", + ]; + let out = attach_orphan_specifiers(&compress_fences(&input)); + assert_eq!( + out, + lines_vec![ + "```Rust", + "fn main() {}", + "```", + "```Python", + "print('hi')", + "```" + ] + ); +} + +#[test] +fn does_not_attach_non_orphan_lines_before_fences() { + let input = lines_vec![ + "Rust code", + "```", + "fn main() {}", + "```", + "rust!", + "```", + "println!(\"hi\");", + "```", + ]; + let out = attach_orphan_specifiers(&input); + assert_eq!(out, input); +} From efd21311f02754c30346dc8067d1b0303532978a Mon Sep 17 00:00:00 2001 From: Leynos Date: Sun, 20 Jul 2025 14:42:39 +0100 Subject: [PATCH 2/4] Remove Options builder methods --- README.md | 10 ++++++---- src/process.rs | 38 +++++++------------------------------- 2 files changed, 13 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 67898450..3542b115 100644 --- a/README.md +++ b/README.md @@ -119,10 +119,12 @@ use std::path::Path; fn main() -> std::io::Result<()> { let lines = vec!["|A|B|".to_string(), "|1|2|".to_string()]; - let opts = Options::default() - .with_wrap(true) - .with_ellipsis(true) - .with_fences(true); + let opts = Options { + wrap: true, + ellipsis: true, + fences: true, + ..Default::default() + }; let fixed = process_stream_opts(&lines, opts); println!("{}", fixed.join("\n")); rewrite(Path::new("table.md"))?; diff --git a/src/process.rs b/src/process.rs index 09be4b54..4287537f 100644 --- a/src/process.rs +++ b/src/process.rs @@ -26,36 +26,6 @@ pub struct Options { pub footnotes: bool, } -impl Options { - /// Set `wrap` option and return the updated struct. - #[must_use] - pub fn with_wrap(mut self, wrap: bool) -> Self { - self.wrap = wrap; - self - } - - /// Set `ellipsis` option and return the updated struct. - #[must_use] - pub fn with_ellipsis(mut self, ellipsis: bool) -> Self { - self.ellipsis = ellipsis; - self - } - - /// Set `fences` option and return the updated struct. - #[must_use] - pub fn with_fences(mut self, fences: bool) -> Self { - self.fences = fences; - self - } - - /// Set `footnotes` option and return the updated struct. - #[must_use] - pub fn with_footnotes(mut self, footnotes: bool) -> Self { - self.footnotes = footnotes; - self - } -} - #[must_use] pub fn process_stream_inner(lines: &[String], opts: Options) -> Vec { let lines = if opts.fences { @@ -138,7 +108,13 @@ pub fn process_stream_inner(lines: &[String], opts: Options) -> Vec { #[must_use] pub fn process_stream(lines: &[String]) -> Vec { - process_stream_inner(lines, Options::default().with_wrap(true)) + process_stream_inner( + lines, + Options { + wrap: true, + ..Default::default() + }, + ) } #[must_use] From 63254b3efa60f5e17aa27e6bc5b1343de32405ab Mon Sep 17 00:00:00 2001 From: Leynos Date: Sun, 20 Jul 2025 17:55:57 +0100 Subject: [PATCH 3/4] Mark process_stream_no_wrap inline --- src/process.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/process.rs b/src/process.rs index 4287537f..d2c0335b 100644 --- a/src/process.rs +++ b/src/process.rs @@ -118,6 +118,7 @@ pub fn process_stream(lines: &[String]) -> Vec { } #[must_use] +#[inline] pub fn process_stream_no_wrap(lines: &[String]) -> Vec { process_stream_inner(lines, Options::default()) } From 57269c1069d85771af60eb640cd1b53121ae0c92 Mon Sep 17 00:00:00 2001 From: Leynos Date: Sun, 20 Jul 2025 19:11:31 +0100 Subject: [PATCH 4/4] Add WRAP_COLS constant and document no-wrap helper --- src/process.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/process.rs b/src/process.rs index d2c0335b..209ca989 100644 --- a/src/process.rs +++ b/src/process.rs @@ -9,6 +9,9 @@ use crate::{ wrap::{self, wrap_text}, }; +/// Column width used when wrapping text. +pub(crate) const WRAP_COLS: usize = 80; + /// Processing options controlling the behaviour of `process_stream_inner`. #[expect( clippy::struct_excessive_bools, @@ -96,7 +99,11 @@ pub fn process_stream_inner(lines: &[String], opts: Options) -> Vec { } } - let mut out = if opts.wrap { wrap_text(&out, 80) } else { out }; + let mut out = if opts.wrap { + wrap_text(&out, WRAP_COLS) + } else { + out + }; if opts.ellipsis { out = replace_ellipsis(&out); } @@ -117,6 +124,14 @@ pub fn process_stream(lines: &[String]) -> Vec { ) } +/// Process a Markdown stream without wrapping paragraphs. +/// +/// ``` +/// use mdtablefix::process_stream_no_wrap; +/// let lines = vec!["one".to_string(), "two".to_string()]; +/// let out = process_stream_no_wrap(&lines); +/// assert_eq!(out, lines); +/// ``` #[must_use] #[inline] pub fn process_stream_no_wrap(lines: &[String]) -> Vec {