diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ae03aa2f..df804949a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 expressions](docs/filesets.md). Note that filesets are currently experimental, but will be enabled by default in a future release. +* Revsets and templates now support single-quoted raw string literals. + * `jj prev` and `jj next` now work when the working copy revision is a merge. * Operation objects in templates now have a `snapshot() -> Boolean` method that diff --git a/cli/src/template.pest b/cli/src/template.pest index 547b6fd5c..928b99132 100644 --- a/cli/src/template.pest +++ b/cli/src/template.pest @@ -24,6 +24,9 @@ string_content_char = @{ !("\"" | "\\") ~ ANY } string_content = @{ string_content_char+ } string_literal = ${ "\"" ~ (string_content | string_escape)* ~ "\"" } +raw_string_content = @{ (!"'" ~ ANY)* } +raw_string_literal = ${ "'" ~ raw_string_content ~ "'" } + integer_literal = @{ ASCII_NONZERO_DIGIT ~ ASCII_DIGIT* | "0" @@ -59,6 +62,7 @@ primary = _{ | lambda | identifier | string_literal + | raw_string_literal | integer_literal } diff --git a/cli/src/template_parser.rs b/cli/src/template_parser.rs index 0f040a611..0c68a9d83 100644 --- a/cli/src/template_parser.rs +++ b/cli/src/template_parser.rs @@ -42,6 +42,8 @@ impl Rule { Rule::string_content_char => None, Rule::string_content => None, Rule::string_literal => None, + Rule::raw_string_content => None, + Rule::raw_string_literal => None, Rule::integer_literal => None, Rule::identifier => None, Rule::concat_op => Some("++"), @@ -383,6 +385,12 @@ fn parse_term_node(pair: Pair) -> TemplateParseResult { let text = STRING_LITERAL_PARSER.parse(expr.into_inner()); ExpressionNode::new(ExpressionKind::String(text), span) } + Rule::raw_string_literal => { + let (content,) = expr.into_inner().collect_tuple().unwrap(); + assert_eq!(content.as_rule(), Rule::raw_string_content); + let text = content.as_str().to_owned(); + ExpressionNode::new(ExpressionKind::String(text), span) + } Rule::integer_literal => { let value = expr.as_str().parse().map_err(|err| { TemplateParseError::expression("Invalid integer literal", span).with_source(err) @@ -1178,6 +1186,24 @@ mod tests { parse_into_kind(r#" "\y" "#), Err(TemplateParseErrorKind::SyntaxError), ); + + // Single-quoted raw string + assert_eq!( + parse_into_kind(r#" '' "#), + Ok(ExpressionKind::String("".to_owned())), + ); + assert_eq!( + parse_into_kind(r#" 'a\n' "#), + Ok(ExpressionKind::String(r"a\n".to_owned())), + ); + assert_eq!( + parse_into_kind(r#" '\' "#), + Ok(ExpressionKind::String(r"\".to_owned())), + ); + assert_eq!( + parse_into_kind(r#" '"' "#), + Ok(ExpressionKind::String(r#"""#.to_owned())), + ); } #[test] diff --git a/docs/filesets.md b/docs/filesets.md index 9b68c724d..18c2d9667 100644 --- a/docs/filesets.md +++ b/docs/filesets.md @@ -13,14 +13,16 @@ ui.allow-filesets = true ``` Many `jj` commands accept fileset expressions as positional arguments. File -names passed to these commands must be quoted if they contain whitespace or meta -characters. However, as a special case, quotes can be omitted if the expression -has no operators nor function calls. For example: +names passed to these commands [must be quoted][string-literals] if they contain +whitespace or meta characters. However, as a special case, quotes can be omitted +if the expression has no operators nor function calls. For example: * `jj diff 'Foo Bar'` (shell quotes are required, but inner quotes are optional) * `jj diff '~"Foo Bar"'` (both shell and inner quotes are required) * `jj diff '"Foo(1)"'` (both shell and inner quotes are required) +[string-literals]: templates.md#string-literals + ## File patterns The following patterns are supported: diff --git a/docs/revsets.md b/docs/revsets.md index af956529c..59f061411 100644 --- a/docs/revsets.md +++ b/docs/revsets.md @@ -29,10 +29,12 @@ A full change ID refers to all visible commits with that change ID (there is typically only one visible commit with a given change ID). A unique prefix of the full change ID can also be used. It is an error to use a non-unique prefix. -Use double quotes to prevent a symbol from being interpreted as an expression. -For example, `"x-"` is the symbol `x-`, not the parents of symbol `x`. -Taking shell quoting into account, you may need to use something like -`jj log -r '"x-"'`. +Use [single or double quotes][string-literals] to prevent a symbol from being +interpreted as an expression. For example, `"x-"` is the symbol `x-`, not the +parents of symbol `x`. Taking shell quoting into account, you may need to use +something like `jj log -r '"x-"'`. + +[string-literals]: templates.md#string-literals ### Priority diff --git a/docs/templates.md b/docs/templates.md index 8230b138e..f0f2a396c 100644 --- a/docs/templates.md +++ b/docs/templates.md @@ -194,11 +194,22 @@ defined. #### String literals -String literals must be surrounded by double quotes (`"`). The following escape -sequences starting with a backslash have their usual meaning: `\"`, `\\`, `\n`, -`\r`, `\t`, `\0`. Other escape sequences are not supported. Any UTF-8 characters -are allowed inside a string literal, with two exceptions: unescaped `"`-s and -uses of `\` that don't form a valid escape sequence. +String literals must be surrounded by single or double quotes (`'` or `"`). +A double-quoted string literal supports the following escape sequences: + +* `\"`: double quote +* `\\`: backslash +* `\t`: horizontal tab +* `\r`: carriage return +* `\n`: new line +* `\0`: null + +Other escape sequences are not supported. Any UTF-8 characters are allowed +inside a string literal, with two exceptions: unescaped `"`-s and uses of `\` +that don't form a valid escape sequence. + +A single-quoted string literal has no escape syntax. `'` can't be expressed +inside a single-quoted string literal. ### Template type diff --git a/lib/src/fileset.pest b/lib/src/fileset.pest index a57da837a..e8b1488b9 100644 --- a/lib/src/fileset.pest +++ b/lib/src/fileset.pest @@ -40,6 +40,9 @@ string_content_char = @{ !("\"" | "\\") ~ ANY } string_content = @{ string_content_char+ } string_literal = ${ "\"" ~ (string_content | string_escape)* ~ "\"" } +raw_string_content = @{ (!"'" ~ ANY)* } +raw_string_literal = ${ "'" ~ raw_string_content ~ "'" } + pattern_kind_op = { ":" } negate_op = { "~" } @@ -57,7 +60,11 @@ function_arguments = { } // TODO: change rhs to string_literal to require quoting? #2101 -string_pattern = { strict_identifier ~ pattern_kind_op ~ (identifier | string_literal) } +string_pattern = { + strict_identifier + ~ pattern_kind_op + ~ (identifier | string_literal | raw_string_literal) +} bare_string_pattern = { strict_identifier ~ pattern_kind_op ~ bare_string } primary = { @@ -66,6 +73,7 @@ primary = { | string_pattern | identifier | string_literal + | raw_string_literal } expression = { diff --git a/lib/src/fileset_parser.rs b/lib/src/fileset_parser.rs index a3ce461a3..d77e65057 100644 --- a/lib/src/fileset_parser.rs +++ b/lib/src/fileset_parser.rs @@ -48,6 +48,8 @@ impl Rule { Rule::string_content_char => None, Rule::string_content => None, Rule::string_literal => None, + Rule::raw_string_content => None, + Rule::raw_string_literal => None, Rule::pattern_kind_op => Some(":"), Rule::negate_op => Some("~"), Rule::union_op => Some("|"), @@ -233,6 +235,11 @@ fn parse_as_string_literal(pair: Pair) -> String { match pair.as_rule() { Rule::identifier => pair.as_str().to_owned(), Rule::string_literal => STRING_LITERAL_PARSER.parse(pair.into_inner()), + Rule::raw_string_literal => { + let (content,) = pair.into_inner().collect_tuple().unwrap(); + assert_eq!(content.as_rule(), Rule::raw_string_content); + content.as_str().to_owned() + } r => panic!("unexpected string literal rule: {r:?}"), } } @@ -256,7 +263,9 @@ fn parse_primary_node(pair: Pair) -> FilesetParseResult { ExpressionKind::StringPattern { kind, value } } Rule::identifier => ExpressionKind::Identifier(first.as_str()), - Rule::string_literal => ExpressionKind::String(parse_as_string_literal(first)), + Rule::string_literal | Rule::raw_string_literal => { + ExpressionKind::String(parse_as_string_literal(first)) + } r => panic!("unexpected primary rule: {r:?}"), }; Ok(ExpressionNode::new(expr, span)) @@ -468,6 +477,24 @@ mod tests { parse_into_kind(r#" "\y" "#), Err(FilesetParseErrorKind::SyntaxError) ); + + // Single-quoted raw string + assert_eq!( + parse_into_kind(r#" '' "#), + Ok(ExpressionKind::String("".to_owned())), + ); + assert_eq!( + parse_into_kind(r#" 'a\n' "#), + Ok(ExpressionKind::String(r"a\n".to_owned())), + ); + assert_eq!( + parse_into_kind(r#" '\' "#), + Ok(ExpressionKind::String(r"\".to_owned())), + ); + assert_eq!( + parse_into_kind(r#" '"' "#), + Ok(ExpressionKind::String(r#"""#.to_owned())), + ); } #[test] @@ -493,6 +520,13 @@ mod tests { value: "".to_owned() }) ); + assert_eq!( + parse_into_kind(r#" foo:'\' "#), + Ok(ExpressionKind::StringPattern { + kind: "foo", + value: r"\".to_owned() + }) + ); assert_eq!( parse_into_kind(r#" foo: "#), Err(FilesetParseErrorKind::SyntaxError) diff --git a/lib/src/revset.pest b/lib/src/revset.pest index 0ad793b45..2461f8d0b 100644 --- a/lib/src/revset.pest +++ b/lib/src/revset.pest @@ -21,6 +21,7 @@ identifier = @{ symbol = { identifier | string_literal + | raw_string_literal } string_escape = @{ "\\" ~ ("t" | "r" | "n" | "0" | "\"" | "\\") } @@ -28,6 +29,9 @@ string_content_char = @{ !("\"" | "\\") ~ ANY } string_content = @{ string_content_char+ } string_literal = ${ "\"" ~ (string_content | string_escape)* ~ "\"" } +raw_string_content = @{ (!"'" ~ ANY)* } +raw_string_literal = ${ "'" ~ raw_string_content ~ "'" } + at_op = { "@" } pattern_kind_op = { ":" } diff --git a/lib/src/revset.rs b/lib/src/revset.rs index 3e62c6b67..33395aef1 100644 --- a/lib/src/revset.rs +++ b/lib/src/revset.rs @@ -108,6 +108,8 @@ impl Rule { Rule::string_content_char => None, Rule::string_content => None, Rule::string_literal => None, + Rule::raw_string_content => None, + Rule::raw_string_literal => None, Rule::at_op => Some("@"), Rule::pattern_kind_op => Some(":"), Rule::parents_op => Some("-"), @@ -1114,6 +1116,11 @@ fn parse_symbol_rule_as_literal(mut pairs: Pairs) -> String { match first.as_rule() { Rule::identifier => first.as_str().to_owned(), Rule::string_literal => STRING_LITERAL_PARSER.parse(first.into_inner()), + Rule::raw_string_literal => { + let (content,) = first.into_inner().collect_tuple().unwrap(); + assert_eq!(content.as_rule(), Rule::raw_string_content); + content.as_str().to_owned() + } _ => { panic!("unexpected symbol parse rule: {:?}", first.as_str()); } @@ -2839,6 +2846,13 @@ mod tests { "foo bar".to_string() )) ); + assert_eq!( + parse(r#"'foo bar'@'bar baz'"#), + Ok(RevsetExpression::remote_symbol( + "foo bar".to_string(), + "bar baz".to_string() + )) + ); // Quoted "@" is not interpreted as a working copy or remote symbol assert_eq!( parse(r#""@""#), @@ -2895,6 +2909,7 @@ mod tests { assert_eq!(parse("(foo)"), Ok(foo_symbol.clone())); // Parse a quoted symbol assert_eq!(parse("\"foo\""), Ok(foo_symbol.clone())); + assert_eq!(parse("'foo'"), Ok(foo_symbol.clone())); // Parse the "parents" operator assert_eq!(parse("foo-"), Ok(foo_symbol.parents())); // Parse the "children" operator @@ -3070,12 +3085,13 @@ mod tests { #[test] fn test_parse_string_literal() { + let branches_expr = + |s: &str| RevsetExpression::branches(StringPattern::Substring(s.to_owned())); + // "\" escapes assert_eq!( parse(r#"branches("\t\r\n\"\\\0")"#), - Ok(RevsetExpression::branches(StringPattern::Substring( - "\t\r\n\"\\\0".to_owned() - ))) + Ok(branches_expr("\t\r\n\"\\\0")) ); // Invalid "\" escape @@ -3083,6 +3099,12 @@ mod tests { parse(r#"branches("\y")"#), Err(RevsetParseErrorKind::SyntaxError) ); + + // Single-quoted raw string + assert_eq!(parse(r#"branches('')"#), Ok(branches_expr(""))); + assert_eq!(parse(r#"branches('a\n')"#), Ok(branches_expr(r"a\n"))); + assert_eq!(parse(r#"branches('\')"#), Ok(branches_expr(r"\"))); + assert_eq!(parse(r#"branches('"')"#), Ok(branches_expr(r#"""#))); } #[test] @@ -3117,6 +3139,12 @@ mod tests { "foo".to_owned() ))) ); + assert_eq!( + parse(r#"branches(exact:'\')"#), + Ok(RevsetExpression::branches(StringPattern::Exact( + r"\".to_owned() + ))) + ); assert_eq!( parse(r#"branches(bad:"foo")"#), Err(RevsetParseErrorKind::InvalidFunctionArguments { @@ -3426,8 +3454,8 @@ mod tests { // String literal should not be substituted with alias. assert_eq!( - parse_with_aliases(r#"A|"A""#, [("A", "a")]).unwrap(), - parse("a|A").unwrap() + parse_with_aliases(r#"A|"A"|'A'"#, [("A", "a")]).unwrap(), + parse("a|A|A").unwrap() ); // Alias can be substituted to string literal.