Robin Malfait d684733d80
Only expose used CSS variables (#16211)
This PR only exposes used CSS variables. 

My initial approach was to track the used variables, this was a bit
messy because it meant that we had to walk part of the AST(s) in
multiple places. We also had to be careful because sometimes if a
variable exists in an AST, that doesn't mean that it's actually used.
E.g.:

```css
h1 {
  color: var(--color-red-500); /* Definitely used, so let's keep it */
}

@utility foo {
  color: var(--color-blue-500); /* Hmm, used? */
}
```
In this last case, the `--color-blue-500` is part of the CSS AST, but as
long as `foo` the utility is not used, it won't end up in your actual
CSS file, therefore the variable is **not** used.

Alternatively, if the `foo` utility is used with an invalid variant
(e.g.: `group-[>.foo]:foo`, then the `@utility foo` code will still run
internally because variants are applied on top of the utility. This
means that it looks like `var(--color-blue-500)` is being used.

Another annoying side effect was that because variables are
conditionally generated, that the `@theme` -> `:root, :host` conversion
had to happen for every build, instead of once in the `compile(…)` step.

---

To prevent all the messy rules and additional booking while walking of
ASTs I thought about a different approach. We are only interested in
variables that are actually used. The only way we know for sure, is
right before the `toCss(…)` step. Any step before that could still throw
away AST nodes.

However, we do have an `optimizeAst` step right before printing to
simplify and optimize the AST. So the idea was to keep all the CSS
variables in the AST, and only in the `optimizeAst` step we perform a
kind of mark-and-sweep algorithm where we can first check which
variables are _actually_ used (these are the ones that are left in the
AST), and later we removed the ones that weren't part of known used
list.

Moving the logic to this step feels a natural spot for this to happen,
because we are in fact optimizing the AST. We were already walking the
AST, so we can just handle these cases while we are walking without
additional walks. Last but not least, this also means that there is only
a single spot where need to track and remove variables.

Now, there is a different part to this story. If you use a variable in
JS land for example, we also want to make sure that we keep the CSS
variable in the CSS. To do this, we can mark variables as being used in
the internal `Theme`.

The Oxide scanner will also emit used variables that it can find such as
`var(--color-red-500)` and will emit `--color-red-500` as a "candidate".
We can then proactively mark this one as used even though it may not be
used anyway in the actual AST.

---

### Always including all variables

Some users might make heavy use of JavaScript and string interpolation
where they _need_ all the variables to be present. Similar to the
`inline` and `reference` theme options, this also exposes a new `static`
option. This ensures that all the CSS variables will always be generated
regardless of whether it's used or not.

One handy feature is that you have granular control over this:
```css
/* These will always be generated */
@theme static {
  --color-primary: red;
  --color-secondary: blue;
}

/* Only generated when used */
@theme {
  --color-maybe: pink;
}
```

### Performance considerations:

Now that we are tracking which variables are being used, it means that
we will produce a smaller CSS file, but we are also doing more work (the
mark-and-sweep part). That said, ran some benchmarks and the changes
look like this:

Running it on Catalyst:
<img width="1086" alt="image"
src="https://github.com/user-attachments/assets/ec2124f0-2e64-4a11-aa5e-5f7ae6605962"
/>
_(probably within margin of error)_

Running it on Tailwind UI:
<img width="1113" alt="image"
src="https://github.com/user-attachments/assets/6bea2328-d790-4f33-a0ae-72654c688edb"
/>

### Test plan

- Tests have been updated with the removed CSS variables
- Added a dedicated integration test to show that Oxide can find
variables and mark them as used (so they are included)
- Ran the code on Catalyst, and verified that all the removed variables
are in fact not used anywhere in the codebase.

The diff on Catalyst looks like this:

<details>

```diff
diff --git a/templates/catalyst/out.css b/templates/catalyst/out.css
index f2b364ea..240d1d90 100644
--- a/templates/catalyst/out.css
+++ b/templates/catalyst/out.css
@@ -29,218 +29,111 @@
 @layer theme {
   :root, :host {
     --font-sans: Inter, sans-serif;
-    --font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
     --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
       "Liberation Mono", "Courier New", monospace;
-    --color-red-50: oklch(0.971 0.013 17.38);
-    --color-red-100: oklch(0.936 0.032 17.717);
     --color-red-200: oklch(0.885 0.062 18.334);
     --color-red-300: oklch(0.808 0.114 19.571);
     --color-red-400: oklch(0.704 0.191 22.216);
     --color-red-500: oklch(0.637 0.237 25.331);
     --color-red-600: oklch(0.577 0.245 27.325);
     --color-red-700: oklch(0.505 0.213 27.518);
-    --color-red-800: oklch(0.444 0.177 26.899);
     --color-red-900: oklch(0.396 0.141 25.723);
-    --color-red-950: oklch(0.258 0.092 26.042);
-    --color-orange-50: oklch(0.98 0.016 73.684);
-    --color-orange-100: oklch(0.954 0.038 75.164);
     --color-orange-200: oklch(0.901 0.076 70.697);
     --color-orange-300: oklch(0.837 0.128 66.29);
     --color-orange-400: oklch(0.75 0.183 55.934);
     --color-orange-500: oklch(0.705 0.213 47.604);
     --color-orange-600: oklch(0.646 0.222 41.116);
     --color-orange-700: oklch(0.553 0.195 38.402);
-    --color-orange-800: oklch(0.47 0.157 37.304);
     --color-orange-900: oklch(0.408 0.123 38.172);
-    --color-orange-950: oklch(0.266 0.079 36.259);
-    --color-amber-50: oklch(0.987 0.022 95.277);
-    --color-amber-100: oklch(0.962 0.059 95.617);
-    --color-amber-200: oklch(0.924 0.12 95.746);
-    --color-amber-300: oklch(0.879 0.169 91.605);
     --color-amber-400: oklch(0.828 0.189 84.429);
     --color-amber-500: oklch(0.769 0.188 70.08);
     --color-amber-600: oklch(0.666 0.179 58.318);
     --color-amber-700: oklch(0.555 0.163 48.998);
-    --color-amber-800: oklch(0.473 0.137 46.201);
-    --color-amber-900: oklch(0.414 0.112 45.904);
     --color-amber-950: oklch(0.279 0.077 45.635);
-    --color-yellow-50: oklch(0.987 0.026 102.212);
-    --color-yellow-100: oklch(0.973 0.071 103.193);
-    --color-yellow-200: oklch(0.945 0.129 101.54);
     --color-yellow-300: oklch(0.905 0.182 98.111);
     --color-yellow-400: oklch(0.852 0.199 91.936);
-    --color-yellow-500: oklch(0.795 0.184 86.047);
     --color-yellow-600: oklch(0.681 0.162 75.834);
     --color-yellow-700: oklch(0.554 0.135 66.442);
-    --color-yellow-800: oklch(0.476 0.114 61.907);
-    --color-yellow-900: oklch(0.421 0.095 57.708);
     --color-yellow-950: oklch(0.286 0.066 53.813);
-    --color-lime-50: oklch(0.986 0.031 120.757);
-    --color-lime-100: oklch(0.967 0.067 122.328);
-    --color-lime-200: oklch(0.938 0.127 124.321);
     --color-lime-300: oklch(0.897 0.196 126.665);
     --color-lime-400: oklch(0.841 0.238 128.85);
-    --color-lime-500: oklch(0.768 0.233 130.85);
     --color-lime-600: oklch(0.648 0.2 131.684);
     --color-lime-700: oklch(0.532 0.157 131.589);
-    --color-lime-800: oklch(0.453 0.124 130.933);
-    --color-lime-900: oklch(0.405 0.101 131.063);
     --color-lime-950: oklch(0.274 0.072 132.109);
-    --color-green-50: oklch(0.982 0.018 155.826);
-    --color-green-100: oklch(0.962 0.044 156.743);
-    --color-green-200: oklch(0.925 0.084 155.995);
-    --color-green-300: oklch(0.871 0.15 154.449);
     --color-green-400: oklch(0.792 0.209 151.711);
     --color-green-500: oklch(0.723 0.219 149.579);
     --color-green-600: oklch(0.627 0.194 149.214);
     --color-green-700: oklch(0.527 0.154 150.069);
-    --color-green-800: oklch(0.448 0.119 151.328);
     --color-green-900: oklch(0.393 0.095 152.535);
-    --color-green-950: oklch(0.266 0.065 152.934);
-    --color-emerald-50: oklch(0.979 0.021 166.113);
-    --color-emerald-100: oklch(0.95 0.052 163.051);
-    --color-emerald-200: oklch(0.905 0.093 164.15);
-    --color-emerald-300: oklch(0.845 0.143 164.978);
     --color-emerald-400: oklch(0.765 0.177 163.223);
     --color-emerald-500: oklch(0.696 0.17 162.48);
     --color-emerald-600: oklch(0.596 0.145 163.225);
     --color-emerald-700: oklch(0.508 0.118 165.612);
-    --color-emerald-800: oklch(0.432 0.095 166.913);
     --color-emerald-900: oklch(0.378 0.077 168.94);
-    --color-emerald-950: oklch(0.262 0.051 172.552);
-    --color-teal-50: oklch(0.984 0.014 180.72);
-    --color-teal-100: oklch(0.953 0.051 180.801);
-    --color-teal-200: oklch(0.91 0.096 180.426);
     --color-teal-300: oklch(0.855 0.138 181.071);
     --color-teal-400: oklch(0.777 0.152 181.912);
     --color-teal-500: oklch(0.704 0.14 182.503);
     --color-teal-600: oklch(0.6 0.118 184.704);
     --color-teal-700: oklch(0.511 0.096 186.391);
-    --color-teal-800: oklch(0.437 0.078 188.216);
     --color-teal-900: oklch(0.386 0.063 188.416);
-    --color-teal-950: oklch(0.277 0.046 192.524);
-    --color-cyan-50: oklch(0.984 0.019 200.873);
-    --color-cyan-100: oklch(0.956 0.045 203.388);
-    --color-cyan-200: oklch(0.917 0.08 205.041);
     --color-cyan-300: oklch(0.865 0.127 207.078);
     --color-cyan-400: oklch(0.789 0.154 211.53);
     --color-cyan-500: oklch(0.715 0.143 215.221);
-    --color-cyan-600: oklch(0.609 0.126 221.723);
     --color-cyan-700: oklch(0.52 0.105 223.128);
-    --color-cyan-800: oklch(0.45 0.085 224.283);
-    --color-cyan-900: oklch(0.398 0.07 227.392);
     --color-cyan-950: oklch(0.302 0.056 229.695);
-    --color-sky-50: oklch(0.977 0.013 236.62);
-    --color-sky-100: oklch(0.951 0.026 236.824);
-    --color-sky-200: oklch(0.901 0.058 230.902);
     --color-sky-300: oklch(0.828 0.111 230.318);
-    --color-sky-400: oklch(0.746 0.16 232.661);
     --color-sky-500: oklch(0.685 0.169 237.323);
     --color-sky-600: oklch(0.588 0.158 241.966);
     --color-sky-700: oklch(0.5 0.134 242.749);
-    --color-sky-800: oklch(0.443 0.11 240.79);
     --color-sky-900: oklch(0.391 0.09 240.876);
-    --color-sky-950: oklch(0.293 0.066 243.157);
-    --color-blue-50: oklch(0.97 0.014 254.604);
-    --color-blue-100: oklch(0.932 0.032 255.585);
-    --color-blue-200: oklch(0.882 0.059 254.128);
     --color-blue-300: oklch(0.809 0.105 251.813);
     --color-blue-400: oklch(0.707 0.165 254.624);
     --color-blue-500: oklch(0.623 0.214 259.815);
     --color-blue-600: oklch(0.546 0.245 262.881);
     --color-blue-700: oklch(0.488 0.243 264.376);
-    --color-blue-800: oklch(0.424 0.199 265.638);
     --color-blue-900: oklch(0.379 0.146 265.522);
-    --color-blue-950: oklch(0.282 0.091 267.935);
-    --color-indigo-50: oklch(0.962 0.018 272.314);
-    --color-indigo-100: oklch(0.93 0.034 272.788);
     --color-indigo-200: oklch(0.87 0.065 274.039);
     --color-indigo-300: oklch(0.785 0.115 274.713);
     --color-indigo-400: oklch(0.673 0.182 276.935);
     --color-indigo-500: oklch(0.585 0.233 277.117);
     --color-indigo-600: oklch(0.511 0.262 276.966);
     --color-indigo-700: oklch(0.457 0.24 277.023);
-    --color-indigo-800: oklch(0.398 0.195 277.366);
     --color-indigo-900: oklch(0.359 0.144 278.697);
-    --color-indigo-950: oklch(0.257 0.09 281.288);
-    --color-violet-50: oklch(0.969 0.016 293.756);
-    --color-violet-100: oklch(0.943 0.029 294.588);
     --color-violet-200: oklch(0.894 0.057 293.283);
     --color-violet-300: oklch(0.811 0.111 293.571);
     --color-violet-400: oklch(0.702 0.183 293.541);
     --color-violet-500: oklch(0.606 0.25 292.717);
     --color-violet-600: oklch(0.541 0.281 293.009);
     --color-violet-700: oklch(0.491 0.27 292.581);
-    --color-violet-800: oklch(0.432 0.232 292.759);
     --color-violet-900: oklch(0.38 0.189 293.745);
-    --color-violet-950: oklch(0.283 0.141 291.089);
-    --color-purple-50: oklch(0.977 0.014 308.299);
-    --color-purple-100: oklch(0.946 0.033 307.174);
     --color-purple-200: oklch(0.902 0.063 306.703);
     --color-purple-300: oklch(0.827 0.119 306.383);
     --color-purple-400: oklch(0.714 0.203 305.504);
     --color-purple-500: oklch(0.627 0.265 303.9);
     --color-purple-600: oklch(0.558 0.288 302.321);
     --color-purple-700: oklch(0.496 0.265 301.924);
-    --color-purple-800: oklch(0.438 0.218 303.724);
     --color-purple-900: oklch(0.381 0.176 304.987);
-    --color-purple-950: oklch(0.291 0.149 302.717);
-    --color-fuchsia-50: oklch(0.977 0.017 320.058);
-    --color-fuchsia-100: oklch(0.952 0.037 318.852);
     --color-fuchsia-200: oklch(0.903 0.076 319.62);
     --color-fuchsia-300: oklch(0.833 0.145 321.434);
     --color-fuchsia-400: oklch(0.74 0.238 322.16);
     --color-fuchsia-500: oklch(0.667 0.295 322.15);
     --color-fuchsia-600: oklch(0.591 0.293 322.896);
     --color-fuchsia-700: oklch(0.518 0.253 323.949);
-    --color-fuchsia-800: oklch(0.452 0.211 324.591);
     --color-fuchsia-900: oklch(0.401 0.17 325.612);
-    --color-fuchsia-950: oklch(0.293 0.136 325.661);
-    --color-pink-50: oklch(0.971 0.014 343.198);
-    --color-pink-100: oklch(0.948 0.028 342.258);
     --color-pink-200: oklch(0.899 0.061 343.231);
     --color-pink-300: oklch(0.823 0.12 346.018);
     --color-pink-400: oklch(0.718 0.202 349.761);
     --color-pink-500: oklch(0.656 0.241 354.308);
     --color-pink-600: oklch(0.592 0.249 0.584);
     --color-pink-700: oklch(0.525 0.223 3.958);
-    --color-pink-800: oklch(0.459 0.187 3.815);
     --color-pink-900: oklch(0.408 0.153 2.432);
-    --color-pink-950: oklch(0.284 0.109 3.907);
-    --color-rose-50: oklch(0.969 0.015 12.422);
-    --color-rose-100: oklch(0.941 0.03 12.58);
     --color-rose-200: oklch(0.892 0.058 10.001);
     --color-rose-300: oklch(0.81 0.117 11.638);
     --color-rose-400: oklch(0.712 0.194 13.428);
     --color-rose-500: oklch(0.645 0.246 16.439);
     --color-rose-600: oklch(0.586 0.253 17.585);
     --color-rose-700: oklch(0.514 0.222 16.935);
-    --color-rose-800: oklch(0.455 0.188 13.697);
     --color-rose-900: oklch(0.41 0.159 10.272);
-    --color-rose-950: oklch(0.271 0.105 12.094);
-    --color-slate-50: oklch(0.984 0.003 247.858);
-    --color-slate-100: oklch(0.968 0.007 247.896);
-    --color-slate-200: oklch(0.929 0.013 255.508);
-    --color-slate-300: oklch(0.869 0.022 252.894);
-    --color-slate-400: oklch(0.704 0.04 256.788);
-    --color-slate-500: oklch(0.554 0.046 257.417);
-    --color-slate-600: oklch(0.446 0.043 257.281);
-    --color-slate-700: oklch(0.372 0.044 257.287);
-    --color-slate-800: oklch(0.279 0.041 260.031);
-    --color-slate-900: oklch(0.208 0.042 265.755);
-    --color-slate-950: oklch(0.129 0.042 264.695);
-    --color-gray-50: oklch(0.985 0.002 247.839);
-    --color-gray-100: oklch(0.967 0.003 264.542);
-    --color-gray-200: oklch(0.928 0.006 264.531);
-    --color-gray-300: oklch(0.872 0.01 258.338);
-    --color-gray-400: oklch(0.707 0.022 261.325);
-    --color-gray-500: oklch(0.551 0.027 264.364);
-    --color-gray-600: oklch(0.446 0.03 256.802);
-    --color-gray-700: oklch(0.373 0.034 259.733);
-    --color-gray-800: oklch(0.278 0.033 256.848);
-    --color-gray-900: oklch(0.21 0.034 264.665);
-    --color-gray-950: oklch(0.13 0.028 261.692);
     --color-zinc-50: oklch(0.985 0 0);
     --color-zinc-100: oklch(0.967 0.001 286.375);
     --color-zinc-200: oklch(0.92 0.004 286.32);
@@ -252,38 +145,9 @@
     --color-zinc-800: oklch(0.274 0.006 286.033);
     --color-zinc-900: oklch(0.21 0.006 285.885);
     --color-zinc-950: oklch(0.141 0.005 285.823);
-    --color-neutral-50: oklch(0.985 0 0);
-    --color-neutral-100: oklch(0.97 0 0);
-    --color-neutral-200: oklch(0.922 0 0);
-    --color-neutral-300: oklch(0.87 0 0);
-    --color-neutral-400: oklch(0.708 0 0);
-    --color-neutral-500: oklch(0.556 0 0);
-    --color-neutral-600: oklch(0.439 0 0);
-    --color-neutral-700: oklch(0.371 0 0);
-    --color-neutral-800: oklch(0.269 0 0);
-    --color-neutral-900: oklch(0.205 0 0);
-    --color-neutral-950: oklch(0.145 0 0);
-    --color-stone-50: oklch(0.985 0.001 106.423);
-    --color-stone-100: oklch(0.97 0.001 106.424);
-    --color-stone-200: oklch(0.923 0.003 48.717);
-    --color-stone-300: oklch(0.869 0.005 56.366);
-    --color-stone-400: oklch(0.709 0.01 56.259);
-    --color-stone-500: oklch(0.553 0.013 58.071);
-    --color-stone-600: oklch(0.444 0.011 73.639);
-    --color-stone-700: oklch(0.374 0.01 67.558);
-    --color-stone-800: oklch(0.268 0.007 34.298);
-    --color-stone-900: oklch(0.216 0.006 56.043);
-    --color-stone-950: oklch(0.147 0.004 49.25);
     --color-black: #000;
     --color-white: #fff;
     --spacing: 0.25rem;
-    --breakpoint-sm: 40rem;
-    --breakpoint-md: 48rem;
-    --breakpoint-lg: 64rem;
-    --breakpoint-xl: 80rem;
-    --breakpoint-2xl: 96rem;
-    --container-3xs: 16rem;
-    --container-2xs: 18rem;
     --container-xs: 20rem;
     --container-sm: 24rem;
     --container-md: 28rem;
@@ -302,92 +166,23 @@
     --text-base: 1rem;
     --text-base--line-height: calc(1.5 / 1);
     --text-lg: 1.125rem;
-    --text-lg--line-height: calc(1.75 / 1.125);
     --text-xl: 1.25rem;
-    --text-xl--line-height: calc(1.75 / 1.25);
     --text-2xl: 1.5rem;
-    --text-2xl--line-height: calc(2 / 1.5);
-    --text-3xl: 1.875rem;
-    --text-3xl--line-height: calc(2.25 / 1.875);
-    --text-4xl: 2.25rem;
-    --text-4xl--line-height: calc(2.5 / 2.25);
-    --text-5xl: 3rem;
-    --text-5xl--line-height: 1;
-    --text-6xl: 3.75rem;
-    --text-6xl--line-height: 1;
-    --text-7xl: 4.5rem;
-    --text-7xl--line-height: 1;
-    --text-8xl: 6rem;
-    --text-8xl--line-height: 1;
-    --text-9xl: 8rem;
-    --text-9xl--line-height: 1;
-    --font-weight-thin: 100;
-    --font-weight-extralight: 200;
-    --font-weight-light: 300;
     --font-weight-normal: 400;
     --font-weight-medium: 500;
     --font-weight-semibold: 600;
     --font-weight-bold: 700;
-    --font-weight-extrabold: 800;
-    --font-weight-black: 900;
-    --tracking-tighter: -0.05em;
-    --tracking-tight: -0.025em;
-    --tracking-normal: 0em;
-    --tracking-wide: 0.025em;
-    --tracking-wider: 0.05em;
-    --tracking-widest: 0.1em;
-    --leading-tight: 1.25;
-    --leading-snug: 1.375;
-    --leading-normal: 1.5;
-    --leading-relaxed: 1.625;
-    --leading-loose: 2;
-    --radius-xs: 0.125rem;
     --radius-sm: 0.25rem;
     --radius-md: 0.375rem;
     --radius-lg: 0.5rem;
     --radius-xl: 0.75rem;
     --radius-2xl: 1rem;
     --radius-3xl: 1.5rem;
-    --radius-4xl: 2rem;
-    --shadow-2xs: 0 1px rgb(0 0 0 / 0.05);
-    --shadow-xs: 0 1px 2px 0 rgb(0 0 0 / 0.05);
-    --shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
-    --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1),
-      0 2px 4px -2px rgb(0 0 0 / 0.1);
-    --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1),
-      0 4px 6px -4px rgb(0 0 0 / 0.1);
-    --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1),
-      0 8px 10px -6px rgb(0 0 0 / 0.1);
-    --shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25);
-    --inset-shadow-2xs: inset 0 1px rgb(0 0 0 / 0.05);
-    --inset-shadow-xs: inset 0 1px 1px rgb(0 0 0 / 0.05);
-    --inset-shadow-sm: inset 0 2px 4px rgb(0 0 0 / 0.05);
-    --drop-shadow-xs: 0 1px 1px rgb(0 0 0 / 0.05);
-    --drop-shadow-sm: 0 1px 2px rgb(0 0 0 / 0.15);
-    --drop-shadow-md: 0 3px 3px rgb(0 0 0 / 0.12);
-    --drop-shadow-lg: 0 4px 4px rgb(0 0 0 / 0.15);
-    --drop-shadow-xl: 0 9px 7px rgb(0 0 0 / 0.1);
-    --drop-shadow-2xl: 0 25px 25px rgb(0 0 0 / 0.15);
     --ease-in: cubic-bezier(0.4, 0, 1, 1);
     --ease-out: cubic-bezier(0, 0, 0.2, 1);
     --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
-    --animate-spin: spin 1s linear infinite;
-    --animate-ping: ping 1s cubic-bezier(0, 0, 0.2, 1) infinite;
-    --animate-pulse: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
-    --animate-bounce: bounce 1s infinite;
-    --blur-xs: 4px;
-    --blur-sm: 8px;
     --blur-md: 12px;
-    --blur-lg: 16px;
     --blur-xl: 24px;
-    --blur-2xl: 40px;
-    --blur-3xl: 64px;
-    --perspective-dramatic: 100px;
-    --perspective-near: 300px;
-    --perspective-normal: 500px;
-    --perspective-midrange: 800px;
-    --perspective-distant: 1200px;
-    --aspect-video: 16 / 9;
     --default-transition-duration: 150ms;
     --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
     --default-font-family: var(--font-sans);
```

</details>

If you have `ripgrep` installed, you can use this command to verify that
these variables are indeed not used anywhere:

<details>

```shell
rg "\-\-font-serif\b"
rg "\-\-color-red-50\b"
rg "\-\-color-red-100\b"
rg "\-\-color-red-800\b"
rg "\-\-color-red-950\b"
rg "\-\-color-orange-50\b"
rg "\-\-color-orange-100\b"
rg "\-\-color-orange-800\b"
rg "\-\-color-orange-950\b"
rg "\-\-color-amber-50\b"
rg "\-\-color-amber-100\b"
rg "\-\-color-amber-200\b"
rg "\-\-color-amber-300\b"
rg "\-\-color-amber-800\b"
rg "\-\-color-amber-900\b"
rg "\-\-color-yellow-50\b"
rg "\-\-color-yellow-100\b"
rg "\-\-color-yellow-200\b"
rg "\-\-color-yellow-500\b"
rg "\-\-color-yellow-800\b"
rg "\-\-color-yellow-900\b"
rg "\-\-color-lime-50\b"
rg "\-\-color-lime-100\b"
rg "\-\-color-lime-200\b"
rg "\-\-color-lime-500\b"
rg "\-\-color-lime-800\b"
rg "\-\-color-lime-900\b"
rg "\-\-color-green-50\b"
rg "\-\-color-green-100\b"
rg "\-\-color-green-200\b"
rg "\-\-color-green-300\b"
rg "\-\-color-green-800\b"
rg "\-\-color-green-950\b"
rg "\-\-color-emerald-50\b"
rg "\-\-color-emerald-100\b"
rg "\-\-color-emerald-200\b"
rg "\-\-color-emerald-300\b"
rg "\-\-color-emerald-800\b"
rg "\-\-color-emerald-950\b"
rg "\-\-color-teal-50\b"
rg "\-\-color-teal-100\b"
rg "\-\-color-teal-200\b"
rg "\-\-color-teal-800\b"
rg "\-\-color-teal-950\b"
rg "\-\-color-cyan-50\b"
rg "\-\-color-cyan-100\b"
rg "\-\-color-cyan-200\b"
rg "\-\-color-cyan-600\b"
rg "\-\-color-cyan-800\b"
rg "\-\-color-cyan-900\b"
rg "\-\-color-sky-50\b"
rg "\-\-color-sky-100\b"
rg "\-\-color-sky-200\b"
rg "\-\-color-sky-400\b"
rg "\-\-color-sky-800\b"
rg "\-\-color-sky-950\b"
rg "\-\-color-blue-50\b"
rg "\-\-color-blue-100\b"
rg "\-\-color-blue-200\b"
rg "\-\-color-blue-800\b"
rg "\-\-color-blue-950\b"
rg "\-\-color-indigo-50\b"
rg "\-\-color-indigo-100\b"
rg "\-\-color-indigo-800\b"
rg "\-\-color-indigo-950\b"
rg "\-\-color-violet-50\b"
rg "\-\-color-violet-100\b"
rg "\-\-color-violet-800\b"
rg "\-\-color-violet-950\b"
rg "\-\-color-purple-50\b"
rg "\-\-color-purple-100\b"
rg "\-\-color-purple-800\b"
rg "\-\-color-purple-950\b"
rg "\-\-color-fuchsia-50\b"
rg "\-\-color-fuchsia-100\b"
rg "\-\-color-fuchsia-800\b"
rg "\-\-color-fuchsia-950\b"
rg "\-\-color-pink-50\b"
rg "\-\-color-pink-100\b"
rg "\-\-color-pink-800\b"
rg "\-\-color-pink-950\b"
rg "\-\-color-rose-50\b"
rg "\-\-color-rose-100\b"
rg "\-\-color-rose-800\b"
rg "\-\-color-rose-950\b"
rg "\-\-color-slate-50\b"
rg "\-\-color-slate-100\b"
rg "\-\-color-slate-200\b"
rg "\-\-color-slate-300\b"
rg "\-\-color-slate-400\b"
rg "\-\-color-slate-500\b"
rg "\-\-color-slate-600\b"
rg "\-\-color-slate-700\b"
rg "\-\-color-slate-800\b"
rg "\-\-color-slate-900\b"
rg "\-\-color-slate-950\b"
rg "\-\-color-gray-50\b"
rg "\-\-color-gray-100\b"
rg "\-\-color-gray-200\b"
rg "\-\-color-gray-300\b"
rg "\-\-color-gray-400\b"
rg "\-\-color-gray-500\b"
rg "\-\-color-gray-600\b"
rg "\-\-color-gray-700\b"
rg "\-\-color-gray-800\b"
rg "\-\-color-gray-900\b"
rg "\-\-color-gray-950\b"
rg "\-\-color-neutral-50\b"
rg "\-\-color-neutral-100\b"
rg "\-\-color-neutral-200\b"
rg "\-\-color-neutral-300\b"
rg "\-\-color-neutral-400\b"
rg "\-\-color-neutral-500\b"
rg "\-\-color-neutral-600\b"
rg "\-\-color-neutral-700\b"
rg "\-\-color-neutral-800\b"
rg "\-\-color-neutral-900\b"
rg "\-\-color-neutral-950\b"
rg "\-\-color-stone-50\b"
rg "\-\-color-stone-100\b"
rg "\-\-color-stone-200\b"
rg "\-\-color-stone-300\b"
rg "\-\-color-stone-400\b"
rg "\-\-color-stone-500\b"
rg "\-\-color-stone-600\b"
rg "\-\-color-stone-700\b"
rg "\-\-color-stone-800\b"
rg "\-\-color-stone-900\b"
rg "\-\-color-stone-950\b"
rg "\-\-breakpoint-sm\b"
rg "\-\-breakpoint-md\b"
rg "\-\-breakpoint-lg\b"
rg "\-\-breakpoint-xl\b"
rg "\-\-breakpoint-2xl\b"
rg "\-\-container-3xs\b"
rg "\-\-container-2xs\b"
rg "\-\-text-lg--line-height\b"
rg "\-\-text-xl--line-height\b"
rg "\-\-text-2xl--line-height\b"
rg "\-\-text-3xl\b"
rg "\-\-text-3xl--line-height\b"
rg "\-\-text-4xl\b"
rg "\-\-text-4xl--line-height\b"
rg "\-\-text-5xl\b"
rg "\-\-text-5xl--line-height\b"
rg "\-\-text-6xl\b"
rg "\-\-text-6xl--line-height\b"
rg "\-\-text-7xl\b"
rg "\-\-text-7xl--line-height\b"
rg "\-\-text-8xl\b"
rg "\-\-text-8xl--line-height\b"
rg "\-\-text-9xl\b"
rg "\-\-text-9xl--line-height\b"
rg "\-\-font-weight-thin\b"
rg "\-\-font-weight-extralight\b"
rg "\-\-font-weight-light\b"
rg "\-\-font-weight-extrabold\b"
rg "\-\-font-weight-black\b"
rg "\-\-tracking-tighter\b"
rg "\-\-tracking-tight\b"
rg "\-\-tracking-normal\b"
rg "\-\-tracking-wide\b"
rg "\-\-tracking-wider\b"
rg "\-\-tracking-widest\b"
rg "\-\-leading-tight\b"
rg "\-\-leading-snug\b"
rg "\-\-leading-normal\b"
rg "\-\-leading-relaxed\b"
rg "\-\-leading-loose\b"
rg "\-\-radius-xs\b"
rg "\-\-radius-4xl\b"
rg "\-\-shadow-2xs\b"
rg "\-\-shadow-xs\b"
rg "\-\-shadow-sm\b"
rg "\-\-shadow-md\b"
rg "\-\-shadow-lg\b"
rg "\-\-shadow-xl\b"
rg "\-\-shadow-2xl\b"
rg "\-\-inset-shadow-2xs\b"
rg "\-\-inset-shadow-xs\b"
rg "\-\-inset-shadow-sm\b"
rg "\-\-drop-shadow-xs\b"
rg "\-\-drop-shadow-sm\b"
rg "\-\-drop-shadow-md\b"
rg "\-\-drop-shadow-lg\b"
rg "\-\-drop-shadow-xl\b"
rg "\-\-drop-shadow-2xl\b"
rg "\-\-animate-spin\b"
rg "\-\-animate-ping\b"
rg "\-\-animate-pulse\b"
rg "\-\-animate-bounce\b"
rg "\-\-blur-xs\b"
rg "\-\-blur-sm\b"
rg "\-\-blur-lg\b"
rg "\-\-blur-2xl\b"
rg "\-\-blur-3xl\b"
rg "\-\-perspective-dramatic\b"
rg "\-\-perspective-near\b"
rg "\-\-perspective-normal\b"
rg "\-\-perspective-midrange\b"
rg "\-\-perspective-distant\b"
rg "\-\-aspect-video\b"
```

</details>

The only exception I noticed is that we have this:
```css
src/typography.utilities.css
10:  @media (width >= theme(--breakpoint-sm)) {
```
But this is not a variable, but it's replaced at build time with the
actual value, so this is not a real issue.

Testing on other templates:

<img width="2968" alt="image"
src="https://github.com/user-attachments/assets/cabf121d-4cb9-468f-9cf5-ceb02609dc7d"
/>


Fixes: https://github.com/tailwindlabs/tailwindcss/issues/16145

---------

Co-authored-by: Adam Wathan <4323180+adamwathan@users.noreply.github.com>
2025-02-07 17:12:47 +00:00

1758 lines
56 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use crate::{cursor::Cursor, fast_skip::fast_skip};
use bstr::ByteSlice;
use fxhash::FxHashSet;
use tracing::trace;
#[derive(Debug, PartialEq, Eq, Clone)]
pub enum ParseAction<'a> {
Consume,
Skip,
RestartAt(usize),
SingleCandidate(&'a [u8]),
MultipleCandidates(Vec<&'a [u8]>),
Done,
}
#[derive(Debug, PartialEq, Eq, Clone)]
pub enum Bracketing<'a> {
Included(&'a [u8]),
Wrapped(&'a [u8]),
None,
}
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub struct SplitCandidate<'a> {
variant: &'a [u8],
utility: &'a [u8],
}
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum ValidationResult {
Invalid,
Valid,
Restart,
}
#[derive(Default)]
pub struct ExtractorOptions {
pub preserve_spaces_in_arbitrary: bool,
}
#[derive(Debug, PartialEq, Eq, Clone)]
enum Arbitrary {
/// Not inside any arbitrary value
None,
/// In arbitrary value mode with square brackets
///
/// E.g.: `bg-[…]`
/// ^
Brackets { start_idx: usize },
/// In arbitrary value mode with parens
///
/// E.g.: `bg-(…)`
/// ^
Parens { start_idx: usize },
}
pub struct Extractor<'a> {
opts: ExtractorOptions,
input: &'a [u8],
cursor: Cursor<'a>,
idx_start: usize,
idx_end: usize,
idx_last: usize,
arbitrary: Arbitrary,
in_candidate: bool,
in_escape: bool,
discard_next: bool,
quote_stack: Vec<u8>,
bracket_stack: Vec<u8>,
}
impl<'a> Extractor<'a> {
pub fn all(input: &'a [u8], opts: ExtractorOptions) -> Vec<&'a [u8]> {
Self::new(input, opts).flatten().collect()
}
pub fn unique(input: &'a [u8], opts: ExtractorOptions) -> FxHashSet<&'a [u8]> {
let mut candidates: FxHashSet<&[u8]> = Default::default();
candidates.reserve(100);
candidates.extend(Self::new(input, opts).flatten());
candidates
}
pub fn unique_ord(input: &'a [u8], opts: ExtractorOptions) -> Vec<&'a [u8]> {
// This is an inefficient way to get an ordered, unique
// list as a Vec but it is only meant for testing.
let mut candidates = Self::all(input, opts);
let mut unique_list = FxHashSet::default();
unique_list.reserve(candidates.len());
candidates.retain(|c| unique_list.insert(*c));
candidates
}
pub fn with_positions(input: &'a [u8], opts: ExtractorOptions) -> Vec<(&'a [u8], usize)> {
let mut result = Vec::new();
let extractor = Self::new(input, opts).flatten();
for item in extractor {
// Since the items are slices of the input buffer, we can calculate the start index
// by doing some pointer arithmetics.
let start_index = item.as_ptr() as usize - input.as_ptr() as usize;
result.push((item, start_index));
}
result
}
}
impl<'a> Extractor<'a> {
pub fn new(input: &'a [u8], opts: ExtractorOptions) -> Self {
Self {
opts,
input,
cursor: Cursor::new(input),
idx_start: 0,
idx_end: 0,
arbitrary: Arbitrary::None,
in_candidate: false,
in_escape: false,
discard_next: false,
idx_last: input.len(),
quote_stack: Vec::with_capacity(8),
bracket_stack: Vec::with_capacity(8),
}
}
}
/// Helpers
impl<'a> Extractor<'a> {
#[inline(always)]
fn in_quotes(&self) -> bool {
!self.quote_stack.is_empty()
}
#[inline(always)]
fn get_current_candidate(&mut self) -> ParseAction<'a> {
if self.discard_next {
return ParseAction::Skip;
}
let mut candidate = &self.input[self.idx_start..=self.idx_end];
// The bracket stack is not empty, which means that we are dealing with unbalanced
// brackets.
if !self.bracket_stack.is_empty() {
return ParseAction::Skip;
}
while !candidate.is_empty() {
match Extractor::is_valid_candidate_string(candidate) {
ValidationResult::Valid => return ParseAction::SingleCandidate(candidate),
ValidationResult::Restart => return ParseAction::RestartAt(self.idx_start + 1),
_ => {}
}
match candidate.split_last() {
// At this point the candidate is technically invalid, however it can be that it
// has a few dangling characters attached to it. For example, think about a
// JavaScript object:
//
// ```js
// { underline: true }
// ```
//
// The candidate at this point will be `underline:`, which is invalid. However, we
// can assume in this case that the `:` should not be there, and therefore we can
// try to slice it off and retry the validation.
Some((b':' | b'/' | b'.', head)) => {
candidate = head;
}
// It could also be that we have the candidate is nested inside of bracket or quote
// pairs. In this case we want to retrieve the inner part and try to validate that
// inner part instead. For example, in a JavaScript array:
//
// ```js
// let myClasses = ["underline"]
// ```
//
// The `underline` is nested inside of quotes and in square brackets. Let's try to
// get the inner part and validate that instead.
_ => match Self::slice_surrounding(candidate) {
Some(shorter) if shorter != candidate => {
candidate = shorter;
}
_ => break,
},
}
}
ParseAction::Consume
}
#[inline(always)]
fn split_candidate(candidate: &'a [u8]) -> SplitCandidate<'a> {
let mut brackets = 0;
let mut idx_end = 0;
for (n, c) in candidate.iter().enumerate() {
match c {
b'[' => brackets += 1,
b']' if brackets > 0 => brackets -= 1,
b':' if brackets == 0 => idx_end = n + 1,
_ => {}
}
}
SplitCandidate {
variant: &candidate[0..idx_end],
utility: &candidate[idx_end..],
}
}
#[inline(always)]
fn contains_in_constrained(candidate: &'a [u8], bytes: Vec<u8>) -> bool {
let mut brackets = 0;
for c in candidate {
match c {
b'[' => brackets += 1,
b']' if brackets > 0 => brackets -= 1,
_ if brackets == 0 && bytes.contains(c) => return true,
_ => {}
}
}
false
}
#[inline(always)]
fn is_valid_candidate_string(candidate: &'a [u8]) -> ValidationResult {
// Reject candidates that start with a capital letter
if candidate[0].is_ascii_uppercase() {
return ValidationResult::Invalid;
}
// Rejects candidates that end with "-" or "_"
if candidate.ends_with(b"-") || candidate.ends_with(b"_") {
return ValidationResult::Invalid;
}
// Reject candidates that are single camelCase words, e.g.: `useEffect`
if candidate.iter().all(|c| c.is_ascii_alphanumeric())
&& candidate.iter().any(|c| c.is_ascii_uppercase())
{
return ValidationResult::Invalid;
}
// Reject candidates that look like SVG path data, e.g.: `m32.368 m7.5`
if !candidate.contains(&b'-')
&& !candidate.contains(&b':')
&& candidate.iter().any(|c| c == &b'.')
{
return ValidationResult::Invalid;
}
// Reject candidates that look like version constraints or email addresses, e.g.: `next@latest`, `bob@example.com`
if candidate
.iter()
.all(|c| c.is_ascii_alphanumeric() || c == &b'.' || c == &b'-' || c == &b'@')
&& candidate[1..].contains(&b'@')
{
return ValidationResult::Invalid;
}
// Reject candidates that look like URLs
if candidate.starts_with(b"http://") || candidate.starts_with(b"https://") {
return ValidationResult::Invalid;
}
// Reject candidates that look short markdown links, e.g.: `[https://example.com]`
if candidate.starts_with(b"[http://") || candidate.starts_with(b"[https://") {
return ValidationResult::Invalid;
}
// Reject candidates that look like imports with path aliases, e.g.: `@/components/button`
if candidate.len() > 1 && candidate[1] == b'/' {
return ValidationResult::Invalid;
}
// Reject candidates that look like paths, e.g.: `app/assets/stylesheets`
if !candidate.contains(&b':') && !candidate.contains(&b'[') {
let mut count = 0;
for c in candidate {
if c == &b'/' {
count += 1;
}
if count > 1 {
return ValidationResult::Invalid;
}
}
}
let split_candidate = Extractor::split_candidate(candidate);
let mut offset = 0;
let mut offset_end = 0;
let utility = &split_candidate.utility;
let original_utility = &utility;
// Some special cases that we can ignore while validating
if utility.starts_with(b"!-") {
offset += 2;
} else if utility.starts_with(b"!") || utility.starts_with(b"-") {
offset += 1;
} else if utility.ends_with(b"!") {
offset_end += 1;
}
// These are allowed in arbitrary values and in variants but nowhere else
if Extractor::contains_in_constrained(utility, vec![b'<', b'>']) {
return ValidationResult::Restart;
}
// It's an arbitrary property
if utility.starts_with(b"[")
&& utility.ends_with(b"]")
&& (utility.starts_with(b"['")
|| utility.starts_with(b"[\"")
|| utility.starts_with(b"[`"))
{
return ValidationResult::Restart;
}
// Only allow parentheses for the shorthand arbitrary custom properties syntax
if let Some(index) = utility.find(b"(") {
let mut skip_parens_check = false;
let start_brace_index = utility.find(b"[");
let end_brace_index = utility.find(b"]");
if let (Some(start_brace_index), Some(end_brace_index)) =
(start_brace_index, end_brace_index)
{
if start_brace_index < index && end_brace_index > index {
skip_parens_check = true;
}
}
if !skip_parens_check && !utility[index + 1..].starts_with(b"--") {
return ValidationResult::Restart;
}
}
// Pluck out the part that we are interested in.
let utility = &utility[offset..(utility.len() - offset_end)];
// Validations
// We should have _something_
if utility.is_empty() {
return ValidationResult::Invalid;
}
// <sm is fine, but only as a variant
// TODO: We probably have to ensure that this `:` is not inside the arbitrary values...
if utility.starts_with(b"<") && !utility.contains(&b':') {
return ValidationResult::Invalid;
}
// Only variants can start with a number. E.g.: `2xl` is fine, but only as a variant.
// TODO: Adjust this if we run into issues with actual utilities starting with a number?
// TODO: We probably have to ensure that this `:` is not inside the arbitrary values...
if utility[0] >= b'0' && utility[0] <= b'9' && !utility.contains(&b':') {
return ValidationResult::Invalid;
}
// In case of an arbitrary property, we should have at least this structure: [a:b]
if utility.starts_with(b"[") && utility.ends_with(b"]") {
// [a:b] is at least 5 characters long
if utility.len() < 5 {
return ValidationResult::Invalid;
}
// Now that we validated that the candidate is technically fine, let's ensure that it
// doesn't start with a `-` because that would make it invalid for arbitrary properties.
if original_utility.starts_with(b"-") || original_utility.starts_with(b"!-") {
return ValidationResult::Invalid;
}
// Make sure an arbitrary property/value pair is valid, otherwise
// we may generate invalid CSS that will cause tools like PostCSS
// to crash when trying to parse the generated CSS.
if !Self::validate_arbitrary_property(utility) {
return ValidationResult::Invalid;
}
// The ':` must be preceded by a-Z0-9 because it represents a property name.
// SAFETY: the Self::validate_arbitrary_property function from above validates that the
// `:` exists.
let colon = utility.find(":").unwrap();
if !utility
.chars()
.nth(colon - 1)
.map_or_else(|| false, |c| c.is_ascii_alphanumeric())
{
return ValidationResult::Invalid;
}
let property = &utility[1..colon];
// The property must match /^[a-zA-Z-][a-zA-Z0-9-_]+$/
if !property[0].is_ascii_alphabetic() && property[0] != b'-' {
return ValidationResult::Invalid;
}
if !property
.iter()
.all(|c| c.is_ascii_alphanumeric() || c == &b'-' || c == &b'_')
{
return ValidationResult::Invalid;
}
}
ValidationResult::Valid
}
/**
* Make sure an arbitrary property/value pair is valid, otherwise
* PostCSS may crash when trying to parse the generated CSS.
*
* `input` - the full candidate string, including the brackets
*/
fn validate_arbitrary_property(candidate: &[u8]) -> bool {
if !candidate.starts_with(b"[") || !candidate.ends_with(b"]") {
return false;
}
let property = &candidate[1..candidate.len() - 1];
let is_custom_property = property.starts_with(b"--");
let Some(colon_pos) = property.find(b":") else {
return false;
};
if is_custom_property {
return true;
}
let mut stack = vec![];
let mut iter = property[colon_pos + 1..].iter();
while let Some(c) = iter.next() {
match c {
// The value portion cannot contain unquoted colons.
// E.g. `[foo::bar]` leads to "foo::bar; which errors because of the `:`.
b':' | b'{' | b'}' if stack.is_empty() => {
return false;
}
b'\'' => {
if let Some(b'\'') = stack.last() {
_ = stack.pop()
} else {
stack.push(b'\'')
}
}
b'"' => {
if let Some(b'"') = stack.last() {
_ = stack.pop()
} else {
stack.push(b'"')
}
}
// Skip escaped characters.
b'\\' => {
iter.next();
}
_ => {}
}
}
true
}
#[inline(always)]
fn parse_escaped(&mut self) -> ParseAction<'a> {
// If this character is escaped, we don't care about it.
// It gets consumed.
trace!("Escape::Consume");
self.in_escape = false;
ParseAction::Consume
}
#[inline(always)]
fn parse_arbitrary(&mut self) -> ParseAction<'a> {
// In this we could technically use memchr 6 times (then looped) to find the indexes / bounds of arbitrary values
if self.in_escape {
return self.parse_escaped();
}
match self.cursor.curr {
b'\\' => {
// The `\` character is used to escape characters in arbitrary content _and_ to prevent the starting of arbitrary content
trace!("Arbitrary::Escape");
self.in_escape = true;
}
b'(' => self.bracket_stack.push(self.cursor.curr),
b')' => match self.bracket_stack.last() {
Some(&b'(') => {
self.bracket_stack.pop();
}
// This is the last bracket meaning the end of arbitrary content
_ if !self.in_quotes() => {
if matches!(self.cursor.next, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9') {
return ParseAction::Consume;
}
if let Arbitrary::Parens { start_idx } = self.arbitrary {
trace!("Arbitrary::End\t");
self.arbitrary = Arbitrary::None;
if self.cursor.pos - start_idx == 1 {
// We have an empty arbitrary value, which is not allowed
return ParseAction::Skip;
}
// We have a valid arbitrary value
return ParseAction::Consume;
}
// Last parenthesis is different compared to what we expect, therefore we are
// not in a valid arbitrary value.
return ParseAction::Skip;
}
// We're probably in quotes or nested brackets, so we keep going
_ => {}
},
// Make sure the brackets are balanced
b'[' => self.bracket_stack.push(self.cursor.curr),
b']' => match self.bracket_stack.last() {
// We've ended a nested bracket
Some(&b'[') => {
self.bracket_stack.pop();
}
// This is the last bracket meaning the end of arbitrary content
_ if !self.in_quotes() => {
if matches!(self.cursor.next, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9') {
return ParseAction::Consume;
}
if let Arbitrary::Brackets { start_idx: _ } = self.arbitrary {
trace!("Arbitrary::End\t");
self.arbitrary = Arbitrary::None;
// TODO: This is temporarily disabled such that the upgrade tool can work
// with legacy arbitrary values. This will be re-enabled in the future (or
// with a flag)
// if self.cursor.pos - start_idx == 1 {
// // We have an empty arbitrary value, which is not allowed
// return ParseAction::Skip;
// }
}
}
// We're probably in quotes or nested brackets, so we keep going
_ => {}
},
// Arbitrary values sometimes contain quotes
// These can "escape" the arbitrary value mode
// switching of `[` and `]` characters
b'"' | b'\'' | b'`' => match self.quote_stack.last() {
Some(&last_quote) if last_quote == self.cursor.curr => {
trace!("Quote::End\t");
self.quote_stack.pop();
}
_ => {
trace!("Quote::Start\t");
self.quote_stack.push(self.cursor.curr);
}
},
c if c.is_ascii_whitespace() && !self.opts.preserve_spaces_in_arbitrary => {
trace!("Arbitrary::SkipAndEndEarly\t");
if let Arbitrary::Brackets { start_idx } | Arbitrary::Parens { start_idx } =
self.arbitrary
{
// Restart the parser ahead of the arbitrary value It may pick up more
// candidates
return ParseAction::RestartAt(start_idx + 1);
}
}
// Arbitrary values allow any character inside them
// Except spaces unless you are in loose mode
_ => {
trace!("Arbitrary::Consume\t");
// No need to move the end index because either the arbitrary value will end properly OR we'll hit invalid characters
}
}
ParseAction::Consume
}
#[inline(always)]
fn parse_start(&mut self) -> ParseAction<'a> {
match self.cursor.curr {
// Enter arbitrary property mode
b'[' if self.cursor.prev != b'\\' => {
trace!("Arbitrary::Start\t");
self.arbitrary = Arbitrary::Brackets {
start_idx: self.cursor.pos,
};
ParseAction::Consume
}
// Allowed first characters.
b'@' | b'!' | b'-' | b'<' | b'>' | b'0'..=b'9' | b'a'..=b'z' | b'A'..=b'Z' | b'*' => {
// TODO: A bunch of characters that we currently support but maybe we only want it behind
// a flag. E.g.: `<sm`
// | '$' | '^' | '_'
// When the new candidate is preceded by a `:`, then we want to keep parsing, but
// throw away the full candidate because it can not be a valid candidate at the end
// of the day.
if self.cursor.prev == b':' {
self.discard_next = true;
}
trace!("Candidate::Start\t");
ParseAction::Consume
}
_ => ParseAction::Skip,
}
}
#[inline(always)]
fn parse_continue(&mut self) -> ParseAction<'a> {
match self.cursor.curr {
// Enter arbitrary value mode. E.g.: `bg-[rgba(0, 0, 0)]`
// ^
b'[' if matches!(self.cursor.prev, b'@' | b'-' | b':' | b'/' | b'!' | b'\0')
|| self.cursor.prev.is_ascii_whitespace() =>
{
trace!("Arbitrary::Start\t");
self.arbitrary = Arbitrary::Brackets {
start_idx: self.cursor.pos,
};
}
// Enter arbitrary value mode. E.g.: `bg-(--my-color)`
// ^
b'(' if matches!(self.cursor.prev, b'-' | b'/') => {
trace!("Arbitrary::Start\t");
self.arbitrary = Arbitrary::Parens {
start_idx: self.cursor.pos,
};
}
// Can't enter arbitrary value mode. This can't be a candidate.
b'[' | b'(' => {
trace!("Arbitrary::Skip_Start\t");
return ParseAction::Skip;
}
// A % can only appear at the end of the candidate itself. It can also only be after a
// digit 0-9. This covers the following cases:
// - from-15%
b'%' if self.cursor.prev.is_ascii_digit() => {
return match (self.cursor.at_end, self.cursor.next) {
// End of string == end of candidate == okay
(true, _) => ParseAction::Consume,
// Looks like the end of a candidate == okay
(_, b'\'' | b'"' | b'`') => ParseAction::Consume,
(_, c) if c.is_ascii_whitespace() => ParseAction::Consume,
// Otherwise, not a valid character in a candidate
_ => ParseAction::Skip,
};
}
b'%' => return ParseAction::Skip,
// < and > can only be part of a variant and only be the first or last character
b'<' | b'>' | b'*' => {
// Can only be the first or last character
//
// E.g.:
//
// - <sm:underline
// ^
// - md>:underline
// ^
if self.cursor.pos == self.idx_start || self.cursor.pos == self.idx_last {
trace!("Candidate::Consume\t");
}
// If it is in the middle, it can only be part of a stacked variant
// - dark:<sm:underline
// ^
// - dark:md>:underline
// ^
else if self.cursor.prev == b':' || self.cursor.next == b':' {
trace!("Candidate::Consume\t");
} else {
return ParseAction::Skip;
}
}
// Allowed characters in the candidate itself
// None of these can come after a closing bracket `]`
b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b'@'
if self.cursor.prev != b']' =>
{
/* TODO: The `b'@'` is necessary for custom separators like _@, maybe we can handle this in a better way... */
trace!("Candidate::Consume\t");
}
// A dot (.) can only appear in the candidate itself (not the arbitrary part), if the previous
// and next characters are both digits. This covers the following cases:
// - p-1.5
b'.' if self.cursor.prev.is_ascii_digit() => match self.cursor.next {
next if next.is_ascii_digit() => {
trace!("Candidate::Consume\t");
}
_ => return ParseAction::Skip,
},
// Allowed characters in the candidate itself
// These MUST NOT appear at the end of the candidate
b'/' | b':' if !self.cursor.at_end => {
trace!("Candidate::Consume\t");
}
// The important character `!`, is allowed at the end of the candidate
b'!' => {
trace!("Candidate::Consume\t");
}
_ => return ParseAction::Skip,
}
ParseAction::Consume
}
#[inline(always)]
fn can_be_candidate(&mut self) -> bool {
self.in_candidate
&& matches!(self.arbitrary, Arbitrary::None)
&& (0..=127).contains(&self.cursor.curr)
&& (self.idx_start == 0 || self.input[self.idx_start - 1] <= 127)
}
#[inline(always)]
fn handle_skip(&mut self) {
// In all other cases, we skip characters and reset everything so we can make new candidates
trace!("Characters::Skip\t");
self.idx_start = self.cursor.pos;
self.idx_end = self.cursor.pos;
self.in_candidate = false;
self.arbitrary = Arbitrary::None;
self.in_escape = false;
}
#[inline(always)]
fn parse_char(&mut self) -> ParseAction<'a> {
if !matches!(self.arbitrary, Arbitrary::None) {
self.parse_arbitrary()
} else if self.in_candidate {
self.parse_continue()
} else if self.parse_start() == ParseAction::Consume {
self.in_candidate = true;
self.idx_start = self.cursor.pos;
self.idx_end = self.cursor.pos;
ParseAction::Consume
} else {
ParseAction::Skip
}
}
#[inline(always)]
fn yield_candidate(&mut self) -> ParseAction<'a> {
if self.can_be_candidate() {
self.get_current_candidate()
} else {
ParseAction::Consume
}
}
#[inline(always)]
fn restart(&mut self, pos: usize) {
trace!("Parser::Restart\t{}", pos);
self.idx_start = pos;
self.idx_end = pos;
self.arbitrary = Arbitrary::None;
self.in_candidate = false;
self.in_escape = false;
self.discard_next = false;
self.quote_stack.clear();
self.bracket_stack.clear();
self.cursor.move_to(pos);
}
#[inline(always)]
fn without_surrounding(&self) -> Bracketing<'a> {
let range = self.idx_start..=self.idx_end;
let clipped = &self.input[range];
Self::slice_surrounding(clipped)
.map(Bracketing::Included)
.or_else(|| {
if self.idx_start == 0 || self.idx_end + 1 == self.idx_last {
return None;
}
let range = self.idx_start - 1..=self.idx_end + 1;
let clipped = &self.input[range];
Self::slice_surrounding(clipped).map(Bracketing::Wrapped)
})
.unwrap_or(Bracketing::None)
}
#[inline(always)]
fn is_balanced(input: &[u8]) -> bool {
let mut depth = 0isize;
for n in input {
match n {
b'[' | b'{' | b'(' => depth += 1,
b']' | b'}' | b')' => depth -= 1,
_ => continue,
}
if depth < 0 {
return false;
}
}
depth == 0
}
#[inline(always)]
fn slice_surrounding(input: &[u8]) -> Option<&[u8]> {
let mut prev = None;
let mut input = input;
loop {
let leading = input.first().unwrap_or(&0x00);
let trailing = input.last().unwrap_or(&0x00);
let needed = matches!(
(leading, trailing),
(b'(', b')')
| (b'{', b'}')
| (b'[', b']')
| (b'"', b'"')
| (b'`', b'`')
| (b'\'', b'\'')
);
if needed {
prev = Some(input);
input = &input[1..input.len() - 1];
continue;
}
if Self::is_balanced(input) && prev.is_some() {
return Some(input);
}
return prev;
}
}
#[inline(always)]
fn parse_and_yield(&mut self) -> ParseAction<'a> {
trace!("Cursor {}", self.cursor);
// Fast skipping of invalid characters
let can_skip_whitespace = false; // if self.opts.preserve_spaces_in_arbitrary { !self.in_arbitrary } else { true };
if can_skip_whitespace {
if let Some(pos) = fast_skip(&self.cursor) {
trace!("FastSkip::Restart\t{}", pos);
return ParseAction::RestartAt(pos);
}
}
let action = self.parse_char();
match action {
ParseAction::RestartAt(_) => return action,
ParseAction::Consume => {
self.idx_end = self.cursor.pos;
// If we're still consuming characters, we keep going
// Only exception is if we've hit the end of the input
if !self.cursor.at_end {
return action;
}
}
_ => {}
}
let action = self.yield_candidate();
match (&action, self.cursor.curr) {
(ParseAction::RestartAt(_), _) => action,
(_, 0x00) => ParseAction::Done,
(ParseAction::SingleCandidate(candidate), _) => self.generate_slices(candidate),
_ => ParseAction::RestartAt(self.cursor.pos + 1),
}
}
/// Peek inside `[]`, `{}`, and `()` pairs
/// to look for an additional candidate
#[inline(always)]
fn generate_slices(&mut self, candidate: &'a [u8]) -> ParseAction<'a> {
match self.without_surrounding() {
Bracketing::None => ParseAction::SingleCandidate(candidate),
Bracketing::Included(sliceable) | Bracketing::Wrapped(sliceable) => {
if candidate == sliceable {
ParseAction::SingleCandidate(candidate)
} else {
let parts = vec![candidate, sliceable];
let parts = parts
.into_iter()
.filter(|v| !v.is_empty())
.collect::<Vec<_>>();
ParseAction::MultipleCandidates(parts)
}
}
}
}
}
impl<'a> Iterator for Extractor<'a> {
type Item = Vec<&'a [u8]>;
fn next(&mut self) -> Option<Self::Item> {
if self.cursor.at_end {
return None;
}
loop {
let result = self.parse_and_yield();
// Cursor control
match result {
ParseAction::RestartAt(pos) => self.restart(pos),
_ => self.cursor.advance_by(1),
}
// Candidate state control
match result {
ParseAction::SingleCandidate(_) => self.handle_skip(),
ParseAction::MultipleCandidates(_) => self.handle_skip(),
_ => {}
}
// Iterator results
return match result {
ParseAction::SingleCandidate(candidate) => Some(vec![candidate]),
ParseAction::MultipleCandidates(candidates) => Some(candidates),
ParseAction::Done => None,
_ => continue,
};
}
}
}
#[cfg(test)]
mod test {
use super::*;
fn _please_trace() {
tracing_subscriber::fmt()
.with_max_level(tracing::Level::TRACE)
.with_span_events(tracing_subscriber::fmt::format::FmtSpan::ACTIVE)
.compact()
.init();
}
fn run(input: &str, loose: bool) -> Vec<&str> {
Extractor::unique_ord(
input.as_bytes(),
ExtractorOptions {
preserve_spaces_in_arbitrary: loose,
},
)
.into_iter()
.map(|s| unsafe { std::str::from_utf8_unchecked(s) })
.collect()
}
#[test]
fn it_can_parse_simple_candidates() {
let candidates = run("underline", false);
assert_eq!(candidates, vec!["underline"]);
}
#[test]
fn it_can_parse_multiple_simple_utilities() {
let candidates = run("font-bold underline", false);
assert_eq!(candidates, vec!["font-bold", "underline"]);
}
#[test]
fn it_can_parse_simple_candidates_with_variants() {
let candidates = run("hover:underline", false);
assert_eq!(candidates, vec!["hover:underline"]);
}
#[test]
fn it_can_parse_start_variants() {
let candidates = run("*:underline", false);
assert_eq!(candidates, vec!["*:underline"]);
let candidates = run("hover:*:underline", false);
assert_eq!(candidates, vec!["hover:*:underline"]);
}
#[test]
fn it_can_parse_simple_candidates_with_stacked_variants() {
let candidates = run("focus:hover:underline", false);
assert_eq!(candidates, vec!["focus:hover:underline"]);
}
#[test]
fn it_can_parse_utilities_with_arbitrary_values() {
let candidates = run("m-[2px]", false);
assert_eq!(candidates, vec!["m-[2px]"]);
}
#[test]
fn it_can_parse_utilities_with_arbitrary_var_shorthand() {
let candidates = run("m-(--my-var)", false);
assert_eq!(candidates, vec!["m-(--my-var)"]);
}
#[test]
fn it_can_parse_utilities_with_arbitrary_var_shorthand_as_modifier() {
let candidates = run("bg-(--my-color)/(--my-opacity)", false);
assert_eq!(candidates, vec!["bg-(--my-color)/(--my-opacity)"]);
}
#[test]
fn it_throws_away_arbitrary_values_that_are_unbalanced() {
let candidates = run("m-[calc(100px*2]", false);
assert!(candidates.is_empty());
}
#[test]
fn it_can_parse_utilities_with_arbitrary_values_and_variants() {
let candidates = run("hover:m-[2px]", false);
assert_eq!(candidates, vec!["hover:m-[2px]"]);
}
#[test]
fn it_can_parse_arbitrary_variants() {
let candidates = run("[@media(min-width:200px)]:underline", false);
assert_eq!(candidates, vec!["[@media(min-width:200px)]:underline"]);
}
#[test]
fn it_can_parse_matched_variants() {
let candidates = run("group-[&:hover]:underline", false);
assert_eq!(candidates, vec!["group-[&:hover]:underline"]);
}
#[test]
fn it_should_not_keep_spaces() {
let candidates = run("bg-[rgba(0, 0, 0)]", false);
assert_eq!(candidates, vec!["rgba"]);
}
#[test]
fn it_should_keep_spaces_in_loose_mode() {
let candidates = run("bg-[rgba(0, 0, 0)]", true);
assert_eq!(candidates, vec!["bg-[rgba(0, 0, 0)]"]);
}
#[test]
fn it_should_keep_important_arbitrary_properties_legacy() {
let candidates = run("![foo:bar]", false);
assert_eq!(candidates, vec!["![foo:bar]"]);
}
#[test]
fn it_should_keep_important_arbitrary_properties() {
let candidates = run("[foo:bar]!", false);
assert_eq!(candidates, vec!["[foo:bar]!"]);
}
#[test]
fn it_should_keep_important_arbitrary_values() {
let candidates = run("w-[calc(var(--size)/2)]!", false);
assert_eq!(candidates, vec!["w-[calc(var(--size)/2)]!"]);
}
#[test]
fn it_should_keep_important_candidates_legacy() {
let candidates = run("!w-4", false);
assert_eq!(candidates, vec!["!w-4"]);
}
#[test]
fn it_should_keep_important_candidates() {
let candidates = run("w-4!", false);
assert_eq!(candidates, vec!["w-4!"]);
}
#[test]
fn it_should_not_allow_for_bogus_candidates() {
let candidates = run("[0]", false);
assert!(candidates.is_empty());
let candidates = run("[something]", false);
assert_eq!(candidates, vec!["something"]);
let candidates = run(" [feature(slice_as_chunks)]", false);
assert_eq!(candidates, vec!["feature", "slice_as_chunks"]);
let candidates = run("![feature(slice_as_chunks)]", false);
assert!(candidates.is_empty());
let candidates = run("-[feature(slice_as_chunks)]", false);
assert!(candidates.is_empty());
let candidates = run("!-[feature(slice_as_chunks)]", false);
assert!(candidates.is_empty());
let candidates = run("-[foo:bar]", false);
assert!(candidates.is_empty());
let candidates = run("!-[foo:bar]", false);
assert!(candidates.is_empty());
}
#[test]
fn it_should_keep_candidates_with_brackets_in_arbitrary_values_inside_quotes() {
let candidates = run("content-['hello_[_]_world']", false);
assert_eq!(candidates, vec!["content-['hello_[_]_world']"]);
}
#[test]
fn it_should_ignore_leading_spaces() {
let candidates = run(" backdrop-filter-none", false);
assert_eq!(candidates, vec!["backdrop-filter-none"]);
}
#[test]
fn it_should_ignore_trailing_spaces() {
let candidates = run("backdrop-filter-none ", false);
assert_eq!(candidates, vec!["backdrop-filter-none"]);
}
#[test]
fn it_should_keep_classes_before_an_ending_newline() {
let candidates = run("backdrop-filter-none\n", false);
assert_eq!(candidates, vec!["backdrop-filter-none"]);
}
#[test]
fn it_should_parse_out_the_correct_classes_from_tailwind_tests() {
// From: tests/arbitrary-variants.test.js
let candidates = run(
r#"
<div class="dark:lg:hover:[&>*]:underline"></div>
<div class="[&_.foo\_\_bar]:hover:underline"></div>
<div class="hover:[&_.foo\_\_bar]:underline"></div>
"#,
false,
);
// TODO: it should not include additional (separate) classes: class, hover:, foo: bar, underline
// TODO: Double check the expectations based on above information
assert_eq!(
candidates,
vec![
"div",
"class",
r#"dark:lg:hover:[&>*]:underline"#,
r#"[&_.foo\_\_bar]:hover:underline"#,
r#"hover:[&_.foo\_\_bar]:underline"#
]
);
}
#[test]
fn potential_candidates_are_skipped_when_hitting_impossible_characters() {
let candidates = run(" <p class=\"text-sm text-blue-700\">A new software update is available. See whats new in version 2.0.4.</p>", false);
assert_eq!(
candidates,
vec![
"p",
"class",
"text-sm",
"text-blue-700",
// "A", // Uppercase first letter is not allowed
"new",
"software",
"update",
"is",
"available",
// "See", // Uppercase first letter is not allowed
// "what", // what is dropped because it is followed by the fancy:
// "s", // s is dropped because it is preceded by the fancy:
// "new", // Already seen
"in",
"version",
]
);
}
#[test]
fn ignores_arbitrary_property_ish_things() {
let candidates = run(" [feature(slice_as_chunks)]", false);
assert_eq!(candidates, vec!["feature", "slice_as_chunks",]);
}
#[test]
fn foo_bar() {
// w[…] is not a valid pattern for part of candidate
// but @[] is (specifically in the context of a variant)
let candidates = run("%w[text-[#bada55]]", false);
assert_eq!(candidates, vec!["w", "text-[#bada55]"]);
}
#[test]
fn crash_001() {
let candidates = run("Aҿɿ[~5", false);
assert!(candidates.is_empty());
}
#[test]
fn crash_002() {
let candidates = run("", false);
assert!(candidates.is_empty());
}
#[test]
fn bad_001() {
let candidates = run("[杛杛]/", false);
assert_eq!(candidates, vec!["杛杛"]);
}
#[test]
fn bad_002() {
let candidates = run(r"[\]\\\:[]", false);
assert!(candidates.is_empty());
}
#[test]
fn bad_003() {
// TODO: This seems… wrong
let candidates = run(r"[𕤵:]", false);
assert_eq!(candidates, vec!["𕤵", "𕤵:",]);
}
#[test]
fn classes_in_js_arrays() {
let candidates = run(
r#"let classes = ['bg-black', 'hover:px-0.5', 'text-[13px]', '[--my-var:1_/_2]', '[.foo_&]:px-[0]', '[.foo_&]:[color:red]']">"#,
false,
);
assert_eq!(
candidates,
vec![
"let",
"classes",
"bg-black",
"hover:px-0.5",
"text-[13px]",
"[--my-var:1_/_2]",
"--my-var:1_/_2",
"[.foo_&]:px-[0]",
"[.foo_&]:[color:red]",
]
);
}
#[test]
fn classes_in_js_arrays_without_spaces() {
let candidates = run(
r#"let classes = ['bg-black','hover:px-0.5','text-[13px]','[--my-var:1_/_2]','[.foo_&]:px-[0]','[.foo_&]:[color:red]']">"#,
false,
);
assert_eq!(
candidates,
vec![
"let",
"classes",
"bg-black",
"hover:px-0.5",
"text-[13px]",
"[--my-var:1_/_2]",
"--my-var:1_/_2",
"[.foo_&]:px-[0]",
"[.foo_&]:[color:red]",
]
);
}
#[test]
fn classes_as_object_keys() {
let candidates = run(
r#"<div :class="{ underline: active, 'px-1.5': online }"></div>"#,
false,
);
assert_eq!(
candidates,
vec!["div", "underline", "active", "px-1.5", "online"]
);
}
#[test]
fn multiple_nested_candidates() {
let candidates = run(r#"{color:red}"#, false);
assert_eq!(candidates, vec!["color:red"]);
}
#[test]
fn percent_ended_candidates() {
let candidates = run(
r#"<!-- This should work `underline from-50% flex` -->"#,
false,
);
assert_eq!(
candidates,
vec!["should", "work", "underline", "from-50%", "flex",]
);
}
#[test]
fn candidate_cannot_start_with_uppercase_character() {
let candidates = run(r#"<div class="foo Bar baz"></div>"#, false);
assert_eq!(candidates, vec!["div", "class", "foo", "baz"]);
}
#[test]
fn candidate_cannot_end_with_a_dash() {
let candidates = run(r#"<div class="foo bar- baz"></div>"#, false);
assert_eq!(candidates, vec!["div", "class", "foo", "baz"]);
}
#[test]
fn candidate_cannot_end_with_an_underscore() {
let candidates = run(r#"<div class="foo bar_ baz"></div>"#, false);
assert_eq!(candidates, vec!["div", "class", "foo", "baz"]);
}
#[test]
fn candidate_cannot_be_a_single_camelcase_word() {
let candidates = run(r#"<div class="foo useEffect baz"></div>"#, false);
assert_eq!(candidates, vec!["div", "class", "foo", "baz"]);
}
#[test]
fn candidate_cannot_be_svg_path_data() {
let candidates = run(r#"<path d="M25.517 0C18.712">"#, false);
assert_eq!(candidates, vec!["path", "d"]);
}
#[test]
fn candidate_cannot_be_email_or_version_constraint() {
let candidates = run(r#"<div class="@container/dialog"> next@latest"#, false);
assert_eq!(candidates, vec!["div", "class", "@container/dialog"]);
}
#[test]
fn candidate_cannot_be_a_url() {
let candidates = run(
r#"Our website is https://example.com or http://example.com if you want a virus"#,
false,
);
assert_eq!(
candidates,
vec!["website", "is", "com", "or", "if", "you", "want", "a", "virus"]
);
}
#[test]
fn candidate_cannot_be_a_paths_with_aliases() {
let candidates = run(r#"import potato from '@/potato';"#, false);
assert_eq!(candidates, vec!["import", "potato", "from"]);
}
#[test]
fn candidate_cannot_be_a_path() {
let candidates = run(
r#"import potato from 'some/path/to/something';
import banana from '@/banana';"#,
false,
);
assert_eq!(candidates, vec!["import", "potato", "from", "banana"]);
}
#[test]
fn ruby_percent_formatted_strings() {
let candidates = run(r#"%w[hover:flex]"#, false);
assert_eq!(candidates, vec!["w", "hover:flex"]);
}
#[test]
fn urls_in_arbitrary_values_are_ok() {
let candidates = run(r#"<div class="bg-[url('/img/hero-pattern.svg')]">"#, false);
assert_eq!(
candidates,
vec!["div", "class", "bg-[url('/img/hero-pattern.svg')]"]
);
}
#[test]
fn colon_in_arbitrary_property_value() {
let candidates = run("[color::] #[test::foo]", false);
assert!(candidates
.iter()
.all(|candidate| !candidate.starts_with('[')));
}
#[test]
fn braces_in_arbitrary_property_value() {
let candidates = run("[color:${foo}] #[test:{foo}]", false);
assert!(candidates
.iter()
.all(|candidate| !candidate.starts_with('[')));
}
#[test]
fn quoted_colon_in_arbitrary_property_value() {
let candidates = run("[content:'bar:bar'] [content:\"bar:bar\"]", false);
assert!(candidates
.iter()
.any(|candidate| candidate == &"[content:'bar:bar']"));
assert!(candidates
.iter()
.any(|candidate| candidate == &"[content:\"bar:bar\"]"));
}
#[test]
fn quoted_braces_in_arbitrary_property_value() {
let candidates = run("[content:'{bar}'] [content:\"{bar}\"]", false);
assert!(candidates
.iter()
.any(|candidate| candidate == &"[content:'{bar}']"));
assert!(candidates
.iter()
.any(|candidate| candidate == &"[content:\"{bar}\"]"));
}
#[test]
fn colon_in_custom_property_value() {
let candidates = run("[--foo:bar:bar]", false);
assert!(candidates
.iter()
.any(|candidate| candidate == &"[--foo:bar:bar]"));
}
#[test]
fn braces_in_custom_property_value() {
let candidates = run("[--foo:{bar}]", false);
assert!(candidates
.iter()
.any(|candidate| candidate == &"[--foo:{bar}]"));
}
#[test]
fn candidate_slicing() {
let result = Extractor::slice_surrounding(&b".foo_&]:px-[0"[..])
.map(std::str::from_utf8)
.transpose()
.unwrap();
assert_eq!(result, None);
let result = Extractor::slice_surrounding(&b"[.foo_&]:px-[0]"[..])
.map(std::str::from_utf8)
.transpose()
.unwrap();
assert_eq!(result, Some("[.foo_&]:px-[0]"));
let result = Extractor::slice_surrounding(&b"{[.foo_&]:px-[0]}"[..])
.map(std::str::from_utf8)
.transpose()
.unwrap();
assert_eq!(result, Some("[.foo_&]:px-[0]"));
let result = Extractor::slice_surrounding(&b"![foo:bar]"[..])
.map(std::str::from_utf8)
.transpose()
.unwrap();
assert_eq!(result, None);
let result = Extractor::slice_surrounding(&b"[\"pt-1.5\"]"[..])
.map(std::str::from_utf8)
.transpose()
.unwrap();
assert_eq!(result, Some("pt-1.5"));
let count = 1_000;
let crazy = format!("{}[.foo_&]:px-[0]{}", "[".repeat(count), "]".repeat(count));
let result = Extractor::slice_surrounding(crazy.as_bytes())
.map(std::str::from_utf8)
.transpose()
.unwrap();
assert_eq!(result, Some("[.foo_&]:px-[0]"));
}
#[test]
fn does_not_emit_the_same_slice_multiple_times() {
let candidates: Vec<_> =
Extractor::with_positions("<div class=\"flex\"></div>".as_bytes(), Default::default())
.into_iter()
.map(|(s, p)| unsafe { (std::str::from_utf8_unchecked(s), p) })
.collect();
assert_eq!(candidates, vec![("div", 1), ("class", 5), ("flex", 12),]);
}
#[test]
fn empty_arbitrary_values_are_allowed_for_codemods() {
let candidates = run(
r#"<div class="group-[]:flex group-[]/name:flex peer-[]:flex peer-[]/name:flex"></div>"#,
false,
);
assert_eq!(
candidates,
vec![
"div",
"class",
"group-[]:flex",
"group-[]/name:flex",
"peer-[]:flex",
"peer-[]/name:flex"
]
);
}
#[test]
fn simple_utility_names_with_numbers_work() {
let candidates = run(r#"<div class="h2 hz"></div>"#, false);
assert_eq!(candidates, vec!["div", "class", "h2", "hz",]);
}
#[test]
fn classes_in_an_array_without_whitespace() {
let candidates = run(
"let classes = ['bg-black','hover:px-0.5','text-[13px]','[--my-var:1_/_2]','[.foo_&]:px-[0]','[.foo_&]:[color:red]']",
false,
);
assert_eq!(
candidates,
vec![
"let",
"classes",
"bg-black",
"hover:px-0.5",
"text-[13px]",
"[--my-var:1_/_2]",
"--my-var:1_/_2",
"[.foo_&]:px-[0]",
"[.foo_&]:[color:red]",
]
);
}
#[test]
fn classes_in_an_array_with_spaces() {
let candidates = run(
"let classes = ['bg-black', 'hover:px-0.5', 'text-[13px]', '[--my-var:1_/_2]', '[.foo_&]:px-[0]', '[.foo_&]:[color:red]']",
false,
);
assert_eq!(
candidates,
vec![
"let",
"classes",
"bg-black",
"hover:px-0.5",
"text-[13px]",
"[--my-var:1_/_2]",
"--my-var:1_/_2",
"[.foo_&]:px-[0]",
"[.foo_&]:[color:red]",
]
);
}
#[test]
fn classes_in_an_array_with_tabs() {
let candidates = run(
"let classes = ['bg-black',\t'hover:px-0.5',\t'text-[13px]',\t'[--my-var:1_/_2]',\t'[.foo_&]:px-[0]',\t'[.foo_&]:[color:red]']",
false,
);
assert_eq!(
candidates,
vec![
"let",
"classes",
"bg-black",
"hover:px-0.5",
"text-[13px]",
"[--my-var:1_/_2]",
"--my-var:1_/_2",
"[.foo_&]:px-[0]",
"[.foo_&]:[color:red]",
]
);
}
#[test]
fn classes_in_an_array_with_newlines() {
let candidates = run(
"let classes = [\n'bg-black',\n'hover:px-0.5',\n'text-[13px]',\n'[--my-var:1_/_2]',\n'[.foo_&]:px-[0]',\n'[.foo_&]:[color:red]'\n]",
false,
);
assert_eq!(
candidates,
vec![
"let",
"classes",
"bg-black",
"hover:px-0.5",
"text-[13px]",
"[--my-var:1_/_2]",
"--my-var:1_/_2",
"[.foo_&]:px-[0]",
"[.foo_&]:[color:red]",
]
);
}
#[test]
fn arbitrary_properties_are_not_picked_up_after_an_escape() {
let candidates = run(
r#"
<!-- [!code word:group-has-\\[a\\]\\:block] -->
\\[a\\]\\:block]
"#,
false,
);
assert_eq!(candidates, vec!["!code", "a"]);
}
#[test]
fn test_find_candidates_in_braces_inside_brackets() {
let candidates = run(
r#"
const classes = [wrapper("bg-red-500")]
"#,
false,
);
assert_eq!(
candidates,
vec!["const", "classes", "wrapper", "bg-red-500"]
);
}
#[test]
fn test_find_css_variables() {
let candidates = run("var(--color-red-500)", false);
assert_eq!(candidates, vec!["var", "--color-red-500"]);
let candidates = run("<div style={{ 'color': 'var(--color-red-500)' }}/>", false);
assert_eq!(
candidates,
vec!["div", "style", "color", "var", "--color-red-500"]
);
}
#[test]
fn test_find_css_variables_with_fallback_values() {
let candidates = run("var(--color-red-500, red)", false);
assert_eq!(candidates, vec!["var", "--color-red-500", "red"]);
let candidates = run("var(--color-red-500,red)", false);
assert_eq!(candidates, vec!["var", "--color-red-500", "red"]);
let candidates = run(
"<div style={{ 'color': 'var(--color-red-500, red)' }}/>",
false,
);
assert_eq!(
candidates,
vec!["div", "style", "color", "var", "--color-red-500", "red"]
);
let candidates = run(
"<div style={{ 'color': 'var(--color-red-500,red)' }}/>",
false,
);
assert_eq!(
candidates,
vec!["div", "style", "color", "var", "--color-red-500", "red"]
);
}
#[test]
fn test_find_css_variables_with_fallback_css_variable_values() {
let candidates = run("var(--color-red-500, var(--color-blue-500))", false);
assert_eq!(
candidates,
vec!["var", "--color-red-500", "--color-blue-500"]
);
}
#[test]
fn test_is_valid_candidate_string() {
assert_eq!(
Extractor::is_valid_candidate_string(b"foo"),
ValidationResult::Valid
);
assert_eq!(
Extractor::is_valid_candidate_string(b"foo-(--color-red-500)"),
ValidationResult::Valid
);
assert_eq!(
Extractor::is_valid_candidate_string(b"bg-[url(foo)]"),
ValidationResult::Valid
);
assert_eq!(
Extractor::is_valid_candidate_string(b"group-foo/(--bar)"),
ValidationResult::Valid
);
assert_eq!(
Extractor::is_valid_candidate_string(b"foo(\"bg-red-500\")"),
ValidationResult::Restart
);
assert_eq!(
Extractor::is_valid_candidate_string(b"foo-("),
ValidationResult::Restart
);
}
}