markdown-includes 0.1.1

Include other documents, table of content, or rust-doc in Markdown using a simple template system
Documentation
use anyhow::Result;
use percent_encoding::{percent_encode, CONTROLS};
use serde::Deserialize;
use std::{ops::Range, path::Path, str::FromStr};
use string_sections::SectionSpan;

use super::Fence;

pub struct TocFence {
    conf: TocConfig,
    outer: Range<usize>,
}

impl Fence for TocFence {
    fn is_match(name: &str) -> bool
    where
        Self: Sized,
    {
        let name = name.to_lowercase();
        name.ends_with("toc") || name.ends_with("table-of-contents")
    }

    fn priority(self: &Self) -> u8 {
        10
    }

    fn create(document: &str, section: SectionSpan, _template_dir: &Path) -> Result<Box<Self>>
    where
        Self: Sized,
    {
        let conf = toml::de::from_str(&document[section.inner_range()])?;
        let outer = section.outer_range();
        Ok(Box::new(Self { conf, outer }))
    }

    fn run(self: &Self, document: &mut String) -> Result<()> {
        let mut output = String::new();

        if let Some(ref header) = self.conf.header {
            output.push_str(&header);
            output.push_str("\n\n");
        }

        let headings = find_headings(document);
        let toc = headings
            .iter()
            .filter_map(|h| h.format(&self.conf))
            .collect::<Vec<String>>()
            .join("\n");

        output.push_str(&toc);
        document.replace_range(self.outer.clone(), &output);
        Ok(())
    }
}

#[derive(Deserialize)]
pub struct TocConfig {
    #[serde(default = "default_bullet")]
    pub bullet: String,
    #[serde(default = "default_inline")]
    pub indent: usize,
    pub max_depth: Option<usize>,
    #[serde(default)]
    pub min_depth: usize,
    pub header: Option<String>,
    #[serde(default = "default_link")]
    pub link: bool,
}

fn default_link() -> bool {
    true
}
fn default_inline() -> usize {
    4
}
fn default_bullet() -> String {
    "-".to_string()
}
fn slugify(text: &str) -> String {
    percent_encode(text.replace(" ", "-").to_lowercase().as_bytes(), CONTROLS).to_string()
}

pub fn find_headings(content: &str) -> Vec<Heading> {
    let mut in_fence = false;

    content
        .lines()
        .filter(|line| {
            let was_inside = in_fence;
            line.starts_with("```").then(|| in_fence = !in_fence);
            !(was_inside || in_fence)
        })
        .map(Heading::from_str)
        .filter_map(Result::ok)
        .collect::<Vec<Heading>>()
}

pub struct Heading {
    pub depth: usize,
    pub title: String,
}

impl FromStr for Heading {
    type Err = ();

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let trimmed = s.trim_end();
        if trimmed.starts_with("#") {
            let mut depth = 0usize;
            let title = trimmed
                .chars()
                .skip_while(|c| {
                    if *c == '#' {
                        depth += 1;
                        true
                    } else {
                        false
                    }
                })
                .collect::<String>()
                .trim_start()
                .to_owned();
            Ok(Heading {
                depth: depth - 1,
                title,
            })
        } else {
            Err(())
        }
    }
}

impl Heading {
    pub fn format(&self, config: &TocConfig) -> Option<String> {
        if self.depth >= config.min_depth
            && config.max_depth.map(|d| self.depth <= d).unwrap_or(true)
        {
            let head = format!(
                "{}{} {}",
                " ".repeat(config.indent)
                    .repeat(self.depth - config.min_depth),
                &config.bullet,
                if !config.link {
                    self.title.clone()
                } else {
                    format!("[{}](#{})", &self.title, slugify(&self.title))
                }
            );
            Some(head)
        } else {
            None
        }
    }
}