From 1f01de087cee6499529b67382dd241661ed37f83 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Wed, 18 Feb 2026 14:52:25 -0300 Subject: [PATCH] fix(tui): consolidate md-table resize handling and fence parsing --- codex-rs/tui/src/markdown.rs | 54 +--- codex-rs/tui/src/streaming/controller.rs | 269 ++++++------------ codex-rs/tui/src/table_detect.rs | 346 ++++++++++++++++++++++- 3 files changed, 447 insertions(+), 222 deletions(-) diff --git a/codex-rs/tui/src/markdown.rs b/codex-rs/tui/src/markdown.rs index 34d9aec9d5..708bbf8190 100644 --- a/codex-rs/tui/src/markdown.rs +++ b/codex-rs/tui/src/markdown.rs @@ -94,16 +94,6 @@ fn unwrap_markdown_fences(markdown_source: &str) -> String { is_markdown: bool, } - fn strip_blockquote_prefix(line: &str) -> &str { - let mut rest = line.trim_start(); - loop { - let Some(stripped) = rest.strip_prefix('>') else { - return rest; - }; - rest = stripped.strip_prefix(' ').unwrap_or(stripped).trim_start(); - } - } - fn parse_open_fence(line: &str) -> Option { let without_newline = line.strip_suffix('\n').unwrap_or(line); let leading_ws = without_newline @@ -114,24 +104,12 @@ fn unwrap_markdown_fences(markdown_source: &str) -> String { if leading_ws > 3 { return None; } - let trimmed = strip_blockquote_prefix(&without_newline[leading_ws..]); - let marker = *trimmed.as_bytes().first()?; - if marker != b'`' && marker != b'~' { - return None; - } - let len = trimmed - .as_bytes() - .iter() - .take_while(|byte| **byte == marker) - .count(); - if len < 3 { - return None; - } - let rest = trimmed[len..].trim(); - let info = rest.split_whitespace().next().unwrap_or_default(); - let is_markdown = info.eq_ignore_ascii_case("md") || info.eq_ignore_ascii_case("markdown"); + let trimmed = &without_newline[leading_ws..]; + let fence_scan_text = table_detect::strip_blockquote_prefix(trimmed); + let (marker, len) = table_detect::parse_fence_marker(fence_scan_text)?; + let is_markdown = table_detect::is_markdown_fence_info(fence_scan_text, len); Some(Fence { - marker, + marker: marker as u8, len, is_markdown, }) @@ -147,25 +125,21 @@ fn unwrap_markdown_fences(markdown_source: &str) -> String { if leading_ws > 3 { return false; } - let trimmed = strip_blockquote_prefix(&without_newline[leading_ws..]); - if !trimmed.as_bytes().starts_with(&[fence.marker]) { - return false; + let trimmed = &without_newline[leading_ws..]; + let fence_scan_text = table_detect::strip_blockquote_prefix(trimmed); + if let Some((marker, len)) = table_detect::parse_fence_marker(fence_scan_text) { + marker as u8 == fence.marker + && len >= fence.len + && fence_scan_text[len..].trim().is_empty() + } else { + false } - let len = trimmed - .as_bytes() - .iter() - .take_while(|byte| **byte == fence.marker) - .count(); - if len < fence.len { - return false; - } - trimmed[len..].trim().is_empty() } fn markdown_fence_contains_table(content: &str) -> bool { let mut previous_non_empty: Option<&str> = None; for line in content.lines() { - let trimmed = strip_blockquote_prefix(line).trim(); + let trimmed = table_detect::strip_blockquote_prefix(line).trim(); if trimmed.is_empty() { continue; } diff --git a/codex-rs/tui/src/streaming/controller.rs b/codex-rs/tui/src/streaming/controller.rs index e36c786c2e..8a7b8e26f9 100644 --- a/codex-rs/tui/src/streaming/controller.rs +++ b/codex-rs/tui/src/streaming/controller.rs @@ -46,9 +46,12 @@ use std::path::PathBuf; use std::time::Duration; use std::time::Instant; +use crate::table_detect::FenceKind; +use crate::table_detect::FenceTracker; use crate::table_detect::is_table_delimiter_line; use crate::table_detect::is_table_header_line; use crate::table_detect::parse_table_segments; +use crate::table_detect::strip_blockquote_prefix; use super::StreamState; @@ -202,6 +205,7 @@ impl StreamCore { return; } let had_pending_queue = self.state.queued_len() > 0; + let had_live_tail = self.has_tail(); self.width = width; self.state.collector.set_width(width); if self.raw_source.is_empty() { @@ -210,10 +214,20 @@ impl StreamCore { self.recompute_streaming_render(); self.emitted_stable_len = self.emitted_stable_len.min(self.rendered_lines.len()); + if had_pending_queue + && self.emitted_stable_len == self.rendered_lines.len() + && self.emitted_stable_len > 0 + { + // If wrapped remainder compresses into fewer lines at the new width, + // keep at least one line un-emitted so pre-resize pending content is + // not skipped permanently. + self.emitted_stable_len -= 1; + } self.state.clear_queue(); - if self.emitted_stable_len > 0 && !had_pending_queue { + if self.emitted_stable_len > 0 && !had_pending_queue && !had_live_tail { // Avoid replaying already-emitted content after resize when no - // stable lines were waiting in the queue. + // stable lines were waiting in the queue and there was no mutable + // tail to preserve. self.enqueued_stable_len = self.rendered_lines.len(); return; } @@ -574,116 +588,31 @@ impl PlanStreamController { // Table holdback infrastructure // --------------------------------------------------------------------------- -/// Return fence marker character and run length for a potential fence line. -/// -/// Recognises backtick and tilde fences with a minimum run of 3. -/// Used by `parse_lines_with_fence_state` to track fence open/close transitions. -fn parse_fence_marker(line: &str) -> Option<(char, usize)> { - let mut chars = line.chars(); - let first = chars.next()?; - if first != '`' && first != '~' { - return None; - } - let mut len = 1usize; - for ch in chars { - if ch == first { - len += 1; - } else { - break; - } - } - if len < 3 { - return None; - } - Some((first, len)) -} - /// A source line annotated with whether it falls inside a fenced code block. #[cfg(test)] struct ParsedLine<'a> { text: &'a str, - fence_context: FenceContext, + fence_context: FenceKind, source_start: usize, } -/// Where a source line sits relative to fenced code blocks. -/// -/// Table holdback only applies to lines that are `Outside` or inside a -/// `Markdown` fence. Lines inside `Other` fences (e.g. `sh`, `rust`) are -/// ignored by the table scanner because their pipe characters are code, not -/// table syntax. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum FenceContext { - /// Not inside any fenced code block. - Outside, - /// Inside a `` ```md `` or `` ```markdown `` fence. - Markdown, - /// Inside a fence with a non-markdown info string. - Other, -} - -fn is_markdown_fence(trimmed_line: &str, marker_len: usize) -> bool { - let info = trimmed_line[marker_len..] - .split_whitespace() - .next() - .unwrap_or_default(); - info.eq_ignore_ascii_case("md") || info.eq_ignore_ascii_case("markdown") -} - /// Parse source into lines tagged with fenced-code context for table scanning. /// -/// Fence close markers must use the same marker character and at least the -/// opening marker length, with no trailing content. This avoids accidentally -/// closing on shorter markers that appear inside longer fences. +/// Uses [`FenceTracker`] from `table_detect` for fence state transitions. #[cfg(test)] fn parse_lines_with_fence_state(source: &str) -> Vec> { - let mut in_fence = false; - let mut fence_char = '\0'; - let mut fence_len = 0usize; - let mut fence_context = FenceContext::Other; + let mut tracker = FenceTracker::new(); let mut lines = Vec::new(); let mut source_start = 0usize; for raw_line in source.split('\n') { lines.push(ParsedLine { text: raw_line, - fence_context: if in_fence { - fence_context - } else { - FenceContext::Outside - }, + fence_context: tracker.kind(), source_start, }); - let leading_spaces = raw_line - .as_bytes() - .iter() - .take_while(|byte| **byte == b' ') - .count(); - if leading_spaces > 3 { - continue; - } - let trimmed = &raw_line[leading_spaces..]; - let fence_scan_text = strip_blockquote_prefix(trimmed); - if let Some((marker, len)) = parse_fence_marker(fence_scan_text) { - if !in_fence { - in_fence = true; - fence_char = marker; - fence_len = len; - fence_context = if is_markdown_fence(fence_scan_text, len) { - FenceContext::Markdown - } else { - FenceContext::Other - }; - } else if marker == fence_char - && len >= fence_len - && fence_scan_text[len..].trim().is_empty() - { - in_fence = false; - fence_len = 0; - fence_context = FenceContext::Other; - } - } + tracker.advance(raw_line); source_start = source_start .saturating_add(raw_line.len()) .saturating_add(1); @@ -692,20 +621,6 @@ fn parse_lines_with_fence_state(source: &str) -> Vec> { lines } -/// Peel all leading `>` blockquote markers from a line. -/// -/// Tables can appear inside blockquotes (`> | A | B |`), so the holdback -/// scanner must strip these markers before checking for table syntax. -fn strip_blockquote_prefix(line: &str) -> &str { - let mut rest = line.trim_start(); - loop { - let Some(stripped) = rest.strip_prefix('>') else { - return rest; - }; - rest = stripped.strip_prefix(' ').unwrap_or(stripped).trim_start(); - } -} - /// Strip blockquote prefixes and return the trimmed text if it contains /// pipe-table segments, or `None` otherwise. fn table_candidate_text(line: &str) -> Option<&str> { @@ -734,33 +649,14 @@ enum TableHoldbackState { #[derive(Clone, Copy)] struct PreviousLineState { source_start: usize, - fence_context: FenceContext, + fence_kind: FenceKind, is_header: bool, } -#[derive(Clone, Copy)] -struct FenceScanState { - in_fence: bool, - fence_char: char, - fence_len: usize, - fence_context: FenceContext, -} - -impl FenceScanState { - fn new() -> Self { - Self { - in_fence: false, - fence_char: '\0', - fence_len: 0, - fence_context: FenceContext::Other, - } - } -} - /// Incremental scanner for table holdback state on append-only source streams. struct TableHoldbackScanner { source_offset: usize, - fence_state: FenceScanState, + fence_tracker: FenceTracker, previous_line: Option, pending_header_start: Option, confirmed_table_start: Option, @@ -770,7 +666,7 @@ impl TableHoldbackScanner { fn new() -> Self { Self { source_offset: 0, - fence_state: FenceScanState::new(), + fence_tracker: FenceTracker::new(), previous_line: None, pending_header_start: None, confirmed_table_start: None, @@ -814,13 +710,9 @@ impl TableHoldbackScanner { fn push_line(&mut self, source_line: &str) { let line = source_line.strip_suffix('\n').unwrap_or(source_line); let source_start = self.source_offset; - let fence_context = if self.fence_state.in_fence { - self.fence_state.fence_context - } else { - FenceContext::Outside - }; + let fence_kind = self.fence_tracker.kind(); - let candidate_text = if fence_context == FenceContext::Other { + let candidate_text = if fence_kind == FenceKind::Other { None } else { table_candidate_text(line) @@ -830,8 +722,8 @@ impl TableHoldbackScanner { if self.confirmed_table_start.is_none() && let Some(previous_line) = self.previous_line - && previous_line.fence_context != FenceContext::Other - && fence_context != FenceContext::Other + && previous_line.fence_kind != FenceKind::Other + && fence_kind != FenceKind::Other && previous_line.is_header && is_delimiter { @@ -840,7 +732,7 @@ impl TableHoldbackScanner { } if self.confirmed_table_start.is_none() && !line.trim().is_empty() { - if fence_context != FenceContext::Other && is_header { + if fence_kind != FenceKind::Other && is_header { self.pending_header_start = Some(source_start); } else { self.pending_header_start = None; @@ -849,54 +741,20 @@ impl TableHoldbackScanner { self.previous_line = Some(PreviousLineState { source_start, - fence_context, + fence_kind, is_header, }); - self.advance_fence_state(line); + self.fence_tracker.advance(line); self.source_offset = self.source_offset.saturating_add(source_line.len()); } - - fn advance_fence_state(&mut self, raw_line: &str) { - let leading_spaces = raw_line - .as_bytes() - .iter() - .take_while(|byte| **byte == b' ') - .count(); - if leading_spaces > 3 { - return; - } - - let trimmed = &raw_line[leading_spaces..]; - let fence_scan_text = strip_blockquote_prefix(trimmed); - if let Some((marker, len)) = parse_fence_marker(fence_scan_text) { - if !self.fence_state.in_fence { - self.fence_state.in_fence = true; - self.fence_state.fence_char = marker; - self.fence_state.fence_len = len; - self.fence_state.fence_context = if is_markdown_fence(fence_scan_text, len) { - FenceContext::Markdown - } else { - FenceContext::Other - }; - } else if marker == self.fence_state.fence_char - && len >= self.fence_state.fence_len - && fence_scan_text[len..].trim().is_empty() - { - self.fence_state.in_fence = false; - self.fence_state.fence_char = '\0'; - self.fence_state.fence_len = 0; - self.fence_state.fence_context = FenceContext::Other; - } - } - } } /// Scan `source` for pipe-table patterns outside of non-markdown fenced code /// blocks. /// /// Walks consecutive line pairs looking for a header + delimiter match. Lines -/// inside `FenceContext::Other` fences are skipped. The scan also peels +/// inside `FenceKind::Other` fences are skipped. The scan also peels /// blockquote prefixes (`>`) before checking for table syntax, so tables nested /// inside blockquotes are detected. /// @@ -910,8 +768,8 @@ fn table_holdback_state(source: &str) -> TableHoldbackState { let [header_line, delimiter_line] = pair else { continue; }; - if header_line.fence_context == FenceContext::Other - || delimiter_line.fence_context == FenceContext::Other + if header_line.fence_context == FenceKind::Other + || delimiter_line.fence_context == FenceKind::Other { continue; } @@ -932,7 +790,7 @@ fn table_holdback_state(source: &str) -> TableHoldbackState { let pending_header = lines.iter().rev().find(|line| !line.text.trim().is_empty()); if let Some(line) = pending_header - && line.fence_context != FenceContext::Other + && line.fence_context != FenceKind::Other && table_candidate_text(line.text).is_some_and(is_table_header_line) { return TableHoldbackState::PendingHeader { @@ -1163,6 +1021,37 @@ mod tests { assert_eq!(rendered, vec!["• tail without newline".to_string()]); } + #[test] + fn controller_set_width_preserves_table_tail_when_queue_is_empty() { + let mut ctrl = StreamController::new(Some(80)); + ctrl.push("intro line\n"); + + let (_cell, idle) = ctrl.on_commit_tick(); + assert!(idle, "intro line should fully drain"); + assert_eq!(ctrl.queued_lines(), 0, "expected empty queue before table"); + + ctrl.push("| A | B |\n"); + assert_eq!( + ctrl.queued_lines(), + 0, + "pending table header should remain mutable tail, not queued", + ); + assert!(ctrl.has_live_tail(), "expected live tail before resize"); + + ctrl.set_width(Some(24)); + + let tail_after = lines_to_plain_strings(&ctrl.current_tail_lines()); + assert!( + !tail_after.is_empty(), + "resize must keep mutable tail when queue is empty", + ); + let joined = tail_after.join(" "); + assert!( + joined.contains('A') && joined.contains('B'), + "expected table header content to remain in tail after resize: {tail_after:?}", + ); + } + #[test] fn plan_controller_set_width_preserves_in_flight_tail() { let mut ctrl = plan_stream_controller(Some(80)); @@ -1882,4 +1771,30 @@ mod tests { "un-emitted content should remain after resize remap: {remaining:?}", ); } + + #[test] + fn controller_set_width_partial_wrapped_emit_keeps_wrapped_remainder() { + let mut ctrl = StreamController::new(Some(18)); + ctrl.push("alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu\n"); + + let (first_emit, idle) = ctrl.on_commit_tick(); + assert!(first_emit.is_some(), "expected first wrapped line emission"); + assert!(!idle, "expected remaining wrapped content after one tick"); + assert!( + ctrl.queued_lines() > 0, + "expected queued wrapped remainder before resize" + ); + + ctrl.set_width(Some(80)); + + let (cell, _source) = ctrl.finalize(); + let remaining = cell + .map(|c| lines_to_plain_strings(&c.transcript_lines(u16::MAX))) + .unwrap_or_default(); + let joined = remaining.join(" "); + assert!( + joined.contains("kappa") || joined.contains("lambda") || joined.contains("mu"), + "wrapped remainder from partially emitted source line was lost after resize: {remaining:?}", + ); + } } diff --git a/codex-rs/tui/src/table_detect.rs b/codex-rs/tui/src/table_detect.rs index 94663fd984..312232d0ff 100644 --- a/codex-rs/tui/src/table_detect.rs +++ b/codex-rs/tui/src/table_detect.rs @@ -1,9 +1,11 @@ -//! Canonical pipe-table structure detection for raw markdown source. +//! Canonical pipe-table structure detection and fenced-code-block tracking for +//! raw markdown source. //! //! Both the streaming controller (`streaming/controller.rs`) and the //! markdown-fence unwrapper (`markdown.rs`) need to identify pipe-table -//! structure in raw markdown source. This module provides the canonical -//! implementations so fixes only need to happen in one place. +//! structure and fenced code blocks in raw markdown source. This module +//! provides the canonical implementations so fixes only need to happen in one +//! place. //! //! ## Concepts //! @@ -15,7 +17,12 @@ //! three dashes. //! - **Body rows** follow the delimiter. //! -//! The functions here operate on single lines and do not maintain cross-line +//! A **fenced code block** starts with 3+ backticks or tildes and ends with a +//! matching close marker. [`FenceTracker`] classifies each line as +//! [`FenceKind::Outside`], [`FenceKind::Markdown`], or [`FenceKind::Other`] +//! so callers can skip pipe characters that appear inside non-markdown fences. +//! +//! The table functions operate on single lines and do not maintain cross-line //! state. Callers (the streaming controller and fence unwrapper) are //! responsible for pairing consecutive lines to confirm a table. @@ -37,10 +44,39 @@ pub(crate) fn parse_table_segments(line: &str) -> Option> { content = without_trailing; } - let segments: Vec<&str> = content.split('|').map(str::trim).collect(); + let segments: Vec<&str> = split_unescaped_pipe(content) + .iter() + .map(|s| s.trim()) + .collect(); (!segments.is_empty()).then_some(segments) } +/// Split `content` on unescaped `|` characters. +/// +/// A pipe preceded by `\` is treated as literal text, not a column separator. +/// The backslash remains in the segment (this is structure detection, not +/// rendering). +fn split_unescaped_pipe(content: &str) -> Vec<&str> { + let mut segments = Vec::new(); + let mut start = 0; + let bytes = content.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'\\' { + // Skip the escaped character. + i += 2; + } else if bytes[i] == b'|' { + segments.push(&content[start..i]); + start = i + 1; + i += 1; + } else { + i += 1; + } + } + segments.push(&content[start..]); + segments +} + /// Whether `line` looks like a table header row (has pipe-separated /// segments with at least one non-empty cell). pub(crate) fn is_table_header_line(line: &str) -> bool { @@ -66,6 +102,149 @@ pub(crate) fn is_table_delimiter_line(line: &str) -> bool { .is_some_and(|segments| segments.into_iter().all(is_table_delimiter_segment)) } +// --------------------------------------------------------------------------- +// Fenced code block tracking +// --------------------------------------------------------------------------- + +/// Where a source line sits relative to fenced code blocks. +/// +/// Table holdback only applies to lines that are `Outside` or inside a +/// `Markdown` fence. Lines inside `Other` fences (e.g. `sh`, `rust`) are +/// ignored by the table scanner because their pipe characters are code, not +/// table syntax. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum FenceKind { + /// Not inside any fenced code block. + Outside, + /// Inside a `` ```md `` or `` ```markdown `` fence. + Markdown, + /// Inside a fence with a non-markdown info string. + Other, +} + +/// Incremental tracker for fenced-code-block open/close transitions. +/// +/// Feed lines one at a time via [`advance`](Self::advance); query the current +/// context with [`kind`](Self::kind). The tracker handles leading-whitespace +/// limits (>3 spaces → not a fence), blockquote prefix stripping, and +/// backtick/tilde marker matching. +pub(crate) struct FenceTracker { + in_fence: bool, + fence_char: char, + fence_len: usize, + fence_kind: FenceKind, +} + +impl FenceTracker { + pub(crate) fn new() -> Self { + Self { + in_fence: false, + fence_char: '\0', + fence_len: 0, + fence_kind: FenceKind::Other, + } + } + + /// Process one raw source line and update fence state. + /// + /// Lines with >3 leading spaces are ignored (indented code blocks, not + /// fences). Blockquote prefixes (`>`) are stripped before scanning. + pub(crate) fn advance(&mut self, raw_line: &str) { + let leading_spaces = raw_line + .as_bytes() + .iter() + .take_while(|byte| **byte == b' ') + .count(); + if leading_spaces > 3 { + return; + } + + let trimmed = &raw_line[leading_spaces..]; + let fence_scan_text = strip_blockquote_prefix(trimmed); + if let Some((marker, len)) = parse_fence_marker(fence_scan_text) { + if !self.in_fence { + // Opening a new fence. + self.in_fence = true; + self.fence_char = marker; + self.fence_len = len; + self.fence_kind = if is_markdown_fence_info(fence_scan_text, len) { + FenceKind::Markdown + } else { + FenceKind::Other + }; + } else if marker == self.fence_char + && len >= self.fence_len + && fence_scan_text[len..].trim().is_empty() + { + // Closing the current fence. + self.in_fence = false; + self.fence_char = '\0'; + self.fence_len = 0; + self.fence_kind = FenceKind::Other; + } + } + } + + /// Current fence context for the most-recently-advanced line. + pub(crate) fn kind(&self) -> FenceKind { + if self.in_fence { + self.fence_kind + } else { + FenceKind::Outside + } + } +} + +/// Return fence marker character and run length for a potential fence line. +/// +/// Recognises backtick and tilde fences with a minimum run of 3. +/// The input should already have leading whitespace and blockquote prefixes +/// stripped. +pub(crate) fn parse_fence_marker(line: &str) -> Option<(char, usize)> { + let mut chars = line.chars(); + let first = chars.next()?; + if first != '`' && first != '~' { + return None; + } + let mut len = 1usize; + for ch in chars { + if ch == first { + len += 1; + } else { + break; + } + } + if len < 3 { + return None; + } + Some((first, len)) +} + +/// Whether the info string after a fence marker indicates markdown content. +/// +/// Matches `md` and `markdown` (case-insensitive). +pub(crate) fn is_markdown_fence_info(trimmed_line: &str, marker_len: usize) -> bool { + let info = trimmed_line[marker_len..] + .split_whitespace() + .next() + .unwrap_or_default(); + info.eq_ignore_ascii_case("md") || info.eq_ignore_ascii_case("markdown") +} + +/// Peel all leading `>` blockquote markers from a line. +/// +/// Tables can appear inside blockquotes (`> | A | B |`), so the holdback +/// scanner must strip these markers before checking for table syntax. +pub(crate) fn strip_blockquote_prefix(line: &str) -> &str { + let mut rest = line.trim_start(); + loop { + let Some(stripped) = rest.strip_prefix('>') else { + return rest; + }; + rest = stripped.strip_prefix(' ').unwrap_or(stripped).trim_start(); + } +} + #[cfg(test)] mod tests { use super::*; @@ -115,6 +294,15 @@ mod tests { assert_eq!(parse_table_segments(" "), None); } + #[test] + fn parse_table_segments_escaped_pipe() { + // Escaped pipe should NOT split — stays inside the segment. + assert_eq!( + parse_table_segments(r"| A \| B | C |"), + Some(vec![r"A \| B", "C"]) + ); + } + #[test] fn is_table_delimiter_segment_valid() { assert!(is_table_delimiter_segment("---")); @@ -155,4 +343,152 @@ mod tests { fn is_table_header_line_all_empty_segments() { assert!(!is_table_header_line("| | |")); } + + // ----------------------------------------------------------------------- + // FenceTracker tests + // ----------------------------------------------------------------------- + + #[test] + fn fence_tracker_outside_by_default() { + let tracker = FenceTracker::new(); + assert_eq!(tracker.kind(), FenceKind::Outside); + } + + #[test] + fn fence_tracker_opens_and_closes_backtick_fence() { + let mut tracker = FenceTracker::new(); + tracker.advance("```rust"); + assert_eq!(tracker.kind(), FenceKind::Other); + + tracker.advance("let x = 1;"); + assert_eq!(tracker.kind(), FenceKind::Other); + + tracker.advance("```"); + assert_eq!(tracker.kind(), FenceKind::Outside); + } + + #[test] + fn fence_tracker_opens_and_closes_tilde_fence() { + let mut tracker = FenceTracker::new(); + tracker.advance("~~~python"); + assert_eq!(tracker.kind(), FenceKind::Other); + tracker.advance("~~~"); + assert_eq!(tracker.kind(), FenceKind::Outside); + } + + #[test] + fn fence_tracker_markdown_fence() { + let mut tracker = FenceTracker::new(); + tracker.advance("```md"); + assert_eq!(tracker.kind(), FenceKind::Markdown); + tracker.advance("| A | B |"); + assert_eq!(tracker.kind(), FenceKind::Markdown); + tracker.advance("```"); + assert_eq!(tracker.kind(), FenceKind::Outside); + } + + #[test] + fn fence_tracker_markdown_case_insensitive() { + let mut tracker = FenceTracker::new(); + tracker.advance("```Markdown"); + assert_eq!(tracker.kind(), FenceKind::Markdown); + tracker.advance("```"); + assert_eq!(tracker.kind(), FenceKind::Outside); + } + + #[test] + fn fence_tracker_nested_shorter_marker_does_not_close() { + let mut tracker = FenceTracker::new(); + tracker.advance("````sh"); + assert_eq!(tracker.kind(), FenceKind::Other); + // Shorter marker inside should not close. + tracker.advance("```"); + assert_eq!(tracker.kind(), FenceKind::Other); + // Matching length closes. + tracker.advance("````"); + assert_eq!(tracker.kind(), FenceKind::Outside); + } + + #[test] + fn fence_tracker_mismatched_char_does_not_close() { + let mut tracker = FenceTracker::new(); + tracker.advance("```sh"); + assert_eq!(tracker.kind(), FenceKind::Other); + // Tilde marker should not close a backtick fence. + tracker.advance("~~~"); + assert_eq!(tracker.kind(), FenceKind::Other); + tracker.advance("```"); + assert_eq!(tracker.kind(), FenceKind::Outside); + } + + #[test] + fn fence_tracker_indented_4_spaces_ignored() { + let mut tracker = FenceTracker::new(); + tracker.advance(" ```sh"); + assert_eq!(tracker.kind(), FenceKind::Outside); + } + + #[test] + fn fence_tracker_blockquote_prefix_stripped() { + let mut tracker = FenceTracker::new(); + tracker.advance("> ```sh"); + assert_eq!(tracker.kind(), FenceKind::Other); + tracker.advance("> ```"); + assert_eq!(tracker.kind(), FenceKind::Outside); + } + + #[test] + fn fence_tracker_close_with_trailing_content_does_not_close() { + let mut tracker = FenceTracker::new(); + tracker.advance("```sh"); + assert_eq!(tracker.kind(), FenceKind::Other); + // Trailing content prevents closing. + tracker.advance("``` extra"); + assert_eq!(tracker.kind(), FenceKind::Other); + tracker.advance("```"); + assert_eq!(tracker.kind(), FenceKind::Outside); + } + + // ----------------------------------------------------------------------- + // Fence helper function tests + // ----------------------------------------------------------------------- + + #[test] + fn parse_fence_marker_backtick() { + assert_eq!(parse_fence_marker("```rust"), Some(('`', 3))); + assert_eq!(parse_fence_marker("````"), Some(('`', 4))); + } + + #[test] + fn parse_fence_marker_tilde() { + assert_eq!(parse_fence_marker("~~~python"), Some(('~', 3))); + } + + #[test] + fn parse_fence_marker_too_short() { + assert_eq!(parse_fence_marker("``"), None); + assert_eq!(parse_fence_marker("~~"), None); + } + + #[test] + fn parse_fence_marker_not_fence() { + assert_eq!(parse_fence_marker("hello"), None); + assert_eq!(parse_fence_marker(""), None); + } + + #[test] + fn is_markdown_fence_info_basic() { + assert!(is_markdown_fence_info("```md", 3)); + assert!(is_markdown_fence_info("```markdown", 3)); + assert!(is_markdown_fence_info("```MD", 3)); + assert!(!is_markdown_fence_info("```rust", 3)); + assert!(!is_markdown_fence_info("```", 3)); + } + + #[test] + fn strip_blockquote_prefix_basic() { + assert_eq!(strip_blockquote_prefix("> hello"), "hello"); + assert_eq!(strip_blockquote_prefix("> > nested"), "nested"); + assert_eq!(strip_blockquote_prefix("no prefix"), "no prefix"); + } }