1#![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
177pub fn expand_tabs(input: &str, tab_width: u16) -> std::borrow::Cow<'_, str> {
192 if tab_width == 0 || !input.contains('\t') {
193 return std::borrow::Cow::Borrowed(input);
194 }
195
196 let tab_width = usize::from(tab_width);
197 let mut output = String::with_capacity(input.len());
198 let mut column = 0;
199 for c in input.chars() {
200 match c {
201 '\t' => {
202 let spaces = tab_width - (column % tab_width);
203 output.extend(std::iter::repeat_n(' ', spaces));
204 column += spaces;
205 }
206 '\n' => {
207 output.push('\n');
208 column = 0;
209 }
210 _ => {
211 output.push(c);
212 column += 1;
213 }
214 }
215 }
216 std::borrow::Cow::Owned(output)
217}
218
219#[instrument(level = "debug", skip_all, fields(environment.hostname = environment.hostname.as_str(), environment.base_url = &environment.base_url.as_str()))]
228pub fn push_tty<'a, 'e, W, I>(
229 settings: &Settings,
230 environment: &Environment,
231 resource_handler: &dyn ResourceUrlHandler,
232 writer: &'a mut W,
233 mut events: I,
234) -> Result<()>
235where
236 I: Iterator<Item = Event<'e>>,
237 W: Write,
238{
239 use render::*;
240 let StateAndData(final_state, final_data) = events.try_fold(
241 StateAndData(State::default(), StateData::default()),
242 |StateAndData(state, data), event| {
243 write_event(
244 writer,
245 settings,
246 environment,
247 &resource_handler,
248 state,
249 data,
250 event,
251 )
252 },
253 )?;
254 finish(writer, settings, environment, final_state, final_data)
255}
256
257#[cfg(test)]
258mod tests {
259 use pulldown_cmark::Parser;
260
261 use crate::resources::NoopResourceHandler;
262
263 use super::*;
264
265 fn render_string(input: &str, settings: &Settings) -> Result<String> {
266 let source = Parser::new(input);
267 let mut sink = Vec::new();
268 let env =
269 Environment::for_local_directory(&std::env::current_dir().expect("Working directory"))?;
270 push_tty(settings, &env, &NoopResourceHandler, &mut sink, source)?;
271 Ok(String::from_utf8_lossy(&sink).into())
272 }
273
274 fn render_string_dumb(markup: &str) -> Result<String> {
275 render_string(
276 markup,
277 &Settings {
278 syntax_set: &SyntaxSet::default(),
279 terminal_capabilities: TerminalProgram::Dumb.capabilities(),
280 terminal_size: TerminalSize::default(),
281 theme: Theme::default(),
282 syntax_theme: None,
283 },
284 )
285 }
286
287 #[test]
288 fn markdown_options_smart_punctuation_toggle() {
289 assert!(!markdown_options(false).contains(Options::ENABLE_SMART_PUNCTUATION));
290 assert!(markdown_options(true).contains(Options::ENABLE_SMART_PUNCTUATION));
291 }
292
293 #[test]
294 fn expand_tabs_zero_width_leaves_input_unchanged() {
295 assert_eq!(expand_tabs("a\tb", 0), "a\tb");
296 }
297
298 #[test]
299 fn expand_tabs_without_tabs_does_not_allocate() {
300 assert!(matches!(
301 expand_tabs("no tabs here", 4),
302 std::borrow::Cow::Borrowed(_)
303 ));
304 }
305
306 #[test]
307 fn expand_tabs_advances_to_next_tab_stop() {
308 assert_eq!(expand_tabs("a\tb", 4), "a b");
309 assert_eq!(expand_tabs("ab\tc", 4), "ab c");
310 assert_eq!(expand_tabs("abcd\te", 4), "abcd e");
311 }
312
313 #[test]
314 fn expand_tabs_resets_column_at_newline() {
315 assert_eq!(expand_tabs("a\tb\nc\td", 4), "a b\nc d");
316 }
317
318 #[test]
319 fn expand_tabs_handles_consecutive_tabs() {
320 assert_eq!(expand_tabs("a\t\tb", 4), "a b");
321 }
322
323 fn render_definition_list(markup: &str) -> Result<String> {
324 let source = Parser::new_ext(markup, markdown_options(false));
325 let mut sink = Vec::new();
326 let env =
327 Environment::for_local_directory(&std::env::current_dir().expect("Working directory"))?;
328 push_tty(
329 &Settings {
330 syntax_set: &SyntaxSet::default(),
331 terminal_capabilities: TerminalProgram::Dumb.capabilities(),
332 terminal_size: TerminalSize::default(),
333 theme: Theme::default(),
334 syntax_theme: None,
335 },
336 &env,
337 &NoopResourceHandler,
338 &mut sink,
339 source,
340 )?;
341 Ok(String::from_utf8_lossy(&sink).into())
342 }
343
344 #[test]
345 fn definition_list_tight() {
346 assert_eq!(
347 render_definition_list("Apple\n: A fruit.\n: A tech company.\n\nBanana\n: A fruit.\n")
348 .unwrap(),
349 "Apple\n A fruit.\n A tech company.\nBanana\n A fruit.\n"
350 );
351 }
352
353 #[test]
354 fn definition_list_with_inline_markup_does_not_panic() {
355 let output = render_definition_list(
358 "Term with `code` and **bold**\n: Def with [a link](https://example.com) and _italics_.\n",
359 )
360 .unwrap();
361 assert!(output.contains("Term with code and bold"));
362 assert!(output.contains("Def with a link"));
363 assert!(output.contains("https://example.com"));
364 }
365
366 #[test]
367 fn definition_list_nested_blocks_do_not_panic() {
368 render_definition_list(
371 "Term\n\n: First paragraph.\n\n Second paragraph.\n\n - a nested item\n\n ```\n code\n ```\n",
372 )
373 .unwrap();
374 }
375
376 mod layout {
377 use super::render_string_dumb;
378 use insta::assert_snapshot;
379
380 #[test]
381 #[allow(non_snake_case)]
382 fn GH_49_format_no_colour_simple() {
383 assert_eq!(
384 render_string_dumb("_lorem_ **ipsum** dolor **sit** _amet_").unwrap(),
385 "lorem ipsum dolor sit amet\n",
386 )
387 }
388
389 #[test]
390 fn begins_with_rule() {
391 assert_snapshot!(render_string_dumb("----").unwrap())
392 }
393
394 #[test]
395 fn begins_with_block_quote() {
396 assert_snapshot!(render_string_dumb("> Hello World").unwrap());
397 }
398
399 #[test]
400 fn rule_in_block_quote() {
401 assert_snapshot!(render_string_dumb(
402 "> Hello World
403
404> ----"
405 )
406 .unwrap());
407 }
408
409 #[test]
410 fn heading_in_block_quote() {
411 assert_snapshot!(render_string_dumb(
412 "> Hello World
413
414> # Hello World"
415 )
416 .unwrap())
417 }
418
419 #[test]
420 fn heading_levels() {
421 assert_snapshot!(render_string_dumb(
422 "
423# First
424
425## Second
426
427### Third"
428 )
429 .unwrap())
430 }
431
432 #[test]
433 fn autolink_creates_no_reference() {
434 assert_eq!(
435 render_string_dumb("Hello <http://example.com>").unwrap(),
436 "Hello http://example.com\n"
437 )
438 }
439
440 #[test]
441 fn flush_ref_links_before_toplevel_heading() {
442 assert_snapshot!(render_string_dumb(
443 "> Hello [World](http://example.com/world)
444
445> # No refs before this headline
446
447# But before this"
448 )
449 .unwrap())
450 }
451
452 #[test]
453 fn flush_ref_links_at_end() {
454 assert_snapshot!(render_string_dumb(
455 "Hello [World](http://example.com/world)
456
457# Headline
458
459Hello [Donald](http://example.com/Donald)"
460 )
461 .unwrap())
462 }
463 }
464
465 mod disabled_features {
466 use insta::assert_snapshot;
467
468 use super::render_string_dumb;
469
470 #[test]
471 #[allow(non_snake_case)]
472 fn GH_155_do_not_choke_on_footnotes() {
473 assert_snapshot!(render_string_dumb(
474 "A footnote [^1]
475
476[^1: We do not support footnotes."
477 )
478 .unwrap())
479 }
480 }
481}