diff --git a/CHANGELOG.md b/CHANGELOG.md index dc394a59a..173297b18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Commit templates now support `immutable` keyword. +* New template function `coalesce(content, ..)` is added. + * Timestamps are now shown in local timezone and without milliseconds and timezone offset by default. diff --git a/cli/src/config/templates.toml b/cli/src/config/templates.toml index 732b4c73f..80b0f9aa6 100644 --- a/cli/src/config/templates.toml +++ b/cli/src/config/templates.toml @@ -96,7 +96,7 @@ concat( "Author: " ++ format_detailed_signature(author) ++ "\n", "Committer: " ++ format_detailed_signature(committer) ++ "\n", "\n", - indent(" ", if(description, description, description_placeholder ++ "\n")), + indent(" ", coalesce(description, description_placeholder ++ "\n")), "\n", ) ''' diff --git a/cli/src/template_builder.rs b/cli/src/template_builder.rs index 6125a23b5..db9788e10 100644 --- a/cli/src/template_builder.rs +++ b/cli/src/template_builder.rs @@ -22,10 +22,10 @@ use crate::template_parser::{ TemplateParseError, TemplateParseErrorKind, TemplateParseResult, UnaryOp, }; use crate::templater::{ - ConcatTemplate, ConditionalTemplate, IntoTemplate, LabelTemplate, ListPropertyTemplate, - ListTemplate, Literal, PlainTextFormattedProperty, PropertyPlaceholder, ReformatTemplate, - SeparateTemplate, Template, TemplateProperty, TemplatePropertyError, TemplatePropertyExt as _, - TemplateRenderer, TimestampRange, + CoalesceTemplate, ConcatTemplate, ConditionalTemplate, IntoTemplate, LabelTemplate, + ListPropertyTemplate, ListTemplate, Literal, PlainTextFormattedProperty, PropertyPlaceholder, + ReformatTemplate, SeparateTemplate, Template, TemplateProperty, TemplatePropertyError, + TemplatePropertyExt as _, TemplateRenderer, TimestampRange, }; use crate::{text_util, time_util}; @@ -921,6 +921,14 @@ fn builtin_functions<'a, L: TemplateLanguage<'a> + ?Sized>() -> TemplateBuildFun let template = ConditionalTemplate::new(condition, true_template, false_template); Ok(L::wrap_template(Box::new(template))) }); + map.insert("coalesce", |language, build_ctx, function| { + let contents = function + .args + .iter() + .map(|node| expect_template_expression(language, build_ctx, node)) + .try_collect()?; + Ok(L::wrap_template(Box::new(CoalesceTemplate(contents)))) + }); map.insert("concat", |language, build_ctx, function| { let contents = function .args @@ -1993,6 +2001,30 @@ mod tests { @"text"); } + #[test] + fn test_coalesce_function() { + let mut env = TestTemplateEnv::new(); + env.add_keyword("bad_string", || L::wrap_string(new_error_property("Bad"))); + env.add_keyword("empty_string", || L::wrap_string(Literal("".to_owned()))); + env.add_keyword("non_empty_string", || { + L::wrap_string(Literal("a".to_owned())) + }); + + insta::assert_snapshot!(env.render_ok(r#"coalesce()"#), @""); + insta::assert_snapshot!(env.render_ok(r#"coalesce("")"#), @""); + insta::assert_snapshot!(env.render_ok(r#"coalesce("", "a", "", "b")"#), @"a"); + insta::assert_snapshot!( + env.render_ok(r#"coalesce(empty_string, "", non_empty_string)"#), @"a"); + + // "false" is not empty + insta::assert_snapshot!(env.render_ok(r#"coalesce(false, true)"#), @"false"); + + // Error is not empty + insta::assert_snapshot!(env.render_ok(r#"coalesce(bad_string, "a")"#), @""); + // but can be short-circuited + insta::assert_snapshot!(env.render_ok(r#"coalesce("a", bad_string)"#), @"a"); + } + #[test] fn test_concat_function() { let mut env = TestTemplateEnv::new(); diff --git a/cli/src/templater.rs b/cli/src/templater.rs index d5268f477..6455c9b27 100644 --- a/cli/src/templater.rs +++ b/cli/src/templater.rs @@ -184,6 +184,22 @@ where } } +/// Renders contents in order, and returns the first non-empty output. +pub struct CoalesceTemplate(pub Vec); + +impl Template for CoalesceTemplate { + fn format(&self, formatter: &mut dyn Formatter) -> io::Result<()> { + let Some((last, contents)) = self.0.split_last() else { + return Ok(()); + }; + if let Some(recorder) = contents.iter().find_map(record_non_empty) { + recorder?.replay(formatter) + } else { + last.format(formatter) // no need to capture the last content + } + } +} + pub struct ConcatTemplate(pub Vec); impl Template for ConcatTemplate { @@ -248,18 +264,7 @@ where T: Template, { fn format(&self, formatter: &mut dyn Formatter) -> io::Result<()> { - let mut content_recorders = self - .contents - .iter() - .filter_map(|template| { - let mut recorder = FormatRecorder::new(); - match template.format(&mut recorder) { - Ok(()) if recorder.data().is_empty() => None, // omit empty content - Ok(()) => Some(Ok(recorder)), - Err(e) => Some(Err(e)), - } - }) - .fuse(); + let mut content_recorders = self.contents.iter().filter_map(record_non_empty).fuse(); if let Some(recorder) = content_recorders.next() { recorder?.replay(formatter)?; } @@ -697,3 +702,12 @@ fn format_error_inline(formatter: &mut dyn Formatter, err: &dyn error::Error) -> Ok(()) }) } + +fn record_non_empty(template: impl Template) -> Option> { + let mut recorder = FormatRecorder::new(); + match template.format(&mut recorder) { + Ok(()) if recorder.data().is_empty() => None, // omit empty content + Ok(()) => Some(Ok(recorder)), + Err(e) => Some(Err(e)), + } +} diff --git a/docs/config.md b/docs/config.md index 3ec7a5a72..6ff3b99ad 100644 --- a/docs/config.md +++ b/docs/config.md @@ -241,13 +241,11 @@ For example: ```toml [templates] log_node = ''' -if(self, - if(current_working_copy, "@", - if(root, "┴", - if(immutable, "●", "○") - ) - ), - "🮀", +coalesce( + if(!self, "🮀"), + if(current_working_copy, "@"), + if(root, "┴"), + if(immutable, "●", "○"), ) ''' op_log_node = 'if(current_operation, "@", "○")' diff --git a/docs/templates.md b/docs/templates.md index 5ab5472d7..9db50f628 100644 --- a/docs/templates.md +++ b/docs/templates.md @@ -47,6 +47,8 @@ The following functions are defined. the content. The `label` is evaluated as a space-separated string. * `if(condition: Boolean, then: Template[, else: Template]) -> Template`: Conditionally evaluate `then`/`else` template content. +* `coalesce(content: Template...) -> Template`: Returns the first **non-empty** + content. * `concat(content: Template...) -> Template`: Same as `content_1 ++ ... ++ content_n`. * `separate(separator: Template, content: Template...) -> Template`: