mkhelp/
lib.rs

1use std::{collections::VecDeque, mem, str::FromStr};
2
3const PREFIX: &str = "## ";
4
5fn discard_doc(lines: &mut VecDeque<&str>) {
6    while let Some(line) = lines.front() {
7        if !line.starts_with(PREFIX) && *line != "##" {
8            return;
9        }
10        lines.pop_front().unwrap();
11    }
12}
13
14fn discard_non_doc(lines: &mut VecDeque<&str>) {
15    while let Some(line) = lines.front() {
16        if line.starts_with(PREFIX) {
17            return;
18        }
19        lines.pop_front().unwrap();
20    }
21}
22
23#[derive(Debug)]
24pub struct Document {
25    modules: Vec<Module>,
26}
27
28impl FromStr for Document {
29    type Err = &'static str;
30
31    fn from_str(s: &str) -> Result<Self, Self::Err> {
32        let mut lines = s.lines().collect::<VecDeque<_>>();
33        Ok(Self {
34            modules: Module::take_all(&mut lines),
35        })
36    }
37}
38
39impl Document {
40    fn push_targets_help(s: &mut String, targets: &[Target]) {
41        let Some(width) = targets.iter().map(|t| t.name.len()).max() else {
42            return;
43        };
44        for target in targets {
45            s.push_str(&format!(
46                "{:>width$}: {}\n",
47                target.name,
48                target.summary,
49                width = width + 1
50            ));
51        }
52    }
53
54    /// Format the document as a help text for displaying in a CLI.
55    pub fn help(&self) -> String {
56        let Self { modules } = self;
57        let mut s = String::new();
58        let mut first = true;
59        for module in modules {
60            if !module.targets.is_empty() {
61                if !mem::replace(&mut first, false) {
62                    s.push('\n');
63                }
64                s.push_str(&module.name);
65                s.push_str(":\n");
66                Self::push_targets_help(&mut s, &module.targets);
67            }
68            for submodule in &module.submodules {
69                if !submodule.targets.is_empty() {
70                    if !mem::replace(&mut first, false) {
71                        s.push('\n');
72                    }
73                    s.push_str(&submodule.name);
74                    s.push_str(":\n");
75                    Self::push_targets_help(&mut s, &submodule.targets);
76                }
77            }
78        }
79        s
80    }
81}
82
83#[derive(Debug)]
84struct Module {
85    name: String,
86    targets: Vec<Target>,
87    submodules: Vec<Submodule>,
88}
89
90impl Module {
91    fn take_all(lines: &mut VecDeque<&str>) -> Vec<Self> {
92        let mut modules = Vec::new();
93        while let Some(module) = Self::take_one(lines) {
94            modules.push(module);
95        }
96        modules
97    }
98
99    fn take_one(lines: &mut VecDeque<&str>) -> Option<Self> {
100        discard_non_doc(lines);
101        let name = lines.front()?.strip_prefix(PREFIX)?;
102        let underscore = lines.get(1)?.strip_prefix(PREFIX)?;
103        if !underscore.chars().all(|c| c == '=') {
104            debug_assert_eq!(underscore.len(), name.len());
105            return None;
106        }
107        discard_doc(lines);
108
109        let targets = Target::take_all(lines);
110        let submodules = Submodule::take_all(lines);
111
112        Some(Self {
113            name: name.to_string(),
114            targets,
115            submodules,
116        })
117    }
118}
119
120#[derive(Debug)]
121struct Submodule {
122    name: String,
123    targets: Vec<Target>,
124}
125
126impl Submodule {
127    fn take_all(lines: &mut VecDeque<&str>) -> Vec<Self> {
128        let mut submodules = Vec::new();
129        while let Some(submodule) = Self::take_one(lines) {
130            submodules.push(submodule);
131        }
132        submodules
133    }
134
135    fn take_one(lines: &mut VecDeque<&str>) -> Option<Self> {
136        discard_non_doc(lines);
137        let name = lines.front()?.strip_prefix(PREFIX)?;
138        let underscore = lines.get(1)?.strip_prefix(PREFIX)?;
139        if !underscore.chars().all(|c| c == '-') {
140            debug_assert_eq!(underscore.len(), name.len());
141            return None;
142        }
143        discard_doc(lines);
144
145        let targets = Target::take_all(lines);
146
147        Some(Self {
148            name: name.to_string(),
149            targets,
150        })
151    }
152}
153
154#[derive(Debug)]
155struct Target {
156    name: String,
157    summary: String,
158}
159
160impl Target {
161    fn title_case(s: &str) -> String {
162        let s = s.to_string();
163        let mut chars = s.chars().collect::<Vec<_>>();
164        if let Some(c) = chars.get_mut(0) {
165            c.make_ascii_uppercase();
166        }
167        chars.into_iter().collect()
168    }
169
170    fn take_one(lines: &mut VecDeque<&str>) -> Option<Self> {
171        discard_non_doc(lines);
172        let summary = lines.front()?.strip_prefix(PREFIX)?;
173        if let Some(underscore) = lines.get(1).and_then(|s| s.strip_prefix(PREFIX)) {
174            if (underscore.chars().all(|c| c == '=') || underscore.chars().all(|c| c == '-'))
175                && !underscore.is_empty()
176            {
177                return None;
178            }
179        }
180        discard_doc(lines);
181        let name = lines.front()?.split_once(':')?.0.to_string();
182
183        Some(Target {
184            summary: if summary == "_" {
185                Self::title_case(&name).replace('_', " ")
186            } else {
187                summary.to_string()
188            },
189            name,
190        })
191    }
192
193    fn take_all(lines: &mut VecDeque<&str>) -> Vec<Self> {
194        let mut targets = Vec::new();
195        while let Some(target) = Target::take_one(lines) {
196            targets.push(target);
197        }
198        targets
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use crate::Document;
205
206    #[test]
207    fn output_is_correct_for_this_project() {
208        let expected = r#"Verbs:
209 help: Print help message
210
211Checks:
212    check_all: Run all checks
213 check_format: Check format
214   check_lint: Check lint
215   check_docs: Check that documentation can be built
216  check_tests: Check that unit tests pass
217
218Fixes:
219 fix_format: Fix format
220   fix_lint: Fix lint
221"#;
222        let doc: Document = std::fs::read_to_string("Makefile")
223            .unwrap()
224            .parse()
225            .unwrap();
226        let actual = doc.help();
227        assert_eq!(actual, expected);
228    }
229}