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#![deny(warnings, missing_docs, clippy::all)]
38#![forbid(unsafe_code)]
39
40use std::io::{Error, ErrorKind, Result, Write};
41use std::path::Path;
42
43use gethostname::gethostname;
44use pulldown_cmark::Event;
45use syntect::highlighting::Theme as SyntectTheme;
46use syntect::parsing::SyntaxSet;
47use tracing::instrument;
48use url::Url;
49
50pub use crate::resources::ResourceUrlHandler;
51pub use crate::terminal::capabilities::TerminalCapabilities;
52pub use crate::terminal::{TerminalProgram, TerminalSize};
53pub use crate::theme::Theme;
54
55mod references;
56pub mod resources;
57pub mod terminal;
58mod theme;
59
60mod render;
61
62/// Settings for markdown rendering.
63#[derive(Debug)]
64pub struct Settings<'a> {
65    /// Capabilities of the terminal mdcat writes to.
66    pub terminal_capabilities: TerminalCapabilities,
67    /// The size of the terminal mdcat writes to.
68    pub terminal_size: TerminalSize,
69    /// Syntax set for syntax highlighting of code blocks.
70    pub syntax_set: &'a SyntaxSet,
71    /// Colour theme for mdcat
72    pub theme: Theme,
73    /// Syntect theme for syntax-highlighted code blocks.
74    ///
75    /// When set, code blocks are rendered with 24-bit RGB colors from this theme.
76    /// When absent, falls back to the built-in Solarized Dark → ANSI color mapping.
77    pub syntax_theme: Option<SyntectTheme>,
78}
79
80/// The environment to render markdown in.
81#[derive(Debug)]
82pub struct Environment {
83    /// The base URL to resolve relative URLs with.
84    pub base_url: Url,
85    /// The local host name.
86    pub hostname: String,
87}
88
89impl Environment {
90    /// Create an environment for the local host with the given `base_url`.
91    ///
92    /// Take the local hostname from `gethostname`.
93    pub fn for_localhost(base_url: Url) -> Result<Self> {
94        gethostname()
95            .into_string()
96            .map_err(|raw| {
97                Error::new(
98                    ErrorKind::InvalidData,
99                    format!("gethostname() returned invalid unicode data: {raw:?}"),
100                )
101            })
102            .map(|hostname| Environment { base_url, hostname })
103    }
104
105    /// Create an environment for a local directory.
106    ///
107    /// Convert the directory to a directory URL, and obtain the hostname from `gethostname`.
108    ///
109    /// `base_dir` must be an absolute path; return an IO error with `ErrorKind::InvalidInput`
110    /// otherwise.
111    pub fn for_local_directory<P: AsRef<Path>>(base_dir: &P) -> Result<Self> {
112        Url::from_directory_path(base_dir)
113            .map_err(|_| {
114                Error::new(
115                    ErrorKind::InvalidInput,
116                    format!(
117                        "Base directory {} must be an absolute path",
118                        base_dir.as_ref().display()
119                    ),
120                )
121            })
122            .and_then(Self::for_localhost)
123    }
124}
125
126/// Write markdown to a TTY.
127///
128/// Iterate over Markdown AST `events`, format each event for TTY output and
129/// write the result to a `writer`, using the given `settings` and `environment`
130/// for rendering and resource access.
131///
132/// `push_tty` tries to limit output to the given number of TTY `columns` but
133/// does not guarantee that output stays within the column limit.
134#[instrument(level = "debug", skip_all, fields(environment.hostname = environment.hostname.as_str(), environment.base_url = &environment.base_url.as_str()))]
135pub fn push_tty<'a, 'e, W, I>(
136    settings: &Settings,
137    environment: &Environment,
138    resource_handler: &dyn ResourceUrlHandler,
139    writer: &'a mut W,
140    mut events: I,
141) -> Result<()>
142where
143    I: Iterator<Item = Event<'e>>,
144    W: Write,
145{
146    use render::*;
147    let StateAndData(final_state, final_data) = events.try_fold(
148        StateAndData(State::default(), StateData::default()),
149        |StateAndData(state, data), event| {
150            write_event(
151                writer,
152                settings,
153                environment,
154                &resource_handler,
155                state,
156                data,
157                event,
158            )
159        },
160    )?;
161    finish(writer, settings, environment, final_state, final_data)
162}
163
164#[cfg(test)]
165mod tests {
166    use pulldown_cmark::Parser;
167
168    use crate::resources::NoopResourceHandler;
169
170    use super::*;
171
172    fn render_string(input: &str, settings: &Settings) -> Result<String> {
173        let source = Parser::new(input);
174        let mut sink = Vec::new();
175        let env =
176            Environment::for_local_directory(&std::env::current_dir().expect("Working directory"))?;
177        push_tty(settings, &env, &NoopResourceHandler, &mut sink, source)?;
178        Ok(String::from_utf8_lossy(&sink).into())
179    }
180
181    fn render_string_dumb(markup: &str) -> Result<String> {
182        render_string(
183            markup,
184            &Settings {
185                syntax_set: &SyntaxSet::default(),
186                terminal_capabilities: TerminalProgram::Dumb.capabilities(),
187                terminal_size: TerminalSize::default(),
188                theme: Theme::default(),
189                syntax_theme: None,
190            },
191        )
192    }
193
194    mod layout {
195        use super::render_string_dumb;
196        use insta::assert_snapshot;
197
198        #[test]
199        #[allow(non_snake_case)]
200        fn GH_49_format_no_colour_simple() {
201            assert_eq!(
202                render_string_dumb("_lorem_ **ipsum** dolor **sit** _amet_").unwrap(),
203                "lorem ipsum dolor sit amet\n",
204            )
205        }
206
207        #[test]
208        fn begins_with_rule() {
209            assert_snapshot!(render_string_dumb("----").unwrap())
210        }
211
212        #[test]
213        fn begins_with_block_quote() {
214            assert_snapshot!(render_string_dumb("> Hello World").unwrap());
215        }
216
217        #[test]
218        fn rule_in_block_quote() {
219            assert_snapshot!(render_string_dumb(
220                "> Hello World
221
222> ----"
223            )
224            .unwrap());
225        }
226
227        #[test]
228        fn heading_in_block_quote() {
229            assert_snapshot!(render_string_dumb(
230                "> Hello World
231
232> # Hello World"
233            )
234            .unwrap())
235        }
236
237        #[test]
238        fn heading_levels() {
239            assert_snapshot!(render_string_dumb(
240                "
241# First
242
243## Second
244
245### Third"
246            )
247            .unwrap())
248        }
249
250        #[test]
251        fn autolink_creates_no_reference() {
252            assert_eq!(
253                render_string_dumb("Hello <http://example.com>").unwrap(),
254                "Hello http://example.com\n"
255            )
256        }
257
258        #[test]
259        fn flush_ref_links_before_toplevel_heading() {
260            assert_snapshot!(render_string_dumb(
261                "> Hello [World](http://example.com/world)
262
263> # No refs before this headline
264
265# But before this"
266            )
267            .unwrap())
268        }
269
270        #[test]
271        fn flush_ref_links_at_end() {
272            assert_snapshot!(render_string_dumb(
273                "Hello [World](http://example.com/world)
274
275# Headline
276
277Hello [Donald](http://example.com/Donald)"
278            )
279            .unwrap())
280        }
281    }
282
283    mod disabled_features {
284        use insta::assert_snapshot;
285
286        use super::render_string_dumb;
287
288        #[test]
289        #[allow(non_snake_case)]
290        fn GH_155_do_not_choke_on_footnotes() {
291            assert_snapshot!(render_string_dumb(
292                "A footnote [^1]
293
294[^1: We do not support footnotes."
295            )
296            .unwrap())
297        }
298    }
299}