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