diff --git a/CHANGELOG.md b/CHANGELOG.md index 9128643fd..8861c1a1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -105,6 +105,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Revsets gained a new function `mine()` that aliases `author(exact:"your_email")`. * `jj log` timestamp format now accepts `.utc()` to convert a timestamp to UTC. + +* templates now support additional string methods `.starts_with(x)`, `.ends_with(x)` + `.remove_prefix(x)`, `.remove_suffix(x)`, and `.substr(start, end)`. ### Fixed bugs diff --git a/cli/src/template_builder.rs b/cli/src/template_builder.rs index 166827acf..bfa452cb7 100644 --- a/cli/src/template_builder.rs +++ b/cli/src/template_builder.rs @@ -329,6 +329,76 @@ fn build_string_method<'a, L: TemplateLanguage<'a>>( |(haystack, needle)| haystack.contains(&needle), )) } + "starts_with" => { + let [needle_node] = template_parser::expect_exact_arguments(function)?; + let needle_property = expect_plain_text_expression(language, build_ctx, needle_node)?; + language.wrap_boolean(TemplateFunction::new( + (self_property, needle_property), + move |(haystack, needle)| haystack.starts_with(&needle), + )) + } + "ends_with" => { + let [needle_node] = template_parser::expect_exact_arguments(function)?; + let needle_property = expect_plain_text_expression(language, build_ctx, needle_node)?; + language.wrap_boolean(TemplateFunction::new( + (self_property, needle_property), + move |(haystack, needle)| haystack.ends_with(&needle), + )) + } + "remove_prefix" => { + let [needle_node] = template_parser::expect_exact_arguments(function)?; + let needle_property = expect_plain_text_expression(language, build_ctx, needle_node)?; + language.wrap_string(TemplateFunction::new( + (self_property, needle_property), + move |(haystack, needle)| { + haystack + .strip_prefix(&needle) + .map(ToOwned::to_owned) + .unwrap_or(haystack) + }, + )) + } + "remove_suffix" => { + let [needle_node] = template_parser::expect_exact_arguments(function)?; + let needle_property = expect_plain_text_expression(language, build_ctx, needle_node)?; + language.wrap_string(TemplateFunction::new( + (self_property, needle_property), + move |(haystack, needle)| { + haystack + .strip_suffix(&needle) + .map(ToOwned::to_owned) + .unwrap_or(haystack) + }, + )) + } + "substr" => { + let [start_idx, end_idx] = template_parser::expect_exact_arguments(function)?; + let start_idx_property = expect_integer_expression(language, build_ctx, start_idx)?; + let end_idx_property = expect_integer_expression(language, build_ctx, end_idx)?; + language.wrap_string(TemplateFunction::new( + (self_property, start_idx_property, end_idx_property), + |(s, start_idx, end_idx)| { + let to_idx = |i: i64| -> usize { + let magnitude = usize::try_from(i.unsigned_abs()).unwrap_or(usize::MAX); + if i < 0 { + s.len().saturating_sub(magnitude) + } else { + magnitude + } + }; + let start_idx = to_idx(start_idx); + let end_idx = to_idx(end_idx); + if start_idx >= end_idx { + String::new() + } else { + s.chars() + .skip(start_idx) + .take(end_idx - start_idx) + .collect() + } + }, + )) + } "first_line" => { template_parser::expect_no_arguments(function)?; language.wrap_string(TemplateFunction::new(self_property, |s| { diff --git a/cli/tests/test_templater.rs b/cli/tests/test_templater.rs index 401a5728b..eeb6e22d6 100644 --- a/cli/tests/test_templater.rs +++ b/cli/tests/test_templater.rs @@ -289,6 +289,8 @@ fn test_templater_list_method() { insta::assert_snapshot!( render(r#""a\nb\nc".lines().map(|s| "x\ny".lines().map(|t| s ++ t).join(",")).join(";")"#), @"ax,ay;bx,by;cx,cy"); + // Nested string operations + insta::assert_snapshot!(render(r#""!a\n!b\nc\nend".remove_suffix("end").lines().map(|s| s.remove_prefix("!"))"#), @"a b c"); // Lambda expression in alias insta::assert_snapshot!(render(r#""a\nb\nc".lines().map(identity)"#), @"a b c"); @@ -364,6 +366,36 @@ fn test_templater_string_method() { insta::assert_snapshot!(render(r#""".lines()"#), @""); insta::assert_snapshot!(render(r#""a\nb\nc\n".lines()"#), @"a b c"); + + insta::assert_snapshot!(render(r#""".starts_with("")"#), @"true"); + insta::assert_snapshot!(render(r#""everything".starts_with("")"#), @"true"); + insta::assert_snapshot!(render(r#""".starts_with("foo")"#), @"false"); + insta::assert_snapshot!(render(r#""foo".starts_with("foo")"#), @"true"); + insta::assert_snapshot!(render(r#""foobar".starts_with("foo")"#), @"true"); + insta::assert_snapshot!(render(r#""foobar".starts_with("bar")"#), @"false"); + + insta::assert_snapshot!(render(r#""".ends_with("")"#), @"true"); + insta::assert_snapshot!(render(r#""everything".ends_with("")"#), @"true"); + insta::assert_snapshot!(render(r#""".ends_with("foo")"#), @"false"); + insta::assert_snapshot!(render(r#""foo".ends_with("foo")"#), @"true"); + insta::assert_snapshot!(render(r#""foobar".ends_with("foo")"#), @"false"); + insta::assert_snapshot!(render(r#""foobar".ends_with("bar")"#), @"true"); + + insta::assert_snapshot!(render(r#""".remove_prefix("wip: ")"#), @""); + insta::assert_snapshot!(render(r#""wip: testing".remove_prefix("wip: ")"#), @"testing"); + + insta::assert_snapshot!(render(r#""bar@my.example.com".remove_suffix("@other.example.com")"#), @"bar@my.example.com"); + insta::assert_snapshot!(render(r#""bar@other.example.com".remove_suffix("@other.example.com")"#), @"bar"); + + insta::assert_snapshot!(render(r#""foo".substr(0, 0)"#), @""); + insta::assert_snapshot!(render(r#""foo".substr(0, 1)"#), @"f"); + insta::assert_snapshot!(render(r#""foo".substr(0, 99)"#), @"foo"); + insta::assert_snapshot!(render(r#""abcdef".substr(2, -1)"#), @"cde"); + insta::assert_snapshot!(render(r#""abcdef".substr(-3, 99)"#), @"def"); + + // ranges with end > start are empty + insta::assert_snapshot!(render(r#""abcdef".substr(4, 2)"#), @""); + insta::assert_snapshot!(render(r#""abcdef".substr(-2, -4)"#), @""); } #[test] diff --git a/docs/templates.md b/docs/templates.md index 4601a5716..2ce5d9a1f 100644 --- a/docs/templates.md +++ b/docs/templates.md @@ -142,6 +142,11 @@ defined. * `.lines() -> List`: Split into lines excluding newline characters. * `.upper() -> String` * `.lower() -> String` +* `.starts_with(needle: Template) -> Boolean` +* `.ends_with(needle: Template) -> Boolean` +* `.remove_prefix(needle: Template) -> String`: Removes the passed prefix, if present +* `.remove_suffix(needle: Template) -> String`: Removes the passed suffix, if present +* `.substr(start: Integer, end: Integer) -> String`: Extract substring. Negative values count from the end. ### Template type