Skip to main content

atomcode_tuix/
markdown.rs

1// crates/atomcode-tuix/src/markdown.rs
2//
3// Line-oriented markdown renderer. Handles:
4//   **bold** / *italic* / `code` (inline)
5//   # / ## / ### headings
6//   - / * bullet lists
7//   ```fenced code blocks``` (state-tracked)
8//   --- horizontal rules
9// Tables are passed through as raw text (pipes show literally).
10
11use crate::highlight::theme;
12use crate::terminal::TerminalCaps;
13
14/// Parser state maintained across lines of a streamed response.
15#[derive(Default)]
16pub struct MdState {
17    pub in_code_block: bool,
18    /// Accumulates consecutive `|…|` rows; flushed as an aligned block
19    /// when a non-table line arrives.
20    pub table_buf: Vec<String>,
21    /// Lines accumulated between an opening and closing code fence.
22    /// Flushed through `highlight::highlight_block` on close fence so
23    /// the syntax highlighter sees the whole block at once. Code thus
24    /// appears in one chunk at fence close rather than streaming
25    /// line-by-line.
26    pub code_buf: Vec<String>,
27    /// Language tag captured from the opening fence (`"rust"` from
28    /// ```` ```rust ````). `None` for fences with no tag.
29    pub code_lang: Option<String>,
30}
31
32impl MdState {
33    pub fn new() -> Self {
34        Self::default()
35    }
36    pub fn reset(&mut self) {
37        self.in_code_block = false;
38        self.table_buf.clear();
39        self.code_buf.clear();
40        self.code_lang = None;
41    }
42}
43
44/// Render one complete line with block- and inline-level markdown applied.
45/// Returns None if the line should be omitted from output (e.g., a fence
46/// marker ``` that toggles code-block state but isn't itself visible text).
47pub fn render_line(line: &str, state: &mut MdState, caps: TerminalCaps) -> Option<String> {
48    render_line_with_width(line, state, caps, 0)
49}
50
51/// Width-aware variant of [`render_line`]. When `max_width > 0`, a flushed
52/// table's column widths are capped so every line fits the budget — otherwise
53/// `wrap_cells_to_width` downstream chops long rows and shatters the table's
54/// border structure. `max_width = 0` keeps legacy behaviour.
55pub fn render_line_with_width(
56    line: &str,
57    state: &mut MdState,
58    caps: TerminalCaps,
59    max_width: usize,
60) -> Option<String> {
61    let trimmed = line.trim();
62
63    // Table row: buffer and defer emit until block ends.
64    if !state.in_code_block && trimmed.starts_with('|') {
65        state.table_buf.push(trimmed.to_string());
66        return None;
67    }
68
69    // Pre-drawn Unicode box-drawing table row (`┌─┬─┐ │ ├─┼─┤ └─┴─┘`).
70    // Some models — usually weaker ones mimicking earlier-turn output that
71    // we ourselves rendered — emit tables fully drawn in box characters
72    // instead of `|`-form markdown. Without detection, those rows fall
73    // through to the inline-only branch and `push_markdown_body`'s
74    // wrap-at-cell-level chops them at terminal width, shattering the
75    // borders (the macOS overflow case in the screenshot). Convert each
76    // row to the equivalent pipe form (│ → |, ─ → -, junctions → |) and
77    // route through the same buffer + flush path the `|`-form takes;
78    // `flush_aligned_table_with_width` then enforces flat-mode fallback
79    // for narrow terminals exactly like a real markdown table would get.
80    if !state.in_code_block {
81        if let Some(converted) = box_drawing_table_row(trimmed) {
82            state.table_buf.push(converted);
83            return None;
84        }
85    }
86
87    // Non-table line arriving after buffered rows: flush as aligned block.
88    let prefix = if !state.table_buf.is_empty() {
89        let t = flush_aligned_table_with_width(&state.table_buf, caps, max_width);
90        state.table_buf.clear();
91        Some(t)
92    } else {
93        None
94    };
95    let prepend = |body: String| -> String {
96        match prefix.as_ref() {
97            Some(p) => format!("{}\n{}", p, body),
98            None => body,
99        }
100    };
101    let prefix_only = || -> Option<String> { prefix.as_ref().map(|p| p.clone()) };
102
103    // Fenced code block fence (``` or ~~~).
104    //
105    // OPEN fence: capture the language tag (e.g., `rust` in ```rust),
106    // start buffering body lines into `state.code_buf`. We don't emit
107    // anything for the body until close fence — the syntax highlighter
108    // needs the whole block at once to classify multi-line strings /
109    // block comments correctly.
110    //
111    // CLOSE fence: flush the buffered block through `highlight::highlight_block`,
112    // which handles caps gating (no-color path returns plain 2-space-indented
113    // text, matching the pre-existing CC-style behavior).
114    if is_fence(trimmed) {
115        if state.in_code_block {
116            // CLOSE
117            let source = state.code_buf.join("\n");
118            let highlighted = crate::highlight::highlight_block(
119                state.code_lang.as_deref(),
120                &source,
121                caps,
122            );
123            state.in_code_block = false;
124            state.code_buf.clear();
125            state.code_lang = None;
126            return Some(prepend(highlighted));
127        } else {
128            // OPEN — extract optional language tag.
129            state.in_code_block = true;
130            state.code_lang = parse_fence_lang(trimmed);
131            state.code_buf.clear();
132            return prefix_only();
133        }
134    }
135
136    // Inside code block: buffer the line, defer rendering until close fence.
137    // No per-line output; the highlighter needs full context.
138    if state.in_code_block {
139        state.code_buf.push(line.to_string());
140        return prefix_only();
141    }
142
143    // Horizontal rule — render as a blank separator line, not a visible
144    // rule. A horizontal bar overwhelms the surrounding prose; a blank line
145    // communicates the same thematic break far more gracefully.
146    if is_hrule(trimmed) {
147        return Some(prepend(String::new()));
148    }
149
150    // Heading — H1-H3 get bold + bright cyan (Palette::ACCENT, SGR 96)
151    // so headings sit on their own colour layer above the default-colour
152    // body. Bright cyan was chosen over bright magenta (BRAND, 95)
153    // because terminals that remap bright white (97, used by inline code
154    // and code blocks) to lavender — Catppuccin / Tokyo Night / similar
155    // — typically remap bright magenta to the same lavender, which
156    // would collapse heading colour into the inline-code colour.
157    // Cyan stays hue-distinct on those palettes and on plain ANSI.
158    // H4+ keeps italic-only so the deep-hierarchy levels still read as
159    // "weaker than a real heading" without adding a third colour tier.
160    if let Some((level, rest)) = parse_heading(line) {
161        let inner = render_inline(rest, caps);
162        let body = if !caps.colors {
163            format!("{} {}", "#".repeat(level as usize), inner)
164        } else {
165            match level {
166                1 | 2 | 3 => format!("{}{}{}", theme::md_heading_open(), inner, theme::MD_HEADING_CLOSE),
167                _ => format!("{}{}{}", theme::MD_ITALIC_OPEN, inner, theme::MD_ITALIC_CLOSE),
168            }
169        };
170        return Some(prepend(body));
171    }
172
173    // List (unordered or ordered): `- text` / `* text` / `1. text`
174    // Marker (• / 1.) rendered in muted gray so it sits quietly next to
175    // the default-fg body text — visually distinct without adding another
176    // bright colour tier. The space after the marker keeps readability.
177    if let Some(item) = parse_list_item(line) {
178        let inner = render_inline(&item.rest, caps);
179        let indent = " ".repeat(item.indent);
180        let body = if caps.colors {
181            format!(
182                "{}{}{}{}{}",
183                indent, theme::MD_MUTED_OPEN, item.marker, theme::MD_MUTED_CLOSE, inner
184            )
185        } else {
186            format!("{}{} {}", indent, item.marker, inner)
187        };
188        return Some(prepend(body));
189    }
190
191    // Default: inline-only
192    Some(prepend(render_inline(line, caps)))
193}
194
195/// Emit any still-buffered block (e.g., a table that ended without a
196/// following non-table line). Call at stream end.
197pub fn finalize(state: &mut MdState, caps: TerminalCaps) -> Option<String> {
198    finalize_with_width(state, caps, 0)
199}
200
201/// Width-aware variant of [`finalize`]. See [`render_line_with_width`].
202pub fn finalize_with_width(
203    state: &mut MdState,
204    caps: TerminalCaps,
205    max_width: usize,
206) -> Option<String> {
207    // Two independent buffers can be open at stream end: a table waiting
208    // for a separator row, or a code block whose close fence never came.
209    // Both must be emitted so the user doesn't lose content.
210    let table_part = if !state.table_buf.is_empty() {
211        let t = flush_aligned_table_with_width(&state.table_buf, caps, max_width);
212        state.table_buf.clear();
213        Some(t)
214    } else {
215        None
216    };
217
218    let code_part = if state.in_code_block && !state.code_buf.is_empty() {
219        let source = state.code_buf.join("\n");
220        let highlighted = crate::highlight::highlight_block(
221            state.code_lang.as_deref(),
222            &source,
223            caps,
224        );
225        state.in_code_block = false;
226        state.code_buf.clear();
227        state.code_lang = None;
228        Some(highlighted)
229    } else {
230        None
231    };
232
233    match (table_part, code_part) {
234        (None, None) => None,
235        (Some(t), None) => Some(t),
236        (None, Some(c)) => Some(c),
237        (Some(t), Some(c)) => Some(format!("{}\n{}", t, c)),
238    }
239}
240
241/// Recognise a pre-drawn Unicode box-drawing table line and return the
242/// equivalent `|`-pipe form so it can join the same buffering path as
243/// real markdown tables. Returns None for lines that aren't part of a box
244/// table.
245///
246/// Two row shapes accepted:
247///   1. **Data row** — starts with `│`. Each `│` becomes `|`; cell content
248///      passes through unchanged. Caller buffers the result and the
249///      existing flush logic splits on `|` and trims as usual.
250///   2. **Border row** — starts with `┌`/`├`/`└` AND every char is in the
251///      box-drawing set (`─┌┬┐├┼┤└┴┘`) plus spaces. Junctions become `|`
252///      and `─` becomes `-`, producing a `|---|---|`-style separator that
253///      `flush_aligned_table_with_width`'s `is_sep` matcher already
254///      recognises (its predicate is `[-: ]+` per cell).
255///
256/// The "every char is box-drawing" guard on border rows defends against
257/// false positives: a stray paragraph that happens to begin with `├` for
258/// some unrelated reason would NOT match (it has letters too).
259fn box_drawing_table_row(trimmed: &str) -> Option<String> {
260    let first = trimmed.chars().next()?;
261    match first {
262        '│' => Some(trimmed.replace('│', "|")),
263        '┌' | '├' | '└' => {
264            if trimmed.chars().all(|c| {
265                matches!(
266                    c,
267                    '─' | '┌' | '┬' | '┐' | '├' | '┼' | '┤' | '└' | '┴' | '┘' | ' '
268                )
269            }) {
270                let converted: String = trimmed
271                    .chars()
272                    .map(|c| match c {
273                        '┌' | '┬' | '┐' | '├' | '┼' | '┤' | '└' | '┴' | '┘' => '|',
274                        '─' => '-',
275                        other => other,
276                    })
277                    .collect();
278                Some(converted)
279            } else {
280                None
281            }
282        }
283        _ => None,
284    }
285}
286
287/// Flush a buffered markdown table as a column-aligned block. Computes the
288/// max display width per column, pads every cell accordingly, renders with
289/// `│`/`┼`/`─` box chars in muted gray. Inline markdown inside cells is
290/// honoured.
291pub fn flush_aligned_table(rows: &[String], caps: TerminalCaps) -> String {
292    flush_aligned_table_with_width(rows, caps, 0)
293}
294
295/// Width-aware variant. When `max_width > 0` and the table can't fit at its
296/// natural column widths, fall back to a flat key/value record format
297/// (`header: cell` per line, blank line between rows) so no information is
298/// lost to per-cell truncation. `max_width = 0` keeps box-table rendering
299/// at natural widths regardless of size.
300pub fn flush_aligned_table_with_width(
301    rows: &[String],
302    caps: TerminalCaps,
303    max_width: usize,
304) -> String {
305    // Parse each row: strip leading/trailing '|', split by '|', trim cells.
306    let parsed: Vec<Vec<String>> = rows
307        .iter()
308        .map(|r| {
309            let s = r.trim_start_matches('|').trim_end_matches('|');
310            s.split('|').map(|c| c.trim().to_string()).collect()
311        })
312        .collect();
313
314    // Identify separator row(s) — cells match `[-: ]+` only.
315    let is_sep = |row: &[String]| -> bool {
316        row.iter()
317            .all(|c| !c.is_empty() && c.chars().all(|ch| matches!(ch, '-' | ':' | ' ')))
318    };
319
320    let ncols = parsed.iter().map(|r| r.len()).max().unwrap_or(0);
321    if ncols == 0 {
322        return String::new();
323    }
324
325    // Compute natural column widths from non-separator rows. We do NOT cap
326    // these — the cap-and-truncate-with-… approach the previous code took
327    // chopped real content out of cells and made wide tables in narrow
328    // terminals unreadable. Instead, if the natural table doesn't fit, the
329    // flat-mode fallback below renders every cell in full.
330    let mut col_widths = vec![0usize; ncols];
331    for row in &parsed {
332        if is_sep(row) {
333            continue;
334        }
335        for (j, cell) in row.iter().enumerate() {
336            if j >= ncols {
337                break;
338            }
339            let plain = strip_md_for_width(cell);
340            let w = crate::width::display_width(&plain);
341            col_widths[j] = col_widths[j].max(w);
342        }
343    }
344
345    // Total width of one rendered row at natural widths:
346    //   `│` + per-col ` cell ` + `│` between/after each col
347    //   = 1 + sum(w + 3 for w in col_widths)
348    // If this exceeds the terminal budget, switch to flat mode.
349    let natural_row_width: usize = 1 + col_widths.iter().map(|w| w + 3).sum::<usize>();
350    if max_width > 0 && natural_row_width > max_width {
351        return render_flat_table(&parsed, caps);
352    }
353
354    // Bright-black / DarkGrey (SGR 90) — table borders are chrome,
355    // not content. Cyan (SGR 96) made them collide with the input
356    // box separator and the inline-code colour, collapsing the
357    // visual hierarchy. Gray reads as quiet structure and lets
358    // header text + cell content carry the visual weight.
359    let border_on = if caps.colors { theme::MD_MUTED_OPEN } else { "" };
360    let border_off = if caps.colors { theme::MD_MUTED_CLOSE } else { "" };
361
362    // Draw a horizontal rule row with given connector characters.
363    let rule = |left: char, mid: char, right: char| -> String {
364        let mut s = String::new();
365        s.push_str(border_on);
366        s.push(left);
367        for (j, w) in col_widths.iter().enumerate() {
368            for _ in 0..(w + 2) {
369                s.push('─');
370            }
371            if j + 1 < col_widths.len() {
372                s.push(mid);
373            }
374        }
375        s.push(right);
376        s.push_str(border_off);
377        s
378    };
379
380    let data_rows: Vec<&Vec<String>> = parsed.iter().filter(|r| !is_sep(r)).collect();
381
382    let mut out = String::new();
383    // Top border: ┌─┬─┐
384    out.push_str(&rule('┌', '┬', '┐'));
385    out.push('\n');
386
387    for (i, row) in data_rows.iter().enumerate() {
388        // Data row: │ cell │ cell │
389        out.push_str(border_on);
390        out.push('│');
391        out.push_str(border_off);
392        for (j, w) in col_widths.iter().enumerate() {
393            let cell = row.get(j).map(|s| s.as_str()).unwrap_or("");
394            let plain_w = crate::width::display_width(&strip_md_for_width(cell));
395            let body = render_inline(cell, caps);
396            out.push(' ');
397            out.push_str(&body);
398            let pad = w.saturating_sub(plain_w);
399            for _ in 0..pad {
400                out.push(' ');
401            }
402            out.push(' ');
403            out.push_str(border_on);
404            out.push('│');
405            out.push_str(border_off);
406        }
407        out.push('\n');
408
409        // Separator between every pair of rows: ├─┼─┤
410        if i + 1 < data_rows.len() {
411            out.push_str(&rule('├', '┼', '┤'));
412            out.push('\n');
413        }
414    }
415
416    // Bottom border: └─┴─┘
417    out.push_str(&rule('└', '┴', '┘'));
418    out
419}
420
421/// Narrow-terminal fallback for tables that can't fit at natural column
422/// widths. Each data row is expanded into N lines of `header:cell` (one
423/// per column), with a blank line between successive rows. Soft-wrapping
424/// of long lines is left to the caller's downstream wrap stage so the
425/// terminal width budget is honoured without losing any cell content.
426fn render_flat_table(parsed: &[Vec<String>], caps: TerminalCaps) -> String {
427    let is_sep = |row: &[String]| -> bool {
428        row.iter()
429            .all(|c| !c.is_empty() && c.chars().all(|ch| matches!(ch, '-' | ':' | ' ')))
430    };
431    let has_sep = parsed.iter().any(|r| is_sep(r));
432    let mut data_iter = parsed.iter().filter(|r| !is_sep(r));
433
434    // First non-sep row is treated as headers when a separator exists.
435    // Without a separator the source isn't a real markdown table (it's
436    // just `|` lines); fall back to printing every cell with no label.
437    let headers: Vec<String> = if has_sep {
438        match data_iter.next() {
439            Some(h) => h.clone(),
440            None => return String::new(),
441        }
442    } else {
443        Vec::new()
444    };
445
446    let ncols = parsed.iter().map(|r| r.len()).max().unwrap_or(0);
447    let mut out = String::new();
448    let mut first = true;
449    for row in data_iter {
450        if !first {
451            out.push('\n');
452        }
453        first = false;
454        for j in 0..ncols {
455            let cell = row.get(j).map(|s| s.as_str()).unwrap_or("");
456            let cell_rendered = render_inline(cell, caps);
457            if let Some(header) = headers.get(j) {
458                let h_rendered = render_inline(header, caps);
459                out.push_str(&h_rendered);
460                out.push(':');
461                out.push_str(&cell_rendered);
462            } else {
463                out.push_str(&cell_rendered);
464            }
465            out.push('\n');
466        }
467    }
468    // Drop the trailing newline so the caller's `format!("{}\n{}", t, body)`
469    // doesn't sprinkle an extra blank line after the block.
470    if out.ends_with('\n') {
471        out.pop();
472    }
473    out
474}
475
476fn strip_md_for_width(s: &str) -> String {
477    // Remove markdown markers that add bytes but no display width.
478    s.replace("**", "").replace('`', "")
479}
480
481/// Legacy single-line inline renderer — kept for direct callers (tests,
482/// simple assistant lines). Does not track block state.
483pub fn render_inline_line(line: &str, caps: TerminalCaps) -> String {
484    render_inline(line, caps)
485}
486
487// ─── Helpers ───
488
489fn render_inline(line: &str, caps: TerminalCaps) -> String {
490    if !caps.colors {
491        return line.to_string();
492    }
493    let mut out = String::with_capacity(line.len() + 16);
494    let mut chars = line.chars().peekable();
495
496    while let Some(c) = chars.next() {
497        match c {
498            '*' => {
499                if chars.peek() == Some(&'*') {
500                    chars.next();
501                    let mut inner = String::new();
502                    let mut closed = false;
503                    while let Some(&p) = chars.peek() {
504                        if p == '*' {
505                            chars.next();
506                            if chars.peek() == Some(&'*') {
507                                chars.next();
508                                closed = true;
509                                break;
510                            } else {
511                                inner.push('*');
512                            }
513                        } else {
514                            chars.next();
515                            inner.push(p);
516                        }
517                    }
518                    if closed && !inner.is_empty() {
519                        out.push_str(theme::MD_BOLD_OPEN);
520                        out.push_str(&inner);
521                        out.push_str(theme::MD_BOLD_CLOSE);
522                    } else {
523                        out.push_str("**");
524                        out.push_str(&inner);
525                    }
526                } else {
527                    let mut inner = String::new();
528                    let mut closed = false;
529                    while let Some(&p) = chars.peek() {
530                        chars.next();
531                        if p == '*' {
532                            closed = true;
533                            break;
534                        }
535                        inner.push(p);
536                    }
537                    if closed && !inner.is_empty() {
538                        out.push_str(theme::MD_ITALIC_OPEN);
539                        out.push_str(&inner);
540                        out.push_str(theme::MD_ITALIC_CLOSE);
541                    } else {
542                        out.push('*');
543                        out.push_str(&inner);
544                    }
545                }
546            }
547            '`' => {
548                let mut inner = String::new();
549                let mut closed = false;
550                while let Some(&p) = chars.peek() {
551                    chars.next();
552                    if p == '`' {
553                        closed = true;
554                        break;
555                    }
556                    inner.push(p);
557                }
558                if closed && !inner.is_empty() {
559                    // Bold + bright cyan (SGR 1;96). Earlier iterations
560                    // used bold-only (`\x1b[1m`), bright-white
561                    // (`\x1b[1;97m`), and truecolor blue-500
562                    // (`\x1b[1;38;2;59;130;246m`). Bold-only was too
563                    // subtle — in long mixed output, inline code
564                    // `path/to/foo.rs` was visually indistinguishable
565                    // from **bold** prose. Bright cyan (96) matches the
566                    // heading and code-block accent colour; it's a 16-colour
567                    // SGR interpreted by the terminal's own theme palette,
568                    // so it adapts to both light and dark backgrounds
569                    // (same reason `Palette::CODE` uses SGR 96). The
570                    // close sequence `\x1b[22;39m` resets both bold
571                    // (SGR 22) and fg (SGR 39) so neither bleeds into
572                    // the next span.
573                    out.push_str(theme::md_inline_code_open());
574                    out.push_str(&inner);
575                    out.push_str(theme::MD_INLINE_CODE_CLOSE);
576                } else {
577                    out.push('`');
578                    out.push_str(&inner);
579                }
580            }
581            _ => out.push(c),
582        }
583    }
584    out
585}
586
587fn is_fence(trimmed: &str) -> bool {
588    let mut chars = trimmed.chars();
589    match chars.next() {
590        Some('`') => {
591            trimmed.len() >= 3 && trimmed.as_bytes()[1] == b'`' && trimmed.as_bytes()[2] == b'`'
592        }
593        Some('~') => {
594            trimmed.len() >= 3 && trimmed.as_bytes()[1] == b'~' && trimmed.as_bytes()[2] == b'~'
595        }
596        _ => false,
597    }
598}
599
600/// Extract the language tag from an opening fence line. Handles both
601/// backtick and tilde fences; returns `None` if no tag is present or
602/// the line is just the fence character.
603///
604/// Examples:
605///   "```rust"        -> Some("rust")
606///   "```rust  "      -> Some("rust")
607///   "```Rust"        -> Some("rust")     ← lowercased
608///   "```"            -> None
609///   "~~~python"      -> Some("python")
610fn parse_fence_lang(trimmed: &str) -> Option<String> {
611    let after = trimmed
612        .trim_start_matches('`')
613        .trim_start_matches('~')
614        .trim();
615    if after.is_empty() {
616        None
617    } else {
618        Some(after.to_lowercase())
619    }
620}
621
622fn is_hrule(trimmed: &str) -> bool {
623    if trimmed.len() < 3 {
624        return false;
625    }
626    let first = trimmed.chars().next().unwrap();
627    if first != '-' && first != '*' && first != '_' {
628        return false;
629    }
630    let mut n = 0;
631    for c in trimmed.chars() {
632        if c == first {
633            n += 1;
634        } else if !c.is_whitespace() {
635            return false;
636        }
637    }
638    n >= 3
639}
640
641fn parse_heading(line: &str) -> Option<(u8, &str)> {
642    let line = line.trim_start();
643    let mut level = 0u8;
644    for c in line.chars() {
645        if c == '#' && level < 6 {
646            level += 1;
647        } else if level > 0 && c == ' ' {
648            let content = &line[(level as usize) + 1..];
649            return Some((level, content));
650        } else {
651            return None;
652        }
653    }
654    None
655}
656
657/// Parsed list item: indent level, the marker string (e.g. "•", "1."),
658/// and the remaining text after the marker.
659struct ParsedListItem {
660    indent: usize,
661    marker: String,
662    rest: String,
663}
664
665fn parse_list_item(line: &str) -> Option<ParsedListItem> {
666    let indent = line.chars().take_while(|c| *c == ' ').count();
667    let rest = &line[indent..];
668
669    // Unordered: "- text" / "* text"
670    if let Some(r) = rest.strip_prefix("- ").or_else(|| rest.strip_prefix("* ")) {
671        return Some(ParsedListItem {
672            indent,
673            marker: "•".to_string(),
674            rest: r.to_string(),
675        });
676    }
677
678    // Ordered: "1. text" / "12. text" — one or more digits followed by ". "
679    let digits_end = rest.chars().take_while(|c| c.is_ascii_digit()).count();
680    if digits_end > 0 {
681        let after_digits = &rest[digits_end..];
682        if let Some(r) = after_digits.strip_prefix(". ") {
683            let marker = &rest[..digits_end]; // "1", "12", etc.
684            return Some(ParsedListItem {
685                indent,
686                marker: format!("{}.", marker),
687                rest: r.to_string(),
688            });
689        }
690    }
691
692    None
693}
694
695#[cfg(test)]
696mod tests {
697    use super::*;
698    use crate::highlight::theme;
699    use crate::terminal::{EnvView, TerminalCaps};
700
701    fn caps() -> TerminalCaps {
702        TerminalCaps::from_env(EnvView {
703            is_stdout_tty: true,
704            term: Some("xterm-256color".to_string()),
705            colorterm: Some("truecolor".to_string()),
706            lang: Some("en_US.UTF-8".to_string()),
707            ..Default::default()
708        })
709    }
710    fn plain_caps() -> TerminalCaps {
711        TerminalCaps::from_env(EnvView {
712            is_stdout_tty: true,
713            no_color: true,
714            term: Some("xterm".to_string()),
715            lang: Some("en_US.UTF-8".to_string()),
716            ..Default::default()
717        })
718    }
719
720    #[test]
721    fn inline_bold() {
722        assert_eq!(
723            render_inline_line("**bold**", caps()),
724            format!("{}bold{}", theme::MD_BOLD_OPEN, theme::MD_BOLD_CLOSE)
725        );
726    }
727
728    #[test]
729    fn inline_italic() {
730        assert_eq!(render_inline_line("*em*", caps()), format!("{}em{}", theme::MD_ITALIC_OPEN, theme::MD_ITALIC_CLOSE));
731    }
732
733    #[test]
734    fn inline_code() {
735        // Inline code uses bold + bright cyan. This matches
736        // the heading and code-block accent colour. The close sequence
737        // resets both bold and fg.
738        let rendered = render_inline_line("`x`", caps());
739        assert!(
740            rendered.contains(theme::md_inline_code_open()),
741            "inline code must open with MD_INLINE_CODE_OPEN: {}",
742            rendered
743        );
744        assert!(
745            rendered.contains(theme::MD_INLINE_CODE_CLOSE),
746            "inline code must close with MD_INLINE_CODE_CLOSE: {}",
747            rendered
748        );
749        assert!(
750            !rendered.contains("\x1b[1;97m"),
751            "inline code must NOT include bright-white SGR 97: {}",
752            rendered
753        );
754        assert!(
755            !rendered.contains("\x1b[1;38;2;"),
756            "inline code must NOT include truecolor RGB: {}",
757            rendered
758        );
759    }
760
761    #[test]
762    fn fenced_code_block_colors_off_renders_plain_indented() {
763        // With NO_COLOR / non-TTY caps, code blocks remain plain 2-space-indented
764        // text with NO ANSI bytes. Pins the no-color invariant.
765        let mut state = MdState::new();
766        let _ = render_line("```", &mut state, plain_caps()); // open fence, no lang
767        assert!(render_line("let x = 1;", &mut state, plain_caps()).is_none());
768        let out = render_line("```", &mut state, plain_caps()).unwrap();
769        assert!(
770            out.contains("  let x = 1;"),
771            "code body must appear with 2-space indent: {:?}",
772            out
773        );
774        assert!(
775            !out.contains('\x1b'),
776            "colors-off must emit zero ANSI bytes: {:?}",
777            out
778        );
779        assert!(!out.contains('│'), "no `│` gutter glyph: {:?}", out);
780    }
781
782    #[test]
783    fn fenced_code_block_colors_on_emits_truecolor_for_known_lang() {
784        // With colors enabled and a known language tag, the close-fence
785        // flush must include at least one truecolor SGR (theme color).
786        // We don't assert exact bytes — the palette is intentionally
787        // free to evolve in `highlight::theme`.
788        let mut state = MdState::new();
789        let _ = render_line("```rust", &mut state, caps());
790        assert!(render_line("fn main() {}", &mut state, caps()).is_none());
791        let out = render_line("```", &mut state, caps()).unwrap();
792        assert!(out.contains("  "), "indent preserved: {:?}", out);
793        assert!(
794            out.contains("\x1b[38;2;"),
795            "expected at least one truecolor SGR, got: {:?}",
796            out
797        );
798    }
799
800    #[test]
801    fn fenced_code_block_unknown_lang_falls_back_to_plain_indent() {
802        // Unknown lang tag → syntect's find_syntax_by_token returns None
803        // → dispatch falls through to plain indent. No ANSI emitted even
804        // though caps.colors is true. Matches the design's "no fallback
805        // module" decision (syntect's lookup is the gate).
806        let mut state = MdState::new();
807        let _ = render_line("```frobnicate", &mut state, caps());
808        assert!(render_line(r#"x = "hello""#, &mut state, caps()).is_none());
809        let out = render_line("```", &mut state, caps()).unwrap();
810        assert!(
811            out.contains(r#"x = "hello""#),
812            "unknown-lang body must survive verbatim: {:?}",
813            out
814        );
815        assert!(
816            !out.contains("\x1b["),
817            "unknown lang must emit zero ANSI: {:?}",
818            out
819        );
820    }
821
822    #[test]
823    fn plain_pass_through() {
824        assert_eq!(render_inline_line("**b**", plain_caps()), "**b**");
825    }
826
827    #[test]
828    fn heading_styled() {
829        let mut st = MdState::new();
830        let out = render_line("## Hello", &mut st, caps()).unwrap();
831        assert!(out.contains("Hello"));
832        // H1-H3 use the heading colour so they sit on a separate
833        // colour layer from default-colour body text.
834        assert!(out.contains(theme::md_heading_open()), "H2 should use MD_HEADING_OPEN, got: {:?}", out);
835    }
836
837    #[test]
838    fn heading_h4_uses_italic_not_color() {
839        let mut st = MdState::new();
840        let out = render_line("#### Sub-deep", &mut st, caps()).unwrap();
841        assert!(out.contains("Sub-deep"));
842        // H4+ keeps italic-only — distinct from coloured H1-H3 without
843        // adding a third colour tier.
844        assert!(out.contains(theme::MD_ITALIC_OPEN), "H4 should use MD_ITALIC_OPEN, got: {:?}", out);
845        assert!(!out.contains(theme::md_heading_open()), "H4 must not pick up the H1-H3 heading colour");
846    }
847
848    #[test]
849    fn heading_plain_keeps_hashes() {
850        let mut st = MdState::new();
851        let out = render_line("### Sub", &mut st, plain_caps()).unwrap();
852        assert_eq!(out, "### Sub");
853    }
854
855    #[test]
856    fn fence_toggles_state_open_close_with_buffering() {
857        // Updated for buffer-and-flush:
858        //   - open fence sets in_code_block, returns None (no body yet)
859        //   - body lines return None and accumulate to code_buf
860        //   - inline markdown inside a buffered line is preserved verbatim
861        //     (we flush as code, not as inline markdown)
862        //   - close fence flushes everything, resets state, returns Some(...)
863        let mut st = MdState::new();
864        assert!(render_line("```rust", &mut st, plain_caps()).is_none());
865        assert!(st.in_code_block);
866
867        // Body lines are buffered, not emitted.
868        assert!(render_line("let x = 1;", &mut st, plain_caps()).is_none());
869        assert!(render_line("**not bold**", &mut st, plain_caps()).is_none());
870        assert_eq!(st.code_buf.len(), 2);
871
872        // Close fence flushes — final output contains both body lines,
873        // and the **not bold** markdown is preserved literally (not interpreted).
874        // Using plain_caps so substring assertions aren't broken by ANSI interleave.
875        let out = render_line("```", &mut st, plain_caps()).unwrap();
876        assert!(out.contains("let x = 1;"));
877        assert!(
878            out.contains("**not bold**"),
879            "inline markdown inside code must be preserved literally: {:?}",
880            out
881        );
882        assert!(!st.in_code_block);
883        assert!(st.code_buf.is_empty());
884    }
885
886    #[test]
887    fn hrule_becomes_blank_line() {
888        // Horizontal rules now render as blank lines (thematic break), not
889        // visible rules — a line of "─" chars is visually noisier than the
890        // blank separator it's supposed to stand in for.
891        let mut st = MdState::new();
892        let out = render_line("---", &mut st, caps()).unwrap();
893        assert_eq!(out, "");
894    }
895
896    #[test]
897    fn list_bullets() {
898        let mut st = MdState::new();
899        let out = render_line("- item", &mut st, caps()).unwrap();
900        // Bullet marker rendered in muted colour.
901        assert!(
902            out.contains(&format!("{}•{}", theme::MD_MUTED_OPEN, theme::MD_MUTED_CLOSE)),
903            "bullet must use MD_MUTED colour: {:?}",
904            out
905        );
906        assert!(out.contains("item"));
907    }
908
909    #[test]
910    fn list_bullets_plain_caps_no_ansi() {
911        let mut st = MdState::new();
912        let out = render_line("- item", &mut st, plain_caps()).unwrap();
913        // No colour → plain "• item" without any SGR.
914        assert_eq!(out, "• item");
915    }
916
917    #[test]
918    fn list_nested_indent() {
919        let mut st = MdState::new();
920        let out = render_line("  - nested", &mut st, caps()).unwrap();
921        assert!(out.starts_with(&format!("  {}•{}", theme::MD_MUTED_OPEN, theme::MD_MUTED_CLOSE)), "nested bullet with indent: {:?}", out);
922    }
923
924    #[test]
925    fn ordered_list_single_digit() {
926        let mut st = MdState::new();
927        let out = render_line("1. first item", &mut st, caps()).unwrap();
928        assert!(
929            out.contains(&format!("{}1.{}", theme::MD_MUTED_OPEN, theme::MD_MUTED_CLOSE)),
930            "ordered marker must use MD_MUTED colour: {:?}",
931            out
932        );
933        assert!(out.contains("first item"));
934    }
935
936    #[test]
937    fn ordered_list_double_digit() {
938        let mut st = MdState::new();
939        let out = render_line("12. twelfth item", &mut st, caps()).unwrap();
940        assert!(
941            out.contains(&format!("{}12.{}", theme::MD_MUTED_OPEN, theme::MD_MUTED_CLOSE)),
942            "double-digit marker must use MD_MUTED colour: {:?}",
943            out
944        );
945        assert!(out.contains("twelfth item"));
946    }
947
948    #[test]
949    fn ordered_list_plain_caps_no_ansi() {
950        let mut st = MdState::new();
951        let out = render_line("3. third", &mut st, plain_caps()).unwrap();
952        // No colour → plain "3. third" without any SGR.
953        assert_eq!(out, "3. third");
954    }
955
956    #[test]
957    fn ordered_list_nested() {
958        let mut st = MdState::new();
959        let out = render_line("  5. nested ordered", &mut st, caps()).unwrap();
960        assert!(
961            out.starts_with(&format!("  {}5.{}", theme::MD_MUTED_OPEN, theme::MD_MUTED_CLOSE)),
962            "nested ordered with indent: {:?}",
963            out
964        );
965        assert!(out.contains("nested ordered"));
966    }
967
968    #[test]
969    fn number_dot_without_space_is_not_list() {
970        // "3.text" (no space after dot) should NOT be parsed as a list item.
971        let mut st = MdState::new();
972        let out = render_line("3.text", &mut st, caps()).unwrap();
973        assert!(!out.contains(theme::MD_MUTED_OPEN), "no muted marker: {:?}", out);
974        assert!(out.contains("3.text"));
975    }
976
977    #[test]
978    fn cjk_bold() {
979        assert_eq!(
980            render_inline_line("**你好**", caps()),
981            format!("{}你好{}", theme::MD_BOLD_OPEN, theme::MD_BOLD_CLOSE)
982        );
983    }
984
985    /// Wide-enough terminal: render as a normal box-drawing table at the
986    /// table's natural column widths. No truncation, no ellipsis.
987    #[test]
988    fn wide_table_renders_as_box_at_natural_widths() {
989        let rows = vec![
990            "| Feature | Status |".to_string(),
991            "|---------|--------|".to_string(),
992            "| login   | done   |".to_string(),
993            "| signup  | wip    |".to_string(),
994        ];
995        // Plenty of room — natural width is well under 80.
996        let out = flush_aligned_table_with_width(&rows, plain_caps(), 80);
997        assert!(out.contains('┌'));
998        assert!(out.contains('│'));
999        assert!(out.contains('└'));
1000        // Cell contents survive in full.
1001        assert!(out.contains("login"));
1002        assert!(out.contains("signup"));
1003        // No ellipsis introduced.
1004        assert!(!out.contains('…'));
1005    }
1006
1007    /// Narrow terminal: table can't fit at natural widths → fall back to
1008    /// flat `header:cell` records so no cell content is lost. Mirrors the
1009    /// CC narrow-mode rendering the user requested.
1010    #[test]
1011    fn narrow_terminal_falls_back_to_flat_records() {
1012        let rows = vec![
1013            "| 能力 | AtomCode Air | Cursor | Copilot |".to_string(),
1014            "|------|--------------|--------|---------|".to_string(),
1015            "| 开源 | ✅ | ❌ | ❌ |".to_string(),
1016            "| 多语言运行 | ✅ Python+ | 🟡 | ❌ |".to_string(),
1017        ];
1018        // Tight budget — the natural box layout needs > 40 cols.
1019        let out = flush_aligned_table_with_width(&rows, plain_caps(), 40);
1020
1021        // Flat mode: no box-drawing characters anywhere.
1022        assert!(!out.contains('│'), "narrow output must not contain border │");
1023        assert!(!out.contains('┌'), "narrow output must not contain top corner");
1024
1025        // Every cell value survives in full — no truncation.
1026        assert!(out.contains("AtomCode Air"));
1027        assert!(out.contains("Python+"));
1028
1029        // Each header label appears once per data row.
1030        let count_neng_li = out.matches("能力").count();
1031        assert_eq!(count_neng_li, 2, "header `能力` should label both data rows");
1032        let count_cursor = out.matches("Cursor").count();
1033        assert_eq!(count_cursor, 2, "header `Cursor` should label both data rows");
1034
1035        // Records are separated by a blank line.
1036        assert!(
1037            out.contains("\n\n"),
1038            "expected blank line between flat records"
1039        );
1040    }
1041
1042    /// Threshold transition: the same table in a slightly different
1043    /// terminal width should switch modes cleanly.
1044    #[test]
1045    fn flat_mode_kicks_in_when_natural_width_exceeds_budget() {
1046        let rows = vec![
1047            "| A | B | C |".to_string(),
1048            "|---|---|---|".to_string(),
1049            "| short | also short | x |".to_string(),
1050        ];
1051        // Natural width ~ 1 + (5+3) + (10+3) + (1+3) = 26.
1052        let wide = flush_aligned_table_with_width(&rows, plain_caps(), 80);
1053        assert!(wide.contains('│'), "80 cols should render as box");
1054
1055        let narrow = flush_aligned_table_with_width(&rows, plain_caps(), 20);
1056        assert!(!narrow.contains('│'), "20 cols should fall back to flat");
1057    }
1058
1059    /// Pre-drawn Unicode box-drawing tables (the `┌─┬─┐ │ ├─┼─┤ └─┴─┘`
1060    /// shape some weak models emit instead of `|`-form markdown) must
1061    /// route through the same flat-mode-aware flush path: at narrow widths
1062    /// they collapse to `header:cell` records — no box characters survive.
1063    /// This is the macOS-overflow regression captured in the screenshot.
1064    #[test]
1065    fn box_drawing_table_collapses_to_flat_when_narrow() {
1066        let mut st = MdState::new();
1067        let lines = [
1068            "┌──────────────┬──────────────────────────────────────────┐",
1069            "│ 场景         │ 作用                                     │",
1070            "├──────────────┼──────────────────────────────────────────┤",
1071            "│ 多文件并行编辑 │ parallel_edit_files 工具触发时分发给子智能体 │",
1072            "├──────────────┼──────────────────────────────────────────┤",
1073            "│ 弹性预算控制 │ 每个 SubAgent 有初始 4 轮对话预算          │",
1074            "└──────────────┴──────────────────────────────────────────┘",
1075            "", // boundary line triggers flush
1076        ];
1077        let mut out = String::new();
1078        for line in &lines {
1079            if let Some(r) = render_line_with_width(line, &mut st, plain_caps(), 30) {
1080                out.push_str(&r);
1081                out.push('\n');
1082            }
1083        }
1084        // Narrow → flat-mode kicks in. No box corners survive.
1085        assert!(
1086            !out.contains('┌') && !out.contains('└'),
1087            "narrow box-drawing table must collapse to flat:\n{out}"
1088        );
1089        // Each header label appears once per data row (2 data rows here).
1090        assert_eq!(
1091            out.matches("场景").count(),
1092            2,
1093            "header `场景` should label each data record:\n{out}"
1094        );
1095        assert_eq!(out.matches("作用").count(), 2);
1096        // Cell content survives in full — no truncation.
1097        assert!(out.contains("parallel_edit_files"));
1098        assert!(out.contains("初始 4 轮"));
1099    }
1100
1101    /// Wide terminal: a box-drawing table re-renders as a clean box at
1102    /// natural widths (the input is converted to pipe form, then
1103    /// `flush_aligned_table_with_width` re-emits its own box drawing).
1104    #[test]
1105    fn box_drawing_table_re_renders_as_box_when_fits() {
1106        let mut st = MdState::new();
1107        let lines = [
1108            "┌─────┬─────┐",
1109            "│ a   │ b   │",
1110            "├─────┼─────┤",
1111            "│ 1   │ 2   │",
1112            "└─────┴─────┘",
1113            "",
1114        ];
1115        let mut out = String::new();
1116        for line in &lines {
1117            if let Some(r) = render_line_with_width(line, &mut st, plain_caps(), 80) {
1118                out.push_str(&r);
1119                out.push('\n');
1120            }
1121        }
1122        assert!(out.contains('┌'), "wide terminal should keep box rendering:\n{out}");
1123        assert!(out.contains('└'));
1124        assert!(out.contains("a") && out.contains("2"));
1125    }
1126
1127    /// False-positive guard: a paragraph whose first character happens to
1128    /// be `├` (or any junction) but has surrounding prose must NOT be
1129    /// pulled into the box-table buffer. The border-row matcher requires
1130    /// the entire trimmed line to consist of box-drawing chars + spaces.
1131    #[test]
1132    fn box_drawing_detection_does_not_swallow_prose_with_stray_box_char() {
1133        let mut st = MdState::new();
1134        // Prose that starts with `├` followed by regular words. Real-world
1135        // probability is near-zero but the guard matters.
1136        let line = "├ hello, this is not a table line";
1137        let out = render_line_with_width(line, &mut st, plain_caps(), 80);
1138        // Must render inline (Some), not buffer (None).
1139        assert!(out.is_some(), "prose with stray junction must not buffer");
1140        assert!(st.table_buf.is_empty(), "table_buf must stay empty");
1141    }
1142
1143    #[test]
1144    fn mdstate_default_has_empty_code_buf_and_no_lang() {
1145        let s = MdState::new();
1146        assert!(s.code_buf.is_empty(), "code_buf must start empty");
1147        assert!(s.code_lang.is_none(), "code_lang must start None");
1148    }
1149
1150    #[test]
1151    fn mdstate_reset_clears_code_buf_and_lang() {
1152        let mut s = MdState::new();
1153        s.code_buf.push("dirty".into());
1154        s.code_lang = Some("rust".into());
1155        s.in_code_block = true;
1156        s.reset();
1157        assert!(s.code_buf.is_empty(), "reset must clear code_buf");
1158        assert!(s.code_lang.is_none(), "reset must clear code_lang");
1159        assert!(!s.in_code_block, "reset must clear in_code_block");
1160    }
1161
1162    #[test]
1163    fn fence_open_with_lang_captures_lang_and_buffers_lines() {
1164        let mut st = MdState::new();
1165        // Open fence with `rust` tag — language captured, no body output yet.
1166        assert!(render_line("```rust", &mut st, caps()).is_none());
1167        assert_eq!(st.code_lang.as_deref(), Some("rust"));
1168        assert!(st.in_code_block);
1169
1170        // Body lines accumulate to code_buf, no output emitted yet.
1171        assert!(render_line("let x = 1;", &mut st, caps()).is_none());
1172        assert!(render_line("let y = 2;", &mut st, caps()).is_none());
1173        assert_eq!(st.code_buf.len(), 2);
1174    }
1175
1176    #[test]
1177    fn fence_close_flushes_buffered_block_as_one_chunk() {
1178        // Use plain_caps so the substring checks see the literal source text
1179        // — with truecolor caps, syntect interleaves ANSI escapes between
1180        // every token boundary (keywords/identifiers/operators each get
1181        // their own SGR pair), so `out.contains("let x = 1;")` won't match.
1182        // The colored path is covered separately by
1183        // `fence_close_with_colors_produces_truecolor_ansi`.
1184        let mut st = MdState::new();
1185        assert!(render_line("```rust", &mut st, plain_caps()).is_none());
1186        assert!(render_line("let x = 1;", &mut st, plain_caps()).is_none());
1187        assert!(render_line("let y = 2;", &mut st, plain_caps()).is_none());
1188
1189        // Close fence -> highlighted block returned; state reset.
1190        let out = render_line("```", &mut st, plain_caps()).expect("close fence flushes");
1191        assert!(out.contains("let x = 1;"));
1192        assert!(out.contains("let y = 2;"));
1193        // Output is a single multi-line string (two indented lines + newline between).
1194        assert!(out.split('\n').count() >= 2);
1195        // State is reset for the next block.
1196        assert!(!st.in_code_block);
1197        assert!(st.code_buf.is_empty());
1198        assert!(st.code_lang.is_none());
1199    }
1200
1201    #[test]
1202    fn fence_close_with_colors_produces_truecolor_ansi() {
1203        let mut st = MdState::new();
1204        render_line("```rust", &mut st, caps());
1205        render_line("fn main() {}", &mut st, caps());
1206        let out = render_line("```", &mut st, caps()).unwrap();
1207        assert!(
1208            out.contains("\x1b[38;2;"),
1209            "tinted output must contain a truecolor SGR, got: {:?}",
1210            out
1211        );
1212    }
1213
1214    #[test]
1215    fn fence_close_with_no_color_caps_emits_plain_indent_no_ansi() {
1216        let mut st = MdState::new();
1217        render_line("```rust", &mut st, plain_caps());
1218        render_line("let x = 1;", &mut st, plain_caps());
1219        let out = render_line("```", &mut st, plain_caps()).unwrap();
1220        assert!(out.contains("  let x = 1;"));
1221        assert!(!out.contains('\x1b'), "plain_caps must emit zero ANSI, got: {:?}", out);
1222    }
1223
1224    #[test]
1225    fn fence_open_with_no_lang_tag_buffers_with_none_lang() {
1226        let mut st = MdState::new();
1227        assert!(render_line("```", &mut st, caps()).is_none());
1228        assert_eq!(st.code_lang, None);
1229        assert!(st.in_code_block);
1230    }
1231
1232    #[test]
1233    fn lang_tag_with_trailing_whitespace_is_trimmed() {
1234        let mut st = MdState::new();
1235        render_line("```rust  ", &mut st, caps());
1236        assert_eq!(st.code_lang.as_deref(), Some("rust"));
1237    }
1238
1239    #[test]
1240    fn finalize_emits_unclosed_code_block_as_fallback() {
1241        // Stream cuts off before close fence — finalize must still emit
1242        // the buffered body, otherwise the user's last few lines vanish.
1243        let mut st = MdState::new();
1244        render_line("```rust", &mut st, caps());
1245        render_line("let x = 1;", &mut st, caps());
1246        render_line("let y = 2;", &mut st, caps());
1247        // No close fence.
1248
1249        let out = finalize(&mut st, caps()).expect("unclosed block must emit something");
1250        // Use plain caps for substring check: syntect interleaves ANSI between
1251        // tokens so "let x = 1;" never appears contiguously in tinted output.
1252        // The colored-output path is already covered by Task 6's tests; here we
1253        // just verify the body survives at all.
1254        let mut st_plain = MdState::new();
1255        render_line("```rust", &mut st_plain, plain_caps());
1256        render_line("let x = 1;", &mut st_plain, plain_caps());
1257        render_line("let y = 2;", &mut st_plain, plain_caps());
1258        let out_plain = finalize(&mut st_plain, plain_caps()).expect("unclosed block must emit");
1259        assert!(out_plain.contains("let x = 1;"), "got: {:?}", out_plain);
1260        assert!(out_plain.contains("let y = 2;"), "got: {:?}", out_plain);
1261
1262        // Tinted path: at least some output (non-empty) and state cleared.
1263        assert!(!out.is_empty());
1264        assert!(st.code_buf.is_empty());
1265        assert!(!st.in_code_block);
1266    }
1267
1268    #[test]
1269    fn finalize_with_no_active_block_returns_none() {
1270        // Existing behavior: no buffered table / code → returns None.
1271        let mut st = MdState::new();
1272        assert!(finalize(&mut st, caps()).is_none());
1273    }
1274}