diff --git a/crates/perry-runtime/src/regex.rs b/crates/perry-runtime/src/regex.rs index da999c4c7a..e21f84b1f1 100644 --- a/crates/perry-runtime/src/regex.rs +++ b/crates/perry-runtime/src/regex.rs @@ -2040,6 +2040,51 @@ mod tests { } } + #[test] + fn annexb_legacy_decimal_escapes() { + // #5594: a `\` 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. diff --git a/crates/perry-runtime/src/regex/grammar.rs b/crates/perry-runtime/src/regex/grammar.rs index 1909c931ce..e011b26c75 100644 --- a/crates/perry-runtime/src/regex/grammar.rs +++ b/crates/perry-runtime/src/regex/grammar.rs @@ -13,6 +13,34 @@ fn parse_decimal_escape(chars: &[char], mut i: usize) -> (usize, usize) { (value, i - start) } +/// Annex B.1.4: emit a `\` 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 { let mut spans = Vec::new(); let mut stack: Vec<(usize, usize)> = Vec::new(); @@ -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, `\` 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(&'{') => { diff --git a/test-files/test_issue_5594_regexp_annexb_escapes.ts b/test-files/test_issue_5594_regexp_annexb_escapes.ts new file mode 100644 index 0000000000..80e15a14b3 --- /dev/null +++ b/test-files/test_issue_5594_regexp_annexb_escapes.ts @@ -0,0 +1,50 @@ +// #5594: Annex B legacy RegExp escapes — `\` decimal escapes that aren't +// backreferences become legacy octal/identity escapes (not the `\1` the regex +// engines reject), and an invalid `\c` is the literal sequence `\c`. + +function check(name: string, value: boolean) { + if (!value) { + throw new Error(name); + } + console.log(name + ": ok"); +} + +// A `\` 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));