diff --git a/docs/templates.md b/docs/templates.md index 4238fd2a5..e7ebc5a89 100644 --- a/docs/templates.md +++ b/docs/templates.md @@ -137,6 +137,8 @@ Any types can be implicitly converted to `Template`. No methods are defined. The following methods are defined. * `.ago() -> String`: Format as relative timestamp. +* `.format(format: String) -> String`: Format with [the specified strftime-like + format string](https://docs.rs/chrono/latest/chrono/format/strftime/). ### TimestampRange type diff --git a/src/template_builder.rs b/src/template_builder.rs index 158b941f3..86544ccf8 100644 --- a/src/template_builder.rs +++ b/src/template_builder.rs @@ -17,7 +17,7 @@ use jujutsu_lib::backend::{Signature, Timestamp}; use crate::template_parser::{ self, ExpressionKind, ExpressionNode, FunctionCallNode, MethodCallNode, TemplateParseError, - TemplateParseResult, + TemplateParseErrorKind, TemplateParseResult, }; use crate::templater::{ ConcatTemplate, ConditionalTemplate, FormattablePropertyListTemplate, IntoTemplate, @@ -382,6 +382,21 @@ fn build_timestamp_method<'a, L: TemplateLanguage<'a>>( time_util::format_timestamp_relative_to_now(×tamp) })) } + "format" => { + // No dynamic string is allowed as the templater has no runtime error type. + let [format_node] = template_parser::expect_exact_arguments(function)?; + let format = + template_parser::expect_string_literal_with(format_node, |format, span| { + time_util::FormattingItems::parse(format).ok_or_else(|| { + let kind = TemplateParseErrorKind::InvalidTimeFormat; + TemplateParseError::with_span(kind, span) + }) + })? + .into_owned(); + language.wrap_string(TemplateFunction::new(self_property, move |timestamp| { + time_util::format_absolute_timestamp_with(×tamp, &format) + })) + } _ => return Err(TemplateParseError::no_such_method("Timestamp", function)), }; Ok(property) diff --git a/src/template_parser.rs b/src/template_parser.rs index c3d6e6b53..4e3693385 100644 --- a/src/template_parser.rs +++ b/src/template_parser.rs @@ -57,6 +57,8 @@ pub enum TemplateParseErrorKind { InvalidArgumentCountRangeFrom(RangeFrom), #[error(r#"Expected argument of type "{0}""#)] InvalidArgumentType(String), + #[error("Invalid time format")] + InvalidTimeFormat, #[error("Redefinition of function parameter")] RedefinedFunctionParameter, #[error(r#"Alias "{0}" cannot be expanded"#)] @@ -648,6 +650,26 @@ pub fn expect_arguments<'a, 'i, const N: usize, const M: usize>( } } +/// Applies the given function if the `node` is a string literal. +pub fn expect_string_literal_with<'a, 'i, T>( + node: &'a ExpressionNode<'i>, + f: impl FnOnce(&'a str, pest::Span<'i>) -> TemplateParseResult, +) -> TemplateParseResult { + match &node.kind { + ExpressionKind::String(s) => f(s, node.span), + ExpressionKind::Identifier(_) + | ExpressionKind::Integer(_) + | ExpressionKind::Concat(_) + | ExpressionKind::FunctionCall(_) + | ExpressionKind::MethodCall(_) => Err(TemplateParseError::invalid_argument_type( + "String literal", + node.span, + )), + ExpressionKind::AliasExpanded(id, subst) => expect_string_literal_with(subst, f) + .map_err(|e| e.within_alias_expansion(*id, node.span)), + } +} + #[cfg(test)] mod tests { use assert_matches::assert_matches; diff --git a/tests/test_templater.rs b/tests/test_templater.rs index 22152fa55..e54709da2 100644 --- a/tests/test_templater.rs +++ b/tests/test_templater.rs @@ -332,6 +332,73 @@ fn test_templater_signature() { insta::assert_snapshot!(render(r#"author.username()"#), @"x"); } +#[test] +fn test_templater_timestamp_method() { + let test_env = TestEnvironment::default(); + test_env.jj_cmd_success(test_env.env_root(), &["init", "repo", "--git"]); + let repo_path = test_env.env_root().join("repo"); + let render = |template| get_template_output(&test_env, &repo_path, "@-", template); + let render_err = |template| test_env.jj_cmd_failure(&repo_path, &["log", "-T", template]); + + test_env.add_config( + r###" + [template-aliases] + 'time_format' = '"%Y-%m-%d"' + 'bad_time_format' = '"%_"' + "###, + ); + + insta::assert_snapshot!( + render(r#"author.timestamp().format("%Y%m%d %H:%M:%S")"#), @"19700101 00:00:00"); + + // Invalid format string + insta::assert_snapshot!(render_err(r#"author.timestamp().format("%_")"#), @r###" + Error: Failed to parse template: --> 1:27 + | + 1 | author.timestamp().format("%_") + | ^--^ + | + = Invalid time format + "###); + + // Invalid type + insta::assert_snapshot!(render_err(r#"author.timestamp().format(0)"#), @r###" + Error: Failed to parse template: --> 1:27 + | + 1 | author.timestamp().format(0) + | ^ + | + = Expected argument of type "String literal" + "###); + + // Dynamic string isn't supported yet + insta::assert_snapshot!(render_err(r#"author.timestamp().format("%Y" ++ "%m")"#), @r###" + Error: Failed to parse template: --> 1:27 + | + 1 | author.timestamp().format("%Y" ++ "%m") + | ^----------^ + | + = Expected argument of type "String literal" + "###); + + // Literal alias expansion + insta::assert_snapshot!(render(r#"author.timestamp().format(time_format)"#), @"1970-01-01"); + insta::assert_snapshot!(render_err(r#"author.timestamp().format(bad_time_format)"#), @r###" + Error: Failed to parse template: --> 1:27 + | + 1 | author.timestamp().format(bad_time_format) + | ^-------------^ + | + = Alias "bad_time_format" cannot be expanded + --> 1:1 + | + 1 | "%_" + | ^--^ + | + = Invalid time format + "###); +} + #[test] fn test_templater_fill_function() { let test_env = TestEnvironment::default();