From 223627765806880a7eadec52ff5df548930ce09b Mon Sep 17 00:00:00 2001 From: Leynos Date: Sat, 27 Sep 2025 18:33:37 +0100 Subject: [PATCH 1/5] Unify fence tracking across modules - Introduce a shared `FenceTracker` in `wrap::fence` so all features reuse consistent fence semantics.\n- Replace bespoke toggles in stream processing, list renumbering, thematic break formatting, and footnote detection with the tracker.\n- Exercise the new tracker behaviour with dedicated unit tests. --- src/breaks.rs | 10 +++--- src/footnotes/lists.rs | 11 +++--- src/lists.rs | 10 +++--- src/process.rs | 23 +++++++------ src/wrap.rs | 12 +++---- src/wrap/fence.rs | 78 ++++++++++++++++++++++++++++-------------- src/wrap/tests.rs | 23 ++++++++++++- 7 files changed, 107 insertions(+), 60 deletions(-) diff --git a/src/breaks.rs b/src/breaks.rs index 3c70d578..f55f513b 100644 --- a/src/breaks.rs +++ b/src/breaks.rs @@ -4,7 +4,7 @@ use std::borrow::Cow; use regex::Regex; -use crate::wrap::is_fence; +use crate::wrap::FenceTracker; pub const THEMATIC_BREAK_LEN: usize = 70; @@ -43,16 +43,16 @@ static THEMATIC_BREAK_LINE: std::sync::LazyLock = #[must_use] pub fn format_breaks(lines: &[String]) -> Vec> { let mut out = Vec::with_capacity(lines.len()); - let mut in_code = false; + // Track fenced code blocks consistently while formatting breaks. + let mut fences = FenceTracker::default(); for line in lines { - if is_fence(line).is_some() { - in_code = !in_code; + if fences.observe(line) { out.push(Cow::Borrowed(line.as_str())); continue; } - if !in_code && THEMATIC_BREAK_RE.is_match(line.trim_end()) { + if !fences.in_fence() && THEMATIC_BREAK_RE.is_match(line.trim_end()) { out.push(Cow::Borrowed(THEMATIC_BREAK_LINE.as_str())); } else { out.push(Cow::Borrowed(line.as_str())); diff --git a/src/footnotes/lists.rs b/src/footnotes/lists.rs index 119d5f72..bc5d3f07 100644 --- a/src/footnotes/lists.rs +++ b/src/footnotes/lists.rs @@ -6,6 +6,7 @@ use regex::Captures; use super::parsing::{FOOTNOTE_LINE_RE, is_definition_continuation}; +use crate::wrap::FenceTracker; /// Find the trailing block of lines that satisfy a predicate. pub(super) fn trimmed_range(lines: &[String], predicate: F) -> (usize, usize) @@ -50,17 +51,15 @@ pub(super) fn has_h2_heading_before(lines: &[String], start: usize) -> bool { /// Check for existing footnote definitions before the block. pub(super) fn has_existing_footnote_block(lines: &[String], start: usize) -> bool { - let mut in_fence = false; + let mut fences = FenceTracker::default(); for l in &lines[..start] { - let t = l.trim_start(); - if t.starts_with("```") || t.starts_with("~~~") { - in_fence = !in_fence; + if fences.observe(l) { continue; } - if in_fence { + if fences.in_fence() { continue; } - let mut t = t; + let mut t = l.trim_start(); while let Some(rest) = t.strip_prefix('>') { t = rest.trim_start(); } diff --git a/src/lists.rs b/src/lists.rs index 4520a4b0..3e4fa98e 100644 --- a/src/lists.rs +++ b/src/lists.rs @@ -3,7 +3,7 @@ use regex::Regex; use std::collections::HashMap; -use crate::{breaks::THEMATIC_BREAK_RE, wrap::is_fence}; +use crate::{breaks::THEMATIC_BREAK_RE, wrap::FenceTracker}; /// Characters that mark formatted text at the start of a line. const FORMATTING_CHARS: [char; 3] = ['*', '_', '`']; @@ -86,18 +86,18 @@ pub fn renumber_lists(lines: &[String]) -> Vec { let mut out = Vec::with_capacity(lines.len()); let mut indent_stack: Vec = Vec::new(); let mut counters: HashMap = HashMap::new(); - let mut in_code = false; + // Track fenced code blocks consistently across list processing. + let mut fences = FenceTracker::default(); #[allow(clippy::unnecessary_map_or)] let mut prev_blank = lines.first().map_or(true, |l| l.trim().is_empty()); for line in lines { - if is_fence(line).is_some() { - in_code = !in_code; + if fences.observe(line) { out.push(line.clone()); prev_blank = false; continue; } - if in_code { + if fences.in_fence() { out.push(line.clone()); prev_blank = line.trim().is_empty(); continue; diff --git a/src/process.rs b/src/process.rs index 24fd3135..5cef3989 100644 --- a/src/process.rs +++ b/src/process.rs @@ -6,7 +6,7 @@ use crate::{ footnotes::convert_footnotes, html::convert_html_tables, table::reflow_table, - wrap::{self, wrap_text}, + wrap::{FenceTracker, wrap_text}, }; /// Column width used when wrapping text. @@ -66,17 +66,17 @@ fn flush_buffer(buf: &mut Vec, in_table: &mut bool, out: &mut Vec, - in_code: &mut bool, in_table: &mut bool, out: &mut Vec, + fences: &mut FenceTracker, ) -> bool { - if wrap::is_fence(line).is_some() { - flush_buffer(buf, in_table, out); - *in_code = !*in_code; - out.push(line.to_string()); - return true; + if !fences.observe(line) { + return false; } - false + + flush_buffer(buf, in_table, out); + out.push(line.to_string()); + true } /// Buffers table lines, returning `true` when a line was consumed. @@ -155,15 +155,16 @@ pub fn process_stream_inner(lines: &[String], opts: Options) -> Vec { let mut out = Vec::new(); let mut buf = Vec::new(); - let mut in_code = false; + // Track fences so subsequent logic respects shared semantics. + let mut fence_tracker = FenceTracker::default(); let mut in_table = false; for line in &pre { - if handle_fence_line(line, &mut buf, &mut in_code, &mut in_table, &mut out) { + if handle_fence_line(line, &mut buf, &mut in_table, &mut out, &mut fence_tracker) { continue; } - if in_code { + if fence_tracker.in_fence() { out.push(line.to_string()); continue; } diff --git a/src/wrap.rs b/src/wrap.rs index 7b127df1..8b204073 100644 --- a/src/wrap.rs +++ b/src/wrap.rs @@ -15,7 +15,7 @@ mod fence; mod line_buffer; mod tokenize; pub(crate) use self::line_buffer::LineBuffer; -pub use fence::is_fence; +pub use fence::{FenceTracker, is_fence}; /// Token emitted by the `tokenize::segment_inline` parser and used by /// higher-level wrappers. /// @@ -328,9 +328,8 @@ pub fn wrap_text(lines: &[String], width: usize) -> Vec { let mut out = Vec::new(); let mut buf: Vec<(String, bool)> = Vec::new(); let mut indent = String::new(); - let mut in_code = false; - // Track the currently open fence: (marker char, run length), e.g., ('`', 4) or ('~', 3). - let mut fence_state: Option<(char, usize)> = None; + // Track fenced code blocks so wrapping honours shared fence semantics. + let mut fence_tracker = FenceTracker::default(); for line in lines { if fence::handle_fence_line( @@ -339,13 +338,12 @@ pub fn wrap_text(lines: &[String], width: usize) -> Vec { &mut indent, width, line, - &mut in_code, - &mut fence_state, + &mut fence_tracker, ) { continue; } - if in_code { + if fence_tracker.in_fence() { out.push(line.clone()); continue; } diff --git a/src/wrap/fence.rs b/src/wrap/fence.rs index 74701f24..d5fc86bb 100644 --- a/src/wrap/fence.rs +++ b/src/wrap/fence.rs @@ -46,37 +46,65 @@ pub(crate) fn handle_fence_line( indent: &mut String, width: usize, line: &str, - in_code: &mut bool, - fence_state: &mut Option<(char, usize)>, + tracker: &mut FenceTracker, ) -> bool { - if let Some((_f_indent, fence, _info)) = is_fence(line) { - super::flush_paragraph(out, buf, indent, width); - buf.clear(); - indent.clear(); + if !tracker.observe(line) { + return false; + } + + super::flush_paragraph(out, buf, indent, width); + buf.clear(); + indent.clear(); + out.push(line.to_string()); + true +} - // Determine fence marker kind and length to manage open/close state. - let marker_ch = fence.chars().next().unwrap_or('`'); - let marker_len = fence.chars().count(); +/// Tracks Markdown fenced code block state across lines. +/// +/// The tracker centralises fence matching logic so that callers share the +/// same semantics for opening and closing blocks. +#[derive(Default)] +pub struct FenceTracker { + state: Option<(char, usize)>, +} - if *in_code { - if let Some((open_ch, open_len)) = fence_state { - // Only close if the marker matches and its length is >= opened length. - if marker_ch == *open_ch && marker_len >= *open_len { - *in_code = false; - *fence_state = None; - } +impl FenceTracker { + /// Create a new tracker with no active fence. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Update the tracker with a potential fence line. + /// + /// Returns `true` when the line is treated as a fence marker and updates + /// the internal state accordingly. + #[must_use] + pub fn observe(&mut self, line: &str) -> bool { + let Some((_indent, fence, _info)) = is_fence(line) else { + return false; + }; + + let mut chars = fence.chars(); + let marker_ch = chars.next().unwrap_or('`'); + let marker_len = chars.count() + 1; + + match self.state { + Some((open_ch, open_len)) if marker_ch == open_ch && marker_len >= open_len => { + self.state = None; + } + Some(_) => {} + None => { + self.state = Some((marker_ch, marker_len)); } - // Re-emit the fence line unmodified. - out.push(line.to_string()); - return true; } - // Open a new fenced block. - *in_code = true; - *fence_state = Some((marker_ch, marker_len)); - out.push(line.to_string()); - return true; + true } - false + /// Check whether the tracker is currently inside a fenced block. + #[must_use] + pub fn in_fence(&self) -> bool { + self.state.is_some() + } } diff --git a/src/wrap/tests.rs b/src/wrap/tests.rs index 26bcc824..602e2361 100644 --- a/src/wrap/tests.rs +++ b/src/wrap/tests.rs @@ -9,7 +9,7 @@ use super::{ LineBuffer, attach_punctuation_to_previous_line, determine_token_span, tokenize::segment_inline, wrap_preserving_code, }; -use crate::wrap::wrap_text; +use crate::wrap::{FenceTracker, wrap_text}; #[rstest] #[case("`code`!", "`code`!")] @@ -332,3 +332,24 @@ fn wrap_text_keeps_trailing_spaces_for_bullet_final_line() { vec!["- word1".to_string(), " word2 ".to_string()] ); } + +#[test] +fn fence_tracker_closes_matching_markers() { + let mut tracker = FenceTracker::default(); + assert!(!tracker.in_fence()); + assert!(tracker.observe("```rust")); + assert!(tracker.in_fence()); + assert!(tracker.observe("```")); + assert!(!tracker.in_fence()); +} + +#[test] +fn fence_tracker_requires_matching_marker_to_close() { + let mut tracker = FenceTracker::default(); + assert!(tracker.observe("```")); + assert!(tracker.in_fence()); + assert!(tracker.observe("~~~")); + assert!(tracker.in_fence()); + assert!(tracker.observe("````")); + assert!(!tracker.in_fence()); +} From 93d3b665831008f9733104147a7da9d8276d4427 Mon Sep 17 00:00:00 2001 From: Leynos Date: Sat, 27 Sep 2025 20:47:45 +0100 Subject: [PATCH 2/5] Add tests for shared fence tracker Document FenceTracker usage and cover its behaviour with new unit and integration tests. --- src/wrap/fence.rs | 13 +++++++++++ src/wrap/tests.rs | 15 ++++++++++++ tests/wrap/fence_behaviour.rs | 43 +++++++++++++++++++++++++++++++++++ tests/wrap/mod.rs | 1 + 4 files changed, 72 insertions(+) create mode 100644 tests/wrap/fence_behaviour.rs diff --git a/src/wrap/fence.rs b/src/wrap/fence.rs index d5fc86bb..6a93cac9 100644 --- a/src/wrap/fence.rs +++ b/src/wrap/fence.rs @@ -63,6 +63,19 @@ pub(crate) fn handle_fence_line( /// /// The tracker centralises fence matching logic so that callers share the /// same semantics for opening and closing blocks. +/// +/// # Examples +/// +/// ``` +/// use mdtablefix::wrap::FenceTracker; +/// +/// let mut tracker = FenceTracker::new(); +/// assert!(!tracker.in_fence()); +/// assert!(tracker.observe("```rust")); +/// assert!(tracker.in_fence()); +/// assert!(tracker.observe("```")); +/// assert!(!tracker.in_fence()); +/// ``` #[derive(Default)] pub struct FenceTracker { state: Option<(char, usize)>, diff --git a/src/wrap/tests.rs b/src/wrap/tests.rs index 602e2361..5abd98c3 100644 --- a/src/wrap/tests.rs +++ b/src/wrap/tests.rs @@ -333,6 +333,12 @@ fn wrap_text_keeps_trailing_spaces_for_bullet_final_line() { ); } +#[test] +fn fence_tracker_new_starts_outside_fence() { + let tracker = FenceTracker::new(); + assert!(!tracker.in_fence()); +} + #[test] fn fence_tracker_closes_matching_markers() { let mut tracker = FenceTracker::default(); @@ -343,6 +349,15 @@ fn fence_tracker_closes_matching_markers() { assert!(!tracker.in_fence()); } +#[test] +fn fence_tracker_ignores_shorter_closing_marker() { + let mut tracker = FenceTracker::new(); + assert!(tracker.observe("````")); + assert!(tracker.in_fence()); + assert!(tracker.observe("```")); + assert!(tracker.in_fence()); +} + #[test] fn fence_tracker_requires_matching_marker_to_close() { let mut tracker = FenceTracker::default(); diff --git a/tests/wrap/fence_behaviour.rs b/tests/wrap/fence_behaviour.rs new file mode 100644 index 00000000..de9b24cf --- /dev/null +++ b/tests/wrap/fence_behaviour.rs @@ -0,0 +1,43 @@ +//! Behavioural tests for fence-aware wrapping. + +use super::*; + +#[test] +fn wrap_respects_fence_boundaries_in_paragraphs() { + let first_paragraph = concat!( + "This introductory paragraph is intentionally verbose to ensure that wrapping ", + "is required before reaching the fenced code block, demonstrating how the ", + "tracker suspends prose formatting once a fence begins.", + ); + let closing_paragraph = concat!( + "This closing paragraph is equally loquacious so that we can prove wrapping ", + "resumes immediately after the fenced block without altering the code content.", + ); + let code_line = concat!( + "fn demonstrate() { println!(\"This code line intentionally exceeds eighty characters ", + "to ensure the wrapping logic would normally split it if fences were not honoured.\"); }", + ); + let input = lines_vec![first_paragraph, "```", code_line, "```", closing_paragraph]; + let output = process_stream(&input); + + let fence_positions: Vec = output + .iter() + .enumerate() + .filter_map(|(idx, line)| (line == "```").then_some(idx)) + .collect(); + assert_eq!(fence_positions.len(), 2, "expected exactly two fence markers"); + assert!(output.contains(&code_line.to_string()), "expected code line to remain intact"); + + let before_fence = &output[..fence_positions[0]]; + assert!(before_fence.len() > 1, "prose before the fence should wrap"); + + let after_fence = &output[fence_positions[1] + 1..]; + assert!(after_fence.len() > 1, "prose after the fence should resume wrapping"); + assert!( + after_fence + .iter() + .any(|line| line.contains("closing paragraph is equally loquacious")), + "expected trailing paragraph content after fence", + ); +} + diff --git a/tests/wrap/mod.rs b/tests/wrap/mod.rs index b60c62d9..deb633e5 100644 --- a/tests/wrap/mod.rs +++ b/tests/wrap/mod.rs @@ -15,4 +15,5 @@ mod footnotes; mod blockquotes; mod hard_line_breaks; mod links; +mod fence_behaviour; mod cli; From 8906b7954d6bb200d42f000303a596cc000e924d Mon Sep 17 00:00:00 2001 From: Leynos Date: Sat, 27 Sep 2025 21:11:38 +0100 Subject: [PATCH 3/5] Clarify fence tracker contract --- src/wrap/fence.rs | 9 +++++++-- src/wrap/tests.rs | 9 +++++++++ tests/wrap/fence_behaviour.rs | 1 + 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/wrap/fence.rs b/src/wrap/fence.rs index 6a93cac9..d176b66d 100644 --- a/src/wrap/fence.rs +++ b/src/wrap/fence.rs @@ -76,7 +76,7 @@ pub(crate) fn handle_fence_line( /// assert!(tracker.observe("```")); /// assert!(!tracker.in_fence()); /// ``` -#[derive(Default)] +#[derive(Default, Debug)] pub struct FenceTracker { state: Option<(char, usize)>, } @@ -92,6 +92,11 @@ impl FenceTracker { /// /// Returns `true` when the line is treated as a fence marker and updates /// the internal state accordingly. + /// + /// # Panics + /// + /// Panics when the fence regular expression yields an empty marker, which + /// would indicate the regex is inconsistent with Markdown fence rules. #[must_use] pub fn observe(&mut self, line: &str) -> bool { let Some((_indent, fence, _info)) = is_fence(line) else { @@ -99,7 +104,7 @@ impl FenceTracker { }; let mut chars = fence.chars(); - let marker_ch = chars.next().unwrap_or('`'); + let marker_ch = chars.next().expect("FENCE_RE guarantees a non-empty fence"); let marker_len = chars.count() + 1; match self.state { diff --git a/src/wrap/tests.rs b/src/wrap/tests.rs index 5abd98c3..b9623d22 100644 --- a/src/wrap/tests.rs +++ b/src/wrap/tests.rs @@ -349,6 +349,15 @@ fn fence_tracker_closes_matching_markers() { assert!(!tracker.in_fence()); } +#[test] +fn fence_tracker_closes_with_info_string() { + let mut tracker = FenceTracker::new(); + assert!(tracker.observe("```rust")); + assert!(tracker.in_fence()); + assert!(tracker.observe("``` ")); + assert!(!tracker.in_fence()); +} + #[test] fn fence_tracker_ignores_shorter_closing_marker() { let mut tracker = FenceTracker::new(); diff --git a/tests/wrap/fence_behaviour.rs b/tests/wrap/fence_behaviour.rs index de9b24cf..3e267eda 100644 --- a/tests/wrap/fence_behaviour.rs +++ b/tests/wrap/fence_behaviour.rs @@ -19,6 +19,7 @@ fn wrap_respects_fence_boundaries_in_paragraphs() { ); let input = lines_vec![first_paragraph, "```", code_line, "```", closing_paragraph]; let output = process_stream(&input); + assert!(!output.iter().any(|l| l.starts_with("``") && l.len() == 2), "no false 2-tick fences"); let fence_positions: Vec = output .iter() From f5c830710ca0647f3081095d89461eec770b794a Mon Sep 17 00:00:00 2001 From: Leynos Date: Sun, 28 Sep 2025 15:42:15 +0100 Subject: [PATCH 4/5] Broaden fence tracker tests Add unit coverage for inline markers, tilde fences, and malformed sequences while reinforcing list renumbering and wrap behaviour with info string and short-marker scenarios. --- src/wrap/tests.rs | 48 +++++++++++++ tests/lists.rs | 52 ++++++++++++++ tests/wrap/fence_behaviour.rs | 129 ++++++++++++++++++++++++++++++++++ 3 files changed, 229 insertions(+) diff --git a/src/wrap/tests.rs b/src/wrap/tests.rs index b9623d22..ae1ba0de 100644 --- a/src/wrap/tests.rs +++ b/src/wrap/tests.rs @@ -377,3 +377,51 @@ fn fence_tracker_requires_matching_marker_to_close() { assert!(tracker.observe("````")); assert!(!tracker.in_fence()); } + +#[test] +fn fence_tracker_handles_inline_and_indented_markers() { + let lines = [ + "```rust code fence on one line```", + " ``` ", + "text outside fence", + "```", + concat!( + "text inside fence that should remain intact even if it exceeds the usual width ", + "limit when wrapping is enabled." + ), + "``` ", + "text after fence", + ]; + let mut tracker = FenceTracker::default(); + let results: Vec = lines.iter().map(|line| tracker.observe(line)).collect(); + assert_eq!( + results, + vec![true, true, false, true, false, true, false], + "expected fences to be recognised with inline markers and atypical spacing" + ); + assert!( + !tracker.in_fence(), + "tracker should end outside of a fence after matching closures" + ); +} + +#[test] +fn fence_tracker_handles_tilde_fences() { + let mut tracker = FenceTracker::new(); + assert!(tracker.observe("~~~~rust")); + assert!(tracker.in_fence()); + assert!(tracker.observe("~~~~")); + assert!(!tracker.in_fence()); +} + +#[rstest] +#[case("`")] +#[case("``")] +#[case("`~~`")] +#[case("~~`")] +#[case("`` ~~")] +fn fence_tracker_rejects_short_or_mixed_markers(#[case] line: &str) { + let mut tracker = FenceTracker::default(); + assert!(!tracker.observe(line)); + assert!(!tracker.in_fence()); +} diff --git a/tests/lists.rs b/tests/lists.rs index fcc976d6..0b7824d1 100644 --- a/tests/lists.rs +++ b/tests/lists.rs @@ -76,6 +76,58 @@ fn test_cli_renumber_option() { .stdout("1. a\n2. b\n"); } +#[test] +fn nested_lists_respect_fence_tracker() { + let input = lines_vec![ + "1. Outer list", + " ```", + " 1. Code block list", + " ```", + "9. Outer list continued", + " 4. Nested list", + " ```", + " - Malformed fence", + " ```", + " 8. Nested list continued", + ]; + let expected = lines_vec![ + "1. Outer list", + " ```", + " 1. Code block list", + " ```", + "2. Outer list continued", + " 1. Nested list", + " ```", + " - Malformed fence", + " ```", + " 2. Nested list continued", + ]; + assert_eq!(renumber_lists(&input), expected); +} + +#[test] +fn malformed_fences_do_not_break_list_renumbering() { + let input = lines_vec![ + "1. List before fence", + " ```", + " 1. Inside code block", + " ``", + " still inside fence", + " ```", + "7. List after fence", + ]; + let expected = lines_vec![ + "1. List before fence", + " ```", + " 1. Inside code block", + " ``", + " still inside fence", + " ```", + "2. List after fence", + ]; + assert_eq!(renumber_lists(&input), expected); +} + #[rstest( input, expected, diff --git a/tests/wrap/fence_behaviour.rs b/tests/wrap/fence_behaviour.rs index 3e267eda..cac68f9f 100644 --- a/tests/wrap/fence_behaviour.rs +++ b/tests/wrap/fence_behaviour.rs @@ -42,3 +42,132 @@ fn wrap_respects_fence_boundaries_in_paragraphs() { ); } +#[test] +fn wrap_respects_fences_with_info_strings_and_whitespace() { + let intro = concat!( + "The introductory paragraph needs enough length to force wrapping so that we can confirm ", + "behaviour when the subsequent fence appears with indentation." + ); + let outro = concat!( + "The final paragraph is equally verbose to verify that wrapping resumes immediately after ", + "the closing fence with trailing spaces." + ); + let rust_line = concat!( + " println!(\"This line deliberately exceeds eighty characters to prove that wrapping ", + "remains disabled inside the fenced block.\");" + ); + let json_line = concat!( + "{ \"message\": \"This JSON object should stay on one line even though it is wordy\" }" + ); + let input = lines_vec![ + intro, + " ```rust lineno=1", + rust_line, + " ``` ", + "```json ", + json_line, + "``` ", + outro, + ]; + let output = process_stream(&input); + + let fence_lines: Vec<_> = output + .iter() + .filter(|line| line.trim_start().starts_with("```")) + .collect(); + assert_eq!( + fence_lines.len(), + 4, + "expected both opening and closing fences to be retained" + ); + + assert!( + output.contains(&rust_line.to_string()), + "indented code lines should remain unchanged" + ); + assert!( + output.contains(&json_line.to_string()), + "info string fences should keep their payload intact" + ); + + let before_fence: Vec<_> = output + .iter() + .take_while(|line| !line.trim_start().starts_with("```")) + .collect(); + assert!( + before_fence.len() > 1, + "introductory paragraph should wrap before the fence" + ); + + let after_fence: Vec<_> = output + .iter() + .rev() + .take_while(|line| !line.trim_start().starts_with("```")) + .collect(); + assert!( + after_fence.len() > 1, + "closing paragraph should wrap after the fence" + ); +} + +#[test] +fn wrap_does_not_close_on_shorter_closing_marker() { + let intro = concat!( + "This paragraph intentionally spans more than eighty characters so that wrapping occurs ", + "before the fenced block." + ); + let code_line = concat!( + "print(\"short marker test that remains inside the code fence even when the closing marker ", + "is too short\")" + ); + let long_code_after_short = concat!( + "print(\"this line should stay intact because the shorter closing fence should not end the ", + "block prematurely even though the content is wide\")" + ); + let outro = concat!( + "After the fence we expect wrapping to resume, demonstrating that the tracker only closes ", + "when a marker of adequate length appears." + ); + let input = lines_vec![ + intro, + "````python", + code_line, + "```", + long_code_after_short, + "````", + outro, + ]; + let output = process_stream(&input); + + let long_line_count = output + .iter() + .filter(|line| line.contains("should stay intact")) + .count(); + assert_eq!( + long_line_count, 1, + "long code lines after the shorter closing marker must remain unwrapped inside the fence" + ); + + let fence_lines: Vec<_> = output + .iter() + .filter(|line| line.trim_start().starts_with("```")) + .collect(); + assert_eq!( + fence_lines.len(), + 3, + "all fence markers, including the ignored shorter one, should be retained" + ); + + let post_fence: Vec<_> = output + .iter() + .skip_while(|line| !line.trim_start().starts_with("````")) + .skip(1) + .take_while(|line| !line.trim_start().starts_with("```")) + .collect(); + assert!( + post_fence.len() > 1, + "paragraph after the fence should resume wrapping" + ); +} + + From 5eb0731f3f4bba6d925d0b5c49955f469a41beec Mon Sep 17 00:00:00 2001 From: Leynos Date: Sun, 28 Sep 2025 16:26:23 +0100 Subject: [PATCH 5/5] Fix post-fence slice in fence behaviour test --- tests/wrap/fence_behaviour.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/wrap/fence_behaviour.rs b/tests/wrap/fence_behaviour.rs index cac68f9f..2ead9a3a 100644 --- a/tests/wrap/fence_behaviour.rs +++ b/tests/wrap/fence_behaviour.rs @@ -158,16 +158,21 @@ fn wrap_does_not_close_on_shorter_closing_marker() { "all fence markers, including the ignored shorter one, should be retained" ); - let post_fence: Vec<_> = output + let closing_idx = output .iter() - .skip_while(|line| !line.trim_start().starts_with("````")) - .skip(1) - .take_while(|line| !line.trim_start().starts_with("```")) - .collect(); + .rposition(|line| line.trim_start().starts_with("````")) + .expect("closing fence should exist"); + let post_fence = &output[closing_idx + 1..]; assert!( post_fence.len() > 1, "paragraph after the fence should resume wrapping" ); + assert!( + post_fence + .iter() + .all(|line| !line.trim_start().starts_with("```")), + "trailing paragraph must not be treated as fenced content" + ); }