Skip to main content

j_cli/command/chat/markdown/
parser.rs

1use super::super::render::{display_width, wrap_text};
2use super::super::theme::Theme;
3use super::highlight::highlight_code_line;
4use ratatui::{
5    style::{Modifier, Style},
6    text::{Line, Span},
7};
8
9pub fn markdown_to_lines(md: &str, max_width: usize, theme: &Theme) -> Vec<Line<'static>> {
10    use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
11
12    // 内容区宽度 = max_width - 2(左侧 "  " 缩进由外层负责)
13    let content_width = max_width.saturating_sub(2);
14
15    // 预处理:修复 **"text"** 加粗不生效的问题。
16    // CommonMark 规范规定:左侧分隔符 ** 后面是标点(如 " U+201C)且前面是字母(如中文字符)时,
17    // 不被识别为有效的加粗开始标记。
18    // 解决方案:在 ** 与中文引号之间插入零宽空格(U+200B),使 ** 后面不再紧跟标点,
19    // 从而满足 CommonMark 规范。零宽空格在终端中不可见,不影响显示。
20    let md_owned;
21    let md = if md.contains("**\u{201C}")
22        || md.contains("**\u{2018}")
23        || md.contains("\u{201D}**")
24        || md.contains("\u{2019}**")
25    {
26        md_owned = md
27            .replace("**\u{201C}", "**\u{200B}\u{201C}")
28            .replace("**\u{2018}", "**\u{200B}\u{2018}")
29            .replace("\u{201D}**", "\u{201D}\u{200B}**")
30            .replace("\u{2019}**", "\u{2019}\u{200B}**");
31        &md_owned as &str
32    } else {
33        md
34    };
35
36    let options = Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES;
37    let parser = Parser::new_ext(md, options);
38
39    let mut lines: Vec<Line<'static>> = Vec::new();
40    let mut current_spans: Vec<Span<'static>> = Vec::new();
41    let mut style_stack: Vec<Style> = vec![Style::default().fg(theme.text_normal)];
42    let mut in_code_block = false;
43    let mut code_block_content = String::new();
44    let mut code_block_lang = String::new();
45    let mut list_depth: usize = 0;
46    let mut ordered_index: Option<u64> = None;
47    let mut heading_level: Option<u8> = None;
48    let mut in_blockquote = false;
49    // 表格相关状态
50    let mut in_table = false;
51    let mut table_rows: Vec<Vec<String>> = Vec::new();
52    let mut current_row: Vec<String> = Vec::new();
53    let mut current_cell = String::new();
54    let mut table_alignments: Vec<pulldown_cmark::Alignment> = Vec::new();
55
56    let base_style = Style::default().fg(theme.text_normal);
57
58    let flush_line = |current_spans: &mut Vec<Span<'static>>, lines: &mut Vec<Line<'static>>| {
59        if !current_spans.is_empty() {
60            lines.push(Line::from(current_spans.drain(..).collect::<Vec<_>>()));
61        }
62    };
63
64    for event in parser {
65        match event {
66            Event::Start(Tag::Heading { level, .. }) => {
67                flush_line(&mut current_spans, &mut lines);
68                heading_level = Some(level as u8);
69                if !lines.is_empty() {
70                    lines.push(Line::from(""));
71                }
72                let heading_style = match level as u8 {
73                    1 => Style::default()
74                        .fg(theme.md_h1)
75                        .add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
76                    2 => Style::default()
77                        .fg(theme.md_h2)
78                        .add_modifier(Modifier::BOLD),
79                    3 => Style::default()
80                        .fg(theme.md_h3)
81                        .add_modifier(Modifier::BOLD),
82                    _ => Style::default()
83                        .fg(theme.md_h4)
84                        .add_modifier(Modifier::BOLD),
85                };
86                style_stack.push(heading_style);
87            }
88            Event::End(TagEnd::Heading(level)) => {
89                flush_line(&mut current_spans, &mut lines);
90                if (level as u8) <= 2 {
91                    let sep_char = if (level as u8) == 1 { "━" } else { "─" };
92                    lines.push(Line::from(Span::styled(
93                        sep_char.repeat(content_width),
94                        Style::default().fg(theme.md_heading_sep),
95                    )));
96                }
97                style_stack.pop();
98                heading_level = None;
99            }
100            Event::Start(Tag::Strong) => {
101                let current = *style_stack.last().unwrap_or(&base_style);
102                style_stack.push(current.add_modifier(Modifier::BOLD).fg(theme.text_bold));
103            }
104            Event::End(TagEnd::Strong) => {
105                style_stack.pop();
106            }
107            Event::Start(Tag::Emphasis) => {
108                let current = *style_stack.last().unwrap_or(&base_style);
109                style_stack.push(current.add_modifier(Modifier::ITALIC));
110            }
111            Event::End(TagEnd::Emphasis) => {
112                style_stack.pop();
113            }
114            Event::Start(Tag::Strikethrough) => {
115                let current = *style_stack.last().unwrap_or(&base_style);
116                style_stack.push(current.add_modifier(Modifier::CROSSED_OUT));
117            }
118            Event::End(TagEnd::Strikethrough) => {
119                style_stack.pop();
120            }
121            Event::Start(Tag::CodeBlock(kind)) => {
122                flush_line(&mut current_spans, &mut lines);
123                in_code_block = true;
124                code_block_content.clear();
125                code_block_lang = match kind {
126                    CodeBlockKind::Fenced(lang) => lang.to_string(),
127                    CodeBlockKind::Indented => String::new(),
128                };
129                let label = if code_block_lang.is_empty() {
130                    " code ".to_string()
131                } else {
132                    format!(" {} ", code_block_lang)
133                };
134                let label_w = display_width(&label);
135                let border_fill = content_width.saturating_sub(2 + label_w);
136                let top_border = format!("┌─{}{}", label, "─".repeat(border_fill));
137                lines.push(Line::from(Span::styled(
138                    top_border,
139                    Style::default().fg(theme.code_border),
140                )));
141            }
142            Event::End(TagEnd::CodeBlock) => {
143                let code_inner_w = content_width.saturating_sub(4);
144                let code_content_expanded = code_block_content.replace('\t', "    ");
145                for code_line in code_content_expanded.lines() {
146                    let wrapped = wrap_text(code_line, code_inner_w);
147                    for wl in wrapped {
148                        let highlighted = highlight_code_line(&wl, &code_block_lang, theme);
149                        let text_w: usize =
150                            highlighted.iter().map(|s| display_width(&s.content)).sum();
151                        let fill = code_inner_w.saturating_sub(text_w);
152                        let mut spans_vec = Vec::new();
153                        spans_vec.push(Span::styled("│ ", Style::default().fg(theme.code_border)));
154                        for hs in highlighted {
155                            spans_vec.push(Span::styled(
156                                hs.content.to_string(),
157                                hs.style.bg(theme.code_bg),
158                            ));
159                        }
160                        spans_vec.push(Span::styled(
161                            format!("{} │", " ".repeat(fill)),
162                            Style::default().fg(theme.code_border).bg(theme.code_bg),
163                        ));
164                        lines.push(Line::from(spans_vec));
165                    }
166                }
167                let bottom_border = format!("└{}", "─".repeat(content_width.saturating_sub(1)));
168                lines.push(Line::from(Span::styled(
169                    bottom_border,
170                    Style::default().fg(theme.code_border),
171                )));
172                in_code_block = false;
173                code_block_content.clear();
174                code_block_lang.clear();
175            }
176            Event::Code(text) => {
177                if in_table {
178                    current_cell.push('`');
179                    current_cell.push_str(&text);
180                    current_cell.push('`');
181                } else {
182                    let code_str = format!(" {} ", text);
183                    let code_w = display_width(&code_str);
184                    let effective_prefix_w = if in_blockquote { 2 } else { 0 };
185                    let full_line_w = content_width.saturating_sub(effective_prefix_w);
186                    let existing_w: usize = current_spans
187                        .iter()
188                        .map(|s| display_width(&s.content))
189                        .sum();
190                    if existing_w + code_w > full_line_w && !current_spans.is_empty() {
191                        flush_line(&mut current_spans, &mut lines);
192                        if in_blockquote {
193                            current_spans.push(Span::styled(
194                                "| ".to_string(),
195                                Style::default().fg(theme.md_blockquote_bar),
196                            ));
197                        }
198                    }
199                    current_spans.push(Span::styled(
200                        code_str,
201                        Style::default()
202                            .fg(theme.md_inline_code_fg)
203                            .bg(theme.md_inline_code_bg),
204                    ));
205                }
206            }
207            Event::Start(Tag::List(start)) => {
208                flush_line(&mut current_spans, &mut lines);
209                list_depth += 1;
210                ordered_index = start;
211            }
212            Event::End(TagEnd::List(_)) => {
213                flush_line(&mut current_spans, &mut lines);
214                list_depth = list_depth.saturating_sub(1);
215                ordered_index = None;
216            }
217            Event::Start(Tag::Item) => {
218                flush_line(&mut current_spans, &mut lines);
219                let indent = "  ".repeat(list_depth);
220                let bullet = if let Some(ref mut idx) = ordered_index {
221                    let s = format!("{}{}. ", indent, idx);
222                    *idx += 1;
223                    s
224                } else {
225                    format!("{}• ", indent)
226                };
227                current_spans.push(Span::styled(
228                    bullet,
229                    Style::default().fg(theme.md_list_bullet),
230                ));
231            }
232            Event::End(TagEnd::Item) => {
233                flush_line(&mut current_spans, &mut lines);
234            }
235            Event::Start(Tag::Paragraph) => {
236                if !lines.is_empty() && !in_code_block && heading_level.is_none() {
237                    let last_empty = lines.last().map(|l| l.spans.is_empty()).unwrap_or(false);
238                    if !last_empty {
239                        lines.push(Line::from(""));
240                    }
241                }
242            }
243            Event::End(TagEnd::Paragraph) => {
244                flush_line(&mut current_spans, &mut lines);
245            }
246            Event::Start(Tag::BlockQuote(_)) => {
247                flush_line(&mut current_spans, &mut lines);
248                in_blockquote = true;
249                style_stack.push(Style::default().fg(theme.md_blockquote_text));
250            }
251            Event::End(TagEnd::BlockQuote(_)) => {
252                flush_line(&mut current_spans, &mut lines);
253                in_blockquote = false;
254                style_stack.pop();
255            }
256            Event::Text(text) => {
257                if in_code_block {
258                    code_block_content.push_str(&text);
259                } else if in_table {
260                    current_cell.push_str(&text);
261                } else {
262                    let style = *style_stack.last().unwrap_or(&base_style);
263                    let text_str = text.to_string().replace('\u{200B}', "");
264
265                    if let Some(level) = heading_level {
266                        let (prefix, prefix_style) = match level {
267                            1 => (
268                                "◆ ",
269                                Style::default()
270                                    .fg(theme.md_h1)
271                                    .add_modifier(Modifier::BOLD),
272                            ),
273                            2 => (
274                                "◇ ",
275                                Style::default()
276                                    .fg(theme.md_h2)
277                                    .add_modifier(Modifier::BOLD),
278                            ),
279                            3 => (
280                                "▸ ",
281                                Style::default()
282                                    .fg(theme.md_h3)
283                                    .add_modifier(Modifier::BOLD),
284                            ),
285                            _ => (
286                                "▹ ",
287                                Style::default()
288                                    .fg(theme.md_h4)
289                                    .add_modifier(Modifier::BOLD),
290                            ),
291                        };
292                        current_spans.push(Span::styled(prefix.to_string(), prefix_style));
293                        heading_level = None;
294                    }
295
296                    let effective_prefix_w = if in_blockquote { 2 } else { 0 };
297                    let full_line_w = content_width.saturating_sub(effective_prefix_w);
298
299                    let existing_w: usize = current_spans
300                        .iter()
301                        .map(|s| display_width(&s.content))
302                        .sum();
303
304                    let wrap_w = full_line_w.saturating_sub(existing_w);
305
306                    let min_useful_w = full_line_w / 4;
307                    let wrap_w = if wrap_w < min_useful_w.max(4) && !current_spans.is_empty() {
308                        flush_line(&mut current_spans, &mut lines);
309                        if in_blockquote {
310                            current_spans.push(Span::styled(
311                                "| ".to_string(),
312                                Style::default().fg(theme.md_blockquote_bar),
313                            ));
314                        }
315                        full_line_w
316                    } else {
317                        wrap_w
318                    };
319
320                    for (i, line) in text_str.split('\n').enumerate() {
321                        if i > 0 {
322                            flush_line(&mut current_spans, &mut lines);
323                            if in_blockquote {
324                                current_spans.push(Span::styled(
325                                    "| ".to_string(),
326                                    Style::default().fg(theme.md_blockquote_bar),
327                                ));
328                            }
329                        }
330                        if !line.is_empty() {
331                            let effective_wrap = if i == 0 {
332                                wrap_w
333                            } else {
334                                content_width.saturating_sub(effective_prefix_w)
335                            };
336                            let wrapped = wrap_text(line, effective_wrap);
337                            for (j, wl) in wrapped.iter().enumerate() {
338                                if j > 0 {
339                                    flush_line(&mut current_spans, &mut lines);
340                                    if in_blockquote {
341                                        current_spans.push(Span::styled(
342                                            "| ".to_string(),
343                                            Style::default().fg(theme.md_blockquote_bar),
344                                        ));
345                                    }
346                                }
347                                current_spans.push(Span::styled(wl.clone(), style));
348                            }
349                        }
350                    }
351                }
352            }
353            Event::SoftBreak => {
354                if in_table {
355                    current_cell.push(' ');
356                } else {
357                    current_spans.push(Span::raw(" "));
358                }
359            }
360            Event::HardBreak => {
361                if in_table {
362                    current_cell.push(' ');
363                } else {
364                    flush_line(&mut current_spans, &mut lines);
365                }
366            }
367            Event::Rule => {
368                flush_line(&mut current_spans, &mut lines);
369                lines.push(Line::from(Span::styled(
370                    "─".repeat(content_width),
371                    Style::default().fg(theme.md_rule),
372                )));
373            }
374            // ===== 表格支持 =====
375            Event::Start(Tag::Table(alignments)) => {
376                flush_line(&mut current_spans, &mut lines);
377                in_table = true;
378                table_rows.clear();
379                table_alignments = alignments;
380            }
381            Event::End(TagEnd::Table) => {
382                flush_line(&mut current_spans, &mut lines);
383                in_table = false;
384
385                if !table_rows.is_empty() {
386                    let num_cols = table_rows.iter().map(|r| r.len()).max().unwrap_or(0);
387                    if num_cols > 0 {
388                        let mut col_widths: Vec<usize> = vec![0; num_cols];
389                        for row in &table_rows {
390                            for (i, cell) in row.iter().enumerate() {
391                                let w = display_width(cell);
392                                if w > col_widths[i] {
393                                    col_widths[i] = w;
394                                }
395                            }
396                        }
397
398                        let sep_w = num_cols + 1;
399                        let pad_w = num_cols * 2;
400                        let avail = content_width.saturating_sub(sep_w + pad_w);
401                        let max_col_w = avail * 2 / 3;
402                        for cw in col_widths.iter_mut() {
403                            if *cw > max_col_w {
404                                *cw = max_col_w;
405                            }
406                        }
407                        let total_col_w: usize = col_widths.iter().sum();
408                        if total_col_w > avail && total_col_w > 0 {
409                            let mut remaining = avail;
410                            for (i, cw) in col_widths.iter_mut().enumerate() {
411                                if i == num_cols - 1 {
412                                    *cw = remaining.max(1);
413                                } else {
414                                    *cw = ((*cw) * avail / total_col_w).max(1);
415                                    remaining = remaining.saturating_sub(*cw);
416                                }
417                            }
418                        }
419
420                        let table_style = Style::default().fg(theme.table_body);
421                        let header_style = Style::default()
422                            .fg(theme.table_header)
423                            .add_modifier(Modifier::BOLD);
424                        let border_style = Style::default().fg(theme.table_border);
425
426                        let total_col_w_final: usize = col_widths.iter().sum();
427                        let table_row_w = sep_w + pad_w + total_col_w_final;
428                        let table_right_pad = content_width.saturating_sub(table_row_w);
429
430                        // 渲染顶边框 ┌─┬─┐
431                        let mut top = String::from("┌");
432                        for (i, cw) in col_widths.iter().enumerate() {
433                            top.push_str(&"─".repeat(cw + 2));
434                            if i < num_cols - 1 {
435                                top.push('┬');
436                            }
437                        }
438                        top.push('┐');
439                        let mut top_spans = vec![Span::styled(top, border_style)];
440                        if table_right_pad > 0 {
441                            top_spans.push(Span::raw(" ".repeat(table_right_pad)));
442                        }
443                        lines.push(Line::from(top_spans));
444
445                        for (row_idx, row) in table_rows.iter().enumerate() {
446                            let mut row_spans: Vec<Span> = Vec::new();
447                            row_spans.push(Span::styled("│", border_style));
448                            for (i, cw) in col_widths.iter().enumerate() {
449                                let cell_text = row.get(i).map(|s| s.as_str()).unwrap_or("");
450                                let cell_w = display_width(cell_text);
451                                let text = if cell_w > *cw {
452                                    let mut t = String::new();
453                                    let mut w = 0;
454                                    for ch in cell_text.chars() {
455                                        use super::super::render::char_width;
456                                        let chw = char_width(ch);
457                                        if w + chw > *cw {
458                                            break;
459                                        }
460                                        t.push(ch);
461                                        w += chw;
462                                    }
463                                    let fill = cw.saturating_sub(w);
464                                    format!(" {}{} ", t, " ".repeat(fill))
465                                } else {
466                                    let fill = cw.saturating_sub(cell_w);
467                                    let align = table_alignments
468                                        .get(i)
469                                        .copied()
470                                        .unwrap_or(pulldown_cmark::Alignment::None);
471                                    match align {
472                                        pulldown_cmark::Alignment::Center => {
473                                            let left = fill / 2;
474                                            let right = fill - left;
475                                            format!(
476                                                " {}{}{} ",
477                                                " ".repeat(left),
478                                                cell_text,
479                                                " ".repeat(right)
480                                            )
481                                        }
482                                        pulldown_cmark::Alignment::Right => {
483                                            format!(" {}{} ", " ".repeat(fill), cell_text)
484                                        }
485                                        _ => format!(" {}{} ", cell_text, " ".repeat(fill)),
486                                    }
487                                };
488                                let style = if row_idx == 0 {
489                                    header_style
490                                } else {
491                                    table_style
492                                };
493                                row_spans.push(Span::styled(text, style));
494                                row_spans.push(Span::styled("│", border_style));
495                            }
496                            if table_right_pad > 0 {
497                                row_spans.push(Span::raw(" ".repeat(table_right_pad)));
498                            }
499                            lines.push(Line::from(row_spans));
500
501                            if row_idx == 0 {
502                                let mut sep = String::from("├");
503                                for (i, cw) in col_widths.iter().enumerate() {
504                                    sep.push_str(&"─".repeat(cw + 2));
505                                    if i < num_cols - 1 {
506                                        sep.push('┼');
507                                    }
508                                }
509                                sep.push('┤');
510                                let mut sep_spans = vec![Span::styled(sep, border_style)];
511                                if table_right_pad > 0 {
512                                    sep_spans.push(Span::raw(" ".repeat(table_right_pad)));
513                                }
514                                lines.push(Line::from(sep_spans));
515                            }
516                        }
517
518                        // 底边框 └─┴─┘
519                        let mut bottom = String::from("└");
520                        for (i, cw) in col_widths.iter().enumerate() {
521                            bottom.push_str(&"─".repeat(cw + 2));
522                            if i < num_cols - 1 {
523                                bottom.push('┴');
524                            }
525                        }
526                        bottom.push('┘');
527                        let mut bottom_spans = vec![Span::styled(bottom, border_style)];
528                        if table_right_pad > 0 {
529                            bottom_spans.push(Span::raw(" ".repeat(table_right_pad)));
530                        }
531                        lines.push(Line::from(bottom_spans));
532                    }
533                }
534                table_rows.clear();
535                table_alignments.clear();
536            }
537            Event::Start(Tag::TableHead) => {
538                current_row.clear();
539            }
540            Event::End(TagEnd::TableHead) => {
541                table_rows.push(current_row.clone());
542                current_row.clear();
543            }
544            Event::Start(Tag::TableRow) => {
545                current_row.clear();
546            }
547            Event::End(TagEnd::TableRow) => {
548                table_rows.push(current_row.clone());
549                current_row.clear();
550            }
551            Event::Start(Tag::TableCell) => {
552                current_cell.clear();
553            }
554            Event::End(TagEnd::TableCell) => {
555                current_row.push(current_cell.clone());
556                current_cell.clear();
557            }
558            _ => {}
559        }
560    }
561
562    // 刷新最后一行
563    if !current_spans.is_empty() {
564        lines.push(Line::from(current_spans));
565    }
566
567    // 如果解析结果为空,至少返回原始文本
568    if lines.is_empty() {
569        let wrapped = wrap_text(md, content_width);
570        for wl in wrapped {
571            lines.push(Line::from(Span::styled(wl, base_style)));
572        }
573    }
574
575    lines
576}