fix(linter/eslint/no-control-regex): detect raw control characters (#28548)

Report raw control characters in regex literals and strings passed to `RegExp`, matching ESLint on the reported cases.

Compare the source character with its code point and restrict capture-group filtering to octal escapes so raw controls remain visible. Add regression coverage for raw controls and preserve the existing octal escape behavior and diagnostics.

Fixes #28276
This commit is contained in:
camc314 committed 2026-10-30 23:08:07 +00:00
1 parent 7c65777da2
commit a205cd3e1c
1 file changed
+60 -15
@@ -21,7 +21,6 @@ fn no_control_regex_diagnostic(control_chars: &[Character]) -> OxcDiagnostic {
)
}
_ => {
// Show the code point since the character itself is not printable
format!("'U+{:04X}' is a control character.", ch.value)
}
};
@@ -118,24 +117,18 @@ struct ControlCharacterFinder<'a> {
impl<'a> Visit<'a> for ControlCharacterFinder<'a> {
fn visit_pattern(&mut self, it: &Pattern<'a>) {
walk::walk_pattern(self, it);
// \1, \2, etc. are sometimes valid "control" characters as they can be
// used to reference values from capturing groups. Note in this case
// they're not actually control characters. However, if there's no
// corresponding capturing group, they _are_ invalid control characters.
//
// Some important notes:
// 1. Capture groups are 1-indexed.
// 2. Capture groups can be nested.
// 3. Capture groups may be referenced before they are defined. This is
// why we need to do this check here, instead of filtering inside of
// visit_character.
if self.num_capture_groups > 0 && !self.control_chars.is_empty() {
self.control_chars.retain(|c| !(c.value > 0x01 && c.value <= self.num_capture_groups));
self.control_chars.retain(|c| {
!(matches!(
c.kind,
CharacterKind::Octal1 | CharacterKind::Octal2 | CharacterKind::Octal3
) && c.value > 0x01
&& c.value <= self.num_capture_groups)
});
}
}
fn visit_character(&mut self, ch: &Character) {
// Control characters are in the range 0x00 to 0x1F
if ch.value <= 0x1F && ch.kind != CharacterKind::Null {
let text: &str = ch.span.source_text(self.source_text);
let is_code_point_match = text
@@ -143,7 +136,8 @@ impl<'a> Visit<'a> for ControlCharacterFinder<'a> {
.chars()
.nth(0)
.is_some_and(|c| c.to_digit(16) == Some(ch.value));
if is_code_point_match
if text.chars().next().is_some_and(|c| u32::from(c) == ch.value)
|| is_code_point_match
|| text.starts_with("\\x")
|| text.starts_with("\\\\x")
|| text.starts_with("\\u")
@@ -165,6 +159,57 @@ mod tests {
use super::*;
use crate::tester::Tester;
#[test]
fn test_raw_control_characters() {
let pass = vec![
"/ /",
"new RegExp(' ')",
r"/\t\n\r\f\v\0/",
r"new RegExp('\\t\\n\\r\\f\\v\\0')",
"/\\\x08/",
];
let fail = vec![
"/\x00/",
"/a\x01b/",
"/\x08import/",
"/\t/",
"/\x0b/",
"/\x0c/",
"/a\x1fb/",
"/[\x08]/u",
"new RegExp('\x08import')",
"RegExp('\x08import')",
"new RegExp('\x00')",
"new RegExp('\t')",
"new RegExp('a\x1fb')",
"/(a)(b)\x02/",
"new RegExp('(a)(b)\x02')",
];
Tester::new(NoControlRegex::NAME, NoControlRegex::PLUGIN, pass, fail).test();
}
#[test]
fn test_octal_escape_behavior() {
let pass = vec![
r"/\01/",
r"/\10/",
r"/(a)(b)[\2]/",
r"new RegExp('\\01')",
r"new RegExp('(a)(b)[\\2]')",
];
let fail = vec![
r"/\1/",
r"/\2/",
r"/\00/",
r"/\000/",
r"new RegExp('\\1')",
r"new RegExp('\\00')",
];
Tester::new(NoControlRegex::NAME, NoControlRegex::PLUGIN, pass, fail).test();
}
#[test]
fn test_hex_literals() {
let pass = vec![