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