mirror of
https://github.com/oxc-project/oxc.git
synced 2026-11-04 10:29:58 +00:00
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:
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();
|
||||
|
||||
+21
-19
@@ -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)
|
||||
|
||||
+23
-19
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user