diff --git a/omegaconf/grammar/OmegaConfGrammarLexer.g4 b/omegaconf/grammar/OmegaConfGrammarLexer.g4 index 50c4f10a6..e7325f8ad 100644 --- a/omegaconf/grammar/OmegaConfGrammarLexer.g4 +++ b/omegaconf/grammar/OmegaConfGrammarLexer.g4 @@ -16,13 +16,23 @@ fragment ESC_BACKSLASH: '\\\\'; // escaped backslash TOP_INTER_OPEN: INTER_OPEN -> type(INTER_OPEN), pushMode(INTERPOLATION_MODE); -ESC_INTER: '\\${'; -TOP_ESC: ESC_BACKSLASH+ -> type(ESC); +// Regular string: anything that does not contain any $ and does not end with \ +// (this ensures this rule will not consume characters required to recognize other tokens). +ANY_STR: ~[$]* ~[\\$]; + +// Escaped interpolation: '\${', optionally preceded by an even number of \ +ESC_INTER: ESC_BACKSLASH* '\\${'; + +// Escaped backslashes: even number of \ followed by an interpolation. +// The interpolation must *not* be matched by this rule (this is why we use a predicate lookahead). +TOP_ESC: ESC_BACKSLASH+ { self._input.LA(1) == ord("$") and self._input.LA(2) == ord("{") }? -> type(ESC); + +// Other backslashes that will not need escaping. +BACKSLASHES: '\\'+ -> type(ANY_STR); + +// The dollar sign must be singled out so that we can recognize interpolations. +DOLLAR: '$' -> type(ANY_STR); -// The backslash and dollar characters must not be grouped with others, so that -// we can properly detect the tokens above. -SPECIAL_CHAR: [\\$]; -ANY_STR: ~[\\$]+; // anything else //////////////// // VALUE_MODE // @@ -96,11 +106,26 @@ mode QUOTED_SINGLE_MODE; QSINGLE_INTER_OPEN: INTER_OPEN -> type(INTER_OPEN), pushMode(INTERPOLATION_MODE); MATCHING_QUOTE_CLOSE: '\'' -> popMode; -QSINGLE_ESC: ('\\\'' | ESC_BACKSLASH)+ -> type(ESC); +// Regular string: anything that does not contain any $ *or quote* and does not end with \ +QSINGLE_STR: ~['$]* ~['\\$] -> type(ANY_STR); + QSINGLE_ESC_INTER: ESC_INTER -> type(ESC_INTER); -QSINGLE_SPECIAL_CHAR: SPECIAL_CHAR -> type(SPECIAL_CHAR); -QSINGLE_STR: ~['\\$]+ -> type(ANY_STR); +// In a quoted string we also have the following escape sequences: +// - \', optionally preceded by an even number of \ (escaped quote) +// - an even number of \ followed by either the closing quote (trailing backslashes) or by +// an interpolation (as in `DEFAULT_MODE`) -- which must not be matched by this rule +QSINGLE_ESC: + ( + ESC_BACKSLASH* '\\\'' | + ESC_BACKSLASH+ {( + self._input.LA(1) == ord("'") or + (self._input.LA(1) == ord("$") and self._input.LA(2) == ord("{")) + )}? + ) -> type(ESC); + +QSINGLE_BACKSLASHES: '\\'+ -> type(ANY_STR); +QSINGLE_DOLLAR: '$' -> type(ANY_STR); //////////////////////// @@ -114,8 +139,18 @@ mode QUOTED_DOUBLE_MODE; QDOUBLE_INTER_OPEN: INTER_OPEN -> type(INTER_OPEN), pushMode(INTERPOLATION_MODE); QDOUBLE_CLOSE: '"' -> type(MATCHING_QUOTE_CLOSE), popMode; -QDOUBLE_ESC: ('\\"' | ESC_BACKSLASH)+ -> type(ESC); +QDOUBLE_STR: ~["$]* ~["\\$] -> type(ANY_STR); + QDOUBLE_ESC_INTER: ESC_INTER -> type(ESC_INTER); -QDOUBLE_SPECIAL_CHAR: SPECIAL_CHAR -> type(SPECIAL_CHAR); -QDOUBLE_STR: ~["\\$]+ -> type(ANY_STR); +QDOUBLE_ESC: + ( + ESC_BACKSLASH* '\\"' | + ESC_BACKSLASH+ {( + self._input.LA(1) == ord('"') or + (self._input.LA(1) == ord("$") and self._input.LA(2) == ord("{")) + )}? + ) -> type(ESC); + +QDOUBLE_BACKSLASHES: '\\'+ -> type(ANY_STR); +QDOUBLE_DOLLAR: '$' -> type(ANY_STR); diff --git a/omegaconf/grammar/OmegaConfGrammarParser.g4 b/omegaconf/grammar/OmegaConfGrammarParser.g4 index 4383da946..5ec70cf07 100644 --- a/omegaconf/grammar/OmegaConfGrammarParser.g4 +++ b/omegaconf/grammar/OmegaConfGrammarParser.g4 @@ -23,7 +23,7 @@ singleElement: element EOF; // Composite text expression (may contain interpolations). -text: (interpolation | ESC | ESC_INTER | SPECIAL_CHAR | ANY_STR)+; +text: (interpolation | ESC | ESC_INTER | ANY_STR)+; // Elements. diff --git a/omegaconf/grammar_visitor.py b/omegaconf/grammar_visitor.py index 4588253ef..8df6bfa70 100644 --- a/omegaconf/grammar_visitor.py +++ b/omegaconf/grammar_visitor.py @@ -289,7 +289,7 @@ def visitSingleElement( return self.visit(ctx.getChild(0)) def visitText(self, ctx: OmegaConfGrammarParser.TextContext) -> Any: - # (interpolation | ESC | ESC_INTER | SPECIAL_CHAR | ANY_STR)+ + # (interpolation | ESC | ESC_INTER | ANY_STR)+ # Single interpolation? If yes, return its resolved value "as is". if ctx.getChildCount() == 1: @@ -356,7 +356,10 @@ def _unescape( if s.type == OmegaConfGrammarLexer.ESC: chrs.append(s.text[1::2]) elif s.type == OmegaConfGrammarLexer.ESC_INTER: - chrs.append(s.text[1:]) + # `ESC_INTER` is of the form `\\...\${`: the formula below computes + # the number of characters to keep at the end of the string to remove + # the correct number of backslashes. + chrs.append(s.text[-(len(s.text) // 2 + 1) :]) else: chrs.append(s.text) else: diff --git a/tests/test_grammar.py b/tests/test_grammar.py index 025511bea..cabf4e54c 100644 --- a/tests/test_grammar.py +++ b/tests/test_grammar.py @@ -143,9 +143,14 @@ ("str_quoted_too_many_3", "''a''", GrammarParseError), ("str_quoted_trailing_esc_1", r"'abc\\'", r" abc\ ".strip()), ("str_quoted_trailing_esc_2", r"'abc\\\\'", r" abc\\ ".strip()), - ("str_quoted_no_esc_1", r'"abc\def"', r"abc\def"), - ("str_quoted_no_esc_2", r'"abc\\def"', r"abc\def"), - ("str_quoted_no_esc_3", r'"\\\abc\def"', r"\\abc\def"), + ("str_quoted_no_esc_single_1", r"'abc\def'", r"abc\def"), + ("str_quoted_no_esc_single_2", r"'abc\\def'", r"abc\\def"), + ("str_quoted_no_esc_single_3", r"'\\\abc\def'", r"\\\abc\def"), + ("str_quoted_no_esc_dollar_single", r"'abc\\$$'", r"abc\\$$"), + ("str_quoted_no_esc_double_1", r'"abc\def"', r"abc\def"), + ("str_quoted_no_esc_double_2", r'"abc\\def"', r"abc\\def"), + ("str_quoted_no_esc_double_3", r'"\\\abc\def"', r"\\\abc\def"), + ("str_quoted_no_esc_dollar_double", r'"abc\\$$"', r"abc\\$$"), ("str_quoted_bad_1", r'"abc\"', GrammarParseError), ("str_quoted_bad_2", r'"abc\\\"', GrammarParseError), ("str_quoted_esc_quote_single_1", r"'abc\'def'", "abc'def"), @@ -255,8 +260,10 @@ ("str_quoted_inter", "'${null}'", "None"), ("str_quoted_esc_single_1", r"'ab\'cd\'\'${str}'", "ab'cd''hi"), ("str_quoted_esc_single_2", r"""'\\\${foo}'""", r"\${foo}"), + ("str_quoted_esc_single_3", r"""'\\a_${str}\\'""", r" \\a_hi\ ".strip()), ("str_quoted_esc_double_1", r'"ab\"cd\"\"${str}"', 'ab"cd""hi'), ("str_quoted_esc_double_2", r'''"\\\${foo}"''', r"\${foo}"), + ("str_quoted_esc_double_3", r'''"\\a_${str}\\"''', r" \\a_hi\ ".strip()), ("str_quoted_other_quote_double", """'double"'""", 'double"'), ("str_quoted_other_quote_single", '''"single'"''', "single'"), ("str_quoted_concat_bad_1", '"Hi "${str}', GrammarParseError), @@ -357,13 +364,16 @@ ("str_top_esc_inter", r"Esc: \${str}", "Esc: ${str}"), ("str_top_esc_inter_wrong_1", r"Wrong: $\{str\}", r"Wrong: $\{str\}"), ("str_top_esc_inter_wrong_2", r"Wrong: \${str\}", r"Wrong: ${str\}"), - ("str_top_esc_backslash", r"Esc: \\${str}", r"Esc: \hi"), + ("str_top_esc_backslash_1", r"Esc: \\${str}", r"Esc: \hi"), + ("str_top_esc_backslash_2", r"Esc: \\\\${str}", r"Esc: \\hi"), ("str_top_quoted_braces_wrong", r"Wrong: \{${str}\}", r"Wrong: \{hi\}"), ("str_top_leading_dollars", r"$$${str}", "$$hi"), ("str_top_trailing_dollars", r"${str}$$$$", "hi$$$$"), - ("str_top_leading_escapes", r"\\\\\${str}", r"\\${str}"), - ("str_top_middle_escapes", r"abc\\\\\${str}", r"abc\\${str}"), - ("str_top_trailing_escapes", "${str}" + "\\" * 5, "hi" + "\\" * 3), + ("str_top_leading_escapes_1", r"\\\\\${str}", r"\\${str}"), + ("str_top_leading_escapes_2", r"\\\\ \${str}", r"\\\\ ${str}"), + ("str_top_middle_escapes_1", r"abc\\\\\${str}", r"abc\\${str}"), + ("str_top_middle_escapes_2", r"abc\\\\ \${str}", r"abc\\\\ ${str}"), + ("str_top_trailing_escapes", "${str}" + "\\" * 5, "hi" + "\\" * 5), ("str_top_concat_interpolations", "${null}${float}", "None1.2"), ("str_top_issue_617", r""" ${test: "hi\\" }"} """, r" hi\"} "), # Whitespaces.