mirror of
https://github.com/oxc-project/oxc.git
synced 2026-11-04 18:43:44 +00:00
fix(linter): search string values without UTF-8 conversion (#26716)
A lone surrogate anywhere in a string made `as_str()` return `None`, so rules skipped checks on ordinary text elsewhere in the value. For example, `test(" \uD800 my title ", () => {})` got no leading/trailing-space diagnostic.
This adds `contains`, `starts_with`, `ends_with`, `find` and `rfind` to `JSStr`. They take a `&str`, a `char` or a predicate, search the WTF-8 bytes without converting or allocating, and never match a lone surrogate with a character pattern. Seven linter rules and the formatter's JSX attribute checks use them. Lowercase-title fixes now check the source spelling, so an escaped first letter is reported without a fix.
Based on #26712. Part of #26242.
Implemented with AI assistance.
This commit is contained in:
1 parent
c7d229b6a9
commit
782ab6e2cd
26 files changed
+809
-86
No files matched your search
Generated
+1
@@ -2676,6 +2676,7 @@ version = "0.154.0"
|
||||
dependencies = [
|
||||
"compact_str",
|
||||
"hashbrown 0.17.1",
|
||||
"memchr",
|
||||
"oxc-schemars",
|
||||
"oxc_allocator",
|
||||
"oxc_data_structures",
|
||||
|
||||
@@ -63,10 +63,12 @@ fn is_multiline_string_literal_attribute(attribute: &JSXAttributeItem<'_>) -> bo
|
||||
let JSXAttributeItem::Attribute(attr) = attribute else {
|
||||
return false;
|
||||
};
|
||||
attr.value.as_ref().is_some_and(|value| matches!(
|
||||
value,
|
||||
JSXAttributeValue::StringLiteral(s) if s.value.as_str().is_some_and(|v| v.contains(['\n', '\r']))
|
||||
))
|
||||
attr.value.as_ref().is_some_and(|value| {
|
||||
matches!(
|
||||
value,
|
||||
JSXAttributeValue::StringLiteral(s) if s.value.contains(|c: char| matches!(c, '\n' | '\r'))
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
impl<'a> Format<'a, JsFormatContext<'a>> for FormatOpeningElement<'a, '_> {
|
||||
@@ -161,9 +163,8 @@ pub enum OpeningElementLayout {
|
||||
|
||||
/// Returns `true` if this is an attribute with a string literal initializer that does not contain any new line characters.
|
||||
fn is_single_line_string_literal_attribute(attribute: &JSXAttributeItem) -> bool {
|
||||
as_string_literal_attribute_value(attribute).is_some_and(|string| {
|
||||
!string.value.as_str().is_some_and(|value| value.contains(['\n', '\r']))
|
||||
})
|
||||
as_string_literal_attribute_value(attribute)
|
||||
.is_some_and(|string| !string.value.contains(|c: char| matches!(c, '\n' | '\r')))
|
||||
}
|
||||
|
||||
/// Returns `Some` if the initializer value of this attribute is a string literal.
|
||||
|
||||
@@ -115,7 +115,7 @@ impl NoRegexSpaces {
|
||||
let Some(Argument::StringLiteral(pattern)) = args.first() else {
|
||||
return None;
|
||||
};
|
||||
if !Self::has_double_space(pattern.value.as_str()?) {
|
||||
if !pattern.value.contains(" ") {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -276,6 +276,8 @@ fn test() {
|
||||
"var foo = /[[ ] ] /v;",
|
||||
"var foo = new RegExp('[ ] ');",
|
||||
"var foo = new RegExp('[[ ] ] ', 'v');",
|
||||
r"var foo = RegExp('\uD800 bar');",
|
||||
r"var foo = new RegExp('bar \uDC00');",
|
||||
];
|
||||
|
||||
let fix = vec![
|
||||
@@ -309,6 +311,8 @@ fn test() {
|
||||
"var foo = new RegExp('[[ ] {4}] ', 'v');",
|
||||
None,
|
||||
),
|
||||
(r"var foo = RegExp('\uD800 bar');", r"var foo = RegExp('\uD800 {2}bar');", None),
|
||||
(r"var foo = new RegExp('bar \uDC00');", r"var foo = new RegExp('bar {2}\uDC00');", None),
|
||||
];
|
||||
|
||||
Tester::new(NoRegexSpaces::NAME, NoRegexSpaces::PLUGIN, pass, fail)
|
||||
|
||||
@@ -45,10 +45,7 @@ impl Rule for NoScriptUrl {
|
||||
fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) {
|
||||
match node.kind() {
|
||||
AstKind::StringLiteral(literal)
|
||||
if literal
|
||||
.value
|
||||
.as_str()
|
||||
.is_some_and(|value| starts_with_ignore_case(value, "javascript:")) =>
|
||||
if starts_with_ignore_case(literal.value.as_bytes(), "javascript:") =>
|
||||
{
|
||||
ctx.diagnostic(no_script_url_diagnostic(literal.span));
|
||||
}
|
||||
@@ -56,7 +53,7 @@ impl Rule for NoScriptUrl {
|
||||
if !is_tagged_template_expression(ctx, node, literal.span)
|
||||
&& literal.quasis.len() == 1
|
||||
&& starts_with_ignore_case(
|
||||
&literal.quasis.first().unwrap().value.raw,
|
||||
literal.quasis.first().unwrap().value.raw.as_bytes(),
|
||||
"javascript:",
|
||||
) =>
|
||||
{
|
||||
@@ -88,6 +85,8 @@ fn test() {
|
||||
"var a = 'js:';",
|
||||
"var url = `js:`",
|
||||
"var a = 'über cool stuff';",
|
||||
r"var a = '\uD800javascript:';",
|
||||
r"var a = 'javascript\uD800:';",
|
||||
];
|
||||
|
||||
let fail = vec![
|
||||
@@ -95,6 +94,8 @@ fn test() {
|
||||
"var a = 'javascript:';",
|
||||
"var a = `javascript:`;",
|
||||
"var a = `JavaScript:`;",
|
||||
r"var a = 'javascript:\uD800';",
|
||||
r"var a = 'JAVASCRIPT:void(\uDC00)';",
|
||||
];
|
||||
|
||||
Tester::new(NoScriptUrl::NAME, NoScriptUrl::PLUGIN, pass, fail).test_and_snapshot();
|
||||
|
||||
@@ -57,10 +57,9 @@ impl Rule for NoTemplateCurlyInString {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(text) = literal.value.as_str() else { return };
|
||||
let Some(start) = text.find("${") else { return };
|
||||
let Some(start) = literal.value.find("${") else { return };
|
||||
|
||||
if text[start + 2..].contains('}') {
|
||||
if literal.value.rfind('}').is_some_and(|end| end >= start + 2) {
|
||||
ctx.diagnostic(no_template_curly_in_string_diagnostic(literal.span));
|
||||
}
|
||||
}
|
||||
@@ -86,6 +85,9 @@ fn test() {
|
||||
"'{foo}'",
|
||||
r#"'{foo: "bar"}'"#,
|
||||
"const number = 3",
|
||||
r"'\uD800'",
|
||||
r"'${\uD800'",
|
||||
r"'}\uD800${'",
|
||||
];
|
||||
|
||||
let fail = vec![
|
||||
@@ -97,6 +99,9 @@ fn test() {
|
||||
r#"'Hello, ${name + " foo"}'"#,
|
||||
r#"'Hello, ${name || "foo"}'"#,
|
||||
r#"'Hello, ${{foo: "bar"}.foo}'"#,
|
||||
r"'\uD800 ${name}'",
|
||||
r"'${\uDC00}'",
|
||||
r"'${name} \uD800'",
|
||||
];
|
||||
|
||||
Tester::new(NoTemplateCurlyInString::NAME, NoTemplateCurlyInString::PLUGIN, pass, fail)
|
||||
|
||||
@@ -6,11 +6,12 @@ use oxc_diagnostics::OxcDiagnostic;
|
||||
use oxc_macros::declare_oxc_lint;
|
||||
use oxc_semantic::AstNode;
|
||||
use oxc_span::Span;
|
||||
use oxc_str::JSStr;
|
||||
|
||||
use crate::{context::LintContext, rule::Rule};
|
||||
|
||||
fn no_named_as_default_diagnostic(name: &str, span: Span) -> OxcDiagnostic {
|
||||
OxcDiagnostic::warn(format!("Unexpected `!` in `{name}`."))
|
||||
fn no_webpack_loader_syntax_diagnostic(name: JSStr<'_>, span: Span) -> OxcDiagnostic {
|
||||
OxcDiagnostic::warn(format!("Unexpected `!` in `{}`.", name.display()))
|
||||
.with_help("Do not use import syntax to configure webpack loaders")
|
||||
.with_label(span)
|
||||
}
|
||||
@@ -76,10 +77,11 @@ impl Rule for NoWebpackLoaderSyntax {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(value) = ident.value.as_str()
|
||||
&& value.contains('!')
|
||||
{
|
||||
ctx.diagnostic(no_named_as_default_diagnostic(value, ident.span));
|
||||
if ident.value.contains('!') {
|
||||
ctx.diagnostic(no_webpack_loader_syntax_diagnostic(
|
||||
ident.value,
|
||||
ident.span,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,10 +91,11 @@ impl Rule for NoWebpackLoaderSyntax {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(value) = import_decl.source.value.as_str()
|
||||
&& value.contains('!')
|
||||
{
|
||||
ctx.diagnostic(no_named_as_default_diagnostic(value, import_decl.source.span));
|
||||
if import_decl.source.value.contains('!') {
|
||||
ctx.diagnostic(no_webpack_loader_syntax_diagnostic(
|
||||
import_decl.source.value,
|
||||
import_decl.source.span,
|
||||
));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -116,6 +119,8 @@ fn test() {
|
||||
"var foo = require('foo')",
|
||||
"var foo = require('./')",
|
||||
"var foo = require('@scope/foo')",
|
||||
r"import foo from '\uD800'",
|
||||
r"var foo = require('\uDC00/foo')",
|
||||
];
|
||||
|
||||
let fail = vec![
|
||||
@@ -127,6 +132,9 @@ fn test() {
|
||||
"var find = require('-babel-loader!lodash.find')",
|
||||
"var foo = require('style!css!./foo.css')",
|
||||
"var data = require('json!@scope/my-package/data.json')",
|
||||
r"import foo from 'babel!\uD800'",
|
||||
r"var foo = require('\uDC00!lodash')",
|
||||
r"var foo = require('\u0062abel!\uD800')",
|
||||
];
|
||||
|
||||
Tester::new(NoWebpackLoaderSyntax::NAME, NoWebpackLoaderSyntax::PLUGIN, pass, fail)
|
||||
|
||||
@@ -34,6 +34,43 @@ impl Rule for PreferLowercaseTitle {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_escaped_titles() {
|
||||
use crate::tester::Tester;
|
||||
|
||||
let escaped_first_characters = [
|
||||
r"it('\u0046oo\uD800', () => {})",
|
||||
r"it('\x46oo\uD800', () => {})",
|
||||
r"it(`\u{46}oo\uDC00`, () => {})",
|
||||
r"it('\u0046oo', () => {})",
|
||||
r"it('\
|
||||
Foo\uD800', () => {})",
|
||||
];
|
||||
for plugin in ["jest", "vitest"] {
|
||||
for first_only in [true, false] {
|
||||
let config = Some(serde_json::json!([{ "lowercaseFirstCharacterOnly": first_only }]));
|
||||
let mut fix: Vec<_> = escaped_first_characters
|
||||
.iter()
|
||||
.map(|&source| (source, source, config.clone()))
|
||||
.collect();
|
||||
if first_only {
|
||||
fix.push((r"it('Foo\nBar', () => {})", r"it('foo\nBar', () => {})", config));
|
||||
} else {
|
||||
for source in [
|
||||
r"it('Foo\nBar', () => {})",
|
||||
r"it(`Foo\${Bar}`, () => {})",
|
||||
r"it('Foo \uD800', () => {})",
|
||||
] {
|
||||
fix.push((source, source, config.clone()));
|
||||
}
|
||||
}
|
||||
Tester::new("prefer-lowercase-title", plugin, Vec::<&str>::new(), vec![])
|
||||
.expect_fix(fix)
|
||||
.test();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test() {
|
||||
use crate::{rule::RuleMeta, tester::Tester};
|
||||
@@ -42,6 +79,13 @@ fn test() {
|
||||
("it.each()", None),
|
||||
("it.each()(1)", None),
|
||||
("randomFunction()", None),
|
||||
(r"it('\uD800', function () {})", None),
|
||||
(r"it('\uD800 Foo', function () {})", None),
|
||||
(r"it('foo \uD800 Bar', function () {})", None),
|
||||
(
|
||||
r"it('GET \uD800', function () {})",
|
||||
Some(serde_json::json!([{ "allowedPrefixes": ["GET"] }])),
|
||||
),
|
||||
("foo.bar()", None),
|
||||
("it()", None),
|
||||
("it(' ', function () {})", None),
|
||||
@@ -168,6 +212,14 @@ fn test() {
|
||||
|
||||
let fail = vec![
|
||||
("it('Foo', function () {})", None),
|
||||
("it(\"Foo's bar\", function () {})", None),
|
||||
("it('Foo \"bar\"', function () {})", None),
|
||||
(r"it('Foo \uD800', function () {})", None),
|
||||
(r"it(`Foo \uDC00`, function () {})", None),
|
||||
(
|
||||
r"it('\uD800 Foo', function () {})",
|
||||
Some(serde_json::json!([{ "lowercaseFirstCharacterOnly": false }])),
|
||||
),
|
||||
("xit('Foo', function () {})", None),
|
||||
("it(\"Foo\", function () {})", None),
|
||||
("it(`Foo`, function () {})", None),
|
||||
@@ -246,6 +298,8 @@ fn test() {
|
||||
|
||||
let fix = vec![
|
||||
("it('Foo', function () {})", "it('foo', function () {})", None),
|
||||
(r"it('Foo \uD800', function () {})", r"it('foo \uD800', function () {})", None),
|
||||
(r"it(`Foo \uDC00`, function () {})", r"it(`foo \uDC00`, function () {})", None),
|
||||
("xit('Foo', function () {})", "xit('foo', function () {})", None),
|
||||
("it(\"Foo\", function () {})", "it(\"foo\", function () {})", None),
|
||||
("it(`Foo`, function () {})", "it(`foo`, function () {})", None),
|
||||
|
||||
@@ -45,6 +45,13 @@ fn test() {
|
||||
let pass = vec![
|
||||
("describe('the correct way to properly handle all the things', () => {});", None),
|
||||
("test('that all is as it should be', () => {});", None),
|
||||
(r"test('\uD800', () => {});", None),
|
||||
(r"test('foo \uD800 bar', () => {});", None),
|
||||
(r"test('\uD800test foo', () => {});", None),
|
||||
(
|
||||
r"test('\uD800 correct', () => {});",
|
||||
Some(serde_json::json!([{ "disallowedWords": ["correct"] }])),
|
||||
),
|
||||
(
|
||||
"it('correctly sets the value', () => {});",
|
||||
Some(serde_json::json!([
|
||||
@@ -651,6 +658,10 @@ fn test() {
|
||||
("it(abc, function () {})", None),
|
||||
// Vitest-specific fail test with allowArguments: false
|
||||
("test(bar, () => {});", Some(serde_json::json!([{ "allowArguments": false }]))),
|
||||
(r"test(' \uD800 ', () => {});", None),
|
||||
(r"describe(`\uDC00 `, () => {});", None),
|
||||
(r"test('test \uD800', () => {});", None),
|
||||
(r"it('it \uD800 foo', () => {});", None),
|
||||
];
|
||||
|
||||
let fix = vec![
|
||||
@@ -783,6 +794,9 @@ fn test() {
|
||||
"test('test that it doesn\\'t break', () => {});",
|
||||
"test('that it doesn\\'t break', () => {});",
|
||||
),
|
||||
// Lone surrogates elsewhere in the title do not hide the space or prefix.
|
||||
(r"test(' \uD800 ', () => {});", r"test('\uD800', () => {});"),
|
||||
(r"test('test \uD800', () => {});", r"test('\uD800', () => {});"),
|
||||
];
|
||||
|
||||
let fix = fix
|
||||
|
||||
@@ -70,7 +70,7 @@ impl Rule for AriaProps {
|
||||
// allocating so the common non-ARIA attribute (including camelCase names like
|
||||
// `className`/`onClick`, which would otherwise allocate a lowercased copy)
|
||||
// short-circuits before the lowercasing.
|
||||
if !starts_with_ignore_case(&name, "aria-") {
|
||||
if !starts_with_ignore_case(name.as_bytes(), "aria-") {
|
||||
return;
|
||||
}
|
||||
let name = name.cow_to_ascii_lowercase();
|
||||
|
||||
@@ -77,7 +77,7 @@ impl Rule for AriaProptypes {
|
||||
// Only `aria-*` attributes resolve to an `AriaProperty`. Check the prefix without
|
||||
// allocating so non-ARIA attributes (including camelCase names that would
|
||||
// otherwise allocate a lowercased copy) short-circuit before the lowercasing.
|
||||
if !starts_with_ignore_case(&name, "aria-") {
|
||||
if !starts_with_ignore_case(name.as_bytes(), "aria-") {
|
||||
return;
|
||||
}
|
||||
let name = name.cow_to_ascii_lowercase();
|
||||
|
||||
@@ -15,8 +15,9 @@ use crate::{
|
||||
rule::{DefaultRuleConfig, Rule},
|
||||
};
|
||||
|
||||
fn no_sync_diagnostic(span: Span, property_name: &str) -> OxcDiagnostic {
|
||||
OxcDiagnostic::warn(format!("Unexpected sync method: '{property_name}'.")).with_label(span)
|
||||
fn no_sync_diagnostic(span: Span, property_name: JSStr<'_>) -> OxcDiagnostic {
|
||||
OxcDiagnostic::warn(format!("Unexpected sync method: '{}'.", property_name.display()))
|
||||
.with_label(span)
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Deserialize, JsonSchema)]
|
||||
@@ -85,7 +86,7 @@ impl Rule for NoSync {
|
||||
return;
|
||||
};
|
||||
|
||||
if self.0.ignores.contains(property_name) {
|
||||
if property_name.as_str().is_some_and(|name| self.0.ignores.contains(name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -97,20 +98,20 @@ impl Rule for NoSync {
|
||||
}
|
||||
}
|
||||
|
||||
fn get_sync_property_name<'a>(expr: &'a Expression<'a>) -> Option<&'a str> {
|
||||
fn get_sync_property_name<'a>(expr: &'a Expression<'a>) -> Option<JSStr<'a>> {
|
||||
match expr.get_inner_expression() {
|
||||
Expression::Identifier(ident) if ident.name.as_str().ends_with("Sync") => {
|
||||
Some(ident.name.as_str())
|
||||
Some(JSStr::from(ident.name))
|
||||
}
|
||||
Expression::StaticMemberExpression(member) => {
|
||||
if member.property.name.as_str().ends_with("Sync") {
|
||||
Some(member.property.name.as_str())
|
||||
Some(JSStr::from(member.property.name))
|
||||
} else {
|
||||
get_sync_property_name(&member.object)
|
||||
}
|
||||
}
|
||||
Expression::ComputedMemberExpression(member) => {
|
||||
if let Some(name) = member.static_property_name().and_then(JSStr::as_str)
|
||||
if let Some(name) = member.static_property_name()
|
||||
&& name.ends_with("Sync")
|
||||
{
|
||||
return Some(name);
|
||||
@@ -135,6 +136,8 @@ fn test() {
|
||||
("if (true) {fs.fooSync();}", Some(serde_json::json!([{ "allowAtRootLevel": true }]))),
|
||||
("if (true) {fooSync();}", Some(serde_json::json!([{ "allowAtRootLevel": true }]))),
|
||||
("fooSync();", Some(serde_json::json!([{ "ignores": ["fooSync"] }]))),
|
||||
(r#"fs["\uD800sync"]();"#, None),
|
||||
(r#"fs["Sync\uDC00"]();"#, None),
|
||||
];
|
||||
|
||||
let fail = vec![
|
||||
@@ -157,6 +160,8 @@ fn test() {
|
||||
"() => {fs.fooSync();}",
|
||||
Some(serde_json::json!([{ "allowAtRootLevel": true, "ignores": ["barSync"] }])),
|
||||
),
|
||||
(r#"fs["\uD800Sync"]();"#, None),
|
||||
(r#"fs["a\uDC00bSync"]();"#, Some(serde_json::json!([{ "ignores": ["abSync"] }]))),
|
||||
];
|
||||
|
||||
Tester::new(NoSync::NAME, NoSync::PLUGIN, pass, fail).test_and_snapshot();
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use std::borrow::Cow;
|
||||
|
||||
use oxc_ast::{AstKind, ast::Argument};
|
||||
use oxc_diagnostics::OxcDiagnostic;
|
||||
use oxc_span::Span;
|
||||
use oxc_str::CompactStr;
|
||||
use oxc_str::{CompactStr, JSChar, JSStr};
|
||||
use schemars::JsonSchema;
|
||||
use serde::Deserialize;
|
||||
|
||||
@@ -13,7 +15,7 @@ use crate::{
|
||||
},
|
||||
};
|
||||
|
||||
fn prefer_lowercase_title_diagnostic(title: &str, span: Span) -> OxcDiagnostic {
|
||||
fn prefer_lowercase_title_diagnostic(title: JSStr<'_>, span: Span) -> OxcDiagnostic {
|
||||
OxcDiagnostic::warn("Enforce lowercase test names")
|
||||
.with_help(format!("`{title:?}`s should begin with lowercase"))
|
||||
.with_label(span)
|
||||
@@ -186,28 +188,25 @@ impl PreferLowercaseTitleConfig {
|
||||
};
|
||||
|
||||
if let Argument::StringLiteral(string_expr) = arg {
|
||||
if let Some(value) = string_expr.value.as_str() {
|
||||
self.lint_string(ctx, value, string_expr.span);
|
||||
}
|
||||
self.lint_string(ctx, string_expr.value, string_expr.span);
|
||||
} else if let Argument::TemplateLiteral(template_expr) = arg {
|
||||
let Some(template_string) = template_expr.single_quasi() else {
|
||||
return;
|
||||
};
|
||||
if let Some(value) = template_string.as_str() {
|
||||
self.lint_string(ctx, value, template_expr.span);
|
||||
}
|
||||
self.lint_string(ctx, template_string, template_expr.span);
|
||||
}
|
||||
}
|
||||
|
||||
fn lint_string<'a>(&self, ctx: &LintContext<'a>, literal: &'a str, span: Span) {
|
||||
if literal.is_empty()
|
||||
|| self.allowed_prefixes.iter().any(|name| literal.starts_with(name.as_str()))
|
||||
fn lint_string<'a>(&self, ctx: &LintContext<'a>, title: JSStr<'a>, span: Span) {
|
||||
if title.is_empty()
|
||||
|| self.allowed_prefixes.iter().any(|name| title.starts_with(name.as_str()))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if self.lowercase_first_character_only {
|
||||
let Some(first_char) = literal.chars().next() else {
|
||||
let replacement = if self.lowercase_first_character_only {
|
||||
// A lone surrogate has no case, so a title starting with one is fine.
|
||||
let Some(first_char) = title.chars().next().and_then(JSChar::to_char) else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -215,20 +214,31 @@ impl PreferLowercaseTitleConfig {
|
||||
if first_char == lower {
|
||||
return;
|
||||
}
|
||||
} else if !literal.bytes().any(|b| b.is_ascii_uppercase()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let replacement = if self.lowercase_first_character_only {
|
||||
cow_utils::CowUtils::cow_to_ascii_lowercase(&literal.chars().as_str()[0..1])
|
||||
// Only replace a literal ASCII letter, not the start of an escape or line continuation.
|
||||
if ctx.source_range(span.shrink(1)).as_bytes().first() != title.as_bytes().first() {
|
||||
ctx.diagnostic(prefer_lowercase_title_diagnostic(title, span));
|
||||
return;
|
||||
}
|
||||
Cow::Owned(lower.to_string())
|
||||
} else {
|
||||
if !title.as_bytes().iter().any(u8::is_ascii_uppercase) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Replacing decoded text is safe only when it is already the source spelling.
|
||||
let Some(literal) =
|
||||
title.as_str().filter(|literal| *literal == ctx.source_range(span.shrink(1)))
|
||||
else {
|
||||
ctx.diagnostic(prefer_lowercase_title_diagnostic(title, span));
|
||||
return;
|
||||
};
|
||||
cow_utils::CowUtils::cow_to_ascii_lowercase(literal)
|
||||
};
|
||||
|
||||
#[expect(clippy::cast_possible_truncation)]
|
||||
let replacement_len = replacement.len() as u32;
|
||||
|
||||
ctx.diagnostic_with_fix(prefer_lowercase_title_diagnostic(literal, span), |fixer| {
|
||||
ctx.diagnostic_with_fix(prefer_lowercase_title_diagnostic(title, span), |fixer| {
|
||||
fixer.replace(Span::sized(span.start + 1, replacement_len), replacement.into_owned())
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use oxc_ast::{
|
||||
};
|
||||
use oxc_diagnostics::OxcDiagnostic;
|
||||
use oxc_span::{GetSpan, Span};
|
||||
use oxc_str::{CompactStr, JSStr};
|
||||
use oxc_str::{CompactStr, JSChar, JSStr};
|
||||
|
||||
use crate::{
|
||||
context::LintContext,
|
||||
@@ -226,10 +226,13 @@ impl ValidTitleConfig {
|
||||
|
||||
match arg {
|
||||
Argument::StringLiteral(string_literal) => {
|
||||
let Some(value) = string_literal.value.as_str() else {
|
||||
return;
|
||||
};
|
||||
validate_title(value, string_literal.span, config, &jest_fn_call.name, ctx);
|
||||
validate_title(
|
||||
string_literal.value,
|
||||
string_literal.span,
|
||||
config,
|
||||
&jest_fn_call.name,
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
// Handle String.raw`foo`
|
||||
Argument::TaggedTemplateExpression(tagged_template) => {
|
||||
@@ -242,7 +245,7 @@ impl ValidTitleConfig {
|
||||
|
||||
if tagged_template.quasi.is_no_substitution_template() {
|
||||
validate_title(
|
||||
tagged_template.quasi.quasis[0].value.raw.as_str(),
|
||||
tagged_template.quasi.quasis[0].value.raw.into(),
|
||||
tagged_template.quasi.span,
|
||||
config,
|
||||
&jest_fn_call.name,
|
||||
@@ -251,7 +254,7 @@ impl ValidTitleConfig {
|
||||
}
|
||||
}
|
||||
Argument::TemplateLiteral(template_literal) => {
|
||||
if let Some(quasi) = template_literal.single_quasi().and_then(JSStr::as_str) {
|
||||
if let Some(quasi) = template_literal.single_quasi() {
|
||||
validate_title(quasi, template_literal.span, config, &jest_fn_call.name, ctx);
|
||||
}
|
||||
}
|
||||
@@ -386,13 +389,30 @@ fn compile_matcher_pattern(pattern: MatcherPattern) -> Option<CompiledMatcherAnd
|
||||
}
|
||||
}
|
||||
|
||||
fn can_trim_raw_title(cooked: &str, raw: &str) -> bool {
|
||||
cooked.len() - cooked.trim_start().len() == raw.len() - raw.trim_start().len()
|
||||
&& cooked.len() - cooked.trim_end().len() == raw.len() - raw.trim_end().len()
|
||||
fn can_trim_raw_title(cooked: impl Iterator<Item = JSChar>, raw: &str) -> bool {
|
||||
whitespace_lens(cooked) == whitespace_lens(raw.chars().map(JSChar::from))
|
||||
}
|
||||
|
||||
/// Return the byte lengths of the leading and trailing whitespace.
|
||||
/// A lone surrogate is not whitespace.
|
||||
fn whitespace_lens(chars: impl Iterator<Item = JSChar>) -> (usize, usize) {
|
||||
let (mut leading, mut trailing, mut seen_other) = (0, 0, false);
|
||||
for c in chars {
|
||||
if c.to_char().is_some_and(char::is_whitespace) {
|
||||
trailing += c.len_bytes();
|
||||
if !seen_other {
|
||||
leading += c.len_bytes();
|
||||
}
|
||||
} else {
|
||||
seen_other = true;
|
||||
trailing = 0;
|
||||
}
|
||||
}
|
||||
(leading, trailing)
|
||||
}
|
||||
|
||||
fn validate_title(
|
||||
title: &str,
|
||||
title: JSStr<'_>,
|
||||
span: Span,
|
||||
config: &ValidTitleConfig,
|
||||
name: &str,
|
||||
@@ -403,18 +423,22 @@ fn validate_title(
|
||||
return;
|
||||
}
|
||||
|
||||
// Regular expressions match UTF-8 only.
|
||||
// A title with a lone surrogate skips this check.
|
||||
if let Some(disallowed_words_reg) = &config.disallowed_words_reg
|
||||
&& let Some(matched) = disallowed_words_reg.find(title)
|
||||
&& let Some(utf8_title) = title.as_str()
|
||||
&& let Some(matched) = disallowed_words_reg.find(utf8_title)
|
||||
{
|
||||
ctx.diagnostic(disallowed_word_diagnostic(matched.as_str(), span));
|
||||
return;
|
||||
}
|
||||
|
||||
let trimmed_title = title.trim();
|
||||
if !config.ignore_spaces && trimmed_title != title {
|
||||
if !config.ignore_spaces
|
||||
&& (title.starts_with(char::is_whitespace) || title.ends_with(char::is_whitespace))
|
||||
{
|
||||
let inner_span = span.shrink(1);
|
||||
let raw_text = ctx.source_range(inner_span);
|
||||
if can_trim_raw_title(title, raw_text) {
|
||||
if can_trim_raw_title(title.chars(), raw_text) {
|
||||
ctx.diagnostic_with_fix(accidental_space_diagnostic(span), |fixer| {
|
||||
fixer.replace(inner_span, raw_text.trim().to_string())
|
||||
});
|
||||
@@ -425,18 +449,19 @@ fn validate_title(
|
||||
}
|
||||
|
||||
let un_prefixed_name = name.trim_start_matches(['f', 'x']);
|
||||
let Some(first_word) = title.split(' ').next() else {
|
||||
return;
|
||||
};
|
||||
// The first word is the text before the first space, or the whole title.
|
||||
let first_word_len = title.find(' ').unwrap_or(title.len());
|
||||
|
||||
if first_word == un_prefixed_name {
|
||||
if first_word_len == un_prefixed_name.len() && title.starts_with(un_prefixed_name) {
|
||||
let inner_span = span.shrink(1);
|
||||
let raw_text = ctx.source_range(inner_span);
|
||||
if let Some(unprefixed_raw) =
|
||||
raw_text.strip_prefix(first_word).and_then(|rest| rest.strip_prefix(' '))
|
||||
&& let Some(unprefixed_cooked) =
|
||||
title.strip_prefix(first_word).and_then(|rest| rest.strip_prefix(' '))
|
||||
&& can_trim_raw_title(unprefixed_cooked, unprefixed_raw)
|
||||
raw_text.strip_prefix(un_prefixed_name).and_then(|rest| rest.strip_prefix(' '))
|
||||
&& first_word_len < title.len()
|
||||
&& can_trim_raw_title(
|
||||
title.chars().skip(un_prefixed_name.chars().count() + 1),
|
||||
unprefixed_raw,
|
||||
)
|
||||
{
|
||||
ctx.diagnostic_with_fix(duplicate_prefix_diagnostic(span), |fixer| {
|
||||
fixer.replace(inner_span, unprefixed_raw.trim().to_string())
|
||||
@@ -452,6 +477,12 @@ 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;
|
||||
};
|
||||
|
||||
if let Some((regex, message)) = config.must_match_patterns.get(&jest_fn_name)
|
||||
&& !regex.is_match(title)
|
||||
{
|
||||
|
||||
@@ -183,3 +183,17 @@ source: crates/oxc_linter/src/tester.rs
|
||||
· ────
|
||||
╰────
|
||||
help: Use a quantifier: ` {4}`
|
||||
|
||||
⚠ eslint(no-regex-spaces): Multiple consecutive spaces are hard to count.
|
||||
╭─[no_regex_spaces.tsx:1:25]
|
||||
1 │ var foo = RegExp('\uD800 bar');
|
||||
· ──
|
||||
╰────
|
||||
help: Use a quantifier: ` {2}`
|
||||
|
||||
⚠ eslint(no-regex-spaces): Multiple consecutive spaces are hard to count.
|
||||
╭─[no_regex_spaces.tsx:1:26]
|
||||
1 │ var foo = new RegExp('bar \uDC00');
|
||||
· ──
|
||||
╰────
|
||||
help: Use a quantifier: ` {2}`
|
||||
@@ -29,3 +29,17 @@ source: crates/oxc_linter/src/tester.rs
|
||||
· ─────────────
|
||||
╰────
|
||||
help: Execute the code directly instead.
|
||||
|
||||
⚠ eslint(no-script-url): Unexpected `javascript:` url
|
||||
╭─[no_script_url.tsx:1:9]
|
||||
1 │ var a = 'javascript:\uD800';
|
||||
· ───────────────────
|
||||
╰────
|
||||
help: Execute the code directly instead.
|
||||
|
||||
⚠ eslint(no-script-url): Unexpected `javascript:` url
|
||||
╭─[no_script_url.tsx:1:9]
|
||||
1 │ var a = 'JAVASCRIPT:void(\uDC00)';
|
||||
· ─────────────────────────
|
||||
╰────
|
||||
help: Execute the code directly instead.
|
||||
@@ -57,3 +57,24 @@ source: crates/oxc_linter/src/tester.rs
|
||||
· ────────────────────────────
|
||||
╰────
|
||||
help: Did you mean to use a template string literal?
|
||||
|
||||
⚠ eslint(no-template-curly-in-string): Template placeholders will not interpolate in regular strings
|
||||
╭─[no_template_curly_in_string.tsx:1:1]
|
||||
1 │ '\uD800 ${name}'
|
||||
· ────────────────
|
||||
╰────
|
||||
help: Did you mean to use a template string literal?
|
||||
|
||||
⚠ eslint(no-template-curly-in-string): Template placeholders will not interpolate in regular strings
|
||||
╭─[no_template_curly_in_string.tsx:1:1]
|
||||
1 │ '${\uDC00}'
|
||||
· ───────────
|
||||
╰────
|
||||
help: Did you mean to use a template string literal?
|
||||
|
||||
⚠ eslint(no-template-curly-in-string): Template placeholders will not interpolate in regular strings
|
||||
╭─[no_template_curly_in_string.tsx:1:1]
|
||||
1 │ '${name} \uD800'
|
||||
· ────────────────
|
||||
╰────
|
||||
help: Did you mean to use a template string literal?
|
||||
@@ -57,3 +57,24 @@ source: crates/oxc_linter/src/tester.rs
|
||||
· ──────────────────────────────────
|
||||
╰────
|
||||
help: Do not use import syntax to configure webpack loaders
|
||||
|
||||
⚠ import(no-webpack-loader-syntax): Unexpected `!` in `babel!\ud800`.
|
||||
╭─[no_webpack_loader_syntax.tsx:1:17]
|
||||
1 │ import foo from 'babel!\uD800'
|
||||
· ──────────────
|
||||
╰────
|
||||
help: Do not use import syntax to configure webpack loaders
|
||||
|
||||
⚠ import(no-webpack-loader-syntax): Unexpected `!` in `\udc00!lodash`.
|
||||
╭─[no_webpack_loader_syntax.tsx:1:19]
|
||||
1 │ var foo = require('\uDC00!lodash')
|
||||
· ───────────────
|
||||
╰────
|
||||
help: Do not use import syntax to configure webpack loaders
|
||||
|
||||
⚠ import(no-webpack-loader-syntax): Unexpected `!` in `babel!\ud800`.
|
||||
╭─[no_webpack_loader_syntax.tsx:1:19]
|
||||
1 │ var foo = require('\u0062abel!\uD800')
|
||||
· ───────────────────
|
||||
╰────
|
||||
help: Do not use import syntax to configure webpack loaders
|
||||
@@ -9,6 +9,41 @@ source: crates/oxc_linter/src/tester.rs
|
||||
╰────
|
||||
help: `"Foo"`s should begin with lowercase
|
||||
|
||||
⚠ jest(prefer-lowercase-title): Enforce lowercase test names
|
||||
╭─[prefer_lowercase_title.tsx:1:4]
|
||||
1 │ it("Foo's bar", function () {})
|
||||
· ───────────
|
||||
╰────
|
||||
help: `"Foo's bar"`s should begin with lowercase
|
||||
|
||||
⚠ jest(prefer-lowercase-title): Enforce lowercase test names
|
||||
╭─[prefer_lowercase_title.tsx:1:4]
|
||||
1 │ it('Foo "bar"', function () {})
|
||||
· ───────────
|
||||
╰────
|
||||
help: `"Foo \"bar\""`s should begin with lowercase
|
||||
|
||||
⚠ jest(prefer-lowercase-title): Enforce lowercase test names
|
||||
╭─[prefer_lowercase_title.tsx:1:4]
|
||||
1 │ it('Foo \uD800', function () {})
|
||||
· ────────────
|
||||
╰────
|
||||
help: `"Foo \ud800"`s should begin with lowercase
|
||||
|
||||
⚠ jest(prefer-lowercase-title): Enforce lowercase test names
|
||||
╭─[prefer_lowercase_title.tsx:1:4]
|
||||
1 │ it(`Foo \uDC00`, function () {})
|
||||
· ────────────
|
||||
╰────
|
||||
help: `"Foo \udc00"`s should begin with lowercase
|
||||
|
||||
⚠ jest(prefer-lowercase-title): Enforce lowercase test names
|
||||
╭─[prefer_lowercase_title.tsx:1:4]
|
||||
1 │ it('\uD800 Foo', function () {})
|
||||
· ────────────
|
||||
╰────
|
||||
help: `"\ud800 Foo"`s should begin with lowercase
|
||||
|
||||
⚠ jest(prefer-lowercase-title): Enforce lowercase test names
|
||||
╭─[prefer_lowercase_title.tsx:1:5]
|
||||
1 │ xit('Foo', function () {})
|
||||
|
||||
@@ -694,3 +694,31 @@ source: crates/oxc_linter/src/tester.rs
|
||||
· ───
|
||||
╰────
|
||||
help: Replace your title with a string
|
||||
|
||||
⚠ jest(valid-title): Should not have leading or trailing spaces
|
||||
╭─[valid_title.tsx:1:6]
|
||||
1 │ test(' \uD800 ', () => {});
|
||||
· ──────────
|
||||
╰────
|
||||
help: Remove the leading or trailing spaces
|
||||
|
||||
⚠ jest(valid-title): Should not have leading or trailing spaces
|
||||
╭─[valid_title.tsx:1:10]
|
||||
1 │ describe(`\uDC00 `, () => {});
|
||||
· ─────────
|
||||
╰────
|
||||
help: Remove the leading or trailing spaces
|
||||
|
||||
⚠ jest(valid-title): Should not have duplicate prefix
|
||||
╭─[valid_title.tsx:1:6]
|
||||
1 │ test('test \uD800', () => {});
|
||||
· ─────────────
|
||||
╰────
|
||||
help: The function name already has the prefix, try to remove the duplicate prefix
|
||||
|
||||
⚠ jest(valid-title): Should not have duplicate prefix
|
||||
╭─[valid_title.tsx:1:4]
|
||||
1 │ it('it \uD800 foo', () => {});
|
||||
· ───────────────
|
||||
╰────
|
||||
help: The function name already has the prefix, try to remove the duplicate prefix
|
||||
@@ -61,3 +61,15 @@ source: crates/oxc_linter/src/tester.rs
|
||||
1 │ () => {fs.fooSync();}
|
||||
· ────────────
|
||||
╰────
|
||||
|
||||
⚠ node(no-sync): Unexpected sync method: '\ud800Sync'.
|
||||
╭─[no_sync.tsx:1:1]
|
||||
1 │ fs["\uD800Sync"]();
|
||||
· ──────────────────
|
||||
╰────
|
||||
|
||||
⚠ node(no-sync): Unexpected sync method: 'a\udc00bSync'.
|
||||
╭─[no_sync.tsx:1:1]
|
||||
1 │ fs["a\uDC00bSync"]();
|
||||
· ────────────────────
|
||||
╰────
|
||||
@@ -132,12 +132,8 @@ pub fn is_string_raw_member_expression(expr: &Expression, scoping: &Scoping) ->
|
||||
}
|
||||
|
||||
/// Checks if `haystack` starts with `prefix`, ignoring ASCII case.
|
||||
pub fn starts_with_ignore_case(haystack: &str, prefix: &str) -> bool {
|
||||
let len = prefix.len();
|
||||
if haystack.len() < len {
|
||||
return false;
|
||||
}
|
||||
haystack.as_bytes()[..len].eq_ignore_ascii_case(prefix.as_bytes())
|
||||
pub fn starts_with_ignore_case(haystack: &[u8], prefix: &str) -> bool {
|
||||
haystack.get(..prefix.len()).is_some_and(|start| start.eq_ignore_ascii_case(prefix.as_bytes()))
|
||||
}
|
||||
|
||||
/// Reads the content of a path and returns it.
|
||||
|
||||
@@ -25,6 +25,7 @@ oxc_estree = { workspace = true }
|
||||
|
||||
compact_str = { workspace = true }
|
||||
hashbrown = { workspace = true, default-features = false, features = ["inline-more"] }
|
||||
memchr = { workspace = true }
|
||||
|
||||
schemars = { workspace = true, optional = true }
|
||||
serde = { workspace = true, optional = true }
|
||||
|
||||
@@ -13,6 +13,11 @@ use oxc_estree::{ESTree, LoneSurrogatesString, Serializer as ESTreeSerializer};
|
||||
|
||||
use crate::{Ident, JSChar, JSStrBuilder, Str, wtf8::*};
|
||||
|
||||
#[path = "js_str/pattern.rs"]
|
||||
mod pattern;
|
||||
|
||||
pub use pattern::JSStrPattern;
|
||||
|
||||
/// An immutable JavaScript string borrowed from source text or arena memory.
|
||||
///
|
||||
/// JavaScript strings can contain lone surrogates, which Rust's [`str`] cannot represent.
|
||||
@@ -36,6 +41,7 @@ use crate::{Ident, JSChar, JSStrBuilder, Str, wtf8::*};
|
||||
/// * [`from_bytes_unchecked`] re-borrows them under the same invariants.
|
||||
///
|
||||
/// Consumers read the value with [`as_str`], [`chars`], or [`encode_utf16`].
|
||||
/// Search methods such as [`contains`] inspect it without UTF-8 conversion.
|
||||
///
|
||||
/// Equality uses the canonical bytes, and UTF-8 values hash like `str`. Ordering
|
||||
/// by these bytes would differ from JavaScript's UTF-16 ordering, so `JSStr`
|
||||
@@ -87,6 +93,7 @@ use crate::{Ident, JSChar, JSStrBuilder, Str, wtf8::*};
|
||||
/// [`as_str`]: Self::as_str
|
||||
/// [`chars`]: Self::chars
|
||||
/// [`encode_utf16`]: Self::encode_utf16
|
||||
/// [`contains`]: Self::contains
|
||||
#[derive(Clone, Copy)]
|
||||
#[repr(C)]
|
||||
pub struct JSStr<'a> {
|
||||
@@ -226,6 +233,68 @@ impl<'a> JSStr<'a> {
|
||||
EncodeUtf16 { chars: JSChars { remaining: self.as_bytes() }, pending: 0 }
|
||||
}
|
||||
|
||||
/// Return whether the value contains `pattern`.
|
||||
///
|
||||
/// See [`JSStrPattern`] for the accepted patterns and how they treat lone surrogates.
|
||||
/// Like `str::contains`, an empty text pattern always matches.
|
||||
///
|
||||
/// ```
|
||||
/// # use oxc_allocator::Allocator;
|
||||
/// # let allocator = Allocator::new();
|
||||
/// use oxc_str::JSStrBuilder;
|
||||
///
|
||||
/// let mut builder = JSStrBuilder::new_in(&&allocator);
|
||||
/// builder.push_str("a\n");
|
||||
/// builder.push_code_unit(0xD800);
|
||||
/// let value = builder.into_js_str();
|
||||
/// assert_eq!(value.as_str(), None);
|
||||
/// assert!(value.contains('\n'));
|
||||
/// assert!(!value.contains("ab"));
|
||||
/// ```
|
||||
#[inline]
|
||||
pub fn contains<P: JSStrPattern>(self, pattern: P) -> bool {
|
||||
pattern.find_in(self).is_some()
|
||||
}
|
||||
|
||||
/// Return whether the value starts with `pattern`.
|
||||
///
|
||||
/// See [`JSStrPattern`] for the accepted patterns and how they treat lone surrogates.
|
||||
#[inline]
|
||||
pub fn starts_with<P: JSStrPattern>(self, pattern: P) -> bool {
|
||||
pattern.is_prefix_of(self)
|
||||
}
|
||||
|
||||
/// Return whether the value ends with `pattern`.
|
||||
///
|
||||
/// See [`JSStrPattern`] for the accepted patterns and how they treat lone surrogates.
|
||||
#[inline]
|
||||
pub fn ends_with<P: JSStrPattern>(self, pattern: P) -> bool {
|
||||
pattern.is_suffix_of(self)
|
||||
}
|
||||
|
||||
/// Return the byte offset of the first match of `pattern`, or `None`.
|
||||
///
|
||||
/// Like `str::find`, the offset counts bytes of the encoded value,
|
||||
/// here its WTF-8 representation, not UTF-16 code units.
|
||||
/// It can be compared with [`len`] and with other offsets from the same value.
|
||||
/// See [`JSStrPattern`] for the accepted patterns.
|
||||
///
|
||||
/// [`len`]: Self::len
|
||||
#[inline]
|
||||
pub fn find<P: JSStrPattern>(self, pattern: P) -> Option<usize> {
|
||||
pattern.find_in(self)
|
||||
}
|
||||
|
||||
/// Return the byte offset of the last match of `pattern`, or `None`.
|
||||
///
|
||||
/// The offset has the same meaning as for [`find`].
|
||||
///
|
||||
/// [`find`]: Self::find
|
||||
#[inline]
|
||||
pub fn rfind<P: JSStrPattern>(self, pattern: P) -> Option<usize> {
|
||||
pattern.rfind_in(self)
|
||||
}
|
||||
|
||||
/// Return an adapter that implements [`Display`], like [`Path::display`].
|
||||
///
|
||||
/// UTF-8 text is written as is, and each lone surrogate is written as `\uXXXX` in lowercase hex.
|
||||
@@ -450,7 +519,7 @@ impl<'a> Dummy<'a> for JSStr<'a> {
|
||||
|
||||
/// Iterator over [`JSChar`]s comprising a [`JSStr`].
|
||||
//
|
||||
// `remaining` is always canonical WTF-8 starting at a code-point boundary.
|
||||
// `remaining` is always canonical WTF-8 that starts and ends at code-point boundaries.
|
||||
#[derive(Clone)]
|
||||
struct JSChars<'a> {
|
||||
remaining: &'a [u8],
|
||||
@@ -497,6 +566,19 @@ impl Iterator for JSChars<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
impl DoubleEndedIterator for JSChars<'_> {
|
||||
#[inline]
|
||||
fn next_back(&mut self) -> Option<JSChar> {
|
||||
// The last non-continuation byte starts the final code point,
|
||||
// and the bytes after it are exactly that code point's continuation bytes.
|
||||
let start = self.remaining.iter().rposition(|&byte| byte & !CONT_MASK != CONT_TAG)?;
|
||||
let (rest, last) = self.remaining.split_at(start);
|
||||
self.remaining = rest;
|
||||
// `last` is one complete code point, so it is valid WTF-8 at a code-point boundary.
|
||||
JSChars { remaining: last }.next()
|
||||
}
|
||||
}
|
||||
|
||||
impl FusedIterator for JSChars<'_> {}
|
||||
|
||||
/// Iterator over `u16` code units comprising a [`JSStr`].
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
use memchr::{memchr, memmem, memrchr};
|
||||
|
||||
use super::{JSChar, JSChars, JSStr};
|
||||
|
||||
mod private {
|
||||
pub trait Sealed {}
|
||||
}
|
||||
|
||||
/// A search pattern for [`JSStr`]: `&str`, `char`, or `FnMut(char) -> bool`.
|
||||
///
|
||||
/// Character patterns and predicates never match lone surrogates; predicates
|
||||
/// are not called for them. Even a negated predicate such as
|
||||
/// `|c: char| !c.is_whitespace()` does not match a lone surrogate.
|
||||
/// Use [`JSStr::chars`] to inspect every code point, including surrogates.
|
||||
pub trait JSStrPattern: private::Sealed {
|
||||
/// Return the WTF-8 byte offset of the first match in `haystack`, or `None`.
|
||||
fn find_in(self, haystack: JSStr<'_>) -> Option<usize>;
|
||||
|
||||
/// Return the WTF-8 byte offset of the last match in `haystack`, or `None`.
|
||||
fn rfind_in(self, haystack: JSStr<'_>) -> Option<usize>;
|
||||
|
||||
/// Return whether this pattern matches the start of `haystack`.
|
||||
#[expect(clippy::wrong_self_convention, reason = "mirrors std's `Pattern::is_prefix_of`")]
|
||||
fn is_prefix_of(self, haystack: JSStr<'_>) -> bool;
|
||||
|
||||
/// Return whether this pattern matches the end of `haystack`.
|
||||
#[expect(clippy::wrong_self_convention, reason = "mirrors std's `Pattern::is_suffix_of`")]
|
||||
fn is_suffix_of(self, haystack: JSStr<'_>) -> bool;
|
||||
}
|
||||
|
||||
impl private::Sealed for &str {}
|
||||
|
||||
// UTF-8 patterns cannot start or end inside a WTF-8 code point or match a lone
|
||||
// surrogate's encoding, so byte searches preserve code point boundaries.
|
||||
impl JSStrPattern for &str {
|
||||
#[inline]
|
||||
fn find_in(self, haystack: JSStr<'_>) -> Option<usize> {
|
||||
memmem::find(haystack.as_bytes(), self.as_bytes())
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn rfind_in(self, haystack: JSStr<'_>) -> Option<usize> {
|
||||
memmem::rfind(haystack.as_bytes(), self.as_bytes())
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn is_prefix_of(self, haystack: JSStr<'_>) -> bool {
|
||||
haystack.as_bytes().starts_with(self.as_bytes())
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn is_suffix_of(self, haystack: JSStr<'_>) -> bool {
|
||||
haystack.as_bytes().ends_with(self.as_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
impl private::Sealed for char {}
|
||||
|
||||
// ASCII bytes cannot occur inside a multibyte WTF-8 code point, and other characters
|
||||
// search as their UTF-8 encoding.
|
||||
impl JSStrPattern for char {
|
||||
#[inline]
|
||||
fn find_in(self, haystack: JSStr<'_>) -> Option<usize> {
|
||||
if self.is_ascii() {
|
||||
memchr(self as u8, haystack.as_bytes())
|
||||
} else {
|
||||
self.encode_utf8(&mut [0; 4]).find_in(haystack)
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn rfind_in(self, haystack: JSStr<'_>) -> Option<usize> {
|
||||
if self.is_ascii() {
|
||||
memrchr(self as u8, haystack.as_bytes())
|
||||
} else {
|
||||
self.encode_utf8(&mut [0; 4]).rfind_in(haystack)
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn is_prefix_of(self, haystack: JSStr<'_>) -> bool {
|
||||
self.encode_utf8(&mut [0; 4]).is_prefix_of(haystack)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn is_suffix_of(self, haystack: JSStr<'_>) -> bool {
|
||||
self.encode_utf8(&mut [0; 4]).is_suffix_of(haystack)
|
||||
}
|
||||
}
|
||||
|
||||
// `FnMut` is a fundamental trait, so this blanket impl cannot overlap with the
|
||||
// concrete impls above.
|
||||
impl<F: FnMut(char) -> bool> private::Sealed for F {}
|
||||
|
||||
impl<F: FnMut(char) -> bool> JSStrPattern for F {
|
||||
#[inline]
|
||||
fn find_in(mut self, haystack: JSStr<'_>) -> Option<usize> {
|
||||
if let Some(haystack) = haystack.as_str() {
|
||||
return haystack.find(self);
|
||||
}
|
||||
let mut chars = JSChars { remaining: haystack.as_bytes() };
|
||||
loop {
|
||||
let offset = haystack.len() - chars.remaining.len();
|
||||
if chars.next()?.to_char().is_some_and(&mut self) {
|
||||
return Some(offset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn rfind_in(mut self, haystack: JSStr<'_>) -> Option<usize> {
|
||||
if let Some(haystack) = haystack.as_str() {
|
||||
return haystack.rfind(self);
|
||||
}
|
||||
// Walk code points from the end, as `str::rfind` does, so the predicate sees
|
||||
// them in the same order.
|
||||
let mut chars = JSChars { remaining: haystack.as_bytes() };
|
||||
while let Some(c) = chars.next_back() {
|
||||
if c.to_char().is_some_and(&mut self) {
|
||||
return Some(chars.remaining.len());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn is_prefix_of(self, haystack: JSStr<'_>) -> bool {
|
||||
haystack.chars().next().and_then(JSChar::to_char).is_some_and(self)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn is_suffix_of(self, haystack: JSStr<'_>) -> bool {
|
||||
let mut chars = JSChars { remaining: haystack.as_bytes() };
|
||||
chars.next_back().and_then(JSChar::to_char).is_some_and(self)
|
||||
}
|
||||
}
|
||||
@@ -409,6 +409,184 @@ fn cloned_iterators_and_fused_end() {
|
||||
assert_eq!(units.next(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn searches_agree_with_str_for_utf8() {
|
||||
let haystacks = [
|
||||
"",
|
||||
"a",
|
||||
"ab",
|
||||
"abc",
|
||||
"aXbXc",
|
||||
"é漢😀x",
|
||||
" pad ",
|
||||
"${a}",
|
||||
"\n",
|
||||
"a\nb",
|
||||
"\0é\u{7f}😀\0",
|
||||
"😀😀",
|
||||
"javascript:x",
|
||||
];
|
||||
let texts =
|
||||
["", "a", "b", "ab", "bc", "X", "é", "漢", "😀", "😀x", "${", "}", "\n", " ", "z", "abcd"];
|
||||
let chars = ['a', 'X', 'é', '漢', '😀', '\n', ' ', 'z', '\0', '\u{7f}'];
|
||||
let predicates: [fn(char) -> bool; 3] =
|
||||
[char::is_whitespace, char::is_alphanumeric, |c| c > '\u{7f}'];
|
||||
for haystack in haystacks {
|
||||
let value = JSStr::from(haystack);
|
||||
for text in texts {
|
||||
assert_eq!(value.contains(text), haystack.contains(text), "{haystack:?} {text:?}");
|
||||
assert_eq!(
|
||||
value.starts_with(text),
|
||||
haystack.starts_with(text),
|
||||
"{haystack:?} {text:?}"
|
||||
);
|
||||
assert_eq!(value.ends_with(text), haystack.ends_with(text), "{haystack:?} {text:?}");
|
||||
assert_eq!(value.find(text), haystack.find(text), "{haystack:?} {text:?}");
|
||||
assert_eq!(value.rfind(text), haystack.rfind(text), "{haystack:?} {text:?}");
|
||||
}
|
||||
for c in chars {
|
||||
assert_eq!(value.contains(c), haystack.contains(c), "{haystack:?} {c:?}");
|
||||
assert_eq!(value.starts_with(c), haystack.starts_with(c), "{haystack:?} {c:?}");
|
||||
assert_eq!(value.ends_with(c), haystack.ends_with(c), "{haystack:?} {c:?}");
|
||||
assert_eq!(value.find(c), haystack.find(c), "{haystack:?} {c:?}");
|
||||
assert_eq!(value.rfind(c), haystack.rfind(c), "{haystack:?} {c:?}");
|
||||
}
|
||||
for predicate in predicates {
|
||||
assert_eq!(value.contains(predicate), haystack.contains(predicate), "{haystack:?}");
|
||||
assert_eq!(
|
||||
value.starts_with(predicate),
|
||||
haystack.starts_with(predicate),
|
||||
"{haystack:?}"
|
||||
);
|
||||
assert_eq!(value.ends_with(predicate), haystack.ends_with(predicate), "{haystack:?}");
|
||||
assert_eq!(value.find(predicate), haystack.find(predicate), "{haystack:?}");
|
||||
assert_eq!(value.rfind(predicate), haystack.rfind(predicate), "{haystack:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Byte offset of UTF-16 unit index `index` in a value built from `units`.
|
||||
fn byte_offset(units: &[u16], index: usize) -> usize {
|
||||
let allocator = Allocator::new();
|
||||
from_utf16_in(&units[..index], &allocator).len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_searches_around_lone_surrogates_match_utf16_model() {
|
||||
let allocator = Allocator::new();
|
||||
let haystacks: [&[u16]; 10] = [
|
||||
&[],
|
||||
&[0xD800],
|
||||
&[0xDC00],
|
||||
&[0x61, 0xD800, 0x62],
|
||||
&[0xD800, 0x61, 0xDC00],
|
||||
&[0xDC00, 0xD800],
|
||||
&[0x20, 0xD800, 0x20],
|
||||
&[0xD83D, 0xDE00, 0x61],
|
||||
&[0x61, 0xD83D, 0xDE00],
|
||||
&[0xD83D, 0xD83D, 0xDE00, 0xDE00],
|
||||
];
|
||||
let needles = ["", "a", "b", "ab", " ", "😀", "😀a", "a😀", "aa"];
|
||||
for units in haystacks {
|
||||
let value = from_utf16_in(units, &allocator);
|
||||
for needle in needles {
|
||||
let needle_units: Vec<u16> = needle.encode_utf16().collect();
|
||||
let positions: Vec<usize> =
|
||||
(0..=units.len()).filter(|&i| units[i..].starts_with(&needle_units)).collect();
|
||||
let expected_first = positions.first().map(|&i| byte_offset(units, i));
|
||||
let expected_last = positions.last().map(|&i| byte_offset(units, i));
|
||||
assert_eq!(value.contains(needle), !positions.is_empty(), "{units:x?} {needle:?}");
|
||||
assert_eq!(value.starts_with(needle), units.starts_with(&needle_units), "{units:x?}");
|
||||
assert_eq!(value.ends_with(needle), units.ends_with(&needle_units), "{units:x?}");
|
||||
assert_eq!(value.find(needle), expected_first, "{units:x?} {needle:?}");
|
||||
assert_eq!(value.rfind(needle), expected_last, "{units:x?} {needle:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn char_searches_never_match_lone_surrogates() {
|
||||
let allocator = Allocator::new();
|
||||
let haystacks: [&[u16]; 10] = [
|
||||
&[],
|
||||
&[0xD800],
|
||||
&[0xDC00, 0xD800],
|
||||
&[0x20, 0xD800, 0x20, 0x61, 0xDC00, 0x0A],
|
||||
&[0x61, 0xD800],
|
||||
&[0xD800, 0x61],
|
||||
&[0xD83D, 0xDE00, 0xD800],
|
||||
&[0xD800, 0xD83D, 0xDE00],
|
||||
&[0x00, 0xD800, 0x7F, 0xDC00, 0x00],
|
||||
// U+D7FF shares the 0xED lead byte with lone surrogates.
|
||||
&[0xD7FF, 0xD800, 0xE000, 0xDFFF, 0xD7FF],
|
||||
];
|
||||
let predicates: [fn(char) -> bool; 4] =
|
||||
[char::is_whitespace, char::is_alphabetic, |_| true, |c| c == '😀'];
|
||||
for units in haystacks {
|
||||
let value = from_utf16_in(units, &allocator);
|
||||
// Decode independently: `Err` marks a lone surrogate, which no `char` predicate can match.
|
||||
// Track WTF-8 byte offsets alongside.
|
||||
let decoded: Vec<(usize, Option<char>)> = {
|
||||
let mut offset = 0;
|
||||
char::decode_utf16(units.iter().copied())
|
||||
.map(|result| {
|
||||
let start = offset;
|
||||
let c = result.ok();
|
||||
offset += c.map_or(3, char::len_utf8);
|
||||
(start, c)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
for predicate in predicates {
|
||||
let matches: Vec<usize> = decoded
|
||||
.iter()
|
||||
.filter(|(_, c)| c.is_some_and(predicate))
|
||||
.map(|(offset, _)| *offset)
|
||||
.collect();
|
||||
assert_eq!(value.contains(predicate), !matches.is_empty(), "{units:x?}");
|
||||
assert_eq!(value.find(predicate), matches.first().copied(), "{units:x?}");
|
||||
assert_eq!(value.rfind(predicate), matches.last().copied(), "{units:x?}");
|
||||
assert_eq!(
|
||||
value.starts_with(predicate),
|
||||
decoded.first().is_some_and(|(_, c)| c.is_some_and(predicate)),
|
||||
"{units:x?}"
|
||||
);
|
||||
assert_eq!(
|
||||
value.ends_with(predicate),
|
||||
decoded.last().is_some_and(|(_, c)| c.is_some_and(predicate)),
|
||||
"{units:x?}"
|
||||
);
|
||||
}
|
||||
for c in ['a', ' ', '😀', '\n', '\0', '\u{7f}', '\u{d7ff}', '\u{e000}'] {
|
||||
let expected: Vec<usize> =
|
||||
decoded.iter().filter(|(_, d)| *d == Some(c)).map(|(o, _)| *o).collect();
|
||||
assert_eq!(value.contains(c), !expected.is_empty(), "{units:x?} {c:?}");
|
||||
assert_eq!(value.find(c), expected.first().copied(), "{units:x?} {c:?}");
|
||||
assert_eq!(value.rfind(c), expected.last().copied(), "{units:x?} {c:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// The documented predicate limitation: a value that is only a lone surrogate has no `char`,
|
||||
// so even a negated predicate sees nothing.
|
||||
let lone = from_utf16_in(&[0xD800], &allocator);
|
||||
assert!(!lone.contains(|c: char| !c.is_whitespace()));
|
||||
assert!(lone.chars().any(|c| c.to_char().is_none()));
|
||||
// Text patterns still see the surrounding text.
|
||||
let text = from_utf16_in(&[0x20, 0xD800, 0x20, 0x61, 0xDC00, 0x0A], &allocator);
|
||||
assert_eq!(text.find(' '), Some(0));
|
||||
assert_eq!(text.rfind(' '), Some(4));
|
||||
assert_eq!(text.find('a'), Some(5));
|
||||
assert_eq!(text.rfind('\n'), Some(9));
|
||||
assert!(text.starts_with(char::is_whitespace));
|
||||
assert!(text.ends_with(char::is_whitespace));
|
||||
assert!(!text.contains("a\n"));
|
||||
assert_eq!(text.find(""), Some(0));
|
||||
assert_eq!(text.rfind(""), Some(text.len()));
|
||||
assert_eq!(JSStr::empty().find(""), Some(0));
|
||||
assert_eq!(JSStr::empty().rfind(""), Some(0));
|
||||
assert!(!JSStr::empty().ends_with(char::is_whitespace));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "capacity overflow")]
|
||||
fn reject_capacity_overflow() {
|
||||
@@ -560,3 +738,54 @@ fn every_piece_sequence_and_push_method() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chars_decode_the_same_from_both_ends() {
|
||||
let allocator = Allocator::new();
|
||||
let mut builder = JSStrBuilder::new_in(&&allocator);
|
||||
builder.push_utf16(&[0x61, 0xE9, 0xD800, 0x6F22, 0xD83D, 0xDE00, 0xD7FF, 0xDC00, 0xE000]);
|
||||
let value = builder.into_js_str();
|
||||
let chars = || super::JSChars { remaining: value.as_bytes() };
|
||||
|
||||
let forward = chars().map(JSChar::to_u32).collect::<Vec<_>>();
|
||||
assert_eq!(forward, [0x61, 0xE9, 0xD800, 0x6F22, 0x1F600, 0xD7FF, 0xDC00, 0xE000]);
|
||||
|
||||
let mut backward = chars().rev().map(JSChar::to_u32).collect::<Vec<_>>();
|
||||
backward.reverse();
|
||||
assert_eq!(backward, forward);
|
||||
|
||||
// Alternate between the ends until they meet.
|
||||
let mut iter = chars();
|
||||
let (mut front, mut back) = (Vec::new(), Vec::new());
|
||||
while let Some(c) = iter.next() {
|
||||
front.push(c.to_u32());
|
||||
let Some(c) = iter.next_back() else { break };
|
||||
back.push(c.to_u32());
|
||||
}
|
||||
front.extend(back.into_iter().rev());
|
||||
assert_eq!(front, forward);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rfind_calls_the_predicate_from_the_end() {
|
||||
let allocator = Allocator::new();
|
||||
let mut builder = JSStrBuilder::new_in(&&allocator);
|
||||
builder.push_utf16(&[0xD800, 0x61, 0x62]);
|
||||
let value = builder.into_js_str();
|
||||
|
||||
let mut calls = Vec::new();
|
||||
assert_eq!(
|
||||
value.rfind(|c: char| {
|
||||
calls.push(c);
|
||||
false
|
||||
}),
|
||||
None
|
||||
);
|
||||
assert_eq!(calls, ['b', 'a']);
|
||||
|
||||
// Like `str::rfind`, a predicate that matches on its first call finds the last code point.
|
||||
let mut first = true;
|
||||
assert_eq!(value.rfind(|_: char| std::mem::take(&mut first)), Some(4));
|
||||
let mut first = true;
|
||||
assert_eq!("ab".rfind(|_: char| std::mem::take(&mut first)), Some(1));
|
||||
}
|
||||
@@ -40,7 +40,7 @@ pub use compact_str::{CompactStr, MAX_INLINE_LEN};
|
||||
pub use ident::{ArenaIdentHashMap, ArenaIdentHashSet, Ident, IdentHashMap, IdentHashSet};
|
||||
pub use ident_hasher::{IdentBuildHasher, IdentHasher};
|
||||
pub use js_char::JSChar;
|
||||
pub use js_str::JSStr;
|
||||
pub use js_str::{JSStr, JSStrPattern};
|
||||
pub use js_str_builder::JSStrBuilder;
|
||||
pub use str::{Str, Str as ArenaStr};
|
||||
|
||||
|
||||
Reference in new issue
Block a user