1use crate::maskfile::*;
2use pulldown_cmark::Event::{Code, End, InlineHtml, Start, Text};
3use pulldown_cmark::{Options, Parser, Tag};
4
5pub fn parse(maskfile_contents: String) -> Maskfile {
6 let parser = create_markdown_parser(&maskfile_contents);
7 let mut commands = vec![];
8 let mut current_command = Command::new(1);
9 let mut current_option_flag = NamedFlag::new();
10 let mut text = "".to_string();
11 let mut list_level = 0;
12
13 for event in parser {
14 match event {
15 Start(tag) => {
16 match tag {
17 Tag::Header(heading_level) => {
18 if heading_level > 1 {
21 commands.push(current_command.build());
22 } else if heading_level == 1 && commands.len() > 0 {
23 break;
25 }
26 current_command = Command::new(heading_level as u8);
27 }
28 #[cfg(not(windows))]
29 Tag::CodeBlock(lang_code) => {
30 if lang_code.to_string() != "powershell"
31 && lang_code.to_string() != "batch"
32 && lang_code.to_string() != "cmd"
33 {
34 if let Some(s) = &mut current_command.script {
35 s.executor = lang_code.to_string();
36 }
37 }
38 }
39 #[cfg(windows)]
40 Tag::CodeBlock(lang_code) => {
41 if let Some(s) = &mut current_command.script {
42 s.executor = lang_code.to_string();
43 }
44 }
45 Tag::List(_) => {
46 if text == "OPTIONS" || list_level > 0 {
48 list_level += 1;
49 }
50 }
51 _ => (),
52 };
53
54 text = "".to_string();
56 }
57 End(tag) => match tag {
58 Tag::Header(_) => {
59 let (name, required_args, optional_args) =
60 parse_command_name_required_and_optional_args(text.clone());
61 current_command.name = name;
62 current_command.required_args = required_args;
63 current_command.optional_args = optional_args;
64 }
65 Tag::BlockQuote => {
66 current_command.description = text.clone();
67 }
68 #[cfg(not(windows))]
69 Tag::CodeBlock(lang_code) => {
70 if lang_code.to_string() != "powershell"
71 && lang_code.to_string() != "batch"
72 && lang_code.to_string() != "cmd"
73 {
74 if let Some(s) = &mut current_command.script {
75 s.source = text.to_string();
76 }
77 }
78 }
79 #[cfg(windows)]
80 Tag::CodeBlock(_) => {
81 if let Some(s) = &mut current_command.script {
82 s.source = text.to_string();
83 }
84 }
85 Tag::List(_) => {
86 list_level = std::cmp::max(list_level - 1, 0);
88
89 if list_level == 1 {
91 current_command
93 .named_flags
94 .push(current_option_flag.clone());
95 current_option_flag = NamedFlag::new();
96 }
97 }
98 _ => (),
99 },
100 Text(body) => {
101 text += &body.to_string();
102
103 if list_level == 1 {
105 current_option_flag.name = text.clone();
106 }
107 else if list_level == 2 {
109 let mut config_split = text.splitn(2, ":");
110 let param = config_split.next().unwrap_or("").trim();
111 let val = config_split.next().unwrap_or("").trim();
112 match param {
113 "desc" => current_option_flag.description = val.to_string(),
114 "type" => {
115 if val == "string" || val == "number" {
116 current_option_flag.takes_value = true;
117 }
118
119 if val == "number" {
120 current_option_flag.validate_as_number = true;
121 }
122 }
123 "flags" => {
125 let short_and_long_flags: Vec<&str> = val.splitn(2, " ").collect();
126 for flag in short_and_long_flags {
127 if flag.starts_with("--") {
129 let name = flag.split("--").collect::<Vec<&str>>().join("");
130 current_option_flag.long = name;
131 }
132 else if flag.starts_with("-") {
134 let name = flag.get(1..2).unwrap_or("");
136 current_option_flag.short = name.to_string();
137 }
138 }
139 }
140 "choices" => {
141 current_option_flag.choices = val
142 .split(',')
143 .map(|choice| choice.trim().to_owned())
144 .collect();
145 }
146 "required" => {
147 current_option_flag.required = true;
148 }
149 _ => (),
150 };
151 }
152 }
153 InlineHtml(html) => {
154 text += &html.to_string();
155 }
156 Code(inline_code) => {
157 text += &format!("`{}`", inline_code);
158 }
159 _ => (),
160 };
161 }
162
163 commands.push(current_command.build());
165
166 let all = treeify_commands(commands);
168 let root_command = all.first().expect("root command must exist");
169
170 Maskfile {
171 title: root_command.name.clone(),
172 description: root_command.description.clone(),
173 commands: root_command.subcommands.clone(),
174 }
175}
176
177fn create_markdown_parser<'a>(maskfile_contents: &'a String) -> Parser<'a> {
178 let mut options = Options::empty();
181 options.insert(Options::ENABLE_STRIKETHROUGH);
182 let parser = Parser::new_ext(&maskfile_contents, options);
183 parser
184}
185
186fn treeify_commands(commands: Vec<Command>) -> Vec<Command> {
187 let mut command_tree = vec![];
188 let mut current_command = commands.first().expect("command should exist").clone();
189 let num_commands = commands.len();
190
191 for i in 0..num_commands {
192 let mut c = commands[i].clone();
193
194 if c.level > current_command.level {
196 if c.name.starts_with(¤t_command.name) {
197 c.name = c
199 .name
200 .strip_prefix(¤t_command.name)
201 .unwrap()
202 .trim()
203 .to_string();
204 }
205 current_command.subcommands.push(c);
206 }
207 else if c.level == current_command.level {
209 if i > 0 {
211 command_tree.push(current_command);
213 current_command = c;
214 }
215 }
216 }
217
218 command_tree.push(current_command);
220
221 for c in &mut command_tree {
223 if !c.subcommands.is_empty() {
224 c.subcommands = treeify_commands(c.subcommands.clone());
225 }
226 }
227
228 command_tree.retain(|c| c.script.is_some() || !c.subcommands.is_empty() || c.level == 1);
231
232 command_tree
233}
234
235fn parse_command_name_required_and_optional_args(
236 text: String,
237) -> (String, Vec<RequiredArg>, Vec<OptionalArg>) {
238 let split_idx = match text.find(|c| c == '(' || c == '[') {
240 Some(idx) => idx,
241 None => return (text.trim().to_string(), vec![], vec![]),
242 };
243
244 let (name, args) = text.split_at(split_idx);
245 let name = name.trim().to_string();
246
247 let required_args = args
249 .split(|c| c == '(' || c == ')')
250 .filter_map(|arg| match arg.trim() {
251 a if !a.is_empty() && !a.contains('[') => Some(RequiredArg::new(a.trim().to_string())),
252 _ => None,
253 })
254 .collect();
255
256 let optional_args = args
258 .split(|c| c == '[' || c == ']')
259 .filter_map(|arg| match arg.trim() {
260 a if !a.is_empty() && !a.contains('(') => Some(OptionalArg::new(a.trim().to_string())),
261 _ => None,
262 })
263 .collect();
264
265 (name, required_args, optional_args)
266}
267
268#[cfg(test)]
269const TEST_MASKFILE: &str = r#"
270# Document Title
271
272This is an example maskfile for the tests below.
273
274## serve (port)
275
276> Serve the app on the `port`
277
278~~~bash
279echo "Serving on port $port"
280~~~
281
282## node (name)
283
284> An example node script
285
286Valid lang codes: js, javascript
287
288```js
289const { name } = process.env;
290console.log(`Hello, ${name}!`);
291```
292
293## parent
294### parent subcommand
295> This is a subcommand
296
297~~~bash
298echo hey
299~~~
300
301## no_script
302
303This command has no source/script.
304
305## multi (required) [optional]
306
307> Example with optional args
308
309~~~bash
310if ! [ -z "$optional" ]; then
311 echo "This is optional - $optional"
312fi
313
314echo "This is required - $required"
315~~~
316"#;
317
318#[cfg(test)]
319mod parse {
320 use super::*;
321 use serde_json::json;
322
323 #[test]
324 fn parses_the_maskfile_structure() {
325 let maskfile = parse(TEST_MASKFILE.to_string());
326
327 let verbose_flag = json!({
328 "name": "verbose",
329 "description": "Sets the level of verbosity",
330 "short": "v",
331 "long": "verbose",
332 "multiple": false,
333 "takes_value": false,
334 "required": false,
335 "validate_as_number": false,
336 "choices": [],
337 });
338
339 assert_eq!(
340 json!({
341 "title": "Document Title",
342 "description": "",
343 "commands": [
344 {
345 "level": 2,
346 "name": "serve",
347 "description": "Serve the app on the `port`",
348 "script": {
349 "executor": "bash",
350 "source": "echo \"Serving on port $port\"\n",
351 },
352 "subcommands": [],
353 "required_args": [
354 {
355 "name": "port"
356 }
357 ],
358 "optional_args": [],
359 "named_flags": [verbose_flag],
360 },
361 {
362 "level": 2,
363 "name": "node",
364 "description": "An example node script",
365 "script": {
366 "executor": "js",
367 "source": "const { name } = process.env;\nconsole.log(`Hello, ${name}!`);\n",
368 },
369 "subcommands": [],
370 "required_args": [
371 {
372 "name": "name"
373 }
374 ],
375 "optional_args": [],
376 "named_flags": [verbose_flag],
377 },
378 {
379 "level": 2,
380 "name": "parent",
381 "description": "",
382 "script": null,
383 "subcommands": [
384 {
385 "level": 3,
386 "name": "subcommand",
387 "description": "This is a subcommand",
388 "script": {
389 "executor": "bash",
390 "source": "echo hey\n",
391 },
392 "subcommands": [],
393 "optional_args": [],
394 "required_args": [],
395 "named_flags": [verbose_flag],
396 }
397 ],
398 "required_args": [],
399 "optional_args": [],
400 "named_flags": [],
401 },
402 {
403 "level": 2,
404 "name": "multi",
405 "description": "Example with optional args",
406 "script": {
407 "executor": "bash",
408 "source": "if ! [ -z \"$optional\" ]; then\n echo \"This is optional - $optional\"\nfi\n\necho \"This is required - $required\"\n",
409 },
410 "subcommands": [],
411 "required_args": [{ "name": "required" }],
412 "optional_args": [{ "name": "optional" }],
413 "named_flags": [verbose_flag],
414 }
415 ]
416 }),
417 maskfile.to_json().expect("should have serialized to json")
418 );
419 }
420}