返回 DeepSeek-TUI-2026
markdown_render.rs
根目录 / crates / tui / src / tui / markdown_render.rs
1 //! Markdown rendering for TUI transcript lines.
2 //!
3 //! ## Width-independent parse vs width-dependent render (CX#6)
4 //!
5 //! The previous renderer was a single function `render_markdown(content, width)`
6 //! that scanned the source, classified each line (heading / list / code-fence /
7 //! paragraph / link), and word-wrapped to `Line<'static>` in one pass. That meant
8 //! every terminal resize forced a full re-parse of the source for every visible
9 //! cell — wasted work on the streaming cell whose content is changing anyway.
10 //!
11 //! The codex tui solves this by splitting parse from render. We mirror that:
12 //!
13 //! * [`parse`] turns the markdown source into a [`ParsedMarkdown`] AST: a vector
14 //! of width-independent [`Block`]s. The block kind already records all the
15 //! classification decisions (heading level, list bullet, code block membership)
16 //! that don't depend on width.
17 //! * [`render_parsed`] takes a `ParsedMarkdown` plus a width and a base style and
18 //! produces `Vec<Line<'static>>`. It only does word-wrap and span styling.
19 //!
20 //! [`render_markdown`] is kept as a thin convenience that does both — useful for
21 //! callers (Thinking body, message body) that don't want to manage the cache.
22 //!
23 //! The transcript cache layer (see `tui/transcript.rs`) caches the parsed AST per
24 //! cell and re-runs only the render step on width changes. That makes resize a
25 //! re-flow operation rather than a re-parse + re-flow operation.
26
27 #[cfg(test)]
28 use std::cell::Cell;
29
30 use ratatui::style::{Modifier, Style};
31 use ratatui::text::{Line, Span};
32 use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
33
34 use crate::palette;
35 use crate::tui::osc8;
36
37 // Thread-local counter incremented every time `parse` runs. Used by tests to
38 // prove that width-only changes hit the cached-AST path and skip parsing.
39 // Thread-local (not global atomic) so concurrent tests calling `parse()` can't
40 // pollute each other's counters.
41 #[cfg(test)]
42 thread_local! {
43 static PARSE_INVOCATIONS: Cell<u64> = const { Cell::new(0) };
44 }
45
46 #[cfg(test)]
47 #[must_use]
48 pub fn parse_invocation_count() -> u64 {
49 PARSE_INVOCATIONS.with(|c| c.get())
50 }
51
52 #[cfg(test)]
53 pub fn reset_parse_invocation_count() {
54 PARSE_INVOCATIONS.with(|c| c.set(0));
55 }
56
57 /// One classified line of markdown source, width-independent.
58 ///
59 /// All decisions that depend only on the source text (heading level, bullet
60 /// kind, whether we're inside a fenced code block, paragraph text) are made at
61 /// parse time. Width-dependent layout (word-wrap, prefix indent) is deferred to
62 /// the render step.
63 #[derive(Debug, Clone, PartialEq, Eq)]
64 pub enum Block {
65 /// `# heading text`. Includes the heading level (1..6).
66 Heading { level: usize, text: String },
67 /// A horizontal rule emitted under a level-1 heading.
68 HeadingRule,
69 /// A standalone `---` / `***` / `___` horizontal rule.
70 HorizontalRule,
71 /// A bullet (`-`/`*`) or ordered (`1.`) list item with its prefix and body.
72 ListItem { bullet: String, text: String },
73 /// A line inside a fenced code block. Fences themselves are dropped.
74 Code { line: String },
75 /// A table row: cells split on `|`.
76 TableRow(Vec<String>),
77 /// A table separator row (`|---|---|`). Kept so the renderer can draw
78 /// horizontal rules at the correct positions.
79 TableSeparator,
80 /// A non-empty paragraph line that may contain inline links.
81 Paragraph { text: String },
82 /// An empty source line, preserved so paragraph spacing survives.
83 Blank,
84 }
85
86 /// Width-independent parsed-markdown AST for one cell's source.
87 ///
88 /// Wrapped in `Arc` at the cache layer so the cache can hand the same AST to
89 /// many render calls without copying.
90 #[derive(Debug, Clone, PartialEq, Eq)]
91 pub struct ParsedMarkdown {
92 blocks: Vec<Block>,
93 }
94
95 /// Parse markdown source into a width-independent block AST.
96 ///
97 /// This is a small line-oriented parser tuned for the patterns we render:
98 /// fenced code blocks, ATX headings, dash/star/numbered list items, and plain
99 /// paragraphs with optional links. It does not attempt to handle every CommonMark
100 /// edge case — that's intentional. The renderer will treat anything we don't
101 /// classify as `Block::Paragraph`.
102 #[must_use]
103 pub fn parse(content: &str) -> ParsedMarkdown {
104 #[cfg(test)]
105 PARSE_INVOCATIONS.with(|c| c.set(c.get() + 1));
106
107 let mut blocks = Vec::new();
108 let mut in_code_block = false;
109
110 for raw_line in content.lines() {
111 let trimmed = raw_line.trim_start();
112 if trimmed.starts_with("```") {
113 in_code_block = !in_code_block;
114 continue;
115 }
116
117 if in_code_block {
118 blocks.push(Block::Code {
119 line: raw_line.to_string(),
120 });
121 continue;
122 }
123
124 if let Some((level, text)) = parse_heading(trimmed) {
125 blocks.push(Block::Heading {
126 level,
127 text: text.to_string(),
128 });
129 if level == 1 {
130 blocks.push(Block::HeadingRule);
131 }
132 continue;
133 }
134
135 if let Some((bullet, text)) = parse_list_item(trimmed) {
136 blocks.push(Block::ListItem {
137 bullet,
138 text: text.to_string(),
139 });
140 continue;
141 }
142
143 if is_horizontal_rule(trimmed) {
144 blocks.push(Block::HorizontalRule);
145 continue;
146 }
147
148 match parse_table_row(trimmed) {
149 Some(cells) => {
150 blocks.push(Block::TableRow(cells));
151 continue;
152 }
153 None if trimmed.starts_with('|') => {
154 blocks.push(Block::TableSeparator);
155 continue;
156 }
157 None => {}
158 }
159
160 if raw_line.is_empty() {
161 blocks.push(Block::Blank);
162 continue;
163 }
164
165 blocks.push(Block::Paragraph {
166 text: trimmed.to_string(),
167 });
168 }
169
170 ParsedMarkdown { blocks }
171 }
172
173 /// Render a parsed-markdown AST at the given terminal width.
174 ///
175 /// This is the width-dependent half: word-wrapping, link styling, code-block
176 /// formatting. The AST is owned by the caller (typically the transcript cache),
177 /// so width-only changes can call `render_parsed` again with the same AST and
178 /// skip the parse step entirely.
179 #[must_use]
180 pub fn render_parsed(parsed: &ParsedMarkdown, width: u16, base_style: Style) -> Vec<Line<'static>> {
181 let width = width.max(1) as usize;
182 let mut out: Vec<Line<'static>> = Vec::with_capacity(parsed.blocks.len());
183
184 let mut i = 0;
185 while i < parsed.blocks.len() {
186 if matches!(
187 &parsed.blocks[i],
188 Block::TableRow(_) | Block::TableSeparator
189 ) {
190 let start = i;
191 while i < parsed.blocks.len()
192 && matches!(
193 &parsed.blocks[i],
194 Block::TableRow(_) | Block::TableSeparator
195 )
196 {
197 i += 1;
198 }
199 out.extend(render_table_group(
200 &parsed.blocks[start..i],
201 width,
202 base_style,
203 ));
204 continue;
205 }
206
207 match &parsed.blocks[i] {
208 Block::Heading { text, .. } => {
209 let style = Style::default()
210 .fg(palette::DEEPSEEK_SKY)
211 .add_modifier(Modifier::BOLD);
212 out.extend(render_wrapped_line(text, width, style, false));
213 }
214 Block::HeadingRule => {
215 out.push(Line::from(Span::styled(
216 "─".repeat(width.min(40)),
217 Style::default().fg(palette::TEXT_DIM),
218 )));
219 }
220 Block::HorizontalRule => {
221 out.push(Line::from(Span::styled(
222 "─".repeat(width.min(60)),
223 Style::default().fg(palette::TEXT_DIM),
224 )));
225 }
226 Block::ListItem { bullet, text } => {
227 let bullet_style = Style::default().fg(palette::DEEPSEEK_SKY);
228 out.extend(render_list_line(
229 bullet,
230 text,
231 width,
232 bullet_style,
233 base_style,
234 ));
235 }
236 Block::Code { line } => {
237 let code_style = Style::default()
238 .fg(palette::DEEPSEEK_SKY)
239 .add_modifier(Modifier::ITALIC);
240 out.extend(render_wrapped_line(line, width, code_style, true));
241 }
242 Block::Paragraph { text } => {
243 let link_style = Style::default()
244 .fg(palette::DEEPSEEK_BLUE)
245 .add_modifier(Modifier::UNDERLINED);
246 out.extend(render_line_with_links(text, width, base_style, link_style));
247 }
248 Block::Blank => {
249 out.push(Line::from(""));
250 }
251 Block::TableRow(_) | Block::TableSeparator => unreachable!(),
252 }
253 i += 1;
254 }
255
256 if out.is_empty() {
257 out.push(Line::from(""));
258 }
259
260 out
261 }
262
263 /// Convenience wrapper: parse + render in one call.
264 ///
265 /// Equivalent to `render_parsed(&parse(content), width, base_style)`. Callers
266 /// that don't manage their own cache (the Thinking body, the immediate message
267 /// body) use this.
268 #[must_use]
269 pub fn render_markdown(content: &str, width: u16, base_style: Style) -> Vec<Line<'static>> {
270 let parsed = parse(content);
271 render_parsed(&parsed, width, base_style)
272 }
273
274 fn parse_heading(line: &str) -> Option<(usize, &str)> {
275 let trimmed = line.trim_start();
276 let hashes = trimmed.chars().take_while(|c| *c == '#').count();
277 if hashes == 0 {
278 return None;
279 }
280 let text = trimmed[hashes..].trim();
281 if text.is_empty() {
282 None
283 } else {
284 Some((hashes, text))
285 }
286 }
287
288 fn parse_list_item(line: &str) -> Option<(String, &str)> {
289 let trimmed = line.trim_start();
290 if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
291 return Some(("-".to_string(), trimmed[2..].trim()));
292 }
293 let bytes = trimmed.as_bytes();
294 let mut idx = 0;
295 while idx < bytes.len() && bytes[idx].is_ascii_digit() {
296 idx += 1;
297 }
298 if idx == 0 || idx >= bytes.len() || bytes[idx] != b'.' {
299 return None;
300 }
301 let rest = &trimmed[idx + 1..];
302 if !rest.starts_with(' ') {
303 return None;
304 }
305 Some((format!("{}.", &trimmed[..idx]), rest.trim_start()))
306 }
307
308 fn render_wrapped_line(
309 line: &str,
310 width: usize,
311 style: Style,
312 indent_code: bool,
313 ) -> Vec<Line<'static>> {
314 let prefix = if indent_code { " " } else { "" };
315 let prefix_width = prefix.width();
316 let available = width.saturating_sub(prefix_width).max(1);
317 let wrapped = wrap_text(line, available);
318 let mut out = Vec::new();
319
320 for (idx, chunk) in wrapped.into_iter().enumerate() {
321 if idx == 0 {
322 out.push(Line::from(vec![
323 Span::raw(prefix),
324 Span::styled(chunk, style),
325 ]));
326 } else {
327 out.push(Line::from(vec![
328 Span::raw(" ".repeat(prefix_width)),
329 Span::styled(chunk, style),
330 ]));
331 }
332 }
333
334 out
335 }
336
337 fn render_list_line(
338 bullet: &str,
339 text: &str,
340 width: usize,
341 bullet_style: Style,
342 text_style: Style,
343 ) -> Vec<Line<'static>> {
344 let bullet_prefix = format!("{bullet} ");
345 let bullet_width = bullet_prefix.width();
346 let available = width.saturating_sub(bullet_width).max(1);
347 let wrapped = render_line_with_links(text, available, text_style, link_style());
348
349 let mut out = Vec::new();
350 for (idx, line) in wrapped.into_iter().enumerate() {
351 if idx == 0 {
352 let mut spans = vec![Span::styled(bullet_prefix.clone(), bullet_style)];
353 spans.extend(line.spans);
354 out.push(Line::from(spans));
355 } else {
356 let mut spans = vec![Span::raw(" ".repeat(bullet_width))];
357 spans.extend(line.spans);
358 out.push(Line::from(spans));
359 }
360 }
361 out
362 }
363
364 fn render_line_with_links(
365 line: &str,
366 width: usize,
367 base_style: Style,
368 link_style: Style,
369 ) -> Vec<Line<'static>> {
370 if line.trim().is_empty() {
371 return vec![Line::from("")];
372 }
373
374 // Flatten inline tokens into (word, style) pairs preserving inter-token spaces.
375 let tokens = parse_inline_spans(line, base_style, link_style);
376 let mut words: Vec<(String, Style)> = Vec::new();
377 for (text, style) in tokens {
378 let mut first = true;
379 for part in text.split(' ') {
380 if !first {
381 // The space consumed by split — attach as a plain space word
382 // so the wrap loop can decide whether to keep or break it.
383 words.push((" ".to_string(), style));
384 }
385 if !part.is_empty() {
386 words.push((part.to_string(), style));
387 }
388 first = false;
389 }
390 }
391
392 let mut lines = Vec::new();
393 let mut current_spans: Vec<Span> = Vec::new();
394 let mut current_width = 0usize;
395
396 for (word, style) in words {
397 let ww = word.width();
398 if word == " " {
399 // Space: emit only if we're mid-line and it fits; otherwise drop
400 // (it's a potential wrap point, not content).
401 if !current_spans.is_empty() && current_width < width {
402 current_spans.push(Span::raw(" "));
403 current_width += 1;
404 }
405 continue;
406 }
407 // Wrap before this word if it doesn't fit.
408 if current_width > 0 && current_width + ww > width {
409 // Trim trailing space span before breaking.
410 if let Some(last) = current_spans.last()
411 && last.content.as_ref() == " "
412 {
413 current_spans.pop();
414 }
415 lines.push(Line::from(current_spans));
416 current_spans = Vec::new();
417 current_width = 0;
418 }
419 current_spans.push(Span::styled(word, style));
420 current_width += ww;
421 }
422
423 if !current_spans.is_empty() {
424 lines.push(Line::from(current_spans));
425 }
426 if lines.is_empty() {
427 lines.push(Line::from(""));
428 }
429 lines
430 }
431
432 /// Parse an entire line into (text, style) segments, handling **bold**,
433 /// *italic*, `code`, ~~strikethrough~~, [text](url) links, and bare URLs.
434 fn parse_inline_spans(line: &str, base_style: Style, link_style: Style) -> Vec<(String, Style)> {
435 let bold_style = base_style.add_modifier(Modifier::BOLD);
436 let italic_style = base_style.add_modifier(Modifier::ITALIC);
437 let code_style = base_style
438 .add_modifier(Modifier::ITALIC)
439 .bg(palette::SURFACE_ELEVATED);
440 let strike_style = base_style.add_modifier(Modifier::CROSSED_OUT);
441 let mut out = Vec::new();
442 let mut rest = line;
443
444 while !rest.is_empty() {
445 // **bold**
446 if let Some(end) = rest.strip_prefix("**").and_then(|s| s.find("**")) {
447 let inner = &rest[2..2 + end];
448 out.push((inner.to_string(), bold_style));
449 rest = &rest[2 + end + 2..];
450 continue;
451 }
452 // __bold__
453 if let Some(end) = rest.strip_prefix("__").and_then(|s| s.find("__")) {
454 let inner = &rest[2..2 + end];
455 out.push((inner.to_string(), bold_style));
456 rest = &rest[2 + end + 2..];
457 continue;
458 }
459 // *italic*
460 if rest.starts_with('*')
461 && !rest.starts_with("**")
462 && let Some(end) = rest[1..].find('*')
463 {
464 let inner = &rest[1..1 + end];
465 out.push((inner.to_string(), italic_style));
466 rest = &rest[1 + end + 1..];
467 continue;
468 }
469 // _italic_
470 if rest.starts_with('_')
471 && !rest.starts_with("__")
472 && let Some(end) = rest[1..].find('_')
473 {
474 let inner = &rest[1..1 + end];
475 out.push((inner.to_string(), italic_style));
476 rest = &rest[1 + end + 1..];
477 continue;
478 }
479 // `inline code`
480 if let Some(end) = rest.strip_prefix('`').and_then(|s| s.find('`')) {
481 let inner = &rest[1..1 + end];
482 out.push((inner.to_string(), code_style));
483 rest = &rest[1 + end + 1..];
484 continue;
485 }
486 // ~~strikethrough~~
487 if let Some(end) = rest.strip_prefix("~~").and_then(|s| s.find("~~")) {
488 let inner = &rest[2..2 + end];
489 out.push((inner.to_string(), strike_style));
490 rest = &rest[2 + end + 2..];
491 continue;
492 }
493 // [text](url)
494 if rest.starts_with('[')
495 && let Some(bracket_end) = rest.find(']')
496 {
497 let text = &rest[1..bracket_end];
498 let after_bracket = &rest[bracket_end + 1..];
499 if after_bracket.starts_with('(')
500 && let Some(paren_end) = after_bracket.find(')')
501 {
502 let url = &after_bracket[1..paren_end];
503 let content = if osc8::enabled() {
504 osc8::wrap_link(url, text)
505 } else {
506 format!("{text} ({url})")
507 };
508 out.push((content, link_style));
509 rest = &after_bracket[paren_end + 1..];
510 continue;
511 }
512 }
513 // URL: consume until whitespace
514 if rest.starts_with("http://") || rest.starts_with("https://") {
515 let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
516 let url = &rest[..end];
517 let content = if osc8::enabled() {
518 osc8::wrap_link(url, url)
519 } else {
520 url.to_string()
521 };
522 out.push((content, link_style));
523 rest = &rest[end..];
524 continue;
525 }
526 // Plain text: consume until next marker or URL; always advance at least 1 char.
527 let next = find_next_marker(rest).max(rest.chars().next().map_or(1, |c| c.len_utf8()));
528 out.push((rest[..next].to_string(), base_style));
529 rest = &rest[next..];
530 }
531 out
532 }
533
534 /// Find the index of the next inline marker (`**`, `__`, `*`, `_`, `http`)
535 /// in `s`, or `s.len()` if none found.
536 fn find_next_marker(s: &str) -> usize {
537 let mut i = 0;
538 let bytes = s.as_bytes();
539 while i < bytes.len() {
540 let ch_len = s[i..].chars().next().map_or(1, |c| c.len_utf8());
541 let slice = &s[i..];
542 if slice.starts_with("**")
543 || slice.starts_with("__")
544 || slice.starts_with("~~")
545 || slice.starts_with('`')
546 || slice.starts_with('[')
547 || (slice.starts_with('*') && !slice.starts_with("**"))
548 || (slice.starts_with('_') && !slice.starts_with("__"))
549 || slice.starts_with("http://")
550 || slice.starts_with("https://")
551 {
552 return i;
553 }
554 i += ch_len;
555 }
556 s.len()
557 }
558
559 fn is_horizontal_rule(line: &str) -> bool {
560 let stripped: String = line.chars().filter(|c| !c.is_whitespace()).collect();
561 (stripped.chars().all(|c| c == '-')
562 || stripped.chars().all(|c| c == '*')
563 || stripped.chars().all(|c| c == '_'))
564 && stripped.len() >= 3
565 }
566
567 /// Parse a markdown table row like `| foo | bar |` into trimmed cell strings.
568 /// Returns `None` for separator rows (`|---|---|`).
569 fn parse_table_row(line: &str) -> Option<Vec<String>> {
570 if !line.starts_with('|') {
571 return None;
572 }
573 let inner = line.trim_matches('|');
574 let cells: Vec<String> = inner.split('|').map(|c| c.trim().to_string()).collect();
575 // Separator row: every non-empty cell is only dashes/colons/spaces
576 if cells
577 .iter()
578 .all(|c| c.is_empty() || c.chars().all(|ch| ch == '-' || ch == ':' || ch == ' '))
579 {
580 return None;
581 }
582 Some(cells)
583 }
584
585 fn render_table_row(cells: &[String], width: usize, base_style: Style) -> Vec<Line<'static>> {
586 if cells.is_empty() {
587 return vec![Line::from("")];
588 }
589 let col_width = (width.saturating_sub(3 * cells.len() + 1)) / cells.len();
590 let col_width = col_width.max(4);
591 let sep_style = Style::default().fg(palette::TEXT_DIM);
592 let mut spans: Vec<Span> = vec![Span::styled("│ ".to_string(), sep_style)];
593 for (i, cell) in cells.iter().enumerate() {
594 let truncated = if cell.width() > col_width {
595 let mut s = String::new();
596 let mut w = 0;
597 for ch in cell.chars() {
598 let cw = ch.width().unwrap_or(1);
599 if w + cw + 1 > col_width {
600 s.push('…');
601 break;
602 }
603 s.push(ch);
604 w += cw;
605 }
606 s
607 } else {
608 cell.clone()
609 };
610 let cell_spans: Vec<(String, Style)> =
611 parse_inline_spans(&truncated, base_style, link_style());
612 let cell_width: usize = cell_spans.iter().map(|(t, _)| t.width()).sum();
613 let pad = col_width.saturating_sub(cell_width);
614 for (text, style) in cell_spans {
615 spans.push(Span::styled(text, style));
616 }
617 spans.push(Span::raw(" ".repeat(pad)));
618 if i + 1 < cells.len() {
619 spans.push(Span::styled(" │ ".to_string(), sep_style));
620 } else {
621 spans.push(Span::styled(" │".to_string(), sep_style));
622 }
623 }
624 vec![Line::from(spans)]
625 }
626
627 fn table_col_width(num_cols: usize, term_width: usize) -> usize {
628 let col_width = (term_width.saturating_sub(3 * num_cols + 1)) / num_cols;
629 col_width.max(4)
630 }
631
632 fn render_table_border(
633 num_cols: usize,
634 col_width: usize,
635 sep_style: Style,
636 left: &str,
637 mid: &str,
638 right: &str,
639 ) -> Line<'static> {
640 let fill = "\u{2500}".repeat(col_width);
641 let mut s = String::new();
642 s.push_str(left);
643 for i in 0..num_cols {
644 s.push_str(&fill);
645 if i + 1 < num_cols {
646 s.push_str(mid);
647 } else {
648 s.push_str(right);
649 }
650 }
651 Line::from(Span::styled(s, sep_style))
652 }
653
654 fn render_table_group(blocks: &[Block], width: usize, base_style: Style) -> Vec<Line<'static>> {
655 let sep_style = Style::default().fg(palette::TEXT_DIM);
656
657 let num_cols = blocks
658 .iter()
659 .filter_map(|b| match b {
660 Block::TableRow(cells) => Some(cells.len()),
661 _ => None,
662 })
663 .max()
664 .unwrap_or(1);
665
666 let col_width = table_col_width(num_cols, width);
667
668 let mut lines = Vec::new();
669
670 // Top border
671 lines.push(render_table_border(
672 num_cols,
673 col_width,
674 sep_style,
675 "\u{250C}\u{2500}",
676 "\u{2500}\u{252C}\u{2500}",
677 "\u{2500}\u{2510}",
678 ));
679
680 let mid_border = || {
681 render_table_border(
682 num_cols,
683 col_width,
684 sep_style,
685 "\u{251C}\u{2500}",
686 "\u{2500}\u{253C}\u{2500}",
687 "\u{2500}\u{2524}",
688 )
689 };
690
691 for i in 0..blocks.len() {
692 match &blocks[i] {
693 Block::TableRow(cells) => {
694 lines.extend(render_table_row(cells, width, base_style));
695 if i + 1 < blocks.len() && matches!(&blocks[i + 1], Block::TableRow(_)) {
696 lines.push(mid_border());
697 }
698 }
699 Block::TableSeparator => {
700 lines.push(mid_border());
701 }
702 _ => {}
703 }
704 }
705
706 // Bottom border
707 lines.push(render_table_border(
708 num_cols,
709 col_width,
710 sep_style,
711 "\u{2514}\u{2500}",
712 "\u{2500}\u{2534}\u{2500}",
713 "\u{2500}\u{2518}",
714 ));
715
716 lines
717 }
718
719 fn link_style() -> Style {
720 Style::default()
721 .fg(palette::DEEPSEEK_BLUE)
722 .add_modifier(Modifier::UNDERLINED)
723 }
724
725 fn wrap_text(text: &str, width: usize) -> Vec<String> {
726 if width == 0 {
727 return vec![text.to_string()];
728 }
729 let mut lines = Vec::new();
730 let mut current = String::new();
731 let mut current_width = 0;
732
733 for word in text.split_whitespace() {
734 let word_width = word.width();
735 let additional = if current.is_empty() {
736 word_width
737 } else {
738 word_width + 1
739 };
740 if current_width + additional > width && !current.is_empty() {
741 lines.push(current);
742 current = word.to_string();
743 current_width = word_width;
744 } else {
745 if !current.is_empty() {
746 current.push(' ');
747 current_width += 1;
748 }
749 current.push_str(word);
750 current_width += word_width;
751 }
752 }
753
754 if current.is_empty() {
755 lines.push(String::new());
756 } else {
757 lines.push(current);
758 }
759
760 lines
761 }
762
763 #[cfg(test)]
764 mod tests {
765 use super::*;
766 use ratatui::style::Style;
767
768 #[test]
769 fn render_markdown_matches_parse_then_render() {
770 // Both calls run in the same thread under the same OSC8 lock so the
771 // flag is identical for both paths.
772 let source = "# Title\n\nA paragraph with a https://example.com link.\n\n- one\n- two\n```\ncode\n```";
773 let direct = render_with_osc8(false, source);
774 let two_step = with_osc8(false, || {
775 let parsed = parse(source);
776 render_parsed(&parsed, 80, Style::default())
777 .iter()
778 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
779 .collect::<String>()
780 });
781 assert_eq!(direct, two_step);
782 }
783
784 #[test]
785 fn parse_is_width_independent() {
786 // Same source, two parses, must produce identical AST. (Sanity:
787 // parse must not depend on hidden global state like terminal width.)
788 let source = "Hello\n\n## Heading\n- list\n";
789 let a = parse(source);
790 let b = parse(source);
791 assert_eq!(a, b);
792 }
793
794 #[test]
795 fn render_parsed_word_wrap_changes_with_width() {
796 // The same AST must produce different layouts at different widths;
797 // otherwise the split is decorative, not functional.
798 let parsed = parse("alpha beta gamma delta epsilon zeta");
799 let wide = render_parsed(&parsed, 80, Style::default());
800 let narrow = render_parsed(&parsed, 10, Style::default());
801 assert!(
802 narrow.len() > wide.len(),
803 "narrow should produce more lines"
804 );
805 }
806
807 #[test]
808 fn parse_invocations_increment() {
809 // Counter is thread-local, so concurrent tests calling `parse()`
810 // can't pollute each other.
811 reset_parse_invocation_count();
812 let _ = parse("hello\n");
813 let _ = parse("world\n");
814 assert_eq!(parse_invocation_count(), 2);
815 }
816
817 #[test]
818 fn render_parsed_does_not_call_parse() {
819 // Width-only changes must hit only the render path. This is the
820 // perf invariant CX#6 was filed for.
821 let parsed = parse("multiline\nsource\nwith several\nlines\n");
822 reset_parse_invocation_count();
823 let _ = render_parsed(&parsed, 80, Style::default());
824 let _ = render_parsed(&parsed, 40, Style::default());
825 let _ = render_parsed(&parsed, 20, Style::default());
826 assert_eq!(
827 parse_invocation_count(),
828 0,
829 "render_parsed must not call parse"
830 );
831 }
832
833 #[test]
834 fn fenced_code_block_collected_in_parse() {
835 let parsed = parse("text\n```\ncode line one\ncode line two\n```\nmore\n");
836 let blocks = &parsed.blocks;
837 // text paragraph, two code lines, more paragraph (fences are dropped)
838 let code_lines: Vec<_> = blocks
839 .iter()
840 .filter_map(|b| match b {
841 Block::Code { line } => Some(line.as_str()),
842 _ => None,
843 })
844 .collect();
845 assert_eq!(code_lines, vec!["code line one", "code line two"]);
846 }
847
848 #[test]
849 fn ordered_and_unordered_list_items_parse() {
850 let parsed = parse("- alpha\n* beta\n1. gamma\n");
851 let items: Vec<_> = parsed
852 .blocks
853 .iter()
854 .filter_map(|b| match b {
855 Block::ListItem { bullet, text } => Some((bullet.as_str(), text.as_str())),
856 _ => None,
857 })
858 .collect();
859 assert_eq!(items, vec![("-", "alpha"), ("-", "beta"), ("1.", "gamma")]);
860 }
861
862 /// Render with the OSC 8 flag pinned to `enabled`, then restore the prior
863 /// value. We serialize through a static mutex because `osc8::ENABLED` is
864 /// process-wide state and other tests touching it would race otherwise.
865 fn render_with_osc8(enabled: bool, source: &str) -> String {
866 with_osc8(enabled, || {
867 render_markdown(source, 80, Style::default())
868 .iter()
869 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
870 .collect::<String>()
871 })
872 }
873
874 fn with_osc8<T>(enabled: bool, f: impl FnOnce() -> T) -> T {
875 use std::sync::Mutex;
876 static OSC8_GUARD: Mutex<()> = Mutex::new(());
877 let _guard = OSC8_GUARD.lock().unwrap_or_else(|e| e.into_inner());
878 let prior = osc8::enabled();
879 osc8::set_enabled(enabled);
880 let result = f();
881 osc8::set_enabled(prior);
882 result
883 }
884
885 #[test]
886 fn http_links_get_osc_8_wrapped_when_enabled() {
887 let joined = render_with_osc8(true, "see https://example.com for details");
888 assert!(
889 joined.contains("\x1b]8;;https://example.com\x1b\\https://example.com\x1b]8;;\x1b\\"),
890 "expected OSC 8 wrapper around URL; got {joined:?}"
891 );
892 }
893
894 #[test]
895 fn osc_8_disabled_emits_plain_url() {
896 let joined = render_with_osc8(false, "see https://example.com for details");
897 assert!(
898 !joined.contains("\x1b]8;;"),
899 "expected no OSC 8 wrapper when disabled; got {joined:?}"
900 );
901 assert!(joined.contains("https://example.com"));
902 }
903
904 #[test]
905 fn table_separator_row_is_kept() {
906 // Separator rows are now kept as TableSeparator blocks so the
907 // renderer can draw horizontal rules at the correct positions.
908 let src = "| 项目属性 | 详情 |\n|----------|------|\n| **语言** | Rust 1.88+ |\n";
909 let parsed = parse(src);
910 let blocks: Vec<_> = parsed.blocks.iter().collect();
911 // Should have 2 TableRow blocks (header + data) + 1 TableSeparator
912 let table_rows: Vec<_> = blocks
913 .iter()
914 .filter(|b| matches!(b, Block::TableRow(_)))
915 .collect();
916 assert_eq!(table_rows.len(), 2, "expected 2 table rows: {blocks:?}");
917 let separators: Vec<_> = blocks
918 .iter()
919 .filter(|b| matches!(b, Block::TableSeparator))
920 .collect();
921 assert_eq!(
922 separators.len(),
923 1,
924 "expected 1 table separator: {blocks:?}"
925 );
926 }
927
928 #[test]
929 fn bold_markers_stripped_in_render() {
930 let src = "这是一个 **Rust 工作区项目**,包含多个 crate。\n";
931 let lines = render_markdown(src, 80, Style::default());
932 let text: String = lines
933 .iter()
934 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
935 .collect();
936 assert!(
937 !text.contains("**"),
938 "bold markers leaked into output: {text:?}"
939 );
940 assert!(text.contains("Rust"), "bold content missing: {text:?}");
941 }
942
943 #[test]
944 fn table_renders_with_box_drawing_borders() {
945 let src = "| 文件 | 改动 |\n|---|---|\n| foo.rs | 重写 |\n";
946 let lines = render_markdown(src, 60, Style::default());
947 let text: String = lines
948 .iter()
949 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
950 .collect();
951 // Column pipes still present
952 assert!(text.contains('│'), "table pipe separator missing: {text:?}");
953 // Separator row rendered as middle border, not raw markdown
954 assert!(
955 !text.contains("|---|"),
956 "raw separator row leaked: {text:?}"
957 );
958 // Top and bottom borders present
959 assert!(
960 text.contains('\u{250C}'),
961 "top-left corner missing: {text:?}"
962 );
963 assert!(
964 text.contains('\u{2510}'),
965 "top-right corner missing: {text:?}"
966 );
967 assert!(
968 text.contains('\u{2514}'),
969 "bottom-left corner missing: {text:?}"
970 );
971 assert!(
972 text.contains('\u{2518}'),
973 "bottom-right corner missing: {text:?}"
974 );
975 // Middle separator present (at the |---|---| position)
976 assert!(
977 text.contains('\u{251C}'),
978 "middle-left junction missing: {text:?}"
979 );
980 assert!(
981 text.contains('\u{2524}'),
982 "middle-right junction missing: {text:?}"
983 );
984 }
985 }
986
986 lines RUST