header_parsing/lib.rs
1#![deny(missing_docs)]
2
3//! This crate provides functionality for parsing markdown style headers.
4
5/// Represents the changes to be made to a path.
6///
7/// This is generated when a header is found.
8/// It's still possible to access the previous path of headers before applying the changes.
9#[must_use]
10pub struct PathChanges<'a> {
11 drop: usize,
12 /// The header name that will be added.
13 pub header: Box<str>,
14 /// The path to which changes will be applied.
15 pub path: &'a mut Vec<Box<str>>,
16}
17
18impl PathChanges<'_> {
19 /// The nesting level of the found header, starting at `0` for a top level header (`#`).
20 ///
21 /// This is available before [`apply`](Self::apply) and equals the length the path will have
22 /// after applying, minus one.
23 pub fn level(&self) -> usize {
24 self.path.len() - self.drop
25 }
26
27 /// Applies the changes to the path and returns the [`level`](Self::level) of the header.
28 pub fn apply(self) -> usize {
29 let Self { drop, header, path } = self;
30
31 for _ in 0..drop {
32 path.pop();
33 }
34
35 path.push(header);
36
37 path.len() - 1
38 }
39}
40
41/// Indicates that a subheader was found without a corresponding header.
42pub struct SubheaderWithoutHeader;
43
44/// Parses a header from a line of text.
45///
46/// # Arguments
47///
48/// * `path`: A list of headers including the current header.
49/// * `line`: The line of text to parse.
50///
51/// # Returns
52///
53/// If the line is not a header, returns `None`.
54/// If the line is a valid header, returns the `PathChanges` which have to be used to update the path.
55/// If the header is a subheader without a corresponding header, an error is returned.
56pub fn parse_header<'a>(
57 path: &'a mut Vec<Box<str>>,
58 line: &str,
59) -> Option<Result<PathChanges<'a>, SubheaderWithoutHeader>> {
60 let mut start = 0;
61
62 let mut chars = line.chars();
63 while Some('#') == chars.next() {
64 start += 1;
65 }
66
67 if start == 0 {
68 return None;
69 }
70
71 let level = start - 1;
72
73 let len = path.len();
74
75 Some(if len < level {
76 Err(SubheaderWithoutHeader)
77 } else {
78 Ok(PathChanges {
79 drop: len - level,
80 header: line[start..].trim().into(),
81 path,
82 })
83 })
84}
85
86/// The classification of a single line by a [`Parser`].
87pub enum Line<'a> {
88 /// The line was a header at the given [`level`](PathChanges::level) (`0` for `#`).
89 ///
90 /// The current path of headers is available through [`Parser::path`].
91 Header {
92 /// The nesting level of the header, starting at `0`.
93 level: usize,
94 },
95 /// The line was not a header. It is passed through unchanged.
96 Content(&'a str),
97}
98
99/// A stateful line parser that tracks the current path of headers.
100///
101/// This wraps [`parse_header`] and applies the changes automatically, so consumers only need to
102/// match on [`Line`] instead of tracking the path themselves.
103#[derive(Default)]
104pub struct Parser {
105 path: Vec<Box<str>>,
106}
107
108impl Parser {
109 /// Creates a parser with an empty path.
110 pub fn new() -> Self {
111 Self::default()
112 }
113
114 /// The current path of headers, outermost first.
115 pub fn path(&self) -> &[Box<str>] {
116 &self.path
117 }
118
119 /// Classifies a single line.
120 ///
121 /// On a header, the path is updated before returning and [`path`](Self::path) reflects the new
122 /// state. Returns an error if the header is a subheader without a corresponding header.
123 pub fn line<'a>(&mut self, line: &'a str) -> Result<Line<'a>, SubheaderWithoutHeader> {
124 match parse_header(&mut self.path, line) {
125 None => Ok(Line::Content(line)),
126 Some(changes) => changes.map(|changes| Line::Header {
127 level: changes.apply(),
128 }),
129 }
130 }
131}
132
133#[cfg(test)]
134mod tests {
135 use super::{Line, Parser};
136
137 #[test]
138 fn dedent_reports_correct_level() {
139 let mut parser = Parser::new();
140 let levels: Vec<usize> = ["# A", "## B", "# C"]
141 .into_iter()
142 .map(|line| match parser.line(line) {
143 Ok(Line::Header { level }) => level,
144 _ => panic!("expected header"),
145 })
146 .collect();
147
148 assert_eq!(levels, [0, 1, 0]);
149 assert_eq!(parser.path(), &["C".into()]);
150 }
151}