pulldown_cmark_mdcat/
lib.rs1#![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#[derive(Debug)]
63pub struct Settings<'a> {
64 pub terminal_capabilities: TerminalCapabilities,
66 pub terminal_size: TerminalSize,
68 pub syntax_set: &'a SyntaxSet,
70 pub theme: Theme,
72}
73
74#[derive(Debug)]
76pub struct Environment {
77 pub base_url: Url,
79 pub hostname: String,
81}
82
83impl Environment {
84 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 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#[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}