#
mdman/
lib.rs

    1//! mdman markdown to man converter.
    2//!
    3//! > This crate is maintained by the Cargo team, primarily for use by Cargo
    4//! > and not intended for external use (except as a transitive dependency). This
    5//! > crate may make major changes to its APIs or be deprecated without warning.
    6
    7use anyhow::{Context, Error, bail};
    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
    22/// Mapping of `(name, section)` of a man page to a URL.
    23pub type ManMap = HashMap<(String, u8), String>;
    24
    25/// A man section.
    26pub type Section = u8;
    27
    28/// The output formats supported by mdman.
    29#[derive(Copy, Clone)]
    30pub enum Format {
    31    Man,
    32    Md,
    33    Text,
    34}
    35
    36impl Format {
    37    /// The filename extension for the format.
    38    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
    47/// Converts the handlebars markdown file at the given path into the given
    48/// format, returning the translated result.
    49pub 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    // pulldown-cmark can behave a little differently with Windows newlines,
    62    // just normalize it.
    63    let expanded = expanded.replace("\r\n", "\n");
    64    formatter.render(&expanded)
    65}
    66
    67/// Pulldown-cmark iterator yielding an `(event, range)` tuple.
    68type EventIter<'a> = Box<dyn Iterator<Item = (Event<'a>, Range<usize>)> + 'a>;
    69
    70/// Creates a new markdown parser with the given input.
    71pub(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    // Translate all links to include the base url.
    80    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            // Absolute URL or page-relative anchor doesn't need to be translated.
    105            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}