diff --git a/CHANGELOG.md b/CHANGELOG.md index 541c51ab6..70678aff8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,9 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). * Timestamp objects in templates now have `after(date) -> Boolean` and `before(date) -> Boolean` methods for comparing timestamps to other dates. +* New template functions `pad_start()`, `pad_end()`, `truncate_start()`, and + `truncate_end()` are added. + ### Fixed bugs * Error on `trunk()` revset resolution is now handled gracefully. diff --git a/cli/src/template_builder.rs b/cli/src/template_builder.rs index b14e6e17a..bd7c2ea4f 100644 --- a/cli/src/template_builder.rs +++ b/cli/src/template_builder.rs @@ -13,6 +13,7 @@ // limitations under the License. use std::collections::HashMap; +use std::io; use itertools::Itertools as _; use jj_lib::backend::Signature; @@ -20,6 +21,8 @@ use jj_lib::backend::Timestamp; use jj_lib::dsl_util::AliasExpandError as _; use jj_lib::time_util::DatePattern; +use crate::formatter::FormatRecorder; +use crate::formatter::Formatter; use crate::template_parser; use crate::template_parser::BinaryOp; use crate::template_parser::ExpressionKind; @@ -1126,6 +1129,50 @@ fn builtin_functions<'a, L: TemplateLanguage<'a> + ?Sized>() -> TemplateBuildFun }); Ok(L::wrap_template(Box::new(template))) }); + map.insert("pad_start", |language, diagnostics, build_ctx, function| { + let ([width_node, content_node], [fill_char_node]) = + function.expect_named_arguments(&["", "", "fill_char"])?; + let width = expect_usize_expression(language, diagnostics, build_ctx, width_node)?; + let content = expect_template_expression(language, diagnostics, build_ctx, content_node)?; + let fill_char = fill_char_node + .map(|node| expect_template_expression(language, diagnostics, build_ctx, node)) + .transpose()?; + let template = new_pad_template(content, fill_char, width, text_util::write_padded_start); + Ok(L::wrap_template(template)) + }); + map.insert("pad_end", |language, diagnostics, build_ctx, function| { + let ([width_node, content_node], [fill_char_node]) = + function.expect_named_arguments(&["", "", "fill_char"])?; + let width = expect_usize_expression(language, diagnostics, build_ctx, width_node)?; + let content = expect_template_expression(language, diagnostics, build_ctx, content_node)?; + let fill_char = fill_char_node + .map(|node| expect_template_expression(language, diagnostics, build_ctx, node)) + .transpose()?; + let template = new_pad_template(content, fill_char, width, text_util::write_padded_end); + Ok(L::wrap_template(template)) + }); + map.insert( + "truncate_start", + |language, diagnostics, build_ctx, function| { + let [width_node, content_node] = function.expect_exact_arguments()?; + let width = expect_usize_expression(language, diagnostics, build_ctx, width_node)?; + let content = + expect_template_expression(language, diagnostics, build_ctx, content_node)?; + let template = new_truncate_template(content, width, text_util::write_truncated_start); + Ok(L::wrap_template(template)) + }, + ); + map.insert( + "truncate_end", + |language, diagnostics, build_ctx, function| { + let [width_node, content_node] = function.expect_exact_arguments()?; + let width = expect_usize_expression(language, diagnostics, build_ctx, width_node)?; + let content = + expect_template_expression(language, diagnostics, build_ctx, content_node)?; + let template = new_truncate_template(content, width, text_util::write_truncated_end); + Ok(L::wrap_template(template)) + }, + ); map.insert("label", |language, diagnostics, build_ctx, function| { let [label_node, content_node] = function.expect_exact_arguments()?; let label_property = @@ -1207,6 +1254,54 @@ fn builtin_functions<'a, L: TemplateLanguage<'a> + ?Sized>() -> TemplateBuildFun map } +fn new_pad_template<'a, W>( + content: Box, + fill_char: Option>, + width: Box + 'a>, + write_padded: W, +) -> Box +where + W: Fn(&mut dyn Formatter, &FormatRecorder, &FormatRecorder, usize) -> io::Result<()> + 'a, +{ + let default_fill_char = FormatRecorder::with_data(" "); + let template = ReformatTemplate::new(content, move |formatter, recorded| { + let width = match width.extract() { + Ok(width) => width, + Err(err) => return formatter.handle_error(err), + }; + let mut fill_char_recorder; + let recorded_fill_char = if let Some(fill_char) = &fill_char { + let rewrap = formatter.rewrap_fn(); + fill_char_recorder = FormatRecorder::new(); + fill_char.format(&mut rewrap(&mut fill_char_recorder))?; + &fill_char_recorder + } else { + &default_fill_char + }; + write_padded(formatter.as_mut(), recorded, recorded_fill_char, width) + }); + Box::new(template) +} + +fn new_truncate_template<'a, W>( + content: Box, + width: Box + 'a>, + write_truncated: W, +) -> Box +where + W: Fn(&mut dyn Formatter, &FormatRecorder, usize) -> io::Result + 'a, +{ + let template = ReformatTemplate::new(content, move |formatter, recorded| { + let width = match width.extract() { + Ok(width) => width, + Err(err) => return formatter.handle_error(err), + }; + write_truncated(formatter.as_mut(), recorded, width)?; + Ok(()) + }); + Box::new(template) +} + /// Builds intermediate expression tree from AST nodes. pub fn build_expression<'a, L: TemplateLanguage<'a> + ?Sized>( language: &L, @@ -1729,6 +1824,15 @@ mod tests { = Function "if": Expected 2 to 3 arguments "###); + insta::assert_snapshot!(env.parse_err(r#"pad_start("foo", fill_char = "bar", "baz")"#), @r#" + --> 1:37 + | + 1 | pad_start("foo", fill_char = "bar", "baz") + | ^---^ + | + = Function "pad_start": Positional argument follows keyword argument + "#); + insta::assert_snapshot!(env.parse_err(r#"if(label("foo", "bar"), "baz")"#), @r###" --> 1:4 | @@ -2343,6 +2447,52 @@ mod tests { "###); } + #[test] + fn test_pad_function() { + let mut env = TestTemplateEnv::new(); + env.add_keyword("bad_string", || L::wrap_string(new_error_property("Bad"))); + env.add_color("red", crossterm::style::Color::Red); + env.add_color("cyan", crossterm::style::Color::DarkCyan); + + // Default fill_char is ' ' + insta::assert_snapshot!( + env.render_ok(r"'{' ++ pad_start(5, label('red', 'foo')) ++ '}'"), + @"{ foo}"); + insta::assert_snapshot!( + env.render_ok(r"'{' ++ pad_end(5, label('red', 'foo')) ++ '}'"), + @"{foo }"); + + // Labeled fill char + insta::assert_snapshot!( + env.render_ok(r"pad_start(5, label('red', 'foo'), fill_char=label('cyan', '='))"), + @"==foo"); + insta::assert_snapshot!( + env.render_ok(r"pad_end(5, label('red', 'foo'), fill_char=label('cyan', '='))"), + @"foo=="); + + // Error in fill char: the output looks odd (because the error message + // isn't 1-width character), but is still readable. + insta::assert_snapshot!( + env.render_ok(r"pad_start(3, 'foo', fill_char=bad_string)"), + @"foo"); + insta::assert_snapshot!( + env.render_ok(r"pad_end(5, 'foo', fill_char=bad_string)"), + @"foo<Bad>"); + } + + #[test] + fn test_truncate_function() { + let mut env = TestTemplateEnv::new(); + env.add_color("red", crossterm::style::Color::Red); + + insta::assert_snapshot!( + env.render_ok(r"truncate_start(2, label('red', 'foobar')) ++ 'baz'"), + @"arbaz"); + insta::assert_snapshot!( + env.render_ok(r"truncate_end(2, label('red', 'foobar')) ++ 'baz'"), + @"fobaz"); + } + #[test] fn test_label_function() { let mut env = TestTemplateEnv::new(); diff --git a/docs/templates.md b/docs/templates.md index 06100fc5d..08762cc73 100644 --- a/docs/templates.md +++ b/docs/templates.md @@ -45,6 +45,16 @@ The following functions are defined. the given `width`. * `indent(prefix: Template, content: Template) -> Template`: Indent non-empty lines by the given `prefix`. +* `pad_start(width: Integer, content: Template[, fill_char: Template])`: Pad (or + right-justify) content by adding leading fill characters. The `content` + shouldn't have newline character. +* `pad_end(width: Integer, content: Template[, fill_char: Template])`: Pad (or + left-justify) content by adding trailing fill characters. The `content` + shouldn't have newline character. +* `truncate_start(width: Integer, content: Template)`: Truncate `content` by + removing leading characters. The `content` shouldn't have newline character. +* `truncate_end(width: Integer, content: Template)`: Truncate `content` by + removing trailing characters. The `content` shouldn't have newline character. * `label(label: Template, content: Template) -> Template`: Apply label to the content. The `label` is evaluated as a space-separated string. * `raw_escape_sequence(content: Template) -> Template`: Preserves any escape