fix(regular_expression): preserve UnicodeEscape CharacterKind in string literals (#18998)

## Summary

When parsing regex patterns from string literals (e.g., `RegExp("[A\\u0301]")`), unicode escape sequences were incorrectly identified as `CharacterKind::Symbol` instead of `CharacterKind::UnicodeEscape`.

**Before:**
```
RegExp("[A\u0301]")
// Character { value: 769, kind: Symbol }  // Wrong!
```

**After:**
```
RegExp("[A\u0301]")
// Character { value: 769, kind: UnicodeEscape }  // Correct!
```

The fix adds escape kind tracking through the parsing pipeline:
- Added `EscapeKind` enum to `CodePoint` to track how characters were written in source
- `StringLiteralParser` and `TemplateLiteralParser` now track unicode (`\uXXXX`, `\u{XXXX}`) and hex (`\xXX`) escapes
- `PatternParser` uses this information when assigning `CharacterKind`

Closes #13660

🤖 Generated with [Claude Code](https://claude.ai/code)
This commit is contained in:
Boshen committed 2026-02-06 00:37:13 +00:00
1 parent deb3c4fbf5
commit e3609e3918
7 files changed
+157 -44

No files matched your search

@@ -370,4 +370,49 @@ mod test {
.unwrap();
assert_eq!(ret1.to_string(), ret2.to_string());
}
/// Test for <https://github.com/oxc-project/oxc/issues/13660>
/// Unicode escapes in string literals should be detected as CharacterKind::UnicodeEscape
#[test]
fn unicode_escape_in_string_literal() {
use crate::ast::{CharacterClassContents, CharacterKind, Term};
let allocator = Allocator::default();
// RegExp literal: /[A\u0301]/
let pattern1 =
LiteralParser::new(&allocator, r"[A\u0301]", None, Options::default()).parse().unwrap();
// RegExp constructor: RegExp("[A\u0301]") - the string literal has the escape resolved
let pattern2 =
ConstructorParser::new(&allocator, r#""[A\u0301]""#, None, Options::default())
.parse()
.unwrap();
// Extract the character class from both patterns
let get_char_kinds = |pattern: &crate::ast::Pattern<'_>| -> Vec<CharacterKind> {
let term = &pattern.body.body[0].body[0];
if let Term::CharacterClass(cc) = term {
cc.body
.iter()
.filter_map(|item| {
if let CharacterClassContents::Character(c) = item {
Some(c.kind)
} else {
None
}
})
.collect()
} else {
vec![]
}
};
let kinds1 = get_char_kinds(&pattern1);
let kinds2 = get_char_kinds(&pattern2);
// Both should have: Symbol (A), UnicodeEscape (\u0301)
assert_eq!(kinds1, vec![CharacterKind::Symbol, CharacterKind::UnicodeEscape]);
assert_eq!(kinds2, vec![CharacterKind::Symbol, CharacterKind::UnicodeEscape]);
}
}
@@ -6,7 +6,7 @@ use crate::{
ast, diagnostics,
parser::{
pattern_parser::{character, state::State, unicode_property},
reader::Reader,
reader::{EscapeKind, Reader},
span_factory::SpanFactory,
},
surrogate_pair,
@@ -34,6 +34,16 @@ impl<'a> PatternParser<'a> {
}
}
/// Converts an `EscapeKind` from the reader to the appropriate `CharacterKind`.
/// This is used to preserve information about how a character was written in source code.
fn escape_kind_to_character_kind(escape_kind: EscapeKind) -> ast::CharacterKind {
match escape_kind {
EscapeKind::None => ast::CharacterKind::Symbol,
EscapeKind::Unicode => ast::CharacterKind::UnicodeEscape,
EscapeKind::Hexadecimal => ast::CharacterKind::HexadecimalEscape,
}
}
pub fn parse(mut self) -> Result<ast::Pattern<'a>> {
// Pre parse whole pattern to collect:
// - the number of (named|unnamed) capturing groups
@@ -311,12 +321,13 @@ impl<'a> PatternParser<'a> {
// PatternCharacter
if let Some(cp) = self.reader.peek().filter(|&cp| !character::is_syntax_character(cp)) {
let kind = Self::escape_kind_to_character_kind(self.reader.peek_escape_kind());
self.reader.advance();
return Ok(Some(ast::Term::Character(Box::new_in(
ast::Character {
span: self.span_factory.create(span_start, self.reader.offset()),
kind: ast::CharacterKind::Symbol,
kind,
value: cp,
},
self.allocator,
@@ -445,11 +456,12 @@ impl<'a> PatternParser<'a> {
}
// ExtendedPatternCharacter
let escape_kind = self.reader.peek_escape_kind();
if let Some(cp) = self.consume_extended_pattern_character() {
return Ok(Some(ast::Term::Character(Box::new_in(
ast::Character {
span: self.span_factory.create(span_start, self.reader.offset()),
kind: ast::CharacterKind::Symbol,
kind: Self::escape_kind_to_character_kind(escape_kind),
value: cp,
},
self.allocator,
@@ -957,12 +969,13 @@ impl<'a> PatternParser<'a> {
.peek()
.filter(|&cp| cp != '\\' as u32 && cp != ']' as u32 && cp != '-' as u32)
{
let kind = Self::escape_kind_to_character_kind(self.reader.peek_escape_kind());
self.reader.advance();
return Ok(Some(ast::CharacterClassContents::Character(Box::new_in(
ast::Character {
span: self.span_factory.create(span_start, self.reader.offset()),
kind: ast::CharacterKind::Symbol,
kind,
value: cp,
},
self.allocator,
@@ -1451,11 +1464,12 @@ impl<'a> PatternParser<'a> {
&& !character::is_class_set_reserved_double_punctuator(cp1, cp2)
&& !character::is_class_set_syntax_character(cp1)
{
let kind = Self::escape_kind_to_character_kind(self.reader.peek_escape_kind());
self.reader.advance();
return Ok(Some(ast::Character {
span: self.span_factory.create(span_start, self.reader.offset()),
kind: ast::CharacterKind::Symbol,
kind,
value: cp1,
}));
}
@@ -1,5 +1,19 @@
use oxc_span::Span;
/// Tracks how a code point was represented in source code.
/// This is needed to preserve information about escape sequences
/// when parsing string literals for RegExp constructor.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum EscapeKind {
/// Not an escape sequence (literal character)
#[default]
None,
/// Unicode escape: `\uXXXX` or `\u{XXXX}`
Unicode,
/// Hexadecimal escape: `\xXX`
Hexadecimal,
}
/// Represents UTF-16 code unit(u16 as u32) or Unicode code point(char as u32).
/// `Span` width may be more than 1, since there will be escape sequences.
#[derive(Debug, Clone, Copy)]
@@ -7,4 +21,6 @@ pub struct CodePoint {
pub span: Span,
// NOTE: If we need codegen, more information should be added.
pub value: u32,
/// The kind of escape sequence used to represent this code point in source.
pub escape_kind: EscapeKind,
}
@@ -161,4 +161,29 @@ mod test {
}
}
}
#[test]
fn escape_kind_tracking() {
use crate::parser::reader::EscapeKind;
// For regexp literal, escape kind should be None (escapes are handled by pattern parser)
let mut reader1 = Reader::initialize(r"A\u0301", true, false).unwrap();
assert_eq!(reader1.peek_escape_kind(), EscapeKind::None); // 'A'
reader1.advance();
assert_eq!(reader1.peek_escape_kind(), EscapeKind::None); // '\' - literal in regexp
reader1.advance();
assert_eq!(reader1.peek_escape_kind(), EscapeKind::None); // 'u'
// For string literal, unicode escapes should be tracked
let mut reader2 = Reader::initialize(r#""A\u0301""#, true, true).unwrap();
assert_eq!(reader2.peek_escape_kind(), EscapeKind::None); // 'A'
reader2.advance();
assert_eq!(reader2.peek_escape_kind(), EscapeKind::Unicode); // \u0301 resolved to U+0301
// For string literal with hex escape
let mut reader3 = Reader::initialize(r#""A\x41""#, true, true).unwrap();
assert_eq!(reader3.peek_escape_kind(), EscapeKind::None); // 'A'
reader3.advance();
assert_eq!(reader3.peek_escape_kind(), EscapeKind::Hexadecimal); // \x41 resolved to 'A'
}
}
@@ -3,7 +3,7 @@ use oxc_span::Atom;
use crate::parser::reader::{
Options,
ast::CodePoint,
ast::{CodePoint, EscapeKind},
string_literal_parser::{
Parser as StringLiteralParser, ast as StringLiteralAst, parse_regexp_literal,
},
@@ -100,6 +100,13 @@ impl<'a> Reader<'a> {
self.peek_nth(1)
}
/// Returns the escape kind of the current code point.
/// This is used to preserve information about how the character was
/// written in source code (e.g., as `\u0301` vs literal character).
pub fn peek_escape_kind(&self) -> EscapeKind {
self.units.get(self.index).map_or(EscapeKind::None, |cp| cp.escape_kind)
}
pub fn eat(&mut self, ch: char) -> bool {
if self.peek_nth(0) == Some(ch as u32) {
self.advance();
@@ -3,6 +3,7 @@ use oxc_span::Span;
use crate::parser::reader::{
CodePoint, Options,
ast::EscapeKind,
characters::{
CR, LF, LS, PS, is_line_terminator, is_non_escape_character, is_single_escape_character,
},
@@ -10,7 +11,8 @@ use crate::parser::reader::{
};
// Internal representation of escape sequence resolved unit in a string literal.
type OffsetsAndCp = ((u32, u32), u32);
// ((start, end), code_point, escape_kind)
type OffsetsAndCp = ((u32, u32), u32, EscapeKind);
/// Helper API for `RegExp` literal parsing.
/// This time, we don't need to handle escape sequences.
@@ -27,7 +29,7 @@ pub fn parse_regexp_literal(
#[expect(clippy::cast_possible_truncation)]
let end = start + ch.len_utf8() as u32;
let offsets_and_cp: OffsetsAndCp = ((start, end), ch as u32);
let offsets_and_cp: OffsetsAndCp = ((start, end), ch as u32, EscapeKind::None);
Parser::handle_code_point(&mut body, offsets_and_cp, span_offset, combine_surrogate_pair);
offset = end;
}
@@ -50,7 +52,7 @@ impl Parser {
// This is public because it is used in `parse_regexp_literal()`.
pub fn handle_code_point(
body: &mut Vec<CodePoint>,
(offsets, cp): OffsetsAndCp,
(offsets, cp, escape_kind): OffsetsAndCp,
span_offset: u32,
combine_surrogate_pair: bool,
) {
@@ -58,13 +60,13 @@ impl Parser {
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 });
body.push(CodePoint { span, value: cp, escape_kind });
} else {
// Otherwise, split the code point into a surrogate pair, sharing the same span
let (lead, trail) =
(0xd800 + ((cp - 0x10000) >> 10), 0xdc00 + ((cp - 0x10000) & 0x3ff));
body.push(CodePoint { span, value: lead });
body.push(CodePoint { span, value: trail });
body.push(CodePoint { span, value: lead, escape_kind });
body.push(CodePoint { span, value: trail, escape_kind });
}
}
@@ -153,24 +155,24 @@ impl Parser {
.filter(|&ch| ch != single_or_double_quote && ch != '\\' && !is_line_terminator(ch))
{
self.advance();
return Ok(Some(((offset_start, self.offset()), ch as u32)));
return Ok(Some(((offset_start, self.offset()), ch as u32, EscapeKind::None)));
}
if self.peek() == Some(LS) {
self.advance();
return Ok(Some(((offset_start, self.offset()), LS as u32)));
return Ok(Some(((offset_start, self.offset()), LS as u32, EscapeKind::None)));
}
if self.peek() == Some(PS) {
self.advance();
return Ok(Some(((offset_start, self.offset()), PS as u32)));
return Ok(Some(((offset_start, self.offset()), PS as u32, EscapeKind::None)));
}
if self.eat('\\') {
if let Some(cp) = self.parse_escape_sequence(offset_start)? {
return Ok(Some(((offset_start, self.offset()), cp)));
if let Some((cp, escape_kind)) = self.parse_escape_sequence(offset_start)? {
return Ok(Some(((offset_start, self.offset()), cp, escape_kind)));
}
self.rewind(checkpoint);
}
if let Some(cp) = self.parse_line_terminator_sequence() {
return Ok(Some(((offset_start, self.offset()), cp)));
return Ok(Some(((offset_start, self.offset()), cp, EscapeKind::None)));
}
Ok(None)
@@ -185,13 +187,13 @@ impl Parser {
// HexEscapeSequence
// UnicodeEscapeSequence
// ```
fn parse_escape_sequence(&mut self, offset_start: u32) -> Result<Option<u32>> {
fn parse_escape_sequence(&mut self, offset_start: u32) -> Result<Option<(u32, EscapeKind)>> {
if let Some(cp) = self.parse_character_escape_sequence() {
return Ok(Some(cp));
return Ok(Some((cp, EscapeKind::None)));
}
if self.peek() == Some('0') && self.peek2().is_none_or(|ch| !ch.is_ascii_digit()) {
self.advance();
return Ok(Some(0x00));
return Ok(Some((0x00, EscapeKind::None)));
}
if let Some(cp) = self.parse_legacy_octal_escape_sequence() {
// [SS:EE] EscapeSequence :: LegacyOctalEscapeSequence
@@ -205,7 +207,7 @@ impl Parser {
),
));
}
return Ok(Some(cp));
return Ok(Some((cp, EscapeKind::None)));
}
if let Some(cp) = self.parse_non_octal_decimal_escape_sequence() {
// [SS:EE] EscapeSequence :: NonOctalDecimalEscapeSequence
@@ -219,13 +221,13 @@ impl Parser {
),
));
}
return Ok(Some(cp));
return Ok(Some((cp, EscapeKind::None)));
}
if let Some(cp) = self.parse_hex_escape_sequence() {
return Ok(Some(cp));
return Ok(Some((cp, EscapeKind::Hexadecimal)));
}
if let Some(cp) = self.parse_unicode_escape_sequence(offset_start)? {
return Ok(Some(cp));
return Ok(Some((cp, EscapeKind::Unicode)));
}
Ok(None)
@@ -3,15 +3,16 @@ use oxc_span::Span;
use crate::parser::reader::{
Options,
ast::CodePoint,
ast::{CodePoint, EscapeKind},
characters::{
CR, LF, LS, PS, is_line_terminator, is_non_escape_character, is_single_escape_character,
},
template_literal_parser::{ast, diagnostics},
};
// Internal representation of escape sequence resolved unit in a string literal.
type OffsetsAndCp = ((u32, u32), u32);
// Internal representation of escape sequence resolved unit in a template literal.
// ((start, end), code_point, escape_kind)
type OffsetsAndCp = ((u32, u32), u32, EscapeKind);
pub struct Parser {
// NOTE: In JavaScript, template literals are UTF-16 encoded,
@@ -27,7 +28,7 @@ pub struct Parser {
impl Parser {
fn handle_code_point(
body: &mut Vec<CodePoint>,
(offsets, cp): OffsetsAndCp,
(offsets, cp, escape_kind): OffsetsAndCp,
span_offset: u32,
combine_surrogate_pair: bool,
) {
@@ -35,13 +36,13 @@ impl Parser {
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 });
body.push(CodePoint { span, value: cp, escape_kind });
} else {
// Otherwise, split the code point into a surrogate pair, sharing the same span
let (lead, trail) =
(0xd800 + ((cp - 0x10000) >> 10), 0xdc00 + ((cp - 0x10000) & 0x3ff));
body.push(CodePoint { span, value: lead });
body.push(CodePoint { span, value: trail });
body.push(CodePoint { span, value: lead, escape_kind });
body.push(CodePoint { span, value: trail, escape_kind });
}
}
@@ -126,22 +127,22 @@ impl Parser {
// $ [lookahead ≠ {]
if self.peek() == Some('$') && self.peek2() != Some('{') {
self.advance();
return Ok(Some(((offset_start, self.offset()), '$' as u32)));
return Ok(Some(((offset_start, self.offset()), '$' as u32, EscapeKind::None)));
}
if self.eat('\\') {
if let Some(cp) = self.parse_template_escape_sequence(offset_start)? {
return Ok(Some(((offset_start, self.offset()), cp)));
if let Some((cp, escape_kind)) = self.parse_template_escape_sequence(offset_start)? {
return Ok(Some(((offset_start, self.offset()), cp, escape_kind)));
}
if let Some(cp) = self.parse_not_escape_sequence()? {
return Ok(Some(((offset_start, self.offset()), cp)));
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)));
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)));
return Ok(Some(((offset_start, self.offset()), cp, EscapeKind::None)));
}
if let Some(ch) = self.peek() {
@@ -159,7 +160,7 @@ impl Parser {
self.advance();
return Ok(Some(((offset_start, self.offset()), ch as u32)));
return Ok(Some(((offset_start, self.offset()), ch as u32, EscapeKind::None)));
}
Ok(None)
@@ -172,19 +173,22 @@ impl Parser {
// HexEscapeSequence
// UnicodeEscapeSequence
// ```
fn parse_template_escape_sequence(&mut self, offset_start: u32) -> Result<Option<u32>> {
fn parse_template_escape_sequence(
&mut self,
offset_start: u32,
) -> Result<Option<(u32, EscapeKind)>> {
if let Some(cp) = self.parse_character_escape_sequence() {
return Ok(Some(cp));
return Ok(Some((cp, EscapeKind::None)));
}
if self.peek() == Some('0') && self.peek2().is_none_or(|ch| !ch.is_ascii_digit()) {
self.advance();
return Ok(Some(0x00));
return Ok(Some((0x00, EscapeKind::None)));
}
if let Some(cp) = self.parse_hex_escape_sequence()? {
return Ok(Some(cp));
return Ok(Some((cp, EscapeKind::Hexadecimal)));
}
if let Some(cp) = self.parse_unicode_escape_sequence(offset_start)? {
return Ok(Some(cp));
return Ok(Some((cp, EscapeKind::Unicode)));
}
Ok(None)