newt_core/agentic/markdown/
mod.rs1mod emitter;
20mod inline;
21mod stream;
22mod syntect;
23mod table;
24mod width;
25
26pub use stream::MarkdownStreamWriter;
27
28use emitter::Emitter;
29use pulldown_cmark::{Options, Parser};
30
31#[derive(Debug, Clone, Copy)]
35pub struct RenderOpts {
36 pub color: bool,
38 pub cols: usize,
40}
41
42pub fn render_markdown(src: &str, opts: RenderOpts) -> String {
46 if !opts.color {
47 return src.to_string();
48 }
49 let mut options = Options::empty();
50 options.insert(Options::ENABLE_STRIKETHROUGH);
51 options.insert(Options::ENABLE_TASKLISTS);
52 options.insert(Options::ENABLE_TABLES);
53 let parser = Parser::new_ext(src, options);
54 let mut em = Emitter::new(opts.cols);
55 for ev in parser {
56 em.handle(ev);
57 }
58 em.finish()
59}
60
61#[cfg(test)]
62mod tests {
63 use super::{render_markdown, RenderOpts};
64
65 const BOLD: &str = "\x1b[1m";
67 const ITALIC: &str = "\x1b[3m";
68 const UNDER: &str = "\x1b[4m";
69 const STRIKE: &str = "\x1b[9m";
70 const RESET: &str = "\x1b[0m";
71 const ORANGE: &str = "\x1b[38;2;220;60;20m";
72 const FADE: &str = "\x1b[38;2;90;90;90m";
73
74 fn r(src: &str) -> String {
75 render_markdown(
76 src,
77 RenderOpts {
78 color: true,
79 cols: 80,
80 },
81 )
82 }
83 fn rw(src: &str, cols: usize) -> String {
84 render_markdown(src, RenderOpts { color: true, cols })
85 }
86
87 #[test]
88 fn plain_text_passes_through_unstyled() {
89 assert_eq!(r("hello world"), "hello world");
90 }
91
92 #[test]
93 fn inline_emphasis_variants() {
94 assert_eq!(r("**bold**"), format!("{BOLD}bold{RESET}"));
95 assert_eq!(r("*it*"), format!("{ITALIC}it{RESET}"));
96 assert_eq!(r("~~s~~"), format!("{STRIKE}s{RESET}"));
97 assert_eq!(r("`c`"), format!("{FADE}c{RESET}"));
98 }
99
100 #[test]
101 fn emphasis_adjacent_to_text_has_no_spurious_space() {
102 assert_eq!(r("un**bold**ed"), format!("un{BOLD}bold{RESET}ed"));
104 }
105
106 #[test]
107 fn heading_is_bold_orange() {
108 assert_eq!(r("# Title"), format!("{BOLD}{ORANGE}Title{RESET}"));
109 }
110
111 #[test]
112 fn paragraphs_are_separated_by_a_blank_line() {
113 assert_eq!(r("a\n\nb"), "a\n\nb");
114 }
115
116 #[test]
117 fn greedy_word_wrap_breaks_at_spaces() {
118 assert_eq!(rw("alpha beta gamma", 11), "alpha beta\ngamma");
119 }
120
121 #[test]
122 fn wrap_counts_display_width_not_chars() {
123 assert_eq!(rw("日本語 語", 8), "日本語\n語");
127 }
128
129 #[test]
130 fn bullet_list_tight() {
131 assert_eq!(r("- one\n- two"), "• one\n• two");
132 }
133
134 #[test]
135 fn ordered_list_numbers() {
136 assert_eq!(r("1. a\n2. b"), "1. a\n2. b");
137 }
138
139 #[test]
140 fn task_list_markers() {
141 assert_eq!(r("- [x] done\n- [ ] todo"), "• ✓ done\n• ☐ todo");
142 }
143
144 #[test]
145 fn nested_list_indents_under_parent() {
146 assert_eq!(r("- a\n - b"), "• a\n • b");
148 }
149
150 #[test]
151 fn blockquote_has_a_dim_bar() {
152 assert_eq!(r("> quote"), format!("{FADE}│ {RESET}quote"));
153 }
154
155 #[test]
156 fn fenced_code_is_dim_and_inset_not_reflowed() {
157 assert_eq!(
158 r("```\nlet x = 1;\n```"),
159 format!(" {FADE}let x = 1;{RESET}")
160 );
161 }
162
163 #[cfg(feature = "markdown-syntect")]
166 #[test]
167 fn syntect_highlights_a_rust_code_block() {
168 let out = r("```rust\nfn main() {}\n```");
169 assert!(
171 strip(&out).contains("fn main"),
172 "content preserved: {out:?}"
173 );
174 assert!(out.contains("\x1b[38;2;"), "has 24-bit color: {out:?}");
177 assert_ne!(
178 out,
179 format!(" {FADE}fn main() {{}}{RESET}"),
180 "not the plain dim stub"
181 );
182 }
183
184 #[test]
185 fn thematic_break_is_a_full_width_rule() {
186 assert_eq!(r("---"), format!("{FADE}{}{RESET}", "─".repeat(80)));
187 }
188
189 #[test]
190 fn link_text_is_underlined_with_dim_url() {
191 assert_eq!(
194 r("[text](https://x.io)"),
195 format!("{UNDER}text{RESET} {FADE}(https://x.io){RESET}")
196 );
197 }
198
199 #[test]
200 fn color_off_is_byte_for_byte_passthrough() {
201 for s in [
202 "**x** and `y`",
203 "# h\n\n- a\n- b",
204 "| a | b |\n| - | - |\n| 1 | 2 |",
205 "> quote\n\n```\ncode\n```",
206 "",
207 ] {
208 assert_eq!(
209 render_markdown(
210 s,
211 RenderOpts {
212 color: false,
213 cols: 80
214 }
215 ),
216 s,
217 "color-off must return source verbatim"
218 );
219 }
220 }
221
222 #[test]
223 fn empty_input_renders_empty() {
224 assert_eq!(r(""), "");
225 }
226
227 fn strip(s: &str) -> String {
232 let mut out = String::new();
233 let mut chars = s.chars();
234 while let Some(c) = chars.next() {
235 if c == '\x1b' {
236 for n in chars.by_ref() {
237 if n == 'm' {
238 break;
239 }
240 }
241 } else {
242 out.push(c);
243 }
244 }
245 out
246 }
247
248 #[test]
249 fn table_box_drawing_skeleton() {
250 let t = "| a | b |\n|---|---|\n| 1 | 2 |";
251 assert_eq!(
252 strip(&r(t)),
253 "┌───┬───┐\n│ a │ b │\n├───┼───┤\n│ 1 │ 2 │\n└───┴───┘"
254 );
255 }
256
257 #[test]
258 fn table_header_is_bold_borders_are_dim() {
259 let out = r("| a |\n|---|\n| 1 |");
260 assert!(
261 out.contains(&format!("{BOLD}a")),
262 "header cell must be bold"
263 );
264 assert!(out.contains(FADE), "borders must be dim");
265 assert!(strip(&out).contains("│ 1 │"));
267 }
268
269 #[test]
270 fn table_right_alignment_pads_on_the_left() {
271 let t = "| ab |\n|---:|\n| x |";
273 assert!(
274 strip(&r(t)).contains("│ x │"),
275 "right-aligned cell pads left"
276 );
277 }
278
279 #[test]
280 fn table_overflow_truncates_with_ellipsis() {
281 let t = "| name |\n|------|\n| abcdefgh |";
283 assert!(
284 strip(&rw(t, 10)).contains("abcde…"),
285 "overflowing cell truncates with an ellipsis"
286 );
287 }
288
289 #[test]
290 fn table_column_width_counts_cjk_display_width() {
291 let t = "| 日本語 |\n|--------|\n| x |";
293 assert!(
294 strip(&r(t)).contains("┌────────┐"),
295 "CJK header sized by display width, not char count"
296 );
297 }
298
299 #[test]
300 fn table_inside_blockquote_is_prefixed_with_the_bar() {
301 let out = r("> | a |\n> |---|\n> | 1 |");
302 for line in out.lines() {
304 let plain = strip(line);
305 assert!(
306 plain.starts_with("│ ┌")
307 || plain.starts_with("│ │")
308 || plain.starts_with("│ ├")
309 || plain.starts_with("│ └"),
310 "quoted table line must open with the bar then box: {plain:?}"
311 );
312 }
313 }
314}