1use colored::Colorize as _;
33use std::{fmt::Display, io::Write};
34
35pub trait ToMarkdown {
44 fn write_markdown<W: Write>(&self, writer: &mut W) -> Result<(), MarkdownError>;
45 fn to_markdown_string(&self) -> Result<String, MarkdownError> {
46 let mut buffer = Vec::new();
47 self.write_markdown(&mut buffer)?;
48 Ok(String::from_utf8(buffer)?)
49 }
50}
51
52pub trait ToMarkdownWith {
53 type Context: Sized;
54
55 fn write_markdown_with<W: Write>(
56 &self,
57 writer: &mut W,
58 context: Self::Context,
59 ) -> Result<(), MarkdownError>;
60 fn to_markdown_string_with(&self, context: Self::Context) -> Result<String, MarkdownError> {
61 let mut buffer = Vec::new();
62 self.write_markdown_with(&mut buffer, context)?;
63 Ok(String::from_utf8(buffer)?)
64 }
65}
66
67impl<T: ToMarkdownWith<Context = C>, C: Default> ToMarkdown for T {
68 fn write_markdown<W: Write>(&self, writer: &mut W) -> Result<(), MarkdownError> {
69 self.write_markdown_with(writer, C::default())
70 }
71}
72
73#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
74pub enum ColumnJustification {
75 #[default]
76 Left,
77 Right,
78 Centered,
79}
80
81#[derive(Clone, Debug, PartialEq)]
82pub struct Column {
83 label: String,
84 justification: Option<ColumnJustification>,
85 width: Option<usize>,
86}
87
88#[derive(Clone, Debug)]
89pub struct Table {
90 super_labels: Vec<Column>,
91 columns: Vec<Column>,
92}
93
94const VERTICAL_SEPARATOR_END: &str = "|";
99const VERTICAL_SEPARATOR_INNER: &str = " | ";
100const BULLET_LIST_BULLET: &str = "*";
101const NUMBER_LIST_SEPARATOR: &str = ".";
102const HEADING_PREFIX: &str = "#";
103const DEFN_LIST_TERM_PREFIX: &str = ";";
104const DEFN_LIST_DEFINITION_PREFIX: &str = ":";
105const FMT_ITALIC_DELIM: &str = "*";
106const FMT_BOLD_DELIM: &str = "**";
107const FMT_STRIKETHROUGH_DELIM: &str = "~~";
108const FMT_CODE_DELIM: &str = "`";
109const BLOCK_QUOTE_PREFIX: &str = ">";
110
111pub fn blank_line<W: Write>(w: &mut W) -> Result<(), MarkdownError> {
112 writeln!(w)?;
113 Ok(())
114}
115
116pub fn plain_text<W: Write, S: AsRef<str>>(w: &mut W, content: S) -> Result<(), MarkdownError> {
117 writeln!(w, "{}", content.as_ref().normal())?;
118 Ok(())
119}
120
121pub fn block_quote<W: Write, S: AsRef<str>>(w: &mut W, content: S) -> Result<(), MarkdownError> {
122 writeln!(w, "{} {}", BLOCK_QUOTE_PREFIX, content.as_ref().italic())?;
123 Ok(())
124}
125
126pub fn bold_to_string<S: AsRef<str>>(content: S) -> String {
127 format!(
128 "{}{}{}",
129 FMT_BOLD_DELIM,
130 content.as_ref().bold(),
131 FMT_BOLD_DELIM
132 )
133}
134
135pub fn bold<W: Write, S: AsRef<str>>(w: &mut W, content: S) -> Result<(), MarkdownError> {
136 write!(w, "{}", bold_to_string(content))?;
137 Ok(())
138}
139
140pub fn code_to_string<S: AsRef<str>>(content: S) -> String {
141 format!(
142 "{}{}{}",
143 FMT_CODE_DELIM,
144 content.as_ref().white().dimmed(),
145 FMT_CODE_DELIM
146 )
147}
148
149pub fn code<W: Write, S: AsRef<str>>(w: &mut W, content: S) -> Result<(), MarkdownError> {
150 write!(w, "{}", code_to_string(content))?;
151 Ok(())
152}
153
154pub fn italic_to_string<S: AsRef<str>>(content: S) -> String {
155 format!(
156 "{}{}{}",
157 FMT_ITALIC_DELIM,
158 content.as_ref().italic(),
159 FMT_ITALIC_DELIM
160 )
161}
162
163pub fn italic<W: Write, S: AsRef<str>>(w: &mut W, content: S) -> Result<(), MarkdownError> {
164 write!(w, "{}", italic_to_string(content))?;
165 Ok(())
166}
167
168pub fn strikethrough_to_string<S: AsRef<str>>(content: S) -> String {
169 format!(
170 "{}{}{}",
171 FMT_STRIKETHROUGH_DELIM,
172 content.as_ref().strikethrough(),
173 FMT_STRIKETHROUGH_DELIM
174 )
175}
176
177pub fn strikethrough<W: Write, S: AsRef<str>>(w: &mut W, content: S) -> Result<(), MarkdownError> {
178 write!(w, "{}", strikethrough_to_string(content))?;
179 Ok(())
180}
181
182pub fn link_to_string<S1: AsRef<str>, S2: AsRef<str>>(text: S1, url: S2) -> String {
183 format!("[{}]({})", text.as_ref(), url.as_ref())
184 .magenta()
185 .underline()
186 .to_string()
187}
188
189pub fn link<W: Write, S1: AsRef<str>, S2: AsRef<str>>(
190 w: &mut W,
191 text: S1,
192 url: S2,
193) -> Result<(), MarkdownError> {
194 write!(w, "{}", link_to_string(text, url))?;
195 Ok(())
196}
197
198pub fn header<W: Write, S: AsRef<str>>(
199 w: &mut W,
200 level: u16,
201 content: S,
202) -> Result<(), MarkdownError> {
203 assert!(level > 0);
204 writeln!(w, "{}", header_to_string(level, content))?;
205 Ok(())
206}
207
208pub fn header_to_string<S: AsRef<str>>(level: u16, content: S) -> String {
209 format!(
210 "{} {}",
211 HEADING_PREFIX.repeat(level as usize),
212 content.as_ref()
213 )
214 .blue()
215 .bold()
216 .to_string()
217}
218
219const CODE_FENCE_STR: &str = "```";
220
221pub fn fenced_code_block_start<W: Write>(w: &mut W) -> Result<(), MarkdownError> {
222 writeln!(w, "{}", format!("{CODE_FENCE_STR}text").dimmed())?;
223 Ok(())
224}
225
226pub fn fenced_code_block_start_for<W: Write, S: AsRef<str>>(
227 w: &mut W,
228 language: S,
229) -> Result<(), MarkdownError> {
230 writeln!(
231 w,
232 "{}",
233 format!("{CODE_FENCE_STR}{}", language.as_ref()).dimmed()
234 )?;
235 Ok(())
236}
237
238pub fn fenced_code_block_end<W: Write>(w: &mut W) -> Result<(), MarkdownError> {
239 writeln!(w, "{}", CODE_FENCE_STR.dimmed())?;
240 Ok(())
241}
242
243pub fn bulleted_list<W: Write, S: AsRef<str>>(
244 w: &mut W,
245 level: u16,
246 content: &[S],
247) -> Result<(), MarkdownError> {
248 let result: Result<Vec<()>, MarkdownError> = content
249 .iter()
250 .map(|content| bulleted_list_item(w, level, content))
251 .collect();
252 result.map(|_| ())
253}
254
255pub fn bulleted_list_item<W: Write, S: AsRef<str>>(
256 w: &mut W,
257 level: u16,
258 content: S,
259) -> Result<(), MarkdownError> {
260 assert!(level > 0);
261 writeln!(
262 w,
263 "{}",
264 format!(
265 "{}{} {}",
266 " ".repeat((level as usize - 1) * 2_usize),
267 BULLET_LIST_BULLET,
268 content.as_ref()
269 )
270 .yellow()
271 )?;
272 Ok(())
273}
274
275pub fn numbered_list<W: Write, S: AsRef<str>>(
276 w: &mut W,
277 level: u16,
278 content: &[S],
279) -> Result<(), MarkdownError> {
280 let result: Result<Vec<()>, MarkdownError> = content
281 .iter()
282 .enumerate()
283 .map(|(number, content)| numbered_list_item(w, level, number, content))
284 .collect();
285 result.map(|_| ())
286}
287
288pub fn numbered_list_item<W: Write, S: AsRef<str>>(
289 w: &mut W,
290 level: u16,
291 number: usize,
292 content: S,
293) -> Result<(), MarkdownError> {
294 assert!(level > 0);
295 writeln!(
296 w,
297 "{}",
298 format!(
299 "{}{}{} {}",
300 " ".repeat((level as usize - 1) * 3_usize),
301 number,
302 NUMBER_LIST_SEPARATOR,
303 content.as_ref()
304 )
305 .yellow()
306 )?;
307 Ok(())
308}
309
310pub fn definition_list_item<W: Write, S1: AsRef<str>, S2: AsRef<str>>(
311 w: &mut W,
312 term: S1,
313 definition: S2,
314) -> Result<(), MarkdownError> {
315 writeln!(
316 w,
317 "{}",
318 format!("{} {}", DEFN_LIST_TERM_PREFIX, term.as_ref()).yellow()
319 )?;
320 writeln!(
321 w,
322 "{}",
323 format!("{} {}", DEFN_LIST_DEFINITION_PREFIX, definition.as_ref()).yellow()
324 )?;
325 Ok(())
326}
327
328impl Display for Column {
337 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
338 write!(
339 f,
340 "{}",
341 if let Some(width) = &self.width {
342 match self.justification {
343 Some(ColumnJustification::Left) => format!("{:<width$}", self.label),
344 Some(ColumnJustification::Right) => format!("{:>width$}", self.label),
345 Some(ColumnJustification::Centered) => format!("{:^width$}", self.label),
346 None => format!("{:width$}", self.label),
347 }
348 } else {
349 self.label.to_string()
350 }
351 )
352 }
353}
354
355impl From<String> for Column {
356 fn from(value: String) -> Self {
357 Self::new(value)
358 }
359}
360
361impl From<(&str, usize)> for Column {
362 fn from(value: (&str, usize)) -> Self {
363 Self::new(value.0).with_width(value.1)
364 }
365}
366
367impl From<(String, usize)> for Column {
368 fn from(value: (String, usize)) -> Self {
369 Self::new(value.0).with_width(value.1)
370 }
371}
372
373impl Column {
374 pub fn new<S: Into<String>>(content: S) -> Self {
375 Self {
376 label: content.into(),
377 justification: None,
378 width: None,
379 }
380 }
381
382 pub fn left_justified<S: Into<String>>(content: S) -> Self {
383 Self::new(content).with_justification(ColumnJustification::Left)
384 }
385
386 pub fn right_justified<S: Into<String>>(content: S) -> Self {
387 Self::new(content).with_justification(ColumnJustification::Right)
388 }
389
390 pub fn centered<S: Into<String>>(content: S) -> Self {
391 Self::new(content).with_justification(ColumnJustification::Centered)
392 }
393
394 pub fn fill(fill_char: char, width: usize) -> Self {
395 Self::new(fill_char.to_string().repeat(width)).with_width(width)
396 }
397
398 pub fn with_justification(mut self, justification: ColumnJustification) -> Self {
399 self.justification = Some(justification);
400 self
401 }
402
403 pub fn with_width(mut self, width: usize) -> Self {
404 self.width = Some(width);
405 self
406 }
407
408 pub fn row_separator(&self) -> Self {
409 match (self.justification, self.width) {
410 (Some(ColumnJustification::Left), Some(width)) if width >= 2 => Self {
411 label: format!(":{}", "-".repeat(width - 1)),
412 ..*self
413 },
414 (Some(ColumnJustification::Right), Some(width)) if width >= 2 => Self {
415 label: format!("{}:", "-".repeat(width - 1)),
416 ..*self
417 },
418 (Some(ColumnJustification::Centered), Some(width)) if width >= 3 => Self {
419 label: format!(":{}:", "-".repeat(width - 2)),
420 ..*self
421 },
422 (None, Some(width)) => Self {
423 label: "-".repeat(width),
424 ..*self
425 },
426 _ => Self {
427 label: "-".repeat(2),
428 ..*self
429 },
430 }
431 }
432}
433
434impl From<Vec<Column>> for Table {
437 fn from(value: Vec<Column>) -> Self {
438 Self::new(value)
439 }
440}
441
442impl Table {
443 pub fn new(columns: Vec<Column>) -> Self {
444 Self {
445 super_labels: Default::default(),
446 columns,
447 }
448 }
449
450 pub fn with_super_labels<S>(mut self, labels: Vec<S>) -> Self
451 where
452 S: Into<String>,
453 {
454 assert_eq!(labels.len(), self.columns.len());
455 self.super_labels = labels
456 .into_iter()
457 .zip(self.columns.iter())
458 .map(|(label, col)| Column {
459 label: label.into(),
460 ..col.clone()
461 })
462 .collect();
463 self
464 }
465
466 pub fn headers<W>(&self, w: &mut W) -> Result<(), MarkdownError>
467 where
468 W: Write,
469 {
470 if !self.super_labels.is_empty() {
471 self.write_row(w, &self.super_labels, true)?;
472 }
473 self.write_row(w, &self.columns, true)?;
474 self.write_row(
475 w,
476 &self
477 .columns
478 .iter()
479 .map(|c| c.row_separator())
480 .collect::<Vec<_>>(),
481 true,
482 )?;
483 Ok(())
484 }
485
486 pub fn data_row<W, S>(&self, w: &mut W, row: &[S]) -> Result<(), MarkdownError>
487 where
488 W: Write,
489 S: Into<String>,
490 String: for<'a> From<&'a S>,
491 {
492 let row: Vec<Column> = row
493 .iter()
494 .zip(self.columns.iter())
495 .map(|(label, col): (&S, &Column)| Column {
496 label: String::from(label),
497 ..col.clone()
498 })
499 .collect();
500 self.write_row(w, &row, false)?;
501 Ok(())
502 }
503
504 fn write_row<W>(&self, w: &mut W, row: &[Column], is_header: bool) -> Result<(), MarkdownError>
505 where
506 W: Write,
507 {
508 let row_string = format!(
509 "{} {} {}",
510 VERTICAL_SEPARATOR_END.bold(),
511 row.iter()
512 .map(|cell| if is_header {
513 cell.to_string().bold()
514 } else {
515 cell.to_string().normal()
516 }
517 .to_string())
518 .collect::<Vec<_>>()
519 .join(&VERTICAL_SEPARATOR_INNER.bold()),
520 VERTICAL_SEPARATOR_END.bold()
521 );
522 writeln!(w, "{}", row_string)?;
523 Ok(())
524 }
525}
526
527pub mod error;
536pub use error::{MarkdownError, MarkdownResult};
537
538#[cfg(test)]
543mod tests {
544 use super::*;
545
546 fn collect(f: impl FnOnce(&mut Vec<u8>) -> Result<(), MarkdownError>) -> String {
547 let mut buf = Vec::new();
548 f(&mut buf).unwrap();
549 let s = String::from_utf8(buf).unwrap();
551 let re_free: String = s
553 .chars()
554 .fold((String::new(), false), |(mut acc, in_esc), c| {
555 if c == '\x1b' {
556 (acc, true)
557 } else if in_esc && c == 'm' {
558 (acc, false)
559 } else if !in_esc {
560 acc.push(c);
561 (acc, false)
562 } else {
563 (acc, true)
564 }
565 })
566 .0;
567 re_free
568 }
569
570 #[test]
571 fn test_header_level_1() {
572 let s = collect(|w| header(w, 1, "Title"));
573 assert!(s.contains("# Title"), "got: {s:?}");
574 }
575
576 #[test]
577 fn test_plain_text() {
578 let s = collect(|w| plain_text(w, "Hello world"));
579 assert!(s.contains("Hello world"), "got: {s:?}");
580 }
581
582 #[test]
583 fn test_bulleted_list_item() {
584 let s = collect(|w| bulleted_list_item(w, 1, "Item one"));
585 assert!(s.contains("* Item one"), "got: {s:?}");
586 }
587
588 #[test]
589 fn test_bold_to_string() {
590 let s = bold_to_string("strong");
591 assert!(s.contains("strong"));
592 assert!(s.contains("**"));
593 }
594
595 #[test]
596 fn test_italic_to_string() {
597 let s = italic_to_string("em");
598 assert!(s.contains("em"));
599 assert!(s.contains("*"));
600 }
601
602 #[test]
603 fn test_link_to_string() {
604 let s = link_to_string("ARRL", "https://arrl.org");
605 assert!(s.contains("[ARRL]"));
606 assert!(s.contains("https://arrl.org"));
607 }
608
609 #[test]
610 fn test_to_markdown_string() {
611 struct Dummy;
612 impl ToMarkdown for Dummy {
613 fn write_markdown<W: std::io::Write>(
614 &self,
615 w: &mut W,
616 ) -> Result<(), MarkdownError> {
617 plain_text(w, "dummy content")
618 }
619 }
620 let s = Dummy.to_markdown_string().unwrap();
621 assert!(s.contains("dummy content"));
622 }
623}