mdman/
lib.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
//! mdman markdown to man converter.
//!
//! > This crate is maintained by the Cargo team, primarily for use by Cargo
//! > and not intended for external use (except as a transitive dependency). This
//! > crate may make major changes to its APIs or be deprecated without warning.

use anyhow::{bail, Context, Error};
use pulldown_cmark::{CowStr, Event, LinkType, Options, Parser, Tag, TagEnd};
use std::collections::HashMap;
use std::fs;
use std::io::{self, BufRead};
use std::ops::Range;
use std::path::Path;
use url::Url;

mod format;
mod hbs;
mod util;

use format::Formatter;

/// Mapping of `(name, section)` of a man page to a URL.
pub type ManMap = HashMap<(String, u8), String>;

/// A man section.
pub type Section = u8;

/// The output formats supported by mdman.
#[derive(Copy, Clone)]
pub enum Format {
    Man,
    Md,
    Text,
}

impl Format {
    /// The filename extension for the format.
    pub fn extension(&self, section: Section) -> String {
        match self {
            Format::Man => section.to_string(),
            Format::Md => "md".to_string(),
            Format::Text => "txt".to_string(),
        }
    }
}

/// Converts the handlebars markdown file at the given path into the given
/// format, returning the translated result.
pub fn convert(
    file: &Path,
    format: Format,
    url: Option<Url>,
    man_map: ManMap,
) -> Result<String, Error> {
    let formatter: Box<dyn Formatter + Send + Sync> = match format {
        Format::Man => Box::new(format::man::ManFormatter::new(url)),
        Format::Md => Box::new(format::md::MdFormatter::new(man_map)),
        Format::Text => Box::new(format::text::TextFormatter::new(url)),
    };
    let expanded = hbs::expand(file, &*formatter)?;
    // pulldown-cmark can behave a little differently with Windows newlines,
    // just normalize it.
    let expanded = expanded.replace("\r\n", "\n");
    formatter.render(&expanded)
}

/// Pulldown-cmark iterator yielding an `(event, range)` tuple.
type EventIter<'a> = Box<dyn Iterator<Item = (Event<'a>, Range<usize>)> + 'a>;

/// Creates a new markdown parser with the given input.
pub(crate) fn md_parser(input: &str, url: Option<Url>) -> EventIter<'_> {
    let mut options = Options::empty();
    options.insert(Options::ENABLE_TABLES);
    options.insert(Options::ENABLE_FOOTNOTES);
    options.insert(Options::ENABLE_STRIKETHROUGH);
    options.insert(Options::ENABLE_SMART_PUNCTUATION);
    let parser = Parser::new_ext(input, options);
    let parser = parser.into_offset_iter();
    // Translate all links to include the base url.
    let parser = parser.map(move |(event, range)| match event {
        Event::Start(Tag::Link {
            link_type,
            dest_url,
            title,
            id,
        }) if !matches!(link_type, LinkType::Email) => (
            Event::Start(Tag::Link {
                link_type,
                dest_url: join_url(url.as_ref(), dest_url),
                title,
                id,
            }),
            range,
        ),
        Event::End(TagEnd::Link) => (Event::End(TagEnd::Link), range),
        _ => (event, range),
    });
    Box::new(parser)
}

fn join_url<'a>(base: Option<&Url>, dest: CowStr<'a>) -> CowStr<'a> {
    match base {
        Some(base_url) => {
            // Absolute URL or page-relative anchor doesn't need to be translated.
            if dest.contains(':') || dest.starts_with('#') {
                dest
            } else {
                let joined = base_url.join(&dest).unwrap_or_else(|e| {
                    panic!("failed to join URL `{}` to `{}`: {}", dest, base_url, e)
                });
                String::from(joined).into()
            }
        }
        None => dest,
    }
}

pub fn extract_section(file: &Path) -> Result<Section, Error> {
    let f = fs::File::open(file).with_context(|| format!("could not open `{}`", file.display()))?;
    let mut f = io::BufReader::new(f);
    let mut line = String::new();
    f.read_line(&mut line)?;
    if !line.starts_with("# ") {
        bail!("expected input file to start with # header");
    }
    let (_name, section) = util::parse_name_and_section(&line[2..].trim()).with_context(|| {
        format!(
            "expected input file to have header with the format `# command-name(1)`, found: `{}`",
            line
        )
    })?;
    Ok(section)
}