From 2ad0379f5a71cb107f490525c555ee9f8067332e Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Fri, 10 Jul 2026 12:00:46 -0300 Subject: [PATCH 1/2] Fix I-Regexp deviances Signed-off-by: Juan Cruz Viotti --- .../regex/include/sourcemeta/core/regex.h | 6 +- src/core/regex/iregexp.h | 8 +++ src/core/regex/regex.cc | 11 ++- test/regex/regex_rfc9485_matches_test.cc | 67 ++++++++++--------- 4 files changed, 50 insertions(+), 42 deletions(-) diff --git a/src/core/regex/include/sourcemeta/core/regex.h b/src/core/regex/include/sourcemeta/core/regex.h index 7c8be93c9..713d48e70 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 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..64ed3ca5a 100644 --- a/src/core/regex/iregexp.h +++ b/src/core/regex/iregexp.h @@ -346,6 +346,14 @@ 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 are ordinary characters, so they are escaped for the + // engine instead of acting as assertions + 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..54f631ad8 100644 --- a/test/regex/regex_rfc9485_matches_test.cc +++ b/test/regex/regex_rfc9485_matches_test.cc @@ -65,50 +65,56 @@ 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) { 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 +1061,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) { From dbdaa901a7db424599db9759b9ded1f3e5da83c6 Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Fri, 10 Jul 2026 12:16:42 -0300 Subject: [PATCH 2/2] More Signed-off-by: Juan Cruz Viotti --- src/core/regex/include/sourcemeta/core/regex.h | 2 +- src/core/regex/iregexp.h | 10 ++++++++-- test/regex/regex_rfc9485_matches_test.cc | 6 ++++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/core/regex/include/sourcemeta/core/regex.h b/src/core/regex/include/sourcemeta/core/regex.h index 713d48e70..cda2876f3 100644 --- a/src/core/regex/include/sourcemeta/core/regex.h +++ b/src/core/regex/include/sourcemeta/core/regex.h @@ -76,7 +76,7 @@ enum class RegexDialect : std::uint8_t { Permissive, /// Strict RFC 9485 I-Regexp, where any pattern outside the grammar is /// rejected, matching considers the whole input, and an unescaped caret - /// or dollar sign is an ordinary character + /// 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 64ed3ca5a..28b83e035 100644 --- a/src/core/regex/iregexp.h +++ b/src/core/regex/iregexp.h @@ -349,8 +349,14 @@ inline auto iregexp_branches(const std::string_view pattern, quantifiable = true; } else if (character == '^' || character == '$') { // RFC 9485 Section 4 defers to XSD semantics, where the caret and the - // dollar sign are ordinary characters, so they are escaped for the - // engine instead of acting as assertions + // 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; diff --git a/test/regex/regex_rfc9485_matches_test.cc b/test/regex/regex_rfc9485_matches_test.cc index 54f631ad8..5565dd4de 100644 --- a/test/regex/regex_rfc9485_matches_test.cc +++ b/test/regex/regex_rfc9485_matches_test.cc @@ -66,6 +66,12 @@ TEST(rfc9485_matches_empty_branches_only) { } 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());