diff --git a/README.md b/README.md index 9d24bb13..4e4248fa 100644 --- a/README.md +++ b/README.md @@ -1032,6 +1032,7 @@ foo: | `export` | boolean | `false` | Export all variables as environment variables. | | `fallback` | boolean | `false` | Search `justfile` in parent directory if the first recipe on the command line is not found. | | `ignore-comments` | boolean | `false` | Ignore recipe lines beginning with `#`. | +| `named-parameters` | boolean | `false` | Using named-parameters feature for all recipes in `justfile`. | | `positional-arguments` | boolean | `false` | Pass positional arguments. | | `quiet` | boolean | `false` | Disable echoing recipe lines before executing. | | `script-interpreter`1.33.0 | `[COMMAND, ARGS…]` | `['sh', '-eu']` | Set command used to invoke recipes with empty `[script]` attribute. | @@ -2151,6 +2152,7 @@ change their behavior. | `[linux]`1.8.0 | recipe | Enable recipe on Linux. | | `[macos]`1.8.0 | recipe | Enable recipe on MacOS. | | `[metadata(METADATA)]`1.42.0 | recipe | Attach `METADATA` to recipe. | +| `[named-parameters]`1.44.0 | recipe | Turn on [named_parameters](#named_parameters) for this recipe. | | `[no-cd]`1.9.0 | recipe | Don't change directory before executing recipe. | | `[no-exit-message]`1.7.0 | recipe | Don't print an error message if recipe fails. | | `[no-quiet]`1.23.0 | recipe | Override globally quiet recipes and always echo out the recipe. | @@ -4013,6 +4015,29 @@ This preserves `just`'s ability to catch variable name typos before running, for example if you were to write `{{argument}}`, but will not do what you want if the value of `argument` contains single quotes. +#### Named Parameters + +The `named-arguments` setting enables python like keyword arguments for recipe calls. +Take for instance the following + +``` just +set named-parameters + +foo default1="changes_often" default2="changes_sometimes" default3="changes_rarely": + echo {{ default1 }} {{ default2 }} {{ default3 }} +``` + +Calling `just foo default2="x"` would normally result in stdout of +`default2=x changes_sometimes changes_rarely`. while with `named-arguments` +one would get `changes_often x changes_rarely`. Notice it is still possible +to have positional parameters such as the recipe: + +``` just +foo a b="default" c=b: + echo {{ a }} {{ b }} {{ c }} +``` + + #### Positional Arguments The `positional-arguments` setting causes all arguments to be passed as diff --git a/shell.nix b/shell.nix new file mode 100644 index 00000000..6102ae95 --- /dev/null +++ b/shell.nix @@ -0,0 +1,20 @@ +let + rust-overlay = builtins.fetchTarball "https://github.com/oxalica/rust-overlay/archive/master.tar.gz"; + pkgs = import { + overlays = [(import rust-overlay)]; + }; + env = { + }; + toolchain = pkgs.rust-bin.fromRustupToolchainFile ./toolchain.toml; +in + pkgs.mkShell { + packages = [ + toolchain + # If the dependencies need system libs, you usually need pkg-config + the lib + pkgs.pkg-config + pkgs.openssl + ]; + + RUST_BACKTRACE = "full"; + TMPDIR = "/tmp"; +} diff --git a/src/attribute.rs b/src/attribute.rs index 2099f650..ef26f122 100644 --- a/src/attribute.rs +++ b/src/attribute.rs @@ -24,6 +24,7 @@ pub(crate) enum Attribute<'src> { Linux, Macos, Metadata(Vec>), + NamedParameters, NoCd, NoExitMessage, NoQuiet, @@ -44,6 +45,7 @@ impl AttributeDiscriminant { | Self::ExitMessage | Self::Linux | Self::Macos + | Self::NamedParameters | Self::NoCd | Self::NoExitMessage | Self::NoQuiet @@ -117,6 +119,7 @@ impl<'src> Attribute<'src> { AttributeDiscriminant::Linux => Self::Linux, AttributeDiscriminant::Macos => Self::Macos, AttributeDiscriminant::Metadata => Self::Metadata(arguments), + AttributeDiscriminant::NamedParameters => Self::NamedParameters, AttributeDiscriminant::NoCd => Self::NoCd, AttributeDiscriminant::NoExitMessage => Self::NoExitMessage, AttributeDiscriminant::NoQuiet => Self::NoQuiet, @@ -186,6 +189,7 @@ impl Display for Attribute<'_> { | Self::ExitMessage | Self::Linux | Self::Macos + | Self::NamedParameters | Self::NoCd | Self::NoExitMessage | Self::NoQuiet diff --git a/src/error.rs b/src/error.rs index a4037f7d..303a0410 100644 --- a/src/error.rs +++ b/src/error.rs @@ -93,6 +93,13 @@ pub(crate) enum Error<'src> { editor: OsString, status: ExitStatus, }, + EvalNamedParameterDuplicate { + parameter: String, + }, + EvalUnknownNamedParameter { + parameter: String, + suggestion: Option>, + }, EvalUnknownVariable { variable: String, suggestion: Option>, @@ -424,6 +431,15 @@ impl ColorDisplay for Error<'_> { let editor = editor.to_string_lossy(); write!(f, "Editor `{editor}` failed: {status}")?; } + EvalNamedParameterDuplicate { parameter } => { + write!(f, "`{parameter}` defined multiple times.")?; + } + EvalUnknownNamedParameter { parameter, suggestion} => { + write!(f, "Recipe does not contain parameter `{parameter}`.")?; + if let Some(suggestion) = suggestion { + write!(f, "\n{suggestion}")?; + } + } EvalUnknownVariable { variable, suggestion} => { write!(f, "Justfile does not contain variable `{variable}`.")?; if let Some(suggestion) = suggestion { diff --git a/src/evaluator.rs b/src/evaluator.rs index d01b2769..bd384015 100644 --- a/src/evaluator.rs +++ b/src/evaluator.rs @@ -342,12 +342,20 @@ impl<'src, 'run> Evaluator<'src, 'run> { parameters: &[Parameter<'src>], recipe: &Recipe<'src>, scope: &'run Scope<'src, 'run>, + use_named_parameters: bool, ) -> RunResult<'src, (Scope<'src, 'run>, Vec)> { let mut evaluator = Self::new(context, is_dependency, scope); let mut positional = Vec::new(); let mut rest = arguments; + let mut defaults = Table::new(); + for parameter in parameters { + if parameter.default.is_some() { + defaults.insert(parameter); + } + } + for parameter in parameters { let value = if rest.is_empty() { if let Some(ref default) = parameter.default { @@ -369,9 +377,24 @@ impl<'src, 'run> Evaluator<'src, 'run> { rest = &[]; value } else { - let value = rest[0].clone(); + let mut value = rest[0].clone(); + if use_named_parameters { + // We stop handling of positional parameters once we hit a named parameter + // if it is escaped we continue treating it as positional but unescape it + if value.starts_with(|c| c == '\'' || c == '"') { + let quote = value.chars().next().unwrap(); + if value.ends_with(quote) && value.len() >= 2 { + value = value[1..value.len() - 1].to_string(); + } else if value.contains('=') { + break; + } + } else if value.contains('=') { + break; + } + } positional.push(value.clone()); rest = &rest[1..]; + defaults.remove(parameter.name.lexeme()); value }; @@ -383,10 +406,71 @@ impl<'src, 'run> Evaluator<'src, 'run> { file_depth: 0, name: parameter.name, private: false, - value, + value: value.clone(), }); } + // invariantly rest is only non-empty if we have named parameters + if !rest.is_empty() { + assert!(use_named_parameters); + for arg in rest { + let mut split = arg.splitn(2, '='); + // name and value should always be present. Earlier static analysis + // ensures once we hit default values no more positional parameters exist + let name = split.next().ok_or_else(|| + Error::Internal { + message: "named parameter missing name".to_string(), + })?; + let value = split.next().ok_or_else(|| Error::Internal { + message: "named parameter missing value".to_string(), + })?; + match defaults.remove(name) { + None => { + if parameters.iter().any(|p| p.name.lexeme() == name) { + return Err(Error::EvalNamedParameterDuplicate { + parameter: name.to_string(), + }); + } + let candidates = parameters + .iter() + .filter(|p| p.default.is_some()) + .map(|p| Suggestion { + name: p.name.lexeme(), + target: None, + }); + let suggestion = Suggestion::find_suggestion(name, candidates); + return Err(Error::EvalUnknownNamedParameter { + parameter: name.to_string(), + suggestion, + }); + }, + Some(parameter) => { + evaluator.scope.bind(Binding { + constant: false, + export: parameter.export, + file_depth: 0, + name: parameter.name, + private: false, + value: value.to_string(), + }); + } + } + } + + for parameter in defaults.values() { + let default = parameter.default.as_ref().expect("Default value expected for parameter"); + let value = evaluator.evaluate_expression(default)?; + evaluator.scope.bind(Binding { + constant: false, + export: parameter.export, + file_depth: 0, + name: parameter.name, + private: false, + value: value, + }); + } + } + Ok((evaluator.scope, positional)) } diff --git a/src/justfile.rs b/src/justfile.rs index 52e91bce..5094bdfb 100644 --- a/src/justfile.rs +++ b/src/justfile.rs @@ -33,19 +33,8 @@ pub(crate) struct Justfile<'src> { } impl<'src> Justfile<'src> { - fn find_suggestion( - input: &str, - candidates: impl Iterator>, - ) -> Option> { - candidates - .map(|suggestion| (edit_distance(input, suggestion.name), suggestion)) - .filter(|(distance, _suggestion)| *distance < 3) - .min_by_key(|(distance, _suggestion)| *distance) - .map(|(_distance, suggestion)| suggestion) - } - pub(crate) fn suggest_recipe(&self, input: &str) -> Option> { - Self::find_suggestion( + Suggestion::find_suggestion( input, self .recipes @@ -69,7 +58,7 @@ impl<'src> Justfile<'src> { } pub(crate) fn suggest_variable(&self, input: &str) -> Option> { - Self::find_suggestion( + Suggestion::find_suggestion( input, self .assignments @@ -343,6 +332,11 @@ impl<'src> Justfile<'src> { search, }; + let use_named_parameters = recipe + .attributes + .contains(AttributeDiscriminant::NamedParameters) + || module.settings.named_parameters; + let (outer, positional) = Evaluator::evaluate_parameters( arguments, &context, @@ -350,6 +344,7 @@ impl<'src> Justfile<'src> { &recipe.parameters, recipe, scope, + use_named_parameters, )?; let scope = outer.child(); diff --git a/src/keyword.rs b/src/keyword.rs index 1f0d9186..7efd7a9e 100644 --- a/src/keyword.rs +++ b/src/keyword.rs @@ -21,6 +21,7 @@ pub(crate) enum Keyword { IgnoreComments, Import, Mod, + NamedParameters, NoExitMessage, PositionalArguments, Quiet, diff --git a/src/node.rs b/src/node.rs index e57eb123..43f3af12 100644 --- a/src/node.rs +++ b/src/node.rs @@ -317,6 +317,7 @@ impl<'src> Node<'src> for Set<'src> { | Setting::Export(value) | Setting::Fallback(value) | Setting::NoExitMessage(value) + | Setting::NamedParameters(value) | Setting::PositionalArguments(value) | Setting::Quiet(value) | Setting::Unstable(value) diff --git a/src/parameter.rs b/src/parameter.rs index b0ce0a75..43eafae0 100644 --- a/src/parameter.rs +++ b/src/parameter.rs @@ -36,6 +36,18 @@ impl<'src> Parameter<'src> { } } +impl<'src> Keyed<'src> for Parameter<'src> { + fn key(&self) -> &'src str { + self.name.lexeme() + } +} + +impl<'src> Keyed<'src> for &Parameter<'src> { + fn key(&self) -> &'src str { + self.name.lexeme() + } +} + impl ColorDisplay for Parameter<'_> { fn fmt(&self, f: &mut Formatter, color: Color) -> fmt::Result { if let Some(prefix) = self.kind.prefix() { diff --git a/src/parser.rs b/src/parser.rs index f0cdb4ea..739cf32b 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -1275,6 +1275,9 @@ impl<'run, 'src> Parser<'run, 'src> { Keyword::Quiet => Some(Setting::Quiet(self.parse_set_bool()?)), Keyword::Unstable => Some(Setting::Unstable(self.parse_set_bool()?)), Keyword::WindowsPowershell => Some(Setting::WindowsPowerShell(self.parse_set_bool()?)), + Keyword::NamedParameters => { + Some(Setting::NamedParameters(self.parse_set_bool()?)) + } _ => None, }; diff --git a/src/setting.rs b/src/setting.rs index 211ba9d3..039db5a1 100644 --- a/src/setting.rs +++ b/src/setting.rs @@ -12,6 +12,7 @@ pub(crate) enum Setting<'src> { Export(bool), Fallback(bool), IgnoreComments(bool), + NamedParameters(bool), NoExitMessage(bool), PositionalArguments(bool), Quiet(bool), @@ -35,6 +36,7 @@ impl Display for Setting<'_> { | Self::Export(value) | Self::Fallback(value) | Self::IgnoreComments(value) + | Self::NamedParameters(value) | Self::NoExitMessage(value) | Self::PositionalArguments(value) | Self::Quiet(value) diff --git a/src/settings.rs b/src/settings.rs index 78240ca5..355dcaa8 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -17,6 +17,7 @@ pub(crate) struct Settings<'src> { pub(crate) export: bool, pub(crate) fallback: bool, pub(crate) ignore_comments: bool, + pub(crate) named_parameters: bool, pub(crate) no_exit_message: bool, pub(crate) positional_arguments: bool, pub(crate) quiet: bool, @@ -66,6 +67,9 @@ impl<'src> Settings<'src> { Setting::IgnoreComments(ignore_comments) => { settings.ignore_comments = ignore_comments; } + Setting::NamedParameters(named_parameters) => { + settings.named_parameters = named_parameters; + } Setting::NoExitMessage(no_exit_message) => { settings.no_exit_message = no_exit_message; } diff --git a/src/suggestion.rs b/src/suggestion.rs index c30c23ea..0fef73b3 100644 --- a/src/suggestion.rs +++ b/src/suggestion.rs @@ -6,6 +6,19 @@ pub(crate) struct Suggestion<'src> { pub(crate) target: Option<&'src str>, } +impl<'src> Suggestion<'src> { + pub fn find_suggestion( + input: &str, + candidates: impl Iterator>, + ) -> Option> { + candidates + .map(|suggestion| (edit_distance(input, suggestion.name), suggestion)) + .filter(|(distance, _suggestion)| *distance < 3) + .min_by_key(|(distance, _suggestion)| *distance) + .map(|(_distance, suggestion)| suggestion) + } +} + impl Display for Suggestion<'_> { fn fmt(&self, f: &mut Formatter) -> fmt::Result { write!(f, "Did you mean `{}`", self.name)?; diff --git a/tests/json.rs b/tests/json.rs index 48b58aef..b53ecb32 100644 --- a/tests/json.rs +++ b/tests/json.rs @@ -95,6 +95,7 @@ struct Settings<'a> { windows_powershell: bool, windows_shell: Option<&'a str>, working_directory: Option<&'a str>, + named_parameters: bool, } #[track_caller] diff --git a/tests/lib.rs b/tests/lib.rs index 363c8730..b1d1e8f4 100644 --- a/tests/lib.rs +++ b/tests/lib.rs @@ -94,6 +94,7 @@ mod man; mod misc; mod modules; mod multibyte_char; +mod named_parameters; mod newline_escape; mod no_aliases; mod no_cd; diff --git a/tests/named_parameters.rs b/tests/named_parameters.rs new file mode 100644 index 00000000..db912132 --- /dev/null +++ b/tests/named_parameters.rs @@ -0,0 +1,179 @@ +use super::*; + +#[test] +fn only_named_parameters_original_order() { + Test::new() + .arg("foo") + .arg("a=1") + .arg("b=2") + .arg("c=3") + .justfile( + r#" + [named-parameters] + foo a="a" b="b" c="c": + echo {{ a }} + echo {{ b }} + echo {{ c }} + "#, + ) + .stdout( + " + 1 + 2 + 3 + ", + ) + .stderr( + r#" + echo 1 + echo 2 + echo 3 + "#, + ) + .run(); +} + +#[test] +fn only_named_parameters_mixed_order() { + Test::new() + .arg("foo") + .arg("c=3") + .arg("b=2") + .arg("a=1") + .justfile( + r#" + [named-parameters] + foo a="a" b="b" c="c": + echo {{ a }} + echo {{ b }} + echo {{ c }} + "#, + ) + .stdout( + " + 1 + 2 + 3 + ", + ) + .stderr( + r#" + echo 1 + echo 2 + echo 3 + "#, + ) + .run(); +} + +#[test] +fn positional_and_named_parameters() { + Test::new() + .arg("foo") + .arg("\"a=1\"") + .arg("c=3") + .arg("b=2") + .justfile( + r#" + [named-parameters] + foo a b="b" c="c": + echo {{ a }} + echo {{ b }} + echo {{ c }} + "#, + ) + .stdout( + " + a=1 + 2 + 3 + ", + ) + .stderr( + r#" + echo a=1 + echo 2 + echo 3 + "#, + ) + .run(); +} + +#[test] +fn fail_on_duplicate_assignment_of_named_param() { + Test::new() + .arg("foo") + .arg("b=2") + .arg("b=2") + .justfile( + r#" + [named-parameters] + foo a="1" b="b": + echo {{ a }} + echo {{ b }} + "#, + ) + .stderr( + r#" + error: `b` defined multiple times. + "#, + ) + .status(EXIT_FAILURE) + .run(); +} + +#[test] +fn fail_on_unknown_named_param() { + Test::new() + .arg("foo") + .arg("c=2") + .justfile( + r#" + [named-parameters] + foo a="1" b="b": + echo {{ a }} + echo {{ b }} + "#, + ) + .stderr( + r#" + error: Recipe does not contain parameter `c`. + Did you mean `a`? + "#, + ) + .status(EXIT_FAILURE) + .run(); +} + +#[test] +fn named_parameters_setting() { + Test::new() + .arg("foo") + .arg("a=1") + .arg("c=3") + .justfile( + r#" + set named-parameters + + foo a="a" b="b" c="c": + echo {{ a }} + echo {{ b }} + echo {{ c }} + "#, + ) + .stdout( + " + 1 + b + 3 + ", + ) + .stderr( + r#" + echo 1 + echo b + echo 3 + "#, + ) + .run(); +} diff --git a/toolchain.toml b/toolchain.toml new file mode 100644 index 00000000..c5c81fdf --- /dev/null +++ b/toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "stable" +components = ["clippy", "rust-analyzer"] \ No newline at end of file