Skip to main content

pulldown_cmark_mdcat/
lib.rs

1// Copyright 2018-2020 Sebastian Wiesner <sebastian@swsnr.de>
2
3// This Source Code Form is subject to the terms of the Mozilla Public
4// License, v. 2.0. If a copy of the MPL was not distributed with this
5// file, You can obtain one at http://mozilla.org/MPL/2.0/.
6
7//! Write markdown to TTYs.
8//!
9//! See [`push_tty`] for the main entry point.
10//!
11//! ## MSRV
12//!
13//! This library generally supports only the latest stable Rust version.
14//!
15//! ## Features
16//!
17//! - `default` enables `svg` and `image-processing`.
18//!
19//! - `svg` includes support for rendering SVG images to PNG for terminals which do not support SVG
20//!   images natively.  This feature adds a dependency on `resvg`.
21//!
22//! - `image-processing` enables processing of pixel images before rendering.  This feature adds
23//!   a dependency on `image`.  If disabled mdcat will not be able to render inline images on some
24//!   terminals, or render images incorrectly or at wrong sizes on other terminals.
25//!
26//!   Do not disable this feature unless you are sure that you won't use inline images, or accept
27//!   incomplete rendering of images.  Please do not report issues with inline images with this
28//!   feature disabled.
29//!
30//!   This feature only exists to allow building with minimal dependencies for use cases where
31//!   inline image support is not used or required.  Do not disable this feature unless you know
32//!   you won't use inline images, or can accept buggy inline image rendering.
33//!
34//!   Please **do not report bugs** about inline image rendering with this feature disabled, unless
35//!   the issue can also be reproduced if the feature is enabled.
36//!
37//! - `ratatui` enables rendering markdown into Ratatui `Text` and stateful widgets.
38
39#![deny(warnings, missing_docs, clippy::all)]
40#![forbid(unsafe_code)]
41
42use std::io::{Error, ErrorKind, Result, Write};
43use std::path::Path;
44
45use gethostname::gethostname;
46use pulldown_cmark::{Event, Options};
47use syntect::highlighting::Theme as SyntectTheme;
48use syntect::parsing::SyntaxSet;
49use tracing::instrument;
50use url::Url;
51
52pub use crate::resources::ResourceUrlHandler;
53pub use crate::terminal::capabilities::TerminalCapabilities;
54pub use crate::terminal::{TerminalProgram, TerminalSize};
55pub use crate::theme::Theme;
56
57#[cfg(feature = "ratatui")]
58pub mod ratatui;
59mod references;
60pub mod resources;
61pub mod terminal;
62mod theme;
63
64mod render;
65
66/// Settings for markdown rendering.
67#[derive(Debug)]
68pub struct Settings<'a> {
69    /// Capabilities of the terminal mdcat writes to.
70    pub terminal_capabilities: TerminalCapabilities,
71    /// The size of the terminal mdcat writes to.
72    pub terminal_size: TerminalSize,
73    /// Syntax set for syntax highlighting of code blocks.
74    pub syntax_set: &'a SyntaxSet,
75    /// Colour theme for mdcat
76    pub theme: Theme,
77    /// Syntect theme for syntax-highlighted code blocks.
78    ///
79    /// When set, code blocks are rendered with 24-bit RGB colors from this theme.
80    /// When absent, falls back to the built-in Solarized Dark → ANSI color mapping.
81    pub syntax_theme: Option<SyntectTheme>,
82}
83
84/// The environment to render markdown in.
85#[derive(Debug, Clone)]
86pub struct Environment {
87    /// The base URL to resolve relative URLs with.
88    pub base_url: Url,
89    /// The local host name.
90    pub hostname: String,
91}
92
93impl Environment {
94    /// Create an environment for the local host with the given `base_url`.
95    ///
96    /// Take the local hostname from `gethostname`.
97    pub fn for_localhost(base_url: Url) -> Result<Self> {
98        gethostname()
99            .into_string()
100            .map_err(|raw| {
101                Error::new(
102                    ErrorKind::InvalidData,
103                    format!("gethostname() returned invalid unicode data: {raw:?}"),
104                )
105            })
106            .map(|hostname| Environment { base_url, hostname })
107    }
108
109    /// Create an environment for a local directory.
110    ///
111    /// Convert the directory to a directory URL, and obtain the hostname from `gethostname`.
112    ///
113    /// `base_dir` must be an absolute path; return an IO error with `ErrorKind::InvalidInput`
114    /// otherwise.
115    pub fn for_local_directory<P: AsRef<Path>>(base_dir: &P) -> Result<Self> {
116        Url::from_directory_path(base_dir)
117            .map_err(|_| {
118                Error::new(
119                    ErrorKind::InvalidInput,
120                    format!(
121                        "Base directory {} must be an absolute path",
122                        base_dir.as_ref().display()
123                    ),
124                )
125            })
126            .and_then(Self::for_localhost)
127    }
128}
129
130/// Return the pulldown-cmark options mdcat uses for Markdown parsing.
131///
132/// If `smart_punctuation` is `true`, straight quotes, `--`/`---`, and `...` are rendered as their
133/// typographic equivalents (curly quotes, en/em dashes, ellipsis).
134pub fn markdown_options(smart_punctuation: bool) -> Options {
135    let mut options = Options::ENABLE_TASKLISTS
136        | Options::ENABLE_STRIKETHROUGH
137        | Options::ENABLE_TABLES
138        | Options::ENABLE_FOOTNOTES
139        | Options::ENABLE_MATH
140        | Options::ENABLE_GFM
141        | Options::ENABLE_DEFINITION_LIST;
142    if smart_punctuation {
143        options |= Options::ENABLE_SMART_PUNCTUATION;
144    }
145    options
146}
147
148/// Strip YAML frontmatter from the beginning of a Markdown document.
149///
150/// Frontmatter is a `---` block at the very start of the input, closed by another `---` or `...`
151/// line. If no valid frontmatter block is found, return the input unchanged.
152pub fn strip_frontmatter(input: &str) -> &str {
153    let after_open = match input
154        .strip_prefix("---\n")
155        .or_else(|| input.strip_prefix("---\r\n"))
156    {
157        Some(s) => s,
158        None => return input,
159    };
160
161    let mut start = 0;
162    while start < after_open.len() {
163        let end = after_open[start..]
164            .find('\n')
165            .map_or(after_open.len(), |i| start + i);
166        let line = after_open[start..end].trim_end_matches('\r');
167        let next = (end + 1).min(after_open.len());
168        if line == "---" || line == "..." {
169            return &after_open[next..];
170        }
171        start = end + 1;
172    }
173
174    input
175}
176
177/// Expand literal tab characters in `input` to spaces, using a tab stop width of `tab_width`.
178///
179/// CommonMark treats tabs specially only for block structure (e.g. list/code indentation),
180/// internally assuming a tab stop of 4; a literal tab inside text content (a paragraph, inline
181/// code, a fenced code block, ...) passes through parsing untouched. Since mdcat's line-wrapping
182/// and alignment treat every character as one column wide, such a leftover tab throws off width
183/// calculations downstream, as terminals render it as jumping to the next tab stop rather than
184/// occupying a single column. Expanding tabs to spaces before parsing avoids that mismatch.
185///
186/// Tracks the current column per line, resetting after each `\n`, and inserts enough spaces to
187/// reach the next multiple of `tab_width`. Column tracking counts one column per `char`; wide
188/// characters (e.g. CJK) are not accounted for, matching the rest of mdcat's width handling.
189///
190/// Returns `input` unchanged, without allocating, if `tab_width` is `0` or `input` has no tabs.
191pub fn expand_tabs(input: &str, tab_width: u16) -> std::borrow::Cow<'_, str> {
192    if tab_width == 0 || !input.contains('\t') {
193        return std::borrow::Cow::Borrowed(input);
194    }
195
196    let tab_width = usize::from(tab_width);
197    let mut output = String::with_capacity(input.len());
198    let mut column = 0;
199    for c in input.chars() {
200        match c {
201            '\t' => {
202                let spaces = tab_width - (column % tab_width);
203                output.extend(std::iter::repeat_n(' ', spaces));
204                column += spaces;
205            }
206            '\n' => {
207                output.push('\n');
208                column = 0;
209            }
210            _ => {
211                output.push(c);
212                column += 1;
213            }
214        }
215    }
216    std::borrow::Cow::Owned(output)
217}
218
219/// Write markdown to a TTY.
220///
221/// Iterate over Markdown AST `events`, format each event for TTY output and
222/// write the result to a `writer`, using the given `settings` and `environment`
223/// for rendering and resource access.
224///
225/// `push_tty` tries to limit output to the given number of TTY `columns` but
226/// does not guarantee that output stays within the column limit.
227#[instrument(level = "debug", skip_all, fields(environment.hostname = environment.hostname.as_str(), environment.base_url = &environment.base_url.as_str()))]
228pub fn push_tty<'a, 'e, W, I>(
229    settings: &Settings,
230    environment: &Environment,
231    resource_handler: &dyn ResourceUrlHandler,
232    writer: &'a mut W,
233    mut events: I,
234) -> Result<()>
235where
236    I: Iterator<Item = Event<'e>>,
237    W: Write,
238{
239    use render::*;
240    let StateAndData(final_state, final_data) = events.try_fold(
241        StateAndData(State::default(), StateData::default()),
242        |StateAndData(state, data), event| {
243            write_event(
244                writer,
245                settings,
246                environment,
247                &resource_handler,
248                state,
249                data,
250                event,
251            )
252        },
253    )?;
254    finish(writer, settings, environment, final_state, final_data)
255}
256
257#[cfg(test)]
258mod tests {
259    use pulldown_cmark::Parser;
260
261    use crate::resources::NoopResourceHandler;
262
263    use super::*;
264
265    fn render_string(input: &str, settings: &Settings) -> Result<String> {
266        let source = Parser::new(input);
267        let mut sink = Vec::new();
268        let env =
269            Environment::for_local_directory(&std::env::current_dir().expect("Working directory"))?;
270        push_tty(settings, &env, &NoopResourceHandler, &mut sink, source)?;
271        Ok(String::from_utf8_lossy(&sink).into())
272    }
273
274    fn render_string_dumb(markup: &str) -> Result<String> {
275        render_string(
276            markup,
277            &Settings {
278                syntax_set: &SyntaxSet::default(),
279                terminal_capabilities: TerminalProgram::Dumb.capabilities(),
280                terminal_size: TerminalSize::default(),
281                theme: Theme::default(),
282                syntax_theme: None,
283            },
284        )
285    }
286
287    #[test]
288    fn markdown_options_smart_punctuation_toggle() {
289        assert!(!markdown_options(false).contains(Options::ENABLE_SMART_PUNCTUATION));
290        assert!(markdown_options(true).contains(Options::ENABLE_SMART_PUNCTUATION));
291    }
292
293    #[test]
294    fn expand_tabs_zero_width_leaves_input_unchanged() {
295        assert_eq!(expand_tabs("a\tb", 0), "a\tb");
296    }
297
298    #[test]
299    fn expand_tabs_without_tabs_does_not_allocate() {
300        assert!(matches!(
301            expand_tabs("no tabs here", 4),
302            std::borrow::Cow::Borrowed(_)
303        ));
304    }
305
306    #[test]
307    fn expand_tabs_advances_to_next_tab_stop() {
308        assert_eq!(expand_tabs("a\tb", 4), "a   b");
309        assert_eq!(expand_tabs("ab\tc", 4), "ab  c");
310        assert_eq!(expand_tabs("abcd\te", 4), "abcd    e");
311    }
312
313    #[test]
314    fn expand_tabs_resets_column_at_newline() {
315        assert_eq!(expand_tabs("a\tb\nc\td", 4), "a   b\nc   d");
316    }
317
318    #[test]
319    fn expand_tabs_handles_consecutive_tabs() {
320        assert_eq!(expand_tabs("a\t\tb", 4), "a       b");
321    }
322
323    fn render_definition_list(markup: &str) -> Result<String> {
324        let source = Parser::new_ext(markup, markdown_options(false));
325        let mut sink = Vec::new();
326        let env =
327            Environment::for_local_directory(&std::env::current_dir().expect("Working directory"))?;
328        push_tty(
329            &Settings {
330                syntax_set: &SyntaxSet::default(),
331                terminal_capabilities: TerminalProgram::Dumb.capabilities(),
332                terminal_size: TerminalSize::default(),
333                theme: Theme::default(),
334                syntax_theme: None,
335            },
336            &env,
337            &NoopResourceHandler,
338            &mut sink,
339            source,
340        )?;
341        Ok(String::from_utf8_lossy(&sink).into())
342    }
343
344    #[test]
345    fn definition_list_tight() {
346        assert_eq!(
347            render_definition_list("Apple\n: A fruit.\n: A tech company.\n\nBanana\n: A fruit.\n")
348                .unwrap(),
349            "Apple\n    A fruit.\n    A tech company.\nBanana\n    A fruit.\n"
350        );
351    }
352
353    #[test]
354    fn definition_list_with_inline_markup_does_not_panic() {
355        // Regression test: bold/code/link/emphasis inside a term or description must not hit
356        // the "impossible state" panic in `write_event`.
357        let output = render_definition_list(
358            "Term with `code` and **bold**\n: Def with [a link](https://example.com) and _italics_.\n",
359        )
360        .unwrap();
361        assert!(output.contains("Term with code and bold"));
362        assert!(output.contains("Def with a link"));
363        assert!(output.contains("https://example.com"));
364    }
365
366    #[test]
367    fn definition_list_nested_blocks_do_not_panic() {
368        // Regression test: a loose definition (blank line before it) may contain nested
369        // paragraphs, lists, and code blocks; none of these must hit the panic either.
370        render_definition_list(
371            "Term\n\n: First paragraph.\n\n  Second paragraph.\n\n  - a nested item\n\n  ```\n  code\n  ```\n",
372        )
373        .unwrap();
374    }
375
376    mod layout {
377        use super::render_string_dumb;
378        use insta::assert_snapshot;
379
380        #[test]
381        #[allow(non_snake_case)]
382        fn GH_49_format_no_colour_simple() {
383            assert_eq!(
384                render_string_dumb("_lorem_ **ipsum** dolor **sit** _amet_").unwrap(),
385                "lorem ipsum dolor sit amet\n",
386            )
387        }
388
389        #[test]
390        fn begins_with_rule() {
391            assert_snapshot!(render_string_dumb("----").unwrap())
392        }
393
394        #[test]
395        fn begins_with_block_quote() {
396            assert_snapshot!(render_string_dumb("> Hello World").unwrap());
397        }
398
399        #[test]
400        fn rule_in_block_quote() {
401            assert_snapshot!(render_string_dumb(
402                "> Hello World
403
404> ----"
405            )
406            .unwrap());
407        }
408
409        #[test]
410        fn heading_in_block_quote() {
411            assert_snapshot!(render_string_dumb(
412                "> Hello World
413
414> # Hello World"
415            )
416            .unwrap())
417        }
418
419        #[test]
420        fn heading_levels() {
421            assert_snapshot!(render_string_dumb(
422                "
423# First
424
425## Second
426
427### Third"
428            )
429            .unwrap())
430        }
431
432        #[test]
433        fn autolink_creates_no_reference() {
434            assert_eq!(
435                render_string_dumb("Hello <http://example.com>").unwrap(),
436                "Hello http://example.com\n"
437            )
438        }
439
440        #[test]
441        fn flush_ref_links_before_toplevel_heading() {
442            assert_snapshot!(render_string_dumb(
443                "> Hello [World](http://example.com/world)
444
445> # No refs before this headline
446
447# But before this"
448            )
449            .unwrap())
450        }
451
452        #[test]
453        fn flush_ref_links_at_end() {
454            assert_snapshot!(render_string_dumb(
455                "Hello [World](http://example.com/world)
456
457# Headline
458
459Hello [Donald](http://example.com/Donald)"
460            )
461            .unwrap())
462        }
463    }
464
465    mod disabled_features {
466        use insta::assert_snapshot;
467
468        use super::render_string_dumb;
469
470        #[test]
471        #[allow(non_snake_case)]
472        fn GH_155_do_not_choke_on_footnotes() {
473            assert_snapshot!(render_string_dumb(
474                "A footnote [^1]
475
476[^1: We do not support footnotes."
477            )
478            .unwrap())
479        }
480    }
481}