Skip to main content

pub_just/
argument_parser.rs

1use super::*;
2
3#[allow(clippy::doc_markdown)]
4/// The argument parser is responsible for grouping positional arguments into
5/// argument groups, which consist of a path to a recipe and its arguments.
6///
7/// Argument parsing is substantially complicated by the fact that recipe paths
8/// can be given on the command line as multiple arguments, i.e., "foo" "bar"
9/// baz", or as a single "::"-separated argument.
10///
11/// Error messages produced by the argument parser should use the format of the
12/// recipe path as passed on the command line.
13///
14/// Additionally, if a recipe is specified with a "::"-separated path, extra
15/// components of that path after a valid recipe must not be used as arguments,
16/// whereas arguments after multiple argument path may be used as arguments. As
17/// an example, `foo bar baz` may refer to recipe `foo::bar` with argument
18/// `baz`, but `foo::bar::baz` is an error, since `bar` is a recipe, not a
19/// module.
20pub struct ArgumentParser<'src: 'run, 'run> {
21  arguments: &'run [&'run str],
22  next: usize,
23  root: &'run Justfile<'src>,
24}
25
26#[derive(Debug, PartialEq)]
27pub struct ArgumentGroup<'run> {
28  pub arguments: Vec<&'run str>,
29  pub path: Vec<String>,
30}
31
32impl<'src: 'run, 'run> ArgumentParser<'src, 'run> {
33  pub fn parse_arguments(
34    root: &'run Justfile<'src>,
35    arguments: &'run [&'run str],
36  ) -> RunResult<'src, Vec<ArgumentGroup<'run>>> {
37    let mut groups = Vec::new();
38
39    let mut invocation_parser = Self {
40      arguments,
41      next: 0,
42      root,
43    };
44
45    loop {
46      groups.push(invocation_parser.parse_group()?);
47
48      if invocation_parser.next == arguments.len() {
49        break;
50      }
51    }
52
53    Ok(groups)
54  }
55
56  fn parse_group(&mut self) -> RunResult<'src, ArgumentGroup<'run>> {
57    let (recipe, path) = if let Some(next) = self.next() {
58      if next.contains(':') {
59        let module_path =
60          ModulePath::try_from([next].as_slice()).map_err(|()| Error::UnknownRecipe {
61            recipe: next.into(),
62            suggestion: None,
63          })?;
64        let (recipe, path, _) = self.resolve_recipe(true, &module_path.path)?;
65        self.next += 1;
66        (recipe, path)
67      } else {
68        let (recipe, path, consumed) = self.resolve_recipe(false, self.rest())?;
69        self.next += consumed;
70        (recipe, path)
71      }
72    } else {
73      let (recipe, path, consumed) = self.resolve_recipe(false, self.rest())?;
74      assert_eq!(consumed, 0);
75      (recipe, path)
76    };
77
78    let rest = self.rest();
79
80    let argument_range = recipe.argument_range();
81    let argument_count = cmp::min(rest.len(), recipe.max_arguments());
82    if !argument_range.range_contains(&argument_count) {
83      return Err(Error::ArgumentCountMismatch {
84        recipe: recipe.name(),
85        parameters: recipe.parameters.clone(),
86        found: rest.len(),
87        min: recipe.min_arguments(),
88        max: recipe.max_arguments(),
89      });
90    }
91
92    let arguments = rest[..argument_count].to_vec();
93
94    self.next += argument_count;
95
96    Ok(ArgumentGroup { arguments, path })
97  }
98
99  fn resolve_recipe(
100    &self,
101    module_path: bool,
102    args: &[impl AsRef<str>],
103  ) -> RunResult<'src, (&'run Recipe<'src>, Vec<String>, usize)> {
104    let mut current = self.root;
105    let mut path = Vec::new();
106
107    for (i, arg) in args.iter().enumerate() {
108      let arg = arg.as_ref();
109
110      path.push(arg.to_string());
111
112      if let Some(module) = current.modules.get(arg) {
113        current = module;
114      } else if let Some(recipe) = current.get_recipe(arg) {
115        if module_path && i + 1 < args.len() {
116          return Err(Error::ExpectedSubmoduleButFoundRecipe {
117            path: if module_path {
118              path.join("::")
119            } else {
120              path.join(" ")
121            },
122          });
123        }
124        return Ok((recipe, path, i + 1));
125      } else {
126        if module_path && i + 1 < args.len() {
127          return Err(Error::UnknownSubmodule {
128            path: path.join("::"),
129          });
130        }
131
132        return Err(Error::UnknownRecipe {
133          recipe: if module_path {
134            path.join("::")
135          } else {
136            path.join(" ")
137          },
138          suggestion: current.suggest_recipe(arg),
139        });
140      }
141    }
142
143    if let Some(recipe) = &current.default {
144      recipe.check_can_be_default_recipe()?;
145      path.push(recipe.name().into());
146      Ok((recipe, path, args.len()))
147    } else if current.recipes.is_empty() {
148      Err(Error::NoRecipes)
149    } else {
150      Err(Error::NoDefaultRecipe)
151    }
152  }
153
154  fn next(&self) -> Option<&'run str> {
155    self.arguments.get(self.next).copied()
156  }
157
158  fn rest(&self) -> &[&'run str] {
159    &self.arguments[self.next..]
160  }
161}
162
163#[cfg(test)]
164mod tests {
165  use {super::*, tempfile::TempDir};
166
167  trait TempDirExt {
168    fn write(&self, path: &str, content: &str);
169  }
170
171  impl TempDirExt for TempDir {
172    fn write(&self, path: &str, content: &str) {
173      let path = self.path().join(path);
174      fs::create_dir_all(path.parent().unwrap()).unwrap();
175      fs::write(path, content).unwrap();
176    }
177  }
178
179  #[test]
180  fn single_no_arguments() {
181    let justfile = testing::compile("foo:");
182
183    assert_eq!(
184      ArgumentParser::parse_arguments(&justfile, &["foo"]).unwrap(),
185      vec![ArgumentGroup {
186        path: vec!["foo".into()],
187        arguments: Vec::new()
188      }],
189    );
190  }
191
192  #[test]
193  fn single_with_argument() {
194    let justfile = testing::compile("foo bar:");
195
196    assert_eq!(
197      ArgumentParser::parse_arguments(&justfile, &["foo", "baz"]).unwrap(),
198      vec![ArgumentGroup {
199        path: vec!["foo".into()],
200        arguments: vec!["baz"],
201      }],
202    );
203  }
204
205  #[test]
206  fn single_argument_count_mismatch() {
207    let justfile = testing::compile("foo bar:");
208
209    assert_matches!(
210      ArgumentParser::parse_arguments(&justfile, &["foo"]).unwrap_err(),
211      Error::ArgumentCountMismatch {
212        recipe: "foo",
213        found: 0,
214        min: 1,
215        max: 1,
216        ..
217      },
218    );
219  }
220
221  #[test]
222  fn single_unknown() {
223    let justfile = testing::compile("foo:");
224
225    assert_matches!(
226      ArgumentParser::parse_arguments(&justfile, &["bar"]).unwrap_err(),
227      Error::UnknownRecipe {
228        recipe,
229        suggestion: None
230      } if recipe == "bar",
231    );
232  }
233
234  #[test]
235  fn multiple_unknown() {
236    let justfile = testing::compile("foo:");
237
238    assert_matches!(
239      ArgumentParser::parse_arguments(&justfile, &["bar", "baz"]).unwrap_err(),
240      Error::UnknownRecipe {
241        recipe,
242        suggestion: None
243      } if recipe == "bar",
244    );
245  }
246
247  #[test]
248  fn recipe_in_submodule() {
249    let loader = Loader::new();
250    let tempdir = tempfile::tempdir().unwrap();
251    let path = tempdir.path().join("justfile");
252    fs::write(&path, "mod foo").unwrap();
253    fs::create_dir(tempdir.path().join("foo")).unwrap();
254    fs::write(tempdir.path().join("foo/mod.just"), "bar:").unwrap();
255    let compilation = Compiler::compile(&loader, &path).unwrap();
256
257    assert_eq!(
258      ArgumentParser::parse_arguments(&compilation.justfile, &["foo", "bar"]).unwrap(),
259      vec![ArgumentGroup {
260        path: vec!["foo".into(), "bar".into()],
261        arguments: Vec::new()
262      }],
263    );
264  }
265
266  #[test]
267  fn recipe_in_submodule_unknown() {
268    let loader = Loader::new();
269    let tempdir = tempfile::tempdir().unwrap();
270    let path = tempdir.path().join("justfile");
271    fs::write(&path, "mod foo").unwrap();
272    fs::create_dir(tempdir.path().join("foo")).unwrap();
273    fs::write(tempdir.path().join("foo/mod.just"), "bar:").unwrap();
274    let compilation = Compiler::compile(&loader, &path).unwrap();
275
276    assert_matches!(
277      ArgumentParser::parse_arguments(&compilation.justfile, &["foo", "zzz"]).unwrap_err(),
278      Error::UnknownRecipe {
279        recipe,
280        suggestion: None
281      } if recipe == "foo zzz",
282    );
283  }
284
285  #[test]
286  fn recipe_in_submodule_path_unknown() {
287    let tempdir = tempfile::tempdir().unwrap();
288    tempdir.write("justfile", "mod foo");
289    tempdir.write("foo.just", "bar:");
290
291    let loader = Loader::new();
292    let compilation = Compiler::compile(&loader, &tempdir.path().join("justfile")).unwrap();
293
294    assert_matches!(
295      ArgumentParser::parse_arguments(&compilation.justfile, &["foo::zzz"]).unwrap_err(),
296      Error::UnknownRecipe {
297        recipe,
298        suggestion: None
299      } if recipe == "foo::zzz",
300    );
301  }
302
303  #[test]
304  fn module_path_not_consumed() {
305    let tempdir = tempfile::tempdir().unwrap();
306    tempdir.write("justfile", "mod foo");
307    tempdir.write("foo.just", "bar:");
308
309    let loader = Loader::new();
310    let compilation = Compiler::compile(&loader, &tempdir.path().join("justfile")).unwrap();
311
312    assert_matches!(
313      ArgumentParser::parse_arguments(&compilation.justfile, &["foo::bar::baz"]).unwrap_err(),
314      Error::ExpectedSubmoduleButFoundRecipe {
315        path,
316      } if path == "foo::bar",
317    );
318  }
319
320  #[test]
321  fn no_recipes() {
322    let tempdir = tempfile::tempdir().unwrap();
323    tempdir.write("justfile", "");
324
325    let loader = Loader::new();
326    let compilation = Compiler::compile(&loader, &tempdir.path().join("justfile")).unwrap();
327
328    assert_matches!(
329      ArgumentParser::parse_arguments(&compilation.justfile, &[]).unwrap_err(),
330      Error::NoRecipes,
331    );
332  }
333
334  #[test]
335  fn default_recipe_requires_arguments() {
336    let tempdir = tempfile::tempdir().unwrap();
337    tempdir.write("justfile", "foo bar:");
338
339    let loader = Loader::new();
340    let compilation = Compiler::compile(&loader, &tempdir.path().join("justfile")).unwrap();
341
342    assert_matches!(
343      ArgumentParser::parse_arguments(&compilation.justfile, &[]).unwrap_err(),
344      Error::DefaultRecipeRequiresArguments {
345        recipe: "foo",
346        min_arguments: 1,
347      },
348    );
349  }
350
351  #[test]
352  fn no_default_recipe() {
353    let tempdir = tempfile::tempdir().unwrap();
354    tempdir.write("justfile", "import 'foo.just'");
355    tempdir.write("foo.just", "bar:");
356
357    let loader = Loader::new();
358    let compilation = Compiler::compile(&loader, &tempdir.path().join("justfile")).unwrap();
359
360    assert_matches!(
361      ArgumentParser::parse_arguments(&compilation.justfile, &[]).unwrap_err(),
362      Error::NoDefaultRecipe,
363    );
364  }
365
366  #[test]
367  fn complex_grouping() {
368    let justfile = testing::compile(
369      "
370FOO A B='blarg':
371  echo foo: {{A}} {{B}}
372
373BAR X:
374  echo bar: {{X}}
375
376BAZ +Z:
377  echo baz: {{Z}}
378",
379    );
380
381    assert_eq!(
382      ArgumentParser::parse_arguments(
383        &justfile,
384        &["BAR", "0", "FOO", "1", "2", "BAZ", "3", "4", "5"]
385      )
386      .unwrap(),
387      vec![
388        ArgumentGroup {
389          path: vec!["BAR".into()],
390          arguments: vec!["0"],
391        },
392        ArgumentGroup {
393          path: vec!["FOO".into()],
394          arguments: vec!["1", "2"],
395        },
396        ArgumentGroup {
397          path: vec!["BAZ".into()],
398          arguments: vec!["3", "4", "5"],
399        },
400      ],
401    );
402  }
403}