fix(linter): check module specifiers and string values that contain lone surrogates (#26717)

Lone surrogates disabled several lint checks and could count as allow-list matches. Module specifiers, names, whitespace and duplicate keys are now checked on the `JSStr` value. Fixes that need UTF-8 text keep the diagnostic and skip the fix.

User-configured regexes and globs only match UTF-8 text, so a value with a lone surrogate matches none of them. Allow-lists in `new-cap`, `no-unassigned-import`, `no-require-imports`, `no-large-snapshots` and `prop-name-casing` don't exempt it, `no-restricted-imports` and `valid-title` don't report it, and `valid-title`'s `mustMatch` does. `img-redundant-alt` searches the value's bytes directly. `import/extensions` (for `require()`) and `relative-url-style` still skip such values, as do static imports, because module records omit them.

`no-require-imports` now matches `allow` against a template's cooked value, and only when the template has no expressions, like typescript-eslint. The TypeScript `rewriteImportExtensions` option also rewrites specifiers that contain a lone surrogate.

Based on #26716. Part of #26242.

Implemented with AI assistance.
This commit is contained in:
Dunqing committed 2026-10-19 08:44:53 +00:00
1 parent e236de026a
commit 7c47246c71
83 files changed
+1907 -466

No files matched your search

@@ -472,9 +472,9 @@ fn string_literal_key_name<'a>(key: &'a PropertyKey<'a>) -> Option<&'a str> {
}
}
fn string_literal_argument<'a>(argument: &'a Argument<'a>) -> Option<&'a str> {
fn string_literal_argument<'a>(argument: &'a Argument<'a>) -> Option<JSStr<'a>> {
match argument.as_expression()?.without_parentheses() {
Expression::StringLiteral(lit) => lit.value.as_str(),
Expression::StringLiteral(lit) => Some(lit.value),
_ => None,
}
}
@@ -841,6 +841,10 @@ fn test() {
"(Object?.defineProperties)(foo, { bar: { value: function bar() {} } })",
Some(serde_json::json!(["never", { "considerPropertyDescriptor": true }])),
), // { "ecmaVersion": 2020 },
(
r#"Object.defineProperty(o, "\uD800", { value: function foo() {} })"#,
Some(serde_json::json!(["always", { "considerPropertyDescriptor": true }])),
),
("class C { x = function y() {}; }", Some(serde_json::json!(["always"]))), // { "ecmaVersion": 2022 },
("class C { x = function x() {}; }", Some(serde_json::json!(["never"]))), // { "ecmaVersion": 2022 },
("class C { 'x' = function y() {}; }", Some(serde_json::json!(["always"]))), // { "ecmaVersion": 2022 },
+89 -83
View File
@@ -1,4 +1,5 @@
use lazy_regex::Regex;
use oxc_allocator::Allocator;
use schemars::JsonSchema;
use serde::Deserialize;
@@ -9,7 +10,7 @@ use oxc_ast::{
use oxc_diagnostics::OxcDiagnostic;
use oxc_macros::declare_oxc_lint;
use oxc_span::Span;
use oxc_str::CompactStr;
use oxc_str::{CompactStr, JSChar, JSStr, JSStrBuilder};
use crate::{
AstNode,
@@ -435,7 +436,7 @@ impl Rule for NewCap {
return;
}
let Some(name) = &extract_name_deep_from_expression(callee) else {
let Some(name) = extract_name_deep_from_expression(callee, ctx.allocator()) else {
return;
};
@@ -444,7 +445,7 @@ impl Rule for NewCap {
name,
self.new_is_cap_exceptions.iter().map(CompactStr::as_str),
self.new_is_cap_exception_pattern.as_ref(),
) || (!self.properties && short_name != name.as_str());
) || (!self.properties && short_name != name);
if !allowed {
ctx.diagnostic(new_cap_diagnostic(short_name_span, capitalization));
@@ -463,7 +464,7 @@ impl Rule for NewCap {
return;
}
let Some(name) = &extract_name_deep_from_expression(callee) else {
let Some(name) = extract_name_deep_from_expression(callee, ctx.allocator()) else {
return;
};
@@ -472,7 +473,7 @@ impl Rule for NewCap {
name,
self.cap_is_new_exceptions.iter().map(CompactStr::as_str).chain(CAPS_ALLOWED),
self.cap_is_new_exception_pattern.as_ref(),
) || (!self.properties && short_name != name.as_str());
) || (!self.properties && short_name != name);
if !allowed {
ctx.diagnostic(new_cap_diagnostic(short_name_span, capitalization));
@@ -483,100 +484,63 @@ impl Rule for NewCap {
}
}
fn extract_name_deep_from_expression(expression: &Expression) -> Option<CompactStr> {
fn extract_name_deep_from_expression<'a>(
expression: &Expression<'a>,
allocator: &'a Allocator,
) -> Option<JSStr<'a>> {
if let Some(identifier) = expression.get_identifier_reference() {
return Some(identifier.name.into());
}
match expression.without_parentheses() {
Expression::StaticMemberExpression(expression) => {
let prop_name = expression.property.name.into_compact_str();
let obj_name =
extract_name_deep_from_expression(expression.object.without_parentheses());
let mut name = JSStrBuilder::new_in(&allocator);
append_name_deep(expression, &mut name)?;
Some(name.into_js_str())
}
if let Some(obj_name) = obj_name {
let new_name = format!("{obj_name}.{prop_name}");
return Some(CompactStr::new(&new_name));
}
Some(prop_name)
}
Expression::ComputedMemberExpression(expression) => {
let (prop_name, _) = get_computed_member_name(expression)?;
let obj_name =
extract_name_deep_from_expression(expression.object.without_parentheses());
if let Some(obj_name) = obj_name {
let new_name = format!("{obj_name}.{prop_name}");
return Some(CompactStr::new(&new_name));
}
Some(prop_name.into())
}
Expression::ChainExpression(chain) => match &chain.expression {
ChainElement::CallExpression(call) => extract_name_deep_from_expression(&call.callee),
fn append_name_deep(expression: &Expression<'_>, name: &mut JSStrBuilder<'_>) -> Option<()> {
if let Expression::ChainExpression(chain) = expression.without_parentheses() {
match &chain.expression {
ChainElement::CallExpression(call) => return append_name_deep(&call.callee, name),
ChainElement::TSNonNullExpression(non_null) => {
extract_name_deep_from_expression(&non_null.expression)
return append_name_deep(&non_null.expression, name);
}
ChainElement::StaticMemberExpression(expression) => {
let prop_name = expression.property.name.into_compact_str();
let obj_name =
extract_name_deep_from_expression(expression.object.without_parentheses());
if let Some(obj_name) = obj_name {
let new_name = format!("{obj_name}.{prop_name}");
return Some(CompactStr::new(&new_name));
}
Some(prop_name)
}
ChainElement::ComputedMemberExpression(expression) => {
let (prop_name, _) = get_computed_member_name(expression)?;
let obj_name =
extract_name_deep_from_expression(expression.object.without_parentheses());
if let Some(obj_name) = obj_name {
let new_name = format!("{obj_name}.{prop_name}");
return Some(CompactStr::new(&new_name));
}
Some(prop_name.into())
}
ChainElement::PrivateFieldExpression(_) => None,
},
_ => None,
_ => {}
}
}
let (prop_name, _) = extract_name_from_expression(expression)?;
if let Some(member) = expression.get_member_expr()
&& append_name_deep(member.object(), name).is_some()
{
name.push('.');
}
name.push_js_str(prop_name);
Some(())
}
fn get_computed_member_name<'a>(
computed_member: &ComputedMemberExpression<'a>,
) -> Option<(&'a str, Span)> {
) -> Option<(JSStr<'a>, Span)> {
let expression = computed_member.expression.without_parentheses();
match &expression {
Expression::StringLiteral(lit) if !lit.value.is_empty() => {
Some((lit.value.as_str()?, lit.span))
Expression::StringLiteral(lit) if !lit.value.is_empty() => Some((lit.value, lit.span)),
Expression::TemplateLiteral(lit) => {
lit.single_quasi().filter(|value| !value.is_empty()).map(|value| (value, lit.span))
}
Expression::TemplateLiteral(lit)
if lit.expressions.is_empty()
&& lit.quasis.len() == 1
&& !lit.quasis[0].value.raw.is_empty() =>
{
Some((lit.quasis[0].value.raw.as_str(), lit.span))
}
Expression::RegExpLiteral(lit) => lit.raw.as_ref().map(|x| (x.as_str(), lit.span)),
Expression::RegExpLiteral(lit) => lit.raw.map(|raw| (JSStr::from(raw), lit.span)),
_ => None,
}
}
fn extract_name_from_expression<'a>(expression: &Expression<'a>) -> Option<(&'a str, Span)> {
fn extract_name_from_expression<'a>(expression: &Expression<'a>) -> Option<(JSStr<'a>, Span)> {
if let Some(identifier) = expression.get_identifier_reference() {
return Some((identifier.name.as_str(), identifier.span));
return Some((JSStr::from(identifier.name), identifier.span));
}
match expression.without_parentheses() {
Expression::StaticMemberExpression(expression) => {
Some((expression.property.name.as_str(), expression.property.span))
Some((JSStr::from(expression.property.name), expression.property.span))
}
Expression::ComputedMemberExpression(expression) => get_computed_member_name(expression),
Expression::ChainExpression(chain) => match &chain.expression {
@@ -585,7 +549,7 @@ fn extract_name_from_expression<'a>(expression: &Expression<'a>) -> Option<(&'a
extract_name_from_expression(&non_null.expression)
}
ChainElement::StaticMemberExpression(expression) => {
Some((expression.property.name.as_str(), expression.property.span))
Some((JSStr::from(expression.property.name), expression.property.span))
}
ChainElement::ComputedMemberExpression(expression) => {
get_computed_member_name(expression)
@@ -597,8 +561,8 @@ fn extract_name_from_expression<'a>(expression: &Expression<'a>) -> Option<(&'a
}
fn is_cap_allowed_expression<'a, I>(
short_name: &str,
name: &CompactStr,
short_name: JSStr<'_>,
name: JSStr<'_>,
exceptions: I,
patterns: Option<&Regex>,
) -> bool
@@ -606,7 +570,7 @@ where
I: Iterator<Item = &'a str>,
{
for exception in exceptions {
if exception == name.as_str() || exception == short_name {
if name == exception || short_name == exception {
return true;
}
}
@@ -616,7 +580,8 @@ where
}
if let Some(pattern) = &patterns {
return pattern.find(name).is_some();
// A name with a lone surrogate matches no exception pattern.
return name.as_str().is_some_and(|name| pattern.is_match(name));
}
false
@@ -629,8 +594,12 @@ enum GetCapResult {
NonAlpha,
}
fn get_cap(string: &str) -> GetCapResult {
let first_char = string.chars().next().unwrap();
fn get_cap(name: JSStr) -> GetCapResult {
// A lone surrogate has no case (`toUpperCase` leaves it unchanged),
// so ESLint classifies such a name as non-alphabetic.
let Some(first_char) = name.chars().next().and_then(JSChar::to_char) else {
return GetCapResult::NonAlpha;
};
if !first_char.is_alphabetic() {
return GetCapResult::NonAlpha;
@@ -648,6 +617,11 @@ fn test() {
use crate::tester::Tester;
let pass = vec![
// Lone surrogates are part of the value.
// A search or prefix check must still see the rest.
(r#"var x = new o["\uD800a"]();"#, None),
(r#"var x = new o["a\uD800"]();"#, Some(serde_json::json!([{ "properties": false }]))),
(r#"var x = new o["A\uD83D\uDE00"]();"#, None),
("var x = new Constructor();", None),
("var x = new a.b.Constructor();", None),
("var x = new a.b['Constructor']();", None),
@@ -733,11 +707,43 @@ fn test() {
"capIsNewExceptions": ["services.registry.CreateDocumentForCurrentSession"]
}])),
),
// Template literal property names use their raw text.
(r"services.registry[`\u0043reateDocumentForCurrentSession`]();", None),
(
r"services.registry[`\u0043reateDocumentForCurrentSession`]();",
Some(serde_json::json!([{
"capIsNewExceptions": ["services.registry.CreateDocumentForCurrentSession"]
}])),
),
];
let fail = vec![
(
r"new o['a\uD800']();",
Some(serde_json::json!([{ "newIsCapExceptions": [r#"o."a\ud800""#] }])),
),
(
r"o['A\uDC00']();",
Some(serde_json::json!([{ "capIsNewExceptions": [r#"o."A\udc00""#] }])),
),
(
r"new o['a\uD800']();",
Some(serde_json::json!([{ "newIsCapExceptionPattern": "^other" }])),
),
// A name with a lone surrogate matches no exception pattern.
(
r"new o['a\uD800']();",
Some(serde_json::json!([{ "newIsCapExceptionPattern": "^o\\.a" }])),
),
(r"o['A\uDC00']();", Some(serde_json::json!([{ "capIsNewExceptionPattern": "^o\\.A" }]))),
(
r"(o?.['A\uDC00'])();",
Some(serde_json::json!([{ "capIsNewExceptionPattern": "^o\\.A" }])),
),
(r"services.registry[`\u0043reateDocumentForCurrentSession`]();", None),
(r"new o[`a\uD800`]();", None),
// Lone surrogates are part of the value.
// A search or prefix check must still see the rest.
(r#"var x = new o["a\uD800"]();"#, None),
(r#"var x = o["A\uDC00"]();"#, None),
("var x = new c();", None),
("var x = new φ;", None),
("var x = new a.b.c;", None),
@@ -2033,6 +2033,13 @@ fn test() {
),
// dynamic `import()` with a string-literal source
("import('os');", Some(serde_json::json!(["fs"]))),
// A source with a lone surrogate matches no configured name or pattern.
(r"import('a\uD800');", Some(serde_json::json!(["a\u{FFFD}"]))),
(r"import('a\uD800');", Some(serde_json::json!([{ "patterns": [{ "regex": "^a.$" }] }]))),
(
r"import('a\uD800/b');",
Some(serde_json::json!([{ "patterns": [{ "group": ["a*/b"] }] }])),
),
("import('foo/bar');", Some(serde_json::json!([{ "patterns": ["foo/baz"] }]))),
// non-literal dynamic import sources are ignored
("import(foo);", Some(serde_json::json!(["foo"]))),
@@ -1,16 +1,21 @@
use std::borrow::Cow;
use oxc_allocator::Allocator;
use oxc_ast::{
AstKind,
ast::{Argument, Expression},
builder::AstBuilder,
};
use oxc_codegen::Codegen;
use oxc_diagnostics::OxcDiagnostic;
use oxc_macros::declare_oxc_lint;
use oxc_regular_expression::{
LiteralParser, Options,
ConstructorParser, LiteralParser, Options,
ast::Pattern,
visit::{RegExpAstKind, Visit},
};
use oxc_span::{GetSpan, Span};
use oxc_str::JSStr;
use crate::{
AstNode,
@@ -110,29 +115,50 @@ fn check_static_arguments(arg0: Option<&Argument>, arg1: Option<&Argument>, ctx:
return;
};
let Some(pattern_text) = static_string_value(pattern_expr) else {
let Some(pattern_value) = static_string_value(pattern_expr, ctx.allocator()) else {
return;
};
let flags_text = arg1
let flags_value = arg1
.and_then(Argument::as_expression)
.map(Expression::get_inner_expression)
.and_then(static_string_value);
let allocator = Allocator::default();
let Ok(pattern) = LiteralParser::new(
&allocator,
&pattern_text,
flags_text.as_deref(),
Options { pattern_span_offset: pattern_expr.span().start, flags_span_offset: 0 },
)
.parse() else {
return;
.and_then(|expr| static_string_value(expr, ctx.allocator()));
let flags_text = match flags_value {
Some(value) => {
let Some(text) = value.as_str() else { return };
Some(text)
}
None => None,
};
let allocator = Allocator::default();
let options = Options { pattern_span_offset: pattern_expr.span().start, flags_span_offset: 0 };
let pattern_text = pattern_value.as_str().map_or_else(
|| Cow::Owned(string_literal_source(pattern_value, &allocator)),
Cow::Borrowed,
);
let flags_source = pattern_value
.has_lone_surrogate()
.then(|| flags_text.map(|text| string_literal_source(text.into(), &allocator)))
.flatten();
let parsed = if pattern_value.has_lone_surrogate() {
// Parse the generated string literal to retain the original UTF-16 values.
ConstructorParser::new(&allocator, &pattern_text, flags_source.as_deref(), options).parse()
} else {
LiteralParser::new(&allocator, &pattern_text, flags_text, options).parse()
};
let Ok(pattern) = parsed else { return };
check_pattern(&pattern, ctx, Some(pattern_expr.span()));
}
fn string_literal_source(value: JSStr<'_>, allocator: &Allocator) -> String {
let expression =
Expression::new_string_literal(Span::default(), value, None, &AstBuilder::new(allocator));
let mut codegen = Codegen::new();
codegen.print_expression(&expression);
codegen.into_source_text()
}
fn is_directly_supported_regex_argument(expr: &Expression<'_>) -> bool {
match expr {
Expression::RegExpLiteral(_) | Expression::StringLiteral(_) => true,
@@ -161,6 +187,9 @@ fn test() {
use crate::tester::Tester;
let pass = vec![
r#"new RegExp("(a)[" + "\uD801" + "-" + "\uD800" + "]");"#,
r#"new RegExp("(a)[" + "\uD801" + "-" + "\uD800" + "]", "u");"#,
r#"new RegExp("(a)" + "", "u\uD800");"#,
"/normal_regex/",
"/(?:[0-9]{4})/",
"/(?<year>[0-9]{4})/",
@@ -193,6 +222,8 @@ fn test() {
];
let fail = vec![
r#"new RegExp("(a)[" + "\uD800" + "-" + "\uE000" + "]");"#,
r#"new RegExp("(a)[" + "\uD800" + "-" + "\uE000" + "]", "u");"#,
"/([0-9]{4})/",
"new RegExp('([0-9]{4})')",
"RegExp('([0-9]{4})')",
@@ -210,6 +241,7 @@ fn test() {
"new RegExp('a(bc)d' + 'e')",
r#"new RegExp("foo" + "(a)" + "(b)");"#,
r#"new RegExp("foo" + "(?:a)" + "(b)");"#,
r#"new RegExp("(a)" + "\uD800");"#,
"RegExp('(a)'+'')",
"RegExp( '' + '(ab)')",
"new RegExp(`(ab)${''}`)",
@@ -9,6 +9,7 @@ use oxc_ast::{
use oxc_diagnostics::OxcDiagnostic;
use oxc_macros::declare_oxc_lint;
use oxc_span::{GetSpan, Span};
use oxc_str::{JSChar, JSStr};
use oxc_syntax::operator::{AssignmentOperator, BinaryOperator};
use crate::{
@@ -236,8 +237,14 @@ fn extract_regex_flags<'a>(
resolve_flags(flags_arg, ctx, true, 0)
}
fn parse_flags(flags_text: &str) -> RegExpFlags {
flags_text.chars().filter_map(|ch| RegExpFlags::try_from(ch).ok()).collect()
fn parse_flags(flags: JSStr) -> RegExpFlags {
// A lone surrogate is never a flag.
// The other characters still count, so a `u` beside one is still seen.
flags
.chars()
.filter_map(JSChar::to_char)
.filter_map(|ch| RegExpFlags::try_from(ch).ok())
.collect()
}
const MAX_RESOLVE_DEPTH: usize = 8;
@@ -259,7 +266,7 @@ fn resolve_flags<'a>(
}
match expr {
Expression::StringLiteral(lit) => Some(parse_flags(lit.value.as_str()?)),
Expression::StringLiteral(lit) => Some(parse_flags(lit.value)),
Expression::TemplateLiteral(template) => resolve_template_flags(template, ctx, next_depth),
Expression::BooleanLiteral(lit) => {
Some(if lit.value { RegExpFlags::U } else { RegExpFlags::empty() })
@@ -274,8 +281,12 @@ fn resolve_flags<'a>(
Expression::ComputedMemberExpression(member) => {
let object = resolve_static_string(&member.object, ctx)?;
let index = resolve_static_index(&member.expression)?;
let ch = object.chars().nth(index)?;
Some(RegExpFlags::try_from(ch).unwrap_or_else(|_| RegExpFlags::empty()))
let unit = object.encode_utf16().nth(index)?;
Some(
char::from_u32(u32::from(unit))
.and_then(|ch| RegExpFlags::try_from(ch).ok())
.unwrap_or_else(RegExpFlags::empty),
)
}
Expression::SequenceExpression(sequence) => {
resolve_flags(sequence.expressions.last()?, ctx, true, next_depth)
@@ -297,22 +308,22 @@ fn resolve_template_flags<'a>(
let mut flags = RegExpFlags::empty();
for (index, expression) in template.expressions.iter().enumerate() {
flags |= parse_flags(template.quasis.get(index)?.value.cooked?.as_str()?);
flags |= parse_flags(template.quasis.get(index)?.value.cooked?);
flags |= resolve_flags(expression, ctx, true, depth)?;
}
flags |= parse_flags(template.quasis.last()?.value.cooked?.as_str()?);
flags |= parse_flags(template.quasis.last()?.value.cooked?);
Some(flags)
}
fn resolve_static_string<'a>(expr: &'a Expression<'a>, ctx: &LintContext<'a>) -> Option<&'a str> {
fn resolve_static_string<'a>(expr: &'a Expression<'a>, ctx: &LintContext<'a>) -> Option<JSStr<'a>> {
match expr.get_inner_expression() {
Expression::StringLiteral(lit) => lit.value.as_str(),
Expression::TemplateLiteral(template) => template.single_quasi()?.as_str(),
Expression::StringLiteral(lit) => Some(lit.value),
Expression::TemplateLiteral(template) => template.single_quasi(),
Expression::Identifier(ident) => {
match resolve_const_initializer(ident, ctx)?.get_inner_expression() {
Expression::StringLiteral(lit) => lit.value.as_str(),
Expression::TemplateLiteral(template) => template.single_quasi()?.as_str(),
Expression::StringLiteral(lit) => Some(lit.value),
Expression::TemplateLiteral(template) => template.single_quasi(),
_ => None,
}
}
@@ -356,6 +367,12 @@ fn test() {
use crate::tester::Tester;
let pass = vec![
(r#"new RegExp("a", "😀u\uD800"[2]);"#, None),
(r#"new RegExp("a", "😀u"[2]);"#, None),
// Lone surrogates are part of the value.
// A search or prefix check must still see the rest.
(r#"new RegExp("foo", "u\uD800")"#, None),
(r#"new RegExp("foo", `\uDC00u`)"#, None),
("/foo/u", None),
("/foo/gimuy", None),
("RegExp('', 'u')", None),
@@ -399,6 +416,10 @@ fn test() {
];
let fail = vec![
// Lone surrogates are part of the value.
// A search or prefix check must still see the rest.
(r#"new RegExp("foo", "\uD800")"#, None),
(r#"new RegExp("foo", `g\uDC00`)"#, None),
(r"/\a/", None),
("/foo/", None),
("/foo/gimy", None),
+34 -5
View File
@@ -9,6 +9,7 @@ use oxc_diagnostics::OxcDiagnostic;
use oxc_ecmascript::{ToBigInt, WithoutGlobalReferenceInformation};
use oxc_macros::declare_oxc_lint;
use oxc_span::{GetSpan, Span};
use oxc_str::JSStr;
use schemars::JsonSchema;
use serde::Deserialize;
@@ -404,7 +405,7 @@ fn is_range(expr: &LogicalExpression, ctx: &LintContext) -> bool {
if let (Some(left_left), Some(right_right)) =
(get_string_literal(left_left), get_string_literal(right_right))
{
return left_left <= right_right;
return is_string_le(left_left, right_right);
}
if let (Some(left_left), Some(right_right)) =
@@ -438,7 +439,7 @@ fn is_range(expr: &LogicalExpression, ctx: &LintContext) -> bool {
if let (Some(left_right), Some(right_left)) =
(get_string_literal(left_right), get_string_literal(right_left))
{
return left_right <= right_left;
return is_string_le(left_right, right_left);
}
if let (Some(left_right), Some(right_left)) =
@@ -468,15 +469,25 @@ fn is_target_literal(expr: &Expression) -> bool {
get_string_literal(expr).is_some() || is_number(expr)
}
fn get_string_literal<'a>(expr: &'a Expression) -> Option<&'a str> {
/// Compares strings by bytes.
/// A string with a lone surrogate has no UTF-8 form, so it is compared by UTF-16 code unit.
fn is_string_le(left: JSStr, right: JSStr) -> bool {
if left.has_lone_surrogate() || right.has_lone_surrogate() {
left.encode_utf16().le(right.encode_utf16())
} else {
left.as_bytes() <= right.as_bytes()
}
}
fn get_string_literal<'a>(expr: &Expression<'a>) -> Option<JSStr<'a>> {
match expr {
Expression::StringLiteral(string) => string.value.as_str(),
Expression::StringLiteral(string) => Some(string.value),
Expression::TemplateLiteral(template) => {
if template.quasis.len() != 1 {
return None;
}
template.quasis.first().map(|e| e.value.raw.as_str())
template.quasis.first().map(|e| JSStr::from(e.value.raw))
}
_ => None,
}
@@ -523,6 +534,19 @@ fn test() {
use crate::tester::Tester;
let pass = vec![
// A lone surrogate orders by its UTF-16 code unit.
(
r#"if ("\uD800" <= x && x <= "\uDBFF") {}"#,
Some(serde_json::json!(["never", { "exceptRange": true }])),
),
(
r#"if ("\uDC00" <= x && x <= "\uDFFF") {}"#,
Some(serde_json::json!(["never", { "exceptRange": true }])),
),
(
r#"if ("a\uD800" <= x && x <= "b") {}"#,
Some(serde_json::json!(["never", { "exceptRange": true }])),
),
(r#"if (value === "red") {}"#, Some(serde_json::json!(["never"]))),
("if (value === value) {}", Some(serde_json::json!(["never"]))),
("if (value != 5) {}", Some(serde_json::json!(["never"]))),
@@ -699,6 +723,11 @@ fn test() {
];
let fail = vec![
// A lone surrogate orders by its UTF-16 code unit.
(
r#"if ("\uDBFF" <= x && x <= "\uD800") {}"#,
Some(serde_json::json!(["never", { "exceptRange": true }])),
),
(
"if (x <= 'foo' || 'bar' < x) {}",
Some(serde_json::json!(["always", { "exceptRange": true }])),
@@ -10,6 +10,7 @@ use oxc_codegen::{Context, Gen};
use oxc_diagnostics::OxcDiagnostic;
use oxc_macros::declare_oxc_lint;
use oxc_span::{FileExtension, GetSpan, SPAN, Span};
use oxc_str::JSStr;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -314,8 +315,9 @@ fn gen_type_import_declaration<'c, 'a: 'c>(
}
fn is_declaration_file_import(import_decl: &ImportDeclaration) -> bool {
let Some(source) = import_decl.source.value.as_str() else {
return false;
let value = import_decl.source.value;
let Some(source) = value.as_str() else {
return is_declaration_file_js_path(value);
};
// Relatively fast check to avoid unnecessary Path and extension parsing
// if it doesn't even look like a declaration file import
@@ -333,12 +335,29 @@ fn is_declaration_file_import(import_decl: &ImportDeclaration) -> bool {
}
}
/// `is_declaration_file_import` for a source with a lone surrogate, which `std::path` cannot hold.
/// The extension is ASCII, so suffix checks decide the same way
/// `FileExtension::is_ts_declaration` does.
fn is_declaration_file_js_path(source: JSStr) -> bool {
if source.ends_with(".ts") {
source.rfind(".d.").is_some_and(|i| i != 0)
} else if source.ends_with(".mts") || source.ends_with(".cts") {
source.len() > 6 && (source.ends_with(".d.mts") || source.ends_with(".d.cts"))
} else {
false
}
}
#[test]
fn test() {
use crate::tester::Tester;
use serde_json::json;
let pass = vec![
// Lone surrogates are part of the value.
// A search or prefix check must still see the rest.
(r"import type { Foo } from './\uD800.d.ts';", Some(json!(["prefer-inline"]))),
(r"import type { Foo } from './\uDC00.d.mts';", Some(json!(["prefer-inline"]))),
("import Foo from 'Foo'", None),
("import type Foo from 'Foo'", None),
("import { Foo } from 'Foo';", None),
@@ -415,6 +434,9 @@ fn test() {
];
let fail = vec![
// Lone surrogates are part of the value.
// A search or prefix check must still see the rest.
(r"import type { Foo } from './\uD800.ts';", Some(json!(["prefer-inline"]))),
("import { type Foo, type Bar } from 'Foo'", None),
("import type { Foo } from 'Foo'", Some(json!(["prefer-inline"]))),
("import { type Foo as Bar } from 'Foo';", None),
@@ -878,6 +878,8 @@ fn test() {
use serde_json::json;
let pass = vec![
// A `require()` specifier with a lone surrogate is not checked.
(r#"const foo = require("./foo\uD800");"#, Some(json!(["always"]))),
// Default config: no extension requirements, unconfigured extensions are ignored
(r#"import a from "@/a""#, None),
(r#"import a from "a""#, None),
+18 -3
View File
@@ -2,6 +2,7 @@ use oxc_ast::ast::{Statement, TSModuleReference};
use oxc_diagnostics::OxcDiagnostic;
use oxc_macros::declare_oxc_lint;
use oxc_span::Span;
use oxc_str::JSStr;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
@@ -84,7 +85,7 @@ declare_oxc_lint!(
short_description = "Forbids any non-import statements before imports except directives.",
);
fn is_relative_path(path: &str) -> bool {
fn is_relative_path(path: JSStr) -> bool {
// A path is considered relative if it starts with "/", "./", or "../"
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import#module_specifier_resolution
path.starts_with("./") || path.starts_with("../") || path.starts_with('/')
@@ -107,7 +108,7 @@ impl Rule for First {
Statement::TSImportEqualsDeclaration(decl) => match &decl.module_reference {
TSModuleReference::ExternalModuleReference(mod_ref) => {
if matches!(self.0, AbsoluteFirst::AbsoluteFirst) {
if mod_ref.expression.value.as_str().is_some_and(is_relative_path) {
if is_relative_path(mod_ref.expression.value) {
any_relative = true;
} else if any_relative {
ctx.diagnostic(absolute_first_diagnostic(mod_ref.expression.span));
@@ -122,7 +123,7 @@ impl Rule for First {
},
Statement::ImportDeclaration(decl) => {
if matches!(self.0, AbsoluteFirst::AbsoluteFirst) {
if decl.source.value.as_str().is_some_and(is_relative_path) {
if is_relative_path(decl.source.value) {
any_relative = true;
} else if any_relative {
ctx.diagnostic(absolute_first_diagnostic(decl.source.span));
@@ -147,6 +148,9 @@ fn test() {
use crate::tester::Tester;
let pass = vec![
// Lone surrogates are part of the value.
// A search or prefix check must still see the rest.
(r"import { y } from 'bar'; import { x } from './\uD800'", Some(json!(["absolute-first"]))),
(
r"import { x } from './foo'; import { y } from './bar';
export { x, y }",
@@ -186,6 +190,17 @@ fn test() {
];
let fail = vec![
// Lone surrogates are part of the value.
// A search or prefix check must still see the rest.
(r"import { x } from './\uD800'; import { y } from 'bar'", Some(json!(["absolute-first"]))),
(
r"import { x } from '../\uDC00'; import { y } from 'bar'",
Some(json!(["absolute-first"])),
),
(
r"import { x } from '/\uD83D\uDE00'; import { y } from 'bar'",
Some(json!(["absolute-first"])),
),
(
r"import { x } from './foo';
export { x };
@@ -1,5 +1,7 @@
use std::path::Path;
use oxc_str::JSStr;
use schemars::JsonSchema;
use serde_json::Value;
@@ -118,8 +120,7 @@ impl Rule for NoAbsolutePath {
fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) {
match node.kind() {
AstKind::ImportDeclaration(import_decl)
if self.esmodule
&& import_decl.source.value.as_str().is_some_and(check_path_is_absolute) =>
if self.esmodule && check_path_is_absolute(import_decl.source.value) =>
{
ctx.diagnostic(no_absolute_path_diagnostic(import_decl.source.span));
}
@@ -135,17 +136,14 @@ impl Rule for NoAbsolutePath {
if count == 1
&& func_name == "require"
&& self.commonjs
&& str_literal
.value
.as_str()
.is_some_and(check_path_is_absolute) =>
&& check_path_is_absolute(str_literal.value) =>
{
ctx.diagnostic(no_absolute_path_diagnostic(str_literal.span));
}
Argument::ArrayExpression(arr_expr) if count == 2 && self.amd => {
for el in &arr_expr.elements {
if let Some(el_expr) = el.as_expression()
&& matches!(el_expr, Expression::StringLiteral(literal) if literal.value.as_str().is_some_and(check_path_is_absolute))
&& matches!(el_expr, Expression::StringLiteral(literal) if check_path_is_absolute(literal.value))
{
ctx.diagnostic(no_absolute_path_diagnostic(el_expr.span()));
}
@@ -160,8 +158,14 @@ impl Rule for NoAbsolutePath {
}
}
fn check_path_is_absolute(path_str: &str) -> bool {
Path::new(path_str).is_absolute()
fn check_path_is_absolute(path: JSStr) -> bool {
match path.as_str() {
Some(path) => Path::new(path).is_absolute(),
// `std::path` requires UTF-8.
// A path with a lone surrogate is absolute when it starts with `/`,
// which is what Node's `path.isAbsolute` checks.
None => path.starts_with('/'),
}
}
#[test]
@@ -171,6 +175,10 @@ fn test() {
use serde_json::json;
let pass = vec![
// Lone surrogates are part of the value.
// A search or prefix check must still see the rest.
(r"import _ from './\uD800'", None),
(r"import _ from '\uDC00/x'", None),
(r"import _ from 'lodash'", None),
(r"import _ from '/lodash'", Some(json!([{ "esmodule": false }]))),
(r"import _ from './lodash'", None),
@@ -216,6 +224,11 @@ fn test() {
];
let fail = vec![
// Lone surrogates are part of the value.
// A search or prefix check must still see the rest.
(r"import _ from '/\uD800'", None),
(r"var f = require('/\uDC00/x')", Some(json!([{ "commonjs": true }]))),
(r"require(['/\uD83D\uDE00'], function(){})", Some(json!([{ "amd": true }]))),
(r"import _ from '/lodash'", None),
(r"import _ from '/lodash'", Some(json!([{ "esmodule": true }]))),
(r"import f from '/foo/path'", None),
@@ -6,7 +6,7 @@ use oxc_ast::{
use oxc_diagnostics::OxcDiagnostic;
use oxc_macros::declare_oxc_lint;
use oxc_span::{GetSpan, Span};
use oxc_str::{CompactStr, JSStr};
use oxc_str::CompactStr;
use rustc_hash::FxHashSet;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
@@ -17,7 +17,7 @@ use crate::{
rule::{DefaultRuleConfig, Rule},
};
fn no_nodejs_modules_diagnostic(span: Span, module_name: &str) -> OxcDiagnostic {
fn no_nodejs_modules_diagnostic(span: Span, module_name: impl std::fmt::Display) -> OxcDiagnostic {
OxcDiagnostic::warn(format!("Do not import Node.js builtin module `{module_name}`"))
.with_help("Use a browser-compatible alternative or add this module to the `allow` list if Node.js usage is intentional.")
.with_label(span)
@@ -112,16 +112,25 @@ impl Rule for NoNodejsModules {
_ => return,
};
let Some(module_name) = module_name.and_then(JSStr::as_str) else {
let Some(module_name) = module_name else {
return;
};
if self.allow.contains(module_name) {
let Some(name) = module_name.as_str() else {
// A name with a lone surrogate matches neither the `allow` list nor a builtin,
// but the `node:` scheme still marks a Node.js module.
if module_name.starts_with("node:") {
ctx.diagnostic(no_nodejs_modules_diagnostic(node.span(), module_name.display()));
}
return;
};
if self.allow.contains(name) {
return;
}
if module_name.starts_with("node:") || is_nodejs_builtin_module(module_name) {
ctx.diagnostic(no_nodejs_modules_diagnostic(node.span(), module_name));
if name.starts_with("node:") || is_nodejs_builtin_module(name) {
ctx.diagnostic(no_nodejs_modules_diagnostic(node.span(), name));
}
}
}
@@ -131,6 +140,10 @@ fn test() {
use crate::tester::Tester;
let pass = vec![
// Lone surrogates are part of the value.
// A search or prefix check must still see the rest.
(r#"import x from "\uD800""#, None),
(r#"var x = require("\uDC00")"#, None),
// Non Node.js modules
(r#"import _ from "lodash""#, None),
(r#"import find from "lodash.find""#, None),
@@ -184,6 +197,11 @@ fn test() {
];
let fail = vec![
// Lone surrogates are part of the value.
// A search or prefix check must still see the rest.
(r#"import x from "node:\uD800""#, None),
(r#"var x = require("node:\uDC00")"#, None),
(r#"import x from "node:\uD83D\uDE00""#, None),
// Node.js builtin modules
(r#"import path from "path""#, None),
(r#"import fs from "fs""#, None),
@@ -2,6 +2,7 @@ use oxc_ast::{AstKind, ast::Expression};
use oxc_diagnostics::OxcDiagnostic;
use oxc_macros::declare_oxc_lint;
use oxc_span::Span;
use oxc_str::JSStr;
use crate::{AstNode, context::LintContext, rule::Rule};
@@ -56,26 +57,26 @@ impl Rule for NoRelativeParentImports {
match node.kind() {
// ESM import declarations
AstKind::ImportDeclaration(import_decl)
if import_decl.source.value.as_str().is_some_and(is_parent_import) =>
if is_parent_import(import_decl.source.value) =>
{
ctx.diagnostic(no_relative_parent_imports_diagnostic(import_decl.source.span));
}
// ESM export { } from '...'
AstKind::ExportFromDeclaration(export_decl) => {
if export_decl.source.value.as_str().is_some_and(is_parent_import) {
if is_parent_import(export_decl.source.value) {
ctx.diagnostic(no_relative_parent_imports_diagnostic(export_decl.source.span));
}
}
// ESM export * from '...'
AstKind::ExportAllDeclaration(export_decl)
if export_decl.source.value.as_str().is_some_and(is_parent_import) =>
if is_parent_import(export_decl.source.value) =>
{
ctx.diagnostic(no_relative_parent_imports_diagnostic(export_decl.source.span));
}
// Dynamic import expressions: import('../foo')
AstKind::ImportExpression(import_expr) => {
if let Expression::StringLiteral(str_literal) = &import_expr.source
&& str_literal.value.as_str().is_some_and(is_parent_import)
&& is_parent_import(str_literal.value)
{
ctx.diagnostic(no_relative_parent_imports_diagnostic(str_literal.span));
}
@@ -83,7 +84,7 @@ impl Rule for NoRelativeParentImports {
// CommonJS require() calls
AstKind::CallExpression(call_expr) => {
if let Some(str_literal) = call_expr.common_js_require()
&& str_literal.value.as_str().is_some_and(is_parent_import)
&& is_parent_import(str_literal.value)
{
ctx.diagnostic(no_relative_parent_imports_diagnostic(str_literal.span));
}
@@ -95,12 +96,15 @@ impl Rule for NoRelativeParentImports {
/// Check if the import path is a relative parent import.
/// Matches paths like `../foo`, `..`, `./../foo`, etc.
fn is_parent_import(path: &str) -> bool {
let mut normalized = path;
while let Some(rest) = normalized.strip_prefix("./") {
normalized = rest;
fn is_parent_import(path: JSStr) -> bool {
// `.`
// and `/` are ASCII, and an ASCII byte never occurs inside a multi-byte WTF-8 sequence,
// so the byte view is exact for both tiers.
let mut rest = path.as_bytes();
while let Some(tail) = rest.strip_prefix(b"./") {
rest = tail;
}
normalized == ".." || normalized.starts_with("../")
rest == b".." || rest.starts_with(b"../")
}
#[test]
@@ -108,6 +112,11 @@ fn test() {
use crate::tester::Tester;
let pass = vec![
// Lone surrogates are part of the value.
// A search or prefix check must still see the rest.
r#"import foo from "./\uD800""#,
r#"import foo from "\uD800/../x""#,
r#"require("./\uDC00/..x")"#,
// ESLint test cases
r#"import foo from "./internal.js""#,
r#"import foo from "./app/index.js""#,
@@ -127,6 +136,13 @@ fn test() {
];
let fail = vec![
// Lone surrogates are part of the value.
// A search or prefix check must still see the rest.
r#"import foo from "../\uD800""#,
r#"import foo from "./../\uDC00.js""#,
r#"require("../\uD83D\uDE00")"#,
r#"import("../\uD800")"#,
r#"export * from "..\u002F\uDC00""#,
// ESLint test cases
r#"import foo from "../plugin.js""#,
r#"import foo from "./../plugin.js""#,
@@ -168,6 +168,10 @@ fn test() {
];
let fail = vec![
// A source with a lone surrogate matches no allow glob.
(r"import './\uD800.css'", None),
(r"import './\uD800.css'", Some(json!([{ "allow": ["**"] }]))),
(r"require('./\uD800.css')", Some(json!([{ "allow": ["**/*.css"] }]))),
("require('should')", None),
("import 'foo'", None),
("import './styles/app.css'", Some(json!([{ "allow": ["styles/*.css"]}]))),
@@ -28,6 +28,9 @@ fn test() {
use crate::tester::Tester;
let pass = vec![
// A segment with a lone surrogate is not `__mocks__`.
(r"require('./\uD800__mocks__/x')", None),
(r"require('./__mocks__\uDC00/x')", None),
("import something from 'something'", None),
("require('somethingElse')", None),
("require('./__mocks__.js')", None),
@@ -41,6 +44,9 @@ fn test() {
];
let fail = vec![
(r"require('./\uD83D\uDE00/__mocks__')", None),
(r"require('./__mocks__/\uD800')", None),
(r"require('\uDC00/__mocks__/x')", None),
("require('./__mocks__')", None),
("require('./__mocks__/')", None),
("require('./__mocks__/index')", None),
@@ -5,10 +5,11 @@ use oxc_ast::{
use oxc_diagnostics::OxcDiagnostic;
use oxc_macros::declare_oxc_lint;
use oxc_span::Span;
use oxc_str::JSStr;
use crate::{context::LintContext, rule::Rule, utils::PossibleJestNode};
fn add_type_parameter_to_module_mock_diagnostic(module_name: &str, span: Span) -> OxcDiagnostic {
fn add_type_parameter_to_module_mock_diagnostic(module_name: JSStr, span: Span) -> OxcDiagnostic {
OxcDiagnostic::warn(
"`jest.mock()` factories should not be used without an explicit type parameter.",
)
@@ -137,13 +138,17 @@ impl NoUntypedMockFactory {
return;
};
if let Expression::StringLiteral(string_literal) = expr
&& let Some(value) = string_literal.value.as_str()
{
if let Expression::StringLiteral(string_literal) = expr {
let value = string_literal.value;
ctx.diagnostic_with_fix(
add_type_parameter_to_module_mock_diagnostic(value, property_span),
|fixer| {
let mut code = String::with_capacity(string_literal.value.len() + 20);
// The fix is built from `&str`, so a name with a lone surrogate is
// reported without it.
let Some(value) = value.as_str() else {
return fixer.noop();
};
let mut code = String::with_capacity(value.len() + 20);
code.push_str("<typeof import('");
code.push_str(value);
code.push_str("')>");
@@ -153,7 +158,7 @@ impl NoUntypedMockFactory {
);
} else if let Expression::Identifier(ident) = expr {
ctx.diagnostic(add_type_parameter_to_module_mock_diagnostic(
ident.name.as_str(),
JSStr::from(ident.name),
property_span,
));
}
@@ -280,6 +285,12 @@ fn test() {
];
let fail = vec![
// Lone surrogates are part of the value.
// A search or prefix check must still see the rest.
(r"jest.mock('\uD800', () => ({}))", None),
(r"jest.doMock('a\uDC00b', () => ({}))", None),
// Ordinary names keep `str` Debug rendering, so an apostrophe is not escaped.
(r#"jest.mock("it's", () => ({}))"#, None),
(
"
jest.mock('../moduleName', () => {
@@ -333,6 +344,8 @@ fn test() {
];
let fix = vec![
// A name with a lone surrogate gets no fix.
(r"jest.mock('\uD800', () => ({}))", r"jest.mock('\uD800', () => ({}))", None),
(
"
jest.mock('../moduleName', () => {
@@ -48,10 +48,12 @@ fn test() {
(r"test('\uD800', () => {});", None),
(r"test('foo \uD800 bar', () => {});", None),
(r"test('\uD800test foo', () => {});", None),
// A title with a lone surrogate matches no disallowed word or configured pattern.
(
r"test('\uD800 correct', () => {});",
Some(serde_json::json!([{ "disallowedWords": ["correct"] }])),
),
(r"test('\uD800 foo', () => {});", Some(serde_json::json!([{ "mustNotMatch": "^\\W" }]))),
(
"it('correctly sets the value', () => {});",
Some(serde_json::json!([
@@ -248,6 +250,8 @@ fn test() {
];
let fail = vec![
// A title with a lone surrogate matches no configured pattern.
(r"test('\uD800 foo', () => {});", Some(serde_json::json!([{ "mustMatch": "^\\W" }]))),
("const String = { raw: () => 'foo' }; it(String.raw`foo`, () => {})", None),
(
"test('the correct way to properly handle all things', () => {});",
@@ -11,7 +11,7 @@ use oxc_ast::{
use oxc_diagnostics::OxcDiagnostic;
use oxc_macros::declare_oxc_lint;
use oxc_span::{GetSpan, Span};
use oxc_str::CompactStr;
use oxc_str::{CompactStr, JSStr};
use crate::{
AstNode,
@@ -250,19 +250,17 @@ impl AnchorIsValid {
fn check_value(value: &JSXAttributeValue) -> HrefValueKind {
match value {
JSXAttributeValue::Element(_) => HrefValueKind::Valid,
JSXAttributeValue::StringLiteral(str_lit) => str_lit
.value
.as_str()
.map_or(HrefValueKind::Valid, Self::href_value_kind_from_string),
JSXAttributeValue::StringLiteral(str_lit) => {
Self::href_value_kind_from_string(str_lit.value)
}
JSXAttributeValue::ExpressionContainer(exp) => match &exp.expression {
JSXExpression::Identifier(ident) if ident.name == "undefined" => {
HrefValueKind::Nullish
}
JSXExpression::NullLiteral(_) => HrefValueKind::Nullish,
JSXExpression::StringLiteral(str_lit) => str_lit
.value
.as_str()
.map_or(HrefValueKind::Valid, Self::href_value_kind_from_string),
JSXExpression::StringLiteral(str_lit) => {
Self::href_value_kind_from_string(str_lit.value)
}
JSXExpression::TemplateLiteral(temp_lit) => {
if !temp_lit.expressions.is_empty() {
return HrefValueKind::Valid;
@@ -271,7 +269,7 @@ impl AnchorIsValid {
let Some(quasi) = temp_lit.single_quasi() else {
return HrefValueKind::Valid;
};
quasi.as_str().map_or(HrefValueKind::Valid, Self::href_value_kind_from_string)
Self::href_value_kind_from_string(quasi)
}
_ => HrefValueKind::Valid,
},
@@ -279,14 +277,15 @@ impl AnchorIsValid {
}
}
fn href_value_kind_from_string(href: &str) -> HrefValueKind {
fn href_value_kind_from_string(href: JSStr<'_>) -> HrefValueKind {
if Self::is_invalid_href(href) { HrefValueKind::Invalid } else { HrefValueKind::Valid }
}
fn is_invalid_href(href: &str) -> bool {
let href_without_leading_non_word =
href.trim_start_matches(|c: char| !c.is_ascii_alphanumeric() && c != '_');
href.is_empty() || href == "#" || href_without_leading_non_word.starts_with("javascript:")
fn is_invalid_href(href: JSStr<'_>) -> bool {
// A lone surrogate is a non-word character, and the predicate never matches one.
let word_start =
href.find(|c: char| c.is_ascii_alphanumeric() || c == '_').unwrap_or(href.len());
href.is_empty() || href == "#" || href.as_bytes()[word_start..].starts_with(b"javascript:")
}
}
@@ -593,6 +592,8 @@ fn test() {
(r"<a href='#' onClick={() => void 0} />", None, None),
(r"<a href='javascript:void(0)' onClick={() => void 0} />", None, None),
(r"<a href={'javascript:void(0)'} onClick={() => void 0} />", None, None),
(r#"<a href={"javascript:void(0)\uD800"} />"#, None, None),
(r"<a href={`\uD800javascript:void(0)`} />", None, None),
(r"<Link />", Some(components()), None),
(r"<Link href={undefined} />", Some(components()), None),
(r"<Link href={null} />", Some(components()), None),
@@ -8,7 +8,7 @@ use oxc_diagnostics::OxcDiagnostic;
use oxc_ecmascript::{ToBoolean, WithoutGlobalReferenceInformation};
use oxc_macros::declare_oxc_lint;
use oxc_span::Span;
use oxc_str::CompactStr;
use oxc_str::{CompactStr, JSStr};
use oxc_syntax::operator::UnaryOperator;
use crate::{
@@ -157,7 +157,8 @@ fn is_valid_value_for_aria_prop_type(
{
return true;
}
parse_aria_prop_value_as_string(value, false).is_some()
// Any string is valid, including one containing a lone surrogate.
string_literal_value(value).is_some()
}
AriaPropType::Integer | AriaPropType::Number => {
if let Some(value_string) = parse_aria_prop_value_as_string(value, false) {
@@ -180,11 +181,13 @@ fn is_valid_value_for_aria_prop_type(
return true;
}
let Some(value_string) = parse_aria_prop_value_as_string(value, false) else {
let Some(value_string) = string_literal_value(value) else {
return false;
};
value_string.split_whitespace().next().is_some()
// At least one ID: a code point that is not whitespace.
// A lone surrogate is not whitespace.
value_string.chars().any(|c| c.to_char().is_none_or(|c| !c.is_whitespace()))
}
AriaPropType::Token(valid_tokens) => {
let Some(value_string) = parse_aria_prop_value_as_string(value, true) else {
@@ -209,6 +212,22 @@ fn is_valid_value_for_aria_prop_type(
}
}
/// The value of a string-shaped attribute:
/// * A string literal.
/// * A string literal in an expression container.
/// * A template literal without substitutions.
fn string_literal_value<'a>(value: &JSXAttributeValue<'a>) -> Option<JSStr<'a>> {
match value {
JSXAttributeValue::StringLiteral(string_lit) => Some(string_lit.value),
JSXAttributeValue::ExpressionContainer(container) => match &container.expression {
JSXExpression::StringLiteral(string_lit) => Some(string_lit.value),
JSXExpression::TemplateLiteral(template_lit) => template_lit.single_quasi(),
_ => None,
},
_ => None,
}
}
fn parse_aria_prop_value_as_string(
value: &JSXAttributeValue,
boolean_as_string: bool, // whether to convert boolean literal to string
@@ -409,6 +428,13 @@ fn test() {
"<div aria-label={`Hello ${foo}`} />",
"<div aria-label={`foo-${id}`} />",
"<div aria-label={`foo-${id} bar`} />",
// A string containing a lone surrogate is still a string.
r#"<div aria-label={"\uD800"} />"#,
r#"<div aria-label={"\uDC00 close"} />"#,
r"<div aria-label={`\uD83D\uDE00`} />",
r#"<div aria-labelledby={"\uD800"} />"#,
r#"<div aria-labelledby={"a \uDC00"} />"#,
r"<div aria-controls={`\uD83D\uDE00`} />",
"<div aria-label={foo} />",
"<div aria-label={foo.bar} />",
"<div aria-label={null} />",
@@ -538,6 +564,10 @@ fn test() {
];
let fail = vec![
// A lone surrogate never matches a token.
r#"<div aria-invalid={"\uD800"} />"#,
r#"<div aria-checked={"true\uDC00"} />"#,
r#"<div aria-level={"\uD83D\uDE00"} />"#,
r#"<div aria-hidden="yes" />"#,
r#"<div aria-hidden="no" />"#,
"<div aria-hidden={1234} />",
@@ -165,25 +165,19 @@ impl Rule for ImgRedundantAlt {
match alt_attribute {
JSXAttributeValue::StringLiteral(lit) => {
let alt_text = lit.value.as_str();
if alt_text.is_some_and(|text| self.is_redundant_alt_text(text)) {
if self.is_redundant_alt_text(lit.value.as_bytes()) {
ctx.diagnostic(img_redundant_alt_diagnostic(alt_attribute_name_span));
}
}
JSXAttributeValue::ExpressionContainer(container) => match &container.expression {
JSXExpression::StringLiteral(lit) => {
let alt_text = lit.value.as_str();
if alt_text.is_some_and(|text| self.is_redundant_alt_text(text)) {
if self.is_redundant_alt_text(lit.value.as_bytes()) {
ctx.diagnostic(img_redundant_alt_diagnostic(alt_attribute_name_span));
}
}
JSXExpression::TemplateLiteral(lit) => {
for quasi in &lit.quasis {
let alt_text = quasi.value.raw.as_str();
if self.is_redundant_alt_text(alt_text) {
if self.is_redundant_alt_text(quasi.value.raw.as_bytes()) {
ctx.diagnostic(img_redundant_alt_diagnostic(alt_attribute_name_span));
}
}
@@ -203,20 +197,29 @@ impl ImgRedundantAlt {
starts_boundary && ends_boundary
}
/// Whether `alt_text` contains a configured word, ignoring ASCII case.
///
/// `alt_text` may be WTF-8. A word is UTF-8, so it never matches part of a lone surrogate,
/// and a lone surrogate is not a word character.
#[inline]
fn is_redundant_alt_text(&self, alt_text: &str) -> bool {
let alt_text = alt_text.cow_to_ascii_lowercase();
let alt_text_bytes = alt_text.as_bytes();
fn is_redundant_alt_text(&self, alt_text: &[u8]) -> bool {
let lowercase;
let alt_text = if alt_text.iter().any(u8::is_ascii_uppercase) {
lowercase = alt_text.to_ascii_lowercase();
lowercase.as_slice()
} else {
alt_text
};
for word in &self.words {
for (index, _) in alt_text.match_indices(word.as_ref()) {
let end = index + word.len();
if Self::is_word_boundary(alt_text_bytes, index, end) {
return true;
}
}
}
false
self.words.iter().any(|word| {
memchr::memmem::find_iter(alt_text, word.as_bytes()).any(|index| {
// Like `str::match_indices`, match only at a character boundary. Only an empty
// word can match elsewhere.
let at_char_boundary =
alt_text.get(index).is_none_or(|byte| !matches!(byte, 0x80..=0xBF));
at_char_boundary && Self::is_word_boundary(alt_text, index, index + word.len())
})
})
}
}
@@ -243,6 +246,13 @@ fn test() {
let pass = vec![
(r"<img alt='foo' />;", None, None),
// A lone surrogate is not part of a word.
(r#"<img alt={"\uD800"} />;"#, None, None),
// An empty word matches only at character boundaries.
(r#"<img alt="aéb" />;"#, Some(serde_json::json!([{ "words": [""] }])), None),
(r#"<img alt={"a\uD800b"} />;"#, Some(serde_json::json!([{ "words": [""] }])), None),
(r#"<img alt={"\uD800 imagery"} />;"#, None, None),
(r#"<img alt={"photos\uDC00"} />;"#, None, None),
(r"<img alt='picture of me taking a photo of an image' aria-hidden />", None, None),
(r"<img aria-hidden alt='photo of image' />", None, None),
(r"<img ALt='foo' />;", None, None),
@@ -280,6 +290,11 @@ fn test() {
let fail = vec![
(r"<img alt='Photo of friend.' />;", None, None),
(r#"<img alt={"\uD83D\uDE00 picture"} />;"#, None, None),
// A lone surrogate is not part of a word.
(r#"<img alt={"image \uD800"} />;"#, None, None),
(r#"<img alt={"\uDC00 Photo"} />;"#, None, None),
(r#"<img alt={"\uD800image"} />;"#, None, None),
(r"<img alt='Picture of friend.' />;", None, None),
(r"<img alt='Image of friend.' />;", None, None),
(
@@ -1,4 +1,4 @@
use std::borrow::Cow;
use std::{borrow::Cow, iter};
use cow_utils::CowUtils;
use oxc_ast::{
@@ -8,7 +8,7 @@ use oxc_ast::{
use oxc_diagnostics::OxcDiagnostic;
use oxc_macros::declare_oxc_lint;
use oxc_span::{GetSpan, Span};
use oxc_str::CompactStr;
use oxc_str::{CompactStr, JSChar, JSStr};
use rustc_hash::FxHashMap;
use schemars::JsonSchema;
use serde::Deserialize;
@@ -167,7 +167,7 @@ impl Rule for NoNoninteractiveElementInteractions {
let role_value = role_value(jsx_el);
if is_content_editable(jsx_el)
|| is_hidden_from_screen_reader(ctx, jsx_el)
|| role_value.is_some_and(|role| {
|| role_value.and_then(JSStr::as_str).is_some_and(|role| {
let role = role.cow_to_lowercase();
matches!(role.as_ref(), "presentation" | "none")
})
@@ -175,7 +175,7 @@ impl Rule for NoNoninteractiveElementInteractions {
return;
}
if role_value.is_some_and(|role| {
if role_value.and_then(JSStr::as_str).is_some_and(|role| {
let role = role.cow_to_lowercase();
is_abstract_role_name(role.as_ref())
}) {
@@ -254,12 +254,12 @@ fn is_content_editable(jsx_el: &JSXOpeningElement) -> bool {
.is_some_and(|value| value == "true")
}
fn role_value<'b>(jsx_el: &'b JSXOpeningElement<'_>) -> Option<&'b str> {
fn role_value<'a>(jsx_el: &JSXOpeningElement<'a>) -> Option<JSStr<'a>> {
has_jsx_prop_ignore_case(jsx_el, "role").and_then(get_prop_value).and_then(
|value| match value {
JSXAttributeValue::StringLiteral(role) => role.value.as_str(),
JSXAttributeValue::StringLiteral(role) => Some(role.value),
JSXAttributeValue::ExpressionContainer(container) => match &container.expression {
JSXExpression::StringLiteral(role) => role.value.as_str(),
JSXExpression::StringLiteral(role) => Some(role.value),
_ => None,
},
_ => None,
@@ -315,11 +315,28 @@ fn is_focusable(jsx_el: &JSXOpeningElement, element_type: &str) -> bool {
}
}
fn first_recognized_role(role_value: &str) -> Option<Cow<'_, str>> {
role_value.split_whitespace().find_map(|role| {
let role = role.cow_to_lowercase();
is_recognized_role(role.as_ref()).then_some(role)
})
/// Split the value on whitespace like `str::split_whitespace`
/// and return the first token that is a recognized role.
fn first_recognized_role(role_value: JSStr<'_>) -> Option<Cow<'_, str>> {
let bytes = role_value.as_bytes();
let mut token_start = 0;
let mut offset = 0;
for c in role_value.chars().chain(iter::once(JSChar::from(' '))) {
if c.to_char().is_some_and(char::is_whitespace) {
// A token with a lone surrogate is not UTF-8 and is never a recognized role.
if let Ok(token) = std::str::from_utf8(&bytes[token_start..offset])
&& !token.is_empty()
{
let role = token.cow_to_lowercase();
if is_recognized_role(role.as_ref()) {
return Some(role);
}
}
token_start = offset + c.len_bytes();
}
offset += c.len_bytes();
}
None
}
fn is_recognized_role(role: &str) -> bool {
@@ -401,6 +418,7 @@ fn test() {
(r#"<div role="presentation" onClick={() => void 0} />"#, None, None).into(),
(r#"<main role={"button"} onClick={() => void 0} />"#, None, None).into(),
(r#"<main role="unknown button" onClick={() => void 0} />"#, None, None).into(),
(r#"<li onClick={() => void 0} role={"\uD800 button"} />"#, None, None).into(),
(
r#"<div role="separator" tabIndex={0} aria-valuenow={50} onClick={() => void 0} />"#,
None,
@@ -9,7 +9,7 @@ use oxc_ast::{
use oxc_diagnostics::OxcDiagnostic;
use oxc_macros::declare_oxc_lint;
use oxc_span::Span;
use oxc_str::CompactStr;
use oxc_str::{CompactStr, JSStr};
use crate::{
AstNode,
@@ -197,14 +197,14 @@ fn get_select_implicit_role(jsx_el: &JSXOpeningElement) -> &'static str {
}
}
fn get_static_string_prop_value<'a>(item: &'a JSXAttributeItem<'_>) -> Option<&'a str> {
fn get_static_string_prop_value<'a>(item: &JSXAttributeItem<'a>) -> Option<JSStr<'a>> {
match get_prop_value(item)? {
JSXAttributeValue::StringLiteral(lit) => lit.value.as_str(),
JSXAttributeValue::StringLiteral(lit) => Some(lit.value),
JSXAttributeValue::ExpressionContainer(container) => {
let Expression::StringLiteral(lit) = container.expression.as_expression()? else {
return None;
};
lit.value.as_str()
Some(lit.value)
}
_ => None,
}
@@ -241,6 +241,7 @@ fn get_input_implicit_role(
) -> Option<&'static str> {
let input_type = has_jsx_prop_ignore_case(jsx_el, "type")
.and_then(get_static_string_prop_value)
.and_then(JSStr::as_str)
.unwrap_or("text");
let has_list = has_jsx_prop_ignore_case(jsx_el, "list").is_some();
@@ -351,6 +352,7 @@ fn test() {
Some(serde_json::json!([{ "ul": ["list"], "ol": ["list"] }])),
None,
),
(r#"<img src={"a\uD800.svg"} role="img" />"#, None, None),
(r#"<li role="listitem" />"#, Some(serde_json::json!([{ "li": ["listitem"] }])), None),
// Ancestor-dependent elements: implicit role cannot be determined
// from a single opening element, so do not flag. See #22743.
@@ -235,14 +235,12 @@ fn infer_module_type(init: &Expression) -> ModuleType {
return ModuleType::Computed;
};
let Some(value) = arg.as_expression().and_then(|expr| match expr {
Expression::StringLiteral(lit) => lit.value.as_str(),
_ => None,
}) else {
let Some(Expression::StringLiteral(lit)) = arg.as_expression() else {
return ModuleType::Computed;
};
let value = lit.value;
if BUILTIN_MODULES.contains(&value) {
if value.as_str().is_some_and(|value| BUILTIN_MODULES.contains(&value)) {
return ModuleType::Core;
}
@@ -309,6 +307,9 @@ fn test() {
use crate::tester::Tester;
let pass = vec![
// A path with a lone surrogate is still classified as a file or a module.
(r"var a = require('./\uD800'), b = require('../x')", Some(serde_json::json!([true]))),
(r"var a = require('\uDC00'), b = require('foo')", Some(serde_json::json!([true]))),
("var a, b = 42, c = doStuff()", Some(serde_json::json!([false]))),
(
"var a = require(42), b = require(), c = require('y'), d = require(doStuff())",
@@ -340,6 +341,9 @@ fn test() {
];
let fail = vec![
// A path with a lone surrogate is still classified as a file or a module.
(r"var fs = require('fs'), a = require('\uD800')", Some(serde_json::json!([true]))),
(r"var a = require('./\uD83D\uDE00'), b = require('foo')", Some(serde_json::json!([true]))),
("var fs = require('fs'), foo = 42", Some(serde_json::json!([false]))),
("var fs = require('fs'), foo", Some(serde_json::json!([false]))),
(
+59 -17
View File
@@ -16,6 +16,7 @@ use oxc_ast::{
use oxc_diagnostics::OxcDiagnostic;
use oxc_macros::declare_oxc_lint;
use oxc_span::{GetSpan, Span};
use oxc_str::JSStr;
use crate::{
AstNode,
@@ -46,7 +47,8 @@ fn key_prop_must_be_placed_before_spread(span: Span) -> OxcDiagnostic {
.with_label(span)
}
fn duplicate_key_prop(key_value: &str, span: Span) -> OxcDiagnostic {
fn duplicate_key_prop(key_value: JSStr<'_>, span: Span) -> OxcDiagnostic {
let key_value = key_value.display();
OxcDiagnostic::warn(format!("Duplicate key '{key_value}' found in JSX elements"))
.with_help("Each child in a list should have a unique 'key' prop")
.with_label(span)
@@ -390,7 +392,10 @@ fn gen_diagnostic(span: Span, outer: &InsideArrayOrIterator) -> OxcDiagnostic {
}
}
fn get_jsx_element_key_value(jsx_elem: &JSXElement) -> Option<(String, Span)> {
fn get_jsx_element_key_value<'a>(
jsx_elem: &JSXElement<'a>,
ctx: &LintContext<'a>,
) -> Option<(JSStr<'a>, Span)> {
for attr in &jsx_elem.opening_element.attributes {
if let JSXAttributeItem::Attribute(attr) = attr
&& let JSXAttributeName::Identifier(ident) = &attr.name
@@ -400,21 +405,21 @@ fn get_jsx_element_key_value(jsx_elem: &JSXElement) -> Option<(String, Span)> {
if let Some(value) = &attr.value {
match value {
JSXAttributeValue::StringLiteral(lit) => {
return Some((lit.value.as_str()?.to_owned(), attr.span));
return Some((lit.value, attr.span));
}
JSXAttributeValue::ExpressionContainer(container) => {
// JSXExpression inherits from Expression, so we match the Expression variants directly
match &container.expression {
JSXExpression::StringLiteral(lit) => {
return Some((lit.value.as_str()?.to_owned(), attr.span));
return Some((lit.value, attr.span));
}
JSXExpression::NumericLiteral(lit) => {
return Some((lit.value.to_string(), attr.span));
let value =
JSStr::from_str_in(&lit.value.to_string(), &ctx.allocator());
return Some((value, attr.span));
}
JSXExpression::TemplateLiteral(lit)
if lit.expressions.is_empty() && lit.quasis.len() == 1 =>
{
return Some((lit.quasis[0].value.raw.to_string(), attr.span));
JSXExpression::TemplateLiteral(lit) => {
return lit.single_quasi().map(|value| (value, attr.span));
}
_ => {}
}
@@ -428,28 +433,28 @@ fn get_jsx_element_key_value(jsx_elem: &JSXElement) -> Option<(String, Span)> {
}
fn check_duplicate_keys_in_array<'a>(array_expr: &ArrayExpression<'a>, ctx: &LintContext<'a>) {
let mut seen_keys: FxHashSet<String> = FxHashSet::default();
let mut seen_keys: FxHashSet<JSStr<'a>> = FxHashSet::default();
for element in &array_expr.elements {
// ArrayExpressionElement also inherits from Expression
if let ArrayExpressionElement::JSXElement(jsx_elem) = element
&& let Some((key_value, span)) = get_jsx_element_key_value(jsx_elem)
&& let Some(key_value) = seen_keys.replace(key_value)
&& let Some((key_value, span)) = get_jsx_element_key_value(jsx_elem, ctx)
&& !seen_keys.insert(key_value)
{
ctx.diagnostic(duplicate_key_prop(&key_value, span));
ctx.diagnostic(duplicate_key_prop(key_value, span));
}
}
}
fn check_duplicate_keys_in_children<'a>(jsx_elem: &JSXElement<'a>, ctx: &LintContext<'a>) {
let mut seen_keys: FxHashSet<String> = FxHashSet::default();
let mut seen_keys: FxHashSet<JSStr<'a>> = FxHashSet::default();
for child in &jsx_elem.children {
if let JSXChild::Element(child_elem) = child
&& let Some((key_value, span)) = get_jsx_element_key_value(child_elem)
&& let Some(key_value) = seen_keys.replace(key_value)
&& let Some((key_value, span)) = get_jsx_element_key_value(child_elem, ctx)
&& !seen_keys.insert(key_value)
{
ctx.diagnostic(duplicate_key_prop(&key_value, span));
ctx.diagnostic(duplicate_key_prop(key_value, span));
}
}
}
@@ -467,6 +472,17 @@ fn test() {
(r"[1, 2, 3].map(x => <App key={x} />);", None, None),
(r"[1, 2 ,3].map(x => x && <App x={x} key={x} />);", None, None),
(r#"[1, 2 ,3].map(x => x ? <App x={x} key="1" /> : <OtherApp x={x} key="2" />);"#, None, None),
// Different lone surrogates, and a pair versus its lead, are different keys.
(
r#"const spans = [<span key={"\uD800"}/>, <span key={"\uDC00"}/>];"#,
Some(serde_json::json!([{ "warnOnDuplicates": true }])),
None,
),
(
r#"const div = <div><span key={"\uD83D\uDE00"}/><span key={"\uD83D"}/></div>;"#,
Some(serde_json::json!([{ "warnOnDuplicates": true }])),
None,
),
(r"[1, 2, 3].map(x => { return <App key={x} /> });", None, None),
(r"Array.from([1, 2, 3], function(x) { return <App key={x} /> });", None, None),
(r"Array.from([1, 2, 3], (x => <App key={x} />));", None, None),
@@ -600,6 +616,16 @@ fn test() {
];
let fail = vec![
(
r#"const xs = [<span key={"a\uD800"} />, <span key={`a\uD800`} />];"#,
Some(serde_json::json!([{ "warnOnDuplicates": true }])),
None,
),
(
r#"const xs = [<span key={"foo"} />, <span key={`\u0066oo`} />];"#,
Some(serde_json::json!([{ "warnOnDuplicates": true }])),
None,
),
("[<App />];", None, None),
("[<App {...key} />];", None, None),
("[<App key={0}/>, <App />];", None, None),
@@ -652,6 +678,22 @@ fn test() {
Some(serde_json::json!([{ "warnOnDuplicates": true }])),
None,
),
// Keys containing lone surrogates are compared by their JavaScript value.
(
r#"const spans = [<span key={"\uD800"}/>, <span key={"\uD800"}/>];"#,
Some(serde_json::json!([{ "warnOnDuplicates": true }])),
None,
),
(
r"const spans = [<span key={`a\uDC00`}/>, <span key={`a\uDC00`}/>];",
Some(serde_json::json!([{ "warnOnDuplicates": true }])),
None,
),
(
r#"const div = <div><span key={"\uD83D\uDE00"}/><span key={"\uD83D\uDE00"}/></div>;"#,
Some(serde_json::json!([{ "warnOnDuplicates": true }])),
None,
),
(
r#"
const div = (
@@ -11,6 +11,7 @@ use oxc_diagnostics::OxcDiagnostic;
use oxc_macros::declare_oxc_lint;
use oxc_span::{GetSpan, Span};
use oxc_str::CompactStr;
use oxc_str::JSStr;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
@@ -350,7 +351,7 @@ fn append_noreferrer(raw: &str) -> String {
format!("{quote}{}{quote}", parts.join(" "))
}
fn check_is_external_link(link: &str) -> bool {
fn check_is_external_link(link: JSStr<'_>) -> bool {
link.contains("//")
}
@@ -361,7 +362,7 @@ fn match_href_expression(
) {
match expr {
Expression::StringLiteral(str) => {
*is_external_link = str.value.as_str().is_some_and(check_is_external_link);
*is_external_link = check_is_external_link(str.value);
}
Expression::Identifier(_) => *is_dynamic_link = true,
Expression::ConditionalExpression(expr) => {
@@ -382,7 +383,7 @@ fn check_href(
matches!(enforce_dynamic_links, EnforceDynamicLinksEnum::Never);
match attribute_value {
JSXAttributeValue::StringLiteral(str) => {
is_external_link = str.value.as_str().is_some_and(check_is_external_link);
is_external_link = check_is_external_link(str.value);
}
JSXAttributeValue::ExpressionContainer(expr) => {
if let Some(expr) = expr.expression.as_expression() {
@@ -408,22 +409,17 @@ fn check_href(
}
fn check_rel_val(str: &StringLiteral, allow_referrer: bool) -> bool {
let Some(value) = str.value.as_str() else {
return false;
};
let mut splits = value.split(' ');
if allow_referrer {
return splits.any(|str| {
if str == "noopener" {
return true;
}
if str == "noreferrer" {
return true;
}
false
});
return any_rel_token(str.value, |token| token == b"noopener" || token == b"noreferrer");
}
splits.any(|str| str.eq_ignore_ascii_case("noreferrer"))
any_rel_token(str.value, |token| token.eq_ignore_ascii_case(b"noreferrer"))
}
/// Split `rel` on spaces and test each token.
/// The space is ASCII, so splitting the WTF-8 bytes finds the same tokens.
/// A token with a lone surrogate never equals an ASCII keyword.
fn any_rel_token(value: JSStr<'_>, is_match: impl FnMut(&[u8]) -> bool) -> bool {
value.as_bytes().split(|&byte| byte == b' ').any(is_match)
}
fn match_rel_expression<'a>(
@@ -517,6 +513,19 @@ fn test() {
(r#"<a href="foobar" target="_blank" rel={"noreferrer"}></a>"#, None, None),
(r#"<a href={"foobar"} target={"_blank"} rel={"noopener noreferrer"}></a>"#, None, None),
(r#"<a href={"foobar"} target={"_blank"} rel={"noreferrer"}></a>"#, None, None),
// A lone surrogate in `rel` does not hide the tokens beside it.
(
r#"<a href={"https://x/\uD800"} target="_blank" rel={"\uD800 noreferrer"}></a>"#,
None,
None,
),
(r#"<a href={"//x/\uDC00"} target="_blank" rel={"noreferrer \uDC00"}></a>"#, None, None),
(
r#"<a href={"https://x/\uD83D\uDE00"} target="_blank" rel={"noopener \uD83D noreferrer"}></a>"#,
None,
None,
),
(r#"<a href={"\uD800"} target="_blank"></a>"#, None, None),
(r"<a href={'foobar'} target={'_blank'} rel={'noopener noreferrer'}></a>", None, None),
(r"<a href={'foobar'} target={'_blank'} rel={'noreferrer'}></a>", None, None),
(r"<a href={`foobar`} target={`_blank`} rel={`noopener noreferrer`}></a>", None, None),
@@ -704,6 +713,16 @@ fn test() {
let fail = vec![
(r#"<a target="_blank" href="https://example.com/1"></a>"#, None, None),
// A lone surrogate in the link does not make it internal.
(r#"<a target="_blank" href={"https://x/\uD800"}></a>"#, None, None),
// A token with a lone surrogate is not `noreferrer`.
(r#"<a target="_blank" href="http://x.com" rel={"\uD800"}></a>"#, None, None),
(r#"<a target="_blank" href={"//x/\uDC00"} rel={"\uDC00"}></a>"#, None, None),
(
r#"<a target="_blank" href={"https://x/\uD83D\uDE00"} rel={"noreferrer\uD800"}></a>"#,
None,
None,
),
(r#"<a target="_blank" rel="" href="https://example.com/2"></a>"#, None, None),
(r#"<a target="_blank" rel={0} href="https://example.com/3"></a>"#, None, None),
(r#"<a target="_blank" rel={1} href="https://example.com/3"></a>"#, None, None),
@@ -8,7 +8,7 @@ use oxc_span::Span;
use crate::{AstNode, context::LintContext, rule::Rule, utils::is_create_element_call};
fn no_namespace_diagnostic(span: Span, component_name: &str) -> OxcDiagnostic {
fn no_namespace_diagnostic(span: Span, component_name: impl std::fmt::Display) -> OxcDiagnostic {
let message = format!(
r"React component {component_name} must not be in a namespace, as React does not support them."
);
@@ -67,10 +67,9 @@ impl Rule for NoNamespace {
return;
};
if let Some(value) = str_lit.value.as_str()
&& value.contains(':')
{
ctx.diagnostic(no_namespace_diagnostic(str_lit.span, value));
let value = str_lit.value;
if value.contains(':') {
ctx.diagnostic(no_namespace_diagnostic(str_lit.span, value.display()));
}
}
_ => {}
@@ -108,6 +107,8 @@ fn test() {
"<Object.TestComponent />",
r#"React.createElement("Object.TestComponent")"#,
"React.createElement(null)",
r#"React.createElement("a\uD800b")"#,
r#"React.createElement("\uDC00")"#,
"React.createElement(true)",
"React.createElement({})",
];
@@ -115,6 +116,10 @@ fn test() {
let fail = vec![
"<ns:testcomponent />",
r#"React.createElement("ns:testcomponent")"#,
// Lone surrogates beside the namespace separator are still namespaced names.
r#"React.createElement("ns:\uD800")"#,
r#"React.createElement("\uDC00:testcomponent")"#,
r#"React.createElement("\uD83D\uDE00:\uD800")"#,
"<ns:testComponent />",
r#"React.createElement("ns:testComponent")"#,
"<ns:test_component />",
@@ -282,6 +282,7 @@ impl NoLargeSnapshotsConfig {
member_expr: &MemberExpression,
ctx: &LintContext,
) -> bool {
// A snapshot name with a lone surrogate matches no allowed pattern.
let Some(snapshot_name) = member_expr.static_property_name().and_then(JSStr::as_str) else {
return false;
};
@@ -324,6 +325,64 @@ mod test {
assert!(!matchers[1].is_match("snapshot literal"));
}
#[test]
fn external_snapshot_allow_list_requires_a_match() {
use std::{rc::Rc, sync::Arc};
use oxc_allocator::Allocator;
use oxc_ast::AstKind;
use oxc_parser::Parser;
use oxc_semantic::SemanticBuilder;
use oxc_span::SourceType;
use crate::{
LintOptions, ModuleRecord,
context::{ContextHost, ContextSubHost, ContextSubHostOptions},
};
let allocator = Allocator::default();
let source = r#"exports["bar\uD800"] = ``; exports["foo"] = ``;"#;
let parsed = Parser::new(&allocator, source, SourceType::default()).parse();
assert!(parsed.diagnostics.is_empty());
let semantic =
SemanticBuilder::new_linter().build(allocator.alloc(parsed.program)).semantic;
let ctx = Rc::new(ContextHost::new(
"/test.snap",
vec![ContextSubHost::new(
semantic,
Arc::new(ModuleRecord::default()),
0,
ContextSubHostOptions::default(),
)],
&allocator,
LintOptions::default(),
Arc::default(),
))
.spawn_for_test();
// A snapshot name with a lone surrogate matches no allowed pattern.
for (allowed, foo_allowed, bar_allowed) in [
(serde_json::json!([]), false, false),
(serde_json::json!(["^foo"]), true, false),
(serde_json::json!(["^bar.$"]), false, false),
(serde_json::json!([".*"]), true, false),
] {
let config = NoLargeSnapshotsConfig::from_configuration(serde_json::json!([{
"allowedSnapshots": { "/test.snap": allowed }
}]))
.unwrap();
for node in ctx.nodes().iter() {
if let AstKind::AssignmentExpression(assignment) = node.kind()
&& let Some(member) = assignment.left.as_member_expression()
{
let is_foo = member.static_property_name().is_some_and(|name| name == "foo");
let expected = if is_foo { foo_allowed } else { bar_allowed };
assert_eq!(config.check_allowed_in_snapshots(member, &ctx), expected);
}
}
}
}
#[test]
fn inline_max_size_defaults_to_max_size() {
let config = NoLargeSnapshotsConfig::from_configuration(serde_json::json!([{
@@ -3,6 +3,7 @@ use std::path::PathBuf;
use oxc_ast::{AstKind, ast::Argument};
use oxc_diagnostics::OxcDiagnostic;
use oxc_span::Span;
use oxc_str::JSStr;
use crate::context::LintContext;
@@ -65,12 +66,24 @@ pub fn run_once(ctx: &LintContext) {
return;
};
if string_literal.value.as_str().is_some_and(contains_mocks_dir) {
if js_str_contains_mocks_dir(string_literal.value) {
ctx.diagnostic(no_mocks_import_diagnostic(string_literal.span));
}
}
}
fn js_str_contains_mocks_dir(value: JSStr<'_>) -> bool {
if let Some(value) = value.as_str() {
return contains_mocks_dir(value);
}
// Separators are ASCII, so splitting the WTF-8 bytes finds the same segments.
// A segment with a lone surrogate never equals `__mocks__`.
value
.as_bytes()
.split(|&byte| std::path::is_separator(char::from(byte)))
.any(|segment| segment == b"__mocks__")
}
fn contains_mocks_dir(value: &str) -> bool {
PathBuf::from(value).components().any(|c| match c {
std::path::Component::Normal(p) => p == std::ffi::OsStr::new("__mocks__"),
@@ -423,8 +423,7 @@ fn validate_title(
return;
}
// Regular expressions match UTF-8 only.
// A title with a lone surrogate skips this check.
// A title with a lone surrogate matches no disallowed word.
if let Some(disallowed_words_reg) = &config.disallowed_words_reg
&& let Some(utf8_title) = title.as_str()
&& let Some(matched) = disallowed_words_reg.find(utf8_title)
@@ -477,14 +476,11 @@ fn validate_title(
return;
};
// Regular expressions match UTF-8 only.
// A title with a lone surrogate skips the pattern checks.
let Some(title) = title.as_str() else {
return;
};
// A title with a lone surrogate matches no configured pattern.
let title = title.as_str();
if let Some((regex, message)) = config.must_match_patterns.get(&jest_fn_name)
&& !regex.is_match(title)
&& !title.is_some_and(|title| regex.is_match(title))
{
let raw_pattern = regex.as_str();
let message = match message.as_ref() {
@@ -495,7 +491,7 @@ fn validate_title(
}
if let Some((regex, message)) = config.must_not_match_patterns.get(&jest_fn_name)
&& regex.is_match(title)
&& title.is_some_and(|title| regex.is_match(title))
{
let raw_pattern = regex.as_str();
let message = match message.as_ref() {
@@ -1,5 +1,3 @@
use std::borrow::Cow;
use oxc_ast::{
AstKind,
ast::{Expression, TSEnumMember},
@@ -27,12 +25,7 @@ fn no_duplicate_enum_values_diagnostic(
let second_init_span = second_member.initializer.as_ref().map(GetSpan::span).unwrap();
OxcDiagnostic::warn(format!("Duplicate enum value `{value}`"))
.with_help(format!(
"Give {} a unique value",
second_name
.as_str()
.map_or_else(|| Cow::Owned(format!("{second_name:?}")), Cow::Borrowed)
))
.with_help(format!("Give {} a unique value", second_name.display()))
.with_labels([
first_init_span.label(format!("{value} is first used as an initializer here")),
second_init_span.label("and is re-used here"),
@@ -8,7 +8,7 @@ use oxc_diagnostics::OxcDiagnostic;
use oxc_macros::declare_oxc_lint;
use oxc_semantic::IsGlobalReference;
use oxc_span::Span;
use oxc_str::static_ident;
use oxc_str::{JSStr, static_ident};
use schemars::JsonSchema;
use serde::Deserialize;
@@ -149,13 +149,17 @@ impl Rule for NoRequireImports {
&& let Some(argument) = call_expr.arguments.first()
{
match argument {
Argument::TemplateLiteral(template_literal) => {
let Some(quasi) = template_literal.quasis.first() else {
return;
};
if match_argument_value_with_regex(&self.allow, &quasi.value.raw) {
return;
}
// Like typescript-eslint's `getStaticStringValue`, only a template
// without expressions has a value, and it is the cooked one.
Argument::TemplateLiteral(template_literal)
if template_literal
.single_quasi()
.and_then(JSStr::as_str)
.is_some_and(|value| {
match_argument_value_with_regex(&self.allow, value)
}) =>
{
return;
}
Argument::StringLiteral(string_literal)
if string_literal.value.as_str().is_some_and(|value| {
@@ -321,6 +325,24 @@ fn test() {
];
let fail = vec![
// A source with a lone surrogate matches no allow pattern.
(
r"const pkg = require('a\uD800b.json');",
Some(serde_json::json!([{ "allow": ["\\.json$"] }])),
),
(
r"import pkg = require('a\uD800b.json');",
Some(serde_json::json!([{ "allow": ["\\.json$"] }])),
),
(
r"const pkg = require(`a\uD800b.json`);",
Some(serde_json::json!([{ "allow": ["^a.b\\.json$"] }])),
),
// A template with expressions has no static value.
(
"const pkg = require(`./${name}.json`);",
Some(serde_json::json!([{ "allow": ["^\\./"] }])),
),
("var lib = require('lib');", None),
("let lib2 = require('lib2');", None),
(
@@ -74,13 +74,11 @@ impl Rule for NoConsoleSpaces {
for (i, arg) in call_expr.arguments.iter().enumerate() {
if let Some(expression_arg) = arg.as_expression() {
let (literal_raw, is_template_lit) = match expression_arg {
let (has_leading_space, has_trailing_space, is_template_lit) = match expression_arg
{
Expression::StringLiteral(string_lit) => {
let Some(literal_raw) = string_lit.value.as_str() else {
continue;
};
(literal_raw, false)
let value = string_lit.value;
(value.starts_with(' '), value.ends_with(' '), false)
}
Expression::TemplateLiteral(string_lit) => {
let literal_raw = string_lit
@@ -89,13 +87,13 @@ impl Rule for NoConsoleSpaces {
.trim_start_matches('`')
.trim_end_matches('`');
(literal_raw, true)
(literal_raw.starts_with(' '), literal_raw.ends_with(' '), true)
}
_ => continue,
};
if check_literal_leading(i, literal_raw) {
if i != 0 && has_leading_space {
report_diagnostic(
"leading",
// SAFETY: `is_method_call` ensures that `call_expr`'s `callee` is a `MemberExpression` with a `MemberExpression` as its `object`.
@@ -106,7 +104,7 @@ impl Rule for NoConsoleSpaces {
);
}
if check_literal_trailing(i, literal_raw, call_expr_arg_len) {
if i != call_expr_arg_len - 1 && has_trailing_space {
report_diagnostic(
"trailing",
// SAFETY: `is_method_call` ensures that `call_expr`'s `callee` is a `MemberExpression` with a `MemberExpression` as its `object`.
@@ -121,12 +119,6 @@ impl Rule for NoConsoleSpaces {
}
}
fn check_literal_leading(i: usize, literal: &str) -> bool {
i != 0 && literal.starts_with(' ')
}
fn check_literal_trailing(i: usize, literal: &str, call_expr_arg_len: usize) -> bool {
i != call_expr_arg_len - 1 && literal.ends_with(' ')
}
fn report_diagnostic<'a>(
direction: &'static str,
ident: &'a str,
@@ -195,6 +187,9 @@ fn test() {
"lib.console.info(\" a \", \" b \");",
"lib.console.warn(\" a \", \" b \");",
"lib.console.error(\" a \", \" b \");",
// A lone surrogate is not a space.
r#"console.log("\uD800", "\uDC00");"#,
r#"console.log("\uD800 x", "y \uDC00");"#,
];
let fail = vec![
@@ -232,9 +227,15 @@ fn test() {
"console[\"info\"](\" a \", \" b \");",
"console[\"warn\"](\" a \", \" b \");",
"console[\"error\"](\" a \", \" b \");",
// Lone surrogates elsewhere in the value do not hide the spaces.
r#"console.log("\uD800 ", "def");"#,
r#"console.log("abc", " \uDC00");"#,
r#"console.log("_", " \uD83D\uDE00 ", "_");"#,
];
let fix = vec![
(r#"console.log("\uD800 ", bar)"#, r#"console.log("\uD800", bar)"#),
(r#"console.log(foo, " \uDC00")"#, r#"console.log(foo, "\uDC00")"#),
("console.log(\"foo \", bar)", "console.log(\"foo\", bar)"),
("console.debug(\"foo \", bar)", "console.debug(\"foo\", bar)"),
("console.info(\"foo \", bar)", "console.info(\"foo\", bar)"),
@@ -145,7 +145,13 @@ fn is_invalid_fetch_options<'a>(
if let Some(Expression::StringLiteral(str_lit)) =
&m.initializer
{
str_lit.value.as_str().map(CompactStr::from)
// A value with a lone surrogate is not a known method.
Some(CompactStr::from(
str_lit
.value
.as_str()
.unwrap_or(&UNKNOWN_METHOD_NAME),
))
} else {
None
}
@@ -329,6 +335,8 @@ fn test() {
(r#"new Request("/url", { method: getOptions().method, body: "some body" });"#),
(r#"fetch("/url", { method: (options).method, body: "some body" });"#),
(r#"new Request("/url", { method: (options).method, body: "some body" });"#),
r#"enum M { A = "\uD800" } fetch(u, {method: M.A, body: "x"})"#,
r#"enum M { A = "\uD800", B = "GET" } fetch(u, {method: M.A, body: "x"})"#,
];
let fail = vec![
@@ -128,10 +128,12 @@ impl Rule for PreferDomNodeDataset {
return;
};
let Some(dataset_property_name) = string_lit.value.as_str().and_then(strip_data_prefix)
else {
let value = string_lit.value;
if !value.starts_with("data-") && !value.starts_with("DATA-") {
return;
};
}
// The fix writes the rest of the name as text, so it is skipped for a lone surrogate.
let dataset_property_name = value.as_str().and_then(strip_data_prefix);
match method {
AttributeMethod::Set => {
@@ -143,6 +145,9 @@ impl Rule for PreferDomNodeDataset {
return fixer.noop();
}
let Some(dataset_property_name) = dataset_property_name else {
return fixer.noop();
};
let dataset_property_name_camel = dash_to_camel_case(dataset_property_name);
let object_span = member_expr.object().span();
@@ -163,6 +168,9 @@ impl Rule for PreferDomNodeDataset {
return fixer.noop();
}
let Some(dataset_property_name) = dataset_property_name else {
return fixer.noop();
};
let dataset_property_name_camel = dash_to_camel_case(dataset_property_name);
let object_span = member_expr.object().span();
@@ -185,6 +193,9 @@ impl Rule for PreferDomNodeDataset {
return fixer.noop();
}
let Some(dataset_property_name) = dataset_property_name else {
return fixer.noop();
};
let dataset_property_name_camel = dash_to_camel_case(dataset_property_name);
let object_span = member_expr.object().span();
@@ -204,6 +215,9 @@ impl Rule for PreferDomNodeDataset {
return fixer.noop();
}
let Some(dataset_property_name) = dataset_property_name else {
return fixer.noop();
};
let dataset_property_name_camel = dash_to_camel_case(dataset_property_name);
let object_span = member_expr.object().span();
@@ -469,6 +483,7 @@ fn test() {
r#"element.optional?.getAttribute("data-unicorn")?.length;"#,
r#"element.getAttribute("data-unicorn").toString()"#,
r#"(await promise).getAttribute("data-foo")"#,
r#"el.setAttribute("data-\uD800", "x");"#,
];
let fix = vec![
@@ -611,6 +626,7 @@ fn test() {
r"element.dataset.unicorn.toString()",
),
(r#"(await promise).getAttribute("data-foo")"#, r"(await promise).dataset.foo"),
(r#"el.setAttribute("data-\uD800", "x");"#, r#"el.setAttribute("data-\uD800", "x");"#),
];
Tester::new(PreferDomNodeDataset::NAME, PreferDomNodeDataset::PLUGIN, pass, fail)
@@ -9,7 +9,7 @@ use oxc_ast::{
AstKind,
ast::{
BindingPattern, ExportFromDeclaration, ExportSpecifier, ImportAttributeKey,
ImportDeclaration, ImportDeclarationSpecifier, ModuleExportName, Program, Statement,
ImportDeclaration, ImportDeclarationSpecifier, ModuleExportName, Statement,
VariableDeclarationKind, VariableDeclarator, WithClause, WithClauseKeyword,
},
};
@@ -147,9 +147,7 @@ impl PreferExportFrom {
let re_export_decl = find_corresponding_export(ctx, import_decl);
let Some(source) = import_decl.source.value.as_str() else {
return;
};
let source = ctx.source_range(import_decl.source.span);
let with_clause = import_decl.with_clause.as_ref().map(|with_clause| {
let keyword = match with_clause.keyword {
WithClauseKeyword::With => "with",
@@ -760,7 +758,7 @@ impl PreferExportFrom {
}
fn format_export_statement(exports: &str, source: &str, with_clause: Option<&str>) -> String {
let mut result = format!("export {{ {exports} }} from '{source}'");
let mut result = format!("export {{ {exports} }} from {source}");
if let Some(clause) = with_clause {
result.push(' ');
@@ -782,7 +780,7 @@ impl PreferExportFrom {
let formatted_name =
if is_typescript_type { format!("type {name}") } else { name.to_string() };
let mut result = format!("export {formatted_name} from '{source}'");
let mut result = format!("export {formatted_name} from {source}");
if let Some(clause) = with_clause {
result.push(' ');
@@ -1146,19 +1144,15 @@ fn find_corresponding_export<'a>(
ctx: &LintContext<'a>,
import_decl: &'a ImportDeclaration<'a>,
) -> Option<&'a ExportFromDeclaration<'a>> {
let source = import_decl.source.value.as_str()?;
let program = ctx.nodes().program();
for requested_module in ctx.module_record().requested_modules.get(source)? {
if requested_module.is_import {
continue;
}
let Some(export_decl) =
find_export_named_declaration_by_span(program, requested_module.statement_span)
else {
for statement in &program.body {
let Statement::ExportFromDeclaration(export_decl) = statement else {
continue;
};
if export_decl.source.value != import_decl.source.value {
continue;
}
if is_matching_export_kind(import_decl, export_decl)
&& has_matching_with_clause(
@@ -1174,18 +1168,6 @@ fn find_corresponding_export<'a>(
None
}
fn find_export_named_declaration_by_span<'a>(
program: &'a Program<'a>,
span: Span,
) -> Option<&'a ExportFromDeclaration<'a>> {
program.body.iter().find_map(|statement| {
let Statement::ExportFromDeclaration(export_decl) = statement else {
return None;
};
(export_decl.span() == span).then_some(export_decl.as_ref())
})
}
fn is_matching_export_kind(
import_decl: &ImportDeclaration<'_>,
export_decl: &ExportFromDeclaration<'_>,
@@ -1498,9 +1480,13 @@ fn test() {
"import {foo} from './foo.json';
export {foo};
export {bar} from './foo.json' with { type: 'unknown' };",
"import {a} from \"\\uD800\";\nexport {a};",
"import {a} from \"\\uD800\";\nexport {a};\nexport {b} from \"\\uD800\";",
];
let fix = vec![
(r#"import {foo} from "foo'bar";export {foo};"#, "export { foo } from \"foo'bar\";\n"),
(r"import {foo} from 'foo\\bar';export {foo};", "export { foo } from 'foo\\\\bar';\n"),
// the `{` inside the comment is not the start of the specifier list; the
// exported name used to be swallowed into the comment
(
@@ -1789,7 +1775,7 @@ fn test() {
r#"import { foo } from "foo";
export { foo };
export type { bar } from "foo";"#,
"export { foo } from 'foo';\n\n export type { bar } from \"foo\";",
"export { foo } from \"foo\";\n\n export type { bar } from \"foo\";",
),
(
r#"import { foo } from "foo";
@@ -1864,7 +1850,7 @@ fn test() {
(
r#"import type foo from "foo";
export default foo"#,
"export { type default } from 'foo';\n",
"export { type default } from \"foo\";\n",
),
(
"import {type foo} from 'foo';
@@ -1952,6 +1938,11 @@ fn test() {
export {bar,} from 'foo';",
"export {bar, default as foo,} from 'foo';",
),
("import {a} from \"\\uD800\";\nexport {a};", "export { a } from \"\\uD800\";\n"),
(
"import {a} from \"\\uD800\";\nexport {a};\nexport {b} from \"\\uD800\";",
"export {b, a} from \"\\uD800\";",
),
];
Tester::new(PreferExportFrom::NAME, PreferExportFrom::PLUGIN, pass, fail)
.expect_fix(fix)
@@ -285,10 +285,10 @@ fn is_ternary_expression(expression: &Expression<'_>) -> bool {
matches!(expression.get_inner_expression(), Expression::ConditionalExpression(_))
}
fn is_same_assignment_target(
left: &AssignmentTarget<'_>,
right: &AssignmentTarget<'_>,
ctx: &LintContext<'_>,
fn is_same_assignment_target<'a>(
left: &AssignmentTarget<'a>,
right: &AssignmentTarget<'a>,
ctx: &LintContext<'a>,
) -> bool {
if let (
AssignmentTarget::AssignmentTargetIdentifier(left),
@@ -301,9 +301,10 @@ fn is_same_assignment_target(
if let (Some(left_member), Some(right_member)) =
(left.as_member_expression(), right.as_member_expression())
{
if let (Some(left_name), Some(right_name)) =
(member_static_property_name(left_member), member_static_property_name(right_member))
{
if let (Some(left_name), Some(right_name)) = (
member_static_property_name(left_member, ctx),
member_static_property_name(right_member, ctx),
) {
return left_name == right_name
&& is_same_expression(
left_member.object().get_inner_expression(),
@@ -321,16 +322,19 @@ fn is_same_assignment_target(
}
}
fn member_static_property_name(member: &MemberExpression<'_>) -> Option<String> {
if let Some(name) = member.static_property_name().and_then(JSStr::as_str) {
return Some(name.to_string());
fn member_static_property_name<'a>(
member: &MemberExpression<'a>,
ctx: &LintContext<'a>,
) -> Option<JSStr<'a>> {
if let Some(name) = member.static_property_name() {
return Some(name);
}
let MemberExpression::ComputedMemberExpression(computed) = member else {
return None;
};
static_string_value(computed.expression.get_inner_expression())
static_string_value(computed.expression.get_inner_expression(), ctx.allocator())
}
#[test]
@@ -349,6 +353,9 @@ fn test() {
}",
None,
),
// Different lone surrogates are different names.
(r#"if (test) { obj["\uD800"] = a; } else { obj["\uDC00"] = b; }"#, None),
(r#"if (test) { obj["\uD83D\uDE00"] = a; } else { obj["\uD83D"] = b; }"#, None),
(
"function unicorn() {
if(test){
@@ -607,6 +614,10 @@ fn test() {
}",
None,
),
// Computed member names are compared by their JavaScript value.
(r#"if (test) { obj["\uD800"] = a; } else { obj["\uD800"] = b; }"#, None),
(r#"if (test) { obj["\uD800" + "x"] = a; } else { obj[`\uD800x`] = b; }"#, None),
(r#"if (test) { obj["\uD83D" + "\uDE00"] = a; } else { obj["\uD83D\uDE00"] = b; }"#, None),
(
"async function unicorn() {
if(test){
@@ -229,6 +229,11 @@ fn test() {
use crate::tester::Tester;
let pass = vec![
// A URL or base with a lone surrogate is not checked.
(r#"new URL("./a\uD800", "https://x.com/")"#, None),
(r#"new URL("a\uD800", "https://x.com/")"#, Some(serde_json::json!(["always"]))),
(r#"new URL("./foo", "https://x.com/\uD800/")"#, None),
(r#"new URL("foo", "https://x.com/\uD800/")"#, Some(serde_json::json!(["always"]))),
// Default "never" option
(r#"URL("./foo", base)"#, None),
(r#"new URL(...["./foo"], base)"#, None),
@@ -27,6 +27,9 @@ fn test() {
use crate::tester::Tester;
let mut pass = vec![
// A segment with a lone surrogate is not `__mocks__`.
(r"require('./\uD800__mocks__/x')", None),
(r"require('./__mocks__\uDC00/x')", None),
("import something from 'something'", None),
("require('somethingElse')", None),
("require('./__mocks__.js')", None),
@@ -40,6 +43,9 @@ fn test() {
];
let mut fail = vec![
(r"require('./\uD83D\uDE00/__mocks__')", None),
(r"require('./__mocks__/\uD800')", None),
(r"require('\uDC00/__mocks__/x')", None),
("require('./__mocks__')", None),
("require('./__mocks__/')", None),
("require('./__mocks__/index')", None),
@@ -2,6 +2,7 @@ use oxc_ast::{AstKind, ast::Argument};
use oxc_diagnostics::OxcDiagnostic;
use oxc_macros::declare_oxc_lint;
use oxc_span::Span;
use oxc_str::JSStr;
use schemars::JsonSchema;
use serde::Deserialize;
@@ -11,10 +12,16 @@ use crate::{
utils::{PossibleJestNode, parse_general_jest_fn_call},
};
fn prefer_import_in_mock_diagnostic(span: Span, path: &str) -> OxcDiagnostic {
let help = format!(
"Dynamic import improves the type information and IntelliSense. Substitute `{path}` with `import('{path}')`"
);
fn prefer_import_in_mock_diagnostic(span: Span, path: JSStr) -> OxcDiagnostic {
// Debug supplies a quoted, escaped spelling when the path has a lone surrogate.
let help = match path.as_str() {
Some(path) => format!(
"Dynamic import improves the type information and IntelliSense. Substitute `{path}` with `import('{path}')`"
),
None => format!(
"Dynamic import improves the type information and IntelliSense. Substitute `{path:?}` with `import({path:?})`"
),
};
OxcDiagnostic::warn("Mocked modules must be dynamic imported.").with_help(help).with_label(span)
}
@@ -118,13 +125,18 @@ impl PreferImportInMock {
return;
};
let Some(value) = import_value.value.as_str() else { return };
let value = import_value.value;
ctx.diagnostic_with_fix(
prefer_import_in_mock_diagnostic(call_expr.arguments_span().unwrap(), value),
|fixer| {
if !self.fixable {
return fixer.noop();
}
// The fix is built from `&str`, so a path with a lone surrogate is
// reported without it.
let Some(value) = value.as_str() else {
return fixer.noop();
};
fixer.replace(import_value.span, format!("import('{value}')"))
},
@@ -151,6 +163,10 @@ fn test() {
];
let fail = vec![
// Lone surrogates are part of the value.
// A search or prefix check must still see the rest.
(r#"vi.mock("\uD800")"#, None),
(r#"vi.doMock("a\uDC00b", () => ({}))"#, None),
("vi.mock('foo', () => {})", Some(serde_json::json!([ { "fixable": false, }, ]))),
(r#"vi.mock("node:fs/promises")"#, Some(serde_json::json!([ { "fixable": false, }, ]))),
(
@@ -190,6 +206,8 @@ fn test() {
];
let fix = vec![
// A path with a lone surrogate gets no fix.
(r#"vi.mock("\uD800")"#, r#"vi.mock("\uD800")"#),
("vi.mock('foo', () => {})", "vi.mock(import('foo'), () => {})"),
(r#"vi.mock("node:fs/promises")"#, "vi.mock(import('node:fs/promises'))"),
(
@@ -8,6 +8,7 @@ use oxc_ast::{
use oxc_diagnostics::OxcDiagnostic;
use oxc_macros::declare_oxc_lint;
use oxc_span::{GetSpan, Span};
use oxc_str::JSStr;
use crate::{
AstNode,
@@ -19,7 +20,7 @@ use crate::{
fn component_definition_name_casing_diagnostic(
span: Span,
value: &str,
value: impl std::fmt::Display,
case_type: &str,
) -> OxcDiagnostic {
OxcDiagnostic::warn(format!("Property name \"{value}\" is not {case_type}.")).with_label(span)
@@ -143,21 +144,26 @@ impl ComponentDefinitionNameCasing {
self.check_name_node(&prop.value, ctx);
}
fn check_name_node(&self, expr: &Expression<'_>, ctx: &LintContext<'_>) {
fn check_name_node<'a>(&self, expr: &Expression<'a>, ctx: &LintContext<'a>) {
let inner = expr.get_inner_expression();
let Some((value, inner_span)) = extract_convertible(inner) else { return };
let case_type = self.0;
if check_case(&value, case_type) {
if check_case(value, case_type) {
return;
}
let report_span = inner.span();
let case_type_str = case_type.as_str();
let diagnostic =
component_definition_name_casing_diagnostic(report_span, value.as_str(), case_type_str);
let diagnostic = component_definition_name_casing_diagnostic(
report_span,
value.display(),
case_type_str,
);
if let Some(converted) = exact_convert(&value, case_type) {
// The case converters take UTF-8 text.
// A name with a lone surrogate gets no fix.
if let Some(converted) = value.as_str().and_then(|value| exact_convert(value, case_type)) {
ctx.diagnostic_with_fix(diagnostic, |fixer| fixer.replace(inner_span, converted));
} else {
ctx.diagnostic(diagnostic);
@@ -169,26 +175,26 @@ impl ComponentDefinitionNameCasing {
/// simple template literal (no expressions, single quasi). `inner_span`
/// is the range of the literal *contents* (excluding the quotes / backticks),
/// suitable as a fix target.
fn extract_convertible(expr: &Expression<'_>) -> Option<(String, Span)> {
fn extract_convertible<'a>(expr: &Expression<'a>) -> Option<(JSStr<'a>, Span)> {
match expr {
Expression::StringLiteral(lit) => {
let inner = Span::new(lit.span.start + 1, lit.span.end - 1);
Some((lit.value.as_str()?.to_owned(), inner))
Some((lit.value, inner))
}
Expression::TemplateLiteral(tpl) => {
if !tpl.expressions.is_empty() || tpl.quasis.len() != 1 {
return None;
}
let quasi = tpl.quasis.first()?;
let cooked = quasi.value.cooked.as_ref()?;
let cooked = quasi.value.cooked?;
let inner = Span::new(tpl.span.start + 1, tpl.span.end - 1);
Some((cooked.as_str()?.to_owned(), inner))
Some((cooked, inner))
}
_ => None,
}
}
fn check_case(s: &str, case_type: CaseType) -> bool {
fn check_case(s: JSStr<'_>, case_type: CaseType) -> bool {
match case_type {
CaseType::PascalCase => vue_casing::is_pascal_case(s),
CaseType::KebabCase => vue_casing::is_kebab_case(s),
@@ -205,7 +211,7 @@ fn exact_convert(s: &str, case_type: CaseType) -> Option<String> {
CaseType::PascalCase => vue_casing::pascal_case(s),
CaseType::KebabCase => vue_casing::kebab_case(s),
};
if check_case(&converted, case_type) { Some(converted) } else { None }
if check_case(JSStr::from(converted.as_str()), case_type) { Some(converted) } else { None }
}
#[test]
@@ -226,6 +232,36 @@ fn test() {
None,
vue(),
),
(
r#"<script>
export default {
name: "Foo\uD800"
}
</script>"#,
None,
None,
vue(),
),
(
r"<script>
export default {
name: `\uDC00Foo`
}
</script>",
None,
None,
vue(),
),
(
r#"<script>
export default {
name: "foo-\uD83D\uDE00"
}
</script>"#,
Some(serde_json::json!(["kebab-case"])),
None,
vue(),
),
(
"<script>
export default {
@@ -346,6 +382,38 @@ fn test() {
];
let fail = vec![
// A lone surrogate is neither a letter nor a separator, so the surrounding casing decides.
// The fix is declined because the converters need UTF-8.
(
r#"<script>
export default {
name: "foo-\uD800"
}
</script>"#,
None,
None,
vue(),
),
(
r"<script>
export default {
name: `\uDC00_bar`
}
</script>",
None,
None,
vue(),
),
(
r#"<script>
export default {
name: "Foo\uD83D\uDE00"
}
</script>"#,
Some(serde_json::json!(["kebab-case"])),
None,
vue(),
),
(
"<script>
export default {
@@ -53,7 +53,11 @@ fn reserved_key_diagnostic(name: &str, span: Span) -> OxcDiagnostic {
OxcDiagnostic::warn(format!("Key `{name}` is reserved.")).with_label(span)
}
fn starts_with_underscore_diagnostic(key: &str, group: &str, span: Span) -> OxcDiagnostic {
fn starts_with_underscore_diagnostic(
key: impl std::fmt::Display,
group: &str,
span: Span,
) -> OxcDiagnostic {
OxcDiagnostic::warn(format!("Key `{key}` is reserved in `{group}` group.")).with_label(span)
}
@@ -220,14 +224,17 @@ impl NoReservedKeys {
fn check_keys<'a>(&self, group: &str, obj: &ObjectExpression<'a>, ctx: &LintContext<'a>) {
for prop_kind in &obj.properties {
let ObjectPropertyKind::ObjectProperty(p) = prop_kind else { continue };
let Some(n) = p.key.static_name().and_then(StaticPropertyName::into_cow_str) else {
let Some(name) = p.key.static_name() else {
continue;
};
let name = name.as_js_str();
let span = p.key.span();
if self.is_reserved(&n) {
ctx.diagnostic(reserved_key_diagnostic(&n, span));
} else if matches!(group, "data" | "asyncData") && n.starts_with('_') {
ctx.diagnostic(starts_with_underscore_diagnostic(&n, group, span));
if let Some(n) = name.as_str()
&& self.is_reserved(n)
{
ctx.diagnostic(reserved_key_diagnostic(n, span));
} else if matches!(group, "data" | "asyncData") && name.starts_with('_') {
ctx.diagnostic(starts_with_underscore_diagnostic(name.display(), group, span));
}
}
}
@@ -585,6 +592,18 @@ fn test() {
None,
Some(PathBuf::from("test.vue")),
),
(
r#"
<script>
new Vue({
data: () => ({ "_\uD800": 1 })
})
</script>
"#,
None,
None,
Some(PathBuf::from("test.vue")),
),
// `reserved` + `groups` options (eslint-plugin-vue parity)
(
"
@@ -3,7 +3,7 @@ use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use oxc_ast::{
AstKind, StaticPropertyName,
AstKind,
ast::{
ArrayExpression, ArrayExpressionElement, CallExpression, Expression, ObjectExpression,
ObjectPropertyKind, PropertyKey, TSSignature,
@@ -12,6 +12,7 @@ use oxc_ast::{
use oxc_diagnostics::OxcDiagnostic;
use oxc_macros::declare_oxc_lint;
use oxc_span::{GetSpan, Span};
use oxc_str::JSStr;
use crate::{
AstNode,
@@ -24,7 +25,11 @@ use crate::{
},
};
fn prop_name_casing_diagnostic(span: Span, name: &str, case_type: &str) -> OxcDiagnostic {
fn prop_name_casing_diagnostic(
span: Span,
name: impl std::fmt::Display,
case_type: &str,
) -> OxcDiagnostic {
OxcDiagnostic::warn(format!("Prop '{name}' is not in {case_type}.")).with_label(span)
}
@@ -162,9 +167,7 @@ impl PropNameCasing {
fn check_array_props<'a>(&self, arr: &ArrayExpression<'a>, ctx: &LintContext<'a>) {
for element in &arr.elements {
let ArrayExpressionElement::StringLiteral(lit) = element else { continue };
if let Some(value) = lit.value.as_str() {
self.report_if_invalid(value, lit.span, ctx);
}
self.report_if_invalid(lit.value, lit.span, ctx);
}
}
@@ -172,7 +175,7 @@ impl PropNameCasing {
for prop in &obj.properties {
let ObjectPropertyKind::ObjectProperty(prop) = prop else { continue };
let Some((name, span)) = property_key_static_name(&prop.key) else { continue };
self.report_if_invalid(name.as_ref(), span, ctx);
self.report_if_invalid(name, span, ctx);
}
}
@@ -182,31 +185,29 @@ impl PropNameCasing {
TSSignature::TSMethodSignature(sig) => (sig.key.static_name(), sig.key.span()),
_ => return,
};
let name = key_opt.and_then(StaticPropertyName::into_cow_str);
let Some(name) = name else { return };
self.report_if_invalid(&name, span, ctx);
let Some(name) = key_opt else { return };
self.report_if_invalid(name.as_js_str(), span, ctx);
}
fn report_if_invalid(&self, name: &str, span: Span, ctx: &LintContext<'_>) {
fn report_if_invalid(&self, name: JSStr<'_>, span: Span, ctx: &LintContext<'_>) {
let Config(case_type, options) = &*self.0;
if is_ignored(name, &options.ignore_props) {
// A name with a lone surrogate matches no `ignoreProps` pattern.
if name.as_str().is_some_and(|name| is_ignored(name, &options.ignore_props)) {
return;
}
if check_case(name, *case_type) {
return;
}
ctx.diagnostic(prop_name_casing_diagnostic(span, name, case_type.as_str()));
ctx.diagnostic(prop_name_casing_diagnostic(span, name.display(), case_type.as_str()));
}
}
/// Returns `(static_name, span_of_key_text)` for a property key when it can be
/// resolved statically. Dynamic keys (computed identifiers, calls, binary
/// expressions, etc.) return `None`.
fn property_key_static_name<'a>(
key: &PropertyKey<'a>,
) -> Option<(std::borrow::Cow<'a, str>, Span)> {
fn property_key_static_name<'a>(key: &PropertyKey<'a>) -> Option<(JSStr<'a>, Span)> {
match key {
PropertyKey::StaticIdentifier(ident) => Some((ident.name.as_str().into(), ident.span)),
PropertyKey::StaticIdentifier(ident) => Some((ident.name.into(), ident.span)),
PropertyKey::PrivateIdentifier(_) => None,
key => {
// Computed key expressions: pull the inner expression and accept
@@ -215,24 +216,22 @@ fn property_key_static_name<'a>(
// unresolvable and skipped.
let expr = key.as_expression()?.get_inner_expression();
match expr {
Expression::StringLiteral(lit) => Some((lit.value.as_str()?.into(), lit.span)),
Expression::StringLiteral(lit) => Some((lit.value, lit.span)),
Expression::TemplateLiteral(tpl)
if tpl.expressions.is_empty() && tpl.quasis.len() == 1 =>
{
let quasi = tpl.quasis.first()?;
let cooked = quasi.value.cooked.as_ref()?;
Some((cooked.as_str()?.into(), tpl.span))
}
Expression::RegExpLiteral(regex) => {
Some((regex.raw.as_ref()?.as_str().into(), regex.span))
let cooked = quasi.value.cooked?;
Some((cooked, tpl.span))
}
Expression::RegExpLiteral(regex) => Some((regex.raw?.into(), regex.span)),
_ => None,
}
}
}
}
fn check_case(s: &str, case_type: CaseType) -> bool {
fn check_case(s: JSStr<'_>, case_type: CaseType) -> bool {
match case_type {
CaseType::CamelCase => vue_casing::is_camel_case(s),
CaseType::SnakeCase => vue_casing::is_snake_case(s),
@@ -262,6 +261,42 @@ fn test() {
None,
Some(PathBuf::from("test.vue")),
), // languageOptions,
(
r#"
<script>
export default {
props: ["greeting\uD800"]
}
</script>
"#,
None,
None,
Some(PathBuf::from("test.vue")),
),
(
r#"
<script>
export default {
props: { "\uDC00text": String, [`greeting\uD83D\uDE00`]: String }
}
</script>
"#,
None,
None,
Some(PathBuf::from("test.vue")),
),
(
r#"
<script>
export default {
props: ["greeting_\uD800"]
}
</script>
"#,
Some(serde_json::json!(["snake_case"])),
None,
Some(PathBuf::from("test.vue")),
),
(
"
<script>
@@ -666,6 +701,58 @@ fn test() {
];
let fail = vec![
// A name with a lone surrogate matches no `ignoreProps` pattern.
(
r#"
<script>
export default {
props: {
"\uDC00_text": String,
}
}
</script>
"#,
Some(serde_json::json!([ "camelCase", { "ignoreProps": ["_text$"] } ])),
None,
Some(PathBuf::from("test.vue")),
),
// A lone surrogate is neither a letter nor a separator, so the surrounding casing decides.
(
r#"
<script>
export default {
props: ["greeting-\uD800"]
}
</script>
"#,
None,
None,
Some(PathBuf::from("test.vue")),
),
(
r#"
<script>
export default {
props: { "\uDC00_text": String }
}
</script>
"#,
None,
None,
Some(PathBuf::from("test.vue")),
),
(
r"
<script>
export default {
props: { [`Greeting\uD83D\uDE00`]: String }
}
</script>
",
None,
None,
Some(PathBuf::from("test.vue")),
),
(
"
<script>
@@ -275,6 +275,13 @@ source: crates/oxc_linter/src/tester.rs
╰────
help: Rename the function or the variable/property so the names satisfy this rule.
⚠ eslint(func-name-matching): Function name `foo` should match property name `\ud800`.
╭─[func_name_matching.tsx:1:54]
1 │ Object.defineProperty(o, "\uD800", { value: function foo() {} })
· ───
╰────
help: Rename the function or the variable/property so the names satisfy this rule.
⚠ eslint(func-name-matching): Function name `y` should match property name `x`.
╭─[func_name_matching.tsx:1:24]
1 │ class C { x = function y() {}; }
@@ -2,6 +2,86 @@
source: crates/oxc_linter/src/tester.rs
---
⚠ eslint(new-cap): A constructor name should not start with a lowercase letter.
╭─[new_cap.tsx:1:7]
1 │ new o['a\uD800']();
· ────┬────
· ╰── This should be uppercase
╰────
help: Capitalize the first letter of the constructor name, or add it to the exceptions list if it should not be capitalized.
⚠ eslint(new-cap): A function with a name starting with an uppercase letter should only be used as a constructor.
╭─[new_cap.tsx:1:3]
1 │ o['A\uDC00']();
· ────┬────
· ╰── This should be called with `new`
╰────
help: Use the new operator when calling this function, or add it to the exceptions list if it should not be called with new.
⚠ eslint(new-cap): A constructor name should not start with a lowercase letter.
╭─[new_cap.tsx:1:7]
1 │ new o['a\uD800']();
· ────┬────
· ╰── This should be uppercase
╰────
help: Capitalize the first letter of the constructor name, or add it to the exceptions list if it should not be capitalized.
⚠ eslint(new-cap): A constructor name should not start with a lowercase letter.
╭─[new_cap.tsx:1:7]
1 │ new o['a\uD800']();
· ────┬────
· ╰── This should be uppercase
╰────
help: Capitalize the first letter of the constructor name, or add it to the exceptions list if it should not be capitalized.
⚠ eslint(new-cap): A function with a name starting with an uppercase letter should only be used as a constructor.
╭─[new_cap.tsx:1:3]
1 │ o['A\uDC00']();
· ────┬────
· ╰── This should be called with `new`
╰────
help: Use the new operator when calling this function, or add it to the exceptions list if it should not be called with new.
⚠ eslint(new-cap): A function with a name starting with an uppercase letter should only be used as a constructor.
╭─[new_cap.tsx:1:6]
1 │ (o?.['A\uDC00'])();
· ────┬────
· ╰── This should be called with `new`
╰────
help: Use the new operator when calling this function, or add it to the exceptions list if it should not be called with new.
⚠ eslint(new-cap): A function with a name starting with an uppercase letter should only be used as a constructor.
╭─[new_cap.tsx:1:19]
1 │ services.registry[`\u0043reateDocumentForCurrentSession`]();
· ───────────────────┬──────────────────
· ╰── This should be called with `new`
╰────
help: Use the new operator when calling this function, or add it to the exceptions list if it should not be called with new.
⚠ eslint(new-cap): A constructor name should not start with a lowercase letter.
╭─[new_cap.tsx:1:7]
1 │ new o[`a\uD800`]();
· ────┬────
· ╰── This should be uppercase
╰────
help: Capitalize the first letter of the constructor name, or add it to the exceptions list if it should not be capitalized.
⚠ eslint(new-cap): A constructor name should not start with a lowercase letter.
╭─[new_cap.tsx:1:15]
1 │ var x = new o["a\uD800"]();
· ────┬────
· ╰── This should be uppercase
╰────
help: Capitalize the first letter of the constructor name, or add it to the exceptions list if it should not be capitalized.
⚠ eslint(new-cap): A function with a name starting with an uppercase letter should only be used as a constructor.
╭─[new_cap.tsx:1:11]
1 │ var x = o["A\uDC00"]();
· ────┬────
· ╰── This should be called with `new`
╰────
help: Use the new operator when calling this function, or add it to the exceptions list if it should not be called with new.
⚠ eslint(new-cap): A constructor name should not start with a lowercase letter.
╭─[new_cap.tsx:1:13]
1 │ var x = new c();
@@ -2,6 +2,20 @@
source: crates/oxc_linter/src/tester.rs
---
⚠ eslint(prefer-named-capture-group): Capture group should be named.
╭─[prefer_named_capture_group.tsx:1:12]
1 │ new RegExp("(a)[" + "\uD800" + "-" + "\uE000" + "]");
· ────────────────────────────────────────
╰────
help: Use a named capture group like "(?<name>...)" — this regex has 1 unnamed group.
⚠ eslint(prefer-named-capture-group): Capture group should be named.
╭─[prefer_named_capture_group.tsx:1:12]
1 │ new RegExp("(a)[" + "\uD800" + "-" + "\uE000" + "]", "u");
· ────────────────────────────────────────
╰────
help: Use a named capture group like "(?<name>...)" — this regex has 1 unnamed group.
⚠ eslint(prefer-named-capture-group): Capture group should be named.
╭─[prefer_named_capture_group.tsx:1:2]
1 │ /([0-9]{4})/
@@ -142,6 +156,13 @@ source: crates/oxc_linter/src/tester.rs
╰────
help: Use a named capture group like "(?<name>...)" — this regex has 1 unnamed group.
⚠ eslint(prefer-named-capture-group): Capture group should be named.
╭─[prefer_named_capture_group.tsx:1:12]
1 │ new RegExp("(a)" + "\uD800");
· ────────────────
╰────
help: Use a named capture group like "(?<name>...)" — this regex has 1 unnamed group.
⚠ eslint(prefer-named-capture-group): Capture group should be named.
╭─[prefer_named_capture_group.tsx:1:8]
1 │ RegExp('(a)'+'')
@@ -2,6 +2,22 @@
source: crates/oxc_linter/src/tester.rs
---
⚠ eslint(require-unicode-regexp): Use the 'u' flag.
╭─[require_unicode_regexp.tsx:1:1]
1 │ new RegExp("foo", "\uD800")
· ───────────────────────────
╰────
help: Add the 'u' flag.
note: The 'u' and 'v' flags enable Unicode-aware regular expression behavior and stricter pattern parsing.
⚠ eslint(require-unicode-regexp): Use the 'u' flag.
╭─[require_unicode_regexp.tsx:1:1]
1 │ new RegExp("foo", `g\uDC00`)
· ────────────────────────────
╰────
help: Add the 'u' flag.
note: The 'u' and 'v' flags enable Unicode-aware regular expression behavior and stricter pattern parsing.
⚠ eslint(require-unicode-regexp): Use the 'u' flag.
╭─[require_unicode_regexp.tsx:1:1]
1 │ /\a/
@@ -2,6 +2,13 @@
source: crates/oxc_linter/src/tester.rs
---
⚠ eslint(yoda): Require or disallow "Yoda" conditions
╭─[yoda.tsx:1:5]
1 │ if ("\uDBFF" <= x && x <= "\uD800") {}
· ─────────────
╰────
help: Expected literal to be on the right side of <=.
⚠ eslint(yoda): Require or disallow "Yoda" conditions
╭─[yoda.tsx:1:5]
1 │ if (x <= 'foo' || 'bar' < x) {}
@@ -2,6 +2,13 @@
source: crates/oxc_linter/src/tester.rs
---
⚠ import(consistent-type-specifier-style): Prefer using inline type specifiers instead of a top-level type-only import.
╭─[consistent_type_specifier_style.tsx:1:1]
1 │ import type { Foo } from './\uD800.ts';
· ───────────────────────────────────────
╰────
help: Replace top‐level import type with an inline type specifier.
⚠ import(consistent-type-specifier-style): Prefer using a top-level type-only import instead of inline type specifiers.
╭─[consistent_type_specifier_style.tsx:1:10]
1 │ import { type Foo, type Bar } from 'Foo'
@@ -2,6 +2,27 @@
source: crates/oxc_linter/src/tester.rs
---
⚠ import(first): Relative imports before absolute imports are prohibited
╭─[index.ts:1:49]
1 │ import { x } from './\uD800'; import { y } from 'bar'
· ─────
╰────
help: Move absolute import above relative import
⚠ import(first): Relative imports before absolute imports are prohibited
╭─[index.ts:1:50]
1 │ import { x } from '../\uDC00'; import { y } from 'bar'
· ─────
╰────
help: Move absolute import above relative import
⚠ import(first): Relative imports before absolute imports are prohibited
╭─[index.ts:1:54]
1 │ import { x } from '/\uD83D\uDE00'; import { y } from 'bar'
· ─────
╰────
help: Move absolute import above relative import
⚠ import(first): Import statements must come first
╭─[index.ts:3:15]
2 │ export { x };
@@ -2,6 +2,27 @@
source: crates/oxc_linter/src/tester.rs
---
⚠ import(no-absolute-path): Do not import modules using an absolute path
╭─[index.js:1:15]
1 │ import _ from '/\uD800'
· ─────────
╰────
help: Replace the absolute path with a relative path or a module alias.
⚠ import(no-absolute-path): Do not import modules using an absolute path
╭─[index.js:1:17]
1 │ var f = require('/\uDC00/x')
· ───────────
╰────
help: Replace the absolute path with a relative path or a module alias.
⚠ import(no-absolute-path): Do not import modules using an absolute path
╭─[index.js:1:10]
1 │ require(['/\uD83D\uDE00'], function(){})
· ───────────────
╰────
help: Replace the absolute path with a relative path or a module alias.
⚠ import(no-absolute-path): Do not import modules using an absolute path
╭─[index.js:1:15]
1 │ import _ from '/lodash'
@@ -2,6 +2,27 @@
source: crates/oxc_linter/src/tester.rs
---
⚠ import(no-nodejs-modules): Do not import Node.js builtin module `node:\ud800`
╭─[no_nodejs_modules.tsx:1:1]
1 │ import x from "node:\uD800"
· ───────────────────────────
╰────
help: Use a browser-compatible alternative or add this module to the `allow` list if Node.js usage is intentional.
⚠ import(no-nodejs-modules): Do not import Node.js builtin module `node:\udc00`
╭─[no_nodejs_modules.tsx:1:9]
1 │ var x = require("node:\uDC00")
· ──────────────────────
╰────
help: Use a browser-compatible alternative or add this module to the `allow` list if Node.js usage is intentional.
⚠ import(no-nodejs-modules): Do not import Node.js builtin module `node:😀`
╭─[no_nodejs_modules.tsx:1:1]
1 │ import x from "node:\uD83D\uDE00"
· ─────────────────────────────────
╰────
help: Use a browser-compatible alternative or add this module to the `allow` list if Node.js usage is intentional.
⚠ import(no-nodejs-modules): Do not import Node.js builtin module `path`
╭─[no_nodejs_modules.tsx:1:1]
1 │ import path from "path"
@@ -2,6 +2,41 @@
source: crates/oxc_linter/src/tester.rs
---
⚠ import(no-relative-parent-imports): Relative imports from parent directories are not allowed
╭─[index.js:1:17]
1 │ import foo from "../\uD800"
· ───────────
╰────
help: Move the file to the same directory, use dependency injection, or convert to a package.
⚠ import(no-relative-parent-imports): Relative imports from parent directories are not allowed
╭─[index.js:1:17]
1 │ import foo from "./../\uDC00.js"
· ────────────────
╰────
help: Move the file to the same directory, use dependency injection, or convert to a package.
⚠ import(no-relative-parent-imports): Relative imports from parent directories are not allowed
╭─[index.js:1:9]
1 │ require("../\uD83D\uDE00")
· ─────────────────
╰────
help: Move the file to the same directory, use dependency injection, or convert to a package.
⚠ import(no-relative-parent-imports): Relative imports from parent directories are not allowed
╭─[index.js:1:8]
1 │ import("../\uD800")
· ───────────
╰────
help: Move the file to the same directory, use dependency injection, or convert to a package.
⚠ import(no-relative-parent-imports): Relative imports from parent directories are not allowed
╭─[index.js:1:15]
1 │ export * from "..\u002F\uDC00"
· ────────────────
╰────
help: Move the file to the same directory, use dependency injection, or convert to a package.
⚠ import(no-relative-parent-imports): Relative imports from parent directories are not allowed
╭─[index.js:1:17]
1 │ import foo from "../plugin.js"
@@ -2,6 +2,27 @@
source: crates/oxc_linter/src/tester.rs
---
⚠ import(no-unassigned-import): Imported module should be assigned
╭─[no-unassigned-import.js:1:1]
1 │ import './\uD800.css'
· ─────────────────────
╰────
help: Consider assigning the import to a variable or removing it if it's unused.
⚠ import(no-unassigned-import): Imported module should be assigned
╭─[no-unassigned-import.js:1:1]
1 │ import './\uD800.css'
· ─────────────────────
╰────
help: Consider assigning the import to a variable or removing it if it's unused.
⚠ import(no-unassigned-import): A `require()` style import is forbidden.
╭─[no-unassigned-import.js:1:1]
1 │ require('./\uD800.css')
· ───────────────────────
╰────
help: Consider assigning the import to a variable or removing it if it's unused.
⚠ import(no-unassigned-import): A `require()` style import is forbidden.
╭─[no-unassigned-import.js:1:1]
1 │ require('should')
@@ -2,6 +2,27 @@
source: crates/oxc_linter/src/tester.rs
---
⚠ jest(no-mocks-import): Mocks should not be manually imported from a `__mocks__` directory.
╭─[no_mocks_import.tsx:1:9]
1 │ require('./\uD83D\uDE00/__mocks__')
· ──────────────────────────
╰────
help: Instead use `jest.mock` or `vi.mock` and import from the original module path.
⚠ jest(no-mocks-import): Mocks should not be manually imported from a `__mocks__` directory.
╭─[no_mocks_import.tsx:1:9]
1 │ require('./__mocks__/\uD800')
· ────────────────────
╰────
help: Instead use `jest.mock` or `vi.mock` and import from the original module path.
⚠ jest(no-mocks-import): Mocks should not be manually imported from a `__mocks__` directory.
╭─[no_mocks_import.tsx:1:9]
1 │ require('\uDC00/__mocks__/x')
· ────────────────────
╰────
help: Instead use `jest.mock` or `vi.mock` and import from the original module path.
⚠ jest(no-mocks-import): Mocks should not be manually imported from a `__mocks__` directory.
╭─[no_mocks_import.tsx:1:9]
1 │ require('./__mocks__')
@@ -2,6 +2,27 @@
source: crates/oxc_linter/src/tester.rs
---
⚠ jest(no-untyped-mock-factory): `jest.mock()` factories should not be used without an explicit type parameter.
╭─[no_untyped_mock_factory.tsx:1:6]
1 │ jest.mock('\uD800', () => ({}))
· ────
╰────
help: Add a type parameter to the mock factory such as `typeof import("\ud800")`
⚠ jest(no-untyped-mock-factory): `jest.mock()` factories should not be used without an explicit type parameter.
╭─[no_untyped_mock_factory.tsx:1:6]
1 │ jest.doMock('a\uDC00b', () => ({}))
· ──────
╰────
help: Add a type parameter to the mock factory such as `typeof import("a\udc00b")`
⚠ jest(no-untyped-mock-factory): `jest.mock()` factories should not be used without an explicit type parameter.
╭─[no_untyped_mock_factory.tsx:1:6]
1 │ jest.mock("it's", () => ({}))
· ────
╰────
help: Add a type parameter to the mock factory such as `typeof import("it's")`
⚠ jest(no-untyped-mock-factory): `jest.mock()` factories should not be used without an explicit type parameter.
╭─[no_untyped_mock_factory.tsx:2:22]
1 │
@@ -2,6 +2,13 @@
source: crates/oxc_linter/src/tester.rs
---
⚠ jest(valid-title): test should match (?u)^\W
╭─[valid_title.tsx:1:6]
1 │ test('\uD800 foo', () => {});
· ────────────
╰────
help: Make sure the title matches the `mustMatch` of your config file
⚠ jest(valid-title): Title must be a string
╭─[valid_title.tsx:1:41]
1 │ const String = { raw: () => 'foo' }; it(String.raw`foo`, () => {})
@@ -100,6 +100,20 @@ source: crates/oxc_linter/src/tester.rs
╰────
help: Use a `button` element instead of an `a` element.
⚠ jsx-a11y(anchor-is-valid): Use of incorrect `href` for the 'a' element.
╭─[anchor_is_valid.tsx:1:2]
1 │ <a href={"javascript:void(0)\uD800"} />
· ─
╰────
help: Provide a correct `href` for the `a` element.
⚠ jsx-a11y(anchor-is-valid): Use of incorrect `href` for the 'a' element.
╭─[anchor_is_valid.tsx:1:2]
1 │ <a href={`\uD800javascript:void(0)`} />
· ─
╰────
help: Provide a correct `href` for the `a` element.
⚠ jsx-a11y(anchor-is-valid): Missing `href` attribute for the `a` element.
╭─[anchor_is_valid.tsx:1:2]
1 │ <Link />
@@ -2,6 +2,30 @@
source: crates/oxc_linter/src/tester.rs
---
⚠ jsx-a11y(aria-proptypes): This is not a valid ARIA state and property value for 'aria-invalid'.
╭─[aria_proptypes.tsx:1:6]
1 │ <div aria-invalid={"\uD800"} />
· ───────────────────────
╰────
help: The valid value for 'aria-invalid' is: one of the following tokens: grammar, false, spelling, true.
You can find a list of valid ARIA state and property values at https://www.w3.org/TR/wai-aria/#x6-7-definitions-of-states-and-properties-all-aria-attributes
⚠ jsx-a11y(aria-proptypes): This is not a valid ARIA state and property value for 'aria-checked'.
╭─[aria_proptypes.tsx:1:6]
1 │ <div aria-checked={"true\uDC00"} />
· ───────────────────────────
╰────
help: The valid value for 'aria-checked' is: 'true', 'false', or 'mixed'.
You can find a list of valid ARIA state and property values at https://www.w3.org/TR/wai-aria/#x6-7-definitions-of-states-and-properties-all-aria-attributes
⚠ jsx-a11y(aria-proptypes): This is not a valid ARIA state and property value for 'aria-level'.
╭─[aria_proptypes.tsx:1:6]
1 │ <div aria-level={"\uD83D\uDE00"} />
· ───────────────────────────
╰────
help: The valid value for 'aria-level' is: an integer value.
You can find a list of valid ARIA state and property values at https://www.w3.org/TR/wai-aria/#x6-7-definitions-of-states-and-properties-all-aria-attributes
⚠ jsx-a11y(aria-proptypes): This is not a valid ARIA state and property value for 'aria-hidden'.
╭─[aria_proptypes.tsx:1:6]
1 │ <div aria-hidden="yes" />
@@ -9,6 +9,34 @@ source: crates/oxc_linter/src/tester.rs
╰────
help: Provide no redundant alt text for image. Screen-readers already announce `img` tags as an image. You don't need to use the words `image`, `photo`, or `picture` (or any specified custom words) in the `alt` prop.
⚠ jsx-a11y(img-redundant-alt): Redundant `alt` attribute.
╭─[img_redundant_alt.tsx:1:6]
1 │ <img alt={"\uD83D\uDE00 picture"} />;
· ───
╰────
help: Provide no redundant alt text for image. Screen-readers already announce `img` tags as an image. You don't need to use the words `image`, `photo`, or `picture` (or any specified custom words) in the `alt` prop.
⚠ jsx-a11y(img-redundant-alt): Redundant `alt` attribute.
╭─[img_redundant_alt.tsx:1:6]
1 │ <img alt={"image \uD800"} />;
· ───
╰────
help: Provide no redundant alt text for image. Screen-readers already announce `img` tags as an image. You don't need to use the words `image`, `photo`, or `picture` (or any specified custom words) in the `alt` prop.
⚠ jsx-a11y(img-redundant-alt): Redundant `alt` attribute.
╭─[img_redundant_alt.tsx:1:6]
1 │ <img alt={"\uDC00 Photo"} />;
· ───
╰────
help: Provide no redundant alt text for image. Screen-readers already announce `img` tags as an image. You don't need to use the words `image`, `photo`, or `picture` (or any specified custom words) in the `alt` prop.
⚠ jsx-a11y(img-redundant-alt): Redundant `alt` attribute.
╭─[img_redundant_alt.tsx:1:6]
1 │ <img alt={"\uD800image"} />;
· ───
╰────
help: Provide no redundant alt text for image. Screen-readers already announce `img` tags as an image. You don't need to use the words `image`, `photo`, or `picture` (or any specified custom words) in the `alt` prop.
⚠ jsx-a11y(img-redundant-alt): Redundant `alt` attribute.
╭─[img_redundant_alt.tsx:1:6]
1 │ <img alt='Picture of friend.' />;
@@ -2,6 +2,18 @@
source: crates/oxc_linter/src/tester.rs
---
⚠ node(no-mixed-requires): Do not mix core, module, file and computed requires.
╭─[no_mixed_requires.tsx:1:1]
1 │ var fs = require('fs'), a = require('\uD800')
· ─────────────────────────────────────────────
╰────
⚠ node(no-mixed-requires): Do not mix core, module, file and computed requires.
╭─[no_mixed_requires.tsx:1:1]
1 │ var a = require('./\uD83D\uDE00'), b = require('foo')
· ─────────────────────────────────────────────────────
╰────
⚠ node(no-mixed-requires): Do not mix 'require' and other declarations.
╭─[no_mixed_requires.tsx:1:1]
1 │ var fs = require('fs'), foo = 42
@@ -2,6 +2,20 @@
source: crates/oxc_linter/src/tester.rs
---
⚠ react(jsx-key): Duplicate key 'a\ud800' found in JSX elements
╭─[jsx_key.tsx:1:45]
1 │ const xs = [<span key={"a\uD800"} />, <span key={`a\uD800`} />];
· ───────────────
╰────
help: Each child in a list should have a unique 'key' prop
⚠ react(jsx-key): Duplicate key 'foo' found in JSX elements
╭─[jsx_key.tsx:1:41]
1 │ const xs = [<span key={"foo"} />, <span key={`\u0066oo`} />];
· ────────────────
╰────
help: Each child in a list should have a unique 'key' prop
⚠ react(jsx-key): Missing "key" prop for element in array.
╭─[jsx_key.tsx:1:3]
1 │ [<App />];
@@ -157,6 +171,27 @@ source: crates/oxc_linter/src/tester.rs
╰────
help: Each child in a list should have a unique 'key' prop
⚠ react(jsx-key): Duplicate key '\ud800' found in JSX elements
╭─[jsx_key.tsx:1:46]
1 │ const spans = [<span key={"\uD800"}/>, <span key={"\uD800"}/>];
· ──────────────
╰────
help: Each child in a list should have a unique 'key' prop
⚠ react(jsx-key): Duplicate key 'a\udc00' found in JSX elements
╭─[jsx_key.tsx:1:47]
1 │ const spans = [<span key={`a\uDC00`}/>, <span key={`a\uDC00`}/>];
· ───────────────
╰────
help: Each child in a list should have a unique 'key' prop
⚠ react(jsx-key): Duplicate key '😀' found in JSX elements
╭─[jsx_key.tsx:1:52]
1 │ const div = <div><span key={"\uD83D\uDE00"}/><span key={"\uD83D\uDE00"}/></div>;
· ────────────────────
╰────
help: Each child in a list should have a unique 'key' prop
⚠ react(jsx-key): Duplicate key 'notunique' found in JSX elements
╭─[jsx_key.tsx:5:31]
4 │ <span key="notunique"/>
@@ -9,6 +9,34 @@ source: crates/oxc_linter/src/tester.rs
╰────
help: add rel=`noreferrer` to the element
⚠ react(jsx-no-target-blank): Using target=`_blank` without rel=`noreferrer` (which implies rel=`noopener`) is a security risk in older browsers: see https://mathiasbynens.github.io/rel-noopener/#recommendations
╭─[jsx_no_target_blank.tsx:1:11]
1 │ <a target="_blank" href={"https://x/\uD800"}></a>
· ────────
╰────
help: add rel=`noreferrer` to the element
⚠ react(jsx-no-target-blank): Using target=`_blank` without rel=`noreferrer` (which implies rel=`noopener`) is a security risk in older browsers: see https://mathiasbynens.github.io/rel-noopener/#recommendations
╭─[jsx_no_target_blank.tsx:1:11]
1 │ <a target="_blank" href="http://x.com" rel={"\uD800"}></a>
· ────────
╰────
help: add rel=`noreferrer` to the element
⚠ react(jsx-no-target-blank): Using target=`_blank` without rel=`noreferrer` (which implies rel=`noopener`) is a security risk in older browsers: see https://mathiasbynens.github.io/rel-noopener/#recommendations
╭─[jsx_no_target_blank.tsx:1:11]
1 │ <a target="_blank" href={"//x/\uDC00"} rel={"\uDC00"}></a>
· ────────
╰────
help: add rel=`noreferrer` to the element
⚠ react(jsx-no-target-blank): Using target=`_blank` without rel=`noreferrer` (which implies rel=`noopener`) is a security risk in older browsers: see https://mathiasbynens.github.io/rel-noopener/#recommendations
╭─[jsx_no_target_blank.tsx:1:11]
1 │ <a target="_blank" href={"https://x/\uD83D\uDE00"} rel={"noreferrer\uD800"}></a>
· ────────
╰────
help: add rel=`noreferrer` to the element
⚠ react(jsx-no-target-blank): Using target=`_blank` without rel=`noreferrer` (which implies rel=`noopener`) is a security risk in older browsers: see https://mathiasbynens.github.io/rel-noopener/#recommendations
╭─[jsx_no_target_blank.tsx:1:11]
1 │ <a target="_blank" rel="" href="https://example.com/2"></a>
@@ -14,6 +14,24 @@ source: crates/oxc_linter/src/tester.rs
· ──────────────────
╰────
⚠ react(no-namespace): React component ns:\ud800 must not be in a namespace, as React does not support them.
╭─[no_namespace.tsx:1:21]
1 │ React.createElement("ns:\uD800")
· ───────────
╰────
⚠ react(no-namespace): React component \udc00:testcomponent must not be in a namespace, as React does not support them.
╭─[no_namespace.tsx:1:21]
1 │ React.createElement("\uDC00:testcomponent")
· ──────────────────────
╰────
⚠ react(no-namespace): React component 😀:\ud800 must not be in a namespace, as React does not support them.
╭─[no_namespace.tsx:1:21]
1 │ React.createElement("\uD83D\uDE00:\uD800")
· ─────────────────────
╰────
⚠ react(no-namespace): React component ns:testComponent must not be in a namespace, as React does not support them.
╭─[no_namespace.tsx:1:2]
1 │ <ns:testComponent />
@@ -2,6 +2,34 @@
source: crates/oxc_linter/src/tester.rs
---
⚠ typescript(no-require-imports): Expected "import" statement instead of "require" call
╭─[no_require_imports.ts:1:13]
1 │ const pkg = require('a\uD800b.json');
· ────────────────────────
╰────
help: Do not use CommonJS `require` calls
⚠ typescript(no-require-imports): Expected "import" statement instead of "require" call
╭─[no_require_imports.ts:1:1]
1 │ import pkg = require('a\uD800b.json');
· ──────────────────────────────────────
╰────
help: Do not use CommonJS `require` calls
⚠ typescript(no-require-imports): Expected "import" statement instead of "require" call
╭─[no_require_imports.ts:1:13]
1 │ const pkg = require(`a\uD800b.json`);
· ────────────────────────
╰────
help: Do not use CommonJS `require` calls
⚠ typescript(no-require-imports): Expected "import" statement instead of "require" call
╭─[no_require_imports.ts:1:13]
1 │ const pkg = require(`./${name}.json`);
· ─────────────────────────
╰────
help: Do not use CommonJS `require` calls
⚠ typescript(no-require-imports): Expected "import" statement instead of "require" call
╭─[no_require_imports.ts:1:11]
1 │ var lib = require('lib');
@@ -344,3 +344,31 @@ source: crates/oxc_linter/src/tester.rs
· ───
╰────
help: The `console.log()` method and similar methods join the parameters with a space so adding a leading/trailing space to a parameter, results in two spaces being added.
⚠ unicorn(no-console-spaces): Do not use trailing spaces with `console.log` parameters
╭─[no_console_spaces.tsx:1:14]
1 │ console.log("\uD800 ", "def");
· ───────
╰────
help: The `console.log()` method and similar methods join the parameters with a space so adding a leading/trailing space to a parameter, results in two spaces being added.
⚠ unicorn(no-console-spaces): Do not use leading spaces with `console.log` parameters
╭─[no_console_spaces.tsx:1:21]
1 │ console.log("abc", " \uDC00");
· ───────
╰────
help: The `console.log()` method and similar methods join the parameters with a space so adding a leading/trailing space to a parameter, results in two spaces being added.
⚠ unicorn(no-console-spaces): Do not use leading spaces with `console.log` parameters
╭─[no_console_spaces.tsx:1:19]
1 │ console.log("_", " \uD83D\uDE00 ", "_");
· ──────────────
╰────
help: The `console.log()` method and similar methods join the parameters with a space so adding a leading/trailing space to a parameter, results in two spaces being added.
⚠ unicorn(no-console-spaces): Do not use trailing spaces with `console.log` parameters
╭─[no_console_spaces.tsx:1:19]
1 │ console.log("_", " \uD83D\uDE00 ", "_");
· ──────────────
╰────
help: The `console.log()` method and similar methods join the parameters with a space so adding a leading/trailing space to a parameter, results in two spaces being added.
@@ -485,3 +485,9 @@ source: crates/oxc_linter/src/tester.rs
· ────────────
╰────
help: Replace `(await promise).getAttribute("data-foo")` with `(await promise).dataset.foo`.
⚠ unicorn(prefer-dom-node-dataset): Prefer using `dataset` over `setAttribute`.
╭─[prefer_dom_node_dataset.mjs:1:4]
1 │ el.setAttribute("data-\uD800", "x");
· ────────────
╰────
@@ -1180,3 +1180,26 @@ source: crates/oxc_linter/src/tester.rs
3 │ export {bar} from './foo.json' with { type: 'unknown' };
╰────
help: use `export ... from ...;`
⚠ unicorn(prefer-export-from): Prefer re-exporting directly from the source module.
╭─[prefer_export_from.tsx:1:1]
1 │ import {a} from "\uD800";
· ────────────┬────────────
· ╰── Imported here.
2 │ export {a};
· ┬
· ╰── Re-exported here.
╰────
help: use `export ... from ...;`
⚠ unicorn(prefer-export-from): Prefer re-exporting directly from the source module.
╭─[prefer_export_from.tsx:1:1]
1 │ import {a} from "\uD800";
· ────────────┬────────────
· ╰── Imported here.
2 │ export {a};
· ┬
· ╰── Re-exported here.
3 │ export {b} from "\uD800";
╰────
help: use `export ... from ...;`
@@ -14,6 +14,27 @@ source: crates/oxc_linter/src/tester.rs
╰────
help: Rewrite this `if`/`else` as a ternary expression.
⚠ unicorn(prefer-ternary): Prefer ternary expressions over simple `if-else` statements.
╭─[prefer_ternary.tsx:1:1]
1 │ if (test) { obj["\uD800"] = a; } else { obj["\uD800"] = b; }
· ────────────────────────────────────────────────────────────
╰────
help: Rewrite this `if`/`else` as a ternary expression.
⚠ unicorn(prefer-ternary): Prefer ternary expressions over simple `if-else` statements.
╭─[prefer_ternary.tsx:1:1]
1 │ if (test) { obj["\uD800" + "x"] = a; } else { obj[`\uD800x`] = b; }
· ───────────────────────────────────────────────────────────────────
╰────
help: Rewrite this `if`/`else` as a ternary expression.
⚠ unicorn(prefer-ternary): Prefer ternary expressions over simple `if-else` statements.
╭─[prefer_ternary.tsx:1:1]
1 │ if (test) { obj["\uD83D" + "\uDE00"] = a; } else { obj["\uD83D\uDE00"] = b; }
· ─────────────────────────────────────────────────────────────────────────────
╰────
help: Rewrite this `if`/`else` as a ternary expression.
⚠ unicorn(prefer-ternary): Prefer ternary expressions over simple `if-else` statements.
╭─[prefer_ternary.tsx:2:17]
1 │ async function unicorn() {
@@ -2,6 +2,27 @@
source: crates/oxc_linter/src/tester.rs
---
⚠ vitest(no-mocks-import): Mocks should not be manually imported from a `__mocks__` directory.
╭─[no_mocks_import.tsx:1:9]
1 │ require('./\uD83D\uDE00/__mocks__')
· ──────────────────────────
╰────
help: Instead use `jest.mock` or `vi.mock` and import from the original module path.
⚠ vitest(no-mocks-import): Mocks should not be manually imported from a `__mocks__` directory.
╭─[no_mocks_import.tsx:1:9]
1 │ require('./__mocks__/\uD800')
· ────────────────────
╰────
help: Instead use `jest.mock` or `vi.mock` and import from the original module path.
⚠ vitest(no-mocks-import): Mocks should not be manually imported from a `__mocks__` directory.
╭─[no_mocks_import.tsx:1:9]
1 │ require('\uDC00/__mocks__/x')
· ────────────────────
╰────
help: Instead use `jest.mock` or `vi.mock` and import from the original module path.
⚠ vitest(no-mocks-import): Mocks should not be manually imported from a `__mocks__` directory.
╭─[no_mocks_import.tsx:1:9]
1 │ require('./__mocks__')
@@ -2,6 +2,20 @@
source: crates/oxc_linter/src/tester.rs
---
⚠ vitest(prefer-import-in-mock): Mocked modules must be dynamic imported.
╭─[prefer_import_in_mock.tsx:1:9]
1 │ vi.mock("\uD800")
· ────────
╰────
help: Dynamic import improves the type information and IntelliSense. Substitute `"\ud800"` with `import("\ud800")`
⚠ vitest(prefer-import-in-mock): Mocked modules must be dynamic imported.
╭─[prefer_import_in_mock.tsx:1:11]
1 │ vi.doMock("a\uDC00b", () => ({}))
· ──────────────────────
╰────
help: Dynamic import improves the type information and IntelliSense. Substitute `"a\udc00b"` with `import("a\udc00b")`
⚠ vitest(prefer-import-in-mock): Mocked modules must be dynamic imported.
╭─[prefer_import_in_mock.tsx:1:9]
1 │ vi.mock('foo', () => {})
@@ -2,6 +2,31 @@
source: crates/oxc_linter/src/tester.rs
---
⚠ vue(component-definition-name-casing): Property name "foo-\ud800" is not PascalCase.
╭─[component_definition_name_casing.tsx:3:29]
2 │ export default {
3 │ name: "foo-\uD800"
· ────────────
4 │ }
╰────
⚠ vue(component-definition-name-casing): Property name "\udc00_bar" is not PascalCase.
╭─[component_definition_name_casing.tsx:3:29]
2 │ export default {
3 │ name: `\uDC00_bar`
· ────────────
4 │ }
╰────
⚠ vue(component-definition-name-casing): Property name "Foo😀" is not kebab-case.
╭─[component_definition_name_casing.tsx:3:29]
2 │ export default {
3 │ name: "Foo\uD83D\uDE00"
· ─────────────────
4 │ }
╰────
help: Replace `Foo\uD83D\uDE00` with `foo😀`.
⚠ vue(component-definition-name-casing): Property name "foo-bar" is not PascalCase.
╭─[component_definition_name_casing.tsx:3:29]
2 │ export default {
@@ -114,6 +114,14 @@ source: crates/oxc_linter/src/tester.rs
5 │ })
╰────
⚠ vue(no-reserved-keys): Key `_\ud800` is reserved in `data` group.
╭─[no_reserved_keys.tsx:4:34]
3 │ new Vue({
4 │ data: () => ({ "_\uD800": 1 })
· ─────────
5 │ })
╰────
⚠ vue(no-reserved-keys): Key `bar` is reserved.
╭─[no_reserved_keys.tsx:4:26]
3 │ new Vue({
@@ -2,6 +2,38 @@
source: crates/oxc_linter/src/tester.rs
---
⚠ vue(prop-name-casing): Prop '\udc00_text' is not in camelCase.
╭─[prop_name_casing.tsx:5:23]
4 │ props: {
5 │ "\uDC00_text": String,
· ─────────────
6 │ }
╰────
⚠ vue(prop-name-casing): Prop 'greeting-\ud800' is not in camelCase.
╭─[prop_name_casing.tsx:4:31]
3 │ export default {
4 │ props: ["greeting-\uD800"]
· ─────────────────
5 │ }
╰────
⚠ vue(prop-name-casing): Prop '\udc00_text' is not in camelCase.
╭─[prop_name_casing.tsx:4:32]
3 │ export default {
4 │ props: { "\uDC00_text": String }
· ─────────────
5 │ }
╰────
⚠ vue(prop-name-casing): Prop 'Greeting😀' is not in camelCase.
╭─[prop_name_casing.tsx:4:33]
3 │ export default {
4 │ props: { [`Greeting\uD83D\uDE00`]: String }
· ──────────────────────
5 │ }
╰────
⚠ vue(prop-name-casing): Prop 'greeting_text' is not in camelCase.
╭─[prop_name_casing.tsx:5:25]
4 │ props: {
+29 -10
View File
@@ -1,25 +1,44 @@
use oxc_allocator::Allocator;
use oxc_ast::ast::{BinaryOperator, Expression};
use oxc_str::{JSStr, JSStrBuilder};
/// Resolve a side-effect-free string expression made from string literals, template literals,
/// and `+` concatenation. Returns `None` when any part cannot be determined statically.
pub fn static_string_value(expression: &Expression<'_>) -> Option<String> {
///
/// A plain string literal is borrowed.
/// A concatenation is built in `allocator`.
/// Lone surrogates are preserved, and a pair split across parts is joined.
pub fn static_string_value<'a>(
expression: &Expression<'a>,
allocator: &'a Allocator,
) -> Option<JSStr<'a>> {
if let Expression::StringLiteral(literal) = expression.get_inner_expression() {
return Some(literal.value);
}
let mut builder = JSStrBuilder::new_in(&allocator);
push_static_string_value(expression, &mut builder)?;
Some(builder.into_js_str())
}
fn push_static_string_value(
expression: &Expression<'_>,
builder: &mut JSStrBuilder<'_>,
) -> Option<()> {
match expression.get_inner_expression() {
Expression::StringLiteral(literal) => literal.value.as_str().map(str::to_owned),
Expression::StringLiteral(literal) => builder.push_js_str(literal.value),
Expression::TemplateLiteral(template) => {
let mut value = String::new();
for (index, quasi) in template.quasis.iter().enumerate() {
value.push_str(quasi.value.cooked?.as_str()?);
builder.push_js_str(quasi.value.cooked?);
if let Some(expr) = template.expressions.get(index) {
value.push_str(&static_string_value(expr)?);
push_static_string_value(expr, builder)?;
}
}
Some(value)
}
Expression::BinaryExpression(binary) if binary.operator == BinaryOperator::Addition => {
let mut value = static_string_value(&binary.left)?;
value.push_str(&static_string_value(&binary.right)?);
Some(value)
push_static_string_value(&binary.left, builder)?;
push_static_string_value(&binary.right, builder)?;
}
_ => None,
_ => return None,
}
Some(())
}
+51 -46
View File
@@ -3,6 +3,7 @@
//! prop name, etc.) is in a particular casing.
use convert_case::{Boundary, Case, Converter};
use oxc_str::JSStr;
/// Returns true if `s` contains any character that is not allowed in any of
/// the casings recognised by eslint-plugin-vue.
@@ -10,73 +11,77 @@ use convert_case::{Boundary, Case, Converter};
/// Mirrors upstream `hasSymbols`. The character class excludes ` `, `$`,
/// `-`, `_` deliberately — `-` and `_` are case-specific word separators
/// and `$` is allowed in JavaScript identifiers (e.g. `$actionEl`).
pub fn has_symbols(s: &str) -> bool {
s.chars().any(|c| {
matches!(
c,
'!' | '"'
| '#'
| '%'
| '&'
| '\''
| '('
| ')'
| '*'
| '+'
| ','
| '.'
| '/'
| ':'
| ';'
| '<'
| '='
| '>'
| '?'
| '@'
| '['
| '\\'
| ']'
| '^'
| '`'
| '{'
| '|'
| '}'
)
})
///
/// A lone surrogate is not a symbol, so it is treated like any other non-ASCII code point.
pub fn has_symbols(s: JSStr<'_>) -> bool {
s.contains(is_symbol)
}
fn is_symbol(c: char) -> bool {
matches!(
c,
'!' | '"'
| '#'
| '%'
| '&'
| '\''
| '('
| ')'
| '*'
| '+'
| ','
| '.'
| '/'
| ':'
| ';'
| '<'
| '='
| '>'
| '?'
| '@'
| '['
| '\\'
| ']'
| '^'
| '`'
| '{'
| '|'
| '}'
)
}
/// Returns true if `s` contains any ASCII uppercase letter.
pub fn has_upper(s: &str) -> bool {
s.chars().any(|c| c.is_ascii_uppercase())
pub fn has_upper(s: JSStr<'_>) -> bool {
s.as_bytes().iter().any(u8::is_ascii_uppercase)
}
pub fn is_pascal_case(s: &str) -> bool {
pub fn is_pascal_case(s: JSStr<'_>) -> bool {
!has_symbols(s)
&& !s.chars().next().is_some_and(|c| c.is_ascii_lowercase())
&& !s.chars().any(|c| matches!(c, '-' | '_') || c.is_whitespace())
&& !s.starts_with(|c: char| c.is_ascii_lowercase())
&& !s.contains(|c: char| matches!(c, '-' | '_') || c.is_whitespace())
}
pub fn is_kebab_case(s: &str) -> bool {
pub fn is_kebab_case(s: JSStr<'_>) -> bool {
if has_upper(s) || has_symbols(s) || s.starts_with('-') {
return false;
}
if s.contains('_') || s.contains("--") || s.chars().any(char::is_whitespace) {
if s.contains('_') || s.contains("--") || s.contains(char::is_whitespace) {
return false;
}
true
}
pub fn is_camel_case(s: &str) -> bool {
pub fn is_camel_case(s: JSStr<'_>) -> bool {
!has_symbols(s)
&& !s.chars().next().is_some_and(|c| c.is_ascii_uppercase())
&& !s.chars().any(|c| matches!(c, '-' | '_') || c.is_whitespace())
&& !s.starts_with(|c: char| c.is_ascii_uppercase())
&& !s.contains(|c: char| matches!(c, '-' | '_') || c.is_whitespace())
}
pub fn is_snake_case(s: &str) -> bool {
pub fn is_snake_case(s: JSStr<'_>) -> bool {
if has_upper(s) || has_symbols(s) {
return false;
}
if s.contains('-') || s.contains("__") || s.chars().any(char::is_whitespace) {
if s.contains('-') || s.contains("__") || s.contains(char::is_whitespace) {
return false;
}
true
@@ -108,7 +113,7 @@ fn regex_word_before_upper(graphemes: &[&str]) -> bool {
/// - if input is already PascalCase: lowercase the first char
/// - else: replace `[-_](\w)` with `\w` uppercased
pub fn camel_case(s: &str) -> String {
if is_pascal_case(s) {
if is_pascal_case(JSStr::from(s)) {
let mut chars = s.chars();
return match chars.next() {
Some(c) => c.to_lowercase().collect::<String>() + chars.as_str(),
+18 -13
View File
@@ -12,20 +12,25 @@
//!
//! # Consuming `JSStr`
//!
//! A consumer that needs `str` decides what a lone surrogate means for its
//! check, and two policies cover the cases in practice. Checks over names,
//! paths, and specifiers decline when [`JSStr::as_str`] returns `None`: a
//! fixed name set or a well-known path never contains a lone surrogate, so
//! declining changes nothing. Comparisons with fixed names use `JSStr`'s
//! `PartialEq<&str>` directly and need no conversion. A value with a lone
//! surrogate is not matched against user-configured patterns; rules skip
//! that value. A value shown to a person uses the `Debug` form, which
//! escapes the surrogate instead of replacing it.
//! A consumer that would otherwise need `str` decides what a lone surrogate
//! means for its check:
//!
//! Declining is only safe where skipping the work is conservative, as for an
//! optional optimization or a lookup that cannot match. Code that decides
//! whether a program is valid, whether a rewrite is safe, or what code to emit
//! must handle the value instead of skipping it.
//! - Comparisons with fixed names use `JSStr`'s `PartialEq<&str>` directly and
//! need no conversion. A value with a lone surrogate never equals one.
//! - Searches for fixed text, such as a prefix or a substring, use the
//! [`JSStrPattern`] methods ([`JSStr::contains`], [`JSStr::starts_with`],
//! [`JSStr::find`], and others), which search the value without converting it.
//! - User-configured patterns, such as regexes and globs, take UTF-8 text, so
//! they match only [`JSStr::as_str`]. A value with a lone surrogate matches no
//! pattern.
//! - A value shown to a person uses [`JSStr::display`] or the `Debug` form,
//! which escape the surrogate instead of replacing it.
//!
//! Declining when [`JSStr::as_str`] returns `None` is only safe where skipping
//! the work is conservative, as for an optional optimization or a lookup that
//! cannot match. Code that decides whether a program is valid, whether a
//! rewrite is safe, or what code to emit must handle the value instead of
//! skipping it.
mod compact_str;
mod ident;
@@ -9,7 +9,7 @@ use oxc_ast::ast::{
ExportAllDeclaration, ExportFromDeclaration, Expression, ImportDeclaration, ImportExpression,
StringLiteral, TemplateLiteral,
};
use oxc_str::Str;
use oxc_str::{JSStr, JSStrBuilder, Str};
use oxc_traverse::Traverse;
use crate::{TypeScriptOptions, context::TraverseCtx, state::TransformState};
@@ -32,13 +32,7 @@ fn rewritten_specifier<'a>(
}
let (without_extension, extension) = value.rsplit_once('.')?;
let replace = match extension {
"mts" => ".mjs",
"cts" => ".cjs",
"ts" | "tsx" => ".js",
_ => return None,
};
let replace = replacement_extension(extension)?;
Some(if mode.is_remove() {
Str::from(without_extension)
@@ -47,16 +41,58 @@ fn rewritten_specifier<'a>(
})
}
fn replacement_extension(extension: &str) -> Option<&'static str> {
match extension {
"mts" => Some(".mjs"),
"cts" => Some(".cjs"),
"ts" | "tsx" => Some(".js"),
_ => None,
}
}
/// Like [`rewritten_specifier`], for a value that may contain lone surrogates.
/// The part before the extension is copied unchanged.
fn rewritten_js_specifier<'a>(
value: JSStr<'a>,
mode: RewriteExtensionsMode,
ctx: &TraverseCtx<'a>,
) -> Option<JSStr<'a>> {
if let Some(value) = value.as_str() {
return rewritten_specifier(value, mode, ctx).map(JSStr::from);
}
if !value.contains(|c| c == '/' || c == '\\') {
return None;
}
let dot = value.rfind('.')?;
// An extension with a lone surrogate is not valid UTF-8 and matches no rewrite.
let extension = str::from_utf8(&value.as_bytes()[dot + 1..]).ok()?;
let replace = replacement_extension(extension)?;
let mut builder = JSStrBuilder::with_capacity_in(dot + replace.len(), ctx);
let mut len = 0;
for ch in value.chars() {
if len == dot {
break;
}
len += ch.len_bytes();
builder.push_js_char(ch);
}
if !mode.is_remove() {
builder.push_str(replace);
}
Some(builder.into_js_str())
}
impl TypeScriptRewriteExtensions {
pub fn new(options: &TypeScriptOptions) -> Option<Self> {
options.rewrite_import_extensions.map(|mode| Self { mode })
}
pub fn rewrite_extensions<'a>(&self, source: &mut StringLiteral<'a>, ctx: &TraverseCtx<'a>) {
if let Some(value) = source.value.as_str()
&& let Some(rewritten) = rewritten_specifier(value, self.mode, ctx)
{
source.value = rewritten.into();
if let Some(rewritten) = rewritten_js_specifier(source.value, self.mode, ctx) {
source.value = rewritten;
source.raw = None;
}
}
@@ -1,6 +1,6 @@
commit: 4f4ef5d4
Passed: 304/433
Passed: 306/435
# All Passed:
* babel-plugin-transform-class-static-block
@@ -0,0 +1,9 @@
import a from "./x\uD800.ts";
import b from "./y.ts";
import "./x\uD800.mts";
import "\uD800/a.tsx";
// An extension with a lone surrogate should not be transformed.
import "./a.ts\uD800";
// Bare import should not be rewritten.
import "\uD800.ts";
console.log(a, b);
@@ -0,0 +1,4 @@
{
"sourceType": "module",
"presets": [["typescript", { "rewriteImportExtensions": "remove" }]]
}
@@ -0,0 +1,9 @@
import a from "./x\uD800";
import b from "./y";
import "./x\uD800";
import "\uD800/a";
// An extension with a lone surrogate should not be transformed.
import "./a.ts\uD800";
// Bare import should not be rewritten.
import "\uD800.ts";
console.log(a, b);
@@ -0,0 +1,9 @@
import a from "./x\uD800.ts";
import b from "./y.ts";
import "./x\uD800.mts";
import "\uD800/a.tsx";
// An extension with a lone surrogate should not be transformed.
import "./a.ts\uD800";
// Bare import should not be rewritten.
import "\uD800.ts";
console.log(a, b);
@@ -0,0 +1,4 @@
{
"sourceType": "module",
"presets": [["typescript", { "rewriteImportExtensions": "rewrite" }]]
}
@@ -0,0 +1,9 @@
import a from "./x\uD800.js";
import b from "./y.js";
import "./x\uD800.mjs";
import "\uD800/a.js";
// An extension with a lone surrogate should not be transformed.
import "./a.ts\uD800";
// Bare import should not be rewritten.
import "\uD800.ts";
console.log(a, b);