From 070f929736d1df64460b63da513043c6c5815af5 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Tue, 30 Jun 2026 15:01:48 -0700 Subject: [PATCH 1/5] Allow `[doc]` attributes to accept expression --- README.md | 4 +- src/analyzer.rs | 39 +++++++++------ src/attribute.rs | 22 +++++--- src/parser.rs | 6 +-- src/table.rs | 4 ++ src/unresolved_recipe.rs | 8 +++ tests/attributes.rs | 105 +++++++++++++++++++++++++++++++++++++++ tests/format.rs | 14 ++++++ tests/modules.rs | 16 ++++++ 9 files changed, 192 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 6a3e37cfcf..8412328460 100644 --- a/README.md +++ b/README.md @@ -1531,6 +1531,8 @@ Available recipes: test ``` +The value of `doc` may be a const expressionmaster. + ### Groups Recipes and modules may be annotated with one or more group names: @@ -4689,7 +4691,7 @@ change their behavior. | `[confirm]`1.17.0 | recipe | Require confirmation prior to executing recipe. | | `[continue(SIGNALS)]`1.54.0 | recipe | Continue execution normally if a command is interrupted by any of `SIGNALS` and exits successfully. Defaults to `SIGINT`. | | `[default]`1.43.0 | recipe | Use recipe as module's default recipe. | -| `[doc(DOC)]`1.27.0 | module, recipe | Set recipe or module's [documentation comment](#documentation-comments) to `DOC`. | +| `[doc(DOC)]`1.27.0 | module, recipe | Set recipe or module's [documentation comment](#documentation-comments) to `DOC`. May be a const expressionmaster. | | `[dragonfly]`1.47.0 | anymaster | Enable item on DragonFly BSD. | | `[env(NAME, VALUE)]` 1.47.0 | recipe | Set environment variable `NAME` to `VALUE` for recipe. `NAME` and `VALUE` may be expressions1.51.0. | | `[extension(EXT)]`1.32.0 | recipe | Set shebang recipe script's file extension to `EXT`. `EXT` should include a period if one is desired. | diff --git a/src/analyzer.rs b/src/analyzer.rs index a7f26f8c1d..6d0566c5b9 100644 --- a/src/analyzer.rs +++ b/src/analyzer.rs @@ -47,6 +47,7 @@ impl<'run, 'src> Analyzer<'run, 'src> { let mut definitions = HashMap::new(); let mut imports = HashSet::new(); let mut list_features = Vec::new(); + let mut module_docs: Vec<(&str, &Expression)> = Vec::new(); let mut unstable_features = BTreeSet::new(); let mut stack = Vec::new(); @@ -104,6 +105,9 @@ impl<'run, 'src> Analyzer<'run, 'src> { attributes.private(), absolute, )?); + if let Some(Attribute::Doc(Some(expression))) = attributes.get(AttributeKind::Doc) { + module_docs.push((name.lexeme(), expression)); + } } else if *optional { absent_modules.insert(name.lexeme().to_string()); } @@ -218,23 +222,23 @@ impl<'run, 'src> Analyzer<'run, 'src> { .iter() .flat_map(|recipe| &recipe.attributes) .flat_map(|attribute| { - let (help, pattern) = if let Attribute::Arg { - help_property, - pattern_property, - .. - } = attribute - { - (help_property.as_ref(), pattern_property.as_ref()) - } else { - (None, None) - }; - - help - .into_iter() - .chain(pattern) - .map(|(_, expression)| expression) + let mut expressions = Vec::new(); + match attribute { + Attribute::Arg { + help_property, + pattern_property, + .. + } => { + expressions.extend(help_property.as_ref().map(|(_, expression)| expression)); + expressions.extend(pattern_property.as_ref().map(|(_, expression)| expression)); + } + Attribute::Doc(Some(expression)) => expressions.push(expression), + _ => {} + } + expressions }), ) + .chain(module_docs.iter().map(|(_, expression)| *expression)) .flat_map(|expression| expression.references()) .filter_map(|reference| { if let Reference::Variable(variable) = reference { @@ -266,6 +270,11 @@ impl<'run, 'src> Analyzer<'run, 'src> { } } + for (name, expression) in module_docs { + let value = evaluator.evaluate_value_const(expression)?; + self.modules.get_mut(name).unwrap().doc = Some(value.join()).filter(|doc| !doc.is_empty()); + } + let mut deduplicated_recipes = Table::<'src, UnresolvedRecipe<'src>>::default(); for recipe in self.recipes { Self::define( diff --git a/src/attribute.rs b/src/attribute.rs index 9200c131d9..a7c48f0f39 100644 --- a/src/attribute.rs +++ b/src/attribute.rs @@ -39,7 +39,7 @@ pub(crate) enum Attribute<'src> { Confirm(Option>), Continue(BTreeSet), Default, - Doc(Option>), + Doc(Option>), Dragonfly, Env(Expression<'src>, Expression<'src>), ExitMessage, @@ -66,7 +66,10 @@ pub(crate) enum Attribute<'src> { impl AttributeKind { fn accepts_expressions(self) -> bool { - matches!(self, Self::Confirm | Self::Env | Self::WorkingDirectory) + matches!( + self, + Self::Confirm | Self::Doc | Self::Env | Self::WorkingDirectory + ) } pub(crate) fn accepts_keyword_arguments(self) -> bool { @@ -173,6 +176,9 @@ impl<'src> Attribute<'src> { AttributeKind::Confirm => Ok(Self::Confirm( arguments.into_iter().next().map(|(_, expr)| expr), )), + AttributeKind::Doc => Ok(Self::Doc( + arguments.into_iter().next().map(|(_, expr)| expr), + )), AttributeKind::Env => { let mut arguments = arguments.into_iter(); let (_, key) = arguments.next().unwrap(); @@ -330,11 +336,13 @@ impl<'src> Attribute<'src> { }) .collect::>>()?, ), - AttributeKind::Confirm | AttributeKind::Env | AttributeKind::WorkingDirectory => { + AttributeKind::Confirm + | AttributeKind::Doc + | AttributeKind::Env + | AttributeKind::WorkingDirectory => { unreachable!() } AttributeKind::Default => Self::Default, - AttributeKind::Doc => Self::Doc(arguments.into_iter().next()), AttributeKind::Dragonfly => Self::Dragonfly, AttributeKind::ExitMessage => Self::ExitMessage, AttributeKind::Extension => Self::Extension(arguments.into_iter().next().unwrap()), @@ -512,10 +520,12 @@ impl Display for Attribute<'_> { write!(f, "({})", arguments.join(", "))?; } } - Self::Confirm(Some(argument)) | Self::WorkingDirectory(argument) => { + Self::Confirm(Some(argument)) + | Self::Doc(Some(argument)) + | Self::WorkingDirectory(argument) => { write!(f, "({argument})")?; } - Self::Doc(Some(argument)) | Self::Extension(argument) | Self::Group(argument) => { + Self::Extension(argument) | Self::Group(argument) => { write!(f, "({argument})")?; } Self::Continue(signals) => { diff --git a/src/parser.rs b/src/parser.rs index 765c75f900..b154a877a7 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -418,10 +418,8 @@ impl<'run, 'src> Parser<'run, 'src> { } fn take_doc_comment(&mut self, attributes: &AttributeSet<'src>) -> Option { - for attribute in attributes { - if let Attribute::Doc(doc) = attribute { - return doc.as_ref().map(|doc| doc.cooked.clone()); - } + if attributes.contains(AttributeKind::Doc) { + return None; } let mut items = self.items.iter().rev(); diff --git a/src/table.rs b/src/table.rs index cc7c18e7fe..b4250cba73 100644 --- a/src/table.rs +++ b/src/table.rs @@ -25,6 +25,10 @@ impl<'key, V: Keyed<'key>> Table<'key, V> { self.map.get(key) } + pub(crate) fn get_mut(&mut self, key: &str) -> Option<&mut V> { + self.map.get_mut(key) + } + pub(crate) fn into_values(self) -> btree_map::IntoValues<&'key str, V> { self.map.into_values() } diff --git a/src/unresolved_recipe.rs b/src/unresolved_recipe.rs index 047f47e6ad..9fd6766ad5 100644 --- a/src/unresolved_recipe.rs +++ b/src/unresolved_recipe.rs @@ -79,6 +79,9 @@ impl<'src> UnresolvedRecipe<'src> { Attribute::Confirm(Some(expression)) | Attribute::WorkingDirectory(expression) => { resolve_expression(expression, &self.parameters)?; } + Attribute::Doc(Some(expression)) => { + resolve_expression(expression, &[])?; + } Attribute::Arg { help_property, pattern_property, @@ -103,6 +106,11 @@ impl<'src> UnresolvedRecipe<'src> { .attributes .into_items() .map(|(mut attribute, name)| { + if let Attribute::Doc(Some(expression)) = &attribute { + let value = evaluator.evaluate_value_const(expression)?; + self.doc = (!value.is_empty()).then(|| value.join()); + } + if let Attribute::Arg { help, help_property: Some((_key, expression)), diff --git a/tests/attributes.rs b/tests/attributes.rs index 72d3bf2b15..221f2e4214 100644 --- a/tests/attributes.rs +++ b/tests/attributes.rs @@ -268,6 +268,111 @@ fn doc_multiline() { .success(); } +#[test] +fn doc_attribute_may_be_expression() { + Test::new() + .justfile( + " + prefix := 'hello ' + [doc(prefix + 'world')] + foo: + ", + ) + .args(["--list"]) + .stdout( + " + Available recipes: + foo # hello world + ", + ) + .success(); +} + +#[test] +fn doc_attribute_list_is_joined() { + Test::new() + .justfile( + " + set lists + [doc(['hello', 'world'])] + foo: + ", + ) + .env("JUST_UNSTABLE", "1") + .args(["--list"]) + .stdout( + " + Available recipes: + foo # hello world + ", + ) + .success(); +} + +#[test] +fn doc_attribute_empty_list_is_no_doc() { + Test::new() + .justfile( + " + set lists + [doc([])] + foo: + ", + ) + .env("JUST_UNSTABLE", "1") + .args(["--list"]) + .stdout( + " + Available recipes: + foo + ", + ) + .success(); +} + +#[test] +fn doc_attribute_cannot_reference_undefined_variable() { + Test::new() + .justfile( + " + [doc(undefined)] + foo: + ", + ) + .stderr( + " + error: variable `undefined` not defined + ——▶ justfile:1:6 + │ + 1 │ [doc(undefined)] + │ ^^^^^^^^^ + ", + ) + .failure(); +} + +#[test] +fn doc_attribute_cannot_reference_non_const_variable() { + Test::new() + .justfile( + " + bar := `echo BAR` + [doc(bar)] + foo: + ", + ) + .stderr( + " + error: cannot access non-const variable `bar` in const context + ——▶ justfile:2:6 + │ + 2 │ [doc(bar)] + │ ^^^ + ", + ) + .failure(); +} + #[test] fn extension() { Test::new() diff --git a/tests/format.rs b/tests/format.rs index bf6bff89e2..7e0f1f864c 100644 --- a/tests/format.rs +++ b/tests/format.rs @@ -1184,6 +1184,20 @@ fn doc_attribute_suppresses_comment() { ); } +#[test] +fn doc_attribute_expression() { + assert_dump( + " + [doc('f' + 'oo')] + foo: + ", + " + [doc('f' + 'oo')] + foo: + ", + ); +} + #[test] fn unchanged_justfiles_are_not_written_to_disk() { let tmp = tempdir(); diff --git a/tests/modules.rs b/tests/modules.rs index 9a66123a04..c86baf0ac0 100644 --- a/tests/modules.rs +++ b/tests/modules.rs @@ -1607,6 +1607,22 @@ fn doc_attribute_on_module() { .success(); } +#[test] +fn doc_attribute_on_module_may_be_expression() { + Test::new() + .write("foo.just", "") + .justfile( + " + prefix := 'hello ' + [doc(prefix + 'world')] + mod foo + ", + ) + .arg("--list") + .stdout("Available recipes:\n foo ... # hello world\n") + .success(); +} + #[test] fn group_attribute_on_module() { Test::new() From 1a6a7230b9fcc9e688869d2f0fb0aa4b550f11dc Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Tue, 30 Jun 2026 15:20:13 -0700 Subject: [PATCH 2/5] Reform --- README.md | 2 +- src/analyzer.rs | 2 +- tests/json.rs | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8412328460..64e5c72fae 100644 --- a/README.md +++ b/README.md @@ -1531,7 +1531,7 @@ Available recipes: test ``` -The value of `doc` may be a const expressionmaster. +The value of `[doc]` may be a const expressionmaster. ### Groups diff --git a/src/analyzer.rs b/src/analyzer.rs index 6d0566c5b9..4acd82e75e 100644 --- a/src/analyzer.rs +++ b/src/analyzer.rs @@ -272,7 +272,7 @@ impl<'run, 'src> Analyzer<'run, 'src> { for (name, expression) in module_docs { let value = evaluator.evaluate_value_const(expression)?; - self.modules.get_mut(name).unwrap().doc = Some(value.join()).filter(|doc| !doc.is_empty()); + self.modules.get_mut(name).unwrap().doc = (!value.is_empty()).then(|| value.join()); } let mut deduplicated_recipes = Table::<'src, UnresolvedRecipe<'src>>::default(); diff --git a/tests/json.rs b/tests/json.rs index befbc0c750..bfe3449eb7 100644 --- a/tests/json.rs +++ b/tests/json.rs @@ -1095,6 +1095,40 @@ fn module() { ); } +#[test] +fn module_doc_attribute_empty_string() { + case_with_submodule( + " + [doc('')] + mod foo + ", + Some(("foo.just", "bar:")), + Module { + modules: [( + "foo", + Module { + doc: Some(""), + first: Some("bar"), + module_path: "foo", + source: "foo.just".into(), + recipes: [( + "bar", + Recipe { + name: "bar", + namepath: "foo::bar", + ..default() + }, + )] + .into(), + ..default() + }, + )] + .into(), + ..default() + }, + ); +} + #[test] fn module_group() { case_with_submodule( From 41234e23be8973bff68e8af5855408144f2b9e56 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Tue, 30 Jun 2026 15:29:32 -0700 Subject: [PATCH 3/5] Adjust --- src/analyzer.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/analyzer.rs b/src/analyzer.rs index 4acd82e75e..0b8a9d0080 100644 --- a/src/analyzer.rs +++ b/src/analyzer.rs @@ -272,7 +272,11 @@ impl<'run, 'src> Analyzer<'run, 'src> { for (name, expression) in module_docs { let value = evaluator.evaluate_value_const(expression)?; - self.modules.get_mut(name).unwrap().doc = (!value.is_empty()).then(|| value.join()); + self.modules.get_mut(name).unwrap().doc = if value.is_empty { + None + } else { + Some(value.join()); + }; } let mut deduplicated_recipes = Table::<'src, UnresolvedRecipe<'src>>::default(); From ee065692ee7d2cf5e7af11b1db0dca035b3312ca Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Tue, 30 Jun 2026 15:31:48 -0700 Subject: [PATCH 4/5] Tweak --- src/analyzer.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/analyzer.rs b/src/analyzer.rs index 0b8a9d0080..d8778c56ce 100644 --- a/src/analyzer.rs +++ b/src/analyzer.rs @@ -229,8 +229,12 @@ impl<'run, 'src> Analyzer<'run, 'src> { pattern_property, .. } => { - expressions.extend(help_property.as_ref().map(|(_, expression)| expression)); - expressions.extend(pattern_property.as_ref().map(|(_, expression)| expression)); + if let Some((_, expression)) = help_property { + expressions.push(expression); + } + if let Some((_, expression)) = pattern_property { + expressions.push(expression); + } } Attribute::Doc(Some(expression)) => expressions.push(expression), _ => {} @@ -272,10 +276,10 @@ impl<'run, 'src> Analyzer<'run, 'src> { for (name, expression) in module_docs { let value = evaluator.evaluate_value_const(expression)?; - self.modules.get_mut(name).unwrap().doc = if value.is_empty { + self.modules.get_mut(name).unwrap().doc = if value.is_empty() { None } else { - Some(value.join()); + Some(value.join()) }; } From 22345d8f7958d12598af6dd13168a992a51286e8 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Tue, 30 Jun 2026 15:33:56 -0700 Subject: [PATCH 5/5] Tweak --- src/unresolved_recipe.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/unresolved_recipe.rs b/src/unresolved_recipe.rs index 9fd6766ad5..e89a047607 100644 --- a/src/unresolved_recipe.rs +++ b/src/unresolved_recipe.rs @@ -108,7 +108,11 @@ impl<'src> UnresolvedRecipe<'src> { .map(|(mut attribute, name)| { if let Attribute::Doc(Some(expression)) = &attribute { let value = evaluator.evaluate_value_const(expression)?; - self.doc = (!value.is_empty()).then(|| value.join()); + self.doc = if value.is_empty() { + None + } else { + Some(value.join()) + }; } if let Attribute::Arg {