mask_parser/
parser.rs

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                        // Add the last command before starting a new one.
19                        // Don't add commands for level 1 heading blocks (the title).
20                        if heading_level > 1 {
21                            commands.push(current_command.build());
22                        } else if heading_level == 1 && commands.len() > 0 {
23                            // Found another level 1 heading block, so quit parsing.
24                            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                        // We're in an options list if the current text above it is "OPTIONS"
47                        if text == "OPTIONS" || list_level > 0 {
48                            list_level += 1;
49                        }
50                    }
51                    _ => (),
52                };
53
54                // Reset all state
55                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                    // Don't go lower than zero (for cases where it's a non-OPTIONS list)
87                    list_level = std::cmp::max(list_level - 1, 0);
88
89                    // Must be finished parsing the current option
90                    if list_level == 1 {
91                        // Add the current one to the list and start a new one
92                        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                // Options level 1 is the flag name
104                if list_level == 1 {
105                    current_option_flag.name = text.clone();
106                }
107                // Options level 2 is the flag config
108                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                        // Parse out the short and long flag names
124                        "flags" => {
125                            let short_and_long_flags: Vec<&str> = val.splitn(2, " ").collect();
126                            for flag in short_and_long_flags {
127                                // Must be a long flag name
128                                if flag.starts_with("--") {
129                                    let name = flag.split("--").collect::<Vec<&str>>().join("");
130                                    current_option_flag.long = name;
131                                }
132                                // Must be a short flag name
133                                else if flag.starts_with("-") {
134                                    // Get the single char
135                                    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    // Add the last command
164    commands.push(current_command.build());
165
166    // Convert the flat commands array and to a tree of subcommands based on level
167    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    // Set up options and parser. Strikethroughs are not part of the CommonMark standard
179    // and we therefore must enable it explicitly.
180    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        // This must be a subcommand
195        if c.level > current_command.level {
196            if c.name.starts_with(&current_command.name) {
197                // remove parent command name prefixes from subcommand
198                c.name = c
199                    .name
200                    .strip_prefix(&current_command.name)
201                    .unwrap()
202                    .trim()
203                    .to_string();
204            }
205            current_command.subcommands.push(c);
206        }
207        // This must be a sibling command
208        else if c.level == current_command.level {
209            // Make sure the initial command doesn't skip itself before it finds children
210            if i > 0 {
211                // Found a sibling, so the current command has found all children.
212                command_tree.push(current_command);
213                current_command = c;
214            }
215        }
216    }
217
218    // Adding last command which was not added in the above loop
219    command_tree.push(current_command);
220
221    // Treeify all subcommands recursively
222    for c in &mut command_tree {
223        if !c.subcommands.is_empty() {
224            c.subcommands = treeify_commands(c.subcommands.clone());
225        }
226    }
227
228    // the command or any one of its subcommands must have script to be included in the tree
229    // root level commands must be retained
230    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    // Checks if any args are present and if not, return early
239    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    // Collects (required_args)
248    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    // Collects [optional_args]
257    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}