fix(regex): decode constructor patterns and flags consistently (#28298)

RegExp constructor source must decode to the same pattern and flags as
JavaScript string/template values. Correct escape values, continuations,
surrogate pairing, line endings, and escaped property aliases.

AI-assisted implementation.
This commit is contained in:
Boshen authored and GitHub committed 2026-10-29 16:30:18 +08:00
1 parent 8a90be278d
commit 2e3126c1cf
16 files changed
+172 -110

No files matched your search

+1
View File
@@ -20,3 +20,4 @@ crates/oxc_linter/src/generated/assert_layouts.rs linguist-generated=true
# Preserve the line terminators exercised by these parser regression fixtures.
tasks/coverage/misc/pass/tagged-template-invalid-escape-cr.js -text whitespace=cr-at-eol
tasks/coverage/misc/pass/tagged-template-invalid-escape-crlf.js -text whitespace=cr-at-eol
tasks/coverage/misc/pass/regexp-constructor-line-endings.js -text whitespace=cr-at-eol
@@ -93,11 +93,7 @@ impl<'a> ConstructorParser<'a> {
(false, false)
};
let pattern_text = if matches!(self.pattern_text, r#""""# | "''" | "``") {
r#""(?:)""#
} else {
self.pattern_text
};
let pattern_text = self.pattern_text;
let reader =
Reader::initialize(pattern_text, unicode_mode, parse_string_literal).map_err(|_| {
let span_start = pattern_span_offset;
@@ -106,6 +102,14 @@ impl<'a> ConstructorParser<'a> {
diagnostics::invalid_input(Span::new(span_start, span_end))
})?;
// A literal containing only line continuations also has an empty cooked value.
// Use the same replacement and synthetic spans as an empty quoted literal.
let reader = if reader.peek().is_none() {
Reader::initialize(r#""(?:)""#, unicode_mode, parse_string_literal)?
} else {
reader
};
PatternParser::new(
self.allocator,
reader,
@@ -1926,8 +1926,6 @@ impl<'a> PatternParser<'a> {
}
fn consume_unicode_property_name(&mut self) -> Option<Str<'a>> {
let span_start = self.reader.offset();
let checkpoint = self.reader.checkpoint();
while character::is_unicode_property_name_character(self.reader.peek()?) {
self.reader.advance();
@@ -1937,12 +1935,10 @@ impl<'a> PatternParser<'a> {
return None;
}
Some(self.reader.str(span_start, self.reader.offset()))
Some(self.unicode_property_text(checkpoint))
}
fn consume_unicode_property_value(&mut self) -> Option<Str<'a>> {
let span_start = self.reader.offset();
let checkpoint = self.reader.checkpoint();
while character::is_unicode_property_value_character(self.reader.peek()?) {
self.reader.advance();
@@ -1952,7 +1948,23 @@ impl<'a> PatternParser<'a> {
return None;
}
Some(self.reader.str(span_start, self.reader.offset()))
Some(self.unicode_property_text(checkpoint))
}
fn unicode_property_text(&self, checkpoint: (usize, u32)) -> Str<'a> {
let raw = self.reader.str(checkpoint.1, self.reader.offset());
let units = self.reader.code_points_since(checkpoint);
// Property names and values are ASCII. Equal lengths mean no string-source
// escapes or line continuations need decoding, so keep borrowing the source.
if raw.as_str().len() == units.len() {
return raw;
}
let mut decoded = ArenaStringBuilder::with_capacity_in(units.len(), self.allocator);
for unit in units {
decoded.push(char::from_u32(unit.value).expect("validated Unicode property character"));
}
Str::from(decoded.into_str())
}
// ```
@@ -19,7 +19,21 @@ pub fn is_line_terminator(ch: char) -> bool {
// ' " \ b f n r t v
// ```
pub fn is_single_escape_character(ch: char) -> bool {
matches!(ch, '\'' | '"' | '\\' | 'b' | 'f' | 'n' | 'r' | 't' | 'v')
map_single_escape_character(ch).is_some()
}
/// Code unit value of a `SingleEscapeCharacter` (ECMA-262, Table 33).
pub fn map_single_escape_character(ch: char) -> Option<u32> {
Some(match ch {
'b' => 0x08,
't' => 0x09,
'n' => 0x0a,
'v' => 0x0b,
'f' => 0x0c,
'r' => 0x0d,
'\'' | '"' | '\\' => ch as u32,
_ => return None,
})
}
// ```
@@ -5,10 +5,11 @@ use crate::parser::reader::{
CodePoint, Options,
ast::EscapeKind,
characters::{
CR, LF, LS, PS, is_line_terminator, is_non_escape_character, is_single_escape_character,
CR, LF, LS, PS, is_line_terminator, is_non_escape_character, map_single_escape_character,
},
string_literal_parser::{ast, diagnostics},
};
use crate::surrogate_pair;
// Internal representation of escape sequence resolved unit in a string literal.
// ((start, end), code_point, escape_kind)
@@ -58,6 +59,18 @@ impl Parser {
) {
let span = Span::new(span_offset + offsets.0, span_offset + offsets.1);
// Unicode patterns decode adjacent UTF-16 surrogate halves into one code point.
// Both halves came from Unicode escapes, so retain the leading escape kind.
if combine_surrogate_pair
&& surrogate_pair::is_trail_surrogate(cp)
&& let Some(previous) = body.last_mut()
&& surrogate_pair::is_lead_surrogate(previous.value)
{
previous.value = surrogate_pair::combine_surrogate_pair(previous.value, cp);
previous.span = previous.span.merge(span);
return;
}
if combine_surrogate_pair || (0..=0xffff).contains(&cp) {
// If the code point is in the BMP or if forced, just push it
body.push(CodePoint { span, value: cp, escape_kind });
@@ -117,7 +130,14 @@ impl Parser {
// ```
fn parse_string_characters(&mut self, single_or_double_quote: char) -> Result<Vec<CodePoint>> {
let mut body = vec![];
while let Some(code_point) = self.parse_string_character(single_or_double_quote)? {
loop {
// A LineContinuation contributes no code units to the string value.
if self.parse_line_continuation() {
continue;
}
let Some(code_point) = self.parse_string_character(single_or_double_quote)? else {
break;
};
Parser::handle_code_point(
&mut body,
code_point,
@@ -171,10 +191,6 @@ impl Parser {
}
self.rewind(checkpoint);
}
if let Some(cp) = self.parse_line_terminator_sequence() {
return Ok(Some(((offset_start, self.offset()), cp, EscapeKind::None)));
}
Ok(None)
}
@@ -239,9 +255,9 @@ impl Parser {
// NonEscapeCharacter
// ```
fn parse_character_escape_sequence(&mut self) -> Option<u32> {
if let Some(ch) = self.peek().filter(|&ch| is_single_escape_character(ch)) {
if let Some(cp) = self.peek().and_then(map_single_escape_character) {
self.advance();
return Some(ch as u32);
return Some(cp);
}
if let Some(ch) = self.peek().filter(|&ch| is_non_escape_character(ch)) {
self.advance();
@@ -271,16 +287,16 @@ impl Parser {
fn parse_legacy_octal_escape_sequence(&mut self) -> Option<u32> {
if let Some(first) = self.consume_octal_digit() {
// 0 [lookahead ∈ { 8, 9 }]
if first == 0 && self.peek().as_ref().is_some_and(|&ch| !matches!(ch, '8' | '9')) {
if first == 0 && self.peek().as_ref().is_some_and(|&ch| matches!(ch, '8' | '9')) {
return Some(first);
}
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);
}
// FourToSeven permits only two digits; leave a third for the next character.
if first <= 3
&& let Some(third) = self.consume_octal_digit()
{
return Some(first * 64 + second * 8 + third);
}
// ZeroToThree OctalDigit [lookahead ∉ OctalDigit]
@@ -369,38 +385,17 @@ impl Parser {
// <PS>
// <CR> <LF>
// ```
fn parse_line_terminator_sequence(&mut self) -> Option<u32> {
let checkpoint = self.checkpoint();
if self.eat('\\') {
if self.peek() == Some(LF) {
self.advance();
return Some(LF as u32);
}
if self.peek() == Some(CR) && self.peek2() != Some(LF) {
self.advance();
return Some(CR as u32);
}
if self.peek() == Some(LS) {
self.advance();
return Some(LS as u32);
}
if self.peek() == Some(PS) {
self.advance();
return Some(PS as u32);
}
// NOTE: CR+LF can not represent as a single code point.
// I don't know the best way to handle this.
// To distinguish this from CR and LF, structural change is needed...
if self.peek() == Some(CR) && self.peek2() == Some(LF) {
self.advance();
self.advance();
return Some(LF as u32);
}
fn parse_line_continuation(&mut self) -> bool {
if self.peek() != Some('\\') || !self.peek2().is_some_and(is_line_terminator) {
return false;
}
self.rewind(checkpoint);
None
self.advance(); // backslash
let terminator = self.peek();
self.advance();
if terminator == Some(CR) {
self.eat(LF);
}
true
}
// ---
@@ -5,10 +5,11 @@ use crate::parser::reader::{
Options,
ast::{CodePoint, EscapeKind},
characters::{
CR, LF, LS, PS, is_line_terminator, is_non_escape_character, is_single_escape_character,
CR, LF, LS, PS, is_line_terminator, is_non_escape_character, map_single_escape_character,
},
template_literal_parser::{ast, diagnostics},
};
use crate::surrogate_pair;
// Internal representation of escape sequence resolved unit in a template literal.
// ((start, end), code_point, escape_kind)
@@ -34,6 +35,18 @@ impl Parser {
) {
let span = Span::new(span_offset + offsets.0, span_offset + offsets.1);
// Unicode patterns decode adjacent UTF-16 surrogate halves into one code point.
// Both halves came from Unicode escapes, so retain the leading escape kind.
if combine_surrogate_pair
&& surrogate_pair::is_trail_surrogate(cp)
&& let Some(previous) = body.last_mut()
&& surrogate_pair::is_lead_surrogate(previous.value)
{
previous.value = surrogate_pair::combine_surrogate_pair(previous.value, cp);
previous.span = previous.span.merge(span);
return;
}
if combine_surrogate_pair || (0..=0xffff).contains(&cp) {
// If the code point is in the BMP or if forced, just push it
body.push(CodePoint { span, value: cp, escape_kind });
@@ -101,7 +114,14 @@ impl Parser {
// ```
fn parse_template_characters(&mut self) -> Result<Vec<CodePoint>> {
let mut body = vec![];
while let Some(code_point) = self.parse_template_character()? {
loop {
// A LineContinuation contributes no code units to the cooked value.
if self.parse_line_continuation() {
continue;
}
let Some(code_point) = self.parse_template_character()? else {
break;
};
Parser::handle_code_point(
&mut body,
code_point,
@@ -138,9 +158,6 @@ impl Parser {
return Ok(Some(((offset_start, self.offset()), cp, EscapeKind::None)));
}
}
if let Some(cp) = self.parse_line_continuation() {
return Ok(Some(((offset_start, self.offset()), cp, EscapeKind::None)));
}
if let Some(cp) = self.parse_line_terminator_sequence() {
return Ok(Some(((offset_start, self.offset()), cp, EscapeKind::None)));
}
@@ -200,9 +217,9 @@ impl Parser {
// NonEscapeCharacter
// ```
fn parse_character_escape_sequence(&mut self) -> Option<u32> {
if let Some(ch) = self.peek().filter(|&ch| is_single_escape_character(ch)) {
if let Some(cp) = self.peek().and_then(map_single_escape_character) {
self.advance();
return Some(ch as u32);
return Some(cp);
}
if let Some(ch) = self.peek().filter(|&ch| is_non_escape_character(ch)) {
self.advance();
@@ -400,17 +417,15 @@ impl Parser {
// \ LineTerminatorSequence
//
// ```
fn parse_line_continuation(&mut self) -> Option<u32> {
fn parse_line_continuation(&mut self) -> bool {
let checkpoint = self.checkpoint();
if self.eat('\\')
&& let Some(terminator) = self.parse_line_terminator_sequence()
{
return Some(terminator);
if self.eat('\\') && self.parse_line_terminator_sequence().is_some() {
return true;
}
self.rewind(checkpoint);
None
false
}
// ```
@@ -422,35 +437,19 @@ impl Parser {
// <CR> <LF>
// ```
fn parse_line_terminator_sequence(&mut self) -> Option<u32> {
let checkpoint = self.checkpoint();
if self.peek() == Some(LF) {
self.advance();
return Some(LF as u32);
match self.peek()? {
CR => {
self.advance();
self.eat(LF);
// TV normalizes both CR and CRLF to a single LF.
Some(LF as u32)
}
ch @ (LF | LS | PS) => {
self.advance();
Some(ch as u32)
}
_ => None,
}
if self.peek() == Some(CR) && self.peek2() != Some(LF) {
self.advance();
return Some(CR as u32);
}
if self.peek() == Some(LS) {
self.advance();
return Some(LS as u32);
}
if self.peek() == Some(PS) {
self.advance();
return Some(PS as u32);
}
// NOTE: CR+LF can not represent as a single code point.
// I don't know the best way to handle this.
// To distinguish this from CR and LF, structural change is needed...
if self.peek() == Some(CR) && self.peek2() == Some(LF) {
self.advance();
self.advance();
return Some(LF as u32);
}
self.rewind(checkpoint);
None
}
// ---
@@ -0,0 +1,17 @@
new RegExp("\b\f\n\r\t\v");
new RegExp("\n", "u");
new RegExp("\t", "g");
new RegExp("a\
b");
new RegExp("\
ab");
new RegExp("ab\
");
new RegExp("\
");
new RegExp("a\\nb");
new RegExp(`\b\f\n\r\t\v`);
new RegExp(`a\
b`);
new RegExp(`a
b`);
@@ -0,0 +1,7 @@
new RegExp("\01");
new RegExp("\012", "u");
new RegExp("\377", "v");
new RegExp("\477");
new RegExp("\777");
new RegExp("\08");
new RegExp("\09");
@@ -0,0 +1,9 @@
new RegExp(`a b`);
new RegExp(`a
b`);
new RegExp("a\ b");
new RegExp("a\
b");
new RegExp(`a\ b`);
new RegExp(`a\
b`);
@@ -0,0 +1,4 @@
new RegExp("\\p{\u0041SCII}", "u");
new RegExp("\\p{\x67c=\u004c}", "u");
new RegExp("\\p{Script=\u0047reek}", "\u0075");
new RegExp(`\\p{\u0053cript_Extensions=\x48ira}`, "v");
+2 -2
View File
@@ -1,3 +1,3 @@
codegen_misc Summary:
AST Parsed : 121/121 (100.00%)
Positive Passed: 121/121 (100.00%)
AST Parsed : 125/125 (100.00%)
Positive Passed: 125/125 (100.00%)
+2 -2
View File
@@ -1,3 +1,3 @@
formatter_misc Summary:
AST Parsed : 121/121 (100.00%)
Positive Passed: 121/121 (100.00%)
AST Parsed : 125/125 (100.00%)
Positive Passed: 125/125 (100.00%)
+2 -2
View File
@@ -1,3 +1,3 @@
lexer_misc Summary:
AST Parsed : 384/384 (100.00%)
Positive Passed: 384/384 (100.00%)
AST Parsed : 388/388 (100.00%)
Positive Passed: 388/388 (100.00%)
+2 -2
View File
@@ -1,6 +1,6 @@
parser_misc Summary:
AST Parsed : 121/121 (100.00%)
Positive Passed: 121/121 (100.00%)
AST Parsed : 125/125 (100.00%)
Positive Passed: 125/125 (100.00%)
Negative Passed: 265/265 (100.00%)
× TS(1275): 'accessor' modifier can only appear on a property declaration.
+2 -2
View File
@@ -1,6 +1,6 @@
semantic_misc Summary:
AST Parsed : 121/121 (100.00%)
Positive Passed: 115/121 (95.04%)
AST Parsed : 125/125 (100.00%)
Positive Passed: 119/125 (95.20%)
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 : 121/121 (100.00%)
Positive Passed: 121/121 (100.00%)
AST Parsed : 125/125 (100.00%)
Positive Passed: 125/125 (100.00%)