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:
Dunqing committed 2026-10-19 07:41:35 +00:00
1 parent c7d229b6a9
commit 782ab6e2cd
26 files changed
+809 -86

No files matched your search

Generated
+1
View File
@@ -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();
+12 -7
View File
@@ -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"]();
· ────────────────────
╰────
+2 -6
View File
@@ -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.
+1
View File
@@ -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 }
+83 -1
View File
@@ -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`].
+136
View File
@@ -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)
}
}
+229
View File
@@ -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));
}
+1 -1
View File
@@ -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};