Publish harness and TUI open-source

initial sync from the monorepo
This commit is contained in:
grokkybara[bot] 2026-07-16 06:46:02 +01:00
commit c68e39f604
2734 changed files with 1437016 additions and 0 deletions

View file

@ -0,0 +1,57 @@
[package]
license = "Apache-2.0"
name = "xai-grok-markdown"
version = "0.1.0"
edition.workspace = true
authors = ["xAI"]
description = "Streaming markdown renderer for terminal UIs"
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(fuzzing)'] }
[dependencies]
anstyle = { workspace = true }
anstyle-lossy = { workspace = true }
anstyle-syntect = { workspace = true }
html-escape = { workspace = true }
linkify = { workspace = true }
pulldown-cmark = { workspace = true }
ratatui = { workspace = true }
supports-color = { workspace = true }
syntect = { workspace = true }
two-face = { workspace = true }
textwrap = { workspace = true }
unicode-width = { workspace = true }
url = { workspace = true }
xai-grok-markdown-core = { workspace = true }
[dependencies.crossterm]
workspace = true
features = ["event-stream", "bracketed-paste"]
optional = true
[dependencies.xai-ratatui-textarea]
workspace = true
optional = true
[dev-dependencies]
criterion = { workspace = true }
pretty_assertions = { workspace = true }
[[bin]]
name = "md-table-test"
path = "bin/md_table_test.rs"
required-features = ["playground"]
[[bin]]
name = "md-mermaid-test"
path = "bin/md_mermaid_test.rs"
required-features = ["playground"]
[features]
default = []
playground = ["dep:crossterm", "dep:xai-ratatui-textarea"]
[[bench]]
name = "bench"
harness = false

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,603 @@
//! Benchmarks for markdown rendering.
use std::hint::black_box;
use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main};
use xai_grok_markdown::{
MarkdownStyle, StreamingMarkdownRenderer, Syntect, render_markdown_ratatui_full,
};
/// Default style for benchmarking.
fn default_style() -> MarkdownStyle {
MarkdownStyle::default()
}
/// Create syntect highlighter for benchmarking.
fn create_syntect() -> Syntect {
Syntect::new(include_bytes!("../assets/tokyo-night.tmTheme"))
}
fn bench_render_markdown(c: &mut Criterion) {
let syntect = create_syntect();
c.bench_function("render_markdown", |b| {
let content = black_box(
r#"# Heading 1
## Heading 2, can `contain` *formatted* **text**
### Heading 3
#### Heading 4
##### Heading 5
###### Heading 6
This also
=========
Works
-----
Some `inline`, **bold**, ~~strikethrough~~, *italic*, $math$.
- [ ] Task (not done)
* [x] Subtask (done)
- *Numbered* lists as well
1. One
2) Two
---
- [Link](https://example.com)
- [*Link*](https://foo.com "Title A") or [**Link**](https://bar.com 'Title B')
- ![alt](https://image.link) or ![alt](https://image.link "Image title")
- <https://www.markdownguide.org> or <fake@example.com>
***
```javascript
function hello() { // some code
console.log("Hello, world!");
}
```
> Multi-line
>
> Block-quote
> > and a *nested* one.
```
Plain fenced code block
```
$$
g(t) = \int_a^b K(t,s) f(s) ds
$$
This is *some <a href="https://google.com">inline html</a> block*.
<html>
<foo a="b">HTML block.</foo>
</html>
> [!NOTE]
> note quote
| H 1 | H 2 |
| ---- | ---- |
| C 1 | C 2 |
"#,
);
b.iter(|| {
render_markdown_ratatui_full(content, black_box(default_style()), true, Some(&syntect))
.0
.lines
.len()
})
});
}
/// Generate a markdown document with multiple blocks for streaming simulation.
fn generate_streaming_content(num_blocks: usize) -> String {
let mut content = String::new();
for i in 0..num_blocks {
match i % 5 {
0 => {
content.push_str(&format!("# Heading {}\n\n", i));
}
1 => {
content.push_str(&format!(
"This is paragraph {} with some **bold** and *italic* text.\n\n",
i
));
}
2 => {
content.push_str(&format!(
"```rust\nfn block_{}() {{\n // code\n}}\n```\n\n",
i
));
}
3 => {
content.push_str(&format!("> Quote block {}\n> More quoted text.\n\n", i));
}
4 => {
content.push_str(&format!(
"- Item {}.1\n- Item {}.2\n- Item {}.3\n\n",
i, i, i
));
}
_ => unreachable!(),
}
}
content
}
/// Benchmark streaming with full re-render on each token (O(N²) baseline).
fn bench_streaming_full_rerender(c: &mut Criterion) {
let mut group = c.benchmark_group("streaming");
let syntect = create_syntect();
for num_blocks in [10, 50, 100] {
let content = generate_streaming_content(num_blocks);
let tokens: Vec<&str> = content.split_inclusive(char::is_whitespace).collect();
group.bench_with_input(
BenchmarkId::from_parameter(format!("{}/full", num_blocks)),
&tokens,
|b, tokens| {
b.iter(|| {
let mut text = String::new();
let mut total_lines = 0;
for token in tokens.iter() {
text.push_str(token);
let (output, _) = render_markdown_ratatui_full(
&text,
default_style(),
true,
Some(&syntect),
);
total_lines = output.lines.len();
}
black_box(total_lines)
});
},
);
}
group.finish();
}
/// Generate a hyperlink-heavy markdown document.
///
/// Each block contains 4 inline links and 1 autolink so the renderer's
/// link-translation path is exercised on most rendered lines. Designed
/// to surface O(lines * link_targets) costs in `translate_link_targets`.
fn generate_hyperlink_content(num_blocks: usize) -> String {
let mut content = String::new();
for i in 0..num_blocks {
content.push_str(&format!("# Section {}\n\n", i));
content.push_str(&format!(
"See [docs {i}](https://example.com/docs/{i}), [api {i}](https://example.com/api/{i}), [src {i}](https://github.com/x/r/blob/main/src{i}.rs), and [issue {i}](https://github.com/x/r/issues/{i}).\n\n",
i = i,
));
content.push_str(&format!(
"Reference link: <https://reference.example.com/path/segment-{}/page>.\n\n",
i
));
content.push_str(&format!(
"Mixed prose with [click](https://a{i}.com) and [click](https://b{i}.com) repeating the same text twice on one line for collision testing.\n\n",
i = i,
));
}
content
}
/// Benchmark a single full render of a hyperlink-heavy document.
///
/// Surfaces the cost of the parse-time `link_targets` collection plus the
/// post-render translation step. Pair with `bench_render_markdown` to see
/// the link-translation overhead in isolation.
fn bench_render_markdown_hyperlinks(c: &mut Criterion) {
let syntect = create_syntect();
let mut group = c.benchmark_group("render_markdown_hyperlinks");
for num_blocks in [10, 50, 200] {
let content = generate_hyperlink_content(num_blocks);
group.bench_with_input(
BenchmarkId::from_parameter(num_blocks),
&content,
|b, content| {
b.iter(|| {
let (out, _) = render_markdown_ratatui_full(
content,
black_box(default_style()),
true,
Some(&syntect),
);
(out.lines.len(), out.hyperlinks.len())
});
},
);
}
group.finish();
}
/// Benchmark incremental streaming of a hyperlink-heavy document.
///
/// Exercises `rerender_tail` repeatedly, which calls the link-translation
/// path on the unfrozen tail every push.
fn bench_streaming_hyperlinks_incremental(c: &mut Criterion) {
let mut group = c.benchmark_group("streaming_hyperlinks");
let syntect = create_syntect();
for num_blocks in [10, 50] {
let content = generate_hyperlink_content(num_blocks);
let tokens: Vec<&str> = content.split_inclusive(char::is_whitespace).collect();
group.bench_with_input(
BenchmarkId::from_parameter(format!("{}/incremental", num_blocks)),
&tokens,
|b, tokens| {
b.iter(|| {
let mut renderer = StreamingMarkdownRenderer::new(default_style(), true);
let mut total_links = 0;
for token in tokens.iter() {
renderer.push_and_render(token, Some(&syntect));
total_links = renderer.view().hyperlinks.len();
}
black_box(total_links)
});
},
);
}
group.finish();
}
/// Benchmark streaming with incremental renderer (O(N) target).
fn bench_streaming_incremental(c: &mut Criterion) {
let mut group = c.benchmark_group("streaming");
let syntect = create_syntect();
for num_blocks in [10, 50, 100] {
let content = generate_streaming_content(num_blocks);
let tokens: Vec<&str> = content.split_inclusive(char::is_whitespace).collect();
group.bench_with_input(
BenchmarkId::from_parameter(format!("{}/incremental", num_blocks)),
&tokens,
|b, tokens| {
b.iter(|| {
let mut renderer = StreamingMarkdownRenderer::new(default_style(), true);
let mut total_lines = 0;
for token in tokens.iter() {
renderer.push_and_render(token, Some(&syntect));
total_lines = renderer.view().lines.len();
}
black_box(total_lines)
});
},
);
}
group.finish();
}
/// Generate a math-heavy markdown document.
///
/// Each block exercises all four delimiter forms (`$...$`, `$$...$$`,
/// `\(...\)`, `\[...\]`) plus the expensive converter paths: scripts,
/// fractions, roots, symbol lookups, alphabets, and multi-row environments
/// (aligned / pmatrix / cases) that go through the MathBox 2D layout.
fn generate_math_content(num_blocks: usize) -> String {
let mut content = String::new();
for i in 0..num_blocks {
content.push_str(&format!("## Block {i}: norm \\(\\|x\\|_{{{i}}}\\)\n\n"));
content.push_str(&format!(
"Inline $e^{{i\\pi}} + {i} = \\frac{{\\alpha_{i}}}{{\\beta^2}}$ and \
\\(\\sqrt[3]{{x_{i}}} \\le \\mathbb{{R}}^n\\) mid-prose, then \
$\\sum_{{k=0}}^{{{i}}} \\binom{{n}}{{k}} \\approx 2^n$.\n\n",
));
content.push_str(&format!(
"$$\n\\int_0^{i} \\hat{{f}}(t) \\, dt = \\lim_{{n \\to \\infty}} \
\\frac{{{i}}}{{n+1}}\n$$\n\n",
));
content.push_str(&format!(
"\\[\n\\begin{{aligned}}\nf_{i}(x) &= x^{i} + \\gamma \\\\\n\
g_{i}(x) &= \\nabla f_{i} \\cdot \\vec{{v}}\n\\end{{aligned}}\n\\]\n\n",
));
content.push_str(
"\\[\nA = \\begin{pmatrix} 1 & 2 \\\\ 3 & 4 \\end{pmatrix}, \\quad \
|x| = \\begin{cases} x & x \\ge 0 \\\\ -x & x < 0 \\end{cases}\n\\]\n\n",
);
}
content
}
/// Benchmark a single full render of a math-heavy document.
///
/// Surfaces the cost of the LaTeX → Unicode converter plus the parse-time
/// `\(...\)` / `\[...\]` source scans and block replacements.
fn bench_render_markdown_math(c: &mut Criterion) {
let syntect = create_syntect();
let mut group = c.benchmark_group("render_markdown_math");
for num_blocks in [10, 50, 200] {
let content = generate_math_content(num_blocks);
group.bench_with_input(
BenchmarkId::from_parameter(num_blocks),
&content,
|b, content| {
b.iter(|| {
let (out, _) = render_markdown_ratatui_full(
content,
black_box(default_style()),
true,
Some(&syntect),
);
out.lines.len()
});
},
);
}
group.finish();
}
/// Benchmark incremental streaming of a math-heavy document.
///
/// Exercises the streaming hot path the `MAX_MATH_SOURCE_LEN` guard
/// protects: every push re-renders the unfrozen tail, re-running the math
/// scans and conversions on it.
fn bench_streaming_math_incremental(c: &mut Criterion) {
let mut group = c.benchmark_group("streaming_math");
let syntect = create_syntect();
for num_blocks in [10, 50] {
let content = generate_math_content(num_blocks);
let tokens: Vec<&str> = content.split_inclusive(char::is_whitespace).collect();
group.bench_with_input(
BenchmarkId::from_parameter(format!("{}/incremental", num_blocks)),
&tokens,
|b, tokens| {
b.iter(|| {
let mut renderer = StreamingMarkdownRenderer::new(default_style(), true);
let mut total_lines = 0;
for token in tokens.iter() {
renderer.push_and_render(token, Some(&syntect));
total_lines = renderer.view().lines.len();
}
black_box(total_lines)
});
},
);
}
group.finish();
}
/// Generate a document with plain URLs in prose (no markdown link syntax).
fn generate_plain_url_content(num_blocks: usize) -> String {
let mut content = String::new();
for i in 0..num_blocks {
content.push_str(&format!(
"See https://example.com/page/{i} for details about topic {i}.\n\n",
));
}
content
}
/// Benchmark streaming + finish() of a plain-URL-heavy document.
///
/// Exercises the `detect_plain_urls` scan, which after the multi-line-URL
/// fix runs inside both `rerender_tail` (every `push_and_render`) and
/// `finish()`. Bench numbers from this point forward are not comparable
/// to historical runs that measured the prior `finish()`-only path.
fn bench_streaming_plain_urls_incremental(c: &mut Criterion) {
let mut group = c.benchmark_group("streaming_plain_urls");
let syntect = create_syntect();
for num_blocks in [10, 50] {
let content = generate_plain_url_content(num_blocks);
let tokens: Vec<&str> = content.split_inclusive(char::is_whitespace).collect();
group.bench_with_input(
BenchmarkId::from_parameter(format!("{}/incremental", num_blocks)),
&tokens,
|b, tokens| {
b.iter(|| {
let mut renderer = StreamingMarkdownRenderer::new(default_style(), true);
for token in tokens.iter() {
renderer.push_and_render(token, Some(&syntect));
}
let view = renderer.finish(Some(&syntect));
black_box(view.hyperlinks.len())
});
},
);
}
group.finish();
}
/// Generate a realistic nested YAML document of roughly `num_lines` lines.
///
/// Produces nested keys, lists, and scalars (the kind of config an LLM streams
/// into a single fenced block) so the syntect highlighter does real work per
/// line rather than trivial whitespace.
fn generate_yaml_lines(num_lines: usize) -> Vec<String> {
let mut lines = Vec::with_capacity(num_lines);
let mut i = 0usize;
while lines.len() < num_lines {
lines.push(format!("service_{i}:"));
lines.push(format!(" name: \"service-{i}\""));
lines.push(" enabled: true".to_string());
lines.push(format!(" replicas: {}", i % 7 + 1));
lines.push(" resources:".to_string());
lines.push(format!(" cpu: \"{}m\"", (i % 4 + 1) * 250));
lines.push(format!(" memory: \"{}Mi\"", (i % 8 + 1) * 128));
lines.push(" env:".to_string());
lines.push(" - name: LOG_LEVEL".to_string());
lines.push(format!(
" value: \"{}\"",
if i.is_multiple_of(2) { "info" } else { "debug" }
));
lines.push(" - name: REGION".to_string());
lines.push(format!(" value: us-east-{}", i % 3 + 1));
lines.push(" ports:".to_string());
lines.push(format!(" - {}", 8000 + i));
lines.push(" tags:".to_string());
lines.push(format!(" team: team-{}", i % 5));
i += 1;
}
lines.truncate(num_lines);
lines
}
/// Benchmark streaming a SINGLE open ```yaml fenced block line-by-line WITHOUT
/// ever closing the fence.
///
/// This reproduces the UI-freeze pathology: while the fence is open the block
/// never checkpoints, so every `push_and_render` re-highlights the whole tail.
/// With the incremental open-code cache, per-line cost should stay roughly flat
/// in block size instead of growing linearly (overall O(N) instead of O(N²)).
fn bench_streaming_open_yaml_incremental(c: &mut Criterion) {
let mut group = c.benchmark_group("streaming_open_yaml");
let syntect = create_syntect();
for num_lines in [100, 500, 1081] {
let lines = generate_yaml_lines(num_lines);
// Only the cache-on path ships; the parameter is just the line count
// (no A/B baseline here — the no-cache baseline was measured ad hoc and
// is not committed).
group.bench_with_input(
BenchmarkId::from_parameter(num_lines),
&lines,
|b, lines| {
b.iter(|| {
let mut renderer = StreamingMarkdownRenderer::new(default_style(), true);
// Open the fence but never close it.
renderer.push_and_render("```yaml\n", Some(&syntect));
let mut total_lines = 0;
for line in lines.iter() {
renderer.push_and_render(line, Some(&syntect));
renderer.push_and_render("\n", Some(&syntect));
total_lines = renderer.view().lines.len();
}
black_box(total_lines)
});
},
);
}
group.finish();
}
/// Closed scala fences inside bullet list items, then `trailing_words` more
/// streamed content in the same never-closing list. Lists can't checkpoint,
/// so the fences stay in the re-rendered tail for the whole stream
/// (~108 ms/token, ~4.5 s UI stall).
fn generate_fence_in_list_content(trailing_words: usize) -> String {
let mut s = String::new();
s.push_str("Here is where you're stuck:\n\n");
// Two list items embedding closed scala fences (the pathological shape).
for i in 0..2 {
s.push_str(&format!(
"- **[File{i}.scala:{}](https://example.com/f{i})** (domain)\n \
```scala\n \
final case class SampleRecord{i}(\n \
baseId: RecordId,\n \
payload: PersistedPayload,\n \
creationTimestamp: Instant,\n \
) extends RecordContent {{\n \
override lazy val raw: Growable[Raw] = ??? // TODO: implement\n \
}}\n \
```\n",
100 + i,
));
}
// Continued streaming within the same (never-closing) list context.
for w in 0..trailing_words {
if w % 12 == 0 {
s.push_str("\n- item: ");
}
s.push_str(&format!("word{w} "));
}
s.push('\n');
s
}
/// Control: same fences and trailing content at top level with blank lines,
/// so checkpoints advance past the fences.
fn generate_fence_top_level_content(trailing_words: usize) -> String {
let mut s = String::new();
s.push_str("Here is where you're stuck:\n\n");
for i in 0..2 {
s.push_str(&format!(
"```scala\nfinal case class SampleRecord{i}(\n \
baseId: RecordId,\n \
payload: PersistedPayload,\n \
creationTimestamp: Instant,\n) extends RecordContent {{\n \
override lazy val raw: Growable[Raw] = ??? // TODO: implement\n}}\n```\n\n",
));
}
for w in 0..trailing_words {
s.push_str(&format!("word{w} "));
if w % 12 == 11 {
s.push_str("\n\n");
}
}
s.push('\n');
s
}
/// Stream closed-fences-in-open-list token-by-token (`in_list`) against the
/// checkpoint-friendly `top_level` control. `in_list` must stay within a
/// small constant of `top_level`; unbounded growth in `trailing_words` is
/// the regression.
fn bench_streaming_fence_in_list(c: &mut Criterion) {
let mut group = c.benchmark_group("streaming_fence_in_list");
group.sample_size(10);
let syntect = create_syntect();
for trailing_words in [200, 400, 800] {
for (variant, content) in [
("in_list", generate_fence_in_list_content(trailing_words)),
(
"top_level",
generate_fence_top_level_content(trailing_words),
),
] {
let tokens: Vec<&str> = content.split_inclusive(char::is_whitespace).collect();
group.bench_with_input(
BenchmarkId::from_parameter(format!("{trailing_words}/{variant}")),
&tokens,
|b, tokens| {
b.iter(|| {
let mut renderer = StreamingMarkdownRenderer::new(default_style(), true);
let mut total_lines = 0;
for token in tokens.iter() {
renderer.push_and_render(token, Some(&syntect));
total_lines = renderer.view().lines.len();
}
black_box(total_lines)
});
},
);
}
}
group.finish();
}
criterion_group!(
benches,
bench_render_markdown,
bench_render_markdown_hyperlinks,
bench_render_markdown_math,
bench_streaming_full_rerender,
bench_streaming_incremental,
bench_streaming_hyperlinks_incremental,
bench_streaming_math_incremental,
bench_streaming_plain_urls_incremental,
bench_streaming_open_yaml_incremental,
bench_streaming_fence_in_list,
);
criterion_main!(benches);

View file

@ -0,0 +1,325 @@
//! Interactive Mermaid diagram rendering playground.
//!
//! Run with:
//! cargo run -p xai-grok-markdown --features playground --bin md-mermaid-test
//!
//! Controls:
//! Esc / Tab — toggle textarea focus
//! h / Left — shrink render width (when unfocused)
//! l / Right — grow render width (when unfocused)
//! n — next sample (when unfocused)
//! q / ^C / ^D — quit (when unfocused)
use std::io::{self, stdout};
use std::time::Duration;
use crossterm::ExecutableCommand;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, StatefulWidgetRef, Wrap};
use xai_grok_markdown::{
MarkdownBuffers, MarkdownStyle, render_markdown_ratatui_with_buffers_width,
};
use xai_ratatui_textarea::{TextArea, TextAreaState};
#[path = "playground_common.rs"]
mod playground_common;
use playground_common::{fg, get_syntect, md_style, rgb_color};
const MD_STYLE: MarkdownStyle = md_style(fg(rgb_color(192, 202, 245)));
const SAMPLES: &[&str] = &[
"```mermaid\nflowchart TD\n A[Start] --> B{Is it working?}\n B -->|Yes| C[Ship it]\n B -->|No| D[Debug]\n D --> B\n```\n",
"```mermaid\ngraph TD\n A[Client] --> B[Load Balancer]\n B --> C[Server 1]\n B --> D[Server 2]\n C --> E[(Database)]\n D --> E\n```\n",
"```mermaid\nflowchart LR\n A --> B --> C --> D\n```\n",
"```mermaid\nsequenceDiagram\n Alice->>Bob: Hello Bob\n Bob-->>Alice: Hi Alice\n```\n",
"```mermaid\nsequenceDiagram\n autonumber\n participant C as Client\n participant S as Server\n participant D as Database\n C->>S: GET /api/items\n S->>D: SELECT * FROM items\n D-->>S: rows\n S-->>C: 200 OK\n C->>C: render list\n Note over C,S: happy path\n loop retry x3\n C-x S: timeout\n end\n```\n",
"```mermaid\ngraph TD\n Start --> Stop\n```\n",
"```mermaid\nstateDiagram-v2\n [*] --> Idle\n Idle --> Loading: fetch\n Loading --> Ready: ok\n Loading --> Error: fail\n Error --> Idle: retry\n Ready --> [*]\n```\n",
"```mermaid\nflowchart TD\n A[Read config] & B[Load cache] --> C{Valid?}\n C -.->|no| D[Rebuild]\n C ==>|yes| E[Serve]\n D --o E\n E -->|poll| E\n```\n",
"```mermaid\ngraph TD\n C[ccc]\n D[ddd]\n A --> D\n B --> C\n D --> P[pp]\n C --> Q[qq]\n```\n",
"```mermaid\ngraph TD\n A --> D[ddd]\n A --> C[ccc]\n B --> C\n B --> D\n```\n",
"```mermaid\ngraph TD\n U[User] --> gw\n subgraph gw [Gateway]\n LB[load balancer] --> RL[rate limiter]\n end\n subgraph core [Services]\n API[api] --> W[worker]\n W --> Q[(queue)]\n end\n gw --> core\n core --> DB[(postgres)]\n```\n",
"```mermaid\nclassDiagram\n class Animal {\n <<abstract>>\n +int age\n +isMammal() bool\n +mate()\n }\n class Duck {\n +String beakColor\n +swim()\n }\n Animal <|-- Duck\n Animal <|-- Fish\n Duck *-- Bill\n Duck ..> Pond : swims in\n```\n",
"```mermaid\nerDiagram\n CUSTOMER ||--o{ ORDER : places\n ORDER ||--|{ LINE_ITEM : contains\n PRODUCT }o..o{ LINE_ITEM : \"is in\"\n CUSTOMER {\n string name PK\n int custNumber\n }\n ORDER {\n int orderNumber\n date placed\n }\n```\n",
];
fn render_full(source: &str, width: usize) -> Vec<Line<'static>> {
let mut buffers = MarkdownBuffers::new();
let (output, _) = render_markdown_ratatui_with_buffers_width(
source,
MD_STYLE,
true,
&mut buffers,
Some(get_syntect()),
Some(width),
);
output.lines
}
struct App {
textarea: TextArea,
textarea_state: TextAreaState,
textarea_focused: bool,
textarea_area: Rect,
render_width: usize,
sample: usize,
source: String,
lines: Vec<Line<'static>>,
}
impl App {
fn new() -> Self {
let sample = std::env::var("MERMAID_SAMPLE")
.ok()
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(0)
% SAMPLES.len();
let width = std::env::var("MERMAID_WIDTH")
.ok()
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(70);
let mut textarea = TextArea::new();
textarea.set_text(SAMPLES[sample]);
textarea.show_scrollbar = false;
let source = SAMPLES[sample].to_string();
let lines = render_full(&source, width);
Self {
textarea,
textarea_state: TextAreaState::default(),
textarea_focused: false,
textarea_area: Rect::default(),
render_width: width,
sample,
source,
lines,
}
}
fn rerender(&mut self) {
self.source = self.textarea.text().to_string();
self.lines = render_full(&self.source, self.render_width);
}
fn adjust_width(&mut self, delta: isize) {
let new_w = (self.render_width as isize + delta).max(10) as usize;
if new_w != self.render_width {
self.render_width = new_w;
self.lines = render_full(&self.source, self.render_width);
}
}
fn next_sample(&mut self) {
self.sample = (self.sample + 1) % SAMPLES.len();
self.textarea.set_text(SAMPLES[self.sample]);
self.rerender();
}
}
fn main() -> io::Result<()> {
terminal::enable_raw_mode()?;
stdout().execute(EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout());
let mut terminal = Terminal::new(backend)?;
let mut app = App::new();
loop {
terminal.draw(|f| draw(f, &mut app))?;
if event::poll(Duration::from_millis(100))? {
let ev = event::read()?;
match &ev {
Event::Key(KeyEvent {
code: KeyCode::Char('q'),
modifiers: KeyModifiers::CONTROL,
..
}) => break,
Event::Key(KeyEvent {
code: KeyCode::Esc | KeyCode::Tab,
..
}) if app.textarea_focused => {
app.textarea_focused = false;
}
Event::Key(KeyEvent {
code: KeyCode::Char('q'),
..
}) if !app.textarea_focused => break,
Event::Key(KeyEvent {
code: KeyCode::Char('c') | KeyCode::Char('d'),
modifiers: KeyModifiers::CONTROL,
..
}) if !app.textarea_focused => break,
Event::Key(KeyEvent {
code: KeyCode::Tab, ..
}) if !app.textarea_focused => {
app.textarea_focused = true;
}
Event::Key(KeyEvent {
code: KeyCode::Char('n'),
..
}) if !app.textarea_focused => app.next_sample(),
Event::Key(KeyEvent {
code: KeyCode::Char('h') | KeyCode::Left,
..
}) if !app.textarea_focused => app.adjust_width(-2),
Event::Key(KeyEvent {
code: KeyCode::Char('l') | KeyCode::Right,
..
}) if !app.textarea_focused => app.adjust_width(2),
Event::Key(key) if app.textarea_focused => {
app.textarea.input(*key);
app.rerender();
}
_ => {}
}
}
}
stdout().execute(LeaveAlternateScreen)?;
terminal::disable_raw_mode()?;
Ok(())
}
fn draw(f: &mut ratatui::Frame, app: &mut App) {
let size = f.area();
let render_w = app.render_width as u16;
let render_height = wrapped_line_count(&app.lines, render_w).max(1) + 2;
let header_height = 2u16;
let textarea_height = size
.height
.saturating_sub(header_height + render_height)
.max(6);
let chunks = Layout::vertical([
Constraint::Length(header_height),
Constraint::Length(textarea_height),
Constraint::Length(render_height),
])
.split(size);
let focus_indicator = if app.textarea_focused {
Span::styled(
" EDITING ",
Style::default().fg(Color::Black).bg(Color::Green),
)
} else {
Span::styled(
" VIEW ",
Style::default().fg(Color::Black).bg(Color::Yellow),
)
};
let width_info = Span::styled(
format!(
" width: {} | sample {}/{} ",
app.render_width,
app.sample + 1,
SAMPLES.len()
),
Style::default().fg(Color::Cyan),
);
let keys = if app.textarea_focused {
Span::styled(
" (live) Esc/Tab: defocus ",
Style::default().fg(Color::DarkGray),
)
} else {
Span::styled(
" Tab: edit | h/l: width | n: next sample | q/^C: quit ",
Style::default().fg(Color::DarkGray),
)
};
let header = Paragraph::new(vec![
Line::from(vec![focus_indicator, Span::raw(" "), width_info]),
Line::from(keys),
]);
f.render_widget(header, chunks[0]);
let border_color = if app.textarea_focused {
Color::Green
} else {
Color::DarkGray
};
let textarea_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.title(Span::styled(
" Mermaid Source ",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
));
let textarea_inner = textarea_block.inner(chunks[1]);
f.render_widget(textarea_block, chunks[1]);
app.textarea_area = textarea_inner;
(&app.textarea).render_ref(textarea_inner, f.buffer_mut(), &mut app.textarea_state);
if app.textarea_focused
&& let Some((cx, cy)) = app
.textarea
.cursor_pos_with_state(textarea_inner, app.textarea_state)
{
f.set_cursor_position((cx, cy));
}
let title = format!(" rendered (inner width {}) ", app.render_width);
render_panel(f, chunks[2], &title, &app.lines, render_w);
}
fn wrapped_line_count(lines: &[Line<'_>], width: u16) -> u16 {
use unicode_width::UnicodeWidthStr;
if width == 0 {
return lines.len() as u16;
}
let w = width as usize;
lines
.iter()
.map(|line| {
let display_w: usize = line
.spans
.iter()
.map(|s| UnicodeWidthStr::width(s.content.as_ref()))
.sum();
if display_w == 0 {
1u16
} else {
display_w.div_ceil(w) as u16
}
})
.sum()
}
fn render_panel(
f: &mut ratatui::Frame,
area: Rect,
title: &str,
lines: &[Line<'static>],
inner_w: u16,
) {
let outer_w = (inner_w + 2).min(area.width);
let box_area = Rect {
x: area.x,
y: area.y,
width: outer_w,
height: area.height,
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray))
.title(Span::styled(
title.to_string(),
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
));
let inner = block.inner(box_area);
f.render_widget(block, box_area);
let para = Paragraph::new(lines.to_vec()).wrap(Wrap { trim: false });
f.render_widget(para, inner);
}

View file

@ -0,0 +1,468 @@
//! Interactive markdown table rendering playground.
//!
//! Run with:
//! cargo run -p xai-grok-markdown --features playground --bin md-table-test
//!
//! Controls:
//! Space — toggle textarea focus
//! Alt/Ctrl/Shift+Enter — submit markdown & defocus (when textarea focused)
//! h / Left — shrink render width (when unfocused)
//! l / Right — grow render width (when unfocused)
//! Esc — quit (always)
use std::io::{self, stdout};
use std::time::Duration;
use crossterm::ExecutableCommand;
use crossterm::event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyModifiers,
};
use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, StatefulWidgetRef, Wrap};
use xai_grok_markdown::{
MarkdownBuffers, MarkdownStyle, StreamingMarkdownRenderer,
render_markdown_ratatui_with_buffers_width,
};
use xai_ratatui_textarea::{TextArea, TextAreaState};
// ── Tokyo Night Storm palette (matches xai-grok-pager) ──────────────────────
#[path = "playground_common.rs"]
mod playground_common;
use playground_common::{get_syntect, md_style};
const MD_STYLE: MarkdownStyle = md_style(anstyle::Style::new());
// ── Compute minimum render width ─────────────────────────────────────────────
/// Minimum width = 4k + 1, where k = number of table columns.
/// Columns = max(`|` count per line) - 1 (the outer pipes are borders).
/// This ensures every column gets at least 1 char + padding. Floor of 10.
fn min_render_width(source: &str) -> usize {
let max_pipes = source
.lines()
.map(|line| line.chars().filter(|&c| c == '|').count())
.max()
.unwrap_or(0);
let num_cols = max_pipes.saturating_sub(1);
if num_cols == 0 {
10
} else {
(4 * num_cols + 1).max(10)
}
}
// ── Render helpers ───────────────────────────────────────────────────────────
/// One-shot full render at a given width.
fn render_full(source: &str, width: usize) -> Vec<Line<'static>> {
let mut buffers = MarkdownBuffers::new();
let (output, _) = render_markdown_ratatui_with_buffers_width(
source,
MD_STYLE,
true,
&mut buffers,
Some(get_syntect()),
Some(width),
);
output.lines
}
/// Streaming render: feed each char individually, return final lines.
fn render_streaming(source: &str, width: usize) -> Vec<Line<'static>> {
let mut renderer = StreamingMarkdownRenderer::new(MD_STYLE, true);
renderer.set_max_table_width(Some(width));
for ch in source.chars() {
renderer.push_and_render(&ch.to_string(), Some(get_syntect()));
}
renderer.view().lines.to_vec()
}
// ── App state ────────────────────────────────────────────────────────────────
const DEFAULT_MARKDOWN: &str = "\
| A | B | C |
|---|---|---|
| 1 | 2 | 3 |
";
struct App {
textarea: TextArea,
textarea_state: TextAreaState,
textarea_focused: bool,
textarea_area: Rect,
render_width: usize,
source: String,
full_lines: Vec<Line<'static>>,
streaming_lines: Vec<Line<'static>>,
}
impl App {
fn new() -> Self {
let mut textarea = TextArea::new();
textarea.set_text(DEFAULT_MARKDOWN);
textarea.show_scrollbar = false;
let source = DEFAULT_MARKDOWN.to_string();
let width = 24usize;
let full_lines = render_full(&source, width);
let streaming_lines = render_streaming(&source, width);
Self {
textarea,
textarea_state: TextAreaState::default(),
textarea_focused: true,
textarea_area: Rect::default(),
render_width: width,
source,
full_lines,
streaming_lines,
}
}
fn rerender(&mut self) {
self.source = self.textarea.text().to_string();
let min_w = min_render_width(&self.source);
if self.render_width < min_w {
self.render_width = min_w;
}
self.full_lines = render_full(&self.source, self.render_width);
self.streaming_lines = render_streaming(&self.source, self.render_width);
}
fn adjust_width(&mut self, delta: isize) {
let min_w = min_render_width(&self.source);
let new_w = (self.render_width as isize + delta).max(min_w as isize) as usize;
if new_w != self.render_width {
self.render_width = new_w;
self.full_lines = render_full(&self.source, self.render_width);
self.streaming_lines = render_streaming(&self.source, self.render_width);
}
}
}
// ── Main ─────────────────────────────────────────────────────────────────────
fn main() -> io::Result<()> {
// Terminal setup
terminal::enable_raw_mode()?;
stdout().execute(EnterAlternateScreen)?;
stdout().execute(EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout());
let mut terminal = Terminal::new(backend)?;
let mut app = App::new();
loop {
terminal.draw(|f| draw(f, &mut app))?;
if event::poll(Duration::from_millis(100))? {
let ev = event::read()?;
match &ev {
// ── Global: Ctrl-Q quits from anywhere ──
Event::Key(KeyEvent {
code: KeyCode::Char('q'),
modifiers: KeyModifiers::CONTROL,
..
}) => break,
// ── Focused: defocus on Esc or Tab ──
Event::Key(KeyEvent {
code: KeyCode::Esc | KeyCode::Tab,
..
}) if app.textarea_focused => {
app.textarea_focused = false;
}
// ── Unfocused: quit on q, Ctrl-C, Ctrl-D ──
Event::Key(KeyEvent {
code: KeyCode::Char('q'),
..
}) if !app.textarea_focused => break,
Event::Key(KeyEvent {
code: KeyCode::Char('c') | KeyCode::Char('d'),
modifiers: KeyModifiers::CONTROL,
..
}) if !app.textarea_focused => break,
// ── Unfocused: Space or Enter to re-focus ──
Event::Key(KeyEvent {
code: KeyCode::Char(' ') | KeyCode::Enter,
..
}) if !app.textarea_focused => {
app.textarea_focused = true;
}
// ── Unfocused: width controls ──
Event::Key(KeyEvent {
code: KeyCode::Char('h') | KeyCode::Left,
..
}) if !app.textarea_focused => {
app.adjust_width(-1);
}
Event::Key(KeyEvent {
code: KeyCode::Char('l') | KeyCode::Right,
..
}) if !app.textarea_focused => {
app.adjust_width(1);
}
Event::Key(KeyEvent {
code: KeyCode::Char('H'),
..
}) if !app.textarea_focused => {
app.adjust_width(-5);
}
Event::Key(KeyEvent {
code: KeyCode::Char('L'),
..
}) if !app.textarea_focused => {
app.adjust_width(5);
}
// ── Focused: forward keys to textarea, live re-render ──
Event::Key(key) if app.textarea_focused => {
app.textarea.input(*key);
app.rerender();
}
// ── Focused: forward mouse to textarea ──
Event::Mouse(mouse) if app.textarea_focused => {
app.textarea
.handle_mouse(*mouse, app.textarea_area, app.textarea_state);
}
_ => {}
}
}
}
// Terminal cleanup
stdout().execute(DisableMouseCapture)?;
stdout().execute(LeaveAlternateScreen)?;
terminal::disable_raw_mode()?;
Ok(())
}
// ── Drawing ──────────────────────────────────────────────────────────────────
fn draw(f: &mut ratatui::Frame, app: &mut App) {
let size = f.area();
// Layout: header (1) | textarea (flexible) | full render | streaming render
//
// We compute the heights we need for the render panels, then give the rest
// to the textarea.
let render_w = app.render_width as u16;
let full_height = wrapped_line_count(&app.full_lines, render_w).max(1) + 2; // +2 for border
let stream_height = wrapped_line_count(&app.streaming_lines, render_w).max(1) + 2;
// Detect mismatches between full and streaming
let mismatch = detect_mismatch(&app.full_lines, &app.streaming_lines);
let header_height = 2u16;
let render_height = full_height + stream_height;
let textarea_min = 5u16;
let textarea_height = size
.height
.saturating_sub(header_height + render_height)
.max(textarea_min);
let chunks = Layout::vertical([
Constraint::Length(header_height),
Constraint::Length(textarea_height),
Constraint::Length(full_height),
Constraint::Length(stream_height),
])
.split(size);
// ── Header ──
let focus_indicator = if app.textarea_focused {
Span::styled(
" EDITING ",
Style::default().fg(Color::Black).bg(Color::Green),
)
} else {
Span::styled(
" VIEW ",
Style::default().fg(Color::Black).bg(Color::Yellow),
)
};
let width_info = Span::styled(
format!(
" width: {} (min: {}) ",
app.render_width,
min_render_width(&app.source)
),
Style::default().fg(Color::Cyan),
);
let keys = if app.textarea_focused {
Span::styled(
" (live) Esc/Tab: defocus ",
Style::default().fg(Color::DarkGray),
)
} else {
Span::styled(
" Space/Enter: edit | h/l: width -/+ | H/L: -5/+5 | q/^C/^D: quit ",
Style::default().fg(Color::DarkGray),
)
};
let mismatch_indicator = if mismatch {
Span::styled(
" MISMATCH! ",
Style::default()
.fg(Color::White)
.bg(Color::Red)
.add_modifier(Modifier::BOLD),
)
} else {
Span::styled(" OK ", Style::default().fg(Color::Black).bg(Color::Green))
};
let header = Paragraph::new(vec![
Line::from(vec![
focus_indicator,
Span::raw(" "),
width_info,
Span::raw(" "),
mismatch_indicator,
]),
Line::from(keys),
]);
f.render_widget(header, chunks[0]);
// ── Textarea ──
let border_color = if app.textarea_focused {
Color::Green
} else {
Color::DarkGray
};
let textarea_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.title(Span::styled(
" Markdown Input ",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
));
let textarea_inner = textarea_block.inner(chunks[1]);
f.render_widget(textarea_block, chunks[1]);
app.textarea_area = textarea_inner;
(&app.textarea).render_ref(textarea_inner, f.buffer_mut(), &mut app.textarea_state);
if app.textarea_focused
&& let Some((cx, cy)) = app
.textarea
.cursor_pos_with_state(textarea_inner, app.textarea_state)
{
f.set_cursor_position((cx, cy));
}
// ── Full render panel ──
let full_title = format!(" full: {} ", app.render_width);
render_panel(f, chunks[2], &full_title, &app.full_lines, render_w, false);
// ── Streaming render panel ──
let stream_title = format!(" stream: {} ", app.render_width);
render_panel(
f,
chunks[3],
&stream_title,
&app.streaming_lines,
render_w,
mismatch,
);
}
/// Count the number of visual rows a set of lines occupies when soft-wrapped
/// to `width` columns. Each line takes ceil(display_width / width) rows,
/// with a minimum of 1 row per line (empty lines still occupy a row).
fn wrapped_line_count(lines: &[Line<'_>], width: u16) -> u16 {
use unicode_width::UnicodeWidthStr;
if width == 0 {
return lines.len() as u16;
}
let w = width as usize;
lines
.iter()
.map(|line| {
let display_w: usize = line
.spans
.iter()
.map(|s| UnicodeWidthStr::width(s.content.as_ref()))
.sum();
if display_w == 0 {
1u16
} else {
display_w.div_ceil(w) as u16 // ceil division
}
})
.sum()
}
/// Render a bordered panel whose *inner* width is exactly `inner_w`.
///
/// Content is soft-wrapped with `Wrap { trim: false }` so long non-table
/// lines fold inside the box. The box is left-aligned within the available
/// `area`. If `is_error` is true the border turns red.
fn render_panel(
f: &mut ratatui::Frame,
area: Rect,
title: &str,
lines: &[Line<'static>],
inner_w: u16,
is_error: bool,
) {
// The block border adds 1 column on each side, so outer width = inner_w + 2.
let outer_w = (inner_w + 2).min(area.width);
let box_area = Rect {
x: area.x,
y: area.y,
width: outer_w,
height: area.height,
};
let border_color = if is_error {
Color::Red
} else {
Color::DarkGray
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.title(Span::styled(
title.to_string(),
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
));
let inner = block.inner(box_area);
f.render_widget(block, box_area);
let para = Paragraph::new(lines.to_vec()).wrap(Wrap { trim: false });
f.render_widget(para, inner);
}
/// Detect if full and streaming outputs differ in text content.
fn detect_mismatch(full: &[Line<'static>], streaming: &[Line<'static>]) -> bool {
if full.len() != streaming.len() {
return true;
}
for (f_line, s_line) in full.iter().zip(streaming.iter()) {
let f_text: String = f_line.spans.iter().map(|s| s.content.as_ref()).collect();
let s_text: String = s_line.spans.iter().map(|s| s.content.as_ref()).collect();
if f_text != s_text {
return true;
}
}
false
}

View file

@ -0,0 +1,84 @@
#![allow(dead_code)]
use xai_grok_markdown::{MarkdownStyle, Syntect};
pub const fn rgb_color(r: u8, g: u8, b: u8) -> anstyle::Color {
anstyle::Color::Rgb(anstyle::RgbColor(r, g, b))
}
pub const fn fg(color: anstyle::Color) -> anstyle::Style {
anstyle::Style::new().fg_color(Some(color))
}
pub const fn bg(color: anstyle::Color) -> anstyle::Style {
anstyle::Style::new().bg_color(Some(color))
}
pub const TEAL: anstyle::Color = rgb_color(26, 188, 156);
pub const BLUE: anstyle::Color = rgb_color(122, 162, 247);
pub const ORANGE: anstyle::Color = rgb_color(255, 158, 100);
pub const RED: anstyle::Color = rgb_color(247, 118, 142);
pub const GREEN: anstyle::Color = rgb_color(158, 206, 106);
pub const MAGENTA: anstyle::Color = rgb_color(187, 154, 247);
pub const YELLOW: anstyle::Color = rgb_color(224, 175, 104);
pub const CYAN: anstyle::Color = rgb_color(125, 207, 255);
pub const COMMENT: anstyle::Color = rgb_color(86, 95, 137);
pub const BG_DARK: anstyle::Color = rgb_color(31, 35, 53);
pub const HEADING_COLORS: [anstyle::Color; 6] = [TEAL, BLUE, ORANGE, RED, GREEN, MAGENTA];
pub const fn heading_styles(bold: bool, dimmed: bool, hidden: bool) -> [anstyle::Style; 6] {
let mut styles = [anstyle::Style::new(); 6];
let mut i = 0;
while i < HEADING_COLORS.len() {
styles[i] = fg(HEADING_COLORS[i]);
if bold {
styles[i] = styles[i].bold();
}
if dimmed {
styles[i] = styles[i].dimmed();
}
if hidden {
styles[i] = styles[i].hidden();
}
i += 1;
}
styles
}
pub const fn md_style(text: anstyle::Style) -> MarkdownStyle {
MarkdownStyle {
heading_inner: heading_styles(true, false, false),
heading_outer: heading_styles(false, true, true),
strong_inner: anstyle::Style::new().bold(),
strong_outer: anstyle::Style::new().dimmed().hidden(),
emphasis_inner: anstyle::Style::new().italic(),
emphasis_outer: anstyle::Style::new().dimmed().hidden(),
strikethrough_inner: anstyle::Style::new().strikethrough(),
strikethrough_outer: anstyle::Style::new().dimmed().hidden(),
inline_code_inner: fg(YELLOW).bold(),
inline_code_outer: fg(YELLOW).dimmed().hidden(),
blockquote_outer: fg(COMMENT).dimmed(),
task_checked: fg(CYAN),
task_unchecked: fg(BLUE).dimmed(),
list_item: fg(BLUE).dimmed(),
rule: fg(COMMENT),
link_outer: fg(COMMENT),
link_text: anstyle::Style::new().bold(),
link_url: fg(COMMENT),
link_title: fg(GREEN),
code_outer: fg(YELLOW).dimmed().hidden(),
code_language: fg(ORANGE).hidden(),
code_untagged: anstyle::Style::new(),
code_background: bg(BG_DARK),
table_outer: fg(BLUE).hidden(),
text,
math: anstyle::Style::new().italic(),
}
}
pub fn get_syntect() -> &'static Syntect {
use std::sync::OnceLock;
static SYNTECT: OnceLock<Syntect> = OnceLock::new();
SYNTECT.get_or_init(|| Syntect::new(include_bytes!("../assets/tokyo-night.tmTheme")))
}

View file

@ -0,0 +1,4 @@
target
corpus
artifacts
coverage

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,25 @@
[package]
name = "xai-grok-markdown-fuzz"
version = "0.0.0"
publish = false
edition = "2021"
[workspace]
members = ["."]
[package.metadata]
cargo-fuzz = true
[dependencies]
libfuzzer-sys = "0.4"
[dependencies.xai-grok-markdown]
path = ".."
# All render modes: pretty/non-pretty × syntect/no-syntect × full/streaming
[[bin]]
name = "render_all"
path = "fuzz_targets/render_all.rs"
test = false
doc = false
bench = false

View file

@ -0,0 +1,47 @@
# Fuzzing xai-grok-markdown
Coverage-guided fuzzing for the markdown renderer using [cargo-fuzz](https://rust-fuzz.github.io/book/cargo-fuzz.html) (libFuzzer).
## Prerequisites
```bash
cargo install cargo-fuzz # if not already installed
rustup toolchain install nightly
```
## Targets
| Target | What it fuzzes |
|---|---|
| `render_all` | All 8 combos: `pretty × syntect × {full, streaming}` for every input |
Each iteration runs:
- `render_markdown_ratatui_full()` — 4 combos (pretty/non-pretty × syntect/no-syntect)
- `StreamingMarkdownRenderer` char-by-char — same 4 combos
## Running
From `crates/codegen/xai-grok-markdown`:
```bash
# Run indefinitely (Ctrl-C to stop):
cargo +nightly fuzz run render_all fuzz/corpus/render_all fuzz/seeds/render_all -- -max_len=16384
# Run for 5 minutes:
cargo +nightly fuzz run render_all fuzz/corpus/render_all fuzz/seeds/render_all -- -max_len=16384 -max_total_time=300
```
- `corpus/` — auto-generated inputs (gitignored)
- `seeds/` — hand-written seed inputs (checked in)
## Reproducing a crash
When a crash is found, the input is saved to `artifacts/render_all/crash-<hash>`. Reproduce it with:
```bash
cargo +nightly fuzz run render_all fuzz/artifacts/render_all/crash-<hash>
```
## Adding seed inputs
Drop `.txt` or `.md` files into `seeds/render_all/`. Good seeds cover distinct markdown features (tables, code blocks, emoji, nested lists, etc.) and help the fuzzer reach new code paths faster.

View file

@ -0,0 +1,35 @@
#![no_main]
use libfuzzer_sys::fuzz_target;
use xai_grok_markdown::style::test_style::STYLE;
use xai_grok_markdown::{render_markdown_ratatui_full, StreamingMarkdownRenderer};
const CHUNK_SIZES: [usize; 3] = [1, 16, 32];
fuzz_target!(|data: &[u8]| {
let Ok(s) = std::str::from_utf8(data) else {
return;
};
// Full render: pretty / non-pretty
for pretty in [true, false] {
let _ = render_markdown_ratatui_full(s, STYLE, pretty, None);
}
// Streaming with rotating chunk sizes: pretty / non-pretty
for pretty in [true, false] {
let mut r = StreamingMarkdownRenderer::new(STYLE, pretty);
let mut pos = 0;
let mut ci = 0;
while pos < s.len() {
let mut end = (pos + CHUNK_SIZES[ci]).min(s.len());
// snap to char boundary
while end < s.len() && !s.is_char_boundary(end) {
end += 1;
}
r.push_and_render(&s[pos..end], None);
pos = end;
ci = (ci + 1) % CHUNK_SIZES.len();
}
}
});

View file

@ -0,0 +1,337 @@
# 🚀 Architecture Overview — `xai-grok-pager` Rendering Engine
The **xai-grok-pager** rendering engine is built on a layered pipeline that transforms raw markdown into terminal-ready cells. This document covers every major subsystem — from markdown parsing through syntax highlighting, word wrapping, block layout, viewport clipping, and final buffer composition. Understanding these layers is critical for anyone profiling or optimising the renderer.
---
## 📐 The Rendering Pipeline
Every frame follows the same sequence of stages. Content flows **downward** through transforms, each adding structure:
1. **Markdown parsing**`StreamingMarkdownRenderer` converts source text into a tree of styled `Line<'static>` spans. Code fences trigger **syntect** highlighting.
2. **Word wrapping**`word_wrap_lines_with_joiners()` breaks logical lines into physical rows that fit the viewport width, tracking *joiners* (continuation markers like `↳`) for copy/paste fidelity.
3. **Block output**`BlockContent::output()` packages wrapped lines into a `BlockOutput` with per-line metadata: background colour, joiner strings, and optional decorations.
4. **Entry rendering**`EntryRenderer` composes the accent column (`┃`), left/right padding, and block content into a horizontal strip. Vertical padding (vpad) adds breathing room above and below.
5. **Viewport clipping**`render_scrolled_entries_with_scratch()` walks the entry list, skips off-screen entries, and uses a `ScratchBuffer` to render partially-visible entries into a temp buffer before copying the visible slice.
6. **Buffer diff** — ratatui's `Terminal::flush()` diffs the old and new `Buffer` and emits only changed cells as escape sequences. This is **O(changed cells)**, not O(total cells).
> **💡 Key insight**: steps 13 are **cached** across frames. Only step 45 run every frame. Profiling should focus there.
### Performance characteristics
| Stage | Complexity | Cached? | Hot path? |
|---|---|---|---|
| Markdown parse | `O(n)` in source length | ✅ Yes, per-generation | ❌ No |
| Syntax highlight | `O(n)` with syntect DFA | ✅ Yes, per-generation | ❌ No |
| Word wrap | `O(lines × width)` | ✅ Yes, `(width, gen)` key | ❌ No |
| `BlockContent::output()` | `O(wrapped_lines)` | ✅ Via `WrapCache` | ⚠️ First call only |
| `EntryRenderer::render()` | `O(height × width)` cell writes | ❌ No | ✅ **Yes** |
| Scratch buffer copy | `O(visible_rows × width)` clones | ❌ No | ✅ **Yes** |
| Buffer diff + flush | `O(changed_cells)` | N/A | ✅ **Yes** |
---
## 🧱 Block Types and Their Render Cost
Each `RenderBlock` variant has different rendering characteristics. Here's a breakdown of the major block types with their typical content patterns and associated costs:
### `AgentMessageBlock` — the heaviest hitter 🔥
Agent messages contain **arbitrary markdown**: paragraphs, code blocks, tables, lists, inline formatting. A single agent response can easily exceed 200 wrapped lines. The `MarkdownContent` subsystem does the heavy lifting:
- `StreamingMarkdownRenderer::push_and_render()` incrementally parses and highlights
- `word_wrap_lines_with_joiners()` handles Unicode-aware line breaking with `unicode-width`
- Wide characters (CJK, emoji) consume 2 columns: `'🦀'.width() == 2`, `'λ'.width() == 1`
```rust
/// The core markdown-to-lines pipeline.
///
/// This function is called on every content mutation (push_chunk, finish)
/// and produces the canonical `Vec<Line<'static>>` that gets cached.
pub fn render_markdown(source: &str, pretty: bool) -> Vec<Line<'static>> {
let mut renderer = StreamingMarkdownRenderer::new(MD_STYLE, pretty);
renderer.push(source);
renderer.render(Some(get_syntect()));
renderer.view().lines.to_vec()
}
/// Word-wrap with joiner tracking for copy fidelity.
///
/// Each output line knows whether it's a continuation of the previous
/// logical line (joiner = Some("↳")) or a fresh line (joiner = None).
/// This matters for selection/copy: we strip joiners when copying.
pub fn word_wrap_lines_with_joiners(
lines: Vec<Line<'static>>,
max_width: usize,
) -> (Vec<Line<'static>>, Vec<Option<String>>) {
let mut wrapped = Vec::with_capacity(lines.len() * 2);
let mut joiners = Vec::with_capacity(lines.len() * 2);
for line in lines {
let line_width = line.width();
if line_width <= max_width {
wrapped.push(line);
joiners.push(None);
} else {
// Split at grapheme cluster boundaries respecting unicode width.
// This is the expensive path — O(spans × chars) per line.
let parts = split_line_at_width(&line, max_width);
for (i, part) in parts.into_iter().enumerate() {
wrapped.push(part);
joiners.push(if i > 0 { Some("↳".into()) } else { None });
}
}
}
(wrapped, joiners)
}
```
### `ThinkingBlock` — truncated by default
Thinking blocks render identically to agent messages but default to `DisplayMode::Truncated` (3 visible lines + `⋯ N more lines`). When expanded, they're as expensive as agent messages. The truncation logic runs *after* wrapping, so the full wrap cost is paid even when collapsed — a potential optimisation target.
### `ToolCallBlock` variants
| Variant | Collapsed height | Expanded cost | Notes |
|---|---|---|---|
| `Execute` | 1 line (command summary) | `O(output_lines)` | Bash output can be huge |
| `Read` | 1 line (path + line count) | `O(file_lines)` | Syntax-highlighted file content |
| `Edit` | 1 line (path + edit count) | `O(diff_lines)` | Diff hunks with `+`/`-` colouring |
| `ListDir` | 1 line (path) | `O(entries)` | Directory tree listing |
| `Search` | 1 line (pattern + count) | `O(matches)` | Grep results with context |
| `Other` | 1 line (tool name) | `O(output)` | Generic tool output |
### `UserPromptBlock` — lightweight ✨
User prompts are short (15 lines typically), render with a `┃` accent in `accent_user` colour, and are **never foldable**. They're the cheapest block to render.
---
## 🎨 The Accent Column and Colour Blending
The leftmost column of every entry shows a vertical accent bar `┃`. This serves as a visual type indicator:
- **User prompts**: `accent_user` (Tokyo Night blue, `#7aa2f7`)
- **Tool calls**: `accent_tool` / `accent_success` / `accent_error`
- **Thinking**: `accent_thinking` (purple, `#bb9af7`)
- **Running blocks**: animated wave effect 🌊
The animation uses `blend_color(bg, fg, brightness)` per-row per-frame:
```rust
/// Compute wave brightness for a single row at a given tick.
///
/// Returns a value in [0.2, 1.0] — never fully invisible.
/// The wave travels downward at WAVE_SPEED radians per tick.
pub fn wave_brightness(tick: u64, row: u16, wave_rows: u16, speed: f32) -> f32 {
let phase = (tick as f32 * speed) - (row as f32 * std::f32::consts::PI / wave_rows as f32);
let raw = (phase.sin() + 1.0) / 2.0; // normalize to [0, 1]
0.2 + raw * 0.8 // scale to [0.2, 1.0]
}
/// Linearly blend two RGB colours.
///
/// `opacity = 0.0` → pure `base`; `opacity = 1.0` → pure `color`.
/// Returns `None` if either colour isn't RGB (indexed colours can't blend).
pub fn blend_color(base: Color, color: Color, opacity: f32) -> Option<Color> {
match (base, color) {
(Color::Rgb(br, bg, bb), Color::Rgb(cr, cg, cb)) => {
let r = br as f32 + (cr as f32 - br as f32) * opacity;
let g = bg as f32 + (cg as f32 - bg as f32) * opacity;
let b = bb as f32 + (cb as f32 - bb as f32) * opacity;
Some(Color::Rgb(r as u8, g as u8, b as u8))
}
_ => None,
}
}
```
---
## 📦 The `ScratchBuffer` and Partial Rendering
When an entry is **partially visible** (clipped at top or bottom of the viewport), we can't render directly into the output buffer — we'd write cells outside the visible area. Instead:
1. Resize a reusable `ScratchBuffer` to the entry's full height
2. Render the complete entry into scratch
3. Copy only the visible rows (`skip_rows..skip_rows + visible_height`) into the output
This is the **cell-by-cell copy loop** — one of the hottest paths:
```rust
for dy in 0..visible_rows {
let src_y = skip_rows + dy;
let dst_y = dest_area.y + dy;
for dx in 0..dest_area.width {
if let Some(src_cell) = temp_buf.cell((dx, src_y))
&& let Some(dst_cell) = buf.cell_mut((dest_area.x + dx, dst_y))
{
dst_cell.clone_from(src_cell);
}
}
}
```
> **🔬 Optimisation opportunity**: `Cell::clone_from` copies `symbol: String` (24 bytes on stack + possible heap), `fg`, `bg`, `underline_color`, `modifier`, `skip`. A `memcpy`-based bulk row copy could be significantly faster for wide terminals. At `width=200`, that's 200 `clone_from` calls per visible row per frame — potentially 6000 calls for a 30-row viewport with top+bottom clipping.
---
## 🔤 Unicode Width Challenges
Terminal rendering must account for **variable-width characters**. The `unicode-width` crate provides `UnicodeWidthChar::width()` and `UnicodeWidthStr::width()`:
| Character | Example | `width()` | Notes |
|---|---|---|---|
| ASCII | `A`, `z`, `!` | 1 | Basic Latin |
| CJK Unified | `漢`, `字`, `中` | 2 | Chinese/Japanese/Korean ideographs |
| Fullwidth forms | ``, ``, `` | 2 | Fullwidth ASCII variants |
| Emoji | `🦀`, `🚀`, `🎨` | 2 | Most emoji are wide |
| Combining marks | `é` (e + ◌́) | 1 | Combining char has width 0 |
| Zero-width | ZWJ, ZWNJ | 0 | Used in emoji sequences like 👨‍👩‍👧‍👦 |
| Tab | `\t` | — | Not handled by unicode-width; we expand to spaces |
The word wrapper must **never split a wide character** across the column boundary. If a 2-cell-wide char would start at column `width - 1`, we must wrap it to the next line and pad the current line with a space.
Here's a stress test: `漢字テスト🦀🚀🎨` contains 5 double-width CJK chars (10 columns) plus 3 double-width emoji (6 columns) = 16 columns total. At `width = 10`, this wraps to 2 lines. At `width = 7`, it wraps to 3 lines with padding cells.
---
## 📊 Inline Code and Syntax Highlighting Deep Dive
Inline code uses backtick syntax: `HashMap<String, Vec<u8>>`, `Option<&'a mut T>`, `impl Fn(usize) -> bool`. Each inline code span gets a distinct background colour (`bg_code`) to visually separate it from prose. The renderer must:
1. Parse the backtick delimiter (single `` ` `` or double ``` `` ```)
2. Extract the code content
3. Apply `Style::default().bg(theme.bg_code).fg(theme.fg_code)`
4. Handle **nested formatting** — e.g., `**bold `code` bold**` where code is inside bold
Fenced code blocks trigger full **syntect** highlighting. The highlighting pipeline:
1. Look up the `SyntaxReference` by language identifier (`rust`, `python`, `typescript`, etc.)
2. Create a `HighlightLines` with the Tokyo Night theme
3. Iterate source lines, calling `highlight_line()` to get `Vec<(syntect::Style, &str)>`
4. Convert syntect styles to ratatui `Span` styles (mapping RGB colours)
5. Each line gets `Style::default().bg(theme.bg_dark)` as a block background
The syntect state machine is **line-stateful** — each line's highlighting depends on the parse state at the end of the previous line. This means we can't parallelise highlighting within a single code block, but we *can* cache the result.
---
## 🧪 Testing Patterns
The scrollback rendering has comprehensive snapshot tests using `insta`. Here's the typical pattern:
```python
# This is a Python code block to exercise a different syntax highlighter.
# The renderer must detect the language and switch syntect grammars.
import asyncio
from dataclasses import dataclass, field
from typing import Optional, Dict, List, Tuple
@dataclass
class TrainingConfig:
"""Configuration for a distributed training run. 🔧"""
model_name: str
batch_size: int = 32
learning_rate: float = 3e-4
max_epochs: int = 100
gradient_accumulation_steps: int = 1
warmup_ratio: float = 0.1
weight_decay: float = 0.01
devices: List[str] = field(default_factory=lambda: ["cuda:0"])
mixed_precision: bool = True
compile_model: bool = False # torch.compile — can 2× throughput
checkpoint_dir: Optional[str] = None
@property
def effective_batch_size(self) -> int:
return self.batch_size * self.gradient_accumulation_steps * len(self.devices)
def validate(self) -> None:
assert self.batch_size > 0, f"batch_size must be positive, got {self.batch_size}"
assert 0 < self.learning_rate < 1, f"learning_rate out of range: {self.learning_rate}"
assert self.max_epochs > 0, f"max_epochs must be positive, got {self.max_epochs}"
for device in self.devices:
assert device.startswith(("cuda", "cpu")), f"unknown device: {device}"
async def train_epoch(
model,
dataloader,
optimizer,
scheduler,
config: TrainingConfig,
epoch: int,
) -> Dict[str, float]:
"""Run a single training epoch. Returns metrics dict. 📈"""
model.train()
total_loss = 0.0
num_batches = 0
for batch_idx, batch in enumerate(dataloader):
# Forward pass — compute loss on this micro-batch
outputs = model(**batch)
loss = outputs.loss / config.gradient_accumulation_steps
loss.backward()
if (batch_idx + 1) % config.gradient_accumulation_steps == 0:
optimizer.step()
scheduler.step()
optimizer.zero_grad()
total_loss += loss.item() * config.gradient_accumulation_steps
num_batches += 1
avg_loss = total_loss / max(num_batches, 1)
return {"epoch": epoch, "avg_loss": avg_loss, "num_batches": num_batches}
```
---
## ⚡ Benchmarking Strategy
To measure render performance, we need to isolate the **per-frame** cost from one-time setup:
- **Setup** (not measured): Parse markdown, create `ScrollbackEntry`, compute initial wrap cache
- **Measured**: For each scroll offset `0..total_height`, render into a `Buffer` of size `width × viewport_height`
This simulates a user holding down `j` (scroll down) and measures the **worst case** — every frame re-renders the viewport at a new scroll position, exercising:
- `EntryRenderer::render()` — accent, padding, content layout
- `BlockRenderer::render()` — vpad, content lines, background fills
- Partial rendering via `ScratchBuffer` — for clipped entries at top/bottom
- Cell-by-cell copy — the innermost hot loop
### Expected results
On a modern machine (M2 Pro), we expect:
- **~50200 µs/frame** for a 120×30 viewport with a 200-line markdown document
- **~80% of time** in `EntryRenderer::render()` + scratch buffer copy
- **~15% of time** in `BlockContent::output()` (cache hit path — just iterating cached lines)
- **~5% of time** in layout computation (`HorizontalLayout`, `EntryLayout`, gap math)
If the benchmark shows >500 µs/frame, there's likely an unexpected cache miss or allocation in the hot path. Use `cargo bench -- --profile-time 10` with `flamegraph` to identify the culprit.
---
## 🌐 Miscellaneous Wide Characters and Edge Cases
Here are some strings that exercise interesting rendering edge cases:
- **Emoji sequences**: 👨‍👩‍👧‍👦 (family ZWJ sequence, should be width 2 but terminal support varies)
- **Flags**: 🇺🇸 🇯🇵 🇩🇪 (regional indicator pairs)
- **Fullwidth**: `` (each char is 2 columns wide)
- **Combining**: `naïve` vs `naïve` (precomposed U+00EF vs combining U+0308)
- **Box drawing**: `┌─────────┐│ content │└─────────┘` (all width 1)
- **Mathematical**: `∀x ∈ : x² ≥ 0`, `∑_{i=0}^{n} aᵢ = S`, `∫₀^∞ e^{-x} dx = 1`
- **CJK mixed**: `これはテストです — this is a test — 這是測試 — 이것은 시험이다`
- **RTL markers**: `Hello dlrow!` (contains RLO/PDF override characters)
The renderer must handle all of these without panicking or producing garbled output. The word wrapper is the critical component — it must correctly account for each character's display width when deciding where to break lines.
> **⚠️ Warning**: Some terminals render emoji sequences incorrectly (showing them as 1-wide or as multiple glyphs). Our renderer uses `unicode-width` which reports the **Unicode standard** width, not the terminal's actual rendering width. This is a known source of misalignment — there is no perfect solution without querying the terminal.
---
*Generated for benchmarking purposes. Total: ~230 lines of rich markdown content with multiple code blocks, tables, inline code, emoji, wide Unicode characters, and varied formatting.*

View file

@ -0,0 +1,5 @@
# Heading
```rust
fn main() {}
```

View file

@ -0,0 +1,5 @@
[link](https://example.com) ![alt](img.png)
***bold italic*** ~~strike~~
`code` **`bold code`** *`italic code`*

View file

@ -0,0 +1,12 @@
# H1
## H2
### H3
---
1. one
2. two
3. three
- [ ] todo
- [x] done

View file

@ -0,0 +1,29 @@
# Math seed
Euler's identity: $e^{i\pi} + 1 = 0$ and a fraction $\frac{a+b}{2}$.
Paren form \(\alpha_1 + \beta^2 \le \gamma\) inline.
$$
\int_0^\infty e^{-x}\,dx = 1
$$
\[
\begin{aligned}
f(x) &= \sqrt{x^2 + 1} \\
g(x) &= \begin{cases} x & x \ge 0 \\ -x & x < 0 \end{cases}
\end{aligned}
\]
Matrix: $$\begin{pmatrix} 1 & 2 \\ 3 & 4 \end{pmatrix}$$
| Col | Math |
|-----|------|
| a | $x^2$ |
- item \(p \to q\)
- sets $S \subseteq \mathbb{R}^n$
> quote $$E = mc^2$$
Escapes: \\(not math\\) and `$code$` and \[unterminated

View file

@ -0,0 +1,5 @@
> **bold** *italic* `code`
- item 1
- nested
- deep

View file

@ -0,0 +1,3 @@
| a | b |
|---|---|
| 1 | 2 |

View file

@ -0,0 +1,5 @@
---
## 📐 Heading
Text after thematic break with emoji.

View file

@ -0,0 +1,7 @@
漢字テスト 👨‍👩‍👧‍👦 ∀x ∈
---
## 📐 Wide chars
Ü ö ñ é à ß µ ∞ ≠ ≤ ≥ ÷ × ← → ↑ ↓

View file

@ -0,0 +1,373 @@
//! Reusable buffers and internal data types for markdown parsing and rendering.
//!
//! This module contains all the intermediate data structures used by
//! MarkdownHighlighter during parsing and rendering.
use std::ops::Range;
use anstyle::Style as AnsiStyle;
use ratatui::text::{Line, Span};
use syntect::highlighting::Style as SyntectStyle;
/// A range of text with optional styling.
#[derive(Debug, Clone)]
pub struct Highlight {
pub style: Option<AnsiStyle>,
pub range: Range<usize>,
}
/// Syntax-highlighted code block replacement.
///
/// Stores the raw highlighted spans per line (intermediate representation).
/// This allows rendering to either ANSI strings or ratatui Lines on demand.
#[derive(Debug, Clone)]
pub struct Replace {
/// Raw highlighted spans per line: Vec<(style, text)>.
/// Each inner Vec represents one line of the code block.
pub highlighted: Vec<Vec<(SyntectStyle, String)>>,
/// Source byte range this replaces.
pub range: Range<usize>,
}
/// Internal representation of a hyperlink target discovered during parsing.
///
/// Populated in the `Tag::Link` / `Tag::Image` arm of `MarkdownParser::on_start`.
/// Consumed during rendering to produce public `HyperlinkTarget`s in the output.
#[derive(Debug, Clone)]
pub struct LinkTarget {
/// Source byte range of the *link text* (not the full `[text](url)` span).
pub source_range: Range<usize>,
/// Destination URL.
pub url: String,
/// Monotonically increasing identifier assigned during parsing.
pub id: u32,
}
/// Parse-time record of a closed fenced code block.
///
/// Populated in the `Tag::CodeBlock` arm of `MarkdownParser`; consumed during
/// rendering (see `output::build_code_block_spans`) to produce the public
/// [`crate::CodeBlockSpan`] once the output line range is known. Only **closed**
/// fences are recorded — an unterminated trailing fence yields no entry.
#[derive(Debug, Clone)]
pub struct CodeBlockMeta {
/// Fence info string (e.g. `"mermaid"`), verbatim from pulldown-cmark.
pub info: String,
/// De-prefixed body content (container markers stripped, CRLF normalized) —
/// pulldown's merged body text, i.e. the clean code/diagram source.
pub body: String,
/// Source byte range of the fence body (delimiter lines excluded).
pub body_source_range: Range<usize>,
}
/// Text transformation for substituting characters (e.g., bullets).
#[derive(Debug, Clone)]
pub struct Transform {
/// Source byte range to transform.
pub(crate) range: Range<usize>,
/// Replacement text.
pub(crate) to: String,
/// Apply this transform even in raw (non-pretty) mode.
///
/// Invariant: `to.len() == range.end - range.start` and the
/// substitution must stay valid UTF-8 at the same byte offsets.
/// `render_ansi` substitutes force transforms in place into a byte
/// buffer; violating the invariant panics at `copy_from_slice` or
/// `String::from_utf8` before any bytes escape the renderer.
pub(crate) force: bool,
}
/// A styled segment within a table cell.
#[derive(Debug, Clone)]
pub struct CellSpan {
pub text: String,
pub bold: bool,
pub italic: bool,
pub code: bool,
/// Hyperlink (url, id) when this span is inside a `[label](url)` link
/// or autolink inside a table cell. `None` for plain text.
pub link: Option<(String, u32)>,
}
impl CellSpan {
pub fn new(
text: String,
bold: bool,
italic: bool,
code: bool,
link: Option<(String, u32)>,
) -> Self {
Self {
text,
bold,
italic,
code,
link,
}
}
}
/// A table cell with styled content.
#[derive(Debug, Clone, Default)]
pub struct StyledCell {
pub spans: Vec<CellSpan>,
}
impl StyledCell {
pub fn new() -> Self {
Self { spans: Vec::new() }
}
/// Get plain text content (for width calculation).
pub fn plain_text(&self) -> String {
self.spans.iter().map(|s| s.text.as_str()).collect()
}
/// Clear the cell content.
pub fn clear(&mut self) {
self.spans.clear();
}
}
/// State for buffering table content during parsing.
#[derive(Debug, Clone)]
pub struct TableState {
/// Column alignments from the table header.
pub alignments: Vec<pulldown_cmark::Alignment>,
/// Header row cells.
pub header: Vec<StyledCell>,
/// Body rows (each row is a Vec of styled cells).
pub rows: Vec<Vec<StyledCell>>,
/// Current row being built.
pub current_row: Vec<StyledCell>,
/// Current cell content being accumulated.
pub current_cell: StyledCell,
/// Current style state for the cell.
pub cell_bold: bool,
pub cell_italic: bool,
pub cell_code: bool,
/// Current link state: `Some((url, id))` while inside a `Tag::Link` /
/// `Tag::Image` inside a table cell. Text events captured while this
/// is set produce link-tagged `CellSpan`s so the table renderer can
/// apply link styling and emit `HyperlinkTarget`s.
pub cell_link: Option<(String, u32)>,
/// Whether we're in the header section.
pub in_header: bool,
/// Source byte range of the entire table.
pub range: Range<usize>,
}
impl TableState {
pub fn new(alignments: Vec<pulldown_cmark::Alignment>, start: usize) -> Self {
Self {
alignments,
header: Vec::new(),
rows: Vec::new(),
current_row: Vec::new(),
current_cell: StyledCell::new(),
cell_bold: false,
cell_italic: false,
cell_code: false,
cell_link: None,
in_header: false,
range: start..start,
}
}
/// Push text with current styling to the cell.
pub fn push_text(&mut self, text: &str) {
self.current_cell.spans.push(CellSpan::new(
text.to_string(),
self.cell_bold,
self.cell_italic,
self.cell_code,
self.cell_link.clone(),
));
}
}
/// One hyperlink target inside a formatted table.
///
/// Coordinates are local to the table's `styled_lines`:
/// `line_offset` indexes into `TableReplace::styled_lines`; the renderer
/// adds the current absolute line count to produce a public
/// `HyperlinkTarget`.
#[derive(Debug, Clone)]
pub struct TableHyperlink {
/// Index within `TableReplace::styled_lines`.
pub line_offset: usize,
/// Column range (display cells) on that line.
pub column_range: Range<usize>,
/// Destination URL.
pub url: String,
/// Stable identifier shared with the paragraph link path.
pub id: u32,
}
/// Formatted table replacement for pretty mode rendering.
#[derive(Debug, Clone)]
pub struct TableReplace {
/// Formatted table lines (plain strings for ANSI rendering).
pub lines: Vec<String>,
/// Styled table lines for ratatui rendering.
pub styled_lines: Vec<Line<'static>>,
/// Source byte range this replaces.
pub range: Range<usize>,
/// Per-rendered-line source offset from the table start.
///
/// Maps each entry in `styled_lines` to the source line offset
/// within the table (0 = header, 1 = separator, 2+ = body rows).
/// Used by the renderer to produce correct `line_source_map` entries
/// instead of the naive `table_start + line_idx` which overshoots
/// when the rendered table has more lines than the source (borders,
/// separators, wrapped cells).
pub line_source_offsets: Vec<usize>,
/// Hyperlinks for `[label](url)` / autolinks inside table cells.
///
/// The paragraph link path (`LinkTarget` -> `chunk_link_offsets`)
/// cannot project links onto a rendered table because the table
/// replace consumes the entire source range — no text chunk's
/// rendering walks over the link text. The parser instead emits
/// `TableHyperlink`s during table formatting with positions in
/// table-local coordinates; the renderer translates them to absolute
/// `HyperlinkTarget`s.
pub hyperlinks: Vec<TableHyperlink>,
}
/// Rendered Mermaid diagram replacement for pretty mode rendering.
#[derive(Debug, Clone)]
pub struct MermaidReplace {
/// Plain lines for ANSI rendering.
pub lines: Vec<String>,
/// Styled lines for ratatui rendering.
pub styled_lines: Vec<Line<'static>>,
/// Source byte range this replaces.
pub range: Range<usize>,
}
/// Calculate the display width of a string (accounting for Unicode).
pub fn unicode_display_width(s: &str) -> usize {
use unicode_width::UnicodeWidthStr;
s.width()
}
/// Polyfill for `str::floor_char_boundary` (stable in Rust 1.91+).
///
/// Snaps `index` down to the nearest UTF-8 char boundary in `s`. Indices
/// past the end of `s` are clamped to `s.len()`. Replace with the std
/// method once the workspace toolchain is bumped to 1.91+.
pub(crate) fn floor_char_boundary(s: &str, index: usize) -> usize {
let mut i = index.min(s.len());
while i > 0 && !s.is_char_boundary(i) {
i -= 1;
}
i
}
/// Polyfill for `str::ceil_char_boundary` (stable in Rust 1.91+).
///
/// Snaps `index` up to the nearest UTF-8 char boundary in `s`. Indices
/// past the end of `s` are clamped to `s.len()`. Replace with the std
/// method once the workspace toolchain is bumped to 1.91+.
pub(crate) fn ceil_char_boundary(s: &str, index: usize) -> usize {
let mut i = index.min(s.len());
while i < s.len() && !s.is_char_boundary(i) {
i += 1;
}
i
}
/// Event kind for the render loop.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[repr(u8)]
pub enum RenderEventKind {
Highlight = 0,
Replace = 1,
Table = 2,
Mermaid = 3,
}
/// Render event: marks where a highlight/replace/table starts or ends.
/// Derives Ord for sorting by (pos, kind, index, is_end).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct RenderEvent {
pub pos: usize,
pub kind: RenderEventKind,
pub index: usize,
pub is_end: bool,
}
/// Reusable buffers for markdown highlighting and rendering.
///
/// All vectors are cleared (keeping capacity) between renders, eliminating
/// allocation overhead in the streaming hot path.
///
/// # Buffer Categories
///
/// **Parse output buffers** - populated during `run()`, read-only during `render()`:
/// - `highlights`: Style ranges for inline formatting
/// - `replaces`: Syntax-highlighted code blocks
/// - `transforms`: Character substitutions (e.g., bullets)
/// - `untagged_code_ranges`: Code blocks without language tags
/// - `table_replaces`: Formatted table replacements
///
/// **Render scratch buffers** - temporary storage during `render()`:
/// - `render_events`: Sorted event queue for the render loop
/// - `current_spans`: Building current line's spans
/// - `active_highlights`: Stack of active highlight indices
pub struct MarkdownBuffers {
// Parse output buffers (written by run(), read by render())
pub highlights: Vec<Highlight>,
pub replaces: Vec<Replace>,
pub transforms: Vec<Transform>,
pub untagged_code_ranges: Vec<Range<usize>>,
pub table_replaces: Vec<TableReplace>,
pub mermaid_replaces: Vec<MermaidReplace>,
pub link_targets: Vec<LinkTarget>,
/// Closed fenced code blocks, in document order (see [`CodeBlockMeta`]).
pub code_blocks: Vec<CodeBlockMeta>,
// Render scratch buffers (used only during render())
pub render_events: Vec<RenderEvent>,
pub current_spans: Vec<Span<'static>>,
pub active_highlights: Vec<usize>,
}
impl MarkdownBuffers {
pub fn new() -> Self {
Self {
highlights: Vec::new(),
replaces: Vec::new(),
transforms: Vec::new(),
untagged_code_ranges: Vec::new(),
table_replaces: Vec::new(),
mermaid_replaces: Vec::new(),
link_targets: Vec::new(),
code_blocks: Vec::new(),
render_events: Vec::new(),
current_spans: Vec::new(),
active_highlights: Vec::new(),
}
}
/// Clear all buffers, keeping allocated capacity.
pub fn clear(&mut self) {
self.highlights.clear();
self.replaces.clear();
self.transforms.clear();
self.untagged_code_ranges.clear();
self.table_replaces.clear();
self.mermaid_replaces.clear();
self.link_targets.clear();
self.code_blocks.clear();
self.render_events.clear();
self.current_spans.clear();
self.active_highlights.clear();
}
}
impl Default for MarkdownBuffers {
fn default() -> Self {
Self::new()
}
}

View file

@ -0,0 +1,58 @@
//! Checkpoint types for incremental markdown rendering.
//!
//! This module defines types for identifying stable boundaries in markdown text
//! where rendered output can be "frozen" and cached. Content before a checkpoint
//! will not change regardless of what text is appended after it.
//!
//! # Design
//!
//! Checkpoints are only created at **top-level** (depth=0) block boundaries. Blocks
//! nested inside lists, blockquotes, or tables cannot be checkpoints because the
//! outer container might continue.
//!
//! # Example
//!
//! ```text
//! # Heading <- Checkpoint after this (heading at depth=0)
//!
//! Paragraph text. <- Checkpoint after blank line (paragraph at depth=0)
//!
//! - List item <- NO checkpoint (inside list)
//! ```code``` <- NO checkpoint (code block inside list)
//! - Another item
//! <- Checkpoint here (list closed at depth=0)
//! ```
/// A position in the source text where rendered content can be frozen.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Checkpoint {
/// Byte offset in source text (exclusive end of frozen region).
/// Content in `text[..source_bytes]` can be cached.
pub source_bytes: usize,
/// Number of output lines that correspond to this checkpoint.
/// Lines `0..output_lines` can be frozen.
pub output_lines: usize,
/// What kind of block ended at this checkpoint.
pub kind: CheckpointKind,
}
/// The type of markdown block that created a checkpoint.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CheckpointKind {
/// A heading (any level: h1-h6)
Heading,
/// A paragraph followed by a blank line
Paragraph,
/// A fenced or indented code block
CodeBlock,
/// A blockquote that closed at top level
BlockQuote,
/// A list (ordered or unordered) that closed at top level
List,
/// A thematic break (horizontal rule: ---, ***, ___)
ThematicBreak,
/// A table that closed at top level
Table,
/// A raw HTML block
HtmlBlock,
}

View file

@ -0,0 +1,287 @@
//! Terminal color support detection and color conversion utilities.
//!
//! This module provides functionality to detect the terminal's color capabilities
//! and downgrade RGB colors to the appropriate level when needed.
use std::sync::OnceLock;
use std::sync::atomic::{AtomicU8, Ordering};
use anstyle::{Ansi256Color, AnsiColor, Color, RgbColor};
/// The level of color support detected for the terminal.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
pub enum ColorLevel {
/// No color support (monochrome terminals)
None,
/// Basic 16-color ANSI support (colors 0-15)
Basic,
/// 256-color support (colors 0-255)
Ansi256,
/// 24-bit truecolor RGB support (16 million colors)
#[default]
TrueColor,
}
impl ColorLevel {
/// Returns true if at least basic color is supported.
pub fn has_color(self) -> bool {
self >= Self::Basic
}
/// Returns true if 256-color mode is supported.
pub fn has_256(self) -> bool {
self >= Self::Ansi256
}
/// Returns true if 24-bit truecolor is supported.
pub fn has_truecolor(self) -> bool {
self >= Self::TrueColor
}
}
static COLOR_LEVEL: OnceLock<ColorLevel> = OnceLock::new();
/// Detect the terminal's color support level.
///
/// This uses the `supports-color` crate which checks:
/// - `COLORTERM` environment variable (for truecolor detection)
/// - `TERM` environment variable
/// - Terminal-specific environment variables (like `ITERM_SESSION_ID`)
/// - Whether stdout is a TTY
///
/// The result is cached after the first call.
pub fn detect_color_level() -> ColorLevel {
*COLOR_LEVEL.get_or_init(|| {
// Explicit opt-out via NO_COLOR takes priority.
if std::env::var_os("NO_COLOR").is_some() {
return ColorLevel::None;
}
let level = match supports_color::on(supports_color::Stream::Stdout) {
// Not a TTY (tests, piped) — default to TrueColor.
// The pager is a TUI app that always runs inside a terminal;
// stdout may not be a TTY when the pager renders to stderr.
None => ColorLevel::TrueColor,
Some(level) => {
if level.has_16m {
ColorLevel::TrueColor
} else if level.has_256 {
ColorLevel::Ansi256
} else if level.has_basic {
ColorLevel::Basic
} else {
ColorLevel::None
}
}
};
// The `supports-color` crate relies on COLORTERM=truecolor, but
// tmux/SSH/mosh often strip that variable. When the crate reports
// only 256-color support, upgrade to TrueColor if we can identify
// a known truecolor-capable terminal via its env vars.
if level < ColorLevel::TrueColor && terminal_supports_truecolor() {
return ColorLevel::TrueColor;
}
level
})
}
/// Check whether the terminal emulator is known to support truecolor.
///
/// Used as a fallback when `COLORTERM` is missing (e.g. inside tmux or over
/// SSH). Checks terminal-specific env vars that survive session forwarding
/// even when `COLORTERM` and `TERM_PROGRAM` are stripped.
fn terminal_supports_truecolor() -> bool {
use std::env;
// TERM_PROGRAM is the most reliable signal (set by the emulator itself).
if let Ok(prog) = env::var("TERM_PROGRAM") {
let norm: String = prog
.trim()
.chars()
.filter(|c| !matches!(c, ' ' | '-' | '_' | '.'))
.map(|c| c.to_ascii_lowercase())
.collect();
// Every modern terminal except Apple Terminal supports truecolor.
if matches!(
norm.as_str(),
"iterm"
| "iterm2"
| "itermapp"
| "ghostty"
| "kitty"
| "wezterm"
| "alacritty"
| "warp"
| "warpterminal"
| "vscode"
) {
return true;
}
}
// Terminal-specific env vars that often survive tmux/SSH.
env::var("ITERM_SESSION_ID").is_ok()
|| env::var("ITERM_PROFILE").is_ok()
|| env::var("WEZTERM_VERSION").is_ok()
|| env::var("KITTY_WINDOW_ID").is_ok()
|| env::var("ALACRITTY_SOCKET").is_ok()
}
/// Process-wide upper bound on the effective color level, stored as the
/// `ColorLevel` declaration-order discriminant.
static COLOR_LEVEL_CAP: AtomicU8 = AtomicU8::new(ColorLevel::TrueColor as u8);
/// Set the process-wide upper bound on the effective color level. Pass
/// [`ColorLevel::TrueColor`] to remove the cap.
pub fn set_color_level_cap(cap: ColorLevel) {
COLOR_LEVEL_CAP.store(cap as u8, Ordering::Relaxed);
}
fn color_level_cap() -> ColorLevel {
match COLOR_LEVEL_CAP.load(Ordering::Relaxed) {
0 => ColorLevel::None,
1 => ColorLevel::Basic,
2 => ColorLevel::Ansi256,
_ => ColorLevel::TrueColor,
}
}
/// Get the current color level (detecting if not already done), bounded by
/// the process-wide cap (see [`set_color_level_cap`]).
pub fn get_color_level() -> ColorLevel {
detect_color_level().min(color_level_cap())
}
/// Override the color level (useful for testing or user preference).
///
/// Returns `Err` if the color level was already set.
#[allow(dead_code)]
pub fn set_color_level(level: ColorLevel) -> Result<(), ColorLevel> {
COLOR_LEVEL.set(level)
}
/// Convert an `anstyle::Color` to the appropriate level based on terminal support.
///
/// This will downgrade colors as needed:
/// - TrueColor terminals: pass through unchanged
/// - 256-color terminals: RGB colors are converted to closest ANSI 256 color
/// - Basic terminals: colors are converted to closest ANSI 16 color
/// - No color: returns None
pub fn adapt_color(color: Color) -> Option<Color> {
let level = get_color_level();
match level {
ColorLevel::None => None,
ColorLevel::TrueColor => Some(color),
ColorLevel::Ansi256 => Some(match color {
Color::Rgb(rgb) => Color::Ansi256(rgb_to_ansi256(rgb)),
other => other,
}),
ColorLevel::Basic => Some(match color {
Color::Rgb(rgb) => Color::Ansi(rgb_to_ansi16(rgb)),
Color::Ansi256(idx) => Color::Ansi(ansi256_to_ansi16(idx)),
Color::Ansi(ansi) => Color::Ansi(ansi),
}),
}
}
/// Convert an `anstyle::Style` to the appropriate color level.
pub fn adapt_style(style: anstyle::Style) -> anstyle::Style {
let fg = style.get_fg_color().and_then(adapt_color);
let bg = style.get_bg_color().and_then(adapt_color);
let effects = style.get_effects();
let mut new_style = anstyle::Style::new();
if let Some(fg) = fg {
new_style = new_style.fg_color(Some(fg));
}
if let Some(bg) = bg {
new_style = new_style.bg_color(Some(bg));
}
new_style | effects
}
/// Convert an RGB color to the closest ANSI 256-color palette entry.
pub fn rgb_to_ansi256(rgb: RgbColor) -> Ansi256Color {
anstyle_lossy::rgb_to_xterm(rgb)
}
/// Convert an RGB color to the closest basic ANSI 16-color.
pub fn rgb_to_ansi16(rgb: RgbColor) -> AnsiColor {
anstyle_lossy::rgb_to_ansi(rgb, anstyle_lossy::palette::VGA)
}
/// Convert an ANSI 256-color to the closest basic ANSI 16-color.
pub fn ansi256_to_ansi16(idx: Ansi256Color) -> AnsiColor {
anstyle_lossy::xterm_to_ansi(idx, anstyle_lossy::palette::VGA)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_rgb_to_ansi256_grayscale() {
// Pure black should map to near-black
let result = rgb_to_ansi256(RgbColor(0, 0, 0));
assert!(result.index() == 16 || result.index() >= 232);
// Pure white should map to near-white
let result = rgb_to_ansi256(RgbColor(255, 255, 255));
assert!(result.index() == 231 || result.index() == 255);
// Medium gray
let result = rgb_to_ansi256(RgbColor(128, 128, 128));
assert!(result.index() >= 232); // Should be in grayscale range
}
#[test]
fn test_rgb_to_ansi256_colors() {
// Pure red
let result = rgb_to_ansi256(RgbColor(255, 0, 0));
assert_eq!(result.index(), 196); // Bright red in the cube
// Pure green
let result = rgb_to_ansi256(RgbColor(0, 255, 0));
assert_eq!(result.index(), 46); // Bright green in the cube
// Pure blue
let result = rgb_to_ansi256(RgbColor(0, 0, 255));
assert_eq!(result.index(), 21); // Bright blue in the cube
}
#[test]
fn test_rgb_to_ansi16() {
// Test basic color mapping
let red = rgb_to_ansi16(RgbColor(200, 0, 0));
assert!(matches!(red, AnsiColor::Red | AnsiColor::BrightRed));
let green = rgb_to_ansi16(RgbColor(0, 200, 0));
assert!(matches!(green, AnsiColor::Green | AnsiColor::BrightGreen));
let blue = rgb_to_ansi16(RgbColor(0, 0, 200));
assert!(matches!(blue, AnsiColor::Blue | AnsiColor::BrightBlue));
// White
let white = rgb_to_ansi16(RgbColor(250, 250, 250));
assert!(matches!(white, AnsiColor::White | AnsiColor::BrightWhite));
}
#[test]
fn test_ansi256_to_ansi16_standard() {
// First 16 colors should map directly
assert_eq!(ansi256_to_ansi16(Ansi256Color(0)), AnsiColor::Black);
assert_eq!(ansi256_to_ansi16(Ansi256Color(1)), AnsiColor::Red);
assert_eq!(ansi256_to_ansi16(Ansi256Color(7)), AnsiColor::White);
assert_eq!(ansi256_to_ansi16(Ansi256Color(15)), AnsiColor::BrightWhite);
}
#[test]
fn test_color_level_ordering() {
assert!(ColorLevel::None < ColorLevel::Basic);
assert!(ColorLevel::Basic < ColorLevel::Ansi256);
assert!(ColorLevel::Ansi256 < ColorLevel::TrueColor);
}
}

View file

@ -0,0 +1,784 @@
//! Project parser-emitted `LinkTarget`s onto rendered display cells.
//!
//! Three coordinate systems are in play:
//!
//! 1. **Source bytes** -- offsets into the raw markdown the parser saw.
//! `LinkTarget::source_range` lives here.
//! 2. **Transformed bytes** -- what `apply_transforms` produces for a *chunk*
//! of source bytes between two render events. In pretty mode the
//! transforms strip `[` and rewrite `](` as ` (`, so transformed bytes
//! do not line up with source bytes.
//! 3. **Display cells** -- `(line_index, display_column)`. What
//! `HyperlinkTarget` exposes for the OSC 8 layer to consume.
//!
//! A chunk's transformed string is split on `\n` into *segments*; one
//! segment becomes one rendered line. A link spanning multiple segments
//! (a wrapped or autolink-bracketed link) produces one `HyperlinkTarget`
//! per segment, all sharing the same `id`.
use crate::buffers::{
LinkTarget, Transform, ceil_char_boundary, floor_char_boundary, unicode_display_width,
};
use crate::output::HyperlinkTarget;
/// One link's projection onto the current chunk's transformed string.
///
/// Returned by `chunk_link_offsets`; bounds are in coordinate system #2
/// (transformed bytes within the chunk), to be mapped onto display cells
/// later by `emit_segment_hyperlinks`.
#[derive(Debug, Clone, Copy)]
pub(crate) struct ChunkLinkRange {
/// Start byte (inclusive) within the chunk's transformed string.
pub(crate) xform_start: usize,
/// End byte (exclusive) within the chunk's transformed string.
pub(crate) xform_end: usize,
/// Index into the `link_targets` slice the caller passed in.
pub(crate) link_idx: usize,
}
/// Project a source byte position into the chunk's transformed coordinate
/// space (system #1 -> system #2). See file docstring.
///
/// Walks `transforms` in source order, accumulating `(to.len() - range.len())`
/// for every transform fully consumed before `src_pos`. When the chunk has
/// no transforms (or `pretty` is false), the caller skips this and uses
/// `src_pos - chunk_start` directly.
///
/// **Invariants assumed of the inputs:**
/// 1. `transforms` is sorted by `range.start` (the existing `apply_transforms`
/// relies on the same invariant; the parser pushes transforms in source
/// order).
/// 2. No transform's source range overlaps the bytes a caller intends to
/// locate — i.e. transforms touch *boundary* characters around link text
/// (the `[` and `](` markers), never the link text itself. All transforms
/// pushed by the parser today (link bracket removal, bullet substitutions)
/// satisfy this; the `debug_assert!` at the call sites in `render_ratatui`
/// enforces it via the cursor invariant.
///
/// **Straddle policy** (when a transform DOES contain `src_pos` despite the
/// invariant above): the source position is clamped to the start of the
/// transform's replacement string. Both endpoints (start/end) clamp the
/// same direction, so a link whose endpoint straddles a transform produces
/// a column range that excludes the straddling bytes. This is intentional
/// rather than precise — a future transform that intentionally rewrites
/// link text should add a typed mapping instead of relying on this clamp.
pub(crate) fn source_to_chunk_offset(
src_pos: usize,
chunk_start: usize,
transforms: &[Transform],
) -> usize {
let mut delta: isize = 0;
for t in transforms {
if t.range.end <= chunk_start {
continue;
}
if t.range.start >= src_pos {
break;
}
let t_src_start = t.range.start.max(chunk_start);
if t.range.end <= src_pos {
let src_len = (t.range.end - t_src_start) as isize;
let dst_len = t.to.len() as isize;
delta += dst_len - src_len;
} else {
// Transform straddles src_pos. See "Straddle policy" above.
debug_assert!(
false,
"source_to_chunk_offset: transform [{}..{}) straddles src_pos {}; \
link text should never overlap a transform. See straddle policy.",
t.range.start, t.range.end, src_pos,
);
let consumed = (src_pos - t_src_start) as isize;
delta -= consumed;
break;
}
}
let raw = (src_pos - chunk_start) as isize + delta;
raw.max(0) as usize
}
/// One `ChunkLinkRange` per link whose source range overlaps
/// `[chunk_start, chunk_end)`.
///
/// `from_idx` is the caller's monotonic cursor: links before this index
/// have already been processed in earlier chunks (see the module doc on
/// the source-order invariant). Returned bounds live in the chunk's
/// transformed coordinate space.
pub(crate) fn chunk_link_offsets(
link_targets: &[LinkTarget],
from_idx: usize,
chunk_start: usize,
chunk_end: usize,
pretty: bool,
transforms: &[Transform],
) -> Vec<ChunkLinkRange> {
let mut out = Vec::new();
for (idx, lt) in link_targets.iter().enumerate().skip(from_idx) {
if lt.source_range.start >= chunk_end {
break;
}
if lt.source_range.end <= chunk_start {
continue;
}
let src_start = lt.source_range.start.max(chunk_start);
let src_end = lt.source_range.end.min(chunk_end);
let (xform_start, xform_end) = if !pretty || transforms.is_empty() {
(src_start - chunk_start, src_end - chunk_start)
} else {
(
source_to_chunk_offset(src_start, chunk_start, transforms),
source_to_chunk_offset(src_end, chunk_start, transforms),
)
};
if xform_end > xform_start {
out.push(ChunkLinkRange {
xform_start,
xform_end,
link_idx: idx,
});
}
}
out
}
/// Push one `HyperlinkTarget` per `ChunkLinkRange` that overlaps this
/// segment (system #2 -> system #3).
///
/// `seg_x_offset` is where this segment starts within the chunk's
/// transformed string; the caller advances it by `segment.len() + 1`
/// per iteration to account for the `\n` consumed by `split('\n')`.
/// `col` is the running display column on the in-progress line.
pub(crate) fn emit_segment_hyperlinks(
chunk_links: &[ChunkLinkRange],
link_targets: &[LinkTarget],
segment: &str,
seg_x_offset: usize,
col: usize,
line_index: usize,
out: &mut Vec<HyperlinkTarget>,
) {
let seg_x_end = seg_x_offset + segment.len();
for clr in chunk_links {
if clr.xform_end <= seg_x_offset || clr.xform_start >= seg_x_end {
continue;
}
let s_in = clr
.xform_start
.saturating_sub(seg_x_offset)
.min(segment.len());
let e_in = (clr.xform_end - seg_x_offset).min(segment.len());
let s_in = floor_char_boundary(segment, s_in);
let e_in = ceil_char_boundary(segment, e_in);
if s_in >= e_in {
continue;
}
let col_start = col + unicode_display_width(&segment[..s_in]);
let col_end = col_start + unicode_display_width(&segment[s_in..e_in]);
let lt = &link_targets[clr.link_idx];
out.push(HyperlinkTarget {
line_index,
column_range: col_start..col_end,
url: lt.url.clone(),
id: lt.id,
});
}
}
#[cfg(test)]
mod hyperlink_tests {
use crate::output::HyperlinkTarget;
use crate::style::test_style;
use crate::{StreamingMarkdownRenderer, render_markdown_ratatui_full};
use pretty_assertions::assert_eq;
use ratatui::text::Line;
fn line_to_string(line: &Line<'static>) -> String {
line.spans.iter().map(|s| s.content.as_ref()).collect()
}
/// Slice the rendered line by display-cell `column_range`. Display
/// width != char count for CJK and other wide characters, so we
/// accumulate width per char until we land inside the requested
/// range.
///
/// Zero-width chars (combining marks, ZWJ, control chars) at the
/// boundary are AMBIGUOUSLY attached: a zero-width char at exactly
/// `col == range.start` is included in the slice (it does not
/// advance `col`), while a zero-width char at `col == range.end`
/// is also included (it satisfies `end <= range.end`). No callers
/// in the test suite use combining marks today; if a future caller
/// needs to disambiguate, change the boundary condition to attach
/// zero-width chars to whichever side semantically owns the grapheme
/// cluster.
fn slice_by_cells(rendered: &str, range: std::ops::Range<usize>) -> String {
use unicode_width::UnicodeWidthChar;
let mut col = 0usize;
let mut out = String::new();
for ch in rendered.chars() {
let w = ch.width().unwrap_or(0);
let end = col + w;
if col >= range.start && end <= range.end {
out.push(ch);
} else if end > range.end {
break;
}
col = end;
}
out
}
/// Find the parser-produced (link-text) hyperlink — by convention the
/// one whose `column_range` slices to `expected_slice` in the
/// rendered output. Since `render_markdown_ratatui_full` now also
/// emits a url_scan target for the pretty-mode `(url)` suffix, tests
/// that previously checked `hyperlinks.len() == 1` must explicitly
/// pick the parser-produced entry.
fn parser_link_text<'a>(
out: &'a crate::output::MarkdownRenderOutput,
expected_slice: &str,
) -> &'a HyperlinkTarget {
out.hyperlinks
.iter()
.find(|h| {
let rendered = line_to_string(&out.lines[h.line_index]);
slice_by_cells(&rendered, h.column_range.clone()) == expected_slice
})
.unwrap_or_else(|| {
panic!(
"expected a hyperlink whose column range covers {expected_slice:?}; got {:?}",
out.hyperlinks,
)
})
}
/// `[link](url)` in pretty mode renders as `link (url)`. The
/// `HyperlinkTarget`'s column range must cover the rendered "link"
/// glyphs (4 cells), not include the stripped `[` or the rewritten ` (`.
#[test]
fn pretty_inline_link_column_range_excludes_brackets() {
let text = "Here is a [link](https://example.com) in text.\n";
let (out, _) = render_markdown_ratatui_full(text, test_style::STYLE, true, None);
// The parser produces one HyperlinkTarget over the link text;
// the url_scan pass produces a second over the `(url)` suffix.
let h = parser_link_text(&out, "link");
assert_eq!(h.url, "https://example.com");
let rendered = line_to_string(&out.lines[h.line_index]);
let slice: String = rendered
.chars()
.skip(h.column_range.start)
.take(h.column_range.len())
.collect();
assert_eq!(
slice, "link",
"column_range should cover only the link text glyphs"
);
}
/// In non-pretty mode the rendered text keeps `[link](url)` literally
/// in place, but the parser's `LinkTarget` source range still points at
/// just `link`. The column range therefore covers `link` (4 cells),
/// shifted by the leading `[` that's now visible in the output.
#[test]
fn non_pretty_inline_link_column_range_covers_text_not_brackets() {
let text = "[link](https://example.com)\n";
let (out, _) = render_markdown_ratatui_full(text, test_style::STYLE, false, None);
// Non-pretty: `[link](url)` is rendered literally; url_scan also
// finds the URL inside `(url)` so two hyperlinks are emitted.
let h = parser_link_text(&out, "link");
let rendered = line_to_string(&out.lines[h.line_index]);
let slice: String = rendered
.chars()
.skip(h.column_range.start)
.take(h.column_range.len())
.collect();
assert_eq!(slice, "link");
}
/// Two links with identical text on the same line MUST produce two
/// distinct `HyperlinkTarget`s with distinct URLs and disjoint column
/// ranges. This is the case the substring approach got wrong (both
/// would resolve to the first occurrence).
#[test]
fn duplicated_link_text_on_one_line_produces_distinct_targets() {
let text = "See [click](https://a.example) and [click](https://b.example) here.\n";
let (out, _) = render_markdown_ratatui_full(text, test_style::STYLE, true, None);
// Two parser-produced link-text hyperlinks (cover "click") plus
// two url_scan-produced hyperlinks for the `(url)` suffixes.
let click_targets: Vec<&HyperlinkTarget> = out
.hyperlinks
.iter()
.filter(|h| {
let rendered = line_to_string(&out.lines[h.line_index]);
let slice: String = rendered
.chars()
.skip(h.column_range.start)
.take(h.column_range.len())
.collect();
slice == "click"
})
.collect();
assert_eq!(
click_targets.len(),
2,
"two parser-produced link-text hyperlinks expected",
);
let urls: Vec<&str> = click_targets.iter().map(|h| h.url.as_str()).collect();
assert_eq!(urls, vec!["https://a.example", "https://b.example"]);
let h0 = click_targets[0];
let h1 = click_targets[1];
assert_eq!(
h0.line_index, h1.line_index,
"both links should be on the same rendered line"
);
assert_ne!(h0.id, h1.id, "ids must differ");
assert!(
h0.column_range.end <= h1.column_range.start,
"column ranges must be disjoint and in order, got {:?} vs {:?}",
h0.column_range,
h1.column_range,
);
}
/// CJK characters in link text consume 2 cells each. The column range
/// must reflect display width, not byte length (`日本語` is 9 bytes / 6 cells).
#[test]
fn cjk_link_uses_display_width_for_column_range() {
let text = "[日本語](https://example.com)\n";
let (out, _) = render_markdown_ratatui_full(text, test_style::STYLE, true, None);
// The parser produces one hyperlink over the CJK link text; the
// url_scan pass produces a second over the `(url)` suffix.
let h = parser_link_text(&out, "日本語");
assert_eq!(
h.column_range.len(),
6,
"三 wide CJK chars -> 6 display cells"
);
}
/// `<https://example.com>` autolink: parser records the source range
/// over the entire `<...>`-bounded text. Because pulldown-cmark fires
/// multiple sub-chunks within a single autolink (the `<`, the URL
/// text, and the `>`), the in-render translation may emit multiple
/// `HyperlinkTarget`s — but they MUST all share the same `id` and
/// `url`, and their column ranges must collectively cover the
/// rendered URL on a single line. This is the same semantic shape
/// as a link that wraps across two rendered lines.
#[test]
fn autolink_emits_grouped_targets_for_same_logical_link() {
let text = "Visit <https://example.com> for info.\n";
let (out, _) = render_markdown_ratatui_full(text, test_style::STYLE, true, None);
assert!(
!out.hyperlinks.is_empty(),
"expected at least one hyperlink"
);
let url = &out.hyperlinks[0].url;
let id = out.hyperlinks[0].id;
let line_index = out.hyperlinks[0].line_index;
for h in &out.hyperlinks {
assert_eq!(&h.url, url, "all autolink fragments share the same URL");
assert_eq!(h.id, id, "all autolink fragments share the same id");
assert_eq!(
h.line_index, line_index,
"autolink stays on one rendered line"
);
}
assert_eq!(url, "https://example.com");
let rendered = line_to_string(&out.lines[line_index]);
let mut covered: Vec<bool> = vec![false; rendered.chars().count()];
for h in &out.hyperlinks {
for col in h.column_range.clone() {
if col < covered.len() {
covered[col] = true;
}
}
}
let covered_text: String = rendered
.chars()
.zip(covered.iter())
.filter_map(|(c, &k)| if k { Some(c) } else { None })
.collect();
assert!(
covered_text.contains("https://example.com"),
"combined column ranges must cover the rendered URL; got covered={:?} on rendered={:?}",
covered_text,
rendered,
);
}
/// Two links with prose between them on the same line: the second
/// link's column range must be measured from the start of the line
/// (i.e. the running `cur_col_in_line` survives across emit chunks).
#[test]
fn two_links_with_prose_between_have_correct_columns() {
let text = "Pre [a](https://a.example) mid [b](https://b.example) post.\n";
let (out, _) = render_markdown_ratatui_full(text, test_style::STYLE, true, None);
// Two parser-produced link-text targets (covering "a" and "b")
// plus two url_scan-produced targets for the `(url)` suffixes.
let h0 = parser_link_text(&out, "a");
let h1 = parser_link_text(&out, "b");
assert_eq!(h0.line_index, h1.line_index);
}
/// Streaming byte-by-byte must produce the same hyperlinks (parser +
/// url_scan) as a single full render. Both code paths run the
/// `url_scan` pass after parsing, so the sets must be equal (modulo
/// ordering, which both paths normalise via the same sort key).
#[test]
fn streaming_byte_by_byte_matches_full_render() {
let text = "# Header\n\nSee [docs](https://example.com/docs) and [api](https://example.com/api).\n\n";
let (full, _) = render_markdown_ratatui_full(text, test_style::STYLE, true, None);
let mut renderer = StreamingMarkdownRenderer::new(test_style::STYLE, true);
for byte in text.as_bytes() {
// Push one byte at a time. The input is pure ASCII so each
// single-byte slice is a valid UTF-8 string.
let buf = [*byte];
let s = std::str::from_utf8(&buf).expect("ascii test input");
renderer.push_and_render(s, None);
}
renderer.finish(None);
let view = renderer.view();
// Compare on `(url, line_index, column_range)` — ids are
// intentionally independent between the two code paths (full
// re-render restarts id counters; streaming preserves continuity).
let extract = |hs: &[HyperlinkTarget]| -> Vec<(String, usize, std::ops::Range<usize>)> {
let mut v: Vec<_> = hs
.iter()
.map(|h| (h.url.clone(), h.line_index, h.column_range.clone()))
.collect();
v.sort_by(|a, b| (a.1, a.2.start).cmp(&(b.1, b.2.start)));
v
};
assert_eq!(
extract(&full.hyperlinks),
extract(view.hyperlinks),
"full-render and streaming+finish must produce the same hyperlinks",
);
}
/// A link whose source bytes straddle the frozen/tail boundary in the
/// streaming renderer must still produce a `HyperlinkTarget` pointing
/// at the right rendered line and columns. In pretty mode,
/// `[my link](url)` renders as `my link (url)`, so the renderer
/// produces 2 targets: one parser-produced over the link text, and
/// one from the url_scan pass over the `(url)` suffix.
#[test]
fn streaming_link_across_chunk_boundaries_resolves_correctly() {
let part1 = "Para one.\n\nSee [my ";
let part2 = "link](https://example.com) here.\n\n";
let full_text = format!("{part1}{part2}");
let (full, _) = render_markdown_ratatui_full(&full_text, test_style::STYLE, true, None);
// Both code paths now run url_scan, so the full-render output
// contains the parser-produced link-text hyperlink and the
// url_scan-produced URL-suffix hyperlink.
assert_eq!(full.hyperlinks.len(), 2);
let expected = parser_link_text(&full, "my link");
let mut renderer = StreamingMarkdownRenderer::new(test_style::STYLE, true);
renderer.push_and_render(part1, None);
renderer.push_and_render(part2, None);
renderer.finish(None);
let view = renderer.view();
assert_eq!(
view.hyperlinks.len(),
2,
"expected 2 hyperlinks (parser link text + URL in pretty-mode suffix)"
);
let got = view
.hyperlinks
.iter()
.find(|h| {
h.column_range == expected.column_range && h.line_index == expected.line_index
})
.expect("parser-produced hyperlink should be present after finish()");
assert_eq!(got.url, expected.url);
assert_eq!(got.line_index, expected.line_index);
assert_eq!(got.column_range, expected.column_range);
}
/// Covers the case where the URL literal itself contains `](` (plus a streaming
/// split inside the tag source). The dest-anchored rfind logic also protects
/// realistic nested-image-in-link cases (see nested_image_in_link_finds_outer_closer).
#[test]
fn dest_url_containing_bracket_paren_with_streaming_split() {
let text = "[t](<u](v>) end\n";
let (full, _) = render_markdown_ratatui_full(text, test_style::STYLE, true, None);
let line0 = line_to_string(&full.lines[0]);
assert!(line0.contains("t (<u](v>)") || line0.contains("t ( <u](v> )"));
let link = full
.hyperlinks
.iter()
.find(|h| h.url.contains("u](v"))
.expect("link");
let slice: String = line0
.chars()
.skip(link.column_range.start)
.take(link.column_range.len())
.collect();
assert_eq!(slice, "t");
let mut r = StreamingMarkdownRenderer::new(test_style::STYLE, true);
r.push_and_render("[t](<u]", None);
r.push_and_render("(v>) end\n", None);
r.finish(None);
let view = r.view();
let view_link = view
.hyperlinks
.iter()
.find(|h| h.url.contains("u](v"))
.expect("view");
assert_eq!(view_link.url, link.url);
assert_eq!(view_link.column_range, link.column_range);
}
/// Realistic trigger for the bug: nested image inside a link (common in LLM
/// output: badges, thumbnails, etc.). The outer closer must be found correctly.
#[test]
fn nested_image_in_link_finds_outer_closer() {
let text = "[![badge](https://img.shields.io/v1.svg)](https://github.com/repo) end\n";
let (full, _) = render_markdown_ratatui_full(text, test_style::STYLE, true, None);
let link = full
.hyperlinks
.iter()
.find(|h| h.url.contains("github.com/repo"))
.expect("outer link should target repo URL");
assert!(
!link.url.contains("shields.io"),
"should not pick the inner image URL"
);
}
/// Markdown links inside table cells must produce `HyperlinkTarget`s
/// the same way links inside paragraphs do — otherwise the pager's OSC 8
/// overlay never learns about them and the link is not clickable and
/// not styled. Before the fix, `Tag::Link` events inside table cells
/// were swallowed by the table state machine, leaving the link text
/// as plain text in `StyledCell::spans` with no URL attached.
///
/// This test asserts:
/// 1. A `HyperlinkTarget` is emitted with the cell's URL.
/// 2. Its `column_range` covers the rendered link text glyphs
/// (not the brackets, not the URL).
/// 3. The link text span carries the same `link_text` styling
/// paragraph links get (bold in the test style).
#[test]
fn link_inside_table_cell_emits_hyperlink_and_styling() {
let text = "\
| Name | Link |
|------|------|
| Foo | [click](https://example.com) |
";
let (out, _) = render_markdown_ratatui_full(text, test_style::STYLE, true, None);
// (1) Hyperlink present with the right URL.
let link = out
.hyperlinks
.iter()
.find(|h| h.url == "https://example.com")
.expect("table cell link should produce a HyperlinkTarget");
// (2) Column range covers only the rendered "click" glyphs.
let rendered = line_to_string(&out.lines[link.line_index]);
let slice: String = rendered
.chars()
.skip(link.column_range.start)
.take(link.column_range.len())
.collect();
assert_eq!(
slice, "click",
"column_range should cover only the link text glyphs in the cell, \
got slice={slice:?} from rendered={rendered:?}"
);
// (3) The link text span carries `link_text` styling (bold in the
// test style). The cell wrapper splits the cell into multiple
// spans; find the span whose content is "click".
let cell_line = &out.lines[link.line_index];
let click_span = cell_line
.spans
.iter()
.find(|s| s.content.as_ref() == "click")
.expect("expected a span containing exactly the link text");
assert!(
click_span
.style
.add_modifier
.contains(ratatui::style::Modifier::BOLD),
"link text span inside the cell should carry link_text styling \
(bold in test_style), got style={:?}",
click_span.style,
);
}
/// Paragraph links must keep the `link_text` foreground color even when
/// the `text` style sets its own foreground. Previously the parser
/// pushed `ms.text` as a highlight after the link_text highlight whenever
/// no `Heading`/`Emphasis`/`Strong`/`Strikethrough` ancestor was present
/// — and `merge_styles` lets the later fg color win, so `ms.text`'s color
/// silently clobbered `link_text`'s color on plain paragraph links.
/// Regression: extending `ancestor_styles` to recognise `Link`/`Image`
/// keeps the link_text color intact.
#[test]
fn paragraph_link_keeps_link_text_fg_over_default_text_fg() {
use crate::MarkdownStyle;
use anstyle::{AnsiColor, Color, Style as AStyle};
let style = MarkdownStyle {
text: AStyle::new().fg_color(Some(Color::Ansi(AnsiColor::Red))),
link_text: AStyle::new()
.fg_color(Some(Color::Ansi(AnsiColor::Blue)))
.underline(),
..test_style::STYLE
};
let text = "Hello [click](https://x.com) world.\n";
let (out, _) = render_markdown_ratatui_full(text, style, true, None);
let line = &out.lines[0];
let click_span = line
.spans
.iter()
.find(|s| s.content == "click")
.expect("expected a span containing the link text");
assert_eq!(
click_span.style.fg,
Some(ratatui::style::Color::Blue),
"link text fg must be link_text's blue, not ms.text's red; got {:?}",
click_span.style,
);
assert!(
click_span
.style
.add_modifier
.contains(ratatui::style::Modifier::UNDERLINED),
"link text must remain underlined; got {:?}",
click_span.style,
);
}
/// Links wrapped in inline formatting (`**[click](url)**`,
/// `[**click**](url)`, `*[click](url)*`, `~~[click](url)~~`) must keep
/// the `link_text` foreground while still gaining the formatting effect. The
/// Strong/Emphasis ancestor's inner style carries the theme's default
/// text fg and its highlight is pushed at `Event::Text` time — *after*
/// the `link_text` highlight from `Tag::Link` start — so merge_styles'
/// last-wins fg ordering let it clobber the link color. Regression:
/// inline-format ancestors contribute effects only inside a link.
#[test]
fn formatted_link_keeps_link_fg_and_gains_effects() {
use crate::MarkdownStyle;
use anstyle::{AnsiColor, Color, Style as AStyle};
use ratatui::style::Modifier;
let style = MarkdownStyle {
text: AStyle::new().fg_color(Some(Color::Ansi(AnsiColor::Red))),
strong_inner: AStyle::new()
.fg_color(Some(Color::Ansi(AnsiColor::Red)))
.bold(),
emphasis_inner: AStyle::new()
.fg_color(Some(Color::Ansi(AnsiColor::Red)))
.italic(),
strikethrough_inner: AStyle::new()
.fg_color(Some(Color::Ansi(AnsiColor::Red)))
.strikethrough(),
link_text: AStyle::new()
.fg_color(Some(Color::Ansi(AnsiColor::Blue)))
.underline(),
..test_style::STYLE
};
for (md, effect) in [
("**[click](https://x.com)** end.\n", Modifier::BOLD),
("[**click**](https://x.com) end.\n", Modifier::BOLD),
("*[click](https://x.com)* end.\n", Modifier::ITALIC),
("~~[click](https://x.com)~~ end.\n", Modifier::CROSSED_OUT),
] {
let (out, _) = render_markdown_ratatui_full(md, style, true, None);
let line = &out.lines[0];
let click_span = line
.spans
.iter()
.find(|s| s.content == "click")
.unwrap_or_else(|| panic!("expected a span for the link text in {md:?}"));
assert_eq!(
click_span.style.fg,
Some(ratatui::style::Color::Blue),
"in {md:?} the link text fg must stay link_text's blue, \
not strong/emphasis_inner's red; got {:?}",
click_span.style,
);
assert!(
click_span.style.add_modifier.contains(effect),
"in {md:?} the link text must gain {effect:?}; got {:?}",
click_span.style,
);
assert!(
click_span.style.add_modifier.contains(Modifier::UNDERLINED),
"in {md:?} the link text must remain underlined; got {:?}",
click_span.style,
);
}
}
// Soft break inside link text: the link stays on one rendered line
// and the fragments sharing this link's id cover exactly "link text".
// SoftBreak splits a link into multiple HyperlinkTargets with the
// same id (OSC 8 wrapped-link grouping), so we check the union.
#[test]
fn soft_break_inside_link_text_preserves_column_range() {
let md = "foo [link\ntext](https://example.com) bar";
let (out, _) = render_markdown_ratatui_full(md, test_style::STYLE, true, None);
assert_eq!(out.lines.len(), 1, "{:?}", out.lines);
// url_scan also emits a target over the `(url)` suffix; filter to the parser id.
let parser_id = out
.hyperlinks
.iter()
.find(|h| h.url == "https://example.com")
.expect("at least one hyperlink target")
.id;
let mut fragments: Vec<&HyperlinkTarget> = out
.hyperlinks
.iter()
.filter(|h| h.id == parser_id && h.url == "https://example.com")
.collect();
fragments.sort_by_key(|h| h.column_range.start);
for f in &fragments {
assert_eq!(f.line_index, 0, "{f:?}");
}
for w in fragments.windows(2) {
assert_eq!(
w[0].column_range.end, w[1].column_range.start,
"gap: {:?} -> {:?}",
w[0].column_range, w[1].column_range,
);
}
let union_start = fragments.first().unwrap().column_range.start;
let union_end = fragments.last().unwrap().column_range.end;
let rendered = line_to_string(&out.lines[0]);
assert_eq!(
slice_by_cells(&rendered, union_start..union_end),
"link text",
"rendered={rendered:?} union={union_start}..{union_end}",
);
assert_eq!(
union_end - union_start,
crate::buffers::unicode_display_width("link text"),
);
}
}

View file

@ -0,0 +1,532 @@
//! Core renderer: sequences, commands, scripts, fractions, accents.
use std::fmt::Write as _;
use super::cursor::Cursor;
use super::environments::render_environment;
use super::math_box::MathBox;
use super::symbols::{
map_mathbb, map_mathbf, map_mathcal, map_mathfrak, symbol, to_subscript, to_superscript,
};
use super::{MAX_DEPTH, Mode};
/// Render an atom's source to a flat (single-line) Unicode string.
///
/// Atoms are arguments to commands (fraction sides, script bodies, accent
/// targets); they always render flat — multi-row content inside them joins
/// with `; `.
pub(super) fn render_atom(atom: &str, depth: usize, mode: Mode) -> String {
let mut cursor = Cursor::new(atom);
let mut out = MathBox::new(true);
render_sequence(&mut cursor, &mut out, depth + 1, mode, None);
out.into_lines().concat()
}
/// Core renderer: walks `cursor`, appending Unicode to `out`.
///
/// `stop_at` optionally terminates the sequence at an unbalanced `}` (used
/// when rendering inside a group whose `{` was consumed by the caller).
pub(super) fn render_sequence(
cursor: &mut Cursor<'_>,
out: &mut MathBox,
depth: usize,
mode: Mode,
stop_at: Option<char>,
) {
while let Some(ch) = cursor.peek() {
if Some(ch) == stop_at {
cursor.bump();
return;
}
match ch {
'\\' => {
cursor.bump();
render_command(cursor, out, depth, mode);
}
'{' => {
cursor.bump();
if depth >= MAX_DEPTH {
// Too deep: render the group body flat, without recursing.
out.push_str(cursor.read_group_body());
} else {
// Render the group body into the same box so environments
// inside groups keep their 2D layout.
let body = cursor.read_group_body();
let mut sub = Cursor::new(body);
render_sequence(&mut sub, out, depth + 1, mode, None);
}
}
'}' => {
// Unbalanced closing brace: drop it.
cursor.bump();
}
'^' => {
cursor.bump();
render_script(cursor, out, depth, mode, Script::Super);
}
'_' => {
cursor.bump();
render_script(cursor, out, depth, mode, Script::Sub);
}
'~' => {
cursor.bump();
out.push(' ');
}
'&' => {
// Alignment marker outside an environment: drop.
cursor.bump();
}
'$' => {
// Stray math delimiter inside math: drop.
cursor.bump();
}
'-' if mode == Mode::Math => {
cursor.bump();
out.push('');
}
'\'' if mode == Mode::Math => {
cursor.bump();
out.push('');
}
c if c.is_whitespace() => {
cursor.skip_ws();
// TeX collapses whitespace runs (including newlines) to
// nothing semantically; keep a single space for readability.
if !out.at_line_start() && !out.ends_with_space() {
out.push(' ');
}
}
c => {
cursor.bump();
out.push(c);
}
}
}
}
/// Which script position is being rendered.
#[derive(Copy, Clone, PartialEq, Eq)]
enum Script {
Super,
Sub,
}
/// Render `^atom` / `_atom` using Unicode script chars when every char of
/// the rendered atom has a script form; otherwise `^x` / `^(...)` fallback.
///
/// Word-like atoms take the fallback even when fully mappable: labels such as
/// `p_{\text{torso}}` or `x_{max}` would otherwise become long modifier-letter
/// runs (`pₜₒᵣₛₒ`) that are hard to read and render with visible gaps in
/// terminal fonts lacking those glyphs. Index-like atoms (`x_{ij}`,
/// `T_{i+1}`, `n^{th}`) keep the compact Unicode form.
fn render_script(
cursor: &mut Cursor<'_>,
out: &mut MathBox,
depth: usize,
mode: Mode,
kind: Script,
) {
let Some(atom) = cursor.read_atom() else {
out.push(match kind {
Script::Super => '^',
Script::Sub => '_',
});
return;
};
let rendered = render_atom(atom, depth, mode);
let mapped: Option<String> = if script_atom_is_wordlike(atom, &rendered) {
None
} else {
rendered
.chars()
.map(|c| match kind {
Script::Super => to_superscript(c),
Script::Sub => to_subscript(c),
})
.collect()
};
match mapped {
Some(s) if !s.is_empty() => out.push_str(&s),
_ => {
out.push(match kind {
Script::Super => '^',
Script::Sub => '_',
});
if rendered.chars().count() > 1 {
let _ = write!(out, "({rendered})");
} else {
out.push_str(&rendered);
}
}
}
}
/// `true` if a script atom is a word-like label rather than indices.
///
/// Two signals, checked on the atom *source* and its rendered form:
///
/// - the source routes through a text-family command (`\text{…}`, `\mathrm{…}`,
/// `\operatorname{…}`, …): the author explicitly marked the content as a
/// word;
/// - the rendered form contains a run of 3+ ASCII letters: multi-letter runs
/// read as words (`max`, `torso`), while 12 letter runs are index
/// juxtapositions (`ij`, `th`) that stay compact.
fn script_atom_is_wordlike(atom: &str, rendered: &str) -> bool {
// `\text` also catches `\textrm`/`\textbf`/`\textit`/`\textsf`/`\texttt`/
// `\textnormal` by prefix; `\math…` variants and box commands likewise.
const TEXT_MARKERS: [&str; 8] = [
"\\text",
"\\mathrm",
"\\mathsf",
"\\mathtt",
"\\mathit",
"\\operatorname",
"\\mbox",
"\\hbox",
];
if TEXT_MARKERS.iter().any(|m| atom.contains(m)) {
return true;
}
let mut run = 0usize;
for c in rendered.chars() {
if c.is_ascii_alphabetic() {
run += 1;
if run >= 3 {
return true;
}
} else {
run = 0;
}
}
false
}
/// Render a `\command` whose backslash was already consumed.
fn render_command(cursor: &mut Cursor<'_>, out: &mut MathBox, depth: usize, mode: Mode) {
let name = cursor.read_command_name();
match name {
// ── Structure ────────────────────────────────────────────────────
"" => out.push('\\'),
"\\" => out.push('\n'),
"begin" => render_environment(cursor, out, depth, mode),
"end" => {
// Stray \end without matching \begin: drop its argument.
let _ = take_brace_arg(cursor);
}
"left" | "right" => {
// Keep the delimiter that follows; `.` means "no delimiter".
cursor.skip_ws();
match cursor.peek() {
Some('.') => {
cursor.bump();
}
Some('\\') => {
cursor.bump();
render_command(cursor, out, depth, mode);
}
Some(c) => {
cursor.bump();
out.push(c);
}
None => {}
}
}
// ── Fractions / binomials / roots ────────────────────────────────
"frac" | "dfrac" | "tfrac" | "cfrac" => {
let num = take_brace_arg(cursor).map(|a| render_atom(a, depth, mode));
let den = take_brace_arg(cursor).map(|a| render_atom(a, depth, mode));
match (num, den) {
(Some(n), Some(d)) => out.push_str(&format_fraction(&n, &d)),
(Some(n), None) => out.push_str(&n),
_ => {}
}
}
"binom" | "tbinom" | "dbinom" => {
let n = take_brace_arg(cursor).map(|a| render_atom(a, depth, mode));
let k = take_brace_arg(cursor).map(|a| render_atom(a, depth, mode));
if let (Some(n), Some(k)) = (n, k) {
let _ = write!(out, "C({n}, {k})");
}
}
"sqrt" => {
cursor.skip_ws();
let index = if cursor.peek() == Some('[') {
cursor.bump();
let start = cursor.pos;
while let Some(c) = cursor.peek() {
if c == ']' {
break;
}
cursor.bump();
}
let idx = &cursor.src[start..cursor.pos];
cursor.bump(); // consume `]`
Some(render_atom(idx, depth, mode))
} else {
None
};
let radical = match index.as_deref() {
None | Some("2") => "",
Some("3") => "",
Some("4") => "",
Some(other) => {
// ⁿ√ style prefix for other indices.
let sup: Option<String> = other.chars().map(to_superscript).collect();
out.push_str(&sup.unwrap_or_else(|| format!("({other})")));
""
}
};
out.push_str(radical);
if let Some(arg) = cursor.read_atom() {
let rendered = render_atom(arg, depth, mode);
// Parenthesize any multi-char radicand: `√ab` would read as
// `(√a)b`.
if rendered.chars().count() > 1 {
let _ = write!(out, "({rendered})");
} else {
out.push_str(&rendered);
}
}
}
// ── Boxes (frame dropped; content preserved) ─────────────────────
"boxed" => {
if let Some(arg) = take_brace_arg(cursor) {
out.push_str(&render_atom(arg, depth, mode));
}
}
"fbox" | "framebox" => {
if let Some(arg) = take_brace_arg(cursor) {
out.push_str(&render_atom(arg, depth, Mode::Text));
}
}
// ── Text / alphabets ─────────────────────────────────────────────
"text" | "textrm" | "textit" | "textbf" | "textsf" | "texttt" | "textnormal" | "mbox"
| "hbox" => {
if let Some(arg) = take_brace_arg(cursor) {
out.push_str(&render_atom(arg, depth, Mode::Text));
}
}
"mathrm" | "operatorname" | "mathit" | "mathsf" | "mathtt" | "mathnormal" => {
if let Some(arg) = take_brace_arg(cursor) {
out.push_str(&render_atom(arg, depth, Mode::Text));
}
}
"mathbb" => render_mapped_alphabet(cursor, out, depth, mode, map_mathbb),
"mathcal" | "mathscr" => render_mapped_alphabet(cursor, out, depth, mode, map_mathcal),
"mathfrak" => render_mapped_alphabet(cursor, out, depth, mode, map_mathfrak),
"mathbf" | "boldsymbol" | "bm" | "bold" => {
render_mapped_alphabet(cursor, out, depth, mode, map_mathbf)
}
// ── Accents (combining marks) ────────────────────────────────────
"hat" | "widehat" => render_accent(cursor, out, depth, mode, '\u{0302}'),
"bar" | "overline" => render_accent(cursor, out, depth, mode, '\u{0304}'),
"tilde" | "widetilde" => render_accent(cursor, out, depth, mode, '\u{0303}'),
"vec" => render_accent(cursor, out, depth, mode, '\u{20D7}'),
"dot" => render_accent(cursor, out, depth, mode, '\u{0307}'),
"ddot" => render_accent(cursor, out, depth, mode, '\u{0308}'),
"check" => render_accent(cursor, out, depth, mode, '\u{030C}'),
"breve" => render_accent(cursor, out, depth, mode, '\u{0306}'),
"acute" => render_accent(cursor, out, depth, mode, '\u{0301}'),
"grave" => render_accent(cursor, out, depth, mode, '\u{0300}'),
"mathring" => render_accent(cursor, out, depth, mode, '\u{030A}'),
"underline" => render_accent(cursor, out, depth, mode, '\u{0332}'),
// ── Negation ─────────────────────────────────────────────────────
"not" => {
if let Some(atom) = cursor.read_atom() {
let rendered = render_atom(atom, depth, mode);
match rendered.as_str() {
"" => out.push('∉'),
"=" => out.push('≠'),
"<" => out.push('≮'),
">" => out.push('≯'),
"" => out.push('≢'),
"" => out.push('⊄'),
"" => out.push('⊈'),
"" => out.push('∄'),
other => {
out.push_str(other);
// Combining long solidus overlay on the last char.
if !other.is_empty() {
out.push('\u{0338}');
}
}
}
}
}
// ── Decorations rendered as base + script ────────────────────────
"overset" | "stackrel" => {
let over = take_brace_arg(cursor).map(|a| render_atom(a, depth, mode));
let base = take_brace_arg(cursor).map(|a| render_atom(a, depth, mode));
if let (Some(over), Some(base)) = (over, base) {
out.push_str(&base);
let sup: Option<String> = over.chars().map(to_superscript).collect();
match sup {
Some(s) if !s.is_empty() => out.push_str(&s),
_ => {}
}
}
}
"underset" => {
let under = take_brace_arg(cursor).map(|a| render_atom(a, depth, mode));
let base = take_brace_arg(cursor).map(|a| render_atom(a, depth, mode));
if let (Some(under), Some(base)) = (under, base) {
out.push_str(&base);
let sub: Option<String> = under.chars().map(to_subscript).collect();
match sub {
Some(s) if !s.is_empty() => out.push_str(&s),
_ => {}
}
}
}
// ── Modular arithmetic ───────────────────────────────────────────
"pmod" => {
if let Some(arg) = take_brace_arg(cursor) {
if !out.at_line_start() && !out.ends_with_space() {
out.push(' ');
}
let _ = write!(out, "(mod {})", render_atom(arg, depth, mode));
}
}
"bmod" => {
if !out.at_line_start() && !out.ends_with_space() {
out.push(' ');
}
out.push_str("mod ");
}
// ── Spacing ──────────────────────────────────────────────────────
"," | ";" | ":" | ">" | " " | "space" | "thinspace" | "medspace" | "thickspace"
| "enspace" => {
if !out.at_line_start() && !out.ends_with_space() {
out.push(' ');
}
}
"quad" => out.push_str(" "),
"qquad" => out.push_str(" "),
"!" | "negthinspace" | "negmedspace" | "negthickspace" => {}
// ── No-ops (sizing/styling/structure hints) ──────────────────────
"limits" | "nolimits" | "displaystyle" | "textstyle" | "scriptstyle"
| "scriptscriptstyle" | "big" | "Big" | "bigg" | "Bigg" | "bigl" | "Bigl" | "biggl"
| "Biggl" | "bigr" | "Bigr" | "biggr" | "Biggr" | "bigm" | "Bigm" | "biggm" | "Biggm"
| "mathstrut" | "strut" | "allowbreak" | "nonumber" | "notag" | "mathopen"
| "mathclose" | "mathbin" | "mathrel" | "mathord" | "mathpunct" | "mathinner"
| "mathop" | "ensuremath" | "label" | "tag" => {
// \label/\tag carry non-visual arguments: drop them.
if matches!(name, "label" | "tag") {
let _ = take_brace_arg(cursor);
}
}
// ── Symbol table ─────────────────────────────────────────────────
_ => {
if let Some(sym) = symbol(name) {
out.push_str(sym);
} else {
// Unknown command: keep its name as plain text.
out.push_str(name);
}
}
}
}
/// Consume `{...}` (after optional whitespace) and return the body source.
pub(super) fn take_brace_arg<'a>(cursor: &mut Cursor<'a>) -> Option<&'a str> {
cursor.skip_ws();
if cursor.peek() == Some('{') {
cursor.bump();
Some(cursor.read_group_body())
} else {
None
}
}
/// `true` if a fraction/root operand needs parentheses for readability.
fn needs_parens(s: &str) -> bool {
s.chars().count() > 1 && s.contains([' ', '+', '', '-', '=', '/'])
}
/// Format `num/den`, mapping common numeric fractions to vulgar fractions.
fn format_fraction(num: &str, den: &str) -> String {
let vulgar = match (num, den) {
("1", "2") => Some('½'),
("1", "3") => Some('⅓'),
("2", "3") => Some('⅔'),
("1", "4") => Some('¼'),
("3", "4") => Some('¾'),
("1", "5") => Some('⅕'),
("2", "5") => Some('⅖'),
("3", "5") => Some('⅗'),
("4", "5") => Some('⅘'),
("1", "6") => Some('⅙'),
("5", "6") => Some('⅚'),
("1", "7") => Some('⅐'),
("1", "8") => Some('⅛'),
("3", "8") => Some('⅜'),
("5", "8") => Some('⅝'),
("7", "8") => Some('⅞'),
("1", "9") => Some('⅑'),
("1", "10") => Some('⅒'),
_ => None,
};
if let Some(v) = vulgar {
return v.to_string();
}
let n = if needs_parens(num) {
format!("({num})")
} else {
num.to_string()
};
let d = if needs_parens(den) {
format!("({den})")
} else {
den.to_string()
};
format!("{n}/{d}")
}
/// Render an alphabet-mapping command (`\mathbb{R}` etc.): map chars that
/// have a styled form, keep the rest as rendered.
fn render_mapped_alphabet(
cursor: &mut Cursor<'_>,
out: &mut MathBox,
depth: usize,
mode: Mode,
map: fn(char) -> Option<char>,
) {
let Some(atom) = cursor.read_atom() else {
return;
};
let rendered = render_atom(atom, depth, mode);
for c in rendered.chars() {
out.push(map(c).unwrap_or(c));
}
}
/// Render an accent command by appending a combining mark to each char of
/// the argument.
fn render_accent(
cursor: &mut Cursor<'_>,
out: &mut MathBox,
depth: usize,
mode: Mode,
combining: char,
) {
let Some(atom) = cursor.read_atom() else {
return;
};
let rendered = render_atom(atom, depth, mode);
for c in rendered.chars() {
out.push(c);
if !c.is_whitespace() {
out.push(combining);
}
}
}

View file

@ -0,0 +1,99 @@
//! Byte cursor over TeX source.
/// Byte cursor over the TeX source.
pub(super) struct Cursor<'a> {
pub(super) src: &'a str,
pub(super) pos: usize,
}
impl<'a> Cursor<'a> {
pub(super) fn new(src: &'a str) -> Self {
Self { src, pos: 0 }
}
pub(super) fn peek(&self) -> Option<char> {
self.src[self.pos..].chars().next()
}
pub(super) fn bump(&mut self) -> Option<char> {
let ch = self.peek()?;
self.pos += ch.len_utf8();
Some(ch)
}
/// Consume `\command` (alphabetic name) or `\<single char>`; the leading
/// backslash must already be consumed. Returns the command name.
///
/// Unlike TeX we do NOT consume trailing whitespace: the caller's
/// whitespace collapsing keeps `\to 0` rendering as `→ 0`.
pub(super) fn read_command_name(&mut self) -> &'a str {
let start = self.pos;
match self.peek() {
Some(c) if c.is_ascii_alphabetic() => {
while matches!(self.peek(), Some(c) if c.is_ascii_alphabetic()) {
self.bump();
}
&self.src[start..self.pos]
}
Some(_) => {
self.bump();
&self.src[start..self.pos]
}
None => "",
}
}
/// Skip whitespace (TeX collapses it; meaning comes from commands).
pub(super) fn skip_ws(&mut self) {
while matches!(self.peek(), Some(c) if c.is_whitespace()) {
self.bump();
}
}
/// Read a balanced `{...}` group body, assuming `{` was already consumed.
/// Returns the inner source (without braces). Unbalanced input returns
/// the remainder of the source.
pub(super) fn read_group_body(&mut self) -> &'a str {
let start = self.pos;
let mut depth = 1usize;
while let Some(ch) = self.bump() {
match ch {
'\\' => {
// Skip escaped char so `\{`/`\}` don't affect depth.
self.bump();
}
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
return &self.src[start..self.pos - 1];
}
}
_ => {}
}
}
&self.src[start..self.pos]
}
/// Read the next "atom": a `{...}` group body, a `\command` (returned
/// with backslash), or a single char. Skips leading whitespace.
pub(super) fn read_atom(&mut self) -> Option<&'a str> {
self.skip_ws();
let start = self.pos;
match self.peek()? {
'{' => {
self.bump();
Some(self.read_group_body())
}
'\\' => {
self.bump();
self.read_command_name();
Some(&self.src[start..self.pos])
}
_ => {
self.bump();
Some(&self.src[start..self.pos])
}
}
}
}

View file

@ -0,0 +1,325 @@
//! `\\begin{env}...\\end{env}` environments: matrices, cases, alignments.
use crate::buffers::unicode_display_width;
use super::Mode;
use super::commands::{render_atom, take_brace_arg};
use super::cursor::Cursor;
use super::math_box::MathBox;
/// Render `\begin{env}...\end{env}`. The `\begin` name was already consumed.
pub(super) fn render_environment(
cursor: &mut Cursor<'_>,
out: &mut MathBox,
depth: usize,
mode: Mode,
) {
let Some(env_name) = take_brace_arg(cursor) else {
return;
};
let env_name = env_name.trim().trim_end_matches('*');
// Capture body source until the matching `\end{name}`, tracking nesting
// of same-named environments. Scans raw source from the cursor.
let body_start = cursor.pos;
let mut body_end = cursor.src.len();
let mut resume = cursor.src.len();
let mut nest = 0usize;
let mut search = cursor.pos;
while search < cursor.src.len() {
let rest = &cursor.src[search..];
let Some(rel) = rest.find('\\') else {
break;
};
let bs_pos = search + rel;
let after_bs = &cursor.src[bs_pos + 1..];
let kw_len = if command_at(after_bs, "begin") {
"begin".len()
} else if command_at(after_bs, "end") {
"end".len()
} else {
// Not begin/end: skip the backslash and the char after it (so
// `\\` and `\{` never confuse the scan).
let skip = after_bs.chars().next().map_or(0, char::len_utf8);
search = bs_pos + 1 + skip.max(1);
continue;
};
let is_begin = kw_len == "begin".len();
let mut probe = Cursor {
src: cursor.src,
pos: bs_pos + 1 + kw_len,
};
let arg = take_brace_arg(&mut probe).map(|a| a.trim().trim_end_matches('*'));
if arg == Some(env_name) {
if is_begin {
nest += 1;
} else if nest == 0 {
body_end = bs_pos;
resume = probe.pos;
break;
} else {
nest -= 1;
}
}
search = probe.pos.max(bs_pos + 1 + kw_len);
}
cursor.pos = resume;
let mut body = &cursor.src[body_start..body_end.min(cursor.src.len())];
// Optional column spec for array environments: `\begin{array}{ll}`.
if env_name == "array" || env_name == "alignat" {
let mut probe = Cursor::new(body);
probe.skip_ws();
if probe.peek() == Some('{') {
probe.bump();
let _ = probe.read_group_body();
body = &body[probe.pos..];
}
}
let rows = env_rows_to_strings(body, env_name, out.flat, depth, mode);
out.hcat_rows(rows);
}
/// `true` if `rest` starts with command word `word` NOT followed by another
/// ASCII letter (so `\endx` is not mistaken for `\end`).
fn command_at(rest: &str, word: &str) -> bool {
rest.starts_with(word)
&& !rest[word.len()..]
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic())
}
/// Split an environment body into rows (`\\`) and cells (`&`) at brace and
/// environment depth 0, render each cell, then lay the rows out according to
/// the environment. Returns one string per visual row; the caller attaches
/// them as a box. In `flat` mode, matrix/cases environments render as a
/// single row with `; ` between matrix rows.
fn env_rows_to_strings(
body: &str,
env_name: &str,
flat: bool,
depth: usize,
mode: Mode,
) -> Vec<String> {
let mut rows: Vec<Vec<String>> = Vec::new();
let mut row: Vec<String> = Vec::new();
let mut cell_start = 0usize;
let mut brace_depth = 0usize;
let mut env_depth = 0usize;
let bytes = body.as_bytes();
let mut i = 0usize;
while i < bytes.len() {
match bytes[i] {
b'\\' => {
if bytes.get(i + 1) == Some(&b'\\') {
if brace_depth == 0 && env_depth == 0 {
row.push(body[cell_start..i].to_string());
rows.push(std::mem::take(&mut row));
i += 2;
cell_start = i;
continue;
}
i += 2;
continue;
}
let rest = &body[i + 1..];
if command_at(rest, "begin") {
env_depth += 1;
} else if command_at(rest, "end") {
env_depth = env_depth.saturating_sub(1);
}
// Skip the backslash plus the char after it so escaped
// delimiters (`\&`, `\{`, `\}`) never affect depth/splits.
let skip = rest.chars().next().map_or(0, char::len_utf8);
i += 1 + skip.max(1);
continue;
}
b'{' => brace_depth += 1,
b'}' => brace_depth = brace_depth.saturating_sub(1),
b'&' if brace_depth == 0 && env_depth == 0 => {
row.push(body[cell_start..i].to_string());
cell_start = i + 1;
}
_ => {}
}
i += 1;
}
row.push(body[cell_start.min(bytes.len())..].to_string());
rows.push(row);
// Render each cell, drop fully-empty rows.
let mut rendered_rows: Vec<Vec<String>> = rows
.into_iter()
.map(|cells| {
cells
.into_iter()
.map(|c| render_atom(c.trim(), depth, mode).trim().to_string())
.collect::<Vec<_>>()
})
.collect();
rendered_rows.retain(|cells| cells.iter().any(|c| !c.is_empty()));
if rendered_rows.is_empty() {
return Vec::new();
}
let is_matrix = matches!(
env_name,
"matrix"
| "pmatrix"
| "bmatrix"
| "Bmatrix"
| "vmatrix"
| "Vmatrix"
| "smallmatrix"
| "array"
);
let n_rows = rendered_rows.len();
if is_matrix {
// Flat (inline) mode: one row, single delimiter pair, rows joined
// with `; ` — `(1 2; 3 4)`.
if flat {
let inner = rendered_rows
.iter()
.map(|cells| cells.join(" "))
.collect::<Vec<_>>()
.join("; ");
// Single-row delimiter pair; plain `matrix` has none (' ').
let (l, r) = matrix_delims(env_name, 0, 1);
let mut s = String::new();
if l != ' ' {
s.push(l);
}
s.push_str(&inner);
if r != ' ' {
s.push(r);
}
return vec![s];
}
// Pad columns to equal width so rows align.
let n_cols = rendered_rows.iter().map(Vec::len).max().unwrap_or(0);
let mut widths = vec![0usize; n_cols];
for cells in &rendered_rows {
for (i, cell) in cells.iter().enumerate() {
widths[i] = widths[i].max(unicode_display_width(cell));
}
}
rendered_rows
.iter()
.enumerate()
.map(|(row_idx, cells)| {
let mut content = String::new();
for (i, cell) in cells.iter().enumerate() {
if i > 0 {
content.push_str(" ");
}
content.push_str(cell);
if i + 1 < cells.len() {
let pad = widths[i].saturating_sub(unicode_display_width(cell));
content.push_str(&" ".repeat(pad));
}
}
let (l, r) = matrix_delims(env_name, row_idx, n_rows);
format!("{l}{content}{r}")
})
.collect()
} else if env_name == "cases" {
if flat {
let inner = rendered_rows
.iter()
.map(|cells| cells.join(" "))
.collect::<Vec<_>>()
.join("; ");
return vec![format!("{{{inner}}}")];
}
rendered_rows
.iter()
.enumerate()
.map(|(row_idx, cells)| {
let brace = cases_brace(row_idx, n_rows);
format!("{brace} {}", cells.join(" "))
})
.collect()
} else {
// aligned/align/gather/split/equation/…: `&` is an invisible
// alignment marker; rejoin cells with a single space. One string per
// row; the caller's box attachment (or flat `; ` join) handles the
// rest.
rendered_rows
.iter()
.map(|cells| {
let mut s = cells
.iter()
.filter(|c| !c.is_empty())
.cloned()
.collect::<Vec<_>>()
.join(" ");
// Collapse any double spaces introduced around markers.
while s.contains(" ") {
s = s.replace(" ", " ");
}
s
})
.collect()
}
}
/// Per-row delimiters for matrix-family environments.
fn matrix_delims(env: &str, row: usize, n_rows: usize) -> (char, char) {
let single = n_rows == 1;
let first = row == 0;
let last = row + 1 == n_rows;
match env {
"pmatrix" => {
if single {
('(', ')')
} else if first {
('⎛', '⎞')
} else if last {
('⎝', '⎠')
} else {
('⎜', '⎟')
}
}
"bmatrix" | "array" => {
if single {
('[', ']')
} else if first {
('⎡', '⎤')
} else if last {
('⎣', '⎦')
} else {
('⎢', '⎥')
}
}
"Bmatrix" => {
if single {
('{', '}')
} else if first {
('⎧', '⎫')
} else if last {
('⎩', '⎭')
} else {
('⎨', '⎬')
}
}
"vmatrix" | "Vmatrix" => ('│', '│'),
_ => (' ', ' '),
}
}
/// Left-brace column char for `cases` rows.
fn cases_brace(row: usize, n_rows: usize) -> char {
if n_rows == 1 {
'{'
} else if row == 0 {
'⎧'
} else if row + 1 == n_rows {
'⎩'
} else if row == n_rows / 2 {
'⎨'
} else {
'⎪'
}
}

View file

@ -0,0 +1,156 @@
//! Two-dimensional math layout box.
use crate::buffers::unicode_display_width;
/// Two-dimensional text box with an anchor row where horizontal flow
/// attaches.
///
/// Multi-row content (matrix-family environments) extends above/below the
/// anchor row; subsequent output continues on the anchor row. This keeps a
/// prefix, a matrix, and a suffix aligned:
///
/// ```text
/// A = ⎛1 2⎞, det(A) = 2
/// ⎝3 4⎠
/// ```
pub(super) struct MathBox {
lines: Vec<String>,
/// Row index that horizontal flow currently appends to.
anchor: usize,
/// First row belonging to the current visual line. Rows before `floor`
/// are completed lines from earlier `\\` breaks and must never be
/// touched by box attachment.
floor: usize,
/// Flat mode (inline math): vertical layout is impossible, so row breaks
/// render as `; ` and environments render single-row.
pub(super) flat: bool,
}
impl MathBox {
pub(super) fn new(flat: bool) -> Self {
Self {
lines: vec![String::new()],
anchor: 0,
floor: 0,
flat,
}
}
fn cur(&mut self) -> &mut String {
&mut self.lines[self.anchor]
}
/// `true` when nothing has been emitted on the current flow row yet.
pub(super) fn at_line_start(&self) -> bool {
self.lines[self.anchor].is_empty()
}
pub(super) fn ends_with_space(&self) -> bool {
self.lines[self.anchor].ends_with(' ')
}
pub(super) fn push(&mut self, c: char) {
if c == '\n' {
self.vbreak();
} else {
self.cur().push(c);
}
}
pub(super) fn push_str(&mut self, s: &str) {
if s.contains('\n') {
self.hcat_rows(s.split('\n').map(str::to_string).collect());
} else {
self.cur().push_str(s);
}
}
/// End the current visual line; flow continues on a fresh row below all
/// existing rows. Flat mode renders the break as `; `.
fn vbreak(&mut self) {
if self.flat {
if !self.at_line_start() {
let cur = self.cur();
while cur.ends_with(' ') {
cur.pop();
}
cur.push_str("; ");
}
} else {
self.lines.push(String::new());
self.anchor = self.lines.len() - 1;
self.floor = self.anchor;
}
}
/// Attach `rows` as a box at the current flow position, anchored at the
/// box's upper-middle row. All box rows start at the same column; flow
/// resumes on the anchor row past the box's widest row.
pub(super) fn hcat_rows(&mut self, rows: Vec<String>) {
if rows.is_empty() {
return;
}
if self.flat || rows.len() == 1 {
for (i, row) in rows.iter().enumerate() {
if i > 0 {
self.vbreak();
}
self.cur().push_str(row);
}
return;
}
let box_anchor = (rows.len() - 1) / 2;
let attach_col = unicode_display_width(&self.lines[self.anchor]);
let box_width = rows
.iter()
.map(|r| unicode_display_width(r))
.max()
.unwrap_or(0);
// Ensure enough rows above the anchor within the current visual line.
let have_above = self.anchor - self.floor;
if box_anchor > have_above {
let add = box_anchor - have_above;
for _ in 0..add {
self.lines.insert(self.floor, String::new());
}
self.anchor += add;
}
// Ensure enough rows below the anchor.
let below = rows.len() - box_anchor - 1;
let have_below = self.lines.len() - self.anchor - 1;
if below > have_below {
for _ in 0..(below - have_below) {
self.lines.push(String::new());
}
}
// Place the box rows, left-padded to the attach column.
for (i, row) in rows.iter().enumerate() {
let target = self.anchor - box_anchor + i;
let line = &mut self.lines[target];
let cur_w = unicode_display_width(line);
if cur_w < attach_col {
line.push_str(&" ".repeat(attach_col - cur_w));
}
line.push_str(row);
}
// Flow resumes past the box's widest row.
let frontier = attach_col + box_width;
let cur_w = unicode_display_width(&self.lines[self.anchor]);
if cur_w < frontier {
let pad = frontier - cur_w;
self.lines[self.anchor].push_str(&" ".repeat(pad));
}
}
pub(super) fn into_lines(self) -> Vec<String> {
self.lines
}
}
impl std::fmt::Write for MathBox {
fn write_str(&mut self, s: &str) -> std::fmt::Result {
self.push_str(s);
Ok(())
}
}

View file

@ -0,0 +1,96 @@
//! Best-effort LaTeX math → Unicode plain-text conversion.
//!
//! Converts TeX math source (the content of `$...$`, `$$...$$`, `\(...\)`,
//! `\[...\]`) into a readable Unicode approximation for terminal display:
//!
//! - Greek letters and symbol commands (`\alpha` → `α`, `\le` → `≤`, …)
//! - Superscripts/subscripts via Unicode script characters (`x^2` → `x²`,
//! `a_1` → `a₁`) with `^(...)`/`_(...)` fallback when a char has no
//! Unicode script form
//! - Fractions (`\frac{1}{2}` → `½`, `\frac{a+b}{c}` → `(a+b)/c`)
//! - Roots (`\sqrt{x}` → `√x`, `\sqrt[3]{x}` → `∛x`)
//! - Alphabets (`\mathbb{R}` → ``, `\mathcal{L}` → ``, `\mathbf{v}` → `𝐯`)
//! - Accents via combining marks (`\hat{x}` → `x̂`, `\vec{v}` → `v⃗`)
//! - Environments (`aligned`, `cases`, `pmatrix`, …) → multi-line layout
//!
//! The converter is total: it never panics and always produces *some* output
//! (unknown commands degrade to their bare name). Callers decide whether to
//! use the conversion or fall back to raw TeX source.
mod commands;
mod cursor;
mod environments;
mod math_box;
mod symbols;
#[cfg(test)]
mod tests;
use commands::render_sequence;
use cursor::Cursor;
use math_box::MathBox;
/// Inputs larger than this are not converted (callers fall back to raw
/// display). Guards the streaming hot path: the tail is re-rendered on every
/// chunk, so conversion cost must stay trivially small.
pub(crate) const MAX_MATH_SOURCE_LEN: usize = 4096;
/// Hard cap on group-nesting recursion. Inputs deeper than this render their
/// remaining content flatly rather than recursing further.
const MAX_DEPTH: usize = 32;
/// Convert inline math to a single-line Unicode string.
///
/// Row separators (`\\`) collapse to `; ` and multi-row environments render
/// single-row, so inline math never introduces a line break mid-paragraph.
/// Returns `None` when the source is too large to convert (see
/// [`MAX_MATH_SOURCE_LEN`]).
pub(crate) fn latex_to_unicode_inline(src: &str) -> Option<String> {
if src.len() > MAX_MATH_SOURCE_LEN {
return None;
}
let lines = convert(src, true);
let joined = lines
.iter()
.map(|l| l.trim())
.filter(|l| !l.is_empty())
.collect::<Vec<_>>()
.join("; ");
Some(joined)
}
/// Convert display math to one or more Unicode lines.
///
/// Lines come from `\\` row separators and multi-row environments, which lay
/// out as 2D boxes anchored to the surrounding flow (see [`MathBox`]).
/// Leading whitespace is structural (box alignment) and preserved; only line
/// ends are trimmed. Returns `None` when the source is too large to convert,
/// and an empty `Vec` when the math has no visible content (callers should
/// fall back in both cases).
pub(crate) fn latex_to_unicode_display(src: &str) -> Option<Vec<String>> {
if src.len() > MAX_MATH_SOURCE_LEN {
return None;
}
let lines: Vec<String> = convert(src, false)
.into_iter()
.map(|l| l.trim_end().to_string())
.filter(|l| !l.is_empty())
.collect();
Some(lines)
}
/// Run the converter and return the output lines.
fn convert(src: &str, flat: bool) -> Vec<String> {
let mut cursor = Cursor::new(src);
let mut out = MathBox::new(flat);
render_sequence(&mut cursor, &mut out, 0, Mode::Math, None);
out.into_lines()
}
/// Rendering mode: math mode applies typographic substitutions (`-` → ``,
/// `'` → ``) that text fragments (`\text{...}`) must not receive.
#[derive(Copy, Clone, PartialEq, Eq)]
enum Mode {
Math,
Text,
}

View file

@ -0,0 +1,412 @@
//! Character and symbol mapping tables.
pub(super) fn to_superscript(c: char) -> Option<char> {
Some(match c {
'0' => '⁰',
'1' => '¹',
'2' => '²',
'3' => '³',
'4' => '⁴',
'5' => '⁵',
'6' => '⁶',
'7' => '⁷',
'8' => '⁸',
'9' => '⁹',
'+' => '⁺',
'-' | '' => '⁻',
'=' => '⁼',
'(' => '⁽',
')' => '⁾',
'a' => 'ᵃ',
'b' => 'ᵇ',
'c' => 'ᶜ',
'd' => 'ᵈ',
'e' => 'ᵉ',
'f' => 'ᶠ',
'g' => 'ᵍ',
'h' => 'ʰ',
'i' => 'ⁱ',
'j' => 'ʲ',
'k' => 'ᵏ',
'l' => 'ˡ',
'm' => 'ᵐ',
'n' => 'ⁿ',
'o' => 'ᵒ',
'p' => 'ᵖ',
'r' => 'ʳ',
's' => 'ˢ',
't' => 'ᵗ',
'u' => 'ᵘ',
'v' => 'ᵛ',
'w' => 'ʷ',
'x' => 'ˣ',
'y' => 'ʸ',
'z' => 'ᶻ',
'T' => 'ᵀ',
'' | '*' => '*',
'' | '\'' => '',
' ' => ' ',
_ => return None,
})
}
pub(super) fn to_subscript(c: char) -> Option<char> {
Some(match c {
'0' => '₀',
'1' => '₁',
'2' => '₂',
'3' => '₃',
'4' => '₄',
'5' => '₅',
'6' => '₆',
'7' => '₇',
'8' => '₈',
'9' => '₉',
'+' => '₊',
'-' | '' => '₋',
'=' => '₌',
'(' => '₍',
')' => '₎',
'a' => 'ₐ',
'e' => 'ₑ',
'h' => 'ₕ',
'i' => 'ᵢ',
'j' => 'ⱼ',
'k' => 'ₖ',
'l' => 'ₗ',
'm' => 'ₘ',
'n' => 'ₙ',
'o' => 'ₒ',
'p' => 'ₚ',
'r' => 'ᵣ',
's' => 'ₛ',
't' => 'ₜ',
'u' => 'ᵤ',
'v' => 'ᵥ',
'x' => 'ₓ',
' ' => ' ',
_ => return None,
})
}
pub(super) fn map_mathbb(c: char) -> Option<char> {
Some(match c {
'C' => '',
'H' => '',
'N' => '',
'P' => '',
'Q' => '',
'R' => '',
'Z' => '',
'A'..='Z' => char::from_u32(0x1D538 + (c as u32 - 'A' as u32))?,
'a'..='z' => char::from_u32(0x1D552 + (c as u32 - 'a' as u32))?,
'0'..='9' => char::from_u32(0x1D7D8 + (c as u32 - '0' as u32))?,
_ => return None,
})
}
pub(super) fn map_mathcal(c: char) -> Option<char> {
Some(match c {
'B' => '',
'E' => '',
'F' => '',
'H' => '',
'I' => '',
'L' => '',
'M' => '',
'R' => '',
'e' => '',
'g' => '',
'o' => '',
'A'..='Z' => char::from_u32(0x1D49C + (c as u32 - 'A' as u32))?,
'a'..='z' => char::from_u32(0x1D4B6 + (c as u32 - 'a' as u32))?,
_ => return None,
})
}
pub(super) fn map_mathfrak(c: char) -> Option<char> {
Some(match c {
'C' => '',
'H' => '',
'I' => '',
'R' => '',
'Z' => '',
'A'..='Z' => char::from_u32(0x1D504 + (c as u32 - 'A' as u32))?,
'a'..='z' => char::from_u32(0x1D51E + (c as u32 - 'a' as u32))?,
_ => return None,
})
}
pub(super) fn map_mathbf(c: char) -> Option<char> {
Some(match c {
'A'..='Z' => char::from_u32(0x1D400 + (c as u32 - 'A' as u32))?,
'a'..='z' => char::from_u32(0x1D41A + (c as u32 - 'a' as u32))?,
'0'..='9' => char::from_u32(0x1D7CE + (c as u32 - '0' as u32))?,
_ => return None,
})
}
/// Symbol command table (commands with no arguments).
pub(super) fn symbol(name: &str) -> Option<&'static str> {
Some(match name {
// Greek lowercase
"alpha" => "α",
"beta" => "β",
"gamma" => "γ",
"delta" => "δ",
"epsilon" => "ϵ",
"varepsilon" => "ε",
"zeta" => "ζ",
"eta" => "η",
"theta" => "θ",
"vartheta" => "ϑ",
"iota" => "ι",
"kappa" => "κ",
"lambda" => "λ",
"mu" => "μ",
"nu" => "ν",
"xi" => "ξ",
"omicron" => "ο",
"pi" => "π",
"varpi" => "ϖ",
"rho" => "ρ",
"varrho" => "ϱ",
"sigma" => "σ",
"varsigma" => "ς",
"tau" => "τ",
"upsilon" => "υ",
"phi" => "ϕ",
"varphi" => "φ",
"chi" => "χ",
"psi" => "ψ",
"omega" => "ω",
// Greek uppercase
"Gamma" => "Γ",
"Delta" => "Δ",
"Theta" => "Θ",
"Lambda" => "Λ",
"Xi" => "Ξ",
"Pi" => "Π",
"Sigma" => "Σ",
"Upsilon" => "Υ",
"Phi" => "Φ",
"Psi" => "Ψ",
"Omega" => "Ω",
// Big operators
"sum" => "",
"prod" => "",
"coprod" => "",
"int" => "",
"iint" => "",
"iiint" => "",
"oint" => "",
"bigcup" => "",
"bigcap" => "",
"bigvee" => "",
"bigwedge" => "",
"bigoplus" => "",
"bigotimes" => "",
"bigodot" => "",
"biguplus" => "",
// Named operators (render as plain words)
"lim" => "lim",
"limsup" => "lim sup",
"liminf" => "lim inf",
"sin" => "sin",
"cos" => "cos",
"tan" => "tan",
"cot" => "cot",
"sec" => "sec",
"csc" => "csc",
"arcsin" => "arcsin",
"arccos" => "arccos",
"arctan" => "arctan",
"sinh" => "sinh",
"cosh" => "cosh",
"tanh" => "tanh",
"coth" => "coth",
"log" => "log",
"ln" => "ln",
"lg" => "lg",
"exp" => "exp",
"max" => "max",
"min" => "min",
"sup" => "sup",
"inf" => "inf",
"det" => "det",
"dim" => "dim",
"ker" => "ker",
"deg" => "deg",
"arg" => "arg",
"gcd" => "gcd",
"hom" => "hom",
"Pr" => "Pr",
// Binary operators
"times" => "×",
"cdot" => "",
"div" => "÷",
"pm" => "±",
"mp" => "",
"ast" => "",
"star" => "",
"circ" => "",
"bullet" => "",
"oplus" => "",
"ominus" => "",
"otimes" => "",
"oslash" => "",
"odot" => "",
"wedge" | "land" => "",
"vee" | "lor" => "",
"cap" => "",
"cup" => "",
"setminus" => "",
"smallsetminus" => "",
"uplus" => "",
"sqcap" => "",
"sqcup" => "",
"triangleleft" => "",
"triangleright" => "",
"wr" => "",
"diamond" => "",
"dagger" => "",
"ddagger" => "",
"amalg" => "⨿",
// Relations
"le" | "leq" | "leqslant" => "",
"ge" | "geq" | "geqslant" => "",
"ne" | "neq" => "",
"ll" => "",
"gg" => "",
"approx" => "",
"sim" => "",
"simeq" => "",
"cong" => "",
"equiv" => "",
"doteq" => "",
"propto" => "",
"prec" => "",
"succ" => "",
"preceq" => "",
"succeq" => "",
"asymp" => "",
"in" => "",
"ni" | "owns" => "",
"notin" => "",
"subset" => "",
"supset" => "",
"subseteq" => "",
"supseteq" => "",
"subsetneq" => "",
"supsetneq" => "",
"sqsubseteq" => "",
"sqsupseteq" => "",
"vdash" => "",
"dashv" => "",
"models" | "vDash" => "",
"perp" => "",
"parallel" => "",
"nparallel" => "",
"mid" => "",
"nmid" => "",
"smile" => "",
"frown" => "",
"bowtie" => "",
// Arrows
"to" | "rightarrow" => "",
"leftarrow" | "gets" => "",
"leftrightarrow" => "",
"Rightarrow" => "",
"Leftarrow" => "",
"Leftrightarrow" => "",
"implies" => "",
"impliedby" => "",
"iff" => "",
"longrightarrow" => "",
"longleftarrow" => "",
"longmapsto" => "",
"mapsto" => "",
"uparrow" => "",
"downarrow" => "",
"updownarrow" => "",
"Uparrow" => "",
"Downarrow" => "",
"nearrow" => "",
"searrow" => "",
"swarrow" => "",
"nwarrow" => "",
"hookrightarrow" => "",
"hookleftarrow" => "",
"rightharpoonup" => "",
"leftharpoonup" => "",
"rightleftharpoons" => "",
// Logic / sets / misc letters
"forall" => "",
"exists" => "",
"nexists" => "",
"neg" | "lnot" => "¬",
"emptyset" | "varnothing" => "",
"infty" => "",
"nabla" => "",
"partial" => "",
"hbar" => "",
"ell" => "",
"Re" => "",
"Im" => "",
"aleph" => "",
"beth" => "",
"wp" => "",
"imath" => "ı",
"jmath" => "ȷ",
"top" => "",
"bot" => "",
"angle" => "",
"measuredangle" => "",
"triangle" => "",
"square" | "Box" => "",
"blacksquare" => "",
"diamondsuit" => "",
"heartsuit" => "",
"clubsuit" => "",
"spadesuit" => "",
"flat" => "",
"natural" => "",
"sharp" => "",
"checkmark" => "",
"degree" => "°",
"prime" => "",
"dprime" => "",
"therefore" => "",
"because" => "",
"dots" | "ldots" | "dotsc" | "dotso" | "dotsb" | "dotsm" => "",
"cdots" => "",
"vdots" => "",
"ddots" => "",
"surd" => "",
"AA" => "Å",
// Delimiters
"langle" => "",
"rangle" => "",
"lceil" => "",
"rceil" => "",
"lfloor" => "",
"rfloor" => "",
"lbrace" => "{",
"rbrace" => "}",
"lbrack" => "[",
"rbrack" => "]",
"vert" => "|",
"Vert" | "|" => "",
"backslash" => "\\",
"setbslash" => "",
// Escaped literals
"{" => "{",
"}" => "}",
"%" => "%",
"$" => "$",
"&" => "&",
"#" => "#",
"_" => "_",
_ => return None,
})
}

View file

@ -0,0 +1,370 @@
use super::*;
fn inline(src: &str) -> String {
latex_to_unicode_inline(src).expect("within size limit")
}
fn display(src: &str) -> Vec<String> {
latex_to_unicode_display(src).expect("within size limit")
}
#[test]
fn plain_expression_passes_through() {
assert_eq!(inline("E = mc"), "E = mc");
}
#[test]
fn superscripts_map_to_unicode() {
assert_eq!(inline("E = mc^2"), "E = mc²");
assert_eq!(inline("x^{10}"), "x¹⁰");
assert_eq!(inline("e^{-x}"), "e⁻ˣ");
assert_eq!(inline("x^T"), "xᵀ");
}
#[test]
fn subscripts_map_to_unicode() {
assert_eq!(inline("a_1 + a_2"), "a₁ + a₂");
assert_eq!(inline("x_{ij}"), "xᵢⱼ");
}
#[test]
fn script_fallback_uses_parens() {
// φ has no superscript form → fall back to ^(...)
assert_eq!(inline("x^{\\alpha\\beta}"), "x^(αβ)");
assert_eq!(inline("x^\\alpha"), "x^α");
// Single unmappable subscript char.
assert_eq!(inline("a_q"), "a_q");
}
#[test]
fn wordlike_scripts_fall_back_to_parens() {
// Text-family commands mark the atom as a word → no modifier-letter runs
// (`pₜₒᵣₛₒ` is unreadable and gappy in many terminal fonts).
assert_eq!(inline("p_{\\text{torso}}"), "p_(torso)");
assert_eq!(inline("z_{\\mathrm{draft}}"), "z_(draft)");
assert_eq!(inline("x^{\\text{opt}}"), "x^(opt)");
// 3+ letter runs read as words even without \text.
assert_eq!(inline("x_{max}"), "x_(max)");
assert_eq!(inline("z_{torso}"), "z_(torso)");
}
#[test]
fn indexlike_scripts_keep_unicode_forms() {
// 12 letter runs are index juxtapositions, not words.
assert_eq!(inline("x_{ij}"), "xᵢⱼ");
assert_eq!(inline("T_{i+1}"), "Tᵢ₊₁");
assert_eq!(inline("n^{th}"), "nᵗʰ");
assert_eq!(inline("\\sum_{i=0}^{2} \\gamma^{i}"), "∑ᵢ₌₀² γⁱ");
}
#[test]
fn boxed_renders_content_without_frame() {
assert_eq!(inline("\\boxed{x = 1}"), "x = 1");
assert_eq!(inline("\\boxed{\\mathcal{L}}"), "");
assert_eq!(inline("\\fbox{done}"), "done");
// Math typography applies inside \boxed (math mode) …
assert_eq!(inline("\\boxed{a - b}"), "a b");
// … but not inside \fbox (text mode).
assert_eq!(inline("\\fbox{a-b}"), "a-b");
}
#[test]
fn mtp_loss_equation_converts_fully() {
// A complex real-world equation: every command must
// convert — no literal command names in the output.
let src = "\\boxed{\n\\mathcal{L}_{\\text{MTP}}\n=\n\\sum_{i=0}^{2}\n\\gamma^{i}\\,\n\\mathbb{E}_{\\text{positions, mask}}\n\\Big[\n\\mathrm{KL}\\big(\n \\mathrm{softmax}(z_{\\text{torso}}^{(s_i)})\n \\;\\big\\|\\;\n \\mathrm{softmax}(z_{\\text{draft}}^{(i)})\n\\big)\n\\Big]\n}";
let joined = inline(src);
assert!(joined.contains("_(MTP)"), "got: {joined}");
assert!(joined.contains("∑ᵢ₌₀²"), "got: {joined}");
assert!(joined.contains("𝔼_(positions, mask)"), "got: {joined}");
assert!(joined.contains("softmax(z_(torso)"), "got: {joined}");
assert!(joined.contains(""), "got: {joined}");
assert!(!joined.contains("boxed"), "got: {joined}");
assert!(!joined.contains('\\'), "got: {joined}");
}
#[test]
fn greek_letters() {
assert_eq!(inline("\\alpha + \\beta = \\Gamma"), "α + β = Γ");
assert_eq!(inline("\\varepsilon \\varphi"), "ε φ");
}
#[test]
fn relations_and_operators() {
assert_eq!(inline("a \\le b \\ne c \\times d"), "a ≤ b ≠ c × d");
assert_eq!(inline("x \\in A \\cup B"), "x ∈ A B");
assert_eq!(inline("p \\implies q"), "p ⟹ q");
assert_eq!(inline("f: A \\to B"), "f: A → B");
}
#[test]
fn vulgar_and_general_fractions() {
assert_eq!(inline("\\frac{1}{2}"), "½");
assert_eq!(inline("\\frac{3}{4}"), "¾");
assert_eq!(inline("\\frac{dy}{dx}"), "dy/dx");
assert_eq!(inline("\\frac{a+b}{c}"), "(a+b)/c");
assert_eq!(inline("\\frac{x}{y - z}"), "x/(y z)");
}
#[test]
fn roots() {
assert_eq!(inline("\\sqrt{x}"), "√x");
assert_eq!(inline("\\sqrt{a + b}"), "√(a + b)");
assert_eq!(inline("\\sqrt[3]{x}"), "∛x");
assert_eq!(inline("\\sqrt[4]{x}"), "∜x");
assert_eq!(inline("\\sqrt[n]{x}"), "ⁿ√x");
}
#[test]
fn text_commands_pass_content_through() {
assert_eq!(inline("\\text{if } x > 0"), "if x > 0");
assert_eq!(inline("\\mathrm{d}x"), "dx");
assert_eq!(inline("\\operatorname{softmax}(z)"), "softmax(z)");
// Text mode must not map `-` to minus.
assert_eq!(inline("\\text{x-ray}"), "x-ray");
}
#[test]
fn alphabets() {
assert_eq!(inline("\\mathbb{R}^n"), "ℝⁿ");
assert_eq!(inline("\\mathbb{N} \\mathbb{Z} \\mathbb{Q}"), " ");
assert_eq!(inline("\\mathcal{L}"), "");
assert_eq!(inline("\\mathcal{O}(n)"), "𝒪(n)");
assert_eq!(inline("\\mathfrak{g}"), "𝔤");
assert_eq!(inline("\\mathbf{v}"), "𝐯");
}
#[test]
fn accents_use_combining_marks() {
assert_eq!(inline("\\hat{x}"), "x\u{0302}");
assert_eq!(inline("\\bar{y}"), "y\u{0304}");
assert_eq!(inline("\\vec{v}"), "v\u{20D7}");
assert_eq!(inline("\\dot{q}"), "q\u{0307}");
assert_eq!(inline("\\tilde\\theta"), "θ\u{0303}");
}
#[test]
fn left_right_and_spacing() {
assert_eq!(inline("\\left( \\frac{1}{2} \\right)"), "( ½ )".to_string());
assert_eq!(inline("\\left. x \\right|_0^1"), "x |₀¹");
assert_eq!(inline("\\int f(x)\\,dx"), "∫ f(x) dx");
assert_eq!(inline("a\\!b"), "ab");
assert_eq!(inline("a \\quad b"), "a b");
}
#[test]
fn named_function_operators() {
assert_eq!(inline("\\sin(x) + \\cos(y)"), "sin(x) + cos(y)");
assert_eq!(inline("\\lim_{x \\to 0} f(x)"), "lim_(x → 0) f(x)");
assert_eq!(inline("\\log n"), "log n");
}
#[test]
fn integrals_and_sums_with_bounds() {
assert_eq!(inline("\\int_0^\\infty e^{-x} dx"), "∫₀^∞ e⁻ˣ dx");
assert_eq!(inline("\\sum_{i=1}^{n} a_i"), "∑ᵢ₌₁ⁿ aᵢ");
}
#[test]
fn minus_and_prime_typography() {
assert_eq!(inline("a - b"), "a b");
assert_eq!(inline("f'(x)"), "f(x)");
}
#[test]
fn not_negates_known_relations() {
assert_eq!(inline("a \\not= b"), "a ≠ b");
assert_eq!(inline("x \\not\\in S"), "x ∉ S");
assert_eq!(inline("a \\not\\sim b"), "a \u{0338} b");
}
#[test]
fn binomials_and_mod() {
assert_eq!(inline("\\binom{n}{k}"), "C(n, k)");
assert_eq!(inline("a \\equiv b \\pmod{m}"), "a ≡ b (mod m)");
assert_eq!(inline("a \\bmod b"), "a mod b");
}
#[test]
fn row_breaks_join_inline_and_split_display() {
assert_eq!(inline("a \\\\ b"), "a; b");
assert_eq!(display("a \\\\ b"), vec!["a", "b"]);
}
#[test]
fn aligned_environment_strips_markers() {
let lines = display("\\begin{aligned} x &= y + 1 \\\\ y &= 2 \\end{aligned}");
assert_eq!(lines, vec!["x = y + 1", "y = 2"]);
}
#[test]
fn cases_environment_renders_brace_column() {
let lines = display("f(x) = \\begin{cases} x & x > 0 \\\\ 0 & \\text{otherwise} \\end{cases}");
assert_eq!(lines.len(), 2);
assert!(lines[0].starts_with("f(x) = ⎧ x"), "got {lines:?}");
assert!(lines[1].trim_start().starts_with("⎩ 0"), "got {lines:?}");
}
#[test]
fn pmatrix_pads_columns() {
let lines = display("\\begin{pmatrix} 1 & 22 \\\\ 333 & 4 \\end{pmatrix}");
assert_eq!(lines, vec!["⎛1 22⎞", "⎝333 4⎠"]);
}
#[test]
fn bmatrix_single_row_uses_flat_brackets() {
assert_eq!(
display("\\begin{bmatrix} a & b \\end{bmatrix}"),
vec!["[a b]"]
);
}
#[test]
fn vmatrix_uses_bars() {
let lines = display("\\begin{vmatrix} a & b \\\\ c & d \\end{vmatrix}");
assert_eq!(lines, vec!["│a b│", "│c d│"]);
}
#[test]
fn matrix_with_prefix_aligns_as_box() {
// The prefix must stay on the anchor row with the matrix body
// aligned beneath — not glued to the first row only.
let lines = display("A = \\begin{pmatrix} 1 & 2 \\\\ 3 & 4 \\end{pmatrix}");
assert_eq!(lines, vec!["A = ⎛1 2⎞", " ⎝3 4⎠"]);
}
#[test]
fn matrix_with_prefix_and_suffix_flows_on_anchor_row() {
let lines =
display("A = \\begin{pmatrix} 1 & 2 \\\\ 3 & 4 \\end{pmatrix}, \\quad \\det(A) = -2");
assert_eq!(lines, vec!["A = ⎛1 2⎞, det(A) = 2", " ⎝3 4⎠"]);
}
#[test]
fn three_row_matrix_anchors_on_middle_row() {
let lines = display("v = \\begin{pmatrix} 1 \\\\ 2 \\\\ 3 \\end{pmatrix} x");
assert_eq!(lines, vec![" ⎛1⎞", "v = ⎜2⎟ x", " ⎝3⎠"]);
}
#[test]
fn cases_with_prefix_aligns_as_box() {
let lines = display("f(x) = \\begin{cases} x & x > 0 \\\\ 0 & e \\end{cases}");
assert_eq!(lines, vec!["f(x) = ⎧ x x > 0", " ⎩ 0 e"]);
}
#[test]
fn inline_matrix_renders_flat() {
assert_eq!(
inline("\\begin{pmatrix} 1 & 2 \\\\ 3 & 4 \\end{pmatrix}"),
"(1 2; 3 4)"
);
assert_eq!(inline("\\begin{bmatrix} a \\\\ b \\end{bmatrix}"), "[a; b]");
}
#[test]
fn inline_cases_renders_flat() {
assert_eq!(
inline("\\begin{cases} x & x > 0 \\\\ 0 & e \\end{cases}"),
"{x x > 0; 0 e}"
);
}
#[test]
fn two_matrices_on_one_line_share_rows() {
let lines = display(
"\\begin{pmatrix} 1 \\\\ 2 \\end{pmatrix} + \\begin{pmatrix} 3 \\\\ 4 \\end{pmatrix}",
);
assert_eq!(lines, vec!["⎛1⎞ + ⎛3⎞", "⎝2⎠ ⎝4⎠"]);
}
#[test]
fn row_break_then_matrix_does_not_disturb_previous_line() {
let lines = display("a \\\\ B = \\begin{pmatrix} 1 \\\\ 2 \\end{pmatrix}");
assert_eq!(lines, vec!["a", "B = ⎛1⎞", " ⎝2⎠"]);
}
#[test]
fn unknown_environment_renders_rows() {
let lines = display("\\begin{foo} a \\\\ b \\end{foo}");
assert_eq!(lines, vec!["a", "b"]);
}
#[test]
fn nested_environment_resolves_matching_end() {
let lines = display(
"\\begin{aligned} A &= \\begin{pmatrix} 1 \\end{pmatrix} \\\\ B &= 2 \\end{aligned}",
);
assert_eq!(lines, vec!["A = (1)", "B = 2"]);
}
#[test]
fn unknown_commands_keep_their_name() {
assert_eq!(inline("\\foobar x"), "foobar x");
}
#[test]
fn overset_and_stackrel() {
assert_eq!(inline("a \\overset{!}{=} b"), "a = b");
assert_eq!(inline("a \\overset{n}{=} b"), "a =ⁿ b");
}
#[test]
fn malformed_input_does_not_panic() {
for src in [
"",
"{",
"}",
"\\",
"\\frac{a}",
"\\frac",
"\\sqrt[",
"\\begin{aligned} x",
"\\begin",
"\\end{x}",
"^",
"_",
"^{",
"a^",
"{{{{{{",
"\\left",
"\\not",
"$$$",
"\\\\\\",
"&&&&",
] {
let _ = latex_to_unicode_inline(src);
let _ = latex_to_unicode_display(src);
}
}
#[test]
fn deeply_nested_input_is_bounded() {
let mut src = String::new();
for _ in 0..200 {
src.push('{');
}
src.push('x');
for _ in 0..200 {
src.push('}');
}
let _ = latex_to_unicode_inline(&src);
}
#[test]
fn oversized_input_is_rejected() {
let big = "x".repeat(MAX_MATH_SOURCE_LEN + 1);
assert!(latex_to_unicode_inline(&big).is_none());
assert!(latex_to_unicode_display(&big).is_none());
}
#[test]
fn whitespace_only_display_is_empty() {
assert!(display(" \n ").is_empty());
}
#[test]
fn escaped_literals() {
assert_eq!(inline("100\\%"), "100%");
assert_eq!(inline("\\{a, b\\}"), "{a, b}");
assert_eq!(inline("\\$5"), "$5");
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,182 @@
//! Streaming markdown renderer for terminal UIs.
//!
//! This crate provides incremental/streaming markdown rendering optimized for
//! displaying LLM responses in terminal UIs. Key features:
//!
//! - **Streaming rendering**: Efficiently render markdown as it arrives chunk by chunk
//! - **Checkpoint-based freezing**: Only re-render the "tail" after stable boundaries
//! - **Syntax highlighting**: Code blocks highlighted via syntect
//! - **Terminal color adaptation**: Automatic downgrade for 256-color/16-color terminals
//! - **LaTeX math rendering**: `$...$`, `$$...$$`, `\(...\)` and `\[...\]` math is
//! converted to a Unicode approximation (`$E=mc^2$` → `E=mc²`) in pretty mode
//!
//! # Example
//!
//! ```ignore
//! use xai_grok_markdown::{StreamingMarkdownRenderer, MarkdownStyle, Syntect};
//!
//! let syntect = Syntect::new(include_bytes!("theme.tmTheme"));
//! let style = MarkdownStyle::default();
//! let mut renderer = StreamingMarkdownRenderer::new(style, true);
//!
//! for token in stream {
//! renderer.push_and_render(&token, Some(&syntect));
//! let view = renderer.view();
//! // display view.lines
//! }
//! ```
mod buffers;
pub mod checkpoint;
mod colors;
mod hyperlinks;
mod latex;
mod latex_delimiters;
mod mermaid;
mod open_code_highlighter;
mod output;
mod parse;
mod render;
mod source_map;
pub mod streaming;
pub mod style;
mod syntax;
mod url_scan;
// Re-export public API
pub use buffers::MarkdownBuffers;
pub use checkpoint::{Checkpoint, CheckpointKind};
pub use colors::{
ColorLevel, adapt_color, adapt_style, detect_color_level, get_color_level, set_color_level_cap,
};
pub use latex_delimiters::{LatexDelimiterNormalizer, normalize_latex_delimiters};
pub use output::{CodeBlockSpan, HyperlinkTarget, MarkdownRenderOutput, MarkdownRenderView};
pub use parse::{MarkdownParser, ParsedMarkdown};
pub use source_map::SourceMap;
pub use streaming::StreamingMarkdownRenderer;
pub use style::{MarkdownStyle, TableBorders};
pub use syntax::Syntect;
// Re-export test helpers when fuzzing
#[cfg(fuzzing)]
pub use syntax::test_syntect;
/// Render markdown to ratatui Lines with full output including checkpoint.
///
/// Runs the parser pass followed by the `url_scan` pass so the returned
/// output's `hyperlinks` mirrors what `StreamingMarkdownRenderer::finish()`
/// produces for the same input (plain-URL detection for the pretty-mode
/// `(url)` suffix and bare URLs in prose).
pub fn render_markdown_ratatui_full(
text: &str,
ms: MarkdownStyle,
pretty: bool,
syntect: Option<&Syntect>,
) -> (MarkdownRenderOutput, Option<Checkpoint>) {
let mut buffers = MarkdownBuffers::new();
render_markdown_ratatui_with_buffers(text, ms, pretty, &mut buffers, syntect)
}
/// Render markdown to ratatui Lines, reusing the provided buffers.
pub fn render_markdown_ratatui_with_buffers(
text: &str,
ms: MarkdownStyle,
pretty: bool,
buffers: &mut MarkdownBuffers,
syntect: Option<&Syntect>,
) -> (MarkdownRenderOutput, Option<Checkpoint>) {
render_markdown_ratatui_with_buffers_width(text, ms, pretty, buffers, syntect, None)
}
/// Render markdown to ratatui Lines, reusing the provided buffers,
/// with an optional maximum table width.
pub fn render_markdown_ratatui_with_buffers_width(
text: &str,
ms: MarkdownStyle,
pretty: bool,
buffers: &mut MarkdownBuffers,
syntect: Option<&Syntect>,
max_table_width: Option<usize>,
) -> (MarkdownRenderOutput, Option<Checkpoint>) {
// Normalize LaTeX delimiters (`\(…\)`/`\[…\]`/`\begin{equation}`) into the
// canonical `$`/`$$` forms before parsing, so the math handlers convert them
// uniformly (incl. inside table cells). All offsets are in normalized space;
// `StreamingMarkdownRenderer` normalizes at ingestion so its stored source
// matches. Streaming tail renders (`render_markdown_ratatui_with_link_id`)
// do NOT re-normalize — they receive already-normalized source.
let normalized = latex_delimiters::normalize_latex_delimiters(text);
let mut parsed = MarkdownParser::new(&normalized, ms, buffers, syntect)
.max_table_width(max_table_width)
.parse();
let next_link_id = parsed.next_link_id;
let (mut output, checkpoint) = parsed.render_ratatui(pretty);
// Mirror `StreamingMarkdownRenderer::finish()`: detect plain URLs
// so a one-shot full render produces the same hyperlinks a
// `push_and_render` + `finish()` sequence would.
let (extra_links, _post_scan_next_id) =
url_scan::detect_plain_urls(&output.lines, &output.hyperlinks, next_link_id);
output.hyperlinks.extend(extra_links);
output
.hyperlinks
.sort_by_key(|h| (h.line_index, h.column_range.start));
(output, checkpoint)
}
/// Render markdown to ratatui Lines and provide `next_link_id` so the
/// streaming renderer can resume link ID assignment across tail re-renders.
///
/// `open_code` threads an optional incremental highlighter for the trailing
/// still-open fenced code block: only the streaming tail re-render passes
/// `Some(cache)`; `finish()` and non-streaming callers pass `None`. Everything
/// other than that one open block (closed code blocks, HTML, math, tables,
/// inline) always goes through the unchanged batch highlighter, so output is
/// byte-for-byte identical to the cache-less path. See [`open_code_highlighter`].
#[allow(clippy::too_many_arguments)]
pub(crate) fn render_markdown_ratatui_with_link_id(
text: &str,
ms: MarkdownStyle,
pretty: bool,
buffers: &mut MarkdownBuffers,
syntect: Option<&Syntect>,
max_table_width: Option<usize>,
link_id_start: u32,
collapse_soft_breaks: bool,
open_code: Option<&mut open_code_highlighter::OpenCodeHighlighter>,
) -> (MarkdownRenderOutput, Option<Checkpoint>, u32) {
let mut parsed = MarkdownParser::new(text, ms, buffers, syntect)
.max_table_width(max_table_width)
.link_id_start(link_id_start)
.collapse_soft_breaks(collapse_soft_breaks)
.open_code(open_code)
.parse();
// NOTE: There can be multiple links in a tail, hence next_link_id is the return.
let next_link_id = parsed.next_link_id;
let (output, checkpoint) = parsed.render_ratatui(pretty);
(output, checkpoint, next_link_id)
}
/// Render markdown to an ANSI-styled string.
pub fn render_markdown(
text: &str,
ms: MarkdownStyle,
pretty: bool,
syntect: Option<&Syntect>,
) -> (String, SourceMap) {
let mut buffers = MarkdownBuffers::new();
let normalized = latex_delimiters::normalize_latex_delimiters(text);
MarkdownParser::new(&normalized, ms, &mut buffers, syntect)
.parse()
.render_ansi(pretty)
}
/// Render markdown to ratatui Lines (simple API).
pub fn render_markdown_ratatui(
text: &str,
ms: MarkdownStyle,
pretty: bool,
syntect: Option<&Syntect>,
) -> (Vec<ratatui::text::Line<'static>>, Vec<usize>) {
let (out, _checkpoint) = render_markdown_ratatui_full(text, ms, pretty, syntect);
(out.lines, out.line_source_map)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,439 @@
//! Streaming-render syntect caches for fenced code blocks in the unfrozen
//! tail. Two complementary strategies behind one entry point
//! ([`OpenCodeHighlighter::highlight_block`]):
//!
//! - **Still-open trailing block** (closing ``` not arrived): persists
//! syntect's *resumable* per-line state ([`ParseState`]/[`HighlightState`])
//! across `rerender_tail` calls so each committed line is highlighted
//! exactly once. Without it, every push re-ran syntect over the whole
//! growing block — O(N²) over the stream (~35 ms/push near the end of a
//! ~1000-line block).
//! - **Closed blocks trapped in the tail** (e.g. inside an open list, which
//! can never checkpoint): memoizes the batch highlight per
//! `(fence_info, body)` so syntect runs once per distinct fence body
//! instead of once per streamed chunk (~50100 ms per re-run,
//! recorded 4.5 s UI freeze).
//!
//! Both paths are byte-identical to a one-shot batch render. Invalidation is
//! wholesale: the streaming renderer drops this struct on any
//! theme/style/width reset.
//!
//! # Invariants relied upon (open-block path)
//!
//! - **Append-only:** while a block is open the source only grows by appending,
//! and (because nothing freezes) the block's start offset within the tail is
//! stable. Both are guarded defensively here; on any mismatch the persisted
//! state is discarded and rebuilt from scratch.
//! - **One `Event::Text` per pass:** `TextMergeWithOffset` coalesces the block
//! body into a single `Event::Text`, so this is invoked once per block per
//! render pass and only needs to persist *across* passes.
use std::collections::HashMap;
use syntect::highlighting::{
HighlightIterator, HighlightState, Highlighter, Style as SyntectStyle,
};
use syntect::parsing::{ParseState, ScopeStack};
use syntect::util::LinesWithEndings;
use crate::syntax::{Syntect, syntax_highlight_raw};
/// Per-line highlight output: styled `(style, text)` segments for one line.
type HlLine = Vec<(SyntectStyle, String)>;
/// Byte budget for memoized closed-fence bodies; cleared wholesale on
/// overflow. Sized in body bytes (not entries) because pulldown can split a
/// list-indented fence into per-line `Event::Text` fragments — an entry
/// count would overflow on one large fence. If live bodies ever exceed the
/// budget the memo degrades to recomputing each pass (the pre-memo batch
/// behavior), never to unbounded memory or wrong output.
const CLOSED_MEMO_CAP_BYTES: usize = 256 * 1024;
/// Streaming syntect caches for fenced code blocks in the unfrozen tail:
/// incremental state for the single still-open trailing block, plus a memo
/// for closed blocks the tail re-parses every pass (see module docs).
///
/// Owns all the low-level syntect state so the parser/renderer don't have to.
pub(crate) struct OpenCodeHighlighter {
/// Language/info token of the block currently cached. A change means a
/// different syntax (and colors), so the cache must be rebuilt.
fence_info: String,
/// Block start offset within the tail. A change means we are looking at a
/// different block, so the cache must be rebuilt.
start_in_tail: usize,
/// Bytes highlighted up to and including the last committed `\n`.
committed_len: usize,
/// Highlighted, newline-terminated lines (one entry per committed line).
committed_lines: Vec<HlLine>,
/// syntect parse state AFTER the last committed (newline-terminated) line.
parse_state: ParseState,
/// syntect highlight state AFTER the last committed line.
highlight_state: HighlightState,
/// Memo for **closed** fences still in the unfrozen tail
/// (`fence_info -> body -> highlighted lines`). Nested maps keep the hot
/// lookup allocation-free; invalidation is inherited from `self` (the
/// streaming renderer drops this struct on any theme/style/width reset).
closed_memo: HashMap<String, HashMap<String, Vec<HlLine>>>,
/// Total body bytes currently memoized, for the `CLOSED_MEMO_CAP_BYTES`
/// budget check.
closed_memo_bytes: usize,
}
impl OpenCodeHighlighter {
/// Create an empty cache. The `parse_state`/`highlight_state` are seeded
/// with the plain-text syntax purely as placeholders: the empty
/// `fence_info` sentinel guarantees the first real
/// [`highlight`](Self::highlight) call takes the rebuild branch and
/// discards them in favour of the correct syntax.
pub(crate) fn new(syn: &Syntect) -> Self {
let highlighter = Highlighter::new(&syn.theme);
Self {
fence_info: String::new(),
start_in_tail: 0,
committed_len: 0,
committed_lines: Vec::new(),
// Seeded invalid; rebuilt on first highlight (see doc above).
parse_state: ParseState::new(syn.syntax_set.find_syntax_plain_text()),
highlight_state: HighlightState::new(&highlighter, ScopeStack::new()),
closed_memo: HashMap::new(),
closed_memo_bytes: 0,
}
}
/// Highlight a fenced block body from the streaming tail, routing to the
/// right cache: the incremental open-block path when the body reaches the
/// EOF of the tail (block still streaming), the closed-fence memo
/// otherwise. Single entry point so the parser carries no cache policy.
pub(crate) fn highlight_block(
&mut self,
syn: &Syntect,
fence_info: &str,
start_in_tail: usize,
body_reaches_eof: bool,
text: &str,
) -> Option<Vec<HlLine>> {
if body_reaches_eof {
self.highlight(syn, fence_info, start_in_tail, text)
} else {
self.highlight_closed(syn, fence_info, text)
}
}
/// Batch-highlight a **closed** fence body, memoized on
/// `(fence_info, body)`.
///
/// Closed fences trapped in an unfreezable tail (e.g. inside an open
/// list) are re-parsed by every `rerender_tail` pass; the memo makes
/// syntect run once per distinct body. The compute path *is*
/// [`syntax_highlight_raw`], so output is byte-identical by construction.
/// Theme stability follows [`highlight`](Self::highlight): the streaming
/// renderer drops this struct on any style change.
fn highlight_closed(
&mut self,
syn: &Syntect,
fence_info: &str,
text: &str,
) -> Option<Vec<HlLine>> {
if let Some(hit) = self.closed_memo.get(fence_info).and_then(|m| m.get(text)) {
// Hit clone is the same accepted O(lines)/pass residual as the
// open-block return below (see TODO on `highlight`).
return Some(hit.clone());
}
let lines = syntax_highlight_raw(Some(syn), fence_info, text)?;
if self.closed_memo_bytes.saturating_add(text.len()) > CLOSED_MEMO_CAP_BYTES {
self.closed_memo.clear();
self.closed_memo_bytes = 0;
}
let prev = self
.closed_memo
.entry(fence_info.to_owned())
.or_default()
.insert(text.to_owned(), lines.clone());
debug_assert!(prev.is_none(), "miss-checked key cannot already exist");
self.closed_memo_bytes += text.len();
Some(lines)
}
/// Test-only view of memoized closed-fence body bytes.
#[cfg(test)]
pub(crate) fn closed_memo_bytes(&self) -> usize {
self.closed_memo_bytes
}
/// Highlight the open block body `text` (the full body so far, append-only),
/// reusing persisted syntect state where possible.
///
/// Returns one styled line per source line (including the trailing partial
/// line if the body does not end in `\n`), matching what a batch
/// `HighlightLines` run would produce. Returns `None` if the fence has no
/// known syntax or a line fails to parse, so the caller can fall back to the
/// plain/untagged code path exactly like [`syntax_highlight_raw`].
///
/// # Theme stability invariant
///
/// The persisted state and the already-highlighted `committed_lines` bake
/// in the colors of the `syn.theme` seen so far, so the caller MUST pass a
/// [`Syntect`] whose `theme` is stable for the lifetime of a given open
/// block. A theme swap must go through a cache reset (the streaming renderer
/// does this in `set_style`); otherwise committed lines keep their old
/// colors while newly-committed lines use the new theme. The batch path has
/// no such constraint because it re-highlights from scratch every call.
fn highlight(
&mut self,
syn: &Syntect,
fence_info: &str,
start_in_tail: usize,
text: &str,
) -> Option<Vec<HlLine>> {
// Rebuild from scratch when anything that would change the output from
// the very first line changes: the language (different syntax/colors),
// the block position (a different block), or a non-append-only edit to
// the body (the committed prefix no longer matches `text`).
let needs_rebuild = fence_info != self.fence_info
|| start_in_tail != self.start_in_tail
|| !self.committed_prefix_matches(text);
if needs_rebuild {
// `syntax` is only needed to (re)seed the parser, so it is resolved
// here rather than on the warm path where it would be dead work.
let syntax = syn.find_syntax_for_fence_info(fence_info)?;
let highlighter = Highlighter::new(&syn.theme);
fence_info.clone_into(&mut self.fence_info);
self.start_in_tail = start_in_tail;
self.committed_len = 0;
self.committed_lines.clear();
self.parse_state = ParseState::new(syntax);
self.highlight_state = HighlightState::new(&highlighter, ScopeStack::new());
}
// Nothing new since the last committed `\n`: return the cached lines
// without constructing a `Highlighter` at all.
if self.committed_len == text.len() {
return Some(self.committed_lines.clone());
}
// Walk only the not-yet-committed remainder.
let highlighter = Highlighter::new(&syn.theme);
let mut tentative: Option<HlLine> = None;
for line in LinesWithEndings::from(&text[self.committed_len..]) {
if line.ends_with('\n') {
// A newline-terminated line is final: highlight once and
// permanently advance the persisted state. On a (practically
// unreachable) parse error, invalidate the cache so the next
// pass rebuilds from scratch instead of resuming from a
// now-inconsistent `parse_state` — matching the stateless
// batch fallback.
let ops = match self.parse_state.parse_line(line, &syn.syntax_set) {
Ok(ops) => ops,
Err(_) => {
self.fence_info.clear();
return None;
}
};
let highlighted =
HighlightIterator::new(&mut self.highlight_state, &ops, line, &highlighter)
.map(|(s, t)| (s, t.to_string()))
.collect();
self.committed_lines.push(highlighted);
self.committed_len += line.len();
} else {
// The trailing line has no `\n` yet — it is still streaming and
// may be extended by the next push. Highlight it on CLONES so
// the committed state stays anchored at the last `\n`.
let mut parse_state = self.parse_state.clone();
let mut highlight_state = self.highlight_state.clone();
let ops = match parse_state.parse_line(line, &syn.syntax_set) {
Ok(ops) => ops,
Err(_) => {
self.fence_info.clear();
return None;
}
};
tentative = Some(
HighlightIterator::new(&mut highlight_state, &ops, line, &highlighter)
.map(|(s, t)| (s, t.to_string()))
.collect(),
);
}
}
// TODO: this clone keeps the open-block RETURN at O(lines)/pass
// = O(lines^2)/stream. It only copies precomputed style spans (the
// expensive syntect parse/highlight CPU is already O(N) total), and the
// surrounding tail render + url_scan are likewise O(N)/pass, so this is
// tracked as an accepted residual — not a regression. Removing it needs
// a borrowed return threaded through `Replace` + the render pipeline.
let mut out = self.committed_lines.clone();
if let Some(last) = tentative {
out.push(last);
}
Some(out)
}
/// Whether the committed prefix is still a prefix of `text` (append-only
/// safety check). Allocation-free: walks the stored styled segments, whose
/// texts concatenate back to the original first `committed_len` bytes.
fn committed_prefix_matches(&self, text: &str) -> bool {
if self.committed_len > text.len() {
return false;
}
let bytes = text.as_bytes();
let mut pos = 0;
for line in &self.committed_lines {
for (_, piece) in line {
let end = pos + piece.len();
if bytes.get(pos..end) != Some(piece.as_bytes()) {
return false;
}
pos = end;
}
}
pos == self.committed_len
}
}
#[cfg(test)]
mod tests {
use syntect::util::LinesWithEndings;
use super::*;
use crate::syntax::test_syntect;
/// Batch reference highlight, mirroring `parse::syntax_highlight_raw`.
fn batch(syn: &Syntect, fence: &str, text: &str) -> Vec<HlLine> {
let mut hl = syn
.highlight_lines_for_fence_info(fence)
.expect("syntax for fence");
LinesWithEndings::from(text)
.map(|line| {
hl.highlight_line(line, &syn.syntax_set)
.expect("highlight line")
.into_iter()
.map(|(s, t)| (s, t.to_string()))
.collect()
})
.collect()
}
#[test]
fn append_only_growth_matches_fresh_full_highlight() {
let syn = test_syntect();
let full = "foo: 1\nbar:\n - a\n - b\nbaz: true\n";
let mut cache = OpenCodeHighlighter::new(syn);
// Grow one byte at a time; every prefix must equal a one-shot batch
// highlight of that same prefix (incremental == batch, byte-for-byte).
for end in 1..=full.len() {
if !full.is_char_boundary(end) {
continue;
}
let got = cache.highlight(syn, "yaml", 0, &full[..end]).expect("hl");
assert_eq!(got, batch(syn, "yaml", &full[..end]), "prefix len {end}");
}
}
#[test]
fn fence_change_invalidates() {
let syn = test_syntect();
let text = "let x = 1;\nfn main() {}\n";
let mut cache = OpenCodeHighlighter::new(syn);
// Prime with yaml, then re-key to rust: output must match a fresh rust
// batch, proving the persisted yaml state was discarded.
let _ = cache.highlight(syn, "yaml", 0, text).expect("hl yaml");
let got = cache.highlight(syn, "rust", 0, text).expect("hl rust");
assert_eq!(got, batch(syn, "rust", text));
}
#[test]
fn start_offset_change_invalidates() {
let syn = test_syntect();
let mut cache = OpenCodeHighlighter::new(syn);
let a = "alpha: 1\nbeta: 2\n";
let _ = cache.highlight(syn, "yaml", 0, a).expect("hl a");
// Same language, different block position and body: the new body must
// be highlighted fresh (no stale committed lines from the old block).
let b = "gamma: 3\ndelta: 4\n";
let got = cache.highlight(syn, "yaml", 42, b).expect("hl b");
assert_eq!(got, batch(syn, "yaml", b));
}
#[test]
fn unknown_fence_returns_none() {
let syn = test_syntect();
let mut cache = OpenCodeHighlighter::new(syn);
assert!(
cache
.highlight(syn, "definitely-not-a-language-xyz", 0, "data\n")
.is_none(),
);
}
// ── highlight_closed (closed-fence memo) ─────────────────────────
#[test]
fn closed_memo_matches_batch_and_is_idempotent() {
let syn = test_syntect();
let mut cache = OpenCodeHighlighter::new(syn);
let body = "fn answer(x: u64) -> u64 {\n x.wrapping_mul(42)\n}\n";
// First call computes; must equal the batch reference exactly.
let first = cache.highlight_closed(syn, "rust", body).expect("hl");
assert_eq!(first, batch(syn, "rust", body));
assert_eq!(cache.closed_memo_bytes(), body.len());
// Second call is a memo hit: identical output, no new entry.
let second = cache.highlight_closed(syn, "rust", body).expect("hl");
assert_eq!(second, first);
assert_eq!(cache.closed_memo_bytes(), body.len());
}
#[test]
fn closed_memo_distinguishes_fence_info_and_body() {
let syn = test_syntect();
let mut cache = OpenCodeHighlighter::new(syn);
let body_a = "key: value\n";
let body_b = "other: thing\n";
let yaml_a = cache.highlight_closed(syn, "yaml", body_a).expect("hl");
let yaml_b = cache.highlight_closed(syn, "yaml", body_b).expect("hl");
let rust_a = cache.highlight_closed(syn, "rust", body_a).expect("hl");
assert_eq!(yaml_a, batch(syn, "yaml", body_a));
assert_eq!(yaml_b, batch(syn, "yaml", body_b));
assert_eq!(rust_a, batch(syn, "rust", body_a));
}
#[test]
fn closed_memo_does_not_disturb_open_block_state() {
let syn = test_syntect();
let mut cache = OpenCodeHighlighter::new(syn);
// Interleave closed-memo calls with open-block incremental growth
// (a tail with one closed fence above an open one); open-block
// output must stay batch-identical throughout.
let closed = "name: pinned\n";
let full = "a = 1\nb = 2\nc = 3\n";
for end in 1..=full.len() {
if !full.is_char_boundary(end) {
continue;
}
let _ = cache.highlight_closed(syn, "yaml", closed).expect("memo");
let got = cache.highlight(syn, "python", 7, &full[..end]).expect("hl");
assert_eq!(got, batch(syn, "python", &full[..end]), "prefix len {end}");
}
assert_eq!(cache.closed_memo_bytes(), closed.len());
}
#[test]
fn closed_memo_cap_overflow_keeps_output_correct() {
let syn = test_syntect();
let mut cache = OpenCodeHighlighter::new(syn);
// Bodies sized so a handful of distinct ones cross the byte budget
// and trigger the wholesale clear; output must stay batch-identical
// before, at, and after the eviction.
let filler = "x".repeat(CLOSED_MEMO_CAP_BYTES / 4);
for i in 0..6 {
let body = format!("key_{i}: \"{filler}\"\n");
let got = cache.highlight_closed(syn, "yaml", &body).expect("hl");
assert_eq!(got, batch(syn, "yaml", &body), "iteration {i}");
}
assert!(cache.closed_memo_bytes() <= CLOSED_MEMO_CAP_BYTES);
}
}

View file

@ -0,0 +1,602 @@
//! Render output types for markdown.
use std::ops::Range;
use ratatui::text::Line;
use crate::buffers::CodeBlockMeta;
/// A hyperlink target extracted from rendered markdown.
///
/// Each instance maps a contiguous cell range on one rendered line to a URL.
/// When a link wraps across lines, multiple `HyperlinkTarget`s share the same
/// `id` and `url` -- the `id` enables OSC 8 hover-grouping across wrapped lines.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HyperlinkTarget {
/// Index of the rendered line this target appears on.
pub line_index: usize,
/// Column range (in display cells) of the link text on that line.
pub column_range: Range<usize>,
/// The destination URL.
pub url: String,
/// Stable identifier for grouping link fragments that belong to the
/// same logical link (e.g., a link whose text wraps across lines).
pub id: u32,
}
/// A fenced code block discovered while rendering markdown.
///
/// One `CodeBlockSpan` is produced per **closed** fenced code block, in
/// document order. An unterminated (still-open) fence at the end of the input
/// produces no span: `pulldown-cmark` synthesizes a block end at end-of-input,
/// so closure is detected structurally (a closing fence must follow the body)
/// rather than from the end event alone.
///
/// This is a generic, reusable description of a fenced block — it is not
/// specific to any one info string (e.g. `mermaid`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CodeBlockSpan {
/// The fence info string, e.g. `"mermaid"` or `"rust"`.
///
/// Empty for a fence opened with no info (just ` ``` `). Reported verbatim
/// as `pulldown-cmark` yields it (the full info string, not just the first
/// word).
pub info: String,
/// The fence body content — the clean, container-stripped code/diagram
/// source.
///
/// This is `pulldown-cmark`'s merged body text, so container markers are
/// removed (a blockquote `>` / list indentation does **not** leak in) and
/// CRLF line endings are normalized to `\n`. It ends with the body's
/// trailing newline and is empty for an empty-body fence. Prefer this over
/// slicing [`source_byte_range`](Self::source_byte_range) when you need the
/// logical body (e.g. a Mermaid diagram nested in a blockquote).
pub body: String,
/// Range of **pre-wrap** rendered body lines for this block, as indices
/// into [`MarkdownRenderOutput::lines`] / [`MarkdownRenderView::lines`].
///
/// Covers only the body — the delimiter ` ``` ` lines are excluded — so it
/// is independent of whether the renderer hides those delimiters in pretty
/// mode. Empty (`start == end`) for a fence with an empty body.
pub output_line_range: Range<usize>,
/// Byte range of the fence body in the **raw** source text.
///
/// Spans from the first body byte to the last, with the delimiter fence
/// lines excluded; empty (`start == end`) for an empty body. Unlike
/// [`body`](Self::body) this is a raw slice, so for a fence nested in a
/// blockquote or list it covers the source between the delimiters and may
/// include container markers/indentation (and `\r` for CRLF) on
/// continuation lines. Use [`body`](Self::body) for the clean content.
pub source_byte_range: Range<usize>,
}
/// Output from rendering markdown to ratatui Lines.
///
/// Contains all the information needed to display rendered markdown and
/// support copy operations back to source text.
#[derive(Debug, Clone, Default)]
pub struct MarkdownRenderOutput {
/// Rendered lines ready for display.
pub lines: Vec<Line<'static>>,
/// Maps each rendered line index to its source line number.
/// `line_source_map[rendered_line_idx]` = source line number (0-indexed).
pub line_source_map: Vec<usize>,
/// Maps a cell range on a rendered line to a URL. Links that
/// wrap across lines produce multiple entries with the same `id` and `url`.
pub hyperlinks: Vec<HyperlinkTarget>,
/// Fenced code blocks discovered during rendering, in document order.
/// One entry per closed fenced block; see [`CodeBlockSpan`].
pub code_blocks: Vec<CodeBlockSpan>,
}
impl MarkdownRenderOutput {
/// Create a new empty output.
pub fn new() -> Self {
Self::default()
}
/// Clear all content, keeping allocated capacity.
pub fn clear(&mut self) {
self.lines.clear();
self.line_source_map.clear();
self.hyperlinks.clear();
self.code_blocks.clear();
}
/// Get a borrowed view of this output.
pub fn as_view(&self) -> MarkdownRenderView<'_> {
MarkdownRenderView {
lines: &self.lines,
line_source_map: &self.line_source_map,
hyperlinks: &self.hyperlinks,
code_blocks: &self.code_blocks,
}
}
}
/// Borrowed view of rendered markdown output.
///
/// This is a zero-copy reference to rendered content, used by the streaming
/// renderer to avoid cloning frozen content on every render.
#[derive(Debug, Clone, Copy)]
pub struct MarkdownRenderView<'a> {
/// Rendered lines ready for display.
pub lines: &'a [Line<'static>],
/// Maps each rendered line index to its source line number.
pub line_source_map: &'a [usize],
/// Hyperlink targets extracted from the rendered markdown.
pub hyperlinks: &'a [HyperlinkTarget],
/// Fenced code blocks discovered during rendering, in document order.
/// One entry per closed fenced block; see [`CodeBlockSpan`].
pub code_blocks: &'a [CodeBlockSpan],
}
impl<'a> MarkdownRenderView<'a> {
/// Get the number of lines.
pub fn line_count(&self) -> usize {
self.lines.len()
}
}
/// Map parse-time code-block metadata onto the rendered output.
///
/// Runs after `render_ratatui` has produced `line_source_map`, turning each
/// captured [`CodeBlockMeta`] into a public [`CodeBlockSpan`]. The
/// pre-wrap body line range is derived from `line_source_map`: a fence body
/// occupies source lines `[src_first, src_last]`, and because the renderer
/// emits exactly one output line per body source line (and never maps a
/// non-body line into that source-line range), the matching output lines form
/// one contiguous run. `line_source_map` is non-decreasing, so the run is
/// located with two `partition_point`s.
///
/// Cost is O(text_len + lines·log) per render: the metas are in ascending body
/// order, so newline counts come from a single monotonic forward cursor over
/// `text` rather than rescanning from byte 0 for every meta (which would be
/// O(metas·text_len) — quadratic in the number of fences on the streaming hot
/// path).
pub(crate) fn build_code_block_spans(
text: &str,
line_source_map: &[usize],
metas: Vec<CodeBlockMeta>,
) -> Vec<CodeBlockSpan> {
if metas.is_empty() {
return Vec::new();
}
let bytes = text.as_bytes();
// Monotonic newline cursor. Each query advances from the previous position
// (metas ascend by body offset), so the whole pass is O(text_len). '\n' is
// single-byte ASCII, so byte counting is UTF-8-safe at any offset.
let mut cursor_pos = 0usize;
let mut cursor_newlines = 0usize;
let mut newlines_before = |pos: usize| -> usize {
let pos = pos.min(bytes.len());
debug_assert!(
pos >= cursor_pos,
"metas must be processed in ascending body order",
);
while cursor_pos < pos {
if bytes[cursor_pos] == b'\n' {
cursor_newlines += 1;
}
cursor_pos += 1;
}
cursor_newlines
};
metas
.into_iter()
.map(|meta| {
let range = meta.body_source_range;
let src_first = newlines_before(range.start);
let output_line_range = if range.end <= range.start {
// Empty body: no rendered body lines. Anchor an empty range at
// the first output line that does not precede the body.
let start = line_source_map.partition_point(|&src| src < src_first);
start..start
} else {
// `range.end - 1` is the last body byte; its source line is the
// inclusive last body source line, robust to a trailing newline.
let src_last = newlines_before(range.end - 1);
let start = line_source_map.partition_point(|&src| src < src_first);
let end = line_source_map.partition_point(|&src| src <= src_last);
start..end
};
CodeBlockSpan {
info: meta.info,
body: meta.body,
output_line_range,
source_byte_range: range,
}
})
.collect()
}
#[cfg(test)]
mod code_block_span_tests {
use ratatui::text::Line;
use crate::style::test_style::STYLE;
use crate::{CodeBlockSpan, StreamingMarkdownRenderer, render_markdown_ratatui_full};
fn lines_text(lines: &[Line<'static>]) -> Vec<String> {
lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect()
}
/// Plain text of the rendered body lines a span points at.
fn body_lines(lines: &[Line<'static>], span: &CodeBlockSpan) -> Vec<String> {
lines_text(&lines[span.output_line_range.clone()])
}
/// Source bytes a span's `source_byte_range` selects.
fn body_source<'a>(src: &'a str, span: &CodeBlockSpan) -> &'a str {
&src[span.source_byte_range.clone()]
}
fn blocks(src: &str, pretty: bool) -> (Vec<Line<'static>>, Vec<CodeBlockSpan>) {
let (out, _) = render_markdown_ratatui_full(src, STYLE, pretty, None);
(out.lines, out.code_blocks)
}
#[test]
fn closed_fence_top_level_pretty_and_raw() {
// A non-rendered language (`text`): its rendered body lines are the
// verbatim source, so the span's `output_line_range` maps back to them.
// (A `mermaid` fence renders to diagram art instead — see
// `mermaid_fence_renders_inline_but_span_keeps_clean_source`.)
let src = "```text\nflowchart TD\n A --> B\n```\n";
for pretty in [true, false] {
let (lines, cbs) = blocks(src, pretty);
assert_eq!(cbs.len(), 1, "pretty={pretty}");
assert_eq!(cbs[0].info, "text");
// Body line range excludes the delimiter fences in both modes.
assert_eq!(
body_lines(&lines, &cbs[0]),
vec!["flowchart TD", " A --> B"],
"pretty={pretty}",
);
// Byte range and clean body are mode-independent; for a top-level
// fence both equal the verbatim fence body.
assert_eq!(body_source(src, &cbs[0]), "flowchart TD\n A --> B\n");
assert_eq!(cbs[0].body, "flowchart TD\n A --> B\n");
}
}
#[test]
fn open_fence_produces_no_span() {
// Unterminated fence at EOF: pulldown still emits a block end, but no
// span must be produced (malformed/partial input).
for src in [
"```mermaid\nflowchart TD\n",
"```mermaid\nflowchart TD",
"```mermaid",
"intro\n\n```rust\nlet x = 1;",
] {
for pretty in [true, false] {
let (_, cbs) = blocks(src, pretty);
assert!(
cbs.is_empty(),
"open fence {src:?} (pretty={pretty}) should yield no span, got {cbs:?}",
);
}
}
}
#[test]
fn multiple_interspersed_blocks_in_order() {
let src = "Intro\n\n```rust\nfn a() {}\n```\n\nMid prose\n\n```text\nA-->B\n```\n\nEnd\n";
for pretty in [true, false] {
let (lines, cbs) = blocks(src, pretty);
assert_eq!(cbs.len(), 2, "pretty={pretty}");
assert_eq!(cbs[0].info, "rust");
assert_eq!(cbs[1].info, "text");
assert_eq!(body_source(src, &cbs[0]), "fn a() {}\n");
assert_eq!(body_source(src, &cbs[1]), "A-->B\n");
assert_eq!(body_lines(&lines, &cbs[0]), vec!["fn a() {}"]);
assert_eq!(body_lines(&lines, &cbs[1]), vec!["A-->B"]);
// Document order ⇒ disjoint, increasing line ranges.
assert!(cbs[0].output_line_range.end <= cbs[1].output_line_range.start);
}
}
#[test]
fn fence_nested_in_list() {
// Multi-line body so the list's base indent stripping is exercised on a
// continuation line (" A --> B" → " A --> B").
let src = "- item\n ```mermaid\n flowchart TD\n A --> B\n ```\n- next\n";
for pretty in [true, false] {
let (_lines, cbs) = blocks(src, pretty);
assert_eq!(cbs.len(), 1, "pretty={pretty}");
assert_eq!(cbs[0].info, "mermaid");
// `body` is the clean, de-prefixed source: the list base indent is
// stripped but inner relative indentation is preserved.
assert_eq!(cbs[0].body, "flowchart TD\n A --> B\n", "pretty={pretty}");
// The raw byte range, by contrast, also strips the per-line base
// indent here (pulldown's text-event range starts after it).
assert_eq!(
body_source(src, &cbs[0]),
"flowchart TD\n A --> B\n",
"pretty={pretty}",
);
assert!(!cbs[0].output_line_range.is_empty());
}
}
#[test]
fn fence_nested_in_blockquote() {
// The motivating case for the structural closure rule: the closing
// fence line is "> ```" (not a bare fence), and the body must come out
// de-prefixed (no leaked "> " / "│ ").
let src = "> ```mermaid\n> flowchart TD\n> A --> B\n> ```\n";
for pretty in [true, false] {
let (_lines, cbs) = blocks(src, pretty);
assert_eq!(cbs.len(), 1, "pretty={pretty}");
assert_eq!(cbs[0].info, "mermaid");
assert_eq!(cbs[0].body, "flowchart TD\n A --> B\n", "pretty={pretty}");
}
}
#[test]
fn open_blockquote_fence_produces_no_span() {
// Unterminated fence inside a blockquote ⇒ no span.
let src = "> ```mermaid\n> flowchart TD\n";
for pretty in [true, false] {
let (_, cbs) = blocks(src, pretty);
assert!(cbs.is_empty(), "pretty={pretty} got {cbs:?}");
}
}
#[test]
fn indented_code_block_is_not_a_fence() {
// A 4-space indented code block is not a fenced block ⇒ no span, even
// when its literal content looks like a fence.
let src = "para\n\n ```mermaid\n A-->B\n ```\n";
for pretty in [true, false] {
let (_, cbs) = blocks(src, pretty);
assert!(cbs.is_empty(), "pretty={pretty} got {cbs:?}");
}
}
#[test]
fn empty_body_closed_fence() {
let src = "```mermaid\n```\n";
// Pretty: both fence lines are hidden ⇒ no output lines ⇒ the empty
// anchor lands at 0..0 (exact, not merely is_empty()).
let (_, cbs) = blocks(src, true);
assert_eq!(cbs.len(), 1);
assert_eq!(cbs[0].info, "mermaid");
assert_eq!(cbs[0].output_line_range, 0..0);
assert_eq!(body_source(src, &cbs[0]), "");
assert_eq!(cbs[0].body, "");
// Raw: both fence lines are shown ⇒ the empty body is anchored between
// them at 1..1.
let (_, cbs_raw) = blocks(src, false);
assert_eq!(cbs_raw.len(), 1);
assert_eq!(cbs_raw[0].output_line_range, 1..1);
assert_eq!(cbs_raw[0].body, "");
}
#[test]
fn fence_without_info_string() {
let src = "```\nplain code\n```\n";
let (lines, cbs) = blocks(src, true);
assert_eq!(cbs.len(), 1);
assert_eq!(cbs[0].info, "");
assert_eq!(body_lines(&lines, &cbs[0]), vec!["plain code"]);
}
#[test]
fn tilde_fence_and_no_trailing_newline() {
// Tilde delimiters and a closed fence at EOF without a final newline.
for src in ["~~~text\nA-->B\n~~~\n", "```text\nfoo\n```"] {
let (lines, cbs) = blocks(src, true);
assert_eq!(cbs.len(), 1, "{src:?}");
assert_eq!(cbs[0].info, "text");
assert_eq!(body_lines(&lines, &cbs[0]).len(), 1);
}
}
#[test]
fn blank_line_inside_body_is_counted() {
let src = "```text\nfoo\n\nbar\n```\n";
let (lines, cbs) = blocks(src, true);
assert_eq!(cbs.len(), 1);
assert_eq!(body_lines(&lines, &cbs[0]), vec!["foo", "", "bar"]);
assert_eq!(body_source(src, &cbs[0]), "foo\n\nbar\n");
assert_eq!(cbs[0].body, "foo\n\nbar\n");
}
#[test]
fn crlf_body_is_normalized_but_byte_range_retains_cr() {
// CRLF: pulldown normalizes the body content to `\n`, while the raw
// byte range still slices the `\r`. Line counting (over `\n`) is
// unaffected.
let src = "```text\r\nA-->B\r\n```\r\n";
let (lines, cbs) = blocks(src, true);
assert_eq!(cbs.len(), 1);
assert_eq!(cbs[0].body, "A-->B\n");
assert_eq!(body_source(src, &cbs[0]), "A-->B\r\n");
// One rendered body line (the renderer keeps the raw `\r`; `body` is the
// normalized source of truth).
assert_eq!(body_lines(&lines, &cbs[0]).len(), 1);
}
#[test]
fn multibyte_body_slices_safely() {
// Multi-byte UTF-8 in the body: `source_byte_range` must land on char
// boundaries (no slice panic) and `body` is the exact content.
let src = "```text\nA --> \u{65e5}\u{672c}\u{8a9e}\nC --> \u{1f980}\n```\n";
let (lines, cbs) = blocks(src, true);
assert_eq!(cbs.len(), 1);
assert_eq!(
cbs[0].body,
"A --> \u{65e5}\u{672c}\u{8a9e}\nC --> \u{1f980}\n"
);
assert_eq!(
body_source(src, &cbs[0]),
"A --> \u{65e5}\u{672c}\u{8a9e}\nC --> \u{1f980}\n",
);
assert_eq!(body_lines(&lines, &cbs[0]).len(), 2);
}
#[test]
fn long_fence_with_inner_backticks() {
// A 4-backtick fence whose body contains a ``` line must not close
// early — one span whose body includes the inner fence text.
let src = "````mermaid\n```\ninner\n```\n````\n";
let (_lines, cbs) = blocks(src, true);
assert_eq!(cbs.len(), 1);
assert_eq!(cbs[0].info, "mermaid");
assert_eq!(cbs[0].body, "```\ninner\n```\n");
}
#[test]
fn tab_in_body_is_preserved_by_crate() {
// The markdown crate preserves a literal tab in the body (the pager
// expands tabs before rendering; the crate itself does not).
let src = "```mermaid\n\tA --> B\n```\n";
let (_lines, cbs) = blocks(src, true);
assert_eq!(cbs.len(), 1);
assert_eq!(cbs[0].body, "\tA --> B\n");
}
#[test]
fn rendered_body_lines_match_span_body() {
// The rendered (pre-wrap) body lines reconstruct to the span body
// (sans the trailing newline a line-join drops). Both pretty and raw.
let src = "```text\nflowchart TD\n A --> B\n B --> C\n```\n";
for pretty in [true, false] {
let (lines, cbs) = blocks(src, pretty);
let joined = body_lines(&lines, &cbs[0]).join("\n");
assert_eq!(
joined, "flowchart TD\n A --> B\n B --> C",
"pretty={pretty}"
);
assert_eq!(format!("{joined}\n"), cbs[0].body, "pretty={pretty}");
}
}
#[test]
fn mermaid_fence_renders_inline_but_span_keeps_clean_source() {
// A closed ```mermaid fence is rendered inline by the markdown crate
// (its body lines are replaced with diagram art), yet its CodeBlockSpan
// still exposes the clean SOURCE via `body` — what the pager feeds the
// PNG engine — and an `output_line_range` that spans the rendered
// diagram, where the pager anchors its affordance row. This contract is
// what the pager's Mermaid affordance row relies on.
let src = "```mermaid\nflowchart TD\n A --> B\n```\n";
let (lines, cbs) = blocks(src, true);
assert_eq!(cbs.len(), 1);
assert_eq!(cbs[0].info, "mermaid");
// `body` is the verbatim diagram source, independent of rendering.
assert_eq!(cbs[0].body, "flowchart TD\n A --> B\n");
// The fence is rendered inline: the spanned output lines are the diagram
// art, not the verbatim source.
assert!(!cbs[0].output_line_range.is_empty());
let rendered = body_lines(&lines, &cbs[0]).join("\n");
assert_ne!(rendered, "flowchart TD\n A --> B");
}
#[test]
fn streaming_span_stable_once_frozen() {
let full = "Intro\n\n```mermaid\nA-->B\nC-->D\n```\n\nAfter the block.\n\nMore prose.\n";
let mut renderer = StreamingMarkdownRenderer::new(STYLE, true);
let mut frozen_span: Option<CodeBlockSpan> = None;
for ch in full.chars() {
renderer.push_and_render(&ch.to_string(), None);
let view = renderer.view();
let frozen_lines = renderer.frozen_lines_count();
if let Some(cb) = view.code_blocks.iter().find(|c| c.info == "mermaid") {
// Only assert stability once the block is within frozen content.
if cb.output_line_range.end <= frozen_lines {
match &frozen_span {
None => frozen_span = Some(cb.clone()),
Some(prev) => {
assert_eq!(prev, cb, "frozen mermaid span changed across pushes",)
}
}
}
}
}
let frozen_span = frozen_span.expect("mermaid block should freeze mid-stream");
// Streaming output (after finish) must match a one-shot full render.
let finished = renderer.finish(None);
let streamed = finished
.code_blocks
.iter()
.find(|c| c.info == "mermaid")
.expect("finish() keeps the mermaid span");
assert_eq!(&frozen_span, streamed);
let (full_out, _) = render_markdown_ratatui_full(full, STYLE, true, None);
let full_cb = full_out
.code_blocks
.iter()
.find(|c| c.info == "mermaid")
.expect("full render finds the mermaid span");
assert_eq!(&frozen_span, full_cb);
}
#[test]
fn streaming_open_fence_has_no_frozen_span() {
// While the fence is still open, any transient span must remain in the
// unfrozen tail (never within the frozen prefix).
let mut renderer = StreamingMarkdownRenderer::new(STYLE, true);
for chunk in ["intro\n\n", "```mermaid\n", "flowchart TD\n", "A --> B\n"] {
renderer.push_and_render(chunk, None);
let frozen_lines = renderer.frozen_lines_count();
let view = renderer.view();
for cb in view.code_blocks {
assert!(
cb.output_line_range.end > frozen_lines,
"open fence span must not be frozen: {cb:?} frozen_lines={frozen_lines}",
);
}
}
}
#[test]
fn streaming_chunked_matches_full_render() {
let full = "# Title\n\n```mermaid\nA-->B\n```\n\ntext\n\n```rust\nfn f() {}\n```\n\nbye\n";
for pretty in [true, false] {
let mut renderer = StreamingMarkdownRenderer::new(STYLE, pretty);
// Irregular chunk boundaries to exercise tail rebasing.
let bytes = full.as_bytes();
let mut pos = 0;
for step in [4usize, 9, 1, 13, 7].iter().cycle() {
if pos >= bytes.len() {
break;
}
let mut end = (pos + step).min(bytes.len());
while end < bytes.len() && !full.is_char_boundary(end) {
end += 1;
}
renderer.push_and_render(&full[pos..end], None);
pos = end;
}
let view = renderer.finish(None);
let streamed: Vec<_> = view.code_blocks.to_vec();
let (full_out, _) = render_markdown_ratatui_full(full, STYLE, pretty, None);
assert_eq!(
streamed, full_out.code_blocks,
"pretty={pretty}: chunked stream must match full render",
);
}
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,141 @@
//! Source mapping for rendered markdown back to original source.
//!
//! Used for copy-paste operations: when the user selects rendered text,
//! we can look up the corresponding original markdown source.
use std::ops::Range;
/// Maps rendered byte positions back to source byte positions.
///
/// Direction: rendered (new) → source (old)
#[derive(Debug, Clone, Default)]
pub struct SourceMap {
/// Segments: (rendered_range, source_range)
segments: Vec<(Range<usize>, Range<usize>)>,
}
impl SourceMap {
/// Create an empty source map.
pub fn new() -> Self {
Self::default()
}
/// Add a mapping from rendered position to source position.
///
/// The rendered text and source text must have the same length.
pub fn add(&mut self, rendered_start: usize, source_range: Range<usize>) {
let len = source_range.end - source_range.start;
if len > 0 {
self.segments
.push((rendered_start..rendered_start + len, source_range));
}
}
/// Given a rendered byte range, return the corresponding source range.
///
/// Returns None if the range doesn't map cleanly (e.g., spans multiple
/// non-contiguous source regions).
pub fn to_source(&self, rendered: Range<usize>) -> Option<Range<usize>> {
let mut source_start = None;
let mut source_end = None;
for (r_range, s_range) in &self.segments {
if r_range.end <= rendered.start || r_range.start >= rendered.end {
continue;
}
let overlap_start = rendered.start.max(r_range.start);
let overlap_end = rendered.end.min(r_range.end);
let offset_start = overlap_start - r_range.start;
let offset_end = overlap_end - r_range.start;
let s_start = s_range.start + offset_start;
let s_end = s_range.start + offset_end;
source_start = Some(source_start.map_or(s_start, |v: usize| v.min(s_start)));
source_end = Some(source_end.map_or(s_end, |v: usize| v.max(s_end)));
}
match (source_start, source_end) {
(Some(s), Some(e)) => Some(s..e),
_ => None,
}
}
/// Check if the source map is empty.
pub fn is_empty(&self) -> bool {
self.segments.is_empty()
}
/// Get the number of segments.
pub fn len(&self) -> usize {
self.segments.len()
}
/// Extend with entries from another source map, applying offsets.
///
/// Used when combining frozen content with newly rendered tail content.
pub fn extend_with_offsets(
&mut self,
other: &Self,
rendered_offset: usize,
source_offset: usize,
) {
for (r_range, s_range) in &other.segments {
self.segments.push((
(r_range.start + rendered_offset)..(r_range.end + rendered_offset),
(s_range.start + source_offset)..(s_range.end + source_offset),
));
}
}
/// Get read-only access to segments for inspection.
pub fn segments(&self) -> &[(Range<usize>, Range<usize>)] {
&self.segments
}
/// Truncate to keep only the first `n` segments.
pub fn truncate(&mut self, n: usize) {
self.segments.truncate(n);
}
/// Clear all segments.
pub fn clear(&mut self) {
self.segments.clear();
}
}
// ## Restoring Byte-Level Source Maps (if ever needed)
//
// The ratatui rendering path currently only tracks line-level source mapping
// (`line_source_map`), which is sufficient for copy/selection operations.
// Byte-level `SourceMap` was removed for simplicity and ~6% speedup.
//
// To restore byte-level source maps:
//
// 1. Add field to MarkdownRenderOutput and MarkdownRenderView:
// ```
// pub source_map: SourceMap,
// ```
//
// 2. In render_ratatui(), add tracking variables:
// ```
// let mut source_map = SourceMap::new();
// let mut rendered_offset: usize = 0;
// ```
//
// 3. For each text segment emitted, record the mapping:
// ```
// source_map.add(rendered_offset, source_start..source_end);
// rendered_offset += emitted_text.len();
// ```
//
// 4. In streaming.rs, update FrozenState to track:
// ```
// source_map_len: usize,
// rendered_bytes: usize,
// ```
//
// 5. Use SourceMap::extend_with_offsets() to merge tail source maps.
//
// See git history for the removed implementation.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,303 @@
//! Markdown styling types.
//!
//! This module provides the `MarkdownStyle` struct which defines colors and
//! effects for all markdown elements.
use anstyle::{Effects, Style};
use crate::colors::adapt_style;
/// Table border characters for rendering tables in pretty mode.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct TableBorders {
chars: [char; 11],
}
impl TableBorders {
const H: usize = 0;
const V: usize = 1;
const TL: usize = 2;
const TR: usize = 3;
const BL: usize = 4;
const BR: usize = 5;
const T_T: usize = 6;
const T_B: usize = 7;
const T_L: usize = 8;
const T_R: usize = 9;
const X: usize = 10;
pub const BOX: Self = Self {
chars: ['─', '│', '┌', '┐', '└', '┘', '┬', '┴', '├', '┤', '┼'],
};
pub const ASCII: Self = Self {
chars: ['-', '|', '+', '+', '+', '+', '+', '+', '+', '+', '+'],
};
pub const DOUBLE: Self = Self {
chars: ['═', '║', '╔', '╗', '╚', '╝', '╦', '╩', '╠', '╣', '╬'],
};
pub const fn new(chars: [char; 11]) -> Self {
Self { chars }
}
// Short names (used in table formatting)
pub const fn h(&self) -> char {
self.chars[Self::H]
}
pub const fn v(&self) -> char {
self.chars[Self::V]
}
pub const fn c_tl(&self) -> char {
self.chars[Self::TL]
}
pub const fn c_tr(&self) -> char {
self.chars[Self::TR]
}
pub const fn c_bl(&self) -> char {
self.chars[Self::BL]
}
pub const fn c_br(&self) -> char {
self.chars[Self::BR]
}
pub const fn t_t(&self) -> char {
self.chars[Self::T_T]
}
pub const fn t_b(&self) -> char {
self.chars[Self::T_B]
}
pub const fn t_l(&self) -> char {
self.chars[Self::T_L]
}
pub const fn t_r(&self) -> char {
self.chars[Self::T_R]
}
pub const fn x(&self) -> char {
self.chars[Self::X]
}
// Long names (for readability)
pub const fn horizontal(&self) -> char {
self.chars[Self::H]
}
pub const fn vertical(&self) -> char {
self.chars[Self::V]
}
pub const fn top_left(&self) -> char {
self.chars[Self::TL]
}
pub const fn top_right(&self) -> char {
self.chars[Self::TR]
}
pub const fn bottom_left(&self) -> char {
self.chars[Self::BL]
}
pub const fn bottom_right(&self) -> char {
self.chars[Self::BR]
}
pub const fn t_top(&self) -> char {
self.chars[Self::T_T]
}
pub const fn t_bottom(&self) -> char {
self.chars[Self::T_B]
}
pub const fn t_left(&self) -> char {
self.chars[Self::T_L]
}
pub const fn t_right(&self) -> char {
self.chars[Self::T_R]
}
pub const fn cross(&self) -> char {
self.chars[Self::X]
}
}
impl Default for TableBorders {
fn default() -> Self {
Self::BOX
}
}
/// Style configuration for markdown rendering.
///
/// Each field controls the styling for a specific markdown element.
/// The `_inner` variants are applied to the content, while `_outer` variants
/// are applied to the syntax markers (which are hidden in pretty mode).
#[derive(Copy, Clone, Default, Debug, PartialEq, Eq, Hash)]
pub struct MarkdownStyle {
pub heading_inner: [Style; 6],
pub heading_outer: [Style; 6],
pub strong_inner: Style,
pub strong_outer: Style,
pub emphasis_inner: Style,
pub emphasis_outer: Style,
pub strikethrough_inner: Style,
pub strikethrough_outer: Style,
pub inline_code_inner: Style,
pub inline_code_outer: Style,
pub blockquote_outer: Style,
pub task_checked: Style,
pub task_unchecked: Style,
pub list_item: Style,
pub rule: Style,
pub link_outer: Style,
pub link_text: Style,
pub link_url: Style,
pub link_title: Style,
pub code_outer: Style,
pub code_language: Style,
pub code_untagged: Style,
pub code_background: Style,
pub table_outer: Style,
/// Default foreground for plain body text (paragraphs with no formatting).
/// When set, the renderer applies this to text spans that would otherwise
/// inherit the terminal's default foreground.
pub text: Style,
/// Style for rendered LaTeX math (inline `$...$`/`\(...\)` content after
/// Unicode conversion, and display math block lines). In raw mode the
/// style applies to the unconverted TeX source.
pub math: Style,
}
impl MarkdownStyle {
/// Adapt all styles for the terminal's color capabilities.
///
/// This downgrades RGB colors to 256-color or 16-color as needed.
pub fn adapt(self) -> Self {
Self {
heading_inner: [
adapt_style(self.heading_inner[0]),
adapt_style(self.heading_inner[1]),
adapt_style(self.heading_inner[2]),
adapt_style(self.heading_inner[3]),
adapt_style(self.heading_inner[4]),
adapt_style(self.heading_inner[5]),
],
heading_outer: [
adapt_style(self.heading_outer[0]),
adapt_style(self.heading_outer[1]),
adapt_style(self.heading_outer[2]),
adapt_style(self.heading_outer[3]),
adapt_style(self.heading_outer[4]),
adapt_style(self.heading_outer[5]),
],
strong_inner: adapt_style(self.strong_inner),
strong_outer: adapt_style(self.strong_outer),
emphasis_inner: adapt_style(self.emphasis_inner),
emphasis_outer: adapt_style(self.emphasis_outer),
strikethrough_inner: adapt_style(self.strikethrough_inner),
strikethrough_outer: adapt_style(self.strikethrough_outer),
inline_code_inner: adapt_style(self.inline_code_inner),
inline_code_outer: adapt_style(self.inline_code_outer),
blockquote_outer: adapt_style(self.blockquote_outer),
task_checked: adapt_style(self.task_checked),
task_unchecked: adapt_style(self.task_unchecked),
list_item: adapt_style(self.list_item),
rule: adapt_style(self.rule),
link_outer: adapt_style(self.link_outer),
link_text: adapt_style(self.link_text),
link_url: adapt_style(self.link_url),
link_title: adapt_style(self.link_title),
code_outer: adapt_style(self.code_outer),
code_language: adapt_style(self.code_language),
code_untagged: adapt_style(self.code_untagged),
code_background: adapt_style(self.code_background),
table_outer: adapt_style(self.table_outer),
text: adapt_style(self.text),
math: adapt_style(self.math),
}
}
}
/// Check if ALL active styles have HIDDEN effect.
/// Used in pretty mode to determine if text should be skipped.
pub(crate) fn all_hidden(styles: impl IntoIterator<Item = Option<Style>>) -> bool {
let mut has_any = false;
let mut all_are_hidden = true;
for style in styles {
has_any = true;
match style {
Some(s) if s.get_effects().contains(Effects::HIDDEN) => {}
_ => {
all_are_hidden = false;
}
}
}
has_any && all_are_hidden
}
/// Merge multiple styles into one for rendering.
/// Strips HIDDEN from final output - it's a semantic marker, not a visual style.
pub(crate) fn merge_styles(styles: impl IntoIterator<Item = Option<Style>>) -> Style {
let mut out = Style::new();
let mut prev = Style::new();
for style in styles {
if out.get_effects().contains(Effects::HIDDEN) {
out = prev;
} else {
prev = out;
}
let Some(style) = style else {
continue;
};
if !style.get_effects().is_plain() {
out = out.effects(out.get_effects() | style.get_effects());
}
if style.get_effects().contains(Effects::DIMMED) {
out = out.effects(out.get_effects().remove(Effects::BOLD));
}
if style.get_effects().contains(Effects::BOLD) {
out = out.effects(out.get_effects().remove(Effects::DIMMED));
}
if let Some(color) = style.get_fg_color() {
out = out.fg_color(Some(color));
}
if let Some(color) = style.get_bg_color() {
out = out.bg_color(Some(color));
}
if let Some(color) = style.get_underline_color() {
out = out.underline_color(Some(color));
}
}
out.effects(out.get_effects().remove(Effects::HIDDEN))
}
// Simple default style for testing (no colors, just effects)
#[cfg(any(test, fuzzing))]
pub mod test_style {
use super::MarkdownStyle;
use anstyle::Style;
/// A minimal style for testing with no colors.
pub const STYLE: MarkdownStyle = MarkdownStyle {
heading_inner: [Style::new().bold(); 6],
heading_outer: [Style::new().dimmed().hidden(); 6],
strong_inner: Style::new().bold(),
strong_outer: Style::new().dimmed().hidden(),
emphasis_inner: Style::new().italic(),
emphasis_outer: Style::new().dimmed().hidden(),
strikethrough_inner: Style::new().strikethrough(),
strikethrough_outer: Style::new().dimmed().hidden(),
inline_code_inner: Style::new().bold(),
inline_code_outer: Style::new().dimmed().hidden(),
blockquote_outer: Style::new().dimmed(),
task_checked: Style::new(),
task_unchecked: Style::new().dimmed(),
list_item: Style::new().dimmed(),
rule: Style::new(),
link_outer: Style::new(),
link_text: Style::new().bold(),
link_url: Style::new().dimmed(),
link_title: Style::new(),
code_outer: Style::new().dimmed().hidden(),
code_language: Style::new().hidden(),
code_untagged: Style::new(),
code_background: Style::new(),
table_outer: Style::new().bold(),
text: Style::new(),
math: Style::new().italic(),
};
}

View file

@ -0,0 +1,211 @@
//! Syntax highlighting support using syntect.
//!
//! This module provides the `Syntect` struct which holds the syntax definitions
//! and theme for code block highlighting.
use std::io::Cursor;
use std::path::Path;
use syntect::{
easy::HighlightLines,
highlighting::{Theme as SyntectTheme, ThemeSet},
parsing::{SyntaxReference, SyntaxSet},
};
/// Syntax highlighting configuration.
///
/// Holds the theme and syntax definitions for code highlighting.
/// Create one instance and pass it to the markdown renderer.
pub struct Syntect {
/// The color theme for syntax highlighting.
pub theme: SyntectTheme,
/// The syntax definitions (supports 250+ languages via two-face).
pub syntax_set: SyntaxSet,
}
impl Syntect {
/// Create a new Syntect instance from theme bytes.
///
/// The theme bytes should be a TextMate `.tmTheme` file.
/// Uses two-face's extended syntax set with 250+ languages.
///
/// # Example
///
/// ```ignore
/// let syntect = Syntect::new(include_bytes!("assets/tokyo-night.tmTheme"));
/// ```
pub fn new(theme_bytes: &[u8]) -> Self {
let mut cursor = Cursor::new(theme_bytes);
let theme = ThemeSet::load_from_reader(&mut cursor).expect("Failed to load theme");
// Use two-face's extended syntax set which includes 250+ languages from bat
let syntax_set = two_face::syntax::extra_newlines();
Self { theme, syntax_set }
}
/// Find a syntax definition by file path extension.
pub fn find_syntax_by_file_path(&self, file_path: &Path) -> Option<&SyntaxReference> {
let ext = file_path.extension()?.to_str()?;
self.syntax_set.find_syntax_by_extension(ext)
}
/// Find a syntax definition by language token (e.g., "rust", "python").
pub fn find_syntax_by_token(&self, token: &str) -> Option<&SyntaxReference> {
self.syntax_set.find_syntax_by_token(token)
}
/// Create a highlighter for the given file path.
pub fn highlight_lines_by_file_path(&self, file_path: &Path) -> Option<HighlightLines<'_>> {
Some(HighlightLines::new(
self.find_syntax_by_file_path(file_path)?,
&self.theme,
))
}
/// Create a highlighter for the given language token.
pub fn highlight_lines_for_token(&self, token: &str) -> Option<HighlightLines<'_>> {
Some(HighlightLines::new(
self.find_syntax_by_token(token)?,
&self.theme,
))
}
/// Highlighter for a fenced code block *info* string: a normal language token
/// (e.g. `rust`, `python`), or a **line-range citation** of the form
/// `lineStart:lineEnd:path/to/file.ext` where the syntax is resolved the same
/// way as [`Syntect::highlight_lines_by_file_path`] (see
/// [`Syntect::find_syntax_by_file_path`]).
///
/// If the string matches the citation form but no syntax is found for the
/// path, this falls back to [`Syntect::find_syntax_by_token`] with the full
/// `fence_info` string, so plain ` ```lang` blocks keep working and odd
/// citations degrade like the pre-citation code path.
pub fn highlight_lines_for_fence_info(&self, fence_info: &str) -> Option<HighlightLines<'_>> {
Some(HighlightLines::new(
self.find_syntax_for_fence_info(fence_info)?,
&self.theme,
))
}
/// Resolve the [`SyntaxReference`] for a fenced code block *info* string,
/// using the SAME rules as [`Syntect::highlight_lines_for_fence_info`]:
/// a `lineStart:lineEnd:path` citation resolves by file path, otherwise
/// (or if the path has no known syntax) it falls back to a language token.
///
/// Exposed so the incremental open-code highlighter can build its own
/// resumable `ParseState`/`HighlightState` against exactly the syntax the
/// batch `HighlightLines` path would have used — keeping the two
/// byte-identical.
pub(crate) fn find_syntax_for_fence_info(&self, fence_info: &str) -> Option<&SyntaxReference> {
if let Some((_, _, path)) = parse_line_citation_fence_info(fence_info)
&& let Some(s) = self.find_syntax_by_file_path(Path::new(path))
{
return Some(s);
}
self.find_syntax_by_token(fence_info)
}
}
/// ```text
/// lineStart:lineEnd:path/to/file.ext
/// ```
///
/// The path is the segment after the **second** colon; it is then parsed with
/// [`Path::new`]. Paths with extra colons in the first two segments (e.g. some
/// Windows `C:...` forms) are not supported; use a repo-relative or
/// forward-slash form.
fn parse_line_citation_fence_info(info: &str) -> Option<(&str, &str, &str)> {
let mut it = info.splitn(3, ':');
let start = it.next()?;
let end = it.next()?;
let path = it.next()?;
if start.is_empty() || !start.chars().all(|c| c.is_ascii_digit()) {
return None;
}
if end.is_empty() || !end.chars().all(|c| c.is_ascii_digit()) {
return None;
}
if path.is_empty() {
return None;
}
Some((start, end, path))
}
/// Syntax highlight code, returning raw styled segments per line.
///
/// `fence_info` is the fenced code block *info* string (language tag or
/// `lineStart:lineEnd:path` citation form); see
/// [`Syntect::highlight_lines_for_fence_info`]. Lives here (not in `parse`)
/// so both the parser and the streaming highlighter caches depend one-way on
/// `syntax`.
pub(crate) fn syntax_highlight_raw(
syntect: Option<&Syntect>,
fence_info: &str,
text: &str,
) -> Option<Vec<Vec<(syntect::highlighting::Style, String)>>> {
use syntect::util::LinesWithEndings;
let syn = syntect?;
let mut hl = syn.highlight_lines_for_fence_info(fence_info)?;
let mut lines = Vec::new();
for line in LinesWithEndings::from(text) {
let highlighted = hl.highlight_line(line, &syn.syntax_set).ok()?;
lines.push(
highlighted
.into_iter()
.map(|(s, t)| (s, t.to_string()))
.collect(),
);
}
Some(lines)
}
/// Get a shared Syntect instance for tests.
///
/// This loads the tokyo-night theme bundled with the crate.
/// Uses a static OnceLock for efficiency in test runs.
#[cfg(any(test, fuzzing))]
#[allow(dead_code)]
pub fn test_syntect() -> &'static Syntect {
use std::sync::OnceLock;
static TEST_SYNTECT: OnceLock<Syntect> = OnceLock::new();
TEST_SYNTECT.get_or_init(|| Syntect::new(include_bytes!("../assets/tokyo-night.tmTheme")))
}
#[cfg(test)]
mod tests {
use super::parse_line_citation_fence_info;
#[test]
fn line_citation_fence_parses_start_end_path() {
assert_eq!(
parse_line_citation_fence_info("37:65:crates/example/src/tools/read.rs"),
Some(("37", "65", "crates/example/src/tools/read.rs"))
);
}
#[test]
fn line_citation_rejects_non_numeric_line() {
assert_eq!(parse_line_citation_fence_info("37:ab:file.rs"), None);
}
#[test]
fn line_citation_rejects_plain_lang_token() {
assert_eq!(parse_line_citation_fence_info("rust"), None);
assert_eq!(parse_line_citation_fence_info(""), None);
}
#[test]
fn highlight_lines_for_fence_info_resolves_citation_path_to_rust() {
let s = super::test_syntect();
assert!(
s.highlight_lines_for_fence_info("37:65:crates/codegen/xai-grok-markdown/src/parse.rs")
.is_some()
);
}
#[test]
fn highlight_lines_for_fence_info_still_accepts_rust_token() {
let s = super::test_syntect();
assert!(s.highlight_lines_for_fence_info("rust").is_some());
}
}

View file

@ -0,0 +1,429 @@
//! Plain-URL detection over rendered display ratatui Lines.
use linkify::{LinkFinder, LinkKind};
use ratatui::text::Line;
use crate::buffers::unicode_display_width;
use crate::output::HyperlinkTarget;
/// Scan `lines` for plain URLs and return new `HyperlinkTarget` entries
/// that don't overlap any existing target in `existing`.
///
/// `next_id` is the first id to assign; the returned `u32` is the
/// post-scan counter, suitable for stuffing back into
/// `FrozenState::next_link_id`.
pub(crate) fn detect_plain_urls(
lines: &[Line<'_>],
existing: &[HyperlinkTarget],
next_id: u32,
) -> (Vec<HyperlinkTarget>, u32) {
detect_plain_urls_with_offset(lines, 0, existing, next_id)
}
/// Like [`detect_plain_urls`] but scans `lines` whose first element
/// represents document line `line_index_offset` (caller passes a tail
/// slice of `self.output.lines` and the index of its first element).
///
/// Lines fully inside `0..line_index_offset` are assumed to be in
/// `existing` already and are not re-scanned. The dedup overlap check
/// still works correctly because emitted targets use document-absolute
/// `line_index = line_index_offset + i`, matching the indices already
/// present in `existing`.
pub(crate) fn detect_plain_urls_with_offset(
lines: &[Line<'_>],
line_index_offset: usize,
existing: &[HyperlinkTarget],
next_id: u32,
) -> (Vec<HyperlinkTarget>, u32) {
let mut result = Vec::new();
let mut current_id = next_id;
let mut finder = LinkFinder::new();
finder.kinds(&[LinkKind::Url]);
for (i, line) in lines.iter().enumerate() {
let line_index = line_index_offset + i;
let mut display_col: usize = 0;
for span in &line.spans {
let span_text: &str = span.content.as_ref();
for link in finder.links(span_text) {
let before = &span_text[..link.start()];
let matched = &span_text[link.start()..link.end()];
let col_start = display_col + unicode_display_width(before);
let col_end = col_start + unicode_display_width(matched);
let url = link.as_str().to_string();
// Dedup: skip if any existing or already-added target overlaps
// on the same line. Overlap: cand.start < ex.end && ex.start < cand.end.
let overlaps = existing.iter().chain(result.iter()).any(|h| {
h.line_index == line_index
&& col_start < h.column_range.end
&& h.column_range.start < col_end
});
if !overlaps {
result.push(HyperlinkTarget {
line_index,
column_range: col_start..col_end,
url,
id: current_id,
});
current_id += 1;
}
}
display_col += unicode_display_width(span_text);
}
}
(result, current_id)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::StreamingMarkdownRenderer;
use crate::style::test_style;
/// Helper: render markdown via StreamingMarkdownRenderer::finish() and
/// return the hyperlinks from the finalized output.
fn finish_and_get_hyperlinks(text: &str) -> Vec<HyperlinkTarget> {
let mut renderer = StreamingMarkdownRenderer::new(test_style::STYLE, true);
renderer.push_and_render(text, None);
let view = renderer.finish(None);
view.hyperlinks.to_vec()
}
fn line_to_string(line: &Line<'static>) -> String {
line.spans.iter().map(|s| s.content.as_ref()).collect()
}
#[test]
fn plain_url_in_prose_produces_target() {
let text = "See https://example.com for details.\n";
let hyperlinks = finish_and_get_hyperlinks(text);
assert_eq!(hyperlinks.len(), 1, "exactly one hyperlink expected");
let h = &hyperlinks[0];
assert_eq!(h.url, "https://example.com");
// Verify column range covers only the URL
let mut renderer = StreamingMarkdownRenderer::new(test_style::STYLE, true);
renderer.push_and_render(text, None);
let view = renderer.finish(None);
let rendered = line_to_string(&view.lines[h.line_index]);
let slice: String = rendered
.chars()
.skip(h.column_range.start)
.take(h.column_range.len())
.collect();
assert_eq!(slice, "https://example.com");
}
#[test]
fn multiple_urls_one_line_distinct_ids() {
let text = "See https://a.example and https://b.example.\n";
let hyperlinks = finish_and_get_hyperlinks(text);
assert_eq!(hyperlinks.len(), 2, "two hyperlinks expected");
assert_ne!(hyperlinks[0].id, hyperlinks[1].id, "ids must differ");
assert_eq!(hyperlinks[0].url, "https://a.example");
assert_eq!(hyperlinks[1].url, "https://b.example");
// Column ranges must be disjoint
assert!(
hyperlinks[0].column_range.end <= hyperlinks[1].column_range.start,
"column ranges must be disjoint, got {:?} vs {:?}",
hyperlinks[0].column_range,
hyperlinks[1].column_range,
);
}
#[test]
fn markdown_link_with_url_text_does_not_double_link() {
let text = "Visit [https://example.com](https://example.com).\n";
let hyperlinks = finish_and_get_hyperlinks(text);
// NOTE: one might expect 1 target, but in pretty mode
// `[url](url)` renders as `url (url)`, showing the URL in two
// distinct positions: once as the link text (covered by the parser's
// HyperlinkTarget) and once in the `(url)` suffix at a disjoint
// column range. Dedup prevents a *third* entry at the same column
// range as the parser-produced target; the second entry (at the
// suffix position) is correctly detected as a separate target.
assert_eq!(
hyperlinks.len(),
2,
"expected 2 hyperlinks (parser link text + URL in pretty-mode suffix), got {}",
hyperlinks.len()
);
// Both should reference the same URL.
assert!(hyperlinks.iter().all(|h| h.url == "https://example.com"));
// Column ranges must be disjoint (dedup working correctly).
assert!(
hyperlinks[0].column_range.end <= hyperlinks[1].column_range.start
|| hyperlinks[1].column_range.end <= hyperlinks[0].column_range.start,
"column ranges must be disjoint, got {:?} and {:?}",
hyperlinks[0].column_range,
hyperlinks[1].column_range,
);
}
#[test]
fn autolink_does_not_double_link() {
let text = "Visit <https://example.com>.\n";
let hyperlinks = finish_and_get_hyperlinks(text);
// All entries should share the same URL — autolinks may produce
// multiple HyperlinkTarget fragments sharing the same id.
let autolink_count = hyperlinks
.iter()
.filter(|h| h.url == "https://example.com")
.count();
assert!(autolink_count >= 1, "expected at least one autolink target");
// The total count should match the autolink fragments only — no
// extra plain-URL duplicates.
assert_eq!(
hyperlinks.len(),
autolink_count,
"plain-URL scan should not add duplicates on top of autolink targets"
);
}
#[test]
fn trailing_period_excluded_from_url() {
let text = "See https://example.com.\n";
let hyperlinks = finish_and_get_hyperlinks(text);
assert_eq!(hyperlinks.len(), 1);
assert_eq!(
hyperlinks[0].url, "https://example.com",
"trailing dot should be excluded by linkify"
);
}
#[test]
fn cjk_neighbors_preserve_correct_columns() {
use crate::buffers::unicode_display_width;
let text = "日本語 https://example.com 日本語\n";
let hyperlinks = finish_and_get_hyperlinks(text);
assert_eq!(hyperlinks.len(), 1);
let h = &hyperlinks[0];
assert_eq!(h.url, "https://example.com");
// "日本語 " has 3 CJK chars (2 cells each) + 1 space = 7 display cells
let prefix = "日本語 ";
let expected_start = unicode_display_width(prefix);
assert_eq!(expected_start, 7, "prefix should be 7 display cells");
assert_eq!(h.column_range.start, expected_start);
let url_width = unicode_display_width("https://example.com");
assert_eq!(h.column_range.end, expected_start + url_width);
}
#[test]
fn empty_document_returns_empty() {
let hyperlinks = finish_and_get_hyperlinks("");
assert!(
hyperlinks.is_empty(),
"empty document should produce no hyperlinks"
);
}
/// Behavior pin: URL inside inline code.
///
/// This test pins the *current* behavior — if linkify matches inside
/// the code-styled span, the URL becomes a HyperlinkTarget. If we
/// later decide to skip code-styled spans, this test will fail loudly
/// and force the change to be intentional.
#[test]
fn url_inside_inline_code_documented_behavior() {
let text = "Use `https://example.com` carefully.\n";
let hyperlinks = finish_and_get_hyperlinks(text);
// Pin observed behavior: linkify finds the URL inside the
// code-styled span, producing a HyperlinkTarget.
assert!(
!hyperlinks.is_empty(),
"behavior pin: URL inside inline code currently produces a HyperlinkTarget"
);
assert_eq!(hyperlinks[0].url, "https://example.com");
}
/// Behavior pin: URL inside a fenced code block.
///
/// Same rationale as `url_inside_inline_code_documented_behavior` —
/// this pins current behavior, not product spec.
#[test]
fn url_inside_code_fence_documented_behavior() {
let text = "```\nsee https://example.com\n```\n";
let hyperlinks = finish_and_get_hyperlinks(text);
// Pin observed behavior: linkify finds the URL in the code block's
// rendered text. Whether this is desirable is a product decision;
// this test ensures any change is intentional.
let has_url = hyperlinks.iter().any(|h| h.url == "https://example.com");
assert!(
has_url,
"behavior pin: URL inside fenced code block currently produces a HyperlinkTarget"
);
}
/// URL detection must run from `render()` too, not only `finish()`.
/// Otherwise width changes or other state resets (e.g. via
/// `set_max_table_width`) drop the URL hyperlinks that pretty-mode
/// rendering adds for the `(url)` suffix of markdown links.
///
/// Also pins the OSC 8 grouping invariant: the link-text and URL
/// hyperlinks must have DISTINCT ids and DISJOINT column ranges, so
/// terminals group them as two separate hyperlinks (not one merged
/// underline across the brackets).
#[test]
fn render_detects_pretty_mode_url_suffix() {
let text = "[link](https://example.com/some/long/path)\n";
let mut renderer = StreamingMarkdownRenderer::new(test_style::STYLE, true);
renderer.push_and_render(text, None);
// No finish() call: after render() alone, both the parser-produced
// (link text) and the url_scan-produced (URL in `(url)` suffix)
// hyperlinks must be present.
let view = renderer.view();
assert_eq!(
view.hyperlinks.len(),
2,
"render() must produce both the link-text and URL-suffix hyperlinks; \
got {:?}",
view.hyperlinks,
);
assert!(
view.hyperlinks
.iter()
.all(|h| h.url == "https://example.com/some/long/path")
);
assert_ne!(
view.hyperlinks[0].id, view.hyperlinks[1].id,
"link-text and URL-suffix hyperlinks must have distinct OSC 8 ids",
);
let (a, b) = (&view.hyperlinks[0], &view.hyperlinks[1]);
assert!(
a.column_range.end <= b.column_range.start
|| b.column_range.end <= a.column_range.start,
"column ranges must be disjoint, got {:?} and {:?}",
a.column_range,
b.column_range,
);
}
/// Snapshot helper used by survival tests below.
fn snapshot(view: &crate::output::MarkdownRenderView<'_>) -> Vec<HyperlinkTarget> {
let mut snap: Vec<HyperlinkTarget> = view.hyperlinks.to_vec();
snap.sort_by_key(|h| (h.line_index, h.column_range.start));
snap
}
fn assert_url_suffix_preserved(
before: &[HyperlinkTarget],
after: &[HyperlinkTarget],
url: &str,
) {
let before_suffix = before
.iter()
.find(|h| h.url == url && h.column_range.start > 5)
.expect("URL-suffix hyperlink must be present BEFORE reset");
let after_suffix = after
.iter()
.find(|h| h.url == url && h.column_range.start > 5)
.expect("URL-suffix hyperlink must be present AFTER reset");
assert_eq!(
before_suffix.column_range, after_suffix.column_range,
"URL-suffix column range must be stable across the reset",
);
assert_eq!(
before_suffix.line_index, after_suffix.line_index,
"URL-suffix line index must be stable across the reset",
);
}
/// After `finish()`, re-rendering (e.g. triggered by a width change
/// via `set_max_table_width`) must NOT drop the URL hyperlinks that
/// pretty-mode adds for the `(url)` suffix.
///
/// Snapshots the full hyperlink list before/after the reset and
/// asserts that the URL-suffix entry survives with its column range
/// intact (the OSC 8 id may be re-assigned by the post-reset
/// re-render — that's expected — but the location must not move).
#[test]
fn url_hyperlinks_survive_re_render_after_finish() {
let url = "https://example.com/some/long/path";
let text = format!("[link]({url})\n");
let mut renderer = StreamingMarkdownRenderer::new(test_style::STYLE, true);
renderer.push_and_render(&text, None);
renderer.finish(None);
let before = snapshot(&renderer.view());
// Simulate a width change which resets renderer state.
renderer.set_max_table_width(Some(40));
renderer.render(None);
let after = snapshot(&renderer.view());
assert_eq!(
before.len(),
after.len(),
"hyperlink count must be stable across the reset; before={before:?} after={after:?}",
);
assert_url_suffix_preserved(&before, &after, url);
}
/// Identical contract to `url_hyperlinks_survive_re_render_after_finish`
/// but exercising the `set_pretty` reset path (production:
/// `MarkdownContent::set_raw_mode` toggle).
#[test]
fn url_hyperlinks_survive_re_render_after_set_pretty_toggle() {
let url = "https://example.com/some/long/path";
let text = format!("[link]({url})\n");
let mut renderer = StreamingMarkdownRenderer::new(test_style::STYLE, true);
renderer.push_and_render(&text, None);
renderer.finish(None);
let before = snapshot(&renderer.view());
// Toggle pretty off then back on — both transitions reset state.
renderer.set_pretty(false);
renderer.set_pretty(true);
renderer.render(None);
let after = snapshot(&renderer.view());
assert_eq!(
before.len(),
after.len(),
"hyperlink count must be stable across the set_pretty toggle",
);
assert_url_suffix_preserved(&before, &after, url);
}
/// Identical contract to `url_hyperlinks_survive_re_render_after_finish`
/// but exercising the `set_style` reset path (production: theme change
/// via `MarkdownContent::ensure_wrapped` when theme cache kind shifts).
#[test]
fn url_hyperlinks_survive_re_render_after_set_style() {
let url = "https://example.com/some/long/path";
let text = format!("[link]({url})\n");
let mut renderer = StreamingMarkdownRenderer::new(test_style::STYLE, true);
renderer.push_and_render(&text, None);
renderer.finish(None);
let before = snapshot(&renderer.view());
// `set_style` unconditionally resets state, even with same style.
renderer.set_style(test_style::STYLE);
renderer.render(None);
let after = snapshot(&renderer.view());
assert_eq!(
before.len(),
after.len(),
"hyperlink count must be stable across the set_style reset",
);
assert_url_suffix_preserved(&before, &after, url);
}
}