1#![allow(clippy::disallowed_types)]
7
8use anyhow::{Context, Error, bail};
9use pulldown_cmark::{CowStr, Event, LinkType, Options, Parser, Tag, TagEnd};
10use std::collections::HashMap;
11use std::fs;
12use std::io::{self, BufRead};
13use std::ops::Range;
14use std::path::Path;
15use url::Url;
16
17mod format;
18mod hbs;
19mod util;
20
21use format::Formatter;
22
23pub type ManMap = HashMap<(String, u8), String>;
25
26pub type Section = u8;
28
29#[derive(Copy, Clone)]
31pub enum Format {
32 Man,
33 Md,
34 Text,
35}
36
37impl Format {
38 pub fn extension(&self, section: Section) -> String {
40 match self {
41 Format::Man => section.to_string(),
42 Format::Md => "md".to_string(),
43 Format::Text => "txt".to_string(),
44 }
45 }
46}
47
48pub fn convert(
51 file: &Path,
52 format: Format,
53 url: Option<Url>,
54 man_map: ManMap,
55) -> Result<String, Error> {
56 let formatter: Box<dyn Formatter + Send + Sync> = match format {
57 Format::Man => Box::new(format::man::ManFormatter::new(url)),
58 Format::Md => Box::new(format::md::MdFormatter::new(man_map)),
59 Format::Text => Box::new(format::text::TextFormatter::new(url)),
60 };
61 let expanded = hbs::expand(file, &*formatter)?;
62 let expanded = expanded.replace("\r\n", "\n");
65 formatter.render(&expanded)
66}
67
68type EventIter<'a> = Box<dyn Iterator<Item = (Event<'a>, Range<usize>)> + 'a>;
70
71pub(crate) fn md_parser(input: &str, url: Option<Url>) -> EventIter<'_> {
73 let mut options = Options::empty();
74 options.insert(Options::ENABLE_TABLES);
75 options.insert(Options::ENABLE_FOOTNOTES);
76 options.insert(Options::ENABLE_STRIKETHROUGH);
77 options.insert(Options::ENABLE_SMART_PUNCTUATION);
78 let parser = Parser::new_ext(input, options);
79 let parser = parser.into_offset_iter();
80 let parser = parser.map(move |(event, range)| match event {
82 Event::Start(Tag::Link {
83 link_type,
84 dest_url,
85 title,
86 id,
87 }) if !matches!(link_type, LinkType::Email) => (
88 Event::Start(Tag::Link {
89 link_type,
90 dest_url: join_url(url.as_ref(), dest_url),
91 title,
92 id,
93 }),
94 range,
95 ),
96 Event::End(TagEnd::Link) => (Event::End(TagEnd::Link), range),
97 _ => (event, range),
98 });
99 Box::new(parser)
100}
101
102fn join_url<'a>(base: Option<&Url>, dest: CowStr<'a>) -> CowStr<'a> {
103 match base {
104 Some(base_url) => {
105 if dest.contains(':') || dest.starts_with('#') {
107 dest
108 } else {
109 let joined = base_url.join(&dest).unwrap_or_else(|e| {
110 panic!("failed to join URL `{}` to `{}`: {}", dest, base_url, e)
111 });
112 String::from(joined).into()
113 }
114 }
115 None => dest,
116 }
117}
118
119pub fn extract_section(file: &Path) -> Result<Section, Error> {
120 let f = fs::File::open(file).with_context(|| format!("could not open `{}`", file.display()))?;
121 let mut f = io::BufReader::new(f);
122 let mut line = String::new();
123 f.read_line(&mut line)?;
124 if !line.starts_with("# ") {
125 bail!("expected input file to start with # header");
126 }
127 let (_name, section) = util::parse_name_and_section(&line[2..].trim()).with_context(|| {
128 format!(
129 "expected input file to have header with the format `# command-name(1)`, found: `{}`",
130 line
131 )
132 })?;
133 Ok(section)
134}