#![deny(warnings, missing_docs, clippy::all)]
#![forbid(unsafe_code)]
use std::io::{Error, ErrorKind, Result, Write};
use std::path::Path;
use gethostname::gethostname;
use pulldown_cmark::{Event, Options, Tag, TagEnd};
use syntect::highlighting::Theme as SyntectTheme;
use syntect::parsing::SyntaxSet;
use tracing::instrument;
use url::Url;
pub use crate::frontmatter::{frontmatter_table_markdown, split_frontmatter, FrontmatterFormat};
pub use crate::resources::ResourceUrlHandler;
pub use crate::terminal::capabilities::TerminalCapabilities;
pub use crate::terminal::{TerminalProgram, TerminalSize};
pub use crate::theme::Theme;
mod frontmatter;
#[cfg(feature = "ratatui")]
pub mod ratatui;
mod references;
pub mod resources;
pub mod terminal;
mod theme;
mod render;
#[derive(Debug)]
pub struct Settings<'a> {
pub terminal_capabilities: TerminalCapabilities,
pub terminal_size: TerminalSize,
pub syntax_set: &'a SyntaxSet,
pub theme: Theme,
pub syntax_theme: Option<SyntectTheme>,
}
#[derive(Debug, Clone)]
pub struct Environment {
pub base_url: Url,
pub hostname: String,
}
impl Environment {
pub fn for_localhost(base_url: Url) -> Result<Self> {
gethostname()
.into_string()
.map_err(|raw| {
Error::new(
ErrorKind::InvalidData,
format!("gethostname() returned invalid unicode data: {raw:?}"),
)
})
.map(|hostname| Environment { base_url, hostname })
}
pub fn for_local_directory<P: AsRef<Path>>(base_dir: &P) -> Result<Self> {
Url::from_directory_path(base_dir)
.map_err(|_| {
Error::new(
ErrorKind::InvalidInput,
format!(
"Base directory {} must be an absolute path",
base_dir.as_ref().display()
),
)
})
.and_then(Self::for_localhost)
}
}
pub fn markdown_options(smart_punctuation: bool) -> Options {
let mut options = Options::ENABLE_TASKLISTS
| Options::ENABLE_STRIKETHROUGH
| Options::ENABLE_TABLES
| Options::ENABLE_FOOTNOTES
| Options::ENABLE_MATH
| Options::ENABLE_GFM
| Options::ENABLE_DEFINITION_LIST;
if smart_punctuation {
options |= Options::ENABLE_SMART_PUNCTUATION;
}
options
}
pub fn strip_frontmatter(input: &str) -> &str {
frontmatter::split_frontmatter(input).0
}
pub fn expand_tabs(input: &str, tab_width: u16) -> std::borrow::Cow<'_, str> {
if tab_width == 0 || !input.contains('\t') {
return std::borrow::Cow::Borrowed(input);
}
let tab_width = usize::from(tab_width);
let mut output = String::with_capacity(input.len());
let mut column = 0;
for c in input.chars() {
match c {
'\t' => {
let spaces = tab_width - (column % tab_width);
output.extend(std::iter::repeat_n(' ', spaces));
column += spaces;
}
'\n' => {
output.push('\n');
column = 0;
}
_ => {
output.push(c);
column += 1;
}
}
}
std::borrow::Cow::Owned(output)
}
pub fn substitute_emoji<'e>(
events: impl Iterator<Item = Event<'e>> + 'e,
) -> impl Iterator<Item = Event<'e>> {
let replacer = gh_emoji::Replacer::new();
let mut code_block_depth = 0u32;
events.map(move |event| match event {
Event::Start(Tag::CodeBlock(_)) => {
code_block_depth += 1;
event
}
Event::End(TagEnd::CodeBlock) => {
code_block_depth = code_block_depth.saturating_sub(1);
event
}
Event::Text(text) if code_block_depth == 0 => match replacer.replace_all(&text) {
std::borrow::Cow::Borrowed(_) => Event::Text(text),
std::borrow::Cow::Owned(s) => Event::Text(s.into()),
},
other => other,
})
}
#[instrument(level = "debug", skip_all, fields(environment.hostname = environment.hostname.as_str(), environment.base_url = &environment.base_url.as_str()))]
pub fn push_tty<'a, 'e, W, I>(
settings: &Settings,
environment: &Environment,
resource_handler: &dyn ResourceUrlHandler,
writer: &'a mut W,
mut events: I,
) -> Result<()>
where
I: Iterator<Item = Event<'e>>,
W: Write,
{
use render::*;
let StateAndData(final_state, final_data) = events.try_fold(
StateAndData(State::default(), StateData::default()),
|StateAndData(state, data), event| {
write_event(
writer,
settings,
environment,
&resource_handler,
state,
data,
event,
)
},
)?;
finish(writer, settings, environment, final_state, final_data)
}
#[cfg(test)]
mod tests {
use pulldown_cmark::Parser;
use crate::resources::NoopResourceHandler;
use super::*;
fn render_string(input: &str, settings: &Settings) -> Result<String> {
let source = Parser::new(input);
let mut sink = Vec::new();
let env =
Environment::for_local_directory(&std::env::current_dir().expect("Working directory"))?;
push_tty(settings, &env, &NoopResourceHandler, &mut sink, source)?;
Ok(String::from_utf8_lossy(&sink).into())
}
fn render_string_dumb(markup: &str) -> Result<String> {
render_string(
markup,
&Settings {
syntax_set: &SyntaxSet::default(),
terminal_capabilities: TerminalProgram::Dumb.capabilities(),
terminal_size: TerminalSize::default(),
theme: Theme::default(),
syntax_theme: None,
},
)
}
#[test]
fn markdown_options_smart_punctuation_toggle() {
assert!(!markdown_options(false).contains(Options::ENABLE_SMART_PUNCTUATION));
assert!(markdown_options(true).contains(Options::ENABLE_SMART_PUNCTUATION));
}
#[test]
fn strip_frontmatter_yaml() {
assert_eq!(strip_frontmatter("---\na: 1\n---\n# H\n"), "# H\n");
assert_eq!(strip_frontmatter("---\r\na: 1\r\n...\r\n# H\n"), "# H\n");
}
#[test]
fn strip_frontmatter_toml() {
assert_eq!(strip_frontmatter("+++\na = 1\n+++\n# H\n"), "# H\n");
assert_eq!(strip_frontmatter("+++\na = 1\n---\n"), "+++\na = 1\n---\n");
}
#[test]
fn strip_frontmatter_bom_and_trailing_whitespace() {
assert_eq!(strip_frontmatter("\u{feff}---\na: 1\n---\n# H\n"), "# H\n");
assert_eq!(strip_frontmatter("--- \na: 1\n---\t\n# H\n"), "# H\n");
}
#[test]
fn strip_frontmatter_without_closing_delimiter_leaves_input_unchanged() {
assert_eq!(strip_frontmatter("---\na: 1\n"), "---\na: 1\n");
assert_eq!(strip_frontmatter("# H\n---\n"), "# H\n---\n");
assert_eq!(strip_frontmatter("---"), "---");
}
#[test]
fn strip_frontmatter_closing_delimiter_at_eof() {
assert_eq!(strip_frontmatter("---\na: 1\n---"), "");
}
#[test]
fn expand_tabs_zero_width_leaves_input_unchanged() {
assert_eq!(expand_tabs("a\tb", 0), "a\tb");
}
#[test]
fn expand_tabs_without_tabs_does_not_allocate() {
assert!(matches!(
expand_tabs("no tabs here", 4),
std::borrow::Cow::Borrowed(_)
));
}
#[test]
fn expand_tabs_advances_to_next_tab_stop() {
assert_eq!(expand_tabs("a\tb", 4), "a b");
assert_eq!(expand_tabs("ab\tc", 4), "ab c");
assert_eq!(expand_tabs("abcd\te", 4), "abcd e");
}
#[test]
fn expand_tabs_resets_column_at_newline() {
assert_eq!(expand_tabs("a\tb\nc\td", 4), "a b\nc d");
}
#[test]
fn expand_tabs_handles_consecutive_tabs() {
assert_eq!(expand_tabs("a\t\tb", 4), "a b");
}
#[test]
fn substitute_emoji_replaces_known_shortcodes_in_text() {
let events = vec![Event::Text("Hello :+1: and :smile:!".into())];
let result: Vec<_> = substitute_emoji(events.into_iter()).collect();
assert_eq!(result, vec![Event::Text("Hello 👍 and 😄!".into())]);
}
#[test]
fn substitute_emoji_leaves_unknown_shortcodes_untouched() {
let events = vec![Event::Text("before :not_a_real_emoji_xyz: after".into())];
let result: Vec<_> = substitute_emoji(events.into_iter()).collect();
assert_eq!(
result,
vec![Event::Text("before :not_a_real_emoji_xyz: after".into())]
);
}
#[test]
fn substitute_emoji_never_touches_code_events() {
let events = vec![Event::Code(":+1:".into())];
let result: Vec<_> = substitute_emoji(events.into_iter()).collect();
assert_eq!(result, vec![Event::Code(":+1:".into())]);
}
#[test]
fn substitute_emoji_never_touches_fenced_code_block_text() {
let events = vec![
Event::Start(Tag::CodeBlock(pulldown_cmark::CodeBlockKind::Fenced(
"".into(),
))),
Event::Text(":+1:".into()),
Event::End(TagEnd::CodeBlock),
];
let result: Vec<_> = substitute_emoji(events.clone().into_iter()).collect();
assert_eq!(result, events);
}
fn render_definition_list(markup: &str) -> Result<String> {
let source = Parser::new_ext(markup, markdown_options(false));
let mut sink = Vec::new();
let env =
Environment::for_local_directory(&std::env::current_dir().expect("Working directory"))?;
push_tty(
&Settings {
syntax_set: &SyntaxSet::default(),
terminal_capabilities: TerminalProgram::Dumb.capabilities(),
terminal_size: TerminalSize::default(),
theme: Theme::default(),
syntax_theme: None,
},
&env,
&NoopResourceHandler,
&mut sink,
source,
)?;
Ok(String::from_utf8_lossy(&sink).into())
}
#[test]
fn definition_list_tight() {
assert_eq!(
render_definition_list("Apple\n: A fruit.\n: A tech company.\n\nBanana\n: A fruit.\n")
.unwrap(),
"Apple\n A fruit.\n A tech company.\nBanana\n A fruit.\n"
);
}
#[test]
fn definition_list_with_inline_markup_does_not_panic() {
let output = render_definition_list(
"Term with `code` and **bold**\n: Def with [a link](https://example.com) and _italics_.\n",
)
.unwrap();
assert!(output.contains("Term with code and bold"));
assert!(output.contains("Def with a link"));
assert!(output.contains("https://example.com"));
}
#[test]
fn definition_list_nested_blocks_do_not_panic() {
render_definition_list(
"Term\n\n: First paragraph.\n\n Second paragraph.\n\n - a nested item\n\n ```\n code\n ```\n",
)
.unwrap();
}
mod layout {
use super::render_string_dumb;
use insta::assert_snapshot;
#[test]
#[allow(non_snake_case)]
fn GH_49_format_no_colour_simple() {
assert_eq!(
render_string_dumb("_lorem_ **ipsum** dolor **sit** _amet_").unwrap(),
"lorem ipsum dolor sit amet\n",
)
}
#[test]
fn begins_with_rule() {
assert_snapshot!(render_string_dumb("----").unwrap())
}
#[test]
fn begins_with_block_quote() {
assert_snapshot!(render_string_dumb("> Hello World").unwrap());
}
#[test]
fn rule_in_block_quote() {
assert_snapshot!(render_string_dumb(
"> Hello World
> ----"
)
.unwrap());
}
#[test]
fn heading_in_block_quote() {
assert_snapshot!(render_string_dumb(
"> Hello World
> # Hello World"
)
.unwrap())
}
#[test]
fn heading_levels() {
assert_snapshot!(render_string_dumb(
"
# First
## Second
### Third"
)
.unwrap())
}
#[test]
fn autolink_creates_no_reference() {
assert_eq!(
render_string_dumb("Hello <http://example.com>").unwrap(),
"Hello http://example.com\n"
)
}
#[test]
fn flush_ref_links_before_toplevel_heading() {
assert_snapshot!(render_string_dumb(
"> Hello [World](http://example.com/world)
> # No refs before this headline
# But before this"
)
.unwrap())
}
#[test]
fn flush_ref_links_at_end() {
assert_snapshot!(render_string_dumb(
"Hello [World](http://example.com/world)
# Headline
Hello [Donald](http://example.com/Donald)"
)
.unwrap())
}
}
mod disabled_features {
use insta::assert_snapshot;
use super::render_string_dumb;
#[test]
#[allow(non_snake_case)]
fn GH_155_do_not_choke_on_footnotes() {
assert_snapshot!(render_string_dumb(
"A footnote [^1]
[^1: We do not support footnotes."
)
.unwrap())
}
}
}