diff --git a/src/template_parser.rs b/src/template_parser.rs index 85d480f68..9032da552 100644 --- a/src/template_parser.rs +++ b/src/template_parser.rs @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -use std::ops::RangeInclusive; +use std::ops::{RangeFrom, RangeInclusive}; use std::{error, fmt}; use itertools::Itertools as _; @@ -29,8 +29,8 @@ use thiserror::Error; use crate::templater::{ BranchProperty, CommitOrChangeId, ConditionalTemplate, FormattablePropertyTemplate, GitHeadProperty, GitRefsProperty, IdWithHighlightedPrefix, LabelTemplate, ListTemplate, - Literal, PlainTextFormattedProperty, TagProperty, Template, TemplateFunction, TemplateProperty, - TemplatePropertyFn, WorkingCopiesProperty, + Literal, PlainTextFormattedProperty, SeparateTemplate, TagProperty, Template, TemplateFunction, + TemplateProperty, TemplatePropertyFn, WorkingCopiesProperty, }; use crate::{cli_util, time_util}; @@ -59,6 +59,8 @@ pub enum TemplateParseErrorKind { InvalidArgumentCountExact(usize), #[error("Expected {} to {} arguments", .0.start(), .0.end())] InvalidArgumentCountRange(RangeInclusive), + #[error("Expected at least {} arguments", .0.start)] + InvalidArgumentCountRangeFrom(RangeFrom), #[error(r#"Expected argument of type "{0}""#)] InvalidArgumentType(String), } @@ -112,6 +114,13 @@ impl TemplateParseError { ) } + fn invalid_argument_count_range_from(count: RangeFrom, span: pest::Span<'_>) -> Self { + TemplateParseError::with_span( + TemplateParseErrorKind::InvalidArgumentCountRangeFrom(count), + span, + ) + } + fn invalid_argument_type(expected_type_name: impl Into, span: pest::Span<'_>) -> Self { TemplateParseError::with_span( TemplateParseErrorKind::InvalidArgumentType(expected_type_name.into()), @@ -533,6 +542,21 @@ fn parse_commit_term<'a>( )); Expression::Template(template) } + "separate" => { + let arg_count_error = + || TemplateParseError::invalid_argument_count_range_from(1.., args_span); + let separator_pair = args.next().ok_or_else(arg_count_error)?; + let separator = parse_commit_template_rule(repo, workspace_id, separator_pair)? + .into_template(); + let contents = args + .map(|pair| { + parse_commit_template_rule(repo, workspace_id, pair) + .map(|x| x.into_template()) + }) + .try_collect()?; + let template = Box::new(SeparateTemplate::new(separator, contents)); + Expression::Template(template) + } _ => return Err(TemplateParseError::no_such_function(&name)), }; Ok(expression) diff --git a/src/templater.rs b/src/templater.rs index 2c2ca7097..5f01116c0 100644 --- a/src/templater.rs +++ b/src/templater.rs @@ -135,6 +135,57 @@ impl> Template for ListTemplate { } } +/// Like `ListTemplate`, but inserts a separator between non-empty templates. +pub struct SeparateTemplate { + separator: S, + contents: Vec, +} + +impl SeparateTemplate { + pub fn new(separator: S, contents: Vec) -> Self + where + S: Template, + T: Template, + { + SeparateTemplate { + separator, + contents, + } + } +} + +impl Template for SeparateTemplate +where + S: Template, + T: Template, +{ + fn format(&self, context: &C, formatter: &mut dyn Formatter) -> io::Result<()> { + // TemplateProperty may be evaluated twice, by has_content() and format(). + // If that's too expensive, we can instead create a buffered formatter + // inheriting the state, and write to it to test the emptiness. In this case, + // the formatter should guarantee push/pop_label() is noop without content. + let mut content_templates = self + .contents + .iter() + .filter(|template| template.has_content(context)) + .fuse(); + if let Some(template) = content_templates.next() { + template.format(context, formatter)?; + } + for template in content_templates { + self.separator.format(context, formatter)?; + template.format(context, formatter)?; + } + Ok(()) + } + + fn has_content(&self, context: &C) -> bool { + self.contents + .iter() + .any(|template| template.has_content(context)) + } +} + pub trait TemplateProperty { type Output; diff --git a/tests/test_templater.rs b/tests/test_templater.rs index 98da25b4b..6ddb997da 100644 --- a/tests/test_templater.rs +++ b/tests/test_templater.rs @@ -222,6 +222,57 @@ fn test_templater_label_function() { render(r#"label(if(empty, "error", "warning"), "text")"#), @"text"); } +#[test] +fn test_templater_separate_function() { + let test_env = TestEnvironment::default(); + test_env.jj_cmd_success(test_env.env_root(), &["init", "repo", "--git"]); + let repo_path = test_env.env_root().join("repo"); + let render = |template| get_colored_template_output(&test_env, &repo_path, "@-", template); + + insta::assert_snapshot!(render(r#"separate(" ")"#), @""); + insta::assert_snapshot!(render(r#"separate(" ", "")"#), @""); + insta::assert_snapshot!(render(r#"separate(" ", "a")"#), @"a"); + insta::assert_snapshot!(render(r#"separate(" ", "a", "b")"#), @"a b"); + insta::assert_snapshot!(render(r#"separate(" ", "a", "", "b")"#), @"a b"); + insta::assert_snapshot!(render(r#"separate(" ", "a", "b", "")"#), @"a b"); + insta::assert_snapshot!(render(r#"separate(" ", "", "a", "b")"#), @"a b"); + + // Labeled + insta::assert_snapshot!( + render(r#"separate(" ", label("error", ""), label("warning", "a"), "b")"#), + @"a b"); + + // List template + insta::assert_snapshot!(render(r#"separate(" ", "a", ("" ""))"#), @"a"); + insta::assert_snapshot!(render(r#"separate(" ", "a", ("" "b"))"#), @"a b"); + + // Nested separate + insta::assert_snapshot!( + render(r#"separate(" ", "a", separate("|", "", ""))"#), @"a"); + insta::assert_snapshot!( + render(r#"separate(" ", "a", separate("|", "b", ""))"#), @"a b"); + insta::assert_snapshot!( + render(r#"separate(" ", "a", separate("|", "b", "c"))"#), @"a b|c"); + + // Conditional template + insta::assert_snapshot!( + render(r#"separate(" ", "a", if("t", ""))"#), @"a"); + insta::assert_snapshot!( + render(r#"separate(" ", "a", if("t", "", "f"))"#), @"a"); + insta::assert_snapshot!( + render(r#"separate(" ", "a", if("", "t", ""))"#), @"a"); + insta::assert_snapshot!( + render(r#"separate(" ", "a", if("t", "t", "f"))"#), @"a t"); + + // Separate keywords + insta::assert_snapshot!( + render(r#"separate(" ", author, description, empty)"#), @" <> true"); + + // Keyword as separator + insta::assert_snapshot!( + render(r#"separate(author, "X", "Y", "Z")"#), @"X <>Y <>Z"); +} + fn get_template_output( test_env: &TestEnvironment, repo_path: &Path,