Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions src/core/regex/include/sourcemeta/core/regex.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
jviotti marked this conversation as resolved.
/// 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
Expand Down
14 changes: 14 additions & 0 deletions src/core/regex/iregexp.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 4 additions & 7 deletions src/core/regex/regex.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "^(.*)$" ||
Expand Down
73 changes: 42 additions & 31 deletions test/regex/regex_rfc9485_matches_test.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
}

Expand Down Expand Up @@ -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) {
Expand Down
Loading