1#![deny(warnings, missing_docs, clippy::all)]
43#![forbid(unsafe_code)]
44
45use std::io::{Error, ErrorKind, Result, Write};
46use std::path::Path;
47
48use gethostname::gethostname;
49use pulldown_cmark::{Event, Options};
50use syntect::highlighting::Theme as SyntectTheme;
51use syntect::parsing::SyntaxSet;
52use tracing::instrument;
53use url::Url;
54
55pub use crate::resources::ResourceUrlHandler;
56pub use crate::terminal::capabilities::TerminalCapabilities;
57pub use crate::terminal::{TerminalProgram, TerminalSize};
58pub use crate::theme::Theme;
59
60#[cfg(feature = "ratatui")]
61pub mod ratatui;
62mod references;
63pub mod resources;
64pub mod terminal;
65mod theme;
66
67mod render;
68
69#[derive(Debug)]
71pub struct Settings<'a> {
72 pub terminal_capabilities: TerminalCapabilities,
74 pub terminal_size: TerminalSize,
76 pub syntax_set: &'a SyntaxSet,
78 pub theme: Theme,
80 pub syntax_theme: Option<SyntectTheme>,
85}
86
87#[derive(Debug, Clone)]
89pub struct Environment {
90 pub base_url: Url,
92 pub hostname: String,
94}
95
96impl Environment {
97 pub fn for_localhost(base_url: Url) -> Result<Self> {
101 gethostname()
102 .into_string()
103 .map_err(|raw| {
104 Error::new(
105 ErrorKind::InvalidData,
106 format!("gethostname() returned invalid unicode data: {raw:?}"),
107 )
108 })
109 .map(|hostname| Environment { base_url, hostname })
110 }
111
112 pub fn for_local_directory<P: AsRef<Path>>(base_dir: &P) -> Result<Self> {
119 Url::from_directory_path(base_dir)
120 .map_err(|_| {
121 Error::new(
122 ErrorKind::InvalidInput,
123 format!(
124 "Base directory {} must be an absolute path",
125 base_dir.as_ref().display()
126 ),
127 )
128 })
129 .and_then(Self::for_localhost)
130 }
131}
132
133pub fn markdown_options(smart_punctuation: bool) -> Options {
138 let mut options = Options::ENABLE_TASKLISTS
139 | Options::ENABLE_STRIKETHROUGH
140 | Options::ENABLE_TABLES
141 | Options::ENABLE_FOOTNOTES
142 | Options::ENABLE_MATH
143 | Options::ENABLE_GFM
144 | Options::ENABLE_DEFINITION_LIST;
145 if smart_punctuation {
146 options |= Options::ENABLE_SMART_PUNCTUATION;
147 }
148 options
149}
150
151pub fn strip_frontmatter(input: &str) -> &str {
156 let after_open = match input
157 .strip_prefix("---\n")
158 .or_else(|| input.strip_prefix("---\r\n"))
159 {
160 Some(s) => s,
161 None => return input,
162 };
163
164 let mut start = 0;
165 while start < after_open.len() {
166 let end = after_open[start..]
167 .find('\n')
168 .map_or(after_open.len(), |i| start + i);
169 let line = after_open[start..end].trim_end_matches('\r');
170 let next = (end + 1).min(after_open.len());
171 if line == "---" || line == "..." {
172 return &after_open[next..];
173 }
174 start = end + 1;
175 }
176
177 input
178}
179
180pub fn expand_tabs(input: &str, tab_width: u16) -> std::borrow::Cow<'_, str> {
195 if tab_width == 0 || !input.contains('\t') {
196 return std::borrow::Cow::Borrowed(input);
197 }
198
199 let tab_width = usize::from(tab_width);
200 let mut output = String::with_capacity(input.len());
201 let mut column = 0;
202 for c in input.chars() {
203 match c {
204 '\t' => {
205 let spaces = tab_width - (column % tab_width);
206 output.extend(std::iter::repeat_n(' ', spaces));
207 column += spaces;
208 }
209 '\n' => {
210 output.push('\n');
211 column = 0;
212 }
213 _ => {
214 output.push(c);
215 column += 1;
216 }
217 }
218 }
219 std::borrow::Cow::Owned(output)
220}
221
222#[instrument(level = "debug", skip_all, fields(environment.hostname = environment.hostname.as_str(), environment.base_url = &environment.base_url.as_str()))]
231pub fn push_tty<'a, 'e, W, I>(
232 settings: &Settings,
233 environment: &Environment,
234 resource_handler: &dyn ResourceUrlHandler,
235 writer: &'a mut W,
236 mut events: I,
237) -> Result<()>
238where
239 I: Iterator<Item = Event<'e>>,
240 W: Write,
241{
242 use render::*;
243 let StateAndData(final_state, final_data) = events.try_fold(
244 StateAndData(State::default(), StateData::default()),
245 |StateAndData(state, data), event| {
246 write_event(
247 writer,
248 settings,
249 environment,
250 &resource_handler,
251 state,
252 data,
253 event,
254 )
255 },
256 )?;
257 finish(writer, settings, environment, final_state, final_data)
258}
259
260#[cfg(test)]
261mod tests {
262 use pulldown_cmark::Parser;
263
264 use crate::resources::NoopResourceHandler;
265
266 use super::*;
267
268 fn render_string(input: &str, settings: &Settings) -> Result<String> {
269 let source = Parser::new(input);
270 let mut sink = Vec::new();
271 let env =
272 Environment::for_local_directory(&std::env::current_dir().expect("Working directory"))?;
273 push_tty(settings, &env, &NoopResourceHandler, &mut sink, source)?;
274 Ok(String::from_utf8_lossy(&sink).into())
275 }
276
277 fn render_string_dumb(markup: &str) -> Result<String> {
278 render_string(
279 markup,
280 &Settings {
281 syntax_set: &SyntaxSet::default(),
282 terminal_capabilities: TerminalProgram::Dumb.capabilities(),
283 terminal_size: TerminalSize::default(),
284 theme: Theme::default(),
285 syntax_theme: None,
286 },
287 )
288 }
289
290 #[test]
291 fn markdown_options_smart_punctuation_toggle() {
292 assert!(!markdown_options(false).contains(Options::ENABLE_SMART_PUNCTUATION));
293 assert!(markdown_options(true).contains(Options::ENABLE_SMART_PUNCTUATION));
294 }
295
296 #[test]
297 fn expand_tabs_zero_width_leaves_input_unchanged() {
298 assert_eq!(expand_tabs("a\tb", 0), "a\tb");
299 }
300
301 #[test]
302 fn expand_tabs_without_tabs_does_not_allocate() {
303 assert!(matches!(
304 expand_tabs("no tabs here", 4),
305 std::borrow::Cow::Borrowed(_)
306 ));
307 }
308
309 #[test]
310 fn expand_tabs_advances_to_next_tab_stop() {
311 assert_eq!(expand_tabs("a\tb", 4), "a b");
312 assert_eq!(expand_tabs("ab\tc", 4), "ab c");
313 assert_eq!(expand_tabs("abcd\te", 4), "abcd e");
314 }
315
316 #[test]
317 fn expand_tabs_resets_column_at_newline() {
318 assert_eq!(expand_tabs("a\tb\nc\td", 4), "a b\nc d");
319 }
320
321 #[test]
322 fn expand_tabs_handles_consecutive_tabs() {
323 assert_eq!(expand_tabs("a\t\tb", 4), "a b");
324 }
325
326 fn render_definition_list(markup: &str) -> Result<String> {
327 let source = Parser::new_ext(markup, markdown_options(false));
328 let mut sink = Vec::new();
329 let env =
330 Environment::for_local_directory(&std::env::current_dir().expect("Working directory"))?;
331 push_tty(
332 &Settings {
333 syntax_set: &SyntaxSet::default(),
334 terminal_capabilities: TerminalProgram::Dumb.capabilities(),
335 terminal_size: TerminalSize::default(),
336 theme: Theme::default(),
337 syntax_theme: None,
338 },
339 &env,
340 &NoopResourceHandler,
341 &mut sink,
342 source,
343 )?;
344 Ok(String::from_utf8_lossy(&sink).into())
345 }
346
347 #[test]
348 fn definition_list_tight() {
349 assert_eq!(
350 render_definition_list("Apple\n: A fruit.\n: A tech company.\n\nBanana\n: A fruit.\n")
351 .unwrap(),
352 "Apple\n A fruit.\n A tech company.\nBanana\n A fruit.\n"
353 );
354 }
355
356 #[test]
357 fn definition_list_with_inline_markup_does_not_panic() {
358 let output = render_definition_list(
361 "Term with `code` and **bold**\n: Def with [a link](https://example.com) and _italics_.\n",
362 )
363 .unwrap();
364 assert!(output.contains("Term with code and bold"));
365 assert!(output.contains("Def with a link"));
366 assert!(output.contains("https://example.com"));
367 }
368
369 #[test]
370 fn definition_list_nested_blocks_do_not_panic() {
371 render_definition_list(
374 "Term\n\n: First paragraph.\n\n Second paragraph.\n\n - a nested item\n\n ```\n code\n ```\n",
375 )
376 .unwrap();
377 }
378
379 mod layout {
380 use super::render_string_dumb;
381 use insta::assert_snapshot;
382
383 #[test]
384 #[allow(non_snake_case)]
385 fn GH_49_format_no_colour_simple() {
386 assert_eq!(
387 render_string_dumb("_lorem_ **ipsum** dolor **sit** _amet_").unwrap(),
388 "lorem ipsum dolor sit amet\n",
389 )
390 }
391
392 #[test]
393 fn begins_with_rule() {
394 assert_snapshot!(render_string_dumb("----").unwrap())
395 }
396
397 #[test]
398 fn begins_with_block_quote() {
399 assert_snapshot!(render_string_dumb("> Hello World").unwrap());
400 }
401
402 #[test]
403 fn rule_in_block_quote() {
404 assert_snapshot!(render_string_dumb(
405 "> Hello World
406
407> ----"
408 )
409 .unwrap());
410 }
411
412 #[test]
413 fn heading_in_block_quote() {
414 assert_snapshot!(render_string_dumb(
415 "> Hello World
416
417> # Hello World"
418 )
419 .unwrap())
420 }
421
422 #[test]
423 fn heading_levels() {
424 assert_snapshot!(render_string_dumb(
425 "
426# First
427
428## Second
429
430### Third"
431 )
432 .unwrap())
433 }
434
435 #[test]
436 fn autolink_creates_no_reference() {
437 assert_eq!(
438 render_string_dumb("Hello <http://example.com>").unwrap(),
439 "Hello http://example.com\n"
440 )
441 }
442
443 #[test]
444 fn flush_ref_links_before_toplevel_heading() {
445 assert_snapshot!(render_string_dumb(
446 "> Hello [World](http://example.com/world)
447
448> # No refs before this headline
449
450# But before this"
451 )
452 .unwrap())
453 }
454
455 #[test]
456 fn flush_ref_links_at_end() {
457 assert_snapshot!(render_string_dumb(
458 "Hello [World](http://example.com/world)
459
460# Headline
461
462Hello [Donald](http://example.com/Donald)"
463 )
464 .unwrap())
465 }
466 }
467
468 mod disabled_features {
469 use insta::assert_snapshot;
470
471 use super::render_string_dumb;
472
473 #[test]
474 #[allow(non_snake_case)]
475 fn GH_155_do_not_choke_on_footnotes() {
476 assert_snapshot!(render_string_dumb(
477 "A footnote [^1]
478
479[^1: We do not support footnotes."
480 )
481 .unwrap())
482 }
483 }
484}