fix(tui): consolidate md-table resize handling and fence parsing

This commit is contained in:
Felipe Coury
2026-04-06 13:40:18 -03:00
committed by Felipe Coury
parent bcf2897a05
commit 1f01de087c
3 changed files with 447 additions and 222 deletions
+14 -40
View File
@@ -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<Fence> {
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;
}
+92 -177
View File
@@ -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<ParsedLine<'_>> {
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<ParsedLine<'_>> {
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<PreviousLineState>,
pending_header_start: Option<usize>,
confirmed_table_start: Option<usize>,
@@ -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:?}",
);
}
}
+341 -5
View File
@@ -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<Vec<&str>> {
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");
}
}