chore: import upstream snapshot with attribution
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:10:34 +08:00
commit a789495a98
1551 changed files with 718128 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "jcode-tui-markdown"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
jcode-render-core = { path = "../jcode-render-core" }
jcode-tui-mermaid = { path = "../jcode-tui-mermaid", optional = true }
jcode-tui-workspace = { path = "../jcode-tui-workspace" }
dirs = "5"
image = { version = "0.25", default-features = false, features = ["png"] }
pulldown-cmark = "0.12"
ratatui = "0.30"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
syntect = { version = "5", default-features = false, features = ["default-syntaxes", "default-themes", "regex-onig"] }
unicode-width = "0.2"
tempfile = "3"
wait-timeout = "0.2"
[features]
default = ["mermaid-renderer"]
mermaid-renderer = ["dep:jcode-tui-mermaid"]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,136 @@
use crate::{DiagramDisplayMode, MarkdownSpacingMode};
use std::cell::Cell;
use std::sync::{LazyLock, Mutex};
static DIAGRAM_MODE_OVERRIDE: LazyLock<Mutex<Option<DiagramDisplayMode>>> =
LazyLock::new(|| Mutex::new(None));
thread_local! {
/// Whether markdown rendering is running in streaming mode.
/// In this mode mermaid diagrams update an ephemeral side-panel preview
/// instead of being persisted in ACTIVE_DIAGRAMS history.
static STREAMING_RENDER_CONTEXT: Cell<bool> = const { Cell::new(false) };
/// Whether code blocks should be horizontally centered within available width.
/// Set to true in centered mode, false in left-aligned mode.
static CENTER_CODE_BLOCKS: Cell<bool> = const { Cell::new(true) };
/// Optional test/debug override for markdown spacing mode.
static MARKDOWN_SPACING_MODE_OVERRIDE: Cell<Option<MarkdownSpacingMode>> = const { Cell::new(None) };
/// Whether Mermaid cache misses should be rendered in the background and
/// replaced on a later redraw instead of blocking the current frame.
static DEFER_MERMAID_RENDER_CONTEXT: Cell<bool> = const { Cell::new(false) };
/// Optional test/debug override for whether mermaid rendering is enabled.
/// Thread-local (not process-global) so tests that disable mermaid cannot
/// race other test threads that rely on the process-env default.
static MERMAID_RENDERING_OVERRIDE: Cell<Option<bool>> = const { Cell::new(None) };
/// Scoped, thread-local diagram display mode. Takes precedence over the
/// process-global override so render paths (e.g. the side panel, which
/// renders with diagrams inline) can pin a mode for one render without
/// mutating global state that concurrent threads observe.
static DIAGRAM_MODE_SCOPE: Cell<Option<DiagramDisplayMode>> = const { Cell::new(None) };
}
struct ScopedReset<'a, T: Copy> {
cell: &'a Cell<T>,
prev: T,
}
impl<T: Copy> Drop for ScopedReset<'_, T> {
fn drop(&mut self) {
self.cell.set(self.prev);
}
}
fn with_scoped_cell_value<T: Copy, R>(cell: &Cell<T>, value: T, f: impl FnOnce() -> R) -> R {
let prev = cell.replace(value);
let _guard = ScopedReset { cell, prev };
f()
}
pub fn set_diagram_mode_override(mode: Option<DiagramDisplayMode>) {
if let Ok(mut override_mode) = DIAGRAM_MODE_OVERRIDE.lock() {
*override_mode = mode;
}
}
pub fn get_diagram_mode_override() -> Option<DiagramDisplayMode> {
DIAGRAM_MODE_OVERRIDE.lock().ok().and_then(|mode| *mode)
}
pub(super) fn effective_diagram_mode() -> DiagramDisplayMode {
if let Some(scoped) = DIAGRAM_MODE_SCOPE.with(|ctx| ctx.get()) {
return scoped;
}
if let Ok(mode) = DIAGRAM_MODE_OVERRIDE.lock()
&& let Some(override_mode) = *mode
{
return override_mode;
}
crate::config_snapshot().diagram_mode
}
/// Run `f` with the diagram display mode pinned on the current thread.
/// Takes precedence over both the process-global override and the config
/// snapshot, so a render path (e.g. the side panel, which always renders
/// diagrams inline) can pin a mode without mutating process-global state
/// that concurrent threads observe.
pub fn with_diagram_mode_scope<T>(mode: DiagramDisplayMode, f: impl FnOnce() -> T) -> T {
DIAGRAM_MODE_SCOPE.with(|ctx| with_scoped_cell_value(ctx, Some(mode), f))
}
pub(super) fn effective_markdown_spacing_mode() -> MarkdownSpacingMode {
MARKDOWN_SPACING_MODE_OVERRIDE.with(|mode| {
mode.get()
.unwrap_or(crate::config_snapshot().markdown_spacing)
})
}
/// Whether mermaid diagram rendering is enabled.
///
/// The application supplies the effective config value, including environment
/// overrides, through [`crate::MarkdownConfigSnapshot`]. Tests should use
/// [`with_mermaid_rendering_override`] rather than mutating global config.
pub fn mermaid_rendering_enabled() -> bool {
if let Some(enabled) = MERMAID_RENDERING_OVERRIDE.with(|ctx| ctx.get()) {
return enabled;
}
crate::config_snapshot().mermaid_enabled
}
/// Run `f` with mermaid rendering forced on/off (or `None` to restore the
/// config-based default) on the current thread. Thread-local and scoped, so
/// parallel tests cannot observe each other's override.
pub fn with_mermaid_rendering_override<T>(enabled: Option<bool>, f: impl FnOnce() -> T) -> T {
MERMAID_RENDERING_OVERRIDE.with(|ctx| with_scoped_cell_value(ctx, enabled, f))
}
#[cfg(test)]
pub(crate) fn with_markdown_spacing_mode_override<T>(
mode: Option<MarkdownSpacingMode>,
f: impl FnOnce() -> T,
) -> T {
MARKDOWN_SPACING_MODE_OVERRIDE.with(|ctx| with_scoped_cell_value(ctx, mode, f))
}
pub(super) fn with_streaming_render_context<T>(f: impl FnOnce() -> T) -> T {
STREAMING_RENDER_CONTEXT.with(|ctx| with_scoped_cell_value(ctx, true, f))
}
pub(super) fn streaming_render_context_enabled() -> bool {
STREAMING_RENDER_CONTEXT.with(|ctx| ctx.get())
}
pub fn with_deferred_mermaid_render_context<T>(f: impl FnOnce() -> T) -> T {
DEFER_MERMAID_RENDER_CONTEXT.with(|ctx| with_scoped_cell_value(ctx, true, f))
}
pub(super) fn deferred_mermaid_render_context_enabled() -> bool {
DEFER_MERMAID_RENDER_CONTEXT.with(|ctx| ctx.get())
}
pub fn set_center_code_blocks(centered: bool) {
CENTER_CODE_BLOCKS.with(|ctx| ctx.set(centered));
}
pub fn center_code_blocks() -> bool {
CENTER_CODE_BLOCKS.with(|ctx| ctx.get())
}
@@ -0,0 +1,311 @@
use super::*;
pub struct IncrementalMarkdownRenderer {
/// Previously rendered lines
rendered_lines: Vec<Line<'static>>,
/// Text that was rendered (for comparison)
rendered_text: String,
/// Position of last safe checkpoint (after complete block)
last_checkpoint: usize,
/// Number of lines at last checkpoint
lines_at_checkpoint: usize,
/// Whether a blank separator should be preserved at the checkpoint boundary
checkpoint_needs_separator: bool,
/// Whether `rendered_lines` contains a deferred-mermaid pending
/// placeholder; when true the identical-text fast path must re-render
/// once the deferred render epoch advances so the completed diagram
/// replaces the placeholder.
rendered_mermaid_pending: bool,
/// Deferred-render epoch observed just before `rendered_lines` was
/// rendered.
rendered_mermaid_epoch: u64,
/// Width constraint
max_width: Option<usize>,
}
impl IncrementalMarkdownRenderer {
pub fn new(max_width: Option<usize>) -> Self {
Self {
rendered_lines: Vec::new(),
rendered_text: String::new(),
last_checkpoint: 0,
lines_at_checkpoint: 0,
checkpoint_needs_separator: false,
rendered_mermaid_pending: false,
rendered_mermaid_epoch: 0,
max_width,
}
}
/// Update with new text, returns rendered lines
///
/// This method efficiently handles streaming by:
/// 1. Detecting if text was only appended (common case)
/// 2. Finding safe re-render points (after complete blocks)
/// 3. Only re-rendering from the last safe point
pub fn update(&mut self, full_text: &str) -> Vec<Line<'static>> {
with_streaming_render_context(|| self.update_internal(full_text))
}
pub fn debug_memory_profile(&self) -> serde_json::Value {
let rendered_lines_estimate_bytes = estimate_lines_bytes(&self.rendered_lines);
let rendered_text_bytes = self.rendered_text.capacity();
serde_json::json!({
"rendered_lines_count": self.rendered_lines.len(),
"rendered_lines_estimate_bytes": rendered_lines_estimate_bytes,
"rendered_text_bytes": rendered_text_bytes,
"last_checkpoint": self.last_checkpoint,
"lines_at_checkpoint": self.lines_at_checkpoint,
"total_estimate_bytes": rendered_lines_estimate_bytes + rendered_text_bytes,
})
}
fn update_internal(&mut self, full_text: &str) -> Vec<Line<'static>> {
// Fast path: text unchanged. Not taken while a deferred mermaid
// placeholder is baked into the cached lines and the deferred render
// epoch has advanced: the background render finished, so re-render to
// pick up the completed diagram.
if full_text == self.rendered_text
&& !(self.rendered_mermaid_pending
&& mermaid::deferred_render_epoch() != self.rendered_mermaid_epoch)
{
return self.rendered_lines.clone();
}
// Full re-render required.
//
// We previously tried to splice newly-appended markdown from a saved checkpoint,
// but markdown block separators and list continuity make that unsafe without
// carrying richer parser state across updates. In practice this caused transient
// streaming artifacts like duplicated/misaligned content. Favor correctness here.
//
// The epoch is read *before* rendering: if a background diagram render
// completes mid-render, the stamp is already older than the new epoch
// and the next update re-renders instead of waiting forever.
let mermaid_epoch_before = mermaid::deferred_render_epoch();
self.rendered_lines = render_markdown_with_width(full_text, self.max_width);
self.rendered_text = full_text.to_string();
self.rendered_mermaid_pending = self
.rendered_lines
.iter()
.any(line_is_mermaid_pending_placeholder);
self.rendered_mermaid_epoch = mermaid_epoch_before;
// Find checkpoint for next incremental update
self.refresh_checkpoint(full_text, true);
self.rendered_lines.clone()
}
/// Find the last complete block in text
#[cfg(test)]
pub(crate) fn find_last_complete_block(&self, text: &str) -> Option<usize> {
self.find_last_complete_block_checkpoint(text)
.map(|checkpoint| checkpoint.offset)
}
fn find_last_complete_block_checkpoint(&self, text: &str) -> Option<CompleteBlockCheckpoint> {
let mut checkpoint = None;
let mut line_start = 0usize;
let mut fence_state: Option<(char, usize)> = None;
let mut display_math_open = false;
let mut last_nonblank_kind: Option<MarkdownBlockKind> = None;
let spacing_mode = effective_markdown_spacing_mode();
while line_start <= text.len() {
let relative_end = text[line_start..].find('\n');
let (line_end, line_ends_with_newline) = match relative_end {
Some(end) => (line_start + end, true),
None => (text.len(), false),
};
let line = &text[line_start..line_end];
let line_end_including_newline = if line_ends_with_newline {
line_end + 1
} else {
line_end
};
match fence_state {
Some((fence_char, fence_len)) => {
if is_closing_fence(line, fence_char, fence_len) {
fence_state = None;
last_nonblank_kind = Some(MarkdownBlockKind::CodeBlock);
checkpoint = Some(CompleteBlockCheckpoint {
offset: line_end_including_newline,
needs_separator: spacing_separates_after(
MarkdownBlockKind::CodeBlock,
spacing_mode,
),
});
}
}
None => {
if display_math_open {
let dd_count = count_unescaped_double_dollar(line);
if dd_count % 2 == 1 {
display_math_open = false;
last_nonblank_kind = Some(MarkdownBlockKind::DisplayMath);
checkpoint = Some(CompleteBlockCheckpoint {
offset: line_end_including_newline,
needs_separator: spacing_separates_after(
MarkdownBlockKind::DisplayMath,
spacing_mode,
),
});
}
} else if let Some((fence_char, fence_len)) = parse_opening_fence(line) {
fence_state = Some((fence_char, fence_len));
} else {
let dd_count = count_unescaped_double_dollar(line);
if dd_count > 0 {
if dd_count % 2 == 1 {
display_math_open = true;
} else {
last_nonblank_kind = Some(MarkdownBlockKind::DisplayMath);
checkpoint = Some(CompleteBlockCheckpoint {
offset: line_end_including_newline,
needs_separator: spacing_separates_after(
MarkdownBlockKind::DisplayMath,
spacing_mode,
),
});
}
} else if line_ends_with_newline && is_heading_line(line.trim_start()) {
last_nonblank_kind = Some(MarkdownBlockKind::Heading);
checkpoint = Some(CompleteBlockCheckpoint {
offset: line_end_including_newline,
needs_separator: spacing_separates_after(
MarkdownBlockKind::Heading,
spacing_mode,
),
});
} else if line.trim().is_empty() {
checkpoint = Some(CompleteBlockCheckpoint {
offset: line_end_including_newline,
needs_separator: last_nonblank_kind
.map(|kind| spacing_separates_after(kind, spacing_mode))
.unwrap_or(false),
});
} else {
last_nonblank_kind = Some(infer_markdown_line_kind(line));
}
}
}
}
if !line_ends_with_newline {
break;
}
line_start = line_end + 1;
}
checkpoint
}
/// Refresh checkpoint metadata from the latest rendered text.
///
/// `force = true` recomputes prefix line counts even when checkpoint byte position is unchanged.
fn refresh_checkpoint(&mut self, full_text: &str, force: bool) {
let checkpoint = self.find_last_complete_block_checkpoint(full_text);
let new_checkpoint = checkpoint.map(|cp| cp.offset).unwrap_or(0);
let new_checkpoint_needs_separator =
checkpoint.map(|cp| cp.needs_separator).unwrap_or(false);
if !force
&& new_checkpoint == self.last_checkpoint
&& new_checkpoint_needs_separator == self.checkpoint_needs_separator
{
return;
}
self.last_checkpoint = new_checkpoint;
self.checkpoint_needs_separator = new_checkpoint_needs_separator;
if new_checkpoint == 0 {
self.lines_at_checkpoint = 0;
} else {
let prefix_lines =
render_markdown_with_width(&full_text[..new_checkpoint], self.max_width);
self.lines_at_checkpoint = prefix_lines.len();
}
}
/// Reset the renderer state
pub fn reset(&mut self) {
self.rendered_lines.clear();
self.rendered_text.clear();
self.last_checkpoint = 0;
self.lines_at_checkpoint = 0;
self.checkpoint_needs_separator = false;
self.rendered_mermaid_pending = false;
self.rendered_mermaid_epoch = 0;
}
/// Update width constraint, resets if changed
pub fn set_width(&mut self, max_width: Option<usize>) {
if self.max_width != max_width {
self.max_width = max_width;
self.reset();
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct CompleteBlockCheckpoint {
offset: usize,
needs_separator: bool,
}
pub(crate) fn is_heading_line(line: &str) -> bool {
let hashes = line.chars().take_while(|c| *c == '#').count();
hashes > 0 && hashes <= 6 && line.chars().nth(hashes) == Some(' ')
}
pub(crate) fn is_thematic_break_line(line: &str) -> bool {
let trimmed = line.trim();
let mut marker: Option<char> = None;
let mut count = 0usize;
for ch in trimmed.chars() {
if ch == ' ' || ch == '\t' {
continue;
}
match marker {
None if matches!(ch, '-' | '*' | '_') => {
marker = Some(ch);
count = 1;
}
Some(existing) if ch == existing => count += 1,
_ => return false,
}
}
count >= 3
}
pub(crate) fn looks_like_ordered_list_item(line: &str) -> bool {
let trimmed = line.trim_start();
let digit_count = trimmed.chars().take_while(|c| c.is_ascii_digit()).count();
digit_count > 0
&& matches!(trimmed.chars().nth(digit_count), Some('.' | ')'))
&& matches!(trimmed.chars().nth(digit_count + 1), Some(' ' | '\t'))
}
pub(crate) fn infer_markdown_line_kind(line: &str) -> MarkdownBlockKind {
let trimmed = line.trim_start();
if is_heading_line(trimmed) {
MarkdownBlockKind::Heading
} else if is_thematic_break_line(trimmed) {
MarkdownBlockKind::Rule
} else if trimmed.starts_with('>') {
MarkdownBlockKind::BlockQuote
} else if trimmed.starts_with("- ")
|| trimmed.starts_with("* ")
|| trimmed.starts_with("+ ")
|| looks_like_ordered_list_item(trimmed)
{
MarkdownBlockKind::List
} else if trimmed.starts_with('<') {
MarkdownBlockKind::HtmlBlock
} else {
MarkdownBlockKind::Paragraph
}
}
@@ -0,0 +1,650 @@
use image::GenericImageView;
use ratatui::prelude::Line;
use std::fs;
use std::fs::File;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::{LazyLock, Mutex};
use std::time::Duration;
use wait_timeout::ChildExt;
const RENDERER_VERSION: u8 = 3;
const MAX_SOURCE_BYTES: usize = 32 * 1024;
const COMMAND_TIMEOUT: Duration = Duration::from_secs(8);
const FOREGROUND: (u8, u8, u8) = (130, 210, 235);
const FALLBACK_RENDER_DPI: u16 = 240;
const MIN_RENDER_DPI: u16 = 240;
const MAX_RENDER_DPI: u16 = 480;
const DPI_PER_CELL_PIXEL: u16 = 9;
const DPI_QUANTUM: u16 = 12;
static LOG_HOOK: LazyLock<Mutex<fn(&str)>> = LazyLock::new(|| Mutex::new(|_| {}));
static LAST_REPORTED_ERROR: LazyLock<Mutex<Option<String>>> = LazyLock::new(|| Mutex::new(None));
#[cfg(test)]
thread_local! {
static TEST_RENDER_ATTEMPTS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}
#[cfg(test)]
pub(super) fn reset_test_render_attempts() {
TEST_RENDER_ATTEMPTS.with(|attempts| attempts.set(0));
}
#[cfg(test)]
pub(super) fn test_render_attempts() -> u64 {
TEST_RENDER_ATTEMPTS.with(std::cell::Cell::get)
}
pub(crate) fn set_log_hook(hook: fn(&str)) {
if let Ok(mut current) = LOG_HOOK.lock() {
*current = hook;
}
}
pub(crate) fn report_error(error: &str) {
let should_report = LAST_REPORTED_ERROR
.lock()
.map(|mut last| {
if last.as_deref() == Some(error) {
false
} else {
*last = Some(error.to_string());
true
}
})
.unwrap_or(false);
if should_report && let Ok(hook) = LOG_HOOK.lock() {
hook(error);
}
}
#[derive(Debug, Clone)]
struct Toolchain {
latex: PathBuf,
dvipng: PathBuf,
pdflatex: PathBuf,
pdftocairo: PathBuf,
}
impl Toolchain {
fn from_environment() -> Self {
Self {
latex: std::env::var_os("JCODE_LATEX_COMMAND")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("latex")),
dvipng: std::env::var_os("JCODE_DVIPNG_COMMAND")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("dvipng")),
pdflatex: std::env::var_os("JCODE_PDFLATEX_COMMAND")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("pdflatex")),
pdftocairo: std::env::var_os("JCODE_PDFTOCAIRO_COMMAND")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("pdftocairo")),
}
}
}
pub(super) fn render_latex_image(
source: &str,
display: bool,
max_width: Option<usize>,
) -> Result<Vec<Line<'static>>, String> {
#[cfg(test)]
TEST_RENDER_ATTEMPTS.with(|attempts| attempts.set(attempts.get().saturating_add(1)));
if !super::mermaid::image_protocol_available() {
return Err("terminal image protocol unavailable".to_string());
}
let dpi = render_dpi(super::mermaid::get_font_size());
let artifact = render_artifact(source, display, dpi, &Toolchain::from_environment())?;
let hash =
super::mermaid::register_external_image(&artifact.path, artifact.width, artifact.height);
Ok(super::mermaid::result_to_lines(
super::mermaid::RenderResult::Image {
hash,
path: artifact.path,
width: artifact.width,
height: artifact.height,
},
max_width,
))
}
#[derive(Debug)]
struct Artifact {
path: PathBuf,
width: u32,
height: u32,
}
fn render_dpi(font_size: Option<(u16, u16)>) -> u16 {
let Some((_, cell_height)) = font_size else {
return FALLBACK_RENDER_DPI;
};
// Computer Modern's visible ink is roughly 8/72 of the requested DPI for
// ordinary 10pt symbols. Nine DPI per terminal-row pixel therefore keeps
// simple math close to one full row of ink instead of letting it become
// smaller as users increase their terminal font size. Quantizing avoids
// producing redundant cache entries for tiny geometry differences.
let raw = cell_height
.max(1)
.saturating_mul(DPI_PER_CELL_PIXEL)
.clamp(MIN_RENDER_DPI, MAX_RENDER_DPI);
raw.saturating_add(DPI_QUANTUM / 2) / DPI_QUANTUM * DPI_QUANTUM
}
fn render_artifact(
source: &str,
display: bool,
dpi: u16,
toolchain: &Toolchain,
) -> Result<Artifact, String> {
render_artifact_in(source, display, dpi, toolchain, &cache_dir()?)
}
fn render_artifact_in(
source: &str,
display: bool,
dpi: u16,
toolchain: &Toolchain,
cache_dir: &Path,
) -> Result<Artifact, String> {
validate_source(source)?;
fs::create_dir_all(cache_dir).map_err(|e| format!("create LaTeX cache: {e}"))?;
let cache_path = cache_dir.join(format!("{:016x}.png", cache_key(source, display, dpi)));
if let Ok(artifact) = load_artifact(&cache_path) {
return Ok(artifact);
}
let work = tempfile::Builder::new()
.prefix("jcode-latex-")
.tempdir()
.map_err(|e| format!("create LaTeX workspace: {e}"))?;
let tex_path = work.path().join("formula.tex");
fs::write(&tex_path, latex_document(source, display))
.map_err(|e| format!("write LaTeX source: {e}"))?;
let dpi_arg = dpi.to_string();
let dvi_result = run_command(
&toolchain.latex,
[
"-interaction=nonstopmode",
"-halt-on-error",
"-no-shell-escape",
"formula.tex",
],
work.path(),
)
.and_then(|_| {
run_command(
&toolchain.dvipng,
[
"-D",
dpi_arg.as_str(),
"-T",
"tight",
"-bg",
"Transparent",
"-fg",
"rgb 130 210 235",
"-o",
"formula.png",
"formula.dvi",
],
work.path(),
)
});
if let Err(dvi_error) = dvi_result {
render_with_pdf_toolchain(toolchain, work.path(), dpi).map_err(|pdf_error| {
format!("DVI renderer failed ({dvi_error}); PDF renderer failed ({pdf_error})")
})?;
}
let rendered = work.path().join("formula.png");
load_artifact(&rendered)?;
let temporary_cache_path = cache_path.with_extension(format!("{}.tmp", std::process::id()));
fs::copy(&rendered, &temporary_cache_path).map_err(|e| format!("cache rendered LaTeX: {e}"))?;
if let Err(error) = fs::rename(&temporary_cache_path, &cache_path) {
if !cache_path.exists() {
let _ = fs::remove_file(&temporary_cache_path);
return Err(format!("publish rendered LaTeX: {error}"));
}
let _ = fs::remove_file(&temporary_cache_path);
}
load_artifact(&cache_path)
}
fn render_with_pdf_toolchain(
toolchain: &Toolchain,
working_dir: &Path,
dpi: u16,
) -> Result<(), String> {
run_command(
&toolchain.pdflatex,
[
"-interaction=nonstopmode",
"-halt-on-error",
"-no-shell-escape",
"formula.tex",
],
working_dir,
)?;
let dpi_arg = dpi.to_string();
run_command(
&toolchain.pdftocairo,
[
"-png",
"-singlefile",
"-r",
dpi_arg.as_str(),
"formula.pdf",
"formula",
],
working_dir,
)?;
recolor_and_crop(&working_dir.join("formula.png"), dpi)
}
fn recolor_and_crop(path: &Path, dpi: u16) -> Result<(), String> {
let image = image::open(path)
.map_err(|e| format!("read PDF-rendered LaTeX PNG: {e}"))?
.into_rgba8();
let (width, height) = image.dimensions();
let mut bounds: Option<(u32, u32, u32, u32)> = None;
for (x, y, pixel) in image.enumerate_pixels() {
let luminance =
(u16::from(pixel[0]) * 54 + u16::from(pixel[1]) * 183 + u16::from(pixel[2]) * 19) / 256;
if pixel[3] > 0 && luminance < 250 {
bounds = Some(match bounds {
Some((min_x, min_y, max_x, max_y)) => {
(min_x.min(x), min_y.min(y), max_x.max(x), max_y.max(y))
}
None => (x, y, x, y),
});
}
}
let (min_x, min_y, max_x, max_y) =
bounds.ok_or_else(|| "rendered formula is blank".to_string())?;
let padding = u32::from(dpi).saturating_mul(4).div_ceil(180).max(4);
let left = min_x.saturating_sub(padding);
let top = min_y.saturating_sub(padding);
let right = max_x.saturating_add(padding).min(width.saturating_sub(1));
let bottom = max_y.saturating_add(padding).min(height.saturating_sub(1));
let mut cropped = image::imageops::crop_imm(
&image,
left,
top,
right.saturating_sub(left).saturating_add(1),
bottom.saturating_sub(top).saturating_add(1),
)
.to_image();
for pixel in cropped.pixels_mut() {
let luminance =
(u16::from(pixel[0]) * 54 + u16::from(pixel[1]) * 183 + u16::from(pixel[2]) * 19) / 256;
let alpha = 255u16.saturating_sub(luminance) as u8;
*pixel = image::Rgba([FOREGROUND.0, FOREGROUND.1, FOREGROUND.2, alpha]);
}
cropped
.save(path)
.map_err(|e| format!("write cropped LaTeX PNG: {e}"))
}
fn cache_dir() -> Result<PathBuf, String> {
dirs::cache_dir()
.map(|path| path.join("jcode").join("latex"))
.ok_or_else(|| "no user cache directory is available".to_string())
}
fn load_artifact(path: &Path) -> Result<Artifact, String> {
let image = image::open(path).map_err(|e| format!("read rendered LaTeX PNG: {e}"))?;
let (width, height) = image.dimensions();
if width == 0 || height == 0 {
return Err("rendered LaTeX PNG is empty".to_string());
}
Ok(Artifact {
path: path.to_path_buf(),
width,
height,
})
}
fn run_command<const N: usize>(
executable: &Path,
args: [&str; N],
working_dir: &Path,
) -> Result<(), String> {
let output_path = working_dir.join(".jcode-command-output.log");
let stdout = File::create(&output_path)
.map_err(|e| format!("create {} diagnostics: {e}", executable.display()))?;
let stderr = stdout
.try_clone()
.map_err(|e| format!("capture {} diagnostics: {e}", executable.display()))?;
let mut child = Command::new(executable)
.args(args)
.current_dir(working_dir)
.env("openin_any", "p")
.env("openout_any", "p")
.env("TEXMFOUTPUT", working_dir)
.stdin(Stdio::null())
.stdout(Stdio::from(stdout))
.stderr(Stdio::from(stderr))
.spawn()
.map_err(|e| format!("start {}: {e}", executable.display()))?;
match child
.wait_timeout(COMMAND_TIMEOUT)
.map_err(|e| format!("wait for {}: {e}", executable.display()))?
{
Some(status) if status.success() => {
let _ = fs::remove_file(&output_path);
Ok(())
}
Some(status) => Err(format!(
"{} exited with {status}: {}",
executable.display(),
command_diagnostics(&output_path)
)),
None => {
let _ = child.kill();
let _ = child.wait();
Err(format!("{} timed out", executable.display()))
}
}
}
fn command_diagnostics(path: &Path) -> String {
const MAX_DIAGNOSTIC_BYTES: usize = 4 * 1024;
let Ok(output) = fs::read(path) else {
return "no diagnostic output".to_string();
};
let start = output.len().saturating_sub(MAX_DIAGNOSTIC_BYTES);
String::from_utf8_lossy(&output[start..])
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
fn cache_key(source: &str, display: bool, dpi: u16) -> u64 {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
RENDERER_VERSION.hash(&mut hasher);
source.hash(&mut hasher);
display.hash(&mut hasher);
dpi.hash(&mut hasher);
FOREGROUND.hash(&mut hasher);
hasher.finish()
}
fn latex_document(source: &str, display: bool) -> String {
let source = source.trim();
let math = if display {
format!("\\[\\displaystyle\n{source}\n\\]")
} else {
format!("${source}$")
};
format!(
"\\documentclass{{article}}\n\\pagestyle{{empty}}\n\\usepackage{{amsmath,amssymb}}\n\\begin{{document}}\n\\noindent {math}\n\\end{{document}}\n"
)
}
fn validate_source(source: &str) -> Result<(), String> {
if source.trim().is_empty() {
return Err("LaTeX source is empty".to_string());
}
if source.len() > MAX_SOURCE_BYTES {
return Err(format!("LaTeX source exceeds {MAX_SOURCE_BYTES} bytes"));
}
let lowered = source.to_ascii_lowercase();
const FORBIDDEN: &[&str] = &[
"\\input",
"\\include",
"\\openin",
"\\openout",
"\\read",
"\\write",
"\\immediate",
"\\usepackage",
"\\documentclass",
"\\special",
"\\catcode",
];
if let Some(command) = FORBIDDEN.iter().find(|command| lowered.contains(**command)) {
return Err(format!("unsafe LaTeX command is not allowed: {command}"));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn document_wraps_inline_and_display_math_without_shell_escape() {
let inline = latex_document(r"x^2 + \\alpha", false);
assert!(inline.contains(r"$x^2 + \\alpha$"));
assert!(!inline.contains("shell-escape"));
let display = latex_document("\n\\frac{a}{b}\n", true);
assert!(display.contains("\\[\\displaystyle"));
assert!(display.contains(r"\frac{a}{b}"));
assert!(display.contains("\\displaystyle\n\\frac{a}{b}\n\\]"));
assert!(!display.contains("\\displaystyle\n\n"));
}
#[test]
fn cache_key_is_stable_and_separates_inline_from_display() {
assert_eq!(cache_key("x", false, 240), cache_key("x", false, 240));
assert_ne!(cache_key("x", false, 240), cache_key("x", true, 240));
assert_ne!(cache_key("x", false, 240), cache_key("y", false, 240));
assert_ne!(cache_key("x", false, 240), cache_key("x", false, 312));
}
#[test]
fn render_dpi_tracks_terminal_cell_height_with_readable_bounds() {
assert_eq!(render_dpi(None), 240);
assert_eq!(render_dpi(Some((8, 16))), 240);
assert_eq!(render_dpi(Some((15, 34))), 312);
assert_eq!(render_dpi(Some((20, 60))), 480);
}
#[test]
fn unsafe_empty_and_oversized_sources_are_rejected() {
assert!(validate_source(" ").is_err());
assert!(validate_source(r"\\input{/etc/passwd}").is_err());
assert!(validate_source(&"x".repeat(MAX_SOURCE_BYTES + 1)).is_err());
assert!(validate_source(r"\\frac{x}{y}").is_ok());
}
#[test]
fn missing_toolchain_returns_an_error_without_panicking() {
let cache = tempfile::tempdir().unwrap();
let missing = PathBuf::from("/definitely/missing/jcode-latex-command");
let result = render_artifact_in(
"unique_missing_toolchain_test_4815162342",
false,
240,
&Toolchain {
latex: missing.clone(),
dvipng: missing.clone(),
pdflatex: missing.clone(),
pdftocairo: missing,
},
cache.path(),
);
assert!(result.is_err());
}
#[cfg(unix)]
#[test]
fn toolchain_output_is_validated_cached_and_reused() {
use image::{ImageBuffer, Rgba};
use std::os::unix::fs::PermissionsExt;
let root = tempfile::tempdir().unwrap();
let fixture = root.path().join("fixture.png");
ImageBuffer::from_pixel(7, 3, Rgba([130u8, 210, 235, 255]))
.save(&fixture)
.unwrap();
let latex = root.path().join("latex-ok");
let dvipng = root.path().join("dvipng-ok");
fs::write(&latex, "#!/bin/sh\n: > formula.dvi\n").unwrap();
fs::write(
&dvipng,
format!("#!/bin/sh\ncp '{}' formula.png\n", fixture.display()),
)
.unwrap();
fs::set_permissions(&latex, fs::Permissions::from_mode(0o755)).unwrap();
fs::set_permissions(&dvipng, fs::Permissions::from_mode(0o755)).unwrap();
let cache = root.path().join("cache");
let artifact = render_artifact_in(
r"\frac{x+1}{y}",
true,
240,
&Toolchain {
latex,
dvipng,
pdflatex: PathBuf::from("/unused/pdflatex"),
pdftocairo: PathBuf::from("/unused/pdftocairo"),
},
&cache,
)
.unwrap();
assert_eq!((artifact.width, artifact.height), (7, 3));
assert!(artifact.path.starts_with(&cache));
let missing = PathBuf::from("/definitely/missing/jcode-latex-command");
let cached = render_artifact_in(
r"\frac{x+1}{y}",
true,
240,
&Toolchain {
latex: missing.clone(),
dvipng: missing.clone(),
pdflatex: missing.clone(),
pdftocairo: missing,
},
&cache,
)
.expect("the second render should use the validated PNG cache");
assert_eq!((cached.width, cached.height), (7, 3));
assert_eq!(cached.path, artifact.path);
}
#[cfg(unix)]
#[test]
fn pdf_fallback_crops_recolors_and_produces_a_cached_png() {
use image::{ImageBuffer, Rgba};
use std::os::unix::fs::PermissionsExt;
let root = tempfile::tempdir().unwrap();
let fixture = root.path().join("pdf-page.png");
let mut page = ImageBuffer::from_pixel(20, 10, Rgba([255u8, 255, 255, 255]));
for y in 2..=6 {
for x in 5..=10 {
page.put_pixel(x, y, Rgba([0, 0, 0, 255]));
}
}
page.save(&fixture).unwrap();
let failing_latex = root.path().join("latex-fail");
let unused_dvipng = root.path().join("dvipng-unused");
let pdflatex = root.path().join("pdflatex-ok");
let pdftocairo = root.path().join("pdftocairo-ok");
fs::write(&failing_latex, "#!/bin/sh\nexit 1\n").unwrap();
fs::write(&unused_dvipng, "#!/bin/sh\nexit 99\n").unwrap();
fs::write(&pdflatex, "#!/bin/sh\n: > formula.pdf\n").unwrap();
fs::write(
&pdftocairo,
format!("#!/bin/sh\ncp '{}' formula.png\n", fixture.display()),
)
.unwrap();
for path in [&failing_latex, &unused_dvipng, &pdflatex, &pdftocairo] {
fs::set_permissions(path, fs::Permissions::from_mode(0o755)).unwrap();
}
let artifact = render_artifact_in(
r"x^2 + \alpha",
false,
240,
&Toolchain {
latex: failing_latex,
dvipng: unused_dvipng,
pdflatex,
pdftocairo,
},
&root.path().join("cache"),
)
.unwrap();
assert!(artifact.width < 20, "white page margins should be cropped");
assert!(artifact.height <= 10);
let rendered = image::open(&artifact.path).unwrap().into_rgba8();
assert!(rendered.pixels().any(|pixel| pixel[3] == 255));
assert!(rendered.pixels().all(|pixel| {
[pixel[0], pixel[1], pixel[2]] == [FOREGROUND.0, FOREGROUND.1, FOREGROUND.2]
}));
}
#[test]
fn installed_toolchain_renders_gaussian_integral_when_available() {
let toolchain = Toolchain::from_environment();
let has_pdf_fallback = Command::new(&toolchain.pdflatex)
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok_and(|status| status.success())
&& Command::new(&toolchain.pdftocairo)
.arg("-v")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok_and(|status| status.success());
if !has_pdf_fallback {
return;
}
let cache = tempfile::tempdir().unwrap();
let artifact = render_artifact_in(
"\n\\int_{-\\infty}^{\\infty} e^{-x^2}\\,dx = \\sqrt{\\pi}\n",
true,
312,
&toolchain,
cache.path(),
)
.expect("installed PDF toolchain should render the Gaussian integral");
assert!(artifact.width > 0 && artifact.height > 0);
}
#[test]
fn installed_toolchain_scales_simple_math_for_tall_terminal_cells_when_available() {
let toolchain = Toolchain::from_environment();
let has_pdf_fallback = Command::new(&toolchain.pdflatex)
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok_and(|status| status.success())
&& Command::new(&toolchain.pdftocairo)
.arg("-v")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok_and(|status| status.success());
if !has_pdf_fallback {
return;
}
let cache = tempfile::tempdir().unwrap();
let baseline = render_artifact_in("x", false, 180, &toolchain, cache.path())
.expect("installed toolchain should render baseline inline math");
let readable = render_artifact_in("x", false, 312, &toolchain, cache.path())
.expect("installed toolchain should render cell-aware inline math");
assert!(readable.width > baseline.width);
assert!(readable.height > baseline.height);
assert!(
readable.height * 10 >= baseline.height * 14,
"312 DPI should materially increase ink height: baseline={} readable={}",
baseline.height,
readable.height
);
}
}
@@ -0,0 +1,86 @@
use ratatui::prelude::*;
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub enum RenderResult {
Image {
hash: u64,
path: std::path::PathBuf,
width: u32,
height: u32,
},
Error(String),
}
pub fn is_mermaid_lang(lang: &str) -> bool {
lang.eq_ignore_ascii_case("mermaid") || lang.eq_ignore_ascii_case("mmd")
}
pub fn image_protocol_available() -> bool {
false
}
pub fn get_font_size() -> Option<(u16, u16)> {
None
}
/// Monotonic deferred-render epoch. The fallback renderer never defers, so
/// the epoch never advances.
pub fn deferred_render_epoch() -> u64 {
0
}
pub fn render_mermaid_deferred_with_stream_scope(
_content: &str,
_terminal_width: Option<u16>,
_stream_sequence: u64,
) -> Option<RenderResult> {
Some(RenderResult::Error(
"Mermaid rendering is disabled".to_string(),
))
}
pub fn render_mermaid_deferred_with_registration(
_content: &str,
_terminal_width: Option<u16>,
_register_active: bool,
) -> Option<RenderResult> {
Some(RenderResult::Error(
"Mermaid rendering is disabled".to_string(),
))
}
pub fn render_mermaid_untracked(_content: &str, _terminal_width: Option<u16>) -> RenderResult {
RenderResult::Error("Mermaid rendering is disabled".to_string())
}
pub fn render_mermaid_sized(_content: &str, _terminal_width: Option<u16>) -> RenderResult {
RenderResult::Error("Mermaid rendering is disabled".to_string())
}
pub fn set_streaming_preview_diagram(
_hash: u64,
_width: u32,
_height: u32,
_label: Option<String>,
) {
}
pub fn result_to_lines(result: RenderResult, _max_width: Option<usize>) -> Vec<Line<'static>> {
match result {
RenderResult::Image { .. } => Vec::new(),
RenderResult::Error(message) => vec![Line::from(message)],
}
}
pub fn parse_image_placeholder(_line: &Line<'_>) -> Option<u64> {
None
}
pub fn parse_inline_image_placeholder(_line: &Line<'_>) -> Option<(u64, u16, u16)> {
None
}
pub fn register_external_image(_path: &std::path::Path, _width: u32, _height: u32) -> u64 {
0
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,961 @@
use super::*;
pub fn render_markdown_lazy(
text: &str,
max_width: Option<usize>,
visible_range: std::ops::Range<usize>,
) -> Vec<Line<'static>> {
let text = jcode_render_core::normalize_latex_math(text);
let text = escape_currency_dollars(&text);
let text = preserve_line_oriented_softbreaks(&text);
let text = text.as_str();
let mut lines: Vec<Line<'static>> = Vec::new();
let mut current_spans: Vec<Span<'static>> = Vec::new();
let deferred_mermaid_mode = deferred_mermaid_render_context_enabled();
let spacing_mode = effective_markdown_spacing_mode();
let latex_mode = config_snapshot().latex_rendering;
let mut centered_blocks = CenteredStructuredBlockState::default();
// Style stack for nested formatting
let mut bold = false;
let mut italic = false;
// True while inside an emphasis run that opened with the reasoning sentinel.
// Smart-punctuation (e.g. apostrophes) splits a single reasoning line into
// multiple text events; only the first carries the sentinel, so we latch the
// dim/italic styling for the whole emphasis span.
let mut reasoning_emphasis = false;
let mut strike = false;
let mut in_code_block = false;
let mut code_block_lang: Option<String> = None;
let mut code_block_content = String::new();
let mut code_block_start_line: usize = 0;
let mut heading_level: Option<u8> = None;
let mut blockquote_depth = 0usize;
let mut list_stack: Vec<ListRenderState> = Vec::new();
let mut link_targets: Vec<String> = Vec::new();
let mut in_image = false;
let mut image_url: Option<String> = None;
let mut image_alt = String::new();
let mut in_definition_list = false;
let mut in_definition_item = false;
let mut in_footnote_definition = false;
// Table state
let mut in_table = false;
let mut table_row: Vec<String> = Vec::new();
let mut table_rows: Vec<Vec<String>> = Vec::new();
let mut current_cell = String::new();
let mut _is_header_row = false;
// Enable table parsing
let mut options = Options::empty();
options.insert(Options::ENABLE_TABLES);
options.insert(Options::ENABLE_MATH);
options.insert(Options::ENABLE_STRIKETHROUGH);
options.insert(Options::ENABLE_TASKLISTS);
options.insert(Options::ENABLE_FOOTNOTES);
options.insert(Options::ENABLE_GFM);
options.insert(Options::ENABLE_DEFINITION_LIST);
options.insert(Options::ENABLE_SMART_PUNCTUATION);
let parser = Parser::new_ext(text, options);
for event in parser {
match event {
Event::Start(Tag::Heading { level, .. }) => {
flush_current_line_with_alignment(
&mut lines,
&mut current_spans,
structured_markdown_alignment(
blockquote_depth,
&list_stack,
in_definition_list,
in_footnote_definition,
),
);
heading_level = Some(level as u8);
}
Event::End(TagEnd::Heading(_)) => {
if !current_spans.is_empty() {
let color = match heading_level {
Some(1) => heading_h1_color(),
Some(2) => heading_h2_color(),
Some(3) => heading_h3_color(),
_ => heading_color(),
};
let heading_spans: Vec<Span<'static>> = current_spans
.drain(..)
.map(|s| {
Span::styled(s.content.to_string(), Style::default().fg(color).bold())
})
.collect();
lines.push(Line::from(heading_spans));
push_block_separator(&mut lines, MarkdownBlockKind::Heading, spacing_mode);
}
heading_level = None;
}
Event::Start(Tag::Strong) => bold = true,
Event::End(TagEnd::Strong) => bold = false,
Event::Start(Tag::Emphasis) => italic = true,
Event::End(TagEnd::Emphasis) => {
italic = false;
reasoning_emphasis = false;
}
Event::Start(Tag::Strikethrough) => strike = true,
Event::End(TagEnd::Strikethrough) => strike = false,
Event::Start(Tag::BlockQuote(_)) => {
flush_current_line_with_alignment(
&mut lines,
&mut current_spans,
structured_markdown_alignment(
blockquote_depth,
&list_stack,
in_definition_list,
in_footnote_definition,
),
);
enter_centered_structured_block(&mut centered_blocks, lines.len());
blockquote_depth += 1;
}
Event::End(TagEnd::BlockQuote(_)) => {
flush_current_line_with_alignment(
&mut lines,
&mut current_spans,
structured_markdown_alignment(
blockquote_depth,
&list_stack,
in_definition_list,
in_footnote_definition,
),
);
blockquote_depth = blockquote_depth.saturating_sub(1);
exit_centered_structured_block(&mut centered_blocks, lines.len());
if blockquote_depth == 0
&& list_stack.is_empty()
&& !in_definition_list
&& !in_footnote_definition
{
push_block_separator(&mut lines, MarkdownBlockKind::BlockQuote, spacing_mode);
}
}
Event::Start(Tag::List(start)) => {
enter_centered_structured_block(&mut centered_blocks, lines.len());
let start_index = start.unwrap_or(1);
let state = ListRenderState {
ordered: start.is_some(),
next_index: start_index,
item_line_starts: Vec::new(),
max_marker_digits: start_index.to_string().len(),
};
list_stack.push(state);
}
Event::End(TagEnd::List(_)) => {
flush_current_line_with_alignment(
&mut lines,
&mut current_spans,
structured_markdown_alignment(
blockquote_depth,
&list_stack,
in_definition_list,
in_footnote_definition,
),
);
if let Some(state) = list_stack.pop()
&& center_code_blocks()
&& state.ordered
{
align_ordered_list_markers(
&mut lines,
&state.item_line_starts,
state.max_marker_digits,
);
}
exit_centered_structured_block(&mut centered_blocks, lines.len());
if blockquote_depth == 0
&& list_stack.is_empty()
&& !in_definition_list
&& !in_footnote_definition
{
push_block_separator(&mut lines, MarkdownBlockKind::List, spacing_mode);
}
}
Event::Start(Tag::Link { dest_url, .. }) => {
link_targets.push(dest_url.to_string());
}
Event::End(TagEnd::Link) => {
if let Some(url) = link_targets.pop()
&& !url.is_empty()
{
current_spans.push(Span::styled(
format!(" ({})", url),
Style::default().fg(md_dim_color()),
));
}
}
Event::Start(Tag::Image { dest_url, .. }) => {
in_image = true;
image_url = Some(dest_url.to_string());
image_alt.clear();
}
Event::End(TagEnd::Image) => {
let alt = if image_alt.trim().is_empty() {
"image".to_string()
} else {
image_alt.trim().to_string()
};
let label = if let Some(url) = image_url.take() {
format!("[image: {}] ({})", alt, url)
} else {
format!("[image: {}]", alt)
};
if in_table {
current_cell.push_str(&label);
} else {
ensure_blockquote_prefix(&mut current_spans, blockquote_depth);
current_spans.push(Span::styled(label, Style::default().fg(md_dim_color())));
}
in_image = false;
image_alt.clear();
}
Event::Start(Tag::FootnoteDefinition(label)) => {
flush_current_line_with_alignment(
&mut lines,
&mut current_spans,
structured_markdown_alignment(
blockquote_depth,
&list_stack,
in_definition_list,
in_footnote_definition,
),
);
enter_centered_structured_block(&mut centered_blocks, lines.len());
in_footnote_definition = true;
ensure_blockquote_prefix(&mut current_spans, blockquote_depth);
current_spans.push(Span::styled(
format!("[^{}]: ", label),
Style::default().fg(md_dim_color()),
));
}
Event::End(TagEnd::FootnoteDefinition) => {
flush_current_line_with_alignment(
&mut lines,
&mut current_spans,
structured_markdown_alignment(
blockquote_depth,
&list_stack,
in_definition_list,
in_footnote_definition,
),
);
exit_centered_structured_block(&mut centered_blocks, lines.len());
in_footnote_definition = false;
}
Event::Start(Tag::DefinitionList) => {
flush_current_line_with_alignment(
&mut lines,
&mut current_spans,
structured_markdown_alignment(
blockquote_depth,
&list_stack,
in_definition_list,
in_footnote_definition,
),
);
enter_centered_structured_block(&mut centered_blocks, lines.len());
in_definition_list = true;
}
Event::End(TagEnd::DefinitionList) => {
flush_current_line_with_alignment(
&mut lines,
&mut current_spans,
structured_markdown_alignment(
blockquote_depth,
&list_stack,
in_definition_list,
in_footnote_definition,
),
);
exit_centered_structured_block(&mut centered_blocks, lines.len());
in_definition_list = false;
if blockquote_depth == 0 && list_stack.is_empty() && !in_footnote_definition {
push_block_separator(
&mut lines,
MarkdownBlockKind::DefinitionList,
spacing_mode,
);
}
}
Event::Start(Tag::DefinitionListTitle) => {
flush_current_line_with_alignment(
&mut lines,
&mut current_spans,
structured_markdown_alignment(
blockquote_depth,
&list_stack,
in_definition_list,
in_footnote_definition,
),
);
ensure_blockquote_prefix(&mut current_spans, blockquote_depth);
current_spans.push(Span::styled("", Style::default().fg(md_dim_color())));
}
Event::End(TagEnd::DefinitionListTitle) => {
flush_current_line_with_alignment(
&mut lines,
&mut current_spans,
structured_markdown_alignment(
blockquote_depth,
&list_stack,
in_definition_list,
in_footnote_definition,
),
);
}
Event::Start(Tag::DefinitionListDefinition) => {
flush_current_line_with_alignment(
&mut lines,
&mut current_spans,
structured_markdown_alignment(
blockquote_depth,
&list_stack,
in_definition_list,
in_footnote_definition,
),
);
ensure_blockquote_prefix(&mut current_spans, blockquote_depth);
current_spans.push(Span::styled(" -> ", Style::default().fg(md_dim_color())));
in_definition_item = true;
}
Event::End(TagEnd::DefinitionListDefinition) => {
flush_current_line_with_alignment(
&mut lines,
&mut current_spans,
structured_markdown_alignment(
blockquote_depth,
&list_stack,
in_definition_list,
in_footnote_definition,
),
);
in_definition_item = false;
}
Event::Start(Tag::CodeBlock(kind)) => {
flush_current_line_with_alignment(
&mut lines,
&mut current_spans,
structured_markdown_alignment(
blockquote_depth,
&list_stack,
in_definition_list,
in_footnote_definition,
),
);
enter_centered_structured_block(&mut centered_blocks, lines.len());
in_code_block = true;
code_block_start_line = lines.len();
code_block_lang = match kind {
CodeBlockKind::Fenced(lang) if !lang.is_empty() => Some(lang.to_string()),
_ => None,
};
// Don't add header here - we'll add it at the end when we know the block width
code_block_content.clear();
}
Event::End(TagEnd::CodeBlock) => {
let is_mermaid = mermaid_rendering_enabled()
&& code_block_lang
.as_ref()
.map(|l| mermaid::is_mermaid_lang(l))
.unwrap_or(false);
if is_mermaid {
if !mermaid_should_register_active() && !mermaid::image_protocol_available() {
lines.push(mermaid_sidebar_placeholder(
"↗ mermaid diagram (image protocols unavailable)",
));
continue;
}
let terminal_width = max_width.and_then(|w| u16::try_from(w).ok());
let result = if deferred_mermaid_mode {
mermaid::render_mermaid_deferred_with_registration(
&code_block_content,
terminal_width,
mermaid_should_register_active(),
)
} else if mermaid_should_register_active() {
Some(mermaid::render_mermaid_sized(
&code_block_content,
terminal_width,
))
} else {
Some(mermaid::render_mermaid_untracked(
&code_block_content,
terminal_width,
))
};
match result {
Some(other) => {
let mermaid_lines = mermaid::result_to_lines(other, max_width);
lines.extend(mermaid_lines);
}
None => {
lines.push(mermaid_sidebar_placeholder(
MERMAID_PENDING_PLACEHOLDER_TEXT,
));
}
}
} else {
// Calculate the line range this code block will occupy
let code_line_count = code_block_content.lines().count();
let block_range =
code_block_start_line..(code_block_start_line + code_line_count + 2);
// Check if this block is visible
let is_visible = ranges_overlap(block_range.clone(), visible_range.clone());
let lang_label = code_block_lang.as_deref().unwrap_or("");
let highlighted = if is_visible {
let hl =
highlight_code_cached(&code_block_content, code_block_lang.as_deref());
Some(hl)
} else {
None
};
// Add header
lines.push(
Line::from(Span::styled(
format!("┌─ {} ", lang_label),
Style::default().fg(md_dim_color()),
))
.left_aligned(),
);
if let Some(hl_lines) = highlighted {
// Render highlighted code
for hl_line in hl_lines {
let mut spans =
vec![Span::styled("", Style::default().fg(md_dim_color()))];
spans.extend(hl_line.spans);
lines.push(Line::from(spans).left_aligned());
}
} else {
// Use placeholder for off-screen blocks
let placeholder =
placeholder_code_block(&code_block_content, code_block_lang.as_deref());
for pl_line in placeholder {
let mut spans =
vec![Span::styled("", Style::default().fg(md_dim_color()))];
spans.extend(pl_line.spans);
lines.push(Line::from(spans).left_aligned());
}
}
// Add footer
lines.push(
Line::from(Span::styled("└─", Style::default().fg(md_dim_color())))
.left_aligned(),
);
}
exit_centered_structured_block(&mut centered_blocks, lines.len());
in_code_block = false;
code_block_lang = None;
code_block_content.clear();
if blockquote_depth == 0
&& list_stack.is_empty()
&& !in_definition_list
&& !in_footnote_definition
{
push_block_separator(&mut lines, MarkdownBlockKind::CodeBlock, spacing_mode);
}
}
Event::Code(code) => {
if in_image {
image_alt.push_str(&code);
continue;
}
// Inline code - handle differently in tables vs regular text
if in_table {
current_cell.push_str(&code);
} else {
ensure_blockquote_prefix(&mut current_spans, blockquote_depth);
current_spans.push(Span::styled(
code.to_string(),
apply_inline_decorations(
Style::default().fg(code_fg()).bg(code_bg()),
strike,
!link_targets.is_empty(),
),
));
}
}
Event::InlineMath(math) => {
if in_image {
image_alt.push('$');
image_alt.push_str(&math);
image_alt.push('$');
continue;
}
if in_table {
match latex_mode {
LatexRenderingMode::None => current_cell.push_str(&format!("${math}$")),
LatexRenderingMode::Unicode | LatexRenderingMode::Image => {
current_cell.push_str(&jcode_render_core::render_inline_latex(&math));
}
}
} else {
ensure_blockquote_prefix(&mut current_spans, blockquote_depth);
match latex_mode {
LatexRenderingMode::None => current_spans.push(raw_math_inline_span(&math)),
LatexRenderingMode::Unicode => current_spans.push(math_inline_span(&math)),
LatexRenderingMode::Image
if blockquote_depth == 0
&& list_stack.is_empty()
&& !in_definition_list
&& !in_footnote_definition =>
{
if let Some(image_lines) = latex_image_lines(&math, false, max_width) {
flush_current_line_with_alignment(
&mut lines,
&mut current_spans,
None,
);
lines.extend(image_lines);
} else {
current_spans.push(math_inline_span(&math));
}
}
LatexRenderingMode::Image => current_spans.push(math_inline_span(&math)),
}
}
}
Event::DisplayMath(math) => {
if in_image {
image_alt.push_str("$$");
image_alt.push_str(&math);
image_alt.push_str("$$");
continue;
}
flush_current_line_with_alignment(
&mut lines,
&mut current_spans,
structured_markdown_alignment(
blockquote_depth,
&list_stack,
in_definition_list,
in_footnote_definition,
),
);
if in_table {
match latex_mode {
LatexRenderingMode::None => current_cell.push_str(&format!("$${math}$$")),
LatexRenderingMode::Unicode | LatexRenderingMode::Image => {
current_cell.push_str(&jcode_render_core::render_inline_latex(&math));
}
}
} else {
let block_start = lines.len();
let rendered = match latex_mode {
LatexRenderingMode::None => raw_math_display_lines(&math),
LatexRenderingMode::Unicode => math_display_lines(&math),
LatexRenderingMode::Image
if blockquote_depth == 0
&& list_stack.is_empty()
&& !in_definition_list
&& !in_footnote_definition =>
{
latex_image_lines(&math, true, max_width)
.unwrap_or_else(|| math_display_lines(&math))
}
LatexRenderingMode::Image => math_display_lines(&math),
};
for line in rendered {
lines.push(with_blockquote_prefix(line, blockquote_depth));
}
record_centered_independent_block(
&mut centered_blocks,
block_start,
lines.len(),
);
if blockquote_depth == 0
&& list_stack.is_empty()
&& !in_definition_list
&& !in_footnote_definition
{
push_block_separator(
&mut lines,
MarkdownBlockKind::DisplayMath,
spacing_mode,
);
}
}
}
Event::Text(text) => {
if in_code_block {
code_block_content.push_str(&text);
} else if in_image {
image_alt.push_str(&text);
} else if in_table {
current_cell.push_str(&text);
} else {
let is_thinking_duration =
text.starts_with("Thought for ") && text.ends_with('s');
// The sentinel can appear at the start and/or end of the line
// (and smart-punctuation may split it across events), so latch
// on its presence anywhere and strip every occurrence.
let has_sentinel = text.contains(crate::REASONING_SENTINEL);
if has_sentinel {
// Latch for the rest of this emphasis span so smart-
// punctuation splits keep the dim/italic styling.
reasoning_emphasis = true;
}
let is_reasoning = reasoning_emphasis;
let stripped;
let text: &str = if has_sentinel {
stripped = text.replace(crate::REASONING_SENTINEL, "");
&stripped
} else {
&text
};
let mut style = if is_thinking_duration || is_reasoning {
Style::default().fg(md_dim_color()).italic()
} else {
match (bold, italic) {
(true, true) => Style::default().fg(bold_color()).bold().italic(),
(true, false) => Style::default().fg(bold_color()).bold(),
(false, true) => Style::default().fg(text_color()).italic(),
(false, false) => Style::default().fg(text_color()),
}
};
style = apply_inline_decorations(style, strike, !link_targets.is_empty());
ensure_blockquote_prefix(&mut current_spans, blockquote_depth);
current_spans.push(Span::styled(text.to_string(), style));
}
}
Event::SoftBreak => {
if in_image {
image_alt.push(' ');
} else if !in_code_block {
if blockquote_depth > 0 {
flush_current_line_with_alignment(
&mut lines,
&mut current_spans,
structured_markdown_alignment(
blockquote_depth,
&list_stack,
in_definition_list,
in_footnote_definition,
),
);
} else {
current_spans.push(Span::raw(" "));
}
}
}
Event::HardBreak => {
if in_image {
image_alt.push(' ');
} else if !in_code_block {
flush_current_line_with_alignment(
&mut lines,
&mut current_spans,
structured_markdown_alignment(
blockquote_depth,
&list_stack,
in_definition_list,
in_footnote_definition,
),
);
}
}
Event::Rule => {
flush_current_line_with_alignment(
&mut lines,
&mut current_spans,
structured_markdown_alignment(
blockquote_depth,
&list_stack,
in_definition_list,
in_footnote_definition,
),
);
let block_start = lines.len();
let width = rendered_rule_width(max_width);
let rule = Span::styled("".repeat(width), Style::default().fg(md_dim_color()));
lines.push(with_blockquote_prefix(
Line::from(rule).left_aligned(),
blockquote_depth,
));
record_centered_independent_block(&mut centered_blocks, block_start, lines.len());
if blockquote_depth == 0
&& list_stack.is_empty()
&& !in_definition_list
&& !in_footnote_definition
{
push_block_separator(&mut lines, MarkdownBlockKind::Rule, spacing_mode);
}
}
Event::Html(html) => {
flush_current_line_with_alignment(
&mut lines,
&mut current_spans,
structured_markdown_alignment(
blockquote_depth,
&list_stack,
in_definition_list,
in_footnote_definition,
),
);
let block_start = lines.len();
for raw in html.lines() {
let span =
Span::styled(raw.to_string(), Style::default().fg(html_fg()).italic());
lines.push(with_blockquote_prefix(
Line::from(span).left_aligned(),
blockquote_depth,
));
}
record_centered_independent_block(&mut centered_blocks, block_start, lines.len());
if blockquote_depth == 0
&& list_stack.is_empty()
&& !in_definition_list
&& !in_footnote_definition
{
push_block_separator(&mut lines, MarkdownBlockKind::HtmlBlock, spacing_mode);
}
}
Event::InlineHtml(html) => {
if in_image {
image_alt.push_str(&html);
} else if in_table {
current_cell.push_str(&html);
} else {
ensure_blockquote_prefix(&mut current_spans, blockquote_depth);
current_spans.push(Span::styled(
html.to_string(),
Style::default().fg(html_fg()).italic(),
));
}
}
Event::FootnoteReference(label) => {
if in_image {
image_alt.push_str(&format!("[^{}]", label));
} else if in_table {
current_cell.push_str(&format!("[^{}]", label));
} else {
ensure_blockquote_prefix(&mut current_spans, blockquote_depth);
current_spans.push(Span::styled(
format!("[^{}]", label),
Style::default().fg(md_dim_color()),
));
}
}
Event::TaskListMarker(checked) => {
if in_table {
current_cell.push_str(if checked { "[x] " } else { "[ ] " });
} else {
ensure_blockquote_prefix(&mut current_spans, blockquote_depth);
current_spans.push(Span::styled(
if checked { "[x] " } else { "[ ] " },
Style::default().fg(md_dim_color()),
));
}
}
Event::Start(Tag::Paragraph) => {
ensure_blockquote_prefix(&mut current_spans, blockquote_depth);
if in_definition_item && current_spans.is_empty() {
current_spans.push(Span::styled(" ", Style::default().fg(md_dim_color())));
}
}
Event::End(TagEnd::Paragraph) => {
flush_current_line_with_alignment(
&mut lines,
&mut current_spans,
structured_markdown_alignment(
blockquote_depth,
&list_stack,
in_definition_list,
in_footnote_definition,
),
);
push_block_separator(&mut lines, MarkdownBlockKind::Paragraph, spacing_mode);
}
Event::Start(Tag::Item) => {
flush_current_line_with_alignment(
&mut lines,
&mut current_spans,
structured_markdown_alignment(
blockquote_depth,
&list_stack,
in_definition_list,
in_footnote_definition,
),
);
ensure_blockquote_prefix(&mut current_spans, blockquote_depth);
let item_line_start = lines.len();
let depth = list_stack.len().saturating_sub(1);
let indent = " ".repeat(depth);
let marker = if let Some(state) = list_stack.last_mut() {
if state.ordered {
let idx = state.next_index;
state.next_index = state.next_index.saturating_add(1);
state.max_marker_digits =
state.max_marker_digits.max(idx.to_string().len());
state.item_line_starts.push(item_line_start);
format!("{}{}. ", indent, idx)
} else {
format!("{}", indent)
}
} else {
"".to_string()
};
current_spans.push(Span::styled(marker, Style::default().fg(md_dim_color())));
}
Event::End(TagEnd::Item) => {
flush_current_line_with_alignment(
&mut lines,
&mut current_spans,
structured_markdown_alignment(
blockquote_depth,
&list_stack,
in_definition_list,
in_footnote_definition,
),
);
}
Event::Start(Tag::Table(_)) => {
flush_current_line_with_alignment(
&mut lines,
&mut current_spans,
structured_markdown_alignment(
blockquote_depth,
&list_stack,
in_definition_list,
in_footnote_definition,
),
);
enter_centered_structured_block(&mut centered_blocks, lines.len());
in_table = true;
table_rows.clear();
}
Event::End(TagEnd::Table) => {
if !table_rows.is_empty() {
let rendered = render_table(&table_rows, max_width);
lines.extend(rendered);
exit_centered_structured_block(&mut centered_blocks, lines.len());
if blockquote_depth == 0
&& list_stack.is_empty()
&& !in_definition_list
&& !in_footnote_definition
{
push_block_separator(&mut lines, MarkdownBlockKind::Table, spacing_mode);
}
} else {
exit_centered_structured_block(&mut centered_blocks, lines.len());
}
in_table = false;
table_rows.clear();
}
Event::Start(Tag::TableHead) => {
_is_header_row = true;
table_row.clear();
}
Event::End(TagEnd::TableHead) => {
if !table_row.is_empty() {
table_rows.push(table_row.clone());
}
table_row.clear();
_is_header_row = false;
}
Event::Start(Tag::TableRow) => {
table_row.clear();
}
Event::End(TagEnd::TableRow) => {
if !table_row.is_empty() {
table_rows.push(table_row.clone());
}
table_row.clear();
}
Event::Start(Tag::TableCell) => {
current_cell.clear();
}
Event::End(TagEnd::TableCell) => {
table_row.push(current_cell.trim().to_string());
current_cell.clear();
}
Event::Start(Tag::MetadataBlock(_)) => {
flush_current_line_with_alignment(
&mut lines,
&mut current_spans,
structured_markdown_alignment(
blockquote_depth,
&list_stack,
in_definition_list,
in_footnote_definition,
),
);
}
Event::End(TagEnd::MetadataBlock(_)) => {
flush_current_line_with_alignment(
&mut lines,
&mut current_spans,
structured_markdown_alignment(
blockquote_depth,
&list_stack,
in_definition_list,
in_footnote_definition,
),
);
}
_ => {}
}
}
flush_current_line_with_alignment(
&mut lines,
&mut current_spans,
structured_markdown_alignment(
blockquote_depth,
&list_stack,
in_definition_list,
in_footnote_definition,
),
);
finalize_centered_structured_blocks(&mut centered_blocks, lines.len());
normalize_block_separators(&mut lines);
if center_code_blocks()
&& let Some(width) = max_width
{
center_structured_block_ranges(&mut lines, width, &centered_blocks.ranges);
}
lines
}
@@ -0,0 +1,466 @@
use super::*;
pub(super) fn line_plain_text(line: &Line<'_>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect()
}
pub fn extract_copy_targets_from_rendered_lines(lines: &[Line<'static>]) -> Vec<RawCopyTarget> {
let mut targets = Vec::new();
let mut idx = 0usize;
while idx < lines.len() {
let text = line_plain_text(&lines[idx]);
let trimmed = text.trim_start();
if let Some(rest) = trimmed.strip_prefix("┌─ ") {
let label = rest.trim();
let language = if label.is_empty() || label == "code" {
None
} else {
Some(label.to_string())
};
let start = idx;
let badge_line = idx;
idx += 1;
let mut content_lines = Vec::new();
while idx < lines.len() {
let line_text = line_plain_text(&lines[idx]);
let line_trimmed = line_text.trim_start();
if line_trimmed.starts_with("└─") {
idx += 1;
break;
}
if let Some(code) = line_trimmed.strip_prefix("") {
content_lines.push(code.to_string());
}
idx += 1;
}
targets.push(RawCopyTarget {
kind: CopyTargetKind::CodeBlock { language },
content: content_lines.join("\n"),
start_raw_line: start,
end_raw_line: idx,
badge_raw_line: badge_line,
});
continue;
}
// Blockquote lines render flush-left with a `│ ` gutter (repeated when
// nested). Code/math frame bodies also use the gutter, but those are
// consumed by the `┌─` frame branch above, so any gutter line reached
// here belongs to a blockquote.
if is_blockquote_gutter_line(&text) {
let start = idx;
let mut content_lines = Vec::new();
while idx < lines.len() {
let line_text = line_plain_text(&lines[idx]);
if is_blockquote_gutter_line(&line_text) {
content_lines.push(strip_blockquote_gutter(&line_text).to_string());
idx += 1;
continue;
}
// Nested quotes (and multi-paragraph quotes) render a blank
// separator line between gutter runs. Bridge blank lines when
// the run resumes with another gutter line so the whole quote
// gets a single badge.
if line_text.trim().is_empty() {
let mut probe = idx + 1;
while probe < lines.len() && line_plain_text(&lines[probe]).trim().is_empty() {
probe += 1;
}
if probe < lines.len()
&& is_blockquote_gutter_line(&line_plain_text(&lines[probe]))
{
for _ in idx..probe {
content_lines.push(String::new());
}
idx = probe;
continue;
}
}
break;
}
targets.push(RawCopyTarget {
kind: CopyTargetKind::Blockquote,
content: content_lines.join("\n"),
start_raw_line: start,
end_raw_line: idx,
badge_raw_line: start,
});
continue;
}
idx += 1;
}
targets
}
/// A rendered blockquote line starts (without indentation) with the `│ `
/// gutter or is a bare `│` continuation. Table rows use ` │ ` separators
/// mid-line and pad the first cell, so requiring a flush-left gutter avoids
/// misclassifying them.
fn is_blockquote_gutter_line(text: &str) -> bool {
text.starts_with("") || text.trim_end() == ""
}
/// Strip every leading `│ ` gutter level (nested quotes repeat it) from a
/// rendered blockquote line.
fn strip_blockquote_gutter(text: &str) -> &str {
let mut rest = text;
loop {
if let Some(next) = rest.strip_prefix("") {
rest = next;
} else if rest.trim_end() == "" {
return "";
} else {
return rest;
}
}
}
/// Render a table as ASCII-style lines
/// max_width: Optional maximum width for the entire table
pub(super) fn render_table(rows: &[Vec<String>], max_width: Option<usize>) -> Vec<Line<'static>> {
if rows.is_empty() {
return vec![];
}
let mut lines = Vec::new();
// Calculate column widths
let num_cols = rows.iter().map(|r| r.len()).max().unwrap_or(0);
let mut col_widths: Vec<usize> = vec![0; num_cols];
for row in rows {
for (i, cell) in row.iter().enumerate() {
if i < col_widths.len() {
col_widths[i] = col_widths[i].max(UnicodeWidthStr::width(cell.as_str()));
}
}
}
// Apply max width constraint if specified
if let Some(max_w) = max_width {
// Account for separators: " │ " = 3 chars between each column
let separator_space = if num_cols > 1 { (num_cols - 1) * 3 } else { 0 };
let available = max_w.saturating_sub(separator_space);
if available > 0 && num_cols > 0 {
let total_width: usize = col_widths.iter().sum();
if total_width > available {
let min_col_width = (available / num_cols).clamp(1, 5);
for width in &mut col_widths {
*width = (*width).max(min_col_width);
}
while col_widths.iter().sum::<usize>() > available {
if let Some((idx, _)) = col_widths
.iter()
.enumerate()
.filter(|(_, width)| **width > min_col_width)
.max_by_key(|(_, width)| **width)
{
col_widths[idx] -= 1;
} else {
break;
}
}
}
}
}
// Render each row. Cells that exceed their allocated column width are
// wrapped into multiple physical lines instead of being truncated, so table
// output remains bounded to the terminal width without hiding content.
for (row_idx, row) in rows.iter().enumerate() {
let wrapped_cells: Vec<Vec<String>> = row
.iter()
.enumerate()
.map(|(i, cell)| {
let col_width = col_widths
.get(i)
.copied()
.unwrap_or_else(|| UnicodeWidthStr::width(cell.as_str()));
wrap_table_cell(cell, col_width)
})
.collect();
let physical_rows = wrapped_cells
.iter()
.map(|cell_lines| cell_lines.len())
.max()
.unwrap_or(1);
for physical_idx in 0..physical_rows {
let mut spans: Vec<Span<'static>> = Vec::new();
for (i, cell_lines) in wrapped_cells.iter().enumerate() {
let display_text = cell_lines
.get(physical_idx)
.map(String::as_str)
.unwrap_or("");
let col_width = col_widths
.get(i)
.copied()
.unwrap_or_else(|| UnicodeWidthStr::width(display_text));
let text_width = UnicodeWidthStr::width(display_text);
let pad = col_width.saturating_sub(text_width);
let padded = format!("{}{}", display_text, " ".repeat(pad));
// Header row gets bold styling
let style = if row_idx == 0 {
Style::default().fg(bold_color()).bold()
} else {
Style::default().fg(text_color())
};
if i > 0 {
spans.push(Span::styled("", Style::default().fg(table_color())));
}
spans.push(Span::styled(padded, style));
}
lines.push(Line::from(spans).left_aligned());
}
// Add separator after header row
if row_idx == 0 {
let separator: String = col_widths
.iter()
.map(|&w| "".repeat(w))
.collect::<Vec<_>>()
.join("─┼─");
lines.push(
Line::from(Span::styled(separator, Style::default().fg(table_color())))
.left_aligned(),
);
}
}
lines
}
fn wrap_table_cell(cell: &str, width: usize) -> Vec<String> {
if width == 0 || cell.is_empty() {
return vec![String::new()];
}
let mut lines = Vec::new();
let mut current = String::new();
let mut current_width = 0usize;
for word in cell.split(' ') {
let word_width = UnicodeWidthStr::width(word);
let space_width = usize::from(!current.is_empty());
if !current.is_empty() && current_width + space_width + word_width <= width {
current.push(' ');
current.push_str(word);
current_width += space_width + word_width;
continue;
}
if !current.is_empty() {
lines.push(std::mem::take(&mut current));
current_width = 0;
}
if word_width <= width {
current.push_str(word);
current_width = word_width;
} else {
for chunk in wrap_long_table_word(word, width) {
if UnicodeWidthStr::width(chunk.as_str()) == width {
lines.push(chunk);
} else {
current = chunk;
current_width = UnicodeWidthStr::width(current.as_str());
}
}
}
}
if !current.is_empty() || lines.is_empty() {
lines.push(current);
}
lines
}
fn wrap_long_table_word(word: &str, width: usize) -> Vec<String> {
let mut chunks = Vec::new();
let mut current = String::new();
let mut current_width = 0usize;
for ch in word.chars() {
let ch_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
if current_width + ch_width > width && !current.is_empty() {
chunks.push(std::mem::take(&mut current));
current_width = 0;
}
current.push(ch);
current_width += ch_width;
}
if !current.is_empty() {
chunks.push(current);
}
chunks
}
/// Render a table with a specific max width constraint
pub fn render_table_with_width(rows: &[Vec<String>], max_width: usize) -> Vec<Line<'static>> {
render_table(rows, Some(max_width))
}
/// Highlight a code block with syntax highlighting (cached)
/// This is the primary entry point for code highlighting - uses a cache
/// to avoid re-highlighting the same code multiple times during streaming.
pub(super) fn highlight_code_cached(code: &str, lang: Option<&str>) -> Vec<Line<'static>> {
let hash = hash_code(code, lang);
// Check cache first
if let Ok(cache) = HIGHLIGHT_CACHE.lock()
&& let Some(lines) = cache.get(hash)
{
if let Ok(mut state) = MARKDOWN_DEBUG.lock() {
state.stats.highlight_cache_hits += 1;
}
return lines;
}
// Cache miss - do the highlighting
if let Ok(mut state) = MARKDOWN_DEBUG.lock() {
state.stats.highlight_cache_misses += 1;
}
let lines = highlight_code(code, lang);
// Store in cache
if let Ok(mut cache) = HIGHLIGHT_CACHE.lock() {
cache.insert(hash, lines.clone());
}
lines
}
/// Highlight a code block with syntax highlighting
pub(super) fn highlight_code(code: &str, lang: Option<&str>) -> Vec<Line<'static>> {
let mut lines = Vec::new();
// Try to find syntax for the language
let syntax = lang
.and_then(|l| SYNTAX_SET.find_syntax_by_token(l))
.unwrap_or_else(|| SYNTAX_SET.find_syntax_plain_text());
let theme = &THEME_SET.themes["base16-ocean.dark"];
let mut highlighter = HighlightLines::new(syntax, theme);
for line in code.lines() {
let highlighted = highlighter.highlight_line(line, &SYNTAX_SET);
match highlighted {
Ok(ranges) => {
let spans: Vec<Span<'static>> = ranges
.into_iter()
.map(|(style, text)| {
Span::styled(text.to_string(), syntect_to_ratatui_style(style))
})
.collect();
lines.push(Line::from(spans));
}
Err(_) => {
// Fallback to plain text
lines.push(Line::from(Span::styled(
line.to_string(),
Style::default().fg(code_fg()),
)));
}
}
}
lines
}
/// Convert syntect style to ratatui style
fn syntect_to_ratatui_style(style: SynStyle) -> Style {
let fg = rgb(style.foreground.r, style.foreground.g, style.foreground.b);
Style::default().fg(fg)
}
/// Highlight a single line of code (for diff display)
/// Returns styled spans for the line, or None if highlighting fails
/// `ext` is the file extension (e.g., "rs", "py", "js")
pub fn highlight_line(code: &str, ext: Option<&str>) -> Vec<Span<'static>> {
let syntax = ext
.and_then(|e| SYNTAX_SET.find_syntax_by_extension(e))
.or_else(|| ext.and_then(|e| SYNTAX_SET.find_syntax_by_token(e)))
.unwrap_or_else(|| SYNTAX_SET.find_syntax_plain_text());
let theme = &THEME_SET.themes["base16-ocean.dark"];
let mut highlighter = HighlightLines::new(syntax, theme);
match highlighter.highlight_line(code, &SYNTAX_SET) {
Ok(ranges) => ranges
.into_iter()
.map(|(style, text)| Span::styled(text.to_string(), syntect_to_ratatui_style(style)))
.collect(),
Err(_) => {
vec![Span::raw(code.to_string())]
}
}
}
/// Highlight a full file and return spans for specific line numbers (1-indexed)
/// Used for comparison logging with single-line approach
pub fn highlight_file_lines(
content: &str,
ext: Option<&str>,
line_numbers: &[usize],
) -> Vec<(usize, Vec<Span<'static>>)> {
let syntax = ext
.and_then(|e| SYNTAX_SET.find_syntax_by_extension(e))
.or_else(|| ext.and_then(|e| SYNTAX_SET.find_syntax_by_token(e)))
.unwrap_or_else(|| SYNTAX_SET.find_syntax_plain_text());
let theme = &THEME_SET.themes["base16-ocean.dark"];
let mut highlighter = HighlightLines::new(syntax, theme);
let mut results = Vec::new();
let lines: Vec<&str> = content.lines().collect();
for (i, line) in lines.iter().enumerate() {
let line_num = i + 1; // 1-indexed
if let Ok(ranges) = highlighter.highlight_line(line, &SYNTAX_SET)
&& line_numbers.contains(&line_num)
{
let spans: Vec<Span<'static>> = ranges
.into_iter()
.map(|(style, text)| {
Span::styled(text.to_string(), syntect_to_ratatui_style(style))
})
.collect();
results.push((line_num, spans));
}
}
results
}
/// Placeholder for code blocks that are not visible
/// Used by lazy rendering to avoid highlighting off-screen code
pub(super) fn placeholder_code_block(code: &str, lang: Option<&str>) -> Vec<Line<'static>> {
let line_count = code.lines().count();
let lang_str = lang.unwrap_or("code");
// Return placeholder lines that will be replaced when visible
vec![Line::from(Span::styled(
format!(" [{} block: {} lines]", lang_str, line_count),
Style::default().fg(md_dim_color()).italic(),
))]
}
/// Check if two ranges overlap
pub(super) fn ranges_overlap(a: std::ops::Range<usize>, b: std::ops::Range<usize>) -> bool {
a.start < b.end && b.start < a.end
}
@@ -0,0 +1,7 @@
use super::*;
include!("cases/rendering.rs");
include!("cases/latex_streaming.rs");
include!("cases/streaming_cache.rs");
include!("cases/wrapping_currency.rs");
include!("cases/placeholders.rs");
@@ -0,0 +1,73 @@
fn exact_multiline_latex_response() -> &'static str {
concat!(
"\\[\n\\boxed{\ne^{i\\pi}+1=0\n}\n\\]\n\n",
"\\[\n\\int_{-\\infty}^{\\infty} e^{-x^2}\\,dx=\\sqrt{\\pi}\n\\]\n\n",
"\\[\nx=\\frac{-b\\pm\\sqrt{b^2-4ac}}{2a}\n\\]\n\n",
"\\[\n\\nabla\\cdot\\mathbf{E}=\\frac{\\rho}{\\varepsilon_0}\n\\]\n\n",
"\\[\n\\frac{\\partial \\psi}{\\partial t}\n=\n",
"\\alpha\\frac{\\partial^2\\psi}{\\partial x^2}\n\\]",
)
}
#[test]
fn exact_multiline_response_renders_all_five_equations() {
let mut renderer = IncrementalMarkdownRenderer::new(Some(90));
let rendered = lines_to_string(&renderer.update(exact_multiline_latex_response()));
assert_eq!(rendered.matches("┌─ math").count(), 5, "{rendered}");
assert!(!rendered.contains("$$"), "{rendered}");
assert!(!rendered.contains(r"\partial"), "{rendered}");
assert!(rendered.contains('∂'), "{rendered}");
assert!(rendered.contains('α'), "{rendered}");
}
#[test]
fn every_streaming_prefix_converges_to_the_full_math_render() {
let response = exact_multiline_latex_response();
let mut renderer = IncrementalMarkdownRenderer::new(Some(90));
for end in response
.char_indices()
.map(|(index, _)| index)
.chain(std::iter::once(response.len()))
{
let _ = renderer.update(&response[..end]);
}
let incremental = renderer.update(response);
let full = with_streaming_render_context(|| render_markdown_with_width(response, Some(90)));
assert_eq!(incremental, full);
assert_eq!(lines_to_string(&incremental).matches("┌─ math").count(), 5);
}
#[test]
fn streaming_math_never_invokes_the_synchronous_image_toolchain() {
latex_image::reset_test_render_attempts();
let mut renderer = IncrementalMarkdownRenderer::new(Some(90));
let _ = renderer.update(exact_multiline_latex_response());
assert_eq!(latex_image::test_render_attempts(), 0);
latex_image::reset_test_render_attempts();
let _ = render_markdown(r"$$x^2$$");
assert!(
latex_image::test_render_attempts() > 0,
"completed non-streaming Image mode should attempt the configured image renderer"
);
}
#[test]
fn multiline_relations_survive_blockquotes_and_promoted_delimiters() {
let source = concat!(
"> Blockquote display:\n> \\[\n> x^2\n> =\n> y^2\n> \\]\n\n",
"Standalone spelling:\n\\(\nx\n=\ny\n\\)",
);
let rendered = with_streaming_render_context(|| {
lines_to_string(&render_markdown_with_width(source, Some(90)))
});
assert_eq!(rendered.matches("┌─ math").count(), 2, "{rendered}");
assert!(rendered.contains("x² = y²"), "{rendered}");
assert!(rendered.contains("x = y"), "{rendered}");
assert!(!rendered.contains("{}="), "{rendered}");
assert!(!rendered.contains("$$"), "{rendered}");
}
@@ -0,0 +1,83 @@
// Placeholder-preservation tests: image/diagram placeholder bodies are blank
// lines by design, and block-separator normalization must never collapse them.
#[cfg(feature = "mermaid-renderer")]
#[test]
fn test_normalize_block_separators_preserves_inline_image_placeholder_body() {
let rows = 12u16;
let mut lines = vec![Line::from("before")];
lines.push(Line::from(""));
lines.extend(jcode_tui_mermaid::inline_image_placeholder_lines(
0xabcdef, rows, 40,
));
lines.push(Line::from(""));
lines.push(Line::from("after"));
normalize_block_separators(&mut lines);
let marker_idx = lines
.iter()
.position(|line| jcode_tui_mermaid::parse_inline_image_placeholder(line).is_some())
.expect("placeholder marker must survive normalization");
let blank_run = lines[marker_idx + 1..]
.iter()
.take_while(|line| line_is_blank(line))
.count();
assert!(
blank_run >= (rows - 1) as usize,
"placeholder body must keep its {} blank fill lines, found {} (lines: {:?})",
rows - 1,
blank_run,
lines.iter().map(line_to_string).collect::<Vec<_>>()
);
assert_eq!(
line_to_string(lines.last().expect("content after placeholder")),
"after",
"content after the placeholder must remain"
);
}
#[cfg(feature = "mermaid-renderer")]
#[test]
fn test_normalize_block_separators_keeps_trailing_placeholder_body() {
// A diagram at the very end of a message: trailing-blank trimming must not
// eat the placeholder's blank fill lines.
let rows = 8u16;
let mut lines = vec![Line::from("intro")];
lines.push(Line::from(""));
lines.extend(jcode_tui_mermaid::inline_image_placeholder_lines(
0x123456, rows, 30,
));
normalize_block_separators(&mut lines);
let marker_idx = lines
.iter()
.position(|line| jcode_tui_mermaid::parse_inline_image_placeholder(line).is_some())
.expect("placeholder marker must survive normalization");
assert_eq!(
lines.len() - marker_idx,
rows as usize,
"trailing placeholder must keep marker + {} blank rows (lines: {:?})",
rows - 1,
lines.iter().map(line_to_string).collect::<Vec<_>>()
);
}
#[cfg(feature = "mermaid-renderer")]
#[test]
fn test_normalize_block_separators_still_collapses_ordinary_blank_runs() {
let mut lines = vec![
Line::from("a"),
Line::from(""),
Line::from(""),
Line::from(""),
Line::from("b"),
Line::from(""),
];
normalize_block_separators(&mut lines);
let rendered: Vec<String> = lines.iter().map(line_to_string).collect();
assert_eq!(rendered, vec!["a", "", "b"]);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,490 @@
#[test]
fn test_centered_mode_right_aligns_ordered_markers_within_list_block() {
let saved = center_code_blocks();
set_center_code_blocks(true);
let lines = render_markdown_with_width("9. stuff\n10. more stuff here", Some(50));
set_center_code_blocks(saved);
let nine = lines
.iter()
.find(|line| line_to_string(line).contains("stuff"))
.expect("9 line");
let ten = lines
.iter()
.find(|line| line_to_string(line).contains("more stuff here"))
.expect("10 line");
let nine_text = line_to_string(nine);
let ten_text = line_to_string(ten);
let nine_content = nine_text.find("stuff").expect("9 content");
let ten_content = ten_text.find("more").expect("10 content");
assert_eq!(
nine_content, ten_content,
"ordered list content should share a single column: {nine_text:?} / {ten_text:?}"
);
assert!(
nine_text.contains(" 9. "),
"single-digit marker should be right-aligned to match two-digit markers: {nine_text:?}"
);
}
#[test]
fn test_wrapped_centered_ordered_list_keeps_shared_content_column() {
let saved = center_code_blocks();
set_center_code_blocks(true);
let lines = render_markdown_with_width(
"9. short\n10. this centered numbered list item should wrap onto another line cleanly",
Some(42),
);
set_center_code_blocks(saved);
let wrapped = wrap_lines(lines, 26);
let rendered: Vec<String> = wrapped
.iter()
.map(line_to_string)
.filter(|line| !line.is_empty())
.collect();
assert!(
rendered.len() >= 3,
"expected wrapped ordered list: {rendered:?}"
);
let short_line = rendered
.iter()
.find(|line| line.contains("short"))
.expect("short line");
let wrapped_first = rendered
.iter()
.find(|line| line.contains("this centered"))
.expect("wrapped first line");
let wrapped_cont = rendered
.iter()
.find(|line| line.contains("another line"))
.expect("wrapped continuation");
let short_col = short_line.find("short").expect("short col");
let wrapped_first_col = wrapped_first.find("this").expect("first col");
let wrapped_cont_col = wrapped_cont.find("another").expect("cont col");
assert_eq!(
short_col, wrapped_first_col,
"9 and 10 content should align: {rendered:?}"
);
assert_eq!(
wrapped_first_col, wrapped_cont_col,
"wrapped continuation should stay on the shared content column: {rendered:?}"
);
}
#[test]
fn test_wrapped_centered_bullet_list_preserves_content_indent() {
let saved = center_code_blocks();
set_center_code_blocks(true);
let lines = render_markdown_with_width(
"- this centered bullet item should wrap onto another line cleanly",
Some(34),
);
set_center_code_blocks(saved);
let wrapped = wrap_lines(lines, 22);
let rendered: Vec<String> = wrapped
.iter()
.map(line_to_string)
.filter(|line| !line.is_empty())
.collect();
assert!(
rendered.len() >= 2,
"expected wrapped list item: {rendered:?}"
);
let first_pad = leading_spaces(&rendered[0]);
let second_pad = leading_spaces(&rendered[1]);
assert!(rendered[0][first_pad..].starts_with(""));
assert_eq!(second_pad, first_pad + UnicodeWidthStr::width(""));
}
#[test]
fn test_wrapped_centered_numbered_list_preserves_content_indent() {
let saved = center_code_blocks();
set_center_code_blocks(true);
let lines = render_markdown_with_width(
"12. this centered numbered list item should wrap onto another line cleanly",
Some(38),
);
set_center_code_blocks(saved);
let wrapped = wrap_lines(lines, 24);
let rendered: Vec<String> = wrapped
.iter()
.map(line_to_string)
.filter(|line| !line.is_empty())
.collect();
assert!(
rendered.len() >= 2,
"expected wrapped numbered item: {rendered:?}"
);
let first_pad = leading_spaces(&rendered[0]);
let second_pad = leading_spaces(&rendered[1]);
assert!(rendered[0][first_pad..].starts_with("12. "));
assert_eq!(second_pad, first_pad + UnicodeWidthStr::width("12. "));
}
#[test]
fn test_centered_mode_keeps_blockquotes_left_aligned() {
let saved = center_code_blocks();
set_center_code_blocks(true);
let lines = render_markdown_with_width("> quoted\n> second line", Some(50));
set_center_code_blocks(saved);
let rendered: Vec<String> = lines
.iter()
.map(line_to_string)
.filter(|line| !line.is_empty())
.collect();
assert_eq!(rendered, vec!["│ quoted", "│ second line"]);
}
#[test]
fn test_compact_spacing_keeps_heading_tight_but_separates_list_from_next_heading() {
let md = "# Intro\nBody\n\n- one\n- two\n\n# Next\nBody";
let rendered: Vec<String> = render_markdown_with_mode(md, MarkdownSpacingMode::Compact)
.iter()
.map(line_to_string)
.collect();
assert_eq!(
rendered,
vec!["Intro", "Body", "", "• one", "• two", "", "Next", "Body"]
);
}
#[test]
fn test_document_spacing_adds_heading_separation() {
let md = "# Intro\nBody\n\n- one\n- two\n\n# Next\nBody";
let rendered: Vec<String> = render_markdown_with_mode(md, MarkdownSpacingMode::Document)
.iter()
.map(line_to_string)
.collect();
assert_eq!(
rendered,
vec![
"Intro", "", "Body", "", "• one", "• two", "", "Next", "", "Body"
]
);
}
#[test]
fn test_compact_spacing_separates_code_block_from_following_heading_without_trailing_blank() {
let md = "```rust\nfn main() {}\n```\n\n# Next";
let rendered: Vec<String> = render_markdown_with_mode(md, MarkdownSpacingMode::Compact)
.iter()
.map(line_to_string)
.collect();
assert_eq!(
rendered,
vec!["┌─ rust ", "│ fn main() {}", "└─", "", "Next"]
);
}
#[test]
fn test_document_spacing_keeps_table_single_spaced_between_blocks() {
let md = "Before\n\n| A | B |\n| - | - |\n| 1 | 2 |\n\nAfter";
let rendered: Vec<String> =
render_markdown_with_width_and_mode(md, 40, MarkdownSpacingMode::Document)
.iter()
.map(line_to_string)
.collect();
let table_start = rendered
.iter()
.position(|line| line.contains('│') && line.contains('A') && line.contains('B'))
.expect("table header line");
assert_eq!(rendered[table_start - 1], "");
assert_eq!(rendered[table_start + 3], "");
assert_eq!(rendered.last().map(String::as_str), Some("After"));
}
#[test]
fn test_debug_memory_profile_reports_highlight_cache_usage() {
if let Ok(mut cache) = HIGHLIGHT_CACHE.lock() {
cache.entries.clear();
}
let _ = highlight_code_cached("fn main() { println!(\"hi\"); }", Some("rust"));
let profile = debug_memory_profile();
assert!(profile.highlight_cache_entries >= 1);
assert!(profile.highlight_cache_lines >= 1);
assert!(profile.highlight_cache_estimate_bytes > 0);
}
#[test]
fn test_incremental_renderer_basic() {
let mut renderer = IncrementalMarkdownRenderer::new(Some(80));
// First render
let lines1 = renderer.update("Hello **world**");
assert!(!lines1.is_empty());
// Same text should return cached result
let lines2 = renderer.update("Hello **world**");
assert_eq!(lines1.len(), lines2.len());
// Appended text should work
let lines3 = renderer.update("Hello **world**\n\nMore text");
assert!(lines3.len() > lines1.len());
}
#[test]
fn test_incremental_renderer_streaming() {
let mut renderer = IncrementalMarkdownRenderer::new(Some(80));
// Simulate streaming tokens
let _ = renderer.update("Hello ");
let _ = renderer.update("Hello world");
let _ = renderer.update("Hello world\n\n");
let lines = renderer.update("Hello world\n\nParagraph 2");
// Should have rendered both paragraphs
assert!(lines.len() >= 2);
}
#[test]
fn test_incremental_renderer_streaming_heading_does_not_duplicate() {
let mut renderer = IncrementalMarkdownRenderer::new(Some(80));
let _ = renderer.update("## Planning");
let _ = renderer.update("## Planning\n\n");
let lines = renderer.update("## Planning\n\nNext step");
let rendered = lines_to_string(&lines);
assert_eq!(rendered.matches("Planning").count(), 1, "{rendered}");
assert!(rendered.contains("Next step"), "{rendered}");
}
#[test]
fn test_incremental_renderer_streaming_inline_math() {
let mut renderer = IncrementalMarkdownRenderer::new(Some(80));
let _ = renderer.update("Compute $x^");
let lines = renderer.update("Compute $x^2$");
let rendered = lines_to_string(&lines);
assert!(rendered.contains(""), "{rendered}");
assert!(!rendered.contains("$x^2$"), "{rendered}");
}
#[test]
fn test_incremental_renderer_streaming_display_math() {
let mut renderer = IncrementalMarkdownRenderer::new(Some(80));
let _ = renderer.update("Intro\n\n$$\nA + B");
let lines = renderer.update("Intro\n\n$$\nA + B\n$$\n");
let rendered = lines_to_string(&lines);
assert!(
rendered.contains("┌─ math"),
"expected display math block after closing delimiter: {}",
rendered
);
assert!(rendered.contains("│ A + B"), "expected math body");
assert!(
!rendered.contains("$$"),
"expected raw $$ delimiters to be consumed: {}",
rendered
);
}
#[test]
fn test_incremental_renderer_streaming_bracketed_and_fenced_latex() {
let mut renderer = IncrementalMarkdownRenderer::new(Some(80));
let partial = renderer.update("Result:\n\n\\[\\frac{x+1}");
let partial_text = lines_to_string(&partial);
assert!(partial_text.contains("[\\frac{x+1}"), "{partial_text}");
assert!(!partial_text.contains("┌─ math"), "{partial_text}");
let complete = renderer.update("Result:\n\n\\[\\frac{x+1}{y}\\]");
let complete_text = lines_to_string(&complete);
assert!(complete_text.contains("─────"), "{complete_text}");
assert!(!complete_text.contains("\\frac"), "{complete_text}");
let fenced = renderer.update("```latex\n\\alpha_2 + x^2\n```");
let fenced_text = lines_to_string(&fenced);
assert!(fenced_text.contains("α₂ + x²"), "{fenced_text}");
assert!(fenced_text.contains("┌─ math"), "{fenced_text}");
}
#[test]
fn test_incremental_renderer_streams_fenced_block_before_close() {
let mut renderer = IncrementalMarkdownRenderer::new(Some(80));
let _ = renderer.update("Plan:\n\n```\n");
let lines = renderer.update("Plan:\n\n```\nProcess A: |████\n");
let rendered = lines_to_string(&lines);
assert!(
rendered.contains("Process A"),
"Expected streamed code-block content before closing fence: {}",
rendered
);
}
#[cfg(feature = "mermaid-renderer")]
#[test]
fn test_incremental_renderer_defers_mermaid_render_until_background_ready() {
jcode_tui_mermaid::clear_cache().ok();
let mut renderer = IncrementalMarkdownRenderer::new(Some(80));
let text = "Plan:\n\n```mermaid\nflowchart LR\n A[Start] --> B[End]\n```\n";
let lines = renderer.update(text);
let rendered = lines_to_string(&lines);
assert!(
rendered.contains("rendering mermaid diagram")
|| rendered.contains("mermaid diagram rendering"),
"expected deferred mermaid placeholder on first completed streaming render: {}",
rendered
);
}
#[test]
fn test_pending_placeholder_line_detection() {
let placeholder = Line::from(Span::styled(
MERMAID_PENDING_PLACEHOLDER_TEXT.to_string(),
Style::default(),
));
assert!(line_is_mermaid_pending_placeholder(&placeholder));
// Centered display modes prepend a padding span.
let padded = Line::from(vec![
Span::raw(" "),
Span::styled(
MERMAID_PENDING_PLACEHOLDER_TEXT.to_string(),
Style::default(),
),
]);
assert!(line_is_mermaid_pending_placeholder(&padded));
// A narrow wrap can truncate the tail; the prefix still matches.
let wrapped = Line::from(Span::raw("↻ rendering mermaid"));
assert!(line_is_mermaid_pending_placeholder(&wrapped));
assert!(!line_is_mermaid_pending_placeholder(&Line::from("")));
assert!(!line_is_mermaid_pending_placeholder(&Line::from(
"↗ mermaid diagram (image protocols unavailable)"
)));
assert!(!line_is_mermaid_pending_placeholder(&Line::from(vec![
Span::raw(MERMAID_PENDING_PLACEHOLDER_TEXT),
Span::raw("extra content"),
])));
}
/// A completed background mermaid render advances the deferred epoch without
/// changing the streamed text. The incremental renderer must not serve its
/// identical-text fast path in that case, or the transcript placeholder
/// ("rendering mermaid diagram...") never resolves into the diagram.
#[cfg(feature = "mermaid-renderer")]
#[test]
fn test_incremental_renderer_rerenders_pending_mermaid_after_epoch_bump() {
let mut renderer = IncrementalMarkdownRenderer::new(Some(80));
// Unique content so no earlier test populated the render cache for it.
let text = "Plan:\n\n```mermaid\nflowchart LR\n E1[EpochBump] --> E2[FastPath]\n```\n";
let lines = renderer.update(text);
if !lines.iter().any(line_is_mermaid_pending_placeholder) {
// Cache already warm (render finished before this update); nothing to pin.
return;
}
// Simulate the background render completing. (The real worker may also
// bump concurrently; either way the epoch now differs from the stamp
// taken before the pending render above.)
jcode_tui_mermaid::debug_bump_deferred_render_epoch_for_tests();
let before = thread_render_count();
let _ = renderer.update(text);
assert!(
thread_render_count() > before,
"identical text with an advanced deferred epoch must re-render \
so the completed diagram replaces its placeholder"
);
}
#[test]
fn test_checkpoint_does_not_enter_unclosed_fence() {
let renderer = IncrementalMarkdownRenderer::new(Some(80));
let text = "Intro\n\n```\nProcess A\n\nProcess B";
let checkpoint = renderer.find_last_complete_block(text);
assert_eq!(checkpoint, Some("Intro\n\n".len()));
}
#[test]
fn test_checkpoint_advances_after_heading_line() {
let renderer = IncrementalMarkdownRenderer::new(Some(80));
let text = "## Planning\nNext item";
let checkpoint = renderer.find_last_complete_block(text);
assert_eq!(checkpoint, Some("## Planning\n".len()));
}
#[test]
fn test_incremental_renderer_replaces_stale_prefix_chars() {
let mut renderer = IncrementalMarkdownRenderer::new(Some(80));
let _ = renderer.update("Plan:\n\n```\n[\n");
let lines = renderer.update("Plan:\n\n```\nProcess A\n");
let rendered = lines_to_string(&lines);
assert!(
!rendered.contains("│ ["),
"Expected stale '[' to be replaced during streaming: {}",
rendered
);
assert!(rendered.contains("Process A"));
}
#[test]
fn test_streaming_unclosed_bracket_keeps_text_visible() {
let mut renderer = IncrementalMarkdownRenderer::new(Some(80));
let lines = renderer.update("[Process A: |████");
let rendered = lines_to_string(&lines);
assert!(
rendered.contains("Process A"),
"Expected unclosed bracket line to remain visible: {}",
rendered
);
}
#[test]
fn test_incremental_renderer_matches_full_render_for_prefixes() {
let sample = concat!(
"## Plan\n\n",
"First paragraph with **bold** text.\n\n",
"---\n\n",
"- item one\n",
"- item two\n\n",
"```rust\n",
"fn main() {\n",
" println!(\"hi\");\n",
"}\n",
"```\n\n",
"Trailing <span>html</span> text.\n",
);
let mut renderer = IncrementalMarkdownRenderer::new(Some(60));
for end in 0..=sample.len() {
if !sample.is_char_boundary(end) {
continue;
}
let prefix = &sample[..end];
let incremental = lines_to_string(&renderer.update(prefix));
let full = lines_to_string(&render_markdown_with_width(prefix, Some(60)));
assert_eq!(
incremental, full,
"incremental render diverged at prefix {end}:\n--- prefix ---\n{prefix:?}\n--- incremental ---\n{incremental}\n--- full ---\n{full}"
);
}
}
@@ -0,0 +1,267 @@
#[test]
fn test_center_aligned_wrap_balances_lines() {
let line = Line::from("aa aa aa aa aa aa aa aa aa").alignment(Alignment::Center);
let wrapped = wrap_line(line, 20);
let widths: Vec<usize> = wrapped.iter().map(Line::width).collect();
assert_eq!(wrapped.len(), 2, "{wrapped:?}");
let min = widths.iter().copied().min().unwrap_or(0);
let max = widths.iter().copied().max().unwrap_or(0);
assert!(max - min <= 3, "expected balanced widths, got {widths:?}");
}
#[test]
fn test_lazy_rendering_visible_range() {
let md = "```rust\nfn main() {\n println!(\"hello\");\n}\n```\n\nSome text\n\n```python\nprint('hi')\n```";
// Render with full visibility
let lines_full = render_markdown_lazy(md, Some(80), 0..100);
// Render with partial visibility (only first code block visible)
let lines_partial = render_markdown_lazy(md, Some(80), 0..5);
// Both should produce output
assert!(!lines_full.is_empty());
assert!(!lines_partial.is_empty());
}
#[test]
fn test_lazy_rendering_matches_full_latex_output() {
let md = r"Inline $\alpha_2 + x^2$.
$$\frac{x+1}{y}$$";
let full = lines_to_string(&render_markdown_with_width(md, Some(80)));
let lazy = lines_to_string(&render_markdown_lazy(md, Some(80), 0..100));
assert_eq!(lazy, full);
assert!(lazy.contains("α₂ + x²"), "{lazy}");
assert!(lazy.contains("─────"), "{lazy}");
}
#[test]
fn test_ranges_overlap() {
assert!(ranges_overlap(0..10, 5..15));
assert!(ranges_overlap(5..15, 0..10));
assert!(!ranges_overlap(0..5, 10..15));
assert!(!ranges_overlap(10..15, 0..5));
assert!(ranges_overlap(0..10, 0..10)); // Same range
assert!(ranges_overlap(0..10, 5..6)); // Contained
}
#[test]
fn test_highlight_cache_performance() {
// First call should cache
let code = "fn main() {\n println!(\"hello\");\n}";
let lines1 = highlight_code_cached(code, Some("rust"));
// Second call should hit cache
let lines2 = highlight_code_cached(code, Some("rust"));
assert_eq!(lines1.len(), lines2.len());
}
#[test]
fn test_bold_with_dollar_signs() {
let md = "Meet the **$35 minimum** (local delivery) and delivery is **free**. Below that, expect a **$5.99** fee.";
let lines = render_markdown(md);
let rendered = lines_to_string(&lines);
assert!(
!rendered.contains("**"),
"Bold markers should not appear as literal text: {}",
rendered
);
assert!(rendered.contains("$35 minimum"));
assert!(rendered.contains("$5.99"));
}
#[test]
fn test_escape_currency_preserves_math() {
assert_eq!(escape_currency_dollars("$x^2$"), "$x^2$");
assert_eq!(escape_currency_dollars("$$E=mc^2$$"), "$$E=mc^2$$");
assert_eq!(escape_currency_dollars("costs $35"), "costs \\$35");
assert_eq!(escape_currency_dollars("`$100`"), "`$100`");
assert_eq!(escape_currency_dollars("```\n$50\n```"), "```\n$50\n```");
assert_eq!(escape_currency_dollars("\\$10"), "\\$10");
assert_eq!(escape_currency_dollars("████████░░░░"), "████████░░░░");
assert_eq!(escape_currency_dollars("⣿⣿⣿⣀⣀⣀"), "⣿⣿⣿⣀⣀⣀");
assert_eq!(escape_currency_dollars("▓▓▒▒░░"), "▓▓▒▒░░");
assert_eq!(escape_currency_dollars("━━━╺━━━"), "━━━╺━━━");
assert_eq!(escape_currency_dollars("⠋ Loading $5"), "⠋ Loading \\$5");
}
#[test]
fn test_currency_dollars_in_indented_code_block() {
assert_eq!(
escape_currency_dollars(" ```\nCost is $35\n```"),
" ```\nCost is $35\n```"
);
assert_eq!(
escape_currency_dollars(" ```\nCost is $35\n```"),
" ```\nCost is $35\n```"
);
assert_eq!(
escape_currency_dollars(" ```\nCost is $35\n```"),
" ```\nCost is $35\n```"
);
}
#[test]
fn test_fence_closing_not_triggered_mid_line() {
let md = "```\nvalue = `code` and then ``` in same line\n```";
let rendered = lines_to_string(&render_markdown(md));
assert!(rendered.contains("`code`"));
assert!(rendered.contains("in same line"));
}
#[test]
fn test_line_oriented_tool_transcript_softbreaks_are_preserved() {
let md = concat!(
"tool: batch\n",
"✓ batch 3 calls\n",
" ✓ bash $ git status --short --branch\n",
" ✓ communicate list\n",
"┌─ diff\n",
"│ 810- Session(SessionInfo),\n",
"└─\n"
);
let lines = render_markdown_with_width(md, Some(28));
let rendered: Vec<String> = lines.iter().map(line_to_string).collect();
assert!(
rendered
.iter()
.any(|line| line.trim_start() == "tool: batch"),
"expected tool transcript header to stay on its own line: {rendered:?}"
);
assert!(
rendered
.iter()
.any(|line| line.trim_start().starts_with("✓ batch 3 calls")),
"expected batch summary to stay on its own line: {rendered:?}"
);
assert!(
rendered
.iter()
.any(|line| line.trim_start().starts_with("✓ bash $ git status")),
"expected nested transcript line to stay on its own line: {rendered:?}"
);
assert!(
rendered
.iter()
.any(|line| line.trim_start().starts_with("┌─ diff")),
"expected diff box header to stay on its own line: {rendered:?}"
);
assert!(
rendered
.iter()
.all(|line| !(line.contains("tool: batch") && line.contains("✓ batch 3 calls"))),
"tool transcript lines should not collapse into one wrapped paragraph: {rendered:?}"
);
}
#[test]
fn test_line_oriented_tool_transcript_followed_by_prose_gets_blank_line() {
let md = concat!(
"tool: batch\n",
"✓ batch 1 calls\n",
"Done checking the formatting."
);
let rendered: Vec<String> = render_markdown_with_width(md, Some(48))
.iter()
.map(line_to_string)
.collect();
let batch_idx = rendered
.iter()
.position(|line| line.trim_start() == "✓ batch 1 calls")
.expect("missing batch transcript line");
let prose_idx = rendered
.iter()
.position(|line| line.trim_start() == "Done checking the formatting.")
.expect("missing prose line");
assert_eq!(
prose_idx,
batch_idx + 2,
"expected a blank line between transcript block and prose: {rendered:?}"
);
assert!(
rendered[batch_idx + 1].trim().is_empty(),
"expected separator line to be blank: {rendered:?}"
);
}
#[test]
fn test_prose_before_line_oriented_tool_transcript_gets_blank_line() {
let md = concat!(
"I checked the repo state.\n",
"✓ batch 1 calls\n",
" ✓ read src/main.rs"
);
let rendered: Vec<String> = render_markdown_with_width(md, Some(48))
.iter()
.map(line_to_string)
.collect();
let prose_idx = rendered
.iter()
.position(|line| line.trim_start() == "I checked the repo state.")
.expect("missing prose line");
let transcript_idx = rendered
.iter()
.position(|line| line.trim_start() == "✓ batch 1 calls")
.expect("missing transcript line");
assert_eq!(
transcript_idx,
prose_idx + 2,
"expected a blank line before transcript block: {rendered:?}"
);
assert!(
rendered[prose_idx + 1].trim().is_empty(),
"expected separator line to be blank: {rendered:?}"
);
}
#[test]
fn wrap_keeps_word_intact_across_span_boundary() {
// Regression: a word split across multiple styled spans (e.g. smart-quote
// tokenization turning "there's" into "there" + "" + "s") must not be
// broken mid-word at the span boundary when wrapping left-aligned text.
use ratatui::style::{Color, Style};
let spans = vec![
Span::styled("or if there".to_string(), Style::default()),
Span::styled("\u{2019}".to_string(), Style::default().fg(Color::Red)),
Span::styled("s something about the".to_string(), Style::default()),
];
let line = Line::from(spans); // left aligned (default)
let wrapped = wrap_line(line, 13);
let rendered: Vec<String> = wrapped.iter().map(line_to_string).collect();
// "theres" must appear intact on a single rendered line.
assert!(
rendered.iter().any(|l| l.contains("there\u{2019}s")),
"expected word to stay intact, got {rendered:?}"
);
// No line may end with the apostrophe while the next starts with the rest.
for (i, l) in rendered.iter().enumerate() {
if l.trim_end().ends_with('\u{2019}')
&& let Some(next) = rendered.get(i + 1)
{
assert!(
!next.trim_start().starts_with('s'),
"word 'there\u{2019}s' was split across lines: {rendered:?}"
);
}
}
// Each rendered line must respect the width budget.
for l in &rendered {
assert!(l.width() <= 13, "line exceeds width: {l:?} in {rendered:?}");
}
}
@@ -0,0 +1,34 @@
use super::*;
fn line_to_string(line: &Line<'_>) -> String {
line.spans.iter().map(|s| s.content.as_ref()).collect()
}
fn leading_spaces(text: &str) -> usize {
text.chars().take_while(|c| *c == ' ').count()
}
fn render_markdown_with_mode(text: &str, mode: MarkdownSpacingMode) -> Vec<Line<'static>> {
with_markdown_spacing_mode_override(Some(mode), || render_markdown(text))
}
fn render_markdown_with_width_and_mode(
text: &str,
width: usize,
mode: MarkdownSpacingMode,
) -> Vec<Line<'static>> {
with_markdown_spacing_mode_override(Some(mode), || {
render_markdown_with_width(text, Some(width))
})
}
fn lines_to_string(lines: &[Line<'_>]) -> String {
lines
.iter()
.map(line_to_string)
.collect::<Vec<_>>()
.join("\n")
}
#[path = "cases.rs"]
mod cases;
@@ -0,0 +1,197 @@
use super::{is_closing_fence, parse_opening_fence};
pub(crate) fn escape_currency_dollars(text: &str) -> String {
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
let mut out = String::with_capacity(text.len());
let mut i = 0;
let mut in_code_fence = false;
let mut inline_code_len: usize = 0;
let mut at_line_start = true;
let mut leading_spaces = 0;
let count_backticks = |chars: &[char], start: usize| {
let mut j = start;
while j < chars.len() && chars[j] == '`' {
j += 1;
}
j - start
};
let is_escaped = |chars: &[char], pos: usize| {
let mut backslashes = 0usize;
let mut j = pos;
while j > 0 {
if chars[j - 1] != '\\' {
break;
}
backslashes += 1;
j -= 1;
}
backslashes % 2 == 1
};
while i < len {
let c = chars[i];
if c == '\n' {
at_line_start = true;
leading_spaces = 0;
out.push('\n');
i += 1;
continue;
}
if at_line_start && (c == ' ' || c == '\t') {
leading_spaces += 1;
out.push(c);
i += 1;
continue;
}
let maybe_fence = inline_code_len == 0 && c == '`' && count_backticks(&chars, i) >= 3;
if maybe_fence && at_line_start && leading_spaces <= 3 {
let run = count_backticks(&chars, i);
for _ in 0..run {
out.push('`');
}
i += run;
in_code_fence = !in_code_fence;
at_line_start = false;
leading_spaces = 0;
continue;
}
if c == '`' {
let run = count_backticks(&chars, i);
if inline_code_len > 0 {
if run == inline_code_len {
inline_code_len = 0;
}
for _ in 0..run {
out.push('`');
}
i += run;
at_line_start = false;
leading_spaces = 0;
continue;
}
inline_code_len = run;
for _ in 0..run {
out.push('`');
}
i += run;
at_line_start = false;
leading_spaces = 0;
continue;
}
if at_line_start {
at_line_start = false;
}
if c == ' ' || c == '\t' {
out.push(c);
i += 1;
continue;
}
if in_code_fence || inline_code_len > 0 {
out.push(c);
i += 1;
continue;
}
if c == '$' && i + 1 < len && chars[i + 1] == '$' {
out.push_str("$$");
i += 2;
continue;
}
if c == '$' && i + 1 < len && chars[i + 1].is_ascii_digit() {
if is_escaped(&chars, i) {
out.push('$');
} else {
out.push_str("\\$");
}
i += 1;
continue;
}
out.push(c);
i += 1;
}
out
}
pub(crate) fn looks_like_line_oriented_transcript_line(line: &str) -> bool {
let trimmed = line.trim_start();
if trimmed.is_empty() {
return false;
}
if trimmed.starts_with("tool:")
|| trimmed.starts_with("tools:")
|| trimmed.starts_with("broadcast from ")
{
return true;
}
matches!(trimmed.chars().next(), Some('✓' | '✗' | '┌' | '│' | '└'))
}
pub(crate) fn preserve_line_oriented_softbreaks(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let lines: Vec<&str> = text.split('\n').collect();
let mut in_code_fence = false;
let mut fence_char = '\0';
let mut fence_len = 0usize;
for (idx, line) in lines.iter().enumerate() {
let prev_line = idx.checked_sub(1).map(|prev| lines[prev]);
let prev_log_like = prev_line.is_some_and(looks_like_line_oriented_transcript_line);
let next_log_like =
idx + 1 < lines.len() && looks_like_line_oriented_transcript_line(lines[idx + 1]);
let line_log_like = looks_like_line_oriented_transcript_line(line);
let entering_log_block = !in_code_fence
&& line_log_like
&& !prev_log_like
&& prev_line.is_some_and(|prev| !prev.trim().is_empty());
let leaving_log_block = !in_code_fence
&& line_log_like
&& !next_log_like
&& idx + 1 < lines.len()
&& !lines[idx + 1].trim().is_empty();
let preserve_softbreak = !in_code_fence && line_log_like && next_log_like;
if entering_log_block && !out.ends_with("\n\n") {
out.push('\n');
}
out.push_str(line);
if idx + 1 < lines.len() {
if preserve_softbreak && !line.ends_with(" ") {
out.push_str(" ");
}
out.push('\n');
if leaving_log_block {
out.push('\n');
}
}
if in_code_fence {
if is_closing_fence(line, fence_char, fence_len) {
in_code_fence = false;
fence_char = '\0';
fence_len = 0;
}
} else if let Some((marker, min_len)) = parse_opening_fence(line) {
in_code_fence = true;
fence_char = marker;
fence_len = min_len;
}
}
out
}
@@ -0,0 +1,71 @@
use serde::Serialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize)]
pub enum DiagramDisplayMode {
#[default]
None,
Margin,
Pinned,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize)]
pub enum MarkdownSpacingMode {
#[default]
Compact,
Document,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum LatexRenderingMode {
None,
Unicode,
#[default]
Image,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CopyTargetKind {
CodeBlock { language: Option<String> },
Blockquote,
Error,
ToolOutput,
}
impl CopyTargetKind {
pub fn label(&self) -> String {
match self {
Self::CodeBlock { language } => language
.as_deref()
.filter(|lang| !lang.is_empty())
.unwrap_or("code")
.to_string(),
Self::Blockquote => "quote".to_string(),
Self::Error => "error".to_string(),
Self::ToolOutput => "output".to_string(),
}
}
pub fn copied_notice(&self) -> String {
match self {
Self::CodeBlock { language } => {
let label = language
.as_deref()
.filter(|lang| !lang.is_empty())
.unwrap_or("code block");
format!("Copied {}", label)
}
Self::Blockquote => "Copied quote".to_string(),
Self::Error => "Copied error".to_string(),
Self::ToolOutput => "Copied output".to_string(),
}
}
}
#[derive(Clone, Debug)]
pub struct RawCopyTarget {
pub kind: CopyTargetKind,
pub content: String,
pub start_raw_line: usize,
pub end_raw_line: usize,
pub badge_raw_line: usize,
}
@@ -0,0 +1,549 @@
use jcode_tui_workspace::color_support::rgb;
use ratatui::prelude::*;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
pub fn wrap_line(
line: Line<'static>,
width: usize,
repeated_gutter_prefix: impl Fn(&Line<'static>) -> Option<(Vec<Span<'static>>, usize)>,
) -> Vec<Line<'static>> {
if width == 0 {
return vec![line];
}
let alignment = line.alignment;
let repeated_prefix = repeated_gutter_prefix(&line).and_then(|(prefix_spans, prefix_width)| {
if prefix_width == 0 || prefix_width >= width {
None
} else {
Some((prefix_spans, prefix_width))
}
});
let seed_repeated_prefix =
|current_spans: &mut Vec<Span<'static>>, current_width: &mut usize, pending: &mut bool| {
if *pending {
if let Some((prefix_spans, prefix_width)) = &repeated_prefix {
current_spans.extend(prefix_spans.iter().cloned());
*current_width = *prefix_width;
}
*pending = false;
}
};
if let Some(balanced) = wrap_line_balanced(&line, width) {
return balanced;
}
let initial_prefix_width = repeated_prefix
.as_ref()
.map(|(_, prefix_width)| *prefix_width)
.unwrap_or(0);
// Tokenize the entire line into whitespace-delimited words (with their
// trailing spaces) so that words are kept intact even when they span
// multiple styled spans. Breaking only happens at whitespace, and words
// wider than the available width are split character-by-character.
let tokens = tokenize_wrap_words(&line.spans);
let mut result: Vec<Line<'static>> = Vec::new();
let mut current_spans: Vec<Span<'static>> = Vec::with_capacity(line.spans.len());
let mut current_width = 0usize;
let mut current_has_content = false;
let mut pending_repeated_prefix = false;
let flush_line = |result: &mut Vec<Line<'static>>,
current_spans: &mut Vec<Span<'static>>,
current_width: &mut usize,
current_has_content: &mut bool,
pending_repeated_prefix: &mut bool| {
let mut new_line = Line::from(std::mem::take(current_spans));
if let Some(align) = alignment {
new_line = new_line.alignment(align);
}
result.push(new_line);
*current_width = 0;
*current_has_content = false;
*pending_repeated_prefix = repeated_prefix.is_some();
};
for token in tokens {
let token_width = token.word_width + token.space_width;
// If the whole token (word + trailing spaces) does not fit on the
// current line and the line already has content, wrap first so the
// word starts on a fresh line (keeping it intact when possible).
if current_width + token_width > width && current_has_content {
flush_line(
&mut result,
&mut current_spans,
&mut current_width,
&mut current_has_content,
&mut pending_repeated_prefix,
);
}
if token.word_width > width {
// Word is too wide to ever fit on one line: split by characters.
let mut part = String::new();
let mut part_width = 0usize;
let mut part_style: Option<Style> = None;
let flush_part = |current_spans: &mut Vec<Span<'static>>,
current_width: &mut usize,
current_has_content: &mut bool,
part: &mut String,
part_width: &mut usize,
part_style: &mut Option<Style>| {
if !part.is_empty() {
let style = part_style.unwrap_or_default();
current_spans.push(Span::styled(std::mem::take(part), style));
let width_before = *current_width;
*current_width += *part_width;
if width_before + *part_width > initial_prefix_width {
*current_has_content = true;
}
*part_width = 0;
*part_style = None;
}
};
for (c, style) in token.word_chars() {
seed_repeated_prefix(
&mut current_spans,
&mut current_width,
&mut pending_repeated_prefix,
);
let char_width = c.width().unwrap_or(0);
if current_width + part_width + char_width > width
&& (current_width + part_width) > 0
{
flush_part(
&mut current_spans,
&mut current_width,
&mut current_has_content,
&mut part,
&mut part_width,
&mut part_style,
);
if current_has_content {
flush_line(
&mut result,
&mut current_spans,
&mut current_width,
&mut current_has_content,
&mut pending_repeated_prefix,
);
}
seed_repeated_prefix(
&mut current_spans,
&mut current_width,
&mut pending_repeated_prefix,
);
}
if part_style != Some(style) {
flush_part(
&mut current_spans,
&mut current_width,
&mut current_has_content,
&mut part,
&mut part_width,
&mut part_style,
);
part_style = Some(style);
}
part.push(c);
part_width += char_width;
}
flush_part(
&mut current_spans,
&mut current_width,
&mut current_has_content,
&mut part,
&mut part_width,
&mut part_style,
);
// Append trailing spaces (these may be trimmed at line end later).
append_token_spaces(
&token,
&mut current_spans,
&mut current_width,
&mut current_has_content,
initial_prefix_width,
);
} else {
seed_repeated_prefix(
&mut current_spans,
&mut current_width,
&mut pending_repeated_prefix,
);
for piece in &token.word {
current_spans.push(Span::styled(piece.text.clone(), piece.style));
}
let width_before = current_width;
current_width += token.word_width;
if width_before + token.word_width > initial_prefix_width {
current_has_content = true;
}
append_token_spaces(
&token,
&mut current_spans,
&mut current_width,
&mut current_has_content,
initial_prefix_width,
);
}
}
if !current_spans.is_empty() && current_has_content {
let mut new_line = Line::from(current_spans);
if let Some(align) = alignment {
new_line = new_line.alignment(align);
}
result.push(new_line);
}
if result.is_empty() {
let mut empty_line = Line::from("");
if let Some(align) = alignment {
empty_line = empty_line.alignment(align);
}
result.push(empty_line);
}
result
}
#[derive(Clone)]
struct StyledPiece {
text: String,
style: Style,
}
#[derive(Clone)]
struct WrapToken {
word: Vec<StyledPiece>,
spaces: Vec<StyledPiece>,
word_width: usize,
space_width: usize,
}
impl WrapToken {
/// Iterate over the word's characters paired with their style, preserving
/// per-piece styling so split words keep their original colors.
fn word_chars(&self) -> impl Iterator<Item = (char, Style)> + '_ {
self.word
.iter()
.flat_map(|piece| piece.text.chars().map(move |c| (c, piece.style)))
}
}
/// Tokenize line spans into whitespace-delimited words with their trailing
/// whitespace, preserving per-character styles. Unlike `tokenize_balanced_wrap`
/// this never bails out: it handles leading whitespace, runs of whitespace, and
/// tabs by treating any whitespace as a (non-breaking-within) trailing run on
/// the preceding word. A leading whitespace run becomes a token with an empty
/// word so indentation is preserved.
fn tokenize_wrap_words(spans: &[Span<'static>]) -> Vec<WrapToken> {
let mut tokens: Vec<WrapToken> = Vec::new();
let mut word: Vec<StyledPiece> = Vec::new();
let mut spaces: Vec<StyledPiece> = Vec::new();
let mut word_width = 0usize;
let mut space_width = 0usize;
let mut in_spaces = false;
let mut have_token = false;
for span in spans {
let style = span.style;
for ch in span.content.chars() {
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
if ch.is_whitespace() {
in_spaces = true;
have_token = true;
push_piece_char(&mut spaces, ch, style);
space_width += ch_width;
} else {
if in_spaces {
// A new word begins after whitespace: close the previous token.
tokens.push(WrapToken {
word: std::mem::take(&mut word),
spaces: std::mem::take(&mut spaces),
word_width,
space_width,
});
word_width = 0;
space_width = 0;
in_spaces = false;
}
have_token = true;
push_piece_char(&mut word, ch, style);
word_width += ch_width;
}
}
}
if have_token {
tokens.push(WrapToken {
word,
spaces,
word_width,
space_width,
});
}
tokens
}
/// Append a token's trailing whitespace to the current line, accounting for
/// width and content tracking. Whitespace never counts as "content" so it can
/// be trimmed cleanly at line ends by downstream rendering.
fn append_token_spaces(
token: &WrapToken,
current_spans: &mut Vec<Span<'static>>,
current_width: &mut usize,
current_has_content: &mut bool,
initial_prefix_width: usize,
) {
if token.spaces.is_empty() {
return;
}
for piece in &token.spaces {
current_spans.push(Span::styled(piece.text.clone(), piece.style));
}
let width_before = *current_width;
*current_width += token.space_width;
// Spaces only mark content when they extend a line that already had a word
// beyond the prefix (mirrors the previous chunk-based behaviour).
if *current_has_content && width_before + token.space_width > initial_prefix_width {
*current_has_content = true;
}
}
fn wrap_line_balanced(line: &Line<'static>, width: usize) -> Option<Vec<Line<'static>>> {
let alignment = line.alignment?;
if alignment == Alignment::Left {
return None;
}
let flat_text: String = line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
if UnicodeWidthStr::width(flat_text.as_str()) <= width || !flat_text.contains(' ') {
return None;
}
if flat_text.starts_with(char::is_whitespace)
|| flat_text.ends_with(char::is_whitespace)
|| flat_text.contains(" ")
|| flat_text.contains('\t')
{
return None;
}
let tokens = tokenize_balanced_wrap(line)?;
if tokens.len() < 3 || tokens.iter().any(|token| token.word_width > width) {
return None;
}
let (breaks, line_count) = balanced_wrap_breaks(&tokens, width)?;
if line_count <= 1 {
return None;
}
let mut result = Vec::with_capacity(line_count);
let mut start = 0usize;
while start < tokens.len() {
let end = breaks[start];
if end <= start {
return None;
}
let spans = build_balanced_line_spans(&tokens[start..end]);
result.push(Line::from(spans).alignment(alignment));
start = end;
}
Some(result)
}
fn tokenize_balanced_wrap(line: &Line<'static>) -> Option<Vec<WrapToken>> {
let mut tokens = Vec::new();
let mut word = Vec::new();
let mut spaces = Vec::new();
let mut word_width = 0usize;
let mut space_width = 0usize;
let mut seen_word_char = false;
let mut in_spaces = false;
for span in &line.spans {
let style = span.style;
for ch in span.content.chars() {
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
if ch.is_whitespace() {
if !seen_word_char {
return None;
}
in_spaces = true;
push_piece_char(&mut spaces, ch, style);
space_width += ch_width;
} else {
if in_spaces {
tokens.push(WrapToken {
word: std::mem::take(&mut word),
spaces: std::mem::take(&mut spaces),
word_width,
space_width,
});
word_width = 0;
space_width = 0;
in_spaces = false;
}
seen_word_char = true;
push_piece_char(&mut word, ch, style);
word_width += ch_width;
}
}
}
if !seen_word_char || in_spaces {
return None;
}
tokens.push(WrapToken {
word,
spaces,
word_width,
space_width,
});
Some(tokens)
}
fn push_piece_char(pieces: &mut Vec<StyledPiece>, ch: char, style: Style) {
if let Some(last) = pieces.last_mut()
&& last.style == style
{
last.text.push(ch);
return;
}
pieces.push(StyledPiece {
text: ch.to_string(),
style,
});
}
fn balanced_wrap_breaks(tokens: &[WrapToken], width: usize) -> Option<(Vec<usize>, usize)> {
let n = tokens.len();
let mut dp = vec![usize::MAX; n + 1];
let mut breaks = vec![0usize; n];
let mut line_counts = vec![usize::MAX; n + 1];
dp[n] = 0;
line_counts[n] = 0;
for start in (0..n).rev() {
let mut line_width = 0usize;
for end in start..n {
if end == start {
line_width = tokens[end].word_width;
} else {
line_width = line_width
.saturating_add(tokens[end - 1].space_width)
.saturating_add(tokens[end].word_width);
}
if line_width > width {
break;
}
if dp[end + 1] == usize::MAX {
continue;
}
let slack = width - line_width;
let cost = slack.saturating_mul(slack).saturating_add(dp[end + 1]);
let lines_used = 1usize.saturating_add(line_counts[end + 1]);
let should_replace = cost < dp[start]
|| (cost == dp[start] && lines_used < line_counts[start])
|| (cost == dp[start]
&& lines_used == line_counts[start]
&& line_width < line_width_for_break(tokens, start, breaks[start]));
if should_replace {
dp[start] = cost;
breaks[start] = end + 1;
line_counts[start] = lines_used;
}
}
}
if dp[0] == usize::MAX {
None
} else {
Some((breaks, line_counts[0]))
}
}
fn line_width_for_break(tokens: &[WrapToken], start: usize, end: usize) -> usize {
if end <= start {
return usize::MAX;
}
let mut width = 0usize;
for idx in start..end {
if idx > start {
width = width.saturating_add(tokens[idx - 1].space_width);
}
width = width.saturating_add(tokens[idx].word_width);
}
width
}
fn build_balanced_line_spans(tokens: &[WrapToken]) -> Vec<Span<'static>> {
let mut spans = Vec::new();
for (idx, token) in tokens.iter().enumerate() {
for piece in &token.word {
spans.push(Span::styled(piece.text.clone(), piece.style));
}
if idx + 1 < tokens.len() {
for piece in &token.spaces {
spans.push(Span::styled(piece.text.clone(), piece.style));
}
}
}
spans
}
pub fn wrap_lines(
lines: Vec<Line<'static>>,
width: usize,
repeated_gutter_prefix: impl Fn(&Line<'static>) -> Option<(Vec<Span<'static>>, usize)> + Copy,
) -> Vec<Line<'static>> {
lines
.into_iter()
.flat_map(|line| wrap_line(line, width, repeated_gutter_prefix))
.collect()
}
pub fn progress_bar(progress: f32, width: usize) -> String {
let filled = (progress * width as f32) as usize;
let empty = width.saturating_sub(filled);
std::iter::repeat_n('█', filled)
.chain(std::iter::repeat_n('░', empty))
.collect()
}
pub fn progress_line(label: &str, progress: f32, width: usize) -> Line<'static> {
let bar = progress_bar(progress, width.saturating_sub(label.len() + 3));
let pct = (progress * 100.0) as u8;
Line::from(vec![
Span::styled(label.to_string(), Style::default().dim()),
Span::raw(" "),
Span::styled(bar, Style::default().fg(rgb(129, 199, 132))),
Span::styled(format!(" {}%", pct), Style::default().dim()),
])
}
@@ -0,0 +1,200 @@
//! Adapter: backend-neutral [`jcode_render_core::Document`] -> ratatui lines.
//!
//! This is the thin TUI-side translation layer for the shared render core. It
//! resolves the core's semantic [`StyleRole`]/[`FillRole`] to this crate's
//! concrete terminal palette (the same `*_color()` helpers the legacy renderer
//! uses) and turns [`StyledLine`]s into `ratatui::Line<'static>`.
//!
//! The legacy `render_markdown*` path remains authoritative; this adapter is
//! validated against it before any switchover.
use jcode_render_core::{
Alignment as CoreAlignment, BlockKind, Document, FillRole, StyleRole, StyledLine, StyledSpan,
};
use ratatui::layout::Alignment;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use crate::{
bold_color, code_bg, code_fg, heading_color, heading_h1_color, heading_h2_color,
heading_h3_color, html_fg, link_fg, math_fg, md_dim_color, text_color,
};
/// Convert a parsed neutral [`Document`] into ratatui lines using the TUI
/// palette. Blocks are separated by a blank line, matching document spacing.
/// Decorative framing (blockquote bars, code-block borders) is reproduced to
/// match the legacy renderer.
pub fn document_to_lines(doc: &Document) -> Vec<Line<'static>> {
document_to_lines_with_width(doc, None)
}
/// Render a code block with the legacy frame: `┌─ lang`, `│ ` gutter per line,
/// and a closing `└─`.
fn push_code_block(
lines: &mut Vec<Line<'static>>,
block: &jcode_render_core::Block,
language: Option<&str>,
) {
let dim = Style::default().fg(md_dim_color());
let header = match language {
Some(lang) if !lang.is_empty() => format!("┌─ {lang}"),
_ => "┌─".to_string(),
};
lines.push(Line::from(Span::styled(header, dim)));
for sl in &block.lines {
let mut spans = vec![Span::styled("".to_string(), dim)];
spans.extend(sl.spans.iter().map(|s| styled_span_to_span(s, &block.kind)));
lines.push(Line::from(spans));
}
lines.push(Line::from(Span::styled("└─".to_string(), dim)));
}
/// Render a display-math block with the legacy frame: `┌─ math `, `│ ` gutter
/// per source line, and a closing `└─`.
fn push_math_display(lines: &mut Vec<Line<'static>>, block: &jcode_render_core::Block) {
let dim = Style::default().fg(md_dim_color());
lines.push(Line::from(Span::styled("┌─ math ".to_string(), dim)).left_aligned());
for sl in &block.lines {
let text = sl.plain_text();
lines.push(
Line::from(vec![
Span::styled("".to_string(), dim),
Span::styled(text, Style::default().fg(math_fg())),
])
.left_aligned(),
);
}
lines.push(Line::from(Span::styled("└─".to_string(), dim)).left_aligned());
}
/// Convert one neutral [`StyledLine`] to a ratatui [`Line`], given the block it
/// belongs to (used to pick heading-level color).
pub fn styled_line_to_line(sl: &StyledLine, kind: &BlockKind) -> Line<'static> {
let spans: Vec<Span<'static>> = sl
.spans
.iter()
.map(|s| styled_span_to_span(s, kind))
.collect();
let mut line = Line::from(spans);
line.alignment = Some(match sl.alignment {
CoreAlignment::Left => Alignment::Left,
CoreAlignment::Center => Alignment::Center,
CoreAlignment::Right => Alignment::Right,
});
line
}
fn styled_span_to_span(span: &StyledSpan, kind: &BlockKind) -> Span<'static> {
let mut style = Style::default().fg(role_color(span.role, kind));
if span.fill == FillRole::Code {
style = style.bg(code_bg());
}
if span.attrs.bold {
style = style.add_modifier(Modifier::BOLD);
}
if span.attrs.italic {
style = style.add_modifier(Modifier::ITALIC);
}
if span.attrs.strikethrough {
style = style.add_modifier(Modifier::CROSSED_OUT);
}
if span.attrs.underline {
style = style.add_modifier(Modifier::UNDERLINED);
}
Span::styled(span.text.clone(), style)
}
fn role_color(role: StyleRole, kind: &BlockKind) -> ratatui::style::Color {
match role {
StyleRole::Text => text_color(),
StyleRole::Dim => md_dim_color(),
StyleRole::Code => code_fg(),
StyleRole::Link => link_fg(),
StyleRole::Html => html_fg(),
StyleRole::Reasoning => md_dim_color(),
StyleRole::Math => math_fg(),
StyleRole::Strong => match kind {
BlockKind::Heading { level } => match level {
1 => heading_h1_color(),
2 => heading_h2_color(),
3 => heading_h3_color(),
_ => heading_color(),
},
_ => bold_color(),
},
}
}
/// Parse markdown and render it to ratatui lines through the shared core.
pub fn render_markdown_via_core(text: &str) -> Vec<Line<'static>> {
document_to_lines_with_width(&jcode_render_core::parse_markdown(text), None)
}
/// Like [`document_to_lines`] but lays out width-dependent blocks (tables,
/// thematic breaks) to `width` columns when one is supplied. Text is *not*
/// reflowed here; callers wrap separately (mirroring how the legacy
/// `render_markdown_with_width` + `wrap_lines` pipeline is structured).
pub fn document_to_lines_with_width(doc: &Document, width: Option<usize>) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
for (idx, block) in doc.blocks.iter().enumerate() {
if idx > 0 {
lines.push(Line::default());
}
match &block.kind {
BlockKind::CodeBlock { language } => {
push_code_block(&mut lines, block, language.as_deref());
}
BlockKind::MathDisplay => {
push_math_display(&mut lines, block);
}
BlockKind::Table => {
lines.extend(crate::render_support::render_table(&block.table, width));
}
BlockKind::ThematicBreak => {
lines.push(Line::from(Span::styled(
"".repeat(rule_width(width)),
Style::default().fg(md_dim_color()),
)));
}
BlockKind::BlockQuote => {
// The quote gutter (`│ ` per nesting level) is baked into the
// stored lines by the parser, so render spans verbatim.
for sl in &block.lines {
lines.push(styled_line_to_line(sl, &block.kind));
}
}
_ => {
for sl in &block.lines {
lines.push(styled_line_to_line(sl, &block.kind));
}
}
}
}
lines
}
/// Thematic-break width: legacy fills the available width when known and clamps
/// to `RULE_LEN` (24) when centering, otherwise uses `RULE_LEN`.
fn rule_width(width: Option<usize>) -> usize {
match width {
Some(w) if crate::center_code_blocks() => w.min(crate::RULE_LEN),
Some(w) => w,
None => crate::RULE_LEN,
}
}
/// Like [`render_markdown_via_core`] but produces the wrapped layout used in
/// production: width-aware blocks are laid out, then the whole document is
/// reflowed with the authoritative legacy wrapper (which keeps words intact
/// across span boundaries and repeats code/quote gutters).
pub fn render_markdown_via_core_wrapped(text: &str, width: usize) -> Vec<Line<'static>> {
let doc = jcode_render_core::parse_markdown(text);
let lines = document_to_lines_with_width(&doc, Some(width));
crate::wrap_lines(lines, width)
}
#[cfg(test)]
#[path = "render_core_adapter_tests.rs"]
mod tests;
@@ -0,0 +1,563 @@
//! Parity checks: the shared-core adapter vs. the legacy renderer.
//!
//! The two renderers differ in spacing details and some decorative styling, so
//! these tests assert *content* parity (the visible text, modulo blank-line
//! padding and decorative markers) plus key styling invariants, rather than
//! byte-identical `Line` equality. The goal is to prove the shared core
//! reproduces the legacy renderer's meaning before any switchover.
use crate::{render_markdown, render_markdown_via_core};
use ratatui::text::Line;
/// Visible text of each non-blank line, trimmed, for loose comparison.
fn nonblank_texts(lines: &[Line<'static>]) -> Vec<String> {
lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
/// Concatenated visible text with whitespace collapsed, for content-equality
/// checks that ignore layout/spacing differences.
fn flattened(lines: &[Line<'static>]) -> String {
let joined = nonblank_texts(lines).join(" ");
joined.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn assert_content_parity(md: &str) {
let legacy = render_markdown(md);
let core = render_markdown_via_core(md);
assert_eq!(
flattened(&core),
flattened(&legacy),
"content mismatch for input:\n{md}\n--- legacy ---\n{:?}\n--- core ---\n{:?}",
nonblank_texts(&legacy),
nonblank_texts(&core),
);
}
#[test]
fn parity_plain_paragraph() {
assert_content_parity("Hello world, this is a paragraph.");
}
#[test]
fn parity_heading_and_paragraph() {
assert_content_parity("# Title\n\nSome body text here.");
}
#[test]
fn parity_emphasis() {
assert_content_parity("This is *italic* and **bold** and `code`.");
}
#[test]
fn parity_unordered_list() {
assert_content_parity("- alpha\n- beta\n- gamma");
}
#[test]
fn parity_ordered_list() {
assert_content_parity("1. first\n2. second\n3. third");
}
#[test]
fn parity_code_block() {
assert_content_parity("```rust\nfn main() {\n println!(\"hi\");\n}\n```");
}
#[test]
fn parity_blockquote() {
assert_content_parity("> a quoted line");
}
#[test]
fn parity_mixed_document() {
let md = "\
# Heading
Intro paragraph with **bold** and a `snippet`.
- one
- two
Closing line.";
assert_content_parity(md);
}
#[test]
fn core_marks_bold_and_code_styling() {
let core = render_markdown_via_core("text **bold** and `code`");
let spans: Vec<_> = core.iter().flat_map(|l| l.spans.iter()).collect();
assert!(
spans.iter().any(|s| s.content.contains("bold")
&& s.style
.add_modifier
.contains(ratatui::style::Modifier::BOLD)),
"bold word should carry BOLD modifier"
);
assert!(
spans
.iter()
.any(|s| s.content.contains("code") && s.style.bg.is_some()),
"inline code should carry a background fill"
);
}
#[test]
fn parity_table() {
let md = "\
| A | B |
|---|---|
| 1 | 2 |";
assert_content_parity(md);
}
#[test]
fn core_renders_table_borders() {
let core = render_markdown_via_core("| A | B |\n|---|---|\n| 1 | 2 |");
let text: String = core
.iter()
.flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
.collect();
assert!(
text.contains('A') && text.contains('1'),
"table cells present: {text}"
);
}
#[test]
fn core_renders_inline_math() {
let core = render_markdown_via_core("an equation $x^2$ here");
let spans: Vec<_> = core.iter().flat_map(|l| l.spans.iter()).collect();
assert!(
spans.iter().any(|s| s.content.contains("")),
"inline math should render as Unicode"
);
}
#[test]
fn core_renders_display_math_frame() {
let core = render_markdown_via_core("$$\nx^2 + y^2\n$$");
let texts = nonblank_texts(&core);
assert!(
texts.iter().any(|t| t.starts_with("┌─ math")),
"display math should be framed: {texts:?}"
);
assert!(
texts.iter().any(|t| t.contains("x² + y²")),
"display math content present: {texts:?}"
);
}
#[test]
fn parity_currency_dollars() {
assert_content_parity("It costs $35 and then $5.99 total.");
}
#[test]
fn parity_two_currency_amounts_not_math() {
// Legacy escapes $-then-digit so it is NOT treated as inline math.
assert_content_parity("Spend $5 here and $10 there for $15.");
}
#[test]
fn probe_math_divergence() {
let math = crate::math_fg();
for input in [
"$5x$ and more",
"price $5$ each",
"$5+$3 = $8",
"a $1 and $2 b",
"x$5$y",
"buy $5 sell $9 net $4",
"$$\nx=5\n$$",
"inline $a+b$ ok",
] {
let cm: Vec<String> = render_markdown_via_core(input)
.iter()
.flat_map(|l| l.spans.iter())
.filter(|s| s.style.fg == Some(math))
.map(|s| s.content.to_string())
.collect();
let lm: Vec<String> = render_markdown(input)
.iter()
.flat_map(|l| l.spans.iter())
.filter(|s| s.style.fg == Some(math))
.map(|s| s.content.to_string())
.collect();
assert_eq!(cm, lm, "math styling mismatch for {input:?}");
}
}
#[test]
fn fuzz_visible_text_parity() {
// Differential corpus: visible (flattened) text must match the legacy
// renderer across a wide variety of constructs.
let corpus = [
"# H1\n## H2\n### H3",
"plain paragraph with words",
"**bold** _italic_ ~~strike~~ `code`",
"- a\n- b\n - nested\n- c",
"1. one\n2. two\n3. three",
"> quote line one\n> quote line two",
"```rust\nfn f() {}\n```",
"| A | B |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |",
"text $a+b$ inline and $$x=5$$ display",
"money $5 and $9.99 here",
"[link](http://example.com) text",
"a\n\nb\n\nc",
"* item with **bold** and `code`",
"Term\n: definition",
"Mixed: # not heading inline",
"line with \nhard break",
"1. step\n - sub a\n - sub b\n2. step two",
"> nested\n> > deeper quote",
"Para with $35 cost then math $y=2$.",
"---\nafter rule",
"***\nstars rule",
"nested **bold _italic_ end** tail",
"`code with $5` outside $6",
"1. a\n2. b\n 1. b1\n 2. b2\n3. c",
"- [ ] todo\n- [x] done",
"para one\n\n> quote\n\npara two",
"| left | right |\n|:-----|------:|\n| a | b |",
"text with ![alt](img.png) image",
"> quote with **bold** and `code`",
"## Heading with `code` and **bold**",
"Mixed $$\\sum_i x_i$$ display in para",
"emoji 😀 and CJK 中文 text",
"trailing spaces \nnext line",
"$5.00, $6, and $a=b$ together",
"footnote ref[^1]\n\n[^1]: the note",
"auto link <http://example.com> here",
"> - quoted list item\n> - second",
"Heading\n=======\n\nbody",
"Sub\n---\n\nbody",
"a\tb\tc tabs",
"line\\\nwith backslash break",
"**unclosed bold and `code",
"| a |\n|---|\n| $5 |\n| $x$ |",
];
for md in corpus {
assert_eq!(
flattened(&render_markdown_via_core(md)),
flattened(&render_markdown(md)),
"visible-text mismatch for:\n{md}"
);
}
}
// ------------------------------------------------------------------------
// Randomized differential fuzzing.
//
// A tiny xorshift PRNG drives a recursive markdown generator. Each generated
// document is rendered by both the shared core and the legacy renderer; their
// flattened visible text must match. Iteration count is controlled by the
// JCODE_MD_FUZZ_ITERS env var (default 5000) so CI stays fast while local deep
// runs can crank it up.
struct Rng(u64);
impl Rng {
fn new(seed: u64) -> Self {
Rng(seed | 1)
}
fn next_u64(&mut self) -> u64 {
let mut x = self.0;
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
self.0 = x;
x
}
fn below(&mut self, n: usize) -> usize {
(self.next_u64() % n as u64) as usize
}
fn chance(&mut self, n: usize) -> bool {
self.below(n) == 0
}
fn pick<'a, T>(&mut self, items: &'a [T]) -> &'a T {
&items[self.below(items.len())]
}
}
const WORDS: &[&str] = &[
"alpha",
"beta",
"gamma",
"delta",
"x",
"y",
"z",
"the",
"quick",
"brown",
"fox",
"中文",
"데이터",
"emoji",
"lorem",
"ipsum",
"a",
"I",
"we",
"code",
];
fn gen_words(rng: &mut Rng, max: usize) -> String {
let n = 1 + rng.below(max);
(0..n)
.map(|_| *rng.pick(WORDS))
.collect::<Vec<_>>()
.join(" ")
}
/// Generate an inline fragment (no leading/trailing block structure).
fn gen_inline(rng: &mut Rng, depth: usize) -> String {
match rng.below(if depth > 3 { 4 } else { 9 }) {
0 => gen_words(rng, 4),
1 => format!("**{}**", gen_words(rng, 3)),
2 => format!("_{}_", gen_words(rng, 3)),
3 => format!("`{}`", gen_words(rng, 2)),
4 => format!("~~{}~~", gen_words(rng, 2)),
5 => format!(
"[{}](http://example.com/{})",
gen_words(rng, 2),
rng.below(99)
),
6 => format!("${}+{}$", rng.pick(WORDS), rng.pick(WORDS)),
7 => format!("${}", rng.below(999)), // currency
_ => format!(
"{} {} {}",
gen_words(rng, 2),
gen_inline(rng, depth + 1),
gen_words(rng, 2)
),
}
}
/// Generate a block-level fragment.
fn gen_block(rng: &mut Rng, depth: usize) -> String {
match rng.below(if depth > 2 { 6 } else { 11 }) {
0 => gen_inline(rng, 0),
1 => {
let level = 1 + rng.below(3);
format!("{} {}", "#".repeat(level), gen_inline(rng, 0))
}
2 => {
// unordered list
let n = 1 + rng.below(3);
(0..n)
.map(|_| format!("- {}", gen_inline(rng, 0)))
.collect::<Vec<_>>()
.join("\n")
}
3 => {
// ordered list
let n = 1 + rng.below(3);
(0..n)
.map(|i| format!("{}. {}", i + 1, gen_inline(rng, 0)))
.collect::<Vec<_>>()
.join("\n")
}
4 => {
// fenced code block
let lang = if rng.chance(2) { "rust" } else { "" };
let n = 1 + rng.below(3);
let body = (0..n)
.map(|_| format!("let {} = {};", rng.pick(WORDS), rng.below(99)))
.collect::<Vec<_>>()
.join("\n");
format!("```{lang}\n{body}\n```")
}
5 => "---".to_string(),
6 => {
// table
let cols = 1 + rng.below(3);
let header: Vec<String> = (0..cols).map(|_| gen_words(rng, 1)).collect();
let sep: Vec<&str> = (0..cols).map(|_| "---").collect();
let rows = 1 + rng.below(3);
let mut out = format!("| {} |\n| {} |", header.join(" | "), sep.join(" | "));
for _ in 0..rows {
let cells: Vec<String> = (0..cols).map(|_| gen_words(rng, 2)).collect();
out.push_str(&format!("\n| {} |", cells.join(" | ")));
}
out
}
7 => {
// blockquote (possibly nested / multiline)
let n = 1 + rng.below(3);
(0..n)
.map(|_| {
let prefix = "> ".repeat(1 + rng.below(2));
format!("{}{}", prefix, gen_inline(rng, 0))
})
.collect::<Vec<_>>()
.join("\n")
}
8 => {
// task list
let n = 1 + rng.below(3);
(0..n)
.map(|_| {
let mark = if rng.chance(2) { "x" } else { " " };
format!("- [{}] {}", mark, gen_inline(rng, 0))
})
.collect::<Vec<_>>()
.join("\n")
}
9 => {
// definition list
format!("{}\n: {}", gen_words(rng, 2), gen_inline(rng, 0))
}
_ => {
// footnote
format!(
"{}[^{n}]\n\n[^{n}]: {}",
gen_inline(rng, 0),
gen_inline(rng, 0),
n = rng.below(50)
)
}
}
}
fn gen_document(rng: &mut Rng) -> String {
let n = 1 + rng.below(5);
(0..n)
.map(|_| gen_block(rng, 0))
.collect::<Vec<_>>()
.join("\n\n")
}
#[test]
fn fuzz_random_documents_parity() {
let iters: u64 = std::env::var("JCODE_MD_FUZZ_ITERS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(5000);
let base_seed: u64 = std::env::var("JCODE_MD_FUZZ_SEED")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(0x9E3779B97F4A7C15);
let mut failures = Vec::new();
for i in 0..iters {
let mut rng = Rng::new(base_seed.wrapping_add(i.wrapping_mul(0x100000001B3)));
let md = gen_document(&mut rng);
let core = flattened(&render_markdown_via_core(&md));
let legacy = flattened(&render_markdown(&md));
if core != legacy {
failures.push((i, md, core, legacy));
if failures.len() >= 5 {
break;
}
}
}
assert!(
failures.is_empty(),
"random-document parity mismatches ({} shown):\n{}",
failures.len(),
failures
.iter()
.map(|(i, md, core, legacy)| format!(
"--- iter {i} ---\nINPUT:\n{md}\nCORE: {core:?}\nLEGACY: {legacy:?}"
))
.collect::<Vec<_>>()
.join("\n\n")
);
}
/// Stricter than `flattened`: compares the per-line visible text (trimmed,
/// blanks dropped) so line-structure/break divergences are caught, not just
/// whitespace-collapsed content.
#[test]
fn fuzz_random_documents_line_structure() {
let iters: u64 = std::env::var("JCODE_MD_FUZZ_ITERS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(5000);
let base_seed: u64 = std::env::var("JCODE_MD_FUZZ_SEED")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(0x1234_5678_9ABC_DEF0);
let mut failures = Vec::new();
for i in 0..iters {
let mut rng = Rng::new(base_seed.wrapping_add(i.wrapping_mul(0x100000001B3)));
let md = gen_document(&mut rng);
let core = nonblank_texts(&render_markdown_via_core(&md));
let legacy = nonblank_texts(&render_markdown(&md));
if core != legacy {
failures.push((i, md, core, legacy));
if failures.len() >= 5 {
break;
}
}
}
assert!(
failures.is_empty(),
"line-structure parity mismatches ({} shown):\n{}",
failures.len(),
failures
.iter()
.map(|(i, md, core, legacy)| format!(
"--- iter {i} ---\nINPUT:\n{md}\nCORE: {core:#?}\nLEGACY: {legacy:#?}"
))
.collect::<Vec<_>>()
.join("\n\n")
);
}
#[test]
fn fuzz_random_documents_wrapped_parity() {
use crate::render_markdown_via_core_wrapped;
let iters: u64 = std::env::var("JCODE_MD_FUZZ_ITERS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(3000);
let base_seed: u64 = std::env::var("JCODE_MD_FUZZ_SEED")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(0xCAFE_F00D_DEAD_BEEF);
let widths = [20usize, 40, 80];
let mut failures = Vec::new();
'outer: for i in 0..iters {
for &w in &widths {
let mut rng = Rng::new(base_seed.wrapping_add(i.wrapping_mul(0x100000001B3)));
let md = gen_document(&mut rng);
let core = nonblank_texts(&render_markdown_via_core_wrapped(&md, w));
let legacy = nonblank_texts(&crate::wrap_lines(
crate::render_markdown_with_width(&md, Some(w)),
w,
));
if core != legacy {
failures.push((i, w, md, core, legacy));
if failures.len() >= 5 {
break 'outer;
}
}
}
}
assert!(
failures.is_empty(),
"wrapped parity mismatches ({} shown):\n{}",
failures.len(),
failures
.iter()
.map(|(i, w, md, core, legacy)| format!(
"--- iter {i} width {w} ---\nINPUT:\n{md}\nCORE: {core:?}\nLEGACY: {legacy:?}"
))
.collect::<Vec<_>>()
.join("\n\n")
);
}
@@ -0,0 +1,144 @@
//! Ignored perf/memory probe for the syntect regex backend.
//!
//! Motivation: a jemalloc heap profile of a long-lived TUI client attributed
//! ~24 MB (47% of live heap) to lazily-compiled `regex-fancy` highlight
//! grammars (`fancy_regex` + `regex_automata` meta engines). Switching syntect
//! to the `regex-onig` backend cut the probe client's idle live heap from
//! ~51 MB to ~34 MB on the same workload. This test guards the tradeoff both
//! ways: it measures highlight throughput and RSS growth for a representative
//! multi-language workload so a future backend change can be A/B'd with one
//! command instead of a full client probe.
//!
//! Run with:
//! cargo test -p jcode-tui-markdown --test highlight_backend_probe -- --ignored --nocapture
use jcode_tui_markdown::highlight_line;
fn vm_rss_bytes() -> u64 {
let status = std::fs::read_to_string("/proc/self/status").unwrap_or_default();
for line in status.lines() {
if let Some(rest) = line.strip_prefix("VmRSS:") {
let kb: u64 = rest
.trim()
.trim_end_matches("kB")
.trim()
.parse()
.unwrap_or(0);
return kb * 1024;
}
}
0
}
/// Representative code lines across the languages a transcript actually
/// highlights (tool output diffs, fenced blocks in assistant replies).
fn workload() -> Vec<(&'static str, &'static str)> {
vec![
(
"rs",
"pub fn handle_server_event(app: &mut App, event: ServerEvent) -> Result<()> {",
),
(
"rs",
" let resolved = std::sync::Arc::new(resolve_anchored_items(&app.side_pane_images()));",
),
(
"py",
"def compute_totals(items: list[dict], *, key: str = 'bytes') -> int:",
),
(
"py",
" return sum(item.get(key, 0) for item in items if item['active'])",
),
(
"js",
"const totals = items.filter(i => i.active).reduce((acc, i) => acc + i.bytes, 0);",
),
(
"ts",
"export async function fetchState(id: string): Promise<SessionState | null> {",
),
(
"go",
"func (s *Server) HandleConn(ctx context.Context, conn net.Conn) error {",
),
(
"json",
"{\"session_id\": \"abc\", \"messages\": 322, \"json_bytes\": 2133255}",
),
(
"sh",
"for pid in $(pgrep -f jcode); do awk '/VmRSS/{print $2}' /proc/$pid/status; done",
),
(
"toml",
"syntect = { version = \"5\", default-features = false, features = [\"regex-onig\"] }",
),
("md", "## Heading with `inline code` and **bold** text"),
(
"c",
"static int parse_header(const uint8_t *buf, size_t len, struct dims *out) {",
),
(
"sql",
"SELECT session_id, SUM(json_bytes) FROM sessions GROUP BY session_id ORDER BY 2 DESC;",
),
("yaml", "jobs:\n build:\n runs-on: ubuntu-latest"),
(
"html",
"<div class=\"transcript\"><span data-id=\"42\">hello</span></div>",
),
]
}
/// Measures grammar-compile cost (first pass), steady-state throughput, and
/// resident-memory growth from compiled highlight grammars.
#[test]
#[ignore = "perf/memory probe; run explicitly with --ignored --nocapture"]
fn highlight_backend_probe() {
let lines = workload();
let rss_before = vm_rss_bytes();
// First pass: pays lazy grammar compilation for every language.
let cold_start = std::time::Instant::now();
for (ext, code) in &lines {
let spans = highlight_line(code, Some(ext));
assert!(
!spans.is_empty(),
"highlighting produced no spans for {ext}"
);
}
let cold = cold_start.elapsed();
// Steady state: repeated highlighting with warm grammars, sized to mimic
// re-rendering a large transcript several times.
const STEADY_ITERS: usize = 200;
let steady_start = std::time::Instant::now();
for _ in 0..STEADY_ITERS {
for (ext, code) in &lines {
let _ = highlight_line(code, Some(ext));
}
}
let steady = steady_start.elapsed();
let rss_after = vm_rss_bytes();
let rss_growth_mb = rss_after.saturating_sub(rss_before) as f64 / 1_048_576.0;
let steady_lines = STEADY_ITERS * lines.len();
let lines_per_sec = steady_lines as f64 / steady.as_secs_f64();
println!("highlight backend probe:");
println!(" cold pass ({} langs): {:?}", lines.len(), cold);
println!(
" steady state: {steady_lines} lines in {steady:?} ({lines_per_sec:.0} lines/s)"
);
println!(" RSS growth from grammars: {rss_growth_mb:.1} MB");
// Regression guards, generous enough for CI noise/debug builds: the fancy
// backend measured ~40 MB growth on this workload, onig ~10 MB.
assert!(
rss_growth_mb < 25.0,
"compiled highlight grammars grew RSS by {rss_growth_mb:.1} MB; \
expected < 25 MB (regex backend regression?)"
);
}