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:
parent
e572dc645a
commit
55daf8e0f5
12 changed files with 1384 additions and 222 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
159
oxide/crates/core/src/cursor.rs
Normal file
159
oxide/crates/core/src/cursor.rs
Normal 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');
|
||||
}
|
||||
}
|
||||
89
oxide/crates/core/src/fast_skip.rs
Normal file
89
oxide/crates/core/src/fast_skip.rs
Normal 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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
|
@ -9,7 +9,7 @@ let defaults = {
|
|||
logicalSiblingUtilities: false,
|
||||
}
|
||||
|
||||
let featureFlags = {
|
||||
export let featureFlags = {
|
||||
future: [
|
||||
'hoverOnlyWhenSupported',
|
||||
'respectDefaultRingColorOpacity',
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
`
|
||||
)
|
||||
})
|
||||
|
|
|
|||
423
tests/parse-candidate-strings.test.js
Normal file
423
tests/parse-candidate-strings.test.js
Normal 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`)
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue