templater: add pad/truncate functions

As I said in the preceding patch, I settled on separate pad/truncate functions
instead of a function taking multiple optional parameters. It's less efficient
to process truncation and padding independently, but I don't think that would
matter.

The order of arguments follows the current f(..., content) convention. We can
also add a method syntax, but I'm not sure if it's useful. In order to call a
method of Template type, we'll need to coerce printable object to Template:

  concat(author.email()).truncate_end(10).pad_end(10)
  ^^^^^^
  String -> Template

FWIW, String type could provide more efficient truncate/pad methods.

Closes #3183
This commit is contained in:
Yuya Nishihara 2024-10-16 21:35:27 +09:00
parent 6e06a79cfd
commit 009284736d
3 changed files with 163 additions and 0 deletions

View file

@ -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.

View file

@ -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<dyn Template + 'a>,
fill_char: Option<Box<dyn Template + 'a>>,
width: Box<dyn TemplateProperty<Output = usize> + 'a>,
write_padded: W,
) -> Box<dyn Template + 'a>
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<dyn Template + 'a>,
width: Box<dyn TemplateProperty<Output = usize> + 'a>,
write_truncated: W,
) -> Box<dyn Template + 'a>
where
W: Fn(&mut dyn Formatter, &FormatRecorder, usize) -> io::Result<usize> + '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<<Error: Error: Bad>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();

View file

@ -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