mirror of
https://github.com/openai/codex.git
synced 2026-10-25 18:28:38 +00:00
fix(tui): consolidate md-table resize handling and fence parsing
This commit is contained in:
committed by
Felipe Coury
parent
bcf2897a05
commit
1f01de087c
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user