Compare commits

..

No commits in common. "main" and "v4.2.2" have entirely different histories.
main ... v4.2.2

225 changed files with 20172 additions and 38131 deletions

View file

@ -27,10 +27,8 @@ jobs:
- name: Linux - name: Linux
os: namespace-profile-default 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 - name: macOS
os: macos-15 os: macos-14
# Exclude windows and macos from being built on feature branches # Exclude windows and macos from being built on feature branches
run-all: run-all:
@ -49,21 +47,21 @@ jobs:
name: ${{ matrix.runner.name }} name: ${{ matrix.runner.name }}
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - uses: actions/checkout@v6
with: - uses: pnpm/action-setup@v4
persist-credentials: false
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Use Node.js ${{ env.NODE_VERSION }} - name: Use Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 uses: actions/setup-node@v6
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
# Cargo already skips downloading dependencies if they already exist # Cargo already skips downloading dependencies if they already exist
- name: Cache cargo - name: Cache cargo
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 uses: actions/cache@v5
with: with:
path: | path: |
~/.cargo/bin/
~/.cargo/registry/index/ ~/.cargo/registry/index/
~/.cargo/registry/cache/ ~/.cargo/registry/cache/
~/.cargo/git/db/ ~/.cargo/git/db/
@ -72,7 +70,7 @@ jobs:
# Cache the `oxide` Rust build # Cache the `oxide` Rust build
- name: Cache oxide build - name: Cache oxide build
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 uses: actions/cache@v5
with: with:
path: | path: |
./crates/node/*.node ./crates/node/*.node
@ -90,7 +88,7 @@ jobs:
run: rustup target add wasm32-wasip1-threads run: rustup target add wasm32-wasip1-threads
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install
- name: Build - name: Build
run: pnpm run build run: pnpm run build
@ -118,7 +116,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Notify Discord - name: Notify Discord
uses: discord-actions/message@5c7149c81a83146e5d01f142be1bf87a61831c4d # v2 uses: discord-actions/message@v2
with: with:
webhookUrl: ${{ secrets.DISCORD_WEBHOOK_URL }} 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 ${{ github.workflow }} workflow](<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}>) on the `main` branch has failed.'

View file

@ -55,25 +55,25 @@ jobs:
name: ${{ matrix.runner.name }} / ${{ matrix.integration }} name: ${{ matrix.runner.name }} / ${{ matrix.integration }}
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - uses: actions/checkout@v6
with: - uses: pnpm/action-setup@v4
persist-credentials: false
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- run: | - run: |
git config --global user.name "github-actions[bot]" git config --global user.name "github-actions[bot]"
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Use Node.js ${{ env.NODE_VERSION }} - name: Use Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 uses: actions/setup-node@v6
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
# Cargo already skips downloading dependencies if they already exist # Cargo already skips downloading dependencies if they already exist
- name: Cache cargo - name: Cache cargo
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 uses: actions/cache@v5
with: with:
path: | path: |
~/.cargo/bin/
~/.cargo/registry/index/ ~/.cargo/registry/index/
~/.cargo/registry/cache/ ~/.cargo/registry/cache/
~/.cargo/git/db/ ~/.cargo/git/db/
@ -82,7 +82,7 @@ jobs:
# Cache the `oxide` Rust build # Cache the `oxide` Rust build
- name: Cache oxide build - name: Cache oxide build
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 uses: actions/cache@v5
with: with:
path: | path: |
./crates/node/*.node ./crates/node/*.node
@ -119,7 +119,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Notify Discord - name: Notify Discord
uses: discord-actions/message@5c7149c81a83146e5d01f142be1bf87a61831c4d # v2 uses: discord-actions/message@v2
with: with:
webhookUrl: ${{ secrets.DISCORD_WEBHOOK_URL }} 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 ${{ github.workflow }} workflow](<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}>) on the `main` branch has failed.'

View file

@ -2,12 +2,6 @@ name: Prepare Release
on: on:
workflow_dispatch: workflow_dispatch:
inputs:
dry_run:
description: Skip creating the draft GitHub release
required: false
default: true
type: boolean
push: push:
tags: tags:
- 'v*' - 'v*'
@ -15,7 +9,6 @@ on:
env: env:
APP_NAME: tailwindcss-oxide APP_NAME: tailwindcss-oxide
NODE_VERSION: 24 NODE_VERSION: 24
PNPM_VERSION: '11.9.0'
OXIDE_LOCATION: ./crates/node OXIDE_LOCATION: ./crates/node
permissions: permissions:
@ -54,40 +47,44 @@ jobs:
- os: ubuntu-latest - os: ubuntu-latest
target: x86_64-unknown-linux-gnu target: x86_64-unknown-linux-gnu
strip: strip strip: strip
build-flags: --use-napi-cross container:
image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian
- os: ubuntu-latest - os: ubuntu-latest
target: aarch64-unknown-linux-gnu target: aarch64-unknown-linux-gnu
strip: aarch64-linux-gnu-strip strip: llvm-strip
build-flags: --use-napi-cross container:
image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian-aarch64
- os: ubuntu-latest - os: ubuntu-latest
target: armv7-unknown-linux-gnueabihf target: armv7-unknown-linux-gnueabihf
strip: arm-linux-gnueabihf-strip strip: llvm-strip
build-flags: --use-napi-cross container:
image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian-zig
- os: ubuntu-latest - os: ubuntu-latest
target: aarch64-unknown-linux-musl target: aarch64-unknown-linux-musl
strip-zig: true strip: aarch64-linux-musl-strip
build-flags: -x download: true
container:
image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-alpine
- os: ubuntu-latest - os: ubuntu-latest
target: x86_64-unknown-linux-musl target: x86_64-unknown-linux-musl
strip: strip strip: strip
build-flags: -x download: true
container:
image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-alpine
name: Build ${{ matrix.target }} (oxide) name: Build ${{ matrix.target }} (oxide)
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
container: ${{ matrix.container }}
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - uses: actions/checkout@v6
with: - uses: pnpm/action-setup@v4
persist-credentials: false
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with:
version: ${{ env.PNPM_VERSION }}
- name: Use Node.js ${{ env.NODE_VERSION }} - name: Use Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 uses: actions/setup-node@v6
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
package-manager-cache: false cache: 'pnpm'
- name: Install gcc-arm-linux-gnueabihf - name: Install gcc-arm-linux-gnueabihf
if: ${{ matrix.target == 'armv7-unknown-linux-gnueabihf' }} if: ${{ matrix.target == 'armv7-unknown-linux-gnueabihf' }}
@ -95,56 +92,62 @@ jobs:
sudo apt-get update sudo apt-get update
sudo apt-get install gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf -y sudo apt-get install gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf -y
- name: Install binutils-aarch64-linux-gnu # Cargo already skips downloading dependencies if they already exist
if: ${{ matrix.target == 'aarch64-unknown-linux-gnu' }} - name: Cache cargo
uses: actions/cache@v5
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@v5
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@v6
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install Rust (Stable)
if: ${{ matrix.download }}
run: | run: |
sudo apt-get update rustup default stable
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
- name: Setup rust target - name: Setup rust target
run: rustup target add ${{ matrix.target }} run: rustup target add ${{ matrix.target }}
- name: Install dependencies - name: Install dependencies
run: pnpm install --ignore-scripts --frozen-lockfile --filter=!./playgrounds/* run: pnpm install --ignore-scripts --filter=!./playgrounds/*
- name: Build release - 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: env:
RUST_TARGET: ${{ matrix.target }} RUST_TARGET: ${{ matrix.target }}
JEMALLOC_SYS_WITH_LG_PAGE: ${{ matrix.page-size }} JEMALLOC_SYS_WITH_LG_PAGE: ${{ matrix.page-size }}
- name: Strip debug symbols # https://github.com/rust-lang/rust/issues/46034 - name: Strip debug symbols # https://github.com/rust-lang/rust/issues/46034
if: ${{ matrix.strip || matrix.strip-zig }} if: ${{ matrix.strip }}
env: run: ${{ matrix.strip }} ${{ env.OXIDE_LOCATION }}/*.node
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"
- name: Upload artifacts - name: Upload artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 uses: actions/upload-artifact@v6
with: with:
name: bindings-${{ matrix.target }} name: bindings-${{ matrix.target }}
path: ${{ env.OXIDE_LOCATION }}/*.node path: ${{ env.OXIDE_LOCATION }}/*.node
@ -154,11 +157,9 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - uses: actions/checkout@v6
with:
persist-credentials: false
- name: Build FreeBSD - name: Build FreeBSD
uses: cross-platform-actions/action@cdc9ee69ef84a5f2e59c9058335d9c57bcb4ac86 # v0.25.0 uses: cross-platform-actions/action@v0.25.0
env: env:
DEBUG: napi:* DEBUG: napi:*
RUSTUP_HOME: /usr/local/rustup RUSTUP_HOME: /usr/local/rustup
@ -174,12 +175,12 @@ jobs:
shell: bash shell: bash
run: | run: |
sudo pkg install -y -f curl node libnghttp2 npm 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 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 chmod +x rustup-init
./rustup-init -y --profile minimal ./rustup-init -y --profile minimal
source "$HOME/.cargo/env" source "$HOME/.cargo/env"
pnpm install --ignore-scripts --frozen-lockfile --filter=!./playgrounds/* || true pnpm install --ignore-scripts --filter=!./playgrounds/* || true
echo "~~~~ rustc --version ~~~~" echo "~~~~ rustc --version ~~~~"
rustc --version rustc --version
echo "~~~~ node -v ~~~~" echo "~~~~ node -v ~~~~"
@ -190,7 +191,7 @@ jobs:
strip -x ${{ env.OXIDE_LOCATION }}/*.node strip -x ${{ env.OXIDE_LOCATION }}/*.node
ls -la ${{ env.OXIDE_LOCATION }} ls -la ${{ env.OXIDE_LOCATION }}
- name: Upload artifacts - name: Upload artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 uses: actions/upload-artifact@v6
with: with:
name: bindings-x86_64-unknown-freebsd name: bindings-x86_64-unknown-freebsd
path: ${{ env.OXIDE_LOCATION }}/*.node path: ${{ env.OXIDE_LOCATION }}/*.node
@ -201,17 +202,18 @@ jobs:
name: Build and release Tailwind CSS name: Build and release Tailwind CSS
permissions: 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: needs:
- build - build
- build-freebsd - build-freebsd
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - uses: actions/checkout@v6
with: with:
fetch-depth: 20 fetch-depth: 20
persist-credentials: false
- run: git fetch --tags -f - run: git fetch --tags -f
@ -220,25 +222,51 @@ jobs:
run: | run: |
echo "TAG_NAME=$(git describe --tags --abbrev=0)" >> $GITHUB_ENV echo "TAG_NAME=$(git describe --tags --abbrev=0)" >> $GITHUB_ENV
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- name: Use Node.js ${{ env.NODE_VERSION }} - name: Use Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 uses: actions/setup-node@v6
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
registry-url: 'https://registry.npmjs.org' 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@v5
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@v5
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 - name: Setup WASM target
run: rustup target add wasm32-wasip1-threads run: rustup target add wasm32-wasip1-threads
- name: Install dependencies - name: Install dependencies
run: pnpm --filter=!./playgrounds/* install --frozen-lockfile run: pnpm --filter=!./playgrounds/* install
- name: Download artifacts - name: Download artifacts
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 uses: actions/download-artifact@v7
with: with:
path: ${{ env.OXIDE_LOCATION }} path: ${{ env.OXIDE_LOCATION }}
@ -277,31 +305,30 @@ jobs:
echo "EOF" >> $GITHUB_ENV echo "EOF" >> $GITHUB_ENV
- name: Upload standalone artifacts - name: Upload standalone artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 uses: actions/upload-artifact@v6
with: with:
name: tailwindcss-standalone name: tailwindcss-standalone
path: packages/@tailwindcss-standalone/dist/ path: packages/@tailwindcss-standalone/dist/
- name: Upload npm package tarballs - name: Upload npm package tarballs
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 uses: actions/upload-artifact@v6
with: with:
name: npm-package-tarballs name: npm-package-tarballs
path: dist/*.tgz path: dist/*.tgz
- name: Prepare GitHub Release - name: Prepare GitHub Release
if: ${{ !inputs.dry_run }} uses: softprops/action-gh-release@v2
env: with:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} draft: true
run: | tag_name: ${{ env.TAG_NAME }}
gh release create "${TAG_NAME}" \ body: |
--draft \ ${{ env.RELEASE_NOTES }}
--title "${TAG_NAME}" \ files: |
--notes "${RELEASE_NOTES}" \ packages/@tailwindcss-standalone/dist/sha256sums.txt
packages/@tailwindcss-standalone/dist/sha256sums.txt \ packages/@tailwindcss-standalone/dist/tailwindcss-linux-arm64
packages/@tailwindcss-standalone/dist/tailwindcss-linux-arm64 \ packages/@tailwindcss-standalone/dist/tailwindcss-linux-arm64-musl
packages/@tailwindcss-standalone/dist/tailwindcss-linux-arm64-musl \ packages/@tailwindcss-standalone/dist/tailwindcss-linux-x64
packages/@tailwindcss-standalone/dist/tailwindcss-linux-x64 \ packages/@tailwindcss-standalone/dist/tailwindcss-linux-x64-musl
packages/@tailwindcss-standalone/dist/tailwindcss-linux-x64-musl \ packages/@tailwindcss-standalone/dist/tailwindcss-macos-arm64
packages/@tailwindcss-standalone/dist/tailwindcss-macos-arm64 \ packages/@tailwindcss-standalone/dist/tailwindcss-macos-x64
packages/@tailwindcss-standalone/dist/tailwindcss-macos-x64 \
packages/@tailwindcss-standalone/dist/tailwindcss-windows-x64.exe packages/@tailwindcss-standalone/dist/tailwindcss-windows-x64.exe

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

@ -0,0 +1,322 @@
name: Release Insiders
on:
push:
branches: [main]
permissions:
contents: read
env:
APP_NAME: tailwindcss-oxide
NODE_VERSION: 24
OXIDE_LOCATION: ./crates/node
RELEASE_CHANNEL: insiders
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
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@v6
- uses: pnpm/action-setup@v4
- name: Use Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@v6
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@v5
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@v5
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@v6
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@v6
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@v6
- 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@v6
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@v6
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@v6
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@v5
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@v5
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@v7
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@v6
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@v8
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,20 +1,9 @@
name: Release name: Release
on: on:
push:
branches: [main]
release: release:
types: [published] types: [published]
workflow_dispatch: workflow_dispatch:
inputs:
channel:
description: Release channel to publish
required: true
default: insiders
type: choice
options:
- insiders
- release
permissions: permissions:
contents: read contents: read
@ -22,13 +11,8 @@ permissions:
env: env:
APP_NAME: tailwindcss-oxide APP_NAME: tailwindcss-oxide
NODE_VERSION: 24 NODE_VERSION: 24
PNPM_VERSION: '11.9.0'
OXIDE_LOCATION: ./crates/node OXIDE_LOCATION: ./crates/node
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
build: build:
strategy: strategy:
@ -58,40 +42,44 @@ jobs:
- os: ubuntu-latest - os: ubuntu-latest
target: x86_64-unknown-linux-gnu target: x86_64-unknown-linux-gnu
strip: strip strip: strip
build-flags: --use-napi-cross container:
image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian
- os: ubuntu-latest - os: ubuntu-latest
target: aarch64-unknown-linux-gnu target: aarch64-unknown-linux-gnu
strip: aarch64-linux-gnu-strip strip: llvm-strip
build-flags: --use-napi-cross container:
image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian-aarch64
- os: ubuntu-latest - os: ubuntu-latest
target: armv7-unknown-linux-gnueabihf target: armv7-unknown-linux-gnueabihf
strip: arm-linux-gnueabihf-strip strip: llvm-strip
build-flags: --use-napi-cross container:
image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian-zig
- os: ubuntu-latest - os: ubuntu-latest
target: aarch64-unknown-linux-musl target: aarch64-unknown-linux-musl
strip-zig: true strip: aarch64-linux-musl-strip
build-flags: -x download: true
container:
image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-alpine
- os: ubuntu-latest - os: ubuntu-latest
target: x86_64-unknown-linux-musl target: x86_64-unknown-linux-musl
strip: strip strip: strip
build-flags: -x download: true
container:
image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-alpine
name: Build ${{ matrix.target }} (oxide) name: Build ${{ matrix.target }} (oxide)
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
container: ${{ matrix.container }}
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - uses: actions/checkout@v6
with: - uses: pnpm/action-setup@v4
persist-credentials: false
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
with:
version: ${{ env.PNPM_VERSION }}
- name: Use Node.js ${{ env.NODE_VERSION }} - name: Use Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 uses: actions/setup-node@v6
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
package-manager-cache: false cache: 'pnpm'
- name: Install gcc-arm-linux-gnueabihf - name: Install gcc-arm-linux-gnueabihf
if: ${{ matrix.target == 'armv7-unknown-linux-gnueabihf' }} if: ${{ matrix.target == 'armv7-unknown-linux-gnueabihf' }}
@ -99,56 +87,62 @@ jobs:
sudo apt-get update sudo apt-get update
sudo apt-get install gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf -y sudo apt-get install gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf -y
- name: Install binutils-aarch64-linux-gnu # Cargo already skips downloading dependencies if they already exist
if: ${{ matrix.target == 'aarch64-unknown-linux-gnu' }} - name: Cache cargo
uses: actions/cache@v5
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@v5
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@v6
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install Rust (Stable)
if: ${{ matrix.download }}
run: | run: |
sudo apt-get update rustup default stable
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
- name: Setup rust target - name: Setup rust target
run: rustup target add ${{ matrix.target }} run: rustup target add ${{ matrix.target }}
- name: Install dependencies - name: Install dependencies
run: pnpm install --ignore-scripts --frozen-lockfile --filter=!./playgrounds/* run: pnpm install --ignore-scripts --filter=!./playgrounds/*
- name: Build release - 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: env:
RUST_TARGET: ${{ matrix.target }} RUST_TARGET: ${{ matrix.target }}
JEMALLOC_SYS_WITH_LG_PAGE: ${{ matrix.page-size }} JEMALLOC_SYS_WITH_LG_PAGE: ${{ matrix.page-size }}
- name: Strip debug symbols # https://github.com/rust-lang/rust/issues/46034 - name: Strip debug symbols # https://github.com/rust-lang/rust/issues/46034
if: ${{ matrix.strip || matrix.strip-zig }} if: ${{ matrix.strip }}
env: run: ${{ matrix.strip }} ${{ env.OXIDE_LOCATION }}/*.node
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"
- name: Upload artifacts - name: Upload artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 uses: actions/upload-artifact@v6
with: with:
name: bindings-${{ matrix.target }} name: bindings-${{ matrix.target }}
path: ${{ env.OXIDE_LOCATION }}/*.node path: ${{ env.OXIDE_LOCATION }}/*.node
@ -158,11 +152,9 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - uses: actions/checkout@v6
with:
persist-credentials: false
- name: Build FreeBSD - name: Build FreeBSD
uses: cross-platform-actions/action@cdc9ee69ef84a5f2e59c9058335d9c57bcb4ac86 # v0.25.0 uses: cross-platform-actions/action@v0.25.0
env: env:
DEBUG: napi:* DEBUG: napi:*
RUSTUP_HOME: /usr/local/rustup RUSTUP_HOME: /usr/local/rustup
@ -178,7 +170,7 @@ jobs:
shell: bash shell: bash
run: | run: |
sudo pkg install -y -f curl node libnghttp2 npm 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 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 chmod +x rustup-init
./rustup-init -y --profile minimal ./rustup-init -y --profile minimal
@ -189,12 +181,12 @@ jobs:
node -v node -v
echo "~~~~ pnpm --version ~~~~" echo "~~~~ pnpm --version ~~~~"
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 pnpm run --filter ${{ env.OXIDE_LOCATION }} build:platform
strip -x ${{ env.OXIDE_LOCATION }}/*.node strip -x ${{ env.OXIDE_LOCATION }}/*.node
ls -la ${{ env.OXIDE_LOCATION }} ls -la ${{ env.OXIDE_LOCATION }}
- name: Upload artifacts - name: Upload artifacts
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 uses: actions/upload-artifact@v6
with: with:
name: bindings-x86_64-unknown-freebsd name: bindings-x86_64-unknown-freebsd
path: ${{ env.OXIDE_LOCATION }}/*.node path: ${{ env.OXIDE_LOCATION }}/*.node
@ -202,10 +194,10 @@ jobs:
release: release:
runs-on: macos-14 runs-on: macos-14
timeout-minutes: 15 timeout-minutes: 15
name: Build and publish Tailwind CSS name: Build and release Tailwind CSS
permissions: 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 # https://docs.npmjs.com/generating-provenance-statements#publishing-packages-with-provenance-via-github-actions
id-token: write id-token: write
@ -214,51 +206,55 @@ jobs:
- build-freebsd - build-freebsd
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - uses: actions/checkout@v6
with: with:
fetch-depth: 20 fetch-depth: 20
persist-credentials: false
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - uses: pnpm/action-setup@v4
with:
version: ${{ env.PNPM_VERSION }}
- name: Use Node.js ${{ env.NODE_VERSION }} - name: Use Node.js ${{ env.NODE_VERSION }}
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 uses: actions/setup-node@v6
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
cache: 'pnpm'
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'
package-manager-cache: false
# npm trusted publishing validates the caller workflow filename, so all npm publishes live here. # Cargo already skips downloading dependencies if they already exist
# This workflow rebuilds the publish artifacts instead of depending on prepare-release.yml. - name: Cache cargo
- name: Resolve release metadata uses: actions/cache@v5
env: with:
INPUT_CHANNEL: ${{ github.event.inputs.channel || '' }} path: |
run: | ~/.cargo/bin/
if [[ "${{ github.event_name }}" == "release" || "$INPUT_CHANNEL" == "release" ]]; then ~/.cargo/registry/index/
release_channel=$(node ./scripts/release-channel.js) ~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }}
echo "RELEASE_KIND=release" >> $GITHUB_ENV # Cache the `oxide` Rust build
echo "RELEASE_CHANNEL=$release_channel" >> $GITHUB_ENV - name: Cache oxide build
echo "FEATURES_ENV=stable" >> $GITHUB_ENV uses: actions/cache@v5
else with:
sha_short=$(git rev-parse --short HEAD) path: |
./crates/node/*.node
echo "RELEASE_KIND=insiders" >> $GITHUB_ENV ./crates/node/*.wasm
echo "RELEASE_CHANNEL=insiders" >> $GITHUB_ENV ./crates/node/index.d.ts
echo "SHA_SHORT=$sha_short" >> $GITHUB_ENV ./crates/node/index.js
echo "INSIDERS_VERSION=0.0.0-insiders.$sha_short" >> $GITHUB_ENV ./crates/node/browser.js
fi ./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 - name: Setup WASM target
run: rustup target add wasm32-wasip1-threads run: rustup target add wasm32-wasip1-threads
- name: Install dependencies - name: Install dependencies
run: pnpm --filter=!./playgrounds/* install --frozen-lockfile run: pnpm --filter=!./playgrounds/* install
- name: Download artifacts - name: Download artifacts
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 uses: actions/download-artifact@v7
with: with:
path: ${{ env.OXIDE_LOCATION }} 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-linux-musl/* ./npm/linux-x64-musl/
cp bindings-x86_64-unknown-freebsd/* ./npm/freebsd-x64/ 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 - 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 run: pnpm run build
env: env:
FEATURES_ENV: ${{ env.FEATURES_ENV }} FEATURES_ENV: stable
- name: Run pre-publish optimizations scripts - name: Run pre-publish optimizations scripts
run: node ./scripts/pre-publish-optimizations.mjs run: node ./scripts/pre-publish-optimizations.mjs
@ -298,20 +285,22 @@ jobs:
- name: Lock pre-release versions - name: Lock pre-release versions
run: node ./scripts/lock-pre-release-versions.mjs run: node ./scripts/lock-pre-release-versions.mjs
- name: Upload npm package tarballs - name: Calculate environment variables
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 run: |
with: echo "RELEASE_CHANNEL=$(node ./scripts/release-channel.js)" >> $GITHUB_ENV
name: npm-package-tarballs echo "TAILWINDCSS_VERSION=$(node -e 'console.log(require(`./packages/tailwindcss/package.json`).version);')" >> $GITHUB_ENV
path: dist/*.tgz
- name: Publish - name: Publish
run: | 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 # 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 - name: Trigger Tailwind Play update
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 if: env.RELEASE_CHANNEL == 'latest'
uses: actions/github-script@v8
with: with:
github-token: ${{ secrets.TAILWIND_PLAY_TOKEN }} github-token: ${{ secrets.TAILWIND_PLAY_TOKEN }}
script: | script: |
@ -321,38 +310,3 @@ jobs:
ref: 'main', ref: 'main',
workflow_id: 'upgrade-tailwindcss.yml' 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.'

View file

@ -9,169 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Add `@tailwindcss/turbopack` package to run Tailwind CSS with Next.js ([20367](https://github.com/tailwindlabs/tailwindcss/pull/20367)) - _Experimental_: Add `@container-size` utility ([#18901](https://github.com/tailwindlabs/tailwindcss/pull/18901))
### 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 ## [4.2.2] - 2026-03-18
@ -184,7 +22,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add support for Vite 8 in `@tailwindcss/vite` ([#19790](https://github.com/tailwindlabs/tailwindcss/pull/19790)) - 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)) - 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)) - 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 ## [4.2.1] - 2026-02-23
@ -4123,13 +3960,7 @@ No release notes
- Everything! - Everything!
[unreleased]: https://github.com/tailwindlabs/tailwindcss/compare/v4.3.3...HEAD [unreleased]: https://github.com/tailwindlabs/tailwindcss/compare/v4.2.2...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.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.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.2.0]: https://github.com/tailwindlabs/tailwindcss/compare/v4.1.18...v4.2.0

247
Cargo.lock generated
View file

@ -40,7 +40,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "531a9155a481e2ee699d4f98f43c0ca4ff8ee1bfd55c31e9e98fb29d2b176fe0" checksum = "531a9155a481e2ee699d4f98f43c0ca4ff8ee1bfd55c31e9e98fb29d2b176fe0"
dependencies = [ dependencies = [
"memchr", "memchr",
"regex-automata 0.4.18", "regex-automata 0.4.8",
"serde", "serde",
] ]
@ -59,22 +59,11 @@ dependencies = [
"syn", "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]] [[package]]
name = "convert_case" name = "convert_case"
version = "0.11.0" version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f"
dependencies = [ dependencies = [
"unicode-segmentation", "unicode-segmentation",
] ]
@ -115,9 +104,19 @@ checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80"
[[package]] [[package]]
name = "ctor" name = "ctor"
version = "1.0.12" version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" 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]] [[package]]
name = "diff" name = "diff"
@ -125,6 +124,21 @@ version = "0.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" 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]] [[package]]
name = "dunce" name = "dunce"
version = "1.0.5" version = "1.0.5"
@ -137,12 +151,6 @@ version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91" checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91"
[[package]]
name = "encode_unicode"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]] [[package]]
name = "errno" name = "errno"
version = "0.3.9" version = "0.3.9"
@ -168,104 +176,16 @@ version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6" checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6"
[[package]]
name = "futures"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
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",
]
[[package]] [[package]]
name = "globset" name = "globset"
version = "0.4.20" version = "0.4.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" checksum = "eab69130804d941f8075cfd713bf8848a2c3b3f201a9457a11e6f87e1ab62305"
dependencies = [ dependencies = [
"aho-corasick", "aho-corasick",
"bstr", "bstr",
"log", "log",
"regex-automata 0.4.18", "regex-automata 0.4.8",
"regex-syntax 0.8.5", "regex-syntax 0.8.5",
] ]
@ -290,7 +210,7 @@ dependencies = [
"globset", "globset",
"log", "log",
"memchr", "memchr",
"regex-automata 0.4.18", "regex-automata 0.4.8",
"same-file", "same-file",
"walkdir", "walkdir",
"winapi-util", "winapi-util",
@ -298,7 +218,7 @@ dependencies = [
[[package]] [[package]]
name = "ignore" name = "ignore"
version = "0.4.33" version = "0.4.24"
dependencies = [ dependencies = [
"bstr", "bstr",
"crossbeam-channel", "crossbeam-channel",
@ -307,24 +227,12 @@ dependencies = [
"globset", "globset",
"log", "log",
"memchr", "memchr",
"regex-automata 0.4.18", "regex-automata 0.4.8",
"same-file", "same-file",
"walkdir", "walkdir",
"winapi-util", "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]] [[package]]
name = "itertools" name = "itertools"
version = "0.11.0" version = "0.11.0"
@ -348,12 +256,12 @@ checksum = "561d97a539a36e26a9a5fad1ea11a3039a67714694aaa379433e580854bc3dc5"
[[package]] [[package]]
name = "libloading" name = "libloading"
version = "0.9.0" version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" checksum = "4979f22fdb869068da03c9f7528f8297c6fd2606bc3a4affe42e6a823fdb8da4"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"windows-link", "windows-targets",
] ]
[[package]] [[package]]
@ -391,13 +299,12 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]] [[package]]
name = "napi" name = "napi"
version = "3.11.0" version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de33522036981030a75c231829566bc63414e08101a6f5ff4ac6cef19c8e0941" checksum = "f1b74e3dce5230795bb4d2821b941706dee733c7308752507254b0497f39cad7"
dependencies = [ dependencies = [
"bitflags", "bitflags",
"ctor", "ctor",
"futures",
"napi-build", "napi-build",
"napi-sys", "napi-sys",
"nohash-hasher", "nohash-hasher",
@ -406,15 +313,15 @@ dependencies = [
[[package]] [[package]]
name = "napi-build" name = "napi-build"
version = "2.3.2" version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9c366d2c8c60b86fa632df75f745509b52f9128f91a6bad4c796e44abb505e1" checksum = "dcae8ad5609d14afb3a3b91dee88c757016261b151e9dcecabf1b2a31a6cab14"
[[package]] [[package]]
name = "napi-derive" name = "napi-derive"
version = "3.6.0" version = "3.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a49c513341a61a16a10af6efcce46b30d0822ba2d4fb197d24d33dfc199c78d5" checksum = "7552d5a579b834614bbd496db5109f1b9f1c758f08224b0dee1e408333adf0d0"
dependencies = [ dependencies = [
"convert_case", "convert_case",
"ctor", "ctor",
@ -426,9 +333,9 @@ dependencies = [
[[package]] [[package]]
name = "napi-derive-backend" name = "napi-derive-backend"
version = "6.1.1" version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d60b5d773ad46c698c8cc2cd9fde0b283d39cbb7f71c04bee633c7bdba4423bd" checksum = "5f6a81ac7486b70f2532a289603340862c06eea5a1e650c1ffeda2ce1238516a"
dependencies = [ dependencies = [
"convert_case", "convert_case",
"proc-macro2", "proc-macro2",
@ -439,9 +346,9 @@ dependencies = [
[[package]] [[package]]
name = "napi-sys" name = "napi-sys"
version = "3.3.0" version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85fbf1fa9f1babfe396d74bbbf52b3643770243e8f5b0b46715d4caf7f0dfc9a" checksum = "3e4e7135a8f97aa0f1509cce21a8a1f9dcec1b50d8dee006b48a5adb69a9d64d"
dependencies = [ dependencies = [
"libloading", "libloading",
] ]
@ -474,9 +381,9 @@ dependencies = [
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.4" version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
[[package]] [[package]]
name = "overload" name = "overload"
@ -511,18 +418,18 @@ dependencies = [
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.45" version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" checksum = "1b9ab9c7eadfd8df19006f1cf1a4aed13540ed5cbc047010ece5826e10825488"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
] ]
[[package]] [[package]]
name = "rayon" name = "rayon"
version = "1.12.0" version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa"
dependencies = [ dependencies = [
"either", "either",
"rayon-core", "rayon-core",
@ -530,9 +437,9 @@ dependencies = [
[[package]] [[package]]
name = "rayon-core" name = "rayon-core"
version = "1.13.0" version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2"
dependencies = [ dependencies = [
"crossbeam-deque", "crossbeam-deque",
"crossbeam-utils", "crossbeam-utils",
@ -546,7 +453,7 @@ checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
dependencies = [ dependencies = [
"aho-corasick", "aho-corasick",
"memchr", "memchr",
"regex-automata 0.4.18", "regex-automata 0.4.8",
"regex-syntax 0.8.5", "regex-syntax 0.8.5",
] ]
@ -561,9 +468,9 @@ dependencies = [
[[package]] [[package]]
name = "regex-automata" name = "regex-automata"
version = "0.4.18" version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3"
dependencies = [ dependencies = [
"aho-corasick", "aho-corasick",
"memchr", "memchr",
@ -631,18 +538,6 @@ dependencies = [
"lazy_static", "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]] [[package]]
name = "smallvec" name = "smallvec"
version = "1.10.0" version = "1.10.0"
@ -651,9 +546,9 @@ checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0"
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.87" version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25aa4ce346d03a6dcd68dd8b4010bcb74e54e62c90c573f394c46eae99aba32d" checksum = "32d41677bcbe24c20c52e7c70b0d8db04134c5d1066bf98662e2871ad200ea3e"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -681,8 +576,7 @@ dependencies = [
"dunce", "dunce",
"fast-glob", "fast-glob",
"globwalk", "globwalk",
"ignore 0.4.33", "ignore 0.4.24",
"insta",
"log", "log",
"pretty_assertions", "pretty_assertions",
"rayon", "rayon",
@ -844,12 +738,6 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.52.0" version = "0.52.0"
@ -868,15 +756,6 @@ dependencies = [
"windows-targets", "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]] [[package]]
name = "windows-targets" name = "windows-targets"
version = "0.52.6" version = "0.52.6"

View file

@ -1,6 +1,6 @@
[package] [package]
name = "ignore" name = "ignore"
version = "0.4.33" #:version version = "0.4.24" #:version
authors = ["Andrew Gallant <jamslam@gmail.com>"] authors = ["Andrew Gallant <jamslam@gmail.com>"]
description = """ description = """
A fast library for efficiently matching ignore files such as `.gitignore` 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" readme = "README.md"
keywords = ["glob", "ignore", "gitignore", "pattern", "file"] keywords = ["glob", "ignore", "gitignore", "pattern", "file"]
license = "Unlicense OR MIT" 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" edition = "2024"
rust-version = "1.88"
[lib] [lib]
name = "ignore" name = "ignore"
@ -23,17 +20,15 @@ bench = false
[dependencies] [dependencies]
crossbeam-deque = "0.8.3" crossbeam-deque = "0.8.3"
# CHANGED: Use the published globset crate instead of a path dependency. globset = "0.4.17"
globset = "0.4.20"
log = "0.4.20" log = "0.4.20"
memchr = "2.6.3" memchr = "2.6.3"
same-file = "1.0.6" same-file = "1.0.6"
walkdir = "2.4.0" walkdir = "2.4.0"
# CHANGED: Added `dunce` to canonicalize paths without UNC prefixes on Windows.
dunce = "1.0.5" dunce = "1.0.5"
[dependencies.regex-automata] [dependencies.regex-automata]
version = "0.4.18" version = "0.4.0"
default-features = false default-features = false
features = ["std", "perf", "syntax", "meta", "nfa", "hybrid", "dfa-onepass"] features = ["std", "perf", "syntax", "meta", "nfa", "hybrid", "dfa-onepass"]

View file

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

View file

@ -47,7 +47,6 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[
(&["cml"], &["*.cml"]), (&["cml"], &["*.cml"]),
(&["coffeescript"], &["*.coffee"]), (&["coffeescript"], &["*.coffee"]),
(&["config"], &["*.cfg", "*.conf", "*.config", "*.ini"]), (&["config"], &["*.cfg", "*.conf", "*.config", "*.ini"]),
(&["container"], &["*Containerfile*", "*Dockerfile*"]),
(&["coq"], &["*.v"]), (&["coq"], &["*.v"]),
(&["cpp"], &[ (&["cpp"], &[
"*.[ChH]", "*.cc", "*.[ch]pp", "*.[ch]xx", "*.hh", "*.inl", "*.[ChH]", "*.cc", "*.[ch]pp", "*.[ch]xx", "*.hh", "*.inl",
@ -110,7 +109,6 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[
(&["hbs"], &["*.hbs"]), (&["hbs"], &["*.hbs"]),
(&["hs"], &["*.hs", "*.lhs"]), (&["hs"], &["*.hs", "*.lhs"]),
(&["html"], &["*.htm", "*.html", "*.ejs"]), (&["html"], &["*.htm", "*.html", "*.ejs"]),
(&["hurl"], &["*.hurl"]),
(&["hy"], &["*.hy"]), (&["hy"], &["*.hy"]),
(&["idris"], &["*.idr", "*.lidr"]), (&["idris"], &["*.idr", "*.lidr"]),
(&["janet"], &["*.janet"]), (&["janet"], &["*.janet"]),
@ -187,7 +185,6 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[
(&["mint"], &["*.mint"]), (&["mint"], &["*.mint"]),
(&["mk"], &["mkfile"]), (&["mk"], &["mkfile"]),
(&["ml"], &["*.ml"]), (&["ml"], &["*.ml"]),
(&["mojo"], &["*.mojo"]),
(&["motoko"], &["*.mo"]), (&["motoko"], &["*.mo"]),
(&["msbuild"], &[ (&["msbuild"], &[
"*.csproj", "*.fsproj", "*.vcxproj", "*.proj", "*.props", "*.targets", "*.csproj", "*.fsproj", "*.vcxproj", "*.proj", "*.props", "*.targets",
@ -209,12 +206,11 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[
"*.php", "*.php3", "*.php4", "*.php5", "*.php7", "*.php8", "*.php", "*.php3", "*.php4", "*.php5", "*.php7", "*.php8",
"*.pht", "*.phtml" "*.pht", "*.phtml"
]), ]),
(&["pkgbuild"], &["PKGBUILD"]),
(&["po"], &["*.po"]), (&["po"], &["*.po"]),
(&["pod"], &["*.pod"]), (&["pod"], &["*.pod"]),
(&["postscript"], &["*.eps", "*.ps"]), (&["postscript"], &["*.eps", "*.ps"]),
(&["prolog"], &["*.pl", "*.pro", "*.prolog", "*.P"]), (&["prolog"], &["*.pl", "*.pro", "*.prolog", "*.P"]),
(&["proto", "protobuf"], &["*.proto"]), (&["protobuf"], &["*.proto"]),
(&["ps"], &["*.cdxml", "*.ps1", "*.ps1xml", "*.psd1", "*.psm1"]), (&["ps"], &["*.cdxml", "*.ps1", "*.ps1xml", "*.psd1", "*.psm1"]),
(&["puppet"], &["*.epp", "*.erb", "*.pp", "*.rb"]), (&["puppet"], &["*.epp", "*.erb", "*.pp", "*.rb"]),
(&["purs"], &["*.purs"]), (&["purs"], &["*.purs"]),
@ -235,7 +231,6 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[
(&["red"], &["*.r", "*.red", "*.reds"]), (&["red"], &["*.r", "*.red", "*.reds"]),
(&["rescript"], &["*.res", "*.resi"]), (&["rescript"], &["*.res", "*.resi"]),
(&["robot"], &["*.robot"]), (&["robot"], &["*.robot"]),
(&["rocq"], &["*.v"]),
(&["rst"], &["*.rst"]), (&["rst"], &["*.rst"]),
(&["ruby"], &[ (&["ruby"], &[
// Idiomatic files // Idiomatic files
@ -279,7 +274,6 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[
(&["spark"], &["*.spark"]), (&["spark"], &["*.spark"]),
(&["spec"], &["*.spec"]), (&["spec"], &["*.spec"]),
(&["sql"], &["*.sql", "*.psql"]), (&["sql"], &["*.sql", "*.psql"]),
(&["ssa"], &["*.ssa"]),
(&["stylus"], &["*.styl"]), (&["stylus"], &["*.styl"]),
(&["sv"], &["*.v", "*.vg", "*.sv", "*.svh", "*.h"]), (&["sv"], &["*.v", "*.vg", "*.sv", "*.svh", "*.h"]),
(&["svelte"], &["*.svelte", "*.svelte.ts"]), (&["svelte"], &["*.svelte", "*.svelte.ts"]),
@ -365,14 +359,4 @@ mod tests {
previous_name = name; 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

@ -102,9 +102,7 @@ impl Gitignore {
/// ///
/// Note that I/O errors are ignored. For more granular control over /// Note that I/O errors are ignored. For more granular control over
/// errors, use `GitignoreBuilder`. /// errors, use `GitignoreBuilder`.
pub fn new<P: AsRef<Path>>( pub fn new<P: AsRef<Path>>(gitignore_path: P) -> (Gitignore, Option<Error>) {
gitignore_path: P,
) -> (Gitignore, Option<Error>) {
let path = gitignore_path.as_ref(); let path = gitignore_path.as_ref();
let parent = path.parent().unwrap_or(Path::new("/")); let parent = path.parent().unwrap_or(Path::new("/"));
let mut builder = GitignoreBuilder::new(parent); let mut builder = GitignoreBuilder::new(parent);
@ -125,17 +123,6 @@ impl Gitignore {
/// The global config file path is specified by git's `core.excludesFile` /// The global config file path is specified by git's `core.excludesFile`
/// config option. /// 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` /// Git's config file location is `$HOME/.gitconfig`. If `$HOME/.gitconfig`
/// does not exist or does not specify `core.excludesFile`, then /// does not exist or does not specify `core.excludesFile`, then
/// `$XDG_CONFIG_HOME/git/ignore` is read. If `$XDG_CONFIG_HOME` is not /// `$XDG_CONFIG_HOME/git/ignore` is read. If `$XDG_CONFIG_HOME` is not
@ -158,8 +145,7 @@ impl Gitignore {
num_ignores: 0, num_ignores: 0,
num_whitelists: 0, num_whitelists: 0,
matches: None, matches: None,
// CHANGED: Add a flag to have Gitignore rules that apply only to // CHANGED: Add a flag to have Gitignore rules that apply only to files.
// files.
only_on_files: false, only_on_files: false,
} }
} }
@ -204,11 +190,7 @@ impl Gitignore {
/// determined by a common suffix of the directory containing this /// determined by a common suffix of the directory containing this
/// gitignore) is stripped. If there is no common suffix/prefix overlap, /// gitignore) is stripped. If there is no common suffix/prefix overlap,
/// then `path` is assumed to be relative to this matcher. /// then `path` is assumed to be relative to this matcher.
pub fn matched<P: AsRef<Path>>( pub fn matched<P: AsRef<Path>>(&self, path: P, is_dir: bool) -> Match<&Glob> {
&self,
path: P,
is_dir: bool,
) -> Match<&Glob> {
if self.is_empty() { if self.is_empty() {
return Match::None; return Match::None;
} }
@ -261,16 +243,11 @@ impl Gitignore {
} }
/// Like matched, but takes a path that has already been stripped. /// Like matched, but takes a path that has already been stripped.
fn matched_stripped<P: AsRef<Path>>( fn matched_stripped<P: AsRef<Path>>(&self, path: P, is_dir: bool) -> Match<&Glob> {
&self,
path: P,
is_dir: bool,
) -> Match<&Glob> {
if self.is_empty() { if self.is_empty() {
return Match::None; return Match::None;
} }
// CHANGED: Rules marked as only_on_files can not match against // CHANGED: Rules marked as only_on_files can not match against directories.
// directories.
if self.only_on_files && is_dir { if self.only_on_files && is_dir {
return Match::None; return Match::None;
} }
@ -293,10 +270,7 @@ impl Gitignore {
/// Strips the given path such that it's suitable for matching with this /// Strips the given path such that it's suitable for matching with this
/// gitignore matcher. /// gitignore matcher.
fn strip<'a, P: 'a + AsRef<Path> + ?Sized>( fn strip<'a, P: 'a + AsRef<Path> + ?Sized>(&'a self, path: &'a P) -> &'a Path {
&'a self,
path: &'a P,
) -> &'a Path {
let mut path = path.as_ref(); let mut path = path.as_ref();
// A leading ./ is completely superfluous. We also strip it from // A leading ./ is completely superfluous. We also strip it from
// our gitignore root path, so we need to strip it from our candidate // our gitignore root path, so we need to strip it from our candidate
@ -352,8 +326,7 @@ impl GitignoreBuilder {
globs: vec![], globs: vec![],
case_insensitive: false, case_insensitive: false,
allow_unclosed_class: true, allow_unclosed_class: true,
// CHANGED: Add a flag to have Gitignore rules that apply only to // CHANGED: Add a flag to have Gitignore rules that apply only to files.
// files.
only_on_files: false, only_on_files: false,
} }
} }
@ -364,21 +337,18 @@ impl GitignoreBuilder {
pub fn build(&self) -> Result<Gitignore, Error> { pub fn build(&self) -> Result<Gitignore, Error> {
let nignore = self.globs.iter().filter(|g| !g.is_whitelist()).count(); let nignore = self.globs.iter().filter(|g| !g.is_whitelist()).count();
let nwhite = self.globs.iter().filter(|g| g.is_whitelist()).count(); let nwhite = self.globs.iter().filter(|g| g.is_whitelist()).count();
let set = self let set = self.builder.build().map_err(|err| Error::Glob {
.builder glob: None,
.build() err: err.to_string(),
.map_err(|err| Error::Glob { glob: None, err: err.to_string() })?; })?;
Ok(Gitignore { Ok(Gitignore {
set, set,
root: self.root.clone(), root: self.root.clone(),
globs: self.globs.clone(), globs: self.globs.clone(),
num_ignores: nignore as u64, num_ignores: nignore as u64,
num_whitelists: nwhite as u64, num_whitelists: nwhite as u64,
matches: Some(Arc::new( matches: Some(Arc::new(Pool::new(|| vec![]))),
Pool::with_available_parallelism_capacity(|| vec![]), // 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: self.only_on_files, only_on_files: self.only_on_files,
}) })
} }
@ -441,8 +411,11 @@ impl GitignoreBuilder {
// Match Git's handling of .gitignore files that begin with the Unicode BOM // Match Git's handling of .gitignore files that begin with the Unicode BOM
const UTF8_BOM: &str = "\u{feff}"; const UTF8_BOM: &str = "\u{feff}";
let line = let line = if i == 0 {
if i == 0 { line.trim_start_matches(UTF8_BOM) } else { &line }; line.trim_start_matches(UTF8_BOM)
} else {
&line
};
if let Err(err) = self.add_line(Some(path.to_path_buf()), &line) { if let Err(err) = self.add_line(Some(path.to_path_buf()), &line) {
errs.push(err.tagged(path, lineno)); errs.push(err.tagged(path, lineno));
@ -564,10 +537,7 @@ impl GitignoreBuilder {
/// affected. /// affected.
/// ///
/// This is disabled by default. /// This is disabled by default.
pub fn case_insensitive( pub fn case_insensitive(&mut self, yes: bool) -> Result<&mut GitignoreBuilder, Error> {
&mut self,
yes: bool,
) -> Result<&mut GitignoreBuilder, Error> {
// TODO: This should not return a `Result`. Fix this in the next semver // TODO: This should not return a `Result`. Fix this in the next semver
// release. // release.
self.case_insensitive = yes; self.case_insensitive = yes;
@ -586,10 +556,7 @@ impl GitignoreBuilder {
/// modes since the glob parser becomes more permissive. You might want to /// modes since the glob parser becomes more permissive. You might want to
/// enable this when compatibility (e.g., with POSIX glob implementations) /// enable this when compatibility (e.g., with POSIX glob implementations)
/// is more important than good error messages. /// is more important than good error messages.
pub fn allow_unclosed_class( pub fn allow_unclosed_class(&mut self, yes: bool) -> &mut GitignoreBuilder {
&mut self,
yes: bool,
) -> &mut GitignoreBuilder {
self.allow_unclosed_class = yes; self.allow_unclosed_class = yes;
self self
} }
@ -609,56 +576,32 @@ impl GitignoreBuilder {
/// ///
/// Note that the file path returned may not exist. /// Note that the file path returned may not exist.
pub fn gitconfig_excludes_path() -> Option<PathBuf> { pub fn gitconfig_excludes_path() -> Option<PathBuf> {
// When GIT_CONFIG_GLOBAL is set, it replaces both $HOME/.gitconfig and // git supports $HOME/.gitconfig and $XDG_CONFIG_HOME/git/config. Notably,
// $XDG_CONFIG_HOME/git/config (per git 2.32+). Otherwise, git supports // both can be active at the same time, where $HOME/.gitconfig takes
// $HOME/.gitconfig and $XDG_CONFIG_HOME/git/config simultaneously, where // precedent. So if $HOME/.gitconfig defines a `core.excludesFile`, then
// $HOME/.gitconfig takes precedent. // we're done.
gitconfig_global_env_contents() match gitconfig_home_contents().and_then(|x| parse_excludes_file(&x)) {
.and_then(|x| parse_excludes_file(&x)) Some(path) => return Some(path),
.or_else(|| { None => {}
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;
} }
let mut file = BufReader::new(File::open(path).ok()?); match gitconfig_xdg_contents().and_then(|x| parse_excludes_file(&x)) {
let mut contents = vec![]; Some(path) => return Some(path),
file.read_to_end(&mut contents).ok().map(|_| contents) None => {}
} }
excludes_file_default()
/// 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)
} }
/// Returns the file contents of git's global config file, if one exists, in /// Returns the file contents of git's global config file, if one exists, in
/// the user's home directory. /// the user's home directory.
fn gitconfig_home_contents() -> Option<Vec<u8>> { fn gitconfig_home_contents() -> Option<Vec<u8>> {
let home = home_dir()?; let home = match home_dir() {
let mut file = BufReader::new(File::open(home.join(".gitconfig")).ok()?); 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![]; let mut contents = vec![];
file.read_to_end(&mut contents).ok().map(|_| contents) file.read_to_end(&mut contents).ok().map(|_| contents)
} }
@ -667,11 +610,19 @@ fn gitconfig_home_contents() -> Option<Vec<u8>> {
/// the user's XDG_CONFIG_HOME directory. /// the user's XDG_CONFIG_HOME directory.
fn gitconfig_xdg_contents() -> Option<Vec<u8>> { fn gitconfig_xdg_contents() -> Option<Vec<u8>> {
let path = std::env::var_os("XDG_CONFIG_HOME") let path = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from) .and_then(|x| {
.filter(|x| !x.as_os_str().is_empty()) if x.is_empty() {
None
} else {
Some(PathBuf::from(x))
}
})
.or_else(|| home_dir().map(|p| p.join(".config"))) .or_else(|| home_dir().map(|p| p.join(".config")))
.map(|x| x.join("git/config"))?; .map(|x| x.join("git/config"));
let mut file = BufReader::new(File::open(path).ok()?); let mut file = match path.and_then(|p| File::open(p).ok()) {
None => return None,
Some(file) => BufReader::new(file),
};
let mut contents = vec![]; let mut contents = vec![];
file.read_to_end(&mut contents).ok().map(|_| contents) file.read_to_end(&mut contents).ok().map(|_| contents)
} }
@ -681,8 +632,13 @@ fn gitconfig_xdg_contents() -> Option<Vec<u8>> {
/// Specifically, this respects XDG_CONFIG_HOME. /// Specifically, this respects XDG_CONFIG_HOME.
fn excludes_file_default() -> Option<PathBuf> { fn excludes_file_default() -> Option<PathBuf> {
std::env::var_os("XDG_CONFIG_HOME") std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from) .and_then(|x| {
.filter(|x| !x.as_os_str().is_empty()) if x.is_empty() {
None
} else {
Some(PathBuf::from(x))
}
})
.or_else(|| home_dir().map(|p| p.join(".config"))) .or_else(|| home_dir().map(|p| p.join(".config")))
.map(|x| x.join("git/ignore")) .map(|x| x.join("git/ignore"))
} }
@ -711,7 +667,9 @@ fn parse_excludes_file(data: &[u8]) -> Option<PathBuf> {
re.captures(data, &mut caps); re.captures(data, &mut caps);
let span = caps.get_group(1)?; let span = caps.get_group(1)?;
let candidate = &data[span]; 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. /// Expands ~ in file paths to the value of $HOME.
@ -873,10 +831,7 @@ mod tests {
fn parse_excludes_file4() { fn parse_excludes_file4() {
let data = bytes("[core]\nexcludesFile = \"~/foo/bar\""); let data = bytes("[core]\nexcludesFile = \"~/foo/bar\"");
let got = super::parse_excludes_file(&data); let got = super::parse_excludes_file(&data);
assert_eq!( assert_eq!(path_string(got.unwrap()), super::expand_tilde("~/foo/bar"));
path_string(got.unwrap()),
super::expand_tilde("~/foo/bar")
);
} }
#[test] #[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}; use std::path::{Path, PathBuf};
pub use crate::incremental::{IncrementalIgnore, IncrementalMatch};
pub use crate::walk::{ pub use crate::walk::{
DirEntry, ParallelVisitor, ParallelVisitorBuilder, Walk, WalkBuilder, DirEntry, ParallelVisitor, ParallelVisitorBuilder, Walk, WalkBuilder, WalkParallel, WalkState,
WalkParallel, WalkState,
}; };
mod default_types; mod default_types;
mod dir; mod dir;
pub mod gitignore; pub mod gitignore;
mod incremental;
pub mod overrides; pub mod overrides;
mod pathutil; mod pathutil;
pub mod types; pub mod types;
@ -123,31 +120,34 @@ impl Clone for Error {
fn clone(&self) -> Error { fn clone(&self) -> Error {
match *self { match *self {
Error::Partial(ref errs) => Error::Partial(errs.clone()), Error::Partial(ref errs) => Error::Partial(errs.clone()),
Error::WithLineNumber { line, ref err } => { Error::WithLineNumber { line, ref err } => Error::WithLineNumber {
Error::WithLineNumber { line, err: err.clone() } line,
} err: err.clone(),
Error::WithPath { ref path, ref err } => { },
Error::WithPath { path: path.clone(), err: err.clone() } Error::WithPath { ref path, ref err } => Error::WithPath {
} path: path.clone(),
Error::WithDepth { depth, ref err } => { err: err.clone(),
Error::WithDepth { depth, err: err.clone() } },
} Error::WithDepth { depth, ref err } => Error::WithDepth {
Error::Loop { ref ancestor, ref child } => Error::Loop { depth,
err: err.clone(),
},
Error::Loop {
ref ancestor,
ref child,
} => Error::Loop {
ancestor: ancestor.clone(), ancestor: ancestor.clone(),
child: child.clone(), child: child.clone(),
}, },
Error::Io(ref err) => match err.raw_os_error() { Error::Io(ref err) => match err.raw_os_error() {
Some(e) => Error::Io(std::io::Error::from_raw_os_error(e)), Some(e) => Error::Io(std::io::Error::from_raw_os_error(e)),
None => { None => Error::Io(std::io::Error::new(err.kind(), err.to_string())),
Error::Io(std::io::Error::new(err.kind(), err.to_string()))
}
}, },
Error::Glob { ref glob, ref err } => { Error::Glob { ref glob, ref err } => Error::Glob {
Error::Glob { glob: glob.clone(), err: err.clone() } glob: glob.clone(),
} err: err.clone(),
Error::UnrecognizedFileType(ref err) => { },
Error::UnrecognizedFileType(err.clone()) Error::UnrecognizedFileType(ref err) => Error::UnrecognizedFileType(err.clone()),
}
Error::InvalidDefinition => Error::InvalidDefinition, Error::InvalidDefinition => Error::InvalidDefinition,
} }
} }
@ -269,14 +269,19 @@ impl Error {
/// Turn an error into a tagged error with the given depth. /// Turn an error into a tagged error with the given depth.
fn with_depth(self, depth: usize) -> Error { 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 /// 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. /// number. If path is empty, then it is omitted from the error.
fn tagged<P: AsRef<Path>>(self, path: P, lineno: u64) -> Error { fn tagged<P: AsRef<Path>>(self, path: P, lineno: u64) -> Error {
let errline = let errline = Error::WithLineNumber {
Error::WithLineNumber { line: lineno, err: Box::new(self) }; line: lineno,
err: Box::new(self),
};
if path.as_ref().as_os_str().is_empty() { if path.as_ref().as_os_str().is_empty() {
return errline; return errline;
} }
@ -296,12 +301,12 @@ impl Error {
}; };
} }
let path = err.path().map(|p| p.to_path_buf()); let path = err.path().map(|p| p.to_path_buf());
let mut ig_err = Error::WithDepth { let mut ig_err = Error::Io(std::io::Error::from(err));
depth,
err: Box::new(Error::Io(std::io::Error::from(err))),
};
if let Some(path) = path { 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 ig_err
} }
@ -328,8 +333,7 @@ impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match *self { match *self {
Error::Partial(ref errs) => { Error::Partial(ref errs) => {
let msgs: Vec<String> = let msgs: Vec<String> = errs.iter().map(|err| err.to_string()).collect();
errs.iter().map(|err| err.to_string()).collect();
write!(f, "{}", msgs.join("\n")) write!(f, "{}", msgs.join("\n"))
} }
Error::WithLineNumber { line, ref err } => { Error::WithLineNumber { line, ref err } => {
@ -339,7 +343,10 @@ impl std::fmt::Display for Error {
write!(f, "{}: {}", path.display(), err) write!(f, "{}: {}", path.display(), err)
} }
Error::WithDepth { ref err, .. } => err.fmt(f), Error::WithDepth { ref err, .. } => err.fmt(f),
Error::Loop { ref ancestor, ref child } => write!( Error::Loop {
ref ancestor,
ref child,
} => write!(
f, f,
"File system loop found: \ "File system loop found: \
{} points to an ancestor {}", {} points to an ancestor {}",
@ -347,8 +354,14 @@ impl std::fmt::Display for Error {
ancestor.display() ancestor.display()
), ),
Error::Io(ref err) => err.fmt(f), Error::Io(ref err) => err.fmt(f),
Error::Glob { glob: None, ref err } => write!(f, "{}", err), Error::Glob {
Error::Glob { glob: Some(ref glob), ref err } => { glob: None,
ref err,
} => write!(f, "{}", err),
Error::Glob {
glob: Some(ref glob),
ref err,
} => {
write!(f, "error parsing glob '{}': {}", glob, err) write!(f, "error parsing glob '{}': {}", glob, err)
} }
Error::UnrecognizedFileType(ref ty) => { Error::UnrecognizedFileType(ref ty) => {
@ -494,8 +507,7 @@ mod tests {
}; };
/// A convenient result type alias. /// A convenient result type alias.
pub(crate) type Result<T> = pub(crate) type Result<T> = std::result::Result<T, Box<dyn std::error::Error + Send + Sync>>;
std::result::Result<T, Box<dyn std::error::Error + Send + Sync>>;
macro_rules! err { macro_rules! err {
($($tt:tt)*) => { ($($tt:tt)*) => {
@ -533,9 +545,8 @@ mod tests {
if path.is_dir() { if path.is_dir() {
continue; continue;
} }
fs::create_dir_all(&path).map_err(|e| { fs::create_dir_all(&path)
err!("failed to create {}: {}", path.display(), e) .map_err(|e| err!("failed to create {}: {}", path.display(), e))?;
})?;
return Ok(TempDir(path)); return Ok(TempDir(path));
} }
Err(err!("failed to create temp dir after {} tries", TRIES)) Err(err!("failed to create temp dir after {} tries", TRIES))

View file

@ -94,11 +94,7 @@ impl Override {
/// given) is stripped. If there is no common suffix/prefix overlap, then /// 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 /// `path` is assumed to reside in the same directory as the root path for
/// this set of overrides. /// this set of overrides.
pub fn matched<'a, P: AsRef<Path>>( pub fn matched<'a, P: AsRef<Path>>(&'a self, path: P, is_dir: bool) -> Match<Glob<'a>> {
&'a self,
path: P,
is_dir: bool,
) -> Match<Glob<'a>> {
if self.is_empty() { if self.is_empty() {
return Match::None; return Match::None;
} }
@ -150,10 +146,7 @@ impl OverrideBuilder {
/// affected. /// affected.
/// ///
/// This is disabled by default. /// This is disabled by default.
pub fn case_insensitive( pub fn case_insensitive(&mut self, yes: bool) -> Result<&mut OverrideBuilder, Error> {
&mut self,
yes: bool,
) -> Result<&mut OverrideBuilder, Error> {
// TODO: This should not return a `Result`. Fix this in the next semver // TODO: This should not return a `Result`. Fix this in the next semver
// release. // release.
self.builder.case_insensitive(yes)?; self.builder.case_insensitive(yes)?;
@ -283,8 +276,11 @@ mod tests {
#[test] #[test]
fn default_case_sensitive() { fn default_case_sensitive() {
let ov = let ov = OverrideBuilder::new(ROOT)
OverrideBuilder::new(ROOT).add("*.html").unwrap().build().unwrap(); .add("*.html")
.unwrap()
.build()
.unwrap();
assert!(ov.matched("foo.html", false).is_whitelist()); assert!(ov.matched("foo.html", false).is_whitelist());
assert!(ov.matched("foo.HTML", false).is_ignore()); assert!(ov.matched("foo.HTML", false).is_ignore());
assert!(ov.matched("foo.htm", false).is_ignore()); assert!(ov.matched("foo.htm", false).is_ignore());

View file

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

View file

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

View file

@ -17,9 +17,7 @@ use {
use crate::{ use crate::{
Error, PartialErrorBuilder, Error, PartialErrorBuilder,
dir::{Ignore, IgnoreBuilder}, dir::{Ignore, IgnoreBuilder},
// CHANGED: Also import `Gitignore` for `WalkBuilder::add_gitignore`.
gitignore::{Gitignore, GitignoreBuilder}, gitignore::{Gitignore, GitignoreBuilder},
incremental::{IncrementalIgnore, IncrementalIgnoreOptions},
overrides::Override, overrides::Override,
types::Types, types::Types,
}; };
@ -106,15 +104,24 @@ impl DirEntry {
} }
fn new_stdin() -> DirEntry { fn new_stdin() -> DirEntry {
DirEntry { dent: DirEntryInner::Stdin, err: None } DirEntry {
dent: DirEntryInner::Stdin,
err: None,
}
} }
fn new_walkdir(dent: walkdir::DirEntry, err: Option<Error>) -> DirEntry { fn new_walkdir(dent: walkdir::DirEntry, err: Option<Error>) -> DirEntry {
DirEntry { dent: DirEntryInner::Walkdir(dent), err } DirEntry {
dent: DirEntryInner::Walkdir(dent),
err,
}
} }
fn new_raw(dent: DirEntryRaw, err: Option<Error>) -> DirEntry { fn new_raw(dent: DirEntryRaw, err: Option<Error>) -> DirEntry {
DirEntry { dent: DirEntryInner::Raw(dent), err } DirEntry {
dent: DirEntryInner::Raw(dent),
err,
}
} }
} }
@ -180,11 +187,9 @@ impl DirEntryInner {
)); ));
Err(err.with_path("<stdin>")) Err(err.with_path("<stdin>"))
} }
Walkdir(ref x) => x.metadata().map_err(|err| { Walkdir(ref x) => x
Error::Io(io::Error::from(err)) .metadata()
.with_depth(x.depth()) .map_err(|err| Error::Io(io::Error::from(err)).with_path(x.path())),
.with_path(x.path())
}),
Raw(ref x) => x.metadata(), Raw(ref x) => x.metadata(),
} }
} }
@ -303,9 +308,7 @@ impl DirEntryRaw {
} else { } else {
fs::symlink_metadata(&self.path) fs::symlink_metadata(&self.path)
} }
.map_err(|err| { .map_err(|err| Error::Io(io::Error::from(err)).with_path(&self.path))
Error::Io(err).with_depth(self.depth).with_path(&self.path)
})
} }
fn file_type(&self) -> FileType { fn file_type(&self) -> FileType {
@ -313,7 +316,9 @@ impl DirEntryRaw {
} }
fn file_name(&self) -> &OsStr { fn file_name(&self) -> &OsStr {
self.path.file_name().unwrap_or_else(|| self.path.as_os_str()) self.path
.file_name()
.unwrap_or_else(|| self.path.as_os_str())
} }
fn depth(&self) -> usize { fn depth(&self) -> usize {
@ -325,13 +330,13 @@ impl DirEntryRaw {
self.ino self.ino
} }
fn from_entry( fn from_entry(depth: usize, ent: &fs::DirEntry) -> Result<DirEntryRaw, Error> {
depth: usize,
ent: &fs::DirEntry,
) -> Result<DirEntryRaw, Error> {
let ty = ent.file_type().map_err(|err| { let ty = ent.file_type().map_err(|err| {
let err = Error::Io(err).with_depth(depth).with_path(ent.path()); let err = Error::Io(io::Error::from(err)).with_path(ent.path());
Error::WithDepth { depth, err: Box::new(err) } Error::WithDepth {
depth,
err: Box::new(err),
}
})?; })?;
DirEntryRaw::from_entry_os(depth, ent, ty) DirEntryRaw::from_entry_os(depth, ent, ty)
} }
@ -343,8 +348,11 @@ impl DirEntryRaw {
ty: fs::FileType, ty: fs::FileType,
) -> Result<DirEntryRaw, Error> { ) -> Result<DirEntryRaw, Error> {
let md = ent.metadata().map_err(|err| { let md = ent.metadata().map_err(|err| {
let err = Error::Io(err).with_depth(depth).with_path(ent.path()); let err = Error::Io(io::Error::from(err)).with_path(ent.path());
Error::WithDepth { depth, err: Box::new(err) } Error::WithDepth {
depth,
err: Box::new(err),
}
})?; })?;
Ok(DirEntryRaw { Ok(DirEntryRaw {
path: ent.path(), path: ent.path(),
@ -387,13 +395,8 @@ impl DirEntryRaw {
} }
#[cfg(windows)] #[cfg(windows)]
fn from_path( fn from_path(depth: usize, pb: PathBuf, link: bool) -> Result<DirEntryRaw, Error> {
depth: usize, let md = fs::metadata(&pb).map_err(|err| Error::Io(err).with_path(&pb))?;
pb: PathBuf,
link: bool,
) -> Result<DirEntryRaw, Error> {
let md = fs::metadata(&pb)
.map_err(|err| Error::Io(err).with_depth(depth).with_path(&pb))?;
Ok(DirEntryRaw { Ok(DirEntryRaw {
path: pb, path: pb,
ty: md.file_type(), ty: md.file_type(),
@ -404,15 +407,10 @@ impl DirEntryRaw {
} }
#[cfg(unix)] #[cfg(unix)]
fn from_path( fn from_path(depth: usize, pb: PathBuf, link: bool) -> Result<DirEntryRaw, Error> {
depth: usize,
pb: PathBuf,
link: bool,
) -> Result<DirEntryRaw, Error> {
use std::os::unix::fs::MetadataExt; use std::os::unix::fs::MetadataExt;
let md = fs::metadata(&pb) let md = fs::metadata(&pb).map_err(|err| Error::Io(err).with_path(&pb))?;
.map_err(|err| Error::Io(err).with_depth(depth).with_path(&pb))?;
Ok(DirEntryRaw { Ok(DirEntryRaw {
path: pb, path: pb,
ty: md.file_type(), ty: md.file_type(),
@ -425,11 +423,7 @@ impl DirEntryRaw {
// Placeholder implementation to allow compiling on non-standard platforms // Placeholder implementation to allow compiling on non-standard platforms
// (e.g. wasm32). // (e.g. wasm32).
#[cfg(not(any(windows, unix)))] #[cfg(not(any(windows, unix)))]
fn from_path( fn from_path(depth: usize, pb: PathBuf, link: bool) -> Result<DirEntryRaw, Error> {
depth: usize,
pb: PathBuf,
link: bool,
) -> Result<DirEntryRaw, Error> {
Err(Error::Io(io::Error::new( Err(Error::Io(io::Error::new(
io::ErrorKind::Other, io::ErrorKind::Other,
"unsupported platform", "unsupported platform",
@ -508,8 +502,7 @@ pub struct WalkBuilder {
/// ///
/// When `None`, the CWD is fetched from `std::env::current_dir()`. If /// When `None`, the CWD is fetched from `std::env::current_dir()`. If
/// that fails, then global gitignores are ignored (an error is logged). /// that fails, then global gitignores are ignored (an error is logged).
global_gitignores_relative_to: global_gitignores_relative_to: OnceLock<Result<PathBuf, Arc<std::io::Error>>>,
OnceLock<Result<PathBuf, Arc<std::io::Error>>>,
} }
#[derive(Clone)] #[derive(Clone)]
@ -551,16 +544,8 @@ impl WalkBuilder {
/// is better to call `add` on this builder than to create multiple /// is better to call `add` on this builder than to create multiple
/// `Walk` values. /// `Walk` values.
pub fn new<P: AsRef<Path>>(path: P) -> WalkBuilder { pub fn new<P: AsRef<Path>>(path: P) -> WalkBuilder {
WalkBuilder::from_iter([path])
}
/// Create an empty builder to which paths can be added.
///
/// Note that if you call `build` on this instance before calling `add`
/// on it, it will return exactly zero items during iteration.
pub fn empty() -> WalkBuilder {
WalkBuilder { WalkBuilder {
paths: vec![], paths: vec![path.as_ref().to_path_buf()],
ig_builder: IgnoreBuilder::new(), ig_builder: IgnoreBuilder::new(),
max_depth: None, max_depth: None,
min_depth: None, min_depth: None,
@ -575,21 +560,6 @@ impl WalkBuilder {
} }
} }
/// Create a new builder for a recursive directory iterator from the
/// sequence of paths.
///
/// Note that if the iterator is empty, this is the same as
/// `WalkBuilder::empty`.
pub fn from_iter<P: AsRef<Path>, I: IntoIterator<Item = P>>(
paths: I,
) -> WalkBuilder {
let mut builder = WalkBuilder::empty();
for path in paths.into_iter() {
builder.add(path);
}
builder
}
/// Build a new `Walk` iterator. /// Build a new `Walk` iterator.
pub fn build(&self) -> Walk { pub fn build(&self) -> Walk {
let follow_links = self.follow_links; let follow_links = self.follow_links;
@ -615,14 +585,10 @@ impl WalkBuilder {
if let Some(ref sorter) = sorter { if let Some(ref sorter) = sorter {
match sorter.clone() { match sorter.clone() {
Sorter::ByName(cmp) => { Sorter::ByName(cmp) => {
wd = wd.sort_by(move |a, b| { wd = wd.sort_by(move |a, b| cmp(a.file_name(), b.file_name()));
cmp(a.file_name(), b.file_name())
});
} }
Sorter::ByPath(cmp) => { Sorter::ByPath(cmp) => {
wd = wd.sort_by(move |a, b| { wd = wd.sort_by(move |a, b| cmp(a.path(), b.path()));
cmp(a.path(), b.path())
});
} }
} }
} }
@ -631,76 +597,31 @@ impl WalkBuilder {
}) })
.collect::<Vec<_>>() .collect::<Vec<_>>()
.into_iter(); .into_iter();
let ig_root = self.build_ignore(); let ig_root = self
.get_or_set_current_dir()
.map(|cwd| self.ig_builder.build_with_cwd(Some(cwd.to_path_buf())))
.unwrap_or_else(|| self.ig_builder.build());
Walk { Walk {
its, its,
it: None, it: None,
ig_root: ig_root.clone(), ig_root: ig_root.clone(),
ig: ig_root.clone(), ig: ig_root.clone(),
max_depth: self.max_depth,
max_filesize: self.max_filesize, max_filesize: self.max_filesize,
skip: self.skip.clone(), skip: self.skip.clone(),
filter: self.filter.clone(), filter: self.filter.clone(),
} }
} }
/// Build matchers for checking paths against ignore files without
/// recursively walking the configured roots.
///
/// The returned matchers use the path-based filtering configuration
/// on this builder, including glob overrides, file type selections,
/// parent ignore files, `.ignore`, `.gitignore`, global Git
/// ignore files, explicitly added ignore files and custom ignore
/// file names. For example, ripgrep configures `.rgignore` via
/// [`WalkBuilder::add_custom_ignore_filename`]. Minimum and maximum depth
/// limits, maximum file size and hidden-file filtering are also applied.
/// Other options that only control traversal or require a directory entry,
/// such as custom entry predicates, are not applied.
///
/// One matcher is returned for each configured path, in the same order as
/// the paths were added to this builder. Each matcher accepts paths
/// relative to its own [`IncrementalIgnore::root`]. The matcher for the
/// special `-` path representing standard input always returns a non-match
/// for all inputs.
///
/// Ignore matchers are loaded lazily and cached by directory.
/// Thus, the first query may read ignore files from the root and
/// its parents, while later queries reuse the compiled matchers.
/// Errors encountered while loading ignore files are returned by
/// [`IncrementalIgnore::matched_with_errors`]. Once an ignore file has
/// been loaded, changes to it are not observed. Build new matchers to
/// reload changed ignore files.
///
/// Matchers built together share the builder's base ignore configuration
/// and compiled parent matchers.
pub fn build_matchers(&self) -> Vec<IncrementalIgnore> {
let ignore = self.build_ignore();
let options = IncrementalIgnoreOptions {
min_depth: self.min_depth,
max_depth: self.max_depth,
max_filesize: self.max_filesize,
hidden: self.ig_builder.is_hidden(),
follow_links: self.follow_links,
};
self.paths
.iter()
.map(move |path| {
IncrementalIgnore::new(
path.clone(),
ignore.clone(),
options.clone(),
)
})
.collect()
}
/// Build a new `WalkParallel` iterator. /// Build a new `WalkParallel` iterator.
/// ///
/// Note that this *doesn't* return something that implements `Iterator`. /// Note that this *doesn't* return something that implements `Iterator`.
/// Instead, the returned value must be run with a closure. e.g., /// Instead, the returned value must be run with a closure. e.g.,
/// `builder.build_parallel().run(|| |path| { println!("{path:?}"); WalkState::Continue })`. /// `builder.build_parallel().run(|| |path| { println!("{path:?}"); WalkState::Continue })`.
pub fn build_parallel(&self) -> WalkParallel { pub fn build_parallel(&self) -> WalkParallel {
let ig_root = self.build_ignore(); let ig_root = self
.get_or_set_current_dir()
.map(|cwd| self.ig_builder.build_with_cwd(Some(cwd.to_path_buf())))
.unwrap_or_else(|| self.ig_builder.build());
WalkParallel { WalkParallel {
paths: self.paths.clone().into_iter(), paths: self.paths.clone().into_iter(),
ig_root, ig_root,
@ -730,10 +651,7 @@ impl WalkBuilder {
/// The default, `None`, imposes no depth restriction. /// The default, `None`, imposes no depth restriction.
pub fn max_depth(&mut self, depth: Option<usize>) -> &mut WalkBuilder { pub fn max_depth(&mut self, depth: Option<usize>) -> &mut WalkBuilder {
self.max_depth = depth; self.max_depth = depth;
if self.min_depth.is_some() if self.min_depth.is_some() && self.max_depth.is_some() && self.max_depth < self.min_depth {
&& self.max_depth.is_some()
&& self.max_depth < self.min_depth
{
self.max_depth = self.min_depth; self.max_depth = self.min_depth;
} }
self self
@ -744,10 +662,7 @@ impl WalkBuilder {
/// The default, `None`, imposes no minimum depth restriction. /// The default, `None`, imposes no minimum depth restriction.
pub fn min_depth(&mut self, depth: Option<usize>) -> &mut WalkBuilder { pub fn min_depth(&mut self, depth: Option<usize>) -> &mut WalkBuilder {
self.min_depth = depth; self.min_depth = depth;
if self.max_depth.is_some() if self.max_depth.is_some() && self.min_depth.is_some() && self.min_depth > self.max_depth {
&& self.min_depth.is_some()
&& self.min_depth > self.max_depth
{
self.min_depth = self.max_depth; self.min_depth = self.max_depth;
} }
self self
@ -790,12 +705,7 @@ impl WalkBuilder {
/// An error will also occur if this walker could not get the current /// An error will also occur if this walker could not get the current
/// working directory (and `WalkBuilder::current_dir` isn't set). /// working directory (and `WalkBuilder::current_dir` isn't set).
pub fn add_ignore<P: AsRef<Path>>(&mut self, path: P) -> Option<Error> { pub fn add_ignore<P: AsRef<Path>>(&mut self, path: P) -> Option<Error> {
// CHANGED: Root the ignore file at `""` instead of the current working // CHANGED: Dropped this code
// directory. Explicit ignores are scoped to the directory of the
// ignore file (see `matched_ignore`), and a root of `""` makes the
// rules apply to every walked path regardless of the walk root. This
// also avoids depending on the current working directory entirely.
//
// let path = path.as_ref(); // let path = path.as_ref();
// let Some(cwd) = self.get_or_set_current_dir() else { // let Some(cwd) = self.get_or_set_current_dir() else {
// let err = std::io::Error::other(format!( // let err = std::io::Error::other(format!(
@ -819,11 +729,7 @@ impl WalkBuilder {
errs.into_error_option() errs.into_error_option()
} }
/// CHANGED: Add a prebuilt Gitignore to the builder. /// CHANGED: Add a Gitignore to the builder.
///
/// Like the ignore file added via `add_ignore`, these rules are matched
/// against the full path of each walked entry, scoped to the `Gitignore`'s
/// root path.
pub fn add_gitignore(&mut self, gi: Gitignore) { pub fn add_gitignore(&mut self, gi: Gitignore) {
self.ig_builder.add_ignore(gi); self.ig_builder.add_ignore(gi);
} }
@ -1076,10 +982,7 @@ impl WalkBuilder {
/// ///
/// Global gitignore files come from things like a user's git configuration /// Global gitignore files come from things like a user's git configuration
/// or from gitignore files added via [`WalkBuilder::add_ignore`]. /// or from gitignore files added via [`WalkBuilder::add_ignore`].
pub fn current_dir( pub fn current_dir(&mut self, cwd: impl Into<PathBuf>) -> &mut WalkBuilder {
&mut self,
cwd: impl Into<PathBuf>,
) -> &mut WalkBuilder {
let cwd = cwd.into(); let cwd = cwd.into();
self.ig_builder.current_dir(cwd.clone()); self.ig_builder.current_dir(cwd.clone());
if let Err(cwd) = self.global_gitignores_relative_to.set(Ok(cwd)) { if let Err(cwd) = self.global_gitignores_relative_to.set(Ok(cwd)) {
@ -1099,10 +1002,7 @@ impl WalkBuilder {
let result = std::env::current_dir().map_err(Arc::new); let result = std::env::current_dir().map_err(Arc::new);
match result { match result {
Ok(ref path) => { Ok(ref path) => {
log::trace!( log::trace!("automatically discovered CWD: {}", path.display());
"automatically discovered CWD: {}",
path.display()
);
} }
Err(ref err) => { Err(ref err) => {
log::debug!( log::debug!(
@ -1116,13 +1016,6 @@ impl WalkBuilder {
}); });
result.as_ref().ok().map(|path| &**path) result.as_ref().ok().map(|path| &**path)
} }
/// Build the root ignore matcher shared by all consumers of this builder.
fn build_ignore(&self) -> Ignore {
self.get_or_set_current_dir()
.map(|cwd| self.ig_builder.build_with_cwd(Some(cwd.to_path_buf())))
.unwrap_or_else(|| self.ig_builder.build())
}
} }
/// Walk is a recursive directory iterator over file paths in one or more /// Walk is a recursive directory iterator over file paths in one or more
@ -1136,7 +1029,6 @@ pub struct Walk {
it: Option<WalkEventIter>, it: Option<WalkEventIter>,
ig_root: Ignore, ig_root: Ignore,
ig: Ignore, ig: Ignore,
max_depth: Option<usize>,
max_filesize: Option<u64>, max_filesize: Option<u64>,
skip: Option<Arc<Handle>>, skip: Option<Arc<Handle>>,
filter: Option<Filter>, filter: Option<Filter>,
@ -1152,17 +1044,6 @@ impl Walk {
WalkBuilder::new(path).build() WalkBuilder::new(path).build()
} }
/// Create a new recursive directory iterator from the sequence of paths
/// given.
///
/// Note that if the provided iterator is empty, then `Walk` is guaranteed
/// to yield zero entries.
pub fn from_iter<P: AsRef<Path>, I: IntoIterator<Item = P>>(
paths: I,
) -> Walk {
WalkBuilder::from_iter(paths).build()
}
fn skip_entry(&self, ent: &DirEntry) -> Result<bool, Error> { fn skip_entry(&self, ent: &DirEntry) -> Result<bool, Error> {
if ent.depth() == 0 { if ent.depth() == 0 {
return Ok(false); return Ok(false);
@ -1247,17 +1128,12 @@ impl Iterator for Walk {
self.it.as_mut().unwrap().it.skip_current_dir(); self.it.as_mut().unwrap().it.skip_current_dir();
// Still need to push this on the stack because // Still need to push this on the stack because
// we'll get a WalkEvent::Exit event for this dir. // we'll get a WalkEvent::Exit event for this dir.
// Its ignore files cannot apply to any visited entry. // We don't care if it errors though.
let (igtmp, _) = let (igtmp, _) = self.ig.add_child(ent.path());
self.ig.add_child_with_entries(ent.path(), &[]);
self.ig = igtmp; self.ig = igtmp;
continue; continue;
} }
let (igtmp, err) = if self.max_depth == Some(ent.depth()) { let (igtmp, err) = self.ig.add_child(ent.path());
self.ig.add_child_with_entries(ent.path(), &[])
} else {
self.ig.add_child(ent.path())
};
self.ig = igtmp; self.ig = igtmp;
ent.err = err; ent.err = err;
return Some(Ok(ent)); return Some(Ok(ent));
@ -1299,7 +1175,11 @@ enum WalkEvent {
impl From<WalkDir> for WalkEventIter { impl From<WalkDir> for WalkEventIter {
fn from(it: WalkDir) -> WalkEventIter { fn from(it: WalkDir) -> WalkEventIter {
WalkEventIter { depth: 0, it: it.into_iter(), next: None } WalkEventIter {
depth: 0,
it: it.into_iter(),
next: None,
}
} }
} }
@ -1372,9 +1252,7 @@ pub trait ParallelVisitorBuilder<'s> {
fn build(&mut self) -> Box<dyn ParallelVisitor + 's>; fn build(&mut self) -> Box<dyn ParallelVisitor + 's>;
} }
impl<'a, 's, P: ParallelVisitorBuilder<'s>> ParallelVisitorBuilder<'s> impl<'a, 's, P: ParallelVisitorBuilder<'s>> ParallelVisitorBuilder<'s> for &'a mut P {
for &'a mut P
{
fn build(&mut self) -> Box<dyn ParallelVisitor + 's> { fn build(&mut self) -> Box<dyn ParallelVisitor + 's> {
(**self).build() (**self).build()
} }
@ -1395,17 +1273,14 @@ struct FnBuilder<F> {
builder: F, builder: F,
} }
impl<'s, F: FnMut() -> FnVisitor<'s>> ParallelVisitorBuilder<'s> impl<'s, F: FnMut() -> FnVisitor<'s>> ParallelVisitorBuilder<'s> for FnBuilder<F> {
for FnBuilder<F>
{
fn build(&mut self) -> Box<dyn ParallelVisitor + 's> { fn build(&mut self) -> Box<dyn ParallelVisitor + 's> {
let visitor = (self.builder)(); let visitor = (self.builder)();
Box::new(FnVisitorImp { visitor }) Box::new(FnVisitorImp { visitor })
} }
} }
type FnVisitor<'s> = type FnVisitor<'s> = Box<dyn FnMut(Result<DirEntry, Error>) -> WalkState + Send + 's>;
Box<dyn FnMut(Result<DirEntry, Error>) -> WalkState + Send + 's>;
struct FnVisitorImp<'s> { struct FnVisitorImp<'s> {
visitor: FnVisitor<'s>, visitor: FnVisitor<'s>,
@ -1495,9 +1370,7 @@ impl WalkParallel {
} }
}; };
match DirEntryRaw::from_path(0, path, false) { match DirEntryRaw::from_path(0, path, false) {
Ok(dent) => { Ok(dent) => (DirEntry::new_raw(dent, None), root_device),
(DirEntry::new_raw(dent, None), root_device)
}
Err(err) => { Err(err) => {
if visitor.visit(Err(err)).is_quit() { if visitor.visit(Err(err)).is_quit() {
return; return;
@ -1521,11 +1394,8 @@ impl WalkParallel {
let quit_now = Arc::new(AtomicBool::new(false)); let quit_now = Arc::new(AtomicBool::new(false));
let active_workers = Arc::new(AtomicUsize::new(threads)); let active_workers = Arc::new(AtomicUsize::new(threads));
let stacks = Stack::new_for_each_thread(threads, stack); let stacks = Stack::new_for_each_thread(threads, stack);
// Collect all of the workers first. In the case that std::thread::scope(|s| {
// `builder.build()` panics, we want that to happen and let handles: Vec<_> = stacks
// propagate before we actually start to run any of the
// workers.
let workers: Vec<_> = stacks
.into_iter() .into_iter()
.map(|stack| Worker { .map(|stack| Worker {
visitor: builder.build(), visitor: builder.build(),
@ -1539,10 +1409,6 @@ impl WalkParallel {
skip: self.skip.clone(), skip: self.skip.clone(),
filter: self.filter.clone(), filter: self.filter.clone(),
}) })
.collect();
std::thread::scope(|s| {
let handles: Vec<_> = workers
.into_iter()
.map(|worker| s.spawn(|| worker.run())) .map(|worker| s.spawn(|| worker.run()))
.collect(); .collect();
for handle in handles { for handle in handles {
@ -1553,7 +1419,9 @@ impl WalkParallel {
fn threads(&self) -> usize { fn threads(&self) -> usize {
if self.threads == 0 { if self.threads == 0 {
std::thread::available_parallelism().map_or(1, |n| n.get()).min(12) std::thread::available_parallelism()
.map_or(1, |n| n.get())
.min(12)
} else { } else {
self.threads self.threads
} }
@ -1584,12 +1452,6 @@ struct Work {
root_device: Option<u64>, root_device: Option<u64>,
} }
#[derive(Default)]
struct ReadDirResult {
entries: Vec<fs::DirEntry>,
errors: Vec<Error>,
}
impl Work { impl Work {
/// Returns true if and only if this work item is a directory. /// Returns true if and only if this work item is a directory.
fn is_dir(&self) -> bool { fn is_dir(&self) -> bool {
@ -1616,13 +1478,6 @@ impl Work {
err err
} }
/// Adds ignore rules for this directory without reading its contents.
fn add_ignore(&mut self) {
let (ig, err) = self.ignore.add_child(self.dent.path());
self.ignore = ig;
self.dent.err = err;
}
/// Reads the directory contents of this work item and adds ignore /// Reads the directory contents of this work item and adds ignore
/// rules for this directory. /// rules for this directory.
/// ///
@ -1630,7 +1485,7 @@ impl Work {
/// an error is returned. If there was a problem reading the ignore /// an error is returned. If there was a problem reading the ignore
/// rules for this directory, then the error is attached to this /// rules for this directory, then the error is attached to this
/// work item's directory entry. /// work item's directory entry.
fn read_dir(&mut self) -> Result<ReadDirResult, Error> { fn read_dir(&mut self) -> Result<fs::ReadDir, Error> {
let readdir = match fs::read_dir(self.dent.path()) { let readdir = match fs::read_dir(self.dent.path()) {
Ok(readdir) => readdir, Ok(readdir) => readdir,
Err(err) => { Err(err) => {
@ -1640,24 +1495,10 @@ impl Work {
return Err(err); return Err(err);
} }
}; };
// Actually descend into the directory and read its contents let (ig, err) = self.ignore.add_child(self.dent.path());
let mut result = ReadDirResult::default();
for entry in readdir {
match entry {
Ok(entry) => result.entries.push(entry),
Err(err) => result.errors.push(
Error::from(err)
.with_path(self.dent.path())
.with_depth(self.dent.depth() + 1),
),
}
}
let (ig, err) = self
.ignore
.add_child_with_entries(self.dent.path(), &result.entries);
self.ignore = ig; self.ignore = ig;
self.dent.err = err; self.dent.err = err;
Ok(result) Ok(readdir)
} }
} }
@ -1681,11 +1522,11 @@ impl Stack {
// breadth-first. We do depth-first because a breadth first traversal // breadth-first. We do depth-first because a breadth first traversal
// on wide directories with a lot of gitignores is disastrous (for // on wide directories with a lot of gitignores is disastrous (for
// example, searching a directory tree containing all of crates.io). // example, searching a directory tree containing all of crates.io).
let deques: Vec<Deque<Message>> = let deques: Vec<Deque<Message>> = std::iter::repeat_with(Deque::new_lifo)
std::iter::repeat_with(Deque::new_lifo).take(threads).collect(); .take(threads)
let stealers = Arc::<[Stealer<Message>]>::from( .collect();
deques.iter().map(Deque::stealer).collect::<Vec<_>>(), let stealers =
); Arc::<[Stealer<Message>]>::from(deques.iter().map(Deque::stealer).collect::<Vec<_>>());
let stacks: Vec<Stack> = deques let stacks: Vec<Stack> = deques
.into_iter() .into_iter()
.enumerate() .enumerate()
@ -1827,13 +1668,8 @@ impl<'s> Worker<'s> {
// have sufficient read permissions to list the directory. // have sufficient read permissions to list the directory.
// In that case we still want to provide the closure with a valid // In that case we still want to provide the closure with a valid
// entry before passing the error value. // entry before passing the error value.
let readdir = work.read_dir();
let depth = work.dent.depth(); let depth = work.dent.depth();
let readdir = if descend && self.max_depth.is_none_or(|m| depth < m) {
Some(work.read_dir())
} else {
work.add_ignore();
None
};
if should_visit { if should_visit {
let state = self.visitor.visit(Ok(work.dent)); let state = self.visitor.visit(Ok(work.dent));
if !state.is_continue() { if !state.is_continue() {
@ -1844,10 +1680,6 @@ impl<'s> Worker<'s> {
return WalkState::Skip; return WalkState::Skip;
} }
let readdir = match readdir {
Some(readdir) => readdir,
None => return WalkState::Skip,
};
let readdir = match readdir { let readdir = match readdir {
Ok(readdir) => readdir, Ok(readdir) => readdir,
Err(err) => { Err(err) => {
@ -1855,19 +1687,11 @@ impl<'s> Worker<'s> {
} }
}; };
for result in readdir.entries { if self.max_depth.map_or(false, |max| depth >= max) {
let state = self.generate_work( return WalkState::Skip;
&work.ignore,
depth + 1,
work.root_device,
result,
);
if state.is_quit() {
return state;
} }
} for result in readdir {
for err in readdir.errors { let state = self.generate_work(&work.ignore, depth + 1, work.root_device, result);
let state = self.visitor.visit(Err(err));
if state.is_quit() { if state.is_quit() {
return state; return state;
} }
@ -1893,8 +1717,14 @@ impl<'s> Worker<'s> {
ig: &Ignore, ig: &Ignore,
depth: usize, depth: usize,
root_device: Option<u64>, root_device: Option<u64>,
fs_dent: fs::DirEntry, result: Result<fs::DirEntry, io::Error>,
) -> WalkState { ) -> WalkState {
let fs_dent = match result {
Ok(fs_dent) => fs_dent,
Err(err) => {
return self.visitor.visit(Err(Error::from(err).with_depth(depth)));
}
};
let mut dent = match DirEntryRaw::from_entry(depth, &fs_dent) { let mut dent = match DirEntryRaw::from_entry(depth, &fs_dent) {
Ok(dent) => DirEntry::new_raw(dent, None), Ok(dent) => DirEntry::new_raw(dent, None),
Err(err) => { Err(err) => {
@ -1930,8 +1760,7 @@ impl<'s> Worker<'s> {
return WalkState::Continue; return WalkState::Continue;
} }
} }
let should_skip_filesize = let should_skip_filesize = if self.max_filesize.is_some() && !dent.is_dir() {
if self.max_filesize.is_some() && !dent.is_dir() {
skip_filesize( skip_filesize(
self.max_filesize.unwrap(), self.max_filesize.unwrap(),
dent.path(), dent.path(),
@ -1940,14 +1769,17 @@ impl<'s> Worker<'s> {
} else { } else {
false false
}; };
let should_skip_filtered = let should_skip_filtered = if let Some(Filter(predicate)) = &self.filter {
if let Some(Filter(predicate)) = &self.filter {
!predicate(&dent) !predicate(&dent)
} else { } else {
false false
}; };
if !should_skip_filesize && !should_skip_filtered { if !should_skip_filesize && !should_skip_filtered {
self.send(Work { dent, ignore: ig.clone(), root_device }); self.send(Work {
dent,
ignore: ig.clone(),
root_device,
});
} }
WalkState::Continue WalkState::Continue
} }
@ -1988,9 +1820,6 @@ impl<'s> Worker<'s> {
} }
// Wait for next `Work` or `Quit` message. // Wait for next `Work` or `Quit` message.
loop { loop {
if self.is_quit_now() {
return None;
}
if let Some(v) = self.recv() { if let Some(v) = self.recv() {
self.activate_worker(); self.activate_worker();
value = Some(v); value = Some(v);
@ -2044,25 +1873,24 @@ impl<'s> Worker<'s> {
} }
} }
impl<'s> Drop for Worker<'s> {
fn drop(&mut self) {
if std::thread::panicking() {
self.quit_now();
}
}
}
fn check_symlink_loop( fn check_symlink_loop(
ig_parent: &Ignore, ig_parent: &Ignore,
child_path: &Path, child_path: &Path,
child_depth: usize, child_depth: usize,
) -> Result<(), Error> { ) -> Result<(), Error> {
let hchild = Handle::from_path(child_path).map_err(|err| { let hchild = Handle::from_path(child_path).map_err(|err| {
Error::from(err).with_path(child_path).with_depth(child_depth) Error::from(err)
.with_path(child_path)
.with_depth(child_depth)
})?; })?;
for ig in ig_parent.parents().take_while(|ig| !ig.is_absolute_parent()) { for ig in ig_parent
.parents()
.take_while(|ig| !ig.is_absolute_parent())
{
let h = Handle::from_path(ig.path()).map_err(|err| { let h = Handle::from_path(ig.path()).map_err(|err| {
Error::from(err).with_path(child_path).with_depth(child_depth) Error::from(err)
.with_path(child_path)
.with_depth(child_depth)
})?; })?;
if hchild == h { if hchild == h {
return Err(Error::Loop { return Err(Error::Loop {
@ -2077,11 +1905,7 @@ fn check_symlink_loop(
// Before calling this function, make sure that you ensure that is really // Before calling this function, make sure that you ensure that is really
// necessary as the arguments imply a file stat. // necessary as the arguments imply a file stat.
fn skip_filesize( fn skip_filesize(max_filesize: u64, path: &Path, ent: &Option<Metadata>) -> bool {
max_filesize: u64,
path: &Path,
ent: &Option<Metadata>,
) -> bool {
let filesize = match *ent { let filesize = match *ent {
Some(ref md) => Some(md.len()), Some(ref md) => Some(md.len()),
None => None, None => None,
@ -2153,9 +1977,9 @@ fn path_equals(dent: &DirEntry, handle: &Handle) -> Result<bool, Error> {
if dent.is_stdin() || never_equal(dent, handle) { if dent.is_stdin() || never_equal(dent, handle) {
return Ok(false); return Ok(false);
} }
Handle::from_path(dent.path()).map(|h| &h == handle).map_err(|err| { Handle::from_path(dent.path())
Error::Io(err).with_depth(dent.depth()).with_path(dent.path()) .map(|h| &h == handle)
}) .map_err(|err| Error::Io(err).with_path(dent.path()))
} }
/// Returns true if the given walkdir entry corresponds to a directory. /// Returns true if the given walkdir entry corresponds to a directory.
@ -2173,14 +1997,16 @@ fn walkdir_is_dir(dent: &walkdir::DirEntry) -> bool {
if !dent.file_type().is_symlink() || dent.depth() > 0 { if !dent.file_type().is_symlink() || dent.depth() > 0 {
return false; return false;
} }
dent.path().metadata().ok().map_or(false, |md| md.file_type().is_dir()) dent.path()
.metadata()
.ok()
.map_or(false, |md| md.file_type().is_dir())
} }
/// Returns true if and only if the given path is on the same device as the /// Returns true if and only if the given path is on the same device as the
/// given root device. /// given root device.
fn is_same_file_system(root_device: u64, path: &Path) -> Result<bool, Error> { fn is_same_file_system(root_device: u64, path: &Path) -> Result<bool, Error> {
let dent_device = let dent_device = device_num(path).map_err(|err| Error::Io(err).with_path(path))?;
device_num(path).map_err(|err| Error::Io(err).with_path(path))?;
Ok(root_device == dent_device) Ok(root_device == dent_device)
} }
@ -2239,7 +2065,11 @@ mod tests {
} }
fn normal_path(unix: &str) -> String { fn normal_path(unix: &str) -> String {
if cfg!(windows) { unix.replace("\\", "/") } else { unix.to_string() } if cfg!(windows) {
unix.replace("\\", "/")
} else {
unix.to_string()
}
} }
fn walk_collect(prefix: &Path, builder: &WalkBuilder) -> Vec<String> { fn walk_collect(prefix: &Path, builder: &WalkBuilder) -> Vec<String> {
@ -2259,10 +2089,7 @@ mod tests {
paths paths
} }
fn walk_collect_parallel( fn walk_collect_parallel(prefix: &Path, builder: &WalkBuilder) -> Vec<String> {
prefix: &Path,
builder: &WalkBuilder,
) -> Vec<String> {
let mut paths = vec![]; let mut paths = vec![];
for dent in walk_collect_entries_parallel(builder) { for dent in walk_collect_entries_parallel(builder) {
let path = dent.path().strip_prefix(prefix).unwrap(); let path = dent.path().strip_prefix(prefix).unwrap();
@ -2451,27 +2278,6 @@ mod tests {
); );
} }
#[test]
fn max_depth_does_not_load_unreachable_ignore_files() {
let td = tmpdir();
let leaf = td.path().join("leaf");
mkdirp(&leaf);
wfile(leaf.join(".ignore"), "{invalid\n");
let mut builder = WalkBuilder::new(td.path());
builder.max_depth(Some(1));
let entry = builder
.build()
.find_map(|result| {
let entry = result.unwrap();
(entry.path() == leaf).then_some(entry)
})
.unwrap();
assert!(entry.error().is_none());
assert_paths(td.path(), &builder, &["leaf"]);
}
#[test] #[test]
fn min_depth() { fn min_depth() {
let td = tmpdir(); let td = tmpdir();
@ -2582,9 +2388,7 @@ mod tests {
assert_eq!(1, dents.len()); assert_eq!(1, dents.len());
assert!(!dents[0].path_is_symlink()); assert!(!dents[0].path_is_symlink());
let dents = walk_collect_entries_parallel(&WalkBuilder::new( let dents = walk_collect_entries_parallel(&WalkBuilder::new(td.path().join("foo")));
td.path().join("foo"),
));
assert_eq!(1, dents.len()); assert_eq!(1, dents.len());
assert!(!dents[0].path_is_symlink()); assert!(!dents[0].path_is_symlink());
} }
@ -2670,88 +2474,8 @@ mod tests {
assert_paths( assert_paths(
td.path(), td.path(),
&WalkBuilder::new(td.path()) &WalkBuilder::new(td.path()).filter_entry(|entry| entry.file_name() != OsStr::new("a")),
.filter_entry(|entry| entry.file_name() != OsStr::new("a")),
&["x", "x/y", "x/y/foo"], &["x", "x/y", "x/y/foo"],
); );
} }
#[test]
fn empty() {
let td = tmpdir();
assert_paths(td.path(), &WalkBuilder::empty(), &[]);
let empty_paths: Vec<&OsStr> = Vec::new();
assert_paths(td.path(), &WalkBuilder::from_iter(empty_paths), &[]);
}
#[test]
fn from_iter() {
let td = tmpdir();
mkdirp(td.path().join("a/b/c"));
mkdirp(td.path().join("d/e/f"));
mkdirp(td.path().join("x/y"));
wfile(td.path().join("a/b/foo"), "");
wfile(td.path().join("d/e/f/foo"), "");
wfile(td.path().join("x/y/foo"), "");
let paths = vec![
td.path().join("a"),
td.path().join("d"),
td.path().join("x"),
];
assert_paths(
td.path(),
&WalkBuilder::from_iter(paths),
&[
"x",
"x/y",
"x/y/foo",
"d",
"d/e",
"d/e/f",
"d/e/f/foo",
"a",
"a/b",
"a/b/foo",
"a/b/c",
],
);
}
// This should always panic and never hang.
//
// Ref: https://github.com/BurntSushi/ripgrep/issues/3009
#[test]
#[should_panic]
fn panic_in_parallel() {
let td = tmpdir();
wfile(td.path().join("foo.txt"), "");
WalkBuilder::new(td.path())
.threads(40)
.build_parallel()
.run(|| Box::new(|_| panic!("oops!")));
}
// This should always panic and never hang. The first call to the visitor
// builder is used while processing the root paths. Previously, a panic on
// the third call occurred after the first worker had already been spawned,
// leaving it waiting indefinitely for workers that were never created.
#[test]
#[should_panic(expected = "builder panic")]
fn panic_in_parallel_builder() {
let td = tmpdir();
wfile(td.path().join("foo.txt"), "");
let mut builds = 0;
WalkBuilder::new(td.path()).threads(2).build_parallel().run(|| {
builds += 1;
if builds == 3 {
panic!("builder panic");
}
Box::new(|_| WalkState::Continue)
});
}
} }

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
{ {
"name": "@tailwindcss/oxide-android-arm-eabi", "name": "@tailwindcss/oxide-android-arm-eabi",
"version": "4.3.3", "version": "4.2.2",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git",

View file

@ -1,6 +1,6 @@
{ {
"name": "@tailwindcss/oxide-android-arm64", "name": "@tailwindcss/oxide-android-arm64",
"version": "4.3.3", "version": "4.2.2",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git",

View file

@ -1,6 +1,6 @@
{ {
"name": "@tailwindcss/oxide-darwin-arm64", "name": "@tailwindcss/oxide-darwin-arm64",
"version": "4.3.3", "version": "4.2.2",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git",

View file

@ -1,6 +1,6 @@
{ {
"name": "@tailwindcss/oxide-darwin-x64", "name": "@tailwindcss/oxide-darwin-x64",
"version": "4.3.3", "version": "4.2.2",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git",

View file

@ -1,6 +1,6 @@
{ {
"name": "@tailwindcss/oxide-freebsd-x64", "name": "@tailwindcss/oxide-freebsd-x64",
"version": "4.3.3", "version": "4.2.2",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git",

View file

@ -1,6 +1,6 @@
{ {
"name": "@tailwindcss/oxide-linux-arm-gnueabihf", "name": "@tailwindcss/oxide-linux-arm-gnueabihf",
"version": "4.3.3", "version": "4.2.2",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git",

View file

@ -1,6 +1,6 @@
{ {
"name": "@tailwindcss/oxide-linux-arm64-gnu", "name": "@tailwindcss/oxide-linux-arm64-gnu",
"version": "4.3.3", "version": "4.2.2",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git",

View file

@ -1,6 +1,6 @@
{ {
"name": "@tailwindcss/oxide-linux-arm64-musl", "name": "@tailwindcss/oxide-linux-arm64-musl",
"version": "4.3.3", "version": "4.2.2",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git",

View file

@ -1,6 +1,6 @@
{ {
"name": "@tailwindcss/oxide-linux-x64-gnu", "name": "@tailwindcss/oxide-linux-x64-gnu",
"version": "4.3.3", "version": "4.2.2",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git",

View file

@ -1,6 +1,6 @@
{ {
"name": "@tailwindcss/oxide-linux-x64-musl", "name": "@tailwindcss/oxide-linux-x64-musl",
"version": "4.3.3", "version": "4.2.2",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git",

View file

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

View file

@ -1,3 +1,3 @@
# `@tailwindcss/oxide-wasm32-wasi` # `@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", "name": "@tailwindcss/oxide-wasm32-wasi",
"version": "4.3.3", "version": "4.2.2",
"cpu": [
"wasm32"
],
"main": "tailwindcss-oxide.wasi.cjs", "main": "tailwindcss-oxide.wasi.cjs",
"files": [ "files": [
"tailwindcss-oxide.wasm32-wasi.wasm", "tailwindcss-oxide.wasm32-wasi.wasm",
@ -11,7 +14,7 @@
], ],
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": "^20.19.0 || ^22.13.0 || >=23.5.0" "node": ">=14.0.0"
}, },
"publishConfig": { "publishConfig": {
"provenance": true, "provenance": true,
@ -24,11 +27,11 @@
}, },
"browser": "tailwindcss-oxide.wasi-browser.js", "browser": "tailwindcss-oxide.wasi-browser.js",
"dependencies": { "dependencies": {
"@napi-rs/wasm-runtime": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.1",
"@emnapi/core": "^1.11.3", "@emnapi/core": "^1.8.1",
"@emnapi/runtime": "^1.11.3", "@emnapi/runtime": "^1.8.1",
"@tybys/wasm-util": "^0.10.3", "@tybys/wasm-util": "^0.10.1",
"@emnapi/wasi-threads": "^1.2.3", "@emnapi/wasi-threads": "^1.1.0",
"tslib": "^2.8.1" "tslib": "^2.8.1"
}, },
"bundledDependencies": [ "bundledDependencies": [

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", "name": "@tailwindcss/oxide-win32-arm64-msvc",
"version": "4.3.3", "version": "4.2.2",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git",

View file

@ -1,6 +1,6 @@
{ {
"name": "@tailwindcss/oxide-win32-x64-msvc", "name": "@tailwindcss/oxide-win32-x64-msvc",
"version": "4.3.3", "version": "4.2.2",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git",

View file

@ -1,6 +1,6 @@
{ {
"name": "@tailwindcss/oxide", "name": "@tailwindcss/oxide",
"version": "4.3.3", "version": "4.2.2",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://github.com/tailwindlabs/tailwindcss.git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git",
@ -33,11 +33,9 @@
}, },
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@emnapi/core": "1.11.3", "@napi-rs/cli": "3.4.1",
"@emnapi/runtime": "1.11.3", "@napi-rs/wasm-runtime": "^1.1.1",
"@napi-rs/cli": "3.7.4", "emnapi": "1.8.1"
"@napi-rs/wasm-runtime": "^1.2.2",
"emnapi": "1.11.3"
}, },
"engines": { "engines": {
"node": ">= 20" "node": ">= 20"

View file

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

View file

@ -20,7 +20,6 @@ ignore = { path = "../ignore" }
regex = "1.11.1" regex = "1.11.1"
[dev-dependencies] [dev-dependencies]
insta = "1.48.0"
tempfile = "3.13.0" tempfile = "3.13.0"
pretty_assertions = "1.4.1" pretty_assertions = "1.4.1"
unicode-width = "0.2.0" unicode-width = "0.2.0"

View file

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

View file

@ -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 // https://github.com/tailwindlabs/tailwindcss/issues/17050
#[test] #[test]
fn test_haml_syntax() { fn test_haml_syntax() {

View file

@ -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 // Replace following characters with spaces if they are not inside of brackets
b'#' | b'=' if bracket_stack.is_empty() => { b'#' | b'=' if bracket_stack.is_empty() => {
result[cursor.pos] = b' '; result[cursor.pos] = b' ';
@ -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 // https://github.com/tailwindlabs/tailwindcss/pull/17051#issuecomment-2711181352
#[test] #[test]
fn test_haml_full_file_17051() { fn test_haml_full_file_17051() {

View file

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

View file

@ -34,9 +34,8 @@ impl PreProcessor for Ruby {
// Extract embedded template languages // Extract embedded template languages
// https://viewcomponent.org/guide/templates.html#interpolations // https://viewcomponent.org/guide/templates.html#interpolations
// Only process if content is valid UTF-8, otherwise skip HEREDOC extraction let content_as_str = std::str::from_utf8(content).unwrap();
// 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 let starts = TEMPLATE_START_REGEX
.captures_iter(content_as_str) .captures_iter(content_as_str)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@ -76,7 +75,6 @@ impl PreProcessor for Ruby {
break; break;
} }
} }
}
// Ruby extraction // Ruby extraction
while cursor.pos < len { while cursor.pos < len {
@ -158,15 +156,6 @@ impl PreProcessor for Ruby {
continue; 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'}')
{
cursor.advance();
continue;
}
cursor.advance_twice(); cursor.advance_twice();
// Boundary character // Boundary character
@ -174,13 +163,8 @@ impl PreProcessor for Ruby {
b'[' => b']', b'[' => b']',
b'(' => b')', b'(' => b')',
b'{' => b'}', b'{' => b'}',
b'<' => b'>', b'#' => b'#',
b' ' => b'\n', 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(); cursor.advance();
continue; continue;
@ -197,8 +181,8 @@ impl PreProcessor for Ruby {
while cursor.pos < len { while cursor.pos < len {
match cursor.curr() { match cursor.curr() {
// Skip escaped characters, unless the backslash is the delimiter itself // Skip escaped characters
b'\\' if boundary != b'\\' => { b'\\' => {
// Use backslash to embed spaces in the strings. // Use backslash to embed spaces in the strings.
if cursor.next() == b' ' { if cursor.next() == b' ' {
result[cursor.pos] = b' '; result[cursor.pos] = b' ';
@ -212,11 +196,6 @@ impl PreProcessor for Ruby {
bracket_stack.push(cursor.curr()); 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());
}
// End of a nested bracket // End of a nested bracket
b']' | b')' | b'}' if !bracket_stack.is_empty() => { b']' | b')' | b'}' if !bracket_stack.is_empty() => {
if !bracket_stack.pop(cursor.curr()) { if !bracket_stack.pop(cursor.curr()) {
@ -225,14 +204,6 @@ impl PreProcessor for Ruby {
} }
} }
// End of a nested `<…>`
b'>' if boundary == b'>' && !bracket_stack.is_empty() => {
if !bracket_stack.pop(cursor.curr()) {
// Unbalanced
cursor.advance();
}
}
// End of the pattern, replace the boundary character with a space // End of the pattern, replace the boundary character with a space
_ if cursor.curr() == boundary => { _ if cursor.curr() == boundary => {
if boundary != b'\n' { if boundary != b'\n' {
@ -280,24 +251,6 @@ mod tests {
"%w(flex data-[state=pending]:bg-(--my-color) flex-col)", "%w(flex data-[state=pending]:bg-(--my-color) flex-col)",
"%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 …\n
("%w flex px-2.5\n", "%w flex px-2.5\n"), ("%w flex px-2.5\n", "%w flex px-2.5\n"),
@ -375,20 +328,6 @@ mod tests {
"%w(flex data-[state=pending]:bg-(--my-color) flex-col)", "%w(flex data-[state=pending]:bg-(--my-color) flex-col)",
vec!["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]", "# test\n# test\n# {ActiveRecord::Base#save!}[rdoc-ref:Persistence#save!]\n%w[flex px-2.5]",
@ -505,24 +444,4 @@ mod tests {
vec!["text-amber-600", "text-sky-500", "text-green-500"], 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

@ -104,12 +104,6 @@ impl Rust {
b'[' => { b'[' => {
bracket_stack.push(cursor.curr()); 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' ';
}
} }
b']' if !bracket_stack.is_empty() => { b']' if !bracket_stack.is_empty() => {
@ -219,20 +213,4 @@ mod tests {
let input = r#"html! { \x.px-4.text-black { } }"#; let input = r#"html! { \x.px-4.text-black { } }"#;
Rust::test(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

@ -65,74 +65,17 @@ impl PreProcessor for Slim {
// class=%w[bg-blue-500 w-10 h-10] // 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') b'%' if matches!(cursor.next(), b'w' | b'W')
&& !cursor.prev().is_ascii_alphanumeric() && matches!(cursor.input.get(cursor.pos + 2), Some(b'[' | b'(' | b'{')) =>
&& !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 `%` result[cursor.pos] = b' '; // Replace `%`
cursor.advance(); cursor.advance();
result[cursor.pos] = b' '; // Replace `w` result[cursor.pos] = b' '; // Replace `w`
cursor.advance(); cursor.advance();
result[cursor.pos] = b' '; // Replace the opening delimiter result[cursor.pos] = b' '; // Replace `[` or `(` or `{`
cursor.advance(); bracket_stack.push(cursor.curr());
cursor.advance(); // Move past the bracket
// Paired delimiters can be nested as long as they are balanced. E.g.: continue;
// `%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();
}
} }
// Any `[` preceded by an alphanumeric value will not be part of a candidate. // Any `[` preceded by an alphanumeric value will not be part of a candidate.
@ -373,77 +316,16 @@ mod tests {
] ]
"#; "#;
let expected = " let expected = r#"
div \n class= bg-blue-500 w-10 h-10 \n ] div
div \n class= w-10 bg-green-500 h-10 \n ] class= bg-blue-500 w-10 h-10]
"; ]
div
class= w-10 bg-green-500 h-10]
]
"#;
Slim::test(input, expected); Slim::test(input, expected);
Slim::test_extract_contains(input, vec!["bg-blue-500", "bg-green-500", "w-10", "h-10"]); 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

@ -74,8 +74,8 @@
^^^^^^^^^^ ^^^^^^ ^^^^^^^^^^ ^^^^^^
libraries, our components are designed to be fast, flexible, and easy to 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 = doc_example(css: "mt-8 lg:mt-0 lg:basis-3/5 h-44") do
^^^ ^^^^ ^^^^^^^ ^^^^^^^^^^^^ ^^^^ ^^ ^^^ ^^^^ ^^^^^^^ ^^^^^^^^^^^^ ^^^^ ^^
@ -101,7 +101,7 @@
%p.mt-2 %p.mt-2
^^^^ ^^^^
Utilize Utlize
= daisy_link("HAML", "https://haml.info/", target: "_blank") = daisy_link("HAML", "https://haml.info/", target: "_blank")
^^^^^^ ^^^^^^
so your views are simple, concise, and easy to understand. so your views are simple, concise, and easy to understand.
@ -119,8 +119,8 @@
%p.mt-2 %p.mt-2
^^^^ ^^^^
:markdown :markdown
**PLUS!** You can utilize filters like _Markdown_, CoffeeScript, **PLUS!** You can utlize filters like _Markdown_, CoffeeScript,
^^^ ^^^^^^^ ^^^^^^^ ^^^^ ^^^ ^^^^^^ ^^^^^^^ ^^^^
Textile, and many more! Textile, and many more!
^^^ ^^^^ ^^^^^ ^^^ ^^^^ ^^^^^

View file

@ -42,7 +42,7 @@
= daisy_link "DaisyUI", "https://daisyui.com/", target: "_blank" = daisy_link "DaisyUI", "https://daisyui.com/", target: "_blank"
= daisy_link "TailwindCSS", "https://tailwindcss.com/", target: "_blank" = daisy_link "TailwindCSS", "https://tailwindcss.com/", target: "_blank"
libraries, our components are designed to be fast, flexible, and easy to 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 = 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 .flex.flex-col.sm:flex-row.items-center.gap-4
@ -58,7 +58,7 @@
Simple, Concise Views Simple, Concise Views
%p.mt-2 %p.mt-2
Utilize Utlize
= daisy_link("HAML", "https://haml.info/", target: "_blank") = daisy_link("HAML", "https://haml.info/", target: "_blank")
so your views are simple, concise, and easy to understand. so your views are simple, concise, and easy to understand.
@ -69,7 +69,7 @@
%p.mt-2 %p.mt-2
:markdown :markdown
**PLUS!** You can utilize filters like _Markdown_, CoffeeScript, **PLUS!** You can utlize filters like _Markdown_, CoffeeScript,
Textile, and many more! Textile, and many more!
.mt-8.xl:mt-0 .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

@ -14,9 +14,7 @@ pub struct Vue;
impl PreProcessor for Vue { impl PreProcessor for Vue {
fn process(&self, content: &[u8]) -> Vec<u8> { fn process(&self, content: &[u8]) -> Vec<u8> {
let mut result = content.to_vec(); let mut result = content.to_vec();
let content_as_str = std::str::from_utf8(content).unwrap();
// 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 for (_, [lang, body]) in TEMPLATE_REGEX
.captures_iter(content_as_str) .captures_iter(content_as_str)
.map(|c| c.extract()) .map(|c| c.extract())
@ -24,7 +22,6 @@ impl PreProcessor for Vue {
let replaced = pre_process_input(body.as_bytes().to_vec(), lang); let replaced = pre_process_input(body.as_bytes().to_vec(), lang);
result = result.replace(body, replaced); result = result.replace(body, replaced);
} }
}
result result
} }
@ -45,16 +42,4 @@ mod tests {
Vue::test_extract_contains(input, vec!["bg-neutral-900", "text-red-500"]); 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

@ -112,7 +112,6 @@ pub fn resolve_globs(
} }
if !dirs.contains(path) { if !dirs.contains(path) {
it.skip_current_dir();
continue; continue;
} }

View file

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

View file

@ -37,52 +37,20 @@ pub fn init_tracing() {
return; return;
} }
let root = Path::new(".tailwindcss"); let file_path = format!("tailwindcss-{}.log", std::process::id());
let logs_dir = root.join("logs"); let file = OpenOptions::new()
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) .create(true)
.append(true) .append(true)
.open(&file_path) .open(&file_path)
{ .unwrap_or_else(|_| panic!("Failed to 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()); let file_path = Path::new(&file_path);
let absolute_file_path = dunce::canonicalize(file_path)
.unwrap_or_else(|_| panic!("Failed to canonicalize {file_path:?}"));
eprintln!( eprintln!(
"{} Writing debug info to: {}\n", "{} Writing debug info to: {}\n",
dim("[DEBUG]"), dim("[DEBUG]"),
highlight(&absolute_file_path.display().to_string()) highlight(absolute_file_path.as_path().to_str().unwrap())
); );
let file = Arc::new(Mutex::new(file)); let file = Arc::new(Mutex::new(file));

View file

@ -17,6 +17,7 @@ use fxhash::{FxHashMap, FxHashSet};
use ignore::{gitignore::GitignoreBuilder, WalkBuilder}; use ignore::{gitignore::GitignoreBuilder, WalkBuilder};
use init_tracing::{init_tracing, SHOULD_TRACE}; use init_tracing::{init_tracing, SHOULD_TRACE};
use rayon::prelude::*; use rayon::prelude::*;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::SystemTime; use std::time::SystemTime;
@ -78,12 +79,11 @@ pub struct Scanner {
/// Track unique set of candidates /// Track unique set of candidates
candidates: FxHashSet<String>, candidates: FxHashSet<String>,
/// Track mtimes for files so incremental scans can skip unchanged files. /// Track mtimes for files so re-scans can skip unchanged files.
/// Only populated after the first scan completes (to avoid unnecessary
/// metadata calls on initial build).
mtimes: FxHashMap<PathBuf, SystemTime>, 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 /// Whether we've completed at least one full scan. When false, we skip
/// mtime tracking entirely so the initial build stays fast. /// mtime tracking entirely so the initial build stays fast.
has_scanned_once: bool, has_scanned_once: bool,
@ -123,10 +123,9 @@ impl Scanner {
pub fn scan(&mut self) -> Vec<String> { pub fn scan(&mut self) -> Vec<String> {
self.sources_scanned = false; self.sources_scanned = false;
let (scanned_blobs, css_files, files) = self.discover_sources(); let (scanned_blobs, css_files) = self.discover_sources();
self.extract_candidates(scanned_blobs, css_files); self.extract_candidates(scanned_blobs, css_files);
self.scanned_files = files;
// Return all candidates sorted // Return all candidates sorted
let mut result = self.candidates.iter().cloned().collect::<Vec<_>>(); let mut result = self.candidates.iter().cloned().collect::<Vec<_>>();
@ -181,11 +180,6 @@ impl Scanner {
continue; 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![]; let mut drop_file_indexes = vec![];
for (idx, changed_file) in new_unknown_files.iter().enumerate().rev() { for (idx, changed_file) in new_unknown_files.iter().enumerate().rev() {
let ChangedContent::File(file, _) = changed_file else { let ChangedContent::File(file, _) = changed_file else {
@ -194,15 +188,7 @@ impl Scanner {
// When the file is found on disk it means that all the rules pass. We can // 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. // extract the current file and remove it from the list of passed in files.
let matches = file == path if 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 self.files.insert(path.to_path_buf()); // Track for future use
content_to_scan.push(changed_file.clone()); // Track for parsing content_to_scan.push(changed_file.clone()); // Track for parsing
drop_file_indexes.push(idx); drop_file_indexes.push(idx);
@ -271,11 +257,6 @@ impl Scanner {
.collect() .collect()
} }
#[tracing::instrument(skip_all)]
pub fn get_scanned_files(&self) -> Vec<String> {
self.scanned_files.clone()
}
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
pub fn get_globs(&mut self) -> Vec<GlobEntry> { pub fn get_globs(&mut self) -> Vec<GlobEntry> {
if let Some(globs) = &self.globs { if let Some(globs) = &self.globs {
@ -356,14 +337,15 @@ impl Scanner {
let i = s.as_ptr() as usize - offset; let i = s.as_ptr() as usize - offset;
let original = &original_content[i..i + s.len()]; let original = &original_content[i..i + s.len()];
if original.contains_str("-[]") { if original.contains_str("-[]") {
return String::from_utf8(original.to_vec()) return Some(unsafe {
.ok() (String::from_utf8_unchecked(original.to_vec()), i)
.map(|candidate| (candidate, i)); });
} }
String::from_utf8(s.to_vec()) // SAFETY: When we parsed the candidates, we already guaranteed that the byte
.ok() // slices are valid, therefore we don't have to re-check here when we want to
.map(|candidate| (candidate, i)) // convert it back to a string.
Some(unsafe { (String::from_utf8_unchecked(s.to_vec()), i) })
} }
_ => None, _ => None,
@ -372,14 +354,14 @@ impl Scanner {
} }
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
fn discover_sources(&mut self) -> (Vec<Vec<u8>>, Vec<PathBuf>, Vec<String>) { fn discover_sources(&mut self) -> (Vec<Vec<u8>>, Vec<PathBuf>) {
if self.sources_scanned { if self.sources_scanned {
return (vec![], vec![], vec![]); return (vec![], vec![]);
} }
self.sources_scanned = true; self.sources_scanned = true;
let Some(walker) = &mut self.walker else { let Some(walker) = &mut self.walker else {
return (vec![], vec![], vec![]); return (vec![], vec![]);
}; };
// Use synchronous walk for the initial build (lower overhead) and parallel // Use synchronous walk for the initial build (lower overhead) and parallel
@ -391,74 +373,27 @@ impl Scanner {
}; };
let mut css_files: Vec<PathBuf> = vec![]; let mut css_files: Vec<PathBuf> = vec![];
let mut content_paths: Vec<(PathBuf, String)> = vec![]; let mut content_paths: Vec<(PathBuf, String)> = Vec::new();
let mut changed_files = vec![]; let mut seen_files: FxHashSet<PathBuf> = FxHashSet::default();
// Fresh state for (path, is_dir, extension) in all_entries {
self.files.clear(); if is_dir {
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); self.dirs.insert(path);
} } else {
WalkEntry::File {
path,
mtime,
is_symlink,
} => {
// Deduplicate: parallel walk can visit the same file from multiple threads // Deduplicate: parallel walk can visit the same file from multiple threads
if !self.files.insert(path.clone()) { if !seen_files.insert(path.clone()) {
continue; continue;
} }
// Track canonicalized paths in addition to potentially symlinked file paths // On re-scans, check mtime to skip unchanged files.
let canonical = if is_symlink { // On the first scan we skip this entirely to avoid extra
dunce::canonicalize(&path).ok() // metadata syscalls.
} 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 { let changed = if self.has_scanned_once {
match mtime { let current_mtime = std::fs::metadata(&path)
.ok()
.and_then(|m| m.modified().ok());
match current_mtime {
Some(mtime) => { Some(mtime) => {
let prev = self.mtimes.insert(path.clone(), mtime); let prev = self.mtimes.insert(path.clone(), mtime);
prev.is_none_or(|prev| prev != mtime) prev.is_none_or(|prev| prev != mtime)
@ -466,33 +401,28 @@ impl Scanner {
None => true, None => true,
} }
} else { } else {
if let Some(mtime) = mtime {
self.mtimes.insert(path.clone(), mtime);
}
true true
}; };
if !changed {
continue;
}
if let Ok(file) = path.clone().into_os_string().into_string() {
changed_files.push(file);
}
match extension.as_str() { match extension.as_str() {
// Special handing for CSS files, we don't want to extract candidates from // Special handing for CSS files, we don't want to extract candidates from
// these files, but we do want to extract used CSS variables. // these files, but we do want to extract used CSS variables.
"css" => css_files.push(path), "css" => {
_ => content_paths.push((path, extension)), if changed {
css_files.push(path.clone());
} }
} }
_ => {
if changed {
content_paths.push((path.clone(), extension.clone()));
}
} }
} }
// Ensure `mtimes` don't include stale files self.extensions.insert(extension);
self.mtimes.retain(|path, _| self.files.contains(path)); self.files.insert(path);
}
}
// Read + preprocess all discovered files in parallel // Read + preprocess all discovered files in parallel
let scanned_blobs: Vec<Vec<u8>> = content_paths let scanned_blobs: Vec<Vec<u8>> = content_paths
@ -513,9 +443,7 @@ impl Scanner {
self.has_scanned_once = true; self.has_scanned_once = true;
} }
changed_files.par_sort_unstable(); (scanned_blobs, css_files)
(scanned_blobs, css_files, changed_files)
} }
} }
@ -546,15 +474,13 @@ pub fn pre_process_input(content: Vec<u8>, extension: &str) -> Vec<u8> {
"heex" | "eex" | "ex" | "exs" => Elixir.process(&content), "heex" | "eex" | "ex" | "exs" => Elixir.process(&content),
"cshtml" | "razor" => Razor.process(&content), "cshtml" | "razor" => Razor.process(&content),
"haml" => Haml.process(&content), "haml" => Haml.process(&content),
"json" | "jsonl" | "ndjson" => Json.process(&content), "json" => Json.process(&content),
"md" | "mdx" => Markdown.process(&content), "md" | "mdx" => Markdown.process(&content),
"pug" => Pug.process(&content), "pug" => Pug.process(&content),
"rb" | "erb" => Ruby.process(&content), "rb" | "erb" => Ruby.process(&content),
"slim" | "slang" => Slim.process(&content), "slim" | "slang" => Slim.process(&content),
"svelte" => Svelte.process(&content), "svelte" => Svelte.process(&content),
"rs" => Rust.process(&content), "rs" => Rust.process(&content),
"tt" | "tt2" | "tx" => TemplateToolkit.process(&content),
"twig" => Twig.process(&content),
"vue" => Vue.process(&content), "vue" => Vue.process(&content),
_ => content, _ => content,
} }
@ -616,50 +542,35 @@ where
a a
}) })
.into_iter() .into_iter()
.filter_map(|s| String::from_utf8(s.to_vec()).ok()) .map(|s| unsafe { String::from_utf8_unchecked(s.to_vec()) })
.collect() .collect()
} }
#[derive(Debug)] type WalkEntry = (PathBuf, bool, String);
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 /// Walk the file system synchronously. Used for the initial build where the overhead of spawning
/// parallel walker threads is not worth it. /// parallel walker threads is not worth it.
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
fn walk_synchronous(walker: &mut WalkBuilder) -> Vec<WalkEntry> { fn walk_synchronous(walker: &mut WalkBuilder) -> Vec<WalkEntry> {
walker let mut entries = Vec::new();
.build()
.filter_map(Result::ok) for entry in walker.build().filter_map(Result::ok) {
.map(WalkEntry::from) let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
.collect() let path = entry.into_path();
if is_dir {
entries.push((path, true, String::new()));
} else {
let ext = path
.extension()
.and_then(|x| x.to_str())
.unwrap_or_default()
.to_owned();
entries.push((path, false, ext));
}
}
entries
} }
/// Walk the file system in parallel. Used in watch mode where the parallel walker overhead is /// Walk the file system in parallel. Used in watch mode where the parallel walker overhead is
@ -679,7 +590,7 @@ fn walk_parallel(walker: &mut WalkBuilder) -> Vec<WalkEntry> {
} }
} }
let collected: Arc<Mutex<Vec<WalkEntry>>> = Arc::new(Mutex::new(vec![])); let collected: Arc<Mutex<Vec<WalkEntry>>> = Arc::new(Mutex::new(Vec::new()));
walker.build_parallel().run(|| { walker.build_parallel().run(|| {
let mut buf = FlushOnDrop { let mut buf = FlushOnDrop {
@ -692,7 +603,19 @@ fn walk_parallel(walker: &mut WalkBuilder) -> Vec<WalkEntry> {
return ignore::WalkState::Continue; return ignore::WalkState::Continue;
}; };
buf.local.push(WalkEntry::from(entry)); let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
let path = entry.into_path();
if is_dir {
buf.local.push((path, true, String::new()));
} else {
let ext = path
.extension()
.and_then(|x| x.to_str())
.unwrap_or_default()
.to_owned();
buf.local.push((path, false, ext));
}
if buf.local.len() >= 256 { if buf.local.len() >= 256 {
buf.shared.lock().unwrap().append(&mut buf.local); buf.shared.lock().unwrap().append(&mut buf.local);
@ -713,16 +636,7 @@ fn walk_parallel(walker: &mut WalkBuilder) -> Vec<WalkEntry> {
fn create_walker(sources: &Sources) -> Option<WalkBuilder> { fn create_walker(sources: &Sources) -> Option<WalkBuilder> {
let mut other_roots: FxHashSet<&PathBuf> = FxHashSet::default(); let mut other_roots: FxHashSet<&PathBuf> = FxHashSet::default();
let mut first_root: Option<&PathBuf> = None; let mut first_root: Option<&PathBuf> = None;
let mut ignores: BTreeMap<&PathBuf, BTreeSet<String>> = Default::default();
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]));
}
};
for source in sources.iter() { for source in sources.iter() {
match source { match source {
@ -734,7 +648,7 @@ fn create_walker(sources: &Sources) -> Option<WalkBuilder> {
} }
} }
SourceEntry::Pattern { base, pattern } => { SourceEntry::Pattern { base, pattern } => {
let pattern = pattern.to_owned(); let mut pattern = pattern.to_string();
if first_root.is_none() { if first_root.is_none() {
first_root = Some(base); first_root = Some(base);
@ -743,19 +657,35 @@ fn create_walker(sources: &Sources) -> Option<WalkBuilder> {
} }
if !pattern.contains("**") { 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: // Specific patterns should take precedence even over git-ignored files:
emit(base, format!("!{}", pattern)); ignores
.entry(base)
.or_default()
.insert(format!("!{}", pattern));
} else { } else {
// Assumption: the pattern we receive will already be brace expanded. So // Assumption: the pattern we receive will already be brace expanded. So
// `*.{html,jsx}` will result in two separate patterns: `*.html` and `*.jsx`. // `*.{html,jsx}` will result in two separate patterns: `*.html` and `*.jsx`.
if let Some(extension) = Path::new(&pattern).extension() { if let Some(extension) = Path::new(&pattern).extension() {
// Extend auto source detection to include the 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 } => { 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 } => { SourceEntry::External { base } => {
if first_root.is_none() { if first_root.is_none() {
@ -765,10 +695,16 @@ fn create_walker(sources: &Sources) -> Option<WalkBuilder> {
} }
// External sources should take precedence even over git-ignored files: // 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: // External sources should still disallow binary extensions:
emit(base, BINARY_EXTENSIONS_GLOB.clone()); ignores
.entry(base)
.or_default()
.insert(BINARY_EXTENSIONS_GLOB.clone());
} }
} }
} }
@ -857,7 +793,14 @@ fn create_walker(sources: &Sources) -> Option<WalkBuilder> {
let pattern_sources: Vec<(PathBuf, String)> = sources let pattern_sources: Vec<(PathBuf, String)> = sources
.iter() .iter()
.filter_map(|source| match source { .filter_map(|source| match source {
SourceEntry::Pattern { base, pattern } => Some((base.into(), pattern.into())), SourceEntry::Pattern { base, pattern } => {
let normalized = if pattern.starts_with("/") {
pattern.to_string()
} else {
format!("/{pattern}")
};
Some((base.clone(), normalized))
}
_ => None, _ => None,
}) })
.collect(); .collect();

View file

@ -1,8 +1,7 @@
use crate::glob::split_pattern;
use crate::GlobEntry; use crate::GlobEntry;
use bexpand::Expression; use bexpand::Expression;
use fxhash::{FxHashMap, FxHashSet}; use std::path::PathBuf;
use ignore::gitignore::Gitignore;
use std::path::{Component, Path, PathBuf};
use tracing::{event, Level}; use tracing::{event, Level};
use super::auto_source_detection::IGNORED_CONTENT_DIRS; use super::auto_source_detection::IGNORED_CONTENT_DIRS;
@ -50,14 +49,13 @@ pub enum SourceEntry {
/// ``` /// ```
Ignored { base: PathBuf, pattern: String }, Ignored { base: PathBuf, pattern: String },
/// External sources are directories that are ignored (by us or .gitignore rules), but should be /// External sources are sources outside of your git root which should not
/// included bypassing the default ignore rules. /// follow gitignore rules.
/// ///
/// Represented by: /// Represented by:
/// ///
/// ```css /// ```css
/// @source "../node_modules/my-lib";` /// @source "../node_modules/my-lib";`
/// @source "../node_modules/my-lib/**/*";`
/// ``` /// ```
External { base: PathBuf }, 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 { impl PublicSourceEntry {
/// Optimize the PublicSourceEntry by trying to move all the static parts of the pattern to the /// Optimize the PublicSourceEntry by trying to move all the static parts of the pattern to the
/// base of the PublicSourceEntry. /// base of the PublicSourceEntry.
@ -262,456 +102,72 @@ impl PublicSourceEntry {
/// resolved path. /// resolved path.
pub fn optimize(&mut self) { pub fn optimize(&mut self) {
// Resolve base path immediately // Resolve base path immediately
let Ok(mut base) = dunce::canonicalize(&self.base) else { let Ok(base) = dunce::canonicalize(&self.base) else {
event!(Level::ERROR, "Failed to resolve base: {:?}", self.base); event!(Level::ERROR, "Failed to resolve base: {:?}", self.base);
return; return;
}; };
self.base = base.to_string_lossy().to_string();
let mut new_pattern = PathBuf::new(); // No dynamic part, figure out if we are dealing with a file or a directory.
enum ComponentStage { if !self.pattern.contains('*') {
Base, let combined_path = if self.pattern.starts_with("/") {
Pattern, PathBuf::from(&self.pattern)
}
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 => {}
// 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 { } else {
new_pattern.push(part); PathBuf::from(&self.base).join(&self.pattern)
};
match dunce::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;
} }
// When we're dealing with an absolute path, then we have to bypass the // Contains dynamic part
// `base` entirely. let (static_part, dynamic_part) = split_pattern(&self.pattern);
Component::Prefix(_) => {
base.clear(); let base: PathBuf = self.base.clone().into();
base.push(component); let base = match static_part {
} Some(static_part) => {
Component::RootDir => { // TODO: If the base does not exist on disk, try removing the last slash and try
#[cfg(not(windows))] // again.
base.clear(); match dunce::canonicalize(base.join(static_part)) {
base.push(component); Ok(base) => base,
Err(err) => {
event!(tracing::Level::ERROR, "Failed to resolve glob: {:?}", err);
return;
} }
} }
} }
ComponentStage::Pattern => { None => base,
new_pattern.push(component); };
}
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.base = base.to_string_lossy().to_string();
self.pattern = path_to_posix_string(&new_pattern); self.pattern = 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 });
} }
} }
@ -762,111 +218,18 @@ pub fn public_source_entries_to_private_source_entries(
}) })
.collect::<Vec<_>>(); .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 // Convert from public SourceEntry to private SourceEntry
let sources = expanded_globs expanded_globs
.into_iter() .into_iter()
.map(|public_source| { .map(Into::into)
let mut source: SourceEntry = public_source.into(); .collect::<Vec<_>>()
// 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)
} }
/// Convert a public source entry to a source entry /// Convert a public source entry to a source entry
impl From<PublicSourceEntry> for SourceEntry { impl From<PublicSourceEntry> for SourceEntry {
fn from(value: PublicSourceEntry) -> Self { fn from(value: PublicSourceEntry) -> Self {
if value.negated { let auto = value.pattern.ends_with("**/*")
return SourceEntry::Ignored { || PathBuf::from(&value.base).join(&value.pattern).is_dir();
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 inside_ignored_content_dir = IGNORED_CONTENT_DIRS.iter().any(|dir| { let inside_ignored_content_dir = IGNORED_CONTENT_DIRS.iter().any(|dir| {
value.base.contains(&format!( value.base.contains(&format!(
@ -876,16 +239,24 @@ impl From<PublicSourceEntry> for SourceEntry {
std::path::MAIN_SEPARATOR std::path::MAIN_SEPARATOR
)) || value )) || value
.base .base
.ends_with(&format!("{}{}", std::path::MAIN_SEPARATOR, dir)) .ends_with(&format!("{}{}", std::path::MAIN_SEPARATOR, dir,))
}); });
match inside_ignored_content_dir { match (value.negated, auto, inside_ignored_content_dir) {
false => SourceEntry::Auto { (false, true, false) => SourceEntry::Auto {
base: value.base.into(), base: value.base.into(),
}, },
true => SourceEntry::External { (false, true, true) => SourceEntry::External {
base: value.base.into(), 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( test(
'Config files (CJS, watch mode)', 'Config files (CJS, watch mode)',
{ {

View file

@ -375,120 +375,6 @@ describe.each([
}, },
) )
test(
'watch mode with polling',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "workspace:^",
"@tailwindcss/cli": "workspace:^"
}
}
`,
'src/index.css': css`@import 'tailwindcss/utilities';`,
'src/index.html': html`
<div class="underline"></div>
`,
},
},
async ({ fs, spawn }) => {
let process = await spawn(
`${command} --input src/index.css --output dist/out.css --watch --poll=50`,
)
await process.onStderr((m) => m.includes('Done in'))
await fs.expectFileToContain('dist/out.css', [candidate`underline`])
await fs.write('src/index.html', html`
<div class="underline flex"></div>
`)
await fs.expectFileToContain('dist/out.css', [candidate`flex`])
},
)
test(
"watch mode with unknown @source paths shouldn't crash on Windows",
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "workspace:^",
"@tailwindcss/cli": "workspace:^"
}
}
`,
'index.html': html`
<div class="underline"></div>
`,
'src/index.css': css`
@import 'tailwindcss';
@source "unknown-folder/**/*";
`,
},
},
async ({ fs, spawn }) => {
let process = await spawn(`${command} --input src/index.css --output dist/out.css --watch`)
await process.onStderr((m) => m.includes('Done in'))
await fs.expectFileToContain('dist/out.css', [candidate`underline`])
},
)
// https://github.com/tailwindlabs/tailwindcss/issues/17632
test(
'watch mode rebuilds when the input file is in an ignored folder',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "workspace:^",
"@tailwindcss/cli": "workspace:^"
}
}
`,
'assets/tailwind.config.js': js`
module.exports = {
content: {
relative: true,
files: ['html/*.html'],
},
}
`,
'assets/html/index.html': html`
<div class="underline"></div>
`,
'assets/css/index.css': css`
@import 'tailwindcss' source(none);
@config '../tailwind.config.js';
`,
},
},
async ({ fs, spawn }) => {
let process = await spawn(
`${command} --input assets/css/index.css --output dist/out.css --watch`,
)
await process.onStderr((m) => m.includes('Done in'))
await fs.expectFileToContain('dist/out.css', [candidate`underline`])
await fs.write(
'assets/css/index.css',
css`
@import 'tailwindcss' source(none);
@config '../tailwind.config.js';
@source inline("flex");
`,
)
await fs.expectFileToContain('dist/out.css', [candidate`flex`])
},
)
test( test(
'production build (stdin)', 'production build (stdin)',
{ {
@ -673,15 +559,6 @@ describe.each([
'git ignore files outside of a repo are not considered', 'git ignore files outside of a repo are not considered',
{ {
fs: { fs: {
// Make the test root a pnpm workspace that includes `home/project` so
// the install below resolves the transitive dependency overrides that
// the test harness injects into this `pnpm-workspace.yaml`.
'pnpm-workspace.yaml': yaml`
#
packages:
- home/project
`,
// Ignore everything in the "home" directory // Ignore everything in the "home" directory
'home/.gitignore': '*', 'home/.gitignore': '*',
@ -719,7 +596,7 @@ describe.each([
installDependencies: false, installDependencies: false,
}, },
async ({ fs, root, exec }) => { async ({ fs, root, exec }) => {
await exec(`pnpm install`, { await exec(`pnpm install --ignore-workspace`, {
cwd: path.join(root, 'home/project'), cwd: path.join(root, 'home/project'),
}) })
@ -767,7 +644,7 @@ describe.each([
} }
} }
`, `,
'src/index.html': html` 'ssrc/index.html': html`
<div class="flex"></div> <div class="flex"></div>
`, `,
'src/index.css': css` 'src/index.css': css`
@ -828,7 +705,7 @@ describe.each([
} }
} }
`, `,
'src/index.html': html` 'ssrc/index.html': html`
<div class="flex"></div> <div class="flex"></div>
`, `,
'src/index.css': css` 'src/index.css': css`
@ -894,7 +771,7 @@ describe.each([
} }
} }
`, `,
'src/index.html': html` 'ssrc/index.html': html`
<div class="flex"></div> <div class="flex"></div>
`, `,
'src/index.css': css` 'src/index.css': css`
@ -955,7 +832,7 @@ describe.each([
} }
} }
`, `,
'src/index.html': html` 'ssrc/index.html': html`
<div class="flex"></div> <div class="flex"></div>
`, `,
'src/index.css': css` 'src/index.css': css`
@ -1188,7 +1065,7 @@ describe.each([
} }
} }
`, `,
'src/index.html': html` 'ssrc/index.html': html`
<div class="flex"></div> <div class="flex"></div>
`, `,
'src/index.css': css` 'src/index.css': css`
@ -1417,86 +1294,6 @@ describe.each([
}) })
}, },
) )
test(
'watch mode should trigger a full rebuild when a dependency is removed',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "workspace:^",
"@tailwindcss/cli": "workspace:^"
}
}
`,
'src/index.html': html`
<div class="flex text-primary"></div>
`,
'src/index.css': css`
@import 'tailwindcss/utilities';
@config '../tailwind.config.js';
`,
'tailwind.config.js': js`
const myColor = require('./my-color')
module.exports = {
theme: {
extend: {
colors: {
primary: myColor,
},
},
},
}
`,
'my-color.js': js`
//
module.exports = 'blue'
`,
},
},
async ({ spawn, fs, expect }) => {
let process = await spawn(`${command} --input src/index.css --output dist/out.css --watch`)
await process.onStderr((m) => m.includes('Done in'))
expect(await fs.dumpFiles('dist/*.css')).toMatchInlineSnapshot(`
"
--- dist/out.css ---
.flex {
display: flex;
}
.text-primary {
color: blue;
}
"
`)
// Remove the dependency of the tailwind.config.js file
await fs.delete('my-color.js')
// We expect an error
await process.onStderr((m) => m.includes('Error'))
await process.onStderr((m) => m.includes('Done in'))
// Re-create the file to resolve the issue
await fs.write('my-color.js', js`module.exports = 'red'`)
await process.onStderr((m) => m.includes('Done in'))
// Expect a full rebuild
expect(await fs.dumpFiles('dist/*.css')).toMatchInlineSnapshot(`
"
--- dist/out.css ---
.flex {
display: flex;
}
.text-primary {
color: red;
}
"
`)
},
)
}) })
test( test(
@ -2061,7 +1858,7 @@ test(
`, `,
}, },
}, },
async ({ fs, exec, root, expect }) => { async ({ fs, exec, spawn, root, expect }) => {
await exec('pnpm tailwindcss --input ./index.css --output dist/out.css', { cwd: root }) await exec('pnpm tailwindcss --input ./index.css --output dist/out.css', { cwd: root })
let content = await fs.dumpFiles('./dist/*.css') let content = await fs.dumpFiles('./dist/*.css')
@ -2072,335 +1869,6 @@ test(
}, },
) )
test(
'@source order is important (referencing sibling project)',
{
fs: {
'package.json': json`{}`,
'pnpm-workspace.yaml': yaml`
#
packages:
- project-a
`,
'project-a/package.json': json`
{
"dependencies": {
"tailwindcss": "workspace:^",
"@tailwindcss/cli": "workspace:^"
}
}
`,
'project-a/src/index.css': css`
@import 'tailwindcss/utilities' source(none);
@source '../../project-b';
@source not '../../project-b/ignored';
@source '../../project-b/ignored/except.html';
`,
'project-b/keep/keep.html': html`<div class="content-['GOOD-1']"></div>`,
'project-b/ignored/ignored.html': html`<div class="content-['BAD-1']"></div>`,
'project-b/ignored/except.html': html`<div class="content-['GOOD-2']"></div>`,
},
},
async ({ fs, root, exec, expect }) => {
await exec('pnpm tailwindcss --input src/index.css --output dist/out.css', {
cwd: path.join(root, 'project-a'),
})
expect(await fs.dumpFiles('./project-a/dist/*.css')).toMatchInlineSnapshot(`
"
--- ./project-a/dist/out.css ---
@layer properties;
.content-\\[\\'GOOD-1\\'\\] {
--tw-content: 'GOOD-1';
content: var(--tw-content);
}
.content-\\[\\'GOOD-2\\'\\] {
--tw-content: 'GOOD-2';
content: var(--tw-content);
}
@property --tw-content {
syntax: "*";
inherits: false;
initial-value: "";
}
@layer properties {
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
*, ::before, ::after, ::backdrop {
--tw-content: "";
}
}
}
"
`)
},
)
test(
'@source works with symlinks (referencing sibling project)',
{
fs: {
'package.json': json`{}`,
'pnpm-workspace.yaml': yaml`
#
packages:
- project-a
`,
'project-a/package.json': json`
{
"dependencies": {
"tailwindcss": "workspace:^",
"@tailwindcss/cli": "workspace:^"
}
}
`,
'project-a/src/index.css': css`
@import 'tailwindcss/utilities' source(none);
/* Same as the previous test, but using symlinks instead */
@source '../../project-b';
@source not '../../project-b/ignored';
@source '../../project-b/ignored/except.html';
`,
'project-b/keep/keep.html': html`<div class="content-['GOOD-1']"></div>`,
'project-c/ignored/ignored.html': html`<div class="content-['BAD-1']"></div>`,
'project-c/ignored/except.html': html`<div class="content-['GOOD-2']"></div>`,
// Symlink the ignored folder to another project
'project-b/ignored': 'symlink:../../project-c/ignored/',
},
},
async ({ fs, root, exec, expect }) => {
await exec('pnpm tailwindcss --input src/index.css --output dist/out.css', {
cwd: path.join(root, 'project-a'),
})
expect(await fs.dumpFiles('./project-a/dist/*.css')).toMatchInlineSnapshot(`
"
--- ./project-a/dist/out.css ---
@layer properties;
.content-\\[\\'GOOD-1\\'\\] {
--tw-content: 'GOOD-1';
content: var(--tw-content);
}
.content-\\[\\'GOOD-2\\'\\] {
--tw-content: 'GOOD-2';
content: var(--tw-content);
}
@property --tw-content {
syntax: "*";
inherits: false;
initial-value: "";
}
@layer properties {
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
*, ::before, ::after, ::backdrop {
--tw-content: "";
}
}
}
"
`)
},
)
// https://github.com/tailwindlabs/tailwindcss/issues/19844
test(
'@source scans directories ignored by allow-list .gitignore files',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "workspace:^",
"@tailwindcss/cli": "workspace:^"
}
}
`,
'.gitignore': txt`
*
!/app
!/app/design
!/app/design/**
`,
'src/index.css': css`
@import 'tailwindcss/utilities' source(none);
@source '../vendor/acme/theme';
`,
// 1. Ignored by the `*` in `.gitignore`
// 2. Included by the `!` pattern in `.gitignore`
// 3. Ignored by `source(none)`
//
// → Should be ignored
'app/design/frontend/theme/templates/component.phtml': html`
<div
class="content-['app/design/frontend/theme/templates/component.phtml']"
></div>
`,
// 1. Ignored by the `*` in `.gitignore`
// 2. Included by the `!` pattern in `.gitignore`
// 3. Ignored by `source(none)`
// 4. Included by the `@source` directive
//
// → Should be included
'vendor/acme/theme/module/templates/component.phtml': html`
<div
class="content-['vendor/acme/theme/module/templates/component.phtml']"
></div>
`,
},
},
async ({ fs, exec }) => {
await exec('pnpm tailwindcss --input src/index.css --output dist/out.css')
// 1. Ignored by the `*` in `.gitignore`
// 2. Included by the `!` pattern in `.gitignore`
// 3. Ignored by `source(none)`
//
// → Should be ignored
await fs.expectFileNotToContain('dist/out.css', [
candidate`content-['app/design/frontend/theme/templates/component.phtml']`,
])
// 1. Ignored by the `*` in `.gitignore`
// 2. Included by the `!` pattern in `.gitignore`
// 3. Ignored by `source(none)`
// 4. Included by the `@source` directive
//
// → Should be included
await fs.expectFileToContain('dist/out.css', [
candidate`content-['vendor/acme/theme/module/templates/component.phtml']`,
])
},
)
// https://github.com/tailwindlabs/tailwindcss/discussions/20382
test(
'auto source detection respects allow-list .gitignore files',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "workspace:^",
"@tailwindcss/cli": "workspace:^"
}
}
`,
'.gitignore': txt`
/*
!/.gitignore
!/app
!/public
!/package.json
`,
'app/root.css': css` @import 'tailwindcss/utilities'; `,
// Included by the `!/app` pattern in `.gitignore`
//
// → Should be included
'app/index.html': html`
<div class="content-['app/index.html']"></div>
`,
// Included by the `!/public` pattern in `.gitignore`
//
// → Should be included
'public/index.html': html`
<div class="content-['public/index.html']"></div>
`,
// Ignored by the `/*` in `.gitignore`
//
// → Should be ignored
'build/index.html': html`
<div class="content-['build/index.html']"></div>
`,
// Ignored by the `/*` in `.gitignore` and the default `node_modules` rules
//
// → Should be ignored
'node_modules/my-ui-lib/index.html': html`
<div
class="content-['node_modules/my-ui-lib/index.html']"
></div>
`,
},
},
async ({ fs, exec }) => {
await exec('pnpm tailwindcss --input app/root.css --output dist/out.css')
await fs.expectFileToContain('dist/out.css', [
candidate`content-['app/index.html']`,
candidate`content-['public/index.html']`,
])
await fs.expectFileNotToContain('dist/out.css', [
candidate`content-['build/index.html']`,
candidate`content-['node_modules/my-ui-lib/index.html']`,
])
},
)
test(
'@source works with symlinks (referencing folder in current folder)',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "workspace:^",
"@tailwindcss/cli": "workspace:^"
}
}
`,
'src/index.css': css`
@import 'tailwindcss/utilities' source(none);
/* Same as the previous test, but using symlinks instead */
@source '../lib';
@source not '../lib/ignored';
@source '../lib/ignored/except.html';
`,
'lib/keep/keep.html': html`<div class="content-['GOOD-1']"></div>`,
'vendor/ignored/ignored.html': html`<div class="content-['BAD-1']"></div>`,
'vendor/ignored/except.html': html`<div class="content-['GOOD-2']"></div>`,
// Symlink the ignored folder to another folder
'lib/ignored': 'symlink:../../vendor/ignored/',
},
},
async ({ fs, exec, expect }) => {
await exec('pnpm tailwindcss --input src/index.css --output dist/out.css')
expect(await fs.dumpFiles('./dist/*.css')).toMatchInlineSnapshot(`
"
--- ./dist/out.css ---
@layer properties;
.content-\\[\\'GOOD-1\\'\\] {
--tw-content: 'GOOD-1';
content: var(--tw-content);
}
.content-\\[\\'GOOD-2\\'\\] {
--tw-content: 'GOOD-2';
content: var(--tw-content);
}
@property --tw-content {
syntax: "*";
inherits: false;
initial-value: "";
}
@layer properties {
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
*, ::before, ::after, ::backdrop {
--tw-content: "";
}
}
}
"
`)
},
)
test( test(
'auto source detection disabled', 'auto source detection disabled',
{ {
@ -2749,49 +2217,6 @@ test(
}, },
) )
test(
'suppress output by using the --silent flag',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "workspace:^",
"@tailwindcss/cli": "workspace:^"
}
}
`,
'index.css': css` @import 'tailwindcss/utilities';`,
'index.html': html`<div class="flex"></div>`,
},
},
async ({ fs, exec, expect }) => {
let output = await exec('pnpm tailwindcss --input index.css --output dist/loud.css')
expect(output).toContain('Done in')
let silentOutput = await exec(
'pnpm tailwindcss --input index.css --output dist/silent.css --silent',
)
expect(silentOutput).not.toContain('Done in')
expect(silentOutput).not.toContain('tailwindcss v')
expect(await fs.dumpFiles('./dist/*.css')).toMatchInlineSnapshot(`
"
--- ./dist/loud.css ---
.flex {
display: flex;
}
--- ./dist/silent.css ---
.flex {
display: flex;
}
"
`)
},
)
test( test(
'changes to CSS files should pick up new CSS variables (if any)', 'changes to CSS files should pick up new CSS variables (if any)',
{ {
@ -2895,8 +2320,8 @@ test(
@layer theme, base, components, utilities; @layer theme, base, components, utilities;
@layer theme { @layer theme {
:root, :host { :root, :host {
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', 'Noto Sans', Arial, --font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; 'Noto Color Emoji';
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace; monospace;
--color-red-500: oklch(63.7% 0.237 25.331); --color-red-500: oklch(63.7% 0.237 25.331);
@ -2915,7 +2340,7 @@ test(
line-height: 1.5; line-height: 1.5;
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
tab-size: 4; tab-size: 4;
font-family: var(--default-font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', 'Noto Sans', Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'); font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji');
font-feature-settings: var(--default-font-feature-settings, normal); font-feature-settings: var(--default-font-feature-settings, normal);
font-variation-settings: var(--default-font-variation-settings, normal); font-variation-settings: var(--default-font-variation-settings, normal);
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
@ -2967,7 +2392,7 @@ test(
border-color: inherit; border-color: inherit;
border-collapse: collapse; border-collapse: collapse;
} }
:-moz-focusring:where(:not(iframe)) { :-moz-focusring {
outline: auto; outline: auto;
} }
progress { progress {

View file

@ -1,4 +1,4 @@
import { candidate, css, html, json, test, ts } from '../utils' import { candidate, css, html, json, test } from '../utils'
test( test(
'builds the `@tailwindcss/typography` plugin utilities', 'builds the `@tailwindcss/typography` plugin utilities',
@ -42,86 +42,12 @@ test(
await fs.expectFileToContain('dist/out.css', [ await fs.expectFileToContain('dist/out.css', [
candidate`prose`, candidate`prose`,
':where(h1):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"] *))', ':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( test(
'builds the `@tailwindcss/forms` plugin utilities', 'builds the `@tailwindcss/forms` plugin utilities',
{ {
@ -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 os from 'node:os'
import path from 'node:path' import path from 'node:path'
import { candidate, css, html, IS_WINDOWS, js, json, test, ts } from '../utils' import { candidate, css, html, js, json, test, ts } from '../utils'
const STANDALONE_BINARY = (() => { const STANDALONE_BINARY = (() => {
switch (os.platform()) { 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( test(
'includes first-party plugins', 'includes first-party plugins',
{ {

View file

@ -1,4 +1,4 @@
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` runtime.
// //
@ -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", "version": "0.0.0",
"private": true, "private": true,
"devDependencies": { "devDependencies": {
"dedent": "catalog:", "dedent": "1.7.1",
"fast-glob": "^3.3.3", "fast-glob": "^3.3.3",
"source-map-js": "^1.2.1", "source-map-js": "^1.2.1"
"yaml": "^2.9.0"
} }
} }

View file

@ -50,7 +50,7 @@ describe.each(Object.keys(variantConfig))('%s', (variant) => {
async ({ exec, expect }) => { async ({ exec, expect }) => {
await expect( await expect(
exec('pnpm postcss src/index.css --output dist/out.css', undefined, { ignoreStdErr: true }), 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.`, `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.`,
) )
}, },

View file

@ -1,83 +0,0 @@
import { candidate, css, fetchStyles, js, json, jsx, retryAssertion, test } from '../utils'
test(
'@tailwindcss/turbopack loader (dev)',
{
timeout: 120_000,
fs: {
'package.json': json`
{
"dependencies": {
"next": "^16.2.7",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"tailwindcss": "workspace:^",
"@tailwindcss/turbopack": "workspace:^"
}
}
`,
'next.config.mjs': js`
export default {
turbopack: {
rules: {
'*.css': {
loaders: ['@tailwindcss/turbopack'],
as: '*.css',
},
},
},
}
`,
'app/layout.js': jsx`
import './globals.css'
export default function RootLayout({ children }) {
return (
<html>
<body>{children}</body>
</html>
)
}
`,
'app/page.js': jsx`
export default function Page() {
return <div className="flex">Hello, Next.js!</div>
}
`,
'app/globals.css': css` @import 'tailwindcss'; `,
},
},
async ({ fs, spawn, expect }) => {
let process = await spawn('pnpm next dev')
let url = ''
await process.onStdout((message) => {
let match = /Local:\s*(http.*)/.exec(message)
if (match) url = match[1]
return Boolean(url)
})
await process.onStdout((message) => message.includes('Ready in'))
await retryAssertion(async () => {
let styles = await fetchStyles(url)
expect(styles).toContain(candidate`flex`)
expect(styles).not.toContain(candidate`underline`)
})
await fs.write(
'app/page.js',
jsx`
export default function Page() {
return <div className="flex underline">Hello, Next.js!</div>
}
`,
)
await retryAssertion(async () => {
let styles = await fetchStyles(url)
expect(styles).toContain(candidate`flex`)
expect(styles).toContain(candidate`underline`)
})
},
)

View file

@ -1,6 +1,6 @@
import path from 'node:path' import path from 'node:path'
import { isRepoDirty } from '../../packages/@tailwindcss-upgrade/src/utils/git' import { isRepoDirty } from '../../packages/@tailwindcss-upgrade/src/utils/git'
import { candidate, css, html, js, json, test, ts, txt, yaml } from '../utils' import { candidate, css, html, js, json, test, ts, yaml } from '../utils'
test( test(
'error when no CSS file with @tailwind is used', 'error when no CSS file with @tailwind is used',
@ -28,7 +28,7 @@ test(
}, },
}, },
async ({ fs, exec, expect }) => { async ({ fs, exec, expect }) => {
let output = await exec('pnpm exec upgrade') let output = await exec('npx @tailwindcss/upgrade')
expect(output).toContain('Cannot find any CSS files that reference Tailwind CSS.') expect(output).toContain('Cannot find any CSS files that reference Tailwind CSS.')
// Files should not be modified // Files should not be modified
@ -100,7 +100,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade') await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(`
" "
@ -158,7 +158,7 @@ test(
}) })
// Ensure the v4 project compiles correctly // Ensure the v4 project compiles correctly
await exec('pnpm exec tailwindcss --input src/input.css --output dist/out.css') await exec('npx tailwindcss --input src/input.css --output dist/out.css')
await fs.expectFileToContain('dist/out.css', [ await fs.expectFileToContain('dist/out.css', [
candidate`flex!`, candidate`flex!`,
@ -171,57 +171,6 @@ test(
}, },
) )
test(
'only migrates files matched by `config.content` when upgrading from v3 to v4',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "^3",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'tailwind.config.js': js`
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.html'],
}
`,
'src/index.html': html`
<div class="order-[0] bg-[--my-red]"></div>
`,
'src/input.css': css`
@tailwind base;
@tailwind components;
@tailwind utilities;
`,
'templates/email.php': html`
<div class="order-[0] bg-[--my-red]"></div>
`,
'notes/unrelated.txt': `order-[0] bg-[--my-red]`,
},
retry: 0,
},
async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('./**/*.{html,php,txt}')).toMatchInlineSnapshot(`
"
--- notes/unrelated.txt ---
order-[0] bg-[--my-red]
--- src/index.html ---
<div class="order-0 bg-(--my-red)"></div>
--- templates/email.php ---
<div class="order-[0] bg-[--my-red]"></div>
"
`)
},
)
test( test(
`upgrades a v3 project with prefixes to v4`, `upgrades a v3 project with prefixes to v4`,
{ {
@ -263,7 +212,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade') await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(`
" "
@ -335,7 +284,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade') await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(`
" "
@ -407,7 +356,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade') await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(`
" "
@ -484,7 +433,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade') await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(`
" "
@ -587,7 +536,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade') await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(`
" "
@ -695,7 +644,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade') await exec('npx @tailwindcss/upgrade')
await fs.expectFileToContain( await fs.expectFileToContain(
'postcss.config.js', 'postcss.config.js',
@ -766,7 +715,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade') await exec('npx @tailwindcss/upgrade')
let packageJsonContent = await fs.read('package.json') let packageJsonContent = await fs.read('package.json')
let packageJson = JSON.parse(packageJsonContent) let packageJson = JSON.parse(packageJsonContent)
@ -813,7 +762,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade') await exec('npx @tailwindcss/upgrade')
let packageJsonContent = await fs.read('package.json') let packageJsonContent = await fs.read('package.json')
let packageJson = JSON.parse(packageJsonContent) let packageJson = JSON.parse(packageJsonContent)
@ -865,7 +814,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade') await exec('npx @tailwindcss/upgrade')
await fs.expectFileToContain('src/index.css', css`@import 'tailwindcss';`) await fs.expectFileToContain('src/index.css', css`@import 'tailwindcss';`)
await fs.expectFileToContain( await fs.expectFileToContain(
@ -941,7 +890,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade') await exec('npx @tailwindcss/upgrade')
await fs.expectFileToContain('src/index.css', css`@import 'tailwindcss';`) await fs.expectFileToContain('src/index.css', css`@import 'tailwindcss';`)
await fs.expectFileToContain( await fs.expectFileToContain(
@ -1008,7 +957,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade') await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('./src/**/*.html')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('./src/**/*.html')).toMatchInlineSnapshot(`
" "
@ -1055,7 +1004,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade') await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('./src/**/*.html')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('./src/**/*.html')).toMatchInlineSnapshot(`
" "
@ -1099,7 +1048,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade --force') await exec('npx @tailwindcss/upgrade --force')
expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(`
" "
@ -1169,10 +1118,9 @@ test(
} }
`, `,
}, },
retry: 0,
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade --force') await exec('npx @tailwindcss/upgrade --force')
expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(`
" "
@ -1281,10 +1229,9 @@ test(
} }
`, `,
}, },
retry: 0,
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade --force') await exec('npx @tailwindcss/upgrade --force')
expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(`
" "
@ -1417,7 +1364,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
let output = await exec('pnpm exec upgrade --force') let output = await exec('npx @tailwindcss/upgrade --force')
expect(output).toMatch( expect(output).toMatch(
/You have one or more stylesheets that are imported into a utility layer and non-utility layer./, /You have one or more stylesheets that are imported into a utility layer and non-utility layer./,
@ -1541,7 +1488,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade --force') await exec('npx @tailwindcss/upgrade --force')
expect(await fs.dumpFiles('./src/**/*.{html,css}')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('./src/**/*.{html,css}')).toMatchInlineSnapshot(`
" "
@ -1766,8 +1713,8 @@ test(
}, },
}, },
async ({ exec, expect }) => { async ({ exec, expect }) => {
let output = await exec('pnpm exec upgrade --force', {}, { ignoreStdErr: true }).catch((e) => let output = await exec('npx @tailwindcss/upgrade --force', {}, { ignoreStdErr: true }).catch(
e.toString(), (e) => e.toString(),
) )
expect(output).toMatch('Could not determine configuration file for:') expect(output).toMatch('Could not determine configuration file for:')
@ -1838,7 +1785,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade --force') await exec('npx @tailwindcss/upgrade --force')
expect(await fs.dumpFiles('./src/**/*.{html,css}')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('./src/**/*.{html,css}')).toMatchInlineSnapshot(`
" "
@ -1974,7 +1921,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade --force') await exec('npx @tailwindcss/upgrade --force')
expect(await fs.dumpFiles('./src/**/*.{html,css}')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('./src/**/*.{html,css}')).toMatchInlineSnapshot(`
" "
@ -2094,7 +2041,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade --force') await exec('npx @tailwindcss/upgrade --force')
expect(await fs.dumpFiles('./src/**/*.{html,css}')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('./src/**/*.{html,css}')).toMatchInlineSnapshot(`
" "
@ -2174,7 +2121,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade --force') await exec('npx @tailwindcss/upgrade --force')
expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(`
" "
@ -2256,10 +2203,9 @@ test(
} }
`, `,
}, },
retry: 0,
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade --force') await exec('npx @tailwindcss/upgrade --force')
expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(`
" "
@ -2313,7 +2259,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade --force') await exec('npx @tailwindcss/upgrade --force')
let pkg = JSON.parse(await fs.read('package.json')) let pkg = JSON.parse(await fs.read('package.json'))
@ -2392,7 +2338,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade --force') await exec('npx @tailwindcss/upgrade --force')
// Files should not be modified // Files should not be modified
expect(await fs.dumpFiles('./*.{js,css,html}')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('./*.{js,css,html}')).toMatchInlineSnapshot(`
@ -2490,7 +2436,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade --force') await exec('npx @tailwindcss/upgrade --force')
// Files should not be modified // Files should not be modified
expect(await fs.dumpFiles('./*.{js,css,html,tsx}')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('./*.{js,css,html,tsx}')).toMatchInlineSnapshot(`
@ -2557,7 +2503,7 @@ test(
@import './utilities.css'; @import './utilities.css';
@import './generated/ignore-me.css'; @import './generated/ignore-me.css';
`, `,
'src/generated/.gitignore': txt` 'src/generated/.gitignore': `
* *
!.gitignore !.gitignore
`, `,
@ -2597,7 +2543,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade ./src/index.css') await exec('npx @tailwindcss/upgrade ./src/index.css')
expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(`
" "
@ -2723,7 +2669,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade') await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(`
" "
@ -2736,8 +2682,8 @@ test(
text-red-500/10 text-red-500/10
text-red-500/semitransparent text-red-500/semitransparent
text-red-500/transparent text-red-500/transparent
text-red-500/semitransparent text-red-500/50
text-red-500/50.50 text-red-500/50.5
text-red-500/50.50 text-red-500/50.50
text-red-500/50% text-red-500/50%
text-red-500/100%" text-red-500/100%"
@ -2809,7 +2755,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade') await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(`
" "
@ -2817,7 +2763,7 @@ test(
<div class="ring-3"></div> <div class="ring-3"></div>
--- ./src/input.css --- --- ./src/input.css ---
@import 'tailwindcss'; @import 'tailwindcss';
/* /*
The default border color has changed to \`currentcolor\` in Tailwind CSS v4, The default border color has changed to \`currentcolor\` in Tailwind CSS v4,
@ -2877,7 +2823,7 @@ test(
}, },
}, },
async ({ exec, root, fs, expect }) => { async ({ exec, root, fs, expect }) => {
await exec('pnpm exec upgrade') await exec('npx @tailwindcss/upgrade')
let before = await fs.dumpFiles('./src/**/*.{css,html}') let before = await fs.dumpFiles('./src/**/*.{css,html}')
expect(before).toMatchInlineSnapshot(` expect(before).toMatchInlineSnapshot(`
@ -2919,7 +2865,7 @@ test(
} }
// Run the upgrade again // Run the upgrade again
let output = await exec('pnpm exec upgrade') let output = await exec('npx @tailwindcss/upgrade')
expect(output).toContain('No changes were made to your repository') expect(output).toContain('No changes were made to your repository')
let after = await fs.dumpFiles('./src/**/*.{css,html}') let after = await fs.dumpFiles('./src/**/*.{css,html}')
@ -2998,7 +2944,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade') await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(`
" "
@ -3026,202 +2972,6 @@ test(
}, },
) )
test(
'v4 ignores .env files during template migration',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "^4",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'src/app.css': css`@import 'tailwindcss';`,
'src/index.html': html`
<div class="order-[0]"></div>
`,
'src/.env': `TW_TEST_CLASS=order-[0]`,
'src/.env.production': `TW_TEST_CLASS=order-[0]`,
},
},
async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('./src/**/{*,.env,.env.*}')).toMatchInlineSnapshot(`
"
--- ./src/index.html ---
<div class="order-0"></div>
--- ./src/.env ---
TW_TEST_CLASS=order-[0]
--- ./src/.env.production ---
TW_TEST_CLASS=order-[0]
--- ./src/app.css ---
@import 'tailwindcss';
"
`)
},
)
test(
'v4 linked configs respect `content` and still ignore gitignored files',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "^4",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'tailwind.config.js': js`
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/*.html', './src/*.less'],
}
`,
'src/app.css': css`
@import 'tailwindcss';
@config '../tailwind.config.js';
`,
// Ignore all .html files
'src/.gitignore': txt`
*.html
`,
// HTML files are in .gitignore, even though they are explicitly mentioned
// in the `content` array. Still ignore them
'src/do-not-migrate-me.html': html`
<div class="order-[0]"></div>
`,
// Should be picked up by auto-content detection
'templates/migrate-me.php': html`
<div class="order-[0]"></div>
`,
// Does not get picked up by auto content detection (because it's a less
// file), but was explicitly listed in the `content` array.
//
// A bit of a hacky way, I admit, but it allows us to differentiate
// between git ignored files, auto content detection and explicitly listed
// files.
'src/migrate-me.less': html`
<div class="order-[0]"></div>
`,
},
},
async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade')
expect(await fs.dumpFiles('./{src,templates}/**/*')).toMatchInlineSnapshot(`
"
--- ./src/app.css ---
@import 'tailwindcss';
@config '../tailwind.config.js';
--- ./src/do-not-migrate-me.html ---
<div class="order-[0]"></div>
--- ./src/migrate-me.less ---
<div class="order-0"></div>
--- ./templates/migrate-me.php ---
<div class="order-0"></div>
"
`)
},
)
test(
'interrupting template migration does not truncate files',
{
timeout: 180_000,
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "^4",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'src/app.css': css` @import 'tailwindcss'; `,
'src/index.html': html`
<div class="order-[0]"></div>
`,
'hook.cjs': js`
let fs = require('node:fs/promises')
let path = require('node:path')
let originalWriteFile = fs.writeFile.bind(fs)
fs.writeFile = async (file, contents, ...rest) => {
// Mimic a bad write
await originalWriteFile(file, '') // As-if we truncated first
console.error('__TRUNCATED_TARGET__')
await new Promise((r) => setTimeout(r, 50)) // Wait 50ms to allow us to kill the process
await originalWriteFile(file, contents, ...rest) // Write the actual contents
}
`,
'src/keep.php': `
<?php
return [
'keep' => 'this file should never be truncated',
];
`,
},
},
async ({ spawn, fs, expect }) => {
let repeatedCandidates = Array.from(
{ length: 250 },
() => '<div class="order-[0]"></div>',
).join('\n')
for (let i = 0; i < 100; i++) {
await fs.write(
`src/templates/template-${i}.php`,
`<?php\n\n${repeatedCandidates}\n\nreturn ['template' => ${i}];\n`,
)
}
let originalKeepFile = await fs.read('src/keep.php')
let originalTemplate = await fs.read('src/templates/template-0.php')
// NOTE: This test intentionally keeps `npx` instead of `pnpm exec`. It uses
// a `NODE_OPTIONS` hook that overrides `fs.writeFile` to simulate a bad
// write, and `pnpm exec` may write a bin shim on startup which would trip
// that hook before template migration begins.
let process = await spawn('npx @tailwindcss/upgrade --force', {
env: {
NODE_OPTIONS: '--require=./hook.cjs',
},
})
// We're only interested once we start migrating the templates
await process.onStderr((message) => message.includes('Migrating templates'))
// Wait for the trigger that we are mid-write
await process.onStderr((message) => message === '__TRUNCATED_TARGET__')
// Kill the process
await process.dispose()
expect(await fs.read('src/keep.php')).toBe(originalKeepFile)
expect(await fs.read('src/templates/template-0.php')).toBe(originalTemplate)
for (let [file, contents] of await fs.glob('src/**/*.{html,php,css}')) {
expect(contents.trim(), `${file} should not be empty after interruption`).not.toBe('')
}
},
)
test( test(
'upgrades can run in a pnpm workspace', 'upgrades can run in a pnpm workspace',
{ {
@ -3265,7 +3015,7 @@ test(
}, },
}, },
async ({ root, exec, fs, expect }) => { async ({ root, exec, fs, expect }) => {
let stdout = await exec('pnpm exec upgrade', { let stdout = await exec('npx @tailwindcss/upgrade', {
cwd: path.join(root, 'project-a'), cwd: path.join(root, 'project-a'),
}) })
@ -3339,7 +3089,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
let stdout = await exec('pnpm exec upgrade') let stdout = await exec('npx @tailwindcss/upgrade')
expect(stdout).not.toContain( expect(stdout).not.toContain(
'Running this command will add the dependency to the workspace root', 'Running this command will add the dependency to the workspace root',
@ -3371,61 +3121,6 @@ test(
}, },
) )
// https://github.com/tailwindlabs/tailwindcss/issues/20328
test(
'ignored files should not be touched when upgrading from a nested directory',
{
fs: {
'pnpm-workspace.yaml': yaml`
#
packages:
- packages/*
`,
'package.json': json`
{
"name": "root",
"private": true
}
`,
'packages/css/package.json': json`
{
"name": "css-pkg",
"private": true,
"devDependencies": {
"tailwindcss": "^4",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'packages/css/src/input.css': css`
@import 'tailwindcss/utilities.css' layer(utilities) source(none);
`,
'packages/css/src/index.html': html`<div class="flex text-red-500">Hi</div>`,
// Ensure files/folders ignored by a `.gitignore` in the root take effect
// when executing the upgrade tool from a sub-package.
'.gitignore': txt`
node_modules/
`,
},
},
async ({ root, exec, fs, expect }) => {
await exec('git init', { cwd: root })
await exec('pnpm exec upgrade --force', {
cwd: path.join(root, 'packages/css'),
})
expect(await fs.dumpFiles('packages/css/node_modules/tailwindcss/utilities.css'))
.toMatchInlineSnapshot(`
"
--- packages/css/node_modules/tailwindcss/utilities.css ---
@tailwind utilities;
"
`)
},
)
test( test(
'upgrade <style> blocks carefully', 'upgrade <style> blocks carefully',
{ {
@ -3470,7 +3165,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade') await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('./src/**/*.{css,vue}')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('./src/**/*.{css,vue}')).toMatchInlineSnapshot(`
" "

View file

@ -1,6 +1,6 @@
import path from 'node:path' import path from 'node:path'
import { describe } from 'vitest' import { describe } from 'vitest'
import { css, html, json, test, ts, txt } from '../utils' import { css, html, json, test, ts } from '../utils'
test( test(
`upgrade JS config files with flat theme values, darkMode, and content fields`, `upgrade JS config files with flat theme values, darkMode, and content fields`,
@ -164,7 +164,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade') await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('src/**/*.{css,js,html}')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('src/**/*.{css,js,html}')).toMatchInlineSnapshot(`
" "
@ -322,105 +322,6 @@ test(
}, },
) )
test(
'skips gitignored template files even when they are explicitly referenced in `content`',
{
fs: {
'package.json': json`
{
"dependencies": {
"tailwindcss": "^3",
"@tailwindcss/upgrade": "workspace:^"
}
}
`,
'.gitignore': txt`
node_modules
src/ignored
`,
'tailwind.config.ts': ts`
import { type Config } from 'tailwindcss'
module.exports = {
content: [
'./src/migrate-me.html',
'./src/ignored/do-not-migrate-me.html',
'./node_modules/my-external-lib/template.html',
],
theme: {},
plugins: [],
} satisfies Config
`,
'src/input.css': css`
@tailwind base;
@tailwind components;
@tailwind utilities;
`,
'src/migrate-me.html': html`
<div class="order-[0]"></div>
`,
'src/ignored/do-not-migrate-me.html': html`
<div class="order-[0]"></div>
`,
'node_modules/my-external-lib/template.html': html`
<div
class="order-[0]"
></div>
`,
},
},
async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade')
expect(
await fs.dumpFiles(
'{src/**/*.{css,html},node_modules/my-external-lib/template.html,.gitignore}',
),
).toMatchInlineSnapshot(`
"
--- .gitignore ---
node_modules
src/ignored
--- src/input.css ---
@import 'tailwindcss';
@source './ignored/do-not-migrate-me.html';
@source '../node_modules/my-external-lib/template.html';
/*
The default border color has changed to \`currentcolor\` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
--- src/migrate-me.html ---
<div class="order-0"></div>
--- node_modules/my-external-lib/template.html ---
<div
class="order-[0]"
></div>
--- src/ignored/do-not-migrate-me.html ---
<div class="order-[0]"></div>
"
`)
},
)
test( test(
'upgrades JS config files with plugins', 'upgrades JS config files with plugins',
{ {
@ -484,7 +385,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade') await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
" "
@ -586,7 +487,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade') await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('src/**/*.{css,ts}')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('src/**/*.{css,ts}')).toMatchInlineSnapshot(`
" "
@ -594,17 +495,17 @@ test(
@import 'tailwindcss'; @import 'tailwindcss';
@theme { @theme {
--color-gray-50: oklch(98.5% 0 none); --color-gray-50: oklch(98.5% 0 0);
--color-gray-100: oklch(97% 0 none); --color-gray-100: oklch(97% 0 0);
--color-gray-200: oklch(92.2% 0 none); --color-gray-200: oklch(92.2% 0 0);
--color-gray-300: oklch(87% 0 none); --color-gray-300: oklch(87% 0 0);
--color-gray-400: oklch(70.8% 0 none); --color-gray-400: oklch(70.8% 0 0);
--color-gray-500: oklch(55.6% 0 none); --color-gray-500: oklch(55.6% 0 0);
--color-gray-600: oklch(43.9% 0 none); --color-gray-600: oklch(43.9% 0 0);
--color-gray-700: oklch(37.1% 0 none); --color-gray-700: oklch(37.1% 0 0);
--color-gray-800: oklch(26.9% 0 none); --color-gray-800: oklch(26.9% 0 0);
--color-gray-900: oklch(20.5% 0 none); --color-gray-900: oklch(20.5% 0 0);
--color-gray-950: oklch(14.5% 0 none); --color-gray-950: oklch(14.5% 0 0);
} }
/* /*
@ -672,7 +573,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade') await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
" "
@ -750,7 +651,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade') await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
" "
@ -824,7 +725,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade') await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
" "
@ -934,7 +835,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade') await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('project-{a,b}/**/*.{css,ts}')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('project-{a,b}/**/*.{css,ts}')).toMatchInlineSnapshot(`
" "
@ -1032,7 +933,7 @@ test(
}, },
}, },
async ({ root, exec, fs, expect }) => { async ({ root, exec, fs, expect }) => {
await exec('pnpm exec upgrade', { await exec('npx @tailwindcss/upgrade', {
cwd: path.join(root, 'frontend'), cwd: path.join(root, 'frontend'),
}) })
@ -1119,7 +1020,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade') await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('src/*.css')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('src/*.css')).toMatchInlineSnapshot(`
" "
@ -1194,7 +1095,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade') await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('src/*.css')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('src/*.css')).toMatchInlineSnapshot(`
" "
@ -1270,7 +1171,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade') await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('src/*.css')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('src/*.css')).toMatchInlineSnapshot(`
" "
@ -1346,7 +1247,7 @@ describe('border compatibility', () => {
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade') await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
" "
@ -1408,7 +1309,7 @@ describe('border compatibility', () => {
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade') await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
" "
@ -1470,7 +1371,7 @@ describe('border compatibility', () => {
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade') await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
" "
@ -1509,7 +1410,7 @@ describe('border compatibility', () => {
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade') await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
" "
@ -1580,7 +1481,7 @@ describe('border compatibility', () => {
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade') await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
" "
@ -1683,7 +1584,7 @@ describe('border compatibility', () => {
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade') await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('src/**/*.{css,html}')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('src/**/*.{css,html}')).toMatchInlineSnapshot(`
" "
@ -1793,7 +1694,7 @@ describe('border compatibility', () => {
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade') await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('src/**/*.{css,html}')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('src/**/*.{css,html}')).toMatchInlineSnapshot(`
" "
@ -1895,7 +1796,7 @@ describe('border compatibility', () => {
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade') await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('src/**/*.{css,html}')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('src/**/*.{css,html}')).toMatchInlineSnapshot(`
" "
@ -1993,7 +1894,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade') await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
" "
@ -2064,7 +1965,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade') await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
" "
@ -2141,7 +2042,7 @@ test(
}, },
}, },
async ({ exec, fs, expect }) => { async ({ exec, fs, expect }) => {
await exec('pnpm exec upgrade') await exec('npx @tailwindcss/upgrade')
expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(` expect(await fs.dumpFiles('src/**/*.css')).toMatchInlineSnapshot(`
" "

View file

@ -31,7 +31,6 @@ test(
@tailwind utilities; @tailwind utilities;
`, `,
}, },
retry: 0,
}, },
async ({ exec, expect }) => { async ({ exec, expect }) => {
// Ensure we are in a git repo // Ensure we are in a git repo
@ -40,7 +39,7 @@ test(
await exec('git commit -m "before migration"') await exec('git commit -m "before migration"')
// Fully upgrade to v4 // Fully upgrade to v4
await exec('pnpm exec upgrade') await exec('npx @tailwindcss/upgrade')
// Undo all changes to the current repo. This will bring the repo back to a // Undo all changes to the current repo. This will bring the repo back to a
// v3 state, but the `node_modules` will now have v4 installed. // v3 state, but the `node_modules` will now have v4 installed.
@ -48,7 +47,7 @@ test(
// Re-running the upgrade should result in an error // Re-running the upgrade should result in an error
return expect(() => { return expect(() => {
return exec('pnpm exec upgrade', {}, { ignoreStdErr: true }).catch((e) => { return exec('npx @tailwindcss/upgrade', {}, { ignoreStdErr: true }).catch((e) => {
// Replacing the current version with a hardcoded `v4` to make it stable // Replacing the current version with a hardcoded `v4` to make it stable
// when we release new minor/patch versions. // when we release new minor/patch versions.
return Promise.reject( return Promise.reject(
@ -56,7 +55,7 @@ test(
) )
}) })
}).rejects.toThrowErrorMatchingInlineSnapshot(` }).rejects.toThrowErrorMatchingInlineSnapshot(`
"Command failed: pnpm exec upgrade "Command failed: npx @tailwindcss/upgrade
≈ tailwindcss v4.0.0 ≈ tailwindcss v4.0.0
│ ↳ Upgrading from Tailwind CSS \`v4.0.0\` │ ↳ Upgrading from Tailwind CSS \`v4.0.0\`
@ -106,7 +105,6 @@ test(
@tailwind utilities; @tailwind utilities;
`, `,
}, },
retry: 0,
}, },
async ({ exec, expect }) => { async ({ exec, expect }) => {
// Use `bun` to install dependencies // Use `bun` to install dependencies
@ -194,7 +192,6 @@ test(
@tailwind utilities; @tailwind utilities;
`, `,
}, },
retry: 0,
}, },
async ({ exec, expect }) => { async ({ exec, expect }) => {
// Use `npm` to install dependencies // Use `npm` to install dependencies

View file

@ -1,27 +1,22 @@
import dedent from 'dedent' import dedent from 'dedent'
import fastGlob from 'fast-glob' import fastGlob from 'fast-glob'
import { exec, execFile, spawn, type ChildProcess } from 'node:child_process' import { exec, spawn } from 'node:child_process'
import fs from 'node:fs/promises' import fs from 'node:fs/promises'
import { createServer } from 'node:net'
import { platform, tmpdir } from 'node:os' import { platform, tmpdir } from 'node:os'
import path from 'node:path' import path from 'node:path'
import { promisify, stripVTControlCharacters } from 'node:util' import { stripVTControlCharacters } from 'node:util'
import { RawSourceMap, SourceMapConsumer } from 'source-map-js' import { RawSourceMap, SourceMapConsumer } from 'source-map-js'
import { test as defaultTest, type ExpectStatic } from 'vitest' import { test as defaultTest, type ExpectStatic } from 'vitest'
import * as Yaml from 'yaml'
import { createLineTable } from '../packages/tailwindcss/src/source-maps/line-table' import { createLineTable } from '../packages/tailwindcss/src/source-maps/line-table'
import { escape } from '../packages/tailwindcss/src/utils/escape' import { escape } from '../packages/tailwindcss/src/utils/escape'
const REPO_ROOT = path.join(__dirname, '..') const REPO_ROOT = path.join(__dirname, '..')
const ROOT_PNPM_WORKSPACE = Yaml.parse(
await fs.readFile(path.join(REPO_ROOT, 'pnpm-workspace.yaml'), 'utf8'),
)
const PUBLIC_PACKAGES = (await fs.readdir(path.join(REPO_ROOT, 'dist'))).map((name) => const PUBLIC_PACKAGES = (await fs.readdir(path.join(REPO_ROOT, 'dist'))).map((name) =>
name.replace('tailwindcss-', '@tailwindcss/').replace('.tgz', ''), name.replace('tailwindcss-', '@tailwindcss/').replace('.tgz', ''),
) )
interface SpawnedProcess { interface SpawnedProcess {
dispose: () => Promise<void> dispose: () => void
flush: () => void flush: () => void
onStdout: (predicate: (message: string) => boolean) => Promise<void> onStdout: (predicate: (message: string) => boolean) => Promise<void>
onStderr: (predicate: (message: string) => boolean) => Promise<void> onStderr: (predicate: (message: string) => boolean) => Promise<void>
@ -44,7 +39,6 @@ interface TestConfig {
timeout?: number timeout?: number
installDependencies?: boolean installDependencies?: boolean
retry?: number
} }
interface TestContext { interface TestContext {
root: string root: string
@ -54,17 +48,15 @@ interface TestContext {
parseSourceMap(opts: string | SourceMapOptions): SourceMap parseSourceMap(opts: string | SourceMapOptions): SourceMap
fs: { fs: {
write(filePath: string, content: string, encoding?: BufferEncoding): Promise<void> write(filePath: string, content: string, encoding?: BufferEncoding): Promise<void>
symlink(dst: string, src: string): Promise<void>
create(filePaths: string[]): Promise<void> create(filePaths: string[]): Promise<void>
read(filePath: string): Promise<string> read(filePath: string): Promise<string>
delete(filePath: string): Promise<void>
glob(pattern: string): Promise<[string, string][]> glob(pattern: string): Promise<[string, string][]>
dumpFiles(pattern: string): Promise<string> dumpFiles(pattern: string): Promise<string>
expectFileToContain( expectFileToContain(
filePath: string, filePath: string,
contents: string | RegExp | (string | RegExp)[], contents: string | RegExp | (string | RegExp)[],
): Promise<void> ): Promise<void>
expectFileNotToContain(filePath: string, contents: string[]): Promise<void> expectFileNotToContain(filePath: string, contents: string | string[]): Promise<void>
} }
} }
type TestCallback = (context: TestContext) => Promise<void> | void type TestCallback = (context: TestContext) => Promise<void> | void
@ -72,15 +64,12 @@ interface TestFlags {
only?: boolean only?: boolean
skip?: boolean skip?: boolean
debug?: boolean debug?: boolean
concurrent?: boolean
} }
type SpawnActor = { predicate: (message: string) => boolean; resolve: () => void } type SpawnActor = { predicate: (message: string) => boolean; resolve: () => void }
export const IS_WINDOWS = platform() === 'win32' export const IS_WINDOWS = platform() === 'win32'
const execFileAsync = promisify(execFile)
const TEST_TIMEOUT = IS_WINDOWS ? 120000 : 60000 const TEST_TIMEOUT = IS_WINDOWS ? 120000 : 60000
const ASSERTION_TIMEOUT = IS_WINDOWS ? 10000 : 5000 const ASSERTION_TIMEOUT = IS_WINDOWS ? 10000 : 5000
@ -93,16 +82,16 @@ export function test(
name: string, name: string,
config: TestConfig, config: TestConfig,
testCallback: TestCallback, testCallback: TestCallback,
{ only = false, skip = false, debug = false, concurrent = false }: TestFlags = {}, { only = false, skip = false, debug = false }: TestFlags = {},
) { ) {
return defaultTest( return defaultTest(
name, name,
{ {
timeout: config.timeout ?? TEST_TIMEOUT, timeout: config.timeout ?? TEST_TIMEOUT,
retry: config.retry ?? (process.env.CI ? 2 : 0), retry: process.env.CI ? 2 : 0,
only: only || (!process.env.CI && debug), only: only || (!process.env.CI && debug),
skip, skip,
concurrent, concurrent: true,
}, },
async (options) => { async (options) => {
let rootDir = debug ? path.join(REPO_ROOT, '.debug') : TMP_ROOT let rootDir = debug ? path.join(REPO_ROOT, '.debug') : TMP_ROOT
@ -126,39 +115,6 @@ export function test(
execOptions: ExecOptions = {}, execOptions: ExecOptions = {},
) { ) {
let cwd = childProcessOptions.cwd ?? root let cwd = childProcessOptions.cwd ?? root
let originalCommand = command
// Avoid `pnpm exec upgrade` on Windows because the upgrader runs
// `pnpm add`, which rewrites Windows `.cmd` shims while the current
// shim is still executing.
//
// Pretty sure this is a pnpm v11 bug on Windows.
if (IS_WINDOWS && command.includes('pnpm exec upgrade')) {
for (let base = cwd; ; base = path.dirname(base)) {
let upgradeBin = path.join(
base,
'node_modules',
'@tailwindcss',
'upgrade',
'dist',
'index.mjs',
)
try {
await fs.access(upgradeBin)
command = command.replace('pnpm exec upgrade', `node "${upgradeBin}"`)
break
} catch (error: any) {
if (error?.code !== 'ENOENT') throw error
}
let parent = path.dirname(base)
if (parent === base) {
throw new Error(`Unable to resolve @tailwindcss/upgrade from ${cwd}`)
}
}
}
if (debug && cwd !== root) { if (debug && cwd !== root) {
let relative = path.relative(root, cwd) let relative = path.relative(root, cwd)
if (relative[0] !== '.') relative = `./${relative}` if (relative[0] !== '.') relative = `./${relative}`
@ -178,11 +134,6 @@ export function test(
}, },
(error, stdout, stderr) => { (error, stdout, stderr) => {
if (error) { if (error) {
if (command !== originalCommand) {
error.message = error.message.replace(command, originalCommand)
let mappedError = error as any
mappedError.cmd = originalCommand
}
if (execOptions.ignoreStdErr !== true) console.error(stderr) if (execOptions.ignoreStdErr !== true) console.error(stderr)
if (only || debug) { if (only || debug) {
console.error(stdout) console.error(stdout)
@ -219,7 +170,6 @@ export function test(
if (debug) console.log(`>& ${command}`) if (debug) console.log(`>& ${command}`)
let child = spawn(command, { let child = spawn(command, {
cwd, cwd,
detached: !IS_WINDOWS,
shell: true, shell: true,
...childProcessOptions, ...childProcessOptions,
env: { env: {
@ -228,26 +178,22 @@ export function test(
}, },
}) })
let disposed = false function dispose() {
if (!child.kill()) {
child.kill('SIGKILL')
}
async function dispose() { let timer = setTimeout(
if (disposed) return disposePromise () =>
disposed = true rejectDisposal?.(new Error(`spawned process (${command}) did not exit in time`)),
ASSERTION_TIMEOUT,
await killProcessTree(child)
let timer = setTimeout(() => {
forceKillProcessTree(child)
rejectDisposal?.(new Error(`spawned process (${command}) did not exit in time`))
}, ASSERTION_TIMEOUT)
disposePromise.then(
() => clearTimeout(timer),
() => clearTimeout(timer),
) )
disposePromise.finally(() => {
clearTimeout(timer)
})
return disposePromise return disposePromise
} }
disposables.push(dispose) disposables.push(dispose)
function onExit() { function onExit() {
resolveDisposal?.() resolveDisposal?.()
} }
@ -338,7 +284,11 @@ export function test(
} }
}, },
fs: { fs: {
async write(filename, content, encoding = 'utf8') { async write(
filename: string,
content: string | Uint8Array,
encoding: BufferEncoding = 'utf8',
): Promise<void> {
let full = path.join(root, filename) let full = path.join(root, filename)
let dir = path.dirname(full) let dir = path.dirname(full)
await fs.mkdir(dir, { recursive: true }) await fs.mkdir(dir, { recursive: true })
@ -349,8 +299,6 @@ export function test(
if (filename.endsWith('package.json')) { if (filename.endsWith('package.json')) {
content = await overwriteVersionsInPackageJson(content) content = await overwriteVersionsInPackageJson(content)
} else if (filename.endsWith('pnpm-workspace.yaml')) {
content = overwriteVersionsInPnpmWorkspace(content)
} }
// Ensure that files written on Windows use \r\n line ending // Ensure that files written on Windows use \r\n line ending
@ -361,19 +309,7 @@ export function test(
await fs.writeFile(full, content, encoding) await fs.writeFile(full, content, encoding)
}, },
async symlink(target, src) { async create(filenames: string[]): Promise<void> {
let targetAbsolute = path.join(root, target)
let targetParent = path.dirname(targetAbsolute)
await fs.mkdir(targetParent, { recursive: true })
let srcAbsolute = path.join(root, src)
let srcParent = path.dirname(srcAbsolute)
await fs.mkdir(srcParent, { recursive: true })
await fs.symlink(targetAbsolute, srcAbsolute)
},
async create(filenames) {
for (let filename of filenames) { for (let filename of filenames) {
let full = path.join(root, filename) let full = path.join(root, filename)
@ -383,11 +319,7 @@ export function test(
} }
}, },
async delete(filename) { async read(filePath: string) {
await fs.unlink(path.join(root, filename))
},
async read(filePath) {
let content = await fs.readFile(path.resolve(root, filePath), 'utf8') let content = await fs.readFile(path.resolve(root, filePath), 'utf8')
// Ensure that files read on Windows have \r\n line endings removed // Ensure that files read on Windows have \r\n line endings removed
@ -397,7 +329,7 @@ export function test(
return content return content
}, },
async glob(pattern) { async glob(pattern: string) {
let files = await fastGlob(pattern, { cwd: root }) let files = await fastGlob(pattern, { cwd: root })
return Promise.all( return Promise.all(
files.map(async (file) => { files.map(async (file) => {
@ -410,7 +342,7 @@ export function test(
}), }),
) )
}, },
async dumpFiles(pattern) { async dumpFiles(pattern: string) {
let files = await context.fs.glob(pattern) let files = await context.fs.glob(pattern)
return `\n${files return `\n${files
.slice() .slice()
@ -469,44 +401,21 @@ export function test(
node_modules/ node_modules/
` `
// Disable Git's automatic line ending conversion in these throwaway test
// repositories. Tools like pnpm write files (e.g. `pnpm-lock.yaml`) with
// LF endings, which on Windows (where `core.autocrlf=true` is common)
// makes Git emit noisy "LF will be replaced by CRLF" warnings when it
// touches those files.
config.fs['.gitattributes'] ??= txt`
* -text
`
// Ensure there is always a root `pnpm-workspace.yaml` so that transitive
// dependency overrides (and the build allow list) are injected into it.
// As of pnpm v10, these can no longer live in the `pnpm.overrides` field
// of `package.json`.
config.fs['pnpm-workspace.yaml'] ??= ''
for (let [filename, content] of Object.entries(config.fs)) { for (let [filename, content] of Object.entries(config.fs)) {
if (content.toString().startsWith('symlink:')) {
// The symlink path is relative to the target destination's path
let target = path.join(
filename,
content.toString().slice('symlink:'.length), // Relative path
)
await context.fs.symlink(target, filename)
} else {
await context.fs.write(filename, content) await context.fs.write(filename, content)
} }
}
let shouldInstallDependencies = config.installDependencies ?? true let shouldInstallDependencies = config.installDependencies ?? true
try { try {
// Every test project gets its own root `pnpm-workspace.yaml` (see // In debug mode, the directory is going to be inside the pnpm workspace
// above). This makes the test directory its own workspace root, so even // of the tailwindcss package. This means that `pnpm install` will run
// in debug mode — where the directory lives inside this repository's // pnpm install on the workspace instead (expect if the root dir defines
// pnpm workspace — `pnpm install` won't attach to the parent workspace. // a separate workspace). We work around this by using the
// `--ignore-workspace` flag.
if (shouldInstallDependencies) { if (shouldInstallDependencies) {
await context.exec(`pnpm install`) let ignoreWorkspace = debug && !config.fs['pnpm-workspace.yaml']
await context.exec(`pnpm install${ignoreWorkspace ? ' --ignore-workspace' : ''}`)
} }
} catch (error: any) { } catch (error: any) {
console.error(error) console.error(error)
@ -518,18 +427,11 @@ export function test(
let disposables: (() => Promise<void>)[] = [] let disposables: (() => Promise<void>)[] = []
async function dispose() { async function dispose() {
let results = await Promise.allSettled(disposables.map((dispose) => dispose())) await Promise.all(disposables.map((dispose) => dispose()))
if (!debug) { if (!debug) {
await gracefullyRemove(root) await gracefullyRemove(root)
} }
let errors = results.flatMap((result) =>
result.status === 'rejected' ? [result.reason] : [],
)
if (errors.length > 0) {
throw new AggregateError(errors, 'Failed to clean up spawned processes')
}
} }
options.onTestFinished(dispose) options.onTestFinished(dispose)
@ -539,7 +441,7 @@ export function test(
try { try {
await context.exec('git init', { cwd: root }) await context.exec('git init', { cwd: root })
await context.exec('git add --all', { cwd: root }) await context.exec('git add --all', { cwd: root })
await context.exec('git commit -m "before migration" --allow-empty', { cwd: root }) await context.exec('git commit -m "before migration"', { cwd: root })
} catch (error: any) { } catch (error: any) {
console.error(error) console.error(error)
console.error(error.stdout?.toString()) console.error(error.stdout?.toString())
@ -558,9 +460,6 @@ test.only = (name: string, config: TestConfig, testCallback: TestCallback) => {
test.skip = (name: string, config: TestConfig, testCallback: TestCallback) => { test.skip = (name: string, config: TestConfig, testCallback: TestCallback) => {
return test(name, config, testCallback, { skip: true }) return test(name, config, testCallback, { skip: true })
} }
test.concurrent = (name: string, config: TestConfig, testCallback: TestCallback) => {
return test(name, config, testCallback, { concurrent: true })
}
test.debug = (name: string, config: TestConfig, testCallback: TestCallback) => { test.debug = (name: string, config: TestConfig, testCallback: TestCallback) => {
return test(name, config, testCallback, { debug: true }) return test(name, config, testCallback, { debug: true })
} }
@ -584,43 +483,28 @@ async function overwriteVersionsInPackageJson(content: string): Promise<string>
} }
} }
return JSON.stringify(json, null, 2)
}
function overwriteVersionsInPnpmWorkspace(content: string): string {
let workspace = content.trim() === '' ? {} : Yaml.parse(content)
// Inherit information from the root workspace
workspace.allowBuilds = {
...ROOT_PNPM_WORKSPACE.allowBuilds,
...workspace.allowBuilds,
}
// Inject transitive dependency overwrite. This is necessary because // Inject transitive dependency overwrite. This is necessary because
// @tailwindcss/vite internally depends on a specific version of // @tailwindcss/vite internally depends on a specific version of
// @tailwindcss/oxide and we instead want to resolve it to the locally built // @tailwindcss/oxide and we instead want to resolve it to the locally built
// version. // version.
// json.pnpm ||= {}
// As of pnpm v10, the `pnpm.overrides` field in `package.json` is no longer json.pnpm.overrides ||= {}
// read. Overrides have to be defined in `pnpm-workspace.yaml` instead.
workspace.overrides ||= {}
for (let pkg of PUBLIC_PACKAGES) { for (let pkg of PUBLIC_PACKAGES) {
if (pkg === 'tailwindcss') { if (pkg === 'tailwindcss') {
// We want to be explicit about the `tailwindcss` package so our tests can // We want to be explicit about the `tailwindcss` package so our tests can
// also import v3 without conflicting v4 tarballs. // also import v3 without conflicting v4 tarballs.
workspace.overrides['@tailwindcss/node>tailwindcss'] = resolveVersion(pkg) json.pnpm.overrides['@tailwindcss/node>tailwindcss'] = resolveVersion(pkg)
workspace.overrides['@tailwindcss/upgrade>tailwindcss'] = resolveVersion(pkg) json.pnpm.overrides['@tailwindcss/upgrade>tailwindcss'] = resolveVersion(pkg)
workspace.overrides['@tailwindcss/cli>tailwindcss'] = resolveVersion(pkg) json.pnpm.overrides['@tailwindcss/cli>tailwindcss'] = resolveVersion(pkg)
workspace.overrides['@tailwindcss/postcss>tailwindcss'] = resolveVersion(pkg) json.pnpm.overrides['@tailwindcss/postcss>tailwindcss'] = resolveVersion(pkg)
workspace.overrides['@tailwindcss/turbopack>tailwindcss'] = resolveVersion(pkg) json.pnpm.overrides['@tailwindcss/vite>tailwindcss'] = resolveVersion(pkg)
workspace.overrides['@tailwindcss/vite>tailwindcss'] = resolveVersion(pkg) json.pnpm.overrides['@tailwindcss/webpack>tailwindcss'] = resolveVersion(pkg)
workspace.overrides['@tailwindcss/webpack>tailwindcss'] = resolveVersion(pkg)
} else { } else {
workspace.overrides[pkg] = resolveVersion(pkg) json.pnpm.overrides[pkg] = resolveVersion(pkg)
} }
} }
return Yaml.stringify(workspace) return JSON.stringify(json, null, 2)
} }
function resolveVersion(dependency: string) { function resolveVersion(dependency: string) {
@ -670,7 +554,6 @@ export async function retryAssertion<T>(
try { try {
return await fn() return await fn()
} catch (err) { } catch (err) {
Error.captureStackTrace(err, retryAssertion)
error = err error = err
await new Promise((resolve) => setTimeout(resolve, delay)) await new Promise((resolve) => setTimeout(resolve, delay))
} }
@ -723,65 +606,6 @@ export async function fetchStyles(base: string, path = '/'): Promise<string> {
}, '') }, '')
} }
export async function getRandomPort() {
return new Promise<number>((resolve, reject) => {
let server = createServer()
server.unref()
server.on('error', reject)
server.listen(0, '127.0.0.1', () => {
let address = server.address()
server.close(() => {
if (address && typeof address === 'object') {
resolve(address.port)
} else {
reject(new Error('Unable to allocate random port'))
}
})
})
})
}
async function killProcessTree(child: ChildProcess) {
if (child.exitCode !== null || child.signalCode !== null || child.pid === undefined) {
return
}
if (IS_WINDOWS) {
await execFileAsync('taskkill', ['/pid', String(child.pid), '/T', '/F'], {
timeout: ASSERTION_TIMEOUT,
windowsHide: true,
}).catch(() => {})
return
}
try {
process.kill(-child.pid, 'SIGTERM')
} catch (error: any) {
if (error?.code !== 'ESRCH') {
child.kill()
}
}
}
function forceKillProcessTree(child: ChildProcess) {
if (child.exitCode !== null || child.signalCode !== null || child.pid === undefined) {
return
}
if (IS_WINDOWS) {
execFile('taskkill', ['/pid', String(child.pid), '/T', '/F'], { windowsHide: true }, () => {})
return
}
try {
process.kill(-child.pid, 'SIGKILL')
} catch (error: any) {
if (error?.code !== 'ESRCH') {
child.kill('SIGKILL')
}
}
}
async function gracefullyRemove(dir: string) { async function gracefullyRemove(dir: string) {
// Skip removing the directory in CI because it can stall on Windows // Skip removing the directory in CI because it can stall on Windows
if (!process.env.CI) { if (!process.env.CI) {

View file

@ -1,193 +0,0 @@
import path from 'node:path'
import { candidate, css, html, json, retryAssertion, test, ts, txt, yaml } from '../utils'
// Vite's experimental `bundledDev` mode invokes the `hotUpdate` hook without a
// `server`, which used to crash the plugin on every file edit.
//
// - https://github.com/tailwindlabs/tailwindcss/issues/20378
// - https://vite.dev/blog/announcing-vite8-1#experimental-bundled-dev-mode
test(
'dev mode (experimental `bundledDev`)',
{
fs: {
'package.json': json`{}`,
'pnpm-workspace.yaml': yaml`
#
packages:
- project-a
`,
'project-a/package.json': txt`
{
"type": "module",
"dependencies": {
"@tailwindcss/vite": "workspace:^",
"tailwindcss": "workspace:^"
},
"devDependencies": {
"vite": "^8.1"
}
}
`,
'project-a/vite.config.ts': ts`
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
export default defineConfig({
experimental: {
bundledDev: true,
},
plugins: [tailwindcss()],
})
`,
'project-a/index.html': html`
<head>
<link rel="stylesheet" href="./src/index.css" />
</head>
<body>
<div class="underline">Hello, world!</div>
</body>
`,
'project-a/src/index.css': css`
@reference 'tailwindcss/theme';
@import 'tailwindcss/utilities';
@source '../../project-b/src/**/*.html';
`,
'project-b/src/index.html': html`
<div class="flex" />
`,
},
},
async ({ root, spawn, fs, expect }) => {
let process = await spawn('pnpm vite dev', {
cwd: path.join(root, 'project-a'),
env: {
// Some CI machines have an upstream rolldown bug where its file watcher
// never delivers a single event. The debug output is the only way to
// tell that apart from a real regression (see below).
DEBUG: 'vite:full-bundle-mode',
},
})
// Any debug output beyond the "INITIAL:" startup lines proves rolldown's
// watcher delivered at least one file event.
let watcherAlive = false
// `hotUpdate` errors don't kill the dev server, they are only printed to
// stderr. Track them explicitly so a crash fails the test even if the
// rebuild happens to succeed anyway.
let pluginErrors: string[] = []
process.onStderr((message) => {
if (message.includes('vite:full-bundle-mode')) {
if (!message.includes('INITIAL:')) watcherAlive = true
} else if (message.includes('@tailwindcss/vite')) {
pluginErrors.push(message)
}
return false
})
let url = ''
await process.onStdout((m) => {
let match = /Local:\s*(http.*)\//.exec(m)
if (match) url = match[1]
return Boolean(url)
})
// In `bundledDev` mode the stylesheet is not served separately. Instead the
// generated CSS is embedded in the bundled JS and injected at runtime, so
// extract the bundle from the served HTML. While the bundle is being built,
// Vite serves a temporary fallback page instead.
//
// The bundle can be split into multiple chunks (e.g. the HMR client runtime
// and the app itself), and the chunk containing the CSS is not always the
// first one, so fetch every referenced script and stylesheet.
async function fetchBundledStyles(): Promise<string> {
let index = await fetch(`${url}/`)
let html = await index.text()
if (html.includes('__vite_is_fallback_page__')) {
throw new Error('Bundling still in progress')
}
let sources = [
...html.matchAll(/<script[^>]*\ssrc="([^"]+)"/g),
...html.matchAll(/<link[^>]*\srel="stylesheet"[^>]*\shref="([^"]+)"/g),
].map((match) => match[1])
if (sources.length === 0) throw new Error(`No scripts or stylesheets found in:\n\n${html}`)
let contents = await Promise.all(
sources.map(async (src) => {
let response = await fetch(new URL(src, `${url}/`))
return await response.text()
}),
)
return contents.join('\n')
}
await retryAssertion(
async () => {
let styles = await fetchBundledStyles()
expect(styles).toContain(candidate`underline`)
expect(styles).toContain(candidate`flex`)
},
{ timeout: 10_000, delay: 100 },
)
// Edit a module file and a manually `@source`d file. Without a connected
// HMR websocket client (this test only uses HTTP fetches), a file change
// does not trigger an eager rebuild — it only marks the bundle as stale,
// and the next fetch kicks off the rebuild. So poll with fetches without
// touching the files again: rewriting would re-mark the bundle stale right
// before each fetch and the rebuild could never be observed.
await fs.write(
'project-a/index.html',
html`
<head>
<link rel="stylesheet" href="./src/index.css" />
</head>
<body>
<div class="underline m-2">Hello, world!</div>
</body>
`,
)
await fs.write('project-b/src/index.html', html`
<div class="flex font-bold" />
`)
try {
await retryAssertion(
async () => {
let styles = await fetchBundledStyles()
expect(styles).toContain(candidate`underline`)
expect(styles).toContain(candidate`flex`)
expect(styles).toContain(candidate`m-2`)
expect(styles).toContain(candidate`font-bold`)
},
{ timeout: 15_000, delay: 100 },
)
} catch (error) {
// If rolldown's watcher delivered any file event, the updates were
// genuinely dropped somewhere along the way — a real failure.
if (watcherAlive) throw error
// Otherwise the watcher never delivered a single event, so the updates
// can never be observed no matter how long we wait — an upstream
// rolldown bug on some CI machines, not a plugin regression. The crash
// regression this test guards is still covered: `hotUpdate` is driven
// by Vite's own (working) chokidar watcher and asserted via
// `pluginErrors` below.
//
// Skipping is only sound while the server still healthily serves the
// original bundle. If it stopped serving (crashed, or wedged on the
// fallback page), that's real breakage a silent watcher must not mask.
console.log(error)
let styles = await fetchBundledStyles()
expect(styles).toContain(candidate`underline`)
expect(styles).toContain(candidate`flex`)
console.warn(
'Skipping update assertions: rolldown’s file watcher delivered no events on this machine.',
)
}
expect(pluginErrors).toEqual([])
},
)

View file

@ -294,113 +294,6 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => {
}, },
) )
// https://github.com/tailwindlabs/tailwindcss/issues/20346
test(
'dev mode + symlinked `@source` files pick up changes to the real file',
{
fs: {
'package.json': json`{}`,
'pnpm-workspace.yaml': yaml`
#
packages:
- project-a
- packages/*
`,
'project-a/package.json': txt`
{
"type": "module",
"dependencies": {
"@tailwindcss/vite": "workspace:^",
"repro-package": "workspace:*",
"tailwindcss": "workspace:^"
},
"devDependencies": {
${transformer === 'lightningcss' ? `"lightningcss": "^1",` : ''}
"vite": "^7"
}
}
`,
'project-a/vite.config.ts': ts`
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
export default defineConfig({
css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"},
build: { cssMinify: false },
plugins: [tailwindcss()],
})
`,
'project-a/index.html': html`
<head>
<link rel="stylesheet" href="./src/index.css" />
</head>
<body>
<div class="underline">Hello, world!</div>
</body>
`,
// The `@source` points through the `node_modules` symlink that pnpm
// creates for the workspace package. Vite's file watcher ignores
// `node_modules` by default, so the symlinked path can not be watched.
// The real file (in `packages/repro-package`) has to be watched
// instead.
'project-a/src/index.css': css`
@reference 'tailwindcss/theme';
@import 'tailwindcss/utilities';
@source '../node_modules/repro-package/source.html';
`,
'packages/repro-package/package.json': json`
{
"name": "repro-package",
"private": true,
"version": "1.0.0"
}
`,
'packages/repro-package/source.html': html`
<div class="content-['v1']">
Hello, world!
</div>
`,
},
},
async ({ root, spawn, fs, expect }) => {
let process = await spawn('pnpm vite dev', {
cwd: path.join(root, 'project-a'),
})
await process.onStdout((m) => m.includes('ready in'))
let url = ''
await process.onStdout((m) => {
let match = /Local:\s*(http.*)\//.exec(m)
if (match) url = match[1]
return Boolean(url)
})
await retryAssertion(async () => {
let styles = await fetchStyles(url, '/index.html')
expect(styles).toContain(candidate`underline`)
expect(styles).toContain(candidate`content-['v1']`)
})
await retryAssertion(async () => {
// Changes to the real file (behind the symlink) should be picked up
await fs.write(
'packages/repro-package/source.html',
html`
<div class="content-['v1'] content-['v2']">
Hello, world!
</div>
`,
)
let styles = await fetchStyles(url)
expect(styles).toContain(candidate`underline`)
expect(styles).toContain(candidate`content-['v1']`)
expect(styles).toContain(candidate`content-['v2']`)
})
},
)
test( test(
'watch mode', 'watch mode',
{ {
@ -580,11 +473,11 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => {
}, },
) )
describe.each([['^6'], ['7.0.8'], ['7.1.12'], ['7.3.1'], ['8.0.0']])( describe.sequential.each([['^6'], ['7.0.8'], ['7.1.12'], ['7.3.1'], ['8.0.0']])(
'Using Vite %s', 'Using Vite %s',
(version) => { (version) => {
test( test(
'external source file changes update the CSS', 'external source file changes trigger a full reload',
{ {
fs: { fs: {
'package.json': json`{}`, 'package.json': json`{}`,
@ -661,33 +554,26 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => {
expect(styles).toContain(candidate`content-['project-b/src/index.php']`) expect(styles).toContain(candidate`content-['project-b/src/index.php']`)
}) })
// Flush all messages so that we can be sure the next messages are // Flush all messages so that we can be sure the next messages are from
// from the file changes we're about to make // the file changes we're about to make
process.flush() process.flush()
// Changing an external .php file hot-updates the generated CSS // Changing an external .php file should trigger a full reload
{ {
await fs.write( await fs.write(
'project-b/src/index.php', 'project-b/src/index.php',
txt`<div class="content-['updated:project-b/src/index.php']"></div>`, txt`<div class="content-['updated:project-b/src/index.php']"></div>`,
) )
// On Vite < 7.1, Vite itself hard-invalidates watched files that // Ensure the page reloaded
// aren't part of the module graph and reloads the page.
//
// On newer versions nothing reloads the page: the CSS hot-updates
// through the regular pipeline because the changed file is a
// watch dependency of the CSS root.
//
// Reloading the page for external template changes is the
// responsibility of the backend integration (e.g. `laravel-vite-plugin`'s `refresh` option, or `vite-plugin-full-reload`).
//
// https://github.com/tailwindlabs/tailwindcss/issues/20411
if (version === '^6' || version === '7.0.8') { if (version === '^6' || version === '7.0.8') {
await process.onStdout((m) => m.includes('page reload') && m.includes('index.php')) await process.onStdout((m) => m.includes('page reload') && m.includes('index.php'))
} else { } else {
await process.onStdout((m) => m.includes('hmr update') && m.includes('index.css')) await process.onStderr(
(m) => m.includes('vite:hmr (client)') && m.includes('index.php'),
)
} }
await process.onStderr((m) => m.includes('vite:hmr (ssr)') && m.includes('index.php'))
// Ensure the styles were regenerated with the new content // Ensure the styles were regenerated with the new content
let styles = await fetchStyles(url, '/index.html') let styles = await fetchStyles(url, '/index.html')
@ -698,234 +584,6 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => {
}, },
) )
// https://github.com/tailwindlabs/tailwindcss/issues/20320
// https://github.com/tailwindlabs/tailwindcss/issues/19903
test(
'editing scanned files that Vite can process as modules does not trigger a full reload',
{
fs: {
'package.json': json`{}`,
'pnpm-workspace.yaml': yaml`
#
packages:
- project-a
`,
'project-a/package.json': json`
{
"type": "module",
"dependencies": {
"@tailwindcss/vite": "workspace:^",
"tailwindcss": "workspace:^"
},
"devDependencies": {
${transformer === 'lightningcss' ? `"lightningcss": "^1",` : ''}
"vite": "^8"
}
}
`,
'project-a/vite.config.ts': ts`
import fs from 'node:fs'
import path from 'node:path'
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
export default defineConfig({
css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"},
build: { cssMinify: false },
plugins: [
tailwindcss(),
{
// A plugin that processes a custom file type into a JS
// module, similar to e.g. \`.vue\` or \`.svelte\` files
name: 'custom-file-type',
transform(code, id) {
if (id.endsWith('.custom')) {
return { code: 'export default ' + JSON.stringify(code), map: null }
}
},
},
{
// Log all HMR payloads to a file so the test can assert on
// them
name: 'hmr-wiretap',
configureServer(server) {
let logFile = path.resolve('hmr.log')
fs.writeFileSync(logFile, '')
for (let environment of Object.values(server.environments)) {
let send = environment.hot.send.bind(environment.hot)
environment.hot.send = (payload) => {
fs.appendFileSync(logFile, JSON.stringify(payload) + '\\n')
return send(payload)
}
}
},
},
],
})
`,
'project-a/index.html': html`
<html>
<head>
<link rel="stylesheet" href="./src/index.css" />
</head>
<body>
<div id="app"></div>
<script type="module" src="./src/main.ts"></script>
</body>
</html>
`,
'project-a/src/main.ts': ts`
import compA from './comp-a.custom'
import snippet from './snippet.php?raw'
console.log(compA, snippet)
`,
'project-a/src/snippet.php': html`
<div
class="content-['src/snippet.php']"
></div>
`,
'project-a/src/comp-a.custom': html`
<div
class="content-['src/comp-a.custom']"
></div>
`,
'project-a/src/comp-b.custom': html`
<div
class="content-['src/comp-b.custom']"
></div>
`,
'project-a/src/lazy.tsx': jsx`
export default function Lazy() {
return <div className="content-['src/lazy.tsx']" />
}
`,
'project-a/src/unimported.css': css`
.unimported {
color: red;
}
`,
'project-a/src/index.css': css`
@import 'tailwindcss';
@source '../../project-b/**/*.php';
`,
'project-b/src/index.php': html`
<div
class="content-['project-b/src/index.php']"
></div>
`,
},
},
async ({ root, spawn, fs, expect }) => {
let process = await spawn('pnpm vite dev', {
cwd: path.join(root, 'project-a'),
})
await process.onStdout((m) => m.includes('ready in'))
let url = ''
await process.onStdout((m) => {
let match = /Local:\s*(http.*)\//.exec(m)
if (match) url = match[1]
return Boolean(url)
})
await retryAssertion(async () => {
let styles = await fetchStyles(url, '/index.html')
expect(styles).toContain(candidate`content-['src/lazy.tsx']`)
expect(styles).toContain(candidate`content-['src/comp-b.custom']`)
expect(styles).toContain(candidate`content-['project-b/src/index.php']`)
})
// Load `main.ts`, `comp-a.custom`, and `snippet.php?raw` as real
// modules, like a browser visiting the page would
await fetch(`${url}/src/main.ts`)
await fetch(`${url}/src/comp-a.custom?import`)
await fetch(`${url}/src/snippet.php?raw`)
// Changing a scanned `.tsx` file that is not part of the module graph
// (e.g. a lazily-loaded route that hasn't been visited yet) should not
// trigger a full reload, but new classes should still apply
//
// https://github.com/tailwindlabs/tailwindcss/issues/20320
{
await fs.write(
'project-a/src/lazy.tsx',
jsx`
export default function Lazy() {
return <div className="content-['updated:src/lazy.tsx']" />
}
`,
)
await retryAssertion(async () => {
let styles = await fetchStyles(url, '/index.html')
expect(styles).toContain(candidate`content-['updated:src/lazy.tsx']`)
})
}
// The same holds for a custom file type as long as some file of the
// same type was processed as a module before
{
await fs.write(
'project-a/src/comp-b.custom',
html`<div class="content-['updated:src/comp-b.custom']"></div>`,
)
await retryAssertion(async () => {
let styles = await fetchStyles(url, '/index.html')
expect(styles).toContain(candidate`content-['updated:src/comp-b.custom']`)
})
}
// Changing a scanned stylesheet that is not part of the module graph
// (e.g. a component stylesheet that a framework plugin compiles into
// the component) should not trigger a full reload either
//
// https://github.com/tailwindlabs/tailwindcss/issues/19903
{
let updates = (await fs.read('project-a/hmr.log')).split('"type":"update"').length
await fs.write(
'project-a/src/unimported.css',
css`
.unimported {
color: blue;
}
`,
)
// Wait until the change was handled and an update was pushed
await retryAssertion(async () => {
let log = await fs.read('project-a/hmr.log')
expect(log.split('"type":"update"').length).toBeGreaterThan(updates)
})
}
// Changing an external file (e.g. a PHP template) hot-updates the
// generated CSS but does not trigger a full reload either. Reloading the
// page for external template changes is the responsibility of the backend
// integration (e.g. `laravel-vite-plugin`'s `refresh` option, or
// `vite-plugin-full-reload`).
//
// https://github.com/tailwindlabs/tailwindcss/issues/20411
{
let updates = (await fs.read('project-a/hmr.log')).split('"type":"update"').length
await fs.write(
'project-b/src/index.php',
html`<div class="content-['updated:project-b/src/index.php']"></div>`,
)
await retryAssertion(async () => {
let log = await fs.read('project-a/hmr.log')
expect(log.split('"type":"update"').length).toBeGreaterThan(updates)
})
let styles = await fetchStyles(url, '/index.html')
expect(styles).toContain(candidate`content-['updated:project-b/src/index.php']`)
}
},
)
test( test(
`source(none) disables looking at the module graph`, `source(none) disables looking at the module graph`,
{ {
@ -1178,7 +836,7 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => {
async ({ root, fs, exec, expect }) => { async ({ root, fs, exec, expect }) => {
await expect(() => await expect(() =>
exec('pnpm vite build', { cwd: path.join(root, 'project-a') }, { ignoreStdErr: true }), exec('pnpm vite build', { cwd: path.join(root, 'project-a') }, { ignoreStdErr: true }),
).rejects.toThrow('The `source(../i-do-not-exist)` does not exist') ).rejects.toThrowError('The `source(../i-do-not-exist)` does not exist')
let files = await fs.glob('project-a/dist/**/*.css') let files = await fs.glob('project-a/dist/**/*.css')
expect(files).toHaveLength(0) expect(files).toHaveLength(0)
@ -1432,14 +1090,6 @@ test(
</head> </head>
`, `,
'src/index.js': js`import Plotly from 'plotly.js/lib/core'`, 'src/index.js': js`import Plotly from 'plotly.js/lib/core'`,
// `plotly.js` pulls in `es5-ext`, which has a build script. As of pnpm
// v10, build scripts only run when explicitly allowed.
'pnpm-workspace.yaml': yaml`
#
allowBuilds:
es5-ext: true
`,
}, },
}, },
async ({ exec, expect, fs }) => { async ({ exec, expect, fs }) => {
@ -1487,7 +1137,7 @@ test(
<link rel="stylesheet" href="./src/index.css" /> <link rel="stylesheet" href="./src/index.css" />
</head> </head>
<body> <body>
<div class="focus:text-[black]">Hello, world!</div> <div class="hover:flex">Hello, world!</div>
</body> </body>
`, `,
'src/index.css': css` 'src/index.css': css`
@ -1499,16 +1149,16 @@ test(
async ({ exec, expect, fs }) => { async ({ exec, expect, fs }) => {
await exec('pnpm vite build') await exec('pnpm vite build')
let files = await fs.glob('dist/**/*.css')
expect(files).toHaveLength(1)
let [filename] = files[0]
// Should not be minified when optimize is disabled // Should not be minified when optimize is disabled
expect((await fs.dumpFiles('dist/**/*.css')).replace(/-([-_a-zA-Z0-9]*?)\.css/g, '-<hash>.css')) let content = await fs.read(filename)
.toMatchInlineSnapshot(` expect(content).toContain('.hover\\:flex {')
" expect(content).toContain('&:hover {')
--- dist/assets/index-<hash>.css --- expect(content).toContain('@media (hover: hover) {')
.focus\\:text-\\[black\\]:focus { expect(content).toContain('display: flex;')
color: black;
}
"
`)
}, },
) )
@ -1542,7 +1192,7 @@ test(
<link rel="stylesheet" href="./src/index.css" /> <link rel="stylesheet" href="./src/index.css" />
</head> </head>
<body> <body>
<div class="hover:text-[black]">Hello, world!</div> <div class="hover:flex">Hello, world!</div>
</body> </body>
`, `,
'src/index.css': css` 'src/index.css': css`
@ -1554,17 +1204,15 @@ test(
async ({ exec, expect, fs }) => { async ({ exec, expect, fs }) => {
await exec('pnpm vite build') await exec('pnpm vite build')
expect((await fs.dumpFiles('dist/**/*.css')).replace(/-([-_a-zA-Z0-9]*?)\.css/g, '-<hash>.css')) let files = await fs.glob('dist/**/*.css')
.toMatchInlineSnapshot(` expect(files).toHaveLength(1)
" let [filename] = files[0]
--- dist/assets/index-<hash>.css ---
@media (hover: hover) { // Should be optimized but not minified
.hover\\:text-\\[black\\]:hover { let content = await fs.read(filename)
color: #000; expect(content).toContain('@media (hover: hover) {')
} expect(content).toContain('.hover\\:flex:hover {')
} expect(content).toContain('display: flex;')
"
`)
}, },
) )
@ -1627,100 +1275,3 @@ test(
await fs.expectFileToContain(filename, [candidate`content-['index.html']`]) await fs.expectFileToContain(filename, [candidate`content-['index.html']`])
}, },
) )
// https://github.com/tailwindlabs/tailwindcss/issues/17532
test(
'deleting a file should not crash Vite',
{
fs: {
'package.json': json`
{
"type": "module",
"dependencies": {
"@tailwindcss/vite": "workspace:^",
"tailwindcss": "workspace:^"
},
"devDependencies": {
"vite": "6.2.5"
}
}
`,
'vite.config.ts': ts`
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [tailwindcss()],
})
`,
'index.html': html`
<body>
<div id="app" class="underline"></div>
<script type="module" src="./src/main.js"></script>
</body>
`,
'src/index.css': css`@import 'tailwindcss';`,
'src/main.js': js`
import iconUrl from './assets/icon.svg?url'
import './index.css'
document.querySelector('#app').innerHTML = iconUrl
`,
'src/assets/icon.svg': html`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1">
<rect class="fill-red-500" width="1" height="1" />
</svg>
`,
},
},
async ({ fs, spawn, expect }) => {
let process = await spawn('pnpm vite dev')
await process.onStdout((m) => m.includes('ready in'))
let url = ''
await process.onStdout((m) => {
let match = /Local:\s*(http.*)\//.exec(m)
if (match) url = match[1]
return Boolean(url)
})
await retryAssertion(async () => {
let [main, css] = await Promise.all([
fetch(`${url}/src/main.js`).then((r) => r.text()),
fetch(`${url}/src/index.css`).then((r) => r.text()),
])
expect(main).toContain('/src/assets/icon.svg?import&url')
expect(main).toContain('/src/index.css')
expect(css).toContain('.fill-red-500')
})
process.flush()
let unexpectedError = process.onStderr((m) => {
return /error|crash|panic/i.test(m)
})
await fs.write(
'src/main.js',
js`
import './index.css'
document.querySelector('#app').innerHTML = 'Hello World'
`,
)
await fs.delete('src/assets/icon.svg')
let result = await Promise.race([
unexpectedError.then(() => 'stderr'),
new Promise((resolve) => setTimeout(() => resolve('timeout'), 500)),
])
expect(result).toBe('timeout')
await retryAssertion(async () => {
let response = await fetch(`${url}/src/index.css?t=${Date.now()}`)
expect(response.status).toBe(200)
})
},
)

View file

@ -1,15 +1,4 @@
import { import { candidate, css, fetchStyles, html, json, retryAssertion, test, ts } from '../utils'
candidate,
css,
fetchStyles,
getRandomPort,
html,
json,
retryAssertion,
test,
ts,
yaml,
} from '../utils'
const SETUP = { const SETUP = {
fs: { fs: {
@ -22,13 +11,13 @@ const SETUP = {
"nitropack": "2.11.0", "nitropack": "2.11.0",
"tailwindcss": "workspace:^", "tailwindcss": "workspace:^",
"vue": "latest" "vue": "latest"
},
"pnpm": {
"overrides": {
"nuxi": "3.28.0"
}
} }
} }
`,
'pnpm-workspace.yaml': yaml`
#
overrides:
nuxi: 3.28.0
`, `,
'nuxt.config.ts': ts` 'nuxt.config.ts': ts`
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
@ -95,8 +84,7 @@ test('build', SETUP, async ({ spawn, exec, expect }) => {
await exec('pnpm nuxt build') await exec('pnpm nuxt build')
// The Nuxt preview server does not automatically assign a free port if 3000 // The Nuxt preview server does not automatically assign a free port if 3000
// is taken, so we use a random port instead. // is taken, so we use a random port instead.
let port = await getRandomPort() let process = await spawn(`pnpm nuxt preview --port 8724`, {
let process = await spawn(`pnpm nuxt preview --port ${port}`, {
env: { env: {
TEST: 'false', TEST: 'false',
NODE_ENV: 'development', NODE_ENV: 'development',

View file

@ -1,165 +0,0 @@
import { candidate, css, fetchStyles, html, json, retryAssertion, test, ts, txt } from '../utils'
test(
'dev mode',
{
fs: {
'package.json': json`
{
"type": "module",
"dependencies": {
"preact": "^10"
},
"devDependencies": {
"@preact/preset-vite": "^2",
"@tailwindcss/vite": "workspace:^",
"tailwindcss": "workspace:^",
"vite": "^8"
}
}
`,
'vite.config.ts': ts`
import fs from 'node:fs'
import path from 'node:path'
import preact from '@preact/preset-vite'
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [
tailwindcss(),
preact(),
{
// Log all HMR payloads to a file so the test can assert on them
name: 'hmr-wiretap',
configureServer(server) {
let logFile = path.resolve('hmr.log')
fs.writeFileSync(logFile, '')
for (let environment of Object.values(server.environments)) {
let send = environment.hot.send.bind(environment.hot)
environment.hot.send = (payload) => {
fs.appendFileSync(logFile, JSON.stringify(payload) + '\\n')
return send(payload)
}
}
},
},
],
})
`,
'index.html': html`
<html>
<head>
<link rel="stylesheet" href="./src/index.css" />
</head>
<body>
<div id="app"></div>
<script type="module" src="./src/main.tsx"></script>
</body>
</html>
`,
'src/main.tsx': ts`
import { render } from 'preact'
import { App } from './app'
render(<App />, document.getElementById('app')!)
`,
'src/app.tsx': ts`
import { useState } from 'preact/hooks'
export function App() {
const [count, setCount] = useState(0)
return (
<button className="underline" onClick={() => setCount((c) => c + 1)}>
Count: {count}
</button>
)
}
`,
'src/index.css': css`@import 'tailwindcss';`,
},
},
async ({ fs, spawn, expect }) => {
let process = await spawn('pnpm vite dev')
await process.onStdout((m) => m.includes('ready in'))
let url = ''
await process.onStdout((m) => {
let match = /Local:\s*(http.*)\//.exec(m)
if (match) url = match[1]
return Boolean(url)
})
await retryAssertion(async () => {
let styles = await fetchStyles(url)
expect(styles).toContain(candidate`underline`)
})
// Load the component modules, like a browser visiting the page would
await fetch(`${url}/src/main.tsx`)
await fetch(`${url}/src/app.tsx`)
// Editing a component keeps HMR intact: new classes are delivered through
// a regular update, not a full page reload (which would lose all state)
{
await fs.write(
'src/app.tsx',
ts`
import { useState } from 'preact/hooks'
export function App() {
const [count, setCount] = useState(0)
return (
<button className="underline flex" onClick={() => setCount((c) => c + 1)}>
Count: {count}
</button>
)
}
`,
)
await retryAssertion(async () => {
let styles = await fetchStyles(url)
expect(styles).toContain(candidate`underline`)
expect(styles).toContain(candidate`flex`)
})
expect(await fs.read('hmr.log')).toContain('"type":"update"')
expect(await fs.read('hmr.log')).not.toContain('full-reload')
}
// Changing a scanned file that is not part of the module graph (e.g.
// `package.json`, which package managers and other tooling write to while
// the dev server is running) should not trigger a full reload either —
// that would destroy client state. New candidates should still be picked
// up because the file is a watch dependency of the CSS root, so the CSS
// hot-updates through Vite's regular pipeline.
//
// https://github.com/tailwindlabs/tailwindcss/issues/20411
{
await fs.write(
'package.json',
txt`
{
"type": "module",
"description": "content-['package.json']",
"dependencies": {
"preact": "^10"
},
"devDependencies": {
"@preact/preset-vite": "^2",
"@tailwindcss/vite": "workspace:^",
"tailwindcss": "workspace:^",
"vite": "^8"
}
}
`,
)
await retryAssertion(async () => {
let styles = await fetchStyles(url)
expect(styles).toContain(candidate`content-['package.json']`)
})
expect(await fs.read('hmr.log')).not.toContain('full-reload')
}
},
)

View file

@ -1,661 +1,5 @@
import { describe } from 'vitest' import { describe } from 'vitest'
import { import { candidate, css, fetchStyles, html, js, retryAssertion, test, ts, txt } from '../utils'
candidate,
css,
fetchStyles,
html,
js,
json,
retryAssertion,
test,
ts,
txt,
yaml,
} from '../utils'
test(
'resolves tsconfig paths in production build',
{
fs: {
'package.json': json`
{
"type": "module",
"dependencies": {
"@tailwindcss/vite": "workspace:^",
"tailwindcss": "workspace:^"
},
"devDependencies": {
"vite": "^8"
}
}
`,
'tsconfig.json': json`
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
`,
'vite.config.ts': ts`
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
export default defineConfig({
build: { cssMinify: false },
plugins: [tailwindcss()],
resolve: {
tsconfigPaths: true,
},
})
`,
'index.html': html`
<head>
<link rel="stylesheet" href="./src/index.css" />
</head>
<body>
<div class="underline custom-underline">Hello, world!</div>
</body>
`,
'src/index.css': css`
@import '@/styles/base.css';
@plugin '@/plugin.js';
`,
'src/styles/base.css': css`
@reference 'tailwindcss/theme';
@import 'tailwindcss/utilities';
`,
'src/plugin.js': js`
export default function ({ addUtilities }) {
addUtilities({ '.custom-underline': { 'border-bottom': '1px solid green' } })
}
`,
},
},
async ({ fs, exec, expect }) => {
await exec('pnpm vite build')
let files = await fs.glob('dist/**/*.css')
expect(files).toHaveLength(1)
let [filename] = files[0]
await fs.expectFileToContain(filename, [candidate`underline`, candidate`custom-underline`])
},
)
test(
'resolves tsconfig paths in dev mode',
{
fs: {
'package.json': json`
{
"type": "module",
"dependencies": {
"@tailwindcss/vite": "workspace:^",
"tailwindcss": "workspace:^"
},
"devDependencies": {
"vite": "^8"
}
}
`,
'tsconfig.json': json`
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
`,
'vite.config.ts': ts`
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
export default defineConfig({
build: { cssMinify: false },
plugins: [tailwindcss()],
resolve: {
tsconfigPaths: true,
},
})
`,
'index.html': html`
<head>
<link rel="stylesheet" href="./src/index.css" />
</head>
<body>
<div class="underline custom-underline">Hello, world!</div>
</body>
`,
'src/index.css': css`
@import '@/styles/base.css';
@plugin '@/plugin.js';
`,
'src/styles/base.css': css`
@reference 'tailwindcss/theme';
@import 'tailwindcss/utilities';
`,
'src/plugin.js': js`
export default function ({ addUtilities }) {
addUtilities({ '.custom-underline': { 'border-bottom': '1px solid green' } })
}
`,
},
},
async ({ spawn, expect }) => {
let process = await spawn('pnpm vite dev')
await process.onStdout((m) => m.includes('ready in'))
let url = ''
await process.onStdout((m) => {
let match = /Local:\s*(http.*)\//.exec(m)
if (match) url = match[1]
return Boolean(url)
})
await retryAssertion(async () => {
let styles = await fetchStyles(url, '/index.html')
expect(styles).toContain(candidate`underline`)
expect(styles).toContain(candidate`custom-underline`)
})
},
)
test(
'resolves at-sign aliases in production build',
{
fs: {
'package.json': json`
{
"type": "module",
"dependencies": {
"@tailwindcss/vite": "workspace:^",
"tailwindcss": "workspace:^"
},
"devDependencies": {
"vite": "^8"
}
}
`,
'vite.config.ts': ts`
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
import { fileURLToPath } from 'node:url'
export default defineConfig({
build: { cssMinify: false },
plugins: [tailwindcss()],
resolve: {
alias: [{ find: '@', replacement: fileURLToPath(new URL('.', import.meta.url)) }],
},
})
`,
'index.html': html`
<head>
<link rel="stylesheet" href="./src/index.css" />
</head>
<body>
<div class="underline custom-underline">Hello, world!</div>
</body>
`,
'src/index.css': css`
@import '@/src/styles/base.css';
@plugin '@/src/plugin.js';
`,
'src/styles/base.css': css`
@reference 'tailwindcss/theme';
@import 'tailwindcss/utilities';
`,
'src/plugin.js': js`
export default function ({ addUtilities }) {
addUtilities({ '.custom-underline': { 'border-bottom': '1px solid green' } })
}
`,
},
},
async ({ fs, exec, expect }) => {
await exec('pnpm vite build')
let files = await fs.glob('dist/**/*.css')
expect(files).toHaveLength(1)
let [filename] = files[0]
await fs.expectFileToContain(filename, [candidate`underline`, candidate`custom-underline`])
},
)
test(
'resolves package plugins in production build with at-sign aliases',
{
fs: {
'package.json': json`
{
"type": "module",
"dependencies": {
"@tailwindcss/vite": "workspace:^",
"tailwindcss": "workspace:^"
},
"devDependencies": {
"tailwind-scrollbar": "^4.0.2",
"tailwindcss-motion": "^1.1.1",
"vite": "^8"
}
}
`,
'vite.config.ts': ts`
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
import { fileURLToPath } from 'node:url'
export default defineConfig({
build: { cssMinify: false },
plugins: [tailwindcss()],
resolve: {
alias: [{ find: '@', replacement: fileURLToPath(new URL('.', import.meta.url)) }],
},
})
`,
'index.html': html`
<head>
<link rel="stylesheet" href="./src/index.css" />
</head>
<body>
<div class="scrollbar scrollbar-thumb-red-500 motion-preset-fade">Hello, world!</div>
</body>
`,
'src/index.css': css`
@import 'tailwindcss';
@plugin 'tailwind-scrollbar';
@plugin 'tailwindcss-motion';
`,
},
},
async ({ fs, exec, expect }) => {
await exec('pnpm vite build')
let files = await fs.glob('dist/**/*.css')
expect(files).toHaveLength(1)
let [filename] = files[0]
await fs.expectFileToContain(filename, [
candidate`scrollbar`,
candidate`scrollbar-thumb-red-500`,
candidate`motion-preset-fade`,
])
},
)
test(
'resolves package plugins to JS entries in production build when browser points to CSS',
{
fs: {
'package.json': json`
{
"type": "module",
"dependencies": {
"@tailwindcss/vite": "workspace:^",
"plugin-browser-css": "workspace:*",
"tailwindcss": "workspace:^"
},
"devDependencies": {
"vite": "^8"
}
}
`,
'pnpm-workspace.yaml': yaml`
#
packages:
- packages/*
`,
'packages/plugin-browser-css/package.json': json`
{
"name": "plugin-browser-css",
"version": "1.0.0",
"type": "module",
"main": "./index.js",
"module": "./index.js",
"browser": "./browser.css"
}
`,
'packages/plugin-browser-css/index.js': js`
export default function ({ addUtilities }) {
addUtilities({ '.browser-css-plugin': { 'border-bottom': '1px solid green' } })
}
`,
'packages/plugin-browser-css/browser.css': css`
.should-not-be-loaded-as-a-plugin {
display: none;
}
`,
'vite.config.ts': ts`
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
export default defineConfig({
build: { cssMinify: false },
plugins: [tailwindcss()],
})
`,
'index.html': html`
<html>
<head>
<link rel="stylesheet" href="./src/index.css" />
</head>
<body>
<div class="browser-css-plugin">Hello, world!</div>
</body>
</html>
`,
'src/index.css': css`
@import 'tailwindcss';
@plugin 'plugin-browser-css';
`,
},
},
async ({ fs, exec, expect }) => {
await exec('pnpm vite build')
let files = await fs.glob('dist/**/*.css')
expect(files).toHaveLength(1)
let [filename] = files[0]
await fs.expectFileToContain(filename, [candidate`browser-css-plugin`])
},
)
test(
'resolves package plugins to JS entries in dev mode when browser points to CSS',
{
fs: {
'package.json': json`
{
"type": "module",
"dependencies": {
"@tailwindcss/vite": "workspace:^",
"plugin-browser-css": "workspace:*",
"tailwindcss": "workspace:^"
},
"devDependencies": {
"vite": "^8"
}
}
`,
'pnpm-workspace.yaml': yaml`
#
packages:
- packages/*
`,
'packages/plugin-browser-css/package.json': json`
{
"name": "plugin-browser-css",
"version": "1.0.0",
"type": "module",
"main": "./index.js",
"module": "./index.js",
"browser": "./browser.css"
}
`,
'packages/plugin-browser-css/index.js': js`
export default function ({ addUtilities }) {
addUtilities({ '.browser-css-plugin': { 'border-bottom': '1px solid green' } })
}
`,
'packages/plugin-browser-css/browser.css': css`
.should-not-be-loaded-as-a-plugin {
display: none;
}
`,
'vite.config.ts': ts`
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
export default defineConfig({
build: { cssMinify: false },
plugins: [tailwindcss()],
})
`,
'index.html': html`
<html>
<head>
<link rel="stylesheet" href="./src/index.css" />
</head>
<body>
<div class="browser-css-plugin">Hello, world!</div>
</body>
</html>
`,
'src/index.css': css`
@import 'tailwindcss';
@plugin 'plugin-browser-css';
`,
},
},
async ({ spawn, expect }) => {
let process = await spawn('pnpm vite dev')
await process.onStdout((m) => m.includes('ready in'))
let url = ''
await process.onStdout((m) => {
let match = /Local:\s*(http.*)\//.exec(m)
if (match) url = match[1]
return Boolean(url)
})
await retryAssertion(async () => {
let styles = await fetchStyles(url, '/index.html')
expect(styles).toContain(candidate`browser-css-plugin`)
})
},
)
test(
'resolves package plugins in dev mode when package exports CSS files',
{
fs: {
'package.json': json`
{
"type": "module",
"dependencies": {
"@tailwindcss/vite": "workspace:^",
"daisyui": "^5",
"tailwindcss": "workspace:^"
},
"devDependencies": {
"vite": "^8"
}
}
`,
'vite.config.ts': ts`
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
export default defineConfig({
build: { cssMinify: false },
plugins: [tailwindcss()],
})
`,
'index.html': html`
<html>
<head>
<link rel="stylesheet" href="./src/index.css" />
</head>
<body>
<button class="btn btn-primary">Hello, world!</button>
</body>
</html>
`,
'src/index.css': css`
@import 'tailwindcss';
@plugin 'daisyui';
`,
},
},
async ({ spawn, expect }) => {
let process = await spawn('pnpm vite dev')
await process.onStdout((m) => m.includes('ready in'))
let url = ''
await process.onStdout((m) => {
let match = /Local:\s*(http.*)\//.exec(m)
if (match) url = match[1]
return Boolean(url)
})
await retryAssertion(async () => {
let styles = await fetchStyles(url, '/index.html')
expect(styles).toContain('.btn')
expect(styles).toContain('.btn-primary')
})
},
)
test(
'resolve relative CSS files correctly',
{
fs: {
'package.json': json`
{
"type": "module",
"dependencies": {
"@tailwindcss/vite": "workspace:^",
"tailwindcss": "workspace:^"
},
"devDependencies": {
"vite": "^8"
}
}
`,
'vite.config.ts': ts`
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
export default defineConfig({
build: { cssMinify: false },
plugins: [tailwindcss()],
})
`,
'index.html': html`
<html>
<head>
<link rel="stylesheet" href="./src/index.css" />
</head>
<body></body>
</html>
`,
'src/index.css': css`
@reference 'tailwindcss/theme';
@import 'tailwindcss/utilities';
@import './themes/glow.css';
`,
// References a file in the current folder, which names happens to match a
// file in the parent folder as well.
'src/themes/glow.css': css`@import './entry.css';`,
'src/themes/entry.css': css`
.do-include-me {
color: green;
}
`,
// Never rerefenced, so should not be included
'src/entry.css': css`
.do-not-include-me {
color: red;
}
`,
},
},
async ({ exec, fs, expect }) => {
await exec('pnpm vite build')
expect(
(await fs.dumpFiles('./dist/**/*.css')).replace(/-([-_a-zA-Z0-9]*?)\.css/g, '-<hash>.css'),
).toMatchInlineSnapshot(`
"
--- ./dist/assets/index-<hash>.css ---
.do-include-me {
color: green;
}
"
`)
},
)
test(
'resolve relative JS files correctly',
{
fs: {
'package.json': json`
{
"type": "module",
"dependencies": {
"@tailwindcss/vite": "workspace:^",
"tailwindcss": "workspace:^"
},
"devDependencies": {
"vite": "^8"
}
}
`,
'vite.config.ts': ts`
import tailwindcss from '@tailwindcss/vite'
import { defineConfig } from 'vite'
export default defineConfig({
build: { cssMinify: false },
plugins: [tailwindcss()],
})
`,
'index.html': html`
<html>
<head>
<link rel="stylesheet" href="./src/index.css" />
</head>
<body></body>
</html>
`,
'src/index.css': css`
@reference 'tailwindcss/theme';
@import 'tailwindcss/utilities';
@import './themes/glow.css';
`,
// References a file in the current folder, which names happens to match a
// file in the parent folder as well.
'src/themes/glow.css': css`@plugin "./my-plugin.js";`,
'src/themes/my-plugin.js': ts`
export default function ({ addBase }) {
addBase({ '.do-include-me': { color: 'green' } })
}
`,
// Never rerefenced, so should not be included
'src/my-plugin.js': css`
export default function ({ addBase }) {
addBase({ '.do-not-include-me': { 'color': 'red' } })
}
`,
},
},
async ({ exec, fs, expect }) => {
await exec('pnpm vite build')
expect(
(await fs.dumpFiles('./dist/**/*.css')).replace(/-([-_a-zA-Z0-9]*?)\.css/g, '-<hash>.css'),
).toMatchInlineSnapshot(`
"
--- ./dist/assets/index-<hash>.css ---
@layer base {
.do-include-me {
color: green;
}
}
"
`)
},
)
describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { describe.each(['postcss', 'lightningcss'])('%s', (transformer) => {
test( test(

View file

@ -91,67 +91,3 @@ test(
}) })
}, },
) )
// https://github.com/tailwindlabs/tailwindcss/issues/19930
test(
'production build without Tailwind roots should not result in source map warnings',
{
fs: {
'package.json': json`
{
"type": "module",
"dependencies": {
"@tailwindcss/vite": "workspace:^",
"tailwindcss": "workspace:^"
},
"devDependencies": {
"vite": "^7"
}
}
`,
'vite.config.ts': ts`
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [
tailwindcss(),
{
name: 'inspect-source-map-chain',
enforce: 'pre',
transform(_, id) {
if (id.includes('.css')) {
// Force Rollup to collapse the sourcemap chain during this build.
this.getCombinedSourcemap()
}
},
},
],
css: {
devSourcemap: true,
},
build: {
sourcemap: true,
},
})
`,
'index.html': html`
<body>
<script type="module" src="./src/index.js"></script>
<div>Hello, world!</div>
</body>
`,
'src/index.js': ts` import './index.css' `,
'src/index.css': css`
body {
color: red;
}
`,
},
},
async ({ exec, expect }) => {
let output = await exec('pnpm vite build')
expect(output).not.toContain('Sourcemap is likely to be incorrect')
},
)

View file

@ -1,80 +0,0 @@
import { candidate, css, html, js, json, test, ts } from '../utils'
test(
'sveltekit build resolves package plugins',
{
fs: {
'package.json': json`
{
"type": "module",
"scripts": {
"prepare": "svelte-kit sync || echo ''"
},
"dependencies": {
"svelte": "^5",
"tailwindcss": "workspace:^"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^6",
"@sveltejs/kit": "^2",
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/vite": "workspace:^",
"vite": "^8"
}
}
`,
'svelte.config.js': js`
import adapter from '@sveltejs/adapter-auto'
export default {
kit: {
adapter: adapter(),
},
}
`,
'vite.config.ts': ts`
import tailwindcss from '@tailwindcss/vite'
import { sveltekit } from '@sveltejs/kit/vite'
import { defineConfig } from 'vite'
export default defineConfig({
build: { cssMinify: false },
plugins: [tailwindcss(), sveltekit()],
})
`,
'src/app.html': html`
<!doctype html>
<html lang="en">
<head>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
`,
'src/routes/+layout.svelte': html`
<script>
import '../app.css'
</script>
<slot />
`,
'src/routes/+page.svelte': html`
<div class="form-input">Hello, world!</div>
`,
'src/app.css': css`
@import 'tailwindcss';
@plugin '@tailwindcss/forms';
`,
},
},
async ({ exec, fs, expect }) => {
await exec('pnpm vite build')
let files = await fs.glob('.svelte-kit/output/client/**/*.css')
expect(files.length).toBeGreaterThan(0)
await fs.expectFileToContain(files[0][0], [candidate`form-input`])
},
)

View file

@ -1,5 +1,5 @@
import { stripVTControlCharacters } from 'node:util' import { stripVTControlCharacters } from 'node:util'
import { candidate, css, fetchStyles, html, json, retryAssertion, test, ts } from '../utils' import { candidate, html, json, test, ts } from '../utils'
test( test(
'production build', 'production build',
@ -73,110 +73,6 @@ test(
}, },
) )
{
const VUE_COMPONENT_COUNT = 1_000
let vueComponentsWithReferences = Object.fromEntries(
Array.from({ length: VUE_COMPONENT_COUNT }, (_, idx) => [
`src/components/Component${idx}.vue`,
html`
<template>
<div class="content-['component-${idx}']">Component ${idx}</div>
</template>
<style>
@reference '../main.css';
.component-${idx} {
@apply text-red-500;
}
</style>
`,
]),
)
let vueComponentImports = Array.from(
{ length: VUE_COMPONENT_COUNT },
(_, idx) => `import Component${idx} from './components/Component${idx}.vue'`,
).join('\n')
let vueComponentUsages = Array.from(
{ length: VUE_COMPONENT_COUNT },
(_, idx) => `<Component${idx} class="component-${idx}" />`,
).join('\n')
test(
'production build with many Vue style blocks referencing the main stylesheet',
{
fs: {
'package.json': json`
{
"type": "module",
"dependencies": {
"vue": "^3.4.37",
"tailwindcss": "workspace:^"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.2",
"@tailwindcss/vite": "workspace:^",
"vite": "^7"
}
}
`,
'vite.config.ts': ts`
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [vue(), tailwindcss()],
})
`,
'index.html': html`
<!doctype html>
<html>
<body>
<div id="app"></div>
<script type="module" src="./src/main.ts"></script>
</body>
</html>
`,
'src/main.css': css`@import 'tailwindcss';`,
'src/main.ts': ts`
import { createApp } from 'vue'
import './main.css'
import App from './App.vue'
createApp(App).mount('#app')
`,
'src/App.vue': html`
<script setup>
${vueComponentImports}
</script>
<template>${vueComponentUsages}</template>
`,
...vueComponentsWithReferences,
},
},
async ({ fs, exec, expect }) => {
await exec('pnpm vite build')
let files = await fs.glob('dist/**/*.css')
expect(files).toHaveLength(1)
await fs.expectFileToContain(files[0][0], [
candidate`content-['component-0']`,
candidate`component-0`,
candidate`content-['component-99']`,
candidate`component-99`,
candidate`content-['component-999']`,
candidate`component-999`,
])
},
)
}
test( test(
'error when using `@apply` without `@reference`', 'error when using `@apply` without `@reference`',
{ {
@ -236,7 +132,7 @@ test(
expect.assertions(1) expect.assertions(1)
try { try {
await exec('pnpm vite build', {}, { ignoreStdErr: true }) await exec('pnpm vite build')
} catch (error) { } catch (error) {
let [, message] = let [, message] =
/error during build:([\s\S]*?)file:/g.exec( /error during build:([\s\S]*?)file:/g.exec(
@ -248,133 +144,3 @@ test(
} }
}, },
) )
// https://github.com/tailwindlabs/tailwindcss/issues/20320
test(
'editing a scanned `.vue` file that is not loaded as a module does not trigger a full reload',
{
fs: {
'package.json': json`
{
"type": "module",
"dependencies": {
"vue": "^3.4.37",
"tailwindcss": "workspace:^"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6",
"@tailwindcss/vite": "workspace:^",
"vite": "^8"
}
}
`,
'vite.config.ts': ts`
import fs from 'node:fs'
import path from 'node:path'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [
vue(),
tailwindcss(),
{
// Log update and full-reload HMR payloads to a file so the
// test can assert on them. Custom events are not logged
// because \`@vitejs/plugin-vue\` sends a \`file-changed\` event
// for every file change, including changes to the log file
// itself, which would cause an infinite feedback loop.
name: 'hmr-wiretap',
configureServer(server) {
let logFile = path.resolve('hmr.log')
fs.writeFileSync(logFile, '')
for (let environment of Object.values(server.environments)) {
let send = environment.hot.send.bind(environment.hot)
environment.hot.send = (payload) => {
if (payload.type === 'update' || payload.type === 'full-reload') {
fs.appendFileSync(logFile, JSON.stringify(payload) + '\\n')
}
return send(payload)
}
}
},
},
],
})
`,
'index.html': html`
<!doctype html>
<html>
<head>
<link rel="stylesheet" href="./src/index.css" />
</head>
<body>
<div id="app"></div>
<script type="module" src="./src/main.ts"></script>
</body>
</html>
`,
'src/index.css': css`@import 'tailwindcss';`,
'src/main.ts': ts`
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')
`,
'src/App.vue': html`
<template>
<div class="content-['src/App.vue']">Hello Vue!</div>
</template>
`,
// This file is scanned by Tailwind but never imported, so it is not
// part of the loaded module graph (e.g. a lazy route that hasn't been
// visited yet)
'src/LazyRoute.vue': html`
<template>
<div class="content-['src/LazyRoute.vue']">Lazy</div>
</template>
`,
},
},
async ({ spawn, fs, expect }) => {
let process = await spawn('pnpm vite dev')
await process.onStdout((m) => m.includes('ready in'))
let url = ''
await process.onStdout((m) => {
let match = /Local:\s*(http.*)\//.exec(m)
if (match) url = match[1]
return Boolean(url)
})
await retryAssertion(async () => {
let styles = await fetchStyles(url, '/index.html')
expect(styles).toContain(candidate`content-['src/App.vue']`)
expect(styles).toContain(candidate`content-['src/LazyRoute.vue']`)
})
// Load `main.ts` and `App.vue` as real modules, like a browser visiting
// the page would
await fetch(`${url}/src/main.ts`)
await fetch(`${url}/src/App.vue`)
// Changing the scanned but unloaded `.vue` file should not trigger a
// full reload, but new classes should still apply
await fs.write(
'src/LazyRoute.vue',
html`
<template>
<div class="content-['updated:src/LazyRoute.vue']">Lazy</div>
</template>
`,
)
await retryAssertion(async () => {
let styles = await fetchStyles(url, '/index.html')
expect(styles).toContain(candidate`content-['updated:src/LazyRoute.vue']`)
})
expect(await fs.read('hmr.log')).not.toContain('full-reload')
},
)

View file

@ -418,78 +418,3 @@ test(
`) `)
}, },
) )
test(
'@tailwindcss/webpack loader isolates cache by resource including query',
{
fs: {
'package.json': json`
{
"main": "./src/index.js",
"browser": "./src/index.js",
"dependencies": {
"css-loader": "^6",
"webpack": "^5",
"webpack-cli": "^5",
"mini-css-extract-plugin": "^2",
"tailwindcss": "workspace:^",
"@tailwindcss/webpack": "workspace:^"
}
}
`,
'webpack.config.js': js`
let MiniCssExtractPlugin = require('mini-css-extract-plugin')
let path = require('node:path')
module.exports = {
mode: 'development',
entry: {
a: './src/a.js',
b: './src/b.js',
},
output: {
clean: true,
},
plugins: [new MiniCssExtractPlugin()],
module: {
rules: [
{
test: /.css$/i,
use: [
MiniCssExtractPlugin.loader,
'css-loader',
'@tailwindcss/webpack',
path.resolve(__dirname, 'query-loader.js'),
],
},
],
},
}
`,
'query-loader.js': js`
module.exports = function (source) {
if (this.resourceQuery.includes('a')) {
return '@import "tailwindcss/utilities"; @utility only-a {color: var(--color-red-500);}'
}
if (this.resourceQuery.includes('b')) {
return '@import "tailwindcss/utilities"; @utility only-b {color: var(--color-blue-500);}'
}
return source
}
`,
'src/a.js': js`import './index.css?a'`,
'src/b.js': js`import './index.css?b'`,
'src/index.css': css``,
},
},
async ({ fs, exec }) => {
await exec('pnpm webpack --mode=development')
await fs.expectFileToContain('dist/a.css', ['only-a', '--color-red-500'])
await fs.expectFileToContain('dist/b.css', ['only-b', '--color-blue-500'])
await fs.expectFileNotToContain('dist/a.css', ['only-b', '--color-blue-500'])
await fs.expectFileNotToContain('dist/b.css', ['only-a', '--color-red-500'])
},
)

View file

@ -48,17 +48,23 @@
}, },
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.62.1", "@playwright/test": "^1.58.0",
"@types/node": "catalog:", "@types/node": "catalog:",
"postcss": "catalog:", "postcss": "8.5.6",
"postcss-import": "^16.1.1", "postcss-import": "^16.1.1",
"prettier": "catalog:", "prettier": "catalog:",
"prettier-plugin-embed": "^0.5.1", "prettier-plugin-embed": "^0.5.1",
"prettier-plugin-organize-imports": "^4.3.0", "prettier-plugin-organize-imports": "^4.3.0",
"tsup": "^8.5.1", "tsup": "^8.5.1",
"turbo": "^2.10.8", "turbo": "^2.7.6",
"typescript": "^5.9.3", "typescript": "^5.5.4",
"vitest": "^4.1.10" "vitest": "^4.0.18"
}, },
"packageManager": "pnpm@11.9.0" "packageManager": "pnpm@9.6.0",
"pnpm": {
"patchedDependencies": {
"@parcel/watcher@2.5.1": "patches/@parcel__watcher@2.5.1.patch",
"lightningcss@1.32.0": "patches/lightningcss@1.32.0.patch"
}
}
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "@tailwindcss/browser", "name": "@tailwindcss/browser",
"version": "4.3.3", "version": "4.2.2",
"description": "A utility-first CSS framework for rapidly building custom user interfaces.", "description": "A utility-first CSS framework for rapidly building custom user interfaces.",
"license": "MIT", "license": "MIT",
"main": "./dist/index.global.js", "main": "./dist/index.global.js",
@ -30,8 +30,8 @@
"access": "public" "access": "public"
}, },
"devDependencies": { "devDependencies": {
"h3": "^1.15.11", "h3": "^1.15.5",
"listhen": "^1.10.1", "listhen": "^1.9.0",
"tailwindcss": "workspace:*" "tailwindcss": "workspace:*"
} }
} }

View file

@ -14,7 +14,7 @@ export default defineConfig({
/* Opt out of parallel tests on CI. */ /* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined, workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */ /* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: [['html', { open: 'never' }]], reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: { use: {
/* Base URL to use in actions like `await page.goto('/')`. */ /* Base URL to use in actions like `await page.goto('/')`. */
@ -30,11 +30,10 @@ export default defineConfig({
name: 'chromium', name: 'chromium',
use: { ...devices['Desktop Chrome'] }, use: { ...devices['Desktop Chrome'] },
}, },
// Playwright 1.60's Windows WebKit worker hangs on shutdown after all tests {
// pass, causing CI to fail with a worker force-kill error. name: 'webkit',
...(process.platform === 'win32' use: { ...devices['Desktop Safari'] },
? [] },
: [{ name: 'webkit', use: { ...devices['Desktop Safari'] } }]),
{ {
name: 'firefox', name: 'firefox',
use: { use: {

View file

@ -9,10 +9,6 @@ test.beforeAll(async ({}, info) => {
server = await createServer() server = await createServer()
}) })
test.afterAll(async () => {
await server.close()
})
test('basic', async ({ page }) => { test('basic', async ({ page }) => {
await server.render({ await server.render({
page, page,
@ -170,7 +166,6 @@ async function createServer() {
return { return {
app, app,
url: listener.url, url: listener.url,
close: listener.close,
render, render,
} }
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "@tailwindcss/cli", "name": "@tailwindcss/cli",
"version": "4.3.3", "version": "4.2.2",
"description": "A utility-first CSS framework for rapidly building custom user interfaces.", "description": "A utility-first CSS framework for rapidly building custom user interfaces.",
"license": "MIT", "license": "MIT",
"repository": { "repository": {
@ -29,10 +29,10 @@
"access": "public" "access": "public"
}, },
"dependencies": { "dependencies": {
"@parcel/watcher": "catalog:", "@parcel/watcher": "^2.5.1",
"@tailwindcss/node": "workspace:*", "@tailwindcss/node": "workspace:*",
"@tailwindcss/oxide": "workspace:*", "@tailwindcss/oxide": "workspace:*",
"enhanced-resolve": "catalog:", "enhanced-resolve": "^5.19.0",
"mri": "^1.2.0", "mri": "^1.2.0",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"tailwindcss": "workspace:*" "tailwindcss": "workspace:*"

View file

@ -1,3 +1,4 @@
import watcher from '@parcel/watcher'
import { import {
compile, compile,
env, env,
@ -20,13 +21,11 @@ import {
highlight, highlight,
println, println,
relative, relative,
wordWrap,
} from '../../utils/renderer' } from '../../utils/renderer'
import { drainStdin, outputFile } from './utils' import { drainStdin, outputFile } from './utils'
const css = String.raw const css = String.raw
const DEBUG = env.DEBUG const DEBUG = env.DEBUG
const DEFAULT_POLL_INTERVAL_MS = 250
export function options() { export function options() {
return { return {
@ -48,12 +47,6 @@ export function options() {
alias: '-w', alias: '-w',
values: ['always'], values: ['always'],
}, },
'--poll': {
type: 'boolean | number',
description: 'Use polling instead of filesystem events when watching',
default: false,
values: ['ms'],
},
'--minify': { '--minify': {
type: 'boolean', type: 'boolean',
description: 'Optimize and minify the output', description: 'Optimize and minify the output',
@ -73,58 +66,23 @@ export function options() {
description: 'Generate a source map', description: 'Generate a source map',
default: false, default: false,
}, },
'--silent': {
type: 'boolean',
description: 'Suppress non-error output',
},
} satisfies Arg } satisfies Arg
} }
function formatError(error: unknown): string {
let seen = new Set() // Track seen errors to prevent circular errors
function render(err: unknown, depth: number): string[] {
let indent = ' '.repeat(depth)
let width = (process.stderr.columns ?? Infinity) - 2 * (depth + 1)
let output = [
`${indent}${red(depth === 0 ? 'Error:' : 'Caused by:')}`,
`${indent}${dim('\u250C')}`,
]
for (let line of `${err}`.split('\n')) {
let wrapped = wordWrap(line, width)
if (wrapped.length === 0) wrapped = ['']
for (let chunk of wrapped) {
output.push(`${indent}${dim('\u2502')} ${chunk}`)
}
}
output.push(`${indent}${dim('\u2514')}`)
if (typeof err === 'object' && err !== null && 'cause' in err && err.cause != null) {
if (!seen.add(err).has(err.cause)) {
output.push(...render(err.cause, depth + 1))
}
}
return output
}
return render(error, 0).join('\n')
}
async function handleError<T>(fn: () => T): Promise<T> { async function handleError<T>(fn: () => T): Promise<T> {
try { try {
return await fn() return await fn()
} catch (err) { } catch (err) {
eprintln(formatError(err)) if (err instanceof Error) {
eprintln(err.toString())
}
process.exit(1) process.exit(1)
} }
} }
export async function handle(args: Result<ReturnType<typeof options>>) { export async function handle(args: Result<ReturnType<typeof options>>) {
if (!args['--silent']) eprintln(header()) eprintln(header())
if (!args['--silent']) eprintln() eprintln()
using I = new Instrumentation() using I = new Instrumentation()
DEBUG && I.start('[@tailwindcss/cli] (initial build)') DEBUG && I.start('[@tailwindcss/cli] (initial build)')
@ -163,17 +121,7 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
// If the user passes `{bin} build --map -` then this likely means they want to output the map inline // If the user passes `{bin} build --map -` then this likely means they want to output the map inline
// this is the default behavior of `{bin build} --map` to inform the user of that // this is the default behavior of `{bin build} --map` to inform the user of that
if (args['--map'] === '-') { if (args['--map'] === '-') {
eprintln(`Use --map without a value to inline the source map.`) eprintln(`Use --map without a value to inline the source map`)
process.exit(1)
}
if (args['--poll'] === undefined) {
eprintln(`Use --poll with a non-zero value in milliseconds.`)
process.exit(1)
}
let pollInterval = args['--poll'] === true ? DEFAULT_POLL_INTERVAL_MS : args['--poll']
if (pollInterval !== false && pollInterval <= 0) {
eprintln(`Specified polling interval must be a positive number.`)
process.exit(1) process.exit(1)
} }
@ -196,7 +144,6 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
let previous = { let previous = {
css: '', css: '',
optimizedCss: '', optimizedCss: '',
output: null as string | null,
} }
async function write( async function write(
@ -253,18 +200,13 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
} }
} }
let didChange = output !== previous.output
DEBUG && I.start('Write output') DEBUG && I.start('Write output')
if (args['--output'] && args['--output'] !== '-') { if (args['--output'] && args['--output'] !== '-') {
await outputFile(args['--output'], output) await outputFile(args['--output'], output)
} else if (didChange) { } else {
println(output) println(output)
} }
DEBUG && I.end('Write output') DEBUG && I.end('Write output')
previous.output = output
return didChange
} }
let inputFilePath = let inputFilePath =
@ -273,7 +215,6 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
let inputBasePath = inputFilePath ? path.dirname(inputFilePath) : process.cwd() let inputBasePath = inputFilePath ? path.dirname(inputFilePath) : process.cwd()
let fullRebuildPaths: string[] = inputFilePath ? [inputFilePath] : [] let fullRebuildPaths: string[] = inputFilePath ? [inputFilePath] : []
let backupRebuildPaths = fullRebuildPaths
async function createCompiler(css: string, I: Instrumentation) { async function createCompiler(css: string, I: Instrumentation) {
DEBUG && I.start('Setup compiler') DEBUG && I.start('Setup compiler')
@ -300,24 +241,6 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
return [{ ...compiler.root, negated: false }] return [{ ...compiler.root, negated: false }]
})().concat(compiler.sources) })().concat(compiler.sources)
// Do not scan the current executable. Otherwise when using the standalone
// CLI, if the CLI lives in the current repo we would be scanning that file.
//
// This is also immune against renames of the executable file.
sources.push({
base: path.dirname(process.execPath),
pattern: path.basename(process.execPath),
negated: true,
})
if (inputFilePath !== null) {
sources.push({
base: path.dirname(inputFilePath),
pattern: path.basename(inputFilePath),
negated: false,
})
}
let scanner = new Scanner({ sources }) let scanner = new Scanner({ sources })
DEBUG && I.end('Setup compiler') DEBUG && I.end('Setup compiler')
@ -325,16 +248,12 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
} }
let [compiler, scanner] = await handleError(() => createCompiler(input, I)) let [compiler, scanner] = await handleError(() => createCompiler(input, I))
let cleanupWatchers: (() => Promise<void>)[] = []
// Watch for changes // Watch for changes
if (args['--watch'] && pollInterval === false) { if (args['--watch']) {
// Ensure the file watcher can be loaded before setting up any watchers, let cleanupWatchers: (() => Promise<void>)[] = []
// such that we can present a helpful error message if needed.
await handleError(() => loadWatcher())
cleanupWatchers.push( cleanupWatchers.push(
await createWatchers(await watchDirectories(scanner), async function handle(files) { await createWatchers(watchDirectories(scanner), async function handle(files) {
try { try {
// If the only change happened to the output file, then we don't want to // If the only change happened to the output file, then we don't want to
// trigger a rebuild because that will result in an infinite loop. // trigger a rebuild because that will result in an infinite loop.
@ -346,15 +265,36 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
// Re-compile the input // Re-compile the input
let start = process.hrtime.bigint() let start = process.hrtime.bigint()
let changedFiles: ChangedContent[] = []
let rebuildStrategy: 'incremental' | 'full' = 'incremental'
let resolvedFullRebuildPaths = fullRebuildPaths let resolvedFullRebuildPaths = fullRebuildPaths
let rebuildStrategy = getRebuildStrategy(files, resolvedFullRebuildPaths)
for (let file of files) {
// If one of the changed files is related to the input CSS or JS
// config/plugin files, then we need to do a full rebuild because
// the theme might have changed.
if (resolvedFullRebuildPaths.includes(file)) {
rebuildStrategy = 'full'
// No need to check the rest of the events, because we already know we
// need to do a full rebuild.
break
}
// Track new and updated files for incremental rebuilds.
changedFiles.push({
file,
extension: path.extname(file).slice(1),
} satisfies ChangedContent)
}
// Track the compiled CSS // Track the compiled CSS
let compiledCss = '' let compiledCss = ''
let compiledMap: SourceMap | null = null let compiledMap: SourceMap | null = null
// Scan the entire `base` directory for full rebuilds. // Scan the entire `base` directory for full rebuilds.
if (rebuildStrategy.kind === 'full') { if (rebuildStrategy === 'full') {
// Read the new `input`. // Read the new `input`.
let input = args['--input'] let input = args['--input']
? args['--input'] === '-' ? args['--input'] === '-'
@ -364,23 +304,11 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
@import 'tailwindcss'; @import 'tailwindcss';
` `
clearRequireCache(resolvedFullRebuildPaths) clearRequireCache(resolvedFullRebuildPaths)
// Track current rebuild paths in case something goes wrong when
// performing a full rebuild.
backupRebuildPaths = fullRebuildPaths.slice()
// The `inputFilePath`, if provided, will be the only known full
// rebuild path before the compiler is re-created.
fullRebuildPaths = inputFilePath ? [inputFilePath] : [] fullRebuildPaths = inputFilePath ? [inputFilePath] : []
// Create a new compiler, given the new `input` // Create a new compiler, given the new `input`
;[compiler, scanner] = await createCompiler(input, I) ;[compiler, scanner] = await createCompiler(input, I)
// Successfully created a new compiler, so the `fullRebuildPaths`
// will be updated. If other errors occur, we should be able to
// restore the paths unconditionally.
backupRebuildPaths = fullRebuildPaths.slice()
// Scan the directory for candidates // Scan the directory for candidates
DEBUG && I.start('Scan for candidates') DEBUG && I.start('Scan for candidates')
let candidates = scanner.scan() let candidates = scanner.scan()
@ -388,7 +316,7 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
// Setup new watchers // Setup new watchers
DEBUG && I.start('Setup new watchers') DEBUG && I.start('Setup new watchers')
let newCleanupFunction = await createWatchers(await watchDirectories(scanner), handle) let newCleanupFunction = await createWatchers(watchDirectories(scanner), handle)
DEBUG && I.end('Setup new watchers') DEBUG && I.end('Setup new watchers')
// Clear old watchers // Clear old watchers
@ -411,16 +339,16 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
} }
// Scan changed files only for incremental rebuilds. // Scan changed files only for incremental rebuilds.
else if (rebuildStrategy.kind === 'incremental') { else if (rebuildStrategy === 'incremental') {
DEBUG && I.start('Scan for candidates') DEBUG && I.start('Scan for candidates')
let newCandidates = scanner.scanFiles(rebuildStrategy.changedFiles) let newCandidates = scanner.scanFiles(changedFiles)
DEBUG && I.end('Scan for candidates') DEBUG && I.end('Scan for candidates')
// No new candidates found which means we don't need to write to // No new candidates found which means we don't need to write to
// disk, and can return early. // disk, and can return early.
if (newCandidates.length <= 0) { if (newCandidates.length <= 0) {
let end = process.hrtime.bigint() let end = process.hrtime.bigint()
if (!args['--silent']) eprintln(`Done in ${formatDuration(end - start)}`) eprintln(`Done in ${formatDuration(end - start)}`)
return return
} }
@ -438,48 +366,13 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
await write(compiledCss, compiledMap, args, I) await write(compiledCss, compiledMap, args, I)
let end = process.hrtime.bigint() let end = process.hrtime.bigint()
if (!args['--silent']) eprintln(`Done in ${formatDuration(end - start)}`) eprintln(`Done in ${formatDuration(end - start)}`)
} catch (err) { } catch (err) {
// It's important that we perform a full rebuild when any of the
// dependencies tracked in `fullRebuildPaths` has been changed.
//
// If we remove one of those files, then a subsequent build will be
// triggered, but it will fail because the dependency is gone. The
// compiler itself will be in a broken state and won't be able to
// register any dependencies therefore we want to restore all the
// dependencies from before. If we don't do that, then we won't be
// able to recover from a bug in a transitive dependency.
//
// E.g.:
// ```css
// /* input.css — known full rebuild path */
// @import 'tailwindcss';
// @config "./tailwind.config.js";
// ```
//
// ```js
// // tailwind.config.js
// const theme = require('./my-theme.js');
//
// module.exports = {
// theme
// }
// ```
// In this case `my-theme.js` is a transitive dependency of
// `input.css` via `tailwind.config.js`. Removing `my-theme.js` will
// result in an error, restoring `my-theme.js` should trigger a
// fresh build even though the compiler didn't restore.
//
// Once the build error is fixed, a fresh full rebuild will happen
// which in turn will fixup the full rebuild paths.
fullRebuildPaths = backupRebuildPaths
// Catch any errors and print them to stderr, but don't exit the process // Catch any errors and print them to stderr, but don't exit the process
// and keep watching. // and keep watching.
eprintln(formatError(err)) if (err instanceof Error) {
eprintln(err.toString())
let end = process.hrtime.bigint() }
if (!args['--silent']) eprintln(`Done in ${formatDuration(end - start)}`)
} }
}), }),
) )
@ -517,185 +410,10 @@ export async function handle(args: Result<ReturnType<typeof options>>) {
await write(output, map, args, I) await write(output, map, args, I)
let end = process.hrtime.bigint() let end = process.hrtime.bigint()
if (!args['--silent']) eprintln(`Done in ${formatDuration(end - start)}`) eprintln(`Done in ${formatDuration(end - start)}`)
// Watch for changes, using polling
if (args['--watch'] && pollInterval !== false) {
let cleanupPollingIndicator = () => {}
function logPollingMessage(message: string) {
if (!args['--silent']) {
process.stderr.write('\r\x1B[2K')
}
eprintln(message)
if (!args['--silent']) {
process.stderr.write(`\r\x1B[2K${dim(`Polling for changes…`)}`)
}
}
async function fullRebuild(I: Instrumentation) {
clearRequireCache(fullRebuildPaths)
backupRebuildPaths = fullRebuildPaths.slice()
fullRebuildPaths = inputFilePath ? [inputFilePath] : []
let input = args['--input']
? args['--input'] === '-'
? await drainStdin()
: await fs.readFile(args['--input'], 'utf-8')
: css`
@import 'tailwindcss';
`
;[compiler, scanner] = await createCompiler(input, I)
backupRebuildPaths = fullRebuildPaths.slice()
DEBUG && I.start('Scan for candidates')
let candidates = scanner.scan()
DEBUG && I.end('Scan for candidates')
DEBUG && I.start('Build CSS')
let output = compiler.build(candidates)
DEBUG && I.end('Build CSS')
let map: SourceMap | null = null
if (args['--map']) {
DEBUG && I.start('Build Source Map')
map = toSourceMap(compiler.buildSourceMap())
DEBUG && I.end('Build Source Map')
}
await write(output, map, args, I)
}
if (!args['--silent']) {
let restored = false
function restoreCursor() {
if (restored) return
restored = true
process.stderr.write('\r\x1B[2K\x1B[?25h')
process.off('exit', restoreCursor)
process.off('SIGINT', onSigint)
process.off('SIGTERM', onSigterm)
}
function onSigint() {
restoreCursor()
process.exit(130)
}
function onSigterm() {
restoreCursor()
process.exit(143)
}
process.stderr.write('\x1B[?25l')
process.on('exit', restoreCursor)
process.on('SIGINT', onSigint)
process.on('SIGTERM', onSigterm)
cleanupPollingIndicator = restoreCursor
cleanupWatchers.push(async () => cleanupPollingIndicator())
}
cleanupWatchers.push(
createPollingWatcher(async () => {
using I = new Instrumentation()
DEBUG && I.start('Scan for candidates')
let candidates = scanner.scan()
DEBUG && I.end('Scan for candidates')
let files = scanner.scannedFiles.filter(
(file) => file !== args['--output'] && file !== args['--map'],
)
if (files.length <= 0) return
let start = process.hrtime.bigint()
let strategy = getRebuildStrategy(files, fullRebuildPaths)
if (strategy.kind === 'full') {
try {
await fullRebuild(I)
} catch (err) {
fullRebuildPaths = backupRebuildPaths
logPollingMessage(formatError(err))
}
if (!args['--silent']) {
logPollingMessage(`Done in ${formatDuration(process.hrtime.bigint() - start)}`)
}
return
}
// No candidates found which means we don't need to write to disk, and
// can return early.
if (candidates.length <= 0) {
if (!args['--silent']) {
logPollingMessage(`Done in ${formatDuration(process.hrtime.bigint() - start)}`)
}
return
}
DEBUG && I.start('Build CSS')
let output = compiler.build(candidates)
DEBUG && I.end('Build CSS')
let map: SourceMap | null = null
if (args['--map']) {
DEBUG && I.start('Build Source Map')
map = toSourceMap(compiler.buildSourceMap())
DEBUG && I.end('Build Source Map')
}
await write(output, map, args, I)
if (!args['--silent']) {
logPollingMessage(`Done in ${formatDuration(process.hrtime.bigint() - start)}`)
}
}, pollInterval),
)
if (args['--watch'] !== 'always') {
process.stdin.on('end', () => {
Promise.all(cleanupWatchers.map((fn) => fn())).then(
() => process.exit(0),
() => process.exit(1),
)
})
}
process.stdin.resume()
if (!args['--silent']) {
process.stderr.write(`\r\x1B[2K${dim(`Polling for changes…`)}`)
}
}
}
// Load `@parcel/watcher` lazily so a missing or broken native binding only
// affects `--watch` (without `--poll`), instead of crashing one-off builds and
// polling mode as well.
async function loadWatcher(): Promise<typeof import('@parcel/watcher')> {
try {
return (await import('@parcel/watcher')).default
} catch (err) {
throw new Error(
`Failed to load the file watcher. Your platform may not be supported by \`@parcel/watcher\`. As a workaround, you can use polling instead by passing the \`--watch --poll\` flags.`,
{ cause: err },
)
}
} }
async function createWatchers(dirs: string[], cb: (files: string[]) => void) { async function createWatchers(dirs: string[], cb: (files: string[]) => void) {
let watcher = await loadWatcher()
// Remove any directories that are children of an already watched directory. // Remove any directories that are children of an already watched directory.
// If we don't we may not get notified of certain filesystem events regardless // If we don't we may not get notified of certain filesystem events regardless
// of whether or not they are for the directory that is duplicated. // of whether or not they are for the directory that is duplicated.
@ -757,16 +475,10 @@ async function createWatchers(dirs: string[], cb: (files: string[]) => void) {
await Promise.all( await Promise.all(
events.map(async (event) => { events.map(async (event) => {
// When a file is deleted, a rebuild should be triggered such that we // We currently don't handle deleted files because it doesn't influence
// can figure out whether this file must trigger a fresh build or not. // the CSS output. This is because we currently keep all scanned
// // candidates in a cache for performance reasons.
// If it must trigger a fresh build, then we will temporarily end up if (event.type === 'delete') return
// in a broken state, but an error will be shown to the user. Once the
// user resolves the issue, the CLI will recover.
if (event.type === 'delete') {
files.add(event.path)
return
}
// Ignore directory changes. We only care about file changes // Ignore directory changes. We only care about file changes
let stats: Stats | null = null let stats: Stats | null = null
@ -797,78 +509,6 @@ async function createWatchers(dirs: string[], cb: (files: string[]) => void) {
} }
} }
function getRebuildStrategy( function watchDirectories(scanner: Scanner) {
files: string[], return [...new Set(scanner.normalizedSources.flatMap((globEntry) => globEntry.base))]
fullRebuildPaths: string[],
): { kind: 'incremental'; changedFiles: ChangedContent[] } | { kind: 'full' } {
let changedFiles: ChangedContent[] = []
for (let file of files) {
// If one of the changed files is related to the input CSS or JS
// config/plugin files, then we need to do a full rebuild because
// the theme might have changed.
if (fullRebuildPaths.includes(file)) {
return { kind: 'full' }
}
// Track new and updated files for incremental rebuilds.
changedFiles.push({
file,
extension: path.extname(file).slice(1),
} satisfies ChangedContent)
}
return { kind: 'incremental', changedFiles }
}
function createPollingWatcher(cb: () => Promise<void>, pollInterval: number) {
let disposed = false
let timer: ReturnType<typeof setTimeout> | null = null
async function poll() {
if (disposed) return
try {
await cb()
} catch (err) {
console.error(err)
} finally {
if (!disposed) {
timer = setTimeout(poll, pollInterval)
}
}
}
timer = setTimeout(poll, pollInterval)
return async () => {
disposed = true
if (timer) clearTimeout(timer)
}
}
async function watchDirectories(scanner: Scanner) {
let directories = (
await Promise.all(
scanner.normalizedSources.map(async (globEntry) => {
let resolvedPath = path.resolve(globEntry.base)
let realPath = await fs.realpath(resolvedPath).catch(() => resolvedPath)
return fs
.stat(realPath)
.then((stat) => (stat.isDirectory() ? [realPath] : []))
.catch(() => [])
}),
)
).flat(1)
return Array.from(new Set(directories))
}
function dim(str: string) {
return `\x1B[2m${str}\x1B[22m`
}
function red(str: string) {
return `\x1B[31m${str}\x1B[39m`
} }

View file

@ -11,7 +11,7 @@ const sharedOptions = {
} satisfies Arg } satisfies Arg
function buildUsage(command = 'tailwindcss') { function buildUsage(command = 'tailwindcss') {
return `${command} [--input input.css] [--output output.css] [--watch] [--poll=ms] [options…]` return `${command} [--input input.css] [--output output.css] [--watch] [options…]`
} }
function rootHelp({ invalid }: { invalid?: string } = {}) { function rootHelp({ invalid }: { invalid?: string } = {}) {

View file

@ -153,34 +153,3 @@ it('should be possible to provide multiple types, and convert the value to that
} }
`) `)
}) })
it('should be possible to provide boolean or number arguments', () => {
let options = {
'--poll': {
type: 'boolean | number',
description: 'Use polling instead of filesystem events when watching',
default: false,
},
} satisfies Arg
expect(args(options, ['--poll'])).toMatchInlineSnapshot(`
{
"--poll": true,
"_": [],
}
`)
expect(args(options, ['--poll=50'])).toMatchInlineSnapshot(`
{
"--poll": 50,
"_": [],
}
`)
expect(args(options, ['--poll=foo'])).toMatchInlineSnapshot(`
{
"--poll": undefined,
"_": [],
}
`)
})

View file

@ -49,9 +49,7 @@ export function wordWrap(text: string, width: number) {
for (let word of words) { for (let word of words) {
let wordLength = stripVTControlCharacters(word).length let wordLength = stripVTControlCharacters(word).length
// A word longer than `width` is kept on its own line rather than if (lineLength + wordLength + 1 > width) {
// introducing an empty line before it.
if (lineLength > 0 && lineLength + wordLength + 1 > width) {
lines.push(line) lines.push(line)
line = '' line = ''
lineLength = 0 lineLength = 0

View file

@ -1,6 +1,6 @@
{ {
"name": "@tailwindcss/node", "name": "@tailwindcss/node",
"version": "4.3.3", "version": "4.2.2",
"description": "A utility-first CSS framework for rapidly building custom user interfaces.", "description": "A utility-first CSS framework for rapidly building custom user interfaces.",
"license": "MIT", "license": "MIT",
"repository": { "repository": {
@ -19,7 +19,8 @@
], ],
"publishConfig": { "publishConfig": {
"provenance": true, "provenance": true,
"access": "public", "access": "public"
},
"exports": { "exports": {
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
@ -34,30 +35,13 @@
"types": "./dist/esm-cache.loader.d.mts", "types": "./dist/esm-cache.loader.d.mts",
"default": "./dist/esm-cache.loader.mjs" "default": "./dist/esm-cache.loader.mjs"
} }
}
},
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts",
"require": "./src/index.cts"
},
"./require-cache": {
"types": "./src/require-cache.ts",
"import": "./src/require-cache.ts",
"require": "./src/require-cache.cts"
},
"./esm-cache-loader": {
"types": "./src/esm-cache.loader.mts",
"default": "./src/esm-cache.loader.mts"
}
}, },
"dependencies": { "dependencies": {
"@jridgewell/remapping": "^2.3.5", "@jridgewell/remapping": "^2.3.5",
"enhanced-resolve": "catalog:", "enhanced-resolve": "^5.19.0",
"jiti": "^2.7.0", "jiti": "^2.6.1",
"lightningcss": "catalog:", "lightningcss": "catalog:",
"magic-string": "^1.1.0", "magic-string": "^0.30.21",
"source-map-js": "^1.2.1", "source-map-js": "^1.2.1",
"tailwindcss": "workspace:*" "tailwindcss": "workspace:*"
} }

View file

@ -194,10 +194,7 @@ async function importModule(path: string): Promise<any> {
} }
} }
const modules = [ const modules = ['node_modules', ...(process.env.NODE_PATH ? [process.env.NODE_PATH] : [])]
'node_modules',
...(process.env.NODE_PATH ? [...process.env.NODE_PATH.split(path.delimiter)] : []),
]
const cssResolver = EnhancedResolve.ResolverFactory.createResolver({ const cssResolver = EnhancedResolve.ResolverFactory.createResolver({
fileSystem: new EnhancedResolve.CachedInputFileSystem(fs, 4000), fileSystem: new EnhancedResolve.CachedInputFileSystem(fs, 4000),

View file

@ -1,23 +1,8 @@
import { import { isBuiltin, type ResolveHook } from 'node:module'
isBuiltin,
type ResolveFnOutput,
type ResolveHook,
type ResolveHookContext,
type ResolveHookSync,
} from 'node:module'
export let resolve: ResolveHook = async (specifier, context, nextResolve) => { export let resolve: ResolveHook = async (specifier, context, nextResolve) => {
let result = await nextResolve(specifier, context) let result = await nextResolve(specifier, context)
return processResolve(context, result)
}
export let resolveSync: ResolveHookSync = (specifier, context, nextResolve) => {
let result = nextResolve(specifier, context)
return processResolve(context, result)
}
function processResolve(context: ResolveHookContext, result: ResolveFnOutput) {
try {
if (result.url === import.meta.url) return result if (result.url === import.meta.url) return result
if (isBuiltin(result.url)) return result if (isBuiltin(result.url)) return result
if (!context.parentURL) return result if (!context.parentURL) return result
@ -34,7 +19,4 @@ function processResolve(context: ResolveHookContext, result: ResolveFnOutput) {
...result, ...result,
url: `${url}`, url: `${url}`,
} }
} catch {
return result
}
} }

View file

@ -1,7 +1,6 @@
import * as Module from 'node:module' import * as Module from 'node:module'
import { pathToFileURL } from 'node:url' import { pathToFileURL } from 'node:url'
import * as env from './env' import * as env from './env'
import { resolveSync } from './esm-cache.loader.mjs'
export * from './compile' export * from './compile'
export * from './instrumentation' export * from './instrumentation'
export * from './normalize-path' export * from './normalize-path'
@ -13,14 +12,8 @@ export { env }
// not necessary. // not necessary.
if (!process.versions.bun) { if (!process.versions.bun) {
// `Module#register` was added in Node v18.19.0 and v20.6.0 // `Module#register` was added in Node v18.19.0 and v20.6.0
// `Module#registerHooks` was added in Node v22.15.0 and v23.5.0 and is the preferred API since v25.9.0,
// runtime-deprecating `Module#register` since v26
// //
// Not calling it means that while ESM dependencies don't get reloaded, the // Not calling it means that while ESM dependencies don't get reloaded, the
// actual included files will because they cache bust directly via `?id=…` // actual included files will because they cache bust directly via `?id=…`
if (Module.registerHooks) {
Module.registerHooks({ resolve: resolveSync })
} else {
Module.register?.(pathToFileURL(require.resolve('@tailwindcss/node/esm-cache-loader'))) Module.register?.(pathToFileURL(require.resolve('@tailwindcss/node/esm-cache-loader')))
}
} }

View file

@ -1,7 +1,6 @@
import * as Module from 'node:module' import * as Module from 'node:module'
import { pathToFileURL } from 'node:url' import { pathToFileURL } from 'node:url'
import * as env from './env' import * as env from './env'
import { resolveSync } from './esm-cache.loader.mjs'
export * from './compile' export * from './compile'
export * from './instrumentation' export * from './instrumentation'
export * from './normalize-path' export * from './normalize-path'
@ -12,16 +11,11 @@ export { env }
// In Bun, ESM modules will also populate `require.cache`, so the module hook is // In Bun, ESM modules will also populate `require.cache`, so the module hook is
// not necessary. // not necessary.
if (!process.versions.bun) { if (!process.versions.bun) {
let localRequire = Module.createRequire(import.meta.url)
// `Module#register` was added in Node v18.19.0 and v20.6.0 // `Module#register` was added in Node v18.19.0 and v20.6.0
// `Module#registerHooks` was added in Node v22.15.0 and v23.5.0 and is the preferred API since v25.9.0,
// runtime-deprecating `Module#register` since v26
// //
// Not calling it means that while ESM dependencies don't get reloaded, the // Not calling it means that while ESM dependencies don't get reloaded, the
// actual included files will because they cache bust directly via `?id=…` // actual included files will because they cache bust directly via `?id=…`
if (Module.registerHooks) {
Module.registerHooks({ resolve: resolveSync })
} else {
let localRequire = Module.createRequire(import.meta.url)
Module.register?.(pathToFileURL(localRequire.resolve('@tailwindcss/node/esm-cache-loader'))) Module.register?.(pathToFileURL(localRequire.resolve('@tailwindcss/node/esm-cache-loader')))
}
} }

View file

@ -2,7 +2,7 @@ import { stripVTControlCharacters } from 'util'
import { expect, it } from 'vitest' import { expect, it } from 'vitest'
import { Instrumentation } from './instrumentation' import { Instrumentation } from './instrumentation'
it('should add instrumentation using start/end markers', () => { it('should add instrumentation', () => {
let I = new Instrumentation() let I = new Instrumentation()
I.start('Foo') I.start('Foo')
@ -35,83 +35,6 @@ it('should add instrumentation using start/end markers', () => {
}) })
}) })
it('should measure callbacks via the `span` api', () => {
let I = new Instrumentation()
I.span('Foo', () => {
let x = 1
for (let i = 0; i < 100; i++) {
I.span('Bar', () => {
x **= 2
})
}
})
expect.assertions(1)
I.report((output) => {
expect(stripVTControlCharacters(output).replace(/\[.*\]/g, '[0.xxms]')).toMatchInlineSnapshot(`
"
[0.xxms] Foo
[0.xxms] ↳ Bar × 100
"
`)
})
})
it('should measure async callbacks via the `span` api', async () => {
let I = new Instrumentation()
await I.span('Foo', async () => {
let x = 1
for (let i = 0; i < 100; i++) {
I.span('Bar', () => {
x **= 2
})
}
await new Promise((r) => setTimeout(r, 500))
})
expect.assertions(2)
I.report((output) => {
expect(stripVTControlCharacters(output).replace(/\[.*\]/g, '[0.xxms]')).toMatchInlineSnapshot(`
"
[0.xxms] Foo
[0.xxms] ↳ Bar × 100
"
`)
let [, duration] = stripVTControlCharacters(output).match(/\[(.*)\]/)!
expect(parseFloat(duration)).toBeGreaterThanOrEqual(500 - 2 /* Margin of error */)
})
})
it('should measure blocks until they go out of scope via `using`', () => {
let I = new Instrumentation()
{
using _ = I.track('Foo')
let x = 1
for (let i = 0; i < 100; i++) {
using _ = I.track('Bar')
x **= 2
}
}
expect.assertions(1)
I.report((output) => {
expect(stripVTControlCharacters(output).replace(/\[.*\]/g, '[0.xxms]')).toMatchInlineSnapshot(`
"
[0.xxms] Foo
[0.xxms] ↳ Bar × 100
"
`)
})
})
it('should auto end pending timers when reporting', () => { it('should auto end pending timers when reporting', () => {
let I = new Instrumentation() let I = new Instrumentation()

View file

@ -13,7 +13,6 @@ export class Instrumentation implements Disposable {
#timerStack: { id: string; label: string; namespace: string; value: bigint }[] = [] #timerStack: { id: string; label: string; namespace: string; value: bigint }[] = []
constructor( constructor(
private shouldReport = env.DEBUG,
private defaultFlush = (message: string) => void process.stderr.write(`${message}\n`), private defaultFlush = (message: string) => void process.stderr.write(`${message}\n`),
) {} ) {}
@ -49,41 +48,6 @@ export class Instrumentation implements Disposable {
this.#timers.get(parent.id).value += elapsed this.#timers.get(parent.id).value += elapsed
} }
track(label: string) {
this.start(label)
let done = false
return {
[Symbol.dispose]: () => {
if (!done) {
this.end(label)
done = true
}
},
[Symbol.asyncDispose]: () => {
if (!done) {
this.end(label)
done = true
}
},
}
}
span<T>(label: string, fn: () => T): T {
this.start(label)
let isPromise = false
try {
let result = fn()
isPromise = result && typeof (result as any).then === 'function'
// @ts-expect-error — TS can't infer that result is a Promise here
return isPromise ? result.finally(() => this.end(label)) : result
} finally {
if (!isPromise) this.end(label)
}
}
reset() { reset() {
this.#hits.clear() this.#hits.clear()
this.#timers.clear() this.#timers.clear()
@ -136,7 +100,7 @@ export class Instrumentation implements Disposable {
} }
[Symbol.dispose]() { [Symbol.dispose]() {
this.shouldReport && this.report() env.DEBUG && this.report()
} }
} }

View file

@ -61,14 +61,10 @@ export function optimize(
map = result.map?.toString() map = result.map?.toString()
result.warnings = result.warnings.filter((warning) => { result.warnings = result.warnings.filter((warning) => {
// Ignore warnings about unknown pseudo-classes that are used in frameworks // Ignore warnings about unknown pseudo-classes as they are likely caused
// such as Vue or Angular and are handled by their own compilers, before // by the use of `:deep()`, `:slotted()`, and `:global()` which are not
// reaching the browser. // standard CSS but are commonly used in frameworks like Vue.
if ( if (/'(deep|slotted|global)' is not recognized as a valid pseudo-/.test(warning.message)) {
/'(deep|slotted|global|ng-deep|host-context)' is not recognized as a valid pseudo-/.test(
warning.message,
)
) {
return false return false
} }

View file

@ -1,5 +0,0 @@
export function clearRequireCache(files: string[]) {
for (let key of files) {
delete require.cache[key]
}
}

View file

@ -1,6 +1,3 @@
{ {
"extends": "../tsconfig.base.json", "extends": "../tsconfig.base.json",
"compilerOptions": {
"verbatimModuleSyntax": false,
},
} }

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