Compare commits

..

1 commit

Author SHA1 Message Date
Jordan Pittman
9644c1844a Use custom implementation of canonicalize for WASI 2025-09-25 16:52:21 -04:00
344 changed files with 24521 additions and 55941 deletions

View file

@ -50,7 +50,7 @@ If you open a pull request for a new feature, we're likely to close it not becau
## Coding standards
Our code formatting rules are defined in the `"prettier"` section of [package.json](https://github.com/tailwindlabs/tailwindcss/blob/main/package.json). You can check your code against these standards by running:
Our code formatting rules are defined in the `"prettier"` section of [package.json](https://github.com/tailwindcss/tailwindcss/blob/main/package.json). You can check your code against these standards by running:
```sh
pnpm run lint
@ -76,7 +76,7 @@ To run the integration tests, use:
pnpm build && pnpm test:integrations
```
Additionally, some features require testing in browsers (i.e. to ensure CSS variable resolution works as expected). These can be run via:
Additionally, some features require testing in browsers (i.e to ensure CSS variable resolution works as expected). These can be run via:
```sh
pnpm build && pnpm test:ui

View file

@ -8,7 +8,7 @@ It's never a fun experience to have your pull request declined after investing a
For more info, check out the contributing guide:
https://github.com/tailwindlabs/tailwindcss/blob/main/.github/CONTRIBUTING.md
https://github.com/tailwindcss/tailwindcss/blob/main/.github/CONTRIBUTING.md
-->

View file

@ -8,18 +8,12 @@ on:
permissions:
contents: read
env:
NODE_VERSION: 24
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
tests:
strategy:
fail-fast: false
matrix:
node-version: [20]
runner:
- name: Windows
os: windows-latest
@ -27,14 +21,12 @@ jobs:
- name: Linux
os: namespace-profile-default
# Playwright 1.62+ dropped WebKit support for macOS 14, and hangs
# instead of failing when launching WebKit on a macos-14 runner.
- name: macOS
os: macos-15
os: macos-14
# Exclude windows and macos from being built on feature branches
run-all:
- ${{ github.ref == 'refs/heads/main' || contains(github.event.pull_request.body, '[ci-all]') || github.event.pull_request.user.login == 'depfu[bot]' }}
- ${{ github.ref == 'refs/heads/main' || contains(github.event.pull_request.body, '[ci-all]') }}
exclude:
- run-all: false
runner:
@ -49,21 +41,21 @@ jobs:
name: ${{ matrix.runner.name }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- name: Use Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
node-version: ${{ matrix.node-version }}
cache: 'pnpm'
# Cargo already skips downloading dependencies if they already exist
- name: Cache cargo
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
@ -72,7 +64,7 @@ jobs:
# Cache the `oxide` Rust build
- name: Cache oxide build
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
uses: actions/cache@v4
with:
path: |
./crates/node/*.node
@ -90,7 +82,7 @@ jobs:
run: rustup target add wasm32-wasip1-threads
- name: Install dependencies
run: pnpm install --frozen-lockfile
run: pnpm install
- name: Build
run: pnpm run build
@ -112,13 +104,9 @@ jobs:
- name: Run Playwright tests
run: npm run test:ui
notify:
if: ${{ always() && github.ref == 'refs/heads/main' && needs.tests.result == 'failure' }}
needs: tests
runs-on: ubuntu-latest
steps:
- name: Notify Discord
uses: discord-actions/message@5c7149c81a83146e5d01f142be1bf87a61831c4d # v2
if: failure() && github.ref == 'refs/heads/main'
uses: discord-actions/message@v2
with:
webhookUrl: ${{ secrets.DISCORD_WEBHOOK_URL }}
message: 'The [most recent ${{ github.workflow }} workflow](<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}>) on the `main` branch has failed.'
message: 'The [most recent build](<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}>) on the `main` branch has failed.'

View file

@ -8,18 +8,13 @@ on:
permissions:
contents: read
env:
NODE_VERSION: 24
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
tests:
strategy:
fail-fast: false
matrix:
node-version: [20]
runner:
- name: Windows
os: windows-latest
@ -55,25 +50,21 @@ jobs:
name: ${{ matrix.runner.name }} / ${{ matrix.integration }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Use Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
node-version: ${{ matrix.node-version }}
cache: 'pnpm'
# Cargo already skips downloading dependencies if they already exist
- name: Cache cargo
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
@ -82,7 +73,7 @@ jobs:
# Cache the `oxide` Rust build
- name: Cache oxide build
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
uses: actions/cache@v4
with:
path: |
./crates/node/*.node
@ -108,18 +99,24 @@ jobs:
CARGO_PROFILE_RELEASE_LTO: 'off'
CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_LINKER: 'lld-link'
- name: Test ${{ matrix.integration }}
run: pnpm run test:integrations ./integrations/${{ matrix.integration }}
- name: Test ${{ matrix.integration }} 1/3
run: pnpm run test:integrations ./integrations/${{ matrix.integration }} --shard 1/3
env:
GITHUB_WORKSPACE: ${{ github.workspace }}
- name: Test ${{ matrix.integration }} 2/3
run: pnpm run test:integrations ./integrations/${{ matrix.integration }} --shard 2/3
env:
GITHUB_WORKSPACE: ${{ github.workspace }}
- name: Test ${{ matrix.integration }} 3/3
run: pnpm run test:integrations ./integrations/${{ matrix.integration }} --shard 3/3
env:
GITHUB_WORKSPACE: ${{ github.workspace }}
notify:
if: ${{ always() && github.ref == 'refs/heads/main' && needs.tests.result == 'failure' }}
needs: tests
runs-on: ubuntu-latest
steps:
- name: Notify Discord
uses: discord-actions/message@5c7149c81a83146e5d01f142be1bf87a61831c4d # v2
if: failure() && github.ref == 'refs/heads/main'
uses: discord-actions/message@v2
with:
webhookUrl: ${{ secrets.DISCORD_WEBHOOK_URL }}
message: 'The [most recent ${{ github.workflow }} workflow](<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}>) on the `main` branch has failed.'
message: 'The [most recent build](<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}>) on the `main` branch has failed.'

View file

@ -2,29 +2,18 @@ name: Prepare Release
on:
workflow_dispatch:
inputs:
dry_run:
description: Skip creating the draft GitHub release
required: false
default: true
type: boolean
push:
tags:
- 'v*'
env:
APP_NAME: tailwindcss-oxide
NODE_VERSION: 24
PNPM_VERSION: '11.9.0'
NODE_VERSION: 20
OXIDE_LOCATION: ./crates/node
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
strategy:
@ -54,40 +43,44 @@ jobs:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
strip: strip
build-flags: --use-napi-cross
container:
image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian
- os: ubuntu-latest
target: aarch64-unknown-linux-gnu
strip: aarch64-linux-gnu-strip
build-flags: --use-napi-cross
strip: llvm-strip
container:
image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian-aarch64
- os: ubuntu-latest
target: armv7-unknown-linux-gnueabihf
strip: arm-linux-gnueabihf-strip
build-flags: --use-napi-cross
strip: llvm-strip
container:
image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian-zig
- os: ubuntu-latest
target: aarch64-unknown-linux-musl
strip-zig: true
build-flags: -x
strip: aarch64-linux-musl-strip
download: true
container:
image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-alpine
- os: ubuntu-latest
target: x86_64-unknown-linux-musl
strip: strip
build-flags: -x
download: true
container:
image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-alpine
name: Build ${{ matrix.target }} (oxide)
runs-on: ${{ matrix.os }}
container: ${{ matrix.container }}
timeout-minutes: 15
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with:
version: ${{ env.PNPM_VERSION }}
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- name: Use Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
package-manager-cache: false
cache: 'pnpm'
- name: Install gcc-arm-linux-gnueabihf
if: ${{ matrix.target == 'armv7-unknown-linux-gnueabihf' }}
@ -95,56 +88,62 @@ jobs:
sudo apt-get update
sudo apt-get install gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf -y
- name: Install binutils-aarch64-linux-gnu
if: ${{ matrix.target == 'aarch64-unknown-linux-gnu' }}
# Cargo already skips downloading dependencies if they already exist
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }}
# Cache the `oxide` Rust build
- name: Cache oxide build
uses: actions/cache@v4
with:
path: |
./crates/node/*.node
./crates/node/*.wasm
./crates/node/index.d.ts
./crates/node/index.js
./crates/node/browser.js
./crates/node/tailwindcss-oxide.wasi-browser.js
./crates/node/tailwindcss-oxide.wasi.cjs
./crates/node/wasi-worker-browser.mjs
./crates/node/wasi-worker.mjs
key: ${{ runner.os }}-${{ matrix.target }}-oxide-${{ hashFiles('./crates/**/*') }}
- name: Install Node.JS
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install Rust (Stable)
if: ${{ matrix.download }}
run: |
sudo apt-get update
sudo apt-get install binutils-aarch64-linux-gnu -y
- uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2
if: ${{ contains(matrix.target, 'musl') }}
with:
version: 0.14.1
use-cache: false
- name: Install cargo-zigbuild
uses: taiki-e/install-action@65851e10cd6c377f11a60e600abc07cb08643468 # v2
if: ${{ contains(matrix.target, 'musl') }}
env:
GITHUB_TOKEN: ${{ github.token }}
with:
tool: cargo-zigbuild
rustup default stable
- name: Setup rust target
run: rustup target add ${{ matrix.target }}
- name: Install dependencies
run: pnpm install --ignore-scripts --frozen-lockfile --filter=!./playgrounds/*
run: pnpm install --ignore-scripts --filter=!./playgrounds/*
- name: Build release
run: pnpm run --filter ${{ env.OXIDE_LOCATION }} build:platform --target=${{ matrix.target }} ${{ matrix.build-flags }}
run: pnpm run --filter ${{ env.OXIDE_LOCATION }} build:platform --target=${{ matrix.target }}
env:
RUST_TARGET: ${{ matrix.target }}
JEMALLOC_SYS_WITH_LG_PAGE: ${{ matrix.page-size }}
- name: Strip debug symbols # https://github.com/rust-lang/rust/issues/46034
if: ${{ matrix.strip || matrix.strip-zig }}
env:
STRIP_COMMAND: ${{ matrix.strip }}
STRIP_ZIG: ${{ matrix.strip-zig }}
run: |
if [ "$STRIP_ZIG" = "true" ]; then
for file in ${{ env.OXIDE_LOCATION }}/*.node; do
zig objcopy --strip-all "$file" "$file.stripped"
mv "$file.stripped" "$file"
done
exit 0
fi
eval "$STRIP_COMMAND ${{ env.OXIDE_LOCATION }}/*.node"
if: ${{ matrix.strip }}
run: ${{ matrix.strip }} ${{ env.OXIDE_LOCATION }}/*.node
- name: Upload artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@v4
with:
name: bindings-${{ matrix.target }}
path: ${{ env.OXIDE_LOCATION }}/*.node
@ -154,11 +153,9 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- uses: actions/checkout@v4
- name: Build FreeBSD
uses: cross-platform-actions/action@cdc9ee69ef84a5f2e59c9058335d9c57bcb4ac86 # v0.25.0
uses: cross-platform-actions/action@v0.25.0
env:
DEBUG: napi:*
RUSTUP_HOME: /usr/local/rustup
@ -174,12 +171,12 @@ jobs:
shell: bash
run: |
sudo pkg install -y -f curl node libnghttp2 npm
sudo npm install -g pnpm@${{ env.PNPM_VERSION }} --unsafe-perm=true
sudo npm install -g pnpm@9.6.0 --unsafe-perm=true
curl -sSf https://static.rust-lang.org/rustup/archive/1.27.1/x86_64-unknown-freebsd/rustup-init --output rustup-init
chmod +x rustup-init
./rustup-init -y --profile minimal
source "$HOME/.cargo/env"
pnpm install --ignore-scripts --frozen-lockfile --filter=!./playgrounds/* || true
pnpm install --ignore-scripts --filter=!./playgrounds/* || true
echo "~~~~ rustc --version ~~~~"
rustc --version
echo "~~~~ node -v ~~~~"
@ -190,7 +187,7 @@ jobs:
strip -x ${{ env.OXIDE_LOCATION }}/*.node
ls -la ${{ env.OXIDE_LOCATION }}
- name: Upload artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@v4
with:
name: bindings-x86_64-unknown-freebsd
path: ${{ env.OXIDE_LOCATION }}/*.node
@ -201,17 +198,18 @@ jobs:
name: Build and release Tailwind CSS
permissions:
contents: write # Required for creating releases
contents: write # for softprops/action-gh-release to create GitHub release
# https://docs.npmjs.com/generating-provenance-statements#publishing-packages-with-provenance-via-github-actions
id-token: write
needs:
- build
- build-freebsd
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v4
with:
fetch-depth: 20
persist-credentials: false
- run: git fetch --tags -f
@ -220,25 +218,51 @@ jobs:
run: |
echo "TAG_NAME=$(git describe --tags --abbrev=0)" >> $GITHUB_ENV
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with:
version: ${{ env.PNPM_VERSION }}
- uses: pnpm/action-setup@v4
- name: Use Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
registry-url: 'https://registry.npmjs.org'
package-manager-cache: false
# Cargo already skips downloading dependencies if they already exist
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }}
# Cache the `oxide` Rust build
- name: Cache oxide build
uses: actions/cache@v4
with:
path: |
./crates/node/*.node
./crates/node/*.wasm
./crates/node/index.d.ts
./crates/node/index.js
./crates/node/browser.js
./crates/node/tailwindcss-oxide.wasi-browser.js
./crates/node/tailwindcss-oxide.wasi.cjs
./crates/node/wasi-worker-browser.mjs
./crates/node/wasi-worker.mjs
key: ${{ runner.os }}-${{ matrix.target }}-oxide-${{ hashFiles('./crates/**/*') }}
- name: Setup WASM target
run: rustup target add wasm32-wasip1-threads
- name: Install dependencies
run: pnpm --filter=!./playgrounds/* install --frozen-lockfile
run: pnpm --filter=!./playgrounds/* install
- name: Download artifacts
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
uses: actions/download-artifact@v4
with:
path: ${{ env.OXIDE_LOCATION }}
@ -277,31 +301,30 @@ jobs:
echo "EOF" >> $GITHUB_ENV
- name: Upload standalone artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@v4
with:
name: tailwindcss-standalone
path: packages/@tailwindcss-standalone/dist/
- name: Upload npm package tarballs
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@v4
with:
name: npm-package-tarballs
path: dist/*.tgz
- name: Prepare GitHub Release
if: ${{ !inputs.dry_run }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "${TAG_NAME}" \
--draft \
--title "${TAG_NAME}" \
--notes "${RELEASE_NOTES}" \
packages/@tailwindcss-standalone/dist/sha256sums.txt \
packages/@tailwindcss-standalone/dist/tailwindcss-linux-arm64 \
packages/@tailwindcss-standalone/dist/tailwindcss-linux-arm64-musl \
packages/@tailwindcss-standalone/dist/tailwindcss-linux-x64 \
packages/@tailwindcss-standalone/dist/tailwindcss-linux-x64-musl \
packages/@tailwindcss-standalone/dist/tailwindcss-macos-arm64 \
packages/@tailwindcss-standalone/dist/tailwindcss-macos-x64 \
packages/@tailwindcss-standalone/dist/tailwindcss-windows-x64.exe
uses: softprops/action-gh-release@v2
with:
draft: true
tag_name: ${{ env.TAG_NAME }}
body: |
${{ env.RELEASE_NOTES }}
files: |
packages/@tailwindcss-standalone/dist/sha256sums.txt
packages/@tailwindcss-standalone/dist/tailwindcss-linux-arm64
packages/@tailwindcss-standalone/dist/tailwindcss-linux-arm64-musl
packages/@tailwindcss-standalone/dist/tailwindcss-linux-x64
packages/@tailwindcss-standalone/dist/tailwindcss-linux-x64-musl
packages/@tailwindcss-standalone/dist/tailwindcss-macos-arm64
packages/@tailwindcss-standalone/dist/tailwindcss-macos-x64
packages/@tailwindcss-standalone/dist/tailwindcss-windows-x64.exe

318
.github/workflows/release-insiders.yml vendored Normal file
View file

@ -0,0 +1,318 @@
name: Release Insiders
on:
push:
branches: [main]
permissions:
contents: read
env:
APP_NAME: tailwindcss-oxide
NODE_VERSION: 20
OXIDE_LOCATION: ./crates/node
RELEASE_CHANNEL: insiders
jobs:
build:
strategy:
matrix:
include:
# Windows
- os: windows-latest
target: x86_64-pc-windows-msvc
- os: windows-latest
target: aarch64-pc-windows-msvc
# macOS
- os: macos-latest
target: x86_64-apple-darwin
strip: strip -x # Must use -x on macOS. This produces larger results on linux.
- os: macos-latest
target: aarch64-apple-darwin
page-size: 14
strip: strip -x # Must use -x on macOS. This produces larger results on linux.
# Android
- os: ubuntu-latest
target: aarch64-linux-android
strip: ${ANDROID_NDK_LATEST_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip
- os: ubuntu-latest
target: armv7-linux-androideabi
strip: ${ANDROID_NDK_LATEST_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip
# Linux
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
strip: strip
container:
image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian
- os: ubuntu-latest
target: aarch64-unknown-linux-gnu
strip: llvm-strip
container:
image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian-aarch64
- os: ubuntu-latest
target: armv7-unknown-linux-gnueabihf
strip: llvm-strip
container:
image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian-zig
- os: ubuntu-latest
target: aarch64-unknown-linux-musl
strip: aarch64-linux-musl-strip
download: true
container:
image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-alpine
- os: ubuntu-latest
target: x86_64-unknown-linux-musl
strip: strip
download: true
container:
image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-alpine
name: Build ${{ matrix.target }} (oxide)
runs-on: ${{ matrix.os }}
container: ${{ matrix.container }}
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- name: Use Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
- name: Install gcc-arm-linux-gnueabihf
if: ${{ matrix.target == 'armv7-unknown-linux-gnueabihf' }}
run: |
sudo apt-get update
sudo apt-get install gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf -y
# Cargo already skips downloading dependencies if they already exist
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }}
# Cache the `oxide` Rust build
- name: Cache oxide build
uses: actions/cache@v4
with:
path: |
./crates/node/*.node
./crates/node/*.wasm
./crates/node/index.d.ts
./crates/node/index.js
./crates/node/browser.js
./crates/node/tailwindcss-oxide.wasi-browser.js
./crates/node/tailwindcss-oxide.wasi.cjs
./crates/node/wasi-worker-browser.mjs
./crates/node/wasi-worker.mjs
key: ${{ runner.os }}-${{ matrix.target }}-oxide-${{ hashFiles('./crates/**/*') }}
- name: Install Node.JS
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install Rust (Stable)
if: ${{ matrix.download }}
run: |
rustup default stable
- name: Setup rust target
run: rustup target add ${{ matrix.target }}
- name: Install dependencies
run: pnpm install --ignore-scripts --filter=!./playgrounds/*
- name: Build release
run: pnpm run --filter ${{ env.OXIDE_LOCATION }} build:platform --target=${{ matrix.target }}
env:
RUST_TARGET: ${{ matrix.target }}
JEMALLOC_SYS_WITH_LG_PAGE: ${{ matrix.page-size }}
- name: Strip debug symbols # https://github.com/rust-lang/rust/issues/46034
if: ${{ matrix.strip }}
run: ${{ matrix.strip }} ${{ env.OXIDE_LOCATION }}/*.node
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: bindings-${{ matrix.target }}
path: ${{ env.OXIDE_LOCATION }}/*.node
build-freebsd:
name: Build x86_64-unknown-freebsd (OXIDE)
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Build FreeBSD
uses: cross-platform-actions/action@v0.25.0
env:
DEBUG: napi:*
RUSTUP_HOME: /usr/local/rustup
CARGO_HOME: /usr/local/cargo
RUSTUP_IO_THREADS: 1
RUST_TARGET: x86_64-unknown-freebsd
with:
operating_system: freebsd
version: '14.0'
memory: 13G
cpu_count: 3
environment_variables: 'DEBUG RUSTUP_IO_THREADS'
shell: bash
run: |
sudo pkg install -y -f curl node libnghttp2 npm
sudo npm install -g pnpm@9.6.0 --unsafe-perm=true
curl -sSf https://static.rust-lang.org/rustup/archive/1.27.1/x86_64-unknown-freebsd/rustup-init --output rustup-init
chmod +x rustup-init
./rustup-init -y --profile minimal
source "$HOME/.cargo/env"
echo "~~~~ rustc --version ~~~~"
rustc --version
echo "~~~~ node -v ~~~~"
node -v
echo "~~~~ pnpm --version ~~~~"
pnpm --version
pnpm install --ignore-scripts --filter=!./playgrounds/* || true
pnpm run --filter ${{ env.OXIDE_LOCATION }} build:platform
strip -x ${{ env.OXIDE_LOCATION }}/*.node
ls -la ${{ env.OXIDE_LOCATION }}
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: bindings-x86_64-unknown-freebsd
path: ${{ env.OXIDE_LOCATION }}/*.node
release:
runs-on: macos-14
timeout-minutes: 15
name: Build and release Tailwind CSS insiders
permissions:
contents: write # for softprops/action-gh-release to create GitHub release
# https://docs.npmjs.com/generating-provenance-statements#publishing-packages-with-provenance-via-github-actions
id-token: write
needs:
- build
- build-freebsd
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 20
- name: Resolve version
id: vars
run: |
echo "SHA_SHORT=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
- uses: pnpm/action-setup@v4
- name: Use Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
registry-url: 'https://registry.npmjs.org'
# Cargo already skips downloading dependencies if they already exist
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }}
# Cache the `oxide` Rust build
- name: Cache oxide build
uses: actions/cache@v4
with:
path: |
./crates/node/*.node
./crates/node/*.wasm
./crates/node/index.d.ts
./crates/node/index.js
./crates/node/browser.js
./crates/node/tailwindcss-oxide.wasi-browser.js
./crates/node/tailwindcss-oxide.wasi.cjs
./crates/node/wasi-worker-browser.mjs
./crates/node/wasi-worker.mjs
key: ${{ runner.os }}-${{ matrix.target }}-oxide-${{ hashFiles('./crates/**/*') }}
- name: Setup WASM target
run: rustup target add wasm32-wasip1-threads
- name: Install dependencies
run: pnpm --filter=!./playgrounds/* install
- name: Download artifacts
uses: actions/download-artifact@v4
with:
path: ${{ env.OXIDE_LOCATION }}
- name: Move artifacts
run: |
cd ${{ env.OXIDE_LOCATION }}
cp bindings-x86_64-pc-windows-msvc/* ./npm/win32-x64-msvc/
cp bindings-aarch64-pc-windows-msvc/* ./npm/win32-arm64-msvc/
cp bindings-x86_64-apple-darwin/* ./npm/darwin-x64/
cp bindings-aarch64-apple-darwin/* ./npm/darwin-arm64/
cp bindings-aarch64-linux-android/* ./npm/android-arm64/
cp bindings-armv7-linux-androideabi/* ./npm/android-arm-eabi/
cp bindings-aarch64-unknown-linux-gnu/* ./npm/linux-arm64-gnu/
cp bindings-aarch64-unknown-linux-musl/* ./npm/linux-arm64-musl/
cp bindings-armv7-unknown-linux-gnueabihf/* ./npm/linux-arm-gnueabihf/
cp bindings-x86_64-unknown-linux-gnu/* ./npm/linux-x64-gnu/
cp bindings-x86_64-unknown-linux-musl/* ./npm/linux-x64-musl/
cp bindings-x86_64-unknown-freebsd/* ./npm/freebsd-x64/
- name: 'Version based on commit: 0.0.0-${{ env.RELEASE_CHANNEL }}.${{ env.SHA_SHORT }}'
run: pnpm run version-packages 0.0.0-${{ env.RELEASE_CHANNEL }}.${{ env.SHA_SHORT }}
- name: Build Tailwind CSS
run: pnpm run build
- name: Run pre-publish optimizations scripts
run: node ./scripts/pre-publish-optimizations.mjs
- name: Lock pre-release versions
run: node ./scripts/lock-pre-release-versions.mjs
- name: Upload npm package tarballs
uses: actions/upload-artifact@v4
with:
name: npm-package-tarballs
path: dist/*.tgz
- name: Publish
run: |
pnpm --recursive --filter="!@tailwindcss/oxide-wasm32-wasi" publish --tag ${{ env.RELEASE_CHANNEL }} --no-git-checks
# The wasm package needs a special npm config that isn't read when pnpm --recursive is used
pushd crates/node/npm/wasm32-wasi; pnpm publish --tag ${{ env.RELEASE_CHANNEL }} --no-git-checks; popd;
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Trigger Tailwind Play update
uses: actions/github-script@v7
with:
github-token: ${{ secrets.TAILWIND_PLAY_TOKEN }}
script: |
await github.rest.actions.createWorkflowDispatch({
owner: 'tailwindlabs',
repo: 'upgrades',
ref: 'main',
workflow_id: 'upgrade-tailwindcss.yml'
})

View file

@ -1,34 +1,18 @@
name: Release
on:
push:
branches: [main]
release:
types: [published]
workflow_dispatch:
inputs:
channel:
description: Release channel to publish
required: true
default: insiders
type: choice
options:
- insiders
- release
permissions:
contents: read
env:
APP_NAME: tailwindcss-oxide
NODE_VERSION: 24
PNPM_VERSION: '11.9.0'
NODE_VERSION: 20
OXIDE_LOCATION: ./crates/node
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
strategy:
@ -58,40 +42,44 @@ jobs:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
strip: strip
build-flags: --use-napi-cross
container:
image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian
- os: ubuntu-latest
target: aarch64-unknown-linux-gnu
strip: aarch64-linux-gnu-strip
build-flags: --use-napi-cross
strip: llvm-strip
container:
image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian-aarch64
- os: ubuntu-latest
target: armv7-unknown-linux-gnueabihf
strip: arm-linux-gnueabihf-strip
build-flags: --use-napi-cross
strip: llvm-strip
container:
image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian-zig
- os: ubuntu-latest
target: aarch64-unknown-linux-musl
strip-zig: true
build-flags: -x
strip: aarch64-linux-musl-strip
download: true
container:
image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-alpine
- os: ubuntu-latest
target: x86_64-unknown-linux-musl
strip: strip
build-flags: -x
download: true
container:
image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-alpine
name: Build ${{ matrix.target }} (oxide)
runs-on: ${{ matrix.os }}
container: ${{ matrix.container }}
timeout-minutes: 15
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with:
version: ${{ env.PNPM_VERSION }}
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- name: Use Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
package-manager-cache: false
cache: 'pnpm'
- name: Install gcc-arm-linux-gnueabihf
if: ${{ matrix.target == 'armv7-unknown-linux-gnueabihf' }}
@ -99,56 +87,62 @@ jobs:
sudo apt-get update
sudo apt-get install gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf -y
- name: Install binutils-aarch64-linux-gnu
if: ${{ matrix.target == 'aarch64-unknown-linux-gnu' }}
# Cargo already skips downloading dependencies if they already exist
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }}
# Cache the `oxide` Rust build
- name: Cache oxide build
uses: actions/cache@v4
with:
path: |
./crates/node/*.node
./crates/node/*.wasm
./crates/node/index.d.ts
./crates/node/index.js
./crates/node/browser.js
./crates/node/tailwindcss-oxide.wasi-browser.js
./crates/node/tailwindcss-oxide.wasi.cjs
./crates/node/wasi-worker-browser.mjs
./crates/node/wasi-worker.mjs
key: ${{ runner.os }}-${{ matrix.target }}-oxide-${{ hashFiles('./crates/**/*') }}
- name: Install Node.JS
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install Rust (Stable)
if: ${{ matrix.download }}
run: |
sudo apt-get update
sudo apt-get install binutils-aarch64-linux-gnu -y
- uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2
if: ${{ contains(matrix.target, 'musl') }}
with:
version: 0.14.1
use-cache: false
- name: Install cargo-zigbuild
uses: taiki-e/install-action@65851e10cd6c377f11a60e600abc07cb08643468 # v2
if: ${{ contains(matrix.target, 'musl') }}
env:
GITHUB_TOKEN: ${{ github.token }}
with:
tool: cargo-zigbuild
rustup default stable
- name: Setup rust target
run: rustup target add ${{ matrix.target }}
- name: Install dependencies
run: pnpm install --ignore-scripts --frozen-lockfile --filter=!./playgrounds/*
run: pnpm install --ignore-scripts --filter=!./playgrounds/*
- name: Build release
run: pnpm run --filter ${{ env.OXIDE_LOCATION }} build:platform --target=${{ matrix.target }} ${{ matrix.build-flags }}
run: pnpm run --filter ${{ env.OXIDE_LOCATION }} build:platform --target=${{ matrix.target }}
env:
RUST_TARGET: ${{ matrix.target }}
JEMALLOC_SYS_WITH_LG_PAGE: ${{ matrix.page-size }}
- name: Strip debug symbols # https://github.com/rust-lang/rust/issues/46034
if: ${{ matrix.strip || matrix.strip-zig }}
env:
STRIP_COMMAND: ${{ matrix.strip }}
STRIP_ZIG: ${{ matrix.strip-zig }}
run: |
if [ "$STRIP_ZIG" = "true" ]; then
for file in ${{ env.OXIDE_LOCATION }}/*.node; do
zig objcopy --strip-all "$file" "$file.stripped"
mv "$file.stripped" "$file"
done
exit 0
fi
eval "$STRIP_COMMAND ${{ env.OXIDE_LOCATION }}/*.node"
if: ${{ matrix.strip }}
run: ${{ matrix.strip }} ${{ env.OXIDE_LOCATION }}/*.node
- name: Upload artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@v4
with:
name: bindings-${{ matrix.target }}
path: ${{ env.OXIDE_LOCATION }}/*.node
@ -158,11 +152,9 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- uses: actions/checkout@v4
- name: Build FreeBSD
uses: cross-platform-actions/action@cdc9ee69ef84a5f2e59c9058335d9c57bcb4ac86 # v0.25.0
uses: cross-platform-actions/action@v0.25.0
env:
DEBUG: napi:*
RUSTUP_HOME: /usr/local/rustup
@ -178,7 +170,7 @@ jobs:
shell: bash
run: |
sudo pkg install -y -f curl node libnghttp2 npm
sudo npm install -g pnpm@${{ env.PNPM_VERSION }} --unsafe-perm=true
sudo npm install -g pnpm@9.6.0 --unsafe-perm=true
curl -sSf https://static.rust-lang.org/rustup/archive/1.27.1/x86_64-unknown-freebsd/rustup-init --output rustup-init
chmod +x rustup-init
./rustup-init -y --profile minimal
@ -189,12 +181,12 @@ jobs:
node -v
echo "~~~~ pnpm --version ~~~~"
pnpm --version
pnpm install --ignore-scripts --frozen-lockfile --filter=!./playgrounds/* || true
pnpm install --ignore-scripts --filter=!./playgrounds/* || true
pnpm run --filter ${{ env.OXIDE_LOCATION }} build:platform
strip -x ${{ env.OXIDE_LOCATION }}/*.node
ls -la ${{ env.OXIDE_LOCATION }}
- name: Upload artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@v4
with:
name: bindings-x86_64-unknown-freebsd
path: ${{ env.OXIDE_LOCATION }}/*.node
@ -202,10 +194,10 @@ jobs:
release:
runs-on: macos-14
timeout-minutes: 15
name: Build and publish Tailwind CSS
name: Build and release Tailwind CSS
permissions:
contents: read
contents: write # for softprops/action-gh-release to create GitHub release
# https://docs.npmjs.com/generating-provenance-statements#publishing-packages-with-provenance-via-github-actions
id-token: write
@ -214,51 +206,55 @@ jobs:
- build-freebsd
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: actions/checkout@v4
with:
fetch-depth: 20
persist-credentials: false
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with:
version: ${{ env.PNPM_VERSION }}
- uses: pnpm/action-setup@v4
- name: Use Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
registry-url: 'https://registry.npmjs.org'
package-manager-cache: false
# npm trusted publishing validates the caller workflow filename, so all npm publishes live here.
# This workflow rebuilds the publish artifacts instead of depending on prepare-release.yml.
- name: Resolve release metadata
env:
INPUT_CHANNEL: ${{ github.event.inputs.channel || '' }}
run: |
if [[ "${{ github.event_name }}" == "release" || "$INPUT_CHANNEL" == "release" ]]; then
release_channel=$(node ./scripts/release-channel.js)
# Cargo already skips downloading dependencies if they already exist
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }}
echo "RELEASE_KIND=release" >> $GITHUB_ENV
echo "RELEASE_CHANNEL=$release_channel" >> $GITHUB_ENV
echo "FEATURES_ENV=stable" >> $GITHUB_ENV
else
sha_short=$(git rev-parse --short HEAD)
echo "RELEASE_KIND=insiders" >> $GITHUB_ENV
echo "RELEASE_CHANNEL=insiders" >> $GITHUB_ENV
echo "SHA_SHORT=$sha_short" >> $GITHUB_ENV
echo "INSIDERS_VERSION=0.0.0-insiders.$sha_short" >> $GITHUB_ENV
fi
# Cache the `oxide` Rust build
- name: Cache oxide build
uses: actions/cache@v4
with:
path: |
./crates/node/*.node
./crates/node/*.wasm
./crates/node/index.d.ts
./crates/node/index.js
./crates/node/browser.js
./crates/node/tailwindcss-oxide.wasi-browser.js
./crates/node/tailwindcss-oxide.wasi.cjs
./crates/node/wasi-worker-browser.mjs
./crates/node/wasi-worker.mjs
key: ${{ runner.os }}-${{ matrix.target }}-oxide-${{ hashFiles('./crates/**/*') }}
- name: Setup WASM target
run: rustup target add wasm32-wasip1-threads
- name: Install dependencies
run: pnpm --filter=!./playgrounds/* install --frozen-lockfile
run: pnpm --filter=!./playgrounds/* install
- name: Download artifacts
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
uses: actions/download-artifact@v4
with:
path: ${{ env.OXIDE_LOCATION }}
@ -278,19 +274,10 @@ jobs:
cp bindings-x86_64-unknown-linux-musl/* ./npm/linux-x64-musl/
cp bindings-x86_64-unknown-freebsd/* ./npm/freebsd-x64/
- name: 'Version based on commit: ${{ env.INSIDERS_VERSION }}'
if: env.RELEASE_KIND == 'insiders'
run: pnpm run version-packages ${INSIDERS_VERSION}
- name: Build Tailwind CSS
if: env.RELEASE_KIND == 'insiders'
run: pnpm run build
- name: Build Tailwind CSS
if: env.RELEASE_KIND == 'release'
run: pnpm run build
env:
FEATURES_ENV: ${{ env.FEATURES_ENV }}
FEATURES_ENV: stable
- name: Run pre-publish optimizations scripts
run: node ./scripts/pre-publish-optimizations.mjs
@ -298,20 +285,22 @@ jobs:
- name: Lock pre-release versions
run: node ./scripts/lock-pre-release-versions.mjs
- name: Upload npm package tarballs
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: npm-package-tarballs
path: dist/*.tgz
- name: Calculate environment variables
run: |
echo "RELEASE_CHANNEL=$(node ./scripts/release-channel.js)" >> $GITHUB_ENV
echo "TAILWINDCSS_VERSION=$(node -e 'console.log(require(`./packages/tailwindcss/package.json`).version);')" >> $GITHUB_ENV
- name: Publish
run: |
pnpm --recursive --filter="!@tailwindcss/oxide-wasm32-wasi" publish --tag ${RELEASE_CHANNEL} --no-git-checks
pnpm --recursive --filter="!@tailwindcss/oxide-wasm32-wasi" publish --tag ${{ env.RELEASE_CHANNEL }} --no-git-checks
# The wasm package needs a special npm config that isn't read when pnpm --recursive is used
pushd crates/node/npm/wasm32-wasi; pnpm publish --tag ${RELEASE_CHANNEL} --no-git-checks --config.node-linker=hoisted; popd;
pushd crates/node/npm/wasm32-wasi; pnpm publish --tag ${{ env.RELEASE_CHANNEL }} --no-git-checks; popd;
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Trigger Tailwind Play update
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
if: env.RELEASE_CHANNEL == 'latest'
uses: actions/github-script@v7
with:
github-token: ${{ secrets.TAILWIND_PLAY_TOKEN }}
script: |
@ -321,38 +310,3 @@ jobs:
ref: 'main',
workflow_id: 'upgrade-tailwindcss.yml'
})
notify:
if: ${{ always() && (needs.build.result == 'failure' || needs.build-freebsd.result == 'failure' || needs.release.result == 'failure') }}
needs:
- build
- build-freebsd
- release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- name: Resolve release label
id: release
env:
INPUT_CHANNEL: ${{ github.event.inputs.channel || '' }}
RELEASE_TAG: ${{ github.event.release.tag_name || '' }}
run: |
if [[ "${{ github.event_name }}" == "release" ]]; then
tag_name="${RELEASE_TAG:-${GITHUB_REF_NAME}}"
echo "label=release ${tag_name}" >> $GITHUB_OUTPUT
elif [[ "$INPUT_CHANNEL" == "release" ]]; then
version=$(node -p "require('./packages/tailwindcss/package.json').version")
echo "label=release v${version}" >> $GITHUB_OUTPUT
else
sha_short=$(git rev-parse --short HEAD)
echo "label=insiders release 0.0.0-insiders.${sha_short}" >> $GITHUB_OUTPUT
fi
- name: Notify Discord
uses: discord-actions/message@5c7149c81a83146e5d01f142be1bf87a61831c4d # v2
with:
webhookUrl: ${{ secrets.DISCORD_WEBHOOK_URL }}
message: 'The [most recent ${{ github.workflow }} workflow](<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}>) for `${{ steps.release.outputs.label }}` has failed.'

1
.npmrc Normal file
View file

@ -0,0 +1 @@
auto-install-peers = true

View file

@ -4,6 +4,5 @@ pnpm-lock.yaml
target/
crates/node/index.d.ts
crates/node/index.js
crates/ignore/
.next
.fingerprint

View file

@ -9,289 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Add `@tailwindcss/turbopack` package to run Tailwind CSS with Next.js ([20367](https://github.com/tailwindlabs/tailwindcss/pull/20367))
### Fixed
- Ensure watch mode detects changes to symlinked `@source` files whose real paths aren't otherwise scanned ([#20356](https://github.com/tailwindlabs/tailwindcss/pull/20356))
- Ensure custom variants using `@scope` wrap the generated utilities instead of nesting inside them ([#20369](https://github.com/tailwindlabs/tailwindcss/pull/20369))
- Fix flattening of `@scope` at-rules ([#20369](https://github.com/tailwindlabs/tailwindcss/pull/20369))
- Fix standalone declarations in `@scope`, wrap them in `:where(:scope)` ([#20369](https://github.com/tailwindlabs/tailwindcss/pull/20369))
- Always emit a space for empty fallback values in CSS variables (e.g. `var(--tw-blur,)` → `var(--tw-blur, )`) ([#20373](https://github.com/tailwindlabs/tailwindcss/pull/20373))
- Canonicalization: convert arbitrary breakpoint and container query variants to named equivalents (e.g. `max-[64rem]` → `max-lg`) ([#20380](https://github.com/tailwindlabs/tailwindcss/pull/20380))
- Prevent `@tailwindcss/vite` from crashing on every edit under Vite's experimental `bundledDev` mode ([#20379](https://github.com/tailwindlabs/tailwindcss/pull/20379))
- Ensure `@tailwindcss/oxide` falls back to WASM on platforms without native bindings ([#20383](https://github.com/tailwindlabs/tailwindcss/pull/20383))
- Detect classes in Ruby percent literals using angle brackets or custom delimiters (e.g. `%w<flex>`, `%w|flex|`), including in Slim and Haml templates ([#20387](https://github.com/tailwindlabs/tailwindcss/pull/20387))
- Preserve whitespace in `--default(…)` values in custom functional utilities (e.g. `--default(box alphabetic)` no longer becomes `boxalphabetic`) ([#20392](https://github.com/tailwindlabs/tailwindcss/pull/20392))
- Don't scan gitignored directories (e.g. `node_modules` and `.git`) when the project uses a safelist-style `.gitignore` (e.g. `/*` followed by `!/…` negations) ([#20397](https://github.com/tailwindlabs/tailwindcss/pull/20397))
- Ensure root `theme('…')` namespace lookups in JavaScript plugins and config files return the full namespace object instead of the value of its `DEFAULT` key ([#20399](https://github.com/tailwindlabs/tailwindcss/pull/20399))
- Skip ignored directories entirely when computing watch globs (`scanner.globs`), instead of walking their full contents on every rebuild ([#20408](https://github.com/tailwindlabs/tailwindcss/pull/20408))
- Oxide: drop invalid UTF-8 candidates ([#20389](https://github.com/tailwindlabs/tailwindcss/pull/20389))
- `@tailwindcss/vite` no longer forces a full page reload for external files (e.g.: `.php` files) ([#20414](https://github.com/tailwindlabs/tailwindcss/issues/20414))
- Canonicalization: don't merge utilities that reference different theme variables set to CSS-wide keywords like `unset` ([#20417](https://github.com/tailwindlabs/tailwindcss/pull/20417))
- Don't generate utilities when a modifier is used that would otherwise be silently ignored (e.g. `rounded-sm/[5]`, `shadow-sm/foo`, `stroke-2/50`) ([#20419](https://github.com/tailwindlabs/tailwindcss/pull/20419))
- Only normalize top-level `and`, `or`, and `not` keywords in `supports-[…]` variants (e.g. `selector(a: not (.foo))` → `selector(a:not(.foo))`) ([#20420](https://github.com/tailwindlabs/tailwindcss/pull/20420))
- Don't warn about Angular's `::ng-deep` and `:host-context()` when optimizing CSS ([#20434](https://github.com/tailwindlabs/tailwindcss/pull/20434))
- Don't generate CSS for candidates containing an empty additional modifier (e.g. `bg-red-500/50/` and `group-hover/foo//bar:flex`) ([#20466](https://github.com/tailwindlabs/tailwindcss/pull/20466))
- Sort `min-*`, `max-*`, and container query variants with decimal values numerically (e.g. `min-[40.25rem]` before `min-[40.5rem]`) ([#20512](https://github.com/tailwindlabs/tailwindcss/pull/20512))
- Ensure CSS comments ending with `\*/` are closed correctly instead of swallowing the CSS that follows (e.g. `/* C:\temp\*/`) ([#20508](https://github.com/tailwindlabs/tailwindcss/pull/20508))
- Improve style invalidation performance of `group-*` and `peer-*` variants ([#20513](https://github.com/tailwindlabs/tailwindcss/pull/20513))
## [4.3.3] - 2026-07-16
### Fixed
- Support `--watch --poll[=ms]` in `@tailwindcss/cli` when filesystem events are unreliable or unavailable ([#20297](https://github.com/tailwindlabs/tailwindcss/pull/20297))
- Canonicalization: match arbitrary hex colors against theme colors case-insensitively (e.g. `bg-[#fff]` and `bg-[#FFF]` → `bg-white`) ([#20298](https://github.com/tailwindlabs/tailwindcss/pull/20298))
- Prevent Preflight from overriding Firefox's native `iframe:focus-visible` outline styles ([#20292](https://github.com/tailwindlabs/tailwindcss/pull/20292))
- Ensure `theme('colors.foo')` in JS plugins resolves correctly when both `--color-foo` and `--color-foo-bar` exist ([#20299](https://github.com/tailwindlabs/tailwindcss/pull/20299))
- Ensure fractional opacity modifiers work with named shadow sizes like `shadow-sm/12.5`, `text-shadow-sm/12.5`, `drop-shadow-sm/12.5`, and `inset-shadow-sm/12.5` ([#20302](https://github.com/tailwindlabs/tailwindcss/pull/20302))
- Parse selectors like `[data-foo]div` as two selectors instead of one ([#20303](https://github.com/tailwindlabs/tailwindcss/pull/20303))
- Ensure `@tailwindcss/postcss` rebuilds when a preprocessor like Sass changes the input CSS without changing the input file on disk ([#20310](https://github.com/tailwindlabs/tailwindcss/pull/20310))
- Ensure CSS nesting is handled even when Lightning CSS isn't run, such as in `@tailwindcss/browser` and Tailwind Play ([#20124](https://github.com/tailwindlabs/tailwindcss/pull/20124))
- Prevent achromatic theme colors from shifting hue when mixed in polar color spaces like `oklch` ([#20314](https://github.com/tailwindlabs/tailwindcss/pull/20314))
- Ensure `--spacing(0)` is optimized to `0px` instead of `0` so it remains a `<length>` when used in `calc(…)` ([#20319](https://github.com/tailwindlabs/tailwindcss/pull/20319))
- Load `@parcel/watcher` only when needed in `@tailwindcss/cli --watch` mode, so one-off builds and `--watch --poll` work when `@parcel/watcher` can't be loaded ([#20325](https://github.com/tailwindlabs/tailwindcss/pull/20325))
- Use explicit platform fonts instead of `system-ui` and `ui-sans-serif` so CJK text respects the page's `lang` attribute on Windows ([#20318](https://github.com/tailwindlabs/tailwindcss/pull/20318))
- Prevent `@tailwindcss/upgrade` from rewriting ignored files when run from a subdirectory ([#20329](https://github.com/tailwindlabs/tailwindcss/pull/20329))
- Ensure earlier `@source` rules pointing to nested files are scanned when later `@source` rules point to files in parent folders ([#20335](https://github.com/tailwindlabs/tailwindcss/pull/20335))
- Prevent `@tailwindcss/vite` from triggering full page reloads when scanned files are processed by Vite but haven't been loaded as modules yet ([#20336](https://github.com/tailwindlabs/tailwindcss/pull/20336))
## [4.3.2] - 2026-06-26
### Fixed
- Support bare spacing values for `auto-rows-*` and `auto-cols-*` utilities (e.g. `auto-rows-12` and `auto-cols-16`) ([#20229](https://github.com/tailwindlabs/tailwindcss/pull/20229))
- Prevent `@tailwindcss/cli` in `--watch` mode from crashing on Windows when `@source` points to a directory that doesn't exist ([#20242](https://github.com/tailwindlabs/tailwindcss/pull/20242))
- Prevent `@tailwindcss/vite` from crashing in Deno v2.8.x when `context.parentURL` is not a valid URL ([#20245](https://github.com/tailwindlabs/tailwindcss/pull/20245))
- Ensure `@tailwindcss/cli` in `--watch` mode rebuilds when the input CSS file changes in an ignored directory ([#20246](https://github.com/tailwindlabs/tailwindcss/pull/20246))
- Allow `@variant` rules used in `addBase(…)` to use custom variants defined later ([#20247](https://github.com/tailwindlabs/tailwindcss/pull/20247))
- Prevent `@tailwindcss/vite` from crashing during HMR when scanned files or directories are deleted ([#20259](https://github.com/tailwindlabs/tailwindcss/pull/20259))
- Generate `font-size` instead of `color` declarations for `text-[--spacing(…)]` ([#20260](https://github.com/tailwindlabs/tailwindcss/pull/20260))
- Prevent `@source` patterns from scanning unrelated sibling files and folders ([#20263](https://github.com/tailwindlabs/tailwindcss/pull/20263))
- Extract class candidates adjacent to Template Toolkit delimiters like `%]…[%` in `.tt`, `.tt2`, and `.tx` files ([#20269](https://github.com/tailwindlabs/tailwindcss/pull/20269))
- Extract class candidates from conditional Maud syntax like `p.text-black[condition]` ([#20269](https://github.com/tailwindlabs/tailwindcss/pull/20269))
- Prevent `@position-try` rules from triggering unknown at-rule warnings when optimizing CSS ([#20277](https://github.com/tailwindlabs/tailwindcss/pull/20277))
- Support class suggestions for named opacity modifiers from `--opacity` theme values ([#20287](https://github.com/tailwindlabs/tailwindcss/pull/20287))
- Prevent type errors in `@tailwindcss/postcss` when used with newer PostCSS patch releases ([#20289](https://github.com/tailwindlabs/tailwindcss/pull/20289))
## [4.3.1] - 2026-06-12
### Added
- Add `--silent` option to suppress output in `@tailwindcss/cli` ([#20100](https://github.com/tailwindlabs/tailwindcss/pull/20100))
### Fixed
- Remove deprecation warnings by using `Module#registerHooks` instead of `Module#register` on Node 26+ ([#20028](https://github.com/tailwindlabs/tailwindcss/pull/20028))
- Canonicalization: don't crash when plugin utilities throw for unsupported values ([#20052](https://github.com/tailwindlabs/tailwindcss/pull/20052))
- Allow `@apply` to be used with CSS mixins ([#19427](https://github.com/tailwindlabs/tailwindcss/pull/19427))
- Ensure `not-*` correctly negates `@container` queries, including `style(…)` queries ([#20059](https://github.com/tailwindlabs/tailwindcss/pull/20059))
- Ensure `drop-shadow-*` color utilities work with custom shadow values containing `calc(…)` ([#20080](https://github.com/tailwindlabs/tailwindcss/pull/20080))
- Fix 'Sourcemap is likely to be incorrect' warnings when using `@tailwindcss/vite` ([#20103](https://github.com/tailwindlabs/tailwindcss/pull/20103))
- Ensure `@tailwindcss/webpack` can be installed in Rspack projects without requiring `webpack` as a peer dependency ([#20027](https://github.com/tailwindlabs/tailwindcss/pull/20027))
- Canonicalization: don't suggest invalid `calc(…)` expressions (e.g. `px-[calc(1rem+0px)]` → `px-[calc(1rem+0)]`) ([#20127](https://github.com/tailwindlabs/tailwindcss/pull/20127))
- Canonicalization: avoid suggesting large spacing-scale values for arbitrary lengths (e.g. `left-[99999px]` → `left-[99999px]`, not `left-24999.75`) ([#20130](https://github.com/tailwindlabs/tailwindcss/pull/20130))
- Ensure `@tailwindcss/cli` in `--watch` mode recovers when a tracked dependency is deleted and restored ([#20137](https://github.com/tailwindlabs/tailwindcss/pull/20137))
- Ensure standalone `@tailwindcss/cli` binaries are ignored when scanning for class candidates ([#20139](https://github.com/tailwindlabs/tailwindcss/pull/20139))
- Ensure class candidates are extracted from Twig `addClass(…)` and `removeClass(…)` calls ([#20198](https://github.com/tailwindlabs/tailwindcss/pull/20198))
- Don't crash in the Ruby or Vue preprocessors when scanning files containing invalid UTF-8 bytes ([#19588](https://github.com/tailwindlabs/tailwindcss/pull/19588))
- Allow `@variant` to be used inside `addBase` ([#19480](https://github.com/tailwindlabs/tailwindcss/pull/19480))
- Ensure `@source` globs with symlinks are preserved ([#20203](https://github.com/tailwindlabs/tailwindcss/pull/20203))
- Ensure later `@source` rules can re-include files excluded by earlier `@source not` rules ([#20203](https://github.com/tailwindlabs/tailwindcss/pull/20203))
- Upgrade: don't migrate empty class rules to invalid `@utility` rules ([#20205](https://github.com/tailwindlabs/tailwindcss/pull/20205))
- Ensure transitions between `inset-shadow-none` and other inset shadows work correctly ([#20208](https://github.com/tailwindlabs/tailwindcss/pull/20208))
- Ensure explicitly referenced `@source` directories are scanned even when ignored by git ([#20214](https://github.com/tailwindlabs/tailwindcss/pull/20214))
- Ensure `@source` globs ending in `**/*` preserve dynamic path segments to avoid scanning too many files ([#20217](https://github.com/tailwindlabs/tailwindcss/pull/20217))
- Canonicalization: don't fold `calc(…)` divisions when the result would require high precision (e.g. `w-[calc(100%/3.5)]` → `w-[calc(100%/3.5)]`, not `w-[28.571428571428573%]`) ([#20221](https://github.com/tailwindlabs/tailwindcss/pull/20221))
- Serve ESM type declarations to ESM importers of `@tailwindcss/postcss` ([#20228](https://github.com/tailwindlabs/tailwindcss/pull/20228))
### Changed
- Generate `0` instead of `calc(var(--spacing) * 0)` for spacing utilities like `m-0` and `left-0` ([#20196](https://github.com/tailwindlabs/tailwindcss/pull/20196))
- Generate `var(--spacing)` instead of `calc(var(--spacing) * 1)` for spacing utilities like `m-1` and `left-1` ([#20196](https://github.com/tailwindlabs/tailwindcss/pull/20196))
## [4.3.0] - 2026-05-08
### Added
- Add `@container-size` utility ([#18901](https://github.com/tailwindlabs/tailwindcss/pull/18901))
- Add `scrollbar-{auto,thin,none}` utilities for `scrollbar-width`, and `scrollbar-thumb-*` / `scrollbar-track-*` color utilities for `scrollbar-color` ([#19981](https://github.com/tailwindlabs/tailwindcss/pull/19981), [#20019](https://github.com/tailwindlabs/tailwindcss/pull/20019))
- Add `scrollbar-gutter-*` utilities ([#20018](https://github.com/tailwindlabs/tailwindcss/pull/20018))
- Add `zoom-*` utilities ([#20020](https://github.com/tailwindlabs/tailwindcss/pull/20020))
- Add `tab-*` utilities ([#20022](https://github.com/tailwindlabs/tailwindcss/pull/20022))
- Allow using `@variant` with stacked variants (e.g. `@variant hover:focus { … }`) ([#19996](https://github.com/tailwindlabs/tailwindcss/pull/19996))
- Allow using `@variant` with compound variants (e.g. `@variant hover, focus { … }`) ([#19996](https://github.com/tailwindlabs/tailwindcss/pull/19996))
- Support `--default(…)` in `--value(…)` and `--modifier(…)` for functional `@utility` definitions ([#19989](https://github.com/tailwindlabs/tailwindcss/pull/19989))
### Fixed
- Ensure `@plugin` resolves package JavaScript entries instead of browser CSS entries when using `@tailwindcss/vite` ([#19949](https://github.com/tailwindlabs/tailwindcss/pull/19949))
- Fix relative `@import` and `@plugin` paths resolving from the wrong directory when using `@tailwindcss/vite` ([#19965](https://github.com/tailwindlabs/tailwindcss/pull/19965))
- Ensure CSS files containing `@variant` are processed by `@tailwindcss/vite` ([#19966](https://github.com/tailwindlabs/tailwindcss/pull/19966))
- Resolve imports relative to `base` when `result.opts.from` is not provided when using `@tailwindcss/postcss` ([#19980](https://github.com/tailwindlabs/tailwindcss/pull/19980))
- Canonicalization: preserve significant `_` whitespace in arbitrary values ([#19986](https://github.com/tailwindlabs/tailwindcss/pull/19986))
- Canonicalization: add parentheses when removing whitespace from arbitrary values would hurt readability (e.g. `w-[calc(100%---spacing(60))]` → `w-[calc(100%-(--spacing(60)))]`) ([#19986](https://github.com/tailwindlabs/tailwindcss/pull/19986))
- Canonicalization: preserve the original unit in arbitrary values instead of normalizing to base units (e.g. `-mt-[20in]` → `mt-[-20in]`, not `mt-[-1920px]`) ([#19988](https://github.com/tailwindlabs/tailwindcss/pull/19988))
- Canonicalization: migrate arbitrary `:has()` variants from `[&:has(…)]` to `has-[…]` ([#19991](https://github.com/tailwindlabs/tailwindcss/pull/19991))
- Upgrade: don’t migrate inline `style` attributes (e.g. `style="flex-grow: 1"` → `style="flex-grow: 1"`, not `style="grow: 1"`) ([#19918](https://github.com/tailwindlabs/tailwindcss/pull/19918))
- Allow multiple `@utility` definitions with the same name but different value types ([#19777](https://github.com/tailwindlabs/tailwindcss/pull/19777))
- Export missing `PluginWithConfig` type from `tailwindcss/plugin` to fix errors when inferring plugin config types ([#19707](https://github.com/tailwindlabs/tailwindcss/pull/19707))
- Ensure `start` and `end` legacy utilities without values do not generate CSS ([#20003](https://github.com/tailwindlabs/tailwindcss/pull/20003))
- Ensure `--value(…)` is required in functional `@utility` definitions ([#20005](https://github.com/tailwindlabs/tailwindcss/pull/20005))
- Canonicalization: preserve required whitespace around operators in negated arbitrary values (e.g. `-left-[(var(--a)+var(--b))]`) ([#20011](https://github.com/tailwindlabs/tailwindcss/pull/20011))
## [4.2.4] - 2026-04-21
### Fixed
- Ensure imports in `@import` and `@plugin` still resolve correctly when using Vite aliases in `@tailwindcss/vite` ([#19947](https://github.com/tailwindlabs/tailwindcss/pull/19947))
## [4.2.3] - 2026-04-20
### Fixed
- Canonicalization: improve canonicalization for `tracking-*` utilities by preferring non-negative utilities (e.g. `-tracking-tighter` → `tracking-wider`) ([#19827](https://github.com/tailwindlabs/tailwindcss/pull/19827))
- Fix crash due to invalid characters in candidate (exceeding valid unicode code point range) ([#19829](https://github.com/tailwindlabs/tailwindcss/pull/19829))
- Ensure query params in imports are considered unique resources when using `@tailwindcss/webpack` ([#19723](https://github.com/tailwindlabs/tailwindcss/pull/19723))
- Canonicalization: collapse arbitrary values into shorthand utilities (e.g. `px-[1.2rem] py-[1.2rem]` → `p-[1.2rem]`) ([#19837](https://github.com/tailwindlabs/tailwindcss/pull/19837))
- Canonicalization: collapse `border-{t,b}-*` into `border-y-*`, `border-{l,r}-*` into `border-x-*`, and `border-{t,r,b,l}-*` into `border-*` ([#19842](https://github.com/tailwindlabs/tailwindcss/pull/19842))
- Canonicalization: collapse `scroll-m{t,b}-*` into `scroll-my-*`, `scroll-m{l,r}-*` into `scroll-mx-*`, and `scroll-m{t,r,b,l}-*` into `scroll-m-*` ([#19842](https://github.com/tailwindlabs/tailwindcss/pull/19842))
- Canonicalization: collapse `scroll-p{t,b}-*` into `scroll-py-*`, `scroll-p{l,r}-*` into `scroll-px-*`, and `scroll-p{t,r,b,l}-*` into `scroll-p-*` ([#19842](https://github.com/tailwindlabs/tailwindcss/pull/19842))
- Canonicalization: collapse `overflow-{x,y}-*` into `overflow-*` ([#19842](https://github.com/tailwindlabs/tailwindcss/pull/19842))
- Canonicalization: collapse `overscroll-{x,y}-*` into `overscroll-*` ([#19842](https://github.com/tailwindlabs/tailwindcss/pull/19842))
- Read from `--placeholder-color` instead of `--background-color` for `placeholder-*` utilities ([#19843](https://github.com/tailwindlabs/tailwindcss/pull/19843))
- Upgrade: ensure files are not emptied out when killing the upgrade process while it's running ([#19846](https://github.com/tailwindlabs/tailwindcss/pull/19846))
- Upgrade: use `config.content` when migrating from Tailwind CSS v3 to Tailwind CSS v4 ([#19846](https://github.com/tailwindlabs/tailwindcss/pull/19846))
- Upgrade: never migrate files that are ignored by git ([#19846](https://github.com/tailwindlabs/tailwindcss/pull/19846))
- Add `.env` and `.env.*` to default ignored content files ([#19846](https://github.com/tailwindlabs/tailwindcss/pull/19846))
- Canonicalization: migrate `overflow-ellipsis` into `text-ellipsis` ([#19849](https://github.com/tailwindlabs/tailwindcss/pull/19849))
- Canonicalization: migrate `start-full` → `inset-s-full`, `start-auto` → `inset-s-auto`, `start-px` → `inset-s-px`, and `start-<number>` → `inset-s-<number>` as well as negative versions ([#19849](https://github.com/tailwindlabs/tailwindcss/pull/19849))
- Canonicalization: migrate `end-full` → `inset-e-full`, `end-auto` → `inset-e-auto`, `end-px` → `inset-e-px`, and `end-<number>` → `inset-e-<number>` as well as negative versions ([#19849](https://github.com/tailwindlabs/tailwindcss/pull/19849))
- Canonicalization: move the `-` sign inside the arbitrary value `-left-[9rem]` → `left-[-9rem]` ([#19858](https://github.com/tailwindlabs/tailwindcss/pull/19858))
- Canonicalization: move the `-` sign outside the arbitrary value `ml-[calc(-1*var(--width))]` → `-ml-(--width)` ([#19858](https://github.com/tailwindlabs/tailwindcss/pull/19858))
- Improve performance when scanning JSONL / NDJSON files ([#19862](https://github.com/tailwindlabs/tailwindcss/pull/19862))
- Support `NODE_PATH` environment variable in standalone CLI ([#19617](https://github.com/tailwindlabs/tailwindcss/pull/19617))
## [4.2.2] - 2026-03-18
### Fixed
- Don't crash when candidates contain prototype properties like `row-constructor` ([#19725](https://github.com/tailwindlabs/tailwindcss/pull/19725))
- Canonicalize `calc(var(--spacing)*…)` expressions into `--spacing(…)` ([#19769](https://github.com/tailwindlabs/tailwindcss/pull/19769))
- Fix crash in canonicalization step when handling utilities containing `@property` at-rules (e.g. `shadow-sm border`) ([#19727](https://github.com/tailwindlabs/tailwindcss/pull/19727))
- Skip full reload for server only modules scanned by client CSS when using `@tailwindcss/vite` ([#19745](https://github.com/tailwindlabs/tailwindcss/pull/19745))
- Add support for Vite 8 in `@tailwindcss/vite` ([#19790](https://github.com/tailwindlabs/tailwindcss/pull/19790))
- Improve canonicalization for bare values exceeding default spacing scale suggestions (e.g. `w-1234 h-1234` → `size-1234`) ([#19809](https://github.com/tailwindlabs/tailwindcss/pull/19809))
- Fix canonicalization resulting in empty list (e.g. `w-5 h-5 size-5` → `''` instead of `size-5`) ([#19812](https://github.com/tailwindlabs/tailwindcss/pull/19812))
- Resolve tsconfig paths to allow for `@import '@/path/to/file';` when using `@tailwindcss/vite` ([#19803](https://github.com/tailwindlabs/tailwindcss/pull/19803))
## [4.2.1] - 2026-02-23
### Fixed
- Allow trailing dash in functional utility names for backwards compatibility ([#19696](https://github.com/tailwindlabs/tailwindcss/pull/19696))
- Properly detect classes containing `.` characters within curly braces in MDX files ([#19711](https://github.com/tailwindlabs/tailwindcss/pull/19711))
## [4.2.0] - 2026-02-18
### Added
- Add mauve, olive, mist, and taupe color palettes to the default theme ([#19627](https://github.com/tailwindlabs/tailwindcss/pull/19627))
- Add `@tailwindcss/webpack` package to run Tailwind CSS as a webpack plugin ([#19610](https://github.com/tailwindlabs/tailwindcss/pull/19610))
- Add `pbs-*` and `pbe-*` utilities for `padding-block-start` and `padding-block-end` ([#19601](https://github.com/tailwindlabs/tailwindcss/pull/19601))
- Add `mbs-*` and `mbe-*` utilities for `margin-block-start` and `margin-block-end` ([#19601](https://github.com/tailwindlabs/tailwindcss/pull/19601))
- Add `scroll-pbs-*` and `scroll-pbe-*` utilities for `scroll-padding-block-start` and `scroll-padding-block-end` ([#19601](https://github.com/tailwindlabs/tailwindcss/pull/19601))
- Add `scroll-mbs-*` and `scroll-mbe-*` utilities for `scroll-margin-block-start` and `scroll-margin-block-end` ([#19601](https://github.com/tailwindlabs/tailwindcss/pull/19601))
- Add `border-bs-*` and `border-be-*` utilities for `border-block-start` and `border-block-end` ([#19601](https://github.com/tailwindlabs/tailwindcss/pull/19601))
- Add `inline-*`, `min-inline-*`, `max-inline-*` utilities for `inline-size`, `min-inline-size`, and `max-inline-size` ([#19612](https://github.com/tailwindlabs/tailwindcss/pull/19612))
- Add `block-*`, `min-block-*`, `max-block-*` utilities for `block-size`, `min-block-size`, and `max-block-size` ([#19612](https://github.com/tailwindlabs/tailwindcss/pull/19612))
- Add `inset-s-*`, `inset-e-*`, `inset-bs-*`, `inset-be-*` utilities for `inset-inline-start`, `inset-inline-end`, `inset-block-start`, and `inset-block-end` ([#19613](https://github.com/tailwindlabs/tailwindcss/pull/19613))
- Add `font-features-*` utility for `font-feature-settings` ([#19623](https://github.com/tailwindlabs/tailwindcss/pull/19623))
### Fixed
- Prevent double `@supports` wrapper for `color-mix` values ([#19450](https://github.com/tailwindlabs/tailwindcss/pull/19450))
- Allow whitespace around `@source inline()` argument ([#19461](https://github.com/tailwindlabs/tailwindcss/pull/19461))
- Emit comment when source maps are saved to files when using `@tailwindcss/cli` ([#19447](https://github.com/tailwindlabs/tailwindcss/pull/19447))
- Detect utilities containing capital letters followed by numbers ([#19465](https://github.com/tailwindlabs/tailwindcss/pull/19465))
- Fix class extraction for Rails' strict locals ([#19525](https://github.com/tailwindlabs/tailwindcss/pull/19525))
- Align `@utility` name validation with Oxide scanner rules ([#19524](https://github.com/tailwindlabs/tailwindcss/pull/19524))
- Fix infinite loop when using `@variant` inside `@custom-variant` ([#19633](https://github.com/tailwindlabs/tailwindcss/pull/19633))
- Allow multiples of `.25` in `aspect-*` fractions (e.g. `aspect-8.5/11`) ([#19688](https://github.com/tailwindlabs/tailwindcss/pull/19688))
- Ensure changes to external files listed via `@source` trigger a full page reload when using `@tailwindcss/vite` ([#19670](https://github.com/tailwindlabs/tailwindcss/pull/19670))
- Improve performance of Oxide scanner in bigger projects by reducing file system walks ([#19632](https://github.com/tailwindlabs/tailwindcss/pull/19632))
- Ensure import aliases in Astro v5 work without crashing when using `@tailwindcss/vite` ([#19677](https://github.com/tailwindlabs/tailwindcss/issues/19677))
- Allow escape characters in `@utility` names to improve support with formatters such as Biome ([#19626](https://github.com/tailwindlabs/tailwindcss/pull/19626))
- Fix incorrect canonicalization results when canonicalizing multiple times ([#19675](https://github.com/tailwindlabs/tailwindcss/pull/19675))
- Add `.jj` to default ignored content directories ([#19687](https://github.com/tailwindlabs/tailwindcss/pull/19687))
### Deprecated
- Deprecate `start-*` and `end-*` utilities in favor of `inset-s-*` and `inset-e-*` utilities ([#19613](https://github.com/tailwindlabs/tailwindcss/pull/19613))
## [4.1.18] - 2025-12-11
### Fixed
- Ensure validation of `source(…)` happens relative to the file it is in ([#19274](https://github.com/tailwindlabs/tailwindcss/pull/19274))
- Include filename and line numbers in CSS parse errors ([#19282](https://github.com/tailwindlabs/tailwindcss/pull/19282))
- Skip comments in Ruby files when checking for class names ([#19243](https://github.com/tailwindlabs/tailwindcss/pull/19243))
- Skip over arbitrary property utilities with a top-level `!` in the value ([#19243](https://github.com/tailwindlabs/tailwindcss/pull/19243))
- Support environment API in `@tailwindcss/vite` ([#18970](https://github.com/tailwindlabs/tailwindcss/pull/18970))
- Preserve case of theme keys from JS configs and plugins ([#19337](https://github.com/tailwindlabs/tailwindcss/pull/19337))
- Write source maps correctly on the CLI when using `--watch` ([#19373](https://github.com/tailwindlabs/tailwindcss/pull/19373))
- Handle special defaults (like `ringColor.DEFAULT`) in JS configs ([#19348](https://github.com/tailwindlabs/tailwindcss/pull/19348))
- Improve backwards compatibility for `content` theme key from JS configs ([#19381](https://github.com/tailwindlabs/tailwindcss/pull/19381))
- Upgrade: Handle `future` and `experimental` config keys ([#19344](https://github.com/tailwindlabs/tailwindcss/pull/19344))
- Try to canonicalize any arbitrary utility to a bare value ([#19379](https://github.com/tailwindlabs/tailwindcss/pull/19379))
- Validate candidates similarly to Oxide ([#19397](https://github.com/tailwindlabs/tailwindcss/pull/19397))
- Canonicalization: combine `text-*` and `leading-*` classes ([#19396](https://github.com/tailwindlabs/tailwindcss/pull/19396))
- Correctly handle duplicate CLI arguments ([#19416](https://github.com/tailwindlabs/tailwindcss/pull/19416))
- Don’t emit color-mix fallback rules inside `@keyframes` ([#19419](https://github.com/tailwindlabs/tailwindcss/pull/19419))
- CLI: Don't hang when output is `/dev/stdout` ([#19421](https://github.com/tailwindlabs/tailwindcss/pull/19421))
## [3.4.19] - 2025-12-10
### Fixed
- Don’t break `sibling-*()` functions when used inside `calc(…)` ([#19335](https://github.com/tailwindlabs/tailwindcss/pull/19335))
## [4.1.17] - 2025-11-06
### Fixed
- Substitute `@variant` inside legacy JS APIs ([#19263](https://github.com/tailwindlabs/tailwindcss/pull/19263))
- Prevent occasional crash on Windows when loaded into a worker thread ([#19242](https://github.com/tailwindlabs/tailwindcss/pull/19242))
## [4.1.16] - 2025-10-23
### Fixed
- Discard candidates with an empty data type ([#19172](https://github.com/tailwindlabs/tailwindcss/pull/19172))
- Fix canonicalization of arbitrary variants with attribute selectors ([#19176](https://github.com/tailwindlabs/tailwindcss/pull/19176))
- Fix invalid colors due to nested `&` ([#19184](https://github.com/tailwindlabs/tailwindcss/pull/19184))
- Improve canonicalization for `& > :pseudo` and `& :pseudo` arbitrary variants ([#19178](https://github.com/tailwindlabs/tailwindcss/pull/19178))
## [4.1.15] - 2025-10-20
### Fixed
- Fix Safari devtools rendering issue due to `color-mix` fallback ([#19069](https://github.com/tailwindlabs/tailwindcss/pull/19069))
- Suppress Lightning CSS warnings about `:deep`, `:slotted`, and `:global` ([#19094](https://github.com/tailwindlabs/tailwindcss/pull/19094))
- Fix resolving theme keys when starting with the name of another theme key in JS configs and plugins ([#19097](https://github.com/tailwindlabs/tailwindcss/pull/19097))
- Allow named groups in combination with `not-*`, `has-*`, and `in-*` ([#19100](https://github.com/tailwindlabs/tailwindcss/pull/19100))
- Prevent important utilities from affecting other utilities ([#19110](https://github.com/tailwindlabs/tailwindcss/pull/19110))
- Don’t index into strings with the `theme(…)` function ([#19111](https://github.com/tailwindlabs/tailwindcss/pull/19111))
- Fix parsing issue when `\t` is used in at-rules ([#19130](https://github.com/tailwindlabs/tailwindcss/pull/19130))
- Upgrade: Canonicalize utilities containing `0` values ([#19095](https://github.com/tailwindlabs/tailwindcss/pull/19095))
- Upgrade: Migrate deprecated `break-words` to `wrap-break-word` ([#19157](https://github.com/tailwindlabs/tailwindcss/pull/19157))
### Changed
- Remove the `postinstall` script from oxide ([#19149])(https://github.com/tailwindlabs/tailwindcss/pull/19149)
## [4.1.14] - 2025-10-01
- _Experimental_: Add `@container-size` utility ([#18901](https://github.com/tailwindlabs/tailwindcss/pull/18901))
### Fixed
@ -302,26 +20,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Do not generate `grid-column` utilities when configuring `grid-column-start` or `grid-column-end` ([#18907](https://github.com/tailwindlabs/tailwindcss/pull/18907))
- Do not generate `grid-row` utilities when configuring `grid-row-start` or `grid-row-end` ([#18907](https://github.com/tailwindlabs/tailwindcss/pull/18907))
- Prevent duplicate CSS when overwriting a static utility with a theme key ([#18056](https://github.com/tailwindlabs/tailwindcss/pull/18056))
- Do not migrate `variant = 'outline'` during upgrades ([#18922](https://github.com/tailwindlabs/tailwindcss/pull/18922))
- Show Lightning CSS warnings (if any) when optimizing/minifying ([#18918](https://github.com/tailwindlabs/tailwindcss/pull/18918))
- Use `default` export condition for `@tailwindcss/vite` ([#18948](https://github.com/tailwindlabs/tailwindcss/pull/18948))
- Re-throw errors from PostCSS nodes ([#18373](https://github.com/tailwindlabs/tailwindcss/pull/18373))
- Detect classes in markdown inline directives ([#18967](https://github.com/tailwindlabs/tailwindcss/pull/18967))
- Ensure files with only `@theme` produce no output when built ([#18979](https://github.com/tailwindlabs/tailwindcss/pull/18979))
- Support Maud templates when extracting classes ([#18988](https://github.com/tailwindlabs/tailwindcss/pull/18988))
- Upgrade: Do not migrate `variant = 'outline'` during upgrades ([#18922](https://github.com/tailwindlabs/tailwindcss/pull/18922))
- Upgrade: Show version mismatch (if any) when running upgrade tool ([#19028](https://github.com/tailwindlabs/tailwindcss/pull/19028))
- Upgrade: Ensure first class inside `className` is migrated ([#19031](https://github.com/tailwindlabs/tailwindcss/pull/19031))
- Upgrade: Migrate classes inside `*ClassName` and `*Class` attributes ([#19031](https://github.com/tailwindlabs/tailwindcss/pull/19031))
## [3.4.18] - 2025-10-01
### Fixed
- Improve support for raw `supports-[…]` queries in arbitrary values ([#13605](https://github.com/tailwindlabs/tailwindcss/pull/13605))
- Fix `require.cache` error when loaded through a TypeScript file in Node 22.18+ ([#18665](https://github.com/tailwindlabs/tailwindcss/pull/18665))
- Support `import.meta.resolve(…)` in configs for new enough Node.js versions ([#18938](https://github.com/tailwindlabs/tailwindcss/pull/18938))
- Allow using newer versions of `postcss-load-config` for better ESM and TypeScript PostCSS config support with the CLI ([#18938](https://github.com/tailwindlabs/tailwindcss/pull/18938))
- Remove irrelevant utility rules when matching important classes ([#19030](https://github.com/tailwindlabs/tailwindcss/pull/19030))
## [4.1.13] - 2025-09-03
@ -4123,23 +3828,7 @@ No release notes
- Everything!
[unreleased]: https://github.com/tailwindlabs/tailwindcss/compare/v4.3.3...HEAD
[4.3.3]: https://github.com/tailwindlabs/tailwindcss/compare/v4.3.2...v4.3.3
[4.3.2]: https://github.com/tailwindlabs/tailwindcss/compare/v4.3.1...v4.3.2
[4.3.1]: https://github.com/tailwindlabs/tailwindcss/compare/v4.3.0...v4.3.1
[4.3.0]: https://github.com/tailwindlabs/tailwindcss/compare/v4.2.4...v4.3.0
[4.2.4]: https://github.com/tailwindlabs/tailwindcss/compare/v4.2.3...v4.2.4
[4.2.3]: https://github.com/tailwindlabs/tailwindcss/compare/v4.2.2...v4.2.3
[4.2.2]: https://github.com/tailwindlabs/tailwindcss/compare/v4.2.1...v4.2.2
[4.2.1]: https://github.com/tailwindlabs/tailwindcss/compare/v4.2.0...v4.2.1
[4.2.0]: https://github.com/tailwindlabs/tailwindcss/compare/v4.1.18...v4.2.0
[4.1.18]: https://github.com/tailwindlabs/tailwindcss/compare/v4.1.17...v4.1.18
[3.4.19]: https://github.com/tailwindlabs/tailwindcss/compare/v3.4.18...v3.4.19
[4.1.17]: https://github.com/tailwindlabs/tailwindcss/compare/v4.1.16...v4.1.17
[4.1.16]: https://github.com/tailwindlabs/tailwindcss/compare/v4.1.15...v4.1.16
[4.1.15]: https://github.com/tailwindlabs/tailwindcss/compare/v4.1.14...v4.1.15
[4.1.14]: https://github.com/tailwindlabs/tailwindcss/compare/v4.1.13...v4.1.14
[3.4.18]: https://github.com/tailwindlabs/tailwindcss/compare/v3.4.17...v3.4.18
[unreleased]: https://github.com/tailwindlabs/tailwindcss/compare/v4.1.13...HEAD
[4.1.13]: https://github.com/tailwindlabs/tailwindcss/compare/v4.1.12...v4.1.13
[4.1.12]: https://github.com/tailwindlabs/tailwindcss/compare/v4.1.11...v4.1.12
[4.1.11]: https://github.com/tailwindlabs/tailwindcss/compare/v4.1.10...v4.1.11

309
Cargo.lock generated
View file

@ -40,7 +40,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "531a9155a481e2ee699d4f98f43c0ca4ff8ee1bfd55c31e9e98fb29d2b176fe0"
dependencies = [
"memchr",
"regex-automata 0.4.18",
"regex-automata 0.4.8",
"serde",
]
@ -59,31 +59,33 @@ dependencies = [
"syn",
]
[[package]]
name = "console"
version = "0.16.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c"
dependencies = [
"encode_unicode",
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "convert_case"
version = "0.11.0"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49"
checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "crossbeam-channel"
version = "0.5.15"
name = "crossbeam"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8"
dependencies = [
"crossbeam-channel",
"crossbeam-deque",
"crossbeam-epoch",
"crossbeam-queue",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-channel"
version = "0.5.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33480d6946193aa8033910124896ca395333cae7e2d1113d1fef6c3272217df2"
dependencies = [
"crossbeam-utils",
]
@ -107,6 +109,15 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-queue"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df0346b5d5e76ac2fe4e327c5fd1118d6be7c51dfb18f9b7922923f287471e35"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.20"
@ -115,9 +126,19 @@ checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80"
[[package]]
name = "ctor"
version = "1.0.12"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d83cb7e7a873830708d6b02a78cd36a592c6fa14bf267b68725103b85c0d77f"
checksum = "67773048316103656a637612c4a62477603b777d91d9c62ff2290f9cde178fdb"
dependencies = [
"ctor-proc-macro",
"dtor",
]
[[package]]
name = "ctor-proc-macro"
version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2"
[[package]]
name = "diff"
@ -125,6 +146,21 @@ version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
[[package]]
name = "dtor"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e58a0764cddb55ab28955347b45be00ade43d4d6f3ba4bf3dc354e4ec9432934"
dependencies = [
"dtor-proc-macro",
]
[[package]]
name = "dtor-proc-macro"
version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5"
[[package]]
name = "dunce"
version = "1.0.5"
@ -137,12 +173,6 @@ version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91"
[[package]]
name = "encode_unicode"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]]
name = "errno"
version = "0.3.9"
@ -169,103 +199,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6"
[[package]]
name = "futures"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
name = "fsops"
version = "0.1.0"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
name = "futures-core"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-executor"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-io"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
name = "futures-macro"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures-sink"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
[[package]]
name = "futures-task"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-util"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task",
"memchr",
"pin-project-lite",
"slab",
"dunce",
]
[[package]]
name = "globset"
version = "0.4.20"
version = "0.4.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988"
checksum = "54a1028dfc5f5df5da8a56a73e6c153c9a9708ec57232470703592a3f18e49f5"
dependencies = [
"aho-corasick",
"bstr",
"log",
"regex-automata 0.4.18",
"regex-automata 0.4.8",
"regex-syntax 0.8.5",
]
@ -276,10 +225,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757"
dependencies = [
"bitflags",
"ignore 0.4.23",
"ignore 0.4.23 (registry+https://github.com/rust-lang/crates.io-index)",
"walkdir",
]
[[package]]
name = "ignore"
version = "0.4.23"
dependencies = [
"bstr",
"crossbeam-channel",
"crossbeam-deque",
"dunce",
"fsops",
"globset",
"log",
"memchr",
"regex-automata 0.4.8",
"same-file",
"walkdir",
"winapi-util",
]
[[package]]
name = "ignore"
version = "0.4.23"
@ -290,41 +257,12 @@ dependencies = [
"globset",
"log",
"memchr",
"regex-automata 0.4.18",
"regex-automata 0.4.8",
"same-file",
"walkdir",
"winapi-util",
]
[[package]]
name = "ignore"
version = "0.4.33"
dependencies = [
"bstr",
"crossbeam-channel",
"crossbeam-deque",
"dunce",
"globset",
"log",
"memchr",
"regex-automata 0.4.18",
"same-file",
"walkdir",
"winapi-util",
]
[[package]]
name = "insta"
version = "1.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82"
dependencies = [
"console",
"once_cell",
"similar",
"tempfile",
]
[[package]]
name = "itertools"
version = "0.11.0"
@ -348,12 +286,12 @@ checksum = "561d97a539a36e26a9a5fad1ea11a3039a67714694aaa379433e580854bc3dc5"
[[package]]
name = "libloading"
version = "0.9.0"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60"
checksum = "4979f22fdb869068da03c9f7528f8297c6fd2606bc3a4affe42e6a823fdb8da4"
dependencies = [
"cfg-if",
"windows-link",
"windows-targets",
]
[[package]]
@ -391,13 +329,12 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "napi"
version = "3.11.0"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de33522036981030a75c231829566bc63414e08101a6f5ff4ac6cef19c8e0941"
checksum = "f1b74e3dce5230795bb4d2821b941706dee733c7308752507254b0497f39cad7"
dependencies = [
"bitflags",
"ctor",
"futures",
"napi-build",
"napi-sys",
"nohash-hasher",
@ -406,15 +343,15 @@ dependencies = [
[[package]]
name = "napi-build"
version = "2.3.2"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9c366d2c8c60b86fa632df75f745509b52f9128f91a6bad4c796e44abb505e1"
checksum = "dcae8ad5609d14afb3a3b91dee88c757016261b151e9dcecabf1b2a31a6cab14"
[[package]]
name = "napi-derive"
version = "3.6.0"
version = "3.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a49c513341a61a16a10af6efcce46b30d0822ba2d4fb197d24d33dfc199c78d5"
checksum = "7552d5a579b834614bbd496db5109f1b9f1c758f08224b0dee1e408333adf0d0"
dependencies = [
"convert_case",
"ctor",
@ -426,9 +363,9 @@ dependencies = [
[[package]]
name = "napi-derive-backend"
version = "6.1.1"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d60b5d773ad46c698c8cc2cd9fde0b283d39cbb7f71c04bee633c7bdba4423bd"
checksum = "5f6a81ac7486b70f2532a289603340862c06eea5a1e650c1ffeda2ce1238516a"
dependencies = [
"convert_case",
"proc-macro2",
@ -439,9 +376,9 @@ dependencies = [
[[package]]
name = "napi-sys"
version = "3.3.0"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85fbf1fa9f1babfe396d74bbbf52b3643770243e8f5b0b46715d4caf7f0dfc9a"
checksum = "3e4e7135a8f97aa0f1509cce21a8a1f9dcec1b50d8dee006b48a5adb69a9d64d"
dependencies = [
"libloading",
]
@ -474,9 +411,9 @@ dependencies = [
[[package]]
name = "once_cell"
version = "1.21.4"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
[[package]]
name = "overload"
@ -511,18 +448,18 @@ dependencies = [
[[package]]
name = "quote"
version = "1.0.45"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
checksum = "1b9ab9c7eadfd8df19006f1cf1a4aed13540ed5cbc047010ece5826e10825488"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rayon"
version = "1.12.0"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa"
dependencies = [
"either",
"rayon-core",
@ -530,9 +467,9 @@ dependencies = [
[[package]]
name = "rayon-core"
version = "1.13.0"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
@ -546,7 +483,7 @@ checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata 0.4.18",
"regex-automata 0.4.8",
"regex-syntax 0.8.5",
]
@ -561,9 +498,9 @@ dependencies = [
[[package]]
name = "regex-automata"
version = "0.4.18"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3"
dependencies = [
"aho-corasick",
"memchr",
@ -631,18 +568,6 @@ dependencies = [
"lazy_static",
]
[[package]]
name = "similar"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.10.0"
@ -651,9 +576,9 @@ checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0"
[[package]]
name = "syn"
version = "2.0.87"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25aa4ce346d03a6dcd68dd8b4010bcb74e54e62c90c573f394c46eae99aba32d"
checksum = "32d41677bcbe24c20c52e7c70b0d8db04134c5d1066bf98662e2871ad200ea3e"
dependencies = [
"proc-macro2",
"quote",
@ -678,11 +603,12 @@ dependencies = [
"bexpand",
"bstr",
"classification-macros",
"crossbeam",
"dunce",
"fast-glob",
"fsops",
"globwalk",
"ignore 0.4.33",
"insta",
"ignore 0.4.23",
"log",
"pretty_assertions",
"rayon",
@ -844,12 +770,6 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.52.0"
@ -868,15 +788,6 @@ dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.52.6"

View file

@ -13,10 +13,10 @@
</p>
<p align="center">
<a href="https://github.com/tailwindlabs/tailwindcss/actions"><img src="https://img.shields.io/github/actions/workflow/status/tailwindlabs/tailwindcss/ci.yml?branch=main" alt="Build Status"></a>
<a href="https://github.com/tailwindlabs/tailwindcss/actions"><img src="https://img.shields.io/github/actions/workflow/status/tailwindlabs/tailwindcss/ci.yml?branch=next" alt="Build Status"></a>
<a href="https://www.npmjs.com/package/tailwindcss"><img src="https://img.shields.io/npm/dt/tailwindcss.svg" alt="Total Downloads"></a>
<a href="https://github.com/tailwindlabs/tailwindcss/releases"><img src="https://img.shields.io/npm/v/tailwindcss.svg" alt="Latest Release"></a>
<a href="https://github.com/tailwindlabs/tailwindcss/blob/main/LICENSE"><img src="https://img.shields.io/npm/l/tailwindcss.svg" alt="License"></a>
<a href="https://github.com/tailwindcss/tailwindcss/releases"><img src="https://img.shields.io/npm/v/tailwindcss.svg" alt="Latest Release"></a>
<a href="https://github.com/tailwindcss/tailwindcss/blob/master/LICENSE"><img src="https://img.shields.io/npm/l/tailwindcss.svg" alt="License"></a>
</p>
---
@ -29,8 +29,8 @@ For full documentation, visit [tailwindcss.com](https://tailwindcss.com).
For help, discussion about best practices, or feature ideas:
[Discuss Tailwind CSS on GitHub](https://github.com/tailwindlabs/tailwindcss/discussions)
[Discuss Tailwind CSS on GitHub](https://github.com/tailwindcss/tailwindcss/discussions)
## Contributing
If you're interested in contributing to Tailwind CSS, please read our [contributing docs](https://github.com/tailwindlabs/tailwindcss/blob/main/.github/CONTRIBUTING.md) **before submitting a pull request**.
If you're interested in contributing to Tailwind CSS, please read our [contributing docs](https://github.com/tailwindcss/tailwindcss/blob/next/.github/CONTRIBUTING.md) **before submitting a pull request**.

11
crates/fsops/Cargo.toml Normal file
View file

@ -0,0 +1,11 @@
[package]
name = "fsops"
version = "0.1.0"
edition = "2021"
[lib]
name = "fsops"
bench = false
[dependencies]
dunce = "1.0.5"

41
crates/fsops/src/lib.rs Normal file
View file

@ -0,0 +1,41 @@
use std::io;
use std::path::{Path, PathBuf};
/// https://github.com/oxc-project/oxc-resolver/blob/42a1e3eb50e9a1365c422c41d51f287fe5fb8244/src/file_system.rs#L93-L122
pub fn canonicalize<P: AsRef<Path>>(path: P) -> io::Result<PathBuf> {
#[cfg(not(target_os = "wasi"))]
{
dunce::canonicalize(path)
}
#[cfg(target_os = "wasi")]
{
canonicalize_wasi(path)
}
}
#[cfg(target_os = "wasi")]
fn canonicalize_wasi<P: AsRef<Path>>(path: P) -> io::Result<PathBuf> {
use std::fs;
let path = path.as_ref();
let meta = fs::symlink_metadata(path)?;
if meta.file_type().is_symlink() {
let link = fs::read_link(path)?;
let mut path_buf = path.to_path_buf();
path_buf.pop();
for segment in link.iter() {
match segment.to_str() {
Some("..") => {
path_buf.pop();
}
Some(".") | None => {}
Some(seg) => {
// Need to trim the extra \0 introduces by rust std rust-lang/rust#123727
path_buf.push(seg.trim_end_matches('\0'));
}
}
}
Ok(path_buf)
} else {
Ok(path.to_path_buf())
}
}

View file

@ -1,6 +1,6 @@
[package]
name = "ignore"
version = "0.4.33" #:version
version = "0.4.23" #:version
authors = ["Andrew Gallant <jamslam@gmail.com>"]
description = """
A fast library for efficiently matching ignore files such as `.gitignore`
@ -12,10 +12,7 @@ repository = "https://github.com/BurntSushi/ripgrep/tree/master/crates/ignore"
readme = "README.md"
keywords = ["glob", "ignore", "gitignore", "pattern", "file"]
license = "Unlicense OR MIT"
# CHANGED: Use an explicit edition instead of `edition.workspace = true` since this crate is
# vendored into the Tailwind CSS workspace.
edition = "2024"
rust-version = "1.88"
edition = "2021"
[lib]
name = "ignore"
@ -23,17 +20,16 @@ bench = false
[dependencies]
crossbeam-deque = "0.8.3"
# CHANGED: Use the published globset crate instead of a path dependency.
globset = "0.4.20"
globset = "0.4.16"
log = "0.4.20"
memchr = "2.6.3"
same-file = "1.0.6"
walkdir = "2.4.0"
# CHANGED: Added `dunce` to canonicalize paths without UNC prefixes on Windows.
dunce = "1.0.5"
fsops = { path = "../fsops" }
[dependencies.regex-automata]
version = "0.4.18"
version = "0.4.0"
default-features = false
features = ["std", "perf", "syntax", "meta", "nfa", "hybrid", "dfa-onepass"]
@ -42,7 +38,7 @@ version = "0.1.2"
[dev-dependencies]
bstr = { version = "1.6.2", default-features = false, features = ["std"] }
crossbeam-channel = "0.5.15"
crossbeam-channel = "0.5.8"
[features]
# DEPRECATED. It is a no-op. SIMD is done automatically through runtime

View file

@ -1,5 +1,5 @@
ignore
======
# ignore
The ignore crate provides a fast recursive directory iterator that respects
various filters such as globs, file types and `.gitignore` files. This crate
also provides lower level direct access to gitignore and file type matchers.
@ -29,7 +29,6 @@ recursively traverse the current directory while automatically filtering out
files and directories according to ignore globs found in files like
`.ignore` and `.gitignore`:
```rust,no_run
use ignore::Walk;

View file

@ -18,8 +18,8 @@ fn main() {
let stdout_thread = std::thread::spawn(move || {
let mut stdout = std::io::BufWriter::new(std::io::stdout());
for dent in rx {
stdout.write_all(&Vec::from_path_lossy(dent.path())).unwrap();
stdout.write_all(b"\n").unwrap();
stdout.write(&*Vec::from_path_lossy(dent.path())).unwrap();
stdout.write(b"\n").unwrap();
}
});

View file

@ -27,10 +27,9 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[
(&["bat", "batch"], &["*.bat"]),
(&["bazel"], &[
"*.bazel", "*.bzl", "*.BUILD", "*.bazelrc", "BUILD", "MODULE.bazel",
"WORKSPACE", "WORKSPACE.bazel", "WORKSPACE.bzlmod",
"WORKSPACE", "WORKSPACE.bazel",
]),
(&["bitbake"], &["*.bb", "*.bbappend", "*.bbclass", "*.conf", "*.inc"]),
(&["boxlang"], &["*.bx", "*.bxm", "*.bxs"]),
(&["brotli"], &["*.br"]),
(&["buildstream"], &["*.bst"]),
(&["bzip2"], &["*.bz2", "*.tbz2"]),
@ -40,14 +39,12 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[
(&["carp"], &["*.carp"]),
(&["cbor"], &["*.cbor"]),
(&["ceylon"], &["*.ceylon"]),
(&["cfml"], &["*.cfc", "*.cfm"]),
(&["clojure"], &["*.clj", "*.cljc", "*.cljs", "*.cljx"]),
(&["cmake"], &["*.cmake", "CMakeLists.txt"]),
(&["cmd"], &["*.bat", "*.cmd"]),
(&["cml"], &["*.cml"]),
(&["coffeescript"], &["*.coffee"]),
(&["config"], &["*.cfg", "*.conf", "*.config", "*.ini"]),
(&["container"], &["*Containerfile*", "*Dockerfile*"]),
(&["coq"], &["*.v"]),
(&["cpp"], &[
"*.[ChH]", "*.cc", "*.[ch]pp", "*.[ch]xx", "*.hh", "*.inl",
@ -65,7 +62,7 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[
(&["cython"], &["*.pyx", "*.pxi", "*.pxd"]),
(&["d"], &["*.d"]),
(&["dart"], &["*.dart"]),
(&["devicetree"], &["*.dts", "*.dtsi", "*.dtso"]),
(&["devicetree"], &["*.dts", "*.dtsi"]),
(&["dhall"], &["*.dhall"]),
(&["diff"], &["*.patch", "*.diff"]),
(&["dita"], &["*.dita", "*.ditamap", "*.ditaval"]),
@ -91,8 +88,6 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[
(&["fsharp"], &["*.fs", "*.fsx", "*.fsi"]),
(&["fut"], &["*.fut"]),
(&["gap"], &["*.g", "*.gap", "*.gi", "*.gd", "*.tst"]),
(&["gdscript"], &["*.gd"]),
(&["gleam"], &["*.gleam"]),
(&["gn"], &["*.gn", "*.gni"]),
(&["go"], &["*.go"]),
(&["gprbuild"], &["*.gpr"]),
@ -110,7 +105,6 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[
(&["hbs"], &["*.hbs"]),
(&["hs"], &["*.hs", "*.lhs"]),
(&["html"], &["*.htm", "*.html", "*.ejs"]),
(&["hurl"], &["*.hurl"]),
(&["hy"], &["*.hy"]),
(&["idris"], &["*.idr", "*.lidr"]),
(&["janet"], &["*.janet"]),
@ -123,7 +117,6 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[
(&["julia"], &["*.jl"]),
(&["jupyter"], &["*.ipynb", "*.jpynb"]),
(&["k"], &["*.k"]),
(&["kconfig"], &["Kconfig", "Kconfig.*"]),
(&["kotlin"], &["*.kt", "*.kts"]),
(&["lean"], &["*.lean"]),
(&["less"], &["*.less"]),
@ -156,7 +149,6 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[
]),
(&["lilypond"], &["*.ly", "*.ily"]),
(&["lisp"], &["*.el", "*.jl", "*.lisp", "*.lsp", "*.sc", "*.scm"]),
(&["llvm"], &["*.ll"]),
(&["lock"], &["*.lock", "package-lock.json"]),
(&["log"], &["*.log"]),
(&["lua"], &["*.lua"]),
@ -167,7 +159,6 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[
"[Gg][Nn][Uu]makefile", "[Mm]akefile",
"[Gg][Nn][Uu]makefile.am", "[Mm]akefile.am",
"[Gg][Nn][Uu]makefile.in", "[Mm]akefile.in",
"Makefile.*",
"*.mk", "*.mak"
]),
(&["mako"], &["*.mako", "*.mao"]),
@ -187,11 +178,10 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[
(&["mint"], &["*.mint"]),
(&["mk"], &["mkfile"]),
(&["ml"], &["*.ml"]),
(&["mojo"], &["*.mojo"]),
(&["motoko"], &["*.mo"]),
(&["msbuild"], &[
"*.csproj", "*.fsproj", "*.vcxproj", "*.proj", "*.props", "*.targets",
"*.sln", "*.slnf"
"*.sln",
]),
(&["nim"], &["*.nim", "*.nimf", "*.nimble", "*.nims"]),
(&["nix"], &["*.nix"]),
@ -209,21 +199,18 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[
"*.php", "*.php3", "*.php4", "*.php5", "*.php7", "*.php8",
"*.pht", "*.phtml"
]),
(&["pkgbuild"], &["PKGBUILD"]),
(&["po"], &["*.po"]),
(&["pod"], &["*.pod"]),
(&["postscript"], &["*.eps", "*.ps"]),
(&["prolog"], &["*.pl", "*.pro", "*.prolog", "*.P"]),
(&["proto", "protobuf"], &["*.proto"]),
(&["protobuf"], &["*.proto"]),
(&["ps"], &["*.cdxml", "*.ps1", "*.ps1xml", "*.psd1", "*.psm1"]),
(&["puppet"], &["*.epp", "*.erb", "*.pp", "*.rb"]),
(&["purs"], &["*.purs"]),
(&["py", "python"], &["*.py", "*.pyi"]),
(&["qmake"], &["*.pro", "*.pri", "*.prf"]),
(&["qml"], &["*.qml"]),
(&["qrc"], &["*.qrc"]),
(&["qui"], &["*.ui"]),
(&["r"], &["*.R", "*.r", "*.Rmd", "*.rmd", "*.Rnw", "*.rnw"]),
(&["r"], &["*.R", "*.r", "*.Rmd", "*.Rnw"]),
(&["racket"], &["*.rkt"]),
(&["raku"], &[
"*.raku", "*.rakumod", "*.rakudoc", "*.rakutest",
@ -235,22 +222,19 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[
(&["red"], &["*.r", "*.red", "*.reds"]),
(&["rescript"], &["*.res", "*.resi"]),
(&["robot"], &["*.robot"]),
(&["rocq"], &["*.v"]),
(&["rst"], &["*.rst"]),
(&["ruby"], &[
// Idiomatic files
"config.ru", "Gemfile", ".irbrc", "Rakefile",
// Extensions
"*.gemspec", "*.rb", "*.rbw", "*.rake"
"*.gemspec", "*.rb", "*.rbw"
]),
(&["rust"], &["*.rs"]),
(&["sass"], &["*.sass", "*.scss"]),
(&["scala"], &["*.scala", "*.sbt"]),
(&["scdoc"], &["*.scd", "*.scdoc"]),
(&["seed7"], &["*.sd7", "*.s7i"]),
(&["sh"], &[
// Portable/misc. init files
".env", ".login", ".logout", ".profile", "profile",
".login", ".logout", ".profile", "profile",
// bash-specific init files
".bash_login", "bash_login",
".bash_logout", "bash_logout",
@ -269,7 +253,7 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[
".zprofile", "zprofile",
".zshrc", "zshrc",
// Extensions
"*.bash", "*.csh", "*.env", "*.ksh", "*.sh", "*.tcsh", "*.zsh",
"*.bash", "*.csh", "*.ksh", "*.sh", "*.tcsh", "*.zsh",
]),
(&["slim"], &["*.skim", "*.slim", "*.slime"]),
(&["smarty"], &["*.tpl"]),
@ -279,10 +263,9 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[
(&["spark"], &["*.spark"]),
(&["spec"], &["*.spec"]),
(&["sql"], &["*.sql", "*.psql"]),
(&["ssa"], &["*.ssa"]),
(&["stylus"], &["*.styl"]),
(&["sv"], &["*.v", "*.vg", "*.sv", "*.svh", "*.h"]),
(&["svelte"], &["*.svelte", "*.svelte.ts"]),
(&["svelte"], &["*.svelte"]),
(&["svg"], &["*.svg"]),
(&["swift"], &["*.swift"]),
(&["swig"], &["*.def", "*.i"]),
@ -297,8 +280,9 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[
(&["texinfo"], &["*.texi"]),
(&["textile"], &["*.textile"]),
(&["tf"], &[
"*.tf", "*.tf.json", "*.tfvars", "*.tfvars.json",
"*.terraformrc", "terraform.rc", "*.tfrc", "*.terraform.lock.hcl",
"*.tf", "*.auto.tfvars", "terraform.tfvars", "*.tf.json",
"*.auto.tfvars.json", "terraform.tfvars.json", "*.terraformrc",
"terraform.rc", "*.tfrc", "*.terraform.lock.hcl",
]),
(&["thrift"], &["*.thrift"]),
(&["toml"], &["*.toml", "Cargo.lock"]),
@ -306,7 +290,6 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[
(&["twig"], &["*.twig"]),
(&["txt"], &["*.txt"]),
(&["typoscript"], &["*.typoscript", "*.ts"]),
(&["typst"], &["*.typ"]),
(&["usd"], &["*.usd", "*.usda", "*.usdc"]),
(&["v"], &["*.v", "*.vsh"]),
(&["vala"], &["*.vala"]),
@ -365,14 +348,4 @@ mod tests {
previous_name = name;
}
}
#[test]
fn default_types_aliases_are_sorted() {
for (aliases, _) in DEFAULT_TYPES.iter() {
assert!(
aliases.is_sorted(),
"this alias list is not sorted: {aliases:?}",
);
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -20,8 +20,8 @@ use {
};
use crate::{
Error, Match, PartialErrorBuilder,
pathutil::{is_file_name, strip_prefix},
Error, Match, PartialErrorBuilder,
};
/// Glob represents a single glob in a gitignore file.
@ -102,9 +102,7 @@ impl Gitignore {
///
/// Note that I/O errors are ignored. For more granular control over
/// errors, use `GitignoreBuilder`.
pub fn new<P: AsRef<Path>>(
gitignore_path: P,
) -> (Gitignore, Option<Error>) {
pub fn new<P: AsRef<Path>>(gitignore_path: P) -> (Gitignore, Option<Error>) {
let path = gitignore_path.as_ref();
let parent = path.parent().unwrap_or(Path::new("/"));
let mut builder = GitignoreBuilder::new(parent);
@ -125,26 +123,12 @@ impl Gitignore {
/// The global config file path is specified by git's `core.excludesFile`
/// config option.
///
/// # Behavior
///
/// This routine does its best to discover any global git exclude files.
/// This will try to parse out the `excludesFile` config option in your
/// global git configuration, if necessary.
///
/// The specific things this routine tries (which are subject to change
/// based on how git behaves) are:
///
///
///
/// Git's config file location is `$HOME/.gitconfig`. If `$HOME/.gitconfig`
/// does not exist or does not specify `core.excludesFile`, then
/// `$XDG_CONFIG_HOME/git/ignore` is read. If `$XDG_CONFIG_HOME` is not
/// set or is empty, then `$HOME/.config/git/ignore` is used instead.
pub fn global() -> (Gitignore, Option<Error>) {
match std::env::current_dir() {
Ok(cwd) => GitignoreBuilder::new(cwd).build_global(),
Err(err) => (Gitignore::empty(), Some(err.into())),
}
GitignoreBuilder::new("").build_global()
}
/// Creates a new empty gitignore matcher that never matches anything.
@ -158,8 +142,7 @@ impl Gitignore {
num_ignores: 0,
num_whitelists: 0,
matches: None,
// CHANGED: Add a flag to have Gitignore rules that apply only to
// files.
// CHANGED: Add a flag to have Gitignore rules that apply only to files.
only_on_files: false,
}
}
@ -204,11 +187,7 @@ impl Gitignore {
/// determined by a common suffix of the directory containing this
/// gitignore) is stripped. If there is no common suffix/prefix overlap,
/// then `path` is assumed to be relative to this matcher.
pub fn matched<P: AsRef<Path>>(
&self,
path: P,
is_dir: bool,
) -> Match<&Glob> {
pub fn matched<P: AsRef<Path>>(&self, path: P, is_dir: bool) -> Match<&Glob> {
if self.is_empty() {
return Match::None;
}
@ -261,16 +240,11 @@ impl Gitignore {
}
/// Like matched, but takes a path that has already been stripped.
fn matched_stripped<P: AsRef<Path>>(
&self,
path: P,
is_dir: bool,
) -> Match<&Glob> {
fn matched_stripped<P: AsRef<Path>>(&self, path: P, is_dir: bool) -> Match<&Glob> {
if self.is_empty() {
return Match::None;
}
// CHANGED: Rules marked as only_on_files can not match against
// directories.
// CHANGED: Rules marked as only_on_files can not match against directories.
if self.only_on_files && is_dir {
return Match::None;
}
@ -293,10 +267,7 @@ impl Gitignore {
/// Strips the given path such that it's suitable for matching with this
/// gitignore matcher.
fn strip<'a, P: 'a + AsRef<Path> + ?Sized>(
&'a self,
path: &'a P,
) -> &'a Path {
fn strip<'a, P: 'a + AsRef<Path> + ?Sized>(&'a self, path: &'a P) -> &'a Path {
let mut path = path.as_ref();
// A leading ./ is completely superfluous. We also strip it from
// our gitignore root path, so we need to strip it from our candidate
@ -332,7 +303,6 @@ pub struct GitignoreBuilder {
root: PathBuf,
globs: Vec<Glob>,
case_insensitive: bool,
allow_unclosed_class: bool,
// CHANGED: Add a flag to have Gitignore rules that apply only to files.
only_on_files: bool,
}
@ -351,9 +321,7 @@ impl GitignoreBuilder {
root: strip_prefix("./", root).unwrap_or(root).to_path_buf(),
globs: vec![],
case_insensitive: false,
allow_unclosed_class: true,
// CHANGED: Add a flag to have Gitignore rules that apply only to
// files.
// CHANGED: Add a flag to have Gitignore rules that apply only to files.
only_on_files: false,
}
}
@ -364,21 +332,18 @@ impl GitignoreBuilder {
pub fn build(&self) -> Result<Gitignore, Error> {
let nignore = self.globs.iter().filter(|g| !g.is_whitelist()).count();
let nwhite = self.globs.iter().filter(|g| g.is_whitelist()).count();
let set = self
.builder
.build()
.map_err(|err| Error::Glob { glob: None, err: err.to_string() })?;
let set = self.builder.build().map_err(|err| Error::Glob {
glob: None,
err: err.to_string(),
})?;
Ok(Gitignore {
set,
root: self.root.clone(),
globs: self.globs.clone(),
num_ignores: nignore as u64,
num_whitelists: nwhite as u64,
matches: Some(Arc::new(
Pool::with_available_parallelism_capacity(|| vec![]),
)),
// CHANGED: Add a flag to have Gitignore rules that apply only to
// files.
matches: Some(Arc::new(Pool::new(|| vec![]))),
// CHANGED: Add a flag to have Gitignore rules that apply only to files.
only_on_files: self.only_on_files,
})
}
@ -438,12 +403,6 @@ impl GitignoreBuilder {
break;
}
};
// Match Git's handling of .gitignore files that begin with the Unicode BOM
const UTF8_BOM: &str = "\u{feff}";
let line =
if i == 0 { line.trim_start_matches(UTF8_BOM) } else { &line };
if let Err(err) = self.add_line(Some(path.to_path_buf()), &line) {
errs.push(err.tagged(path, lineno));
}
@ -547,7 +506,6 @@ impl GitignoreBuilder {
.literal_separator(true)
.case_insensitive(self.case_insensitive)
.backslash_escape(true)
.allow_unclosed_class(self.allow_unclosed_class)
.build()
.map_err(|err| Error::Glob {
glob: Some(glob.original.clone()),
@ -564,36 +522,13 @@ impl GitignoreBuilder {
/// affected.
///
/// This is disabled by default.
pub fn case_insensitive(
&mut self,
yes: bool,
) -> Result<&mut GitignoreBuilder, Error> {
pub fn case_insensitive(&mut self, yes: bool) -> Result<&mut GitignoreBuilder, Error> {
// TODO: This should not return a `Result`. Fix this in the next semver
// release.
self.case_insensitive = yes;
Ok(self)
}
/// Toggle whether unclosed character classes are allowed. When allowed,
/// a `[` without a matching `]` is treated literally instead of resulting
/// in a parse error.
///
/// For example, if this is set then the glob `[abc` will be treated as the
/// literal string `[abc` instead of returning an error.
///
/// By default, this is true in order to match established `gitignore`
/// semantics. Generally speaking, enabling this leads to worse failure
/// modes since the glob parser becomes more permissive. You might want to
/// enable this when compatibility (e.g., with POSIX glob implementations)
/// is more important than good error messages.
pub fn allow_unclosed_class(
&mut self,
yes: bool,
) -> &mut GitignoreBuilder {
self.allow_unclosed_class = yes;
self
}
/// CHANGED: Add a flag to have Gitignore rules that apply only to files.
///
/// If this is set, then the globs will only be matched against file paths.
@ -609,56 +544,32 @@ impl GitignoreBuilder {
///
/// Note that the file path returned may not exist.
pub fn gitconfig_excludes_path() -> Option<PathBuf> {
// When GIT_CONFIG_GLOBAL is set, it replaces both $HOME/.gitconfig and
// $XDG_CONFIG_HOME/git/config (per git 2.32+). Otherwise, git supports
// $HOME/.gitconfig and $XDG_CONFIG_HOME/git/config simultaneously, where
// $HOME/.gitconfig takes precedent.
gitconfig_global_env_contents()
.and_then(|x| parse_excludes_file(&x))
.or_else(|| {
gitconfig_home_contents().and_then(|x| parse_excludes_file(&x))
})
.or_else(|| {
gitconfig_xdg_contents().and_then(|x| parse_excludes_file(&x))
})
// System-level config has the lowest priority for core.excludesFile.
// GIT_CONFIG_SYSTEM overrides the default /etc/gitconfig path.
.or_else(|| {
gitconfig_system_contents().and_then(|x| parse_excludes_file(&x))
})
.or_else(excludes_file_default)
}
/// Returns the file contents of git's global config file from the path
/// specified by the `GIT_CONFIG_GLOBAL` environment variable.
fn gitconfig_global_env_contents() -> Option<Vec<u8>> {
let path = std::env::var_os("GIT_CONFIG_GLOBAL").map(PathBuf::from)?;
if path.as_os_str().is_empty() {
return None;
// git supports $HOME/.gitconfig and $XDG_CONFIG_HOME/git/config. Notably,
// both can be active at the same time, where $HOME/.gitconfig takes
// precedent. So if $HOME/.gitconfig defines a `core.excludesFile`, then
// we're done.
match gitconfig_home_contents().and_then(|x| parse_excludes_file(&x)) {
Some(path) => return Some(path),
None => {}
}
let mut file = BufReader::new(File::open(path).ok()?);
let mut contents = vec![];
file.read_to_end(&mut contents).ok().map(|_| contents)
}
/// Returns the file contents of git's system-level config file.
///
/// Checks `GIT_CONFIG_SYSTEM` first, then falls back to `/etc/gitconfig`.
fn gitconfig_system_contents() -> Option<Vec<u8>> {
let path = std::env::var_os("GIT_CONFIG_SYSTEM")
.map(PathBuf::from)
.filter(|x| !x.as_os_str().is_empty())
.unwrap_or_else(|| PathBuf::from("/etc/gitconfig"));
let mut file = BufReader::new(File::open(path).ok()?);
let mut contents = vec![];
file.read_to_end(&mut contents).ok().map(|_| contents)
match gitconfig_xdg_contents().and_then(|x| parse_excludes_file(&x)) {
Some(path) => return Some(path),
None => {}
}
excludes_file_default()
}
/// Returns the file contents of git's global config file, if one exists, in
/// the user's home directory.
fn gitconfig_home_contents() -> Option<Vec<u8>> {
let home = home_dir()?;
let mut file = BufReader::new(File::open(home.join(".gitconfig")).ok()?);
let home = match home_dir() {
None => return None,
Some(home) => home,
};
let mut file = match File::open(home.join(".gitconfig")) {
Err(_) => return None,
Ok(file) => BufReader::new(file),
};
let mut contents = vec![];
file.read_to_end(&mut contents).ok().map(|_| contents)
}
@ -667,11 +578,19 @@ fn gitconfig_home_contents() -> Option<Vec<u8>> {
/// the user's XDG_CONFIG_HOME directory.
fn gitconfig_xdg_contents() -> Option<Vec<u8>> {
let path = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.filter(|x| !x.as_os_str().is_empty())
.and_then(|x| {
if x.is_empty() {
None
} else {
Some(PathBuf::from(x))
}
})
.or_else(|| home_dir().map(|p| p.join(".config")))
.map(|x| x.join("git/config"))?;
let mut file = BufReader::new(File::open(path).ok()?);
.map(|x| x.join("git/config"));
let mut file = match path.and_then(|p| File::open(p).ok()) {
None => return None,
Some(file) => BufReader::new(file),
};
let mut contents = vec![];
file.read_to_end(&mut contents).ok().map(|_| contents)
}
@ -681,8 +600,13 @@ fn gitconfig_xdg_contents() -> Option<Vec<u8>> {
/// Specifically, this respects XDG_CONFIG_HOME.
fn excludes_file_default() -> Option<PathBuf> {
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.filter(|x| !x.as_os_str().is_empty())
.and_then(|x| {
if x.is_empty() {
None
} else {
Some(PathBuf::from(x))
}
})
.or_else(|| home_dir().map(|p| p.join(".config")))
.map(|x| x.join("git/ignore"))
}
@ -711,7 +635,9 @@ fn parse_excludes_file(data: &[u8]) -> Option<PathBuf> {
re.captures(data, &mut caps);
let span = caps.get_group(1)?;
let candidate = &data[span];
std::str::from_utf8(candidate).ok().map(|s| PathBuf::from(expand_tilde(s)))
std::str::from_utf8(candidate)
.ok()
.map(|s| PathBuf::from(expand_tilde(s)))
}
/// Expands ~ in file paths to the value of $HOME.
@ -873,10 +799,7 @@ mod tests {
fn parse_excludes_file4() {
let data = bytes("[core]\nexcludesFile = \"~/foo/bar\"");
let got = super::parse_excludes_file(&data);
assert_eq!(
path_string(got.unwrap()),
super::expand_tilde("~/foo/bar")
);
assert_eq!(path_string(got.unwrap()), super::expand_tilde("~/foo/bar"));
}
#[test]

File diff suppressed because it is too large Load diff

View file

@ -48,16 +48,13 @@ See the documentation for `WalkBuilder` for many other options.
use std::path::{Path, PathBuf};
pub use crate::incremental::{IncrementalIgnore, IncrementalMatch};
pub use crate::walk::{
DirEntry, ParallelVisitor, ParallelVisitorBuilder, Walk, WalkBuilder,
WalkParallel, WalkState,
DirEntry, ParallelVisitor, ParallelVisitorBuilder, Walk, WalkBuilder, WalkParallel, WalkState,
};
mod default_types;
mod dir;
pub mod gitignore;
mod incremental;
pub mod overrides;
mod pathutil;
pub mod types;
@ -123,31 +120,34 @@ impl Clone for Error {
fn clone(&self) -> Error {
match *self {
Error::Partial(ref errs) => Error::Partial(errs.clone()),
Error::WithLineNumber { line, ref err } => {
Error::WithLineNumber { line, err: err.clone() }
}
Error::WithPath { ref path, ref err } => {
Error::WithPath { path: path.clone(), err: err.clone() }
}
Error::WithDepth { depth, ref err } => {
Error::WithDepth { depth, err: err.clone() }
}
Error::Loop { ref ancestor, ref child } => Error::Loop {
Error::WithLineNumber { line, ref err } => Error::WithLineNumber {
line,
err: err.clone(),
},
Error::WithPath { ref path, ref err } => Error::WithPath {
path: path.clone(),
err: err.clone(),
},
Error::WithDepth { depth, ref err } => Error::WithDepth {
depth,
err: err.clone(),
},
Error::Loop {
ref ancestor,
ref child,
} => Error::Loop {
ancestor: ancestor.clone(),
child: child.clone(),
},
Error::Io(ref err) => match err.raw_os_error() {
Some(e) => Error::Io(std::io::Error::from_raw_os_error(e)),
None => {
Error::Io(std::io::Error::new(err.kind(), err.to_string()))
}
None => Error::Io(std::io::Error::new(err.kind(), err.to_string())),
},
Error::Glob { ref glob, ref err } => {
Error::Glob { glob: glob.clone(), err: err.clone() }
}
Error::UnrecognizedFileType(ref err) => {
Error::UnrecognizedFileType(err.clone())
}
Error::Glob { ref glob, ref err } => Error::Glob {
glob: glob.clone(),
err: err.clone(),
},
Error::UnrecognizedFileType(ref err) => Error::UnrecognizedFileType(err.clone()),
Error::InvalidDefinition => Error::InvalidDefinition,
}
}
@ -269,14 +269,19 @@ impl Error {
/// Turn an error into a tagged error with the given depth.
fn with_depth(self, depth: usize) -> Error {
Error::WithDepth { depth, err: Box::new(self) }
Error::WithDepth {
depth,
err: Box::new(self),
}
}
/// Turn an error into a tagged error with the given file path and line
/// number. If path is empty, then it is omitted from the error.
fn tagged<P: AsRef<Path>>(self, path: P, lineno: u64) -> Error {
let errline =
Error::WithLineNumber { line: lineno, err: Box::new(self) };
let errline = Error::WithLineNumber {
line: lineno,
err: Box::new(self),
};
if path.as_ref().as_os_str().is_empty() {
return errline;
}
@ -296,12 +301,12 @@ impl Error {
};
}
let path = err.path().map(|p| p.to_path_buf());
let mut ig_err = Error::WithDepth {
depth,
err: Box::new(Error::Io(std::io::Error::from(err))),
};
let mut ig_err = Error::Io(std::io::Error::from(err));
if let Some(path) = path {
ig_err = Error::WithPath { path, err: Box::new(ig_err) };
ig_err = Error::WithPath {
path,
err: Box::new(ig_err),
};
}
ig_err
}
@ -328,8 +333,7 @@ impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match *self {
Error::Partial(ref errs) => {
let msgs: Vec<String> =
errs.iter().map(|err| err.to_string()).collect();
let msgs: Vec<String> = errs.iter().map(|err| err.to_string()).collect();
write!(f, "{}", msgs.join("\n"))
}
Error::WithLineNumber { line, ref err } => {
@ -339,7 +343,10 @@ impl std::fmt::Display for Error {
write!(f, "{}: {}", path.display(), err)
}
Error::WithDepth { ref err, .. } => err.fmt(f),
Error::Loop { ref ancestor, ref child } => write!(
Error::Loop {
ref ancestor,
ref child,
} => write!(
f,
"File system loop found: \
{} points to an ancestor {}",
@ -347,8 +354,14 @@ impl std::fmt::Display for Error {
ancestor.display()
),
Error::Io(ref err) => err.fmt(f),
Error::Glob { glob: None, ref err } => write!(f, "{}", err),
Error::Glob { glob: Some(ref glob), ref err } => {
Error::Glob {
glob: None,
ref err,
} => write!(f, "{}", err),
Error::Glob {
glob: Some(ref glob),
ref err,
} => {
write!(f, "error parsing glob '{}': {}", glob, err)
}
Error::UnrecognizedFileType(ref ty) => {
@ -482,7 +495,11 @@ impl<T> Match<T> {
/// Return the match if it is not none. Otherwise, return other.
pub fn or(self, other: Self) -> Self {
if self.is_none() { other } else { self }
if self.is_none() {
other
} else {
self
}
}
}
@ -494,8 +511,7 @@ mod tests {
};
/// A convenient result type alias.
pub(crate) type Result<T> =
std::result::Result<T, Box<dyn std::error::Error + Send + Sync>>;
pub(crate) type Result<T> = std::result::Result<T, Box<dyn std::error::Error + Send + Sync>>;
macro_rules! err {
($($tt:tt)*) => {
@ -528,14 +544,13 @@ mod tests {
let tmpdir = env::temp_dir();
for _ in 0..TRIES {
let count = COUNTER.fetch_add(1, Ordering::Relaxed);
let count = COUNTER.fetch_add(1, Ordering::SeqCst);
let path = tmpdir.join("rust-ignore").join(count.to_string());
if path.is_dir() {
continue;
}
fs::create_dir_all(&path).map_err(|e| {
err!("failed to create {}: {}", path.display(), e)
})?;
fs::create_dir_all(&path)
.map_err(|e| err!("failed to create {}: {}", path.display(), e))?;
return Ok(TempDir(path));
}
Err(err!("failed to create temp dir after {} tries", TRIES))

View file

@ -1,6 +1,5 @@
/*!
The overrides module provides a way to specify a set of override globs.
This provides functionality similar to `--include` or `--exclude` in command
line tools.
*/
@ -8,8 +7,8 @@ line tools.
use std::path::Path;
use crate::{
Error, Match,
gitignore::{self, Gitignore, GitignoreBuilder},
Error, Match,
};
/// Glob represents a single glob in an override matcher.
@ -94,11 +93,7 @@ impl Override {
/// given) is stripped. If there is no common suffix/prefix overlap, then
/// `path` is assumed to reside in the same directory as the root path for
/// this set of overrides.
pub fn matched<'a, P: AsRef<Path>>(
&'a self,
path: P,
is_dir: bool,
) -> Match<Glob<'a>> {
pub fn matched<'a, P: AsRef<Path>>(&'a self, path: P, is_dir: bool) -> Match<Glob<'a>> {
if self.is_empty() {
return Match::None;
}
@ -121,9 +116,9 @@ impl OverrideBuilder {
///
/// Matching is done relative to the directory path provided.
pub fn new<P: AsRef<Path>>(path: P) -> OverrideBuilder {
let mut builder = GitignoreBuilder::new(path);
builder.allow_unclosed_class(false);
OverrideBuilder { builder }
OverrideBuilder {
builder: GitignoreBuilder::new(path),
}
}
/// Builds a new override matcher from the globs added so far.
@ -146,41 +141,15 @@ impl OverrideBuilder {
/// Toggle whether the globs should be matched case insensitively or not.
///
/// When this option is changed, only globs added after the change will be
/// affected.
/// When this option is changed, only globs added after the change will be affected.
///
/// This is disabled by default.
pub fn case_insensitive(
&mut self,
yes: bool,
) -> Result<&mut OverrideBuilder, Error> {
pub fn case_insensitive(&mut self, yes: bool) -> Result<&mut OverrideBuilder, Error> {
// TODO: This should not return a `Result`. Fix this in the next semver
// release.
self.builder.case_insensitive(yes)?;
Ok(self)
}
/// Toggle whether unclosed character classes are allowed. When allowed,
/// a `[` without a matching `]` is treated literally instead of resulting
/// in a parse error.
///
/// For example, if this is set then the glob `[abc` will be treated as the
/// literal string `[abc` instead of returning an error.
///
/// By default, this is false. Generally speaking, enabling this leads to
/// worse failure modes since the glob parser becomes more permissive. You
/// might want to enable this when compatibility (e.g., with POSIX glob
/// implementations) is more important than good error messages.
///
/// This default is different from the default for [`Gitignore`]. Namely,
/// [`Gitignore`] is intended to match git's behavior as-is. But this
/// abstraction for "override" globs does not necessarily conform to any
/// other known specification and instead prioritizes better error
/// messages.
pub fn allow_unclosed_class(&mut self, yes: bool) -> &mut OverrideBuilder {
self.builder.allow_unclosed_class(yes);
self
}
}
#[cfg(test)]
@ -283,8 +252,11 @@ mod tests {
#[test]
fn default_case_sensitive() {
let ov =
OverrideBuilder::new(ROOT).add("*.html").unwrap().build().unwrap();
let ov = OverrideBuilder::new(ROOT)
.add("*.html")
.unwrap()
.build()
.unwrap();
assert!(ov.matched("foo.html", false).is_whitelist());
assert!(ov.matched("foo.HTML", false).is_ignore());
assert!(ov.matched("foo.htm", false).is_ignore());

View file

@ -2,89 +2,55 @@ use std::{ffi::OsStr, path::Path};
use crate::walk::DirEntry;
/// Returns true if and only if this path is considered to be hidden.
/// Returns true if and only if this entry is considered to be hidden.
///
/// # Platform behavior
/// This only returns true if the base name of the path starts with a `.`.
///
/// ## Windows
/// On Unix, this implements a more optimized check.
#[cfg(unix)]
pub(crate) fn is_hidden(dent: &DirEntry) -> bool {
use std::os::unix::ffi::OsStrExt;
if let Some(name) = file_name(dent.path()) {
name.as_bytes().get(0) == Some(&b'.')
} else {
false
}
}
/// Returns true if and only if this entry is considered to be hidden.
///
/// This returns true if one of the following is true:
/// On Windows, this returns true if one of the following is true:
///
/// * The base name of the path starts with a `.`.
/// * The file attributes have the `HIDDEN` property set.
///
/// ## All other platforms
///
/// This only returns true if the base name of the path starts with a `.`.
pub(crate) fn is_hidden_path(dent: &Path) -> bool {
#[cfg(not(windows))]
fn imp(path: &Path) -> bool {
is_hidden_path_only(path)
}
#[cfg(windows)]
pub(crate) fn is_hidden(dent: &DirEntry) -> bool {
use std::os::windows::fs::MetadataExt;
use winapi_util::file;
#[cfg(windows)]
fn imp(path: &Path) -> bool {
use std::os::windows::fs::MetadataExt;
use winapi_util::file;
if let Ok(md) = path.metadata() {
if file::is_hidden(md.file_attributes() as u64) {
return true;
}
// This looks like we're doing an extra stat call, but on Windows, the
// directory traverser reuses the metadata retrieved from each directory
// entry and stores it on the DirEntry itself. So this is "free."
if let Ok(md) = dent.metadata() {
if file::is_hidden(md.file_attributes() as u64) {
return true;
}
is_hidden_path_only(path)
}
imp(dent)
if let Some(name) = file_name(dent.path()) {
name.to_str().map(|s| s.starts_with(".")).unwrap_or(false)
} else {
false
}
}
/// Returns true if and only if this directory entry is considered to be
/// hidden.
///
/// # Platform behavior
///
/// ## Windows
///
/// This returns true if one of the following is true:
///
/// * The base name of the path starts with a `.`.
/// * The file attributes have the `HIDDEN` property set.
///
/// ## All other platforms
/// Returns true if and only if this entry is considered to be hidden.
///
/// This only returns true if the base name of the path starts with a `.`.
pub(crate) fn is_hidden_entry(dent: &DirEntry) -> bool {
#[cfg(not(windows))]
fn imp(dent: &DirEntry) -> bool {
is_hidden_path_only(dent.path())
}
#[cfg(windows)]
fn imp(dent: &DirEntry) -> bool {
use std::os::windows::fs::MetadataExt;
use winapi_util::file;
// This looks like we're doing an extra stat call, but on Windows, the
// directory traverser reuses the metadata retrieved from each directory
// entry and stores it on the DirEntry itself. So this is "free."
if let Ok(md) = dent.metadata() {
if file::is_hidden(md.file_attributes() as u64) {
return true;
}
}
is_hidden_path_only(dent.path())
}
imp(dent)
}
/// Returns true if and only if this path is considered to be hidden from only
/// the path itself.
///
/// This has the same behavior on all platforms.
fn is_hidden_path_only(path: &Path) -> bool {
if let Some(name) = file_name(path) {
name.as_encoded_bytes().starts_with(b".")
#[cfg(not(any(unix, windows)))]
pub(crate) fn is_hidden(dent: &DirEntry) -> bool {
if let Some(name) = file_name(dent.path()) {
name.to_str().map(|s| s.starts_with(".")).unwrap_or(false)
} else {
false
}
@ -93,79 +59,83 @@ fn is_hidden_path_only(path: &Path) -> bool {
/// Strip `prefix` from the `path` and return the remainder.
///
/// If `path` doesn't have a prefix `prefix`, then return `None`.
#[cfg(unix)]
pub(crate) fn strip_prefix<'a, P: AsRef<Path> + ?Sized>(
prefix: &'a P,
path: &'a Path,
) -> Option<&'a Path> {
#[cfg(unix)]
fn imp<'a>(prefix: &'a Path, path: &'a Path) -> Option<&'a Path> {
use std::os::unix::ffi::OsStrExt;
use std::os::unix::ffi::OsStrExt;
let prefix = prefix.as_os_str().as_bytes();
let path = path.as_os_str().as_bytes();
if prefix.len() > path.len() || prefix != &path[0..prefix.len()] {
None
} else {
Some(&Path::new(OsStr::from_bytes(&path[prefix.len()..])))
}
let prefix = prefix.as_ref().as_os_str().as_bytes();
let path = path.as_os_str().as_bytes();
if prefix.len() > path.len() || prefix != &path[0..prefix.len()] {
None
} else {
Some(&Path::new(OsStr::from_bytes(&path[prefix.len()..])))
}
}
#[cfg(not(unix))]
fn imp<'a>(prefix: &'a Path, path: &'a Path) -> Option<&'a Path> {
path.strip_prefix(prefix).ok()
}
imp(prefix.as_ref(), path)
/// Strip `prefix` from the `path` and return the remainder.
///
/// If `path` doesn't have a prefix `prefix`, then return `None`.
#[cfg(not(unix))]
pub(crate) fn strip_prefix<'a, P: AsRef<Path> + ?Sized>(
prefix: &'a P,
path: &'a Path,
) -> Option<&'a Path> {
path.strip_prefix(prefix).ok()
}
/// Returns true if this file path is just a file name. i.e., Its parent is
/// the empty string.
#[cfg(unix)]
pub(crate) fn is_file_name<P: AsRef<Path>>(path: P) -> bool {
#[cfg(unix)]
{
memchr::memchr(b'/', path.as_ref().as_os_str().as_encoded_bytes())
.is_none()
}
#[cfg(not(unix))]
{
path.as_ref()
.parent()
.map(|p| p.as_os_str().is_empty())
.unwrap_or(false)
}
use std::os::unix::ffi::OsStrExt;
use memchr::memchr;
let path = path.as_ref().as_os_str().as_bytes();
memchr(b'/', path).is_none()
}
/// Returns true if this file path is just a file name. i.e., Its parent is
/// the empty string.
#[cfg(not(unix))]
pub(crate) fn is_file_name<P: AsRef<Path>>(path: P) -> bool {
path.as_ref()
.parent()
.map(|p| p.as_os_str().is_empty())
.unwrap_or(false)
}
/// The final component of the path, if it is a normal file.
///
/// If the path terminates in `.`, `..`, or consists solely of a root of
/// prefix, this will return `None`.
pub(crate) fn file_name<'a, P: AsRef<Path> + ?Sized>(
path: &'a P,
) -> Option<&'a OsStr> {
#[cfg(unix)]
fn imp(path: &Path) -> Option<&OsStr> {
use std::os::unix::ffi::OsStrExt;
/// If the path terminates in ., .., or consists solely of a root of prefix,
/// file_name will return None.
#[cfg(unix)]
pub(crate) fn file_name<'a, P: AsRef<Path> + ?Sized>(path: &'a P) -> Option<&'a OsStr> {
use memchr::memrchr;
use std::os::unix::ffi::OsStrExt;
use memchr::memrchr;
let path = path.as_os_str().as_bytes();
if path.is_empty() {
return None;
} else if path.len() == 1 && path[0] == b'.' {
return None;
} else if path.last() == Some(&b'.') {
return None;
} else if path.len() >= 2 && &path[path.len() - 2..] == &b".."[..] {
return None;
}
let last_slash = memrchr(b'/', path).map(|i| i + 1).unwrap_or(0);
Some(OsStr::from_bytes(&path[last_slash..]))
let path = path.as_ref().as_os_str().as_bytes();
if path.is_empty() {
return None;
} else if path.len() == 1 && path[0] == b'.' {
return None;
} else if path.last() == Some(&b'.') {
return None;
} else if path.len() >= 2 && &path[path.len() - 2..] == &b".."[..] {
return None;
}
#[cfg(not(unix))]
fn imp(path: &Path) -> Option<&OsStr> {
path.file_name()
}
imp(path.as_ref())
let last_slash = memrchr(b'/', path).map(|i| i + 1).unwrap_or(0);
Some(OsStr::from_bytes(&path[last_slash..]))
}
/// The final component of the path, if it is a normal file.
///
/// If the path terminates in ., .., or consists solely of a root of prefix,
/// file_name will return None.
#[cfg(not(unix))]
pub(crate) fn file_name<'a, P: AsRef<Path> + ?Sized>(path: &'a P) -> Option<&'a OsStr> {
path.as_ref().file_name()
}

View file

@ -91,7 +91,7 @@ use {
regex_automata::util::pool::Pool,
};
use crate::{Error, Match, default_types::DEFAULT_TYPES, pathutil::file_name};
use crate::{default_types::DEFAULT_TYPES, pathutil::file_name, Error, Match};
/// Glob represents a single glob in a set of file type definitions.
///
@ -204,12 +204,8 @@ impl<T> Selection<T> {
fn map<U, F: FnOnce(T) -> U>(self, f: F) -> Selection<U> {
match self {
Selection::Select(name, inner) => {
Selection::Select(name, f(inner))
}
Selection::Negate(name, inner) => {
Selection::Negate(name, f(inner))
}
Selection::Select(name, inner) => Selection::Select(name, f(inner)),
Selection::Negate(name, inner) => Selection::Negate(name, f(inner)),
}
}
@ -231,9 +227,7 @@ impl Types {
has_selected: false,
glob_to_selection: vec![],
set: GlobSetBuilder::new().build().unwrap(),
matches: Arc::new(Pool::with_available_parallelism_capacity(
|| vec![],
)),
matches: Arc::new(Pool::new(|| vec![])),
}
}
@ -260,11 +254,7 @@ impl Types {
/// The path is considered ignored if it matches a negated file type.
/// If at least one file type is selected and `path` doesn't match, then
/// the path is also considered ignored.
pub fn matched<'a, P: AsRef<Path>>(
&'a self,
path: P,
is_dir: bool,
) -> Match<Glob<'a>> {
pub fn matched<'a, P: AsRef<Path>>(&'a self, path: P, is_dir: bool) -> Match<Glob<'a>> {
// File types don't apply to directories, and we can't do anything
// if our glob set is empty.
if is_dir || self.set.is_empty() {
@ -316,7 +306,10 @@ impl TypesBuilder {
/// of default type definitions can be added with `add_defaults`, and
/// additional type definitions can be added with `select` and `negate`.
pub fn new() -> TypesBuilder {
TypesBuilder { types: HashMap::new(), selections: vec![] }
TypesBuilder {
types: HashMap::new(),
selections: vec![],
}
}
/// Build the current set of file type definitions *and* selections into
@ -350,18 +343,17 @@ impl TypesBuilder {
}
selections.push(selection.clone().map(move |_| def));
}
let set = build_set
.build()
.map_err(|err| Error::Glob { glob: None, err: err.to_string() })?;
let set = build_set.build().map_err(|err| Error::Glob {
glob: None,
err: err.to_string(),
})?;
Ok(Types {
defs,
selections,
has_selected,
glob_to_selection,
set,
matches: Arc::new(Pool::with_available_parallelism_capacity(
|| vec![],
)),
matches: Arc::new(Pool::new(|| vec![])),
})
}
@ -385,10 +377,12 @@ impl TypesBuilder {
pub fn select(&mut self, name: &str) -> &mut TypesBuilder {
if name == "all" {
for name in self.types.keys() {
self.selections.push(Selection::Select(name.to_string(), ()));
self.selections
.push(Selection::Select(name.to_string(), ()));
}
} else {
self.selections.push(Selection::Select(name.to_string(), ()));
self.selections
.push(Selection::Select(name.to_string(), ()));
}
self
}
@ -399,10 +393,12 @@ impl TypesBuilder {
pub fn negate(&mut self, name: &str) -> &mut TypesBuilder {
if name == "all" {
for name in self.types.keys() {
self.selections.push(Selection::Negate(name.to_string(), ()));
self.selections
.push(Selection::Negate(name.to_string(), ()));
}
} else {
self.selections.push(Selection::Negate(name.to_string(), ()));
self.selections
.push(Selection::Negate(name.to_string(), ()));
}
self
}
@ -457,10 +453,7 @@ impl TypesBuilder {
3 => {
let name = parts[0];
let types_string = parts[2];
if name.is_empty()
|| parts[1] != "include"
|| types_string.is_empty()
{
if name.is_empty() || parts[1] != "include" || types_string.is_empty() {
return Err(Error::InvalidDefinition);
}
let types = types_string.split(',');
@ -470,8 +463,7 @@ impl TypesBuilder {
return Err(Error::InvalidDefinition);
}
for type_name in types {
let globs =
self.types.get(type_name).unwrap().globs.clone();
let globs = self.types.get(type_name).unwrap().globs.clone();
for glob in globs {
self.add(name, &glob)?;
}
@ -557,9 +549,30 @@ mod tests {
matched!(not, matchnot1, types(), vec!["rust"], vec![], "index.html");
matched!(not, matchnot2, types(), vec![], vec!["rust"], "main.rs");
matched!(not, matchnot3, types(), vec!["foo"], vec!["rust"], "main.rs");
matched!(not, matchnot4, types(), vec!["rust"], vec!["foo"], "main.rs");
matched!(not, matchnot5, types(), vec!["rust"], vec!["foo"], "main.foo");
matched!(
not,
matchnot3,
types(),
vec!["foo"],
vec!["rust"],
"main.rs"
);
matched!(
not,
matchnot4,
types(),
vec!["rust"],
vec!["foo"],
"main.rs"
);
matched!(
not,
matchnot5,
types(),
vec!["rust"],
vec!["foo"],
"main.foo"
);
matched!(not, matchnot6, types(), vec!["combo"], vec![], "leftpad.js");
matched!(not, matchnot7, types(), vec!["py"], vec![], "index.html");
matched!(not, matchnot8, types(), vec!["python"], vec![], "doc.md");

File diff suppressed because it is too large Load diff

View file

@ -2,8 +2,7 @@ use std::path::Path;
use ignore::gitignore::{Gitignore, GitignoreBuilder};
const IGNORE_FILE: &'static str =
"tests/gitignore_matched_path_or_any_parents_tests.gitignore";
const IGNORE_FILE: &'static str = "tests/gitignore_matched_path_or_any_parents_tests.gitignore";
fn get_gitignore() -> Gitignore {
let mut builder = GitignoreBuilder::new("ROOT");
@ -24,9 +23,7 @@ fn test_path_should_be_under_root() {
#[test]
fn test_files_in_root() {
let gitignore = get_gitignore();
let m = |path: &str| {
gitignore.matched_path_or_any_parents(Path::new(path), false)
};
let m = |path: &str| gitignore.matched_path_or_any_parents(Path::new(path), false);
// 0x
assert!(m("ROOT/file_root_00").is_ignore());
@ -56,9 +53,7 @@ fn test_files_in_root() {
#[test]
fn test_files_in_deep() {
let gitignore = get_gitignore();
let m = |path: &str| {
gitignore.matched_path_or_any_parents(Path::new(path), false)
};
let m = |path: &str| gitignore.matched_path_or_any_parents(Path::new(path), false);
// 0x
assert!(m("ROOT/parent_dir/file_deep_00").is_ignore());
@ -88,9 +83,8 @@ fn test_files_in_deep() {
#[test]
fn test_dirs_in_root() {
let gitignore = get_gitignore();
let m = |path: &str, is_dir: bool| {
gitignore.matched_path_or_any_parents(Path::new(path), is_dir)
};
let m =
|path: &str, is_dir: bool| gitignore.matched_path_or_any_parents(Path::new(path), is_dir);
// 00
assert!(m("ROOT/dir_root_00", true).is_ignore());
@ -192,25 +186,20 @@ fn test_dirs_in_root() {
#[test]
fn test_dirs_in_deep() {
let gitignore = get_gitignore();
let m = |path: &str, is_dir: bool| {
gitignore.matched_path_or_any_parents(Path::new(path), is_dir)
};
let m =
|path: &str, is_dir: bool| gitignore.matched_path_or_any_parents(Path::new(path), is_dir);
// 00
assert!(m("ROOT/parent_dir/dir_deep_00", true).is_ignore());
assert!(m("ROOT/parent_dir/dir_deep_00/file", false).is_ignore());
assert!(m("ROOT/parent_dir/dir_deep_00/child_dir", true).is_ignore());
assert!(
m("ROOT/parent_dir/dir_deep_00/child_dir/file", false).is_ignore()
);
assert!(m("ROOT/parent_dir/dir_deep_00/child_dir/file", false).is_ignore());
// 01
assert!(m("ROOT/parent_dir/dir_deep_01", true).is_ignore());
assert!(m("ROOT/parent_dir/dir_deep_01/file", false).is_ignore());
assert!(m("ROOT/parent_dir/dir_deep_01/child_dir", true).is_ignore());
assert!(
m("ROOT/parent_dir/dir_deep_01/child_dir/file", false).is_ignore()
);
assert!(m("ROOT/parent_dir/dir_deep_01/child_dir/file", false).is_ignore());
// 02
assert!(m("ROOT/parent_dir/dir_deep_02", true).is_none());
@ -252,67 +241,51 @@ fn test_dirs_in_deep() {
assert!(m("ROOT/parent_dir/dir_deep_20", true).is_ignore());
assert!(m("ROOT/parent_dir/dir_deep_20/file", false).is_ignore());
assert!(m("ROOT/parent_dir/dir_deep_20/child_dir", true).is_ignore());
assert!(
m("ROOT/parent_dir/dir_deep_20/child_dir/file", false).is_ignore()
);
assert!(m("ROOT/parent_dir/dir_deep_20/child_dir/file", false).is_ignore());
// 21
assert!(m("ROOT/parent_dir/dir_deep_21", true).is_ignore());
assert!(m("ROOT/parent_dir/dir_deep_21/file", false).is_ignore());
assert!(m("ROOT/parent_dir/dir_deep_21/child_dir", true).is_ignore());
assert!(
m("ROOT/parent_dir/dir_deep_21/child_dir/file", false).is_ignore()
);
assert!(m("ROOT/parent_dir/dir_deep_21/child_dir/file", false).is_ignore());
// 22
// dir itself doesn't match
assert!(m("ROOT/parent_dir/dir_deep_22", true).is_none());
assert!(m("ROOT/parent_dir/dir_deep_22/file", false).is_ignore());
assert!(m("ROOT/parent_dir/dir_deep_22/child_dir", true).is_ignore());
assert!(
m("ROOT/parent_dir/dir_deep_22/child_dir/file", false).is_ignore()
);
assert!(m("ROOT/parent_dir/dir_deep_22/child_dir/file", false).is_ignore());
// 23
// dir itself doesn't match
assert!(m("ROOT/parent_dir/dir_deep_23", true).is_none());
assert!(m("ROOT/parent_dir/dir_deep_23/file", false).is_ignore());
assert!(m("ROOT/parent_dir/dir_deep_23/child_dir", true).is_ignore());
assert!(
m("ROOT/parent_dir/dir_deep_23/child_dir/file", false).is_ignore()
);
assert!(m("ROOT/parent_dir/dir_deep_23/child_dir/file", false).is_ignore());
// 30
assert!(m("ROOT/parent_dir/dir_deep_30", true).is_ignore());
assert!(m("ROOT/parent_dir/dir_deep_30/file", false).is_ignore());
assert!(m("ROOT/parent_dir/dir_deep_30/child_dir", true).is_ignore());
assert!(
m("ROOT/parent_dir/dir_deep_30/child_dir/file", false).is_ignore()
);
assert!(m("ROOT/parent_dir/dir_deep_30/child_dir/file", false).is_ignore());
// 31
assert!(m("ROOT/parent_dir/dir_deep_31", true).is_ignore());
assert!(m("ROOT/parent_dir/dir_deep_31/file", false).is_ignore());
assert!(m("ROOT/parent_dir/dir_deep_31/child_dir", true).is_ignore());
assert!(
m("ROOT/parent_dir/dir_deep_31/child_dir/file", false).is_ignore()
);
assert!(m("ROOT/parent_dir/dir_deep_31/child_dir/file", false).is_ignore());
// 32
// dir itself doesn't match
assert!(m("ROOT/parent_dir/dir_deep_32", true).is_none());
assert!(m("ROOT/parent_dir/dir_deep_32/file", false).is_ignore());
assert!(m("ROOT/parent_dir/dir_deep_32/child_dir", true).is_ignore());
assert!(
m("ROOT/parent_dir/dir_deep_32/child_dir/file", false).is_ignore()
);
assert!(m("ROOT/parent_dir/dir_deep_32/child_dir/file", false).is_ignore());
// 33
// dir itself doesn't match
assert!(m("ROOT/parent_dir/dir_deep_33", true).is_none());
assert!(m("ROOT/parent_dir/dir_deep_33/file", false).is_ignore());
assert!(m("ROOT/parent_dir/dir_deep_33/child_dir", true).is_ignore());
assert!(
m("ROOT/parent_dir/dir_deep_33/child_dir/file", false).is_ignore()
);
assert!(m("ROOT/parent_dir/dir_deep_33/child_dir/file", false).is_ignore());
}

View file

@ -1,2 +0,0 @@
ignore/this/path
# This file begins with a BOM (U+FEFF)

View file

@ -1,17 +0,0 @@
use ignore::gitignore::GitignoreBuilder;
const IGNORE_FILE: &'static str = "tests/gitignore_skip_bom.gitignore";
/// Skip a Byte-Order Mark (BOM) at the beginning of the file, matching Git's
/// behavior.
///
/// Ref: <https://github.com/BurntSushi/ripgrep/issues/2177>
#[test]
fn gitignore_skip_bom() {
let mut builder = GitignoreBuilder::new("ROOT");
let error = builder.add(IGNORE_FILE);
assert!(error.is_none(), "failed to open gitignore file");
let g = builder.build().unwrap();
assert!(g.matched("ignore/this/path", false).is_ignore());
}

View file

@ -202,6 +202,5 @@ index.js
browser.js
tailwindcss-oxide.wasi-browser.js
tailwindcss-oxide.wasi.cjs
tailwindcss-oxide.wasi.d.cts
wasi-worker-browser.mjs
wasi-worker.mjs

View file

@ -8,10 +8,10 @@ crate-type = ["cdylib"]
[dependencies]
# Default enable napi4 feature, see https://nodejs.org/api/n-api.html#node-api-version-matrix
napi = { version = "3.11.0", default-features = false, features = ["napi4"] }
napi-derive = "3.6.0"
napi = { version = "3.3.0", default-features = false, features = ["napi4"] }
napi-derive = "3.2.5"
tailwindcss-oxide = { path = "../oxide" }
rayon = "1.12.0"
rayon = "1.10.0"
[build-dependencies]
napi-build = "2.3.2"
napi-build = "2.2.3"

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/oxide-android-arm-eabi",
"version": "4.3.3",
"version": "4.1.13",
"repository": {
"type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git",
@ -22,6 +22,6 @@
},
"license": "MIT",
"engines": {
"node": ">= 20"
"node": ">= 10"
}
}

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/oxide-android-arm64",
"version": "4.3.3",
"version": "4.1.13",
"repository": {
"type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git",
@ -22,6 +22,6 @@
},
"license": "MIT",
"engines": {
"node": ">= 20"
"node": ">= 10"
}
}

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/oxide-darwin-arm64",
"version": "4.3.3",
"version": "4.1.13",
"repository": {
"type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git",
@ -22,6 +22,6 @@
},
"license": "MIT",
"engines": {
"node": ">= 20"
"node": ">= 10"
}
}

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/oxide-darwin-x64",
"version": "4.3.3",
"version": "4.1.13",
"repository": {
"type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git",
@ -22,6 +22,6 @@
},
"license": "MIT",
"engines": {
"node": ">= 20"
"node": ">= 10"
}
}

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/oxide-freebsd-x64",
"version": "4.3.3",
"version": "4.1.13",
"repository": {
"type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git",
@ -22,6 +22,6 @@
},
"license": "MIT",
"engines": {
"node": ">= 20"
"node": ">= 10"
}
}

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/oxide-linux-arm-gnueabihf",
"version": "4.3.3",
"version": "4.1.13",
"repository": {
"type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git",
@ -22,6 +22,6 @@
},
"license": "MIT",
"engines": {
"node": ">= 20"
"node": ">= 10"
}
}

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/oxide-linux-arm64-gnu",
"version": "4.3.3",
"version": "4.1.13",
"repository": {
"type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git",
@ -22,7 +22,7 @@
},
"license": "MIT",
"engines": {
"node": ">= 20"
"node": ">= 10"
},
"libc": [
"glibc"

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/oxide-linux-arm64-musl",
"version": "4.3.3",
"version": "4.1.13",
"repository": {
"type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git",
@ -22,7 +22,7 @@
},
"license": "MIT",
"engines": {
"node": ">= 20"
"node": ">= 10"
},
"libc": [
"musl"

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/oxide-linux-x64-gnu",
"version": "4.3.3",
"version": "4.1.13",
"repository": {
"type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git",
@ -22,7 +22,7 @@
},
"license": "MIT",
"engines": {
"node": ">= 20"
"node": ">= 10"
},
"libc": [
"glibc"

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/oxide-linux-x64-musl",
"version": "4.3.3",
"version": "4.1.13",
"repository": {
"type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git",
@ -22,7 +22,7 @@
},
"license": "MIT",
"engines": {
"node": ">= 20"
"node": ">= 10"
},
"libc": [
"musl"

View file

@ -0,0 +1 @@
node-linker=hoisted

View file

@ -1,3 +1,3 @@
# `@tailwindcss/oxide-wasm32-wasi`
This is the **wasm32-wasip1-threads** build of `@tailwindcss/oxide`
This is the **wasm32-wasip1-threads** binary for `@tailwindcss/oxide`

View file

@ -1,6 +1,9 @@
{
"name": "@tailwindcss/oxide-wasm32-wasi",
"version": "4.3.3",
"version": "4.1.13",
"cpu": [
"wasm32"
],
"main": "tailwindcss-oxide.wasi.cjs",
"files": [
"tailwindcss-oxide.wasm32-wasi.wasm",
@ -11,7 +14,7 @@
],
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=23.5.0"
"node": ">=14.0.0"
},
"publishConfig": {
"provenance": true,
@ -24,12 +27,12 @@
},
"browser": "tailwindcss-oxide.wasi-browser.js",
"dependencies": {
"@napi-rs/wasm-runtime": "^1.2.2",
"@emnapi/core": "^1.11.3",
"@emnapi/runtime": "^1.11.3",
"@tybys/wasm-util": "^0.10.3",
"@emnapi/wasi-threads": "^1.2.3",
"tslib": "^2.8.1"
"@napi-rs/wasm-runtime": "^1.0.5",
"@emnapi/core": "^1.5.0",
"@emnapi/runtime": "^1.5.0",
"@tybys/wasm-util": "^0.10.1",
"@emnapi/wasi-threads": "^1.1.0",
"tslib": "^2.4.0"
},
"bundledDependencies": [
"@napi-rs/wasm-runtime",

View file

@ -0,0 +1,75 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@emnapi/core':
specifier: ^1.5.0
version: 1.5.0
'@emnapi/runtime':
specifier: ^1.5.0
version: 1.5.0
'@emnapi/wasi-threads':
specifier: ^1.1.0
version: 1.1.0
'@napi-rs/wasm-runtime':
specifier: ^1.0.5
version: 1.0.5
'@tybys/wasm-util':
specifier: ^0.10.1
version: 0.10.1
tslib:
specifier: ^2.4.0
version: 2.8.1
packages:
'@emnapi/core@1.5.0':
resolution: {integrity: sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==}
'@emnapi/runtime@1.5.0':
resolution: {integrity: sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==}
'@emnapi/wasi-threads@1.1.0':
resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==}
'@napi-rs/wasm-runtime@1.0.5':
resolution: {integrity: sha512-TBr9Cf9onSAS2LQ2+QHx6XcC6h9+RIzJgbqG3++9TUZSH204AwEy5jg3BTQ0VATsyoGj4ee49tN/y6rvaOOtcg==}
'@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
snapshots:
'@emnapi/core@1.5.0':
dependencies:
'@emnapi/wasi-threads': 1.1.0
tslib: 2.8.1
'@emnapi/runtime@1.5.0':
dependencies:
tslib: 2.8.1
'@emnapi/wasi-threads@1.1.0':
dependencies:
tslib: 2.8.1
'@napi-rs/wasm-runtime@1.0.5':
dependencies:
'@emnapi/core': 1.5.0
'@emnapi/runtime': 1.5.0
'@tybys/wasm-util': 0.10.1
'@tybys/wasm-util@0.10.1':
dependencies:
tslib: 2.8.1
tslib@2.8.1: {}

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/oxide-win32-arm64-msvc",
"version": "4.3.3",
"version": "4.1.13",
"repository": {
"type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git",
@ -22,6 +22,6 @@
},
"license": "MIT",
"engines": {
"node": ">= 20"
"node": ">= 10"
}
}

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/oxide-win32-x64-msvc",
"version": "4.3.3",
"version": "4.1.13",
"repository": {
"type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git",
@ -22,6 +22,6 @@
},
"license": "MIT",
"engines": {
"node": ">= 20"
"node": ">= 10"
}
}

View file

@ -1,6 +1,6 @@
{
"name": "@tailwindcss/oxide",
"version": "4.3.3",
"version": "4.1.13",
"repository": {
"type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git",
@ -32,19 +32,22 @@
}
},
"license": "MIT",
"dependencies": {
"tar": "^7.4.3",
"detect-libc": "^2.0.4"
},
"devDependencies": {
"@emnapi/core": "1.11.3",
"@emnapi/runtime": "1.11.3",
"@napi-rs/cli": "3.7.4",
"@napi-rs/wasm-runtime": "^1.2.2",
"emnapi": "1.11.3"
"@napi-rs/cli": "^3.2.0",
"@napi-rs/wasm-runtime": "^1.0.5",
"emnapi": "1.5.0"
},
"engines": {
"node": ">= 20"
"node": ">= 10"
},
"files": [
"index.js",
"index.d.ts"
"index.d.ts",
"scripts/install.js"
],
"publishConfig": {
"provenance": true,
@ -58,7 +61,8 @@
"postbuild:wasm": "node ./scripts/move-artifacts.mjs",
"dev": "cargo watch --quiet --shell 'npm run build'",
"build:debug": "napi build --platform",
"version": "napi version"
"version": "napi version",
"postinstall": "node ./scripts/install.js"
},
"optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "workspace:*",

View file

@ -0,0 +1,143 @@
#!/usr/bin/env node
/**
* @tailwindcss/oxide postinstall script
*
* This script ensures that the correct binary for the current platform and
* architecture is downloaded and available.
*/
const fs = require('fs')
const path = require('path')
const https = require('https')
const { extract } = require('tar')
const packageJson = require('../package.json')
const detectLibc = require('detect-libc')
const version = packageJson.version
function getPlatformPackageName() {
let platform = process.platform
let arch = process.arch
let libc = ''
if (platform === 'linux') {
libc = detectLibc.isNonGlibcLinuxSync() ? 'musl' : 'gnu'
}
// Map to our package naming conventions
switch (platform) {
case 'darwin':
return arch === 'arm64' ? '@tailwindcss/oxide-darwin-arm64' : '@tailwindcss/oxide-darwin-x64'
case 'win32':
if (arch === 'arm64') return '@tailwindcss/oxide-win32-arm64-msvc'
if (arch === 'ia32') return '@tailwindcss/oxide-win32-ia32-msvc'
return '@tailwindcss/oxide-win32-x64-msvc'
case 'linux':
if (arch === 'x64') {
return libc === 'musl'
? '@tailwindcss/oxide-linux-x64-musl'
: '@tailwindcss/oxide-linux-x64-gnu'
} else if (arch === 'arm64') {
return libc === 'musl'
? '@tailwindcss/oxide-linux-arm64-musl'
: '@tailwindcss/oxide-linux-arm64-gnu'
} else if (arch === 'arm') {
return '@tailwindcss/oxide-linux-arm-gnueabihf'
}
break
case 'freebsd':
return '@tailwindcss/oxide-freebsd-x64'
case 'android':
return '@tailwindcss/oxide-android-arm64'
default:
return '@tailwindcss/oxide-wasm32-wasi'
}
}
function isPackageAvailable(packageName) {
try {
require.resolve(packageName)
return true
} catch (e) {
return false
}
}
// Extract all files from a tarball to a destination directory
async function extractTarball(tarballStream, destDir) {
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true })
}
return new Promise((resolve, reject) => {
tarballStream
.pipe(extract({ cwd: destDir, strip: 1 }))
.on('error', (err) => reject(err))
.on('end', () => resolve())
})
}
async function downloadAndExtractBinary(packageName) {
let tarballUrl = `https://registry.npmjs.org/${packageName}/-/${packageName.replace('@tailwindcss/', '')}-${version}.tgz`
console.log(`Downloading ${tarballUrl}...`)
return new Promise((resolve) => {
https
.get(tarballUrl, (response) => {
if (response.statusCode === 302 || response.statusCode === 301) {
// Handle redirects
https.get(response.headers.location, handleResponse).on('error', (err) => {
console.error('Download error:', err)
resolve()
})
return
}
handleResponse(response)
async function handleResponse(response) {
try {
if (response.statusCode !== 200) {
throw new Error(`Download failed with status code: ${response.statusCode}`)
}
await extractTarball(
response,
path.join(__dirname, '..', 'node_modules', ...packageName.split('/')),
)
console.log(`Successfully downloaded and installed ${packageName}`)
} catch (error) {
console.error('Error during extraction:', error)
resolve()
} finally {
resolve()
}
}
})
.on('error', (err) => {
console.error('Download error:', err)
resolve()
})
})
}
async function main() {
// Don't run this script in the package source
try {
if (fs.existsSync(path.join(__dirname, '..', 'build.rs'))) {
return
}
let packageName = getPlatformPackageName()
if (!packageName) return
if (isPackageAvailable(packageName)) return
await downloadAndExtractBinary(packageName)
} catch (error) {
console.error(error)
return
}
}
main()

View file

@ -35,3 +35,4 @@ for (let file of await fs.readdir(tailwindcssOxideRoot)) {
)
console.log(`Moved ${file} to npm/wasm32-wasi`)
}

View file

@ -165,11 +165,6 @@ impl Scanner {
self.scanner.get_files()
}
#[napi(getter)]
pub fn scanned_files(&self) -> Vec<String> {
self.scanner.get_scanned_files()
}
#[napi(getter)]
pub fn globs(&mut self) -> Vec<GlobEntry> {
self

View file

@ -9,6 +9,7 @@ globwalk = "0.9.1"
log = "0.4.22"
rayon = "1.10.0"
fxhash = { package = "rustc-hash", version = "2.1.1" }
crossbeam = "0.8.4"
tracing = { version = "0.1.40", features = [] }
tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
walkdir = "2.5.0"
@ -18,9 +19,9 @@ fast-glob = "0.4.3"
classification-macros = { path = "../classification-macros" }
ignore = { path = "../ignore" }
regex = "1.11.1"
fsops = { path = "../fsops" }
[dev-dependencies]
insta = "1.48.0"
tempfile = "3.13.0"
pretty_assertions = "1.4.1"
unicode-width = "0.2.0"

View file

@ -1,49 +1,44 @@
use std::{ascii::escape_default, fmt::Display};
#[derive(Debug, Clone, Copy)]
#[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> {
#[inline(always)]
pub fn new(input: &'a [u8]) -> Self {
Self { input, pos: 0 }
}
/// The current byte at `pos`, or 0x00 if past the end.
#[inline(always)]
pub fn curr(&self) -> u8 {
if self.pos < self.input.len() {
unsafe { *self.input.get_unchecked(self.pos) }
} else {
0x00
}
}
/// The next byte at `pos + 1`, or 0x00 if past the end.
#[inline(always)]
pub fn next(&self) -> u8 {
let next_pos = self.pos + 1;
if next_pos < self.input.len() {
unsafe { *self.input.get_unchecked(next_pos) }
} else {
0x00
}
}
/// The previous byte at `pos - 1`, or 0x00 if at the start.
#[inline(always)]
pub fn prev(&self) -> u8 {
if self.pos > 0 {
unsafe { *self.input.get_unchecked(self.pos - 1) }
} else {
0x00
}
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 advance_by(&mut self, amount: usize) {
@ -53,15 +48,38 @@ impl<'a> Cursor<'a> {
#[inline(always)]
pub fn advance(&mut self) {
self.pos += 1;
self.prev = self.curr;
self.curr = self.next;
self.next = *self
.input
.get(self.pos.saturating_add(1))
.unwrap_or(&0x00u8);
}
#[inline(always)]
pub fn advance_twice(&mut self) {
self.pos += 2;
self.prev = self.next;
self.curr = *self.input.get(self.pos).unwrap_or(&0x00u8);
self.next = *self
.input
.get(self.pos.saturating_add(1))
.unwrap_or(&0x00u8);
}
pub fn move_to(&mut self, pos: usize) {
self.pos = pos.min(self.input.len());
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 = *self.input.get(pos.wrapping_sub(1)).unwrap_or(&0x00u8);
self.curr = *self.input.get(pos).unwrap_or(&0x00u8);
self.next = *self.input.get(pos.saturating_add(1)).unwrap_or(&0x00u8);
}
}
@ -72,9 +90,9 @@ impl Display for Cursor<'_> {
let pos = format!("{: >len_count$}", self.pos, len_count = len.len());
write!(f, "{}/{} ", pos, len)?;
if self.pos == 0 {
if self.at_start {
write!(f, "S ")?;
} else if self.pos + 1 >= self.input.len() {
} else if self.at_end {
write!(f, "E ")?;
} else {
write!(f, "M ")?;
@ -91,9 +109,9 @@ impl Display for Cursor<'_> {
write!(
f,
"[{} {} {}]",
to_str(self.prev()),
to_str(self.curr()),
to_str(self.next())
to_str(self.prev),
to_str(self.curr),
to_str(self.next)
)
}
}
@ -107,28 +125,36 @@ mod test {
fn test_cursor() {
let mut cursor = Cursor::new(b"hello world");
assert_eq!(cursor.pos, 0);
assert_eq!(cursor.prev(), 0x00);
assert_eq!(cursor.curr(), b'h');
assert_eq!(cursor.next(), b'e');
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_eq!(cursor.prev(), b'h');
assert_eq!(cursor.curr(), b'e');
assert_eq!(cursor.next(), b'l');
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_eq!(cursor.prev(), b'd');
assert_eq!(cursor.curr(), 0x00);
assert_eq!(cursor.next(), 0x00);
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_eq!(cursor.prev(), b'd');
assert_eq!(cursor.curr(), 0x00);
assert_eq!(cursor.next(), 0x00);
assert!(!cursor.at_start);
assert!(cursor.at_end);
assert_eq!(cursor.prev, b'd');
assert_eq!(cursor.curr, 0x00);
assert_eq!(cursor.next, 0x00);
}
}

View file

@ -74,7 +74,7 @@ impl Machine for ArbitraryPropertyMachine<IdleState> {
#[inline]
fn next(&mut self, cursor: &mut cursor::Cursor<'_>) -> MachineState {
match cursor.curr().into() {
match cursor.curr.into() {
// Start of an arbitrary property
Class::OpenBracket => {
self.start_pos = cursor.pos;
@ -97,8 +97,8 @@ impl Machine for ArbitraryPropertyMachine<ParsingPropertyState> {
let len = cursor.input.len();
while cursor.pos < len {
match cursor.curr().into() {
Class::Dash => match cursor.next().into() {
match cursor.curr.into() {
Class::Dash => match cursor.next.into() {
// Start of a CSS variable
//
// E.g.: `[--my-color:red]`
@ -137,7 +137,7 @@ impl ArbitraryPropertyMachine<ParsingPropertyState> {
fn parse_property_variable(&mut self, cursor: &mut cursor::Cursor<'_>) -> MachineState {
match self.css_variable_machine.next(cursor) {
MachineState::Idle => self.restart(),
MachineState::Done(_) => match cursor.next().into() {
MachineState::Done(_) => match cursor.next.into() {
// End of the CSS variable, must be followed by a `:`
//
// E.g.: `[--my-color:red]`
@ -165,8 +165,8 @@ impl Machine for ArbitraryPropertyMachine<ParsingValueState> {
let len = cursor.input.len();
let start_of_value_pos = cursor.pos;
while cursor.pos < len {
match cursor.curr().into() {
Class::Escape => match cursor.next().into() {
match cursor.curr.into() {
Class::Escape => match cursor.next.into() {
// An escaped whitespace character is not allowed
//
// E.g.: `[color:var(--my-\ color)]`
@ -181,7 +181,7 @@ impl Machine for ArbitraryPropertyMachine<ParsingValueState> {
},
Class::OpenParen | Class::OpenBracket | Class::OpenCurly => {
if !self.bracket_stack.push(cursor.curr()) {
if !self.bracket_stack.push(cursor.curr) {
return self.restart();
}
cursor.advance();
@ -190,7 +190,7 @@ impl Machine for ArbitraryPropertyMachine<ParsingValueState> {
Class::CloseParen | Class::CloseBracket | Class::CloseCurly
if !self.bracket_stack.is_empty() =>
{
if !self.bracket_stack.pop(cursor.curr()) {
if !self.bracket_stack.pop(cursor.curr) {
return self.restart();
}
cursor.advance();
@ -227,16 +227,10 @@ impl Machine for ArbitraryPropertyMachine<ParsingValueState> {
Class::Slash if start_of_value_pos == cursor.pos => return self.restart(),
// String interpolation-like syntax is not allowed. E.g.: `[${x}]`
Class::Dollar if matches!(cursor.next().into(), Class::OpenCurly) => {
Class::Dollar if matches!(cursor.next.into(), Class::OpenCurly) => {
return self.restart()
}
// An `!` at the top-level is invalid. We don't allow things to end with
// `!important` either as we have dedicated syntax for this.
Class::Exclamation if self.bracket_stack.is_empty() => {
return self.restart();
}
// Everything else is valid
_ => cursor.advance(),
};
@ -299,9 +293,6 @@ enum Class {
#[bytes(b'/')]
Slash,
#[bytes(b'!')]
Exclamation,
#[bytes(b' ', b'\t', b'\n', b'\r', b'\x0C')]
Whitespace,
@ -378,9 +369,6 @@ mod tests {
"[background:url(https://example.com?q={[{[([{[[2]]}])]}]})]",
vec!["[background:url(https://example.com?q={[{[([{[[2]]}])]}]})]"],
),
// A property containing `!` at the top-level is invalid
("[color:red!]", vec![]),
("[color:red!important]", vec![]),
] {
for wrapper in [
// No wrapper

View file

@ -32,7 +32,7 @@ impl Machine for ArbitraryValueMachine {
#[inline]
fn next(&mut self, cursor: &mut cursor::Cursor<'_>) -> MachineState {
// An arbitrary value must start with an open bracket
if Class::OpenBracket != cursor.curr().into() {
if Class::OpenBracket != cursor.curr.into() {
return MachineState::Idle;
}
@ -42,8 +42,8 @@ impl Machine for ArbitraryValueMachine {
let len = cursor.input.len();
while cursor.pos < len {
match cursor.curr().into() {
Class::Escape => match cursor.next().into() {
match cursor.curr.into() {
Class::Escape => match cursor.next.into() {
// An escaped whitespace character is not allowed
//
// E.g.: `[color:var(--my-\ color)]`
@ -61,7 +61,7 @@ impl Machine for ArbitraryValueMachine {
},
Class::OpenParen | Class::OpenBracket | Class::OpenCurly => {
if !self.bracket_stack.push(cursor.curr()) {
if !self.bracket_stack.push(cursor.curr) {
return self.restart();
}
cursor.advance();
@ -70,7 +70,7 @@ impl Machine for ArbitraryValueMachine {
Class::CloseParen | Class::CloseBracket | Class::CloseCurly
if !self.bracket_stack.is_empty() =>
{
if !self.bracket_stack.pop(cursor.curr()) {
if !self.bracket_stack.pop(cursor.curr) {
return self.restart();
}
cursor.advance();
@ -96,7 +96,7 @@ impl Machine for ArbitraryValueMachine {
Class::Whitespace => return self.restart(),
// String interpolation-like syntax is not allowed. E.g.: `[${x}]`
Class::Dollar if matches!(cursor.next().into(), Class::OpenCurly) => {
Class::Dollar if matches!(cursor.next.into(), Class::OpenCurly) => {
return self.restart()
}

View file

@ -80,13 +80,13 @@ impl Machine for ArbitraryVariableMachine<IdleState> {
#[inline(always)]
fn next(&mut self, cursor: &mut cursor::Cursor<'_>) -> MachineState {
match cursor.curr().into() {
match cursor.curr.into() {
// Arbitrary variables start with `(` followed by a CSS variable
//
// E.g.: `(--my-variable)`
// ^^
//
Class::OpenParen => match cursor.next().into() {
Class::OpenParen => match cursor.next.into() {
Class::Dash => {
self.start_pos = cursor.pos;
cursor.advance();
@ -117,7 +117,7 @@ impl Machine for ArbitraryVariableMachine<ParsingState> {
fn next(&mut self, cursor: &mut cursor::Cursor<'_>) -> MachineState {
match self.css_variable_machine.next(cursor) {
MachineState::Idle => self.restart(),
MachineState::Done(_) => match cursor.next().into() {
MachineState::Done(_) => match cursor.next.into() {
// A CSS variable followed by a `,` means that there is a fallback
//
// E.g.: `(--my-color,red)`
@ -134,7 +134,7 @@ impl Machine for ArbitraryVariableMachine<ParsingState> {
_ => {
cursor.advance();
match cursor.curr().into() {
match cursor.curr.into() {
// End of an arbitrary variable, must be followed by `)`
Class::CloseParen => self.done(self.start_pos, cursor),
@ -156,7 +156,7 @@ impl Machine for ArbitraryVariableMachine<ParsingDataTypeState> {
let len = cursor.input.len();
while cursor.pos < len {
match cursor.curr().into() {
match cursor.curr.into() {
// Valid data type characters
//
// E.g.: `(length:--my-length)`
@ -169,7 +169,7 @@ impl Machine for ArbitraryVariableMachine<ParsingDataTypeState> {
//
// E.g.: `(length:--my-length)`
// ^
Class::Colon => match cursor.next().into() {
Class::Colon => match cursor.next.into() {
Class::Dash => {
cursor.advance();
return self.transition::<ParsingState>().next(cursor);
@ -196,8 +196,8 @@ impl Machine for ArbitraryVariableMachine<ParsingFallbackState> {
fn next(&mut self, cursor: &mut cursor::Cursor<'_>) -> MachineState {
let len = cursor.input.len();
while cursor.pos < len {
match cursor.curr().into() {
Class::Escape => match cursor.next().into() {
match cursor.curr.into() {
Class::Escape => match cursor.next.into() {
// An escaped whitespace character is not allowed
//
// E.g.: `(--my-\ color)`
@ -212,7 +212,7 @@ impl Machine for ArbitraryVariableMachine<ParsingFallbackState> {
},
Class::OpenParen | Class::OpenBracket | Class::OpenCurly => {
if !self.bracket_stack.push(cursor.curr()) {
if !self.bracket_stack.push(cursor.curr) {
return self.restart();
}
cursor.advance();
@ -221,7 +221,7 @@ impl Machine for ArbitraryVariableMachine<ParsingFallbackState> {
Class::CloseParen | Class::CloseBracket | Class::CloseCurly
if !self.bracket_stack.is_empty() =>
{
if !self.bracket_stack.pop(cursor.curr()) {
if !self.bracket_stack.pop(cursor.curr) {
return self.restart();
}
cursor.advance();
@ -253,7 +253,7 @@ impl Machine for ArbitraryVariableMachine<ParsingFallbackState> {
Class::Whitespace => return self.restart(),
// String interpolation-like syntax is not allowed. E.g.: `[${x}]`
Class::Dollar if matches!(cursor.next().into(), Class::OpenCurly) => {
Class::Dollar if matches!(cursor.next.into(), Class::OpenCurly) => {
return self.restart()
}

View file

@ -25,7 +25,6 @@ impl BracketStack {
b'(' => b')',
b'[' => b']',
b'{' => b'}',
b'<' => b'>',
_ => std::hint::unreachable_unchecked(),
};
}

View file

@ -32,14 +32,14 @@ impl Machine for CandidateMachine {
while cursor.pos < len {
// Skip ahead for known characters that will never be part of a candidate. No need to
// run any sub-machines.
if cursor.curr().is_ascii_whitespace() {
if cursor.curr.is_ascii_whitespace() {
self.reset();
cursor.advance();
continue;
}
// Candidates don't start with these characters, so we can skip ahead.
if matches!(cursor.curr(), b':' | b'"' | b'\'' | b'`') {
if matches!(cursor.curr, b':' | b'"' | b'\'' | b'`') {
self.reset();
cursor.advance();
continue;
@ -56,7 +56,7 @@ impl Machine for CandidateMachine {
// E.g.: `Some Class`
// ^ ^ Invalid, we can jump ahead to the next boundary
//
if matches!(cursor.curr(), b'<' | b'A'..=b'Z') {
if matches!(cursor.curr, b'<' | b'A'..=b'Z') {
if let Some(offset) = cursor.input[cursor.pos..]
.iter()
.position(|&c| is_valid_before_boundary(&c))

View file

@ -20,7 +20,7 @@ impl Machine for CssVariableMachine {
#[inline]
fn next(&mut self, cursor: &mut cursor::Cursor<'_>) -> MachineState {
// CSS Variables must start with `--`
if Class::Dash != cursor.curr().into() || Class::Dash != cursor.next().into() {
if Class::Dash != cursor.curr.into() || Class::Dash != cursor.next.into() {
return MachineState::Idle;
}
@ -30,11 +30,11 @@ impl Machine for CssVariableMachine {
cursor.advance_twice();
while cursor.pos < len {
match cursor.curr().into() {
match cursor.curr.into() {
// https://drafts.csswg.org/css-syntax-3/#ident-token-diagram
//
Class::AllowedCharacter | Class::Dash => {
match cursor.next().into() {
match cursor.next.into() {
// Valid character followed by a valid character or an escape character
//
// E.g.: `--my-variable`
@ -58,7 +58,7 @@ impl Machine for CssVariableMachine {
}
}
Class::Escape => match cursor.next().into() {
Class::Escape => match cursor.next.into() {
// An escaped whitespace character is not allowed
//
// In CSS it is allowed, but in the context of a class it's not because then we

View file

@ -86,7 +86,7 @@ impl<'a> Extractor<'a> {
{
let cursor = &mut self.cursor.clone();
while cursor.pos < len {
if cursor.curr().is_ascii_whitespace() {
if cursor.curr.is_ascii_whitespace() {
cursor.advance();
continue;
}
@ -104,7 +104,7 @@ impl<'a> Extractor<'a> {
let cursor = &mut self.cursor.clone();
while cursor.pos < len {
if cursor.curr().is_ascii_whitespace() {
if cursor.curr.is_ascii_whitespace() {
cursor.advance();
continue;
}
@ -147,7 +147,7 @@ impl<'a> Extractor<'a> {
let cursor = &mut self.cursor.clone();
while cursor.pos < len {
if cursor.curr().is_ascii_whitespace() {
if cursor.curr.is_ascii_whitespace() {
cursor.advance();
continue;
}
@ -238,7 +238,7 @@ mod tests {
use std::hint::black_box;
fn pre_process_input(input: &str, extension: &str) -> String {
let input = crate::scanner::pre_process_input(input.as_bytes().to_vec(), extension);
let input = crate::scanner::pre_process_input(input.as_bytes(), extension);
String::from_utf8(input).unwrap()
}
@ -908,18 +908,6 @@ mod tests {
);
}
// https://github.com/tailwindlabs/tailwindcss/issues/20233
#[test]
fn test_template_toolkit_syntax() {
assert_extract_candidates_contains(
&pre_process_input(
r#"<div class="[% IF $is_open %]bg-white/40[% ELSE %]bg-white/10[% END %]"></div>"#,
"tx",
),
vec!["bg-white/40", "bg-white/10"],
);
}
// https://github.com/tailwindlabs/tailwindcss/issues/17050
#[test]
fn test_haml_syntax() {

View file

@ -31,14 +31,14 @@ impl Machine for ModifierMachine {
#[inline]
fn next(&mut self, cursor: &mut cursor::Cursor<'_>) -> MachineState {
// A modifier must start with a `/`, everything else is not a valid start of a modifier
if Class::Slash != cursor.curr().into() {
if Class::Slash != cursor.curr.into() {
return MachineState::Idle;
}
let start_pos = cursor.pos;
cursor.advance();
match cursor.curr().into() {
match cursor.curr.into() {
// Start of an arbitrary value:
//
// ```
@ -70,9 +70,9 @@ impl Machine for ModifierMachine {
Class::ValidStart => {
let len = cursor.input.len();
while cursor.pos < len {
match cursor.curr().into() {
match cursor.curr.into() {
Class::ValidStart | Class::ValidInside => {
match cursor.next().into() {
match cursor.next.into() {
// Only valid characters are allowed, if followed by another valid character
Class::ValidStart | Class::ValidInside => cursor.advance(),

View file

@ -52,8 +52,8 @@ impl Machine for NamedUtilityMachine<IdleState> {
#[inline]
fn next(&mut self, cursor: &mut cursor::Cursor<'_>) -> MachineState {
match cursor.curr().into() {
Class::AlphaLower => match cursor.next().into() {
match cursor.curr.into() {
Class::AlphaLower => match cursor.next.into() {
// Valid single character utility in between quotes
//
// E.g.: `<div class="a"></div>`
@ -90,7 +90,7 @@ impl Machine for NamedUtilityMachine<IdleState> {
//
// E.g.: `-mx-2.5`
// ^^
Class::Dash => match cursor.next().into() {
Class::Dash => match cursor.next.into() {
Class::AlphaLower => {
self.start_pos = cursor.pos;
cursor.advance();
@ -118,7 +118,7 @@ impl Machine for NamedUtilityMachine<ParsingState> {
let len = cursor.input.len();
while cursor.pos < len {
match cursor.curr().into() {
match cursor.curr.into() {
// Followed by a boundary character, we are at the end of the utility.
//
// E.g.: `'flex'`
@ -132,14 +132,14 @@ impl Machine for NamedUtilityMachine<ParsingState> {
// E.g.: `:div="{ flex: true }"` (JavaScript object syntax)
// ^
Class::AlphaLower | Class::AlphaUpper => {
if is_valid_after_boundary(&cursor.next()) || {
if is_valid_after_boundary(&cursor.next) || {
// Or any of these characters
//
// - `:`, because of JS object keys
// - `/`, because of modifiers
// - `!`, because of important
matches!(
cursor.next().into(),
cursor.next.into(),
Class::Colon | Class::Slash | Class::Exclamation
)
} {
@ -150,7 +150,7 @@ impl Machine for NamedUtilityMachine<ParsingState> {
cursor.advance()
}
Class::Dash => match cursor.next().into() {
Class::Dash => match cursor.next.into() {
// Start of an arbitrary value
//
// E.g.: `bg-[#0088cc]`
@ -196,7 +196,7 @@ impl Machine for NamedUtilityMachine<ParsingState> {
_ => return self.restart(),
},
Class::Underscore => match cursor.next().into() {
Class::Underscore => match cursor.next.into() {
// Valid characters _if_ followed by another valid character. These characters are
// only valid inside of the utility but not at the end of the utility.
//
@ -225,14 +225,14 @@ impl Machine for NamedUtilityMachine<ParsingState> {
// ^
// E.g.: `:div="{ flex: true }"` (JavaScript object syntax)
// ^
_ if is_valid_after_boundary(&cursor.next()) || {
_ if is_valid_after_boundary(&cursor.next) || {
// Or any of these characters
//
// - `:`, because of JS object keys
// - `/`, because of modifiers
// - `!`, because of important
matches!(
cursor.next().into(),
cursor.next.into(),
Class::Colon | Class::Slash | Class::Exclamation
)
} =>
@ -249,11 +249,11 @@ impl Machine for NamedUtilityMachine<ParsingState> {
// E.g.: `px-2.5`
// ^^^
Class::Dot => {
if !matches!(cursor.prev().into(), Class::Number) {
if !matches!(cursor.prev.into(), Class::Number) {
return self.restart();
}
if !matches!(cursor.next().into(), Class::Number) {
if !matches!(cursor.next.into(), Class::Number) {
return self.restart();
}
@ -278,19 +278,18 @@ impl Machine for NamedUtilityMachine<ParsingState> {
//
Class::Number => {
if !matches!(
cursor.prev().into(),
cursor.prev.into(),
Class::Dash
| Class::Underscore
| Class::Dot
| Class::Number
| Class::AlphaLower
| Class::AlphaUpper
) {
return self.restart();
}
if !matches!(
cursor.next().into(),
cursor.next.into(),
Class::Dot
| Class::Number
| Class::AlphaLower
@ -314,7 +313,7 @@ impl Machine for NamedUtilityMachine<ParsingState> {
// ^^
// ```
Class::Percent => {
if !matches!(cursor.prev().into(), Class::Number) {
if !matches!(cursor.prev.into(), Class::Number) {
return self.restart();
}
@ -426,8 +425,6 @@ mod tests {
// With number followed by dash or underscore
("text-title1-strong", vec!["text-title1-strong"]),
("text-title1_strong", vec!["text-title1_strong"]),
// With capital letter followed by number
("text-titleV1-strong", vec!["text-titleV1-strong"]),
// With trailing % sign
("from-15%", vec!["from-15%"]),
// Arbitrary value with bracket notation

View file

@ -81,8 +81,8 @@ impl Machine for NamedVariantMachine<IdleState> {
#[inline(always)]
fn next(&mut self, cursor: &mut cursor::Cursor<'_>) -> MachineState {
match cursor.curr().into() {
Class::AlphaLower | Class::Star => match cursor.next().into() {
match cursor.curr.into() {
Class::AlphaLower | Class::Star => match cursor.next.into() {
// Valid single character variant, must be followed by a `:`
//
// E.g.: `<div class="x:flex"></div>`
@ -134,8 +134,8 @@ impl Machine for NamedVariantMachine<ParsingState> {
let len = cursor.input.len();
while cursor.pos < len {
match cursor.curr().into() {
Class::Dash => match cursor.next().into() {
match cursor.curr.into() {
Class::Dash => match cursor.next.into() {
// Start of an arbitrary value
//
// E.g.: `data-[state=pending]:`.
@ -192,7 +192,7 @@ impl Machine for NamedVariantMachine<ParsingState> {
};
}
Class::Underscore => match cursor.next().into() {
Class::Underscore => match cursor.next.into() {
// Valid characters _if_ followed by another valid character. These characters are
// only valid inside of the variant but not at the end of the variant.
//
@ -240,11 +240,11 @@ impl Machine for NamedVariantMachine<ParsingState> {
// E.g.: `2.5xl:flex`
// ^^^
Class::Dot => {
if !matches!(cursor.prev().into(), Class::Number) {
if !matches!(cursor.prev.into(), Class::Number) {
return self.restart();
}
if !matches!(cursor.next().into(), Class::Number) {
if !matches!(cursor.next.into(), Class::Number) {
return self.restart();
}
@ -263,7 +263,7 @@ impl Machine for NamedVariantMachine<ParsingState> {
impl NamedVariantMachine<ParsingState> {
#[inline(always)]
fn parse_arbitrary_end(&mut self, cursor: &mut cursor::Cursor<'_>) -> MachineState {
match cursor.next().into() {
match cursor.next.into() {
Class::Slash => {
cursor.advance();
self.transition::<ParsingModifierState>().next(cursor)
@ -285,7 +285,7 @@ impl Machine for NamedVariantMachine<ParsingModifierState> {
fn next(&mut self, cursor: &mut cursor::Cursor<'_>) -> MachineState {
match self.modifier_machine.next(cursor) {
MachineState::Idle => self.restart(),
MachineState::Done(_) => match cursor.next().into() {
MachineState::Done(_) => match cursor.next.into() {
// Modifier must be followed by a `:`
//
// E.g.: `group-hover/name:`
@ -308,7 +308,7 @@ impl Machine for NamedVariantMachine<ParsingEndState> {
#[inline(always)]
fn next(&mut self, cursor: &mut cursor::Cursor<'_>) -> MachineState {
match cursor.curr().into() {
match cursor.curr.into() {
// The end of a variant must be the `:`
//
// E.g.: `hover:`

View file

@ -32,14 +32,14 @@ impl PreProcessor for Clojure {
let mut cursor = cursor::Cursor::new(&content);
while cursor.pos < len {
match cursor.curr() {
match cursor.curr {
// Consume strings as-is
b'"' => {
result[cursor.pos] = b' ';
cursor.advance();
while cursor.pos < len {
match cursor.curr() {
match cursor.curr {
// Escaped character, skip ahead to the next character
b'\\' => cursor.advance_twice(),
@ -57,8 +57,8 @@ impl PreProcessor for Clojure {
// Discard line comments until the end of the line.
// Comments start with `;;`
b';' if matches!(cursor.next(), b';') => {
while cursor.pos < len && cursor.curr() != b'\n' {
b';' if matches!(cursor.next, b';') => {
while cursor.pos < len && cursor.curr != b'\n' {
result[cursor.pos] = b' ';
cursor.advance();
}
@ -70,7 +70,7 @@ impl PreProcessor for Clojure {
cursor.advance();
while cursor.pos < len {
match cursor.curr() {
match cursor.curr {
// A `.` surrounded by digits is a decimal number, so we don't want to replace it.
//
// E.g.:
@ -78,8 +78,8 @@ impl PreProcessor for Clojure {
// gap-1.5
// ^
// ```
b'.' if cursor.prev().is_ascii_digit()
&& cursor.next().is_ascii_digit() =>
b'.' if cursor.prev.is_ascii_digit()
&& cursor.next.is_ascii_digit() =>
{
// Keep the `.` as-is
}
@ -95,7 +95,7 @@ impl PreProcessor for Clojure {
result[cursor.pos] = b' ';
}
// End of keyword.
_ if !is_keyword_character(cursor.curr()) => {
_ if !is_keyword_character(cursor.curr) => {
result[cursor.pos] = b' ';
break;
}
@ -110,11 +110,11 @@ impl PreProcessor for Clojure {
// Handle quote with a list, e.g.: `'(…)`
// and with a vector, e.g.: `'[…]`
b'\'' if matches!(cursor.next(), b'[' | b'(') => {
b'\'' if matches!(cursor.next, b'[' | b'(') => {
result[cursor.pos] = b' ';
cursor.advance();
result[cursor.pos] = b' ';
let end = match cursor.curr() {
let end = match cursor.curr {
b'[' => b']',
b'(' => b')',
_ => unreachable!(),
@ -122,7 +122,7 @@ impl PreProcessor for Clojure {
// Consume until the closing `]`
while cursor.pos < len {
match cursor.curr() {
match cursor.curr {
x if x == end => {
result[cursor.pos] = b' ';
break;
@ -134,7 +134,7 @@ impl PreProcessor for Clojure {
cursor.advance();
while cursor.pos < len {
match cursor.curr() {
match cursor.curr {
// Escaped character, skip ahead to the next character
b'\\' => cursor.advance_twice(),
@ -157,14 +157,14 @@ impl PreProcessor for Clojure {
}
// Handle quote with a keyword, e.g.: `'bg-white`
b'\'' if !cursor.next().is_ascii_whitespace() => {
b'\'' if !cursor.next.is_ascii_whitespace() => {
result[cursor.pos] = b' ';
cursor.advance();
while cursor.pos < len {
match cursor.curr() {
match cursor.curr {
// End of keyword.
_ if !is_keyword_character(cursor.curr()) => {
_ if !is_keyword_character(cursor.curr) => {
result[cursor.pos] = b' ';
break;
}

View file

@ -13,13 +13,13 @@ impl PreProcessor for Elixir {
while cursor.pos < content.len() {
// Look for a sigil marker
if cursor.curr() != b'~' {
if cursor.curr != b'~' {
cursor.advance();
continue;
}
// Scan charlists, strings, and wordlists
if !matches!(cursor.next(), b'c' | b'C' | b's' | b'S' | b'w' | b'W') {
if !matches!(cursor.next, b'c' | b'C' | b's' | b'S' | b'w' | b'W') {
cursor.advance();
continue;
}
@ -27,7 +27,7 @@ impl PreProcessor for Elixir {
cursor.advance_twice();
// Match the opening for a sigil
if !matches!(cursor.curr(), b'(' | b'[' | b'{') {
if !matches!(cursor.curr, b'(' | b'[' | b'{') {
continue;
}
@ -35,19 +35,19 @@ impl PreProcessor for Elixir {
result[cursor.pos] = b' ';
// Scan until we find a balanced closing one and replace it too
bracket_stack.push(cursor.curr());
bracket_stack.push(cursor.curr);
while cursor.pos < content.len() {
cursor.advance();
match cursor.curr() {
match cursor.curr {
// Escaped character, skip ahead to the next character
b'\\' => cursor.advance_twice(),
b'(' | b'[' | b'{' => {
bracket_stack.push(cursor.curr());
bracket_stack.push(cursor.curr);
}
b')' | b']' | b'}' if !bracket_stack.is_empty() => {
bracket_stack.pop(cursor.curr());
bracket_stack.pop(cursor.curr);
if bracket_stack.is_empty() {
// Replace the closing bracket with a space

View file

@ -101,7 +101,7 @@ impl PreProcessor for Haml {
let mut last_newline_position = 0;
while cursor.pos < len {
match cursor.curr() {
match cursor.curr {
// Escape the next character
b'\\' => {
cursor.advance_twice();
@ -119,7 +119,7 @@ impl PreProcessor for Haml {
b'-' if cursor.input[last_newline_position..cursor.pos]
.iter()
.all(u8::is_ascii_whitespace)
&& matches!(cursor.next(), b'#') =>
&& matches!(cursor.next, b'#') =>
{
// Just consume the comment
let updated_last_newline_position =
@ -159,7 +159,7 @@ impl PreProcessor for Haml {
// Override the last known newline position
last_newline_position = end;
let replaced = pre_process_input(ruby_code.to_vec(), "rb");
let replaced = pre_process_input(ruby_code, "rb");
result.replace_range(start..end, replaced);
}
@ -190,7 +190,7 @@ impl PreProcessor for Haml {
// digit.
// E.g.: `bg-red-500.2xl:flex`
// ^^^
if cursor.prev().is_ascii_digit() && cursor.next().is_ascii_digit() {
if cursor.prev.is_ascii_digit() && cursor.next.is_ascii_digit() {
let mut next_cursor = cursor.clone();
next_cursor.advance();
@ -203,81 +203,6 @@ impl PreProcessor for Haml {
}
}
// Handle Ruby syntax with `%w[]` arrays embedded in Haml attribute hashes. E.g.:
//
// ```haml
// %div{class: %w[bg-blue-500 w-10 h-10]}
// ```
//
// A `%` that follows a value is not a percent literal. E.g.: the `50%w` in
// `hit rate 50%w.`
b'%' if matches!(cursor.next(), b'w' | b'W')
&& !cursor.prev().is_ascii_alphanumeric()
&& !matches!(cursor.prev(), b'_' | b')' | b']' | b'}') =>
{
// Boundary characters
let (open, close) = match cursor.input.get(cursor.pos + 2) {
Some(b'[') => (b'[', b']'),
Some(b'(') => (b'(', b')'),
Some(b'{') => (b'{', b'}'),
Some(b'<') => (b'<', b'>'),
// Any other ASCII punctuation can be used as a custom delimiter
Some(&c) if c.is_ascii_punctuation() => (c, c),
// Everything else is not a valid delimiter
_ => {
cursor.advance();
continue;
}
};
result[cursor.pos] = b' '; // Replace `%`
cursor.advance();
result[cursor.pos] = b' '; // Replace `w`
cursor.advance();
result[cursor.pos] = b' '; // Replace the opening delimiter
cursor.advance();
// Paired delimiters can be nested as long as they are balanced. E.g.:
// `%w[foo[bar]baz]` produces a flat array.
let mut depth = 1_usize;
while cursor.pos < len {
match cursor.curr() {
// Skip escaped characters, unless the backslash is the delimiter
// itself
b'\\' if close != b'\\' => {
// Use backslash to embed spaces in the strings.
if cursor.next() == b' ' {
result[cursor.pos] = b' ';
}
cursor.advance();
}
// Start of a nested delimiter pair
c if c == open && open != close => depth += 1,
// Closing delimiter
c if c == close => {
depth -= 1;
// End of the literal, replace the closing delimiter with a space
if depth == 0 {
result[cursor.pos] = b' ';
break;
}
}
// Everything else is valid content
_ => {}
}
cursor.advance();
}
}
// Replace following characters with spaces if they are not inside of brackets
b'#' | b'=' if bracket_stack.is_empty() => {
result[cursor.pos] = b' ';
@ -288,11 +213,11 @@ impl PreProcessor for Haml {
if bracket_stack.is_empty() {
result[cursor.pos] = b' ';
}
bracket_stack.push(cursor.curr());
bracket_stack.push(cursor.curr);
}
b')' | b']' | b'}' if !bracket_stack.is_empty() => {
bracket_stack.pop(cursor.curr());
bracket_stack.pop(cursor.curr);
// Replace closing bracket with a space
if bracket_stack.is_empty() {
@ -332,7 +257,7 @@ impl Haml {
// :url => { :action => "add", :id => product.id },
// :update => { :success => "cart", :failure => "error" }
// ```
let evaluation_type = cursor.curr();
let evaluation_type = cursor.curr;
let block_indentation_level = cursor
.pos
@ -342,17 +267,17 @@ impl Haml {
let mut last_newline_position = last_known_newline_position;
// Consume until the end of the line first
while cursor.pos < len && cursor.curr() != b'\n' {
while cursor.pos < len && cursor.curr != b'\n' {
cursor.advance();
}
// Block is already done, aka just a line
if evaluation_type == b'=' && cursor.prev() != b',' {
if evaluation_type == b'=' && cursor.prev != b',' {
return cursor.pos;
}
'outer: while cursor.pos < len {
match cursor.curr() {
match cursor.curr {
// Escape the next character
b'\\' => {
cursor.advance_twice();
@ -364,7 +289,7 @@ impl Haml {
last_newline_position = cursor.pos;
// We are done with this block
if evaluation_type == b'=' && cursor.prev() != b',' {
if evaluation_type == b'=' && cursor.prev != b',' {
break;
}
@ -375,11 +300,11 @@ impl Haml {
// Skip whitespace and compute the indentation level
x if x.is_ascii_whitespace() => {
// Find first non-whitespace character
while cursor.pos < len && cursor.curr().is_ascii_whitespace() {
if cursor.curr() == b'\n' {
while cursor.pos < len && cursor.curr.is_ascii_whitespace() {
if cursor.curr == b'\n' {
last_newline_position = cursor.pos;
if evaluation_type == b'=' && cursor.prev() != b',' {
if evaluation_type == b'=' && cursor.prev != b',' {
// We are done with this block
break 'outer;
}
@ -499,81 +424,6 @@ mod tests {
);
}
// https://github.com/tailwindlabs/tailwindcss/issues/20386
#[test]
fn test_embedded_ruby_percent_w_delimiters() {
for (input, expected) in [
// %w[…] in an attribute hash
(
"%div{class: %w[flex px-2.5]}",
"%div class: flex px-2.5 ",
),
// %w<…>
(
"%div{class: %w<flex px-2.5>}",
"%div class: flex px-2.5 ",
),
// Nested `<…>` does not end the literal
(
"%div{class: %w<flex <nested> px-2.5>}",
"%div class: flex <nested> px-2.5 ",
),
// Custom delimiters
(
"%div{class: %w|flex px-2.5|}",
"%div class: flex px-2.5 ",
),
(
"%div{class: %W!flex px-2.5!}",
"%div class: flex px-2.5 ",
),
(
"%div{class: %w#text-sm leading-6#}",
"%div class: text-sm leading-6 ",
),
(
"%div{class: %w=italic tracking-wide=}",
"%div class: italic tracking-wide ",
),
// Nested paired delimiters stay balanced inside the literal
(
"%div{class: %w[content-['[hello]'] p-4]}",
"%div class: content-['[hello]'] p-4 ",
),
// Escaped spaces embed a space in a single array element
(
r#"%div{class: %w[foo\ bar baz-1]}"#,
r#"%div class: foo bar baz-1 "#,
),
// A `%` that follows a value is not a percent literal
("%p hit rate 50%w.", "%p hit rate 50%w "),
] {
Haml::test(input, expected);
}
let input = r#"
%div{class: %w[bg-blue-500 w-10 h-10]}
%div{class: %w<flex px-2.5>}
%div{class: %w|underline font-bold|}
- classes = %w<mt-4 grid>
"#;
Haml::test_extract_contains(
input,
vec![
"bg-blue-500",
"w-10",
"h-10",
"flex",
"px-2.5",
"underline",
"font-bold",
"mt-4",
"grid",
],
);
}
// https://github.com/tailwindlabs/tailwindcss/pull/17051#issuecomment-2711181352
#[test]
fn test_haml_full_file_17051() {

View file

@ -11,13 +11,13 @@ impl PreProcessor for Json {
let mut cursor = cursor::Cursor::new(content);
while cursor.pos < len {
match cursor.curr() {
match cursor.curr {
// Consume strings as-is
b'"' => {
cursor.advance();
while cursor.pos < len {
match cursor.curr() {
match cursor.curr {
// Escaped character, skip ahead to the next character
b'\\' => cursor.advance_twice(),

View file

@ -9,27 +9,20 @@ impl PreProcessor for Markdown {
let len = content.len();
let mut result = content.to_vec();
let mut cursor = cursor::Cursor::new(content);
let mut bracket_stack = vec![];
let mut in_directive = false;
while cursor.pos < len {
match (in_directive, cursor.curr()) {
match (in_directive, cursor.curr) {
(false, b'{') => {
result[cursor.pos] = b' ';
in_directive = true;
}
(true, b'(' | b'[' | b'{' | b'<') => {
bracket_stack.push(cursor.curr());
}
(true, b')' | b']' | b'}' | b'>') if !bracket_stack.is_empty() => {
bracket_stack.pop();
}
(true, b'}') => {
result[cursor.pos] = b' ';
in_directive = false;
}
(true, b'.') if bracket_stack.is_empty() => {
(true, b'.') => {
result[cursor.pos] = b' ';
}
_ => {}
@ -67,17 +60,4 @@ mod tests {
Markdown::test(input, expected);
}
}
#[test]
fn test_nested_classes_keep_the_dots() {
for (input, expected) in [
(
r#"{<div class="px-2.5"></div>}"#,
r#" <div class="px-2.5"></div> "#,
),
(r#"{content-['example.js']}"#, r#" content-['example.js'] "#),
] {
Markdown::test(input, expected);
}
}
}

View file

@ -10,8 +10,6 @@ pub mod ruby;
pub mod rust;
pub mod slim;
pub mod svelte;
pub mod template_toolkit;
pub mod twig;
pub mod vue;
pub use clojure::*;
@ -26,6 +24,4 @@ pub use ruby::*;
pub use rust::*;
pub use slim::*;
pub use svelte::*;
pub use template_toolkit::*;
pub use twig::*;
pub use vue::*;

View file

@ -25,33 +25,7 @@ pub trait PreProcessor: Sized + Default {
}
#[cfg(test)]
fn test_extract_exact(input: &str, expected: Vec<&str>) {
use crate::extractor::{Extracted, Extractor};
let input = input.as_bytes();
let processor = Self::default();
let transformed = processor.process(input);
let extracted = Extractor::new(&transformed).extract();
// Extract all candidates and css variables.
let candidates = extracted
.iter()
.filter_map(|x| match x {
Extracted::Candidate(bytes) => std::str::from_utf8(bytes).ok(),
Extracted::CssVariable(bytes) => std::str::from_utf8(bytes).ok(),
})
.collect::<Vec<_>>();
if candidates != expected {
dbg!(&candidates, &expected);
panic!("Extracted candidates do not match expected candidates");
}
}
#[cfg(test)]
fn test_extract_contains(input: &str, expected: Vec<&str>) {
fn test_extract_contains(input: &str, items: Vec<&str>) {
use crate::extractor::{Extracted, Extractor};
let input = input.as_bytes();
@ -72,7 +46,7 @@ pub trait PreProcessor: Sized + Default {
// Ensure all items are present in the candidates.
let mut missing = vec![];
for item in &expected {
for item in &items {
if !candidates.contains(item) {
missing.push(item);
}

View file

@ -15,7 +15,7 @@ impl PreProcessor for Pug {
let mut bracket_stack = BracketStack::default();
while cursor.pos < len {
match cursor.curr() {
match cursor.curr {
// Only replace `.` with a space if it's not surrounded by numbers. E.g.:
//
// ```diff
@ -43,7 +43,7 @@ impl PreProcessor for Pug {
// digit.
// E.g.: `bg-red-500.2xl:flex`
// ^^^
if cursor.prev().is_ascii_digit() && cursor.next().is_ascii_digit() {
if cursor.prev.is_ascii_digit() && cursor.next.is_ascii_digit() {
let mut next_cursor = cursor.clone();
next_cursor.advance();
@ -68,17 +68,17 @@ impl PreProcessor for Pug {
//
// However, we also need to make sure that we keep the parens that are part of the
// utility class. E.g.: `bg-(--my-color)`.
b'(' if bracket_stack.is_empty() && !matches!(cursor.prev(), b'-' | b'/') => {
b'(' if bracket_stack.is_empty() && !matches!(cursor.prev, b'-' | b'/') => {
result[cursor.pos] = b' ';
bracket_stack.push(cursor.curr());
bracket_stack.push(cursor.curr);
}
b'(' | b'[' | b'{' => {
bracket_stack.push(cursor.curr());
bracket_stack.push(cursor.curr);
}
b')' | b']' | b'}' if !bracket_stack.is_empty() => {
bracket_stack.pop(cursor.curr());
bracket_stack.pop(cursor.curr);
}
// Consume everything else

View file

@ -34,135 +34,51 @@ impl PreProcessor for Ruby {
// Extract embedded template languages
// https://viewcomponent.org/guide/templates.html#interpolations
// Only process if content is valid UTF-8, otherwise skip HEREDOC extraction
// but still perform the byte-level Ruby processing below
if let Ok(content_as_str) = std::str::from_utf8(content) {
let starts = TEMPLATE_START_REGEX
.captures_iter(content_as_str)
.collect::<Vec<_>>();
let ends = TEMPLATE_END_REGEX
.captures_iter(content_as_str)
.collect::<Vec<_>>();
let content_as_str = std::str::from_utf8(content).unwrap();
for start in starts.iter() {
// The language for this block
let lang = start.get(1).unwrap().as_str();
let starts = TEMPLATE_START_REGEX
.captures_iter(content_as_str)
.collect::<Vec<_>>();
let ends = TEMPLATE_END_REGEX
.captures_iter(content_as_str)
.collect::<Vec<_>>();
// The HEREDOC delimiter
let delimiter_start = start.get(2).unwrap().as_str();
for start in starts.iter() {
// The language for this block
let lang = start.get(1).unwrap().as_str();
// Where the "body" starts for the HEREDOC block
let body_start = start.get(0).unwrap().end();
// The HEREDOC delimiter
let delimiter_start = start.get(2).unwrap().as_str();
// Look through all of the ends to find a matching language
for end in ends.iter() {
// 1. This must appear after the start
let body_end = end.get(0).unwrap().start();
if body_end < body_start {
continue;
}
// Where the "body" starts for the HEREDOC block
let body_start = start.get(0).unwrap().end();
// The languages must match otherwise we haven't found the end
let delimiter_end = end.get(1).unwrap().as_str();
if delimiter_end != delimiter_start {
continue;
}
let body = &content_as_str[body_start..body_end];
let replaced =
pre_process_input(body.as_bytes().to_vec(), &lang.to_ascii_lowercase());
result.replace_range(body_start..body_end, replaced);
break;
// Look through all of the ends to find a matching language
for end in ends.iter() {
// 1. This must appear after the start
let body_end = end.get(0).unwrap().start();
if body_end < body_start {
continue;
}
// The languages must match otherwise we haven't found the end
let delimiter_end = end.get(1).unwrap().as_str();
if delimiter_end != delimiter_start {
continue;
}
let body = &content_as_str[body_start..body_end];
let replaced = pre_process_input(body.as_bytes(), &lang.to_ascii_lowercase());
result.replace_range(body_start..body_end, replaced);
break;
}
}
// Ruby extraction
while cursor.pos < len {
match cursor.curr() {
b'"' => {
cursor.advance();
while cursor.pos < len {
match cursor.curr() {
// Escaped character, skip ahead to the next character
b'\\' => cursor.advance_twice(),
// End of the string
b'"' => break,
// Everything else is valid
_ => cursor.advance(),
};
}
cursor.advance();
continue;
}
b'\'' => {
cursor.advance();
while cursor.pos < len {
match cursor.curr() {
// Escaped character, skip ahead to the next character
b'\\' => cursor.advance_twice(),
// End of the string
b'\'' => break,
// Everything else is valid
_ => cursor.advance(),
};
}
cursor.advance();
continue;
}
// Replace comments in Ruby files
//
// Except for strict locals, these are defined in a `<%# locals: … %>`. Checking if
// the comment is preceded by a `%` should be enough without having to perform more
// parsing logic. Worst case we _do_ scan a few comments.
//
// We also want to skip interpolation syntax, which look like `#{…}`.
b'#' if !matches!(cursor.prev(), b'%') && !matches!(cursor.next(), b'{') => {
result[cursor.pos] = b' ';
cursor.advance();
while cursor.pos < len {
match cursor.curr() {
// End of the comment
b'\n' => break,
// Everything else is part of the comment and replaced
_ => {
result[cursor.pos] = b' ';
cursor.advance();
}
};
}
cursor.advance();
continue;
}
_ => {}
}
// Looking for `%w`, `%W`, or `%p`
if cursor.curr() != b'%' || !matches!(cursor.next(), b'w' | b'W' | b'p') {
cursor.advance();
continue;
}
// A `%` that follows a value is a modulo operation, not a percent literal. E.g.: the
// `50%w` in `hit rate 50%w.`
if cursor.prev().is_ascii_alphanumeric()
|| matches!(cursor.prev(), b'_' | b')' | b']' | b'}')
{
// Looking for `%w` or `%W`
if cursor.curr != b'%' && !matches!(cursor.next, b'w' | b'W') {
cursor.advance();
continue;
}
@ -170,17 +86,10 @@ impl PreProcessor for Ruby {
cursor.advance_twice();
// Boundary character
let boundary = match cursor.curr() {
let boundary = match cursor.curr {
b'[' => b']',
b'(' => b')',
b'{' => b'}',
b'<' => b'>',
b' ' => b'\n',
// Any other ASCII punctuation can be used as a custom delimiter
c if c.is_ascii_punctuation() => c,
// Everything else is not a valid delimiter
_ => {
cursor.advance();
continue;
@ -196,11 +105,11 @@ impl PreProcessor for Ruby {
cursor.advance();
while cursor.pos < len {
match cursor.curr() {
// Skip escaped characters, unless the backslash is the delimiter itself
b'\\' if boundary != b'\\' => {
match cursor.curr {
// Skip escaped characters
b'\\' => {
// Use backslash to embed spaces in the strings.
if cursor.next() == b' ' {
if cursor.next == b' ' {
result[cursor.pos] = b' ';
}
@ -209,36 +118,20 @@ impl PreProcessor for Ruby {
// Start of a nested bracket
b'[' | b'(' | b'{' => {
bracket_stack.push(cursor.curr());
}
// Start of a nested `<…>`, which Ruby allows inside a `%w<…>` literal
b'<' if boundary == b'>' => {
bracket_stack.push(cursor.curr());
bracket_stack.push(cursor.curr);
}
// End of a nested bracket
b']' | b')' | b'}' if !bracket_stack.is_empty() => {
if !bracket_stack.pop(cursor.curr()) {
// Unbalanced
cursor.advance();
}
}
// End of a nested `<…>`
b'>' if boundary == b'>' && !bracket_stack.is_empty() => {
if !bracket_stack.pop(cursor.curr()) {
if !bracket_stack.pop(cursor.curr) {
// Unbalanced
cursor.advance();
}
}
// End of the pattern, replace the boundary character with a space
_ if cursor.curr() == boundary => {
if boundary != b'\n' {
result[cursor.pos] = b' ';
}
_ if cursor.curr == boundary => {
result[cursor.pos] = b' ';
break;
}
@ -280,69 +173,12 @@ mod tests {
"%w(flex data-[state=pending]:bg-(--my-color) flex-col)",
"%w flex data-[state=pending]:bg-(--my-color) flex-col ",
),
// %w<…>
("%w<flex px-2.5>", "%w flex px-2.5 "),
(
"%w<flex data-[state=pending]:bg-(--my-color) flex-col>",
"%w flex data-[state=pending]:bg-(--my-color) flex-col ",
),
// Nested `<…>` does not end the literal
("%w<flex <nested> px-2.5>", "%w flex <nested> px-2.5 "),
// %w|…|, %w:…:, %w!…!
("%w|flex px-2.5|", "%w flex px-2.5 "),
("%w:flex px-2.5:", "%w flex px-2.5 "),
("%w!flex px-2.5!", "%w flex px-2.5 "),
(r#"%w\flex px-2.5\"#, r#"%w flex px-2.5 "#),
// A `%` that follows a value is a modulo operation, not a percent literal
(
"hit rate 50%w.\n%w[flex px-2.5]",
"hit rate 50%w.\n%w flex px-2.5 ",
),
// %w …\n
("%w flex px-2.5\n", "%w flex px-2.5\n"),
// Use backslash to embed spaces in the strings.
(r#"%w[foo\ bar baz\ bat]"#, r#"%w foo bar baz bat "#),
(r#"%W[foo\ bar baz\ bat]"#, r#"%W foo bar baz bat "#),
// The nested delimiters evaluated to a flat array of strings
// (not nested array).
(r#"%w[foo[bar baz]qux]"#, r#"%w foo[bar baz]qux "#),
(
"# test\n# test\n# {ActiveRecord::Base#save!}[rdoc-ref:Persistence#save!]\n%w[flex px-2.5]",
" \n \n \n%w flex px-2.5 "
),
(r#""foo # bar""#, r#""foo # bar""#),
(r#"'foo # bar'"#, r#"'foo # bar'"#),
(
r#"def call = tag.span "Foo", class: %w[rounded-full h-0.75 w-0.75]"#,
r#"def call = tag.span "Foo", class: %w rounded-full h-0.75 w-0.75 "#
),
(r#"%w[foo ' bar]"#, r#"%w foo ' bar "#),
(r#"%w[foo " bar]"#, r#"%w foo " bar "#),
(r#"%W[foo ' bar]"#, r#"%W foo ' bar "#),
(r#"%W[foo " bar]"#, r#"%W foo " bar "#),
(r#"%p foo ' bar "#, r#"%p foo ' bar "#),
(r#"%p foo " bar "#, r#"%p foo " bar "#),
(
"%p has a ' quote\n# this should be removed\n%p has a ' quote",
"%p has a ' quote\n \n%p has a ' quote"
),
(
"%p has a \" quote\n# this should be removed\n%p has a \" quote",
"%p has a \" quote\n \n%p has a \" quote"
),
(
"%w#this text is kept# # this text is not",
"%w this text is kept ",
),
] {
Ruby::test(input, expected);
}
@ -375,30 +211,6 @@ mod tests {
"%w(flex data-[state=pending]:bg-(--my-color) flex-col)",
vec!["flex", "data-[state=pending]:bg-(--my-color)", "flex-col"],
),
// %w<…>
("%w<flex px-2.5>", vec!["flex", "px-2.5"]),
("%w<px-2.5 flex>", vec!["flex", "px-2.5"]),
("%w<2xl:flex>", vec!["2xl:flex"]),
(
"%w<flex data-[state=pending]:bg-(--my-color) flex-col>",
vec!["flex", "data-[state=pending]:bg-(--my-color)", "flex-col"],
),
// Nested `<…>` does not end the literal
("%w<flex <nested> px-2.5>", vec!["flex", "px-2.5"]),
// %w|…|, %w:…:, %w!…!
("%w|flex px-2.5|", vec!["flex", "px-2.5"]),
("%w:flex px-2.5:", vec!["flex", "px-2.5"]),
("%w!flex px-2.5!", vec!["flex", "px-2.5"]),
(
"# test\n# test\n# {ActiveRecord::Base#save!}[rdoc-ref:Persistence#save!]\n%w[flex px-2.5]",
vec!["flex", "px-2.5"],
),
(r#""foo # bar""#, vec!["foo", "bar"]),
(r#"'foo # bar'"#, vec!["foo", "bar"]),
(r#"%w[foo ' bar]"#, vec!["foo", "bar"]),
] {
Ruby::test_extract_contains(input, expected);
}
@ -450,79 +262,4 @@ mod tests {
"#;
Ruby::test_extract_contains(input, vec!["z-1", "z-2", "z-3"]);
}
// https://github.com/tailwindlabs/tailwindcss/issues/19728
#[test]
fn test_interpolated_expressions() {
let input = r#"
def width_class(width = nil)
<<~STYLE_CLASS
#{width || 'w-100'}
STYLE_CLASS
end
"#;
Ruby::test_extract_contains(input, vec!["w-100"]);
}
// https://github.com/tailwindlabs/tailwindcss/issues/19239
#[test]
fn test_skip_comments() {
let input = r#"
# From activerecord-8.1.1/lib/active_record/errors.rb:147
# Rails uses RDoc cross-reference syntax in inline documentation:
# {ActiveRecord::Base#save!}[rdoc-ref:Persistence#save!]
"#;
// Nothing should be extracted from comments, so expect an empty array.
Ruby::test_extract_exact(input, vec![]);
}
// https://github.com/tailwindlabs/tailwindcss/issues/19481
#[test]
fn test_strict_locals() {
// Strict locals are defined in a `<%# locals: … %>`, but the `#` looks like a comment
// which we should not ignore in this case.
let input = r#"
<%# locals: (css: "text-amber-600") %>
<% more_css = "text-sky-500" %>
<p class="text-green-500">
In a partial
</p>
<p class="<%= css %>">
In a partial using explicit local variables
</p>
<p class="<%= more_css %>">
In a partial using explicit local variables
</p>
"#;
Ruby::test_extract_contains(
input,
vec!["text-amber-600", "text-sky-500", "text-green-500"],
);
}
#[test]
fn test_invalid_utf8_does_not_panic() {
// Invalid UTF-8 sequence: 0x80 is a continuation byte without a leading byte
let invalid_utf8: &[u8] = &[0x80, 0x81, 0x82];
let processor = Ruby::default();
// Should not panic, just return the input unchanged
let result = processor.process(invalid_utf8);
assert_eq!(result, invalid_utf8);
}
#[test]
fn test_valid_utf8_with_multibyte_chars() {
// Test that valid UTF-8 with multi-byte characters (like em-dashes) works
let input = "# Comment with em—dash\n%w[flex px-2.5]";
Ruby::test_extract_contains(input, vec!["flex", "px-2.5"]);
}
}

View file

@ -33,7 +33,7 @@ impl Rust {
let mut bracket_stack = bracket_stack::BracketStack::default();
while cursor.pos < len {
match cursor.curr() {
match cursor.curr {
// Escaped character, skip ahead to the next character
b'\\' => {
cursor.advance_twice();
@ -46,7 +46,7 @@ impl Rust {
cursor.advance();
while cursor.pos < len {
match cursor.curr() {
match cursor.curr {
// Escaped character, skip ahead to the next character
b'\\' => cursor.advance_twice(),
@ -89,7 +89,7 @@ impl Rust {
// digit.
// E.g.: `bg-red-500.2xl:flex`
// ^^^
if cursor.prev().is_ascii_digit() && cursor.next().is_ascii_digit() {
if cursor.prev.is_ascii_digit() && cursor.next.is_ascii_digit() {
let mut next_cursor = cursor.clone();
next_cursor.advance();
@ -103,17 +103,11 @@ impl Rust {
}
b'[' => {
bracket_stack.push(cursor.curr());
// Handle `p.flex[condition]`. If there is a `-` before it, it will likely be an
// arbitrary value e.g. `text-[red]`
if !matches!(cursor.prev(), b'-') {
result[cursor.pos] = b' ';
}
bracket_stack.push(cursor.curr);
}
b']' if !bracket_stack.is_empty() => {
bracket_stack.pop(cursor.curr());
bracket_stack.pop(cursor.curr);
}
// Consume everything else
@ -219,20 +213,4 @@ mod tests {
let input = r#"html! { \x.px-4.text-black { } }"#;
Rust::test(input, r#"html! { \x px-4 text-black { } }"#);
}
// https://github.com/tailwindlabs/tailwindcss/issues/20233
#[test]
fn test_maud_template_extraction_with_conditional_classes() {
let input = r#"
use maud::{html, Markup};
pub fn main() -> Markup {
html! {
p.text-black[cuteness > 50] { "Squee!" }
}
}
"#;
Rust::test_extract_contains(input, vec!["text-black"]);
}
}

View file

@ -15,7 +15,7 @@ impl PreProcessor for Slim {
let mut bracket_stack = BracketStack::default();
while cursor.pos < len {
match cursor.curr() {
match cursor.curr {
// Only replace `.` with a space if it's not surrounded by numbers. E.g.:
//
// ```diff
@ -43,7 +43,7 @@ impl PreProcessor for Slim {
// digit.
// E.g.: `bg-red-500.2xl:flex`
// ^^^
if cursor.prev().is_ascii_digit() && cursor.next().is_ascii_digit() {
if cursor.prev.is_ascii_digit() && cursor.next.is_ascii_digit() {
let mut next_cursor = cursor.clone();
next_cursor.advance();
@ -65,74 +65,17 @@ impl PreProcessor for Slim {
// class=%w[bg-blue-500 w-10 h-10]
// ]
// ```
//
// A `%` that follows a value is not a percent literal. E.g.: the `50%w` in
// `hit rate 50%w.`
b'%' if matches!(cursor.next(), b'w' | b'W')
&& !cursor.prev().is_ascii_alphanumeric()
&& !matches!(cursor.prev(), b'_' | b')' | b']' | b'}') =>
b'%' if matches!(cursor.next, b'w' | b'W')
&& matches!(cursor.input.get(cursor.pos + 2), Some(b'[' | b'(' | b'{')) =>
{
// Boundary characters
let (open, close) = match cursor.input.get(cursor.pos + 2) {
Some(b'[') => (b'[', b']'),
Some(b'(') => (b'(', b')'),
Some(b'{') => (b'{', b'}'),
Some(b'<') => (b'<', b'>'),
// Any other ASCII punctuation can be used as a custom delimiter
Some(&c) if c.is_ascii_punctuation() => (c, c),
// Everything else is not a valid delimiter
_ => {
cursor.advance();
continue;
}
};
result[cursor.pos] = b' '; // Replace `%`
cursor.advance();
result[cursor.pos] = b' '; // Replace `w`
cursor.advance();
result[cursor.pos] = b' '; // Replace the opening delimiter
cursor.advance();
// Paired delimiters can be nested as long as they are balanced. E.g.:
// `%w[foo[bar]baz]` produces a flat array.
let mut depth = 1_usize;
while cursor.pos < len {
match cursor.curr() {
// Skip escaped characters, unless the backslash is the delimiter
// itself
b'\\' if close != b'\\' => {
// Use backslash to embed spaces in the strings.
if cursor.next() == b' ' {
result[cursor.pos] = b' ';
}
cursor.advance();
}
// Start of a nested delimiter pair
c if c == open && open != close => depth += 1,
// Closing delimiter
c if c == close => {
depth -= 1;
// End of the literal, replace the closing delimiter with a space
if depth == 0 {
result[cursor.pos] = b' ';
break;
}
}
// Everything else is valid content
_ => {}
}
cursor.advance();
}
result[cursor.pos] = b' '; // Replace `[` or `(` or `{`
bracket_stack.push(cursor.curr);
cursor.advance(); // Move past the bracket
continue;
}
// Any `[` preceded by an alphanumeric value will not be part of a candidate.
@ -153,10 +96,10 @@ impl PreProcessor for Slim {
// Instead of listing all boundary characters, let's list the characters we know
// will be invalid instead.
b'[' if bracket_stack.is_empty()
&& matches!(cursor.prev(), b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9') =>
&& matches!(cursor.prev, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9') =>
{
result[cursor.pos] = b' ';
bracket_stack.push(cursor.curr());
bracket_stack.push(cursor.curr);
}
// In Slim the class name shorthand can be followed by a parenthesis. E.g.:
@ -171,17 +114,17 @@ impl PreProcessor for Slim {
//
// However, we also need to make sure that we keep the parens that are part of the
// utility class. E.g.: `bg-(--my-color)`.
b'(' if bracket_stack.is_empty() && !matches!(cursor.prev(), b'-' | b'/') => {
b'(' if bracket_stack.is_empty() && !matches!(cursor.prev, b'-' | b'/') => {
result[cursor.pos] = b' ';
bracket_stack.push(cursor.curr());
bracket_stack.push(cursor.curr);
}
b'(' | b'[' | b'{' => {
bracket_stack.push(cursor.curr());
bracket_stack.push(cursor.curr);
}
b')' | b']' | b'}' if !bracket_stack.is_empty() => {
bracket_stack.pop(cursor.curr());
bracket_stack.pop(cursor.curr);
}
// Consume everything else
@ -373,77 +316,16 @@ mod tests {
]
"#;
let expected = "
div \n class= bg-blue-500 w-10 h-10 \n ]
div \n class= w-10 bg-green-500 h-10 \n ]
";
let expected = r#"
div
class= bg-blue-500 w-10 h-10]
]
div
class= w-10 bg-green-500 h-10]
]
"#;
Slim::test(input, expected);
Slim::test_extract_contains(input, vec!["bg-blue-500", "bg-green-500", "w-10", "h-10"]);
}
// https://github.com/tailwindlabs/tailwindcss/issues/20386
#[test]
fn test_embedded_ruby_percent_w_delimiters() {
for (input, expected) in [
// %w<…>, Slim only counts `[({` nesting in attribute values, so the code must be
// wrapped in parentheses to contain spaces
(
"div class=(%w<bg-blue-500 w-10 h-10>)",
"div class= bg-blue-500 w-10 h-10 )",
),
// Nested `<…>` does not end the literal
(
"div class=(%w<flex <nested> px-2.5>)",
"div class= flex <nested> px-2.5 )",
),
// Custom delimiters
("div class=(%w|flex px-2.5|)", "div class= flex px-2.5 )"),
("div class=(%W!flex px-2.5!)", "div class= flex px-2.5 )"),
(
"div class=(%w#text-sm leading-6#)",
"div class= text-sm leading-6 )",
),
(
"div class=(%w=italic tracking-wide=)",
"div class= italic tracking-wide )",
),
// Nested paired delimiters stay balanced inside the literal
(
"div class=(%w[content-['[hello]'] p-4])",
"div class= content-['[hello]'] p-4 )",
),
// Escaped spaces embed a space in a single array element
(
r#"div class=(%w[foo\ bar baz-1])"#,
r#"div class= foo bar baz-1 )"#,
),
// Ruby control line, which is plain Ruby code
("- classes = %w<mt-4 flex>", "- classes = mt-4 flex "),
// A `%` that follows a value is not a percent literal
("| hit rate 50%w.", "| hit rate 50%w "),
] {
Slim::test(input, expected);
}
let input = r#"
div[
class=(%w<bg-blue-500 w-10 h-10>)
]
- classes = %w|w-10 bg-green-500 h-10|
= tag.div class: %W!px-2.5 flex!
"#;
Slim::test_extract_contains(
input,
vec![
"bg-blue-500",
"bg-green-500",
"w-10",
"h-10",
"px-2.5",
"flex",
],
);
}
}

View file

@ -1,52 +0,0 @@
use crate::cursor;
use crate::extractor::pre_processors::pre_processor::PreProcessor;
#[derive(Debug, Default)]
pub struct TemplateToolkit;
impl PreProcessor for TemplateToolkit {
fn process(&self, content: &[u8]) -> Vec<u8> {
let len = content.len();
let mut result = content.to_vec();
let mut cursor = cursor::Cursor::new(content);
while cursor.pos < len {
match (cursor.curr(), cursor.next()) {
(b'[', b'%') => result[cursor.pos] = b' ',
(b'%', b']') => result[cursor.pos + 1] = b' ',
_ => {}
}
cursor.advance();
}
result
}
}
#[cfg(test)]
mod tests {
use super::TemplateToolkit;
use crate::extractor::pre_processors::pre_processor::PreProcessor;
#[test]
fn test_template_toolkit_pre_processor() {
for (input, expected) in [
(
"[% IF $is_open %]bg-white/40[% ELSE %]bg-white/10[% END %]",
" % IF $is_open % bg-white/40 % ELSE % bg-white/10 % END % ",
),
("[% WRAPPER %]flex[% END %]", " % WRAPPER % flex % END % "),
] {
TemplateToolkit::test(input, expected);
}
}
#[test]
fn test_extraction_between_template_tags_works() {
TemplateToolkit::test_extract_contains(
r#"<div class="[% IF $is_open %]bg-white/40[% ELSE %]bg-white/10[% END %]"></div>"#,
vec!["bg-white/40", "bg-white/10"],
);
}
}

View file

@ -7,6 +7,7 @@
.relative
^^^^^^^^
- # Blurred background star
^^^^^^^^^^ ^^^^
.absolute.left-0.z-0{ class: "-top-[400px] -right-[400px]" }
^^^^^^^^ ^^^^^^ ^^^ ^^^^^ ^^^^^^^^^^^^ ^^^^^^^^^^^^^^
.flex.justify-end.blur-3xl
@ -74,8 +75,8 @@
^^^^^^^^^^ ^^^^^^
libraries, our components are designed to be fast, flexible, and easy to
^^^ ^^^^^^^^^^ ^^^ ^^^^^^^^ ^^ ^^ ^^^ ^^^^ ^^
use <i>directly</i> in Ruby on Rails!
^^^ ^^^^^^^^ ^^ ^^
use <i>directy</i> in Ruby on Rails!
^^^ ^^^^^^^ ^^ ^^
= doc_example(css: "mt-8 lg:mt-0 lg:basis-3/5 h-44") do
^^^ ^^^^ ^^^^^^^ ^^^^^^^^^^^^ ^^^^ ^^
@ -101,7 +102,7 @@
%p.mt-2
^^^^
Utilize
Utlize
= daisy_link("HAML", "https://haml.info/", target: "_blank")
^^^^^^
so your views are simple, concise, and easy to understand.
@ -119,8 +120,8 @@
%p.mt-2
^^^^
:markdown
**PLUS!** You can utilize filters like _Markdown_, CoffeeScript,
^^^ ^^^^^^^ ^^^^^^^ ^^^^
**PLUS!** You can utlize filters like _Markdown_, CoffeeScript,
^^^ ^^^^^^ ^^^^^^^ ^^^^
Textile, and many more!
^^^ ^^^^ ^^^^^
@ -195,6 +196,7 @@
^^^^^^^^ ^^^^ ^^^ ^^^^ ^^
:escaped
- # app/components/character_component.html.haml
^^^^
= part(:component) do
^^
= part(:head)

View file

@ -42,7 +42,7 @@
= daisy_link "DaisyUI", "https://daisyui.com/", target: "_blank"
= daisy_link "TailwindCSS", "https://tailwindcss.com/", target: "_blank"
libraries, our components are designed to be fast, flexible, and easy to
use <i>directly</i> in Ruby on Rails!
use <i>directy</i> in Ruby on Rails!
= doc_example(css: "mt-8 lg:mt-0 lg:basis-3/5 h-44") do
.flex.flex-col.sm:flex-row.items-center.gap-4
@ -58,7 +58,7 @@
Simple, Concise Views
%p.mt-2
Utilize
Utlize
= daisy_link("HAML", "https://haml.info/", target: "_blank")
so your views are simple, concise, and easy to understand.
@ -69,7 +69,7 @@
%p.mt-2
:markdown
**PLUS!** You can utilize filters like _Markdown_, CoffeeScript,
**PLUS!** You can utlize filters like _Markdown_, CoffeeScript,
Textile, and many more!
.mt-8.xl:mt-0

View file

@ -1,179 +0,0 @@
use crate::cursor;
use crate::extractor::pre_processors::pre_processor::PreProcessor;
#[derive(Debug, Default)]
pub struct Twig;
impl PreProcessor for Twig {
fn process(&self, content: &[u8]) -> Vec<u8> {
let len = content.len();
let mut result = content.to_vec();
let mut cursor = cursor::Cursor::new(content);
let mut bracket_stack = vec![];
const ADD_CLASS: &[u8] = b"addClass";
const REMOVE_CLASS: &[u8] = b"removeClass";
while cursor.pos < len {
match (!bracket_stack.is_empty(), cursor.curr()) {
// addClass(
// ^
(false, b'(')
if cursor.pos >= ADD_CLASS.len()
&& matches!(
&content[cursor.pos - ADD_CLASS.len()..cursor.pos],
ADD_CLASS
) =>
{
bracket_stack.push(cursor.curr());
result[cursor.pos] = b' ';
}
// removeClass(
// ^
(false, b'(')
if cursor.pos >= REMOVE_CLASS.len()
&& matches!(
&content[cursor.pos - REMOVE_CLASS.len()..cursor.pos],
REMOVE_CLASS
) =>
{
bracket_stack.push(cursor.curr());
result[cursor.pos] = b' ';
}
(true, b'(') => {
bracket_stack.push(cursor.curr());
}
(true, b')') => {
bracket_stack.pop();
if bracket_stack.is_empty() {
result[cursor.pos] = b' ';
}
}
_ => {}
}
cursor.advance();
}
result
}
}
#[cfg(test)]
mod tests {
use super::Twig;
use crate::extractor::pre_processors::pre_processor::PreProcessor;
#[test]
fn test_twig_pre_processor() {
for (input, expected) in [
(
// Ensure we don't crash when we encounter an `(`
"(p-(--value))",
"(p-(--value))",
),
(
// addClass with single argument
"addClass(p-(--value))",
"addClass p-(--value) ",
),
(
// addClass with single argument
"addClass(m-4 p-8 w-full)",
"addClass m-4 p-8 w-full ",
),
(
// removeClass with single argument
"removeClass(p-(--value))",
"removeClass p-(--value) ",
),
(
// removeClass with single argument
"removeClass(m-4 p-8 w-full)",
"removeClass m-4 p-8 w-full ",
),
(
// Combined with single arguments
"addClass(m-(--value))|removeClass(p-(--value))",
"addClass m-(--value) |removeClass p-(--value) ",
),
] {
Twig::test(input, expected);
}
}
// https://github.com/tailwindlabs/tailwindcss/issues/19458
#[test]
fn test_extraction_in_add_class_and_remove_class_works() {
// Inside normal HTML
let input = r#"
<div data-loading="addClass(opacity-50)">
<!-- -->
</div>
"#;
let expected = r#"
<div data-loading="addClass opacity-50 ">
<!-- -->
</div>
"#;
Twig::test(input, expected);
Twig::test_extract_contains(input, vec!["opacity-50"]);
// Inside a component
let input = r#"
<twig:d:Card
{{ ...attributes.defaults({
as: "a",
class: "cursor-pointer bg-base-200 card-sm md:card-md lg:card-lg",
role: "button",
tabindex: "0",
"data-action": "live#$render",
"data-loading": "addAttribute(disabled)",
"data-poll": "delay(60000)|$render",
"data-loading": "addClass(border border-red-500 opacity-75)",
})
}}
>
"#;
let expected = r#"
<twig:d:Card
{{ ...attributes.defaults({
as: "a",
class: "cursor-pointer bg-base-200 card-sm md:card-md lg:card-lg",
role: "button",
tabindex: "0",
"data-action": "live#$render",
"data-loading": "addAttribute(disabled)",
"data-poll": "delay(60000)|$render",
"data-loading": "addClass border border-red-500 opacity-75 ",
})
}}
>
"#;
Twig::test(input, expected);
Twig::test_extract_contains(
input,
vec![
// class
"cursor-pointer",
"bg-base-200",
"card-sm",
"md:card-md",
"lg:card-lg",
// data-loading
"border",
"border-red-500",
"opacity-75",
],
);
}
}

View file

@ -15,15 +15,13 @@ impl PreProcessor for Vue {
fn process(&self, content: &[u8]) -> Vec<u8> {
let mut result = content.to_vec();
// Only process template tags if content is valid UTF-8
if let Ok(content_as_str) = std::str::from_utf8(content) {
for (_, [lang, body]) in TEMPLATE_REGEX
.captures_iter(content_as_str)
.map(|c| c.extract())
{
let replaced = pre_process_input(body.as_bytes().to_vec(), lang);
result = result.replace(body, replaced);
}
let content_as_str = std::str::from_utf8(content).unwrap();
for (_, [lang, body]) in TEMPLATE_REGEX
.captures_iter(content_as_str)
.map(|c| c.extract())
{
let replaced = pre_process_input(body.as_bytes(), lang);
result = result.replace(body, replaced);
}
result
@ -45,16 +43,4 @@ mod tests {
Vue::test_extract_contains(input, vec!["bg-neutral-900", "text-red-500"]);
}
#[test]
fn test_invalid_utf8_does_not_panic() {
// Invalid UTF-8 sequence: 0x80 is a continuation byte without a leading byte
let invalid_utf8: &[u8] = &[0x80, 0x81, 0x82];
let processor = Vue::default();
// Should not panic, just return the input unchanged
let result = processor.process(invalid_utf8);
assert_eq!(result, invalid_utf8);
}
}

View file

@ -30,20 +30,20 @@ impl Machine for StringMachine {
#[inline]
fn next(&mut self, cursor: &mut cursor::Cursor<'_>) -> MachineState {
if Class::Quote != cursor.curr().into() {
if Class::Quote != cursor.curr.into() {
return MachineState::Idle;
}
// Start of a string
let len = cursor.input.len();
let start_pos = cursor.pos;
let end_char = cursor.curr();
let end_char = cursor.curr;
cursor.advance();
while cursor.pos < len {
match cursor.curr().into() {
Class::Escape => match cursor.next().into() {
match cursor.curr.into() {
Class::Escape => match cursor.next.into() {
// An escaped whitespace character is not allowed
Class::Whitespace => return MachineState::Idle,
@ -52,7 +52,7 @@ impl Machine for StringMachine {
},
// End of the string
Class::Quote if cursor.curr() == end_char => return self.done(start_pos, cursor),
Class::Quote if cursor.curr == end_char => return self.done(start_pos, cursor),
// Any kind of whitespace is not allowed
Class::Whitespace => return MachineState::Idle,

View file

@ -27,12 +27,12 @@ impl Machine for UtilityMachine {
#[inline]
fn next(&mut self, cursor: &mut cursor::Cursor<'_>) -> MachineState {
match cursor.curr().into() {
match cursor.curr.into() {
// LEGACY: Important marker
Class::Exclamation => {
self.legacy_important = true;
match cursor.next().into() {
match cursor.next.into() {
// Start of an arbitrary property
//
// E.g.: `![color:red]`
@ -78,7 +78,7 @@ impl UtilityMachine {
fn parse_arbitrary_property(&mut self, cursor: &mut cursor::Cursor<'_>) -> MachineState {
match self.arbitrary_property_machine.next(cursor) {
MachineState::Idle => self.restart(),
MachineState::Done(_) => match cursor.next().into() {
MachineState::Done(_) => match cursor.next.into() {
// End of arbitrary property, but there is a potential modifier.
//
// E.g.: `[color:#0088cc]/`
@ -109,7 +109,7 @@ impl UtilityMachine {
fn parse_named_utility(&mut self, cursor: &mut cursor::Cursor<'_>) -> MachineState {
match self.named_utility_machine.next(cursor) {
MachineState::Idle => self.restart(),
MachineState::Done(_) => match cursor.next().into() {
MachineState::Done(_) => match cursor.next.into() {
// End of a named utility, but there is a potential modifier.
//
// E.g.: `bg-red-500/`
@ -140,7 +140,7 @@ impl UtilityMachine {
fn parse_modifier(&mut self, cursor: &mut cursor::Cursor<'_>) -> MachineState {
match self.modifier_machine.next(cursor) {
MachineState::Idle => self.restart(),
MachineState::Done(_) => match cursor.next().into() {
MachineState::Done(_) => match cursor.next.into() {
// A modifier followed by a modifier is invalid
Class::Slash => self.restart(),

View file

@ -16,7 +16,7 @@ impl Machine for VariantMachine {
#[inline]
fn next(&mut self, cursor: &mut cursor::Cursor<'_>) -> MachineState {
match cursor.curr().into() {
match cursor.curr.into() {
// Start of an arbitrary variant
//
// E.g.: `[&:hover]:`
@ -48,7 +48,7 @@ impl VariantMachine {
start_pos: usize,
cursor: &mut cursor::Cursor<'_>,
) -> MachineState {
match cursor.next().into() {
match cursor.next.into() {
// End of an arbitrary value, must be followed by a `:`
//
// E.g.: `[&:hover]:`

View file

@ -10,7 +10,7 @@ pub fn fast_skip(cursor: &Cursor) -> Option<usize> {
return None;
}
if !cursor.curr().is_ascii_whitespace() {
if !cursor.curr.is_ascii_whitespace() {
return None;
}

View file

@ -23,7 +23,7 @@ pub fn hoist_static_glob_parts(entries: &Vec<GlobEntry>, emit_parent_glob: bool)
None => base,
};
let base = match dunce::canonicalize(&base) {
let base = match fsops::canonicalize(&base) {
Ok(base) => base,
Err(err) => {
event!(tracing::Level::ERROR, "Failed to resolve glob: {:?}", err);
@ -253,7 +253,7 @@ mod tests {
let optimized_sources = optimize_patterns(&sources);
let parent_dir =
format!("{}{}", dunce::canonicalize(base).unwrap().display(), "/").replace('\\', "/");
format!("{}{}", fsops::canonicalize(base).unwrap().display(), "/").replace('\\', "/");
// Remove the temporary directory from the base
optimized_sources

View file

@ -60,6 +60,6 @@ impl Path {
}
pub fn canonicalize(&self) -> io::Result<Self> {
Ok(dunce::canonicalize(&self.inner)?.into())
Ok(fsops::canonicalize(&self.inner)?.into())
}
}

View file

@ -30,9 +30,11 @@ fn sort_by_dir_and_name(a: &DirEntry, z: &DirEntry) -> Ordering {
pub fn resolve_globs(
base: PathBuf,
dirs: &FxHashSet<PathBuf>,
dirs: &[PathBuf],
extensions: &FxHashSet<String>,
) -> Vec<GlobEntry> {
let allowed_paths: FxHashSet<PathBuf> = FxHashSet::from_iter(dirs.iter().cloned());
// A list of known extensions + a list of extensions we found in the project.
let mut found_extensions: FxHashSet<String> =
FxHashSet::from_iter(KNOWN_EXTENSIONS.iter().map(|x| x.to_string()));
@ -70,7 +72,7 @@ pub fn resolve_globs(
continue;
}
if !dirs.contains(path) {
if !allowed_paths.contains(path) {
let mut path = path;
while let Some(parent) = path.parent() {
if parent == base {
@ -111,8 +113,7 @@ pub fn resolve_globs(
continue;
}
if !dirs.contains(path) {
it.skip_current_dir();
if !allowed_paths.contains(path) {
continue;
}

View file

@ -1,15 +1,14 @@
.git
.hg
.jj
.next
.parcel-cache
.pnpm-store
.svelte-kit
.svn
.turbo
.venv
.vercel
.yarn
__pycache__
node_modules
.yarn
.venv
venv
.next
.turbo
.parcel-cache
__pycache__
.svelte-kit
.pnpm-store
.vercel

View file

@ -2,5 +2,3 @@ package-lock.json
pnpm-lock.yaml
bun.lockb
.gitignore
.env
.env.*

View file

@ -1,102 +0,0 @@
use std::fs::OpenOptions;
use std::io::{self, Write};
use std::path::Path;
use std::sync::{self, Arc, Mutex};
use tracing_subscriber::fmt::writer::BoxMakeWriter;
pub static SHOULD_TRACE: sync::LazyLock<bool> = sync::LazyLock::new(
|| matches!(std::env::var("DEBUG"), Ok(value) if value.eq("*") || (value.contains("tailwindcss:oxide") && !value.contains("-tailwindcss:oxide"))),
);
fn dim(input: &str) -> String {
format!("\u{001b}[2m{input}\u{001b}[22m")
}
fn blue(input: &str) -> String {
format!("\u{001b}[34m{input}\u{001b}[39m")
}
fn highlight(input: &str) -> String {
format!("{}{}{}", dim(&blue("`")), blue(input), dim(&blue("`")))
}
struct MutexWriter(Arc<Mutex<std::fs::File>>);
impl Write for MutexWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.0.lock().unwrap().flush()
}
}
pub fn init_tracing() {
if !*SHOULD_TRACE {
return;
}
let root = Path::new(".tailwindcss");
let logs_dir = root.join("logs");
if let Err(err) = std::fs::create_dir_all(&logs_dir) {
eprintln!(
"{} Failed to create {}, skipping debug logs ({err})",
dim("[DEBUG]"),
highlight(&logs_dir.display().to_string())
);
return;
}
// Ensure everything inside `.tailwindcss/` is ignored by git. The file is only created if it
// doesn't exist yet, an existing `.gitignore` is left untouched.
if let Ok(mut file) = std::fs::File::create_new(root.join(".gitignore")) {
_ = file.write_all(b"*\n");
}
let file_path = logs_dir.join(format!(
"scanner-{}-{}.log",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0),
std::process::id()
));
let file = match OpenOptions::new()
.create(true)
.append(true)
.open(&file_path)
{
Ok(file) => file,
Err(err) => {
eprintln!(
"{} Failed to create {}, skipping debug logs ({err})",
dim("[DEBUG]"),
highlight(&file_path.display().to_string())
);
return;
}
};
let absolute_file_path = dunce::canonicalize(&file_path).unwrap_or_else(|_| file_path.clone());
eprintln!(
"{} Writing debug info to: {}\n",
dim("[DEBUG]"),
highlight(&absolute_file_path.display().to_string())
);
let file = Arc::new(Mutex::new(file));
let writer: BoxMakeWriter = BoxMakeWriter::new({
let file = file.clone();
move || Box::new(MutexWriter(file.clone())) as Box<dyn Write + Send>
});
_ = tracing_subscriber::fmt()
.with_max_level(tracing::Level::INFO)
.with_span_events(tracing_subscriber::fmt::format::FmtSpan::ACTIVE)
.with_writer(writer)
.with_ansi(false)
.compact()
.try_init();
}

View file

@ -1,6 +1,5 @@
pub mod auto_source_detection;
pub mod detect_sources;
pub mod init_tracing;
pub mod sources;
use crate::extractor::{Extracted, Extractor};
@ -15,12 +14,15 @@ use bstr::ByteSlice;
use fast_glob::glob_match;
use fxhash::{FxHashMap, FxHashSet};
use ignore::{gitignore::GitignoreBuilder, WalkBuilder};
use init_tracing::{init_tracing, SHOULD_TRACE};
use rayon::prelude::*;
use std::collections::{BTreeMap, BTreeSet};
use std::fs::OpenOptions;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::sync::{self, Arc, Mutex};
use std::time::SystemTime;
use tracing::event;
use tracing_subscriber::fmt::writer::BoxMakeWriter;
// @source "some/folder"; // This is auto source detection
// @source "some/folder/**/*"; // This is auto source detection
@ -32,6 +34,70 @@ use tracing::event;
//
// @source "do-include-me.bin"; // `.bin` is typically ignored, but now it's explicit so should be included
// @source "git-ignored.html"; // A git ignored file that is listed explicitly, should be scanned
static SHOULD_TRACE: sync::LazyLock<bool> = sync::LazyLock::new(
|| matches!(std::env::var("DEBUG"), Ok(value) if value.eq("*") || (value.contains("tailwindcss:oxide") && !value.contains("-tailwindcss:oxide"))),
);
fn dim(input: &str) -> String {
format!("\u{001b}[2m{input}\u{001b}[22m")
}
fn blue(input: &str) -> String {
format!("\u{001b}[34m{input}\u{001b}[39m")
}
fn highlight(input: &str) -> String {
format!("{}{}{}", dim(&blue("`")), blue(input), dim(&blue("`")))
}
fn init_tracing() {
if !*SHOULD_TRACE {
return;
}
let file_path = format!("tailwindcss-{}.log", std::process::id());
let file = OpenOptions::new()
.create(true)
.append(true)
.open(&file_path)
.unwrap_or_else(|_| panic!("Failed to open {file_path}"));
let file_path = Path::new(&file_path);
let absolute_file_path = fsops::canonicalize(file_path)
.unwrap_or_else(|_| panic!("Failed to canonicalize {file_path:?}"));
eprintln!(
"{} Writing debug info to: {}\n",
dim("[DEBUG]"),
highlight(absolute_file_path.as_path().to_str().unwrap())
);
let file = Arc::new(Mutex::new(file));
let writer: BoxMakeWriter = BoxMakeWriter::new({
let file = file.clone();
move || Box::new(MutexWriter(file.clone())) as Box<dyn Write + Send>
});
_ = tracing_subscriber::fmt()
.with_max_level(tracing::Level::INFO)
.with_span_events(tracing_subscriber::fmt::format::FmtSpan::ACTIVE)
.with_writer(writer)
.with_ansi(false)
.compact()
.try_init();
}
struct MutexWriter(Arc<Mutex<std::fs::File>>);
impl Write for MutexWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.lock().unwrap().write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.0.lock().unwrap().flush()
}
}
#[derive(Debug, Clone)]
pub enum ChangedContent {
@ -63,33 +129,26 @@ pub struct Scanner {
/// The walker to detect all files that we have to scan
walker: Option<WalkBuilder>,
/// All changed content that we have to parse
changed_content: Vec<ChangedContent>,
/// All found extensions
extensions: FxHashSet<String>,
/// All CSS files we want to scan for CSS variable usage
css_files: Vec<PathBuf>,
/// All files that we have to scan
files: FxHashSet<PathBuf>,
files: Vec<PathBuf>,
/// All directories, sub-directories, etc… we saw during source detection
dirs: FxHashSet<PathBuf>,
dirs: Vec<PathBuf>,
/// All generated globs, used for setting up watchers
globs: Option<Vec<GlobEntry>>,
/// Track unique set of candidates
candidates: FxHashSet<String>,
/// Track mtimes for files so incremental scans can skip unchanged files.
mtimes: FxHashMap<PathBuf, SystemTime>,
/// Files that were scanned during the last `scan()` call.
scanned_files: Vec<String>,
/// Whether we've completed at least one full scan. When false, we skip
/// mtime tracking entirely so the initial build stays fast.
has_scanned_once: bool,
/// Whether sources have been scanned since the last `scan()` call
sources_scanned: bool,
}
impl Scanner {
@ -104,6 +163,7 @@ impl Scanner {
}
let sources = Sources::new(public_source_entries_to_private_source_entries(sources));
if *SHOULD_TRACE {
event!(tracing::Level::INFO, "Optimized sources:");
for source in sources.iter() {
@ -111,27 +171,29 @@ impl Scanner {
}
}
let walker = create_walker(&sources);
Self {
sources,
walker,
sources: sources.clone(),
walker: create_walker(sources),
..Default::default()
}
}
pub fn scan(&mut self) -> Vec<String> {
self.sources_scanned = false;
self.scan_sources();
let (scanned_blobs, css_files, files) = self.discover_sources();
// TODO: performance improvement, bail early if we don't have any changed content
// if self.changed_content.is_empty() {
// return vec![];
// }
self.extract_candidates(scanned_blobs, css_files);
self.scanned_files = files;
let _new_candidates = self.extract_candidates();
// Return all candidates sorted
let mut result = self.candidates.iter().cloned().collect::<Vec<_>>();
result.par_sort_unstable();
result
// Make sure we have a sorted list of candidates
let mut candidates = self.candidates.iter().cloned().collect::<Vec<_>>();
candidates.par_sort_unstable();
// Return all candidates instead of only the new ones
candidates
}
#[tracing::instrument(skip_all)]
@ -146,14 +208,14 @@ impl Scanner {
// Raw content can be parsed directly, no need to verify if the file exists and is allowed
// to be scanned.
let mut content_to_scan: Vec<ChangedContent> = changed_contents;
self.changed_content.extend(changed_contents);
// Fully resolve all files
let changed_files = changed_files
.into_iter()
.filter_map(|changed_content| match changed_content {
ChangedContent::File(file, extension) => {
let Ok(file) = dunce::canonicalize(file) else {
let Ok(file) = fsops::canonicalize(file) else {
return None;
};
Some(ChangedContent::File(file, extension))
@ -170,7 +232,7 @@ impl Scanner {
});
// All known files are allowed to be scanned
content_to_scan.extend(known_files);
self.changed_content.extend(known_files);
// Figure out if the new unknown files are allowed to be scanned
if !new_unknown_files.is_empty() {
@ -181,11 +243,6 @@ impl Scanner {
continue;
}
// The walked path can contain symlinks, while the changed files have already
// been canonicalized. Lazily canonicalize the walked path so we can compare
// the real paths as well.
let mut canonical_path: Option<PathBuf> = None;
let mut drop_file_indexes = vec![];
for (idx, changed_file) in new_unknown_files.iter().enumerate().rev() {
let ChangedContent::File(file, _) = changed_file else {
@ -194,17 +251,9 @@ impl Scanner {
// When the file is found on disk it means that all the rules pass. We can
// extract the current file and remove it from the list of passed in files.
let matches = file == path
|| (file.file_name() == path.file_name() && {
if canonical_path.is_none() {
canonical_path = dunce::canonicalize(path).ok();
}
canonical_path.as_deref() == Some(file.as_path())
});
if matches {
self.files.insert(path.to_path_buf()); // Track for future use
content_to_scan.push(changed_file.clone()); // Track for parsing
if file == path {
self.files.push(path.to_path_buf()); // Track for future use
self.changed_content.push(changed_file.clone()); // Track for parsing
drop_file_indexes.push(idx);
}
}
@ -225,17 +274,18 @@ impl Scanner {
}
}
// Read all content into blobs for extraction
let blobs = read_all_files(content_to_scan);
self.extract_candidates(blobs, vec![])
self.extract_candidates()
}
#[tracing::instrument(skip_all)]
fn extract_candidates(&mut self, blobs: Vec<Vec<u8>>, css_files: Vec<PathBuf>) -> Vec<String> {
// Extract all candidates from the pre-read blobs
let mut new_candidates = parse_all_blobs(blobs);
fn extract_candidates(&mut self) -> Vec<String> {
let changed_content = self.changed_content.drain(..).collect::<Vec<_>>();
// Extract all candidates from the changed content
let mut new_candidates = parse_all_blobs(read_all_files(changed_content));
// Extract all CSS variables from the CSS files
let css_files = self.css_files.drain(..).collect::<Vec<_>>();
if !css_files.is_empty() {
let css_variables = extract_css_variables(read_all_files(
css_files
@ -247,23 +297,63 @@ impl Scanner {
new_candidates.extend(css_variables);
}
// Only keep candidates we haven't seen before
for existing in self.candidates.iter() {
new_candidates.remove(existing);
}
// Only compute the new candidates and ignore the ones we already have. This is for
// subsequent calls to prevent serializing the entire set of candidates every time.
let mut new_candidates = new_candidates
.into_par_iter()
.filter(|candidate| !self.candidates.contains(candidate))
.collect::<Vec<_>>();
new_candidates.par_sort_unstable();
// Track new candidates for subsequent calls
self.candidates.extend(new_candidates.iter().cloned());
self.candidates.par_extend(new_candidates.clone());
let mut result: Vec<String> = new_candidates.into_iter().collect();
result.par_sort_unstable();
new_candidates
}
result
#[tracing::instrument(skip_all)]
fn scan_sources(&mut self) {
let Some(walker) = &mut self.walker else {
return;
};
for entry in walker.build().filter_map(Result::ok) {
let path = entry.into_path();
let Ok(metadata) = path.metadata() else {
continue;
};
if metadata.is_dir() {
self.dirs.push(path);
} else if metadata.is_file() {
let extension = path
.extension()
.and_then(|x| x.to_str())
.unwrap_or_default(); // In case the file has no extension
match extension {
// Special handing for CSS files, we don't want to extract candidates from
// these files, but we do want to extract used CSS variables.
"css" => {
self.css_files.push(path.clone());
}
_ => {
self.changed_content.push(ChangedContent::File(
path.to_path_buf(),
extension.to_owned(),
));
}
}
self.extensions.insert(extension.to_owned());
self.files.push(path);
}
}
}
#[tracing::instrument(skip_all)]
pub fn get_files(&mut self) -> Vec<String> {
let _ = self.discover_sources();
self.scan_sources();
self.files
.par_iter()
@ -271,18 +361,13 @@ impl Scanner {
.collect()
}
#[tracing::instrument(skip_all)]
pub fn get_scanned_files(&self) -> Vec<String> {
self.scanned_files.clone()
}
#[tracing::instrument(skip_all)]
pub fn get_globs(&mut self) -> Vec<GlobEntry> {
if let Some(globs) = &self.globs {
return globs.clone();
}
let _ = self.discover_sources();
self.scan_sources();
let mut globs = vec![];
for source in self.sources.iter() {
@ -356,167 +441,21 @@ impl Scanner {
let i = s.as_ptr() as usize - offset;
let original = &original_content[i..i + s.len()];
if original.contains_str("-[]") {
return String::from_utf8(original.to_vec())
.ok()
.map(|candidate| (candidate, i));
return Some(unsafe {
(String::from_utf8_unchecked(original.to_vec()), i)
});
}
String::from_utf8(s.to_vec())
.ok()
.map(|candidate| (candidate, i))
// SAFETY: When we parsed the candidates, we already guaranteed that the byte
// slices are valid, therefore we don't have to re-check here when we want to
// convert it back to a string.
Some(unsafe { (String::from_utf8_unchecked(s.to_vec()), i) })
}
_ => None,
})
.collect()
}
#[tracing::instrument(skip_all)]
fn discover_sources(&mut self) -> (Vec<Vec<u8>>, Vec<PathBuf>, Vec<String>) {
if self.sources_scanned {
return (vec![], vec![], vec![]);
}
self.sources_scanned = true;
let Some(walker) = &mut self.walker else {
return (vec![], vec![], vec![]);
};
// Use synchronous walk for the initial build (lower overhead) and parallel
// walk for subsequent calls (watch mode) where the overhead is amortised.
let all_entries = if self.has_scanned_once {
walk_parallel(walker)
} else {
walk_synchronous(walker)
};
let mut css_files: Vec<PathBuf> = vec![];
let mut content_paths: Vec<(PathBuf, String)> = vec![];
let mut changed_files = vec![];
// Fresh state
self.files.clear();
self.dirs.clear();
self.extensions.clear();
self.globs = None;
// Cache canonicalized folders in case a file itself is not symlinked, but any of the parent
// folders are symlinked.
let mut cached_canonical_dirs: FxHashMap<PathBuf, PathBuf> = FxHashMap::default();
for entry in all_entries {
match entry {
WalkEntry::Dir(path) => {
self.dirs.insert(path);
}
WalkEntry::File {
path,
mtime,
is_symlink,
} => {
// Deduplicate: parallel walk can visit the same file from multiple threads
if !self.files.insert(path.clone()) {
continue;
}
// Track canonicalized paths in addition to potentially symlinked file paths
let canonical = if is_symlink {
dunce::canonicalize(&path).ok()
} else {
path.parent().and_then(|parent| {
// Perf: cache the canonicalized parent path such that sibling files don't
// have to canonicalize over and over again.
let canonical_parent = cached_canonical_dirs
.entry(parent.to_path_buf())
.or_insert_with(|| {
dunce::canonicalize(parent)
.unwrap_or_else(|_| parent.to_path_buf())
});
if canonical_parent.as_path() != parent {
path.file_name()
.map(|file_name| canonical_parent.join(file_name))
} else {
None
}
})
};
if let Some(canonical) = canonical {
if canonical != path {
self.files.insert(canonical);
}
}
let extension = path
.extension()
.and_then(|x| x.to_str())
.unwrap_or_default()
.to_owned();
self.extensions.insert(extension.to_owned());
// On incremental scans, check mtime to skip unchanged files.
// On the first scan, track mtimes while still scanning every file.
let changed = if self.has_scanned_once {
match mtime {
Some(mtime) => {
let prev = self.mtimes.insert(path.clone(), mtime);
prev.is_none_or(|prev| prev != mtime)
}
None => true,
}
} else {
if let Some(mtime) = mtime {
self.mtimes.insert(path.clone(), mtime);
}
true
};
if !changed {
continue;
}
if let Ok(file) = path.clone().into_os_string().into_string() {
changed_files.push(file);
}
match extension.as_str() {
// Special handing for CSS files, we don't want to extract candidates from
// these files, but we do want to extract used CSS variables.
"css" => css_files.push(path),
_ => content_paths.push((path, extension)),
}
}
}
}
// Ensure `mtimes` don't include stale files
self.mtimes.retain(|path, _| self.files.contains(path));
// Read + preprocess all discovered files in parallel
let scanned_blobs: Vec<Vec<u8>> = content_paths
.into_par_iter()
.filter_map(|(path, ext)| {
let content = std::fs::read(&path).ok()?;
event!(tracing::Level::INFO, "Reading {:?}", path);
let processed = pre_process_input(content, &ext);
if processed.is_empty() {
None
} else {
Some(processed)
}
})
.collect();
if !self.has_scanned_once {
self.has_scanned_once = true;
}
changed_files.par_sort_unstable();
(scanned_blobs, css_files, changed_files)
}
}
fn read_changed_content(c: ChangedContent) -> Option<Vec<u8>> {
@ -535,28 +474,26 @@ fn read_changed_content(c: ChangedContent) -> Option<Vec<u8>> {
ChangedContent::Content(contents, extension) => (contents.into_bytes(), extension),
};
Some(pre_process_input(content, &extension))
Some(pre_process_input(&content, &extension))
}
pub fn pre_process_input(content: Vec<u8>, extension: &str) -> Vec<u8> {
pub fn pre_process_input(content: &[u8], extension: &str) -> Vec<u8> {
use crate::extractor::pre_processors::*;
match extension {
"clj" | "cljs" | "cljc" => Clojure.process(&content),
"heex" | "eex" | "ex" | "exs" => Elixir.process(&content),
"cshtml" | "razor" => Razor.process(&content),
"haml" => Haml.process(&content),
"json" | "jsonl" | "ndjson" => Json.process(&content),
"md" | "mdx" => Markdown.process(&content),
"pug" => Pug.process(&content),
"rb" | "erb" => Ruby.process(&content),
"slim" | "slang" => Slim.process(&content),
"svelte" => Svelte.process(&content),
"rs" => Rust.process(&content),
"tt" | "tt2" | "tx" => TemplateToolkit.process(&content),
"twig" => Twig.process(&content),
"vue" => Vue.process(&content),
_ => content,
"clj" | "cljs" | "cljc" => Clojure.process(content),
"heex" | "eex" | "ex" | "exs" => Elixir.process(content),
"cshtml" | "razor" => Razor.process(content),
"haml" => Haml.process(content),
"json" => Json.process(content),
"md" | "mdx" => Markdown.process(content),
"pug" => Pug.process(content),
"rb" | "erb" => Ruby.process(content),
"slim" | "slang" => Slim.process(content),
"svelte" => Svelte.process(content),
"rs" => Rust.process(content),
"vue" => Vue.process(content),
_ => content.to_vec(),
}
}
@ -575,23 +512,23 @@ fn read_all_files(changed_content: Vec<ChangedContent>) -> Vec<Vec<u8>> {
}
#[tracing::instrument(skip_all)]
fn extract_css_variables(blobs: Vec<Vec<u8>>) -> FxHashSet<String> {
fn extract_css_variables(blobs: Vec<Vec<u8>>) -> Vec<String> {
extract(blobs, |mut extractor| {
extractor.extract_variables_from_css()
})
}
#[tracing::instrument(skip_all)]
fn parse_all_blobs(blobs: Vec<Vec<u8>>) -> FxHashSet<String> {
fn parse_all_blobs(blobs: Vec<Vec<u8>>) -> Vec<String> {
extract(blobs, |mut extractor| extractor.extract())
}
#[tracing::instrument(skip_all)]
fn extract<H>(blobs: Vec<Vec<u8>>, handle: H) -> FxHashSet<String>
fn extract<H>(blobs: Vec<Vec<u8>>, handle: H) -> Vec<String>
where
H: Fn(Extractor) -> Vec<Extracted> + std::marker::Sync,
{
blobs
let mut result: Vec<_> = blobs
.par_iter()
.flat_map(|blob| blob.par_split(|x| *x == b'\n'))
.filter_map(|blob| {
@ -616,113 +553,26 @@ where
a
})
.into_iter()
.filter_map(|s| String::from_utf8(s.to_vec()).ok())
.collect()
.map(|s| unsafe { String::from_utf8_unchecked(s.to_vec()) })
.collect();
// SAFETY: Unstable sort is faster and in this scenario it's also safe because we are
// guaranteed to have unique candidates.
result.par_sort_unstable();
result
}
#[derive(Debug)]
enum WalkEntry {
Dir(PathBuf),
File {
path: PathBuf,
mtime: Option<SystemTime>,
/// Whether the path itself is a symlink
is_symlink: bool,
},
}
impl From<ignore::DirEntry> for WalkEntry {
fn from(entry: ignore::DirEntry) -> Self {
let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
let is_symlink = entry.path_is_symlink();
let path = entry.into_path();
if is_dir {
WalkEntry::Dir(path)
} else {
let mtime = path.metadata().ok().and_then(|m| m.modified().ok());
WalkEntry::File {
path,
mtime,
is_symlink,
}
}
}
}
/// Walk the file system synchronously. Used for the initial build where the overhead of spawning
/// parallel walker threads is not worth it.
#[tracing::instrument(skip_all)]
fn walk_synchronous(walker: &mut WalkBuilder) -> Vec<WalkEntry> {
walker
.build()
.filter_map(Result::ok)
.map(WalkEntry::from)
.collect()
}
/// Walk the file system in parallel. Used in watch mode where the parallel walker overhead is
/// amortised across many rebuilds and subsequent calls are much faster.
#[tracing::instrument(skip_all)]
fn walk_parallel(walker: &mut WalkBuilder) -> Vec<WalkEntry> {
struct FlushOnDrop {
local: Vec<WalkEntry>,
shared: Arc<Mutex<Vec<WalkEntry>>>,
}
impl Drop for FlushOnDrop {
fn drop(&mut self) {
if !self.local.is_empty() {
self.shared.lock().unwrap().append(&mut self.local);
}
}
}
let collected: Arc<Mutex<Vec<WalkEntry>>> = Arc::new(Mutex::new(vec![]));
walker.build_parallel().run(|| {
let mut buf = FlushOnDrop {
local: Vec::with_capacity(256),
shared: collected.clone(),
};
Box::new(move |entry| {
let Ok(entry) = entry else {
return ignore::WalkState::Continue;
};
buf.local.push(WalkEntry::from(entry));
if buf.local.len() >= 256 {
buf.shared.lock().unwrap().append(&mut buf.local);
}
ignore::WalkState::Continue
})
});
// All threads have finished and flushed their buffers via FlushOnDrop::drop
Arc::try_unwrap(collected).unwrap().into_inner().unwrap()
}
/// Sets up a WalkBuilder with all source roots, gitignore rules, and source pattern matching.
/// Create a walker for the given sources to detect all the files that we have to scan.
///
/// This is the common setup shared between the full walker (with mtime tracking for re-scans)
/// and the parallel walker (without mtime tracking for the initial scan).
fn create_walker(sources: &Sources) -> Option<WalkBuilder> {
/// The `mtimes` map is used to keep track of the last modified time of each file. This is used to
/// determine if a file or folder has changed since the last scan and we can skip folders that
/// haven't changed.
fn create_walker(sources: Sources) -> Option<WalkBuilder> {
let mtimes: Arc<Mutex<FxHashMap<PathBuf, SystemTime>>> = Default::default();
let mut other_roots: FxHashSet<&PathBuf> = FxHashSet::default();
let mut first_root: Option<&PathBuf> = None;
let mut ignores: Vec<(&PathBuf, Vec<String>)> = Default::default();
let mut emit = |base, pattern| match ignores.last_mut() {
Some((prev_base, patterns)) if *prev_base == base => {
patterns.push(pattern);
}
_ => {
ignores.push((base, vec![pattern]));
}
};
let mut ignores: BTreeMap<&PathBuf, BTreeSet<String>> = Default::default();
for source in sources.iter() {
match source {
@ -734,7 +584,7 @@ fn create_walker(sources: &Sources) -> Option<WalkBuilder> {
}
}
SourceEntry::Pattern { base, pattern } => {
let pattern = pattern.to_owned();
let mut pattern = pattern.to_string();
if first_root.is_none() {
first_root = Some(base);
@ -743,19 +593,35 @@ fn create_walker(sources: &Sources) -> Option<WalkBuilder> {
}
if !pattern.contains("**") {
// Ensure that the pattern is pinned to the base path.
if !pattern.starts_with("/") {
pattern = format!("/{pattern}");
}
// Specific patterns should take precedence even over git-ignored files:
emit(base, format!("!{}", pattern));
ignores
.entry(base)
.or_default()
.insert(format!("!{}", pattern));
} else {
// Assumption: the pattern we receive will already be brace expanded. So
// `*.{html,jsx}` will result in two separate patterns: `*.html` and `*.jsx`.
if let Some(extension) = Path::new(&pattern).extension() {
// Extend auto source detection to include the extension
emit(base, format!("!*.{}", extension.to_string_lossy()));
ignores
.entry(base)
.or_default()
.insert(format!("!*.{}", extension.to_string_lossy()));
}
}
}
SourceEntry::Ignored { base, pattern } => {
emit(base, pattern.to_owned());
let mut pattern = pattern.to_string();
// Ensure that the pattern is pinned to the base path.
if !pattern.starts_with("/") {
pattern = format!("/{pattern}");
}
ignores.entry(base).or_default().insert(pattern);
}
SourceEntry::External { base } => {
if first_root.is_none() {
@ -765,10 +631,16 @@ fn create_walker(sources: &Sources) -> Option<WalkBuilder> {
}
// External sources should take precedence even over git-ignored files:
emit(base, "!/**/*".to_owned());
ignores
.entry(base)
.or_default()
.insert(format!("!{}", "/**/*"));
// External sources should still disallow binary extensions:
emit(base, BINARY_EXTENSIONS_GLOB.clone());
ignores
.entry(base)
.or_default()
.insert(BINARY_EXTENSIONS_GLOB.clone());
}
}
}
@ -845,61 +717,75 @@ fn create_walker(sources: &Sources) -> Option<WalkBuilder> {
builder.add_gitignore(ignore);
}
// Pre-compute source matching data to avoid allocations in the hot filter_entry path
let auto_bases: Vec<PathBuf> = sources
.iter()
.filter_map(|source| match source {
SourceEntry::Auto { base } | SourceEntry::External { base } => Some(base.clone()),
_ => None,
})
.collect();
builder.filter_entry({
move |entry| {
let path = entry.path();
let pattern_sources: Vec<(PathBuf, String)> = sources
.iter()
.filter_map(|source| match source {
SourceEntry::Pattern { base, pattern } => Some((base.into(), pattern.into())),
_ => None,
})
.collect();
// Source pattern matching filter (lock-free, safe for parallel walking)
builder.filter_entry(move |entry| {
let path = entry.path();
// Ensure the entries are matching any of the provided source patterns (this is
// necessary for manual-patterns that can filter the file extension)
if path.is_file() {
let mut matches = false;
for base in &auto_bases {
if path.starts_with(base) {
matches = true;
break;
}
}
if !matches {
for (base, pattern) in &pattern_sources {
let remainder = path.strip_prefix(base);
if remainder.is_ok_and(|remainder| {
let mut path_str = remainder.to_string_lossy().to_string();
if !path_str.starts_with("/") {
path_str = format!("/{path_str}");
// Ensure the entries are matching any of the provided source patterns (this is
// necessary for manual-patterns that can filter the file extension)
if path.is_file() {
let mut matches = false;
for source in sources.iter() {
match source {
SourceEntry::Auto { base } | SourceEntry::External { base } => {
if path.starts_with(base) {
matches = true;
break;
}
}
glob_match(pattern, path_str.as_bytes())
}) {
matches = true;
break;
SourceEntry::Pattern { base, pattern } => {
let mut pattern = pattern.to_string();
// Ensure that the pattern is pinned to the base path.
if !pattern.starts_with("/") {
pattern = format!("/{pattern}");
}
// Check if path starts with base, if so, remove the prefix and check the remainder against the pattern
let remainder = path.strip_prefix(base);
if remainder.is_ok_and(|remainder| {
let mut path_str = remainder.to_string_lossy().to_string();
if !path_str.starts_with("/") {
path_str = format!("/{path_str}");
}
glob_match(pattern, path_str.as_bytes())
}) {
matches = true;
break;
}
}
_ => {}
}
}
if !matches {
return false;
}
}
if !matches {
return false;
let mut mtimes = mtimes.lock().unwrap();
let current_time = match entry.metadata() {
Ok(metadata) if metadata.is_file() => {
if let Ok(time) = metadata.modified() {
Some(time)
} else {
None
}
}
_ => None,
};
let previous_time =
current_time.and_then(|time| mtimes.insert(entry.clone().into_path(), time));
match (current_time, previous_time) {
(Some(current), Some(prev)) if prev == current => false,
_ => {
event!(tracing::Level::INFO, "Discovering {:?}", path);
true
}
}
}
true
});
Some(builder)

View file

@ -1,8 +1,7 @@
use crate::glob::split_pattern;
use crate::GlobEntry;
use bexpand::Expression;
use fxhash::{FxHashMap, FxHashSet};
use ignore::gitignore::Gitignore;
use std::path::{Component, Path, PathBuf};
use std::path::PathBuf;
use tracing::{event, Level};
use super::auto_source_detection::IGNORED_CONTENT_DIRS;
@ -50,14 +49,13 @@ pub enum SourceEntry {
/// ```
Ignored { base: PathBuf, pattern: String },
/// External sources are directories that are ignored (by us or .gitignore rules), but should be
/// included bypassing the default ignore rules.
/// External sources are sources outside of your git root which should not
/// follow gitignore rules.
///
/// Represented by:
///
/// ```css
/// @source "../node_modules/my-lib";`
/// @source "../node_modules/my-lib/**/*";`
/// ```
External { base: PathBuf },
}
@ -77,164 +75,6 @@ impl Sources {
}
}
/// When dealing with a pattern, then it could be that we end up with:
///
/// ```json
/// { base: '/some/folder', pattern: 'foo.ts' }
/// ```
///
/// If we just emit `!foo.ts` for the `/some/folder` path, then _everything_ else in that folder
/// would still be walked (but the result will be ignored).
///
/// Instead, we have to ensure that we ignore everything in that folder _except_ for the `foo.ts`
/// pattern.
///
/// This should be equivalent to:
/// ```gitignore
/// *
/// !foo.ts
/// ```
///
/// However, we have to be careful that we don't start ignoring files/folders that already exist.
/// ```css
/// @source "./some/folder/foo.ts";
/// @source "./some/folder/bar.ts";
/// ```
/// Would result in:
/// ```json
/// { base: '/some/folder', pattern: 'foo.ts' }
/// { base: '/some/folder', pattern: 'bar.ts' }
/// ```
///
/// If we were to blindly emit `*` for each pattern, then the `.gitignore` equivalent would look like
/// this:
/// ```gitignore
/// *
/// !foo.ts
/// *
/// !bar.ts
/// ```
///
/// This would result in ignoring the `foo.ts` file as well. Therefore we only want to insert
/// this `*` pattern when nothing else exists yet.
///
/// There is another problem that we need to solve. Let's say you have a pattern that contains a `*`
/// in the pattern:
/// ```css
/// @source './src/ba*/*.html';
/// ```
///
/// This would result in
/// ```json
/// { base: '/src', pattern: '/ba*/*.html' }
/// ```
///
/// If we now inject the `*` pattern for the `/src` folder, then we wouldn't scan any `ba*` folders
/// (e.g. `bar` or `baz`). For this, we have to make sure that we add inverse patterns for these
/// folders. This would essentially result in:
/// ```gitignore
/// * ← ignore everything
/// !/ba*/ ← except for the `ba*/` pattern, so we scan these folders
/// !/ba*/*.html ← then ensure we scan the `*.html` files in it as well
/// ```
///
fn expand_restricted_patterns(sources: Vec<SourceEntry>) -> Vec<SourceEntry> {
let unrestricted_roots = sources
.iter()
.filter_map(|source| match source {
SourceEntry::Auto { base } | SourceEntry::External { base } => Some(base.clone()),
SourceEntry::Pattern { base, pattern } if pattern.contains("**") => Some(base.clone()),
_ => None,
})
.collect::<Vec<_>>();
// Bases of restricted patterns. Each of these becomes its own walk root with its own
// `*` + `!<pattern>` rules, so an ancestor base must not ignore them recursively.
let pattern_roots = sources
.iter()
.filter_map(|source| match source {
SourceEntry::Pattern { base, .. } => Some(base.clone()),
_ => None,
})
.collect::<Vec<_>>();
let mut restricted_roots: FxHashSet<PathBuf> = FxHashSet::default();
let mut expanded = vec![];
for source in sources {
let SourceEntry::Pattern { base, pattern } = &source else {
expanded.push(source);
continue;
};
// `base` is already included by another `@source` that we know should be walked. This
// includes the case where `base` is _nested_ inside such a root, because everything under
// an unrestricted root is walked already. Restricting it would incorrectly hide siblings
// that the broader source is supposed to pick up.
if unrestricted_roots.iter().any(|root| base.starts_with(root)) {
expanded.push(source);
continue;
}
// Ignore everything in the directory. We will later add the specific patterns we are
// interested in.
if restricted_roots.insert(base.clone()) {
// When another source root is nested inside this base — an unrestricted root, or the
// base of another restricted pattern (which is walked from its own root with its own
// rules) — only ignore direct children so the nested root can still be walked.
let has_nested_root = unrestricted_roots.iter().any(|root| root.starts_with(base))
|| pattern_roots
.iter()
.any(|root| root != base && root.starts_with(base));
let pattern = if has_nested_root { "/*" } else { "*" };
expanded.push(SourceEntry::Ignored {
base: base.clone(),
pattern: pattern.to_owned(),
});
}
// Ensure to _include_ parent paths, otherwise the `*` from above would block walking the
// folders that need to be walked.
//
// ```css
// @source './src/ba*/*.html';
// ```
//
// ```gitignore
// * ← added by the above rule
// !/ba*/ ← this is what we're focusing on in this block
// !/ba*/*.html ← this is added later
// ```
{
let mut dir = PathBuf::new();
let mut components = Path::new(pattern).components().peekable();
while let Some(component) = components.next() {
if components.peek().is_none() {
break;
}
match component {
Component::Prefix(_) | Component::RootDir | Component::CurDir => continue,
Component::ParentDir | Component::Normal(_) => dir.push(component),
}
expanded.push(SourceEntry::Ignored {
base: base.clone(),
pattern: format!("!/{}/", path_to_posix_string(&dir).trim_start_matches('/')),
});
}
}
// Track the original source
expanded.push(source);
}
expanded
}
impl PublicSourceEntry {
/// Optimize the PublicSourceEntry by trying to move all the static parts of the pattern to the
/// base of the PublicSourceEntry.
@ -262,456 +102,72 @@ impl PublicSourceEntry {
/// resolved path.
pub fn optimize(&mut self) {
// Resolve base path immediately
let Ok(mut base) = dunce::canonicalize(&self.base) else {
let Ok(base) = fsops::canonicalize(&self.base) else {
event!(Level::ERROR, "Failed to resolve base: {:?}", self.base);
return;
};
self.base = base.to_string_lossy().to_string();
let mut new_pattern = PathBuf::new();
enum ComponentStage {
Base,
Pattern,
// No dynamic part, figure out if we are dealing with a file or a directory.
if !self.pattern.contains('*') {
let combined_path = if self.pattern.starts_with("/") {
PathBuf::from(&self.pattern)
} else {
PathBuf::from(&self.base).join(&self.pattern)
};
match fsops::canonicalize(combined_path) {
Ok(resolved_path) if resolved_path.is_dir() => {
self.base = resolved_path.to_string_lossy().to_string();
self.pattern = "**/*".to_owned();
}
Ok(resolved_path) if resolved_path.is_file() => {
self.base = resolved_path
.parent()
.unwrap()
.to_string_lossy()
.to_string();
// Ensure leading slash, otherwise it will match against all files in all folders/
self.pattern =
format!("/{}", resolved_path.file_name().unwrap().to_string_lossy());
}
_ => {}
}
return;
}
let mut stage = ComponentStage::Base;
let mut components = Path::new(&self.pattern).components().peekable();
while let Some(component) = components.next() {
match stage {
ComponentStage::Base => {
match component {
// Ignore the current dir, e.g. `.`
Component::CurDir => {}
// Contains dynamic part
let (static_part, dynamic_part) = split_pattern(&self.pattern);
// Go up a directory, e.g. `..`
Component::ParentDir => {
base.pop();
}
// Once we hit a component that contains a wildcard character, then we
// can't change the base anymore and we must move to the pattern part.
Component::Normal(part) if part.to_string_lossy().contains("*") => {
new_pattern.push(component);
stage = ComponentStage::Pattern;
}
// File or folder, but not the last component
Component::Normal(part) if components.peek().is_some() => {
base.push(part);
}
// Last file or folder. If it's a folder, we move it to the base,
// otherwise we move it to the pattern.
Component::Normal(part) => {
let full_path = base.join(part);
if full_path.is_dir() {
base.push(part);
} else {
new_pattern.push(part);
}
}
// When we're dealing with an absolute path, then we have to bypass the
// `base` entirely.
Component::Prefix(_) => {
base.clear();
base.push(component);
}
Component::RootDir => {
#[cfg(not(windows))]
base.clear();
base.push(component);
}
let base: PathBuf = self.base.clone().into();
let base = match static_part {
Some(static_part) => {
// TODO: If the base does not exist on disk, try removing the last slash and try
// again.
match fsops::canonicalize(base.join(static_part)) {
Ok(base) => base,
Err(err) => {
event!(tracing::Level::ERROR, "Failed to resolve glob: {:?}", err);
return;
}
}
ComponentStage::Pattern => {
new_pattern.push(component);
}
None => base,
};
let pattern = match dynamic_part {
Some(dynamic_part) => dynamic_part,
None => {
if base.is_dir() {
"**/*".to_owned()
} else {
"".to_owned()
}
}
}
};
self.base = base.to_string_lossy().to_string();
self.pattern = path_to_posix_string(&new_pattern);
// Ensure we have `**/*` when the base is a folder and we don't have a pattern at all
if self.pattern == "" {
self.pattern = "/**/*".to_owned();
}
// Ensure that the pattern is pinned to the base path.
else if !self.pattern.starts_with("/") {
self.pattern = format!("/{}", self.pattern);
}
}
}
fn path_to_posix_string(path: &Path) -> String {
let mut parts = Vec::new();
let mut is_rooted = false;
for component in path.components() {
match component {
Component::Prefix(prefix) => {
parts.push(prefix.as_os_str().to_string_lossy().to_string());
}
Component::RootDir => {
is_rooted = true;
if parts.is_empty() {
parts.push(String::new());
}
}
Component::CurDir => {
parts.push(".".to_string());
}
Component::ParentDir => {
parts.push("..".to_string());
}
Component::Normal(part) => {
parts.push(part.to_string_lossy().to_string());
}
}
}
let result = parts.join("/");
if result.is_empty() && is_rooted {
"/".to_string()
} else {
result
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use std::fs;
use tempfile::tempdir;
#[test]
fn path_to_posix_string_serializes_relative_paths() {
let path = PathBuf::from("src").join("**").join("*.html");
assert_eq!(path_to_posix_string(&path), "src/**/*.html");
}
#[test]
fn path_to_posix_string_serializes_rooted_paths() {
let path = PathBuf::from(std::path::MAIN_SEPARATOR.to_string())
.join("src")
.join("**")
.join("*.html");
assert_eq!(path_to_posix_string(&path), "/src/**/*.html");
}
#[test]
fn path_to_posix_string_serializes_empty_paths() {
assert_eq!(path_to_posix_string(&PathBuf::new()), "");
}
#[test]
fn optimize_hoists_static_directories_and_keeps_files_in_the_pattern() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("src").join("examples")).unwrap();
let mut source = PublicSourceEntry {
base: dir.path().to_string_lossy().to_string(),
pattern: "src/examples/index.html".to_string(),
negated: false,
};
source.optimize();
assert_eq!(
source.base,
dunce::canonicalize(dir.path().join("src").join("examples"))
.unwrap()
.to_string_lossy()
);
assert_eq!(source.pattern, "/index.html");
}
#[test]
fn optimize_hoists_folder_patterns() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("src").join("examples")).unwrap();
let mut source = PublicSourceEntry {
base: dir.path().to_string_lossy().to_string(),
pattern: "src/examples".to_string(),
negated: false,
};
source.optimize();
assert_eq!(
source.base,
dunce::canonicalize(dir.path().join("src").join("examples"))
.unwrap()
.to_string_lossy()
);
assert_eq!(source.pattern, "/**/*");
}
#[test]
fn optimize_keeps_wildcards_in_the_pattern() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("src")).unwrap();
let mut source = PublicSourceEntry {
base: dir.path().to_string_lossy().to_string(),
pattern: "src/**/*.html".to_string(),
negated: false,
};
source.optimize();
assert_eq!(
source.base,
dunce::canonicalize(dir.path().join("src"))
.unwrap()
.to_string_lossy()
);
assert_eq!(source.pattern, "/**/*.html");
}
#[test]
fn concrete_patterns_are_expanded_to_restrict_their_base() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("src")).unwrap();
let base = dunce::canonicalize(dir.path().join("src")).unwrap();
let sources = public_source_entries_to_private_source_entries(vec![PublicSourceEntry {
base: dir.path().to_string_lossy().to_string(),
pattern: "src/foo.html".to_string(),
negated: false,
}]);
assert_eq!(
sources,
vec![
SourceEntry::Ignored {
base: base.clone(),
pattern: "*".to_string(),
},
SourceEntry::Pattern {
base,
pattern: "/foo.html".to_string(),
},
]
);
}
#[test]
fn restricted_patterns_include_parent_directory_allow_rules() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("src")).unwrap();
let base = dunce::canonicalize(dir.path().join("src")).unwrap();
let sources = public_source_entries_to_private_source_entries(vec![PublicSourceEntry {
base: dir.path().to_string_lossy().to_string(),
pattern: "src/ef*/*.html".to_string(),
negated: false,
}]);
assert_eq!(
sources,
vec![
SourceEntry::Ignored {
base: base.clone(),
pattern: "*".to_string(),
},
SourceEntry::Ignored {
base: base.clone(),
pattern: "!/ef*/".to_string(),
},
SourceEntry::Pattern {
base,
pattern: "/ef*/*.html".to_string(),
},
]
);
}
#[test]
fn unrestricted_sources_do_not_expand_patterns_for_the_same_base() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("src")).unwrap();
let base = dunce::canonicalize(dir.path().join("src")).unwrap();
let sources = public_source_entries_to_private_source_entries(vec![
PublicSourceEntry {
base: dir.path().to_string_lossy().to_string(),
pattern: "src".to_string(),
negated: false,
},
PublicSourceEntry {
base: dir.path().to_string_lossy().to_string(),
pattern: "src/foo.html".to_string(),
negated: false,
},
]);
assert_eq!(
sources,
vec![
SourceEntry::Auto { base: base.clone() },
SourceEntry::Pattern {
base,
pattern: "/foo.html".to_string(),
},
]
);
}
#[test]
fn restricted_parent_bases_do_not_open_unrelated_siblings() {
let dir = tempdir().unwrap();
let project = dir.path().join("Users").join("robin").join("docus-test");
fs::create_dir_all(&project).unwrap();
let users = dunce::canonicalize(dir.path().join("Users")).unwrap();
let project = dunce::canonicalize(project).unwrap();
let sources = public_source_entries_to_private_source_entries(vec![
PublicSourceEntry {
base: project.to_string_lossy().to_string(),
pattern: "**/*".to_string(),
negated: false,
},
PublicSourceEntry {
base: project.to_string_lossy().to_string(),
pattern: "../../app.config.ts".to_string(),
negated: false,
},
]);
assert_eq!(
sources,
vec![
SourceEntry::Auto {
base: project.clone(),
},
SourceEntry::Ignored {
base: users.clone(),
pattern: "/*".to_string(),
},
SourceEntry::Pattern {
base: users,
pattern: "/app.config.ts".to_string(),
},
]
);
}
#[test]
fn restricted_patterns_preserve_source_order() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("src")).unwrap();
let base = dunce::canonicalize(dir.path().join("src")).unwrap();
let sources = public_source_entries_to_private_source_entries(vec![
PublicSourceEntry {
base: dir.path().to_string_lossy().to_string(),
pattern: "src/foo.html".to_string(),
negated: false,
},
PublicSourceEntry {
base: dir.path().to_string_lossy().to_string(),
pattern: "src/foo.html".to_string(),
negated: true,
},
]);
assert_eq!(
sources,
vec![
SourceEntry::Ignored {
base: base.clone(),
pattern: "*".to_string(),
},
SourceEntry::Pattern {
base: base.clone(),
pattern: "/foo.html".to_string(),
},
SourceEntry::Ignored {
base,
pattern: "/foo.html".to_string(),
},
]
);
}
/// Run the public-to-private conversion for an auto-detected source pointing at `base` and
/// return the resulting entry.
fn auto_source_entry(base: &Path) -> SourceEntry {
public_source_entries_to_private_source_entries(vec![PublicSourceEntry {
base: base.to_string_lossy().to_string(),
pattern: "**/*".to_string(),
negated: false,
}])
.into_iter()
.next()
.unwrap()
}
#[test]
fn auto_detected_folders_become_auto_sources() {
let dir = tempdir().unwrap();
let base = dir.path().join("src");
fs::create_dir_all(&base).unwrap();
let base = dunce::canonicalize(&base).unwrap();
assert_eq!(auto_source_entry(&base), SourceEntry::Auto { base });
}
#[test]
fn folders_ignored_by_default_become_external_sources() {
let dir = tempdir().unwrap();
let base = dir.path().join("node_modules").join("my-lib");
fs::create_dir_all(&base).unwrap();
let base = dunce::canonicalize(&base).unwrap();
assert_eq!(auto_source_entry(&base), SourceEntry::External { base });
}
#[test]
fn folders_ignored_by_gitignore_become_external_sources() {
let dir = tempdir().unwrap();
// Pretend this is a git repository so the `.gitignore` search is bounded to it.
fs::create_dir_all(dir.path().join(".git")).unwrap();
fs::write(dir.path().join(".gitignore"), "dist/\n").unwrap();
let base = dir.path().join("dist");
fs::create_dir_all(&base).unwrap();
let base = dunce::canonicalize(&base).unwrap();
assert_eq!(auto_source_entry(&base), SourceEntry::External { base });
}
#[test]
fn folders_ignored_by_a_parent_gitignore_become_external_sources() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join(".git")).unwrap();
// A `.gitignore` higher up in the tree should still apply to nested directories.
fs::write(dir.path().join(".gitignore"), "generated/\n").unwrap();
let base = dir.path().join("packages").join("app").join("generated");
fs::create_dir_all(&base).unwrap();
let base = dunce::canonicalize(&base).unwrap();
assert_eq!(auto_source_entry(&base), SourceEntry::External { base });
}
#[test]
fn folders_not_ignored_by_gitignore_stay_auto_sources() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join(".git")).unwrap();
fs::write(dir.path().join(".gitignore"), "dist/\n").unwrap();
let base = dir.path().join("src");
fs::create_dir_all(&base).unwrap();
let base = dunce::canonicalize(&base).unwrap();
assert_eq!(auto_source_entry(&base), SourceEntry::Auto { base });
self.pattern = pattern;
}
}
@ -762,111 +218,18 @@ pub fn public_source_entries_to_private_source_entries(
})
.collect::<Vec<_>>();
// Compiled `.gitignore` matchers are cached per directory so we read and parse each
// `.gitignore` file at most once, even though entries commonly share ancestor directories (e.g.
// the repository root). A cached `None` means the directory has no `.gitignore` file.
let mut gitignores: FxHashMap<PathBuf, Option<Gitignore>> = FxHashMap::default();
// Boundary for the `.gitignore` walk when a source is not inside a git repository (see below).
let cwd = std::env::current_dir()
.map(|cwd| dunce::canonicalize(&cwd).unwrap_or(cwd))
.ok();
// Convert from public SourceEntry to private SourceEntry
let sources = expanded_globs
expanded_globs
.into_iter()
.map(|public_source| {
let mut source: SourceEntry = public_source.into();
// Promote auto-sources to external sources if they were gitignored
if let SourceEntry::Auto { ref base } = source {
let inside_git_repo = base.ancestors().any(|dir| dir.join(".git").exists());
// Walk up from the folder, applying each `.gitignore` relative to the directory
// that contains it (matching git), and stop at the git repository root so
// `.gitignore` files outside of the repo are not considered.
for dir in base.ancestors() {
let gitignore = gitignores.entry(dir.to_path_buf()).or_insert_with(|| {
let path = dir.join(".gitignore");
// `Gitignore::new` roots the matcher at the directory containing the file,
// so patterns match relative to it.
path.is_file().then(|| Gitignore::new(&path).0)
});
// Only `.gitignore` files in ancestors of `base` can ignore `base` itself.
// Patterns in `base`'s own `.gitignore` only match paths _inside_ `base`, never
// `base` itself (the file walker still applies them to `base`'s contents).
//
// Skipping `base`'s own `.gitignore` also prevents a false positive for
// whitelist style `.gitignore` files, because relativizing `base` against
// itself yields the empty path, which incorrectly matches `/*`.
//
// E.g.:
//
// ```gitignore
// /*
// !/.gitignore
// !/app
// !/public
// ```
//
// Everything inside `base` except `.gitignore`, `app` and `public` is ignored,
// but `base` itself is not.
if dir != base {
if let Some(gitignore) = gitignore {
if gitignore
.matched_path_or_any_parents(&base, true)
.is_ignore()
{
source = SourceEntry::External { base: base.into() };
break;
}
}
}
// Stop at the git repository root.
if dir.join(".git").exists() {
break;
}
// Without a git repository there is no repository root to stop at. Stop once
// the directory contains the current working directory instead, so `.gitignore`
// files outside of the project (e.g. in the user's home directory) can never
// promote a source to an external source. Note that the file walker still
// applies those `.gitignore` files when deciding which files to scan.
if !inside_git_repo && cwd.as_ref().is_some_and(|cwd| cwd.starts_with(dir)) {
break;
}
}
}
source
})
.collect::<Vec<SourceEntry>>();
expand_restricted_patterns(sources)
.map(Into::into)
.collect::<Vec<_>>()
}
/// Convert a public source entry to a source entry
impl From<PublicSourceEntry> for SourceEntry {
fn from(value: PublicSourceEntry) -> Self {
if value.negated {
return SourceEntry::Ignored {
base: value.base.into(),
pattern: value.pattern,
};
}
let auto =
value.pattern == "/**/*" || PathBuf::from(&value.base).join(&value.pattern).is_dir();
if !auto {
return SourceEntry::Pattern {
base: value.base.into(),
pattern: value.pattern,
};
}
let auto = value.pattern.ends_with("**/*")
|| PathBuf::from(&value.base).join(&value.pattern).is_dir();
let inside_ignored_content_dir = IGNORED_CONTENT_DIRS.iter().any(|dir| {
value.base.contains(&format!(
@ -876,16 +239,24 @@ impl From<PublicSourceEntry> for SourceEntry {
std::path::MAIN_SEPARATOR
)) || value
.base
.ends_with(&format!("{}{}", std::path::MAIN_SEPARATOR, dir))
.ends_with(&format!("{}{}", std::path::MAIN_SEPARATOR, dir,))
});
match inside_ignored_content_dir {
false => SourceEntry::Auto {
match (value.negated, auto, inside_ignored_content_dir) {
(false, true, false) => SourceEntry::Auto {
base: value.base.into(),
},
true => SourceEntry::External {
(false, true, true) => SourceEntry::External {
base: value.base.into(),
},
(false, false, _) => SourceEntry::Pattern {
base: value.base.into(),
pattern: value.pattern,
},
(true, _, _) => SourceEntry::Ignored {
base: value.base.into(),
pattern: value.pattern,
},
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -126,72 +126,6 @@ test(
},
)
// https://github.com/tailwindlabs/tailwindcss/issues/19706
test(
'Config files (TS, valid types)',
{
fs: {
'package.json': json`
{
"type": "module",
"dependencies": {
"tailwindcss": "workspace:^",
"@tailwindcss/cli": "workspace:^"
},
"devDependencies": {
"typescript": "^5.9.3"
}
}
`,
'tsconfig.json': json`
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"composite": true,
"outDir": "./.build",
"skipLibCheck": true
},
"include": ["tailwind.config.ts"]
}
`,
'index.html': html`
<div class="text-primary"></div>
`,
'tailwind.config.ts': ts`
import type { Config } from 'tailwindcss'
function defineConfig(config: Config): Config {
return config
}
export default defineConfig({
theme: {
extend: {
colors: {
primary: 'blue',
},
},
},
})
`,
'src/index.css': css`
@import 'tailwindcss';
@config '../tailwind.config.ts';
`,
},
},
async ({ fs, exec }) => {
// We expect that these commands don't crash:
await exec('pnpm tsc -b')
await exec('pnpm tailwindcss --input src/index.css --output dist/out.css')
await fs.expectFileToContain('dist/out.css', [candidate`text-primary`])
},
)
test(
'Config files (CJS, watch mode)',
{

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,4 @@
import { candidate, css, html, json, test, ts } from '../utils'
import { candidate, css, html, json, test } from '../utils'
test(
'builds the `@tailwindcss/typography` plugin utilities',
@ -42,86 +42,12 @@ test(
await fs.expectFileToContain('dist/out.css', [
candidate`prose`,
':where(h1):not(:where([class~="not-prose"], [class~="not-prose"] *))',
':where(tbody td, tfoot td):not(:where([class~="not-prose"], [class~="not-prose"] *))',
':where(h1):not(:where([class~="not-prose"],[class~="not-prose"] *))',
':where(tbody td, tfoot td):not(:where([class~="not-prose"],[class~="not-prose"] *))',
])
},
)
test(
'builds the `@tailwindcss/typography` plugin utilities with `@variant` usages',
{
fs: {
'package.json': json`
{
"dependencies": {
"@tailwindcss/typography": "^0.5.14",
"tailwindcss": "workspace:^",
"@tailwindcss/cli": "workspace:^"
}
}
`,
'index.html': html`
<div className="prose prose-custom">
<h1>Headline</h1>
<p>
Until now, trying to style an article, document, or blog post with Tailwind has been a
tedious task that required a keen eye for typography and a lot of complex custom CSS.
</p>
</div>
`,
'src/index.css': css`
@import 'tailwindcss/utilities';
@theme {
--breakpoint-sm: 640px;
}
@plugin '@tailwindcss/typography';
@config '../tailwind.config.js';
@custom-variant custom (&.custom);
`,
'tailwind.config.js': ts`
module.exports = {
theme: {
typography: ({ theme }) => ({
custom: {
css: {
hr: {
'--x': '1',
'@variant sm:custom': {
'--x': '2',
},
},
},
},
}),
},
}
`,
},
},
async ({ fs, exec, expect }) => {
await exec('pnpm tailwindcss --input src/index.css --output dist/out.css')
// We don't want to see `@variant` in the output
let contents = await fs.read('dist/out.css')
expect(contents).not.toContain('@variant')
expect(await fs.dumpFiles('dist/out.css')).toMatchInlineSnapshot(`
"
--- dist/out.css ---
.prose-custom :where(hr):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
--x: 1;
@media (width >= 640px) {
&.custom {
--x: 2;
}
}
}
"
`)
},
)
test(
'builds the `@tailwindcss/forms` plugin utilities',
{
@ -191,7 +117,7 @@ test(
await fs.expectFileToContain('dist/out.css', [
//
`::-webkit-date-and-time-value`,
`input:where([type='checkbox']):indeterminate`,
`[type='checkbox']:indeterminate`,
])
// No classes are included even though they are used in the HTML
@ -281,88 +207,3 @@ test(
])
},
)
// https://github.com/tailwindlabs/tailwindcss/issues/15844
test(
'builds CSS with a custom plugin compiled from TypeScript',
{
fs: {
'package.json': json`
{
"type": "module",
"dependencies": {
"tailwindcss": "workspace:^",
"@tailwindcss/cli": "workspace:^"
},
"devDependencies": {
"typescript": "^5.7.2"
}
}
`,
'tsconfig.json': json`
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"composite": true,
"rootDir": "./src",
"outDir": "./.build",
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}
`,
'index.html': html`
<div class="test-red"></div>
`,
'src/index.css': css`
@import 'tailwindcss';
@plugin '../.build/plugin.js';
`,
'src/plugin.ts': ts`
import plugin from 'tailwindcss/plugin'
export const typedPlugin = plugin(() => {
return ({ matchComponents }) => {
matchComponents(
{
test: (content: string) => ({
color: content,
}),
},
{
values: {
red: 'red',
},
},
)
}
})
export default plugin(({ matchComponents }) => {
matchComponents(
{
test: (content: string) => ({
color: content,
}),
},
{
values: {
red: 'red',
},
},
)
})
`,
},
},
async ({ fs, exec }) => {
// We expect that these commands don't crash:
await exec('pnpm tsc -b')
await exec('pnpm tailwindcss --input src/index.css --output dist/out.css')
await fs.expectFileToContain('dist/out.css', [candidate`test-red`])
},
)

View file

@ -1,7 +1,6 @@
import nodeFs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { candidate, css, html, IS_WINDOWS, js, json, test, ts } from '../utils'
import { candidate, css, html, js, json, test } from '../utils'
const STANDALONE_BINARY = (() => {
switch (os.platform()) {
@ -16,41 +15,6 @@ const STANDALONE_BINARY = (() => {
}
})()
test(
'does not scan itself for candidates',
{
fs: {
'package.json': json`
{
"dependencies": {}
}
`,
'src/index.css': css` @import 'tailwindcss'; `,
},
},
async ({ root, fs, exec }) => {
let sourceBinary = path.resolve(
__dirname,
`../../packages/@tailwindcss-standalone/dist/${STANDALONE_BINARY}`,
)
let binary = IS_WINDOWS ? 'tailwindcss.exe' : 'tailwindcss'
let localBinary = path.join(root, binary)
await nodeFs.copyFile(sourceBinary, localBinary)
if (!IS_WINDOWS) {
await nodeFs.chmod(localBinary, 0o755)
}
await exec(`${IS_WINDOWS ? binary : `./${binary}`} --input src/index.css --output dist/out.css`)
await fs.expectFileNotToContain('dist/out.css', [
candidate`flex`,
candidate`grid`,
candidate`underline`,
])
},
)
test(
'includes first-party plugins',
{
@ -158,7 +122,7 @@ test(
})
})
`,
'src/plugin.ts': ts`
'src/plugin.ts': js`
import plugin from 'tailwindcss/plugin'
// Make sure all available JS APIs can be imported and used
@ -168,7 +132,8 @@ test(
import defaultTheme from 'tailwindcss/defaultTheme'
import * as pkg from 'tailwindcss/package.json'
export interface PluginOptions {}
export interface PluginOptions {
}
export default plugin(function ({ addUtilities }) {
addUtilities({

View file

@ -0,0 +1,73 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { js, json, test } from '../utils'
test(
'@tailwindcss/oxide will fail when architecture-specific packages are missing',
{
fs: {
'package.json': json`
{
"dependencies": {
"@tailwindcss/oxide": "workspace:^"
}
}
`,
'test.js': js`
try {
let Scanner = require('@tailwindcss/oxide')
console.log('SUCCESS: @tailwindcss/oxide loaded successfully', Scanner)
} catch (error) {
console.log('FAILURE: Failed to load @tailwindcss/oxide:', error.message)
}
`,
},
},
async ({ exec, root, expect, fs }) => {
await removePlatformSpecificExtensions(path.join(root, 'node_modules'))
// Get last published version
let version = (await exec('npm show @tailwindcss/oxide version')).trim()
// Ensure that we don't depend on a specific version number in the download
// script in case we bump the version number in the repository and CI is run
// before a release
let packageJson = JSON.parse(await fs.read('node_modules/@tailwindcss/oxide/package.json'))
packageJson.version = version
await fs.write(
'node_modules/@tailwindcss/oxide/package.json',
JSON.stringify(packageJson, null, 2),
)
let opts = {
// Ensure that we don't include any node paths from the test runner
env: { NODE_PATH: '' },
}
expect(await exec('node test.js', opts)).toMatch(/FAILURE/)
// Now run the post-install script
await exec('node node_modules/@tailwindcss/oxide/scripts/install.js', opts)
expect(await exec('node test.js', opts)).toMatch(/SUCCESS/)
},
)
async function removePlatformSpecificExtensions(directory: string) {
let entries = await fs.readdir(directory, { withFileTypes: true })
for (let entry of entries) {
let fullPath = path.join(directory, entry.name)
if (entry.name.startsWith('oxide-')) {
if (entry.isSymbolicLink()) {
await fs.unlink(fullPath)
} else if (entry.isFile()) {
await fs.unlink(fullPath)
} else if (entry.isDirectory()) {
await fs.rm(fullPath, { recursive: true, force: true })
}
} else if (entry.isDirectory()) {
await removePlatformSpecificExtensions(fullPath)
}
}
}

View file

@ -1,6 +1,6 @@
import { css, js, json, test, yaml } from '../utils'
import { css, js, json, test } from '../utils'
// This test runs the wasm build using the `node:wasi` runtime.
// This test runs the wasm build using the `node:wasi` runtine.
//
// There are currently a known problems that the Node WASI preview implementation does not properly
// handle FS reads on macOS and it does not implement all APIs on Windows. Because of that, this
@ -57,187 +57,3 @@ testFn(
`)
},
)
testFn(
'`@tailwindcss/oxide` falls back to the wasm build when no native binding is available',
{
fs: {
'package.json': json`
{
"dependencies": {
"@tailwindcss/oxide": "workspace:^"
}
}
`,
'pnpm-workspace.yaml': yaml`
# Trick pnpm in only supporting an architecture that @tailwindcss/oxide
# doesn't support, and therefore should fallback to the wasm version.
supportedArchitectures:
os:
- openbsd
cpu:
- x64
`,
'src/index.js': js`
const className = "content-['src/index.js']"
module.exports = { className }
`,
'index.mjs': js`
import { createRequire } from 'node:module'
import { join } from 'node:path'
let require = createRequire(import.meta.url)
let { Scanner } = require('@tailwindcss/oxide')
let loaded = Object.keys(require.cache)
let scanner = new Scanner({
sources: [
{
base: join(process.cwd(), 'src'),
pattern: '**/*',
negated: false,
},
],
})
console.log(
JSON.stringify({
native: loaded.filter((file) => file.endsWith('.node')),
wasi: loaded.some((file) => file.endsWith('tailwindcss-oxide.wasi.cjs')),
candidates: scanner.scan(),
}),
)
process.exit()
`,
},
},
async ({ expect, exec }) => {
// Since vitest runs under `pnpm run`, pnpm's bin shims export a NODE_PATH
// that includes the repository's hidden hoist directory
// (`node_modules/.pnpm/node_modules`), which links every workspace package,
// including all native `@tailwindcss/oxide-*` bindings.
//
// Node uses `NODE_PATH` exactly when the local `node_modules` lookup fails,
// which would defeat the simulated unsupported platform, so clear it.
let output = await exec(`node index.mjs`, { env: { NODE_PATH: '' } })
let { native, wasi, candidates } = JSON.parse(output)
// No native binding was installed or loaded, ...
expect(native).toEqual([])
// ... the wasm32-wasi binding is what actually loaded, ...
expect(wasi).toBe(true)
// ... and scanning real files on disk works through it.
expect(candidates).toMatchInlineSnapshot(`
[
"className",
"const",
"content-['src/index.js']",
"exports",
]
`)
},
)
testFn(
'the wasm build loads even when preopening the filesystem root is denied',
{
fs: {
'package.json': json`
{
"dependencies": {
"@tailwindcss/oxide": "workspace:^"
}
}
`,
'pnpm-workspace.yaml': yaml`
# Trick pnpm in only supporting an architecture that @tailwindcss/oxide
# doesn't support, and therefore should fallback to the wasm version.
supportedArchitectures:
os:
- openbsd
cpu:
- x64
`,
// The wasm bindings generated by `@napi-rs/cli` preopen the filesystem
// root, which sandboxed platforms (e.g. OpenHarmony, Android) deny with
// `UVWASI_EACCES`, making the wasm fallback fail to load on exactly the
// platforms that need it.
//
// We patch `@napi-rs/cli`'s templates to retry with narrower preopens
// (see `patches/@napi-rs__cli@3.7.4.patch`). Simulate such a sandbox by
// denying `/` preopens.
'preload.cjs': js`
const wasi = require('node:wasi')
const RealWASI = wasi.WASI
wasi.WASI = class WASI extends RealWASI {
constructor(options) {
if (options?.preopens?.['/'] !== undefined) {
const error = new Error('UVWASI_EACCES, uvwasi_init')
error.code = 'UVWASI_EACCES'
error.syscall = 'uvwasi_init'
throw error
}
super(options)
}
}
`,
'src/index.js': js`
const className = "content-['src/index.js']"
module.exports = { className }
`,
'index.mjs': js`
import { createRequire } from 'node:module'
import { join } from 'node:path'
let require = createRequire(import.meta.url)
let { Scanner } = require('@tailwindcss/oxide')
let loaded = Object.keys(require.cache)
let scanner = new Scanner({
sources: [
{
base: join(process.cwd(), 'src'),
pattern: '**/*',
negated: false,
},
],
})
console.log(
JSON.stringify({
wasi: loaded.some((file) => file.endsWith('tailwindcss-oxide.wasi.cjs')),
candidates: scanner.scan(),
}),
)
process.exit()
`,
},
},
async ({ expect, exec }) => {
// See the note about NODE_PATH in the test above.
let output = await exec(`node --require ./preload.cjs index.mjs`, { env: { NODE_PATH: '' } })
// Only parse the first line, because Node prints an `ExperimentalWarning`
// about WASI to stderr, which `exec` appends to the output.
let { wasi, candidates } = JSON.parse(output.trim().split('\n')[0])
// The wasm32-wasi binding loaded despite `/` being denied, ...
expect(wasi).toBe(true)
// ... and scanning files under the current working directory still works
// through the narrower preopen.
expect(candidates).toMatchInlineSnapshot(`
[
"className",
"const",
"content-['src/index.js']",
"exports",
]
`)
},
)

View file

@ -3,9 +3,8 @@
"version": "0.0.0",
"private": true,
"devDependencies": {
"dedent": "catalog:",
"dedent": "1.6.0",
"fast-glob": "^3.3.3",
"source-map-js": "^1.2.1",
"yaml": "^2.9.0"
"source-map-js": "^1.2.1"
}
}

View file

@ -48,9 +48,9 @@ describe.each(Object.keys(variantConfig))('%s', (variant) => {
},
},
async ({ exec, expect }) => {
await expect(
expect(
exec('pnpm postcss src/index.css --output dist/out.css', undefined, { ignoreStdErr: true }),
).rejects.toThrow(
).rejects.toThrowError(
`It looks like you're trying to use \`tailwindcss\` directly as a PostCSS plugin. The PostCSS plugin has moved to a separate package, so to continue using Tailwind CSS with PostCSS you'll need to install \`@tailwindcss/postcss\` and update your PostCSS configuration.`,
)
},

Some files were not shown because too many files have changed in this diff Show more