Ensure the oxide parser has feature parity with the stable RegEx parser (#11389)

* WIP

* use `parse` instead of `defaultExtractor`

* skip `Vue` describe block

* add a few more dedicated arbitrary values/properties tests

* use parallel parsing

* splitup Vue tests

* add some Rust specific tests

* setup parse candidate strings test system

These tests will run against the `Regex` and `Rust` based parsers. We
have groups of classes of various shapes and forms + variants and
rendered in various template situation (plain, html, Vue, ...)

+ enable all skipped tests

* ensure we also validate the classes with variants

The classes with variants are built in the `templateTable` function, so
we get them out again by using the potional arguments of the `test.each`
cb function.

* cleanup test suite

* add "anti-test" tests

To make sure that we are _not_ parsing out certain values given a
certain input.

* Add ParseAction enum

* Restart parsing following an arbitrary parse failure

* Split variants off before validating the uility part

* Collapse candidate from the end when validation fails

* Support `<`, and `>` in variant position

* fix error

* format parser.rs

* Refactor

* Update editorconfig

* wip

* wip

* Refactor

* Refactor

* Simplify

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* run `cargo clippy --fix`

* run `cargo fmt`

* implement `cargo clippy` suggestions

These were not applied using `cargo clippy --fix`

* only allow `.` in the candidate part when surrounded by 0-9

This is only in the candidate part, not the arbitrary part.

* % characters can only appear at the end after digits

* > and < should only be part of variants (start OR end)

It can technically be inside the candidate when we have stacked
variants:
```
dark:<sm:underline
dark:md>:underline
```

* handle parsing utilities within quotes, parans or brackets

* mark `pt-1.5` as an expected value sliced out from `["pt-1.5"]`

* Add cursor abstraction

* wip

* disable the oxideParser if using a custom `prefix` or `separator`

* update tests

* Use cursor abstraction

* Refactor more code toward use of global cursor

* wip

* simplify

* Simplify

* Simplify

* Simplify

* Cleanup

* wip

* Simplify

* wip

* Simplify

* Handle candidates ending with % sign

* Tweak code a bit

* fmt

* Simplify

* Add cursor details to trace

* cargo fmt

* use preferred `zoom-0.5` name instead of `zoom-.5`

* drop over-extracted utilities in oxide parser

The RegEx parser does extract `underline` from

```html
<div class="peer-aria-[labelledby='a_b']:underline"></div>
```
... but that's not needed and is not happening in the oxide parser

This means that we have to make the output check a little bit different
but they are explicit based on the feature flag.

* allow extracting variants+utilities inside `{}` for the oxide parser

* characters in candidates such as `group-${id}` should not be allowed

* do not extract any of the following candidate `w-[foo-bar]w-[bar-baz]`

* ensure we can consume the full candidate and discard it

* Add fast skipping of whitespace

* Use fast skipping whenever possible

* Add fast skipping to benchmark

* Hand-tune to generate more optimized assembly

* Move code around a bit

This makes sure all the fancy SIMD stuff is as early as possible. This results in an extremely minor perf increase.

* Undo tweak

no meaningful perf difference in real world scenarios

* Disable fast skipping for now

It needs to be done in a different spot so it doesn’t affect how things are returned

* Change test names

* Fix normalize config error

* cleanup a bit

* Cleanup

* Extract validation result enum

* Cleanup comments

* Simplify

* Fix formatting

* Run clippy

* wip

* add `md>` under the special characters test set

---------

Co-authored-by: Adam Wathan <4323180+adamwathan@users.noreply.github.com>
Co-authored-by: Jordan Pittman <jordan@cryptica.me>
This commit is contained in:
Robin Malfait 2023-06-07 17:44:35 +02:00 • committed by GitHub
parent e572dc645a
commit 55daf8e0f5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 1384 additions and 222 deletions

View file

@ -7,3 +7,11 @@ end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.rs]
indent_style = space
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

View file

@ -31,6 +31,18 @@ pub fn criterion_benchmark(c: &mut Criterion) {
c.bench_function("parse_candidate_strings (real world)", |b| {
b.iter(|| parse(include_bytes!("./fixtures/template-499.html")))
});
let mut group = c.benchmark_group("sample-size-example");
group.sample_size(10);
group.bench_function("parse_candidate_strings (fast space skipping)", |b| {
let count = 10_000;
let crazy1 = format!("{}underline", " ".repeat(count));
let crazy2 = crazy1.repeat(count);
let crazy3 = crazy2.as_bytes();
b.iter(|| parse(black_box(crazy3)))
});
}
criterion_group!(benches, criterion_benchmark);

View file

@ -0,0 +1,159 @@
use std::{ascii::escape_default, fmt::Display};
#[derive(Debug, Clone)]
pub struct Cursor<'a> {
// The input we're scanning
pub input: &'a [u8],
// The location of the cursor in the input
pub pos: usize,
/// Is the cursor at the start of the input
pub at_start: bool,
/// Is the cursor at the end of the input
pub at_end: bool,
/// The previously consumed character
/// If `at_start` is true, this will be NUL
pub prev: u8,
/// The current character
pub curr: u8,
/// The upcoming character (if any)
/// If `at_end` is true, this will be NUL
pub next: u8,
}
impl<'a> Cursor<'a> {
pub fn new(input: &'a [u8]) -> Self {
let mut cursor = Self {
input,
pos: 0,
at_start: true,
at_end: false,
prev: 0x00,
curr: 0x00,
next: 0x00,
};
cursor.move_to(0);
cursor
}
pub fn rewind_by(&mut self, amount: usize) {
self.move_to(self.pos.saturating_sub(amount));
}
pub fn advance_by(&mut self, amount: usize) {
self.move_to(self.pos.saturating_add(amount));
}
pub fn move_to(&mut self, pos: usize) {
let len = self.input.len();
let pos = pos.clamp(0, len);
self.pos = pos;
self.at_start = pos == 0;
self.at_end = pos + 1 >= len;
self.prev = if pos > 0 { self.input[pos - 1] } else { 0x00 };
self.curr = if pos < len { self.input[pos] } else { 0x00 };
self.next = if pos + 1 < len {
self.input[pos + 1]
} else {
0x00
};
}
}
impl<'a> Display for Cursor<'a> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let len = self.input.len().to_string();
let pos = format!("{: >len_count$}", self.pos, len_count = len.len());
write!(f, "{}/{} ", pos, len)?;
if self.at_start {
write!(f, "S ")?;
} else if self.at_end {
write!(f, "E ")?;
} else {
write!(f, "M ")?;
}
fn to_str(c: u8) -> String {
if c == 0x00 {
"NUL".into()
} else {
format!("{:?}", escape_default(c).to_string())
}
}
write!(
f,
"[{} {} {}]",
to_str(self.prev),
to_str(self.curr),
to_str(self.next)
)
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_cursor() {
let mut cursor = Cursor::new(b"hello world");
assert_eq!(cursor.pos, 0);
assert!(cursor.at_start);
assert!(!cursor.at_end);
assert_eq!(cursor.prev, 0x00);
assert_eq!(cursor.curr, b'h');
assert_eq!(cursor.next, b'e');
cursor.advance_by(1);
assert_eq!(cursor.pos, 1);
assert!(!cursor.at_start);
assert!(!cursor.at_end);
assert_eq!(cursor.prev, b'h');
assert_eq!(cursor.curr, b'e');
assert_eq!(cursor.next, b'l');
// Advancing too far should stop at the end
cursor.advance_by(10);
assert_eq!(cursor.pos, 11);
assert!(!cursor.at_start);
assert!(cursor.at_end);
assert_eq!(cursor.prev, b'd');
assert_eq!(cursor.curr, 0x00);
assert_eq!(cursor.next, 0x00);
// Can't advance past the end
cursor.advance_by(1);
assert_eq!(cursor.pos, 11);
assert!(!cursor.at_start);
assert!(cursor.at_end);
assert_eq!(cursor.prev, b'd');
assert_eq!(cursor.curr, 0x00);
assert_eq!(cursor.next, 0x00);
cursor.rewind_by(1);
assert_eq!(cursor.pos, 10);
assert!(!cursor.at_start);
assert!(cursor.at_end);
assert_eq!(cursor.prev, b'l');
assert_eq!(cursor.curr, b'd');
assert_eq!(cursor.next, 0x00);
cursor.rewind_by(10);
assert_eq!(cursor.pos, 0);
assert!(cursor.at_start);
assert!(!cursor.at_end);
assert_eq!(cursor.prev, 0x00);
assert_eq!(cursor.curr, b'h');
assert_eq!(cursor.next, b'e');
}
}

View file

@ -0,0 +1,89 @@
use crate::cursor::Cursor;
const STRIDE: usize = 16;
type Mask = [bool; STRIDE];
#[inline(always)]
pub fn fast_skip(cursor: &Cursor) -> Option<usize> {
// If we don't have enough bytes left to check then bail early
if cursor.pos + STRIDE >= cursor.input.len() {
return None;
}
if !cursor.curr.is_ascii_whitespace() {
return None;
}
let mut offset = 1;
// SAFETY: We've already checked (indirectly) that this index is valid
let remaining = unsafe { cursor.input.get_unchecked(cursor.pos..) };
// NOTE: This loop uses primitives designed to be auto-vectorized
// Do not change this loop without benchmarking the results
// And checking the generated assembly using godbolt.org
for (i, chunk) in remaining.chunks_exact(STRIDE).enumerate() {
let value = load(chunk);
let is_whitespace = is_ascii_whitespace(value);
let is_all_whitespace = all_true(is_whitespace);
if is_all_whitespace {
offset = (i + 1) * STRIDE;
} else {
break;
}
}
Some(cursor.pos + offset)
}
#[inline(always)]
fn load(input: &[u8]) -> [u8; STRIDE] {
let mut value = [0u8; STRIDE];
value.copy_from_slice(input);
value
}
#[inline(always)]
fn eq(input: [u8; STRIDE], val: u8) -> Mask {
let mut res = [false; STRIDE];
for n in 0..STRIDE {
res[n] = input[n] == val
}
res
}
#[inline(always)]
fn or(a: [bool; STRIDE], b: [bool; STRIDE]) -> [bool; STRIDE] {
let mut res = [false; STRIDE];
for n in 0..STRIDE {
res[n] = a[n] | b[n];
}
res
}
#[inline(always)]
fn all_true(a: [bool; STRIDE]) -> bool {
let mut res = true;
for item in a.iter().take(STRIDE) {
res &= item;
}
res
}
#[inline(always)]
fn is_ascii_whitespace(value: [u8; STRIDE]) -> [bool; STRIDE] {
let whitespace_1 = eq(value, b'\t');
let whitespace_2 = eq(value, b'\n');
let whitespace_3 = eq(value, b'\x0C');
let whitespace_4 = eq(value, b'\r');
let whitespace_5 = eq(value, b' ');
or(
or(
or(or(whitespace_1, whitespace_2), whitespace_3),
whitespace_4,
),
whitespace_5,
)
}

View file

@ -9,6 +9,8 @@ use tracing::event;
use walkdir::WalkDir;
pub mod candidate;
pub mod cursor;
pub mod fast_skip;
pub mod glob;
pub mod location;
pub mod modifier;

File diff suppressed because it is too large Load diff

View file

@ -9,7 +9,7 @@ let defaults = {
logicalSiblingUtilities: false,
}
let featureFlags = {
export let featureFlags = {
future: [
'hoverOnlyWhenSupported',
'respectDefaultRingColorOpacity',

View file

@ -1,4 +1,4 @@
import { flagEnabled } from '../featureFlags'
import { flagEnabled, featureFlags } from '../featureFlags'
import log, { dim } from './log'
export function normalizeConfig(config) {
@ -294,6 +294,21 @@ export function normalizeConfig(config) {
})(),
}
// Force disable the `oxideParser` feature flag when using unsupported features.
// TODO: Remove once we have prefix or separator support in the oxide parser.
if (config.prefix !== '' || config.separator !== ':') {
if (config.experimental === 'all') {
config.experimental = {}
for (let key in featureFlags.experimental) {
config.experimental[key] = true
}
} else {
config.experimental = config.experimental ?? {}
}
config.experimental.oxideParser = false
}
// Validate globs to prevent bogus globs.
// E.g.: `./src/*.{html}` is invalid, the `{html}` should just be `html`
if (config.content.files !== 'auto') {

View file

@ -187,7 +187,7 @@ test('with dots in the name', () => {
content: [
{
raw: html`
<div class="animate-zoom-.5"></div>
<div class="animate-zoom-0.5"></div>
<div class="animate-zoom-1.5"></div>
`,
},
@ -195,11 +195,11 @@ test('with dots in the name', () => {
theme: {
extend: {
keyframes: {
'zoom-.5': { to: { transform: 'scale(0.5)' } },
'zoom-0.5': { to: { transform: 'scale(0.5)' } },
'zoom-1.5': { to: { transform: 'scale(1.5)' } },
},
animation: {
'zoom-.5': 'zoom-.5 2s',
'zoom-0.5': 'zoom-0.5 2s',
'zoom-1.5': 'zoom-1.5 2s',
},
},
@ -208,19 +208,22 @@ test('with dots in the name', () => {
return run('@tailwind utilities', config).then((result) => {
expect(result.css).toMatchFormattedCss(css`
@keyframes zoom-\.5 {
@keyframes zoom-0\.5 {
to {
transform: scale(0.5);
}
}
.animate-zoom-\.5 {
animation: 2s zoom-\.5;
.animate-zoom-0\.5 {
animation: 2s zoom-0\.5;
}
@keyframes zoom-1\.5 {
to {
transform: scale(1.5);
}
}
.animate-zoom-1\.5 {
animation: 2s zoom-1\.5;
}

View file

@ -1,4 +1,5 @@
import { run, html, css, defaults } from './util/run'
import { flagEnabled } from '../src/featureFlags'
test('basic arbitrary variants', () => {
let config = {
@ -612,24 +613,44 @@ it('should support aria variants', () => {
`
return run(input, config).then((result) => {
expect(result.css).toMatchFormattedCss(css`
.underline,
.aria-checked\:underline[aria-checked='true'],
.aria-\[labelledby\=\'a_b\'\]\:underline[aria-labelledby='a b'],
.aria-\[sort\=ascending\]\:underline[aria-sort='ascending'],
.group\/foo[aria-checked='true'] .group-aria-checked\/foo\:underline,
.group[aria-checked='true'] .group-aria-checked\:underline,
.group[aria-labelledby='a b'] .group-aria-\[labelledby\=\'a_b\'\]\:underline,
.group\/foo[aria-sort='ascending'] .group-aria-\[sort\=ascending\]\/foo\:underline,
.group[aria-sort='ascending'] .group-aria-\[sort\=ascending\]\:underline,
.peer\/foo[aria-checked='true'] ~ .peer-aria-checked\/foo\:underline,
.peer[aria-checked='true'] ~ .peer-aria-checked\:underline,
.peer[aria-labelledby='a b'] ~ .peer-aria-\[labelledby\=\'a_b\'\]\:underline,
.peer\/foo[aria-sort='ascending'] ~ .peer-aria-\[sort\=ascending\]\/foo\:underline,
.peer[aria-sort='ascending'] ~ .peer-aria-\[sort\=ascending\]\:underline {
text-decoration-line: underline;
}
`)
expect(result.css).toMatchFormattedCss(
flagEnabled(config, 'oxideParser')
? css`
.aria-checked\:underline[aria-checked='true'],
.aria-\[labelledby\=\'a_b\'\]\:underline[aria-labelledby='a b'],
.aria-\[sort\=ascending\]\:underline[aria-sort='ascending'],
.group\/foo[aria-checked='true'] .group-aria-checked\/foo\:underline,
.group[aria-checked='true'] .group-aria-checked\:underline,
.group[aria-labelledby='a b'] .group-aria-\[labelledby\=\'a_b\'\]\:underline,
.group\/foo[aria-sort='ascending'] .group-aria-\[sort\=ascending\]\/foo\:underline,
.group[aria-sort='ascending'] .group-aria-\[sort\=ascending\]\:underline,
.peer\/foo[aria-checked='true'] ~ .peer-aria-checked\/foo\:underline,
.peer[aria-checked='true'] ~ .peer-aria-checked\:underline,
.peer[aria-labelledby='a b'] ~ .peer-aria-\[labelledby\=\'a_b\'\]\:underline,
.peer\/foo[aria-sort='ascending'] ~ .peer-aria-\[sort\=ascending\]\/foo\:underline,
.peer[aria-sort='ascending'] ~ .peer-aria-\[sort\=ascending\]\:underline {
text-decoration-line: underline;
}
`
: css`
.underline,
.aria-checked\:underline[aria-checked='true'],
.aria-\[labelledby\=\'a_b\'\]\:underline[aria-labelledby='a b'],
.aria-\[sort\=ascending\]\:underline[aria-sort='ascending'],
.group\/foo[aria-checked='true'] .group-aria-checked\/foo\:underline,
.group[aria-checked='true'] .group-aria-checked\:underline,
.group[aria-labelledby='a b'] .group-aria-\[labelledby\=\'a_b\'\]\:underline,
.group\/foo[aria-sort='ascending'] .group-aria-\[sort\=ascending\]\/foo\:underline,
.group[aria-sort='ascending'] .group-aria-\[sort\=ascending\]\:underline,
.peer\/foo[aria-checked='true'] ~ .peer-aria-checked\/foo\:underline,
.peer[aria-checked='true'] ~ .peer-aria-checked\:underline,
.peer[aria-labelledby='a b'] ~ .peer-aria-\[labelledby\=\'a_b\'\]\:underline,
.peer\/foo[aria-sort='ascending'] ~ .peer-aria-\[sort\=ascending\]\/foo\:underline,
.peer[aria-sort='ascending'] ~ .peer-aria-\[sort\=ascending\]\:underline {
text-decoration-line: underline;
}
`
)
})
})
@ -669,24 +690,44 @@ it('should support data variants', () => {
`
return run(input, config).then((result) => {
expect(result.css).toMatchFormattedCss(css`
.underline,
.data-checked\:underline[data-ui~='checked'],
.data-\[foo\=\'bar_baz\'\]\:underline[data-foo='bar baz'],
.data-\[position\=top\]\:underline[data-position='top'],
.group\/foo[data-ui~='checked'] .group-data-checked\/foo\:underline,
.group[data-ui~='checked'] .group-data-checked\:underline,
.group[data-foo='bar baz'] .group-data-\[foo\=\'bar_baz\'\]\:underline,
.group\/foo[data-position='top'] .group-data-\[position\=top\]\/foo\:underline,
.group[data-position='top'] .group-data-\[position\=top\]\:underline,
.peer\/foo[data-ui~='checked'] ~ .peer-data-checked\/foo\:underline,
.peer[data-ui~='checked'] ~ .peer-data-checked\:underline,
.peer[data-foo='bar baz'] ~ .peer-data-\[foo\=\'bar_baz\'\]\:underline,
.peer\/foo[data-position='top'] ~ .peer-data-\[position\=top\]\/foo\:underline,
.peer[data-position='top'] ~ .peer-data-\[position\=top\]\:underline {
text-decoration-line: underline;
}
`)
expect(result.css).toMatchFormattedCss(
flagEnabled(config, 'oxideParser')
? css`
.data-checked\:underline[data-ui~='checked'],
.data-\[foo\=\'bar_baz\'\]\:underline[data-foo='bar baz'],
.data-\[position\=top\]\:underline[data-position='top'],
.group\/foo[data-ui~='checked'] .group-data-checked\/foo\:underline,
.group[data-ui~='checked'] .group-data-checked\:underline,
.group[data-foo='bar baz'] .group-data-\[foo\=\'bar_baz\'\]\:underline,
.group\/foo[data-position='top'] .group-data-\[position\=top\]\/foo\:underline,
.group[data-position='top'] .group-data-\[position\=top\]\:underline,
.peer\/foo[data-ui~='checked'] ~ .peer-data-checked\/foo\:underline,
.peer[data-ui~='checked'] ~ .peer-data-checked\:underline,
.peer[data-foo='bar baz'] ~ .peer-data-\[foo\=\'bar_baz\'\]\:underline,
.peer\/foo[data-position='top'] ~ .peer-data-\[position\=top\]\/foo\:underline,
.peer[data-position='top'] ~ .peer-data-\[position\=top\]\:underline {
text-decoration-line: underline;
}
`
: css`
.underline,
.data-checked\:underline[data-ui~='checked'],
.data-\[foo\=\'bar_baz\'\]\:underline[data-foo='bar baz'],
.data-\[position\=top\]\:underline[data-position='top'],
.group\/foo[data-ui~='checked'] .group-data-checked\/foo\:underline,
.group[data-ui~='checked'] .group-data-checked\:underline,
.group[data-foo='bar baz'] .group-data-\[foo\=\'bar_baz\'\]\:underline,
.group\/foo[data-position='top'] .group-data-\[position\=top\]\/foo\:underline,
.group[data-position='top'] .group-data-\[position\=top\]\:underline,
.peer\/foo[data-ui~='checked'] ~ .peer-data-checked\/foo\:underline,
.peer[data-ui~='checked'] ~ .peer-data-checked\:underline,
.peer[data-foo='bar baz'] ~ .peer-data-\[foo\=\'bar_baz\'\]\:underline,
.peer\/foo[data-position='top'] ~ .peer-data-\[position\=top\]\/foo\:underline,
.peer[data-position='top'] ~ .peer-data-\[position\=top\]\:underline {
text-decoration-line: underline;
}
`
)
})
})

View file

@ -1,6 +1,7 @@
import fs from 'fs'
import path from 'path'
import { run, html, css, defaults } from './util/run'
import { flagEnabled } from '../src/featureFlags'
test('basic usage', () => {
let config = {
@ -877,11 +878,15 @@ test('should not crash when group names contain special characters', () => {
`
return run(input, config).then((result) => {
expect(result.css).toMatchFormattedCss(css`
.group\/\$\{id\}:hover .group-hover\/\$\{id\}\:visible {
visibility: visible;
}
`)
if (flagEnabled(config, 'oxideParser')) {
expect(result.css).toMatchFormattedCss(css``)
} else {
expect(result.css).toMatchFormattedCss(css`
.group\/\$\{id\}:hover .group-hover\/\$\{id\}\:visible {
visibility: visible;
}
`)
}
})
})
@ -927,12 +932,20 @@ test('detects quoted arbitrary values containing a slash', async () => {
let result = await run(input, config)
expect(result.css).toMatchFormattedCss(css`
.hidden,
.group[href^='/'] .group-\[\[href\^\=\'\/\'\]\]\:hidden {
display: none;
}
`)
expect(result.css).toMatchFormattedCss(
flagEnabled(config, 'oxideParser')
? css`
.group[href^='/'] .group-\[\[href\^\=\'\/\'\]\]\:hidden {
display: none;
}
`
: css`
.hidden,
.group[href^='/'] .group-\[\[href\^\=\'\/\'\]\]\:hidden {
display: none;
}
`
)
})
test('handled quoted arbitrary values containing escaped spaces', async () => {
@ -950,10 +963,18 @@ test('handled quoted arbitrary values containing escaped spaces', async () => {
let result = await run(input, config)
expect(result.css).toMatchFormattedCss(css`
.hidden,
.group[href^=' bar'] .group-\[\[href\^\=\'_bar\'\]\]\:hidden {
display: none;
}
`)
expect(result.css).toMatchFormattedCss(
flagEnabled(config, 'oxideParser')
? css`
.group[href^=' bar'] .group-\[\[href\^\=\'_bar\'\]\]\:hidden {
display: none;
}
`
: css`
.hidden,
.group[href^=' bar'] .group-\[\[href\^\=\'_bar\'\]\]\:hidden {
display: none;
}
`
)
})

View file

@ -0,0 +1,423 @@
import { parseCandidateStrings, IO, Parsing } from '@tailwindcss/oxide'
import { defaultExtractor as createDefaultExtractor } from '../src/lib/defaultExtractor'
let html = String.raw
let defaultExtractor = createDefaultExtractor({ tailwindConfig: { separator: ':' } })
function regexParser(str) {
return defaultExtractor(str)
}
function oxideParser(str) {
return parseCandidateStrings(
[{ content: str, extension: 'html' }],
IO.Sequential | Parsing.Parallel
)
}
function templateTable(classes) {
classes = classes.concat(
// Variants
classes.flatMap((c) => {
return [
// Simple variant
`hover:${c}`,
// Combined variant
`focus:hover:${c}`,
// Variant with dashes
`group-hover:group-focus:${c}`,
// With special characters
`<sm:${c}`,
`md>:${c}`,
// With arbitrary values
`min-[300px]:${c}`,
// With arbitrary values
`[@media(hover:hover)]:${c}`,
// With parent selector
`[.foo_&]:${c}`,
]
})
)
let classString = classes.join(' ')
let singleQuoteArraySyntax = `'${classes.join("', '")}'`
return [
['Plain', classString],
['HTML', html`<div class="${classString}"></div>`],
['JavaScript variable', `let foo = "${classString}"`],
['JavaScript expression', `document.body.classList.add(['${classString}'].join(' '))`],
['JavaScript object key', `let foo = {'${classString}': true}`],
['JSX basic', html`<div className="${classString}"></div>`],
['JSX with JavaScript expression', html`<div className={"${classString}"}></div>`],
['Vue basic', html`<div :class="${classString}"></div>`],
['Vue array (single quote)', html`<div :class="[${singleQuoteArraySyntax}]"></div>`],
['Vue object (single quote)', html`<div :class="{'${classString}': true}"></div>`],
['Markdown code fences', `<!-- This should work \`${classString}\` -->`],
].map(([name, template]) => [name, template, classes])
}
describe.each([
['Regex', regexParser],
['Oxide', oxideParser],
])('%s parser', (_, parse) => {
describe('basic utility classes', () => {
let classes = [
// One word classes
'underline',
// With dashes
'text-center',
'pointer-events-none',
// With numbers
'px-4',
// With special characters
'px-1.5',
'translate-x-1/2',
'from-50%',
// With negative signs
'-translate-x-full',
// With halves and negative signs
'-translate-x-1/2',
]
test.each(templateTable(classes))('%# — %s', (_, template, classes) => {
let extractions = parse(template)
for (let c of classes) {
expect(extractions).toContain(c)
}
})
})
describe('utility classes with arbitrary values', () => {
let classes = [
// With simple number
'px-[0]',
'px-[0.5]',
// With number and unit
'px-[123px]',
'px-[123.45px]',
// With special symbols
'px-[#bada55]',
// ^
'px-[color:#bada55]',
// ^^
'content-[>]',
// ^
'content-[<]',
// ^
// With functions and math expressions
'px-[calc(100%-1rem)]',
'px-[theme(spacing.1)]',
'px-[theme(spacing[1.5])]',
// With spaces (replaced by `_`)
'bg-[rgb(255_0_0)]',
// Examples with combinations
'w-[calc(100%_-_theme("spacing[1.5]))"]',
'fill-[oklab(59.69%_0.1007_0.1191_/_0.5)]/[33.7%]',
'fill-[color:oklab(59.69%_0.1007_0.1191_/_0.5)]/[33.7%]',
'shadow-[inset_0_-3em_3em_rgba(0,_0,_0,_0.1),_0_0_0_2px_rgb(255,_255,_255),_0.3em_0.3em_1em_rgba(0,_0,_0,_0.3)]',
]
test.each(templateTable(classes))('%# — %s', (_, template, classes) => {
let extractions = parse(template)
for (let c of classes) {
expect(extractions).toContain(c)
}
})
})
describe('utility classes with modifiers', () => {
let classes = [
// With simple modifiers
'bg-red-500/50',
// With arbitrary modifiers
'bg-red-500/[0.5]',
'bg-red-500/[50%]',
'bg-red-500/[var(--opacity)]',
// With spces (replaced by `_`)
'bg-red-500/[var(--opacity,_50%)]',
]
test.each(templateTable(classes))('%# — %s', (_, template, classes) => {
let extractions = parse(template)
for (let c of classes) {
expect(extractions).toContain(c)
}
})
})
describe('utility classes with important modifier', () => {
let classes = ['!bg-red-500', '!bg-[#bada55]', '![display:flex]', '!-translate-x-1/2']
test.each(templateTable(classes))('%# — %s', (_, template, classes) => {
let extractions = parse(template)
for (let c of classes) {
expect(extractions).toContain(c)
}
})
})
describe('arbitrary properties', () => {
let classes = [
// With simple arbitrary property
'[display:flex]',
]
test.each(templateTable(classes))('%# — %s', (_, template, classes) => {
let extractions = parse(template)
for (let c of classes) {
expect(extractions).toContain(c)
}
})
})
describe('arbitrary values with quotes', () => {
test('arbitrary values with single quotes', async () => {
let extractions = parse(`
<div class="content-['hello_world'] hover:content-['hello_world_2'] hover:focus:content-['hello_world_3']"></div>
`)
expect(extractions).toContain(`content-['hello_world']`)
expect(extractions).toContain(`hover:content-['hello_world_2']`)
expect(extractions).toContain(`hover:focus:content-['hello_world_3']`)
})
test('arbitrary values with double quotes', async () => {
let extractions = parse(`
<div class='content-["hello_world"] hover:content-["hello_world_2"] hover:focus:content-["hello_world_3"]'></div>
`)
expect(extractions).toContain(`content-["hello_world"]`)
expect(extractions).toContain(`hover:content-["hello_world_2"]`)
expect(extractions).toContain(`hover:focus:content-["hello_world_3"]`)
})
test('arbitrary values with some single quoted values', async () => {
let extractions = parse(`
<div class="font-['Open_Sans',_system-ui,_sans-serif] hover:font-['Proxima_Nova',_system-ui,_sans-serif] hover:focus:font-['Inter_var',_system-ui,_sans-serif]"></div>
`)
expect(extractions).toContain(`font-['Open_Sans',_system-ui,_sans-serif]`)
expect(extractions).toContain(`hover:font-['Proxima_Nova',_system-ui,_sans-serif]`)
expect(extractions).toContain(`hover:focus:font-['Inter_var',_system-ui,_sans-serif]`)
})
test('arbitrary values with some double quoted values', async () => {
let extractions = parse(`
<div class='font-["Open_Sans",_system-ui,_sans-serif] hover:font-["Proxima_Nova",_system-ui,_sans-serif] hover:focus:font-["Inter_var",_system-ui,_sans-serif]'></div>
`)
expect(extractions).toContain(`font-["Open_Sans",_system-ui,_sans-serif]`)
expect(extractions).toContain(`hover:font-["Proxima_Nova",_system-ui,_sans-serif]`)
expect(extractions).toContain(`hover:focus:font-["Inter_var",_system-ui,_sans-serif]`)
})
test('arbitrary values with escaped underscores', async () => {
let extractions = parse(`
<div class="content-['hello\\_world'] hover:content-['hello\\_world\\_2'] hover:focus:content-['hello\\_world\\_3']"></div>
`)
expect(extractions).toContain(`content-['hello\\_world']`)
expect(extractions).toContain(`hover:content-['hello\\_world\\_2']`)
expect(extractions).toContain(`hover:focus:content-['hello\\_world\\_3']`)
})
test('arbitrary values with angle brackets in single quotes', async () => {
let extractions = parse(`
<div class="content-['>'] hover:content-['<'] hover:focus:content-['>']"></div>
`)
expect(extractions).toContain(`content-['>']`)
expect(extractions).toContain(`hover:content-['<']`)
expect(extractions).toContain(`hover:focus:content-['>']`)
})
test('arbitrary values with angle brackets in double quotes', async () => {
let extractions = parse(`
<div class="content-[">"] hover:content-["<"] hover:focus:content-[">"]"></div>
`)
expect(extractions).toContain(`content-[">"]`)
expect(extractions).toContain(`hover:content-["<"]`)
expect(extractions).toContain(`hover:focus:content-[">"]`)
})
test('arbitrary values with theme lookup using quotes', () => {
let extractions = parse(`
<p class="[--y:theme('colors.blue.500')] [color:var(--y)]"></p>
`)
expect(extractions).toContain(`[--y:theme('colors.blue.500')]`)
expect(extractions).toContain(`[color:var(--y)]`)
})
})
test('special characters', async () => {
let extractions = parse(`
<div class="<sm:underline md>:font-bold"></div>
`)
expect(extractions).toContain(`<sm:underline`)
expect(extractions).toContain(`md>:font-bold`)
})
test('with single quotes array within template literal', async () => {
let extractions = parse(`<div class=\`\${['pr-1.5']}\`></div>`)
expect(extractions).toContain('pr-1.5')
})
test('with double quotes array within template literal', async () => {
let extractions = parse(`<div class=\`\${["pr-1.5"]}\`></div>`)
expect(extractions).toContain('pr-1.5')
})
test('with single quotes array within function', async () => {
let extractions = parse(`document.body.classList.add(['pl-1.5'].join(" "));`)
expect(extractions).toContain('pl-1.5')
})
test('with double quotes array within function', async () => {
let extractions = parse(`document.body.classList.add(["pl-1.5"].join(" "));`)
expect(extractions).toContain('pl-1.5')
})
test('with angle brackets', async () => {
let extractions = parse(
`<div class="bg-blue-200 <% if (useShadow) { %>shadow-xl<% } %>">test</div>`
)
expect(extractions).toContain('bg-blue-200')
expect(extractions).toContain('shadow-xl')
expect(extractions).not.toContain('>shadow-xl')
expect(extractions).not.toContain('shadow-xl<')
})
test('markdown code fences', async () => {
let extractions = parse('<!-- this should work: `.font-bold`, `.font-normal` -->')
expect(extractions).toContain('font-bold')
expect(extractions).toContain('font-normal')
expect(extractions).not.toContain('.font-bold')
expect(extractions).not.toContain('.font-normal')
})
test('classes in slim templates', async () => {
let extractions = parse(`
p.bg-red-500.text-sm
'This is a paragraph
small.italic.text-gray-500
'(Look mom, no closing tag!)
`)
expect(extractions).toContain('bg-red-500')
expect(extractions).toContain('text-sm')
expect(extractions).toContain('italic')
expect(extractions).toContain('text-gray-500')
})
test('multi-word + arbitrary values + quotes', async () => {
let extractions = parse(`
grid-cols-['repeat(2)']
`)
expect(extractions).toContain(`grid-cols-['repeat(2)']`)
})
test('a lot of data', () => {
let extractions = parse('underline '.repeat(2 ** 17))
expect(extractions).toContain(`underline`)
})
test('ruby percent string array', () => {
let extractions = parse('%w[text-[#bada55]]')
expect(extractions).toContain(`text-[#bada55]`)
})
test('arbitrary properties followed by square bracketed stuff', () => {
let extractions = parse(
'<div class="h-16 items-end border border-white [display:inherit]">[foo]</div>'
)
expect(extractions).toContain(`[display:inherit]`)
})
describe('Vue', () => {
test('Class object syntax', () => {
let extractions = parse(
`<Examples with combinations :class="{ underline: myCondition, 'font-bold': myCondition }">[foo]</div>`
)
expect(extractions).toContain(`underline`, `font-bold`)
})
test('Class array syntax', () => {
// With leading space
let extractions = parse(
`<div :class="[ 'underline', myCondition && 'font-bold', myCondition ? 'flex' : 'block' ]">[foo]</div>`
)
expect(extractions).toContain(`underline`, `font-bold`, `flex`, `block`)
// Without leading space
extractions = parse(
`<div :class="['underline', myCondition && 'font-bold', myCondition ? 'flex' : 'block']">[foo]</div>`
)
expect(extractions).toContain(`underline`, `font-bold`, `flex`, `block`)
})
})
describe('anti-test', () => {
it('should not parse candidates without spaces in object-like syntax', () => {
let extractions = parse(html`<div class="{underline:isActive,flex:isOnline,md:bold}"></div>`)
// The oxide parser _does_ allow this!
if (parse !== oxideParser) {
expect(extractions).not.toContain(`underline:isActive`)
expect(extractions).not.toContain(`flex:isOnline`)
expect(extractions).not.toContain(`md:bold`)
}
expect(extractions).not.toContain(`underline`)
expect(extractions).not.toContain(`isActive`)
expect(extractions).not.toContain(`flex`)
expect(extractions).not.toContain(`isOnline`)
expect(extractions).not.toContain(`md`)
expect(extractions).not.toContain(`bold`)
})
})
})