pulldown_cmark_mdcat/
lib.rs1#![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#[derive(Debug)]
68pub struct Settings<'a> {
69 pub terminal_capabilities: TerminalCapabilities,
71 pub terminal_size: TerminalSize,
73 pub syntax_set: &'a SyntaxSet,
75 pub theme: Theme,
77 pub syntax_theme: Option<SyntectTheme>,
82}
83
84#[derive(Debug, Clone)]
86pub struct Environment {
87 pub base_url: Url,
89 pub hostname: String,
91}
92
93impl Environment {
94 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 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
130pub fn markdown_options(smart_punctuation: bool) -> Options {
135 let mut options = Options::ENABLE_TASKLISTS
136 | Options::ENABLE_STRIKETHROUGH
137 | Options::ENABLE_TABLES
138 | Options::ENABLE_FOOTNOTES
139 | Options::ENABLE_MATH
140 | Options::ENABLE_GFM
141 | Options::ENABLE_DEFINITION_LIST;
142 if smart_punctuation {
143 options |= Options::ENABLE_SMART_PUNCTUATION;
144 }
145 options
146}
147
148pub fn strip_frontmatter(input: &str) -> &str {
153 let after_open = match input
154 .strip_prefix("---\n")
155 .or_else(|| input.strip_prefix("---\r\n"))
156 {
157 Some(s) => s,
158 None => return input,
159 };
160
161 let mut start = 0;
162 while start < after_open.len() {
163 let end = after_open[start..]
164 .find('\n')
165 .map_or(after_open.len(), |i| start + i);
166 let line = after_open[start..end].trim_end_matches('\r');
167 let next = (end + 1).min(after_open.len());
168 if line == "---" || line == "..." {
169 return &after_open[next..];
170 }
171 start = end + 1;
172 }
173
174 input
175}
176
177#[instrument(level = "debug", skip_all, fields(environment.hostname = environment.hostname.as_str(), environment.base_url = &environment.base_url.as_str()))]
186pub fn push_tty<'a, 'e, W, I>(
187 settings: &Settings,
188 environment: &Environment,
189 resource_handler: &dyn ResourceUrlHandler,
190 writer: &'a mut W,
191 mut events: I,
192) -> Result<()>
193where
194 I: Iterator<Item = Event<'e>>,
195 W: Write,
196{
197 use render::*;
198 let StateAndData(final_state, final_data) = events.try_fold(
199 StateAndData(State::default(), StateData::default()),
200 |StateAndData(state, data), event| {
201 write_event(
202 writer,
203 settings,
204 environment,
205 &resource_handler,
206 state,
207 data,
208 event,
209 )
210 },
211 )?;
212 finish(writer, settings, environment, final_state, final_data)
213}
214
215#[cfg(test)]
216mod tests {
217 use pulldown_cmark::Parser;
218
219 use crate::resources::NoopResourceHandler;
220
221 use super::*;
222
223 fn render_string(input: &str, settings: &Settings) -> Result<String> {
224 let source = Parser::new(input);
225 let mut sink = Vec::new();
226 let env =
227 Environment::for_local_directory(&std::env::current_dir().expect("Working directory"))?;
228 push_tty(settings, &env, &NoopResourceHandler, &mut sink, source)?;
229 Ok(String::from_utf8_lossy(&sink).into())
230 }
231
232 fn render_string_dumb(markup: &str) -> Result<String> {
233 render_string(
234 markup,
235 &Settings {
236 syntax_set: &SyntaxSet::default(),
237 terminal_capabilities: TerminalProgram::Dumb.capabilities(),
238 terminal_size: TerminalSize::default(),
239 theme: Theme::default(),
240 syntax_theme: None,
241 },
242 )
243 }
244
245 #[test]
246 fn markdown_options_smart_punctuation_toggle() {
247 assert!(!markdown_options(false).contains(Options::ENABLE_SMART_PUNCTUATION));
248 assert!(markdown_options(true).contains(Options::ENABLE_SMART_PUNCTUATION));
249 }
250
251 fn render_definition_list(markup: &str) -> Result<String> {
252 let source = Parser::new_ext(markup, markdown_options(false));
253 let mut sink = Vec::new();
254 let env =
255 Environment::for_local_directory(&std::env::current_dir().expect("Working directory"))?;
256 push_tty(
257 &Settings {
258 syntax_set: &SyntaxSet::default(),
259 terminal_capabilities: TerminalProgram::Dumb.capabilities(),
260 terminal_size: TerminalSize::default(),
261 theme: Theme::default(),
262 syntax_theme: None,
263 },
264 &env,
265 &NoopResourceHandler,
266 &mut sink,
267 source,
268 )?;
269 Ok(String::from_utf8_lossy(&sink).into())
270 }
271
272 #[test]
273 fn definition_list_tight() {
274 assert_eq!(
275 render_definition_list("Apple\n: A fruit.\n: A tech company.\n\nBanana\n: A fruit.\n")
276 .unwrap(),
277 "Apple\n A fruit.\n A tech company.\nBanana\n A fruit.\n"
278 );
279 }
280
281 #[test]
282 fn definition_list_with_inline_markup_does_not_panic() {
283 let output = render_definition_list(
286 "Term with `code` and **bold**\n: Def with [a link](https://example.com) and _italics_.\n",
287 )
288 .unwrap();
289 assert!(output.contains("Term with code and bold"));
290 assert!(output.contains("Def with a link"));
291 assert!(output.contains("https://example.com"));
292 }
293
294 #[test]
295 fn definition_list_nested_blocks_do_not_panic() {
296 render_definition_list(
299 "Term\n\n: First paragraph.\n\n Second paragraph.\n\n - a nested item\n\n ```\n code\n ```\n",
300 )
301 .unwrap();
302 }
303
304 mod layout {
305 use super::render_string_dumb;
306 use insta::assert_snapshot;
307
308 #[test]
309 #[allow(non_snake_case)]
310 fn GH_49_format_no_colour_simple() {
311 assert_eq!(
312 render_string_dumb("_lorem_ **ipsum** dolor **sit** _amet_").unwrap(),
313 "lorem ipsum dolor sit amet\n",
314 )
315 }
316
317 #[test]
318 fn begins_with_rule() {
319 assert_snapshot!(render_string_dumb("----").unwrap())
320 }
321
322 #[test]
323 fn begins_with_block_quote() {
324 assert_snapshot!(render_string_dumb("> Hello World").unwrap());
325 }
326
327 #[test]
328 fn rule_in_block_quote() {
329 assert_snapshot!(render_string_dumb(
330 "> Hello World
331
332> ----"
333 )
334 .unwrap());
335 }
336
337 #[test]
338 fn heading_in_block_quote() {
339 assert_snapshot!(render_string_dumb(
340 "> Hello World
341
342> # Hello World"
343 )
344 .unwrap())
345 }
346
347 #[test]
348 fn heading_levels() {
349 assert_snapshot!(render_string_dumb(
350 "
351# First
352
353## Second
354
355### Third"
356 )
357 .unwrap())
358 }
359
360 #[test]
361 fn autolink_creates_no_reference() {
362 assert_eq!(
363 render_string_dumb("Hello <http://example.com>").unwrap(),
364 "Hello http://example.com\n"
365 )
366 }
367
368 #[test]
369 fn flush_ref_links_before_toplevel_heading() {
370 assert_snapshot!(render_string_dumb(
371 "> Hello [World](http://example.com/world)
372
373> # No refs before this headline
374
375# But before this"
376 )
377 .unwrap())
378 }
379
380 #[test]
381 fn flush_ref_links_at_end() {
382 assert_snapshot!(render_string_dumb(
383 "Hello [World](http://example.com/world)
384
385# Headline
386
387Hello [Donald](http://example.com/Donald)"
388 )
389 .unwrap())
390 }
391 }
392
393 mod disabled_features {
394 use insta::assert_snapshot;
395
396 use super::render_string_dumb;
397
398 #[test]
399 #[allow(non_snake_case)]
400 fn GH_155_do_not_choke_on_footnotes() {
401 assert_snapshot!(render_string_dumb(
402 "A footnote [^1]
403
404[^1: We do not support footnotes."
405 )
406 .unwrap())
407 }
408 }
409}