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.
131pub fn markdown_options() -> Options {
132    Options::ENABLE_TASKLISTS
133        | Options::ENABLE_STRIKETHROUGH
134        | Options::ENABLE_TABLES
135        | Options::ENABLE_FOOTNOTES
136        | Options::ENABLE_MATH
137        | Options::ENABLE_GFM
138}
139
140/// Strip YAML frontmatter from the beginning of a Markdown document.
141///
142/// Frontmatter is a `---` block at the very start of the input, closed by another `---` or `...`
143/// line. If no valid frontmatter block is found, return the input unchanged.
144pub fn strip_frontmatter(input: &str) -> &str {
145    let after_open = match input
146        .strip_prefix("---\n")
147        .or_else(|| input.strip_prefix("---\r\n"))
148    {
149        Some(s) => s,
150        None => return input,
151    };
152
153    let mut start = 0;
154    while start < after_open.len() {
155        let end = after_open[start..]
156            .find('\n')
157            .map_or(after_open.len(), |i| start + i);
158        let line = after_open[start..end].trim_end_matches('\r');
159        let next = (end + 1).min(after_open.len());
160        if line == "---" || line == "..." {
161            return &after_open[next..];
162        }
163        start = end + 1;
164    }
165
166    input
167}
168
169/// Write markdown to a TTY.
170///
171/// Iterate over Markdown AST `events`, format each event for TTY output and
172/// write the result to a `writer`, using the given `settings` and `environment`
173/// for rendering and resource access.
174///
175/// `push_tty` tries to limit output to the given number of TTY `columns` but
176/// does not guarantee that output stays within the column limit.
177#[instrument(level = "debug", skip_all, fields(environment.hostname = environment.hostname.as_str(), environment.base_url = &environment.base_url.as_str()))]
178pub fn push_tty<'a, 'e, W, I>(
179    settings: &Settings,
180    environment: &Environment,
181    resource_handler: &dyn ResourceUrlHandler,
182    writer: &'a mut W,
183    mut events: I,
184) -> Result<()>
185where
186    I: Iterator<Item = Event<'e>>,
187    W: Write,
188{
189    use render::*;
190    let StateAndData(final_state, final_data) = events.try_fold(
191        StateAndData(State::default(), StateData::default()),
192        |StateAndData(state, data), event| {
193            write_event(
194                writer,
195                settings,
196                environment,
197                &resource_handler,
198                state,
199                data,
200                event,
201            )
202        },
203    )?;
204    finish(writer, settings, environment, final_state, final_data)
205}
206
207#[cfg(test)]
208mod tests {
209    use pulldown_cmark::Parser;
210
211    use crate::resources::NoopResourceHandler;
212
213    use super::*;
214
215    fn render_string(input: &str, settings: &Settings) -> Result<String> {
216        let source = Parser::new(input);
217        let mut sink = Vec::new();
218        let env =
219            Environment::for_local_directory(&std::env::current_dir().expect("Working directory"))?;
220        push_tty(settings, &env, &NoopResourceHandler, &mut sink, source)?;
221        Ok(String::from_utf8_lossy(&sink).into())
222    }
223
224    fn render_string_dumb(markup: &str) -> Result<String> {
225        render_string(
226            markup,
227            &Settings {
228                syntax_set: &SyntaxSet::default(),
229                terminal_capabilities: TerminalProgram::Dumb.capabilities(),
230                terminal_size: TerminalSize::default(),
231                theme: Theme::default(),
232                syntax_theme: None,
233            },
234        )
235    }
236
237    mod layout {
238        use super::render_string_dumb;
239        use insta::assert_snapshot;
240
241        #[test]
242        #[allow(non_snake_case)]
243        fn GH_49_format_no_colour_simple() {
244            assert_eq!(
245                render_string_dumb("_lorem_ **ipsum** dolor **sit** _amet_").unwrap(),
246                "lorem ipsum dolor sit amet\n",
247            )
248        }
249
250        #[test]
251        fn begins_with_rule() {
252            assert_snapshot!(render_string_dumb("----").unwrap())
253        }
254
255        #[test]
256        fn begins_with_block_quote() {
257            assert_snapshot!(render_string_dumb("> Hello World").unwrap());
258        }
259
260        #[test]
261        fn rule_in_block_quote() {
262            assert_snapshot!(render_string_dumb(
263                "> Hello World
264
265> ----"
266            )
267            .unwrap());
268        }
269
270        #[test]
271        fn heading_in_block_quote() {
272            assert_snapshot!(render_string_dumb(
273                "> Hello World
274
275> # Hello World"
276            )
277            .unwrap())
278        }
279
280        #[test]
281        fn heading_levels() {
282            assert_snapshot!(render_string_dumb(
283                "
284# First
285
286## Second
287
288### Third"
289            )
290            .unwrap())
291        }
292
293        #[test]
294        fn autolink_creates_no_reference() {
295            assert_eq!(
296                render_string_dumb("Hello <http://example.com>").unwrap(),
297                "Hello http://example.com\n"
298            )
299        }
300
301        #[test]
302        fn flush_ref_links_before_toplevel_heading() {
303            assert_snapshot!(render_string_dumb(
304                "> Hello [World](http://example.com/world)
305
306> # No refs before this headline
307
308# But before this"
309            )
310            .unwrap())
311        }
312
313        #[test]
314        fn flush_ref_links_at_end() {
315            assert_snapshot!(render_string_dumb(
316                "Hello [World](http://example.com/world)
317
318# Headline
319
320Hello [Donald](http://example.com/Donald)"
321            )
322            .unwrap())
323        }
324    }
325
326    mod disabled_features {
327        use insta::assert_snapshot;
328
329        use super::render_string_dumb;
330
331        #[test]
332        #[allow(non_snake_case)]
333        fn GH_155_do_not_choke_on_footnotes() {
334            assert_snapshot!(render_string_dumb(
335                "A footnote [^1]
336
337[^1: We do not support footnotes."
338            )
339            .unwrap())
340        }
341    }
342}