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
45 changes: 45 additions & 0 deletions crates/perry-runtime/src/regex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2040,6 +2040,51 @@ mod tests {
}
}

#[test]
fn annexb_legacy_decimal_escapes() {
// #5594: a `\<n>` with no matching capture group is an Annex B.1.4
// legacy octal escape, not a backreference — `\1` → `\x01`, never the
// bare `\1` the `regex`/`fancy-regex` crates reject.
assert_eq!(js_regex_to_rust(r"\1"), r"\x{01}");
assert_eq!(js_regex_to_rust(r"\b(\w+) \2\b"), r"\b(\w+) \x{02}\b");
// Multi-digit octal: `\12` = 0o12 = 0x0A, `\14` = 0o14 = 0x0C.
assert_eq!(js_regex_to_rust(r"[\12-\14]"), r"[\x{0A}-\x{0C}]");
// Inside a class a decimal escape is always octal, never a backref —
// even when that group exists.
assert_eq!(js_regex_to_rust(r"(a)[\1]"), r"(a)[\x{01}]");
// A real backward backreference is preserved for fancy-regex.
assert_eq!(js_regex_to_rust(r"(a)\1"), r"(a)\1");
// `\8` / `\9` are non-octal decimal escapes → literal digit.
assert_eq!(js_regex_to_rust(r"\8"), "8");
// `\0` is NUL; legacy `\012` = 0o12 = 0x0A.
assert_eq!(js_regex_to_rust(r"\0"), r"\x{00}");
assert_eq!(js_regex_to_rust(r"\012"), r"\x{0A}");

// The patterns that threw at construction must now compile and behave.
for pat in [r"\1", r"\b(\w+) \2\b", r"[\d][\12-\14]{1,}[^\d]"] {
let re = js_regexp_new(make_string(pat), make_string(""));
assert!(!re.is_null(), "pattern failed to construct: {pat}");
}
}

#[test]
fn annexb_invalid_control_escape_is_literal_backslash_c() {
// #5594: `\c` not followed by an ASCII control letter is the literal
// two-char sequence `\c`, not a control escape. The `regex`/`fancy-regex`
// crates reject a bare `\c`, so emit an escaped backslash + `c`.
assert_eq!(js_regex_to_rust(r"\cА"), r"\\cА"); // Cyrillic А (U+0410)
assert_eq!(js_regex_to_rust(r"\c "), r"\\c "); // space follows
assert_eq!(js_regex_to_rust(r"\c"), r"\\c"); // trailing
assert_eq!(js_regex_to_rust(r"[\c ]"), r"[\\c ]"); // inside a class
// A valid control letter still lowers to its control byte (`\cA` = 0x01).
assert_eq!(js_regex_to_rust(r"\cA"), r"\x{01}");

for pat in [r"\cА", r"\c!", r"[\c ]"] {
let re = js_regexp_new(make_string(pat), make_string(""));
assert!(!re.is_null(), "pattern failed to construct: {pat}");
}
}

#[test]
fn surrogate_pairs_fold_to_astral_scalars() {
// High escape + low class → contiguous astral range.
Expand Down
78 changes: 66 additions & 12 deletions crates/perry-runtime/src/regex/grammar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,34 @@ fn parse_decimal_escape(chars: &[char], mut i: usize) -> (usize, usize) {
(value, i - start)
}

/// Annex B.1.4: emit a `\<digits>` escape that is *not* a valid backreference as
/// a `LegacyOctalEscapeSequence` (or a `NonOctalDecimalEscapeSequence` for a
/// leading `8`/`9`). `start` indexes the first digit (just past the backslash).
/// Returns the number of digit chars consumed.
///
/// `\1` with no group 1, or `\2` referencing a non-existent group, must compile
/// as the literal byte rather than throwing — the `regex`/`fancy-regex` crates
/// reject `\1` outright, so we lower it to `\x{HH}`.
fn push_legacy_octal_escape(out: &mut String, chars: &[char], start: usize) -> usize {
let first = chars[start];
// `\8` / `\9` are not octal: they match the literal digit.
if first == '8' || first == '9' {
push_escaped_literal(out, first);
return 1;
}
// Up to three octal digits, but only two when the first is `4`–`7`
// (the value must stay ≤ 0o377 = 255).
let max = if matches!(first, '0'..='3') { 3 } else { 2 };
let mut value: u32 = 0;
let mut n = 0;
while n < max && start + n < chars.len() && matches!(chars[start + n], '0'..='7') {
value = value * 8 + (chars[start + n] as u32 - '0' as u32);
n += 1;
}
push_hex_escape(out, value as u8);
n
}

fn collect_capture_spans(chars: &[char]) -> Vec<CaptureSpan> {
let mut spans = Vec::new();
let mut stack: Vec<(usize, usize)> = Vec::new();
Expand Down Expand Up @@ -628,30 +656,56 @@ pub(super) fn js_regex_to_rust(pattern: &str) -> String {
result.push('/');
i += 2;
}
'c' if i + 2 < chars.len() => {
if let Some(value) = control_escape_value(chars[i + 2]) {
'c' => {
if let Some(value) = chars.get(i + 2).copied().and_then(control_escape_value) {
push_hex_escape(&mut result, value);
i += 3;
} else {
// Annex B.1.4: a `\c` not followed by an ASCII control
// letter (e.g. `\cА` with a Cyrillic letter, `\c$`, or a
// trailing `\c`) is *not* a control escape — it is the
// literal two-character sequence `\` `c`. Emit an escaped
// backslash plus a literal `c` (the `regex`/`fancy-regex`
// crates reject a bare `\c`); the following char, if any,
// is processed normally so quantifiers/class members keep
// their meaning. Works the same inside a `[...]` class.
result.push('\\');
result.push('\\');
result.push('c');
i += 2;
}
}
'0' if i + 2 >= chars.len() || !chars[i + 2].is_ascii_digit() => {
push_hex_escape(&mut result, 0);
i += 2;
'0' => {
// `\0` (NUL) and the legacy octal forms `\0DD` (Annex B.1.4)
// — `push_legacy_octal_escape` consumes the octal run and
// emits `\x{HH}`; a bare `\0` yields `\x00`.
let consumed = push_legacy_octal_escape(&mut result, &chars, i + 1);
i += 1 + consumed;
}
'1'..='9' => {
let (group, digits) = parse_decimal_escape(&chars, i + 1);
if is_forward_backreference(&capture_spans, i, group) {
i += 1 + digits;
} else {
result.push('\\');
for ch in &chars[i + 1..i + 1 + digits] {
result.push(*ch);
// Inside a `[...]` class a decimal escape is never a
// backreference — it is always a legacy octal/identity
// escape (e.g. `[\12-\14]` is the range `\x0A`–`\x0C`).
// Outside a class, `\<n>` is a backreference only when group
// `n` actually exists; otherwise Annex B.1.4 reinterprets it.
if !in_class && group <= capture_spans.len() {
if is_forward_backreference(&capture_spans, i, group) {
// A not-yet-closed group can't be matched by the
// `regex`/`fancy-regex` engines; drop the reference.
i += 1 + digits;
} else {
// A real backward backreference — keep it for
// fancy-regex (the `regex` crate has no backrefs).
result.push('\\');
for ch in &chars[i + 1..i + 1 + digits] {
result.push(*ch);
}
i += 1 + digits;
}
i += 1 + digits;
} else {
let consumed = push_legacy_octal_escape(&mut result, &chars, i + 1);
i += 1 + consumed;
}
}
'p' | 'P' if chars.get(i + 2) == Some(&'{') => {
Expand Down
50 changes: 50 additions & 0 deletions test-files/test_issue_5594_regexp_annexb_escapes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// #5594: Annex B legacy RegExp escapes — `\<n>` decimal escapes that aren't
// backreferences become legacy octal/identity escapes (not the `\1` the regex
// engines reject), and an invalid `\c<letter>` is the literal sequence `\c`.

function check(name: string, value: boolean) {
if (!value) {
throw new Error(name);
}
console.log(name + ": ok");
}

// A `\<n>` with no matching capture group is a legacy octal escape, so the
// pattern compiles and `\2` matches \x02 (absent here) instead of throwing.
check("decimal-not-capturing", /\b(\w+) \2\b/.test("do you listen the the band") === false);

// `.source` preserves the original pattern even though it lowered to \x01.
check("leading-escape-source", /\1/.source === "\\1");
check("leading-escape-a-source", /\a/.source === "\\a");
check("trailing-escape-source", /a\1/.source === "a\\1");

// Multi-digit legacy octal in a class range: `\12`=0o12=\n, `\14`=0o14=\x0C.
const cls = /[\d][\12-\14]{1,}[^\d]/.exec("line1\n\n\n\n\nline2");
check(
"decimal-class-range",
cls !== null && cls[0] === "1\n\n\n\n\nl" && cls.index === 4,
);

// `\8` / `\9` are non-octal decimal escapes → literal digit.
check("non-octal-eight", /\8/.test("8") && !/\8/.test("a"));

// A real backreference still works (fancy-regex path).
const back = /(A)\1/.exec("AA");
check("real-backref", back !== null && back[0] === "AA" && back[1] === "A");

// Invalid `\c` (followed by a non-ASCII-letter) is the literal two chars `\c`.
const cyrillic = String.fromCharCode(0x0410); // Cyrillic А
const source = "\\c" + cyrillic;
const re = new RegExp(source);
check("invalid-control-no-wraparound", re.exec(String.fromCharCode(0x0410 % 32)) === null);
check("invalid-control-not-c", re.exec(source.substring(1)) === null);
check("invalid-control-matches-literal", re.exec(source) !== null);

// Inside a character class, invalid `\c` contributes literal `\` and `c`.
const classRe = new RegExp("[\\c" + cyrillic + "]");
check("invalid-control-class-backslash", classRe.exec("\\") !== null);
check("invalid-control-class-c", classRe.exec("c") !== null);

// A valid control escape still lowers to its control byte.
const ctrl = new RegExp("\\cA").exec(String.fromCharCode(1));
check("valid-control-escape", ctrl !== null && ctrl[0] === String.fromCharCode(1));
Loading