Skip to main content

rfham_markdown/
lib.rs

1//! Markdown formatting utilities and traits for RF-Ham output.
2//!
3//! This crate provides two traits and a set of free formatting functions that write
4//! coloured Markdown to any `Write` sink. Terminal output is styled with ANSI colours
5//! via the `colored` crate; plain writers receive the same text without escape codes.
6//!
7//! | Trait | Purpose |
8//! |-------|---------|
9//! | [`ToMarkdown`] | Convert a value to Markdown without external context |
10//! | [`ToMarkdownWith`] | Convert a value to Markdown given a caller-supplied context |
11//!
12//! Key free functions: [`header`], [`plain_text`], [`bulleted_list_item`],
13//! [`numbered_list_item`], [`bold_to_string`], [`italic_to_string`], [`link_to_string`],
14//! [`fenced_code_block_start`] / [`fenced_code_block_end`].
15//!
16//! [`Table`] and [`Column`] support fixed-width columnar output.
17//!
18//! # Examples
19//!
20//! ```rust
21//! use rfham_markdown::{header, plain_text, bulleted_list_item};
22//!
23//! let mut out = Vec::new();
24//! header(&mut out, 1, "My Section").unwrap();
25//! plain_text(&mut out, "Some content.").unwrap();
26//! bulleted_list_item(&mut out, 1, "First item").unwrap();
27//! let s = String::from_utf8(out).unwrap();
28//! assert!(s.contains("My Section"));
29//! assert!(s.contains("Some content."));
30//! ```
31
32use colored::Colorize as _;
33use std::{fmt::Display, io::Write};
34
35// ------------------------------------------------------------------------------------------------
36// Public Macros
37// ------------------------------------------------------------------------------------------------
38
39// ------------------------------------------------------------------------------------------------
40// Public Types
41// ------------------------------------------------------------------------------------------------
42
43pub 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
94// ------------------------------------------------------------------------------------------------
95// Public Functions
96// ------------------------------------------------------------------------------------------------
97
98const 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
328// ------------------------------------------------------------------------------------------------
329// Private Types
330// ------------------------------------------------------------------------------------------------
331
332// ------------------------------------------------------------------------------------------------
333// Implementations
334// ------------------------------------------------------------------------------------------------
335
336impl 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
434// ------------------------------------------------------------------------------------------------
435
436impl 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
527// ------------------------------------------------------------------------------------------------
528// Private Functions
529// ------------------------------------------------------------------------------------------------
530
531// ------------------------------------------------------------------------------------------------
532// Modules
533// ------------------------------------------------------------------------------------------------
534
535pub mod error;
536pub use error::{MarkdownError, MarkdownResult};
537
538// ------------------------------------------------------------------------------------------------
539// Unit Tests
540// ------------------------------------------------------------------------------------------------
541
542#[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        // Strip ANSI escape sequences for comparison
550        let s = String::from_utf8(buf).unwrap();
551        // Remove ESC[...m sequences
552        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}