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
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:
@@ -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, ¢ered_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("x²"), "{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();
|
||||
|
||||
// "there’s" 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("x²")),
|
||||
"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  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?)"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user