diff --git a/src/core/regex/include/sourcemeta/core/regex.h b/src/core/regex/include/sourcemeta/core/regex.h index 7c8be93c9..cda2876f3 100644 --- a/src/core/regex/include/sourcemeta/core/regex.h +++ b/src/core/regex/include/sourcemeta/core/regex.h @@ -75,10 +75,8 @@ enum class RegexDialect : std::uint8_t { /// A permissive superset of ECMA 262 with PCRE2 extensions Permissive, /// Strict RFC 9485 I-Regexp, where any pattern outside the grammar is - /// rejected and matching considers the whole input. Patterns are - /// interpreted through the RFC 9485 Section 5.4 engine mapping, so an - /// unescaped caret or dollar acts as an assertion instead of a literal - /// and rejects quantification + /// rejected, matching considers the whole input, and an unescaped caret + /// or dollar sign outside a character class is an ordinary character IRegexp, /// Like the strict RFC 9485 dialect, except that matching considers any /// substring of the input diff --git a/src/core/regex/iregexp.h b/src/core/regex/iregexp.h index d158b649b..28b83e035 100644 --- a/src/core/regex/iregexp.h +++ b/src/core/regex/iregexp.h @@ -346,6 +346,20 @@ inline auto iregexp_branches(const std::string_view pattern, return false; } + quantifiable = true; + } else if (character == '^' || character == '$') { + // RFC 9485 Section 4 defers to XSD semantics, where the caret and the + // dollar sign outside character classes are ordinary characters, so + // they are escaped for the engine instead of acting as assertions. A + // leading caret inside a character class remains the negation marker + // per the grammar. Note that the Section 5 engine mappings, which are + // "not normative" by their own introduction, pass these characters + // through unescaped and therefore silently give them assertion + // semantics, which is why most implementations and some test suites + // disagree with the normative behavior implemented here + output += '\\'; + output += character; + position += 1; quantifiable = true; } else if (character == ']' || character == '}') { // NormalChar excludes both closing delimiters, so they may only diff --git a/src/core/regex/regex.cc b/src/core/regex/regex.cc index 4cbbcfe56..c068a940c 100644 --- a/src/core/regex/regex.cc +++ b/src/core/regex/regex.cc @@ -47,13 +47,10 @@ auto to_regex(const std::string_view pattern, const RegexDialect dialect) } // Grouping in RFC 9485 carries no capturing semantics, as matching is - // strictly Boolean, so capturing is disabled altogether. The dollar - // option keeps a trailing dollar from also matching before a final - // newline, mirroring the ECMA 262 interpretation that the RFC mapping - // is defined against - return compile_pcre2(translated.value(), - PCRE2_UTF | PCRE2_UCP | PCRE2_DOLLAR_ENDONLY | - PCRE2_NO_AUTO_CAPTURE | PCRE2_MATCH_INVALID_UTF); + // strictly Boolean, so capturing is disabled altogether + return compile_pcre2(translated.value(), PCRE2_UTF | PCRE2_UCP | + PCRE2_NO_AUTO_CAPTURE | + PCRE2_MATCH_INVALID_UTF); } if (pattern == ".*" || pattern == "^.*$" || pattern == "^(.*)$" || diff --git a/test/regex/regex_rfc9485_matches_test.cc b/test/regex/regex_rfc9485_matches_test.cc index 787982052..5565dd4de 100644 --- a/test/regex/regex_rfc9485_matches_test.cc +++ b/test/regex/regex_rfc9485_matches_test.cc @@ -65,50 +65,62 @@ TEST(rfc9485_matches_empty_branches_only) { EXPECT_FALSE(sourcemeta::core::matches(regex.value(), "x")); } -TEST(rfc9485_matches_mapped_leading_caret) { +TEST(rfc9485_matches_literal_leading_caret) { + // NOTE: RFC 9485 Section 4 normatively makes an unescaped caret and + // dollar sign literal characters. The non normative Section 5 engine + // mappings silently turn them into assertions, so most implementations + // and the JSONPath compliance suite anchor cases disagree with the + // behavior asserted here. We deliberately implement the normative + // semantics, which is also what RFC 9535 requires by reference const auto regex{sourcemeta::core::to_regex( "^ab.*", sourcemeta::core::RegexDialect::IRegexp)}; EXPECT_TRUE(regex.has_value()); - EXPECT_TRUE(sourcemeta::core::matches(regex.value(), "ab")); - EXPECT_TRUE(sourcemeta::core::matches(regex.value(), "abc")); - EXPECT_FALSE(sourcemeta::core::matches(regex.value(), "^ab")); + EXPECT_TRUE(sourcemeta::core::matches(regex.value(), "^ab")); + EXPECT_TRUE(sourcemeta::core::matches(regex.value(), "^abc")); + EXPECT_FALSE(sourcemeta::core::matches(regex.value(), "ab")); + EXPECT_FALSE(sourcemeta::core::matches(regex.value(), "abc")); } -TEST(rfc9485_matches_mapped_trailing_dollar) { +TEST(rfc9485_matches_literal_trailing_dollar) { const auto regex{sourcemeta::core::to_regex( ".*bc$", sourcemeta::core::RegexDialect::IRegexp)}; EXPECT_TRUE(regex.has_value()); - EXPECT_TRUE(sourcemeta::core::matches(regex.value(), "abc")); - EXPECT_FALSE(sourcemeta::core::matches(regex.value(), "bc$")); + EXPECT_TRUE(sourcemeta::core::matches(regex.value(), "abc$")); + EXPECT_TRUE(sourcemeta::core::matches(regex.value(), "bc$")); + EXPECT_FALSE(sourcemeta::core::matches(regex.value(), "abc")); + EXPECT_FALSE(sourcemeta::core::matches(regex.value(), "bc$x")); } -TEST(rfc9485_matches_mapped_trailing_dollar_before_final_newline) { +TEST(rfc9485_matches_literal_dollar_at_end) { const auto regex{sourcemeta::core::to_regex( "ab$", sourcemeta::core::RegexDialect::IRegexp)}; EXPECT_TRUE(regex.has_value()); - EXPECT_TRUE(sourcemeta::core::matches(regex.value(), "ab")); + EXPECT_TRUE(sourcemeta::core::matches(regex.value(), "ab$")); + EXPECT_FALSE(sourcemeta::core::matches(regex.value(), "ab")); EXPECT_FALSE(sourcemeta::core::matches(regex.value(), "ab\n")); } -TEST(rfc9485_matches_mapped_middle_caret_never_matches) { - // NOTE: This test deviates from RFC 9485, which treats an unescaped caret - // as a literal character. We follow the RFC 9485 Section 5.4 engine - // mapping instead, where a caret in the middle of a pattern never matches +TEST(rfc9485_matches_literal_middle_caret) { const auto regex{sourcemeta::core::to_regex( "a^b", sourcemeta::core::RegexDialect::IRegexp)}; EXPECT_TRUE(regex.has_value()); - EXPECT_FALSE(sourcemeta::core::matches(regex.value(), "a^b")); + EXPECT_TRUE(sourcemeta::core::matches(regex.value(), "a^b")); EXPECT_FALSE(sourcemeta::core::matches(regex.value(), "ab")); } -TEST(rfc9485_matches_mapped_middle_dollar_never_matches) { - // NOTE: This test deviates from RFC 9485, which treats an unescaped dollar - // as a literal character. We follow the RFC 9485 Section 5.4 engine - // mapping instead, where a dollar in the middle of a pattern never matches +TEST(rfc9485_matches_literal_middle_dollar) { const auto regex{sourcemeta::core::to_regex( "a$b", sourcemeta::core::RegexDialect::IRegexp)}; EXPECT_TRUE(regex.has_value()); - EXPECT_FALSE(sourcemeta::core::matches(regex.value(), "a$b")); + EXPECT_TRUE(sourcemeta::core::matches(regex.value(), "a$b")); + EXPECT_FALSE(sourcemeta::core::matches(regex.value(), "ab")); +} + +TEST(rfc9485_matches_dollar_in_class) { + const auto regex{sourcemeta::core::to_regex( + "a[$]b", sourcemeta::core::RegexDialect::IRegexp)}; + EXPECT_TRUE(regex.has_value()); + EXPECT_TRUE(sourcemeta::core::matches(regex.value(), "a$b")); EXPECT_FALSE(sourcemeta::core::matches(regex.value(), "ab")); } @@ -1055,24 +1067,23 @@ TEST(rfc9485_invalid_utf8_in_class) { .has_value()); } -TEST(rfc9485_matches_mapped_quantified_caret_rejected) { - // NOTE: This test deviates from RFC 9485, whose grammar permits a - // quantifier on an unescaped caret. We follow the RFC 9485 Section 5.4 - // engine mapping instead, under which a quantified caret is not a valid - // expression, exactly as in the Section 5.3 ECMAScript mapping +TEST(rfc9485_matches_quantified_caret) { const auto regex{sourcemeta::core::to_regex( "^*", sourcemeta::core::RegexDialect::IRegexp)}; - EXPECT_FALSE(regex.has_value()); + EXPECT_TRUE(regex.has_value()); + EXPECT_TRUE(sourcemeta::core::matches(regex.value(), "")); + EXPECT_TRUE(sourcemeta::core::matches(regex.value(), "^")); + EXPECT_TRUE(sourcemeta::core::matches(regex.value(), "^^^")); + EXPECT_FALSE(sourcemeta::core::matches(regex.value(), "a")); } -TEST(rfc9485_matches_mapped_quantified_dollar_rejected) { - // NOTE: This test deviates from RFC 9485, whose grammar permits a - // quantifier on an unescaped dollar. We follow the RFC 9485 Section 5.4 - // engine mapping instead, under which a quantified dollar is not a valid - // expression, exactly as in the Section 5.3 ECMAScript mapping +TEST(rfc9485_matches_quantified_dollar) { const auto regex{sourcemeta::core::to_regex( "a$?", sourcemeta::core::RegexDialect::IRegexp)}; - EXPECT_FALSE(regex.has_value()); + EXPECT_TRUE(regex.has_value()); + EXPECT_TRUE(sourcemeta::core::matches(regex.value(), "a")); + EXPECT_TRUE(sourcemeta::core::matches(regex.value(), "a$")); + EXPECT_FALSE(sourcemeta::core::matches(regex.value(), "a$$")); } TEST(rfc9485_matches_empty_group) {