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