我的第一个开源贡献:修复 DeepSeek-TUI 的 Markdown 渲染问题

背景

DeepSeek-TUI 是一个用 Rust 编写的终端 AI 助手,基于 DeepSeek V4 模型,支持 1M token 上下文。我在日常使用中发现 AI 的回复里 Markdown 格式完全没有被渲染,严重影响阅读体验。这是我第一次接触 Rust,也是我第一次向开源项目提交 PR。

问题现象

使用时发现三类渲染问题:

1. 表格原样输出

| 名称 | 版本 | 状态 |
|----------|------|------|
| React | 18.x | 稳定 |

分隔行 |---| 也直接显示出来,没有任何格式化。

2. 粗体/斜体标记符没有被剥掉

**粗体文本** 和 *斜体文本*

星号原样显示,没有加粗或斜体效果。

3. 水平线没有渲染

---

直接显示成三个横杠,没有渲染成分隔线。


代码分析

项目的渲染逻辑在 crates/tui/src/tui/markdown_render.rs。读完代码后理解了它的设计:

两阶段渲染架构

源文本 → parse() → ParsedMarkdown (AST) → render_parsed(width) → Vec<Line>
  • parse 阶段:与终端宽度无关,把源文本分类成 Block 枚举的各种变体
  • render 阶段:依赖宽度,做折行和样式渲染

这个设计的好处是终端 resize 时只需重新 render,不需要重新 parse,性能更好。

问题根因

问题一:Block 枚举缺少表格和水平线类型

原来的 Block 枚举只有:

pub enum Block {
    Heading { level: usize, text: String },
    HeadingRule,
    ListItem { bullet: String, text: String },
    Code { line: String },
    Paragraph { text: String },
    Blank,
}

没有 HorizontalRuleTableRow,所以 ---| col | 都被当成普通段落原样输出。

问题二:内联格式按词分割导致跨词标记识别失败

原来的 render_line_with_links 函数是这样处理的:

for word in line.split_whitespace() {
    let is_link = looks_like_link(word);
    let style = if is_link { link_style } else { base_style };
    // ...
}

按空格分词后逐词判断,**bold text** 会被拆成 **boldtext** 两个词,每个词单独看都不符合 **...** 的格式,所以识别失败,星号原样输出。


修复方案

第一步:新增 Block 变体

Block 枚举里加入两个新类型:

 pub enum Block {
     Heading { level: usize, text: String },
     HeadingRule,
+    /// A standalone `---` / `***` / `___` horizontal rule.
+    HorizontalRule,
     ListItem { bullet: String, text: String },
     Code { line: String },
+    /// A table row: cells split on `|`. Separator rows (`|---|`) are dropped.
+    TableRow(Vec<String>),
     Paragraph { text: String },
     Blank,
 }

第二步:在 parse() 里识别新类型

在解析循环里加入水平线和表格的识别逻辑:

+        if is_horizontal_rule(trimmed) {
+            blocks.push(Block::HorizontalRule);
+            continue;
+        }
+
+        match parse_table_row(trimmed) {
+            Some(cells) => {
+                blocks.push(Block::TableRow(cells));
+                continue;
+            }
+            None if trimmed.starts_with('|') => continue, // separator row — drop it
+            None => {}
+        }
+
         if raw_line.is_empty() {

关键点:parse_table_row 返回 None 且行以 | 开头时,说明是分隔行(|---|),直接 continue 丢弃,不降级为 Paragraph

is_horizontal_rule 实现:

fn is_horizontal_rule(line: &str) -> bool {
    let stripped: String = line.chars().filter(|c| !c.is_whitespace()).collect();
    (stripped.chars().all(|c| c == '-')
        || stripped.chars().all(|c| c == '*')
        || stripped.chars().all(|c| c == '_'))
        && stripped.len() >= 3
}

去掉空白字符后,判断是否全由 -*_ 组成且长度 ≥ 3。

parse_table_row 实现:

fn parse_table_row(line: &str) -> Option<Vec<String>> {
    if !line.starts_with('|') {
        return None;
    }
    let inner = line.trim_matches('|');
    let cells: Vec<String> = inner.split('|').map(|c| c.trim().to_string()).collect();
    // Separator row: every non-empty cell is only dashes/colons/spaces
    if cells
        .iter()
        .all(|c| c.is_empty() || c.chars().all(|ch| ch == '-' || ch == ':' || ch == ' '))
    {
        return None;
    }
    Some(cells)
}

去掉首尾 | 后按 | 分割,trim 每个单元格。如果所有非空单元格都只含 -: 或空格,说明是分隔行,返回 None

第三步:重写内联格式解析

原来按词分割的方式无法处理跨词的 **bold text**,需要先扫描整行识别所有内联标记,再做折行。

新增 parse_inline_spans 函数,返回 (文本, 样式) 的列表:

fn parse_inline_spans(line: &str, base_style: Style, link_style: Style) -> Vec<(String, Style)> {
    let bold_style = base_style.add_modifier(Modifier::BOLD);
    let italic_style = base_style.add_modifier(Modifier::ITALIC);
    let mut out = Vec::new();
    let mut rest = line;

    while !rest.is_empty() {
        // **bold**
        if let Some(end) = rest.strip_prefix("**").and_then(|s| s.find("**")) {
            let inner = &rest[2..2 + end];
            out.push((inner.to_string(), bold_style));
            rest = &rest[2 + end + 2..];
            continue;
        }
        // __bold__
        if let Some(end) = rest.strip_prefix("__").and_then(|s| s.find("__")) {
            let inner = &rest[2..2 + end];
            out.push((inner.to_string(), bold_style));
            rest = &rest[2 + end + 2..];
            continue;
        }
        // *italic*
        if rest.starts_with('*')
            && !rest.starts_with("**")
            && let Some(end) = rest[1..].find('*')
        {
            let inner = &rest[1..1 + end];
            out.push((inner.to_string(), italic_style));
            rest = &rest[1 + end + 1..];
            continue;
        }
        // _italic_
        if rest.starts_with('_')
            && !rest.starts_with("__")
            && let Some(end) = rest[1..].find('_')
        {
            let inner = &rest[1..1 + end];
            out.push((inner.to_string(), italic_style));
            rest = &rest[1 + end + 1..];
            continue;
        }
        // URL: consume until whitespace
        if rest.starts_with("http://") || rest.starts_with("https://") {
            let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
            let url = &rest[..end];
            let content = if osc8::enabled() {
                osc8::wrap_link(url, url)
            } else {
                url.to_string()
            };
            out.push((content, link_style));
            rest = &rest[end..];
            continue;
        }
        // Plain text: consume until next marker or URL
        let next = find_next_marker(rest).max(rest.chars().next().map_or(1, |c| c.len_utf8()));
        out.push((rest[..next].to_string(), base_style));
        rest = &rest[next..];
    }
    out
}

render_line_with_links 改为先调用 parse_inline_spans 得到带样式的 token 列表,再做折行:

-    for word in line.split_whitespace() {
-        let is_link = looks_like_link(word);
-        let style = if is_link { link_style } else { base_style };
-        // ...
-    }
+    let tokens = parse_inline_spans(line, base_style, link_style);
+    let mut words: Vec<(String, Style)> = Vec::new();
+    for (text, style) in tokens {
+        let mut first = true;
+        for part in text.split(' ') {
+            if !first {
+                words.push((" ".to_string(), style));
+            }
+            if !part.is_empty() {
+                words.push((part.to_string(), style));
+            }
+            first = false;
+        }
+    }

第四步:在 render_parsed() 里渲染新类型

+            Block::HorizontalRule => {
+                out.push(Line::from(Span::styled(
+                    "─".repeat(width.min(60)),
+                    Style::default().fg(palette::TEXT_DIM),
+                )));
+            }
+            Block::TableRow(cells) => {
+                out.extend(render_table_row(cells, width, base_style));
+            }

render_table_row 实现:

fn render_table_row(cells: &[String], width: usize, base_style: Style) -> Vec<Line<'static>> {
    if cells.is_empty() {
        return vec![Line::from("")];
    }
    let col_width = (width.saturating_sub(3 * cells.len() + 1)) / cells.len();
    let col_width = col_width.max(4);
    let sep_style = Style::default().fg(palette::TEXT_DIM);
    let mut spans: Vec<Span> = vec![Span::styled("│ ".to_string(), sep_style)];
    for (i, cell) in cells.iter().enumerate() {
        // 超出列宽时截断并加省略号
        let truncated = if cell.width() > col_width {
            let mut s = String::new();
            let mut w = 0;
            for ch in cell.chars() {
                let cw = ch.width().unwrap_or(1);
                if w + cw + 1 > col_width {
                    s.push('…');
                    break;
                }
                s.push(ch);
                w += cw;
            }
            s
        } else {
            cell.clone()
        };
        // 单元格内容也走内联格式解析(支持表格里的粗体)
        let cell_spans = parse_inline_spans(&truncated, base_style, link_style());
        let cell_width: usize = cell_spans.iter().map(|(t, _)| t.width()).sum();
        let pad = col_width.saturating_sub(cell_width);
        for (text, style) in cell_spans {
            spans.push(Span::styled(text, style));
        }
        spans.push(Span::raw(" ".repeat(pad)));
        if i + 1 < cells.len() {
            spans.push(Span::styled(" │ ".to_string(), sep_style));
        } else {
            spans.push(Span::styled(" │".to_string(), sep_style));
        }
    }
    vec![Line::from(spans)]
}

(Unicode 竖线)而不是 ASCII | 作为分隔符,视觉上更整洁。列宽计算时用 UnicodeWidthChar 按字符实际显示宽度计算,正确处理中文等宽字符。

第五步:PR 审查后的修复

PR 提交后,gemini-code-assist bot 指出了两处小问题:

修复1:空格 token 的样式应继承当前 token 的样式

-words.push((" ".to_string(), base_style));
+words.push((" ".to_string(), style));

修复2:表格列宽计算没有考虑分隔符宽度

-let col_width = (width.saturating_sub(cells.len() + 1)) / cells.len();
+let col_width = (width.saturating_sub(3 * cells.len() + 1)) / cells.len();

每列分隔符占 3 个字符(),原来只减了 1,导致列宽计算偏大,表格会溢出终端宽度。


测试

新增了三个测试用例:

#[test]
fn table_separator_row_is_dropped() {
    let src = "| 项目属性 | 详情 |\n|----------|------|\n| **语言** | Rust 1.85+ |\n";
    let parsed = parse(src);
    let table_rows: Vec<_> = parsed.blocks.iter()
        .filter(|b| matches!(b, Block::TableRow(_)))
        .collect();
    assert_eq!(table_rows.len(), 2); // 只有表头和数据行,没有分隔行
}

#[test]
fn bold_markers_stripped_in_render() {
    let src = "这是一个 **Rust 工作区项目**,包含多个 crate。\n";
    let lines = render_markdown(src, 80, Style::default());
    let text: String = lines.iter()
        .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
        .collect();
    assert!(!text.contains("**")); // 星号不能出现在输出里
    assert!(text.contains("Rust")); // 内容要保留
}

#[test]
fn table_renders_with_pipe_separator() {
    let src = "| 文件 | 改动 |\n|---|---|\n| foo.rs | 重写 |\n";
    let lines = render_markdown(src, 60, Style::default());
    let text: String = lines.iter()
        .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
        .collect();
    assert!(text.contains('│')); // 用 Unicode 竖线
    assert!(!text.contains("|---|")); // 分隔行不能出现
}

最终 12 个测试全部通过。


结果

PR 被作者 merge,修复从 v0.8.10 起包含在所有版本中。项目当天冲上 GitHub Trending 第一,我的头像也挂在上面。

注意:通过 npm install / deepseek update 安装的预编译二进制可能不包含此修复(取决于作者打包时间)。通过 cargo install 从源码编译可以确保拿到最新版本。


收获

技术层面:

  • 第一次读懂并修改 Rust 项目,了解了枚举、模式匹配、字符串切片的基本用法
  • 理解了"解析与渲染分离"的架构设计——parse 结果与终端宽度无关,resize 时只需重新 render
  • 学会了用 UnicodeWidthChar 正确计算中文等宽字符的显示宽度
  • 了解了预编译二进制分发(npm)和从源码编译(cargo install)的区别

流程层面:

  • 第一次完整走完 fork → 修改 → 测试 → PR → review → merge 的开源贡献流程
  • 了解了 CI bot(gemini-code-assist)的代码审查方式,以及如何根据 review 意见修改代码