This commit is contained in:
Jacob Herbst 2025-12-14 13:41:19 +01:00 committed by GitHub
commit 7bb59f0c2b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 379 additions and 15 deletions

View file

@ -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`<sup>1.33.0</sup> | `[COMMAND, ARGS…]` | `['sh', '-eu']` | Set command used to invoke recipes with empty `[script]` attribute. |
@ -2151,6 +2152,7 @@ change their behavior.
| `[linux]`<sup>1.8.0</sup> | recipe | Enable recipe on Linux. |
| `[macos]`<sup>1.8.0</sup> | recipe | Enable recipe on MacOS. |
| `[metadata(METADATA)]`<sup>1.42.0</sup> | recipe | Attach `METADATA` to recipe. |
| `[named-parameters]`<sup>1.44.0<sup> | recipe | Turn on [named_parameters](#named_parameters) for this recipe. |
| `[no-cd]`<sup>1.9.0</sup> | recipe | Don't change directory before executing recipe. |
| `[no-exit-message]`<sup>1.7.0</sup> | recipe | Don't print an error message if recipe fails. |
| `[no-quiet]`<sup>1.23.0</sup> | 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

20
shell.nix Normal file
View file

@ -0,0 +1,20 @@
let
rust-overlay = builtins.fetchTarball "https://github.com/oxalica/rust-overlay/archive/master.tar.gz";
pkgs = import <nixpkgs> {
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";
}

View file

@ -24,6 +24,7 @@ pub(crate) enum Attribute<'src> {
Linux,
Macos,
Metadata(Vec<StringLiteral<'src>>),
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

View file

@ -93,6 +93,13 @@ pub(crate) enum Error<'src> {
editor: OsString,
status: ExitStatus,
},
EvalNamedParameterDuplicate {
parameter: String,
},
EvalUnknownNamedParameter {
parameter: String,
suggestion: Option<Suggestion<'src>>,
},
EvalUnknownVariable {
variable: String,
suggestion: Option<Suggestion<'src>>,
@ -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 {

View file

@ -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<String>)> {
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))
}

View file

@ -33,19 +33,8 @@ pub(crate) struct Justfile<'src> {
}
impl<'src> Justfile<'src> {
fn find_suggestion(
input: &str,
candidates: impl Iterator<Item = Suggestion<'src>>,
) -> Option<Suggestion<'src>> {
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<Suggestion<'src>> {
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<Suggestion<'src>> {
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();

View file

@ -21,6 +21,7 @@ pub(crate) enum Keyword {
IgnoreComments,
Import,
Mod,
NamedParameters,
NoExitMessage,
PositionalArguments,
Quiet,

View file

@ -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)

View file

@ -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() {

View file

@ -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,
};

View file

@ -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)

View file

@ -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;
}

View file

@ -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<Item = Suggestion<'src>>,
) -> Option<Suggestion<'src>> {
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)?;

View file

@ -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]

View file

@ -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;

179
tests/named_parameters.rs Normal file
View file

@ -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();
}

3
toolchain.toml Normal file
View file

@ -0,0 +1,3 @@
[toolchain]
channel = "stable"
components = ["clippy", "rust-analyzer"]