fix(regex): preserve escape values and boundaries (#28296)

Control and octal escapes could produce incorrect values or consume
extra digits, and leading-zero escapes could become backreferences.
Decode their values and boundaries according to the grammar and print
canonical control letters.

AI-assisted implementation.
This commit is contained in:
Boshen authored and GitHub committed 2026-10-29 16:30:17 +08:00
1 parent fc8acbc6ed
commit 3dc2cb0ae2
11 files changed
+60 -44

No files matched your search

@@ -325,13 +325,11 @@ fn character_to_string(
let result = match this.kind {
// Not a surrogate, like BMP, or all units in unicode mode
CharacterKind::Symbol => Cow::Owned(ch.to_string()),
CharacterKind::ControlLetter => match ch {
'\n' => Cow::Borrowed(r"\cJ"),
'\r' => Cow::Borrowed(r"\cM"),
'\t' => Cow::Borrowed(r"\cI"),
'\u{0019}' => Cow::Borrowed(r"\cY"),
_ => Cow::Owned(format!(r"\c{ch}")),
},
CharacterKind::ControlLetter => {
// Store the decoded value, and print its canonical ASCII control letter.
let letter = char::from_u32(cp + 0x40).expect("Invalid control letter `Character`!");
Cow::Owned(format!(r"\c{letter}"))
}
CharacterKind::Identifier => Cow::Owned(format!(r"\{ch}")),
CharacterKind::SingleEscape => match ch {
'\n' => Cow::Borrowed(r"\n"),
@@ -513,12 +511,12 @@ mod test {
(r"/(.)(.)(.)(.)(.)(.)(.)(.)\8\8/", None),
// Class escape from: <https://github.com/tc39/test262/blob/d62fa93c8f9ce5e687c0bbaa5d2b59670ab2ff60/test/annexB/language/literals/regexp/class-escape.js>
(r"/\c0/", None),
(r"/[\c0]/", None),
(r"/[\c0]/", Some(r"/[\cP]/")),
(r"/\c1/", None),
(r"/[\c10]+/", None),
(r"/[\c10]+/", Some(r"/[\cQ0]+/")),
(r"/\c8/", None),
(r"/[\c8]/", None),
(r"/[\c80]+/", None),
(r"/[\c8]/", Some(r"/[\cX]/")),
(r"/[\c80]+/", Some(r"/[\cX0]+/")),
(r"/\c_/", None),
// Capitalize hex unicodes --
(r"/^|\udf06/gu", Some(r"/^|\uDF06/gu")),
@@ -996,7 +996,14 @@ impl<'a> PatternParser<'a> {
}
if self.reader.eat('\\') {
if self.reader.peek().as_ref().is_some_and(|&cp| cp == 'c' as u32) {
if let Some(class_escape) = self.parse_class_escape(span_start)? {
return Ok(Some(class_escape));
}
// Annex B alternatives are tried in order, and do not extend Unicode patterns.
if !self.state.unicode_mode
&& self.reader.peek().as_ref().is_some_and(|&cp| cp == 'c' as u32)
{
return Ok(Some(ast::CharacterClassContents::Character(ArenaBox::new_in(
ast::Character {
span: self.span_factory.create(span_start, self.reader.offset()),
@@ -1007,10 +1014,6 @@ impl<'a> PatternParser<'a> {
))));
}
if let Some(class_escape) = self.parse_class_escape(span_start)? {
return Ok(Some(class_escape));
}
return Err(diagnostics::invalid_class_atom(
self.span_factory.create(span_start, self.reader.offset()),
));
@@ -1068,7 +1071,7 @@ impl<'a> PatternParser<'a> {
if let Some(cp) = self
.reader
.peek()
.filter(|&cp| character::is_decimal_digit(cp) || cp == '-' as u32)
.filter(|&cp| character::is_decimal_digit(cp) || cp == '_' as u32)
{
self.reader.advance();
@@ -1076,7 +1079,7 @@ impl<'a> PatternParser<'a> {
ast::Character {
span: self.span_factory.create(span_start, self.reader.offset()),
kind: ast::CharacterKind::ControlLetter,
value: cp,
value: cp % 0x20,
},
self,
))));
@@ -1798,7 +1801,11 @@ impl<'a> PatternParser<'a> {
// NonZeroDigit DecimalDigits[~Sep][opt] [lookahead ∉ DecimalDigit]
// ```
fn consume_decimal_escape(&mut self) -> Option<u64> {
let checkpoint = self.reader.checkpoint();
// DecimalEscape starts with NonZeroDigit; leading zero belongs to CharacterEscape.
if !self.reader.peek().is_some_and(|cp| character::is_decimal_digit(cp) && cp != '0' as u32)
{
return None;
}
let mut index = 0_u64;
while let Some(cp) = self.reader.peek().filter(|&cp| character::is_decimal_digit(cp)) {
@@ -1812,13 +1819,7 @@ impl<'a> PatternParser<'a> {
self.reader.advance();
}
// \0 is CharacterEscape, not DecimalEscape
if index != 0 {
return Some(index);
}
self.reader.rewind(checkpoint);
None
Some(index)
}
// ```
@@ -2200,11 +2201,11 @@ impl<'a> PatternParser<'a> {
}
if let Some(second) = self.consume_octal_digit() {
if let Some(third) = self.consume_octal_digit() {
// ZeroToThree OctalDigit OctalDigit
if first <= 3 {
return Some(first * 64 + second * 8 + third);
}
// Only ZeroToThree permits a third digit; FourToSeven consumes two.
if first <= 3
&& let Some(third) = self.consume_octal_digit()
{
return Some(first * 64 + second * 8 + third);
}
// ZeroToThree OctalDigit [lookahead ∉ OctalDigit]
@@ -0,0 +1 @@
/[\c1]/u;
@@ -0,0 +1 @@
/()\01/u;
@@ -0,0 +1 @@
const controls = [/\cA/u, /[\cA]/u, /[\cz]/u, /[\cA-\cZ]/u, /()\01/];
+2 -2
View File
@@ -1,3 +1,3 @@
codegen_misc Summary:
AST Parsed : 120/120 (100.00%)
Positive Passed: 120/120 (100.00%)
AST Parsed : 121/121 (100.00%)
Positive Passed: 121/121 (100.00%)
+2 -2
View File
@@ -1,3 +1,3 @@
formatter_misc Summary:
AST Parsed : 120/120 (100.00%)
Positive Passed: 120/120 (100.00%)
AST Parsed : 121/121 (100.00%)
Positive Passed: 121/121 (100.00%)
+2 -2
View File
@@ -1,3 +1,3 @@
lexer_misc Summary:
AST Parsed : 377/377 (100.00%)
Positive Passed: 377/377 (100.00%)
AST Parsed : 380/380 (100.00%)
Positive Passed: 380/380 (100.00%)
+17 -3
View File
@@ -1,7 +1,7 @@
parser_misc Summary:
AST Parsed : 120/120 (100.00%)
Positive Passed: 120/120 (100.00%)
Negative Passed: 259/259 (100.00%)
AST Parsed : 121/121 (100.00%)
Positive Passed: 121/121 (100.00%)
Negative Passed: 261/261 (100.00%)
× TS(1275): 'accessor' modifier can only appear on a property declaration.
╭─[misc/fail/accessor-modifier-abstract-method.ts:1:29]
@@ -5109,6 +5109,20 @@ Negative Passed: 259/259 (100.00%)
· ────────
╰────
× Invalid regular expression: Invalid class atom
╭─[misc/fail/regexp-unicode-class-control-digit.js:1:3]
1 │ /[\c1]/u;
· ─
╰────
help: This character or escape sequence is not valid inside a character class
× Invalid regular expression: Could not parse the entire pattern
╭─[misc/fail/regexp-unicode-leading-zero-reference.js:1:5]
1 │ /()\01/u;
· ▲
╰────
help: Check for unbalanced brackets or invalid escape sequences
× Invalid rest element target in destructuring assignment
╭─[misc/fail/rest-assignment-target.js:1:6]
1 │ ({...[]} = {});
+2 -2
View File
@@ -1,6 +1,6 @@
semantic_misc Summary:
AST Parsed : 120/120 (100.00%)
Positive Passed: 114/120 (95.00%)
AST Parsed : 121/121 (100.00%)
Positive Passed: 115/121 (95.04%)
semantic Error: tasks/coverage/misc/pass/declare-let-private.ts
Bindings mismatch:
after transform: ScopeId(0): ["private"]
@@ -1,3 +1,3 @@
transformer_misc Summary:
AST Parsed : 120/120 (100.00%)
Positive Passed: 120/120 (100.00%)
AST Parsed : 121/121 (100.00%)
Positive Passed: 121/121 (100.00%)