1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360
// Copyright 2018-2020 Sebastian Wiesner <sebastian@swsnr.de>
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//! Write markdown to TTYs.
//!
//! See [`push_tty`] for the main entry point.
//!
//! ## MSRV
//!
//! This library generally supports only the latest stable Rust version.
//!
//! ## Features
//!
//! - `default` enables `svg`, `regex-fancy`, and `image-processing`.
//!
//! - `svg` includes support for rendering SVG images to PNG for terminals which do not support SVG
//! images natively. This feature adds a dependency on `resvg`.
//!
//! - `image-processing` enables processing of pixel images before rendering. This feature adds
//! a dependency on `image`. If disabled mdcat will not be able to render inline images on some
//! terminals, or render images incorrectly or at wrong sizes on other terminals.
//!
//! Do not disable this feature unless you are sure that you won't use inline images, or accept
//! incomplete rendering of images. Please do not report issues with inline images with this
//! feature disabled.
//!
//! This feature only exists to allow building with minimal dependencies for use cases where
//! inline image support is not used or required. Do not disable this feature unless you know
//! you won't use inline images, or can accept buggy inline image rendering.
//!
//! Please **do not report bugs** about inline image rendering with this feature disabled, unless
//! the issue can also be reproduced if the feature is enabled.
//!
//! - `regex-fancy` and `regex-onig` enable the corresponding features of the [`syntect`] crate,
//! i.e. determine whether syntect uses the regex-fancy Rust crate or the Oniguruma C library as
//! its regex engine. The former is slower, but does not imply a native dependency, the latter
//! is faster, but you need to compile and link a C library.
#![deny(warnings, missing_docs, clippy::all)]
#![forbid(unsafe_code)]
use std::io::{Error, ErrorKind, Result, Write};
use std::path::Path;
use gethostname::gethostname;
use pulldown_cmark::Event;
use syntect::parsing::SyntaxSet;
use tracing::instrument;
use url::Url;
pub use crate::resources::ResourceUrlHandler;
pub use crate::terminal::capabilities::TerminalCapabilities;
pub use crate::terminal::{TerminalProgram, TerminalSize};
pub use crate::theme::Theme;
mod references;
pub mod resources;
pub mod terminal;
mod theme;
mod render;
/// Settings for markdown rendering.
#[derive(Debug)]
pub struct Settings<'a> {
/// Capabilities of the terminal mdcat writes to.
pub terminal_capabilities: TerminalCapabilities,
/// The size of the terminal mdcat writes to.
pub terminal_size: TerminalSize,
/// Syntax set for syntax highlighting of code blocks.
pub syntax_set: &'a SyntaxSet,
/// Colour theme for mdcat
pub theme: Theme,
}
/// The environment to render markdown in.
#[derive(Debug)]
pub struct Environment {
/// The base URL to resolve relative URLs with.
pub base_url: Url,
/// The local host name.
pub hostname: String,
}
impl Environment {
/// Create an environment for the local host with the given `base_url`.
///
/// Take the local hostname from `gethostname`.
pub fn for_localhost(base_url: Url) -> Result<Self> {
gethostname()
.into_string()
.map_err(|raw| {
Error::new(
ErrorKind::InvalidData,
format!("gethostname() returned invalid unicode data: {raw:?}"),
)
})
.map(|hostname| Environment { base_url, hostname })
}
/// Create an environment for a local directory.
///
/// Convert the directory to a directory URL, and obtain the hostname from `gethostname`.
///
/// `base_dir` must be an absolute path; return an IO error with `ErrorKind::InvalidInput`
/// otherwise.
pub fn for_local_directory<P: AsRef<Path>>(base_dir: &P) -> Result<Self> {
Url::from_directory_path(base_dir)
.map_err(|_| {
Error::new(
ErrorKind::InvalidInput,
format!(
"Base directory {} must be an absolute path",
base_dir.as_ref().display()
),
)
})
.and_then(Self::for_localhost)
}
}
/// Write markdown to a TTY.
///
/// Iterate over Markdown AST `events`, format each event for TTY output and
/// write the result to a `writer`, using the given `settings` and `environment`
/// for rendering and resource access.
///
/// `push_tty` tries to limit output to the given number of TTY `columns` but
/// does not guarantee that output stays within the column limit.
#[instrument(level = "debug", skip_all, fields(environment.hostname = environment.hostname.as_str(), environment.base_url = &environment.base_url.as_str()))]
pub fn push_tty<'a, 'e, W, I>(
settings: &Settings,
environment: &Environment,
resource_handler: &dyn ResourceUrlHandler,
writer: &'a mut W,
mut events: I,
) -> Result<()>
where
I: Iterator<Item = Event<'e>>,
W: Write,
{
use render::*;
let StateAndData(final_state, final_data) = events.try_fold(
StateAndData(State::default(), StateData::default()),
|StateAndData(state, data), event| {
write_event(
writer,
settings,
environment,
&resource_handler,
state,
data,
event,
)
},
)?;
finish(writer, settings, environment, final_state, final_data)
}
#[cfg(test)]
mod tests {
use pulldown_cmark::Parser;
use crate::resources::NoopResourceHandler;
use super::*;
fn render_string(input: &str, settings: &Settings) -> Result<String> {
let source = Parser::new(input);
let mut sink = Vec::new();
let env =
Environment::for_local_directory(&std::env::current_dir().expect("Working directory"))?;
push_tty(settings, &env, &NoopResourceHandler, &mut sink, source)?;
Ok(String::from_utf8_lossy(&sink).into())
}
mod layout {
use similar_asserts::assert_eq;
use syntect::parsing::SyntaxSet;
use crate::terminal::TerminalProgram;
use crate::*;
use super::render_string;
fn render(markup: &str) -> Result<String> {
render_string(
markup,
&Settings {
syntax_set: &SyntaxSet::default(),
terminal_capabilities: TerminalProgram::Dumb.capabilities(),
terminal_size: TerminalSize::default(),
theme: Theme::default(),
},
)
}
#[test]
#[allow(non_snake_case)]
fn GH_49_format_no_colour_simple() {
assert_eq!(
render("_lorem_ **ipsum** dolor **sit** _amet_").unwrap(),
"lorem ipsum dolor sit amet\n",
)
}
#[test]
fn begins_with_rule() {
assert_eq!(render("----").unwrap(), "════════════════════════════════════════════════════════════════════════════════\n")
}
#[test]
fn begins_with_block_quote() {
assert_eq!(render("> Hello World").unwrap(), " Hello World\n")
}
#[test]
fn rule_in_block_quote() {
assert_eq!(
render(
"> Hello World
> ----"
)
.unwrap(),
" Hello World
════════════════════════════════════════════════════════════════════════════\n"
)
}
#[test]
fn heading_in_block_quote() {
assert_eq!(
render(
"> Hello World
> # Hello World"
)
.unwrap(),
" Hello World
┄Hello World\n"
)
}
#[test]
fn heading_levels() {
assert_eq!(
render(
"
# First
## Second
### Third"
)
.unwrap(),
"┄First
┄┄Second
┄┄┄Third\n"
)
}
#[test]
fn autolink_creates_no_reference() {
assert_eq!(
render("Hello <http://example.com>").unwrap(),
"Hello http://example.com\n"
)
}
#[test]
fn flush_ref_links_before_toplevel_heading() {
assert_eq!(
render(
"> Hello [World](http://example.com/world)
> # No refs before this headline
# But before this"
)
.unwrap(),
" Hello World[1]
┄No refs before this headline
[1]: http://example.com/world
┄But before this\n"
)
}
#[test]
fn flush_ref_links_at_end() {
assert_eq!(
render(
"Hello [World](http://example.com/world)
# Headline
Hello [Donald](http://example.com/Donald)"
)
.unwrap(),
"Hello World[1]
[1]: http://example.com/world
┄Headline
Hello Donald[2]
[2]: http://example.com/Donald\n"
)
}
}
mod disabled_features {
use similar_asserts::assert_eq;
use syntect::parsing::SyntaxSet;
use crate::terminal::TerminalProgram;
use crate::*;
use super::render_string;
fn render(markup: &str) -> Result<String> {
render_string(
markup,
&Settings {
syntax_set: &SyntaxSet::default(),
terminal_capabilities: TerminalProgram::Dumb.capabilities(),
terminal_size: TerminalSize::default(),
theme: Theme::default(),
},
)
}
#[test]
#[allow(non_snake_case)]
fn GH_155_do_not_choke_on_footnotes() {
assert_eq!(
render(
"A footnote [^1]
[^1: We do not support footnotes."
)
.unwrap(),
"A footnote [^1]
[^1: We do not support footnotes.\n"
)
}
}
}