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/// Write markdown to a TTY.
178///
179/// Iterate over Markdown AST `events`, format each event for TTY output and
180/// write the result to a `writer`, using the given `settings` and `environment`
181/// for rendering and resource access.
182///
183/// `push_tty` tries to limit output to the given number of TTY `columns` but
184/// does not guarantee that output stays within the column limit.
185#[instrument(level = "debug", skip_all, fields(environment.hostname = environment.hostname.as_str(), environment.base_url = &environment.base_url.as_str()))]
186pub fn push_tty<'a, 'e, W, I>(
187    settings: &Settings,
188    environment: &Environment,
189    resource_handler: &dyn ResourceUrlHandler,
190    writer: &'a mut W,
191    mut events: I,
192) -> Result<()>
193where
194    I: Iterator<Item = Event<'e>>,
195    W: Write,
196{
197    use render::*;
198    let StateAndData(final_state, final_data) = events.try_fold(
199        StateAndData(State::default(), StateData::default()),
200        |StateAndData(state, data), event| {
201            write_event(
202                writer,
203                settings,
204                environment,
205                &resource_handler,
206                state,
207                data,
208                event,
209            )
210        },
211    )?;
212    finish(writer, settings, environment, final_state, final_data)
213}
214
215#[cfg(test)]
216mod tests {
217    use pulldown_cmark::Parser;
218
219    use crate::resources::NoopResourceHandler;
220
221    use super::*;
222
223    fn render_string(input: &str, settings: &Settings) -> Result<String> {
224        let source = Parser::new(input);
225        let mut sink = Vec::new();
226        let env =
227            Environment::for_local_directory(&std::env::current_dir().expect("Working directory"))?;
228        push_tty(settings, &env, &NoopResourceHandler, &mut sink, source)?;
229        Ok(String::from_utf8_lossy(&sink).into())
230    }
231
232    fn render_string_dumb(markup: &str) -> Result<String> {
233        render_string(
234            markup,
235            &Settings {
236                syntax_set: &SyntaxSet::default(),
237                terminal_capabilities: TerminalProgram::Dumb.capabilities(),
238                terminal_size: TerminalSize::default(),
239                theme: Theme::default(),
240                syntax_theme: None,
241            },
242        )
243    }
244
245    #[test]
246    fn markdown_options_smart_punctuation_toggle() {
247        assert!(!markdown_options(false).contains(Options::ENABLE_SMART_PUNCTUATION));
248        assert!(markdown_options(true).contains(Options::ENABLE_SMART_PUNCTUATION));
249    }
250
251    fn render_definition_list(markup: &str) -> Result<String> {
252        let source = Parser::new_ext(markup, markdown_options(false));
253        let mut sink = Vec::new();
254        let env =
255            Environment::for_local_directory(&std::env::current_dir().expect("Working directory"))?;
256        push_tty(
257            &Settings {
258                syntax_set: &SyntaxSet::default(),
259                terminal_capabilities: TerminalProgram::Dumb.capabilities(),
260                terminal_size: TerminalSize::default(),
261                theme: Theme::default(),
262                syntax_theme: None,
263            },
264            &env,
265            &NoopResourceHandler,
266            &mut sink,
267            source,
268        )?;
269        Ok(String::from_utf8_lossy(&sink).into())
270    }
271
272    #[test]
273    fn definition_list_tight() {
274        assert_eq!(
275            render_definition_list("Apple\n: A fruit.\n: A tech company.\n\nBanana\n: A fruit.\n")
276                .unwrap(),
277            "Apple\n    A fruit.\n    A tech company.\nBanana\n    A fruit.\n"
278        );
279    }
280
281    #[test]
282    fn definition_list_with_inline_markup_does_not_panic() {
283        // Regression test: bold/code/link/emphasis inside a term or description must not hit
284        // the "impossible state" panic in `write_event`.
285        let output = render_definition_list(
286            "Term with `code` and **bold**\n: Def with [a link](https://example.com) and _italics_.\n",
287        )
288        .unwrap();
289        assert!(output.contains("Term with code and bold"));
290        assert!(output.contains("Def with a link"));
291        assert!(output.contains("https://example.com"));
292    }
293
294    #[test]
295    fn definition_list_nested_blocks_do_not_panic() {
296        // Regression test: a loose definition (blank line before it) may contain nested
297        // paragraphs, lists, and code blocks; none of these must hit the panic either.
298        render_definition_list(
299            "Term\n\n: First paragraph.\n\n  Second paragraph.\n\n  - a nested item\n\n  ```\n  code\n  ```\n",
300        )
301        .unwrap();
302    }
303
304    mod layout {
305        use super::render_string_dumb;
306        use insta::assert_snapshot;
307
308        #[test]
309        #[allow(non_snake_case)]
310        fn GH_49_format_no_colour_simple() {
311            assert_eq!(
312                render_string_dumb("_lorem_ **ipsum** dolor **sit** _amet_").unwrap(),
313                "lorem ipsum dolor sit amet\n",
314            )
315        }
316
317        #[test]
318        fn begins_with_rule() {
319            assert_snapshot!(render_string_dumb("----").unwrap())
320        }
321
322        #[test]
323        fn begins_with_block_quote() {
324            assert_snapshot!(render_string_dumb("> Hello World").unwrap());
325        }
326
327        #[test]
328        fn rule_in_block_quote() {
329            assert_snapshot!(render_string_dumb(
330                "> Hello World
331
332> ----"
333            )
334            .unwrap());
335        }
336
337        #[test]
338        fn heading_in_block_quote() {
339            assert_snapshot!(render_string_dumb(
340                "> Hello World
341
342> # Hello World"
343            )
344            .unwrap())
345        }
346
347        #[test]
348        fn heading_levels() {
349            assert_snapshot!(render_string_dumb(
350                "
351# First
352
353## Second
354
355### Third"
356            )
357            .unwrap())
358        }
359
360        #[test]
361        fn autolink_creates_no_reference() {
362            assert_eq!(
363                render_string_dumb("Hello <http://example.com>").unwrap(),
364                "Hello http://example.com\n"
365            )
366        }
367
368        #[test]
369        fn flush_ref_links_before_toplevel_heading() {
370            assert_snapshot!(render_string_dumb(
371                "> Hello [World](http://example.com/world)
372
373> # No refs before this headline
374
375# But before this"
376            )
377            .unwrap())
378        }
379
380        #[test]
381        fn flush_ref_links_at_end() {
382            assert_snapshot!(render_string_dumb(
383                "Hello [World](http://example.com/world)
384
385# Headline
386
387Hello [Donald](http://example.com/Donald)"
388            )
389            .unwrap())
390        }
391    }
392
393    mod disabled_features {
394        use insta::assert_snapshot;
395
396        use super::render_string_dumb;
397
398        #[test]
399        #[allow(non_snake_case)]
400        fn GH_155_do_not_choke_on_footnotes() {
401            assert_snapshot!(render_string_dumb(
402                "A footnote [^1]
403
404[^1: We do not support footnotes."
405            )
406            .unwrap())
407        }
408    }
409}