From 975e1e1e244796bc4472eb2e2b2bfa3bdca2c13e Mon Sep 17 00:00:00 2001 From: Josh Steadmon Date: Mon, 3 Feb 2025 13:58:07 -0800 Subject: [PATCH] templater: add optional ellipsis arg to truncate template functions If an ellipsis arg is given to the truncate_* template functions, append (or prepend) the ellipsis when the template content is truncated to fit the maximum width. Fixes #5085. --- CHANGELOG.md | 4 + cli/src/template_builder.rs | 33 +++- cli/src/text_util.rs | 366 +++++++++++++++++++++++++++++++----- docs/templates.md | 12 +- 4 files changed, 363 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6104a44d5..eb10921de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### New features +* Template functions `truncate_start()` and `truncate_end()` gained an optional + `ellipsis` parameter; passing this prepends or appends the ellipsis to the + content if it is truncated to fit the maximum width. + ### Fixed bugs * `jj status` now shows untracked files under untracked directories. diff --git a/cli/src/template_builder.rs b/cli/src/template_builder.rs index 6597b3fdc..cf8c9dd7a 100644 --- a/cli/src/template_builder.rs +++ b/cli/src/template_builder.rs @@ -1494,22 +1494,32 @@ fn builtin_functions<'a, L: TemplateLanguage<'a> + ?Sized>() -> TemplateBuildFun map.insert( "truncate_start", |language, diagnostics, build_ctx, function| { - let [width_node, content_node] = function.expect_exact_arguments()?; + let ([width_node, content_node], [ellipsis_node]) = + function.expect_named_arguments(&["", "", "ellipsis"])?; 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); + let ellipsis = ellipsis_node + .map(|node| expect_template_expression(language, diagnostics, build_ctx, node)) + .transpose()?; + let template = + new_truncate_template(content, ellipsis, 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_node, content_node], [ellipsis_node]) = + function.expect_named_arguments(&["", "", "ellipsis"])?; 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); + let ellipsis = ellipsis_node + .map(|node| expect_template_expression(language, diagnostics, build_ctx, node)) + .transpose()?; + let template = + new_truncate_template(content, ellipsis, width, text_util::write_truncated_end); Ok(L::wrap_template(template)) }, ); @@ -1643,18 +1653,29 @@ where fn new_truncate_template<'a, W>( content: Box, + ellipsis: Option>, width: Box + 'a>, write_truncated: W, ) -> Box where - W: Fn(&mut dyn Formatter, &FormatRecorder, usize) -> io::Result + 'a, + W: Fn(&mut dyn Formatter, &FormatRecorder, &FormatRecorder, usize) -> io::Result + 'a, { + let default_ellipsis = 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), }; - write_truncated(formatter.as_mut(), recorded, width)?; + let mut ellipsis_recorder; + let recorded_ellipsis = if let Some(ellipsis) = &ellipsis { + let rewrap = formatter.rewrap_fn(); + ellipsis_recorder = FormatRecorder::new(); + ellipsis.format(&mut rewrap(&mut ellipsis_recorder))?; + &ellipsis_recorder + } else { + &default_ellipsis + }; + write_truncated(formatter.as_mut(), recorded, recorded_ellipsis, width)?; Ok(()) }); Box::new(template) diff --git a/cli/src/text_util.rs b/cli/src/text_util.rs index 7595b0dc8..3c7315816 100644 --- a/cli/src/text_util.rs +++ b/cli/src/text_util.rs @@ -230,18 +230,39 @@ fn count_start_zero_width_chars_bytes(text: &[u8]) -> usize { pub fn write_truncated_start( formatter: &mut dyn Formatter, recorded_content: &FormatRecorder, + recorded_ellipsis: &FormatRecorder, max_width: usize, ) -> io::Result { let data = recorded_content.data(); - let (start, truncated_width) = truncate_start_pos_bytes(data, max_width); + let data_width = String::from_utf8_lossy(data).width(); + let ellipsis_data = recorded_ellipsis.data(); + let ellipsis_width = String::from_utf8_lossy(ellipsis_data).width(); + + let (start, mut truncated_width) = if data_width > max_width { + truncate_start_pos_bytes(data, max_width.saturating_sub(ellipsis_width)) + } else { + (0, data_width) + }; + + let mut replay_truncated = |recorded: &FormatRecorder, truncated_start: usize| { + recorded.replay_with(formatter, |formatter, range| { + let start = cmp::max(range.start, truncated_start); + if start < range.end { + formatter.write_all(&recorded.data()[start..range.end])?; + } + Ok(()) + }) + }; + + if data_width > max_width { + // The ellipsis itself may be larger than max_width, so maybe truncate it too. + let (start, ellipsis_width) = truncate_start_pos_bytes(ellipsis_data, max_width); + let truncated_start = start + count_start_zero_width_chars_bytes(&ellipsis_data[start..]); + truncated_width += ellipsis_width; + replay_truncated(recorded_ellipsis, truncated_start)?; + } let truncated_start = start + count_start_zero_width_chars_bytes(&data[start..]); - recorded_content.replay_with(formatter, |formatter, range| { - let start = cmp::max(range.start, truncated_start); - if start < range.end { - formatter.write_all(&data[start..range.end])?; - } - Ok(()) - })?; + replay_truncated(recorded_content, truncated_start)?; Ok(truncated_width) } @@ -252,17 +273,37 @@ pub fn write_truncated_start( pub fn write_truncated_end( formatter: &mut dyn Formatter, recorded_content: &FormatRecorder, + recorded_ellipsis: &FormatRecorder, max_width: usize, ) -> io::Result { let data = recorded_content.data(); - let (truncated_end, truncated_width) = truncate_end_pos_bytes(data, max_width); - recorded_content.replay_with(formatter, |formatter, range| { - let end = cmp::min(range.end, truncated_end); - if range.start < end { - formatter.write_all(&data[range.start..end])?; - } - Ok(()) - })?; + let data_width = String::from_utf8_lossy(data).width(); + let ellipsis_data = recorded_ellipsis.data(); + let ellipsis_width = String::from_utf8_lossy(ellipsis_data).width(); + + let (truncated_end, mut truncated_width) = if data_width > max_width { + truncate_end_pos_bytes(data, max_width.saturating_sub(ellipsis_width)) + } else { + (data.len(), data_width) + }; + + let mut replay_truncated = |recorded: &FormatRecorder, truncated_end: usize| { + recorded.replay_with(formatter, |formatter, range| { + let end = cmp::min(range.end, truncated_end); + if range.start < end { + formatter.write_all(&recorded.data()[range.start..end])?; + } + Ok(()) + }) + }; + + replay_truncated(recorded_content, truncated_end)?; + if data_width > max_width { + // The ellipsis itself may be larger than max_width, so maybe truncate it too. + let (truncated_end, ellipsis_width) = truncate_end_pos_bytes(ellipsis_data, max_width); + truncated_width += ellipsis_width; + replay_truncated(recorded_ellipsis, truncated_end)?; + } Ok(truncated_width) } @@ -693,6 +734,7 @@ mod tests { #[test] fn test_write_truncated_labeled() { + let ellipsis_recorder = FormatRecorder::new(); let mut recorder = FormatRecorder::new(); for (label, word) in [("red", "foo"), ("cyan", "bar")] { recorder.push_label(label).unwrap(); @@ -702,128 +744,368 @@ mod tests { // Truncate start insta::assert_snapshot!( - format_colored(|formatter| write_truncated_start(formatter, &recorder, 6).map(|_| ())), + format_colored(|formatter| { + write_truncated_start(formatter, &recorder, &ellipsis_recorder, 6).map(|_| ()) + }), @"foobar" ); insta::assert_snapshot!( - format_colored(|formatter| write_truncated_start(formatter, &recorder, 5).map(|_| ())), + format_colored(|formatter| { + write_truncated_start(formatter, &recorder, &ellipsis_recorder, 5).map(|_| ()) + }), @"oobar" ); insta::assert_snapshot!( - format_colored(|formatter| write_truncated_start(formatter, &recorder, 3).map(|_| ())), + format_colored(|formatter| { + write_truncated_start(formatter, &recorder, &ellipsis_recorder, 3).map(|_| ()) + }), @"bar" ); insta::assert_snapshot!( - format_colored(|formatter| write_truncated_start(formatter, &recorder, 2).map(|_| ())), + format_colored(|formatter| { + write_truncated_start(formatter, &recorder, &ellipsis_recorder, 2).map(|_| ()) + }), @"ar" ); insta::assert_snapshot!( - format_colored(|formatter| write_truncated_start(formatter, &recorder, 0).map(|_| ())), + format_colored(|formatter| { + write_truncated_start(formatter, &recorder, &ellipsis_recorder, 0).map(|_| ()) + }), @"" ); // Truncate end insta::assert_snapshot!( - format_colored(|formatter| write_truncated_end(formatter, &recorder, 6).map(|_| ())), + format_colored(|formatter| { + write_truncated_end(formatter, &recorder, &ellipsis_recorder, 6).map(|_| ()) + }), @"foobar" ); insta::assert_snapshot!( - format_colored(|formatter| write_truncated_end(formatter, &recorder, 5).map(|_| ())), + format_colored(|formatter| { + write_truncated_end(formatter, &recorder, &ellipsis_recorder, 5).map(|_| ()) + }), @"fooba" ); insta::assert_snapshot!( - format_colored(|formatter| write_truncated_end(formatter, &recorder, 3).map(|_| ())), + format_colored(|formatter| { + write_truncated_end(formatter, &recorder, &ellipsis_recorder, 3).map(|_| ()) + }), @"foo" ); insta::assert_snapshot!( - format_colored(|formatter| write_truncated_end(formatter, &recorder, 2).map(|_| ())), + format_colored(|formatter| { + write_truncated_end(formatter, &recorder, &ellipsis_recorder, 2).map(|_| ()) + }), @"fo" ); insta::assert_snapshot!( - format_colored(|formatter| write_truncated_end(formatter, &recorder, 0).map(|_| ())), + format_colored(|formatter| { + write_truncated_end(formatter, &recorder, &ellipsis_recorder, 0).map(|_| ()) + }), @"" ); } #[test] fn test_write_truncated_non_ascii_chars() { + let ellipsis_recorder = FormatRecorder::new(); let mut recorder = FormatRecorder::new(); write!(recorder, "a\u{300}bc\u{300}一二三").unwrap(); // Truncate start insta::assert_snapshot!( - format_colored(|formatter| write_truncated_start(formatter, &recorder, 1).map(|_| ())), + format_colored(|formatter| { + write_truncated_start(formatter, &recorder, &ellipsis_recorder, 1).map(|_| ()) + }), @"" ); insta::assert_snapshot!( - format_colored(|formatter| write_truncated_start(formatter, &recorder, 2).map(|_| ())), + format_colored(|formatter| { + write_truncated_start(formatter, &recorder, &ellipsis_recorder, 2).map(|_| ()) + }), @"三" ); insta::assert_snapshot!( - format_colored(|formatter| write_truncated_start(formatter, &recorder, 3).map(|_| ())), + format_colored(|formatter| { + write_truncated_start(formatter, &recorder, &ellipsis_recorder, 3).map(|_| ()) + }), @"三" ); insta::assert_snapshot!( - format_colored(|formatter| write_truncated_start(formatter, &recorder, 6).map(|_| ())), + format_colored(|formatter| { + write_truncated_start(formatter, &recorder, &ellipsis_recorder, 6).map(|_| ()) + }), @"一二三" ); insta::assert_snapshot!( - format_colored(|formatter| write_truncated_start(formatter, &recorder, 7).map(|_| ())), + format_colored(|formatter| { + write_truncated_start(formatter, &recorder, &ellipsis_recorder, 7).map(|_| ()) + }), @"c̀一二三" ); insta::assert_snapshot!( - format_colored(|formatter| write_truncated_start(formatter, &recorder, 9).map(|_| ())), + format_colored(|formatter| { + write_truncated_start(formatter, &recorder, &ellipsis_recorder, 9).map(|_| ()) + }), @"àbc̀一二三" ); insta::assert_snapshot!( - format_colored(|formatter| write_truncated_start(formatter, &recorder, 10).map(|_| ())), + format_colored(|formatter| { + write_truncated_start(formatter, &recorder, &ellipsis_recorder, 10).map(|_| ()) + }), @"àbc̀一二三" ); // Truncate end insta::assert_snapshot!( - format_colored(|formatter| write_truncated_end(formatter, &recorder, 1).map(|_| ())), + format_colored(|formatter| { + write_truncated_end(formatter, &recorder, &ellipsis_recorder, 1).map(|_| ()) + }), @"à" ); insta::assert_snapshot!( - format_colored(|formatter| write_truncated_end(formatter, &recorder, 4).map(|_| ())), + format_colored(|formatter| { + write_truncated_end(formatter, &recorder, &ellipsis_recorder, 4).map(|_| ()) + }), @"àbc̀" ); insta::assert_snapshot!( - format_colored(|formatter| write_truncated_end(formatter, &recorder, 5).map(|_| ())), + format_colored(|formatter| { + write_truncated_end(formatter, &recorder, &ellipsis_recorder, 5).map(|_| ()) + }), @"àbc̀一" ); insta::assert_snapshot!( - format_colored(|formatter| write_truncated_end(formatter, &recorder, 9).map(|_| ())), + format_colored(|formatter| { + write_truncated_end(formatter, &recorder, &ellipsis_recorder, 9).map(|_| ()) + }), @"àbc̀一二三" ); insta::assert_snapshot!( - format_colored(|formatter| write_truncated_end(formatter, &recorder, 10).map(|_| ())), + format_colored(|formatter| { + write_truncated_end(formatter, &recorder, &ellipsis_recorder, 10).map(|_| ()) + }), @"àbc̀一二三" ); } #[test] fn test_write_truncated_empty_content() { + let ellipsis_recorder = FormatRecorder::new(); let recorder = FormatRecorder::new(); // Truncate start insta::assert_snapshot!( - format_colored(|formatter| write_truncated_start(formatter, &recorder, 0).map(|_| ())), + format_colored(|formatter| { + write_truncated_start(formatter, &recorder, &ellipsis_recorder, 0).map(|_| ()) + }), @"" ); insta::assert_snapshot!( - format_colored(|formatter| write_truncated_start(formatter, &recorder, 1).map(|_| ())), + format_colored(|formatter| { + write_truncated_start(formatter, &recorder, &ellipsis_recorder, 1).map(|_| ()) + }), @"" ); // Truncate end insta::assert_snapshot!( - format_colored(|formatter| write_truncated_end(formatter, &recorder, 0).map(|_| ())), + format_colored(|formatter| { + write_truncated_end(formatter, &recorder, &ellipsis_recorder, 0).map(|_| ()) + }), @"" ); insta::assert_snapshot!( - format_colored(|formatter| write_truncated_end(formatter, &recorder, 1).map(|_| ())), + format_colored(|formatter| { + write_truncated_end(formatter, &recorder, &ellipsis_recorder, 1).map(|_| ()) + }), + @"" + ); + } + + #[test] + fn test_write_truncated_ellipsis_labeled() { + let ellipsis_recorder = FormatRecorder::with_data(".."); + let mut recorder = FormatRecorder::new(); + for (label, word) in [("red", "foo"), ("cyan", "bar")] { + recorder.push_label(label).unwrap(); + write!(recorder, "{word}").unwrap(); + recorder.pop_label().unwrap(); + } + + // Truncate start + insta::assert_snapshot!( + format_colored(|formatter| { + write_truncated_start(formatter, &recorder, &ellipsis_recorder, 6).map(|_| ()) + }), + @"foobar" + ); + insta::assert_snapshot!( + format_colored(|formatter| { + write_truncated_start(formatter, &recorder, &ellipsis_recorder, 5).map(|_| ()) + }), + @"..bar" + ); + insta::assert_snapshot!( + format_colored(|formatter| { + write_truncated_start(formatter, &recorder, &ellipsis_recorder, 3).map(|_| ()) + }), + @"..r" + ); + insta::assert_snapshot!( + format_colored(|formatter| { + write_truncated_start(formatter, &recorder, &ellipsis_recorder, 2).map(|_| ()) + }), + @".." + ); + insta::assert_snapshot!( + format_colored(|formatter| { + write_truncated_start(formatter, &recorder, &ellipsis_recorder, 1).map(|_| ()) + }), + @"." + ); + insta::assert_snapshot!( + format_colored(|formatter| { + write_truncated_start(formatter, &recorder, &ellipsis_recorder, 0).map(|_| ()) + }), + @"" + ); + + // Truncate end + insta::assert_snapshot!( + format_colored(|formatter| { + write_truncated_end(formatter, &recorder, &ellipsis_recorder, 6).map(|_| ()) + }), + @"foobar" + ); + insta::assert_snapshot!( + format_colored(|formatter| { + write_truncated_end(formatter, &recorder, &ellipsis_recorder, 5).map(|_| ()) + }), + @"foo.." + ); + insta::assert_snapshot!( + format_colored(|formatter| { + write_truncated_end(formatter, &recorder, &ellipsis_recorder, 3).map(|_| ()) + }), + @"f.." + ); + insta::assert_snapshot!( + format_colored(|formatter| { + write_truncated_end(formatter, &recorder, &ellipsis_recorder, 2).map(|_| ()) + }), + @".." + ); + insta::assert_snapshot!( + format_colored(|formatter| { + write_truncated_end(formatter, &recorder, &ellipsis_recorder, 1).map(|_| ()) + }), + @"." + ); + insta::assert_snapshot!( + format_colored(|formatter| { + write_truncated_end(formatter, &recorder, &ellipsis_recorder, 0).map(|_| ()) + }), + @"" + ); + } + + #[test] + fn test_write_truncated_ellipsis_non_ascii_chars() { + let ellipsis_recorder = FormatRecorder::with_data(".."); + let mut recorder = FormatRecorder::new(); + write!(recorder, "a\u{300}bc\u{300}一二三").unwrap(); + + // Truncate start + insta::assert_snapshot!( + format_colored(|formatter| { + write_truncated_start(formatter, &recorder, &ellipsis_recorder, 1).map(|_| ()) + }), + @"." + ); + insta::assert_snapshot!( + format_colored(|formatter| { + write_truncated_start(formatter, &recorder, &ellipsis_recorder, 2).map(|_| ()) + }), + @".." + ); + insta::assert_snapshot!( + format_colored(|formatter| { + write_truncated_start(formatter, &recorder, &ellipsis_recorder, 4).map(|_| ()) + }), + @"..三" + ); + insta::assert_snapshot!( + format_colored(|formatter| { + write_truncated_start(formatter, &recorder, &ellipsis_recorder, 7).map(|_| ()) + }), + @"..二三" + ); + + // Truncate end + insta::assert_snapshot!( + format_colored(|formatter| { + write_truncated_end(formatter, &recorder, &ellipsis_recorder, 1).map(|_| ()) + }), + @"." + ); + insta::assert_snapshot!( + format_colored(|formatter| { + write_truncated_end(formatter, &recorder, &ellipsis_recorder, 4).map(|_| ()) + }), + @"àb.." + ); + insta::assert_snapshot!( + format_colored(|formatter| { + write_truncated_end(formatter, &recorder, &ellipsis_recorder, 5).map(|_| ()) + }), + @"àbc̀.." + ); + insta::assert_snapshot!( + format_colored(|formatter| { + write_truncated_end(formatter, &recorder, &ellipsis_recorder, 9).map(|_| ()) + }), + @"àbc̀一二三" + ); + insta::assert_snapshot!( + format_colored(|formatter| { + write_truncated_end(formatter, &recorder, &ellipsis_recorder, 10).map(|_| ()) + }), + @"àbc̀一二三" + ); + } + + #[test] + fn test_write_truncated_ellipsis_empty_content() { + let ellipsis_recorder = FormatRecorder::with_data(".."); + let recorder = FormatRecorder::new(); + + // Truncate start, empty content + insta::assert_snapshot!( + format_colored(|formatter| { + write_truncated_start(formatter, &recorder, &ellipsis_recorder, 0).map(|_| ()) + }), + @"" + ); + insta::assert_snapshot!( + format_colored(|formatter| { + write_truncated_start(formatter, &recorder, &ellipsis_recorder, 1).map(|_| ()) + }), + @"" + ); + + // Truncate end + insta::assert_snapshot!( + format_colored(|formatter| { + write_truncated_end(formatter, &recorder, &ellipsis_recorder, 0).map(|_| ()) + }), + @"" + ); + insta::assert_snapshot!( + format_colored(|formatter| { + write_truncated_end(formatter, &recorder, &ellipsis_recorder, 1).map(|_| ()) + }), @"" ); } diff --git a/docs/templates.md b/docs/templates.md index 6f5018387..7a238ec3b 100644 --- a/docs/templates.md +++ b/docs/templates.md @@ -59,10 +59,14 @@ The following functions are defined. content by adding both leading and trailing fill characters. If an odd number of fill characters are needed, the trailing fill will be one longer than the leading fill. The `content` shouldn't have newline characters. -* `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. +* `truncate_start(width: Integer, content: Template[, ellipsis: Template])`: + Truncate `content` by removing leading characters. The `content` shouldn't + have newline character. If `ellipsis` is provided and `content` was truncated, + prepend the `ellipsis` to the result. +* `truncate_end(width: Integer, content: Template[, ellipsis: Template])`: + Truncate `content` by removing trailing characters. The `content` shouldn't + have newline character. If `ellipsis` is provided and `content` was truncated, + append the `ellipsis` to the result. * `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