From a2a9b7decb5869d186dd870e9d2ae33070db73ab Mon Sep 17 00:00:00 2001 From: Yuya Nishihara Date: Mon, 25 Mar 2024 18:19:36 +0900 Subject: [PATCH] templater: add coalesce() function that selects first non-empty content This can be used to flatten nested "if()"s. It's not exactly the same as "case" or "switch" expression, but works reasonably well in template. It's not uncommon to show placeholder text in place of an empty content, and a nullish value (e.g. empty string, list, option) is usually rendered as an empty text. --- CHANGELOG.md | 2 ++ cli/src/config/templates.toml | 2 +- cli/src/template_builder.rs | 40 +++++++++++++++++++++++++++++++---- cli/src/templater.rs | 38 ++++++++++++++++++++++----------- docs/config.md | 12 +++++------ docs/templates.md | 2 ++ 6 files changed, 72 insertions(+), 24 deletions(-) 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`: