Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions src/breaks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -43,16 +43,16 @@ static THEMATIC_BREAK_LINE: std::sync::LazyLock<String> =
#[must_use]
pub fn format_breaks(lines: &[String]) -> Vec<Cow<'_, str>> {
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;
Comment thread
leynos marked this conversation as resolved.
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()));
Expand Down
11 changes: 5 additions & 6 deletions src/footnotes/lists.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<F>(lines: &[String], predicate: F) -> (usize, usize)
Expand Down Expand Up @@ -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();
}
Expand Down
10 changes: 5 additions & 5 deletions src/lists.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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] = ['*', '_', '`'];
Expand Down Expand Up @@ -86,18 +86,18 @@ pub fn renumber_lists(lines: &[String]) -> Vec<String> {
let mut out = Vec::with_capacity(lines.len());
let mut indent_stack: Vec<usize> = Vec::new();
let mut counters: HashMap<usize, usize> = 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;
Comment thread
leynos marked this conversation as resolved.
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;
Expand Down
23 changes: 12 additions & 11 deletions src/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -66,17 +66,17 @@ fn flush_buffer(buf: &mut Vec<String>, in_table: &mut bool, out: &mut Vec<String
fn handle_fence_line(
line: &str,
buf: &mut Vec<String>,
in_code: &mut bool,
in_table: &mut bool,
out: &mut Vec<String>,
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.
Expand Down Expand Up @@ -155,15 +155,16 @@ pub fn process_stream_inner(lines: &[String], opts: Options) -> Vec<String> {

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;
}
Expand Down
12 changes: 5 additions & 7 deletions src/wrap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -328,9 +328,8 @@ pub fn wrap_text(lines: &[String], width: usize) -> Vec<String> {
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(
Expand All @@ -339,13 +338,12 @@ pub fn wrap_text(lines: &[String], width: usize) -> Vec<String> {
&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;
}
Expand Down
96 changes: 71 additions & 25 deletions src/wrap/fence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,37 +46,83 @@ 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;
}

// 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();
super::flush_paragraph(out, buf, indent, width);
buf.clear();
indent.clear();
out.push(line.to_string());
true
}

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;
}
/// 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.
///
/// # 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, Debug)]
pub struct FenceTracker {
state: Option<(char, usize)>,
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
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.
///
/// # 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 {
return false;
};

let mut chars = fence.chars();
let marker_ch = chars.next().expect("FENCE_RE guarantees a non-empty fence");
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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

false
/// Check whether the tracker is currently inside a fenced block.
#[must_use]
pub fn in_fence(&self) -> bool {
self.state.is_some()
}
}
95 changes: 94 additions & 1 deletion src/wrap/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`!")]
Expand Down Expand Up @@ -332,3 +332,96 @@ fn wrap_text_keeps_trailing_spaces_for_bullet_final_line() {
vec!["- word1".to_string(), " word2 ".to_string()]
);
}

#[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();
assert!(!tracker.in_fence());
assert!(tracker.observe("```rust"));
assert!(tracker.in_fence());
Comment thread
leynos marked this conversation as resolved.
assert!(tracker.observe("```"));
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();
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();
assert!(tracker.observe("```"));
assert!(tracker.in_fence());
assert!(tracker.observe("~~~"));
assert!(tracker.in_fence());
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<bool> = 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());
}
Comment on lines +336 to +427
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Enforce the 400-line ceiling for this module. Move the new FenceTracker suite into a dedicated submodule (for example, tests::fence_tracker) so that src/wrap/tests.rs shrinks back below 400 lines; this file is now 428 lines long and violates the repository rule that test modules stay under 400 lines. As per coding guidelines.

🤖 Prompt for AI Agents
In src/wrap/tests.rs around lines 336 to 427, the new FenceTracker test suite
must be moved into its own test submodule to keep this file under the 400-line
limit; extract the entire block of FenceTracker tests into a new test file
(e.g., src/wrap/tests/fence_tracker.rs) and replace the removed block in
src/wrap/tests.rs with a mod declaration that pulls in the new tests (e.g., pub
mod fence_tracker or #[cfg(test)] mod fence_tracker as appropriate). Ensure the
new file has the same use/imports needed by the tests, preserves attributes
(#[test] / #[rstest]) and visibility, and update Cargo or module paths if
necessary so the test build still discovers and runs them.

Loading
Loading