j_cli/command/chat/markdown/
parser.rs1use 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 let content_width = max_width.saturating_sub(2);
14
15 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 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 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 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 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 if !current_spans.is_empty() {
564 lines.push(Line::from(current_spans));
565 }
566
567 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}