1use crate::highlight::theme;
12use crate::terminal::TerminalCaps;
13
14#[derive(Default)]
16pub struct MdState {
17 pub in_code_block: bool,
18 pub table_buf: Vec<String>,
21 pub code_buf: Vec<String>,
27 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
44pub fn render_line(line: &str, state: &mut MdState, caps: TerminalCaps) -> Option<String> {
48 render_line_with_width(line, state, caps, 0)
49}
50
51pub 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 if !state.in_code_block && trimmed.starts_with('|') {
65 state.table_buf.push(trimmed.to_string());
66 return None;
67 }
68
69 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 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 if is_fence(trimmed) {
115 if state.in_code_block {
116 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 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 if state.in_code_block {
139 state.code_buf.push(line.to_string());
140 return prefix_only();
141 }
142
143 if is_hrule(trimmed) {
147 return Some(prepend(String::new()));
148 }
149
150 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 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 Some(prepend(render_inline(line, caps)))
193}
194
195pub fn finalize(state: &mut MdState, caps: TerminalCaps) -> Option<String> {
198 finalize_with_width(state, caps, 0)
199}
200
201pub fn finalize_with_width(
203 state: &mut MdState,
204 caps: TerminalCaps,
205 max_width: usize,
206) -> Option<String> {
207 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
241fn 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
287pub fn flush_aligned_table(rows: &[String], caps: TerminalCaps) -> String {
292 flush_aligned_table_with_width(rows, caps, 0)
293}
294
295pub fn flush_aligned_table_with_width(
301 rows: &[String],
302 caps: TerminalCaps,
303 max_width: usize,
304) -> String {
305 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 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 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 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 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 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 out.push_str(&rule('┌', '┬', '┐'));
385 out.push('\n');
386
387 for (i, row) in data_rows.iter().enumerate() {
388 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 if i + 1 < data_rows.len() {
411 out.push_str(&rule('├', '┼', '┤'));
412 out.push('\n');
413 }
414 }
415
416 out.push_str(&rule('└', '┴', '┘'));
418 out
419}
420
421fn 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 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 if out.ends_with('\n') {
471 out.pop();
472 }
473 out
474}
475
476fn strip_md_for_width(s: &str) -> String {
477 s.replace("**", "").replace('`', "")
479}
480
481pub fn render_inline_line(line: &str, caps: TerminalCaps) -> String {
484 render_inline(line, caps)
485}
486
487fn 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 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
600fn 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
657struct 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 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 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]; 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 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 let mut state = MdState::new();
766 let _ = render_line("```", &mut state, plain_caps()); 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 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 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 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 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 let mut st = MdState::new();
864 assert!(render_line("```rust", &mut st, plain_caps()).is_none());
865 assert!(st.in_code_block);
866
867 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 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 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 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 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 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 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 #[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 let out = flush_aligned_table_with_width(&rows, plain_caps(), 80);
997 assert!(out.contains('┌'));
998 assert!(out.contains('│'));
999 assert!(out.contains('└'));
1000 assert!(out.contains("login"));
1002 assert!(out.contains("signup"));
1003 assert!(!out.contains('…'));
1005 }
1006
1007 #[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 let out = flush_aligned_table_with_width(&rows, plain_caps(), 40);
1020
1021 assert!(!out.contains('│'), "narrow output must not contain border │");
1023 assert!(!out.contains('┌'), "narrow output must not contain top corner");
1024
1025 assert!(out.contains("AtomCode Air"));
1027 assert!(out.contains("Python+"));
1028
1029 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 assert!(
1037 out.contains("\n\n"),
1038 "expected blank line between flat records"
1039 );
1040 }
1041
1042 #[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 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 #[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 "", ];
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 assert!(
1086 !out.contains('┌') && !out.contains('└'),
1087 "narrow box-drawing table must collapse to flat:\n{out}"
1088 );
1089 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 assert!(out.contains("parallel_edit_files"));
1098 assert!(out.contains("初始 4 轮"));
1099 }
1100
1101 #[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 #[test]
1132 fn box_drawing_detection_does_not_swallow_prose_with_stray_box_char() {
1133 let mut st = MdState::new();
1134 let line = "├ hello, this is not a table line";
1137 let out = render_line_with_width(line, &mut st, plain_caps(), 80);
1138 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 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 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 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 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 assert!(out.split('\n').count() >= 2);
1195 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 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 let out = finalize(&mut st, caps()).expect("unclosed block must emit something");
1250 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 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 let mut st = MdState::new();
1272 assert!(finalize(&mut st, caps()).is_none());
1273 }
1274}