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.
This commit is contained in:
Josh Steadmon 2025-02-03 13:58:07 -08:00
parent 8144ffcaf8
commit 975e1e1e24
4 changed files with 363 additions and 52 deletions

View file

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

View file

@ -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<dyn Template + 'a>,
ellipsis: Option<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,
W: Fn(&mut dyn Formatter, &FormatRecorder, &FormatRecorder, usize) -> io::Result<usize> + '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)

View file

@ -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<usize> {
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<usize> {
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(|_| ())
}),
@""
);
}

View file

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