pulldown_cmark_mdcat/
lib.rs1#![deny(warnings, missing_docs, clippy::all)]
38#![forbid(unsafe_code)]
39
40use std::io::{Error, ErrorKind, Result, Write};
41use std::path::Path;
42
43use gethostname::gethostname;
44use pulldown_cmark::Event;
45use syntect::highlighting::Theme as SyntectTheme;
46use syntect::parsing::SyntaxSet;
47use tracing::instrument;
48use url::Url;
49
50pub use crate::resources::ResourceUrlHandler;
51pub use crate::terminal::capabilities::TerminalCapabilities;
52pub use crate::terminal::{TerminalProgram, TerminalSize};
53pub use crate::theme::Theme;
54
55mod references;
56pub mod resources;
57pub mod terminal;
58mod theme;
59
60mod render;
61
62#[derive(Debug)]
64pub struct Settings<'a> {
65 pub terminal_capabilities: TerminalCapabilities,
67 pub terminal_size: TerminalSize,
69 pub syntax_set: &'a SyntaxSet,
71 pub theme: Theme,
73 pub syntax_theme: Option<SyntectTheme>,
78}
79
80#[derive(Debug)]
82pub struct Environment {
83 pub base_url: Url,
85 pub hostname: String,
87}
88
89impl Environment {
90 pub fn for_localhost(base_url: Url) -> Result<Self> {
94 gethostname()
95 .into_string()
96 .map_err(|raw| {
97 Error::new(
98 ErrorKind::InvalidData,
99 format!("gethostname() returned invalid unicode data: {raw:?}"),
100 )
101 })
102 .map(|hostname| Environment { base_url, hostname })
103 }
104
105 pub fn for_local_directory<P: AsRef<Path>>(base_dir: &P) -> Result<Self> {
112 Url::from_directory_path(base_dir)
113 .map_err(|_| {
114 Error::new(
115 ErrorKind::InvalidInput,
116 format!(
117 "Base directory {} must be an absolute path",
118 base_dir.as_ref().display()
119 ),
120 )
121 })
122 .and_then(Self::for_localhost)
123 }
124}
125
126#[instrument(level = "debug", skip_all, fields(environment.hostname = environment.hostname.as_str(), environment.base_url = &environment.base_url.as_str()))]
135pub fn push_tty<'a, 'e, W, I>(
136 settings: &Settings,
137 environment: &Environment,
138 resource_handler: &dyn ResourceUrlHandler,
139 writer: &'a mut W,
140 mut events: I,
141) -> Result<()>
142where
143 I: Iterator<Item = Event<'e>>,
144 W: Write,
145{
146 use render::*;
147 let StateAndData(final_state, final_data) = events.try_fold(
148 StateAndData(State::default(), StateData::default()),
149 |StateAndData(state, data), event| {
150 write_event(
151 writer,
152 settings,
153 environment,
154 &resource_handler,
155 state,
156 data,
157 event,
158 )
159 },
160 )?;
161 finish(writer, settings, environment, final_state, final_data)
162}
163
164#[cfg(test)]
165mod tests {
166 use pulldown_cmark::Parser;
167
168 use crate::resources::NoopResourceHandler;
169
170 use super::*;
171
172 fn render_string(input: &str, settings: &Settings) -> Result<String> {
173 let source = Parser::new(input);
174 let mut sink = Vec::new();
175 let env =
176 Environment::for_local_directory(&std::env::current_dir().expect("Working directory"))?;
177 push_tty(settings, &env, &NoopResourceHandler, &mut sink, source)?;
178 Ok(String::from_utf8_lossy(&sink).into())
179 }
180
181 fn render_string_dumb(markup: &str) -> Result<String> {
182 render_string(
183 markup,
184 &Settings {
185 syntax_set: &SyntaxSet::default(),
186 terminal_capabilities: TerminalProgram::Dumb.capabilities(),
187 terminal_size: TerminalSize::default(),
188 theme: Theme::default(),
189 syntax_theme: None,
190 },
191 )
192 }
193
194 mod layout {
195 use super::render_string_dumb;
196 use insta::assert_snapshot;
197
198 #[test]
199 #[allow(non_snake_case)]
200 fn GH_49_format_no_colour_simple() {
201 assert_eq!(
202 render_string_dumb("_lorem_ **ipsum** dolor **sit** _amet_").unwrap(),
203 "lorem ipsum dolor sit amet\n",
204 )
205 }
206
207 #[test]
208 fn begins_with_rule() {
209 assert_snapshot!(render_string_dumb("----").unwrap())
210 }
211
212 #[test]
213 fn begins_with_block_quote() {
214 assert_snapshot!(render_string_dumb("> Hello World").unwrap());
215 }
216
217 #[test]
218 fn rule_in_block_quote() {
219 assert_snapshot!(render_string_dumb(
220 "> Hello World
221
222> ----"
223 )
224 .unwrap());
225 }
226
227 #[test]
228 fn heading_in_block_quote() {
229 assert_snapshot!(render_string_dumb(
230 "> Hello World
231
232> # Hello World"
233 )
234 .unwrap())
235 }
236
237 #[test]
238 fn heading_levels() {
239 assert_snapshot!(render_string_dumb(
240 "
241# First
242
243## Second
244
245### Third"
246 )
247 .unwrap())
248 }
249
250 #[test]
251 fn autolink_creates_no_reference() {
252 assert_eq!(
253 render_string_dumb("Hello <http://example.com>").unwrap(),
254 "Hello http://example.com\n"
255 )
256 }
257
258 #[test]
259 fn flush_ref_links_before_toplevel_heading() {
260 assert_snapshot!(render_string_dumb(
261 "> Hello [World](http://example.com/world)
262
263> # No refs before this headline
264
265# But before this"
266 )
267 .unwrap())
268 }
269
270 #[test]
271 fn flush_ref_links_at_end() {
272 assert_snapshot!(render_string_dumb(
273 "Hello [World](http://example.com/world)
274
275# Headline
276
277Hello [Donald](http://example.com/Donald)"
278 )
279 .unwrap())
280 }
281 }
282
283 mod disabled_features {
284 use insta::assert_snapshot;
285
286 use super::render_string_dumb;
287
288 #[test]
289 #[allow(non_snake_case)]
290 fn GH_155_do_not_choke_on_footnotes() {
291 assert_snapshot!(render_string_dumb(
292 "A footnote [^1]
293
294[^1: We do not support footnotes."
295 )
296 .unwrap())
297 }
298 }
299}