diff --git a/CHANGELOG.md b/CHANGELOG.md index 632c4a224..aa0ab7fdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,8 +14,8 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### New features -* Templates now support the `==` logical operator for `Boolean`, `Integer`, and - `String` types. +* Templates now support the `==` and `!=` logical operators for `Boolean`, + `Integer`, and `String` types. * The `jj desc` and `jj st` aliases are now hidden to not interfere with shell completion. They remain available. diff --git a/cli/src/template.pest b/cli/src/template.pest index 83f8a6ed3..3061628fa 100644 --- a/cli/src/template.pest +++ b/cli/src/template.pest @@ -41,10 +41,11 @@ concat_op = { "++" } logical_or_op = { "||" } logical_and_op = { "&&" } logical_eq_op = { "==" } +logical_ne_op = { "!=" } logical_not_op = { "!" } negate_op = { "-" } prefix_ops = _{ logical_not_op | negate_op } -infix_ops = _{ logical_or_op | logical_and_op | logical_eq_op } +infix_ops = _{ logical_or_op | logical_and_op | logical_eq_op | logical_ne_op } function = { identifier ~ "(" ~ whitespace* ~ function_arguments ~ whitespace* ~ ")" } keyword_argument = { identifier ~ whitespace* ~ "=" ~ whitespace* ~ template } diff --git a/cli/src/template_builder.rs b/cli/src/template_builder.rs index 8456f8f6a..b61757dd7 100644 --- a/cli/src/template_builder.rs +++ b/cli/src/template_builder.rs @@ -638,7 +638,7 @@ fn build_binary_operation<'a, L: TemplateLanguage<'a> + ?Sized>( let out = lhs.and_then(move |l| Ok(l && rhs.extract()?)); Ok(L::wrap_boolean(out)) } - BinaryOp::LogicalEq => { + BinaryOp::LogicalEq | BinaryOp::LogicalNe => { let lhs = build_expression(language, diagnostics, build_ctx, lhs_node)?; let rhs = build_expression(language, diagnostics, build_ctx, rhs_node)?; let lty = lhs.type_name(); @@ -647,7 +647,11 @@ fn build_binary_operation<'a, L: TemplateLanguage<'a> + ?Sized>( let message = format!(r#"Cannot compare expressions of type "{lty}" and "{rty}""#); TemplateParseError::expression(message, span) })?; - Ok(L::wrap_boolean(out)) + match op { + BinaryOp::LogicalEq => Ok(L::wrap_boolean(out)), + BinaryOp::LogicalNe => Ok(L::wrap_boolean(out.map(|eq| !eq))), + _ => unreachable!(), + } } } } @@ -1728,14 +1732,14 @@ mod tests { env.add_keyword("description", || L::wrap_string(Literal("".to_owned()))); env.add_keyword("empty", || L::wrap_boolean(Literal(true))); - insta::assert_snapshot!(env.parse_err(r#"description ()"#), @r#" + insta::assert_snapshot!(env.parse_err(r#"description ()"#), @r" --> 1:13 | 1 | description () | ^--- | - = expected , `++`, `||`, `&&`, or `==` - "#); + = expected , `++`, `||`, `&&`, `==`, or `!=` + "); insta::assert_snapshot!(env.parse_err(r#"foo"#), @r###" --> 1:1 @@ -1787,10 +1791,10 @@ mod tests { | = Cannot compare expressions of type "Boolean" and "Integer" "#); - insta::assert_snapshot!(env.parse_err(r#"true == 'a'"#), @r#" + insta::assert_snapshot!(env.parse_err(r#"true != 'a'"#), @r#" --> 1:1 | - 1 | true == 'a' + 1 | true != 'a' | ^---------^ | = Cannot compare expressions of type "Boolean" and "String" @@ -1803,10 +1807,10 @@ mod tests { | = Cannot compare expressions of type "Integer" and "Boolean" "#); - insta::assert_snapshot!(env.parse_err(r#"1 == 'a'"#), @r#" + insta::assert_snapshot!(env.parse_err(r#"1 != 'a'"#), @r#" --> 1:1 | - 1 | 1 == 'a' + 1 | 1 != 'a' | ^------^ | = Cannot compare expressions of type "Integer" and "String" @@ -1819,10 +1823,10 @@ mod tests { | = Cannot compare expressions of type "String" and "Boolean" "#); - insta::assert_snapshot!(env.parse_err(r#"'a' == 1"#), @r#" + insta::assert_snapshot!(env.parse_err(r#"'a' != 1"#), @r#" --> 1:1 | - 1 | 'a' == 1 + 1 | 'a' != 1 | ^------^ | = Cannot compare expressions of type "String" and "Integer" @@ -2054,10 +2058,16 @@ mod tests { insta::assert_snapshot!(env.render_ok(r#"false && true"#), @"false"); insta::assert_snapshot!(env.render_ok(r#"true == true"#), @"true"); insta::assert_snapshot!(env.render_ok(r#"true == false"#), @"false"); + insta::assert_snapshot!(env.render_ok(r#"true != true"#), @"false"); + insta::assert_snapshot!(env.render_ok(r#"true != false"#), @"true"); insta::assert_snapshot!(env.render_ok(r#"1 == 1"#), @"true"); insta::assert_snapshot!(env.render_ok(r#"1 == 2"#), @"false"); + insta::assert_snapshot!(env.render_ok(r#"1 != 1"#), @"false"); + insta::assert_snapshot!(env.render_ok(r#"1 != 2"#), @"true"); insta::assert_snapshot!(env.render_ok(r#"'a' == 'a'"#), @"true"); insta::assert_snapshot!(env.render_ok(r#"'a' == 'b'"#), @"false"); + insta::assert_snapshot!(env.render_ok(r#"'a' != 'a'"#), @"false"); + insta::assert_snapshot!(env.render_ok(r#"'a' != 'b'"#), @"true"); insta::assert_snapshot!(env.render_ok(r#" !"" "#), @"true"); insta::assert_snapshot!(env.render_ok(r#" "" || "a".lines() "#), @"true"); diff --git a/cli/src/template_parser.rs b/cli/src/template_parser.rs index 536df62ec..406e12bf2 100644 --- a/cli/src/template_parser.rs +++ b/cli/src/template_parser.rs @@ -75,6 +75,7 @@ impl Rule { Rule::logical_or_op => Some("||"), Rule::logical_and_op => Some("&&"), Rule::logical_eq_op => Some("=="), + Rule::logical_ne_op => Some("!="), Rule::logical_not_op => Some("!"), Rule::negate_op => Some("-"), Rule::prefix_ops => None, @@ -377,6 +378,8 @@ pub enum BinaryOp { LogicalAnd, /// `==` LogicalEq, + /// `!=` + LogicalNe, } pub type ExpressionNode<'i> = dsl_util::ExpressionNode<'i, ExpressionKind<'i>>; @@ -507,7 +510,8 @@ fn parse_expression_node(pair: Pair) -> TemplateParseResult) -> TemplateParseResult BinaryOp::LogicalOr, Rule::logical_and_op => BinaryOp::LogicalAnd, Rule::logical_eq_op => BinaryOp::LogicalEq, + Rule::logical_ne_op => BinaryOp::LogicalNe, r => panic!("unexpected infix operator rule {r:?}"), }; let lhs = Box::new(lhs?); @@ -856,8 +861,8 @@ mod tests { parse_normalized("(!(x.f())) || (!(g()))"), ); assert_eq!( - parse_normalized("!x.f() == !x.f() || !g() == !g()"), - parse_normalized("((!(x.f())) == (!(x.f()))) || ((!(g())) == (!(g())))"), + parse_normalized("!x.f() == !x.f() || !g() != !g()"), + parse_normalized("((!(x.f())) == (!(x.f()))) || ((!(g())) != (!(g())))"), ); assert_eq!( parse_normalized("x.f() || y == y || z"), @@ -867,6 +872,10 @@ mod tests { parse_normalized("x || y == y && z.h() == z"), parse_normalized("x || ((y == y) && ((z.h()) == z))"), ); + assert_eq!( + parse_normalized("x == y || y != z && !z"), + parse_normalized("(x == y) || ((y != z) && (!z))"), + ); // Logical operator bounds more tightly than concatenation. This might // not be so intuitive, but should be harmless. @@ -882,6 +891,10 @@ mod tests { parse_normalized(r"x == y ++ z"), parse_normalized(r"(x == y) ++ z"), ); + assert_eq!( + parse_normalized(r"x != y ++ z"), + parse_normalized(r"(x != y) ++ z"), + ); // Expression span assert_eq!(parse_template(" ! x ").unwrap().span.as_str(), "! x"); diff --git a/cli/tests/test_templater.rs b/cli/tests/test_templater.rs index 7e454dda3..0bda44c37 100644 --- a/cli/tests/test_templater.rs +++ b/cli/tests/test_templater.rs @@ -25,15 +25,15 @@ fn test_templater_parse_error() { let repo_path = test_env.env_root().join("repo"); let render_err = |template| test_env.jj_cmd_failure(&repo_path, &["log", "-T", template]); - insta::assert_snapshot!(render_err(r#"description ()"#), @r#" + insta::assert_snapshot!(render_err(r#"description ()"#), @r" Error: Failed to parse template: Syntax error Caused by: --> 1:13 | 1 | description () | ^--- | - = expected , `++`, `||`, `&&`, or `==` - "#); + = expected , `++`, `||`, `&&`, `==`, or `!=` + "); // Typo test_env.add_config( diff --git a/docs/templates.md b/docs/templates.md index aac8f7414..58484135f 100644 --- a/docs/templates.md +++ b/docs/templates.md @@ -31,8 +31,8 @@ The following operators are supported. * `x.f()`: Method call. * `-x`: Negate integer value. * `!x`: Logical not. -* `x == y`: Logical equal. Operands must be either `Boolean`, `Integer`, or - `String`. +* `x == y`, `x != y`: Logical equal/not equal. Operands must be either + `Boolean`, `Integer`, or `String`. * `x && y`: Logical and, short-circuiting. * `x || y`: Logical or, short-circuiting. * `x ++ y`: Concatenate `x` and `y` templates.