header-parsing 0.3.2

Simplifies parsing the headers of markdown inspired file formats
Documentation
#![deny(missing_docs)]

//! This crate provides functionality for parsing markdown style headers.

/// Represents the changes to be made to a path.
///
/// This is generated when a header is found.
/// It's still possible to access the previous path of headers before applying the changes.
#[must_use]
pub struct PathChanges<'a> {
    drop: usize,
    /// The header name that will be added.
    pub header: Box<str>,
    /// The path to which changes will be applied.
    pub path: &'a mut Vec<Box<str>>,
}

impl PathChanges<'_> {
    /// The nesting level of the found header, starting at `0` for a top level header (`#`).
    ///
    /// This is available before [`apply`](Self::apply) and equals the length the path will have
    /// after applying, minus one.
    pub fn level(&self) -> usize {
        self.path.len() - self.drop
    }

    /// Applies the changes to the path and returns the [`level`](Self::level) of the header.
    pub fn apply(self) -> usize {
        let Self { drop, header, path } = self;

        for _ in 0..drop {
            path.pop();
        }

        path.push(header);

        path.len() - 1
    }
}

/// Indicates that a subheader was found without a corresponding header.
pub struct SubheaderWithoutHeader;

/// Parses a header from a line of text.
///
/// # Arguments
///
/// * `path`: A list of headers including the current header.
/// * `line`: The line of text to parse.
///
/// # Returns
///
/// If the line is not a header, returns `None`.
/// If the line is a valid header, returns the `PathChanges` which have to be used to update the path.
/// If the header is a subheader without a corresponding header, an error is returned.
pub fn parse_header<'a>(
    path: &'a mut Vec<Box<str>>,
    line: &str,
) -> Option<Result<PathChanges<'a>, SubheaderWithoutHeader>> {
    let mut start = 0;

    let mut chars = line.chars();
    while Some('#') == chars.next() {
        start += 1;
    }

    if start == 0 {
        return None;
    }

    let level = start - 1;

    let len = path.len();

    Some(if len < level {
        Err(SubheaderWithoutHeader)
    } else {
        Ok(PathChanges {
            drop: len - level,
            header: line[start..].trim().into(),
            path,
        })
    })
}

/// The classification of a single line by a [`Parser`].
pub enum Line<'a> {
    /// The line was a header at the given [`level`](PathChanges::level) (`0` for `#`).
    ///
    /// The current path of headers is available through [`Parser::path`].
    Header {
        /// The nesting level of the header, starting at `0`.
        level: usize,
    },
    /// The line was not a header. It is passed through unchanged.
    Content(&'a str),
}

/// A stateful line parser that tracks the current path of headers.
///
/// This wraps [`parse_header`] and applies the changes automatically, so consumers only need to
/// match on [`Line`] instead of tracking the path themselves.
#[derive(Default)]
pub struct Parser {
    path: Vec<Box<str>>,
}

impl Parser {
    /// Creates a parser with an empty path.
    pub fn new() -> Self {
        Self::default()
    }

    /// The current path of headers, outermost first.
    pub fn path(&self) -> &[Box<str>] {
        &self.path
    }

    /// Classifies a single line.
    ///
    /// On a header, the path is updated before returning and [`path`](Self::path) reflects the new
    /// state. Returns an error if the header is a subheader without a corresponding header.
    pub fn line<'a>(&mut self, line: &'a str) -> Result<Line<'a>, SubheaderWithoutHeader> {
        match parse_header(&mut self.path, line) {
            None => Ok(Line::Content(line)),
            Some(changes) => changes.map(|changes| Line::Header {
                level: changes.apply(),
            }),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::{Line, Parser};

    #[test]
    fn dedent_reports_correct_level() {
        let mut parser = Parser::new();
        let levels: Vec<usize> = ["# A", "## B", "# C"]
            .into_iter()
            .map(|line| match parser.line(line) {
                Ok(Line::Header { level }) => level,
                _ => panic!("expected header"),
            })
            .collect();

        assert_eq!(levels, [0, 1, 0]);
        assert_eq!(parser.path(), &["C".into()]);
    }
}