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