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() -> Options {
132 Options::ENABLE_TASKLISTS
133 | Options::ENABLE_STRIKETHROUGH
134 | Options::ENABLE_TABLES
135 | Options::ENABLE_FOOTNOTES
136 | Options::ENABLE_MATH
137 | Options::ENABLE_GFM
138}
139
140pub fn strip_frontmatter(input: &str) -> &str {
145 let after_open = match input
146 .strip_prefix("---\n")
147 .or_else(|| input.strip_prefix("---\r\n"))
148 {
149 Some(s) => s,
150 None => return input,
151 };
152
153 let mut start = 0;
154 while start < after_open.len() {
155 let end = after_open[start..]
156 .find('\n')
157 .map_or(after_open.len(), |i| start + i);
158 let line = after_open[start..end].trim_end_matches('\r');
159 let next = (end + 1).min(after_open.len());
160 if line == "---" || line == "..." {
161 return &after_open[next..];
162 }
163 start = end + 1;
164 }
165
166 input
167}
168
169#[instrument(level = "debug", skip_all, fields(environment.hostname = environment.hostname.as_str(), environment.base_url = &environment.base_url.as_str()))]
178pub fn push_tty<'a, 'e, W, I>(
179 settings: &Settings,
180 environment: &Environment,
181 resource_handler: &dyn ResourceUrlHandler,
182 writer: &'a mut W,
183 mut events: I,
184) -> Result<()>
185where
186 I: Iterator<Item = Event<'e>>,
187 W: Write,
188{
189 use render::*;
190 let StateAndData(final_state, final_data) = events.try_fold(
191 StateAndData(State::default(), StateData::default()),
192 |StateAndData(state, data), event| {
193 write_event(
194 writer,
195 settings,
196 environment,
197 &resource_handler,
198 state,
199 data,
200 event,
201 )
202 },
203 )?;
204 finish(writer, settings, environment, final_state, final_data)
205}
206
207#[cfg(test)]
208mod tests {
209 use pulldown_cmark::Parser;
210
211 use crate::resources::NoopResourceHandler;
212
213 use super::*;
214
215 fn render_string(input: &str, settings: &Settings) -> Result<String> {
216 let source = Parser::new(input);
217 let mut sink = Vec::new();
218 let env =
219 Environment::for_local_directory(&std::env::current_dir().expect("Working directory"))?;
220 push_tty(settings, &env, &NoopResourceHandler, &mut sink, source)?;
221 Ok(String::from_utf8_lossy(&sink).into())
222 }
223
224 fn render_string_dumb(markup: &str) -> Result<String> {
225 render_string(
226 markup,
227 &Settings {
228 syntax_set: &SyntaxSet::default(),
229 terminal_capabilities: TerminalProgram::Dumb.capabilities(),
230 terminal_size: TerminalSize::default(),
231 theme: Theme::default(),
232 syntax_theme: None,
233 },
234 )
235 }
236
237 mod layout {
238 use super::render_string_dumb;
239 use insta::assert_snapshot;
240
241 #[test]
242 #[allow(non_snake_case)]
243 fn GH_49_format_no_colour_simple() {
244 assert_eq!(
245 render_string_dumb("_lorem_ **ipsum** dolor **sit** _amet_").unwrap(),
246 "lorem ipsum dolor sit amet\n",
247 )
248 }
249
250 #[test]
251 fn begins_with_rule() {
252 assert_snapshot!(render_string_dumb("----").unwrap())
253 }
254
255 #[test]
256 fn begins_with_block_quote() {
257 assert_snapshot!(render_string_dumb("> Hello World").unwrap());
258 }
259
260 #[test]
261 fn rule_in_block_quote() {
262 assert_snapshot!(render_string_dumb(
263 "> Hello World
264
265> ----"
266 )
267 .unwrap());
268 }
269
270 #[test]
271 fn heading_in_block_quote() {
272 assert_snapshot!(render_string_dumb(
273 "> Hello World
274
275> # Hello World"
276 )
277 .unwrap())
278 }
279
280 #[test]
281 fn heading_levels() {
282 assert_snapshot!(render_string_dumb(
283 "
284# First
285
286## Second
287
288### Third"
289 )
290 .unwrap())
291 }
292
293 #[test]
294 fn autolink_creates_no_reference() {
295 assert_eq!(
296 render_string_dumb("Hello <http://example.com>").unwrap(),
297 "Hello http://example.com\n"
298 )
299 }
300
301 #[test]
302 fn flush_ref_links_before_toplevel_heading() {
303 assert_snapshot!(render_string_dumb(
304 "> Hello [World](http://example.com/world)
305
306> # No refs before this headline
307
308# But before this"
309 )
310 .unwrap())
311 }
312
313 #[test]
314 fn flush_ref_links_at_end() {
315 assert_snapshot!(render_string_dumb(
316 "Hello [World](http://example.com/world)
317
318# Headline
319
320Hello [Donald](http://example.com/Donald)"
321 )
322 .unwrap())
323 }
324 }
325
326 mod disabled_features {
327 use insta::assert_snapshot;
328
329 use super::render_string_dumb;
330
331 #[test]
332 #[allow(non_snake_case)]
333 fn GH_155_do_not_choke_on_footnotes() {
334 assert_snapshot!(render_string_dumb(
335 "A footnote [^1]
336
337[^1: We do not support footnotes."
338 )
339 .unwrap())
340 }
341 }
342}