diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 42645aa49..63f79fa10 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -50,7 +50,7 @@ If you open a pull request for a new feature, we're likely to close it not becau ## Coding standards -Our code formatting rules are defined in the `"prettier"` section of [package.json](https://github.com/tailwindcss/tailwindcss/blob/main/package.json). You can check your code against these standards by running: +Our code formatting rules are defined in the `"prettier"` section of [package.json](https://github.com/tailwindlabs/tailwindcss/blob/main/package.json). You can check your code against these standards by running: ```sh pnpm run lint @@ -76,7 +76,7 @@ To run the integration tests, use: pnpm build && pnpm test:integrations ``` -Additionally, some features require testing in browsers (i.e to ensure CSS variable resolution works as expected). These can be run via: +Additionally, some features require testing in browsers (i.e. to ensure CSS variable resolution works as expected). These can be run via: ```sh pnpm build && pnpm test:ui diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index ee9d3f9ab..aa0793aa3 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -8,7 +8,7 @@ It's never a fun experience to have your pull request declined after investing a For more info, check out the contributing guide: -https://github.com/tailwindcss/tailwindcss/blob/main/.github/CONTRIBUTING.md +https://github.com/tailwindlabs/tailwindcss/blob/main/.github/CONTRIBUTING.md --> diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0c8f483e..5ae59e80f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,12 +8,18 @@ on: permissions: contents: read +env: + NODE_VERSION: 24 + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: tests: strategy: fail-fast: false matrix: - node-version: [20] runner: - name: Windows os: windows-latest @@ -21,12 +27,14 @@ jobs: - name: Linux os: namespace-profile-default + # Playwright 1.62+ dropped WebKit support for macOS 14, and hangs + # instead of failing when launching WebKit on a macos-14 runner. - name: macOS - os: macos-14 + os: macos-15 # Exclude windows and macos from being built on feature branches run-all: - - ${{ github.ref == 'refs/heads/main' || contains(github.event.pull_request.body, '[ci-all]') }} + - ${{ github.ref == 'refs/heads/main' || contains(github.event.pull_request.body, '[ci-all]') || github.event.pull_request.user.login == 'depfu[bot]' }} exclude: - run-all: false runner: @@ -41,21 +49,21 @@ jobs: name: ${{ matrix.runner.name }} steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: - node-version: ${{ matrix.node-version }} - cache: 'pnpm' + persist-credentials: false + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + + - name: Use Node.js ${{ env.NODE_VERSION }} + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: ${{ env.NODE_VERSION }} # Cargo already skips downloading dependencies if they already exist - name: Cache cargo - uses: actions/cache@v4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: | - ~/.cargo/bin/ ~/.cargo/registry/index/ ~/.cargo/registry/cache/ ~/.cargo/git/db/ @@ -64,7 +72,7 @@ jobs: # Cache the `oxide` Rust build - name: Cache oxide build - uses: actions/cache@v4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: | ./crates/node/*.node @@ -82,7 +90,7 @@ jobs: run: rustup target add wasm32-wasip1-threads - name: Install dependencies - run: pnpm install + run: pnpm install --frozen-lockfile - name: Build run: pnpm run build @@ -104,9 +112,13 @@ jobs: - name: Run Playwright tests run: npm run test:ui + notify: + if: ${{ always() && github.ref == 'refs/heads/main' && needs.tests.result == 'failure' }} + needs: tests + runs-on: ubuntu-latest + steps: - name: Notify Discord - if: failure() && github.ref == 'refs/heads/main' - uses: discord-actions/message@v2 + uses: discord-actions/message@5c7149c81a83146e5d01f142be1bf87a61831c4d # v2 with: webhookUrl: ${{ secrets.DISCORD_WEBHOOK_URL }} - message: 'The [most recent build](<${{ 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.' diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 0148dd983..457c38424 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -8,13 +8,18 @@ on: permissions: contents: read +env: + NODE_VERSION: 24 + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: tests: strategy: fail-fast: false matrix: - node-version: [20] - runner: - name: Windows os: windows-latest @@ -50,21 +55,25 @@ jobs: name: ${{ matrix.runner.name }} / ${{ matrix.integration }} steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: - node-version: ${{ matrix.node-version }} - cache: 'pnpm' + persist-credentials: false + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + + - run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Use Node.js ${{ env.NODE_VERSION }} + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: ${{ env.NODE_VERSION }} # Cargo already skips downloading dependencies if they already exist - name: Cache cargo - uses: actions/cache@v4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: | - ~/.cargo/bin/ ~/.cargo/registry/index/ ~/.cargo/registry/cache/ ~/.cargo/git/db/ @@ -73,7 +82,7 @@ jobs: # Cache the `oxide` Rust build - name: Cache oxide build - uses: actions/cache@v4 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: | ./crates/node/*.node @@ -99,24 +108,18 @@ jobs: CARGO_PROFILE_RELEASE_LTO: 'off' CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_LINKER: 'lld-link' - - name: Test ${{ matrix.integration }} 1/3 - run: pnpm run test:integrations ./integrations/${{ matrix.integration }} --shard 1/3 - env: - GITHUB_WORKSPACE: ${{ github.workspace }} - - - name: Test ${{ matrix.integration }} 2/3 - run: pnpm run test:integrations ./integrations/${{ matrix.integration }} --shard 2/3 - env: - GITHUB_WORKSPACE: ${{ github.workspace }} - - - name: Test ${{ matrix.integration }} 3/3 - run: pnpm run test:integrations ./integrations/${{ matrix.integration }} --shard 3/3 + - name: Test ${{ matrix.integration }} + run: pnpm run test:integrations ./integrations/${{ matrix.integration }} env: GITHUB_WORKSPACE: ${{ github.workspace }} + notify: + if: ${{ always() && github.ref == 'refs/heads/main' && needs.tests.result == 'failure' }} + needs: tests + runs-on: ubuntu-latest + steps: - name: Notify Discord - if: failure() && github.ref == 'refs/heads/main' - uses: discord-actions/message@v2 + uses: discord-actions/message@5c7149c81a83146e5d01f142be1bf87a61831c4d # v2 with: webhookUrl: ${{ secrets.DISCORD_WEBHOOK_URL }} - message: 'The [most recent build](<${{ 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.' diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index be1050b03..24e829afd 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -2,18 +2,29 @@ name: Prepare Release on: workflow_dispatch: + inputs: + dry_run: + description: Skip creating the draft GitHub release + required: false + default: true + type: boolean push: tags: - 'v*' env: APP_NAME: tailwindcss-oxide - NODE_VERSION: 20 + NODE_VERSION: 24 + PNPM_VERSION: '11.9.0' OXIDE_LOCATION: ./crates/node permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: build: strategy: @@ -43,44 +54,40 @@ jobs: - os: ubuntu-latest target: x86_64-unknown-linux-gnu strip: strip - container: - image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian + build-flags: --use-napi-cross - os: ubuntu-latest target: aarch64-unknown-linux-gnu - strip: llvm-strip - container: - image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian-aarch64 + strip: aarch64-linux-gnu-strip + build-flags: --use-napi-cross - os: ubuntu-latest target: armv7-unknown-linux-gnueabihf - strip: llvm-strip - container: - image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian-zig + strip: arm-linux-gnueabihf-strip + build-flags: --use-napi-cross - 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 + strip-zig: true + build-flags: -x - 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 + build-flags: -x name: Build ${{ matrix.target }} (oxide) runs-on: ${{ matrix.os }} - container: ${{ matrix.container }} timeout-minutes: 15 steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + version: ${{ env.PNPM_VERSION }} - name: Use Node.js ${{ env.NODE_VERSION }} - uses: actions/setup-node@v4 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: ${{ env.NODE_VERSION }} - cache: 'pnpm' + package-manager-cache: false - name: Install gcc-arm-linux-gnueabihf if: ${{ matrix.target == 'armv7-unknown-linux-gnueabihf' }} @@ -88,62 +95,56 @@ jobs: sudo apt-get update sudo apt-get install gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf -y - # Cargo already skips downloading dependencies if they already exist - - name: Cache cargo - uses: actions/cache@v4 - with: - path: | - ~/.cargo/bin/ - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - target/ - key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }} - - # Cache the `oxide` Rust build - - name: Cache oxide build - uses: actions/cache@v4 - with: - path: | - ./crates/node/*.node - ./crates/node/*.wasm - ./crates/node/index.d.ts - ./crates/node/index.js - ./crates/node/browser.js - ./crates/node/tailwindcss-oxide.wasi-browser.js - ./crates/node/tailwindcss-oxide.wasi.cjs - ./crates/node/wasi-worker-browser.mjs - ./crates/node/wasi-worker.mjs - key: ${{ runner.os }}-${{ matrix.target }}-oxide-${{ hashFiles('./crates/**/*') }} - - - name: Install Node.JS - uses: actions/setup-node@v4 - with: - node-version: ${{ env.NODE_VERSION }} - - - name: Install Rust (Stable) - if: ${{ matrix.download }} + - name: Install binutils-aarch64-linux-gnu + if: ${{ matrix.target == 'aarch64-unknown-linux-gnu' }} run: | - rustup default stable + sudo apt-get update + sudo apt-get install binutils-aarch64-linux-gnu -y + + - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 + if: ${{ contains(matrix.target, 'musl') }} + with: + version: 0.14.1 + use-cache: false + + - name: Install cargo-zigbuild + uses: taiki-e/install-action@65851e10cd6c377f11a60e600abc07cb08643468 # v2 + if: ${{ contains(matrix.target, 'musl') }} + env: + GITHUB_TOKEN: ${{ github.token }} + with: + tool: cargo-zigbuild - name: Setup rust target run: rustup target add ${{ matrix.target }} - name: Install dependencies - run: pnpm install --ignore-scripts --filter=!./playgrounds/* + run: pnpm install --ignore-scripts --frozen-lockfile --filter=!./playgrounds/* - name: Build release - run: pnpm run --filter ${{ env.OXIDE_LOCATION }} build:platform --target=${{ matrix.target }} + run: pnpm run --filter ${{ env.OXIDE_LOCATION }} build:platform --target=${{ matrix.target }} ${{ matrix.build-flags }} 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 + if: ${{ matrix.strip || matrix.strip-zig }} + env: + STRIP_COMMAND: ${{ matrix.strip }} + STRIP_ZIG: ${{ matrix.strip-zig }} + run: | + if [ "$STRIP_ZIG" = "true" ]; then + for file in ${{ env.OXIDE_LOCATION }}/*.node; do + zig objcopy --strip-all "$file" "$file.stripped" + mv "$file.stripped" "$file" + done + exit 0 + fi + + eval "$STRIP_COMMAND ${{ env.OXIDE_LOCATION }}/*.node" - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: bindings-${{ matrix.target }} path: ${{ env.OXIDE_LOCATION }}/*.node @@ -153,9 +154,11 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false - name: Build FreeBSD - uses: cross-platform-actions/action@v0.25.0 + uses: cross-platform-actions/action@cdc9ee69ef84a5f2e59c9058335d9c57bcb4ac86 # v0.25.0 env: DEBUG: napi:* RUSTUP_HOME: /usr/local/rustup @@ -171,12 +174,12 @@ jobs: shell: bash run: | sudo pkg install -y -f curl node libnghttp2 npm - sudo npm install -g pnpm@9.6.0 --unsafe-perm=true + sudo npm install -g pnpm@${{ env.PNPM_VERSION }} --unsafe-perm=true curl -sSf https://static.rust-lang.org/rustup/archive/1.27.1/x86_64-unknown-freebsd/rustup-init --output rustup-init chmod +x rustup-init ./rustup-init -y --profile minimal source "$HOME/.cargo/env" - pnpm install --ignore-scripts --filter=!./playgrounds/* || true + pnpm install --ignore-scripts --frozen-lockfile --filter=!./playgrounds/* || true echo "~~~~ rustc --version ~~~~" rustc --version echo "~~~~ node -v ~~~~" @@ -187,7 +190,7 @@ jobs: strip -x ${{ env.OXIDE_LOCATION }}/*.node ls -la ${{ env.OXIDE_LOCATION }} - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: bindings-x86_64-unknown-freebsd path: ${{ env.OXIDE_LOCATION }}/*.node @@ -198,18 +201,17 @@ jobs: name: Build and release Tailwind CSS 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 + contents: write # Required for creating releases needs: - build - build-freebsd steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 20 + persist-credentials: false - run: git fetch --tags -f @@ -218,51 +220,25 @@ jobs: run: | echo "TAG_NAME=$(git describe --tags --abbrev=0)" >> $GITHUB_ENV - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + version: ${{ env.PNPM_VERSION }} - name: Use Node.js ${{ env.NODE_VERSION }} - uses: actions/setup-node@v4 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # 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@v4 - with: - path: | - ~/.cargo/bin/ - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - target/ - key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }} - - # Cache the `oxide` Rust build - - name: Cache oxide build - uses: actions/cache@v4 - with: - path: | - ./crates/node/*.node - ./crates/node/*.wasm - ./crates/node/index.d.ts - ./crates/node/index.js - ./crates/node/browser.js - ./crates/node/tailwindcss-oxide.wasi-browser.js - ./crates/node/tailwindcss-oxide.wasi.cjs - ./crates/node/wasi-worker-browser.mjs - ./crates/node/wasi-worker.mjs - key: ${{ runner.os }}-${{ matrix.target }}-oxide-${{ hashFiles('./crates/**/*') }} + package-manager-cache: false - name: Setup WASM target run: rustup target add wasm32-wasip1-threads - name: Install dependencies - run: pnpm --filter=!./playgrounds/* install + run: pnpm --filter=!./playgrounds/* install --frozen-lockfile - name: Download artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: path: ${{ env.OXIDE_LOCATION }} @@ -301,30 +277,31 @@ jobs: echo "EOF" >> $GITHUB_ENV - name: Upload standalone artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: tailwindcss-standalone path: packages/@tailwindcss-standalone/dist/ - name: Upload npm package tarballs - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: npm-package-tarballs path: dist/*.tgz - name: Prepare GitHub Release - uses: softprops/action-gh-release@v2 - with: - draft: true - tag_name: ${{ env.TAG_NAME }} - body: | - ${{ env.RELEASE_NOTES }} - files: | - packages/@tailwindcss-standalone/dist/sha256sums.txt - packages/@tailwindcss-standalone/dist/tailwindcss-linux-arm64 - packages/@tailwindcss-standalone/dist/tailwindcss-linux-arm64-musl - packages/@tailwindcss-standalone/dist/tailwindcss-linux-x64 - packages/@tailwindcss-standalone/dist/tailwindcss-linux-x64-musl - packages/@tailwindcss-standalone/dist/tailwindcss-macos-arm64 - packages/@tailwindcss-standalone/dist/tailwindcss-macos-x64 - packages/@tailwindcss-standalone/dist/tailwindcss-windows-x64.exe + if: ${{ !inputs.dry_run }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "${TAG_NAME}" \ + --draft \ + --title "${TAG_NAME}" \ + --notes "${RELEASE_NOTES}" \ + packages/@tailwindcss-standalone/dist/sha256sums.txt \ + packages/@tailwindcss-standalone/dist/tailwindcss-linux-arm64 \ + packages/@tailwindcss-standalone/dist/tailwindcss-linux-arm64-musl \ + packages/@tailwindcss-standalone/dist/tailwindcss-linux-x64 \ + packages/@tailwindcss-standalone/dist/tailwindcss-linux-x64-musl \ + packages/@tailwindcss-standalone/dist/tailwindcss-macos-arm64 \ + packages/@tailwindcss-standalone/dist/tailwindcss-macos-x64 \ + packages/@tailwindcss-standalone/dist/tailwindcss-windows-x64.exe diff --git a/.github/workflows/release-insiders.yml b/.github/workflows/release-insiders.yml deleted file mode 100644 index 53478a227..000000000 --- a/.github/workflows/release-insiders.yml +++ /dev/null @@ -1,318 +0,0 @@ -name: Release Insiders - -on: - push: - branches: [main] - -permissions: - contents: read - -env: - APP_NAME: tailwindcss-oxide - NODE_VERSION: 20 - OXIDE_LOCATION: ./crates/node - RELEASE_CHANNEL: insiders - -jobs: - build: - strategy: - matrix: - include: - # Windows - - os: windows-latest - target: x86_64-pc-windows-msvc - - os: windows-latest - target: aarch64-pc-windows-msvc - # macOS - - os: macos-latest - target: x86_64-apple-darwin - strip: strip -x # Must use -x on macOS. This produces larger results on linux. - - os: macos-latest - target: aarch64-apple-darwin - page-size: 14 - strip: strip -x # Must use -x on macOS. This produces larger results on linux. - # Android - - os: ubuntu-latest - target: aarch64-linux-android - strip: ${ANDROID_NDK_LATEST_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip - - os: ubuntu-latest - target: armv7-linux-androideabi - strip: ${ANDROID_NDK_LATEST_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip - # Linux - - os: ubuntu-latest - target: x86_64-unknown-linux-gnu - strip: strip - container: - image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian - - os: ubuntu-latest - target: aarch64-unknown-linux-gnu - strip: llvm-strip - container: - image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian-aarch64 - - os: ubuntu-latest - target: armv7-unknown-linux-gnueabihf - strip: llvm-strip - container: - image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian-zig - - os: ubuntu-latest - target: aarch64-unknown-linux-musl - strip: aarch64-linux-musl-strip - download: true - container: - image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-alpine - - os: ubuntu-latest - target: x86_64-unknown-linux-musl - strip: strip - download: true - container: - image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-alpine - - name: Build ${{ matrix.target }} (oxide) - runs-on: ${{ matrix.os }} - container: ${{ matrix.container }} - timeout-minutes: 15 - steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - - name: Use Node.js ${{ env.NODE_VERSION }} - uses: actions/setup-node@v4 - with: - node-version: ${{ env.NODE_VERSION }} - cache: 'pnpm' - - - name: Install gcc-arm-linux-gnueabihf - if: ${{ matrix.target == 'armv7-unknown-linux-gnueabihf' }} - run: | - sudo apt-get update - sudo apt-get install gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf -y - - # Cargo already skips downloading dependencies if they already exist - - name: Cache cargo - uses: actions/cache@v4 - with: - path: | - ~/.cargo/bin/ - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - target/ - key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }} - - # Cache the `oxide` Rust build - - name: Cache oxide build - uses: actions/cache@v4 - with: - path: | - ./crates/node/*.node - ./crates/node/*.wasm - ./crates/node/index.d.ts - ./crates/node/index.js - ./crates/node/browser.js - ./crates/node/tailwindcss-oxide.wasi-browser.js - ./crates/node/tailwindcss-oxide.wasi.cjs - ./crates/node/wasi-worker-browser.mjs - ./crates/node/wasi-worker.mjs - key: ${{ runner.os }}-${{ matrix.target }}-oxide-${{ hashFiles('./crates/**/*') }} - - - name: Install Node.JS - uses: actions/setup-node@v4 - with: - node-version: ${{ env.NODE_VERSION }} - - - name: Install Rust (Stable) - if: ${{ matrix.download }} - run: | - rustup default stable - - - name: Setup rust target - run: rustup target add ${{ matrix.target }} - - - name: Install dependencies - run: pnpm install --ignore-scripts --filter=!./playgrounds/* - - - name: Build release - run: pnpm run --filter ${{ env.OXIDE_LOCATION }} build:platform --target=${{ matrix.target }} - env: - RUST_TARGET: ${{ matrix.target }} - JEMALLOC_SYS_WITH_LG_PAGE: ${{ matrix.page-size }} - - - name: Strip debug symbols # https://github.com/rust-lang/rust/issues/46034 - if: ${{ matrix.strip }} - run: ${{ matrix.strip }} ${{ env.OXIDE_LOCATION }}/*.node - - - name: Upload artifacts - uses: actions/upload-artifact@v4 - with: - name: bindings-${{ matrix.target }} - path: ${{ env.OXIDE_LOCATION }}/*.node - - build-freebsd: - name: Build x86_64-unknown-freebsd (OXIDE) - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@v4 - - name: Build FreeBSD - uses: cross-platform-actions/action@v0.25.0 - env: - DEBUG: napi:* - RUSTUP_HOME: /usr/local/rustup - CARGO_HOME: /usr/local/cargo - RUSTUP_IO_THREADS: 1 - RUST_TARGET: x86_64-unknown-freebsd - with: - operating_system: freebsd - version: '14.0' - memory: 13G - cpu_count: 3 - environment_variables: 'DEBUG RUSTUP_IO_THREADS' - shell: bash - run: | - sudo pkg install -y -f curl node libnghttp2 npm - sudo npm install -g pnpm@9.6.0 --unsafe-perm=true - curl -sSf https://static.rust-lang.org/rustup/archive/1.27.1/x86_64-unknown-freebsd/rustup-init --output rustup-init - chmod +x rustup-init - ./rustup-init -y --profile minimal - source "$HOME/.cargo/env" - echo "~~~~ rustc --version ~~~~" - rustc --version - echo "~~~~ node -v ~~~~" - node -v - echo "~~~~ pnpm --version ~~~~" - pnpm --version - pnpm install --ignore-scripts --filter=!./playgrounds/* || true - pnpm run --filter ${{ env.OXIDE_LOCATION }} build:platform - strip -x ${{ env.OXIDE_LOCATION }}/*.node - ls -la ${{ env.OXIDE_LOCATION }} - - name: Upload artifacts - uses: actions/upload-artifact@v4 - with: - name: bindings-x86_64-unknown-freebsd - path: ${{ env.OXIDE_LOCATION }}/*.node - - release: - runs-on: macos-14 - timeout-minutes: 15 - name: Build and release Tailwind CSS insiders - - permissions: - contents: write # for softprops/action-gh-release to create GitHub release - # https://docs.npmjs.com/generating-provenance-statements#publishing-packages-with-provenance-via-github-actions - id-token: write - - needs: - - build - - build-freebsd - - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 20 - - - name: Resolve version - id: vars - run: | - echo "SHA_SHORT=$(git rev-parse --short HEAD)" >> $GITHUB_ENV - - - uses: pnpm/action-setup@v4 - - - name: Use Node.js ${{ env.NODE_VERSION }} - uses: actions/setup-node@v4 - with: - node-version: ${{ env.NODE_VERSION }} - cache: 'pnpm' - registry-url: 'https://registry.npmjs.org' - - # Cargo already skips downloading dependencies if they already exist - - name: Cache cargo - uses: actions/cache@v4 - with: - path: | - ~/.cargo/bin/ - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - target/ - key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }} - - # Cache the `oxide` Rust build - - name: Cache oxide build - uses: actions/cache@v4 - with: - path: | - ./crates/node/*.node - ./crates/node/*.wasm - ./crates/node/index.d.ts - ./crates/node/index.js - ./crates/node/browser.js - ./crates/node/tailwindcss-oxide.wasi-browser.js - ./crates/node/tailwindcss-oxide.wasi.cjs - ./crates/node/wasi-worker-browser.mjs - ./crates/node/wasi-worker.mjs - key: ${{ runner.os }}-${{ matrix.target }}-oxide-${{ hashFiles('./crates/**/*') }} - - - name: Setup WASM target - run: rustup target add wasm32-wasip1-threads - - - name: Install dependencies - run: pnpm --filter=!./playgrounds/* install - - - name: Download artifacts - uses: actions/download-artifact@v4 - with: - path: ${{ env.OXIDE_LOCATION }} - - - name: Move artifacts - run: | - cd ${{ env.OXIDE_LOCATION }} - cp bindings-x86_64-pc-windows-msvc/* ./npm/win32-x64-msvc/ - cp bindings-aarch64-pc-windows-msvc/* ./npm/win32-arm64-msvc/ - cp bindings-x86_64-apple-darwin/* ./npm/darwin-x64/ - cp bindings-aarch64-apple-darwin/* ./npm/darwin-arm64/ - cp bindings-aarch64-linux-android/* ./npm/android-arm64/ - cp bindings-armv7-linux-androideabi/* ./npm/android-arm-eabi/ - cp bindings-aarch64-unknown-linux-gnu/* ./npm/linux-arm64-gnu/ - cp bindings-aarch64-unknown-linux-musl/* ./npm/linux-arm64-musl/ - cp bindings-armv7-unknown-linux-gnueabihf/* ./npm/linux-arm-gnueabihf/ - cp bindings-x86_64-unknown-linux-gnu/* ./npm/linux-x64-gnu/ - cp bindings-x86_64-unknown-linux-musl/* ./npm/linux-x64-musl/ - cp bindings-x86_64-unknown-freebsd/* ./npm/freebsd-x64/ - - - name: 'Version based on commit: 0.0.0-${{ env.RELEASE_CHANNEL }}.${{ env.SHA_SHORT }}' - run: pnpm run version-packages 0.0.0-${{ env.RELEASE_CHANNEL }}.${{ env.SHA_SHORT }} - - - name: Build Tailwind CSS - run: pnpm run build - - - name: Run pre-publish optimizations scripts - run: node ./scripts/pre-publish-optimizations.mjs - - - name: Lock pre-release versions - run: node ./scripts/lock-pre-release-versions.mjs - - - name: Upload npm package tarballs - uses: actions/upload-artifact@v4 - with: - name: npm-package-tarballs - path: dist/*.tgz - - - name: Publish - run: | - pnpm --recursive --filter="!@tailwindcss/oxide-wasm32-wasi" publish --tag ${{ env.RELEASE_CHANNEL }} --no-git-checks - # The wasm package needs a special npm config that isn't read when pnpm --recursive is used - pushd crates/node/npm/wasm32-wasi; pnpm publish --tag ${{ env.RELEASE_CHANNEL }} --no-git-checks; popd; - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - - name: Trigger Tailwind Play update - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.TAILWIND_PLAY_TOKEN }} - script: | - await github.rest.actions.createWorkflowDispatch({ - owner: 'tailwindlabs', - repo: 'upgrades', - ref: 'main', - workflow_id: 'upgrade-tailwindcss.yml' - }) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8b27773b3..2c957d3af 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,18 +1,34 @@ name: Release on: + push: + branches: [main] release: types: [published] workflow_dispatch: + inputs: + channel: + description: Release channel to publish + required: true + default: insiders + type: choice + options: + - insiders + - release permissions: contents: read env: APP_NAME: tailwindcss-oxide - NODE_VERSION: 20 + NODE_VERSION: 24 + PNPM_VERSION: '11.9.0' OXIDE_LOCATION: ./crates/node +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: true + jobs: build: strategy: @@ -42,44 +58,40 @@ jobs: - os: ubuntu-latest target: x86_64-unknown-linux-gnu strip: strip - container: - image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian + build-flags: --use-napi-cross - os: ubuntu-latest target: aarch64-unknown-linux-gnu - strip: llvm-strip - container: - image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian-aarch64 + strip: aarch64-linux-gnu-strip + build-flags: --use-napi-cross - os: ubuntu-latest target: armv7-unknown-linux-gnueabihf - strip: llvm-strip - container: - image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian-zig + strip: arm-linux-gnueabihf-strip + build-flags: --use-napi-cross - 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 + strip-zig: true + build-flags: -x - 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 + build-flags: -x name: Build ${{ matrix.target }} (oxide) runs-on: ${{ matrix.os }} - container: ${{ matrix.container }} timeout-minutes: 15 steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + version: ${{ env.PNPM_VERSION }} - name: Use Node.js ${{ env.NODE_VERSION }} - uses: actions/setup-node@v4 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: ${{ env.NODE_VERSION }} - cache: 'pnpm' + package-manager-cache: false - name: Install gcc-arm-linux-gnueabihf if: ${{ matrix.target == 'armv7-unknown-linux-gnueabihf' }} @@ -87,62 +99,56 @@ jobs: sudo apt-get update sudo apt-get install gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf -y - # Cargo already skips downloading dependencies if they already exist - - name: Cache cargo - uses: actions/cache@v4 - with: - path: | - ~/.cargo/bin/ - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - target/ - key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }} - - # Cache the `oxide` Rust build - - name: Cache oxide build - uses: actions/cache@v4 - with: - path: | - ./crates/node/*.node - ./crates/node/*.wasm - ./crates/node/index.d.ts - ./crates/node/index.js - ./crates/node/browser.js - ./crates/node/tailwindcss-oxide.wasi-browser.js - ./crates/node/tailwindcss-oxide.wasi.cjs - ./crates/node/wasi-worker-browser.mjs - ./crates/node/wasi-worker.mjs - key: ${{ runner.os }}-${{ matrix.target }}-oxide-${{ hashFiles('./crates/**/*') }} - - - name: Install Node.JS - uses: actions/setup-node@v4 - with: - node-version: ${{ env.NODE_VERSION }} - - - name: Install Rust (Stable) - if: ${{ matrix.download }} + - name: Install binutils-aarch64-linux-gnu + if: ${{ matrix.target == 'aarch64-unknown-linux-gnu' }} run: | - rustup default stable + sudo apt-get update + sudo apt-get install binutils-aarch64-linux-gnu -y + + - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 + if: ${{ contains(matrix.target, 'musl') }} + with: + version: 0.14.1 + use-cache: false + + - name: Install cargo-zigbuild + uses: taiki-e/install-action@65851e10cd6c377f11a60e600abc07cb08643468 # v2 + if: ${{ contains(matrix.target, 'musl') }} + env: + GITHUB_TOKEN: ${{ github.token }} + with: + tool: cargo-zigbuild - name: Setup rust target run: rustup target add ${{ matrix.target }} - name: Install dependencies - run: pnpm install --ignore-scripts --filter=!./playgrounds/* + run: pnpm install --ignore-scripts --frozen-lockfile --filter=!./playgrounds/* - name: Build release - run: pnpm run --filter ${{ env.OXIDE_LOCATION }} build:platform --target=${{ matrix.target }} + run: pnpm run --filter ${{ env.OXIDE_LOCATION }} build:platform --target=${{ matrix.target }} ${{ matrix.build-flags }} 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 + if: ${{ matrix.strip || matrix.strip-zig }} + env: + STRIP_COMMAND: ${{ matrix.strip }} + STRIP_ZIG: ${{ matrix.strip-zig }} + run: | + if [ "$STRIP_ZIG" = "true" ]; then + for file in ${{ env.OXIDE_LOCATION }}/*.node; do + zig objcopy --strip-all "$file" "$file.stripped" + mv "$file.stripped" "$file" + done + exit 0 + fi + + eval "$STRIP_COMMAND ${{ env.OXIDE_LOCATION }}/*.node" - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: bindings-${{ matrix.target }} path: ${{ env.OXIDE_LOCATION }}/*.node @@ -152,9 +158,11 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false - name: Build FreeBSD - uses: cross-platform-actions/action@v0.25.0 + uses: cross-platform-actions/action@cdc9ee69ef84a5f2e59c9058335d9c57bcb4ac86 # v0.25.0 env: DEBUG: napi:* RUSTUP_HOME: /usr/local/rustup @@ -170,7 +178,7 @@ jobs: shell: bash run: | sudo pkg install -y -f curl node libnghttp2 npm - sudo npm install -g pnpm@9.6.0 --unsafe-perm=true + sudo npm install -g pnpm@${{ env.PNPM_VERSION }} --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 @@ -181,12 +189,12 @@ jobs: node -v echo "~~~~ pnpm --version ~~~~" pnpm --version - pnpm install --ignore-scripts --filter=!./playgrounds/* || true + pnpm install --ignore-scripts --frozen-lockfile --filter=!./playgrounds/* || true pnpm run --filter ${{ env.OXIDE_LOCATION }} build:platform strip -x ${{ env.OXIDE_LOCATION }}/*.node ls -la ${{ env.OXIDE_LOCATION }} - name: Upload artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: bindings-x86_64-unknown-freebsd path: ${{ env.OXIDE_LOCATION }}/*.node @@ -194,10 +202,10 @@ jobs: release: runs-on: macos-14 timeout-minutes: 15 - name: Build and release Tailwind CSS + name: Build and publish Tailwind CSS permissions: - contents: write # for softprops/action-gh-release to create GitHub release + contents: read # https://docs.npmjs.com/generating-provenance-statements#publishing-packages-with-provenance-via-github-actions id-token: write @@ -206,55 +214,51 @@ jobs: - build-freebsd steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: fetch-depth: 20 + persist-credentials: false - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + version: ${{ env.PNPM_VERSION }} - name: Use Node.js ${{ env.NODE_VERSION }} - uses: actions/setup-node@v4 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: ${{ env.NODE_VERSION }} - cache: 'pnpm' registry-url: 'https://registry.npmjs.org' + package-manager-cache: false - # Cargo already skips downloading dependencies if they already exist - - name: Cache cargo - uses: actions/cache@v4 - with: - path: | - ~/.cargo/bin/ - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - target/ - key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }} + # npm trusted publishing validates the caller workflow filename, so all npm publishes live here. + # This workflow rebuilds the publish artifacts instead of depending on prepare-release.yml. + - name: Resolve release metadata + env: + INPUT_CHANNEL: ${{ github.event.inputs.channel || '' }} + run: | + if [[ "${{ github.event_name }}" == "release" || "$INPUT_CHANNEL" == "release" ]]; then + release_channel=$(node ./scripts/release-channel.js) - # Cache the `oxide` Rust build - - name: Cache oxide build - uses: actions/cache@v4 - with: - path: | - ./crates/node/*.node - ./crates/node/*.wasm - ./crates/node/index.d.ts - ./crates/node/index.js - ./crates/node/browser.js - ./crates/node/tailwindcss-oxide.wasi-browser.js - ./crates/node/tailwindcss-oxide.wasi.cjs - ./crates/node/wasi-worker-browser.mjs - ./crates/node/wasi-worker.mjs - key: ${{ runner.os }}-${{ matrix.target }}-oxide-${{ hashFiles('./crates/**/*') }} + echo "RELEASE_KIND=release" >> $GITHUB_ENV + echo "RELEASE_CHANNEL=$release_channel" >> $GITHUB_ENV + echo "FEATURES_ENV=stable" >> $GITHUB_ENV + else + sha_short=$(git rev-parse --short HEAD) + + echo "RELEASE_KIND=insiders" >> $GITHUB_ENV + echo "RELEASE_CHANNEL=insiders" >> $GITHUB_ENV + echo "SHA_SHORT=$sha_short" >> $GITHUB_ENV + echo "INSIDERS_VERSION=0.0.0-insiders.$sha_short" >> $GITHUB_ENV + fi - name: Setup WASM target run: rustup target add wasm32-wasip1-threads - name: Install dependencies - run: pnpm --filter=!./playgrounds/* install + run: pnpm --filter=!./playgrounds/* install --frozen-lockfile - name: Download artifacts - uses: actions/download-artifact@v4 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: path: ${{ env.OXIDE_LOCATION }} @@ -274,10 +278,19 @@ jobs: cp bindings-x86_64-unknown-linux-musl/* ./npm/linux-x64-musl/ cp bindings-x86_64-unknown-freebsd/* ./npm/freebsd-x64/ + - name: 'Version based on commit: ${{ env.INSIDERS_VERSION }}' + if: env.RELEASE_KIND == 'insiders' + run: pnpm run version-packages ${INSIDERS_VERSION} + - name: Build Tailwind CSS + if: env.RELEASE_KIND == 'insiders' + run: pnpm run build + + - name: Build Tailwind CSS + if: env.RELEASE_KIND == 'release' run: pnpm run build env: - FEATURES_ENV: stable + FEATURES_ENV: ${{ env.FEATURES_ENV }} - name: Run pre-publish optimizations scripts run: node ./scripts/pre-publish-optimizations.mjs @@ -285,22 +298,20 @@ jobs: - name: Lock pre-release versions run: node ./scripts/lock-pre-release-versions.mjs - - name: Calculate environment variables - run: | - echo "RELEASE_CHANNEL=$(node ./scripts/release-channel.js)" >> $GITHUB_ENV - echo "TAILWINDCSS_VERSION=$(node -e 'console.log(require(`./packages/tailwindcss/package.json`).version);')" >> $GITHUB_ENV + - name: Upload npm package tarballs + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # 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 + pnpm --recursive --filter="!@tailwindcss/oxide-wasm32-wasi" publish --tag ${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 }} + pushd crates/node/npm/wasm32-wasi; pnpm publish --tag ${RELEASE_CHANNEL} --no-git-checks --config.node-linker=hoisted; popd; - name: Trigger Tailwind Play update - if: env.RELEASE_CHANNEL == 'latest' - uses: actions/github-script@v7 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 with: github-token: ${{ secrets.TAILWIND_PLAY_TOKEN }} script: | @@ -310,3 +321,38 @@ jobs: ref: 'main', workflow_id: 'upgrade-tailwindcss.yml' }) + + notify: + if: ${{ always() && (needs.build.result == 'failure' || needs.build-freebsd.result == 'failure' || needs.release.result == 'failure') }} + needs: + - build + - build-freebsd + - release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - name: Resolve release label + id: release + env: + INPUT_CHANNEL: ${{ github.event.inputs.channel || '' }} + RELEASE_TAG: ${{ github.event.release.tag_name || '' }} + run: | + if [[ "${{ github.event_name }}" == "release" ]]; then + tag_name="${RELEASE_TAG:-${GITHUB_REF_NAME}}" + echo "label=release ${tag_name}" >> $GITHUB_OUTPUT + elif [[ "$INPUT_CHANNEL" == "release" ]]; then + version=$(node -p "require('./packages/tailwindcss/package.json').version") + echo "label=release v${version}" >> $GITHUB_OUTPUT + else + sha_short=$(git rev-parse --short HEAD) + echo "label=insiders release 0.0.0-insiders.${sha_short}" >> $GITHUB_OUTPUT + fi + + - name: Notify Discord + uses: discord-actions/message@5c7149c81a83146e5d01f142be1bf87a61831c4d # v2 + with: + webhookUrl: ${{ secrets.DISCORD_WEBHOOK_URL }} + message: 'The [most recent ${{ github.workflow }} workflow](<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}>) for `${{ steps.release.outputs.label }}` has failed.' diff --git a/.npmrc b/.npmrc deleted file mode 100644 index ded82e2f6..000000000 --- a/.npmrc +++ /dev/null @@ -1 +0,0 @@ -auto-install-peers = true diff --git a/.prettierignore b/.prettierignore index 3de4530dd..4f50b932f 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,5 +4,6 @@ pnpm-lock.yaml target/ crates/node/index.d.ts crates/node/index.js +crates/ignore/ .next .fingerprint diff --git a/CHANGELOG.md b/CHANGELOG.md index eee51b99a..e5e6f6f89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,343 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -- Nothing yet! +### Added + +- Add `@tailwindcss/turbopack` package to run Tailwind CSS with Next.js ([20367](https://github.com/tailwindlabs/tailwindcss/pull/20367)) + +### Fixed + +- Ensure watch mode detects changes to symlinked `@source` files whose real paths aren't otherwise scanned ([#20356](https://github.com/tailwindlabs/tailwindcss/pull/20356)) +- Ensure custom variants using `@scope` wrap the generated utilities instead of nesting inside them ([#20369](https://github.com/tailwindlabs/tailwindcss/pull/20369)) +- Fix flattening of `@scope` at-rules ([#20369](https://github.com/tailwindlabs/tailwindcss/pull/20369)) +- Fix standalone declarations in `@scope`, wrap them in `:where(:scope)` ([#20369](https://github.com/tailwindlabs/tailwindcss/pull/20369)) +- Always emit a space for empty fallback values in CSS variables (e.g. `var(--tw-blur,)` → `var(--tw-blur, )`) ([#20373](https://github.com/tailwindlabs/tailwindcss/pull/20373)) +- Canonicalization: convert arbitrary breakpoint and container query variants to named equivalents (e.g. `max-[64rem]` → `max-lg`) ([#20380](https://github.com/tailwindlabs/tailwindcss/pull/20380)) +- Prevent `@tailwindcss/vite` from crashing on every edit under Vite's experimental `bundledDev` mode ([#20379](https://github.com/tailwindlabs/tailwindcss/pull/20379)) +- Ensure `@tailwindcss/oxide` falls back to WASM on platforms without native bindings ([#20383](https://github.com/tailwindlabs/tailwindcss/pull/20383)) +- Detect classes in Ruby percent literals using angle brackets or custom delimiters (e.g. `%w`, `%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 `` 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-` → `inset-s-` 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-` → `inset-e-` as well as negative versions ([#19849](https://github.com/tailwindlabs/tailwindcss/pull/19849)) +- Canonicalization: move the `-` sign inside the arbitrary value `-left-[9rem]` → `left-[-9rem]` ([#19858](https://github.com/tailwindlabs/tailwindcss/pull/19858)) +- Canonicalization: move the `-` sign outside the arbitrary value `ml-[calc(-1*var(--width))]` → `-ml-(--width)` ([#19858](https://github.com/tailwindlabs/tailwindcss/pull/19858)) +- Improve performance when scanning JSONL / NDJSON files ([#19862](https://github.com/tailwindlabs/tailwindcss/pull/19862)) +- Support `NODE_PATH` environment variable in standalone CLI ([#19617](https://github.com/tailwindlabs/tailwindcss/pull/19617)) + +## [4.2.2] - 2026-03-18 + +### Fixed + +- Don't crash when candidates contain prototype properties like `row-constructor` ([#19725](https://github.com/tailwindlabs/tailwindcss/pull/19725)) +- Canonicalize `calc(var(--spacing)*…)` expressions into `--spacing(…)` ([#19769](https://github.com/tailwindlabs/tailwindcss/pull/19769)) +- Fix crash in canonicalization step when handling utilities containing `@property` at-rules (e.g. `shadow-sm border`) ([#19727](https://github.com/tailwindlabs/tailwindcss/pull/19727)) +- Skip full reload for server only modules scanned by client CSS when using `@tailwindcss/vite` ([#19745](https://github.com/tailwindlabs/tailwindcss/pull/19745)) +- Add support for Vite 8 in `@tailwindcss/vite` ([#19790](https://github.com/tailwindlabs/tailwindcss/pull/19790)) +- Improve canonicalization for bare values exceeding default spacing scale suggestions (e.g. `w-1234 h-1234` → `size-1234`) ([#19809](https://github.com/tailwindlabs/tailwindcss/pull/19809)) +- Fix canonicalization resulting in empty list (e.g. `w-5 h-5 size-5` → `''` instead of `size-5`) ([#19812](https://github.com/tailwindlabs/tailwindcss/pull/19812)) +- Resolve tsconfig paths to allow for `@import '@/path/to/file';` when using `@tailwindcss/vite` ([#19803](https://github.com/tailwindlabs/tailwindcss/pull/19803)) + +## [4.2.1] - 2026-02-23 + +### Fixed + +- Allow trailing dash in functional utility names for backwards compatibility ([#19696](https://github.com/tailwindlabs/tailwindcss/pull/19696)) +- Properly detect classes containing `.` characters within curly braces in MDX files ([#19711](https://github.com/tailwindlabs/tailwindcss/pull/19711)) + +## [4.2.0] - 2026-02-18 + +### Added + +- Add mauve, olive, mist, and taupe color palettes to the default theme ([#19627](https://github.com/tailwindlabs/tailwindcss/pull/19627)) +- Add `@tailwindcss/webpack` package to run Tailwind CSS as a webpack plugin ([#19610](https://github.com/tailwindlabs/tailwindcss/pull/19610)) +- Add `pbs-*` and `pbe-*` utilities for `padding-block-start` and `padding-block-end` ([#19601](https://github.com/tailwindlabs/tailwindcss/pull/19601)) +- Add `mbs-*` and `mbe-*` utilities for `margin-block-start` and `margin-block-end` ([#19601](https://github.com/tailwindlabs/tailwindcss/pull/19601)) +- Add `scroll-pbs-*` and `scroll-pbe-*` utilities for `scroll-padding-block-start` and `scroll-padding-block-end` ([#19601](https://github.com/tailwindlabs/tailwindcss/pull/19601)) +- Add `scroll-mbs-*` and `scroll-mbe-*` utilities for `scroll-margin-block-start` and `scroll-margin-block-end` ([#19601](https://github.com/tailwindlabs/tailwindcss/pull/19601)) +- Add `border-bs-*` and `border-be-*` utilities for `border-block-start` and `border-block-end` ([#19601](https://github.com/tailwindlabs/tailwindcss/pull/19601)) +- Add `inline-*`, `min-inline-*`, `max-inline-*` utilities for `inline-size`, `min-inline-size`, and `max-inline-size` ([#19612](https://github.com/tailwindlabs/tailwindcss/pull/19612)) +- Add `block-*`, `min-block-*`, `max-block-*` utilities for `block-size`, `min-block-size`, and `max-block-size` ([#19612](https://github.com/tailwindlabs/tailwindcss/pull/19612)) +- Add `inset-s-*`, `inset-e-*`, `inset-bs-*`, `inset-be-*` utilities for `inset-inline-start`, `inset-inline-end`, `inset-block-start`, and `inset-block-end` ([#19613](https://github.com/tailwindlabs/tailwindcss/pull/19613)) +- Add `font-features-*` utility for `font-feature-settings` ([#19623](https://github.com/tailwindlabs/tailwindcss/pull/19623)) + +### Fixed + +- Prevent double `@supports` wrapper for `color-mix` values ([#19450](https://github.com/tailwindlabs/tailwindcss/pull/19450)) +- Allow whitespace around `@source inline()` argument ([#19461](https://github.com/tailwindlabs/tailwindcss/pull/19461)) +- Emit comment when source maps are saved to files when using `@tailwindcss/cli` ([#19447](https://github.com/tailwindlabs/tailwindcss/pull/19447)) +- Detect utilities containing capital letters followed by numbers ([#19465](https://github.com/tailwindlabs/tailwindcss/pull/19465)) +- Fix class extraction for Rails' strict locals ([#19525](https://github.com/tailwindlabs/tailwindcss/pull/19525)) +- Align `@utility` name validation with Oxide scanner rules ([#19524](https://github.com/tailwindlabs/tailwindcss/pull/19524)) +- Fix infinite loop when using `@variant` inside `@custom-variant` ([#19633](https://github.com/tailwindlabs/tailwindcss/pull/19633)) +- Allow multiples of `.25` in `aspect-*` fractions (e.g. `aspect-8.5/11`) ([#19688](https://github.com/tailwindlabs/tailwindcss/pull/19688)) +- Ensure changes to external files listed via `@source` trigger a full page reload when using `@tailwindcss/vite` ([#19670](https://github.com/tailwindlabs/tailwindcss/pull/19670)) +- Improve performance of Oxide scanner in bigger projects by reducing file system walks ([#19632](https://github.com/tailwindlabs/tailwindcss/pull/19632)) +- Ensure import aliases in Astro v5 work without crashing when using `@tailwindcss/vite` ([#19677](https://github.com/tailwindlabs/tailwindcss/issues/19677)) +- Allow escape characters in `@utility` names to improve support with formatters such as Biome ([#19626](https://github.com/tailwindlabs/tailwindcss/pull/19626)) +- Fix incorrect canonicalization results when canonicalizing multiple times ([#19675](https://github.com/tailwindlabs/tailwindcss/pull/19675)) +- Add `.jj` to default ignored content directories ([#19687](https://github.com/tailwindlabs/tailwindcss/pull/19687)) + +### Deprecated + +- Deprecate `start-*` and `end-*` utilities in favor of `inset-s-*` and `inset-e-*` utilities ([#19613](https://github.com/tailwindlabs/tailwindcss/pull/19613)) + +## [4.1.18] - 2025-12-11 + +### Fixed + +- Ensure validation of `source(…)` happens relative to the file it is in ([#19274](https://github.com/tailwindlabs/tailwindcss/pull/19274)) +- Include filename and line numbers in CSS parse errors ([#19282](https://github.com/tailwindlabs/tailwindcss/pull/19282)) +- Skip comments in Ruby files when checking for class names ([#19243](https://github.com/tailwindlabs/tailwindcss/pull/19243)) +- Skip over arbitrary property utilities with a top-level `!` in the value ([#19243](https://github.com/tailwindlabs/tailwindcss/pull/19243)) +- Support environment API in `@tailwindcss/vite` ([#18970](https://github.com/tailwindlabs/tailwindcss/pull/18970)) +- Preserve case of theme keys from JS configs and plugins ([#19337](https://github.com/tailwindlabs/tailwindcss/pull/19337)) +- Write source maps correctly on the CLI when using `--watch` ([#19373](https://github.com/tailwindlabs/tailwindcss/pull/19373)) +- Handle special defaults (like `ringColor.DEFAULT`) in JS configs ([#19348](https://github.com/tailwindlabs/tailwindcss/pull/19348)) +- Improve backwards compatibility for `content` theme key from JS configs ([#19381](https://github.com/tailwindlabs/tailwindcss/pull/19381)) +- Upgrade: Handle `future` and `experimental` config keys ([#19344](https://github.com/tailwindlabs/tailwindcss/pull/19344)) +- Try to canonicalize any arbitrary utility to a bare value ([#19379](https://github.com/tailwindlabs/tailwindcss/pull/19379)) +- Validate candidates similarly to Oxide ([#19397](https://github.com/tailwindlabs/tailwindcss/pull/19397)) +- Canonicalization: combine `text-*` and `leading-*` classes ([#19396](https://github.com/tailwindlabs/tailwindcss/pull/19396)) +- Correctly handle duplicate CLI arguments ([#19416](https://github.com/tailwindlabs/tailwindcss/pull/19416)) +- Don’t emit color-mix fallback rules inside `@keyframes` ([#19419](https://github.com/tailwindlabs/tailwindcss/pull/19419)) +- CLI: Don't hang when output is `/dev/stdout` ([#19421](https://github.com/tailwindlabs/tailwindcss/pull/19421)) + +## [3.4.19] - 2025-12-10 + +### Fixed + +- Don’t break `sibling-*()` functions when used inside `calc(…)` ([#19335](https://github.com/tailwindlabs/tailwindcss/pull/19335)) + +## [4.1.17] - 2025-11-06 + +### Fixed + +- Substitute `@variant` inside legacy JS APIs ([#19263](https://github.com/tailwindlabs/tailwindcss/pull/19263)) +- Prevent occasional crash on Windows when loaded into a worker thread ([#19242](https://github.com/tailwindlabs/tailwindcss/pull/19242)) + +## [4.1.16] - 2025-10-23 + +### Fixed + +- Discard candidates with an empty data type ([#19172](https://github.com/tailwindlabs/tailwindcss/pull/19172)) +- Fix canonicalization of arbitrary variants with attribute selectors ([#19176](https://github.com/tailwindlabs/tailwindcss/pull/19176)) +- Fix invalid colors due to nested `&` ([#19184](https://github.com/tailwindlabs/tailwindcss/pull/19184)) +- Improve canonicalization for `& > :pseudo` and `& :pseudo` arbitrary variants ([#19178](https://github.com/tailwindlabs/tailwindcss/pull/19178)) + +## [4.1.15] - 2025-10-20 + +### Fixed + +- Fix Safari devtools rendering issue due to `color-mix` fallback ([#19069](https://github.com/tailwindlabs/tailwindcss/pull/19069)) +- Suppress Lightning CSS warnings about `:deep`, `:slotted`, and `:global` ([#19094](https://github.com/tailwindlabs/tailwindcss/pull/19094)) +- Fix resolving theme keys when starting with the name of another theme key in JS configs and plugins ([#19097](https://github.com/tailwindlabs/tailwindcss/pull/19097)) +- Allow named groups in combination with `not-*`, `has-*`, and `in-*` ([#19100](https://github.com/tailwindlabs/tailwindcss/pull/19100)) +- Prevent important utilities from affecting other utilities ([#19110](https://github.com/tailwindlabs/tailwindcss/pull/19110)) +- Don’t index into strings with the `theme(…)` function ([#19111](https://github.com/tailwindlabs/tailwindcss/pull/19111)) +- Fix parsing issue when `\t` is used in at-rules ([#19130](https://github.com/tailwindlabs/tailwindcss/pull/19130)) +- Upgrade: Canonicalize utilities containing `0` values ([#19095](https://github.com/tailwindlabs/tailwindcss/pull/19095)) +- Upgrade: Migrate deprecated `break-words` to `wrap-break-word` ([#19157](https://github.com/tailwindlabs/tailwindcss/pull/19157)) + +### Changed + +- Remove the `postinstall` script from oxide ([#19149])(https://github.com/tailwindlabs/tailwindcss/pull/19149) + +## [4.1.14] - 2025-10-01 + +### Fixed + +- Handle `'` syntax in ClojureScript when extracting classes ([#18888](https://github.com/tailwindlabs/tailwindcss/pull/18888)) +- Handle `@variant` inside `@custom-variant` ([#18885](https://github.com/tailwindlabs/tailwindcss/pull/18885)) +- Merge suggestions when using `@utility` ([#18900](https://github.com/tailwindlabs/tailwindcss/pull/18900)) +- Ensure that file system watchers created when using the CLI are always cleaned up ([#18905](https://github.com/tailwindlabs/tailwindcss/pull/18905)) +- Do not generate `grid-column` utilities when configuring `grid-column-start` or `grid-column-end` ([#18907](https://github.com/tailwindlabs/tailwindcss/pull/18907)) +- Do not generate `grid-row` utilities when configuring `grid-row-start` or `grid-row-end` ([#18907](https://github.com/tailwindlabs/tailwindcss/pull/18907)) +- Prevent duplicate CSS when overwriting a static utility with a theme key ([#18056](https://github.com/tailwindlabs/tailwindcss/pull/18056)) +- Show Lightning CSS warnings (if any) when optimizing/minifying ([#18918](https://github.com/tailwindlabs/tailwindcss/pull/18918)) +- Use `default` export condition for `@tailwindcss/vite` ([#18948](https://github.com/tailwindlabs/tailwindcss/pull/18948)) +- Re-throw errors from PostCSS nodes ([#18373](https://github.com/tailwindlabs/tailwindcss/pull/18373)) +- Detect classes in markdown inline directives ([#18967](https://github.com/tailwindlabs/tailwindcss/pull/18967)) +- Ensure files with only `@theme` produce no output when built ([#18979](https://github.com/tailwindlabs/tailwindcss/pull/18979)) +- Support Maud templates when extracting classes ([#18988](https://github.com/tailwindlabs/tailwindcss/pull/18988)) +- Upgrade: Do not migrate `variant = 'outline'` during upgrades ([#18922](https://github.com/tailwindlabs/tailwindcss/pull/18922)) +- Upgrade: Show version mismatch (if any) when running upgrade tool ([#19028](https://github.com/tailwindlabs/tailwindcss/pull/19028)) +- Upgrade: Ensure first class inside `className` is migrated ([#19031](https://github.com/tailwindlabs/tailwindcss/pull/19031)) +- Upgrade: Migrate classes inside `*ClassName` and `*Class` attributes ([#19031](https://github.com/tailwindlabs/tailwindcss/pull/19031)) + +## [3.4.18] - 2025-10-01 + +### Fixed + +- Improve support for raw `supports-[…]` queries in arbitrary values ([#13605](https://github.com/tailwindlabs/tailwindcss/pull/13605)) +- Fix `require.cache` error when loaded through a TypeScript file in Node 22.18+ ([#18665](https://github.com/tailwindlabs/tailwindcss/pull/18665)) +- Support `import.meta.resolve(…)` in configs for new enough Node.js versions ([#18938](https://github.com/tailwindlabs/tailwindcss/pull/18938)) +- Allow using newer versions of `postcss-load-config` for better ESM and TypeScript PostCSS config support with the CLI ([#18938](https://github.com/tailwindlabs/tailwindcss/pull/18938)) +- Remove irrelevant utility rules when matching important classes ([#19030](https://github.com/tailwindlabs/tailwindcss/pull/19030)) + +## [4.1.13] - 2025-09-03 + +### Changed + +- Drop warning from browser build ([#18731](https://github.com/tailwindlabs/tailwindcss/issues/18731)) +- Drop exact duplicate declarations when emitting CSS ([#18809](https://github.com/tailwindlabs/tailwindcss/issues/18809)) + +### Fixed + +- Don't transition `visibility` when using `transition` ([#18795](https://github.com/tailwindlabs/tailwindcss/pull/18795)) +- Discard matched variants with unknown named values ([#18799](https://github.com/tailwindlabs/tailwindcss/pull/18799)) +- Discard matched variants with non-string values ([#18799](https://github.com/tailwindlabs/tailwindcss/pull/18799)) +- Show suggestions for known `matchVariant` values ([#18798](https://github.com/tailwindlabs/tailwindcss/pull/18798)) +- Replace deprecated `clip` with `clip-path` in `sr-only` ([#18769](https://github.com/tailwindlabs/tailwindcss/pull/18769)) +- Hide internal fields from completions in `matchUtilities` ([#18820](https://github.com/tailwindlabs/tailwindcss/pull/18820)) +- Ignore `.vercel` folders by default (can be overridden by `@source …` rules) ([#18855](https://github.com/tailwindlabs/tailwindcss/pull/18855)) +- Consider variants starting with `@-` to be invalid (e.g. `@-2xl:flex`) ([#18869](https://github.com/tailwindlabs/tailwindcss/pull/18869)) +- Do not allow custom variants to start or end with a `-` or `_` ([#18867](https://github.com/tailwindlabs/tailwindcss/pull/18867), [#18872](https://github.com/tailwindlabs/tailwindcss/pull/18872)) +- Upgrade: Migrate `aria` theme keys to `@custom-variant` ([#18815](https://github.com/tailwindlabs/tailwindcss/pull/18815)) +- Upgrade: Migrate `data` theme keys to `@custom-variant` ([#18816](https://github.com/tailwindlabs/tailwindcss/pull/18816)) +- Upgrade: Migrate `supports` theme keys to `@custom-variant` ([#18817](https://github.com/tailwindlabs/tailwindcss/pull/18817)) ## [4.1.12] - 2025-08-13 @@ -3787,7 +4123,24 @@ No release notes - Everything! -[unreleased]: https://github.com/tailwindlabs/tailwindcss/compare/v4.1.12...HEAD +[unreleased]: https://github.com/tailwindlabs/tailwindcss/compare/v4.3.3...HEAD +[4.3.3]: https://github.com/tailwindlabs/tailwindcss/compare/v4.3.2...v4.3.3 +[4.3.2]: https://github.com/tailwindlabs/tailwindcss/compare/v4.3.1...v4.3.2 +[4.3.1]: https://github.com/tailwindlabs/tailwindcss/compare/v4.3.0...v4.3.1 +[4.3.0]: https://github.com/tailwindlabs/tailwindcss/compare/v4.2.4...v4.3.0 +[4.2.4]: https://github.com/tailwindlabs/tailwindcss/compare/v4.2.3...v4.2.4 +[4.2.3]: https://github.com/tailwindlabs/tailwindcss/compare/v4.2.2...v4.2.3 +[4.2.2]: https://github.com/tailwindlabs/tailwindcss/compare/v4.2.1...v4.2.2 +[4.2.1]: https://github.com/tailwindlabs/tailwindcss/compare/v4.2.0...v4.2.1 +[4.2.0]: https://github.com/tailwindlabs/tailwindcss/compare/v4.1.18...v4.2.0 +[4.1.18]: https://github.com/tailwindlabs/tailwindcss/compare/v4.1.17...v4.1.18 +[3.4.19]: https://github.com/tailwindlabs/tailwindcss/compare/v3.4.18...v3.4.19 +[4.1.17]: https://github.com/tailwindlabs/tailwindcss/compare/v4.1.16...v4.1.17 +[4.1.16]: https://github.com/tailwindlabs/tailwindcss/compare/v4.1.15...v4.1.16 +[4.1.15]: https://github.com/tailwindlabs/tailwindcss/compare/v4.1.14...v4.1.15 +[4.1.14]: https://github.com/tailwindlabs/tailwindcss/compare/v4.1.13...v4.1.14 +[3.4.18]: https://github.com/tailwindlabs/tailwindcss/compare/v3.4.17...v3.4.18 +[4.1.13]: https://github.com/tailwindlabs/tailwindcss/compare/v4.1.12...v4.1.13 [4.1.12]: https://github.com/tailwindlabs/tailwindcss/compare/v4.1.11...v4.1.12 [4.1.11]: https://github.com/tailwindlabs/tailwindcss/compare/v4.1.10...v4.1.11 [4.1.10]: https://github.com/tailwindlabs/tailwindcss/compare/v4.1.9...v4.1.10 diff --git a/Cargo.lock b/Cargo.lock index 694700fcb..73fd9e995 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -40,7 +40,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "531a9155a481e2ee699d4f98f43c0ca4ff8ee1bfd55c31e9e98fb29d2b176fe0" dependencies = [ "memchr", - "regex-automata 0.4.8", + "regex-automata 0.4.18", "serde", ] @@ -60,32 +60,30 @@ dependencies = [ ] [[package]] -name = "convert_case" -version = "0.6.0" +name = "console" +version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" +dependencies = [ + "encode_unicode", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "convert_case" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" dependencies = [ "unicode-segmentation", ] -[[package]] -name = "crossbeam" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" -dependencies = [ - "crossbeam-channel", - "crossbeam-deque", - "crossbeam-epoch", - "crossbeam-queue", - "crossbeam-utils", -] - [[package]] name = "crossbeam-channel" -version = "0.5.13" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33480d6946193aa8033910124896ca395333cae7e2d1113d1fef6c3272217df2" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" dependencies = [ "crossbeam-utils", ] @@ -109,15 +107,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "crossbeam-queue" -version = "0.3.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df0346b5d5e76ac2fe4e327c5fd1118d6be7c51dfb18f9b7922923f287471e35" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "crossbeam-utils" version = "0.8.20" @@ -126,13 +115,9 @@ checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" [[package]] name = "ctor" -version = "0.2.1" +version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990a40740adf249724a6000c0fc4bd574712f50bb17c2d6f6cec837ae2f0ee75" -dependencies = [ - "quote", - "syn", -] +checksum = "2d83cb7e7a873830708d6b02a78cd36a592c6fa14bf267b68725103b85c0d77f" [[package]] name = "diff" @@ -152,6 +137,12 @@ version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "errno" version = "0.3.9" @@ -178,15 +169,103 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6" [[package]] -name = "globset" -version = "0.4.16" +name = "futures" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54a1028dfc5f5df5da8a56a73e6c153c9a9708ec57232470703592a3f18e49f5" +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]] +name = "globset" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" dependencies = [ "aho-corasick", "bstr", "log", - "regex-automata 0.4.8", + "regex-automata 0.4.18", "regex-syntax 0.8.5", ] @@ -197,27 +276,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" dependencies = [ "bitflags", - "ignore 0.4.23 (registry+https://github.com/rust-lang/crates.io-index)", + "ignore 0.4.23", "walkdir", ] -[[package]] -name = "ignore" -version = "0.4.23" -dependencies = [ - "bstr", - "crossbeam-channel", - "crossbeam-deque", - "dunce", - "globset", - "log", - "memchr", - "regex-automata 0.4.8", - "same-file", - "walkdir", - "winapi-util", -] - [[package]] name = "ignore" version = "0.4.23" @@ -228,12 +290,41 @@ dependencies = [ "globset", "log", "memchr", - "regex-automata 0.4.8", + "regex-automata 0.4.18", "same-file", "walkdir", "winapi-util", ] +[[package]] +name = "ignore" +version = "0.4.33" +dependencies = [ + "bstr", + "crossbeam-channel", + "crossbeam-deque", + "dunce", + "globset", + "log", + "memchr", + "regex-automata 0.4.18", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "insta" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" +dependencies = [ + "console", + "once_cell", + "similar", + "tempfile", +] + [[package]] name = "itertools" version = "0.11.0" @@ -257,12 +348,12 @@ checksum = "561d97a539a36e26a9a5fad1ea11a3039a67714694aaa379433e580854bc3dc5" [[package]] name = "libloading" -version = "0.8.5" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4979f22fdb869068da03c9f7528f8297c6fd2606bc3a4affe42e6a823fdb8da4" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" dependencies = [ "cfg-if", - "windows-targets", + "windows-link", ] [[package]] @@ -300,31 +391,33 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "napi" -version = "2.16.17" +version = "3.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55740c4ae1d8696773c78fdafd5d0e5fe9bc9f1b071c7ba493ba5c413a9184f3" +checksum = "de33522036981030a75c231829566bc63414e08101a6f5ff4ac6cef19c8e0941" dependencies = [ "bitflags", "ctor", - "napi-derive", + "futures", + "napi-build", "napi-sys", - "once_cell", + "nohash-hasher", + "rustc-hash", ] [[package]] name = "napi-build" -version = "2.1.6" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e28acfa557c083f6e254a786e01ba253fc56f18ee000afcd4f79af735f73a6da" +checksum = "c9c366d2c8c60b86fa632df75f745509b52f9128f91a6bad4c796e44abb505e1" [[package]] name = "napi-derive" -version = "2.16.13" +version = "3.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cbe2585d8ac223f7d34f13701434b9d5f4eb9c332cccce8dee57ea18ab8ab0c" +checksum = "a49c513341a61a16a10af6efcce46b30d0822ba2d4fb197d24d33dfc199c78d5" dependencies = [ - "cfg-if", "convert_case", + "ctor", "napi-derive-backend", "proc-macro2", "quote", @@ -333,28 +426,32 @@ dependencies = [ [[package]] name = "napi-derive-backend" -version = "1.0.75" +version = "6.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1639aaa9eeb76e91c6ae66da8ce3e89e921cd3885e99ec85f4abacae72fc91bf" +checksum = "d60b5d773ad46c698c8cc2cd9fde0b283d39cbb7f71c04bee633c7bdba4423bd" dependencies = [ "convert_case", - "once_cell", "proc-macro2", "quote", - "regex", "semver", "syn", ] [[package]] name = "napi-sys" -version = "2.4.0" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "427802e8ec3a734331fec1035594a210ce1ff4dc5bc1950530920ab717964ea3" +checksum = "85fbf1fa9f1babfe396d74bbbf52b3643770243e8f5b0b46715d4caf7f0dfc9a" dependencies = [ "libloading", ] +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + [[package]] name = "nom" version = "7.1.3" @@ -377,9 +474,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.19.0" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "overload" @@ -414,18 +511,18 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.28" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ab9c7eadfd8df19006f1cf1a4aed13540ed5cbc047010ece5826e10825488" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] [[package]] name = "rayon" -version = "1.10.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ "either", "rayon-core", @@ -433,9 +530,9 @@ dependencies = [ [[package]] name = "rayon-core" -version = "1.12.1" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" dependencies = [ "crossbeam-deque", "crossbeam-utils", @@ -449,7 +546,7 @@ checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.8", + "regex-automata 0.4.18", "regex-syntax 0.8.5", ] @@ -464,9 +561,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.8" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -534,6 +631,18 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.10.0" @@ -542,9 +651,9 @@ checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0" [[package]] name = "syn" -version = "2.0.18" +version = "2.0.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32d41677bcbe24c20c52e7c70b0d8db04134c5d1066bf98662e2871ad200ea3e" +checksum = "25aa4ce346d03a6dcd68dd8b4010bcb74e54e62c90c573f394c46eae99aba32d" dependencies = [ "proc-macro2", "quote", @@ -569,11 +678,11 @@ dependencies = [ "bexpand", "bstr", "classification-macros", - "crossbeam", "dunce", "fast-glob", "globwalk", - "ignore 0.4.23", + "ignore 0.4.33", + "insta", "log", "pretty_assertions", "rayon", @@ -735,6 +844,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-sys" version = "0.52.0" @@ -753,6 +868,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-targets" version = "0.52.6" diff --git a/README.md b/README.md index 7d21bd883..5f532607d 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,10 @@

- Build Status + Build Status Total Downloads - Latest Release - License + Latest Release + License

--- @@ -29,8 +29,8 @@ For full documentation, visit [tailwindcss.com](https://tailwindcss.com). For help, discussion about best practices, or feature ideas: -[Discuss Tailwind CSS on GitHub](https://github.com/tailwindcss/tailwindcss/discussions) +[Discuss Tailwind CSS on GitHub](https://github.com/tailwindlabs/tailwindcss/discussions) ## Contributing -If you're interested in contributing to Tailwind CSS, please read our [contributing docs](https://github.com/tailwindcss/tailwindcss/blob/next/.github/CONTRIBUTING.md) **before submitting a pull request**. +If you're interested in contributing to Tailwind CSS, please read our [contributing docs](https://github.com/tailwindlabs/tailwindcss/blob/main/.github/CONTRIBUTING.md) **before submitting a pull request**. diff --git a/crates/ignore/Cargo.toml b/crates/ignore/Cargo.toml index b8ae1b1bf..d0af38ba9 100644 --- a/crates/ignore/Cargo.toml +++ b/crates/ignore/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ignore" -version = "0.4.23" #:version +version = "0.4.33" #:version authors = ["Andrew Gallant "] description = """ A fast library for efficiently matching ignore files such as `.gitignore` @@ -12,7 +12,10 @@ repository = "https://github.com/BurntSushi/ripgrep/tree/master/crates/ignore" readme = "README.md" keywords = ["glob", "ignore", "gitignore", "pattern", "file"] license = "Unlicense OR MIT" -edition = "2021" +# CHANGED: Use an explicit edition instead of `edition.workspace = true` since this crate is +# vendored into the Tailwind CSS workspace. +edition = "2024" +rust-version = "1.88" [lib] name = "ignore" @@ -20,15 +23,17 @@ bench = false [dependencies] crossbeam-deque = "0.8.3" -globset = "0.4.16" +# CHANGED: Use the published globset crate instead of a path dependency. +globset = "0.4.20" log = "0.4.20" memchr = "2.6.3" same-file = "1.0.6" walkdir = "2.4.0" +# CHANGED: Added `dunce` to canonicalize paths without UNC prefixes on Windows. dunce = "1.0.5" [dependencies.regex-automata] -version = "0.4.0" +version = "0.4.18" default-features = false features = ["std", "perf", "syntax", "meta", "nfa", "hybrid", "dfa-onepass"] @@ -37,7 +42,7 @@ version = "0.1.2" [dev-dependencies] bstr = { version = "1.6.2", default-features = false, features = ["std"] } -crossbeam-channel = "0.5.8" +crossbeam-channel = "0.5.15" [features] # DEPRECATED. It is a no-op. SIMD is done automatically through runtime diff --git a/crates/ignore/README.md b/crates/ignore/README.md index a4c34e505..72258e6b5 100644 --- a/crates/ignore/README.md +++ b/crates/ignore/README.md @@ -1,5 +1,5 @@ -# ignore - +ignore +====== The ignore crate provides a fast recursive directory iterator that respects various filters such as globs, file types and `.gitignore` files. This crate also provides lower level direct access to gitignore and file type matchers. @@ -29,6 +29,7 @@ recursively traverse the current directory while automatically filtering out files and directories according to ignore globs found in files like `.ignore` and `.gitignore`: + ```rust,no_run use ignore::Walk; diff --git a/crates/ignore/examples/walk.rs b/crates/ignore/examples/walk.rs index 5bbd10f2b..c61d0515e 100644 --- a/crates/ignore/examples/walk.rs +++ b/crates/ignore/examples/walk.rs @@ -18,8 +18,8 @@ fn main() { let stdout_thread = std::thread::spawn(move || { let mut stdout = std::io::BufWriter::new(std::io::stdout()); for dent in rx { - stdout.write(&*Vec::from_path_lossy(dent.path())).unwrap(); - stdout.write(b"\n").unwrap(); + stdout.write_all(&Vec::from_path_lossy(dent.path())).unwrap(); + stdout.write_all(b"\n").unwrap(); } }); diff --git a/crates/ignore/src/default_types.rs b/crates/ignore/src/default_types.rs index 2cf8ad807..6b5bba0f0 100644 --- a/crates/ignore/src/default_types.rs +++ b/crates/ignore/src/default_types.rs @@ -27,9 +27,10 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[ (&["bat", "batch"], &["*.bat"]), (&["bazel"], &[ "*.bazel", "*.bzl", "*.BUILD", "*.bazelrc", "BUILD", "MODULE.bazel", - "WORKSPACE", "WORKSPACE.bazel", + "WORKSPACE", "WORKSPACE.bazel", "WORKSPACE.bzlmod", ]), (&["bitbake"], &["*.bb", "*.bbappend", "*.bbclass", "*.conf", "*.inc"]), + (&["boxlang"], &["*.bx", "*.bxm", "*.bxs"]), (&["brotli"], &["*.br"]), (&["buildstream"], &["*.bst"]), (&["bzip2"], &["*.bz2", "*.tbz2"]), @@ -39,12 +40,14 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[ (&["carp"], &["*.carp"]), (&["cbor"], &["*.cbor"]), (&["ceylon"], &["*.ceylon"]), + (&["cfml"], &["*.cfc", "*.cfm"]), (&["clojure"], &["*.clj", "*.cljc", "*.cljs", "*.cljx"]), (&["cmake"], &["*.cmake", "CMakeLists.txt"]), (&["cmd"], &["*.bat", "*.cmd"]), (&["cml"], &["*.cml"]), (&["coffeescript"], &["*.coffee"]), (&["config"], &["*.cfg", "*.conf", "*.config", "*.ini"]), + (&["container"], &["*Containerfile*", "*Dockerfile*"]), (&["coq"], &["*.v"]), (&["cpp"], &[ "*.[ChH]", "*.cc", "*.[ch]pp", "*.[ch]xx", "*.hh", "*.inl", @@ -62,7 +65,7 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[ (&["cython"], &["*.pyx", "*.pxi", "*.pxd"]), (&["d"], &["*.d"]), (&["dart"], &["*.dart"]), - (&["devicetree"], &["*.dts", "*.dtsi"]), + (&["devicetree"], &["*.dts", "*.dtsi", "*.dtso"]), (&["dhall"], &["*.dhall"]), (&["diff"], &["*.patch", "*.diff"]), (&["dita"], &["*.dita", "*.ditamap", "*.ditaval"]), @@ -88,6 +91,8 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[ (&["fsharp"], &["*.fs", "*.fsx", "*.fsi"]), (&["fut"], &["*.fut"]), (&["gap"], &["*.g", "*.gap", "*.gi", "*.gd", "*.tst"]), + (&["gdscript"], &["*.gd"]), + (&["gleam"], &["*.gleam"]), (&["gn"], &["*.gn", "*.gni"]), (&["go"], &["*.go"]), (&["gprbuild"], &["*.gpr"]), @@ -105,6 +110,7 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[ (&["hbs"], &["*.hbs"]), (&["hs"], &["*.hs", "*.lhs"]), (&["html"], &["*.htm", "*.html", "*.ejs"]), + (&["hurl"], &["*.hurl"]), (&["hy"], &["*.hy"]), (&["idris"], &["*.idr", "*.lidr"]), (&["janet"], &["*.janet"]), @@ -117,6 +123,7 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[ (&["julia"], &["*.jl"]), (&["jupyter"], &["*.ipynb", "*.jpynb"]), (&["k"], &["*.k"]), + (&["kconfig"], &["Kconfig", "Kconfig.*"]), (&["kotlin"], &["*.kt", "*.kts"]), (&["lean"], &["*.lean"]), (&["less"], &["*.less"]), @@ -149,6 +156,7 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[ ]), (&["lilypond"], &["*.ly", "*.ily"]), (&["lisp"], &["*.el", "*.jl", "*.lisp", "*.lsp", "*.sc", "*.scm"]), + (&["llvm"], &["*.ll"]), (&["lock"], &["*.lock", "package-lock.json"]), (&["log"], &["*.log"]), (&["lua"], &["*.lua"]), @@ -159,6 +167,7 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[ "[Gg][Nn][Uu]makefile", "[Mm]akefile", "[Gg][Nn][Uu]makefile.am", "[Mm]akefile.am", "[Gg][Nn][Uu]makefile.in", "[Mm]akefile.in", + "Makefile.*", "*.mk", "*.mak" ]), (&["mako"], &["*.mako", "*.mao"]), @@ -178,10 +187,11 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[ (&["mint"], &["*.mint"]), (&["mk"], &["mkfile"]), (&["ml"], &["*.ml"]), + (&["mojo"], &["*.mojo"]), (&["motoko"], &["*.mo"]), (&["msbuild"], &[ "*.csproj", "*.fsproj", "*.vcxproj", "*.proj", "*.props", "*.targets", - "*.sln", + "*.sln", "*.slnf" ]), (&["nim"], &["*.nim", "*.nimf", "*.nimble", "*.nims"]), (&["nix"], &["*.nix"]), @@ -199,18 +209,21 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[ "*.php", "*.php3", "*.php4", "*.php5", "*.php7", "*.php8", "*.pht", "*.phtml" ]), + (&["pkgbuild"], &["PKGBUILD"]), (&["po"], &["*.po"]), (&["pod"], &["*.pod"]), (&["postscript"], &["*.eps", "*.ps"]), (&["prolog"], &["*.pl", "*.pro", "*.prolog", "*.P"]), - (&["protobuf"], &["*.proto"]), + (&["proto", "protobuf"], &["*.proto"]), (&["ps"], &["*.cdxml", "*.ps1", "*.ps1xml", "*.psd1", "*.psm1"]), (&["puppet"], &["*.epp", "*.erb", "*.pp", "*.rb"]), (&["purs"], &["*.purs"]), (&["py", "python"], &["*.py", "*.pyi"]), (&["qmake"], &["*.pro", "*.pri", "*.prf"]), (&["qml"], &["*.qml"]), - (&["r"], &["*.R", "*.r", "*.Rmd", "*.Rnw"]), + (&["qrc"], &["*.qrc"]), + (&["qui"], &["*.ui"]), + (&["r"], &["*.R", "*.r", "*.Rmd", "*.rmd", "*.Rnw", "*.rnw"]), (&["racket"], &["*.rkt"]), (&["raku"], &[ "*.raku", "*.rakumod", "*.rakudoc", "*.rakutest", @@ -222,19 +235,22 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[ (&["red"], &["*.r", "*.red", "*.reds"]), (&["rescript"], &["*.res", "*.resi"]), (&["robot"], &["*.robot"]), + (&["rocq"], &["*.v"]), (&["rst"], &["*.rst"]), (&["ruby"], &[ // Idiomatic files "config.ru", "Gemfile", ".irbrc", "Rakefile", // Extensions - "*.gemspec", "*.rb", "*.rbw" + "*.gemspec", "*.rb", "*.rbw", "*.rake" ]), (&["rust"], &["*.rs"]), (&["sass"], &["*.sass", "*.scss"]), (&["scala"], &["*.scala", "*.sbt"]), + (&["scdoc"], &["*.scd", "*.scdoc"]), + (&["seed7"], &["*.sd7", "*.s7i"]), (&["sh"], &[ // Portable/misc. init files - ".login", ".logout", ".profile", "profile", + ".env", ".login", ".logout", ".profile", "profile", // bash-specific init files ".bash_login", "bash_login", ".bash_logout", "bash_logout", @@ -253,7 +269,7 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[ ".zprofile", "zprofile", ".zshrc", "zshrc", // Extensions - "*.bash", "*.csh", "*.ksh", "*.sh", "*.tcsh", "*.zsh", + "*.bash", "*.csh", "*.env", "*.ksh", "*.sh", "*.tcsh", "*.zsh", ]), (&["slim"], &["*.skim", "*.slim", "*.slime"]), (&["smarty"], &["*.tpl"]), @@ -263,9 +279,10 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[ (&["spark"], &["*.spark"]), (&["spec"], &["*.spec"]), (&["sql"], &["*.sql", "*.psql"]), + (&["ssa"], &["*.ssa"]), (&["stylus"], &["*.styl"]), (&["sv"], &["*.v", "*.vg", "*.sv", "*.svh", "*.h"]), - (&["svelte"], &["*.svelte"]), + (&["svelte"], &["*.svelte", "*.svelte.ts"]), (&["svg"], &["*.svg"]), (&["swift"], &["*.swift"]), (&["swig"], &["*.def", "*.i"]), @@ -280,9 +297,8 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[ (&["texinfo"], &["*.texi"]), (&["textile"], &["*.textile"]), (&["tf"], &[ - "*.tf", "*.auto.tfvars", "terraform.tfvars", "*.tf.json", - "*.auto.tfvars.json", "terraform.tfvars.json", "*.terraformrc", - "terraform.rc", "*.tfrc", "*.terraform.lock.hcl", + "*.tf", "*.tf.json", "*.tfvars", "*.tfvars.json", + "*.terraformrc", "terraform.rc", "*.tfrc", "*.terraform.lock.hcl", ]), (&["thrift"], &["*.thrift"]), (&["toml"], &["*.toml", "Cargo.lock"]), @@ -290,6 +306,7 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[ (&["twig"], &["*.twig"]), (&["txt"], &["*.txt"]), (&["typoscript"], &["*.typoscript", "*.ts"]), + (&["typst"], &["*.typ"]), (&["usd"], &["*.usd", "*.usda", "*.usdc"]), (&["v"], &["*.v", "*.vsh"]), (&["vala"], &["*.vala"]), @@ -348,4 +365,14 @@ mod tests { previous_name = name; } } + + #[test] + fn default_types_aliases_are_sorted() { + for (aliases, _) in DEFAULT_TYPES.iter() { + assert!( + aliases.is_sorted(), + "this alias list is not sorted: {aliases:?}", + ); + } + } } diff --git a/crates/ignore/src/dir.rs b/crates/ignore/src/dir.rs index 9bbf1442b..6bee724c8 100644 --- a/crates/ignore/src/dir.rs +++ b/crates/ignore/src/dir.rs @@ -16,7 +16,7 @@ use std::{ collections::HashMap, ffi::{OsStr, OsString}, - fs::{File, FileType}, + fs::{self, File, FileType}, io::{self, BufRead}, path::{Path, PathBuf}, sync::{Arc, RwLock, Weak}, @@ -25,7 +25,7 @@ use std::{ use crate::{ gitignore::{self, Gitignore, GitignoreBuilder}, overrides::{self, Override}, - pathutil::{is_hidden, strip_prefix}, + pathutil::{is_hidden_entry, strip_prefix}, types::{self, Types}, walk::DirEntry, {Error, Match, PartialErrorBuilder}, @@ -91,7 +91,25 @@ struct IgnoreOptions { /// Ignore is a matcher useful for recursively walking one or more directories. #[derive(Clone, Debug)] -pub(crate) struct Ignore(Arc); +pub(crate) struct Ignore { + inner: Arc, + // Parent matchers are cached independently of the path being walked, but + // matching them still needs the canonicalized path originally passed to + // `add_parents`. For example, when walking `/tmp/project/src`, parent + // matchers use `/tmp/project/src` to rewrite `/tmp/project/src/foo.py` + // before matching it against ignore files from `/tmp/project` and its + // ancestors. + // + // For ripgrep itself, this means that `rg pat src tests` must rewrite + // `src/foo` relative to `.../src`, and not whatever root was prepared + // first. + // + // See: https://github.com/BurntSushi/ripgrep/pull/3420 + // See: https://github.com/BurntSushi/ripgrep/issues/3376 + // See: https://github.com/BurntSushi/ripgrep/issues/3419 + // See: https://github.com/BurntSushi/ripgrep/issues/3320 + absolute_base: Option>, +} #[derive(Clone, Debug)] struct IgnoreInner { @@ -112,12 +130,21 @@ struct IgnoreInner { /// /// If this is the root directory or there are otherwise no more /// directories to match, then `parent` is `None`. - parent: Option, + parent: Option>, /// Whether this is an absolute parent matcher, as added by add_parent. is_absolute_parent: bool, - /// The absolute base path of this matcher. Populated only if parent - /// directories are added. - absolute_base: Option>, + /// The directory that gitignores should be interpreted relative to. + /// + /// Usually this is the directory containing the gitignore file. But in + /// some cases, like for global gitignores or for gitignores specified + /// explicitly, this should generally be set to the current working + /// directory. This is only used for global gitignores or "explicit" + /// gitignores. + /// + /// When `None`, this means the CWD could not be determined or is unknown. + /// In this case, global gitignore files are ignored because they otherwise + /// cannot be matched correctly. + global_gitignores_relative_to: Option, /// Explicit global ignore matchers specified by the caller. explicit_ignores: Arc>, /// Ignore files used in addition to `.ignore` @@ -140,34 +167,36 @@ struct IgnoreInner { impl Ignore { /// Return the directory path of this matcher. + #[cfg(test)] pub(crate) fn path(&self) -> &Path { - &self.0.dir + &self.inner.dir } /// Return true if this matcher has no parent. pub(crate) fn is_root(&self) -> bool { - self.0.parent.is_none() - } - - /// Returns true if this matcher was added via the `add_parents` method. - pub(crate) fn is_absolute_parent(&self) -> bool { - self.0.is_absolute_parent + self.inner.parent.is_none() } /// Return this matcher's parent, if one exists. pub(crate) fn parent(&self) -> Option { - self.0.parent.clone() + self.inner.parent.as_ref().map(|parent| Ignore { + inner: parent.clone(), + absolute_base: self.absolute_base.clone(), + }) } /// Create a new `Ignore` matcher with the parent directories of `dir`. /// /// Note that this can only be called on an `Ignore` matcher with no /// parents (i.e., `is_root` returns `true`). This will panic otherwise. - pub(crate) fn add_parents>(&self, path: P) -> (Ignore, Option) { - if !self.0.opts.parents - && !self.0.opts.git_ignore - && !self.0.opts.git_exclude - && !self.0.opts.git_global + pub(crate) fn add_parents>( + &self, + path: P, + ) -> (Ignore, Option) { + if !self.inner.opts.parents + && !self.inner.opts.git_ignore + && !self.inner.opts.git_exclude + && !self.inner.opts.git_global { // If we never need info from parent directories, then don't do // anything. @@ -197,25 +226,34 @@ impl Ignore { let mut errs = PartialErrorBuilder::default(); let mut ig = self.clone(); for parent in parents.into_iter().rev() { - let mut compiled = self.0.compiled.write().unwrap(); + let mut compiled = self.inner.compiled.write().unwrap(); if let Some(weak) = compiled.get(parent.as_os_str()) { if let Some(prebuilt) = weak.upgrade() { - ig = Ignore(prebuilt); + ig = Ignore { + inner: prebuilt, + absolute_base: Some(absolute_base.clone()), + }; continue; } } let (mut igtmp, err) = ig.add_child_path(parent); errs.maybe_push(err); igtmp.is_absolute_parent = true; - igtmp.absolute_base = Some(absolute_base.clone()); - igtmp.has_git = if self.0.opts.require_git && self.0.opts.git_ignore { - parent.join(".git").exists() - } else { - false - }; + igtmp.has_git = + if self.inner.opts.require_git && self.inner.opts.git_ignore { + parent.join(".git").exists() || parent.join(".jj").exists() + } else { + false + }; let ig_arc = Arc::new(igtmp); - ig = Ignore(ig_arc.clone()); - compiled.insert(parent.as_os_str().to_os_string(), Arc::downgrade(&ig_arc)); + ig = Ignore { + inner: ig_arc.clone(), + absolute_base: Some(absolute_base.clone()), + }; + compiled.insert( + parent.as_os_str().to_os_string(), + Arc::downgrade(&ig_arc), + ); } (ig, errs.into_error_option()) } @@ -228,59 +266,161 @@ impl Ignore { /// returned if it exists. /// /// Note that all I/O errors are completely ignored. - pub(crate) fn add_child>(&self, dir: P) -> (Ignore, Option) { + pub(crate) fn add_child>( + &self, + dir: P, + ) -> (Ignore, Option) { let (ig, err) = self.add_child_path(dir.as_ref()); - (Ignore(Arc::new(ig)), err) + ( + Ignore { + inner: Arc::new(ig), + absolute_base: self.absolute_base.clone(), + }, + err, + ) + } + + /// Like add_child, but uses successful read_dir entries to reduce + /// probing when discovering ignore files. + pub(crate) fn add_child_with_entries>( + &self, + dir: P, + entries: &[fs::DirEntry], + ) -> (Ignore, Option) { + let files = self.collect_ignore_files(entries); + let (ig, err) = self.add_child_path_with_found_ignore_files( + dir.as_ref(), + Some(&files), + ); + ( + Ignore { + inner: Arc::new(ig), + absolute_base: self.absolute_base.clone(), + }, + err, + ) } /// Like add_child, but takes a full path and returns an IgnoreInner. fn add_child_path(&self, dir: &Path) -> (IgnoreInner, Option) { - let git_type = - if self.0.opts.require_git && (self.0.opts.git_ignore || self.0.opts.git_exclude) { - dir.join(".git").metadata().ok().map(|md| md.file_type()) - } else { - None - }; - let has_git = git_type.map(|_| true).unwrap_or(false); + self.add_child_path_with_found_ignore_files(dir, None) + } + + fn collect_ignore_files( + &self, + entries: &[fs::DirEntry], + ) -> IgnoreFilesFound { + let custom_ignore_filenames = &self.inner.custom_ignore_filenames; + let mut files = IgnoreFilesFound { + has_ignore: false, + has_git_ignore: false, + has_git_dir: false, + has_jj_dir: false, + custom_ignore_files: vec![false; custom_ignore_filenames.len()], + }; + for entry in entries { + let file_name = entry.file_name(); + if file_name == OsStr::new(".ignore") { + files.has_ignore = true; + } else if file_name == OsStr::new(".gitignore") { + files.has_git_ignore = true; + } else if file_name == OsStr::new(".git") { + files.has_git_dir = true; + } else if file_name == OsStr::new(".jj") { + files.has_jj_dir = true; + } + for (i, name) in custom_ignore_filenames.iter().enumerate() { + if file_name == name.as_os_str() { + files.custom_ignore_files[i] = true; + } + } + } + files + } + + fn add_child_path_with_found_ignore_files( + &self, + dir: &Path, + ignore_files_list: Option<&IgnoreFilesFound>, + ) -> (IgnoreInner, Option) { + let check_vcs_dir = self.inner.opts.require_git + && (self.inner.opts.git_ignore || self.inner.opts.git_exclude); + let git_type = if check_vcs_dir + && ignore_files_list.is_none_or(|i| i.has_git_dir) + { + dir.join(".git").metadata().ok().map(|md| md.file_type()) + } else { + None + }; + let has_jj = check_vcs_dir + && ignore_files_list.is_none_or(|i| i.has_jj_dir) + && dir.join(".jj").exists(); + let has_git = check_vcs_dir && (git_type.is_some() || has_jj); let mut errs = PartialErrorBuilder::default(); - let custom_ig_matcher = if self.0.custom_ignore_filenames.is_empty() { + let custom_ig_matcher = if self + .inner + .custom_ignore_filenames + .is_empty() + { Gitignore::empty() } else { - let (m, err) = create_gitignore( - &dir, - &dir, - &self.0.custom_ignore_filenames, - self.0.opts.ignore_case_insensitive, - ); - errs.maybe_push(err); - m + let custom_ignore_names: Vec<&OsString> = match ignore_files_list { + None => self.inner.custom_ignore_filenames.iter().collect(), + Some(m) => self + .inner + .custom_ignore_filenames + .iter() + .zip(m.custom_ignore_files.iter()) + .filter(|&(_, &matched)| matched) + .map(|(name, _)| name) + .collect(), + }; + if custom_ignore_names.is_empty() { + Gitignore::empty() + } else { + let (m, err) = create_gitignore( + &dir, + &dir, + &custom_ignore_names, + self.inner.opts.ignore_case_insensitive, + ); + errs.maybe_push(err); + m + } }; - let ig_matcher = if !self.0.opts.ignore { + let ig_matcher = if !self.inner.opts.ignore + || !ignore_files_list.is_none_or(|i| i.has_ignore) + { Gitignore::empty() } else { let (m, err) = create_gitignore( &dir, &dir, &[".ignore"], - self.0.opts.ignore_case_insensitive, + self.inner.opts.ignore_case_insensitive, ); errs.maybe_push(err); m }; - let gi_matcher = if !self.0.opts.git_ignore { + let gi_matcher = if !self.inner.opts.git_ignore + || !ignore_files_list.is_none_or(|i| i.has_git_ignore) + { Gitignore::empty() } else { let (m, err) = create_gitignore( &dir, &dir, &[".gitignore"], - self.0.opts.ignore_case_insensitive, + self.inner.opts.ignore_case_insensitive, ); errs.maybe_push(err); m }; - let gi_exclude_matcher = if !self.0.opts.git_exclude { + + let gi_exclude_matcher = if !self.inner.opts.git_exclude + || !ignore_files_list.is_none_or(|i| i.has_git_dir) + { Gitignore::empty() } else { match resolve_git_commondir(dir, git_type) { @@ -289,7 +429,7 @@ impl Ignore { &dir, &git_dir, &["info/exclude"], - self.0.opts.ignore_case_insensitive, + self.inner.opts.ignore_case_insensitive, ); errs.maybe_push(err); m @@ -301,31 +441,38 @@ impl Ignore { } }; let ig = IgnoreInner { - compiled: self.0.compiled.clone(), + compiled: self.inner.compiled.clone(), dir: dir.to_path_buf(), - overrides: self.0.overrides.clone(), - types: self.0.types.clone(), - parent: Some(self.clone()), + overrides: self.inner.overrides.clone(), + types: self.inner.types.clone(), + parent: Some(self.inner.clone()), is_absolute_parent: false, - absolute_base: self.0.absolute_base.clone(), - explicit_ignores: self.0.explicit_ignores.clone(), - custom_ignore_filenames: self.0.custom_ignore_filenames.clone(), + global_gitignores_relative_to: self + .inner + .global_gitignores_relative_to + .clone(), + explicit_ignores: self.inner.explicit_ignores.clone(), + custom_ignore_filenames: self + .inner + .custom_ignore_filenames + .clone(), custom_ignore_matcher: custom_ig_matcher, ignore_matcher: ig_matcher, - git_global_matcher: self.0.git_global_matcher.clone(), + git_global_matcher: self.inner.git_global_matcher.clone(), git_ignore_matcher: gi_matcher, git_exclude_matcher: gi_exclude_matcher, has_git, - opts: self.0.opts, + opts: self.inner.opts, }; (ig, errs.into_error_option()) } /// Returns true if at least one type of ignore rule should be matched. fn has_any_ignore_rules(&self) -> bool { - let opts = self.0.opts; - let has_custom_ignore_files = !self.0.custom_ignore_filenames.is_empty(); - let has_explicit_ignores = !self.0.explicit_ignores.is_empty(); + let opts = self.inner.opts; + let has_custom_ignore_files = + !self.inner.custom_ignore_filenames.is_empty(); + let has_explicit_ignores = !self.inner.explicit_ignores.is_empty(); opts.ignore || opts.git_global @@ -336,9 +483,12 @@ impl Ignore { } /// Like `matched`, but works with a directory entry instead. - pub(crate) fn matched_dir_entry<'a>(&'a self, dent: &DirEntry) -> Match> { + pub(crate) fn matched_dir_entry<'a>( + &'a self, + dent: &DirEntry, + ) -> Match> { let m = self.matched(dent.path(), dent.is_dir()); - if m.is_none() && self.0.opts.hidden && is_hidden(dent) { + if m.is_none() && self.inner.opts.hidden && is_hidden_entry(dent) { return Match::Ignore(IgnoreMatch::hidden()); } m @@ -348,7 +498,11 @@ impl Ignore { /// ignored or not. /// /// The match contains information about its origin. - fn matched<'a, P: AsRef>(&'a self, path: P, is_dir: bool) -> Match> { + pub(crate) fn matched<'a, P: AsRef>( + &'a self, + path: P, + is_dir: bool, + ) -> Match> { // We need to be careful with our path. If it has a leading ./, then // strip it because it causes nothing but trouble. let mut path = path.as_ref(); @@ -359,9 +513,9 @@ impl Ignore { // regardless of whether it's whitelist/ignore, then we quit and // return that result immediately. Overrides have the highest // precedence. - if !self.0.overrides.is_empty() { + if !self.inner.overrides.is_empty() { let mat = self - .0 + .inner .overrides .matched(path, is_dir) .map(IgnoreMatch::overrides); @@ -378,8 +532,9 @@ impl Ignore { whitelisted = mat; } } - if !self.0.types.is_empty() { - let mat = self.0.types.matched(path, is_dir).map(IgnoreMatch::types); + if !self.inner.types.is_empty() { + let mat = + self.inner.types.matched(path, is_dir).map(IgnoreMatch::types); if mat.is_ignore() { return mat; } else if mat.is_whitelist() { @@ -391,96 +546,143 @@ impl Ignore { /// Performs matching only on the ignore files for this directory and /// all parent directories. - fn matched_ignore<'a>(&'a self, path: &Path, is_dir: bool) -> Match> { - let (mut m_custom_ignore, mut m_ignore, mut m_gi, mut m_gi_exclude, mut m_explicit) = ( - Match::None, - Match::None, - Match::None, - Match::None, - Match::None, - ); - let any_git = !self.0.opts.require_git || self.parents().any(|ig| ig.0.has_git); + pub(crate) fn matched_ignore<'a>( + &'a self, + path: &Path, + is_dir: bool, + ) -> Match> { + let ( + mut m_custom_ignore, + mut m_ignore, + mut m_gi, + mut m_gi_exclude, + mut m_explicit, + ) = (Match::None, Match::None, Match::None, Match::None, Match::None); + let any_git = !self.inner.opts.require_git + || self.parents().any(|ig| ig.inner.has_git); let mut saw_git = false; - for ig in self.parents().take_while(|ig| !ig.0.is_absolute_parent) { + for ig in self.parents().take_while(|ig| !ig.inner.is_absolute_parent) + { if m_custom_ignore.is_none() { - m_custom_ignore = - ig.0.custom_ignore_matcher - .matched(path, is_dir) - .map(IgnoreMatch::gitignore); + m_custom_ignore = ig + .inner + .custom_ignore_matcher + .matched(path, is_dir) + .map(IgnoreMatch::gitignore); } if m_ignore.is_none() { - m_ignore = - ig.0.ignore_matcher - .matched(path, is_dir) - .map(IgnoreMatch::gitignore); + m_ignore = ig + .inner + .ignore_matcher + .matched(path, is_dir) + .map(IgnoreMatch::gitignore); } if any_git && !saw_git && m_gi.is_none() { - m_gi = - ig.0.git_ignore_matcher - .matched(path, is_dir) - .map(IgnoreMatch::gitignore); + m_gi = ig + .inner + .git_ignore_matcher + .matched(path, is_dir) + .map(IgnoreMatch::gitignore); } if any_git && !saw_git && m_gi_exclude.is_none() { - m_gi_exclude = - ig.0.git_exclude_matcher - .matched(path, is_dir) - .map(IgnoreMatch::gitignore); + m_gi_exclude = ig + .inner + .git_exclude_matcher + .matched(path, is_dir) + .map(IgnoreMatch::gitignore); } - saw_git = saw_git || ig.0.has_git; + saw_git = saw_git || ig.inner.has_git; } - if self.0.opts.parents { - // CHANGED: We removed a code path that rewrote the `path` to be relative to - // `self.absolute_base()` because it assumed that the every path is inside the base - // which is not the case for us as we use `WalkBuilder#add` to add roots outside of the - // base. - for ig in self.parents().skip_while(|ig| !ig.0.is_absolute_parent) { - if m_custom_ignore.is_none() { - m_custom_ignore = - ig.0.custom_ignore_matcher + if self.inner.opts.parents { + if let Some(abs_parent_path) = self.absolute_base() { + // What we want to do here is take the absolute base path of + // this directory and join it with the path we're searching. + // The main issue we want to avoid is accidentally duplicating + // directory components, so we try to strip any common prefix + // off of `path`. Overall, this seems a little ham-fisted, but + // it does fix a nasty bug. It should do fine until we overhaul + // this crate. + let path = abs_parent_path.join( + self.parents() + .take_while(|ig| !ig.inner.is_absolute_parent) + .last() + .map_or(path, |ig| { + // This is a weird special case when ripgrep users + // search with just a `.`, as some tools do + // automatically (like consult). In this case, if + // we don't bail out now, the code below will strip + // a leading `.` from `path`, which might mangle + // a hidden file name! + if ig.inner.dir.as_path() == Path::new(".") { + return path; + } + let without_dot_slash = strip_if_is_prefix( + "./", + ig.inner.dir.as_path(), + ); + let relative_base = + strip_if_is_prefix(without_dot_slash, path); + strip_if_is_prefix("/", relative_base) + }), + ); + + for ig in self + .parents() + .skip_while(|ig| !ig.inner.is_absolute_parent) + { + if m_custom_ignore.is_none() { + m_custom_ignore = ig + .inner + .custom_ignore_matcher .matched(&path, is_dir) .map(IgnoreMatch::gitignore); - } - if m_ignore.is_none() { - m_ignore = - ig.0.ignore_matcher + } + if m_ignore.is_none() { + m_ignore = ig + .inner + .ignore_matcher .matched(&path, is_dir) .map(IgnoreMatch::gitignore); - } - if any_git && !saw_git && m_gi.is_none() { - m_gi = - ig.0.git_ignore_matcher + } + if any_git && !saw_git && m_gi.is_none() { + m_gi = ig + .inner + .git_ignore_matcher .matched(&path, is_dir) .map(IgnoreMatch::gitignore); - } - if any_git && !saw_git && m_gi_exclude.is_none() { - m_gi_exclude = - ig.0.git_exclude_matcher + } + if any_git && !saw_git && m_gi_exclude.is_none() { + m_gi_exclude = ig + .inner + .git_exclude_matcher .matched(&path, is_dir) .map(IgnoreMatch::gitignore); + } + saw_git = saw_git || ig.inner.has_git; } - saw_git = saw_git || ig.0.has_git; } } - for gi in self.0.explicit_ignores.iter().rev() { - // CHANGED: We need to make sure that the explicit gitignore rules apply to the path - // - // path = Is the current file/folder we are traversing - // gi.path() = Is the path of the custom gitignore file - // - // E.g.: If we have a custom rule for `/src/utils` with `**/*`, and we are looking at - // just `/src`, then the `**/*` rules do not apply to this folder, so we can - // ignore the current custom gitignore file. - // - if !path.starts_with(gi.path()) { - continue; - } + for gi in self.inner.explicit_ignores.iter().rev() { if !m_explicit.is_none() { break; } + // CHANGED: We need to make sure that the explicit gitignore rules + // apply to the path + // + // path = Is the current file/folder we are traversing + // gi.path() = Is the path of the custom gitignore file + // + // E.g.: If we have a custom rule for `/src/utils` with `**/*`, and + // we are looking at just `/src`, then the `**/*` rules do + // not apply to this folder, so we can ignore the current + // custom gitignore file. + if !path.starts_with(gi.path()) { + continue; + } m_explicit = gi.matched(&path, is_dir).map(IgnoreMatch::gitignore); } let m_global = if any_git { - self.0 + self.inner .git_global_matcher .matched(&path, is_dir) .map(IgnoreMatch::gitignore) @@ -488,53 +690,90 @@ impl Ignore { Match::None }; - // CHANGED: We added logic to configure an order in which the ignore files are respected and - // allowed a whitelist in a later file to overrule a block on an earlier file. + // CHANGED: We added logic to configure an order in which the ignore + // files are respected. Explicitly added ignores (via + // `WalkBuilder::add_gitignore`) take precedence over all ignore files + // found on disk, and the first source with a definitive answer wins. let order = [ // Manually added ignores - &m_explicit, + m_explicit, // .custom-ignore - &m_custom_ignore, + m_custom_ignore, // .ignore - &m_ignore, + m_ignore, // .gitignore - &m_gi, + m_gi, // .git/info/exclude - &m_gi_exclude, + m_gi_exclude, // Global gitignore - &m_global, + m_global, ]; - for check in order.into_iter() { - if check.is_none() { - continue; + if !check.is_none() { + return check; } - - return check.clone(); } - - m_explicit + Match::None } /// Returns an iterator over parent ignore matchers, including this one. pub(crate) fn parents(&self) -> Parents<'_> { - Parents(Some(self)) + Parents(Some(IgnoreRef { inner: &self.inner })) + } + + /// Returns the first absolute path of the first absolute parent, if + /// one exists. + fn absolute_base(&self) -> Option<&Path> { + self.absolute_base.as_ref().map(|p| &***p) + } +} + +/// State for tracking what kinds of files ripgrep is interested in for a +/// given directory. +/// +/// This is computed over the entire set of files in a directory instead of +/// trying to stat each file individually. If a file is present, it's only then +/// that we stat it for more information, instead of relying on the stat to +/// determine its existence. +#[derive(Debug)] +struct IgnoreFilesFound { + has_ignore: bool, + has_git_ignore: bool, + has_git_dir: bool, + has_jj_dir: bool, + custom_ignore_files: Vec, +} + +#[derive(Clone, Copy)] +pub(crate) struct IgnoreRef<'a> { + inner: &'a IgnoreInner, +} + +impl IgnoreRef<'_> { + pub(crate) fn path(&self) -> &Path { + &self.inner.dir + } + + pub(crate) fn is_absolute_parent(&self) -> bool { + self.inner.is_absolute_parent } } /// An iterator over all parents of an ignore matcher, including itself. -/// -/// The lifetime `'a` refers to the lifetime of the initial `Ignore` matcher. -pub(crate) struct Parents<'a>(Option<&'a Ignore>); +pub(crate) struct Parents<'a>(Option>); impl<'a> Iterator for Parents<'a> { - type Item = &'a Ignore; + type Item = IgnoreRef<'a>; - fn next(&mut self) -> Option<&'a Ignore> { + fn next(&mut self) -> Option> { match self.0.take() { None => None, Some(ig) => { - self.0 = ig.0.parent.as_ref(); + self.0 = ig + .inner + .parent + .as_deref() + .map(|inner| IgnoreRef { inner }); Some(ig) } } @@ -554,6 +793,16 @@ pub(crate) struct IgnoreBuilder { explicit_ignores: Vec, /// Ignore files in addition to .ignore. custom_ignore_filenames: Vec, + /// The directory that gitignores should be interpreted relative to. + /// + /// Usually this is the directory containing the gitignore file. But in + /// some cases, like for global gitignores or for gitignores specified + /// explicitly, this should generally be set to the current working + /// directory. This is only used for global gitignores or "explicit" + /// gitignores. + /// + /// When `None`, global gitignores are ignored. + global_gitignores_relative_to: Option, /// Ignore config. opts: IgnoreOptions, } @@ -561,8 +810,9 @@ pub(crate) struct IgnoreBuilder { impl IgnoreBuilder { /// Create a new builder for an `Ignore` matcher. /// - /// All relative file paths are resolved with respect to the current - /// working directory. + /// It is likely a bug to use this without also calling `current_dir()` + /// outside of tests. This isn't made mandatory because this is an internal + /// abstraction and it's annoying to update tests. pub(crate) fn new() -> IgnoreBuilder { IgnoreBuilder { dir: Path::new("").to_path_buf(), @@ -570,6 +820,7 @@ impl IgnoreBuilder { types: Arc::new(Types::empty()), explicit_ignores: vec![], custom_ignore_filenames: vec![], + global_gitignores_relative_to: None, opts: IgnoreOptions { hidden: true, ignore: true, @@ -588,10 +839,20 @@ impl IgnoreBuilder { /// The matcher returned won't match anything until ignore rules from /// directories are added to it. pub(crate) fn build(&self) -> Ignore { + self.build_with_cwd(None) + } + + /// Builds a new `Ignore` matcher using the given CWD directory. + /// + /// The matcher returned won't match anything until ignore rules from + /// directories are added to it. + pub(crate) fn build_with_cwd(&self, cwd: Option) -> Ignore { + let global_gitignores_relative_to = + cwd.or_else(|| self.global_gitignores_relative_to.clone()); let git_global_matcher = if !self.opts.git_global { Gitignore::empty() - } else { - let mut builder = GitignoreBuilder::new(""); + } else if let Some(ref cwd) = global_gitignores_relative_to { + let mut builder = GitignoreBuilder::new(cwd); builder .case_insensitive(self.opts.ignore_case_insensitive) .unwrap(); @@ -600,26 +861,45 @@ impl IgnoreBuilder { log::debug!("{}", err); } gi + } else { + log::debug!( + "ignoring global gitignore file because CWD is not known" + ); + Gitignore::empty() }; - Ignore(Arc::new(IgnoreInner { - compiled: Arc::new(RwLock::new(HashMap::new())), - dir: self.dir.clone(), - overrides: self.overrides.clone(), - types: self.types.clone(), - parent: None, - is_absolute_parent: true, + Ignore { + inner: Arc::new(IgnoreInner { + compiled: Arc::new(RwLock::new(HashMap::new())), + dir: self.dir.clone(), + overrides: self.overrides.clone(), + types: self.types.clone(), + parent: None, + is_absolute_parent: true, + global_gitignores_relative_to, + explicit_ignores: Arc::new(self.explicit_ignores.clone()), + custom_ignore_filenames: Arc::new( + self.custom_ignore_filenames.clone(), + ), + custom_ignore_matcher: Gitignore::empty(), + ignore_matcher: Gitignore::empty(), + git_global_matcher: Arc::new(git_global_matcher), + git_ignore_matcher: Gitignore::empty(), + git_exclude_matcher: Gitignore::empty(), + has_git: false, + opts: self.opts, + }), absolute_base: None, - explicit_ignores: Arc::new(self.explicit_ignores.clone()), - custom_ignore_filenames: Arc::new(self.custom_ignore_filenames.clone()), - custom_ignore_matcher: Gitignore::empty(), - ignore_matcher: Gitignore::empty(), - git_global_matcher: Arc::new(git_global_matcher), - git_ignore_matcher: Gitignore::empty(), - git_exclude_matcher: Gitignore::empty(), - has_git: false, - opts: self.opts, - })) + } + } + + /// Set the current directory used for matching global gitignores. + pub(crate) fn current_dir( + &mut self, + cwd: impl Into, + ) -> &mut IgnoreBuilder { + self.global_gitignores_relative_to = Some(cwd.into()); + self } /// Add an override matcher. @@ -627,7 +907,10 @@ impl IgnoreBuilder { /// By default, no override matcher is used. /// /// This overrides any previous setting. - pub(crate) fn overrides(&mut self, overrides: Override) -> &mut IgnoreBuilder { + pub(crate) fn overrides( + &mut self, + overrides: Override, + ) -> &mut IgnoreBuilder { self.overrides = Arc::new(overrides); self } @@ -658,8 +941,7 @@ impl IgnoreBuilder { &mut self, file_name: S, ) -> &mut IgnoreBuilder { - self.custom_ignore_filenames - .push(file_name.as_ref().to_os_string()); + self.custom_ignore_filenames.push(file_name.as_ref().to_os_string()); self } @@ -671,6 +953,11 @@ impl IgnoreBuilder { self } + /// Whether ignoring hidden files is enabled or not. + pub(crate) fn is_hidden(&self) -> bool { + self.opts.hidden + } + /// Enables reading `.ignore` files. /// /// `.ignore` files have the same semantics as `gitignore` files and are @@ -741,7 +1028,10 @@ impl IgnoreBuilder { /// Process ignore files case insensitively /// /// This is disabled by default. - pub(crate) fn ignore_case_insensitive(&mut self, yes: bool) -> &mut IgnoreBuilder { + pub(crate) fn ignore_case_insensitive( + &mut self, + yes: bool, + ) -> &mut IgnoreBuilder { self.opts.ignore_case_insensitive = yes; self } @@ -800,7 +1090,10 @@ pub(crate) fn create_gitignore>( /// them when multiple repositories are searched. /// /// Some I/O errors are ignored. -fn resolve_git_commondir(dir: &Path, git_type: Option) -> Result> { +fn resolve_git_commondir( + dir: &Path, + git_type: Option, +) -> Result> { let git_dir_path = || dir.join(".git"); let git_dir = git_dir_path(); if !git_type.map_or(false, |ft| ft.is_file()) { @@ -843,11 +1136,22 @@ fn resolve_git_commondir(dir: &Path, git_type: Option) -> Result + ?Sized>( + prefix: &'a P, + path: &'a Path, +) -> &'a Path { + strip_prefix(prefix, path).map_or(path, |p| p) +} + #[cfg(test)] mod tests { - use std::{io::Write, path::Path}; + use std::{io::Write, path::Path, sync::Arc}; - use crate::{dir::IgnoreBuilder, gitignore::Gitignore, tests::TempDir, Error}; + use crate::{ + Error, dir::IgnoreBuilder, gitignore::Gitignore, tests::TempDir, + }; fn wfile>(path: P, contents: &str) { let mut file = std::fs::File::create(path).unwrap(); @@ -876,11 +1180,11 @@ mod tests { let (gi, err) = Gitignore::new(td.path().join("not-an-ignore")); assert!(err.is_none()); - let (ig, err) = IgnoreBuilder::new() - .add_ignore(gi) - .build() - .add_child(td.path()); + let (ig, err) = + IgnoreBuilder::new().add_ignore(gi).build().add_child(td.path()); assert!(err.is_none()); + // CHANGED: Explicit ignores only apply to paths inside the directory + // of the ignore file, so we have to match against full paths. assert!(ig.matched(td.path().join("foo"), false).is_ignore()); assert!(ig.matched(td.path().join("bar"), false).is_whitelist()); assert!(ig.matched(td.path().join("baz"), false).is_none()); @@ -913,6 +1217,19 @@ mod tests { assert!(ig.matched("baz", false).is_none()); } + #[test] + fn gitignore_with_jj() { + let td = tmpdir(); + mkdirp(td.path().join(".jj")); + wfile(td.path().join(".gitignore"), "foo\n!bar"); + + let (ig, err) = IgnoreBuilder::new().build().add_child(td.path()); + assert!(err.is_none()); + assert!(ig.matched("foo", false).is_ignore()); + assert!(ig.matched("bar", false).is_whitelist()); + assert!(ig.matched("baz", false).is_none()); + } + #[test] fn gitignore_no_git() { let td = tmpdir(); @@ -1138,15 +1455,152 @@ mod tests { let (ig2, err) = ig1.add_child("src"); assert!(err.is_none()); - // CHANGED: These test cases do not make sense for us as we never call the Ignore with - // relative paths. - assert!(ig1.matched("llvm", true).is_ignore()); - assert!(ig2.matched("llvm", true).is_ignore()); + assert!(ig1.matched("llvm", true).is_none()); + assert!(ig2.matched("llvm", true).is_none()); assert!(ig2.matched("src/llvm", true).is_none()); assert!(ig2.matched("foo", false).is_ignore()); assert!(ig2.matched("src/foo", false).is_ignore()); } + #[test] + fn absolute_parent_matchers_are_cached_across_roots() { + let td = tmpdir(); + mkdirp(td.path().join(".git")); + mkdirp(td.path().join("src/build")); + mkdirp(td.path().join("tests/build")); + wfile(td.path().join(".gitignore"), "tests/**/build/\n"); + + let ig0 = IgnoreBuilder::new().build(); + let (src_parents, err) = ig0.add_parents(td.path().join("src")); + assert!(err.is_none()); + let (src, err) = src_parents.add_child(td.path().join("src")); + assert!(err.is_none()); + let (tests_parents, err) = ig0.add_parents(td.path().join("tests")); + assert!(err.is_none()); + let (tests, err) = tests_parents.add_child(td.path().join("tests")); + assert!(err.is_none()); + + assert!(Arc::ptr_eq(&src_parents.inner, &tests_parents.inner)); + assert!(src.matched("build", true).is_none()); + assert!(tests.matched("build", true).is_ignore()); + } + + /// Parent matchers are shared across search roots, but path rewriting for + /// absolute parents must use each root's own base path. Otherwise a rule + /// like `src/invalid` is matched against the wrong absolute path when + /// `src` is searched before a sibling root (e.g. `tests`). + /// + /// Paths passed to `matched` use the same relative layout as `Walk` when + /// roots are given as relative directory names. + /// + /// Regression for: https://github.com/BurntSushi/ripgrep/issues/3376 + /// and https://github.com/BurntSushi/ripgrep/issues/3419 + #[test] + fn multi_root_gitignore_order_independent() { + let td = tmpdir(); + let cwd = std::env::current_dir().unwrap(); + // Use paths relative to CWD like the CLI walk does for `rg pat src tests`. + let root = td.path().strip_prefix(&cwd).unwrap_or(td.path()); + let src_root = root.join("src"); + let tests_root = root.join("tests"); + + mkdirp(td.path().join(".git")); + mkdirp(td.path().join("src")); + mkdirp(td.path().join("tests")); + wfile(td.path().join(".gitignore"), "src/invalid\n"); + wfile(td.path().join("src/invalid"), "x"); + wfile(td.path().join("src/valid"), "x"); + wfile(td.path().join("tests/valid"), "x"); + + let ig0 = IgnoreBuilder::new().build(); + + // Historically buggy order: search `src` first, then `tests`. + let (src_parents, err) = ig0.add_parents(&src_root); + assert!(err.is_none()); + let (src, err) = src_parents.add_child(&src_root); + assert!(err.is_none()); + let (tests_parents, err) = ig0.add_parents(&tests_root); + assert!(err.is_none()); + let (tests, err) = tests_parents.add_child(&tests_root); + assert!(err.is_none()); + + assert!(Arc::ptr_eq(&src_parents.inner, &tests_parents.inner)); + // Each root must carry its own absolute_base even though inners are shared. + assert_ne!( + src.absolute_base.as_ref().unwrap().as_path(), + tests.absolute_base.as_ref().unwrap().as_path() + ); + assert!( + src.matched(src_root.join("invalid"), false).is_ignore(), + "parent .gitignore must apply for the src root even when \ + another root was prepared in the same process" + ); + assert!(src.matched(src_root.join("valid"), false).is_none()); + assert!(tests.matched(tests_root.join("valid"), false).is_none()); + + // Reverse order should behave the same way. + let ig0 = IgnoreBuilder::new().build(); + let (tests_parents, err) = ig0.add_parents(&tests_root); + assert!(err.is_none()); + let (tests, err) = tests_parents.add_child(&tests_root); + assert!(err.is_none()); + let (src_parents, err) = ig0.add_parents(&src_root); + assert!(err.is_none()); + let (src, err) = src_parents.add_child(&src_root); + assert!(err.is_none()); + + assert!(src.matched(src_root.join("invalid"), false).is_ignore()); + assert!(src.matched(src_root.join("valid"), false).is_none()); + assert!(tests.matched(tests_root.join("valid"), false).is_none()); + } + + /// Same multi-root / order issue for non-git ignore files (e.g. `.rgignore` + /// via custom ignore names). + /// + /// Regression for: https://github.com/BurntSushi/ripgrep/issues/3320 + #[test] + fn multi_root_custom_ignore_order_independent() { + let td = tmpdir(); + let cwd = std::env::current_dir().unwrap(); + let root = td.path().strip_prefix(&cwd).unwrap_or(td.path()); + let alpha_root = root.join("alpha"); + let beta_root = root.join("beta"); + + mkdirp(td.path().join("alpha")); + mkdirp(td.path().join("beta")); + wfile(td.path().join(".rgignore"), "beta/**/*.svg\n"); + wfile(td.path().join("alpha/a.txt"), "x"); + wfile(td.path().join("beta/x.svg"), "x"); + + let ig0 = IgnoreBuilder::new() + .add_custom_ignore_filename(".rgignore") + .ignore(false) + .git_ignore(false) + .git_global(false) + .git_exclude(false) + .build(); + + let (alpha_parents, err) = ig0.add_parents(&alpha_root); + assert!(err.is_none()); + let (alpha, err) = alpha_parents.add_child(&alpha_root); + assert!(err.is_none()); + let (beta_parents, err) = ig0.add_parents(&beta_root); + assert!(err.is_none()); + let (beta, err) = beta_parents.add_child(&beta_root); + assert!(err.is_none()); + + assert_ne!( + alpha.absolute_base.as_ref().unwrap().as_path(), + beta.absolute_base.as_ref().unwrap().as_path() + ); + assert!(alpha.matched(alpha_root.join("a.txt"), false).is_none()); + assert!( + beta.matched(beta_root.join("x.svg"), false).is_ignore(), + "parent .rgignore must apply for the beta root regardless of \ + which root was set up first" + ); + } + #[test] fn git_info_exclude_in_linked_worktree() { let td = tmpdir(); @@ -1154,16 +1608,14 @@ mod tests { mkdirp(git_dir.join("info")); wfile(git_dir.join("info/exclude"), "ignore_me"); mkdirp(git_dir.join("worktrees/linked-worktree")); - let commondir_path = || git_dir.join("worktrees/linked-worktree/commondir"); + let commondir_path = + || git_dir.join("worktrees/linked-worktree/commondir"); mkdirp(td.path().join("linked-worktree")); let worktree_git_dir_abs = format!( "gitdir: {}", git_dir.join("worktrees/linked-worktree").to_str().unwrap(), ); - wfile( - td.path().join("linked-worktree/.git"), - &worktree_git_dir_abs, - ); + wfile(td.path().join("linked-worktree/.git"), &worktree_git_dir_abs); // relative commondir wfile(commondir_path(), "../.."); diff --git a/crates/ignore/src/gitignore.rs b/crates/ignore/src/gitignore.rs index 7da86153c..8824139b5 100644 --- a/crates/ignore/src/gitignore.rs +++ b/crates/ignore/src/gitignore.rs @@ -20,8 +20,8 @@ use { }; use crate::{ - pathutil::{is_file_name, strip_prefix}, Error, Match, PartialErrorBuilder, + pathutil::{is_file_name, strip_prefix}, }; /// Glob represents a single glob in a gitignore file. @@ -102,7 +102,9 @@ impl Gitignore { /// /// Note that I/O errors are ignored. For more granular control over /// errors, use `GitignoreBuilder`. - pub fn new>(gitignore_path: P) -> (Gitignore, Option) { + pub fn new>( + gitignore_path: P, + ) -> (Gitignore, Option) { let path = gitignore_path.as_ref(); let parent = path.parent().unwrap_or(Path::new("/")); let mut builder = GitignoreBuilder::new(parent); @@ -123,12 +125,26 @@ impl Gitignore { /// The global config file path is specified by git's `core.excludesFile` /// config option. /// + /// # Behavior + /// + /// This routine does its best to discover any global git exclude files. + /// This will try to parse out the `excludesFile` config option in your + /// global git configuration, if necessary. + /// + /// The specific things this routine tries (which are subject to change + /// based on how git behaves) are: + /// + /// + /// /// Git's config file location is `$HOME/.gitconfig`. If `$HOME/.gitconfig` /// does not exist or does not specify `core.excludesFile`, then /// `$XDG_CONFIG_HOME/git/ignore` is read. If `$XDG_CONFIG_HOME` is not /// set or is empty, then `$HOME/.config/git/ignore` is used instead. pub fn global() -> (Gitignore, Option) { - GitignoreBuilder::new("").build_global() + match std::env::current_dir() { + Ok(cwd) => GitignoreBuilder::new(cwd).build_global(), + Err(err) => (Gitignore::empty(), Some(err.into())), + } } /// Creates a new empty gitignore matcher that never matches anything. @@ -142,7 +158,8 @@ impl Gitignore { num_ignores: 0, num_whitelists: 0, matches: None, - // CHANGED: Add a flag to have Gitignore rules that apply only to files. + // CHANGED: Add a flag to have Gitignore rules that apply only to + // files. only_on_files: false, } } @@ -187,7 +204,11 @@ impl Gitignore { /// determined by a common suffix of the directory containing this /// gitignore) is stripped. If there is no common suffix/prefix overlap, /// then `path` is assumed to be relative to this matcher. - pub fn matched>(&self, path: P, is_dir: bool) -> Match<&Glob> { + pub fn matched>( + &self, + path: P, + is_dir: bool, + ) -> Match<&Glob> { if self.is_empty() { return Match::None; } @@ -240,11 +261,16 @@ impl Gitignore { } /// Like matched, but takes a path that has already been stripped. - fn matched_stripped>(&self, path: P, is_dir: bool) -> Match<&Glob> { + fn matched_stripped>( + &self, + path: P, + is_dir: bool, + ) -> Match<&Glob> { if self.is_empty() { return Match::None; } - // CHANGED: Rules marked as only_on_files can not match against directories. + // CHANGED: Rules marked as only_on_files can not match against + // directories. if self.only_on_files && is_dir { return Match::None; } @@ -267,7 +293,10 @@ impl Gitignore { /// Strips the given path such that it's suitable for matching with this /// gitignore matcher. - fn strip<'a, P: 'a + AsRef + ?Sized>(&'a self, path: &'a P) -> &'a Path { + fn strip<'a, P: 'a + AsRef + ?Sized>( + &'a self, + path: &'a P, + ) -> &'a Path { let mut path = path.as_ref(); // A leading ./ is completely superfluous. We also strip it from // our gitignore root path, so we need to strip it from our candidate @@ -303,6 +332,7 @@ pub struct GitignoreBuilder { root: PathBuf, globs: Vec, case_insensitive: bool, + allow_unclosed_class: bool, // CHANGED: Add a flag to have Gitignore rules that apply only to files. only_on_files: bool, } @@ -321,7 +351,9 @@ impl GitignoreBuilder { root: strip_prefix("./", root).unwrap_or(root).to_path_buf(), globs: vec![], case_insensitive: false, - // CHANGED: Add a flag to have Gitignore rules that apply only to files. + allow_unclosed_class: true, + // CHANGED: Add a flag to have Gitignore rules that apply only to + // files. only_on_files: false, } } @@ -332,18 +364,21 @@ impl GitignoreBuilder { pub fn build(&self) -> Result { let nignore = self.globs.iter().filter(|g| !g.is_whitelist()).count(); let nwhite = self.globs.iter().filter(|g| g.is_whitelist()).count(); - let set = self.builder.build().map_err(|err| Error::Glob { - glob: None, - err: err.to_string(), - })?; + let set = self + .builder + .build() + .map_err(|err| Error::Glob { glob: None, err: err.to_string() })?; Ok(Gitignore { set, root: self.root.clone(), globs: self.globs.clone(), num_ignores: nignore as u64, num_whitelists: nwhite as u64, - matches: Some(Arc::new(Pool::new(|| vec![]))), - // CHANGED: Add a flag to have Gitignore rules that apply only to files. + matches: Some(Arc::new( + Pool::with_available_parallelism_capacity(|| vec![]), + )), + // CHANGED: Add a flag to have Gitignore rules that apply only to + // files. only_on_files: self.only_on_files, }) } @@ -403,6 +438,12 @@ impl GitignoreBuilder { break; } }; + + // Match Git's handling of .gitignore files that begin with the Unicode BOM + const UTF8_BOM: &str = "\u{feff}"; + let line = + if i == 0 { line.trim_start_matches(UTF8_BOM) } else { &line }; + if let Err(err) = self.add_line(Some(path.to_path_buf()), &line) { errs.push(err.tagged(path, lineno)); } @@ -506,6 +547,7 @@ impl GitignoreBuilder { .literal_separator(true) .case_insensitive(self.case_insensitive) .backslash_escape(true) + .allow_unclosed_class(self.allow_unclosed_class) .build() .map_err(|err| Error::Glob { glob: Some(glob.original.clone()), @@ -522,13 +564,36 @@ impl GitignoreBuilder { /// affected. /// /// This is disabled by default. - pub fn case_insensitive(&mut self, yes: bool) -> Result<&mut GitignoreBuilder, Error> { + pub fn case_insensitive( + &mut self, + yes: bool, + ) -> Result<&mut GitignoreBuilder, Error> { // TODO: This should not return a `Result`. Fix this in the next semver // release. self.case_insensitive = yes; Ok(self) } + /// Toggle whether unclosed character classes are allowed. When allowed, + /// a `[` without a matching `]` is treated literally instead of resulting + /// in a parse error. + /// + /// For example, if this is set then the glob `[abc` will be treated as the + /// literal string `[abc` instead of returning an error. + /// + /// By default, this is true in order to match established `gitignore` + /// semantics. Generally speaking, enabling this leads to worse failure + /// modes since the glob parser becomes more permissive. You might want to + /// enable this when compatibility (e.g., with POSIX glob implementations) + /// is more important than good error messages. + pub fn allow_unclosed_class( + &mut self, + yes: bool, + ) -> &mut GitignoreBuilder { + self.allow_unclosed_class = yes; + self + } + /// CHANGED: Add a flag to have Gitignore rules that apply only to files. /// /// If this is set, then the globs will only be matched against file paths. @@ -544,32 +609,56 @@ impl GitignoreBuilder { /// /// Note that the file path returned may not exist. pub fn gitconfig_excludes_path() -> Option { - // git supports $HOME/.gitconfig and $XDG_CONFIG_HOME/git/config. Notably, - // both can be active at the same time, where $HOME/.gitconfig takes - // precedent. So if $HOME/.gitconfig defines a `core.excludesFile`, then - // we're done. - match gitconfig_home_contents().and_then(|x| parse_excludes_file(&x)) { - Some(path) => return Some(path), - None => {} + // When GIT_CONFIG_GLOBAL is set, it replaces both $HOME/.gitconfig and + // $XDG_CONFIG_HOME/git/config (per git 2.32+). Otherwise, git supports + // $HOME/.gitconfig and $XDG_CONFIG_HOME/git/config simultaneously, where + // $HOME/.gitconfig takes precedent. + gitconfig_global_env_contents() + .and_then(|x| parse_excludes_file(&x)) + .or_else(|| { + gitconfig_home_contents().and_then(|x| parse_excludes_file(&x)) + }) + .or_else(|| { + gitconfig_xdg_contents().and_then(|x| parse_excludes_file(&x)) + }) + // System-level config has the lowest priority for core.excludesFile. + // GIT_CONFIG_SYSTEM overrides the default /etc/gitconfig path. + .or_else(|| { + gitconfig_system_contents().and_then(|x| parse_excludes_file(&x)) + }) + .or_else(excludes_file_default) +} + +/// Returns the file contents of git's global config file from the path +/// specified by the `GIT_CONFIG_GLOBAL` environment variable. +fn gitconfig_global_env_contents() -> Option> { + let path = std::env::var_os("GIT_CONFIG_GLOBAL").map(PathBuf::from)?; + if path.as_os_str().is_empty() { + return None; } - match gitconfig_xdg_contents().and_then(|x| parse_excludes_file(&x)) { - Some(path) => return Some(path), - None => {} - } - excludes_file_default() + let mut file = BufReader::new(File::open(path).ok()?); + let mut contents = vec![]; + file.read_to_end(&mut contents).ok().map(|_| contents) +} + +/// Returns the file contents of git's system-level config file. +/// +/// Checks `GIT_CONFIG_SYSTEM` first, then falls back to `/etc/gitconfig`. +fn gitconfig_system_contents() -> Option> { + 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 /// the user's home directory. fn gitconfig_home_contents() -> Option> { - let home = match home_dir() { - None => return None, - Some(home) => home, - }; - let mut file = match File::open(home.join(".gitconfig")) { - Err(_) => return None, - Ok(file) => BufReader::new(file), - }; + let home = home_dir()?; + let mut file = BufReader::new(File::open(home.join(".gitconfig")).ok()?); let mut contents = vec![]; file.read_to_end(&mut contents).ok().map(|_| contents) } @@ -578,19 +667,11 @@ fn gitconfig_home_contents() -> Option> { /// the user's XDG_CONFIG_HOME directory. fn gitconfig_xdg_contents() -> Option> { let path = std::env::var_os("XDG_CONFIG_HOME") - .and_then(|x| { - if x.is_empty() { - None - } else { - Some(PathBuf::from(x)) - } - }) + .map(PathBuf::from) + .filter(|x| !x.as_os_str().is_empty()) .or_else(|| home_dir().map(|p| p.join(".config"))) - .map(|x| x.join("git/config")); - let mut file = match path.and_then(|p| File::open(p).ok()) { - None => return None, - Some(file) => BufReader::new(file), - }; + .map(|x| x.join("git/config"))?; + let mut file = BufReader::new(File::open(path).ok()?); let mut contents = vec![]; file.read_to_end(&mut contents).ok().map(|_| contents) } @@ -600,13 +681,8 @@ fn gitconfig_xdg_contents() -> Option> { /// Specifically, this respects XDG_CONFIG_HOME. fn excludes_file_default() -> Option { std::env::var_os("XDG_CONFIG_HOME") - .and_then(|x| { - if x.is_empty() { - None - } else { - Some(PathBuf::from(x)) - } - }) + .map(PathBuf::from) + .filter(|x| !x.as_os_str().is_empty()) .or_else(|| home_dir().map(|p| p.join(".config"))) .map(|x| x.join("git/ignore")) } @@ -635,9 +711,7 @@ fn parse_excludes_file(data: &[u8]) -> Option { re.captures(data, &mut caps); let span = caps.get_group(1)?; let candidate = &data[span]; - std::str::from_utf8(candidate) - .ok() - .map(|s| PathBuf::from(expand_tilde(s))) + std::str::from_utf8(candidate).ok().map(|s| PathBuf::from(expand_tilde(s))) } /// Expands ~ in file paths to the value of $HOME. @@ -799,7 +873,10 @@ mod tests { fn parse_excludes_file4() { let data = bytes("[core]\nexcludesFile = \"~/foo/bar\""); let got = super::parse_excludes_file(&data); - assert_eq!(path_string(got.unwrap()), super::expand_tilde("~/foo/bar")); + assert_eq!( + path_string(got.unwrap()), + super::expand_tilde("~/foo/bar") + ); } #[test] diff --git a/crates/ignore/src/incremental.rs b/crates/ignore/src/incremental.rs new file mode 100644 index 000000000..5030b0d00 --- /dev/null +++ b/crates/ignore/src/incremental.rs @@ -0,0 +1,1286 @@ +use std::{ + collections::HashMap, + path::{Path, PathBuf}, + sync::OnceLock, +}; + +use crate::{ + Error, Match, PartialErrorBuilder, dir::Ignore, pathutil::is_hidden_path, +}; + +/// A cached matcher for checking paths against hierarchical ignore files. +/// +/// An `IncrementalIgnore` is built from a [`crate::WalkBuilder`]. Unlike a +/// recursive walk, it can check individual paths while still respecting the +/// ignore files in every relevant parent directory. Matchers for directories +/// are compiled on first use and then retained for later queries. +/// Each matcher corresponds to exactly one root configured on the builder, +/// and paths passed to it are interpreted relative to that root. +/// A matcher for the special `-` root representing standard input is inert +/// and always returns a non-match. +/// +/// The matcher checks path-based filters in the same precedence order as +/// a traversal. This includes glob overrides, `.ignore`, `.gitignore`, +/// `.git/info/exclude`, global and explicitly added ignore files, custom +/// ignore file names and file type selections. It does not apply filters that +/// require a directory entry or other traversal state, such as custom entry +/// predicates. Hidden-file detection, minimum and maximum depth limits and the +/// maximum file size are applied. +/// +/// A matcher is a snapshot at directory granularity. Once the ignore files in +/// a directory have been loaded, edits to those files are not observed. Build +/// a new matcher to reload them. +/// +/// # Warning +/// +/// The incremental path checking here necessarily needs to do a lot more work +/// per path matched. Callers should _not_ use this to run directory traversal. +/// This is intended to avoid the work of re-traversing an entire directory +/// tree when only a few changes are detected. (For example, in response to +/// file additions or deletions.) +/// +/// # Example +/// +/// ```rust,no_run +/// use ignore::WalkBuilder; +/// +/// let mut builder = WalkBuilder::new("."); +/// builder.add_custom_ignore_filename(".rgignore"); +/// let mut matchers = builder.build_matchers(); +/// let matcher = &mut matchers[0]; +/// +/// if matcher.matched("src/generated.rs", false).is_ignore() { +/// println!("ignored"); +/// } +/// ``` +#[derive(Clone, Debug)] +pub struct IncrementalIgnore { + /// The root exactly as it was given to `WalkBuilder`. + root: PathBuf, + /// The normalized root used only by the opt-in normalization routine. + normalized_root: OnceLock>, + /// The matcher for the configured root directory, loaded on first use. + ignore: RootIgnore, + /// Directory paths relative to `root`, excluding the root itself. + dirs: HashMap, + /// Options for additional filtering beyond gitignore. + options: IncrementalIgnoreOptions, +} + +/// The options for a matcher, mostly meant to duplicate as much as we can from +/// `WalkParallel`. +#[derive(Clone, Debug)] +pub(crate) struct IncrementalIgnoreOptions { + pub(crate) min_depth: Option, + pub(crate) max_depth: Option, + pub(crate) max_filesize: Option, + pub(crate) hidden: bool, + pub(crate) follow_links: bool, +} + +#[derive(Clone, Debug)] +enum RootIgnore { + Unloaded(Ignore), + Loaded(Ignore), + NotDirectory, + Stdin, +} + +/// Cached traversal state for a directory relative to the configured root. +/// +/// The presence of an entry means that the directory and every ancestor +/// between it and the root have already been checked. +#[derive(Clone, Debug)] +enum CachedDir { + /// The directory may be descended into. The matcher includes the ignore + /// rules loaded through this directory and is therefore the matcher to use + /// for its children. + Allowed(Ignore), + /// The directory may not be descended into because it was ignored by a + /// path rule or hidden-file filtering. Every descendant is consequently + /// ignored, and ignore files inside this directory are not loaded. + Ignored, +} + +impl IncrementalIgnore { + pub(crate) fn new( + root: PathBuf, + ignore: Ignore, + options: IncrementalIgnoreOptions, + ) -> IncrementalIgnore { + // File traversal special cases `-` to search stdin, so we recognize + // it here for completeness too. In particular, we really want + // `WalkBuilder::build_matchers` to return a matcher for every root, + // even when it's a simple file (handled automatically) or when it's + // stdin (necessarily special cased). + // + // If callers need to search a file or directory named `-`, then they + // can use `./-`. As is the case for file traversal too. + let ignore = if root == Path::new("-") { + RootIgnore::Stdin + } else { + RootIgnore::Unloaded(ignore) + }; + IncrementalIgnore { + root, + normalized_root: OnceLock::new(), + ignore, + dirs: HashMap::new(), + options, + } + } + + /// Return the root that paths matched by this matcher are relative to. + pub fn root(&self) -> &Path { + &self.root + } + + /// Normalize `path` and return it relative to this matcher's root. + /// + /// This returns `None` when `path` cannot be made absolute or + /// when it is known to be outside this matcher's root. Unlike + /// [`IncrementalIgnore::matched`], this performs absolute path conversion, + /// lexical normalization and allocation. It is intended as an opt-in + /// convenience for callers that do not already have root-relative paths. + /// + /// Note that `.` is interpreted relative to the process level current + /// working directory. It is _not_ interpreted relative to the root of + /// this matcher. + /// + /// Note also that this may reject paths that only differ in casing. For + /// example, if the root path for this matcher is `/FOO` but the provided + /// path is `/foo/bar`, then this may return `None`. Callers must ensure + /// casing is consistent between the path provided and the root path for + /// this matcher. + pub fn normalize>(&self, path: P) -> Option { + if matches!(self.ignore, RootIgnore::Stdin) { + return None; + } + let path = normalize_absolute(path.as_ref())?; + let root = self + .normalized_root + .get_or_init(|| normalize_absolute(&self.root)) + .as_ref()?; + path.strip_prefix(root).ok().map(Path::to_path_buf) + } + + /// Match a root-relative path against ignore files in its directory and + /// all relevant parent directories. + /// + /// `is_dir` should be true when `path` should be matched as a directory. + /// + /// For the return value, use [`IncrementalMatch::is_ignore`], + /// [`IncrementalMatch::is_whitelist`] or [`IncrementalMatch::is_none`] to + /// inspect it. + /// + /// Matchers for previously unseen directories are loaded and cached during + /// this call. Errors encountered while loading ignore files are logged. To + /// receive those errors, use [`IncrementalIgnore::matched_with_errors`]. + /// + /// `path` must be relative to this matcher's root and must not contain a + /// parent directory (`..`) component. Behavior is unspecified when these + /// preconditions are violated. Callers with an absolute path or with a + /// path containing `.` or `..` may use [`IncrementalIgnore::normalize`] + /// to get a path satisfying these preconditions. In all cases, a relative + /// path is *assumed* to be relative to the root of this ignore matcher. + /// + /// In general, it is intended that callers doing recursive directory + /// traversal on the root of this matcher may provide relative paths to + /// this routine *without* calling [`IncrementalIgnore::normalize`]. + /// + /// The empty path represents the explicitly configured root and also + /// returns non-match, consistent with recursive traversal where a root is + /// always treated as being at depth zero. + pub fn matched>( + &mut self, + path: P, + is_dir: bool, + ) -> IncrementalMatch { + let (matched, err) = self.matched_with_errors(path, is_dir); + if let Some(err) = err { + log::debug!("error while loading ignore files: {err}"); + } + matched + } + + /// Match a root-relative path and return errors encountered while loading + /// ignore files. + /// + /// This is equivalent to [`IncrementalIgnore::matched`], except that + /// it returns any errors from newly loaded ignore files. Loading can + /// partially succeed, so valid rules are always applied to the returned + /// match even when an error is present. + pub fn matched_with_errors>( + &mut self, + path: P, + is_dir: bool, + ) -> (IncrementalMatch, Option) { + let mut errs = PartialErrorBuilder::default(); + let matched = + self.matched_with_errors_impl(path.as_ref(), is_dir, &mut errs); + (matched, errs.into_error_option()) + } + + fn matched_with_errors_impl( + &mut self, + relative: &Path, + is_dir: bool, + errs: &mut PartialErrorBuilder, + ) -> IncrementalMatch { + // We short-circuit here when our matcher corresponds to `Stdin` in + // order to always return a non-match. This is somewhat redundant with + // `root_ignore()` which does this too, but we do it here so that it + // always happens, e.g., before depth filtering. + if relative.is_absolute() || matches!(self.ignore, RootIgnore::Stdin) { + return IncrementalMatch::none(is_dir); + } + + let (mut satisfies_min, mut edge_max) = (true, false); + if self.options.min_depth.is_some() || self.options.max_depth.is_some() + { + let depth = relative + .components() + .filter_map(|component| match component { + std::path::Component::CurDir => None, + component => Some(component), + }) + .count(); + satisfies_min = + self.options.min_depth.is_none_or(|min| depth >= min); + let satisfies_max = + self.options.max_depth.is_none_or(|max| depth <= max); + edge_max = self.options.max_depth.is_some_and(|max| depth == max); + // When we have a file that isn't past our min depth, we can give + // up right away. + if !is_dir && !satisfies_min { + return IncrementalMatch::ignore().not_within_depth(); + } + // Same for *anything* that exceeds our max depth. + if !satisfies_max { + return IncrementalMatch::ignore().not_within_depth(); + } + } + + // When the path is invalid in some way, we bail out early to avoid + // potentially doing a stat call below. + let (mut mat, valid) = self + .matched_with_errors_ignore(relative, is_dir, errs) + .map(|mat| (mat, true)) + .unwrap_or_else(|| (IncrementalMatch::none(is_dir), false)); + + if !is_dir + && !mat.is_ignore() + && valid + && let Some(max_filesize) = self.options.max_filesize + { + let path = self.root().join(relative); + let result = if self.options.follow_links { + path.metadata() + } else { + path.symlink_metadata() + }; + match result { + Ok(md) if md.len() > max_filesize => { + return IncrementalMatch::ignore(); + } + Ok(_) => {} + Err(err) => { + // Record the error but otherwise fall through + let err = Error::from(err); + errs.push(err.with_path(path)); + } + } + } + + // We still need to tag our `mat` if it doesn't pass the depth filter, + // or if it's a directory on the edge of a depth filter. This can only + // happen when the path is reported as a directory. Otherwise regular + // files are always handled above. + if is_dir { + if !satisfies_min { + mat = mat.not_within_depth(); + } + if edge_max { + mat = mat.no_descent(); + } + } + mat + } + + fn matched_with_errors_ignore( + &mut self, + relative: &Path, + is_dir: bool, + errs: &mut PartialErrorBuilder, + ) -> Option { + let mut components = relative + .components() + .filter_map(|component| match component { + std::path::Component::CurDir => None, + component => Some(component), + }) + .peekable(); + components.peek()?; + + // If the exact parent is cached, then all of its ancestors have + // already been checked. An allowed cache entry is the matcher to use + // for children of that directory, while an ignored cache entry is + // terminal for every descendant. In the usual case, this avoids both + // walking every component and allocating a relative directory path. + // + // Only try the fast path when the final component is a normal path + // component. Invalid paths are handled by the component walk below. + let has_normal_final_component = + relative.components().next_back().is_some_and(|component| { + matches!(component, std::path::Component::Normal(_)) + }); + if has_normal_final_component && let Some(parent) = relative.parent() { + match self.dirs.get(parent) { + Some(CachedDir::Allowed(ignore)) => { + return Some(self.match_path(ignore, relative, is_dir)); + } + Some(CachedDir::Ignored) => { + return Some(IncrementalMatch::ignore()); + } + None => {} + } + } + + let mut ignore = self.root_ignore(errs)?; + let mut dir = PathBuf::new(); + while let Some(component) = components.next() { + match component { + std::path::Component::ParentDir + | std::path::Component::RootDir + | std::path::Component::Prefix(_) => { + return None; + } + std::path::Component::CurDir => continue, + std::path::Component::Normal(_) => {} + } + if components.peek().is_none() { + break; + } + dir.push(component.as_os_str()); + match self.dirs.get(&dir) { + Some(CachedDir::Allowed(cached)) => { + ignore = cached.clone(); + continue; + } + Some(CachedDir::Ignored) => { + return Some(IncrementalMatch::ignore()); + } + None => {} + } + + let path = self.root.join(&dir); + let mat = ignore.matched(&path, true); + let is_hidden = + self.options.hidden && mat.is_none() && is_hidden_path(&path); + if mat.is_ignore() || is_hidden { + self.dirs.insert(dir.clone(), CachedDir::Ignored); + return Some(IncrementalMatch::ignore()); + } + let (child, err) = ignore.add_child(&path); + errs.maybe_push(err); + self.dirs.insert(dir.clone(), CachedDir::Allowed(child.clone())); + ignore = child; + } + + Some(self.match_path(&ignore, relative, is_dir)) + } + + fn match_path( + &self, + ignore: &Ignore, + relative: &Path, + is_dir: bool, + ) -> IncrementalMatch { + let path = self.root.join(relative); + let mut mat = IncrementalMatch::from_match( + ignore.matched(&path, is_dir).map(|_| ()), + is_dir, + ); + // Whether a file is hidden or not has low precedence in filtering. We + // only check it if we haven't matched anything above. This permits + // callers to whitelist hidden files or directories. + if self.options.hidden && mat.is_none() && is_hidden_path(&path) { + mat = IncrementalMatch::ignore(); + } + mat + } + + fn root_ignore( + &mut self, + errs: &mut PartialErrorBuilder, + ) -> Option { + let ignore = match self.ignore { + RootIgnore::Unloaded(ref ignore) => ignore.clone(), + RootIgnore::Loaded(ref ignore) => return Some(ignore.clone()), + RootIgnore::NotDirectory | RootIgnore::Stdin => return None, + }; + if !self.root.is_dir() { + self.ignore = RootIgnore::NotDirectory; + return None; + } + + let (parents, err) = ignore.add_parents(&self.root); + errs.maybe_push(err); + let (root, err) = parents.add_child(&self.root); + errs.maybe_push(err); + self.ignore = RootIgnore::Loaded(root.clone()); + Some(root) + } +} + +/// The result of an incremental match. +/// +/// This is similar to [`Match`] in that it reports whether a file path should +/// be ignored, whitelisted or didn't match anything at all. It also has extra +/// data, such as whether a directory matched but should not be descended into. +/// +/// Generally speaking, callers that only care about specific files can stick +/// to the `is_none()`, `is_ignore()` or `is_whitelist()` predicates. Directory +/// entries are more complicated because we sometimes want to yield directories +/// to descend into, but not actually visit (e.g., for the minimum depth +/// filter). Or, we may want to yield a directory to visit but not descend into +/// (e.g., for the maximum depth filter). +#[derive(Clone, Debug)] +pub struct IncrementalMatch { + mat: Match<()>, + should_descend: bool, + is_within_depth: bool, +} + +impl IncrementalMatch { + fn none(is_dir: bool) -> IncrementalMatch { + IncrementalMatch { + mat: Match::None, + should_descend: is_dir, + is_within_depth: true, + } + } + + fn ignore() -> IncrementalMatch { + IncrementalMatch { + mat: Match::Ignore(()), + should_descend: false, + is_within_depth: true, + } + } + + fn from_match(mat: Match<()>, is_dir: bool) -> IncrementalMatch { + let should_descend = is_dir && !mat.is_ignore(); + IncrementalMatch { mat, should_descend, is_within_depth: true } + } + + fn no_descent(self) -> IncrementalMatch { + IncrementalMatch { should_descend: false, ..self } + } + + fn not_within_depth(self) -> IncrementalMatch { + IncrementalMatch { is_within_depth: false, ..self } + } + + /// Returns true if the match result didn't match anything. + pub fn is_none(&self) -> bool { + self.mat.is_none() + } + + /// Returns true if the match result implies the path should be ignored. + pub fn is_ignore(&self) -> bool { + self.mat.is_ignore() + } + + /// Returns true if the match result implies the path should be + /// whitelisted. + pub fn is_whitelist(&self) -> bool { + self.mat.is_whitelist() + } + + /// Returns true only when this match corresponds to a directory *and* + /// whether the caller should look inside this directory for additional + /// results. + /// + /// It is possible for a match to report `false` for + /// [`IncrementalMatch::is_ignore`] _and_ `false` for + /// [`IncrementalMatch::should_descend`]. This can occur, for example, when + /// a maximum depth setting allows a directory through, but where none of + /// its children entries should be visited. + /// + /// This is always `false` for a file path that does *not* correspond to a + /// directory. + pub fn should_descend(&self) -> bool { + self.should_descend + } + + /// Returns true only when this result corresponds to an entry that is + /// within the depth filter. + /// + /// This is `false` when a path corresponds to a directory and is less than + /// the minimum depth. In this case, callers should continue looking inside + /// that directory. + /// + /// This is always `true` for a match corresponding to a file path that + /// isn't a directory. + pub fn is_within_depth(&self) -> bool { + self.is_within_depth + } + + /// Inverts the match so that `Ignore` becomes `Whitelist` and + /// `Whitelist` becomes `Ignore`. A non-match remains the same. + pub fn invert(self) -> IncrementalMatch { + IncrementalMatch { mat: self.mat.invert(), ..self } + } +} + +/// Return a lexically normalized absolute representation of `path`. +/// +/// This collapses `.` and `..`, but intentionally does not canonicalize or +/// resolve symlinks. Ignore rules apply to the lexical path, and resolving a +/// symlink below a root could move the result outside of that root. +fn normalize_absolute(path: &Path) -> Option { + let absolute = std::path::absolute(path).ok()?; + let mut normalized = PathBuf::new(); + for component in absolute.components() { + match component { + std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + normalized.pop(); + } + _ => normalized.push(component.as_os_str()), + } + } + Some(normalized) +} + +#[cfg(test)] +mod tests { + use std::{ + fs::{self, File}, + io::Write, + path::{Path, PathBuf}, + }; + + use crate::{ + IncrementalIgnore, IncrementalMatch, WalkBuilder, + overrides::OverrideBuilder, tests::TempDir, types::TypesBuilder, + }; + + use super::CachedDir; + + fn wfile>(path: P, contents: &str) { + let mut file = File::create(path).unwrap(); + file.write_all(contents.as_bytes()).unwrap(); + } + + fn mkdirp>(path: P) { + fs::create_dir_all(path).unwrap(); + } + + fn tmpdir() -> TempDir { + TempDir::new().unwrap() + } + + fn builder>(path: P) -> WalkBuilder { + let mut builder = WalkBuilder::new(path); + builder.git_global(false); + builder + } + + fn one_matcher(builder: &WalkBuilder) -> IncrementalIgnore { + let mut matchers = builder.build_matchers(); + assert_eq!(matchers.len(), 1); + matchers.pop().unwrap() + } + + fn builders, P: AsRef>( + paths: I, + ) -> WalkBuilder { + let mut builder = WalkBuilder::from_iter(paths); + builder.git_global(false); + builder + } + + fn matchers(builder: &WalkBuilder) -> Vec { + builder.build_matchers() + } + + fn matchedf>( + matcher: &mut IncrementalIgnore, + path: P, + ) -> IncrementalMatch { + let (matched, err) = matcher.matched_with_errors(path, false); + assert!(err.is_none(), "unexpected matcher error: {err:?}"); + matched + } + + fn matchedd>( + matcher: &mut IncrementalIgnore, + path: P, + ) -> IncrementalMatch { + let (matched, err) = matcher.matched_with_errors(path, true); + assert!(err.is_none(), "unexpected matcher error: {err:?}"); + matched + } + + // Test that multiple parent ignore files, when nested, are respected. + #[test] + fn nested_parent_gitignores() { + let td = tmpdir(); + let root = td.path().join("project/work"); + mkdirp(td.path().join(".git")); + mkdirp(root.join("src")); + wfile(td.path().join(".gitignore"), "*.tmp\n"); + wfile(td.path().join("project/.gitignore"), "!keep.tmp\nnested.log\n"); + + let mut m = one_matcher(&builder(&root)); + assert_eq!(m.root(), root); + assert!(matchedf(&mut m, "src/drop.tmp").is_ignore()); + assert!(matchedf(&mut m, "src/keep.tmp").is_whitelist()); + assert!(matchedf(&mut m, "src/nested.log").is_ignore()); + assert!(matchedf(&mut m, "src/ok.rs").is_none()); + } + + // Test that an anchored rule in a child directory is matched relative to + // that directory, not relative to the configured root. + #[test] + fn anchored_child_rule_uses_child_root() { + let td = tmpdir(); + let root = td.path().join("root"); + mkdirp(root.join("a")); + wfile(root.join("a/.ignore"), "/foo\n"); + + let mut m = one_matcher(&builder(&root)); + assert!(matchedf(&mut m, "a/foo").is_ignore()); + assert!(matchedf(&mut m, "a/b/foo").is_none()); + } + + // Test that a leading `./` impacts how the rules are matched. + #[cfg(not(windows))] + #[test] + fn leading_dot_slash_impacts_matching() { + let td = tmpdir(); + let root = td.path().join("root"); + mkdirp(root.join("a")); + wfile(root.join(".ignore"), "/foo\n"); + wfile(root.join("a/.ignore"), "/foo\n"); + + let mut m = one_matcher(&builder(&root)); + assert!(matchedf(&mut m, "foo").is_ignore()); + assert!(matchedf(&mut m, "./foo").is_none()); + assert!(matchedf(&mut m, "a/allowed").is_none()); + assert!(matchedf(&mut m, "a/foo").is_ignore()); + assert!(matchedf(&mut m, "./a/foo").is_none()); + } + + // Test that custom ignore files are respected. + #[test] + fn parent_ignore_and_custom_ignore() { + let td = tmpdir(); + let root = td.path().join("project/work"); + mkdirp(root.join("src")); + wfile(td.path().join(".ignore"), "*.cache\n"); + wfile(td.path().join(".rgignore"), "*.svg\n"); + wfile(td.path().join("project/.ignore"), "!keep.cache\n"); + wfile(td.path().join("project/.rgignore"), "!keep.svg\n"); + + let mut builder = builder(&root); + builder.add_custom_ignore_filename(".rgignore"); + let mut m = one_matcher(&builder); + assert!(matchedf(&mut m, "src/drop.cache").is_ignore()); + assert!(matchedf(&mut m, "src/keep.cache").is_whitelist()); + assert!(matchedf(&mut m, "src/drop.svg").is_ignore()); + assert!(matchedf(&mut m, "src/keep.svg").is_whitelist()); + } + + // Test that glob overrides take precedence over ignore files. + #[test] + fn glob_overrides_are_applied() { + let td = tmpdir(); + wfile(td.path().join(".ignore"), "keep.rs\n!drop.rs\n"); + + let mut overrides = OverrideBuilder::new(td.path()); + overrides.add("keep.rs").unwrap(); + overrides.add("!drop.rs").unwrap(); + let mut b = builder(td.path()); + b.overrides(overrides.build().unwrap()); + let mut m = one_matcher(&b); + + assert!(matchedf(&mut m, "keep.rs").is_whitelist()); + assert!(matchedf(&mut m, "drop.rs").is_ignore()); + } + + // Test that an override-ignored directory prevents matching rules below + // it, just as it prevents a traversal from descending into the directory. + #[test] + fn glob_overrides_apply_to_ancestors() { + let td = tmpdir(); + mkdirp(td.path().join("blocked")); + wfile(td.path().join("blocked/.ignore"), "!keep.rs\n"); + + let mut overrides = OverrideBuilder::new(td.path()); + overrides.add("!blocked/").unwrap(); + let mut b = builder(td.path()); + b.overrides(overrides.build().unwrap()); + let mut m = one_matcher(&b); + + assert!(matchedf(&mut m, "blocked/keep.rs").is_ignore()); + } + + // Test that file type selections are applied to files, but not + // directories. + #[test] + fn file_types_are_applied() { + let td = tmpdir(); + mkdirp(td.path().join("src")); + + let mut types = TypesBuilder::new(); + types.add("rust", "*.rs").unwrap(); + types.select("rust"); + let mut b = builder(td.path()); + b.types(types.build().unwrap()); + let mut m = one_matcher(&b); + + assert!(matchedd(&mut m, "src").is_none()); + assert!(matchedf(&mut m, "src/lib.rs").is_whitelist()); + assert!(matchedf(&mut m, "README.md").is_ignore()); + } + + #[test] + fn directly_ignored_directory_is_not_descended() { + let td = tmpdir(); + mkdirp(td.path().join(".git")); + mkdirp(td.path().join("blocked")); + wfile(td.path().join(".gitignore"), "blocked/\n"); + + let mut m = one_matcher(&builder(td.path())); + let dir = matchedd(&mut m, "blocked"); + assert!(dir.is_ignore()); + assert!(!dir.should_descend()); + } + + // Test that when a directory is ignored, anything below it always ignored + // even when there are explicit whitelist rules. This matches directory + // traversal semantics. + #[test] + fn ignored_ancestor_wins() { + let td = tmpdir(); + mkdirp(td.path().join(".git")); + mkdirp(td.path().join("blocked")); + mkdirp(td.path().join("open")); + // This is the key line: since the entire `blocked` directory is + // ignored, a proper file traversal won't ever descend into it. So + // `blocked/keep.rs` should be ignored even if there are ignore rules + // "beneath" it that whitelist it. + wfile(td.path().join(".gitignore"), "blocked/\n!blocked/keep.rs\n"); + wfile(td.path().join("blocked/.gitignore"), "!keep.rs\n"); + + let mut m = one_matcher(&builder(td.path())); + assert!(matchedf(&mut m, "blocked/keep.rs").is_ignore()); + assert!(matchedf(&mut m, "blocked/other.rs").is_ignore()); + assert!(matchedf(&mut m, "open/keep.rs").is_none()); + } + + // Test that we respect git boundaries. And that we don't respect git + // boundaries when not configured to do so. + #[test] + fn respects_git_repository_boundaries() { + let td = tmpdir(); + let root = td.path().join("repo/src"); + mkdirp(td.path().join("repo/.git")); + mkdirp(&root); + wfile(td.path().join(".gitignore"), "outside-rule\n"); + wfile(td.path().join(".ignore"), "tool-rule\n"); + wfile(td.path().join("repo/.gitignore"), "inside-rule\n"); + + let mut m = one_matcher(&builder(&root)); + assert!(matchedf(&mut m, "inside-rule").is_ignore()); + assert!(matchedf(&mut m, "outside-rule").is_none()); + assert!(matchedf(&mut m, "tool-rule").is_ignore()); + + let mut no_git_required = builder(&root); + no_git_required.require_git(false); + let mut m = one_matcher(&no_git_required); + assert!(matchedf(&mut m, "outside-rule").is_ignore()); + } + + // Test that when a gitignore matcher in a parent directory is created, we + // reuse that matcher from memory even if it's changed on disk. + #[test] + fn compiled_matchers_are_reused() { + let td = tmpdir(); + mkdirp(td.path().join("a")); + wfile(td.path().join("a/.ignore"), "*.tmp\n"); + + let mut m = one_matcher(&builder(td.path())); + assert!(matchedf(&mut m, "a/first.tmp").is_ignore()); + + // Below demonstrates that this new ignore file contents + // aren't actually picked up because it was already loaded. + wfile(td.path().join("a/.ignore"), "!*.tmp\n*.rs\n"); + assert!(matchedf(&mut m, "a/first.tmp").is_ignore()); + assert!(matchedf(&mut m, "a/second.tmp").is_ignore()); + assert!(matchedf(&mut m, "a/keep.rs").is_none()); + // To get the new ignore file, we need to rebuild the matcher. + let mut m = one_matcher(&builder(td.path())); + assert!(matchedf(&mut m, "a/first.tmp").is_whitelist()); + assert!(matchedf(&mut m, "a/second.tmp").is_whitelist()); + assert!(matchedf(&mut m, "a/keep.rs").is_ignore()); + } + + #[test] + fn cached_allowed_parent_matches_children() { + let td = tmpdir(); + mkdirp(td.path().join("a/b")); + wfile(td.path().join("a/b/.ignore"), "ignored\n"); + + let mut m = one_matcher(&builder(td.path())); + assert!(matchedf(&mut m, "a/b/allowed").is_none()); + assert!(matches!( + m.dirs.get(Path::new("a/b")), + Some(CachedDir::Allowed(_)) + )); + assert!(matchedf(&mut m, "a/b/ignored").is_ignore()); + } + + #[test] + fn cached_ignored_parent_matches_children() { + let td = tmpdir(); + mkdirp(td.path().join("blocked")); + wfile(td.path().join(".ignore"), "blocked/\n"); + + let mut m = one_matcher(&builder(td.path())); + assert!(matchedf(&mut m, "blocked/first").is_ignore()); + assert!(matches!( + m.dirs.get(Path::new("blocked")), + Some(CachedDir::Ignored) + )); + assert!(matchedf(&mut m, "blocked/second").is_ignore()); + } + + #[test] + fn cached_parent_matcher_is_used_for_directory() { + let td = tmpdir(); + mkdirp(td.path().join("a/b")); + wfile(td.path().join("a/b/.ignore"), "**\n"); + + let mut m = one_matcher(&builder(td.path())); + assert!(matchedf(&mut m, "a/b/file").is_ignore()); + assert!(matches!( + m.dirs.get(Path::new("a/b")), + Some(CachedDir::Allowed(_)) + )); + let dir = matchedd(&mut m, "a/b"); + assert!(dir.is_none()); + assert!(dir.should_descend()); + } + + // Like `compiled_matchers_are_reused`, but with multiple roots. + #[test] + fn compiled_multi_matchers_are_reused() { + let td = tmpdir(); + mkdirp(td.path().join("a/b/c")); + + wfile(td.path().join("a/.ignore"), "*.tmp\n"); + let mut mats = matchers(&builders([ + td.path().join("a/b"), + td.path().join("a/b/c"), + ])); + assert!(matchedf(&mut mats[0], "first.tmp").is_ignore()); + assert!(matchedf(&mut mats[0], "c/first.tmp").is_ignore()); + + // Even though we haven't used the second matcher, it should still + // reuse the `a/.ignore` above. If it didn't, then `a/b/c/first.tmp` + // below would be whitelisted. + wfile(td.path().join("a/.ignore"), "!*.tmp\n"); + assert!(matchedf(&mut mats[1], "first.tmp").is_ignore()); + } + + #[test] + fn compiled_multi_child_matchers_are_not_reused() { + let td = tmpdir(); + mkdirp(td.path().join("a/b/c/d/e")); + + wfile(td.path().join("a/b/c/d/e/.ignore"), "*.tmp\n"); + let mut mats = matchers(&builders([ + td.path().join("a/b"), + td.path().join("a/b/c"), + ])); + // Some sanity checking first. + assert!(matchedf(&mut mats[0], "c/first.tmp").is_none()); + assert!(matchedf(&mut mats[0], "c/d/first.tmp").is_none()); + assert!(matchedf(&mut mats[0], "c/d/e/first.tmp").is_ignore()); + + // Now write a new ignore file at the same location as above + // and check that the other matcher still uses the "stale" data. + wfile(td.path().join("a/b/c/d/e/.ignore"), "!*.tmp\n"); + assert!(matchedf(&mut mats[1], "first.tmp").is_none()); + assert!(matchedf(&mut mats[1], "d/first.tmp").is_none()); + // This is the punch line: because we didn't load `mats[1]` before + // changing the ignore file, it loads it here and thus this path gets + // whitelisted. + assert!(matchedf(&mut mats[1], "d/e/first.tmp").is_whitelist()); + // ... but `mats[0]` still uses the stale gitignore matcher cached in + // memory! + assert!(matchedf(&mut mats[0], "c/d/e/first.tmp").is_ignore()); + + // If we rebuilder the matcher... then we force reloading and they're + // now consistent with one another. + let mut mats = matchers(&builders([ + td.path().join("a/b"), + td.path().join("a/b/c"), + ])); + assert!(matchedf(&mut mats[0], "c/d/e/first.tmp").is_whitelist()); + assert!(matchedf(&mut mats[1], "d/e/first.tmp").is_whitelist()); + } + + // Tests that even when there is an error with a glob pattern, we still + // respect other glob patterns that are valid. + #[test] + fn partial_errors_keep_valid_rules() { + let td = tmpdir(); + let root = td.path().join("work"); + mkdirp(&root); + wfile(td.path().join(".ignore"), "{bad\n*.tmp\n"); + + let mut builder = builder(&root); + builder.git_ignore(false).git_exclude(false); + let mut m = one_matcher(&builder); + let (matched, err) = m.matched_with_errors("drop.tmp", false); + assert!(err.is_some()); + assert!(matched.is_ignore()); + } + + // Tests that parent rules are not respected if the matcher is configured + // not to do so. + #[test] + fn parent_loading_can_be_disabled() { + let td = tmpdir(); + let root = td.path().join("work"); + mkdirp(&root); + wfile(td.path().join(".ignore"), "parent-rule\n"); + wfile(root.join(".ignore"), "root-rule\n"); + + let mut b = builder(&root); + b.parents(false); + let mut m = one_matcher(&b); + assert!(matchedf(&mut m, "parent-rule").is_none()); + assert!(matchedf(&mut m, "root-rule").is_ignore()); + + // Sanity check that without `parents(false)`, the parent rule is + // respected. + let mut m = one_matcher(&builder(&root)); + assert!(matchedf(&mut m, "parent-rule").is_ignore()); + assert!(matchedf(&mut m, "root-rule").is_ignore()); + } + + // Tests that we can normalize a file path that isn't already in "normal" + // relative form, and then use that to match on ignore files. + #[test] + fn paths_are_relative_to_the_root() { + let td = tmpdir(); + let root = td.path().join("root"); + let outside = td.path().join("outside"); + mkdirp(&root); + mkdirp(&outside); + wfile(root.join(".ignore"), "file\n"); + + let mut m = one_matcher(&builder(&root)); + assert!(matchedd(&mut m, "").is_none()); + assert!(matchedd(&mut m, ".").is_none()); + assert!(matchedf(&mut m, "file").is_ignore()); + // Doesn't work because it isn't relative to the root. It's absolute. + assert!(matchedf(&mut m, root.join("file")).is_none()); + // Also doesn't work because while it's relative, it contains `..`. + assert!(matchedf(&mut m, "dir/../file").is_none()); + + let norm = m.normalize(root.join("dir/../file")).unwrap(); + assert_eq!(norm, Path::new("file")); + assert!(matchedf(&mut m, "file").is_ignore()); + + // Doesn't normalize because its an absolute path outside of our root. + assert_eq!(m.normalize(outside.join("file")), None); + } + + // Tests that two matchers in two different directories correctly interpret + // the same parent gitignore file. + #[test] + fn multiple_roots_keep_their_own_context() { + let td = tmpdir(); + let root_a = td.path().join("a"); + let root_b = td.path().join("b"); + mkdirp(td.path().join(".git")); + mkdirp(&root_a); + mkdirp(&root_b); + wfile(td.path().join(".gitignore"), "/a/*.tmp\n/b/*.log\n"); + + let mut builder = + builders([root_a.as_path(), Path::new("-"), root_b.as_path()]); + builder.git_global(false); + let mut ms = matchers(&builder); + assert_eq!(ms.len(), 3); + assert_eq!(ms[0].root(), root_a); + assert_eq!(ms[1].root(), Path::new("-")); + assert_eq!(ms[2].root(), root_b); + assert!(matchedf(&mut ms[0], "drop.tmp").is_ignore()); + assert!(matchedf(&mut ms[0], "keep.log").is_none()); + assert!(matchedf(&mut ms[1], "anything").is_none()); + assert_eq!(ms[1].normalize("anything"), None); + assert!(matchedf(&mut ms[2], "keep.tmp").is_none()); + assert!(matchedf(&mut ms[2], "drop.log").is_ignore()); + } + + // Tests that only the exact `-` root represents standard input. + #[test] + fn dot_dash_root_is_not_stdin() { + let m = one_matcher(&builder("./-")); + assert_eq!(m.root(), Path::new("./-")); + assert_eq!(m.normalize("./-/file"), Some(PathBuf::from("file"))); + } + + #[test] + fn stdin_is_inert_with_depth_limits() { + let mut b = builder("-"); + b.min_depth(Some(2)); + let mut m = one_matcher(&b); + let mat = matchedf(&mut m, "file"); + assert!(mat.is_none()); + assert!(mat.is_within_depth()); + + let mut b = builder("-"); + b.max_depth(Some(0)); + let mut m = one_matcher(&b); + let mat = matchedd(&mut m, "dir"); + assert!(mat.is_none()); + assert!(mat.is_within_depth()); + assert!(mat.should_descend()); + } + + // Tests that ignore matching works lexically, and doesn't accidentally + // resolve symbolic links. + #[cfg(unix)] + #[test] + fn symlink_path_stays_under_lexical_root() { + use std::os::unix::fs::symlink; + + let td = tmpdir(); + let root = td.path().join("root"); + let outside = td.path().join("outside"); + mkdirp(&root); + mkdirp(&outside); + wfile(root.join(".ignore"), "link\n"); + wfile(outside.join("target"), ""); + symlink(outside.join("target"), root.join("link")).unwrap(); + + let mut m = one_matcher(&builder(&root)); + assert!(matchedf(&mut m, "link").is_ignore()); + } + + #[test] + fn depth_limits() { + let td = tmpdir(); + mkdirp(td.path().join("a/b/c/d")); + + let mut b = builder(td.path()); + b.min_depth(Some(2)).max_depth(Some(3)); + let mut m = one_matcher(&b); + + let dir = matchedd(&mut m, "a"); + assert!(!dir.is_within_depth()); + assert!(dir.should_descend()); + assert!(matchedf(&mut m, "file").is_ignore()); + + let dir = matchedd(&mut m, "a/b"); + assert!(dir.is_within_depth()); + assert!(dir.should_descend()); + assert!(!matchedf(&mut m, "a/file").is_ignore()); + + let dir = matchedd(&mut m, "a/b/c"); + assert!(dir.is_within_depth()); + assert!(!dir.should_descend()); + assert!(!matchedf(&mut m, "a/b/file").is_ignore()); + assert!(matchedf(&mut m, "a/b/c/file").is_ignore()); + + let dir = matchedd(&mut m, "a/b/c/d"); + assert!(dir.is_ignore()); + assert!(!dir.is_within_depth()); + assert!(!dir.should_descend()); + assert!(matchedf(&mut m, "a/b/c/d/file").is_ignore()); + } + + #[test] + fn depth_limits_apply_to_root() { + let td = tmpdir(); + + let mut b = builder(td.path()); + b.min_depth(Some(1)); + let mut m = one_matcher(&b); + for path in ["", "."] { + let root = matchedd(&mut m, path); + assert!(root.is_none()); + assert!(!root.is_within_depth()); + assert!(root.should_descend()); + } + + let mut b = builder(td.path()); + b.max_depth(Some(0)); + let mut m = one_matcher(&b); + for path in ["", "."] { + let root = matchedd(&mut m, path); + assert!(root.is_none()); + assert!(root.is_within_depth()); + assert!(!root.should_descend()); + } + } + + #[test] + fn max_filesize() { + let td = tmpdir(); + mkdirp(td.path().join("dir")); + wfile(td.path().join("empty"), ""); + wfile(td.path().join("at-limit"), "12345"); + wfile(td.path().join("over-limit"), "123456"); + + let mut b = builder(td.path()); + b.max_filesize(Some(5)); + let mut m = one_matcher(&b); + + assert!(matchedf(&mut m, "empty").is_none()); + assert!(matchedf(&mut m, "at-limit").is_none()); + assert!(matchedf(&mut m, "over-limit").is_ignore()); + + let dir = matchedd(&mut m, "dir"); + assert!(dir.is_none()); + assert!(dir.should_descend()); + } + + #[test] + fn max_filesize_does_not_stat_ignored_file() { + let td = tmpdir(); + wfile(td.path().join(".ignore"), "ignored\n"); + + let mut b = builder(td.path()); + b.max_filesize(Some(0)); + let mut m = one_matcher(&b); + let (mat, err) = m.matched_with_errors("ignored", false); + assert!(mat.is_ignore()); + assert!(err.is_none(), "ignored missing file was statted: {err:?}"); + } + + #[cfg(unix)] + #[test] + fn max_filesize_respects_follow_links() { + use std::os::unix::fs::symlink; + + let td = tmpdir(); + wfile( + td.path().join("target"), + "target contents are much longer than the size limit", + ); + symlink("target", td.path().join("link")).unwrap(); + + let mut b = builder(td.path()); + b.max_filesize(Some(10)); + let mut m = one_matcher(&b); + assert!(matchedf(&mut m, "link").is_none()); + + b.follow_links(true); + let mut m = one_matcher(&b); + assert!(matchedf(&mut m, "link").is_ignore()); + } + + #[test] + fn hidden_files_and_directories() { + let td = tmpdir(); + mkdirp(td.path().join(".hidden-dir")); + mkdirp(td.path().join("visible-dir")); + wfile(td.path().join(".hidden-file"), ""); + wfile(td.path().join("visible-file"), ""); + + let mut m = one_matcher(&builder(td.path())); + assert!(matchedf(&mut m, ".hidden-file").is_ignore()); + let dir = matchedd(&mut m, ".hidden-dir"); + assert!(dir.is_ignore()); + assert!(!dir.should_descend()); + assert!(matchedf(&mut m, "visible-file").is_none()); + let dir = matchedd(&mut m, "visible-dir"); + assert!(dir.is_none()); + assert!(dir.should_descend()); + + let mut b = builder(td.path()); + b.hidden(false); + let mut m = one_matcher(&b); + assert!(matchedf(&mut m, ".hidden-file").is_none()); + let dir = matchedd(&mut m, ".hidden-dir"); + assert!(dir.is_none()); + assert!(dir.should_descend()); + } + + #[test] + fn gitignore_whitelist_overrides_hidden_filter() { + let td = tmpdir(); + mkdirp(td.path().join(".git")); + mkdirp(td.path().join(".hidden-dir")); + wfile(td.path().join(".hidden-file"), ""); + wfile(td.path().join(".gitignore"), "!.hidden-file\n!.hidden-dir/\n"); + + let mut m = one_matcher(&builder(td.path())); + assert!(matchedf(&mut m, ".hidden-file").is_whitelist()); + let dir = matchedd(&mut m, ".hidden-dir"); + assert!(dir.is_whitelist()); + assert!(dir.should_descend()); + } + + #[test] + fn descendants_of_hidden_directories_are_ignored() { + let td = tmpdir(); + mkdirp(td.path().join(".git")); + mkdirp(td.path().join(".hidden/nested")); + mkdirp(td.path().join("visible/.hidden")); + wfile(td.path().join(".hidden/file"), ""); + wfile(td.path().join(".hidden/nested/file"), ""); + wfile(td.path().join("visible/.hidden/file"), ""); + wfile(td.path().join(".hidden/.gitignore"), "!file\n"); + + let mut m = one_matcher(&builder(td.path())); + assert!(matchedf(&mut m, ".hidden/file").is_ignore()); + assert!(matchedf(&mut m, ".hidden/nested/file").is_ignore()); + assert!(matchedf(&mut m, "visible/.hidden/file").is_ignore()); + } + + #[test] + fn whitelisted_hidden_directory_allows_descendants() { + let td = tmpdir(); + mkdirp(td.path().join(".git")); + mkdirp(td.path().join(".hidden")); + wfile(td.path().join(".hidden/file"), ""); + wfile(td.path().join(".gitignore"), "!.hidden/\n"); + + let mut m = one_matcher(&builder(td.path())); + let dir = matchedd(&mut m, ".hidden"); + assert!(dir.is_whitelist()); + assert!(dir.should_descend()); + assert!(matchedf(&mut m, ".hidden/file").is_none()); + } + + #[test] + fn hidden_directories_outside_depth_limits() { + let td = tmpdir(); + mkdirp(td.path().join("a/.hidden")); + + let mut b = builder(td.path()); + b.min_depth(Some(3)); + let mut m = one_matcher(&b); + let dir = matchedd(&mut m, "a/.hidden"); + assert!(dir.is_ignore()); + assert!(!dir.is_within_depth()); + + let mut b = builder(td.path()); + b.max_depth(Some(1)); + let mut m = one_matcher(&b); + let dir = matchedd(&mut m, "a/.hidden"); + assert!(dir.is_ignore()); + assert!(!dir.is_within_depth()); + } +} diff --git a/crates/ignore/src/lib.rs b/crates/ignore/src/lib.rs index cd0af7ad1..b9c9a3ece 100644 --- a/crates/ignore/src/lib.rs +++ b/crates/ignore/src/lib.rs @@ -48,13 +48,16 @@ See the documentation for `WalkBuilder` for many other options. use std::path::{Path, PathBuf}; +pub use crate::incremental::{IncrementalIgnore, IncrementalMatch}; pub use crate::walk::{ - DirEntry, ParallelVisitor, ParallelVisitorBuilder, Walk, WalkBuilder, WalkParallel, WalkState, + DirEntry, ParallelVisitor, ParallelVisitorBuilder, Walk, WalkBuilder, + WalkParallel, WalkState, }; mod default_types; mod dir; pub mod gitignore; +mod incremental; pub mod overrides; mod pathutil; pub mod types; @@ -120,34 +123,31 @@ impl Clone for Error { fn clone(&self) -> Error { match *self { Error::Partial(ref errs) => Error::Partial(errs.clone()), - Error::WithLineNumber { line, ref err } => Error::WithLineNumber { - line, - err: err.clone(), - }, - Error::WithPath { ref path, ref err } => Error::WithPath { - path: path.clone(), - err: err.clone(), - }, - Error::WithDepth { depth, ref err } => Error::WithDepth { - depth, - err: err.clone(), - }, - Error::Loop { - ref ancestor, - ref child, - } => Error::Loop { + Error::WithLineNumber { line, ref err } => { + Error::WithLineNumber { line, err: err.clone() } + } + Error::WithPath { ref path, ref err } => { + Error::WithPath { path: path.clone(), err: err.clone() } + } + Error::WithDepth { depth, ref err } => { + Error::WithDepth { depth, err: err.clone() } + } + Error::Loop { ref ancestor, ref child } => Error::Loop { ancestor: ancestor.clone(), child: child.clone(), }, Error::Io(ref err) => match err.raw_os_error() { Some(e) => Error::Io(std::io::Error::from_raw_os_error(e)), - None => Error::Io(std::io::Error::new(err.kind(), err.to_string())), + None => { + Error::Io(std::io::Error::new(err.kind(), err.to_string())) + } }, - Error::Glob { ref glob, ref err } => Error::Glob { - glob: glob.clone(), - err: err.clone(), - }, - Error::UnrecognizedFileType(ref err) => Error::UnrecognizedFileType(err.clone()), + Error::Glob { ref glob, ref err } => { + Error::Glob { glob: glob.clone(), err: err.clone() } + } + Error::UnrecognizedFileType(ref err) => { + Error::UnrecognizedFileType(err.clone()) + } Error::InvalidDefinition => Error::InvalidDefinition, } } @@ -269,19 +269,14 @@ impl Error { /// Turn an error into a tagged error with the given depth. fn with_depth(self, depth: usize) -> Error { - Error::WithDepth { - depth, - err: Box::new(self), - } + Error::WithDepth { depth, err: Box::new(self) } } /// Turn an error into a tagged error with the given file path and line /// number. If path is empty, then it is omitted from the error. fn tagged>(self, path: P, lineno: u64) -> Error { - let errline = Error::WithLineNumber { - line: lineno, - err: Box::new(self), - }; + let errline = + Error::WithLineNumber { line: lineno, err: Box::new(self) }; if path.as_ref().as_os_str().is_empty() { return errline; } @@ -301,12 +296,12 @@ impl Error { }; } let path = err.path().map(|p| p.to_path_buf()); - let mut ig_err = Error::Io(std::io::Error::from(err)); + let mut ig_err = Error::WithDepth { + depth, + err: Box::new(Error::Io(std::io::Error::from(err))), + }; if let Some(path) = path { - ig_err = Error::WithPath { - path, - err: Box::new(ig_err), - }; + ig_err = Error::WithPath { path, err: Box::new(ig_err) }; } ig_err } @@ -333,7 +328,8 @@ impl std::fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match *self { Error::Partial(ref errs) => { - let msgs: Vec = errs.iter().map(|err| err.to_string()).collect(); + let msgs: Vec = + errs.iter().map(|err| err.to_string()).collect(); write!(f, "{}", msgs.join("\n")) } Error::WithLineNumber { line, ref err } => { @@ -343,10 +339,7 @@ impl std::fmt::Display for Error { write!(f, "{}: {}", path.display(), err) } Error::WithDepth { ref err, .. } => err.fmt(f), - Error::Loop { - ref ancestor, - ref child, - } => write!( + Error::Loop { ref ancestor, ref child } => write!( f, "File system loop found: \ {} points to an ancestor {}", @@ -354,14 +347,8 @@ impl std::fmt::Display for Error { ancestor.display() ), Error::Io(ref err) => err.fmt(f), - Error::Glob { - glob: None, - ref err, - } => write!(f, "{}", err), - Error::Glob { - glob: Some(ref glob), - ref err, - } => { + Error::Glob { glob: None, ref err } => write!(f, "{}", err), + Error::Glob { glob: Some(ref glob), ref err } => { write!(f, "error parsing glob '{}': {}", glob, err) } Error::UnrecognizedFileType(ref ty) => { @@ -495,11 +482,7 @@ impl Match { /// Return the match if it is not none. Otherwise, return other. pub fn or(self, other: Self) -> Self { - if self.is_none() { - other - } else { - self - } + if self.is_none() { other } else { self } } } @@ -511,7 +494,8 @@ mod tests { }; /// A convenient result type alias. - pub(crate) type Result = std::result::Result>; + pub(crate) type Result = + std::result::Result>; macro_rules! err { ($($tt:tt)*) => { @@ -544,13 +528,14 @@ mod tests { let tmpdir = env::temp_dir(); for _ in 0..TRIES { - let count = COUNTER.fetch_add(1, Ordering::SeqCst); + let count = COUNTER.fetch_add(1, Ordering::Relaxed); let path = tmpdir.join("rust-ignore").join(count.to_string()); if path.is_dir() { continue; } - fs::create_dir_all(&path) - .map_err(|e| err!("failed to create {}: {}", path.display(), e))?; + fs::create_dir_all(&path).map_err(|e| { + err!("failed to create {}: {}", path.display(), e) + })?; return Ok(TempDir(path)); } Err(err!("failed to create temp dir after {} tries", TRIES)) diff --git a/crates/ignore/src/overrides.rs b/crates/ignore/src/overrides.rs index 693c7dd0a..005cae8f2 100644 --- a/crates/ignore/src/overrides.rs +++ b/crates/ignore/src/overrides.rs @@ -1,5 +1,6 @@ /*! The overrides module provides a way to specify a set of override globs. + This provides functionality similar to `--include` or `--exclude` in command line tools. */ @@ -7,8 +8,8 @@ line tools. use std::path::Path; use crate::{ - gitignore::{self, Gitignore, GitignoreBuilder}, Error, Match, + gitignore::{self, Gitignore, GitignoreBuilder}, }; /// Glob represents a single glob in an override matcher. @@ -93,7 +94,11 @@ impl Override { /// given) is stripped. If there is no common suffix/prefix overlap, then /// `path` is assumed to reside in the same directory as the root path for /// this set of overrides. - pub fn matched<'a, P: AsRef>(&'a self, path: P, is_dir: bool) -> Match> { + pub fn matched<'a, P: AsRef>( + &'a self, + path: P, + is_dir: bool, + ) -> Match> { if self.is_empty() { return Match::None; } @@ -116,9 +121,9 @@ impl OverrideBuilder { /// /// Matching is done relative to the directory path provided. pub fn new>(path: P) -> OverrideBuilder { - OverrideBuilder { - builder: GitignoreBuilder::new(path), - } + let mut builder = GitignoreBuilder::new(path); + builder.allow_unclosed_class(false); + OverrideBuilder { builder } } /// Builds a new override matcher from the globs added so far. @@ -141,15 +146,41 @@ impl OverrideBuilder { /// Toggle whether the globs should be matched case insensitively or not. /// - /// When this option is changed, only globs added after the change will be affected. + /// When this option is changed, only globs added after the change will be + /// affected. /// /// This is disabled by default. - pub fn case_insensitive(&mut self, yes: bool) -> Result<&mut OverrideBuilder, Error> { + pub fn case_insensitive( + &mut self, + yes: bool, + ) -> Result<&mut OverrideBuilder, Error> { // TODO: This should not return a `Result`. Fix this in the next semver // release. self.builder.case_insensitive(yes)?; Ok(self) } + + /// Toggle whether unclosed character classes are allowed. When allowed, + /// a `[` without a matching `]` is treated literally instead of resulting + /// in a parse error. + /// + /// For example, if this is set then the glob `[abc` will be treated as the + /// literal string `[abc` instead of returning an error. + /// + /// By default, this is false. Generally speaking, enabling this leads to + /// worse failure modes since the glob parser becomes more permissive. You + /// might want to enable this when compatibility (e.g., with POSIX glob + /// implementations) is more important than good error messages. + /// + /// This default is different from the default for [`Gitignore`]. Namely, + /// [`Gitignore`] is intended to match git's behavior as-is. But this + /// abstraction for "override" globs does not necessarily conform to any + /// other known specification and instead prioritizes better error + /// messages. + pub fn allow_unclosed_class(&mut self, yes: bool) -> &mut OverrideBuilder { + self.builder.allow_unclosed_class(yes); + self + } } #[cfg(test)] @@ -252,11 +283,8 @@ mod tests { #[test] fn default_case_sensitive() { - let ov = OverrideBuilder::new(ROOT) - .add("*.html") - .unwrap() - .build() - .unwrap(); + let ov = + OverrideBuilder::new(ROOT).add("*.html").unwrap().build().unwrap(); assert!(ov.matched("foo.html", false).is_whitelist()); assert!(ov.matched("foo.HTML", false).is_ignore()); assert!(ov.matched("foo.htm", false).is_ignore()); diff --git a/crates/ignore/src/pathutil.rs b/crates/ignore/src/pathutil.rs index 0ceb5a356..5de8c106c 100644 --- a/crates/ignore/src/pathutil.rs +++ b/crates/ignore/src/pathutil.rs @@ -2,55 +2,89 @@ use std::{ffi::OsStr, path::Path}; use crate::walk::DirEntry; -/// Returns true if and only if this entry is considered to be hidden. +/// Returns true if and only if this path is considered to be hidden. /// -/// This only returns true if the base name of the path starts with a `.`. +/// # Platform behavior /// -/// On Unix, this implements a more optimized check. -#[cfg(unix)] -pub(crate) fn is_hidden(dent: &DirEntry) -> bool { - use std::os::unix::ffi::OsStrExt; - - if let Some(name) = file_name(dent.path()) { - name.as_bytes().get(0) == Some(&b'.') - } else { - false - } -} - -/// Returns true if and only if this entry is considered to be hidden. +/// ## Windows /// -/// On Windows, this returns true if one of the following is true: +/// 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. -#[cfg(windows)] -pub(crate) fn is_hidden(dent: &DirEntry) -> bool { - use std::os::windows::fs::MetadataExt; - use winapi_util::file; - - // This looks like we're doing an extra stat call, but on Windows, the - // directory traverser reuses the metadata retrieved from each directory - // entry and stores it on the DirEntry itself. So this is "free." - if let Ok(md) = dent.metadata() { - if file::is_hidden(md.file_attributes() as u64) { - return true; - } - } - if let Some(name) = file_name(dent.path()) { - name.to_str().map(|s| s.starts_with(".")).unwrap_or(false) - } else { - false - } -} - -/// Returns true if and only if this entry is considered to be hidden. +/// +/// ## All other platforms /// /// This only returns true if the base name of the path starts with a `.`. -#[cfg(not(any(unix, windows)))] -pub(crate) fn is_hidden(dent: &DirEntry) -> bool { - if let Some(name) = file_name(dent.path()) { - name.to_str().map(|s| s.starts_with(".")).unwrap_or(false) +pub(crate) fn is_hidden_path(dent: &Path) -> bool { + #[cfg(not(windows))] + fn imp(path: &Path) -> bool { + is_hidden_path_only(path) + } + + #[cfg(windows)] + fn imp(path: &Path) -> bool { + use std::os::windows::fs::MetadataExt; + use winapi_util::file; + + if let Ok(md) = path.metadata() { + if file::is_hidden(md.file_attributes() as u64) { + return true; + } + } + is_hidden_path_only(path) + } + + imp(dent) +} + +/// Returns true if and only if this directory entry is considered to be +/// hidden. +/// +/// # Platform behavior +/// +/// ## Windows +/// +/// This returns true if one of the following is true: +/// +/// * The base name of the path starts with a `.`. +/// * The file attributes have the `HIDDEN` property set. +/// +/// ## All other platforms +/// +/// This only returns true if the base name of the path starts with a `.`. +pub(crate) fn is_hidden_entry(dent: &DirEntry) -> bool { + #[cfg(not(windows))] + fn imp(dent: &DirEntry) -> bool { + is_hidden_path_only(dent.path()) + } + + #[cfg(windows)] + fn imp(dent: &DirEntry) -> bool { + use std::os::windows::fs::MetadataExt; + use winapi_util::file; + + // This looks like we're doing an extra stat call, but on Windows, the + // directory traverser reuses the metadata retrieved from each directory + // entry and stores it on the DirEntry itself. So this is "free." + if let Ok(md) = dent.metadata() { + if file::is_hidden(md.file_attributes() as u64) { + return true; + } + } + is_hidden_path_only(dent.path()) + } + + imp(dent) +} + +/// Returns true if and only if this path is considered to be hidden from only +/// the path itself. +/// +/// This has the same behavior on all platforms. +fn is_hidden_path_only(path: &Path) -> bool { + if let Some(name) = file_name(path) { + name.as_encoded_bytes().starts_with(b".") } else { false } @@ -59,83 +93,79 @@ pub(crate) fn is_hidden(dent: &DirEntry) -> bool { /// Strip `prefix` from the `path` and return the remainder. /// /// If `path` doesn't have a prefix `prefix`, then return `None`. -#[cfg(unix)] pub(crate) fn strip_prefix<'a, P: AsRef + ?Sized>( prefix: &'a P, path: &'a Path, ) -> Option<&'a Path> { - use std::os::unix::ffi::OsStrExt; + #[cfg(unix)] + fn imp<'a>(prefix: &'a Path, path: &'a Path) -> Option<&'a Path> { + use std::os::unix::ffi::OsStrExt; - let prefix = prefix.as_ref().as_os_str().as_bytes(); - let path = path.as_os_str().as_bytes(); - if prefix.len() > path.len() || prefix != &path[0..prefix.len()] { - None - } else { - Some(&Path::new(OsStr::from_bytes(&path[prefix.len()..]))) + let prefix = prefix.as_os_str().as_bytes(); + let path = path.as_os_str().as_bytes(); + if prefix.len() > path.len() || prefix != &path[0..prefix.len()] { + None + } else { + Some(&Path::new(OsStr::from_bytes(&path[prefix.len()..]))) + } } -} -/// Strip `prefix` from the `path` and return the remainder. -/// -/// If `path` doesn't have a prefix `prefix`, then return `None`. -#[cfg(not(unix))] -pub(crate) fn strip_prefix<'a, P: AsRef + ?Sized>( - prefix: &'a P, - path: &'a Path, -) -> Option<&'a Path> { - path.strip_prefix(prefix).ok() + #[cfg(not(unix))] + fn imp<'a>(prefix: &'a Path, path: &'a Path) -> Option<&'a Path> { + path.strip_prefix(prefix).ok() + } + + imp(prefix.as_ref(), path) } /// Returns true if this file path is just a file name. i.e., Its parent is /// the empty string. -#[cfg(unix)] pub(crate) fn is_file_name>(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>(path: P) -> bool { - path.as_ref() - .parent() - .map(|p| p.as_os_str().is_empty()) - .unwrap_or(false) -} - -/// The final component of the path, if it is a normal file. -/// -/// If the path terminates in ., .., or consists solely of a root of prefix, -/// file_name will return None. -#[cfg(unix)] -pub(crate) fn file_name<'a, P: AsRef + ?Sized>(path: &'a P) -> Option<&'a OsStr> { - use memchr::memrchr; - use std::os::unix::ffi::OsStrExt; - - let path = path.as_ref().as_os_str().as_bytes(); - if path.is_empty() { - return None; - } else if path.len() == 1 && path[0] == b'.' { - return None; - } else if path.last() == Some(&b'.') { - return None; - } else if path.len() >= 2 && &path[path.len() - 2..] == &b".."[..] { - return None; + #[cfg(unix)] + { + memchr::memchr(b'/', path.as_ref().as_os_str().as_encoded_bytes()) + .is_none() + } + #[cfg(not(unix))] + { + path.as_ref() + .parent() + .map(|p| p.as_os_str().is_empty()) + .unwrap_or(false) } - let last_slash = memrchr(b'/', path).map(|i| i + 1).unwrap_or(0); - Some(OsStr::from_bytes(&path[last_slash..])) } /// The final component of the path, if it is a normal file. /// -/// If the path terminates in ., .., or consists solely of a root of prefix, -/// file_name will return None. -#[cfg(not(unix))] -pub(crate) fn file_name<'a, P: AsRef + ?Sized>(path: &'a P) -> Option<&'a OsStr> { - path.as_ref().file_name() +/// If the path terminates in `.`, `..`, or consists solely of a root of +/// prefix, this will return `None`. +pub(crate) fn file_name<'a, P: AsRef + ?Sized>( + path: &'a P, +) -> Option<&'a OsStr> { + #[cfg(unix)] + fn imp(path: &Path) -> Option<&OsStr> { + use std::os::unix::ffi::OsStrExt; + + use memchr::memrchr; + + let path = path.as_os_str().as_bytes(); + if path.is_empty() { + return None; + } else if path.len() == 1 && path[0] == b'.' { + return None; + } else if path.last() == Some(&b'.') { + return None; + } else if path.len() >= 2 && &path[path.len() - 2..] == &b".."[..] { + return None; + } + let last_slash = memrchr(b'/', path).map(|i| i + 1).unwrap_or(0); + Some(OsStr::from_bytes(&path[last_slash..])) + } + + #[cfg(not(unix))] + fn imp(path: &Path) -> Option<&OsStr> { + path.file_name() + } + + imp(path.as_ref()) } diff --git a/crates/ignore/src/types.rs b/crates/ignore/src/types.rs index 814ee4a0a..313cf5c0e 100644 --- a/crates/ignore/src/types.rs +++ b/crates/ignore/src/types.rs @@ -91,7 +91,7 @@ use { regex_automata::util::pool::Pool, }; -use crate::{default_types::DEFAULT_TYPES, pathutil::file_name, Error, Match}; +use crate::{Error, Match, default_types::DEFAULT_TYPES, pathutil::file_name}; /// Glob represents a single glob in a set of file type definitions. /// @@ -204,8 +204,12 @@ impl Selection { fn map U>(self, f: F) -> Selection { match self { - Selection::Select(name, inner) => Selection::Select(name, f(inner)), - Selection::Negate(name, inner) => Selection::Negate(name, f(inner)), + Selection::Select(name, inner) => { + Selection::Select(name, f(inner)) + } + Selection::Negate(name, inner) => { + Selection::Negate(name, f(inner)) + } } } @@ -227,7 +231,9 @@ impl Types { has_selected: false, glob_to_selection: vec![], set: GlobSetBuilder::new().build().unwrap(), - matches: Arc::new(Pool::new(|| vec![])), + matches: Arc::new(Pool::with_available_parallelism_capacity( + || vec![], + )), } } @@ -254,7 +260,11 @@ impl Types { /// The path is considered ignored if it matches a negated file type. /// If at least one file type is selected and `path` doesn't match, then /// the path is also considered ignored. - pub fn matched<'a, P: AsRef>(&'a self, path: P, is_dir: bool) -> Match> { + pub fn matched<'a, P: AsRef>( + &'a self, + path: P, + is_dir: bool, + ) -> Match> { // File types don't apply to directories, and we can't do anything // if our glob set is empty. if is_dir || self.set.is_empty() { @@ -306,10 +316,7 @@ impl TypesBuilder { /// of default type definitions can be added with `add_defaults`, and /// additional type definitions can be added with `select` and `negate`. pub fn new() -> TypesBuilder { - TypesBuilder { - types: HashMap::new(), - selections: vec![], - } + TypesBuilder { types: HashMap::new(), selections: vec![] } } /// Build the current set of file type definitions *and* selections into @@ -343,17 +350,18 @@ impl TypesBuilder { } selections.push(selection.clone().map(move |_| def)); } - let set = build_set.build().map_err(|err| Error::Glob { - glob: None, - err: err.to_string(), - })?; + let set = build_set + .build() + .map_err(|err| Error::Glob { glob: None, err: err.to_string() })?; Ok(Types { defs, selections, has_selected, glob_to_selection, set, - matches: Arc::new(Pool::new(|| vec![])), + matches: Arc::new(Pool::with_available_parallelism_capacity( + || vec![], + )), }) } @@ -377,12 +385,10 @@ impl TypesBuilder { pub fn select(&mut self, name: &str) -> &mut TypesBuilder { if name == "all" { for name in self.types.keys() { - self.selections - .push(Selection::Select(name.to_string(), ())); + self.selections.push(Selection::Select(name.to_string(), ())); } } else { - self.selections - .push(Selection::Select(name.to_string(), ())); + self.selections.push(Selection::Select(name.to_string(), ())); } self } @@ -393,12 +399,10 @@ impl TypesBuilder { pub fn negate(&mut self, name: &str) -> &mut TypesBuilder { if name == "all" { for name in self.types.keys() { - self.selections - .push(Selection::Negate(name.to_string(), ())); + self.selections.push(Selection::Negate(name.to_string(), ())); } } else { - self.selections - .push(Selection::Negate(name.to_string(), ())); + self.selections.push(Selection::Negate(name.to_string(), ())); } self } @@ -453,7 +457,10 @@ impl TypesBuilder { 3 => { let name = parts[0]; let types_string = parts[2]; - if name.is_empty() || parts[1] != "include" || types_string.is_empty() { + if name.is_empty() + || parts[1] != "include" + || types_string.is_empty() + { return Err(Error::InvalidDefinition); } let types = types_string.split(','); @@ -463,7 +470,8 @@ impl TypesBuilder { return Err(Error::InvalidDefinition); } for type_name in types { - let globs = self.types.get(type_name).unwrap().globs.clone(); + let globs = + self.types.get(type_name).unwrap().globs.clone(); for glob in globs { self.add(name, &glob)?; } @@ -549,30 +557,9 @@ mod tests { matched!(not, matchnot1, types(), vec!["rust"], vec![], "index.html"); matched!(not, matchnot2, types(), vec![], vec!["rust"], "main.rs"); - matched!( - not, - matchnot3, - types(), - vec!["foo"], - vec!["rust"], - "main.rs" - ); - matched!( - not, - matchnot4, - types(), - vec!["rust"], - vec!["foo"], - "main.rs" - ); - matched!( - not, - matchnot5, - types(), - vec!["rust"], - vec!["foo"], - "main.foo" - ); + matched!(not, matchnot3, types(), vec!["foo"], vec!["rust"], "main.rs"); + matched!(not, matchnot4, types(), vec!["rust"], vec!["foo"], "main.rs"); + matched!(not, matchnot5, types(), vec!["rust"], vec!["foo"], "main.foo"); matched!(not, matchnot6, types(), vec!["combo"], vec![], "leftpad.js"); matched!(not, matchnot7, types(), vec!["py"], vec![], "index.html"); matched!(not, matchnot8, types(), vec!["python"], vec![], "doc.md"); diff --git a/crates/ignore/src/walk.rs b/crates/ignore/src/walk.rs index 9c1f7413d..2dff78852 100644 --- a/crates/ignore/src/walk.rs +++ b/crates/ignore/src/walk.rs @@ -5,7 +5,7 @@ use std::{ io, path::{Path, PathBuf}, sync::atomic::{AtomicBool, AtomicUsize, Ordering as AtomicOrdering}, - sync::Arc, + sync::{Arc, OnceLock}, }; use { @@ -15,11 +15,13 @@ use { }; use crate::{ + Error, PartialErrorBuilder, dir::{Ignore, IgnoreBuilder}, + // CHANGED: Also import `Gitignore` for `WalkBuilder::add_gitignore`. gitignore::{Gitignore, GitignoreBuilder}, + incremental::{IncrementalIgnore, IncrementalIgnoreOptions}, overrides::Override, types::Types, - Error, PartialErrorBuilder, }; /// A directory entry with a possible error attached. @@ -104,24 +106,15 @@ impl 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) -> DirEntry { - DirEntry { - dent: DirEntryInner::Walkdir(dent), - err, - } + DirEntry { dent: DirEntryInner::Walkdir(dent), err } } fn new_raw(dent: DirEntryRaw, err: Option) -> DirEntry { - DirEntry { - dent: DirEntryInner::Raw(dent), - err, - } + DirEntry { dent: DirEntryInner::Raw(dent), err } } } @@ -187,9 +180,11 @@ impl DirEntryInner { )); Err(err.with_path("")) } - Walkdir(ref x) => x - .metadata() - .map_err(|err| Error::Io(io::Error::from(err)).with_path(x.path())), + Walkdir(ref x) => x.metadata().map_err(|err| { + Error::Io(io::Error::from(err)) + .with_depth(x.depth()) + .with_path(x.path()) + }), Raw(ref x) => x.metadata(), } } @@ -308,7 +303,9 @@ impl DirEntryRaw { } else { fs::symlink_metadata(&self.path) } - .map_err(|err| Error::Io(io::Error::from(err)).with_path(&self.path)) + .map_err(|err| { + Error::Io(err).with_depth(self.depth).with_path(&self.path) + }) } fn file_type(&self) -> FileType { @@ -316,9 +313,7 @@ impl DirEntryRaw { } 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 { @@ -330,13 +325,13 @@ impl DirEntryRaw { self.ino } - fn from_entry(depth: usize, ent: &fs::DirEntry) -> Result { + fn from_entry( + depth: usize, + ent: &fs::DirEntry, + ) -> Result { let ty = ent.file_type().map_err(|err| { - let err = Error::Io(io::Error::from(err)).with_path(ent.path()); - Error::WithDepth { - depth, - err: Box::new(err), - } + let err = Error::Io(err).with_depth(depth).with_path(ent.path()); + Error::WithDepth { depth, err: Box::new(err) } })?; DirEntryRaw::from_entry_os(depth, ent, ty) } @@ -348,11 +343,8 @@ impl DirEntryRaw { ty: fs::FileType, ) -> Result { let md = ent.metadata().map_err(|err| { - let err = Error::Io(io::Error::from(err)).with_path(ent.path()); - Error::WithDepth { - depth, - err: Box::new(err), - } + let err = Error::Io(err).with_depth(depth).with_path(ent.path()); + Error::WithDepth { depth, err: Box::new(err) } })?; Ok(DirEntryRaw { path: ent.path(), @@ -395,8 +387,13 @@ impl DirEntryRaw { } #[cfg(windows)] - fn from_path(depth: usize, pb: PathBuf, link: bool) -> Result { - let md = fs::metadata(&pb).map_err(|err| Error::Io(err).with_path(&pb))?; + fn from_path( + depth: usize, + pb: PathBuf, + link: bool, + ) -> Result { + let md = fs::metadata(&pb) + .map_err(|err| Error::Io(err).with_depth(depth).with_path(&pb))?; Ok(DirEntryRaw { path: pb, ty: md.file_type(), @@ -407,10 +404,15 @@ impl DirEntryRaw { } #[cfg(unix)] - fn from_path(depth: usize, pb: PathBuf, link: bool) -> Result { + fn from_path( + depth: usize, + pb: PathBuf, + link: bool, + ) -> Result { use std::os::unix::fs::MetadataExt; - let md = fs::metadata(&pb).map_err(|err| Error::Io(err).with_path(&pb))?; + let md = fs::metadata(&pb) + .map_err(|err| Error::Io(err).with_depth(depth).with_path(&pb))?; Ok(DirEntryRaw { path: pb, ty: md.file_type(), @@ -423,7 +425,11 @@ impl DirEntryRaw { // Placeholder implementation to allow compiling on non-standard platforms // (e.g. wasm32). #[cfg(not(any(windows, unix)))] - fn from_path(depth: usize, pb: PathBuf, link: bool) -> Result { + fn from_path( + depth: usize, + pb: PathBuf, + link: bool, + ) -> Result { Err(Error::Io(io::Error::new( io::ErrorKind::Other, "unsupported platform", @@ -484,6 +490,7 @@ pub struct WalkBuilder { paths: Vec, ig_builder: IgnoreBuilder, max_depth: Option, + min_depth: Option, max_filesize: Option, follow_links: bool, same_file_system: bool, @@ -491,6 +498,18 @@ pub struct WalkBuilder { threads: usize, skip: Option>, filter: Option, + /// The directory that gitignores should be interpreted relative to. + /// + /// Usually this is the directory containing the gitignore file. But in + /// some cases, like for global gitignores or for gitignores specified + /// explicitly, this should generally be set to the current working + /// directory. This is only used for global gitignores or "explicit" + /// gitignores. + /// + /// When `None`, the CWD is fetched from `std::env::current_dir()`. If + /// that fails, then global gitignores are ignored (an error is logged). + global_gitignores_relative_to: + OnceLock>>, } #[derive(Clone)] @@ -508,10 +527,18 @@ impl std::fmt::Debug for WalkBuilder { .field("paths", &self.paths) .field("ig_builder", &self.ig_builder) .field("max_depth", &self.max_depth) + .field("min_depth", &self.min_depth) .field("max_filesize", &self.max_filesize) .field("follow_links", &self.follow_links) + .field("same_file_system", &self.same_file_system) + .field("sorter", &"<...>") .field("threads", &self.threads) .field("skip", &self.skip) + .field("filter", &"<...>") + .field( + "global_gitignores_relative_to", + &self.global_gitignores_relative_to, + ) .finish() } } @@ -524,10 +551,19 @@ impl WalkBuilder { /// is better to call `add` on this builder than to create multiple /// `Walk` values. pub fn new>(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 { - paths: vec![path.as_ref().to_path_buf()], + paths: vec![], ig_builder: IgnoreBuilder::new(), max_depth: None, + min_depth: None, max_filesize: None, follow_links: false, same_file_system: false, @@ -535,13 +571,30 @@ impl WalkBuilder { threads: 0, skip: None, filter: None, + global_gitignores_relative_to: OnceLock::new(), } } + /// 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, I: IntoIterator>( + paths: I, + ) -> WalkBuilder { + let mut builder = WalkBuilder::empty(); + for path in paths.into_iter() { + builder.add(path); + } + builder + } + /// Build a new `Walk` iterator. pub fn build(&self) -> Walk { let follow_links = self.follow_links; let max_depth = self.max_depth; + let min_depth = self.min_depth; let sorter = self.sorter.clone(); let its = self .paths @@ -556,13 +609,20 @@ impl WalkBuilder { if let Some(max_depth) = max_depth { wd = wd.max_depth(max_depth); } + if let Some(min_depth) = min_depth { + wd = wd.min_depth(min_depth); + } if let Some(ref sorter) = sorter { match sorter.clone() { Sorter::ByName(cmp) => { - wd = wd.sort_by(move |a, b| cmp(a.file_name(), b.file_name())); + wd = wd.sort_by(move |a, b| { + cmp(a.file_name(), b.file_name()) + }); } Sorter::ByPath(cmp) => { - wd = wd.sort_by(move |a, b| cmp(a.path(), b.path())); + wd = wd.sort_by(move |a, b| { + cmp(a.path(), b.path()) + }); } } } @@ -571,28 +631,81 @@ impl WalkBuilder { }) .collect::>() .into_iter(); - let ig_root = self.ig_builder.build(); + let ig_root = self.build_ignore(); Walk { its, it: None, ig_root: ig_root.clone(), ig: ig_root.clone(), + max_depth: self.max_depth, max_filesize: self.max_filesize, skip: self.skip.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 { + 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. /// /// Note that this *doesn't* return something that implements `Iterator`. /// Instead, the returned value must be run with a closure. e.g., /// `builder.build_parallel().run(|| |path| { println!("{path:?}"); WalkState::Continue })`. pub fn build_parallel(&self) -> WalkParallel { + let ig_root = self.build_ignore(); WalkParallel { paths: self.paths.clone().into_iter(), - ig_root: self.ig_builder.build(), + ig_root, max_depth: self.max_depth, + min_depth: self.min_depth, max_filesize: self.max_filesize, follow_links: self.follow_links, same_file_system: self.same_file_system, @@ -617,6 +730,26 @@ impl WalkBuilder { /// The default, `None`, imposes no depth restriction. pub fn max_depth(&mut self, depth: Option) -> &mut WalkBuilder { self.max_depth = depth; + if self.min_depth.is_some() + && self.max_depth.is_some() + && self.max_depth < self.min_depth + { + self.max_depth = self.min_depth; + } + self + } + + /// The minimum depth to recurse. + /// + /// The default, `None`, imposes no minimum depth restriction. + pub fn min_depth(&mut self, depth: Option) -> &mut WalkBuilder { + self.min_depth = depth; + if self.max_depth.is_some() + && self.min_depth.is_some() + && self.min_depth > self.max_depth + { + self.min_depth = self.max_depth; + } self } @@ -647,11 +780,31 @@ impl WalkBuilder { /// /// This has lower precedence than all other sources of ignore rules. /// + /// # Errors + /// /// If there was a problem adding the ignore file, then an error is /// returned. Note that the error may indicate *partial* failure. For /// example, if an ignore file contains an invalid glob, all other globs /// are still applied. + /// + /// An error will also occur if this walker could not get the current + /// working directory (and `WalkBuilder::current_dir` isn't set). pub fn add_ignore>(&mut self, path: P) -> Option { + // CHANGED: Root the ignore file at `""` instead of the current working + // 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 Some(cwd) = self.get_or_set_current_dir() else { + // let err = std::io::Error::other(format!( + // "CWD is not known, ignoring global gitignore {}", + // path.display() + // )); + // return Some(err.into()); + // }; + // let mut builder = GitignoreBuilder::new(cwd); let mut builder = GitignoreBuilder::new(""); let mut errs = PartialErrorBuilder::default(); errs.maybe_push(builder.add(path)); @@ -666,7 +819,11 @@ impl WalkBuilder { errs.into_error_option() } - /// CHANGED: Add a Gitignore to the builder. + /// CHANGED: Add a prebuilt 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) { self.ig_builder.add_ignore(gi); } @@ -799,6 +956,10 @@ impl WalkBuilder { /// /// When disabled, git-related ignore rules are applied even when searching /// outside a git repository. + /// + /// In particular, if this is `false` then `.gitignore` files will be read + /// from parent directories above the git root directory containing `.git`, + /// which is different from the git behavior. pub fn require_git(&mut self, yes: bool) -> &mut WalkBuilder { self.ig_builder.require_git(yes); self @@ -895,6 +1056,10 @@ impl WalkBuilder { /// /// Note that the errors for reading entries that may not satisfy the /// predicate will still be yielded. + /// + /// Note also that only one filter predicate can be applied to a + /// `WalkBuilder`. Calling this subsequent times overrides previous filter + /// predicates. pub fn filter_entry

(&mut self, filter: P) -> &mut WalkBuilder where P: Fn(&DirEntry) -> bool + Send + Sync + 'static, @@ -902,6 +1067,62 @@ impl WalkBuilder { self.filter = Some(Filter(Arc::new(filter))); self } + + /// Set the current working directory used for matching global gitignores. + /// + /// If this is not set, then this walker will attempt to discover the + /// correct path from the environment's current working directory. If + /// that fails, then global gitignore files will be ignored. + /// + /// Global gitignore files come from things like a user's git configuration + /// or from gitignore files added via [`WalkBuilder::add_ignore`]. + pub fn current_dir( + &mut self, + cwd: impl Into, + ) -> &mut WalkBuilder { + let cwd = cwd.into(); + self.ig_builder.current_dir(cwd.clone()); + if let Err(cwd) = self.global_gitignores_relative_to.set(Ok(cwd)) { + // OK because `Err` from `set` implies a value exists. + *self.global_gitignores_relative_to.get_mut().unwrap() = cwd; + } + self + } + + /// Gets the currently configured CWD on this walk builder. + /// + /// This is "lazy." That is, we only ask for the CWD from the environment + /// if `WalkBuilder::current_dir` hasn't been called yet. And we ensure + /// that we only do it once. + fn get_or_set_current_dir(&self) -> Option<&Path> { + let result = self.global_gitignores_relative_to.get_or_init(|| { + let result = std::env::current_dir().map_err(Arc::new); + match result { + Ok(ref path) => { + log::trace!( + "automatically discovered CWD: {}", + path.display() + ); + } + Err(ref err) => { + log::debug!( + "failed to find CWD \ + (global gitignores will be ignored): \ + {err}" + ); + } + } + result + }); + 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 @@ -915,6 +1136,7 @@ pub struct Walk { it: Option, ig_root: Ignore, ig: Ignore, + max_depth: Option, max_filesize: Option, skip: Option>, filter: Option, @@ -930,6 +1152,17 @@ impl Walk { 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, I: IntoIterator>( + paths: I, + ) -> Walk { + WalkBuilder::from_iter(paths).build() + } + fn skip_entry(&self, ent: &DirEntry) -> Result { if ent.depth() == 0 { return Ok(false); @@ -1014,12 +1247,17 @@ impl Iterator for Walk { self.it.as_mut().unwrap().it.skip_current_dir(); // Still need to push this on the stack because // we'll get a WalkEvent::Exit event for this dir. - // We don't care if it errors though. - let (igtmp, _) = self.ig.add_child(ent.path()); + // Its ignore files cannot apply to any visited entry. + let (igtmp, _) = + self.ig.add_child_with_entries(ent.path(), &[]); self.ig = igtmp; continue; } - let (igtmp, err) = self.ig.add_child(ent.path()); + let (igtmp, err) = if self.max_depth == Some(ent.depth()) { + self.ig.add_child_with_entries(ent.path(), &[]) + } else { + self.ig.add_child(ent.path()) + }; self.ig = igtmp; ent.err = err; return Some(Ok(ent)); @@ -1061,11 +1299,7 @@ enum WalkEvent { impl From for WalkEventIter { fn from(it: WalkDir) -> WalkEventIter { - WalkEventIter { - depth: 0, - it: it.into_iter(), - next: None, - } + WalkEventIter { depth: 0, it: it.into_iter(), next: None } } } @@ -1138,7 +1372,9 @@ pub trait ParallelVisitorBuilder<'s> { fn build(&mut self) -> Box; } -impl<'a, 's, P: ParallelVisitorBuilder<'s>> ParallelVisitorBuilder<'s> for &'a mut P { +impl<'a, 's, P: ParallelVisitorBuilder<'s>> ParallelVisitorBuilder<'s> + for &'a mut P +{ fn build(&mut self) -> Box { (**self).build() } @@ -1159,14 +1395,17 @@ struct FnBuilder { builder: F, } -impl<'s, F: FnMut() -> FnVisitor<'s>> ParallelVisitorBuilder<'s> for FnBuilder { +impl<'s, F: FnMut() -> FnVisitor<'s>> ParallelVisitorBuilder<'s> + for FnBuilder +{ fn build(&mut self) -> Box { let visitor = (self.builder)(); Box::new(FnVisitorImp { visitor }) } } -type FnVisitor<'s> = Box) -> WalkState + Send + 's>; +type FnVisitor<'s> = + Box) -> WalkState + Send + 's>; struct FnVisitorImp<'s> { visitor: FnVisitor<'s>, @@ -1191,6 +1430,7 @@ pub struct WalkParallel { ig_root: Ignore, max_filesize: Option, max_depth: Option, + min_depth: Option, follow_links: bool, same_file_system: bool, threads: usize, @@ -1255,7 +1495,9 @@ impl WalkParallel { } }; match DirEntryRaw::from_path(0, path, false) { - Ok(dent) => (DirEntry::new_raw(dent, None), root_device), + Ok(dent) => { + (DirEntry::new_raw(dent, None), root_device) + } Err(err) => { if visitor.visit(Err(err)).is_quit() { return; @@ -1279,20 +1521,28 @@ impl WalkParallel { let quit_now = Arc::new(AtomicBool::new(false)); let active_workers = Arc::new(AtomicUsize::new(threads)); let stacks = Stack::new_for_each_thread(threads, stack); + // Collect all of the workers first. In the case that + // `builder.build()` panics, we want that to happen and + // propagate before we actually start to run any of the + // workers. + let workers: Vec<_> = stacks + .into_iter() + .map(|stack| Worker { + visitor: builder.build(), + stack, + quit_now: quit_now.clone(), + active_workers: active_workers.clone(), + max_depth: self.max_depth, + min_depth: self.min_depth, + max_filesize: self.max_filesize, + follow_links: self.follow_links, + skip: self.skip.clone(), + filter: self.filter.clone(), + }) + .collect(); std::thread::scope(|s| { - let handles: Vec<_> = stacks + let handles: Vec<_> = workers .into_iter() - .map(|stack| Worker { - visitor: builder.build(), - stack, - quit_now: quit_now.clone(), - active_workers: active_workers.clone(), - max_depth: self.max_depth, - max_filesize: self.max_filesize, - follow_links: self.follow_links, - skip: self.skip.clone(), - filter: self.filter.clone(), - }) .map(|worker| s.spawn(|| worker.run())) .collect(); for handle in handles { @@ -1303,7 +1553,7 @@ impl WalkParallel { fn threads(&self) -> usize { if self.threads == 0 { - 2 + std::thread::available_parallelism().map_or(1, |n| n.get()).min(12) } else { self.threads } @@ -1334,6 +1584,12 @@ struct Work { root_device: Option, } +#[derive(Default)] +struct ReadDirResult { + entries: Vec, + errors: Vec, +} + impl Work { /// Returns true if and only if this work item is a directory. fn is_dir(&self) -> bool { @@ -1360,6 +1616,13 @@ impl Work { 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 /// rules for this directory. /// @@ -1367,7 +1630,7 @@ impl Work { /// an error is returned. If there was a problem reading the ignore /// rules for this directory, then the error is attached to this /// work item's directory entry. - fn read_dir(&mut self) -> Result { + fn read_dir(&mut self) -> Result { let readdir = match fs::read_dir(self.dent.path()) { Ok(readdir) => readdir, Err(err) => { @@ -1377,10 +1640,24 @@ impl Work { return Err(err); } }; - let (ig, err) = self.ignore.add_child(self.dent.path()); + // Actually descend into the directory and read its contents + 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.dent.err = err; - Ok(readdir) + Ok(result) } } @@ -1404,11 +1681,11 @@ impl Stack { // breadth-first. We do depth-first because a breadth first traversal // on wide directories with a lot of gitignores is disastrous (for // example, searching a directory tree containing all of crates.io). - let deques: Vec> = std::iter::repeat_with(Deque::new_lifo) - .take(threads) - .collect(); - let stealers = - Arc::<[Stealer]>::from(deques.iter().map(Deque::stealer).collect::>()); + let deques: Vec> = + std::iter::repeat_with(Deque::new_lifo).take(threads).collect(); + let stealers = Arc::<[Stealer]>::from( + deques.iter().map(Deque::stealer).collect::>(), + ); let stacks: Vec = deques .into_iter() .enumerate() @@ -1418,8 +1695,11 @@ impl Stack { stealers: stealers.clone(), }) .collect(); - // Distribute the initial messages. + // Distribute the initial messages, reverse the order to cancel out + // the other reversal caused by the inherent LIFO processing of the + // per-thread stacks which are filled here. init.into_iter() + .rev() .zip(stacks.iter().cycle()) .for_each(|(m, s)| s.push(m)); stacks @@ -1474,6 +1754,8 @@ struct Worker<'s> { /// The maximum depth of directories to descend. A value of `0` means no /// descension at all. max_depth: Option, + /// The minimum depth of directories to descend. + min_depth: Option, /// The maximum size a searched file can be (in bytes). If a file exceeds /// this size it will be skipped. max_filesize: Option, @@ -1502,10 +1784,19 @@ impl<'s> Worker<'s> { } fn run_one(&mut self, mut work: Work) -> WalkState { + let should_visit = self + .min_depth + .map(|min_depth| work.dent.depth() >= min_depth) + .unwrap_or(true); + // If the work is not a directory, then we can just execute the // caller's callback immediately and move on. if work.is_symlink() || !work.is_dir() { - return self.visitor.visit(Ok(work.dent)); + return if should_visit { + self.visitor.visit(Ok(work.dent)) + } else { + WalkState::Continue + }; } if let Some(err) = work.add_parents() { let state = self.visitor.visit(Err(err)); @@ -1536,16 +1827,27 @@ impl<'s> Worker<'s> { // have sufficient read permissions to list the directory. // In that case we still want to provide the closure with a valid // entry before passing the error value. - let readdir = work.read_dir(); let depth = work.dent.depth(); - let state = self.visitor.visit(Ok(work.dent)); - if !state.is_continue() { - return state; + 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 { + let state = self.visitor.visit(Ok(work.dent)); + if !state.is_continue() { + return state; + } } if !descend { return WalkState::Skip; } + let readdir = match readdir { + Some(readdir) => readdir, + None => return WalkState::Skip, + }; let readdir = match readdir { Ok(readdir) => readdir, Err(err) => { @@ -1553,11 +1855,19 @@ impl<'s> Worker<'s> { } }; - if self.max_depth.map_or(false, |max| depth >= max) { - return WalkState::Skip; + for result in readdir.entries { + let state = self.generate_work( + &work.ignore, + depth + 1, + work.root_device, + result, + ); + if state.is_quit() { + return state; + } } - for result in readdir { - let state = self.generate_work(&work.ignore, depth + 1, work.root_device, result); + for err in readdir.errors { + let state = self.visitor.visit(Err(err)); if state.is_quit() { return state; } @@ -1583,14 +1893,8 @@ impl<'s> Worker<'s> { ig: &Ignore, depth: usize, root_device: Option, - result: Result, + fs_dent: fs::DirEntry, ) -> 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) { Ok(dent) => DirEntry::new_raw(dent, None), Err(err) => { @@ -1626,26 +1930,24 @@ impl<'s> Worker<'s> { return WalkState::Continue; } } - let should_skip_filesize = if self.max_filesize.is_some() && !dent.is_dir() { - skip_filesize( - self.max_filesize.unwrap(), - dent.path(), - &dent.metadata().ok(), - ) - } else { - false - }; - let should_skip_filtered = if let Some(Filter(predicate)) = &self.filter { - !predicate(&dent) - } else { - false - }; + let should_skip_filesize = + if self.max_filesize.is_some() && !dent.is_dir() { + skip_filesize( + self.max_filesize.unwrap(), + dent.path(), + &dent.metadata().ok(), + ) + } else { + false + }; + let should_skip_filtered = + if let Some(Filter(predicate)) = &self.filter { + !predicate(&dent) + } else { + false + }; 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 } @@ -1686,6 +1988,9 @@ impl<'s> Worker<'s> { } // Wait for next `Work` or `Quit` message. loop { + if self.is_quit_now() { + return None; + } if let Some(v) = self.recv() { self.activate_worker(); value = Some(v); @@ -1739,24 +2044,25 @@ 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( ig_parent: &Ignore, child_path: &Path, child_depth: usize, ) -> Result<(), Error> { 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| { - 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 { return Err(Error::Loop { @@ -1771,7 +2077,11 @@ fn check_symlink_loop( // Before calling this function, make sure that you ensure that is really // necessary as the arguments imply a file stat. -fn skip_filesize(max_filesize: u64, path: &Path, ent: &Option) -> bool { +fn skip_filesize( + max_filesize: u64, + path: &Path, + ent: &Option, +) -> bool { let filesize = match *ent { Some(ref md) => Some(md.len()), None => None, @@ -1843,9 +2153,9 @@ fn path_equals(dent: &DirEntry, handle: &Handle) -> Result { if dent.is_stdin() || never_equal(dent, handle) { return Ok(false); } - Handle::from_path(dent.path()) - .map(|h| &h == handle) - .map_err(|err| Error::Io(err).with_path(dent.path())) + Handle::from_path(dent.path()).map(|h| &h == handle).map_err(|err| { + Error::Io(err).with_depth(dent.depth()).with_path(dent.path()) + }) } /// Returns true if the given walkdir entry corresponds to a directory. @@ -1863,16 +2173,14 @@ fn walkdir_is_dir(dent: &walkdir::DirEntry) -> bool { if !dent.file_type().is_symlink() || dent.depth() > 0 { 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 /// given root device. fn is_same_file_system(root_device: u64, path: &Path) -> Result { - let dent_device = device_num(path).map_err(|err| Error::Io(err).with_path(path))?; + let dent_device = + device_num(path).map_err(|err| Error::Io(err).with_path(path))?; Ok(root_device == dent_device) } @@ -1885,7 +2193,7 @@ fn device_num>(path: P) -> io::Result { #[cfg(windows)] fn device_num>(path: P) -> io::Result { - use winapi_util::{file, Handle}; + use winapi_util::{Handle, file}; let h = Handle::from_path_any(path)?; file::information(h).map(|info| info.volume_serial_number()) @@ -1931,11 +2239,7 @@ mod tests { } 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 { @@ -1955,7 +2259,10 @@ mod tests { paths } - fn walk_collect_parallel(prefix: &Path, builder: &WalkBuilder) -> Vec { + fn walk_collect_parallel( + prefix: &Path, + builder: &WalkBuilder, + ) -> Vec { let mut paths = vec![]; for dent in walk_collect_entries_parallel(builder) { let path = dent.path().strip_prefix(prefix).unwrap(); @@ -2144,6 +2451,72 @@ 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] + fn min_depth() { + let td = tmpdir(); + mkdirp(td.path().join("a/b/c")); + wfile(td.path().join("foo"), ""); + wfile(td.path().join("a/foo"), ""); + wfile(td.path().join("a/b/foo"), ""); + wfile(td.path().join("a/b/c/foo"), ""); + + let builder = WalkBuilder::new(td.path()); + assert_paths( + td.path(), + &builder, + &["a", "a/b", "a/b/c", "foo", "a/foo", "a/b/foo", "a/b/c/foo"], + ); + let mut builder = WalkBuilder::new(td.path()); + assert_paths( + td.path(), + &builder.min_depth(Some(0)), + &["a", "a/b", "a/b/c", "foo", "a/foo", "a/b/foo", "a/b/c/foo"], + ); + assert_paths( + td.path(), + &builder.min_depth(Some(1)), + &["a", "a/b", "a/b/c", "foo", "a/foo", "a/b/foo", "a/b/c/foo"], + ); + assert_paths( + td.path(), + builder.min_depth(Some(2)), + &["a/b", "a/b/c", "a/b/c/foo", "a/b/foo", "a/foo"], + ); + assert_paths( + td.path(), + builder.min_depth(Some(3)), + &["a/b/c", "a/b/c/foo", "a/b/foo"], + ); + assert_paths(td.path(), builder.min_depth(Some(10)), &[]); + + assert_paths( + td.path(), + builder.min_depth(Some(2)).max_depth(Some(1)), + &["a/b", "a/foo"], + ); + } + #[test] fn max_filesize() { let td = tmpdir(); @@ -2209,7 +2582,9 @@ mod tests { assert_eq!(1, dents.len()); assert!(!dents[0].path_is_symlink()); - let dents = walk_collect_entries_parallel(&WalkBuilder::new(td.path().join("foo"))); + let dents = walk_collect_entries_parallel(&WalkBuilder::new( + td.path().join("foo"), + )); assert_eq!(1, dents.len()); assert!(!dents[0].path_is_symlink()); } @@ -2295,8 +2670,88 @@ mod tests { assert_paths( td.path(), - &WalkBuilder::new(td.path()).filter_entry(|entry| entry.file_name() != OsStr::new("a")), + &WalkBuilder::new(td.path()) + .filter_entry(|entry| entry.file_name() != OsStr::new("a")), &["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) + }); + } } diff --git a/crates/ignore/tests/gitignore_matched_path_or_any_parents_tests.rs b/crates/ignore/tests/gitignore_matched_path_or_any_parents_tests.rs index b7b7c6f95..ecb7b47e3 100644 --- a/crates/ignore/tests/gitignore_matched_path_or_any_parents_tests.rs +++ b/crates/ignore/tests/gitignore_matched_path_or_any_parents_tests.rs @@ -2,7 +2,8 @@ use std::path::Path; use ignore::gitignore::{Gitignore, GitignoreBuilder}; -const IGNORE_FILE: &'static str = "tests/gitignore_matched_path_or_any_parents_tests.gitignore"; +const IGNORE_FILE: &'static str = + "tests/gitignore_matched_path_or_any_parents_tests.gitignore"; fn get_gitignore() -> Gitignore { let mut builder = GitignoreBuilder::new("ROOT"); @@ -23,7 +24,9 @@ fn test_path_should_be_under_root() { #[test] fn test_files_in_root() { let gitignore = get_gitignore(); - let m = |path: &str| gitignore.matched_path_or_any_parents(Path::new(path), false); + let m = |path: &str| { + gitignore.matched_path_or_any_parents(Path::new(path), false) + }; // 0x assert!(m("ROOT/file_root_00").is_ignore()); @@ -53,7 +56,9 @@ fn test_files_in_root() { #[test] fn test_files_in_deep() { let gitignore = get_gitignore(); - let m = |path: &str| gitignore.matched_path_or_any_parents(Path::new(path), false); + let m = |path: &str| { + gitignore.matched_path_or_any_parents(Path::new(path), false) + }; // 0x assert!(m("ROOT/parent_dir/file_deep_00").is_ignore()); @@ -83,8 +88,9 @@ fn test_files_in_deep() { #[test] fn test_dirs_in_root() { let gitignore = get_gitignore(); - let m = - |path: &str, is_dir: bool| gitignore.matched_path_or_any_parents(Path::new(path), is_dir); + let m = |path: &str, is_dir: bool| { + gitignore.matched_path_or_any_parents(Path::new(path), is_dir) + }; // 00 assert!(m("ROOT/dir_root_00", true).is_ignore()); @@ -186,20 +192,25 @@ fn test_dirs_in_root() { #[test] fn test_dirs_in_deep() { let gitignore = get_gitignore(); - let m = - |path: &str, is_dir: bool| gitignore.matched_path_or_any_parents(Path::new(path), is_dir); + let m = |path: &str, is_dir: bool| { + gitignore.matched_path_or_any_parents(Path::new(path), is_dir) + }; // 00 assert!(m("ROOT/parent_dir/dir_deep_00", true).is_ignore()); assert!(m("ROOT/parent_dir/dir_deep_00/file", false).is_ignore()); assert!(m("ROOT/parent_dir/dir_deep_00/child_dir", true).is_ignore()); - assert!(m("ROOT/parent_dir/dir_deep_00/child_dir/file", false).is_ignore()); + assert!( + m("ROOT/parent_dir/dir_deep_00/child_dir/file", false).is_ignore() + ); // 01 assert!(m("ROOT/parent_dir/dir_deep_01", true).is_ignore()); assert!(m("ROOT/parent_dir/dir_deep_01/file", false).is_ignore()); assert!(m("ROOT/parent_dir/dir_deep_01/child_dir", true).is_ignore()); - assert!(m("ROOT/parent_dir/dir_deep_01/child_dir/file", false).is_ignore()); + assert!( + m("ROOT/parent_dir/dir_deep_01/child_dir/file", false).is_ignore() + ); // 02 assert!(m("ROOT/parent_dir/dir_deep_02", true).is_none()); @@ -241,51 +252,67 @@ fn test_dirs_in_deep() { assert!(m("ROOT/parent_dir/dir_deep_20", true).is_ignore()); assert!(m("ROOT/parent_dir/dir_deep_20/file", false).is_ignore()); assert!(m("ROOT/parent_dir/dir_deep_20/child_dir", true).is_ignore()); - assert!(m("ROOT/parent_dir/dir_deep_20/child_dir/file", false).is_ignore()); + assert!( + m("ROOT/parent_dir/dir_deep_20/child_dir/file", false).is_ignore() + ); // 21 assert!(m("ROOT/parent_dir/dir_deep_21", true).is_ignore()); assert!(m("ROOT/parent_dir/dir_deep_21/file", false).is_ignore()); assert!(m("ROOT/parent_dir/dir_deep_21/child_dir", true).is_ignore()); - assert!(m("ROOT/parent_dir/dir_deep_21/child_dir/file", false).is_ignore()); + assert!( + m("ROOT/parent_dir/dir_deep_21/child_dir/file", false).is_ignore() + ); // 22 // dir itself doesn't match assert!(m("ROOT/parent_dir/dir_deep_22", true).is_none()); assert!(m("ROOT/parent_dir/dir_deep_22/file", false).is_ignore()); assert!(m("ROOT/parent_dir/dir_deep_22/child_dir", true).is_ignore()); - assert!(m("ROOT/parent_dir/dir_deep_22/child_dir/file", false).is_ignore()); + assert!( + m("ROOT/parent_dir/dir_deep_22/child_dir/file", false).is_ignore() + ); // 23 // dir itself doesn't match assert!(m("ROOT/parent_dir/dir_deep_23", true).is_none()); assert!(m("ROOT/parent_dir/dir_deep_23/file", false).is_ignore()); assert!(m("ROOT/parent_dir/dir_deep_23/child_dir", true).is_ignore()); - assert!(m("ROOT/parent_dir/dir_deep_23/child_dir/file", false).is_ignore()); + assert!( + m("ROOT/parent_dir/dir_deep_23/child_dir/file", false).is_ignore() + ); // 30 assert!(m("ROOT/parent_dir/dir_deep_30", true).is_ignore()); assert!(m("ROOT/parent_dir/dir_deep_30/file", false).is_ignore()); assert!(m("ROOT/parent_dir/dir_deep_30/child_dir", true).is_ignore()); - assert!(m("ROOT/parent_dir/dir_deep_30/child_dir/file", false).is_ignore()); + assert!( + m("ROOT/parent_dir/dir_deep_30/child_dir/file", false).is_ignore() + ); // 31 assert!(m("ROOT/parent_dir/dir_deep_31", true).is_ignore()); assert!(m("ROOT/parent_dir/dir_deep_31/file", false).is_ignore()); assert!(m("ROOT/parent_dir/dir_deep_31/child_dir", true).is_ignore()); - assert!(m("ROOT/parent_dir/dir_deep_31/child_dir/file", false).is_ignore()); + assert!( + m("ROOT/parent_dir/dir_deep_31/child_dir/file", false).is_ignore() + ); // 32 // dir itself doesn't match assert!(m("ROOT/parent_dir/dir_deep_32", true).is_none()); assert!(m("ROOT/parent_dir/dir_deep_32/file", false).is_ignore()); assert!(m("ROOT/parent_dir/dir_deep_32/child_dir", true).is_ignore()); - assert!(m("ROOT/parent_dir/dir_deep_32/child_dir/file", false).is_ignore()); + assert!( + m("ROOT/parent_dir/dir_deep_32/child_dir/file", false).is_ignore() + ); // 33 // dir itself doesn't match assert!(m("ROOT/parent_dir/dir_deep_33", true).is_none()); assert!(m("ROOT/parent_dir/dir_deep_33/file", false).is_ignore()); assert!(m("ROOT/parent_dir/dir_deep_33/child_dir", true).is_ignore()); - assert!(m("ROOT/parent_dir/dir_deep_33/child_dir/file", false).is_ignore()); + assert!( + m("ROOT/parent_dir/dir_deep_33/child_dir/file", false).is_ignore() + ); } diff --git a/crates/ignore/tests/gitignore_skip_bom.gitignore b/crates/ignore/tests/gitignore_skip_bom.gitignore new file mode 100644 index 000000000..a78c13a9b --- /dev/null +++ b/crates/ignore/tests/gitignore_skip_bom.gitignore @@ -0,0 +1,2 @@ +ignore/this/path +# This file begins with a BOM (U+FEFF) diff --git a/crates/ignore/tests/gitignore_skip_bom.rs b/crates/ignore/tests/gitignore_skip_bom.rs new file mode 100644 index 000000000..14d533d63 --- /dev/null +++ b/crates/ignore/tests/gitignore_skip_bom.rs @@ -0,0 +1,17 @@ +use ignore::gitignore::GitignoreBuilder; + +const IGNORE_FILE: &'static str = "tests/gitignore_skip_bom.gitignore"; + +/// Skip a Byte-Order Mark (BOM) at the beginning of the file, matching Git's +/// behavior. +/// +/// Ref: +#[test] +fn gitignore_skip_bom() { + let mut builder = GitignoreBuilder::new("ROOT"); + let error = builder.add(IGNORE_FILE); + assert!(error.is_none(), "failed to open gitignore file"); + let g = builder.build().unwrap(); + + assert!(g.matched("ignore/this/path", false).is_ignore()); +} diff --git a/crates/node/.gitignore b/crates/node/.gitignore index d2200a62e..c3f2ec696 100644 --- a/crates/node/.gitignore +++ b/crates/node/.gitignore @@ -202,5 +202,6 @@ index.js browser.js tailwindcss-oxide.wasi-browser.js tailwindcss-oxide.wasi.cjs +tailwindcss-oxide.wasi.d.cts wasi-worker-browser.mjs wasi-worker.mjs diff --git a/crates/node/Cargo.toml b/crates/node/Cargo.toml index 87fa3b559..a3df7b813 100644 --- a/crates/node/Cargo.toml +++ b/crates/node/Cargo.toml @@ -8,10 +8,10 @@ crate-type = ["cdylib"] [dependencies] # Default enable napi4 feature, see https://nodejs.org/api/n-api.html#node-api-version-matrix -napi = { version = "2.16.17", default-features = false, features = ["napi4"] } -napi-derive = "2.16.13" +napi = { version = "3.11.0", default-features = false, features = ["napi4"] } +napi-derive = "3.6.0" tailwindcss-oxide = { path = "../oxide" } -rayon = "1.10.0" +rayon = "1.12.0" [build-dependencies] -napi-build = "2.1.6" +napi-build = "2.3.2" diff --git a/crates/node/npm/android-arm-eabi/package.json b/crates/node/npm/android-arm-eabi/package.json index 9b3606683..d46ceb237 100644 --- a/crates/node/npm/android-arm-eabi/package.json +++ b/crates/node/npm/android-arm-eabi/package.json @@ -1,6 +1,6 @@ { "name": "@tailwindcss/oxide-android-arm-eabi", - "version": "4.1.12", + "version": "4.3.3", "repository": { "type": "git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git", @@ -22,6 +22,6 @@ }, "license": "MIT", "engines": { - "node": ">= 10" + "node": ">= 20" } } diff --git a/crates/node/npm/android-arm64/package.json b/crates/node/npm/android-arm64/package.json index 82c922f09..e6e9d577d 100644 --- a/crates/node/npm/android-arm64/package.json +++ b/crates/node/npm/android-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@tailwindcss/oxide-android-arm64", - "version": "4.1.12", + "version": "4.3.3", "repository": { "type": "git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git", @@ -22,6 +22,6 @@ }, "license": "MIT", "engines": { - "node": ">= 10" + "node": ">= 20" } } diff --git a/crates/node/npm/darwin-arm64/package.json b/crates/node/npm/darwin-arm64/package.json index 579e26a95..1cb737c2e 100644 --- a/crates/node/npm/darwin-arm64/package.json +++ b/crates/node/npm/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@tailwindcss/oxide-darwin-arm64", - "version": "4.1.12", + "version": "4.3.3", "repository": { "type": "git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git", @@ -22,6 +22,6 @@ }, "license": "MIT", "engines": { - "node": ">= 10" + "node": ">= 20" } } diff --git a/crates/node/npm/darwin-x64/package.json b/crates/node/npm/darwin-x64/package.json index 9d90be470..ffd75e54b 100644 --- a/crates/node/npm/darwin-x64/package.json +++ b/crates/node/npm/darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@tailwindcss/oxide-darwin-x64", - "version": "4.1.12", + "version": "4.3.3", "repository": { "type": "git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git", @@ -22,6 +22,6 @@ }, "license": "MIT", "engines": { - "node": ">= 10" + "node": ">= 20" } } diff --git a/crates/node/npm/freebsd-x64/package.json b/crates/node/npm/freebsd-x64/package.json index f5fc01409..e248f39d1 100644 --- a/crates/node/npm/freebsd-x64/package.json +++ b/crates/node/npm/freebsd-x64/package.json @@ -1,6 +1,6 @@ { "name": "@tailwindcss/oxide-freebsd-x64", - "version": "4.1.12", + "version": "4.3.3", "repository": { "type": "git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git", @@ -22,6 +22,6 @@ }, "license": "MIT", "engines": { - "node": ">= 10" + "node": ">= 20" } } diff --git a/crates/node/npm/linux-arm-gnueabihf/package.json b/crates/node/npm/linux-arm-gnueabihf/package.json index 37e0dfe38..50d2aef73 100644 --- a/crates/node/npm/linux-arm-gnueabihf/package.json +++ b/crates/node/npm/linux-arm-gnueabihf/package.json @@ -1,6 +1,6 @@ { "name": "@tailwindcss/oxide-linux-arm-gnueabihf", - "version": "4.1.12", + "version": "4.3.3", "repository": { "type": "git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git", @@ -22,6 +22,6 @@ }, "license": "MIT", "engines": { - "node": ">= 10" + "node": ">= 20" } } diff --git a/crates/node/npm/linux-arm64-gnu/package.json b/crates/node/npm/linux-arm64-gnu/package.json index 74e4d9ec7..5875213f8 100644 --- a/crates/node/npm/linux-arm64-gnu/package.json +++ b/crates/node/npm/linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@tailwindcss/oxide-linux-arm64-gnu", - "version": "4.1.12", + "version": "4.3.3", "repository": { "type": "git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git", @@ -22,7 +22,7 @@ }, "license": "MIT", "engines": { - "node": ">= 10" + "node": ">= 20" }, "libc": [ "glibc" diff --git a/crates/node/npm/linux-arm64-musl/package.json b/crates/node/npm/linux-arm64-musl/package.json index 784da745b..9da37f3fd 100644 --- a/crates/node/npm/linux-arm64-musl/package.json +++ b/crates/node/npm/linux-arm64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@tailwindcss/oxide-linux-arm64-musl", - "version": "4.1.12", + "version": "4.3.3", "repository": { "type": "git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git", @@ -22,7 +22,7 @@ }, "license": "MIT", "engines": { - "node": ">= 10" + "node": ">= 20" }, "libc": [ "musl" diff --git a/crates/node/npm/linux-x64-gnu/package.json b/crates/node/npm/linux-x64-gnu/package.json index 03743935b..d409743fa 100644 --- a/crates/node/npm/linux-x64-gnu/package.json +++ b/crates/node/npm/linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@tailwindcss/oxide-linux-x64-gnu", - "version": "4.1.12", + "version": "4.3.3", "repository": { "type": "git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git", @@ -22,7 +22,7 @@ }, "license": "MIT", "engines": { - "node": ">= 10" + "node": ">= 20" }, "libc": [ "glibc" diff --git a/crates/node/npm/linux-x64-musl/package.json b/crates/node/npm/linux-x64-musl/package.json index 818e666ae..5b404fd70 100644 --- a/crates/node/npm/linux-x64-musl/package.json +++ b/crates/node/npm/linux-x64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@tailwindcss/oxide-linux-x64-musl", - "version": "4.1.12", + "version": "4.3.3", "repository": { "type": "git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git", @@ -22,7 +22,7 @@ }, "license": "MIT", "engines": { - "node": ">= 10" + "node": ">= 20" }, "libc": [ "musl" diff --git a/crates/node/npm/wasm32-wasi/.npmrc b/crates/node/npm/wasm32-wasi/.npmrc deleted file mode 100644 index d67f37488..000000000 --- a/crates/node/npm/wasm32-wasi/.npmrc +++ /dev/null @@ -1 +0,0 @@ -node-linker=hoisted diff --git a/crates/node/npm/wasm32-wasi/README.md b/crates/node/npm/wasm32-wasi/README.md index 9ec3a7b3f..2a5368149 100644 --- a/crates/node/npm/wasm32-wasi/README.md +++ b/crates/node/npm/wasm32-wasi/README.md @@ -1,3 +1,3 @@ # `@tailwindcss/oxide-wasm32-wasi` -This is the **wasm32-wasip1-threads** binary for `@tailwindcss/oxide` +This is the **wasm32-wasip1-threads** build of `@tailwindcss/oxide` diff --git a/crates/node/npm/wasm32-wasi/package.json b/crates/node/npm/wasm32-wasi/package.json index 8d9424d32..cee9fb05a 100644 --- a/crates/node/npm/wasm32-wasi/package.json +++ b/crates/node/npm/wasm32-wasi/package.json @@ -1,9 +1,6 @@ { "name": "@tailwindcss/oxide-wasm32-wasi", - "version": "4.1.12", - "cpu": [ - "wasm32" - ], + "version": "4.3.3", "main": "tailwindcss-oxide.wasi.cjs", "files": [ "tailwindcss-oxide.wasm32-wasi.wasm", @@ -14,7 +11,7 @@ ], "license": "MIT", "engines": { - "node": ">=14.0.0" + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" }, "publishConfig": { "provenance": true, @@ -27,12 +24,12 @@ }, "browser": "tailwindcss-oxide.wasi-browser.js", "dependencies": { - "@napi-rs/wasm-runtime": "^0.2.12", - "@emnapi/core": "^1.4.5", - "@emnapi/runtime": "^1.4.5", - "@tybys/wasm-util": "^0.10.0", - "@emnapi/wasi-threads": "^1.0.4", - "tslib": "^2.8.0" + "@napi-rs/wasm-runtime": "^1.2.2", + "@emnapi/core": "^1.11.3", + "@emnapi/runtime": "^1.11.3", + "@tybys/wasm-util": "^0.10.3", + "@emnapi/wasi-threads": "^1.2.3", + "tslib": "^2.8.1" }, "bundledDependencies": [ "@napi-rs/wasm-runtime", diff --git a/crates/node/npm/win32-arm64-msvc/package.json b/crates/node/npm/win32-arm64-msvc/package.json index aa0d2b4e9..991727a32 100644 --- a/crates/node/npm/win32-arm64-msvc/package.json +++ b/crates/node/npm/win32-arm64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@tailwindcss/oxide-win32-arm64-msvc", - "version": "4.1.12", + "version": "4.3.3", "repository": { "type": "git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git", @@ -22,6 +22,6 @@ }, "license": "MIT", "engines": { - "node": ">= 10" + "node": ">= 20" } } diff --git a/crates/node/npm/win32-x64-msvc/package.json b/crates/node/npm/win32-x64-msvc/package.json index c60d85bae..82d75c300 100644 --- a/crates/node/npm/win32-x64-msvc/package.json +++ b/crates/node/npm/win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@tailwindcss/oxide-win32-x64-msvc", - "version": "4.1.12", + "version": "4.3.3", "repository": { "type": "git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git", @@ -22,6 +22,6 @@ }, "license": "MIT", "engines": { - "node": ">= 10" + "node": ">= 20" } } diff --git a/crates/node/package.json b/crates/node/package.json index 20e4273e8..be09427fb 100644 --- a/crates/node/package.json +++ b/crates/node/package.json @@ -1,6 +1,6 @@ { "name": "@tailwindcss/oxide", - "version": "4.1.12", + "version": "4.3.3", "repository": { "type": "git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git", @@ -32,38 +32,33 @@ } }, "license": "MIT", - "dependencies": { - "tar": "^7.4.3", - "detect-libc": "^2.0.4" - }, "devDependencies": { - "@napi-rs/cli": "^3.0.0-alpha.78", - "@napi-rs/wasm-runtime": "^0.2.12", - "emnapi": "1.4.5" + "@emnapi/core": "1.11.3", + "@emnapi/runtime": "1.11.3", + "@napi-rs/cli": "3.7.4", + "@napi-rs/wasm-runtime": "^1.2.2", + "emnapi": "1.11.3" }, "engines": { - "node": ">= 10" + "node": ">= 20" }, "files": [ "index.js", - "index.d.ts", - "scripts/install.js" + "index.d.ts" ], "publishConfig": { "provenance": true, "access": "public" }, "scripts": { - "artifacts": "napi artifacts", "build": "pnpm run build:platform && pnpm run build:wasm", - "build:platform": "napi build --platform --release --no-const-enum", + "build:platform": "napi build --platform --release", "postbuild:platform": "node ./scripts/move-artifacts.mjs", - "build:wasm": "napi build --release --target wasm32-wasip1-threads --no-const-enum", + "build:wasm": "napi build --release --target wasm32-wasip1-threads", "postbuild:wasm": "node ./scripts/move-artifacts.mjs", "dev": "cargo watch --quiet --shell 'npm run build'", - "build:debug": "napi build --platform --no-const-enum", - "version": "napi version", - "postinstall": "node ./scripts/install.js" + "build:debug": "napi build --platform", + "version": "napi version" }, "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "workspace:*", diff --git a/crates/node/scripts/install.js b/crates/node/scripts/install.js deleted file mode 100644 index f9cefe01c..000000000 --- a/crates/node/scripts/install.js +++ /dev/null @@ -1,143 +0,0 @@ -#!/usr/bin/env node - -/** - * @tailwindcss/oxide postinstall script - * - * This script ensures that the correct binary for the current platform and - * architecture is downloaded and available. - */ - -const fs = require('fs') -const path = require('path') -const https = require('https') -const { extract } = require('tar') -const packageJson = require('../package.json') -const detectLibc = require('detect-libc') - -const version = packageJson.version - -function getPlatformPackageName() { - let platform = process.platform - let arch = process.arch - - let libc = '' - if (platform === 'linux') { - libc = detectLibc.isNonGlibcLinuxSync() ? 'musl' : 'gnu' - } - - // Map to our package naming conventions - switch (platform) { - case 'darwin': - return arch === 'arm64' ? '@tailwindcss/oxide-darwin-arm64' : '@tailwindcss/oxide-darwin-x64' - case 'win32': - if (arch === 'arm64') return '@tailwindcss/oxide-win32-arm64-msvc' - if (arch === 'ia32') return '@tailwindcss/oxide-win32-ia32-msvc' - return '@tailwindcss/oxide-win32-x64-msvc' - case 'linux': - if (arch === 'x64') { - return libc === 'musl' - ? '@tailwindcss/oxide-linux-x64-musl' - : '@tailwindcss/oxide-linux-x64-gnu' - } else if (arch === 'arm64') { - return libc === 'musl' - ? '@tailwindcss/oxide-linux-arm64-musl' - : '@tailwindcss/oxide-linux-arm64-gnu' - } else if (arch === 'arm') { - return '@tailwindcss/oxide-linux-arm-gnueabihf' - } - break - case 'freebsd': - return '@tailwindcss/oxide-freebsd-x64' - case 'android': - return '@tailwindcss/oxide-android-arm64' - default: - return '@tailwindcss/oxide-wasm32-wasi' - } -} - -function isPackageAvailable(packageName) { - try { - require.resolve(packageName) - return true - } catch (e) { - return false - } -} - -// Extract all files from a tarball to a destination directory -async function extractTarball(tarballStream, destDir) { - if (!fs.existsSync(destDir)) { - fs.mkdirSync(destDir, { recursive: true }) - } - - return new Promise((resolve, reject) => { - tarballStream - .pipe(extract({ cwd: destDir, strip: 1 })) - .on('error', (err) => reject(err)) - .on('end', () => resolve()) - }) -} - -async function downloadAndExtractBinary(packageName) { - let tarballUrl = `https://registry.npmjs.org/${packageName}/-/${packageName.replace('@tailwindcss/', '')}-${version}.tgz` - console.log(`Downloading ${tarballUrl}...`) - - return new Promise((resolve) => { - https - .get(tarballUrl, (response) => { - if (response.statusCode === 302 || response.statusCode === 301) { - // Handle redirects - https.get(response.headers.location, handleResponse).on('error', (err) => { - console.error('Download error:', err) - resolve() - }) - return - } - - handleResponse(response) - - async function handleResponse(response) { - try { - if (response.statusCode !== 200) { - throw new Error(`Download failed with status code: ${response.statusCode}`) - } - - await extractTarball( - response, - path.join(__dirname, '..', 'node_modules', ...packageName.split('/')), - ) - console.log(`Successfully downloaded and installed ${packageName}`) - } catch (error) { - console.error('Error during extraction:', error) - resolve() - } finally { - resolve() - } - } - }) - .on('error', (err) => { - console.error('Download error:', err) - resolve() - }) - }) -} - -async function main() { - // Don't run this script in the package source - try { - if (fs.existsSync(path.join(__dirname, '..', 'build.rs'))) { - return - } - - let packageName = getPlatformPackageName() - if (!packageName) return - if (isPackageAvailable(packageName)) return - - await downloadAndExtractBinary(packageName) - } catch (error) { - console.error(error) - return - } -} - -main() diff --git a/crates/node/src/lib.rs b/crates/node/src/lib.rs index 11ff25b0a..fb32a818c 100644 --- a/crates/node/src/lib.rs +++ b/crates/node/src/lib.rs @@ -165,6 +165,11 @@ impl Scanner { self.scanner.get_files() } + #[napi(getter)] + pub fn scanned_files(&self) -> Vec { + self.scanner.get_scanned_files() + } + #[napi(getter)] pub fn globs(&mut self) -> Vec { self diff --git a/crates/oxide/Cargo.toml b/crates/oxide/Cargo.toml index 9b800b049..f8a5f6137 100644 --- a/crates/oxide/Cargo.toml +++ b/crates/oxide/Cargo.toml @@ -9,7 +9,6 @@ globwalk = "0.9.1" log = "0.4.22" rayon = "1.10.0" fxhash = { package = "rustc-hash", version = "2.1.1" } -crossbeam = "0.8.4" tracing = { version = "0.1.40", features = [] } tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } walkdir = "2.5.0" @@ -21,6 +20,7 @@ ignore = { path = "../ignore" } regex = "1.11.1" [dev-dependencies] +insta = "1.48.0" tempfile = "3.13.0" pretty_assertions = "1.4.1" unicode-width = "0.2.0" diff --git a/crates/oxide/src/cursor.rs b/crates/oxide/src/cursor.rs index 818a0ef1b..71cdccdf6 100644 --- a/crates/oxide/src/cursor.rs +++ b/crates/oxide/src/cursor.rs @@ -1,44 +1,49 @@ use std::{ascii::escape_default, fmt::Display}; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Copy)] pub struct Cursor<'a> { // The input we're scanning pub input: &'a [u8], // The location of the cursor in the input pub pos: usize, - - /// Is the cursor at the start of the input - pub at_start: bool, - - /// Is the cursor at the end of the input - pub at_end: bool, - - /// The previously consumed character - /// If `at_start` is true, this will be NUL - pub prev: u8, - - /// The current character - pub curr: u8, - - /// The upcoming character (if any) - /// If `at_end` is true, this will be NUL - pub next: u8, } impl<'a> Cursor<'a> { + #[inline(always)] pub fn new(input: &'a [u8]) -> Self { - let mut cursor = Self { - input, - pos: 0, - at_start: true, - at_end: false, - prev: 0x00, - curr: 0x00, - next: 0x00, - }; - cursor.move_to(0); - cursor + Self { input, pos: 0 } + } + + /// The current byte at `pos`, or 0x00 if past the end. + #[inline(always)] + pub fn curr(&self) -> u8 { + if self.pos < self.input.len() { + unsafe { *self.input.get_unchecked(self.pos) } + } else { + 0x00 + } + } + + /// The next byte at `pos + 1`, or 0x00 if past the end. + #[inline(always)] + pub fn next(&self) -> u8 { + let next_pos = self.pos + 1; + if next_pos < self.input.len() { + unsafe { *self.input.get_unchecked(next_pos) } + } else { + 0x00 + } + } + + /// The previous byte at `pos - 1`, or 0x00 if at the start. + #[inline(always)] + pub fn prev(&self) -> u8 { + if self.pos > 0 { + unsafe { *self.input.get_unchecked(self.pos - 1) } + } else { + 0x00 + } } pub fn advance_by(&mut self, amount: usize) { @@ -48,38 +53,15 @@ impl<'a> Cursor<'a> { #[inline(always)] pub fn advance(&mut self) { self.pos += 1; - - self.prev = self.curr; - self.curr = self.next; - self.next = *self - .input - .get(self.pos.saturating_add(1)) - .unwrap_or(&0x00u8); } #[inline(always)] pub fn advance_twice(&mut self) { self.pos += 2; - - self.prev = self.next; - self.curr = *self.input.get(self.pos).unwrap_or(&0x00u8); - self.next = *self - .input - .get(self.pos.saturating_add(1)) - .unwrap_or(&0x00u8); } pub fn move_to(&mut self, pos: usize) { - let len = self.input.len(); - let pos = pos.clamp(0, len); - - self.pos = pos; - self.at_start = pos == 0; - self.at_end = pos + 1 >= len; - - self.prev = *self.input.get(pos.wrapping_sub(1)).unwrap_or(&0x00u8); - self.curr = *self.input.get(pos).unwrap_or(&0x00u8); - self.next = *self.input.get(pos.saturating_add(1)).unwrap_or(&0x00u8); + self.pos = pos.min(self.input.len()); } } @@ -90,9 +72,9 @@ impl Display for Cursor<'_> { let pos = format!("{: >len_count$}", self.pos, len_count = len.len()); write!(f, "{}/{} ", pos, len)?; - if self.at_start { + if self.pos == 0 { write!(f, "S ")?; - } else if self.at_end { + } else if self.pos + 1 >= self.input.len() { write!(f, "E ")?; } else { write!(f, "M ")?; @@ -109,9 +91,9 @@ impl Display for Cursor<'_> { write!( f, "[{} {} {}]", - to_str(self.prev), - to_str(self.curr), - to_str(self.next) + to_str(self.prev()), + to_str(self.curr()), + to_str(self.next()) ) } } @@ -125,36 +107,28 @@ mod test { fn test_cursor() { let mut cursor = Cursor::new(b"hello world"); assert_eq!(cursor.pos, 0); - assert!(cursor.at_start); - assert!(!cursor.at_end); - assert_eq!(cursor.prev, 0x00); - assert_eq!(cursor.curr, b'h'); - assert_eq!(cursor.next, b'e'); + assert_eq!(cursor.prev(), 0x00); + assert_eq!(cursor.curr(), b'h'); + assert_eq!(cursor.next(), b'e'); cursor.advance_by(1); assert_eq!(cursor.pos, 1); - assert!(!cursor.at_start); - assert!(!cursor.at_end); - assert_eq!(cursor.prev, b'h'); - assert_eq!(cursor.curr, b'e'); - assert_eq!(cursor.next, b'l'); + assert_eq!(cursor.prev(), b'h'); + assert_eq!(cursor.curr(), b'e'); + assert_eq!(cursor.next(), b'l'); // Advancing too far should stop at the end cursor.advance_by(10); assert_eq!(cursor.pos, 11); - assert!(!cursor.at_start); - assert!(cursor.at_end); - assert_eq!(cursor.prev, b'd'); - assert_eq!(cursor.curr, 0x00); - assert_eq!(cursor.next, 0x00); + assert_eq!(cursor.prev(), b'd'); + assert_eq!(cursor.curr(), 0x00); + assert_eq!(cursor.next(), 0x00); // Can't advance past the end cursor.advance_by(1); assert_eq!(cursor.pos, 11); - assert!(!cursor.at_start); - assert!(cursor.at_end); - assert_eq!(cursor.prev, b'd'); - assert_eq!(cursor.curr, 0x00); - assert_eq!(cursor.next, 0x00); + assert_eq!(cursor.prev(), b'd'); + assert_eq!(cursor.curr(), 0x00); + assert_eq!(cursor.next(), 0x00); } } diff --git a/crates/oxide/src/extractor/arbitrary_property_machine.rs b/crates/oxide/src/extractor/arbitrary_property_machine.rs index 8ed3f1763..a43253339 100644 --- a/crates/oxide/src/extractor/arbitrary_property_machine.rs +++ b/crates/oxide/src/extractor/arbitrary_property_machine.rs @@ -74,7 +74,7 @@ impl Machine for ArbitraryPropertyMachine { #[inline] fn next(&mut self, cursor: &mut cursor::Cursor<'_>) -> MachineState { - match cursor.curr.into() { + match cursor.curr().into() { // Start of an arbitrary property Class::OpenBracket => { self.start_pos = cursor.pos; @@ -97,8 +97,8 @@ impl Machine for ArbitraryPropertyMachine { let len = cursor.input.len(); while cursor.pos < len { - match cursor.curr.into() { - Class::Dash => match cursor.next.into() { + match cursor.curr().into() { + Class::Dash => match cursor.next().into() { // Start of a CSS variable // // E.g.: `[--my-color:red]` @@ -137,7 +137,7 @@ impl ArbitraryPropertyMachine { fn parse_property_variable(&mut self, cursor: &mut cursor::Cursor<'_>) -> MachineState { match self.css_variable_machine.next(cursor) { MachineState::Idle => self.restart(), - MachineState::Done(_) => match cursor.next.into() { + MachineState::Done(_) => match cursor.next().into() { // End of the CSS variable, must be followed by a `:` // // E.g.: `[--my-color:red]` @@ -165,8 +165,8 @@ impl Machine for ArbitraryPropertyMachine { let len = cursor.input.len(); let start_of_value_pos = cursor.pos; while cursor.pos < len { - match cursor.curr.into() { - Class::Escape => match cursor.next.into() { + match cursor.curr().into() { + Class::Escape => match cursor.next().into() { // An escaped whitespace character is not allowed // // E.g.: `[color:var(--my-\ color)]` @@ -181,7 +181,7 @@ impl Machine for ArbitraryPropertyMachine { }, Class::OpenParen | Class::OpenBracket | Class::OpenCurly => { - if !self.bracket_stack.push(cursor.curr) { + if !self.bracket_stack.push(cursor.curr()) { return self.restart(); } cursor.advance(); @@ -190,7 +190,7 @@ impl Machine for ArbitraryPropertyMachine { Class::CloseParen | Class::CloseBracket | Class::CloseCurly if !self.bracket_stack.is_empty() => { - if !self.bracket_stack.pop(cursor.curr) { + if !self.bracket_stack.pop(cursor.curr()) { return self.restart(); } cursor.advance(); @@ -227,10 +227,16 @@ impl Machine for ArbitraryPropertyMachine { Class::Slash if start_of_value_pos == cursor.pos => return self.restart(), // String interpolation-like syntax is not allowed. E.g.: `[${x}]` - Class::Dollar if matches!(cursor.next.into(), Class::OpenCurly) => { + Class::Dollar if matches!(cursor.next().into(), Class::OpenCurly) => { return self.restart() } + // An `!` at the top-level is invalid. We don't allow things to end with + // `!important` either as we have dedicated syntax for this. + Class::Exclamation if self.bracket_stack.is_empty() => { + return self.restart(); + } + // Everything else is valid _ => cursor.advance(), }; @@ -293,6 +299,9 @@ enum Class { #[bytes(b'/')] Slash, + #[bytes(b'!')] + Exclamation, + #[bytes(b' ', b'\t', b'\n', b'\r', b'\x0C')] Whitespace, @@ -369,6 +378,9 @@ mod tests { "[background:url(https://example.com?q={[{[([{[[2]]}])]}]})]", vec!["[background:url(https://example.com?q={[{[([{[[2]]}])]}]})]"], ), + // A property containing `!` at the top-level is invalid + ("[color:red!]", vec![]), + ("[color:red!important]", vec![]), ] { for wrapper in [ // No wrapper diff --git a/crates/oxide/src/extractor/arbitrary_value_machine.rs b/crates/oxide/src/extractor/arbitrary_value_machine.rs index f5a612df1..cbef62c42 100644 --- a/crates/oxide/src/extractor/arbitrary_value_machine.rs +++ b/crates/oxide/src/extractor/arbitrary_value_machine.rs @@ -32,7 +32,7 @@ impl Machine for ArbitraryValueMachine { #[inline] fn next(&mut self, cursor: &mut cursor::Cursor<'_>) -> MachineState { // An arbitrary value must start with an open bracket - if Class::OpenBracket != cursor.curr.into() { + if Class::OpenBracket != cursor.curr().into() { return MachineState::Idle; } @@ -42,8 +42,8 @@ impl Machine for ArbitraryValueMachine { let len = cursor.input.len(); while cursor.pos < len { - match cursor.curr.into() { - Class::Escape => match cursor.next.into() { + match cursor.curr().into() { + Class::Escape => match cursor.next().into() { // An escaped whitespace character is not allowed // // E.g.: `[color:var(--my-\ color)]` @@ -61,7 +61,7 @@ impl Machine for ArbitraryValueMachine { }, Class::OpenParen | Class::OpenBracket | Class::OpenCurly => { - if !self.bracket_stack.push(cursor.curr) { + if !self.bracket_stack.push(cursor.curr()) { return self.restart(); } cursor.advance(); @@ -70,7 +70,7 @@ impl Machine for ArbitraryValueMachine { Class::CloseParen | Class::CloseBracket | Class::CloseCurly if !self.bracket_stack.is_empty() => { - if !self.bracket_stack.pop(cursor.curr) { + if !self.bracket_stack.pop(cursor.curr()) { return self.restart(); } cursor.advance(); @@ -96,7 +96,7 @@ impl Machine for ArbitraryValueMachine { Class::Whitespace => return self.restart(), // String interpolation-like syntax is not allowed. E.g.: `[${x}]` - Class::Dollar if matches!(cursor.next.into(), Class::OpenCurly) => { + Class::Dollar if matches!(cursor.next().into(), Class::OpenCurly) => { return self.restart() } diff --git a/crates/oxide/src/extractor/arbitrary_variable_machine.rs b/crates/oxide/src/extractor/arbitrary_variable_machine.rs index 6990e110b..e8d475557 100644 --- a/crates/oxide/src/extractor/arbitrary_variable_machine.rs +++ b/crates/oxide/src/extractor/arbitrary_variable_machine.rs @@ -80,13 +80,13 @@ impl Machine for ArbitraryVariableMachine { #[inline(always)] fn next(&mut self, cursor: &mut cursor::Cursor<'_>) -> MachineState { - match cursor.curr.into() { + match cursor.curr().into() { // Arbitrary variables start with `(` followed by a CSS variable // // E.g.: `(--my-variable)` // ^^ // - Class::OpenParen => match cursor.next.into() { + Class::OpenParen => match cursor.next().into() { Class::Dash => { self.start_pos = cursor.pos; cursor.advance(); @@ -117,7 +117,7 @@ impl Machine for ArbitraryVariableMachine { fn next(&mut self, cursor: &mut cursor::Cursor<'_>) -> MachineState { match self.css_variable_machine.next(cursor) { MachineState::Idle => self.restart(), - MachineState::Done(_) => match cursor.next.into() { + MachineState::Done(_) => match cursor.next().into() { // A CSS variable followed by a `,` means that there is a fallback // // E.g.: `(--my-color,red)` @@ -134,7 +134,7 @@ impl Machine for ArbitraryVariableMachine { _ => { cursor.advance(); - match cursor.curr.into() { + match cursor.curr().into() { // End of an arbitrary variable, must be followed by `)` Class::CloseParen => self.done(self.start_pos, cursor), @@ -156,7 +156,7 @@ impl Machine for ArbitraryVariableMachine { let len = cursor.input.len(); while cursor.pos < len { - match cursor.curr.into() { + match cursor.curr().into() { // Valid data type characters // // E.g.: `(length:--my-length)` @@ -169,7 +169,7 @@ impl Machine for ArbitraryVariableMachine { // // E.g.: `(length:--my-length)` // ^ - Class::Colon => match cursor.next.into() { + Class::Colon => match cursor.next().into() { Class::Dash => { cursor.advance(); return self.transition::().next(cursor); @@ -196,8 +196,8 @@ impl Machine for ArbitraryVariableMachine { fn next(&mut self, cursor: &mut cursor::Cursor<'_>) -> MachineState { let len = cursor.input.len(); while cursor.pos < len { - match cursor.curr.into() { - Class::Escape => match cursor.next.into() { + match cursor.curr().into() { + Class::Escape => match cursor.next().into() { // An escaped whitespace character is not allowed // // E.g.: `(--my-\ color)` @@ -212,7 +212,7 @@ impl Machine for ArbitraryVariableMachine { }, Class::OpenParen | Class::OpenBracket | Class::OpenCurly => { - if !self.bracket_stack.push(cursor.curr) { + if !self.bracket_stack.push(cursor.curr()) { return self.restart(); } cursor.advance(); @@ -221,7 +221,7 @@ impl Machine for ArbitraryVariableMachine { Class::CloseParen | Class::CloseBracket | Class::CloseCurly if !self.bracket_stack.is_empty() => { - if !self.bracket_stack.pop(cursor.curr) { + if !self.bracket_stack.pop(cursor.curr()) { return self.restart(); } cursor.advance(); @@ -253,7 +253,7 @@ impl Machine for ArbitraryVariableMachine { Class::Whitespace => return self.restart(), // String interpolation-like syntax is not allowed. E.g.: `[${x}]` - Class::Dollar if matches!(cursor.next.into(), Class::OpenCurly) => { + Class::Dollar if matches!(cursor.next().into(), Class::OpenCurly) => { return self.restart() } diff --git a/crates/oxide/src/extractor/bracket_stack.rs b/crates/oxide/src/extractor/bracket_stack.rs index f34e56ed3..2b8d3a13f 100644 --- a/crates/oxide/src/extractor/bracket_stack.rs +++ b/crates/oxide/src/extractor/bracket_stack.rs @@ -25,6 +25,7 @@ impl BracketStack { b'(' => b')', b'[' => b']', b'{' => b'}', + b'<' => b'>', _ => std::hint::unreachable_unchecked(), }; } diff --git a/crates/oxide/src/extractor/candidate_machine.rs b/crates/oxide/src/extractor/candidate_machine.rs index c82cc3707..a755cf02a 100644 --- a/crates/oxide/src/extractor/candidate_machine.rs +++ b/crates/oxide/src/extractor/candidate_machine.rs @@ -32,14 +32,14 @@ impl Machine for CandidateMachine { while cursor.pos < len { // Skip ahead for known characters that will never be part of a candidate. No need to // run any sub-machines. - if cursor.curr.is_ascii_whitespace() { + if cursor.curr().is_ascii_whitespace() { self.reset(); cursor.advance(); continue; } // Candidates don't start with these characters, so we can skip ahead. - if matches!(cursor.curr, b':' | b'"' | b'\'' | b'`') { + if matches!(cursor.curr(), b':' | b'"' | b'\'' | b'`') { self.reset(); cursor.advance(); continue; @@ -56,7 +56,7 @@ impl Machine for CandidateMachine { // E.g.: `Some Class` // ^ ^ Invalid, we can jump ahead to the next boundary // - if matches!(cursor.curr, b'<' | b'A'..=b'Z') { + if matches!(cursor.curr(), b'<' | b'A'..=b'Z') { if let Some(offset) = cursor.input[cursor.pos..] .iter() .position(|&c| is_valid_before_boundary(&c)) diff --git a/crates/oxide/src/extractor/css_variable_machine.rs b/crates/oxide/src/extractor/css_variable_machine.rs index 4e19c7111..7beb827ee 100644 --- a/crates/oxide/src/extractor/css_variable_machine.rs +++ b/crates/oxide/src/extractor/css_variable_machine.rs @@ -20,7 +20,7 @@ impl Machine for CssVariableMachine { #[inline] fn next(&mut self, cursor: &mut cursor::Cursor<'_>) -> MachineState { // CSS Variables must start with `--` - if Class::Dash != cursor.curr.into() || Class::Dash != cursor.next.into() { + if Class::Dash != cursor.curr().into() || Class::Dash != cursor.next().into() { return MachineState::Idle; } @@ -30,11 +30,11 @@ impl Machine for CssVariableMachine { cursor.advance_twice(); while cursor.pos < len { - match cursor.curr.into() { + match cursor.curr().into() { // https://drafts.csswg.org/css-syntax-3/#ident-token-diagram // Class::AllowedCharacter | Class::Dash => { - match cursor.next.into() { + match cursor.next().into() { // Valid character followed by a valid character or an escape character // // E.g.: `--my-variable` @@ -58,7 +58,7 @@ impl Machine for CssVariableMachine { } } - Class::Escape => match cursor.next.into() { + Class::Escape => match cursor.next().into() { // An escaped whitespace character is not allowed // // In CSS it is allowed, but in the context of a class it's not because then we diff --git a/crates/oxide/src/extractor/mod.rs b/crates/oxide/src/extractor/mod.rs index add0de4ac..a3d806a27 100644 --- a/crates/oxide/src/extractor/mod.rs +++ b/crates/oxide/src/extractor/mod.rs @@ -86,7 +86,7 @@ impl<'a> Extractor<'a> { { let cursor = &mut self.cursor.clone(); while cursor.pos < len { - if cursor.curr.is_ascii_whitespace() { + if cursor.curr().is_ascii_whitespace() { cursor.advance(); continue; } @@ -104,7 +104,7 @@ impl<'a> Extractor<'a> { let cursor = &mut self.cursor.clone(); while cursor.pos < len { - if cursor.curr.is_ascii_whitespace() { + if cursor.curr().is_ascii_whitespace() { cursor.advance(); continue; } @@ -147,7 +147,7 @@ impl<'a> Extractor<'a> { let cursor = &mut self.cursor.clone(); while cursor.pos < len { - if cursor.curr.is_ascii_whitespace() { + if cursor.curr().is_ascii_whitespace() { cursor.advance(); continue; } @@ -238,7 +238,7 @@ mod tests { use std::hint::black_box; fn pre_process_input(input: &str, extension: &str) -> String { - let input = crate::scanner::pre_process_input(input.as_bytes(), extension); + let input = crate::scanner::pre_process_input(input.as_bytes().to_vec(), extension); String::from_utf8(input).unwrap() } @@ -908,6 +908,18 @@ mod tests { ); } + // https://github.com/tailwindlabs/tailwindcss/issues/20233 + #[test] + fn test_template_toolkit_syntax() { + assert_extract_candidates_contains( + &pre_process_input( + r#"

"#, + "tx", + ), + vec!["bg-white/40", "bg-white/10"], + ); + } + // https://github.com/tailwindlabs/tailwindcss/issues/17050 #[test] fn test_haml_syntax() { diff --git a/crates/oxide/src/extractor/modifier_machine.rs b/crates/oxide/src/extractor/modifier_machine.rs index 22668a828..811bc9ded 100644 --- a/crates/oxide/src/extractor/modifier_machine.rs +++ b/crates/oxide/src/extractor/modifier_machine.rs @@ -31,14 +31,14 @@ impl Machine for ModifierMachine { #[inline] fn next(&mut self, cursor: &mut cursor::Cursor<'_>) -> MachineState { // A modifier must start with a `/`, everything else is not a valid start of a modifier - if Class::Slash != cursor.curr.into() { + if Class::Slash != cursor.curr().into() { return MachineState::Idle; } let start_pos = cursor.pos; cursor.advance(); - match cursor.curr.into() { + match cursor.curr().into() { // Start of an arbitrary value: // // ``` @@ -70,9 +70,9 @@ impl Machine for ModifierMachine { Class::ValidStart => { let len = cursor.input.len(); while cursor.pos < len { - match cursor.curr.into() { + match cursor.curr().into() { Class::ValidStart | Class::ValidInside => { - match cursor.next.into() { + match cursor.next().into() { // Only valid characters are allowed, if followed by another valid character Class::ValidStart | Class::ValidInside => cursor.advance(), diff --git a/crates/oxide/src/extractor/named_utility_machine.rs b/crates/oxide/src/extractor/named_utility_machine.rs index 7c847a2e2..24dc027b2 100644 --- a/crates/oxide/src/extractor/named_utility_machine.rs +++ b/crates/oxide/src/extractor/named_utility_machine.rs @@ -52,8 +52,8 @@ impl Machine for NamedUtilityMachine { #[inline] fn next(&mut self, cursor: &mut cursor::Cursor<'_>) -> MachineState { - match cursor.curr.into() { - Class::AlphaLower => match cursor.next.into() { + match cursor.curr().into() { + Class::AlphaLower => match cursor.next().into() { // Valid single character utility in between quotes // // E.g.: `
` @@ -90,7 +90,7 @@ impl Machine for NamedUtilityMachine { // // E.g.: `-mx-2.5` // ^^ - Class::Dash => match cursor.next.into() { + Class::Dash => match cursor.next().into() { Class::AlphaLower => { self.start_pos = cursor.pos; cursor.advance(); @@ -118,7 +118,7 @@ impl Machine for NamedUtilityMachine { let len = cursor.input.len(); while cursor.pos < len { - match cursor.curr.into() { + match cursor.curr().into() { // Followed by a boundary character, we are at the end of the utility. // // E.g.: `'flex'` @@ -132,14 +132,14 @@ impl Machine for NamedUtilityMachine { // E.g.: `:div="{ flex: true }"` (JavaScript object syntax) // ^ Class::AlphaLower | Class::AlphaUpper => { - if is_valid_after_boundary(&cursor.next) || { + if is_valid_after_boundary(&cursor.next()) || { // Or any of these characters // // - `:`, because of JS object keys // - `/`, because of modifiers // - `!`, because of important matches!( - cursor.next.into(), + cursor.next().into(), Class::Colon | Class::Slash | Class::Exclamation ) } { @@ -150,7 +150,7 @@ impl Machine for NamedUtilityMachine { cursor.advance() } - Class::Dash => match cursor.next.into() { + Class::Dash => match cursor.next().into() { // Start of an arbitrary value // // E.g.: `bg-[#0088cc]` @@ -196,7 +196,7 @@ impl Machine for NamedUtilityMachine { _ => return self.restart(), }, - Class::Underscore => match cursor.next.into() { + Class::Underscore => match cursor.next().into() { // Valid characters _if_ followed by another valid character. These characters are // only valid inside of the utility but not at the end of the utility. // @@ -225,14 +225,14 @@ impl Machine for NamedUtilityMachine { // ^ // E.g.: `:div="{ flex: true }"` (JavaScript object syntax) // ^ - _ if is_valid_after_boundary(&cursor.next) || { + _ if is_valid_after_boundary(&cursor.next()) || { // Or any of these characters // // - `:`, because of JS object keys // - `/`, because of modifiers // - `!`, because of important matches!( - cursor.next.into(), + cursor.next().into(), Class::Colon | Class::Slash | Class::Exclamation ) } => @@ -249,11 +249,11 @@ impl Machine for NamedUtilityMachine { // E.g.: `px-2.5` // ^^^ Class::Dot => { - if !matches!(cursor.prev.into(), Class::Number) { + if !matches!(cursor.prev().into(), Class::Number) { return self.restart(); } - if !matches!(cursor.next.into(), Class::Number) { + if !matches!(cursor.next().into(), Class::Number) { return self.restart(); } @@ -278,18 +278,19 @@ impl Machine for NamedUtilityMachine { // Class::Number => { if !matches!( - cursor.prev.into(), + cursor.prev().into(), Class::Dash | Class::Underscore | Class::Dot | Class::Number | Class::AlphaLower + | Class::AlphaUpper ) { return self.restart(); } if !matches!( - cursor.next.into(), + cursor.next().into(), Class::Dot | Class::Number | Class::AlphaLower @@ -313,7 +314,7 @@ impl Machine for NamedUtilityMachine { // ^^ // ``` Class::Percent => { - if !matches!(cursor.prev.into(), Class::Number) { + if !matches!(cursor.prev().into(), Class::Number) { return self.restart(); } @@ -425,6 +426,8 @@ mod tests { // With number followed by dash or underscore ("text-title1-strong", vec!["text-title1-strong"]), ("text-title1_strong", vec!["text-title1_strong"]), + // With capital letter followed by number + ("text-titleV1-strong", vec!["text-titleV1-strong"]), // With trailing % sign ("from-15%", vec!["from-15%"]), // Arbitrary value with bracket notation diff --git a/crates/oxide/src/extractor/named_variant_machine.rs b/crates/oxide/src/extractor/named_variant_machine.rs index b6de8220b..285fe6ab5 100644 --- a/crates/oxide/src/extractor/named_variant_machine.rs +++ b/crates/oxide/src/extractor/named_variant_machine.rs @@ -81,8 +81,8 @@ impl Machine for NamedVariantMachine { #[inline(always)] fn next(&mut self, cursor: &mut cursor::Cursor<'_>) -> MachineState { - match cursor.curr.into() { - Class::AlphaLower | Class::Star => match cursor.next.into() { + match cursor.curr().into() { + Class::AlphaLower | Class::Star => match cursor.next().into() { // Valid single character variant, must be followed by a `:` // // E.g.: `
` @@ -134,8 +134,8 @@ impl Machine for NamedVariantMachine { let len = cursor.input.len(); while cursor.pos < len { - match cursor.curr.into() { - Class::Dash => match cursor.next.into() { + match cursor.curr().into() { + Class::Dash => match cursor.next().into() { // Start of an arbitrary value // // E.g.: `data-[state=pending]:`. @@ -192,7 +192,7 @@ impl Machine for NamedVariantMachine { }; } - Class::Underscore => match cursor.next.into() { + Class::Underscore => match cursor.next().into() { // Valid characters _if_ followed by another valid character. These characters are // only valid inside of the variant but not at the end of the variant. // @@ -240,11 +240,11 @@ impl Machine for NamedVariantMachine { // E.g.: `2.5xl:flex` // ^^^ Class::Dot => { - if !matches!(cursor.prev.into(), Class::Number) { + if !matches!(cursor.prev().into(), Class::Number) { return self.restart(); } - if !matches!(cursor.next.into(), Class::Number) { + if !matches!(cursor.next().into(), Class::Number) { return self.restart(); } @@ -263,7 +263,7 @@ impl Machine for NamedVariantMachine { impl NamedVariantMachine { #[inline(always)] fn parse_arbitrary_end(&mut self, cursor: &mut cursor::Cursor<'_>) -> MachineState { - match cursor.next.into() { + match cursor.next().into() { Class::Slash => { cursor.advance(); self.transition::().next(cursor) @@ -285,7 +285,7 @@ impl Machine for NamedVariantMachine { fn next(&mut self, cursor: &mut cursor::Cursor<'_>) -> MachineState { match self.modifier_machine.next(cursor) { MachineState::Idle => self.restart(), - MachineState::Done(_) => match cursor.next.into() { + MachineState::Done(_) => match cursor.next().into() { // Modifier must be followed by a `:` // // E.g.: `group-hover/name:` @@ -308,7 +308,7 @@ impl Machine for NamedVariantMachine { #[inline(always)] fn next(&mut self, cursor: &mut cursor::Cursor<'_>) -> MachineState { - match cursor.curr.into() { + match cursor.curr().into() { // The end of a variant must be the `:` // // E.g.: `hover:` diff --git a/crates/oxide/src/extractor/pre_processors/clojure.rs b/crates/oxide/src/extractor/pre_processors/clojure.rs index c53afd552..3fb4ce601 100644 --- a/crates/oxide/src/extractor/pre_processors/clojure.rs +++ b/crates/oxide/src/extractor/pre_processors/clojure.rs @@ -32,14 +32,14 @@ impl PreProcessor for Clojure { let mut cursor = cursor::Cursor::new(&content); while cursor.pos < len { - match cursor.curr { + match cursor.curr() { // Consume strings as-is b'"' => { result[cursor.pos] = b' '; cursor.advance(); while cursor.pos < len { - match cursor.curr { + match cursor.curr() { // Escaped character, skip ahead to the next character b'\\' => cursor.advance_twice(), @@ -57,8 +57,8 @@ impl PreProcessor for Clojure { // Discard line comments until the end of the line. // Comments start with `;;` - b';' if matches!(cursor.next, b';') => { - while cursor.pos < len && cursor.curr != b'\n' { + b';' if matches!(cursor.next(), b';') => { + while cursor.pos < len && cursor.curr() != b'\n' { result[cursor.pos] = b' '; cursor.advance(); } @@ -70,7 +70,7 @@ impl PreProcessor for Clojure { cursor.advance(); while cursor.pos < len { - match cursor.curr { + match cursor.curr() { // A `.` surrounded by digits is a decimal number, so we don't want to replace it. // // E.g.: @@ -78,8 +78,8 @@ impl PreProcessor for Clojure { // gap-1.5 // ^ // ``` - b'.' if cursor.prev.is_ascii_digit() - && cursor.next.is_ascii_digit() => + b'.' if cursor.prev().is_ascii_digit() + && cursor.next().is_ascii_digit() => { // Keep the `.` as-is } @@ -95,7 +95,76 @@ impl PreProcessor for Clojure { result[cursor.pos] = b' '; } // End of keyword. - _ if !is_keyword_character(cursor.curr) => { + _ if !is_keyword_character(cursor.curr()) => { + result[cursor.pos] = b' '; + break; + } + + // Consume everything else. + _ => {} + }; + + cursor.advance(); + } + } + + // Handle quote with a list, e.g.: `'(…)` + // and with a vector, e.g.: `'[…]` + b'\'' if matches!(cursor.next(), b'[' | b'(') => { + result[cursor.pos] = b' '; + cursor.advance(); + result[cursor.pos] = b' '; + let end = match cursor.curr() { + b'[' => b']', + b'(' => b')', + _ => unreachable!(), + }; + + // Consume until the closing `]` + while cursor.pos < len { + match cursor.curr() { + x if x == end => { + result[cursor.pos] = b' '; + break; + } + + // Consume strings as-is + b'"' => { + result[cursor.pos] = b' '; + cursor.advance(); + + while cursor.pos < len { + match cursor.curr() { + // Escaped character, skip ahead to the next character + b'\\' => cursor.advance_twice(), + + // End of the string + b'"' => { + result[cursor.pos] = b' '; + break; + } + + // Everything else is valid + _ => cursor.advance(), + }; + } + } + _ => {} + }; + + cursor.advance(); + } + } + + // Handle quote with a keyword, e.g.: `'bg-white` + b'\'' if !cursor.next().is_ascii_whitespace() => { + result[cursor.pos] = b' '; + cursor.advance(); + + while cursor.pos < len { + match cursor.curr() { + // End of keyword. + _ if !is_keyword_character(cursor.curr()) => { result[cursor.pos] = b' '; break; } @@ -281,4 +350,87 @@ mod tests { vec!["py-5", "flex", "pr-1.5", "bg-white", "bg-black"], ); } + + // https://github.com/tailwindlabs/tailwindcss/issues/18882 + #[test] + fn test_extract_from_symbol_list() { + let input = r#" + [:div {:class '[z-1 z-2 + z-3 z-4]}] + "#; + Clojure::test_extract_contains(input, vec!["z-1", "z-2", "z-3", "z-4"]); + + // https://github.com/tailwindlabs/tailwindcss/pull/18345#issuecomment-3253403847 + let input = r#" + (def hl-class-names '[ring ring-blue-500]) + + [:div + {:class (cond-> '[input w-full] + textarea? (conj 'textarea) + (seq errors) (concat '[border-red-500 bg-red-100]) + highlight? (concat hl-class-names))}] + "#; + Clojure::test_extract_contains( + input, + vec![ + "ring", + "ring-blue-500", + "input", + "w-full", + "textarea", + "border-red-500", + "bg-red-100", + ], + ); + + let input = r#" + [:div + {:class '[h-100 lg:h-200 max-w-32 mx-auto py-60 + flex flex-col justify-end items-center + lg:flex-row lg:justify-between + bg-cover bg-center bg-no-repeat rounded-3xl overflow-hidden + font-semibold text-gray-900]}] + "#; + Clojure::test_extract_contains( + input, + vec![ + "h-100", + "lg:h-200", + "max-w-32", + "mx-auto", + "py-60", + "flex", + "flex-col", + "justify-end", + "items-center", + "lg:flex-row", + "lg:justify-between", + "bg-cover", + "bg-center", + "bg-no-repeat", + "rounded-3xl", + "overflow-hidden", + "font-semibold", + "text-gray-900", + ], + ); + + // `/` is invalid and requires explicit quoting + let input = r#" + '[p-32 "text-black/50"] + "#; + Clojure::test_extract_contains(input, vec!["p-32", "text-black/50"]); + + // `[…]` is invalid and requires explicit quoting + let input = r#" + (print '[ring ring-blue-500 "bg-[#0088cc]"]) + "#; + Clojure::test_extract_contains(input, vec!["ring", "ring-blue-500", "bg-[#0088cc]"]); + + // `'(…)` looks similar to `[…]` but uses parentheses instead of brackets + let input = r#" + (print '(ring ring-blue-500 "bg-[#0088cc]")) + "#; + Clojure::test_extract_contains(input, vec!["ring", "ring-blue-500", "bg-[#0088cc]"]); + } } diff --git a/crates/oxide/src/extractor/pre_processors/elixir.rs b/crates/oxide/src/extractor/pre_processors/elixir.rs index 87b89a2a9..7737a6b2d 100644 --- a/crates/oxide/src/extractor/pre_processors/elixir.rs +++ b/crates/oxide/src/extractor/pre_processors/elixir.rs @@ -13,13 +13,13 @@ impl PreProcessor for Elixir { while cursor.pos < content.len() { // Look for a sigil marker - if cursor.curr != b'~' { + if cursor.curr() != b'~' { cursor.advance(); continue; } // Scan charlists, strings, and wordlists - if !matches!(cursor.next, b'c' | b'C' | b's' | b'S' | b'w' | b'W') { + if !matches!(cursor.next(), b'c' | b'C' | b's' | b'S' | b'w' | b'W') { cursor.advance(); continue; } @@ -27,7 +27,7 @@ impl PreProcessor for Elixir { cursor.advance_twice(); // Match the opening for a sigil - if !matches!(cursor.curr, b'(' | b'[' | b'{') { + if !matches!(cursor.curr(), b'(' | b'[' | b'{') { continue; } @@ -35,19 +35,19 @@ impl PreProcessor for Elixir { result[cursor.pos] = b' '; // Scan until we find a balanced closing one and replace it too - bracket_stack.push(cursor.curr); + bracket_stack.push(cursor.curr()); while cursor.pos < content.len() { cursor.advance(); - match cursor.curr { + match cursor.curr() { // Escaped character, skip ahead to the next character b'\\' => cursor.advance_twice(), b'(' | b'[' | b'{' => { - bracket_stack.push(cursor.curr); + bracket_stack.push(cursor.curr()); } b')' | b']' | b'}' if !bracket_stack.is_empty() => { - bracket_stack.pop(cursor.curr); + bracket_stack.pop(cursor.curr()); if bracket_stack.is_empty() { // Replace the closing bracket with a space diff --git a/crates/oxide/src/extractor/pre_processors/haml.rs b/crates/oxide/src/extractor/pre_processors/haml.rs index a131618ab..ec8be0817 100644 --- a/crates/oxide/src/extractor/pre_processors/haml.rs +++ b/crates/oxide/src/extractor/pre_processors/haml.rs @@ -101,7 +101,7 @@ impl PreProcessor for Haml { let mut last_newline_position = 0; while cursor.pos < len { - match cursor.curr { + match cursor.curr() { // Escape the next character b'\\' => { cursor.advance_twice(); @@ -119,7 +119,7 @@ impl PreProcessor for Haml { b'-' if cursor.input[last_newline_position..cursor.pos] .iter() .all(u8::is_ascii_whitespace) - && matches!(cursor.next, b'#') => + && matches!(cursor.next(), b'#') => { // Just consume the comment let updated_last_newline_position = @@ -159,7 +159,7 @@ impl PreProcessor for Haml { // Override the last known newline position last_newline_position = end; - let replaced = pre_process_input(ruby_code, "rb"); + let replaced = pre_process_input(ruby_code.to_vec(), "rb"); result.replace_range(start..end, replaced); } @@ -190,7 +190,7 @@ impl PreProcessor for Haml { // digit. // E.g.: `bg-red-500.2xl:flex` // ^^^ - if cursor.prev.is_ascii_digit() && cursor.next.is_ascii_digit() { + if cursor.prev().is_ascii_digit() && cursor.next().is_ascii_digit() { let mut next_cursor = cursor.clone(); next_cursor.advance(); @@ -203,6 +203,81 @@ impl PreProcessor for Haml { } } + // Handle Ruby syntax with `%w[]` arrays embedded in Haml attribute hashes. E.g.: + // + // ```haml + // %div{class: %w[bg-blue-500 w-10 h-10]} + // ``` + // + // A `%` that follows a value is not a percent literal. E.g.: the `50%w` in + // `hit rate 50%w.` + b'%' if matches!(cursor.next(), b'w' | b'W') + && !cursor.prev().is_ascii_alphanumeric() + && !matches!(cursor.prev(), b'_' | b')' | b']' | b'}') => + { + // Boundary characters + let (open, close) = match cursor.input.get(cursor.pos + 2) { + Some(b'[') => (b'[', b']'), + Some(b'(') => (b'(', b')'), + Some(b'{') => (b'{', b'}'), + Some(b'<') => (b'<', b'>'), + + // Any other ASCII punctuation can be used as a custom delimiter + Some(&c) if c.is_ascii_punctuation() => (c, c), + + // Everything else is not a valid delimiter + _ => { + cursor.advance(); + continue; + } + }; + + result[cursor.pos] = b' '; // Replace `%` + cursor.advance(); + result[cursor.pos] = b' '; // Replace `w` + cursor.advance(); + result[cursor.pos] = b' '; // Replace the opening delimiter + cursor.advance(); + + // Paired delimiters can be nested as long as they are balanced. E.g.: + // `%w[foo[bar]baz]` produces a flat array. + let mut depth = 1_usize; + + while cursor.pos < len { + match cursor.curr() { + // Skip escaped characters, unless the backslash is the delimiter + // itself + b'\\' if close != b'\\' => { + // Use backslash to embed spaces in the strings. + if cursor.next() == b' ' { + result[cursor.pos] = b' '; + } + + cursor.advance(); + } + + // Start of a nested delimiter pair + c if c == open && open != close => depth += 1, + + // Closing delimiter + c if c == close => { + depth -= 1; + + // End of the literal, replace the closing delimiter with a space + if depth == 0 { + result[cursor.pos] = b' '; + break; + } + } + + // Everything else is valid content + _ => {} + } + + cursor.advance(); + } + } + // Replace following characters with spaces if they are not inside of brackets b'#' | b'=' if bracket_stack.is_empty() => { result[cursor.pos] = b' '; @@ -213,11 +288,11 @@ impl PreProcessor for Haml { if bracket_stack.is_empty() { result[cursor.pos] = b' '; } - bracket_stack.push(cursor.curr); + bracket_stack.push(cursor.curr()); } b')' | b']' | b'}' if !bracket_stack.is_empty() => { - bracket_stack.pop(cursor.curr); + bracket_stack.pop(cursor.curr()); // Replace closing bracket with a space if bracket_stack.is_empty() { @@ -257,7 +332,7 @@ impl Haml { // :url => { :action => "add", :id => product.id }, // :update => { :success => "cart", :failure => "error" } // ``` - let evaluation_type = cursor.curr; + let evaluation_type = cursor.curr(); let block_indentation_level = cursor .pos @@ -267,17 +342,17 @@ impl Haml { let mut last_newline_position = last_known_newline_position; // Consume until the end of the line first - while cursor.pos < len && cursor.curr != b'\n' { + while cursor.pos < len && cursor.curr() != b'\n' { cursor.advance(); } // Block is already done, aka just a line - if evaluation_type == b'=' && cursor.prev != b',' { + if evaluation_type == b'=' && cursor.prev() != b',' { return cursor.pos; } 'outer: while cursor.pos < len { - match cursor.curr { + match cursor.curr() { // Escape the next character b'\\' => { cursor.advance_twice(); @@ -289,7 +364,7 @@ impl Haml { last_newline_position = cursor.pos; // We are done with this block - if evaluation_type == b'=' && cursor.prev != b',' { + if evaluation_type == b'=' && cursor.prev() != b',' { break; } @@ -300,11 +375,11 @@ impl Haml { // Skip whitespace and compute the indentation level x if x.is_ascii_whitespace() => { // Find first non-whitespace character - while cursor.pos < len && cursor.curr.is_ascii_whitespace() { - if cursor.curr == b'\n' { + while cursor.pos < len && cursor.curr().is_ascii_whitespace() { + if cursor.curr() == b'\n' { last_newline_position = cursor.pos; - if evaluation_type == b'=' && cursor.prev != b',' { + if evaluation_type == b'=' && cursor.prev() != b',' { // We are done with this block break 'outer; } @@ -424,6 +499,81 @@ 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}", + "%div class: flex px-2.5 ", + ), + // Nested `<…>` does not end the literal + ( + "%div{class: %w px-2.5>}", + "%div class: flex 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} + %div{class: %w|underline font-bold|} + - classes = %w + "#; + + Haml::test_extract_contains( + input, + vec![ + "bg-blue-500", + "w-10", + "h-10", + "flex", + "px-2.5", + "underline", + "font-bold", + "mt-4", + "grid", + ], + ); + } + // https://github.com/tailwindlabs/tailwindcss/pull/17051#issuecomment-2711181352 #[test] fn test_haml_full_file_17051() { diff --git a/crates/oxide/src/extractor/pre_processors/json.rs b/crates/oxide/src/extractor/pre_processors/json.rs index 809eb501e..cd457050c 100644 --- a/crates/oxide/src/extractor/pre_processors/json.rs +++ b/crates/oxide/src/extractor/pre_processors/json.rs @@ -11,13 +11,13 @@ impl PreProcessor for Json { let mut cursor = cursor::Cursor::new(content); while cursor.pos < len { - match cursor.curr { + match cursor.curr() { // Consume strings as-is b'"' => { cursor.advance(); while cursor.pos < len { - match cursor.curr { + match cursor.curr() { // Escaped character, skip ahead to the next character b'\\' => cursor.advance_twice(), diff --git a/crates/oxide/src/extractor/pre_processors/markdown.rs b/crates/oxide/src/extractor/pre_processors/markdown.rs new file mode 100644 index 000000000..488614ee5 --- /dev/null +++ b/crates/oxide/src/extractor/pre_processors/markdown.rs @@ -0,0 +1,83 @@ +use crate::cursor; +use crate::extractor::pre_processors::pre_processor::PreProcessor; + +#[derive(Debug, Default)] +pub struct Markdown; + +impl PreProcessor for Markdown { + fn process(&self, content: &[u8]) -> Vec { + let len = content.len(); + let mut result = content.to_vec(); + let mut cursor = cursor::Cursor::new(content); + let mut bracket_stack = vec![]; + + let mut in_directive = false; + + while cursor.pos < len { + match (in_directive, cursor.curr()) { + (false, b'{') => { + result[cursor.pos] = b' '; + in_directive = true; + } + (true, b'(' | b'[' | b'{' | b'<') => { + bracket_stack.push(cursor.curr()); + } + (true, b')' | b']' | b'}' | b'>') if !bracket_stack.is_empty() => { + bracket_stack.pop(); + } + (true, b'}') => { + result[cursor.pos] = b' '; + in_directive = false; + } + (true, b'.') if bracket_stack.is_empty() => { + result[cursor.pos] = b' '; + } + _ => {} + } + + cursor.advance(); + } + + result + } +} + +#[cfg(test)] +mod tests { + use super::Markdown; + use crate::extractor::pre_processors::pre_processor::PreProcessor; + + #[test] + fn test_markdown_pre_processor() { + for (input, expected) in [ + // Convert dots to spaces inside markdown inline directives + ( + ":span[Some Text]{.text-gray-500}", + ":span[Some Text] text-gray-500 ", + ), + ( + ":span[Some Text]{.text-gray-500.bg-red-500}", + ":span[Some Text] text-gray-500 bg-red-500 ", + ), + ( + ":span[Some Text]{#myId .my-class key=val key2='val 2'}", + ":span[Some Text] #myId my-class key=val key2='val 2' ", + ), + ] { + Markdown::test(input, expected); + } + } + + #[test] + fn test_nested_classes_keep_the_dots() { + for (input, expected) in [ + ( + r#"{
}"#, + r#"
"#, + ), + (r#"{content-['example.js']}"#, r#" content-['example.js'] "#), + ] { + Markdown::test(input, expected); + } + } +} diff --git a/crates/oxide/src/extractor/pre_processors/mod.rs b/crates/oxide/src/extractor/pre_processors/mod.rs index 7435c349c..1a04af5c9 100644 --- a/crates/oxide/src/extractor/pre_processors/mod.rs +++ b/crates/oxide/src/extractor/pre_processors/mod.rs @@ -2,22 +2,30 @@ pub mod clojure; pub mod elixir; pub mod haml; pub mod json; +pub mod markdown; pub mod pre_processor; pub mod pug; pub mod razor; pub mod ruby; +pub mod rust; pub mod slim; pub mod svelte; +pub mod template_toolkit; +pub mod twig; pub mod vue; pub use clojure::*; pub use elixir::*; pub use haml::*; pub use json::*; +pub use markdown::*; pub use pre_processor::*; pub use pug::*; pub use razor::*; pub use ruby::*; +pub use rust::*; pub use slim::*; pub use svelte::*; +pub use template_toolkit::*; +pub use twig::*; pub use vue::*; diff --git a/crates/oxide/src/extractor/pre_processors/pre_processor.rs b/crates/oxide/src/extractor/pre_processors/pre_processor.rs index 8d42e744a..8e02b8f69 100644 --- a/crates/oxide/src/extractor/pre_processors/pre_processor.rs +++ b/crates/oxide/src/extractor/pre_processors/pre_processor.rs @@ -25,7 +25,33 @@ pub trait PreProcessor: Sized + Default { } #[cfg(test)] - fn test_extract_contains(input: &str, items: Vec<&str>) { + fn test_extract_exact(input: &str, expected: Vec<&str>) { + use crate::extractor::{Extracted, Extractor}; + + let input = input.as_bytes(); + + let processor = Self::default(); + let transformed = processor.process(input); + + let extracted = Extractor::new(&transformed).extract(); + + // Extract all candidates and css variables. + let candidates = extracted + .iter() + .filter_map(|x| match x { + Extracted::Candidate(bytes) => std::str::from_utf8(bytes).ok(), + Extracted::CssVariable(bytes) => std::str::from_utf8(bytes).ok(), + }) + .collect::>(); + + if candidates != expected { + dbg!(&candidates, &expected); + panic!("Extracted candidates do not match expected candidates"); + } + } + + #[cfg(test)] + fn test_extract_contains(input: &str, expected: Vec<&str>) { use crate::extractor::{Extracted, Extractor}; let input = input.as_bytes(); @@ -46,7 +72,7 @@ pub trait PreProcessor: Sized + Default { // Ensure all items are present in the candidates. let mut missing = vec![]; - for item in &items { + for item in &expected { if !candidates.contains(item) { missing.push(item); } diff --git a/crates/oxide/src/extractor/pre_processors/pug.rs b/crates/oxide/src/extractor/pre_processors/pug.rs index 7b44fcf5b..70dfe5683 100644 --- a/crates/oxide/src/extractor/pre_processors/pug.rs +++ b/crates/oxide/src/extractor/pre_processors/pug.rs @@ -15,7 +15,7 @@ impl PreProcessor for Pug { let mut bracket_stack = BracketStack::default(); while cursor.pos < len { - match cursor.curr { + match cursor.curr() { // Only replace `.` with a space if it's not surrounded by numbers. E.g.: // // ```diff @@ -43,7 +43,7 @@ impl PreProcessor for Pug { // digit. // E.g.: `bg-red-500.2xl:flex` // ^^^ - if cursor.prev.is_ascii_digit() && cursor.next.is_ascii_digit() { + if cursor.prev().is_ascii_digit() && cursor.next().is_ascii_digit() { let mut next_cursor = cursor.clone(); next_cursor.advance(); @@ -68,17 +68,17 @@ impl PreProcessor for Pug { // // However, we also need to make sure that we keep the parens that are part of the // utility class. E.g.: `bg-(--my-color)`. - b'(' if bracket_stack.is_empty() && !matches!(cursor.prev, b'-' | b'/') => { + b'(' if bracket_stack.is_empty() && !matches!(cursor.prev(), b'-' | b'/') => { result[cursor.pos] = b' '; - bracket_stack.push(cursor.curr); + bracket_stack.push(cursor.curr()); } b'(' | b'[' | b'{' => { - bracket_stack.push(cursor.curr); + bracket_stack.push(cursor.curr()); } b')' | b']' | b'}' if !bracket_stack.is_empty() => { - bracket_stack.pop(cursor.curr); + bracket_stack.pop(cursor.curr()); } // Consume everything else diff --git a/crates/oxide/src/extractor/pre_processors/ruby.rs b/crates/oxide/src/extractor/pre_processors/ruby.rs index 6bb1d4923..d9a57ab77 100644 --- a/crates/oxide/src/extractor/pre_processors/ruby.rs +++ b/crates/oxide/src/extractor/pre_processors/ruby.rs @@ -34,51 +34,135 @@ impl PreProcessor for Ruby { // Extract embedded template languages // https://viewcomponent.org/guide/templates.html#interpolations - let content_as_str = std::str::from_utf8(content).unwrap(); + // Only process if content is valid UTF-8, otherwise skip HEREDOC extraction + // but still perform the byte-level Ruby processing below + if let Ok(content_as_str) = std::str::from_utf8(content) { + let starts = TEMPLATE_START_REGEX + .captures_iter(content_as_str) + .collect::>(); + let ends = TEMPLATE_END_REGEX + .captures_iter(content_as_str) + .collect::>(); - let starts = TEMPLATE_START_REGEX - .captures_iter(content_as_str) - .collect::>(); - let ends = TEMPLATE_END_REGEX - .captures_iter(content_as_str) - .collect::>(); + for start in starts.iter() { + // The language for this block + let lang = start.get(1).unwrap().as_str(); - for start in starts.iter() { - // The language for this block - let lang = start.get(1).unwrap().as_str(); + // The HEREDOC delimiter + let delimiter_start = start.get(2).unwrap().as_str(); - // The HEREDOC delimiter - let delimiter_start = start.get(2).unwrap().as_str(); + // Where the "body" starts for the HEREDOC block + let body_start = start.get(0).unwrap().end(); - // Where the "body" starts for the HEREDOC block - let body_start = start.get(0).unwrap().end(); + // Look through all of the ends to find a matching language + for end in ends.iter() { + // 1. This must appear after the start + let body_end = end.get(0).unwrap().start(); + if body_end < body_start { + continue; + } - // Look through all of the ends to find a matching language - for end in ends.iter() { - // 1. This must appear after the start - let body_end = end.get(0).unwrap().start(); - if body_end < body_start { - continue; + // The languages must match otherwise we haven't found the end + let delimiter_end = end.get(1).unwrap().as_str(); + if delimiter_end != delimiter_start { + continue; + } + + let body = &content_as_str[body_start..body_end]; + let replaced = + pre_process_input(body.as_bytes().to_vec(), &lang.to_ascii_lowercase()); + + result.replace_range(body_start..body_end, replaced); + break; } - - // The languages must match otherwise we haven't found the end - let delimiter_end = end.get(1).unwrap().as_str(); - if delimiter_end != delimiter_start { - continue; - } - - let body = &content_as_str[body_start..body_end]; - let replaced = pre_process_input(body.as_bytes(), &lang.to_ascii_lowercase()); - - result.replace_range(body_start..body_end, replaced); - break; } } // Ruby extraction while cursor.pos < len { - // Looking for `%w` or `%W` - if cursor.curr != b'%' && !matches!(cursor.next, b'w' | b'W') { + match cursor.curr() { + b'"' => { + cursor.advance(); + + while cursor.pos < len { + match cursor.curr() { + // Escaped character, skip ahead to the next character + b'\\' => cursor.advance_twice(), + + // End of the string + b'"' => break, + + // Everything else is valid + _ => cursor.advance(), + }; + } + + cursor.advance(); + continue; + } + + b'\'' => { + cursor.advance(); + + while cursor.pos < len { + match cursor.curr() { + // Escaped character, skip ahead to the next character + b'\\' => cursor.advance_twice(), + + // End of the string + b'\'' => break, + + // Everything else is valid + _ => cursor.advance(), + }; + } + + cursor.advance(); + continue; + } + + // Replace comments in Ruby files + // + // Except for strict locals, these are defined in a `<%# locals: … %>`. Checking if + // the comment is preceded by a `%` should be enough without having to perform more + // parsing logic. Worst case we _do_ scan a few comments. + // + // We also want to skip interpolation syntax, which look like `#{…}`. + b'#' if !matches!(cursor.prev(), b'%') && !matches!(cursor.next(), b'{') => { + result[cursor.pos] = b' '; + cursor.advance(); + + while cursor.pos < len { + match cursor.curr() { + // End of the comment + b'\n' => break, + + // Everything else is part of the comment and replaced + _ => { + result[cursor.pos] = b' '; + cursor.advance(); + } + }; + } + + cursor.advance(); + continue; + } + + _ => {} + } + + // Looking for `%w`, `%W`, or `%p` + if cursor.curr() != b'%' || !matches!(cursor.next(), b'w' | b'W' | b'p') { + cursor.advance(); + continue; + } + + // A `%` that follows a value is a modulo operation, not a percent literal. E.g.: the + // `50%w` in `hit rate 50%w.` + if cursor.prev().is_ascii_alphanumeric() + || matches!(cursor.prev(), b'_' | b')' | b']' | b'}') + { cursor.advance(); continue; } @@ -86,10 +170,17 @@ impl PreProcessor for Ruby { cursor.advance_twice(); // Boundary character - let boundary = match cursor.curr { + let boundary = match cursor.curr() { b'[' => b']', b'(' => b')', b'{' => b'}', + b'<' => b'>', + b' ' => b'\n', + + // Any other ASCII punctuation can be used as a custom delimiter + c if c.is_ascii_punctuation() => c, + + // Everything else is not a valid delimiter _ => { cursor.advance(); continue; @@ -105,11 +196,11 @@ impl PreProcessor for Ruby { cursor.advance(); while cursor.pos < len { - match cursor.curr { - // Skip escaped characters - b'\\' => { + match cursor.curr() { + // Skip escaped characters, unless the backslash is the delimiter itself + b'\\' if boundary != b'\\' => { // Use backslash to embed spaces in the strings. - if cursor.next == b' ' { + if cursor.next() == b' ' { result[cursor.pos] = b' '; } @@ -118,20 +209,36 @@ impl PreProcessor for Ruby { // Start of a nested bracket b'[' | b'(' | b'{' => { - 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 b']' | b')' | b'}' if !bracket_stack.is_empty() => { - if !bracket_stack.pop(cursor.curr) { + if !bracket_stack.pop(cursor.curr()) { + // Unbalanced + cursor.advance(); + } + } + + // End of a nested `<…>` + b'>' if boundary == b'>' && !bracket_stack.is_empty() => { + if !bracket_stack.pop(cursor.curr()) { // Unbalanced cursor.advance(); } } // End of the pattern, replace the boundary character with a space - _ if cursor.curr == boundary => { - result[cursor.pos] = b' '; + _ if cursor.curr() == boundary => { + if boundary != b'\n' { + result[cursor.pos] = b' '; + } + break; } @@ -173,12 +280,69 @@ mod tests { "%w(flex data-[state=pending]:bg-(--my-color) flex-col)", "%w flex data-[state=pending]:bg-(--my-color) flex-col ", ), + // %w<…> + ("%w", "%w flex px-2.5 "), + ( + "%w", + "%w flex data-[state=pending]:bg-(--my-color) flex-col ", + ), + // Nested `<…>` does not end the literal + ("%w px-2.5>", "%w flex px-2.5 "), + // %w|…|, %w:…:, %w!…! + ("%w|flex px-2.5|", "%w flex px-2.5 "), + ("%w:flex px-2.5:", "%w flex px-2.5 "), + ("%w!flex px-2.5!", "%w flex px-2.5 "), + (r#"%w\flex px-2.5\"#, r#"%w flex px-2.5 "#), + // A `%` that follows a value is a modulo operation, not a percent literal + ( + "hit rate 50%w.\n%w[flex px-2.5]", + "hit rate 50%w.\n%w flex px-2.5 ", + ), + + // %w …\n + ("%w flex px-2.5\n", "%w flex px-2.5\n"), + // Use backslash to embed spaces in the strings. (r#"%w[foo\ bar baz\ bat]"#, r#"%w foo bar baz bat "#), (r#"%W[foo\ bar baz\ bat]"#, r#"%W foo bar baz bat "#), + // The nested delimiters evaluated to a flat array of strings // (not nested array). (r#"%w[foo[bar baz]qux]"#, r#"%w foo[bar baz]qux "#), + + ( + "# test\n# test\n# {ActiveRecord::Base#save!}[rdoc-ref:Persistence#save!]\n%w[flex px-2.5]", + " \n \n \n%w flex px-2.5 " + ), + + (r#""foo # bar""#, r#""foo # bar""#), + (r#"'foo # bar'"#, r#"'foo # bar'"#), + ( + r#"def call = tag.span "Foo", class: %w[rounded-full h-0.75 w-0.75]"#, + r#"def call = tag.span "Foo", class: %w rounded-full h-0.75 w-0.75 "# + ), + + (r#"%w[foo ' bar]"#, r#"%w foo ' bar "#), + (r#"%w[foo " bar]"#, r#"%w foo " bar "#), + (r#"%W[foo ' bar]"#, r#"%W foo ' bar "#), + (r#"%W[foo " bar]"#, r#"%W foo " bar "#), + + (r#"%p foo ' bar "#, r#"%p foo ' bar "#), + (r#"%p foo " bar "#, r#"%p foo " bar "#), + + ( + "%p has a ' quote\n# this should be removed\n%p has a ' quote", + "%p has a ' quote\n \n%p has a ' quote" + ), + ( + "%p has a \" quote\n# this should be removed\n%p has a \" quote", + "%p has a \" quote\n \n%p has a \" quote" + ), + + ( + "%w#this text is kept# # this text is not", + "%w this text is kept ", + ), ] { Ruby::test(input, expected); } @@ -211,6 +375,30 @@ mod tests { "%w(flex data-[state=pending]:bg-(--my-color) flex-col)", vec!["flex", "data-[state=pending]:bg-(--my-color)", "flex-col"], ), + // %w<…> + ("%w", vec!["flex", "px-2.5"]), + ("%w", vec!["flex", "px-2.5"]), + ("%w<2xl:flex>", vec!["2xl:flex"]), + ( + "%w", + vec!["flex", "data-[state=pending]:bg-(--my-color)", "flex-col"], + ), + // Nested `<…>` does not end the literal + ("%w px-2.5>", vec!["flex", "px-2.5"]), + // %w|…|, %w:…:, %w!…! + ("%w|flex px-2.5|", vec!["flex", "px-2.5"]), + ("%w:flex px-2.5:", vec!["flex", "px-2.5"]), + ("%w!flex px-2.5!", vec!["flex", "px-2.5"]), + + ( + "# test\n# test\n# {ActiveRecord::Base#save!}[rdoc-ref:Persistence#save!]\n%w[flex px-2.5]", + vec!["flex", "px-2.5"], + ), + + (r#""foo # bar""#, vec!["foo", "bar"]), + (r#"'foo # bar'"#, vec!["foo", "bar"]), + + (r#"%w[foo ' bar]"#, vec!["foo", "bar"]), ] { Ruby::test_extract_contains(input, expected); } @@ -262,4 +450,79 @@ mod tests { "#; Ruby::test_extract_contains(input, vec!["z-1", "z-2", "z-3"]); } + + // https://github.com/tailwindlabs/tailwindcss/issues/19728 + #[test] + fn test_interpolated_expressions() { + let input = r#" + def width_class(width = nil) + <<~STYLE_CLASS + #{width || 'w-100'} + STYLE_CLASS + end + "#; + + Ruby::test_extract_contains(input, vec!["w-100"]); + } + + // https://github.com/tailwindlabs/tailwindcss/issues/19239 + #[test] + fn test_skip_comments() { + let input = r#" + # From activerecord-8.1.1/lib/active_record/errors.rb:147 + # Rails uses RDoc cross-reference syntax in inline documentation: + # {ActiveRecord::Base#save!}[rdoc-ref:Persistence#save!] + "#; + + // Nothing should be extracted from comments, so expect an empty array. + Ruby::test_extract_exact(input, vec![]); + } + + // https://github.com/tailwindlabs/tailwindcss/issues/19481 + #[test] + fn test_strict_locals() { + // Strict locals are defined in a `<%# locals: … %>`, but the `#` looks like a comment + // which we should not ignore in this case. + let input = r#" + <%# locals: (css: "text-amber-600") %> + <% more_css = "text-sky-500" %> + +

+ In a partial +

+ +

+ In a partial using explicit local variables +

+ +

+ In a partial using explicit local variables +

+ "#; + + Ruby::test_extract_contains( + input, + vec!["text-amber-600", "text-sky-500", "text-green-500"], + ); + } + + #[test] + fn test_invalid_utf8_does_not_panic() { + // Invalid UTF-8 sequence: 0x80 is a continuation byte without a leading byte + let invalid_utf8: &[u8] = &[0x80, 0x81, 0x82]; + + let processor = Ruby::default(); + + // Should not panic, just return the input unchanged + let result = processor.process(invalid_utf8); + assert_eq!(result, invalid_utf8); + } + + #[test] + fn test_valid_utf8_with_multibyte_chars() { + // Test that valid UTF-8 with multi-byte characters (like em-dashes) works + let input = "# Comment with em—dash\n%w[flex px-2.5]"; + + Ruby::test_extract_contains(input, vec!["flex", "px-2.5"]); + } } diff --git a/crates/oxide/src/extractor/pre_processors/rust.rs b/crates/oxide/src/extractor/pre_processors/rust.rs new file mode 100644 index 000000000..4fa53f793 --- /dev/null +++ b/crates/oxide/src/extractor/pre_processors/rust.rs @@ -0,0 +1,238 @@ +use crate::extractor::bracket_stack; +use crate::extractor::cursor; +use crate::extractor::machine::Machine; +use crate::extractor::pre_processors::pre_processor::PreProcessor; +use crate::extractor::variant_machine::VariantMachine; +use crate::extractor::MachineState; +use bstr::ByteSlice; + +#[derive(Debug, Default)] +pub struct Rust; + +impl PreProcessor for Rust { + fn process(&self, content: &[u8]) -> Vec { + // Leptos support: https://github.com/tailwindlabs/tailwindcss/pull/18093 + let replaced_content = content + .replace(" class:", " class ") + .replace("\tclass:", " class ") + .replace("\nclass:", " class "); + + if replaced_content.contains_str(b"html!") { + self.process_maud_templates(&replaced_content) + } else { + replaced_content + } + } +} + +impl Rust { + fn process_maud_templates(&self, replaced_content: &[u8]) -> Vec { + let len = replaced_content.len(); + let mut result = replaced_content.to_vec(); + let mut cursor = cursor::Cursor::new(replaced_content); + let mut bracket_stack = bracket_stack::BracketStack::default(); + + while cursor.pos < len { + match cursor.curr() { + // Escaped character, skip ahead to the next character + b'\\' => { + cursor.advance_twice(); + continue; + } + + // Consume strings as-is + b'"' => { + result[cursor.pos] = b' '; + cursor.advance(); + + while cursor.pos < len { + match cursor.curr() { + // Escaped character, skip ahead to the next character + b'\\' => cursor.advance_twice(), + + // End of the string + b'"' => { + result[cursor.pos] = b' '; + break; + } + + // Everything else is valid + _ => cursor.advance(), + }; + } + } + + // Only replace `.` with a space if it's not surrounded by numbers. E.g.: + // + // ```diff + // - .flex.items-center + // + flex items-center + // ``` + // + // But with numbers, it's allowed: + // + // ```diff + // - px-2.5 + // + px-2.5 + // ``` + b'.' => { + // Don't replace dots with spaces when inside of any type of brackets, because + // this could be part of arbitrary values. E.g.: `bg-[url(https://example.com)]` + // ^ + if !bracket_stack.is_empty() { + cursor.advance(); + continue; + } + + // If the dot is surrounded by digits, we want to keep it. E.g.: `px-2.5` + // EXCEPT if it's followed by a valid variant that happens to start with a + // digit. + // E.g.: `bg-red-500.2xl:flex` + // ^^^ + if cursor.prev().is_ascii_digit() && cursor.next().is_ascii_digit() { + let mut next_cursor = cursor.clone(); + next_cursor.advance(); + + let mut variant_machine = VariantMachine::default(); + if let MachineState::Done(_) = variant_machine.next(&mut next_cursor) { + result[cursor.pos] = b' '; + } + } else { + result[cursor.pos] = b' '; + } + } + + b'[' => { + bracket_stack.push(cursor.curr()); + + // Handle `p.flex[condition]`. If there is a `-` before it, it will likely be an + // arbitrary value e.g. `text-[red]` + if !matches!(cursor.prev(), b'-') { + result[cursor.pos] = b' '; + } + } + + b']' if !bracket_stack.is_empty() => { + bracket_stack.pop(cursor.curr()); + } + + // Consume everything else + _ => {} + }; + + cursor.advance(); + } + + result + } +} + +#[cfg(test)] +mod tests { + use super::Rust; + use crate::extractor::pre_processors::pre_processor::PreProcessor; + + #[test] + fn test_leptos_extraction() { + for (input, expected) in [ + // Spaces + ( + "
", + "
", + ), + // Tabs + ( + "", + "
", + ), + // Newlines + ( + "", + "
", + ), + ] { + Rust::test(input, expected); + } + } + + // https://github.com/tailwindlabs/tailwindcss/issues/18984 + #[test] + fn test_maud_template_extraction() { + let input = r#" + use maud::{html, Markup}; + + pub fn main() -> Markup { + html! { + header.px-8.py-4.text-black { + "Hello, world!" + } + } + } + "#; + + Rust::test_extract_contains(input, vec!["px-8", "py-4", "text-black"]); + + // https://maud.lambda.xyz/elements-attributes.html#classes-and-ids-foo-bar + let input = r#" + html! { + input #cannon .big.scary.bright-red type="button" value="Launch Party Cannon"; + } + "#; + Rust::test_extract_contains(input, vec!["big", "scary", "bright-red"]); + + let input = r#" + html! { + div."bg-[#0088cc]" { "Quotes for backticks" } + } + "#; + Rust::test_extract_contains(input, vec!["bg-[#0088cc]"]); + + let input = r#" + html! { + #main { + "Main content!" + .tip { "Storing food in a refrigerator can make it 20% cooler." } + } + } + "#; + Rust::test_extract_contains(input, vec!["tip"]); + + let input = r#" + html! { + div."bg-[url(https://example.com)]" { "Arbitrary values" } + } + "#; + Rust::test_extract_contains(input, vec!["bg-[url(https://example.com)]"]); + + let input = r#" + html! { + div.px-4.text-black { + "Some text, with unbalanced brackets ][" + } + div.px-8.text-white { + "Some more text, with unbalanced brackets ][" + } + } + "#; + Rust::test_extract_contains(input, vec!["px-4", "text-black", "px-8", "text-white"]); + + let input = r#"html! { \x.px-4.text-black { } }"#; + Rust::test(input, r#"html! { \x px-4 text-black { } }"#); + } + + // https://github.com/tailwindlabs/tailwindcss/issues/20233 + #[test] + fn test_maud_template_extraction_with_conditional_classes() { + let input = r#" + use maud::{html, Markup}; + + pub fn main() -> Markup { + html! { + p.text-black[cuteness > 50] { "Squee!" } + } + } + "#; + + Rust::test_extract_contains(input, vec!["text-black"]); + } +} diff --git a/crates/oxide/src/extractor/pre_processors/slim.rs b/crates/oxide/src/extractor/pre_processors/slim.rs index 6d41e5a09..5683858d5 100644 --- a/crates/oxide/src/extractor/pre_processors/slim.rs +++ b/crates/oxide/src/extractor/pre_processors/slim.rs @@ -15,7 +15,7 @@ impl PreProcessor for Slim { let mut bracket_stack = BracketStack::default(); while cursor.pos < len { - match cursor.curr { + match cursor.curr() { // Only replace `.` with a space if it's not surrounded by numbers. E.g.: // // ```diff @@ -43,7 +43,7 @@ impl PreProcessor for Slim { // digit. // E.g.: `bg-red-500.2xl:flex` // ^^^ - if cursor.prev.is_ascii_digit() && cursor.next.is_ascii_digit() { + if cursor.prev().is_ascii_digit() && cursor.next().is_ascii_digit() { let mut next_cursor = cursor.clone(); next_cursor.advance(); @@ -65,17 +65,74 @@ impl PreProcessor for Slim { // class=%w[bg-blue-500 w-10 h-10] // ] // ``` - b'%' if matches!(cursor.next, b'w' | b'W') - && matches!(cursor.input.get(cursor.pos + 2), Some(b'[' | b'(' | b'{')) => + // + // 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 `[` or `(` or `{` - bracket_stack.push(cursor.curr); - cursor.advance(); // Move past the bracket - continue; + 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(); + } } // Any `[` preceded by an alphanumeric value will not be part of a candidate. @@ -96,10 +153,10 @@ impl PreProcessor for Slim { // Instead of listing all boundary characters, let's list the characters we know // will be invalid instead. b'[' if bracket_stack.is_empty() - && matches!(cursor.prev, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9') => + && matches!(cursor.prev(), b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9') => { result[cursor.pos] = b' '; - bracket_stack.push(cursor.curr); + bracket_stack.push(cursor.curr()); } // In Slim the class name shorthand can be followed by a parenthesis. E.g.: @@ -114,17 +171,17 @@ impl PreProcessor for Slim { // // However, we also need to make sure that we keep the parens that are part of the // utility class. E.g.: `bg-(--my-color)`. - b'(' if bracket_stack.is_empty() && !matches!(cursor.prev, b'-' | b'/') => { + b'(' if bracket_stack.is_empty() && !matches!(cursor.prev(), b'-' | b'/') => { result[cursor.pos] = b' '; - bracket_stack.push(cursor.curr); + bracket_stack.push(cursor.curr()); } b'(' | b'[' | b'{' => { - bracket_stack.push(cursor.curr); + bracket_stack.push(cursor.curr()); } b')' | b']' | b'}' if !bracket_stack.is_empty() => { - bracket_stack.pop(cursor.curr); + bracket_stack.pop(cursor.curr()); } // Consume everything else @@ -316,16 +373,77 @@ mod tests { ] "#; - let expected = r#" - div - class= bg-blue-500 w-10 h-10] - ] - div - class= w-10 bg-green-500 h-10] - ] - "#; + let expected = " + div \n class= bg-blue-500 w-10 h-10 \n ] + div \n class= w-10 bg-green-500 h-10 \n ] + "; Slim::test(input, expected); Slim::test_extract_contains(input, vec!["bg-blue-500", "bg-green-500", "w-10", "h-10"]); } + + // https://github.com/tailwindlabs/tailwindcss/issues/20386 + #[test] + fn test_embedded_ruby_percent_w_delimiters() { + for (input, expected) in [ + // %w<…>, Slim only counts `[({` nesting in attribute values, so the code must be + // wrapped in parentheses to contain spaces + ( + "div class=(%w)", + "div class= bg-blue-500 w-10 h-10 )", + ), + // Nested `<…>` does not end the literal + ( + "div class=(%w px-2.5>)", + "div class= flex 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", "- 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) + ] + - 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", + ], + ); + } } diff --git a/crates/oxide/src/extractor/pre_processors/template_toolkit.rs b/crates/oxide/src/extractor/pre_processors/template_toolkit.rs new file mode 100644 index 000000000..6456a2694 --- /dev/null +++ b/crates/oxide/src/extractor/pre_processors/template_toolkit.rs @@ -0,0 +1,52 @@ +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 { + 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#"
"#, + vec!["bg-white/40", "bg-white/10"], + ); + } +} diff --git a/crates/oxide/src/extractor/pre_processors/test-fixtures/haml/dst-17051.haml b/crates/oxide/src/extractor/pre_processors/test-fixtures/haml/dst-17051.haml index b233f3b8b..56a38cbe3 100644 --- a/crates/oxide/src/extractor/pre_processors/test-fixtures/haml/dst-17051.haml +++ b/crates/oxide/src/extractor/pre_processors/test-fixtures/haml/dst-17051.haml @@ -7,7 +7,6 @@ .relative ^^^^^^^^ - # Blurred background star - ^^^^^^^^^^ ^^^^ .absolute.left-0.z-0{ class: "-top-[400px] -right-[400px]" } ^^^^^^^^ ^^^^^^ ^^^ ^^^^^ ^^^^^^^^^^^^ ^^^^^^^^^^^^^^ .flex.justify-end.blur-3xl @@ -75,8 +74,8 @@ ^^^^^^^^^^ ^^^^^^ libraries, our components are designed to be fast, flexible, and easy to ^^^ ^^^^^^^^^^ ^^^ ^^^^^^^^ ^^ ^^ ^^^ ^^^^ ^^ - use directy in Ruby on Rails! - ^^^ ^^^^^^^ ^^ ^^ + use directly in Ruby on Rails! + ^^^ ^^^^^^^^ ^^ ^^ = doc_example(css: "mt-8 lg:mt-0 lg:basis-3/5 h-44") do ^^^ ^^^^ ^^^^^^^ ^^^^^^^^^^^^ ^^^^ ^^ @@ -102,7 +101,7 @@ %p.mt-2 ^^^^ - Utlize + Utilize = daisy_link("HAML", "https://haml.info/", target: "_blank") ^^^^^^ so your views are simple, concise, and easy to understand. @@ -120,8 +119,8 @@ %p.mt-2 ^^^^ :markdown - **PLUS!** You can utlize filters like _Markdown_, CoffeeScript, - ^^^ ^^^^^^ ^^^^^^^ ^^^^ + **PLUS!** You can utilize filters like _Markdown_, CoffeeScript, + ^^^ ^^^^^^^ ^^^^^^^ ^^^^ Textile, and many more! ^^^ ^^^^ ^^^^^ @@ -196,7 +195,6 @@ ^^^^^^^^ ^^^^ ^^^ ^^^^ ^^ :escaped - # app/components/character_component.html.haml - ^^^^ = part(:component) do ^^ = part(:head) diff --git a/crates/oxide/src/extractor/pre_processors/test-fixtures/haml/src-17051.haml b/crates/oxide/src/extractor/pre_processors/test-fixtures/haml/src-17051.haml index 38e17b134..8f9b61941 100644 --- a/crates/oxide/src/extractor/pre_processors/test-fixtures/haml/src-17051.haml +++ b/crates/oxide/src/extractor/pre_processors/test-fixtures/haml/src-17051.haml @@ -42,7 +42,7 @@ = daisy_link "DaisyUI", "https://daisyui.com/", target: "_blank" = daisy_link "TailwindCSS", "https://tailwindcss.com/", target: "_blank" libraries, our components are designed to be fast, flexible, and easy to - use directy in Ruby on Rails! + use directly in Ruby on Rails! = doc_example(css: "mt-8 lg:mt-0 lg:basis-3/5 h-44") do .flex.flex-col.sm:flex-row.items-center.gap-4 @@ -58,7 +58,7 @@ Simple, Concise Views %p.mt-2 - Utlize + Utilize = daisy_link("HAML", "https://haml.info/", target: "_blank") so your views are simple, concise, and easy to understand. @@ -69,7 +69,7 @@ %p.mt-2 :markdown - **PLUS!** You can utlize filters like _Markdown_, CoffeeScript, + **PLUS!** You can utilize filters like _Markdown_, CoffeeScript, Textile, and many more! .mt-8.xl:mt-0 diff --git a/crates/oxide/src/extractor/pre_processors/twig.rs b/crates/oxide/src/extractor/pre_processors/twig.rs new file mode 100644 index 000000000..b6f4a3319 --- /dev/null +++ b/crates/oxide/src/extractor/pre_processors/twig.rs @@ -0,0 +1,179 @@ +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 { + 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#" +
+ +
+ "#; + + let expected = r#" +
+ +
+ "#; + + Twig::test(input, expected); + Twig::test_extract_contains(input, vec!["opacity-50"]); + + // Inside a component + let input = r#" + + "#; + + let expected = r#" + + "#; + + 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", + ], + ); + } +} diff --git a/crates/oxide/src/extractor/pre_processors/vue.rs b/crates/oxide/src/extractor/pre_processors/vue.rs index 119e2a3d2..ae037435c 100644 --- a/crates/oxide/src/extractor/pre_processors/vue.rs +++ b/crates/oxide/src/extractor/pre_processors/vue.rs @@ -15,13 +15,15 @@ impl PreProcessor for Vue { fn process(&self, content: &[u8]) -> Vec { let mut result = content.to_vec(); - let content_as_str = std::str::from_utf8(content).unwrap(); - for (_, [lang, body]) in TEMPLATE_REGEX - .captures_iter(content_as_str) - .map(|c| c.extract()) - { - let replaced = pre_process_input(body.as_bytes(), lang); - result = result.replace(body, replaced); + // Only process template tags if content is valid UTF-8 + if let Ok(content_as_str) = std::str::from_utf8(content) { + for (_, [lang, body]) in TEMPLATE_REGEX + .captures_iter(content_as_str) + .map(|c| c.extract()) + { + let replaced = pre_process_input(body.as_bytes().to_vec(), lang); + result = result.replace(body, replaced); + } } result @@ -43,4 +45,16 @@ mod tests { Vue::test_extract_contains(input, vec!["bg-neutral-900", "text-red-500"]); } + + #[test] + fn test_invalid_utf8_does_not_panic() { + // Invalid UTF-8 sequence: 0x80 is a continuation byte without a leading byte + let invalid_utf8: &[u8] = &[0x80, 0x81, 0x82]; + + let processor = Vue::default(); + + // Should not panic, just return the input unchanged + let result = processor.process(invalid_utf8); + assert_eq!(result, invalid_utf8); + } } diff --git a/crates/oxide/src/extractor/string_machine.rs b/crates/oxide/src/extractor/string_machine.rs index 9148a1fbf..4fad49faa 100644 --- a/crates/oxide/src/extractor/string_machine.rs +++ b/crates/oxide/src/extractor/string_machine.rs @@ -30,20 +30,20 @@ impl Machine for StringMachine { #[inline] fn next(&mut self, cursor: &mut cursor::Cursor<'_>) -> MachineState { - if Class::Quote != cursor.curr.into() { + if Class::Quote != cursor.curr().into() { return MachineState::Idle; } // Start of a string let len = cursor.input.len(); let start_pos = cursor.pos; - let end_char = cursor.curr; + let end_char = cursor.curr(); cursor.advance(); while cursor.pos < len { - match cursor.curr.into() { - Class::Escape => match cursor.next.into() { + match cursor.curr().into() { + Class::Escape => match cursor.next().into() { // An escaped whitespace character is not allowed Class::Whitespace => return MachineState::Idle, @@ -52,7 +52,7 @@ impl Machine for StringMachine { }, // End of the string - Class::Quote if cursor.curr == end_char => return self.done(start_pos, cursor), + Class::Quote if cursor.curr() == end_char => return self.done(start_pos, cursor), // Any kind of whitespace is not allowed Class::Whitespace => return MachineState::Idle, diff --git a/crates/oxide/src/extractor/utility_machine.rs b/crates/oxide/src/extractor/utility_machine.rs index b9b1038cd..c116ec4e4 100644 --- a/crates/oxide/src/extractor/utility_machine.rs +++ b/crates/oxide/src/extractor/utility_machine.rs @@ -27,12 +27,12 @@ impl Machine for UtilityMachine { #[inline] fn next(&mut self, cursor: &mut cursor::Cursor<'_>) -> MachineState { - match cursor.curr.into() { + match cursor.curr().into() { // LEGACY: Important marker Class::Exclamation => { self.legacy_important = true; - match cursor.next.into() { + match cursor.next().into() { // Start of an arbitrary property // // E.g.: `![color:red]` @@ -78,7 +78,7 @@ impl UtilityMachine { fn parse_arbitrary_property(&mut self, cursor: &mut cursor::Cursor<'_>) -> MachineState { match self.arbitrary_property_machine.next(cursor) { MachineState::Idle => self.restart(), - MachineState::Done(_) => match cursor.next.into() { + MachineState::Done(_) => match cursor.next().into() { // End of arbitrary property, but there is a potential modifier. // // E.g.: `[color:#0088cc]/` @@ -109,7 +109,7 @@ impl UtilityMachine { fn parse_named_utility(&mut self, cursor: &mut cursor::Cursor<'_>) -> MachineState { match self.named_utility_machine.next(cursor) { MachineState::Idle => self.restart(), - MachineState::Done(_) => match cursor.next.into() { + MachineState::Done(_) => match cursor.next().into() { // End of a named utility, but there is a potential modifier. // // E.g.: `bg-red-500/` @@ -140,7 +140,7 @@ impl UtilityMachine { fn parse_modifier(&mut self, cursor: &mut cursor::Cursor<'_>) -> MachineState { match self.modifier_machine.next(cursor) { MachineState::Idle => self.restart(), - MachineState::Done(_) => match cursor.next.into() { + MachineState::Done(_) => match cursor.next().into() { // A modifier followed by a modifier is invalid Class::Slash => self.restart(), diff --git a/crates/oxide/src/extractor/variant_machine.rs b/crates/oxide/src/extractor/variant_machine.rs index 23aa61798..9ec2adc09 100644 --- a/crates/oxide/src/extractor/variant_machine.rs +++ b/crates/oxide/src/extractor/variant_machine.rs @@ -16,7 +16,7 @@ impl Machine for VariantMachine { #[inline] fn next(&mut self, cursor: &mut cursor::Cursor<'_>) -> MachineState { - match cursor.curr.into() { + match cursor.curr().into() { // Start of an arbitrary variant // // E.g.: `[&:hover]:` @@ -48,7 +48,7 @@ impl VariantMachine { start_pos: usize, cursor: &mut cursor::Cursor<'_>, ) -> MachineState { - match cursor.next.into() { + match cursor.next().into() { // End of an arbitrary value, must be followed by a `:` // // E.g.: `[&:hover]:` diff --git a/crates/oxide/src/fast_skip.rs b/crates/oxide/src/fast_skip.rs index 488e61bd6..54930548c 100644 --- a/crates/oxide/src/fast_skip.rs +++ b/crates/oxide/src/fast_skip.rs @@ -10,7 +10,7 @@ pub fn fast_skip(cursor: &Cursor) -> Option { return None; } - if !cursor.curr.is_ascii_whitespace() { + if !cursor.curr().is_ascii_whitespace() { return None; } diff --git a/crates/oxide/src/scanner/detect_sources.rs b/crates/oxide/src/scanner/detect_sources.rs index 9dc340fba..6cfdd49cc 100644 --- a/crates/oxide/src/scanner/detect_sources.rs +++ b/crates/oxide/src/scanner/detect_sources.rs @@ -30,11 +30,9 @@ fn sort_by_dir_and_name(a: &DirEntry, z: &DirEntry) -> Ordering { pub fn resolve_globs( base: PathBuf, - dirs: &[PathBuf], + dirs: &FxHashSet, extensions: &FxHashSet, ) -> Vec { - let allowed_paths: FxHashSet = FxHashSet::from_iter(dirs.iter().cloned()); - // A list of known extensions + a list of extensions we found in the project. let mut found_extensions: FxHashSet = FxHashSet::from_iter(KNOWN_EXTENSIONS.iter().map(|x| x.to_string())); @@ -72,7 +70,7 @@ pub fn resolve_globs( continue; } - if !allowed_paths.contains(path) { + if !dirs.contains(path) { let mut path = path; while let Some(parent) = path.parent() { if parent == base { @@ -113,7 +111,8 @@ pub fn resolve_globs( continue; } - if !allowed_paths.contains(path) { + if !dirs.contains(path) { + it.skip_current_dir(); continue; } diff --git a/crates/oxide/src/scanner/fixtures/ignored-content-dirs.txt b/crates/oxide/src/scanner/fixtures/ignored-content-dirs.txt index e802039a3..79f01ecc1 100644 --- a/crates/oxide/src/scanner/fixtures/ignored-content-dirs.txt +++ b/crates/oxide/src/scanner/fixtures/ignored-content-dirs.txt @@ -1,13 +1,15 @@ .git .hg -.svn -node_modules -.yarn -.venv -venv +.jj .next -.turbo .parcel-cache -__pycache__ -.svelte-kit .pnpm-store +.svelte-kit +.svn +.turbo +.venv +.vercel +.yarn +__pycache__ +node_modules +venv diff --git a/crates/oxide/src/scanner/fixtures/ignored-files.txt b/crates/oxide/src/scanner/fixtures/ignored-files.txt index d2d231ec7..1e8f1029f 100644 --- a/crates/oxide/src/scanner/fixtures/ignored-files.txt +++ b/crates/oxide/src/scanner/fixtures/ignored-files.txt @@ -2,3 +2,5 @@ package-lock.json pnpm-lock.yaml bun.lockb .gitignore +.env +.env.* diff --git a/crates/oxide/src/scanner/init_tracing.rs b/crates/oxide/src/scanner/init_tracing.rs new file mode 100644 index 000000000..dd4c1d071 --- /dev/null +++ b/crates/oxide/src/scanner/init_tracing.rs @@ -0,0 +1,102 @@ +use std::fs::OpenOptions; +use std::io::{self, Write}; +use std::path::Path; +use std::sync::{self, Arc, Mutex}; +use tracing_subscriber::fmt::writer::BoxMakeWriter; + +pub static SHOULD_TRACE: sync::LazyLock = sync::LazyLock::new( + || matches!(std::env::var("DEBUG"), Ok(value) if value.eq("*") || (value.contains("tailwindcss:oxide") && !value.contains("-tailwindcss:oxide"))), +); + +fn dim(input: &str) -> String { + format!("\u{001b}[2m{input}\u{001b}[22m") +} + +fn blue(input: &str) -> String { + format!("\u{001b}[34m{input}\u{001b}[39m") +} + +fn highlight(input: &str) -> String { + format!("{}{}{}", dim(&blue("`")), blue(input), dim(&blue("`"))) +} + +struct MutexWriter(Arc>); + +impl Write for MutexWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.0.lock().unwrap().write(buf) + } + + fn flush(&mut self) -> io::Result<()> { + self.0.lock().unwrap().flush() + } +} + +pub fn init_tracing() { + if !*SHOULD_TRACE { + return; + } + + let root = Path::new(".tailwindcss"); + let logs_dir = root.join("logs"); + if let Err(err) = std::fs::create_dir_all(&logs_dir) { + eprintln!( + "{} Failed to create {}, skipping debug logs ({err})", + dim("[DEBUG]"), + highlight(&logs_dir.display().to_string()) + ); + return; + } + + // Ensure everything inside `.tailwindcss/` is ignored by git. The file is only created if it + // doesn't exist yet, an existing `.gitignore` is left untouched. + if let Ok(mut file) = std::fs::File::create_new(root.join(".gitignore")) { + _ = file.write_all(b"*\n"); + } + + let file_path = logs_dir.join(format!( + "scanner-{}-{}.log", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0), + std::process::id() + )); + let file = match OpenOptions::new() + .create(true) + .append(true) + .open(&file_path) + { + Ok(file) => file, + Err(err) => { + eprintln!( + "{} Failed to create {}, skipping debug logs ({err})", + dim("[DEBUG]"), + highlight(&file_path.display().to_string()) + ); + return; + } + }; + + let absolute_file_path = dunce::canonicalize(&file_path).unwrap_or_else(|_| file_path.clone()); + eprintln!( + "{} Writing debug info to: {}\n", + dim("[DEBUG]"), + highlight(&absolute_file_path.display().to_string()) + ); + + let file = Arc::new(Mutex::new(file)); + + let writer: BoxMakeWriter = BoxMakeWriter::new({ + let file = file.clone(); + move || Box::new(MutexWriter(file.clone())) as Box + }); + + _ = tracing_subscriber::fmt() + .with_max_level(tracing::Level::INFO) + .with_span_events(tracing_subscriber::fmt::format::FmtSpan::ACTIVE) + .with_writer(writer) + .with_ansi(false) + .compact() + .try_init(); +} diff --git a/crates/oxide/src/scanner/mod.rs b/crates/oxide/src/scanner/mod.rs index 469c58146..b7da9aa17 100644 --- a/crates/oxide/src/scanner/mod.rs +++ b/crates/oxide/src/scanner/mod.rs @@ -1,5 +1,6 @@ pub mod auto_source_detection; pub mod detect_sources; +pub mod init_tracing; pub mod sources; use crate::extractor::{Extracted, Extractor}; @@ -14,15 +15,12 @@ use bstr::ByteSlice; use fast_glob::glob_match; use fxhash::{FxHashMap, FxHashSet}; use ignore::{gitignore::GitignoreBuilder, WalkBuilder}; +use init_tracing::{init_tracing, SHOULD_TRACE}; use rayon::prelude::*; -use std::collections::{BTreeMap, BTreeSet}; -use std::fs::OpenOptions; -use std::io::{self, Write}; use std::path::{Path, PathBuf}; -use std::sync::{self, Arc, Mutex}; +use std::sync::{Arc, Mutex}; use std::time::SystemTime; use tracing::event; -use tracing_subscriber::fmt::writer::BoxMakeWriter; // @source "some/folder"; // This is auto source detection // @source "some/folder/**/*"; // This is auto source detection @@ -34,70 +32,6 @@ use tracing_subscriber::fmt::writer::BoxMakeWriter; // // @source "do-include-me.bin"; // `.bin` is typically ignored, but now it's explicit so should be included // @source "git-ignored.html"; // A git ignored file that is listed explicitly, should be scanned -static SHOULD_TRACE: sync::LazyLock = sync::LazyLock::new( - || matches!(std::env::var("DEBUG"), Ok(value) if value.eq("*") || (value.contains("tailwindcss:oxide") && !value.contains("-tailwindcss:oxide"))), -); - -fn dim(input: &str) -> String { - format!("\u{001b}[2m{input}\u{001b}[22m") -} - -fn blue(input: &str) -> String { - format!("\u{001b}[34m{input}\u{001b}[39m") -} - -fn highlight(input: &str) -> String { - format!("{}{}{}", dim(&blue("`")), blue(input), dim(&blue("`"))) -} - -fn init_tracing() { - if !*SHOULD_TRACE { - return; - } - - let file_path = format!("tailwindcss-{}.log", std::process::id()); - let file = OpenOptions::new() - .create(true) - .append(true) - .open(&file_path) - .unwrap_or_else(|_| panic!("Failed to open {file_path}")); - - let file_path = Path::new(&file_path); - let absolute_file_path = dunce::canonicalize(file_path) - .unwrap_or_else(|_| panic!("Failed to canonicalize {file_path:?}")); - eprintln!( - "{} Writing debug info to: {}\n", - dim("[DEBUG]"), - highlight(absolute_file_path.as_path().to_str().unwrap()) - ); - - let file = Arc::new(Mutex::new(file)); - - let writer: BoxMakeWriter = BoxMakeWriter::new({ - let file = file.clone(); - move || Box::new(MutexWriter(file.clone())) as Box - }); - - _ = tracing_subscriber::fmt() - .with_max_level(tracing::Level::INFO) - .with_span_events(tracing_subscriber::fmt::format::FmtSpan::ACTIVE) - .with_writer(writer) - .with_ansi(false) - .compact() - .try_init(); -} - -struct MutexWriter(Arc>); - -impl Write for MutexWriter { - fn write(&mut self, buf: &[u8]) -> io::Result { - self.0.lock().unwrap().write(buf) - } - - fn flush(&mut self) -> io::Result<()> { - self.0.lock().unwrap().flush() - } -} #[derive(Debug, Clone)] pub enum ChangedContent { @@ -129,26 +63,33 @@ pub struct Scanner { /// The walker to detect all files that we have to scan walker: Option, - /// All changed content that we have to parse - changed_content: Vec, - /// All found extensions extensions: FxHashSet, - /// All CSS files we want to scan for CSS variable usage - css_files: Vec, - /// All files that we have to scan - files: Vec, + files: FxHashSet, /// All directories, sub-directories, etc… we saw during source detection - dirs: Vec, + dirs: FxHashSet, /// All generated globs, used for setting up watchers globs: Option>, /// Track unique set of candidates candidates: FxHashSet, + + /// Track mtimes for files so incremental scans can skip unchanged files. + mtimes: FxHashMap, + + /// Files that were scanned during the last `scan()` call. + scanned_files: Vec, + + /// Whether we've completed at least one full scan. When false, we skip + /// mtime tracking entirely so the initial build stays fast. + has_scanned_once: bool, + + /// Whether sources have been scanned since the last `scan()` call + sources_scanned: bool, } impl Scanner { @@ -163,7 +104,6 @@ impl Scanner { } let sources = Sources::new(public_source_entries_to_private_source_entries(sources)); - if *SHOULD_TRACE { event!(tracing::Level::INFO, "Optimized sources:"); for source in sources.iter() { @@ -171,29 +111,27 @@ impl Scanner { } } + let walker = create_walker(&sources); + Self { - sources: sources.clone(), - walker: create_walker(sources), + sources, + walker, ..Default::default() } } pub fn scan(&mut self) -> Vec { - self.scan_sources(); + self.sources_scanned = false; - // TODO: performance improvement, bail early if we don't have any changed content - // if self.changed_content.is_empty() { - // return vec![]; - // } + let (scanned_blobs, css_files, files) = self.discover_sources(); - let _new_candidates = self.extract_candidates(); + self.extract_candidates(scanned_blobs, css_files); + self.scanned_files = files; - // Make sure we have a sorted list of candidates - let mut candidates = self.candidates.iter().cloned().collect::>(); - candidates.par_sort_unstable(); - - // Return all candidates instead of only the new ones - candidates + // Return all candidates sorted + let mut result = self.candidates.iter().cloned().collect::>(); + result.par_sort_unstable(); + result } #[tracing::instrument(skip_all)] @@ -208,7 +146,7 @@ impl Scanner { // Raw content can be parsed directly, no need to verify if the file exists and is allowed // to be scanned. - self.changed_content.extend(changed_contents); + let mut content_to_scan: Vec = changed_contents; // Fully resolve all files let changed_files = changed_files @@ -232,7 +170,7 @@ impl Scanner { }); // All known files are allowed to be scanned - self.changed_content.extend(known_files); + content_to_scan.extend(known_files); // Figure out if the new unknown files are allowed to be scanned if !new_unknown_files.is_empty() { @@ -243,6 +181,11 @@ impl Scanner { continue; } + // The walked path can contain symlinks, while the changed files have already + // been canonicalized. Lazily canonicalize the walked path so we can compare + // the real paths as well. + let mut canonical_path: Option = None; + let mut drop_file_indexes = vec![]; for (idx, changed_file) in new_unknown_files.iter().enumerate().rev() { let ChangedContent::File(file, _) = changed_file else { @@ -251,9 +194,17 @@ impl Scanner { // When the file is found on disk it means that all the rules pass. We can // extract the current file and remove it from the list of passed in files. - if file == path { - self.files.push(path.to_path_buf()); // Track for future use - self.changed_content.push(changed_file.clone()); // Track for parsing + let matches = file == path + || (file.file_name() == path.file_name() && { + if canonical_path.is_none() { + canonical_path = dunce::canonicalize(path).ok(); + } + canonical_path.as_deref() == Some(file.as_path()) + }); + + if matches { + self.files.insert(path.to_path_buf()); // Track for future use + content_to_scan.push(changed_file.clone()); // Track for parsing drop_file_indexes.push(idx); } } @@ -274,18 +225,17 @@ impl Scanner { } } - self.extract_candidates() + // Read all content into blobs for extraction + let blobs = read_all_files(content_to_scan); + self.extract_candidates(blobs, vec![]) } #[tracing::instrument(skip_all)] - fn extract_candidates(&mut self) -> Vec { - let changed_content = self.changed_content.drain(..).collect::>(); - - // Extract all candidates from the changed content - let mut new_candidates = parse_all_blobs(read_all_files(changed_content)); + fn extract_candidates(&mut self, blobs: Vec>, css_files: Vec) -> Vec { + // Extract all candidates from the pre-read blobs + let mut new_candidates = parse_all_blobs(blobs); // Extract all CSS variables from the CSS files - let css_files = self.css_files.drain(..).collect::>(); if !css_files.is_empty() { let css_variables = extract_css_variables(read_all_files( css_files @@ -297,63 +247,23 @@ impl Scanner { new_candidates.extend(css_variables); } - // Only compute the new candidates and ignore the ones we already have. This is for - // subsequent calls to prevent serializing the entire set of candidates every time. - let mut new_candidates = new_candidates - .into_par_iter() - .filter(|candidate| !self.candidates.contains(candidate)) - .collect::>(); - - new_candidates.par_sort_unstable(); + // Only keep candidates we haven't seen before + for existing in self.candidates.iter() { + new_candidates.remove(existing); + } // Track new candidates for subsequent calls - self.candidates.par_extend(new_candidates.clone()); + self.candidates.extend(new_candidates.iter().cloned()); - new_candidates - } + let mut result: Vec = new_candidates.into_iter().collect(); + result.par_sort_unstable(); - #[tracing::instrument(skip_all)] - fn scan_sources(&mut self) { - let Some(walker) = &mut self.walker else { - return; - }; - - for entry in walker.build().filter_map(Result::ok) { - let path = entry.into_path(); - let Ok(metadata) = path.metadata() else { - continue; - }; - if metadata.is_dir() { - self.dirs.push(path); - } else if metadata.is_file() { - let extension = path - .extension() - .and_then(|x| x.to_str()) - .unwrap_or_default(); // In case the file has no extension - - match extension { - // Special handing for CSS files, we don't want to extract candidates from - // these files, but we do want to extract used CSS variables. - "css" => { - self.css_files.push(path.clone()); - } - _ => { - self.changed_content.push(ChangedContent::File( - path.to_path_buf(), - extension.to_owned(), - )); - } - } - - self.extensions.insert(extension.to_owned()); - self.files.push(path); - } - } + result } #[tracing::instrument(skip_all)] pub fn get_files(&mut self) -> Vec { - self.scan_sources(); + let _ = self.discover_sources(); self.files .par_iter() @@ -361,13 +271,18 @@ impl Scanner { .collect() } + #[tracing::instrument(skip_all)] + pub fn get_scanned_files(&self) -> Vec { + self.scanned_files.clone() + } + #[tracing::instrument(skip_all)] pub fn get_globs(&mut self) -> Vec { if let Some(globs) = &self.globs { return globs.clone(); } - self.scan_sources(); + let _ = self.discover_sources(); let mut globs = vec![]; for source in self.sources.iter() { @@ -441,21 +356,167 @@ impl Scanner { let i = s.as_ptr() as usize - offset; let original = &original_content[i..i + s.len()]; if original.contains_str("-[]") { - return Some(unsafe { - (String::from_utf8_unchecked(original.to_vec()), i) - }); + return String::from_utf8(original.to_vec()) + .ok() + .map(|candidate| (candidate, i)); } - // SAFETY: When we parsed the candidates, we already guaranteed that the byte - // slices are valid, therefore we don't have to re-check here when we want to - // convert it back to a string. - Some(unsafe { (String::from_utf8_unchecked(s.to_vec()), i) }) + String::from_utf8(s.to_vec()) + .ok() + .map(|candidate| (candidate, i)) } _ => None, }) .collect() } + + #[tracing::instrument(skip_all)] + fn discover_sources(&mut self) -> (Vec>, Vec, Vec) { + if self.sources_scanned { + return (vec![], vec![], vec![]); + } + self.sources_scanned = true; + + let Some(walker) = &mut self.walker else { + return (vec![], vec![], vec![]); + }; + + // Use synchronous walk for the initial build (lower overhead) and parallel + // walk for subsequent calls (watch mode) where the overhead is amortised. + let all_entries = if self.has_scanned_once { + walk_parallel(walker) + } else { + walk_synchronous(walker) + }; + + let mut css_files: Vec = vec![]; + let mut content_paths: Vec<(PathBuf, String)> = vec![]; + let mut changed_files = vec![]; + + // Fresh state + self.files.clear(); + self.dirs.clear(); + self.extensions.clear(); + self.globs = None; + + // Cache canonicalized folders in case a file itself is not symlinked, but any of the parent + // folders are symlinked. + let mut cached_canonical_dirs: FxHashMap = FxHashMap::default(); + + for entry in all_entries { + match entry { + WalkEntry::Dir(path) => { + self.dirs.insert(path); + } + WalkEntry::File { + path, + mtime, + is_symlink, + } => { + // Deduplicate: parallel walk can visit the same file from multiple threads + if !self.files.insert(path.clone()) { + continue; + } + + // Track canonicalized paths in addition to potentially symlinked file paths + let canonical = if is_symlink { + dunce::canonicalize(&path).ok() + } else { + path.parent().and_then(|parent| { + // Perf: cache the canonicalized parent path such that sibling files don't + // have to canonicalize over and over again. + let canonical_parent = cached_canonical_dirs + .entry(parent.to_path_buf()) + .or_insert_with(|| { + dunce::canonicalize(parent) + .unwrap_or_else(|_| parent.to_path_buf()) + }); + + if canonical_parent.as_path() != parent { + path.file_name() + .map(|file_name| canonical_parent.join(file_name)) + } else { + None + } + }) + }; + + if let Some(canonical) = canonical { + if canonical != path { + self.files.insert(canonical); + } + } + let extension = path + .extension() + .and_then(|x| x.to_str()) + .unwrap_or_default() + .to_owned(); + + self.extensions.insert(extension.to_owned()); + + // On incremental scans, check mtime to skip unchanged files. + // On the first scan, track mtimes while still scanning every file. + let changed = if self.has_scanned_once { + match mtime { + Some(mtime) => { + let prev = self.mtimes.insert(path.clone(), mtime); + prev.is_none_or(|prev| prev != mtime) + } + None => true, + } + } else { + if let Some(mtime) = mtime { + self.mtimes.insert(path.clone(), mtime); + } + + true + }; + + if !changed { + continue; + } + + if let Ok(file) = path.clone().into_os_string().into_string() { + changed_files.push(file); + } + + match extension.as_str() { + // Special handing for CSS files, we don't want to extract candidates from + // these files, but we do want to extract used CSS variables. + "css" => css_files.push(path), + _ => content_paths.push((path, extension)), + } + } + } + } + + // Ensure `mtimes` don't include stale files + self.mtimes.retain(|path, _| self.files.contains(path)); + + // Read + preprocess all discovered files in parallel + let scanned_blobs: Vec> = content_paths + .into_par_iter() + .filter_map(|(path, ext)| { + let content = std::fs::read(&path).ok()?; + event!(tracing::Level::INFO, "Reading {:?}", path); + let processed = pre_process_input(content, &ext); + if processed.is_empty() { + None + } else { + Some(processed) + } + }) + .collect(); + + if !self.has_scanned_once { + self.has_scanned_once = true; + } + + changed_files.par_sort_unstable(); + + (scanned_blobs, css_files, changed_files) + } } fn read_changed_content(c: ChangedContent) -> Option> { @@ -474,24 +535,28 @@ fn read_changed_content(c: ChangedContent) -> Option> { ChangedContent::Content(contents, extension) => (contents.into_bytes(), extension), }; - Some(pre_process_input(&content, &extension)) + Some(pre_process_input(content, &extension)) } -pub fn pre_process_input(content: &[u8], extension: &str) -> Vec { +pub fn pre_process_input(content: Vec, extension: &str) -> Vec { use crate::extractor::pre_processors::*; match extension { - "clj" | "cljs" | "cljc" => Clojure.process(content), - "heex" | "eex" | "ex" | "exs" => Elixir.process(content), - "cshtml" | "razor" => Razor.process(content), - "haml" => Haml.process(content), - "json" => Json.process(content), - "pug" => Pug.process(content), - "rb" | "erb" => Ruby.process(content), - "slim" | "slang" => Slim.process(content), - "svelte" | "rs" => Svelte.process(content), - "vue" => Vue.process(content), - _ => content.to_vec(), + "clj" | "cljs" | "cljc" => Clojure.process(&content), + "heex" | "eex" | "ex" | "exs" => Elixir.process(&content), + "cshtml" | "razor" => Razor.process(&content), + "haml" => Haml.process(&content), + "json" | "jsonl" | "ndjson" => Json.process(&content), + "md" | "mdx" => Markdown.process(&content), + "pug" => Pug.process(&content), + "rb" | "erb" => Ruby.process(&content), + "slim" | "slang" => Slim.process(&content), + "svelte" => Svelte.process(&content), + "rs" => Rust.process(&content), + "tt" | "tt2" | "tx" => TemplateToolkit.process(&content), + "twig" => Twig.process(&content), + "vue" => Vue.process(&content), + _ => content, } } @@ -510,23 +575,23 @@ fn read_all_files(changed_content: Vec) -> Vec> { } #[tracing::instrument(skip_all)] -fn extract_css_variables(blobs: Vec>) -> Vec { +fn extract_css_variables(blobs: Vec>) -> FxHashSet { extract(blobs, |mut extractor| { extractor.extract_variables_from_css() }) } #[tracing::instrument(skip_all)] -fn parse_all_blobs(blobs: Vec>) -> Vec { +fn parse_all_blobs(blobs: Vec>) -> FxHashSet { extract(blobs, |mut extractor| extractor.extract()) } #[tracing::instrument(skip_all)] -fn extract(blobs: Vec>, handle: H) -> Vec +fn extract(blobs: Vec>, handle: H) -> FxHashSet where H: Fn(Extractor) -> Vec + std::marker::Sync, { - let mut result: Vec<_> = blobs + blobs .par_iter() .flat_map(|blob| blob.par_split(|x| *x == b'\n')) .filter_map(|blob| { @@ -551,26 +616,113 @@ where a }) .into_iter() - .map(|s| unsafe { String::from_utf8_unchecked(s.to_vec()) }) - .collect(); - - // SAFETY: Unstable sort is faster and in this scenario it's also safe because we are - // guaranteed to have unique candidates. - result.par_sort_unstable(); - - result + .filter_map(|s| String::from_utf8(s.to_vec()).ok()) + .collect() } -/// Create a walker for the given sources to detect all the files that we have to scan. +#[derive(Debug)] +enum WalkEntry { + Dir(PathBuf), + File { + path: PathBuf, + mtime: Option, + + /// Whether the path itself is a symlink + is_symlink: bool, + }, +} + +impl From for WalkEntry { + fn from(entry: ignore::DirEntry) -> Self { + let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false); + let is_symlink = entry.path_is_symlink(); + let path = entry.into_path(); + + if is_dir { + WalkEntry::Dir(path) + } else { + let mtime = path.metadata().ok().and_then(|m| m.modified().ok()); + WalkEntry::File { + path, + mtime, + is_symlink, + } + } + } +} + +/// Walk the file system synchronously. Used for the initial build where the overhead of spawning +/// parallel walker threads is not worth it. +#[tracing::instrument(skip_all)] +fn walk_synchronous(walker: &mut WalkBuilder) -> Vec { + walker + .build() + .filter_map(Result::ok) + .map(WalkEntry::from) + .collect() +} + +/// Walk the file system in parallel. Used in watch mode where the parallel walker overhead is +/// amortised across many rebuilds and subsequent calls are much faster. +#[tracing::instrument(skip_all)] +fn walk_parallel(walker: &mut WalkBuilder) -> Vec { + struct FlushOnDrop { + local: Vec, + shared: Arc>>, + } + + impl Drop for FlushOnDrop { + fn drop(&mut self) { + if !self.local.is_empty() { + self.shared.lock().unwrap().append(&mut self.local); + } + } + } + + let collected: Arc>> = Arc::new(Mutex::new(vec![])); + + walker.build_parallel().run(|| { + let mut buf = FlushOnDrop { + local: Vec::with_capacity(256), + shared: collected.clone(), + }; + + Box::new(move |entry| { + let Ok(entry) = entry else { + return ignore::WalkState::Continue; + }; + + buf.local.push(WalkEntry::from(entry)); + + if buf.local.len() >= 256 { + buf.shared.lock().unwrap().append(&mut buf.local); + } + + ignore::WalkState::Continue + }) + }); + + // All threads have finished and flushed their buffers via FlushOnDrop::drop + Arc::try_unwrap(collected).unwrap().into_inner().unwrap() +} + +/// Sets up a WalkBuilder with all source roots, gitignore rules, and source pattern matching. /// -/// The `mtimes` map is used to keep track of the last modified time of each file. This is used to -/// determine if a file or folder has changed since the last scan and we can skip folders that -/// haven't changed. -fn create_walker(sources: Sources) -> Option { - let mtimes: Arc>> = Default::default(); +/// This is the common setup shared between the full walker (with mtime tracking for re-scans) +/// and the parallel walker (without mtime tracking for the initial scan). +fn create_walker(sources: &Sources) -> Option { let mut other_roots: FxHashSet<&PathBuf> = FxHashSet::default(); let mut first_root: Option<&PathBuf> = None; - let mut ignores: BTreeMap<&PathBuf, BTreeSet> = Default::default(); + + let mut ignores: Vec<(&PathBuf, Vec)> = 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() { match source { @@ -582,7 +734,7 @@ fn create_walker(sources: Sources) -> Option { } } SourceEntry::Pattern { base, pattern } => { - let mut pattern = pattern.to_string(); + let pattern = pattern.to_owned(); if first_root.is_none() { first_root = Some(base); @@ -591,35 +743,19 @@ fn create_walker(sources: Sources) -> Option { } 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: - ignores - .entry(base) - .or_default() - .insert(format!("!{}", pattern)); + emit(base, format!("!{}", pattern)); } else { // Assumption: the pattern we receive will already be brace expanded. So // `*.{html,jsx}` will result in two separate patterns: `*.html` and `*.jsx`. if let Some(extension) = Path::new(&pattern).extension() { // Extend auto source detection to include the extension - ignores - .entry(base) - .or_default() - .insert(format!("!*.{}", extension.to_string_lossy())); + emit(base, format!("!*.{}", extension.to_string_lossy())); } } } SourceEntry::Ignored { base, pattern } => { - let mut pattern = pattern.to_string(); - // Ensure that the pattern is pinned to the base path. - if !pattern.starts_with("/") { - pattern = format!("/{pattern}"); - } - ignores.entry(base).or_default().insert(pattern); + emit(base, pattern.to_owned()); } SourceEntry::External { base } => { if first_root.is_none() { @@ -629,16 +765,10 @@ fn create_walker(sources: Sources) -> Option { } // External sources should take precedence even over git-ignored files: - ignores - .entry(base) - .or_default() - .insert(format!("!{}", "/**/*")); + emit(base, "!/**/*".to_owned()); // External sources should still disallow binary extensions: - ignores - .entry(base) - .or_default() - .insert(BINARY_EXTENSIONS_GLOB.clone()); + emit(base, BINARY_EXTENSIONS_GLOB.clone()); } } } @@ -715,75 +845,61 @@ fn create_walker(sources: Sources) -> Option { builder.add_gitignore(ignore); } - builder.filter_entry({ - move |entry| { - let path = entry.path(); + // Pre-compute source matching data to avoid allocations in the hot filter_entry path + let auto_bases: Vec = sources + .iter() + .filter_map(|source| match source { + SourceEntry::Auto { base } | SourceEntry::External { base } => Some(base.clone()), + _ => None, + }) + .collect(); - // Ensure the entries are matching any of the provided source patterns (this is - // necessary for manual-patterns that can filter the file extension) - if path.is_file() { - let mut matches = false; - for source in sources.iter() { - match source { - SourceEntry::Auto { base } | SourceEntry::External { base } => { - if path.starts_with(base) { - matches = true; - break; - } - } - SourceEntry::Pattern { base, pattern } => { - let mut pattern = pattern.to_string(); - // Ensure that the pattern is pinned to the base path. - if !pattern.starts_with("/") { - pattern = format!("/{pattern}"); - } + let pattern_sources: Vec<(PathBuf, String)> = sources + .iter() + .filter_map(|source| match source { + SourceEntry::Pattern { base, pattern } => Some((base.into(), pattern.into())), + _ => None, + }) + .collect(); - // Check if path starts with base, if so, remove the prefix and check the remainder against the pattern - let remainder = path.strip_prefix(base); - if remainder.is_ok_and(|remainder| { - let mut path_str = remainder.to_string_lossy().to_string(); - if !path_str.starts_with("/") { - path_str = format!("/{path_str}"); - } - glob_match(pattern, path_str.as_bytes()) - }) { - matches = true; - break; - } - } - _ => {} - } - } + // Source pattern matching filter (lock-free, safe for parallel walking) + builder.filter_entry(move |entry| { + let path = entry.path(); - if !matches { - return false; + // Ensure the entries are matching any of the provided source patterns (this is + // necessary for manual-patterns that can filter the file extension) + if path.is_file() { + let mut matches = false; + + for base in &auto_bases { + if path.starts_with(base) { + matches = true; + break; } } - let mut mtimes = mtimes.lock().unwrap(); - let current_time = match entry.metadata() { - Ok(metadata) if metadata.is_file() => { - if let Ok(time) = metadata.modified() { - Some(time) - } else { - None + if !matches { + for (base, pattern) in &pattern_sources { + let remainder = path.strip_prefix(base); + if remainder.is_ok_and(|remainder| { + let mut path_str = remainder.to_string_lossy().to_string(); + if !path_str.starts_with("/") { + path_str = format!("/{path_str}"); + } + glob_match(pattern, path_str.as_bytes()) + }) { + matches = true; + break; } } - _ => None, - }; + } - let previous_time = - current_time.and_then(|time| mtimes.insert(entry.clone().into_path(), time)); - - match (current_time, previous_time) { - (Some(current), Some(prev)) if prev == current => false, - _ => { - event!(tracing::Level::INFO, "Discovering {:?}", path); - - true - } + if !matches { + return false; } } + + true }); Some(builder) diff --git a/crates/oxide/src/scanner/sources.rs b/crates/oxide/src/scanner/sources.rs index f28b16a49..53c09387f 100644 --- a/crates/oxide/src/scanner/sources.rs +++ b/crates/oxide/src/scanner/sources.rs @@ -1,7 +1,8 @@ -use crate::glob::split_pattern; use crate::GlobEntry; use bexpand::Expression; -use std::path::PathBuf; +use fxhash::{FxHashMap, FxHashSet}; +use ignore::gitignore::Gitignore; +use std::path::{Component, Path, PathBuf}; use tracing::{event, Level}; use super::auto_source_detection::IGNORED_CONTENT_DIRS; @@ -49,13 +50,14 @@ pub enum SourceEntry { /// ``` Ignored { base: PathBuf, pattern: String }, - /// External sources are sources outside of your git root which should not - /// follow gitignore rules. + /// External sources are directories that are ignored (by us or .gitignore rules), but should be + /// included bypassing the default ignore rules. /// /// Represented by: /// /// ```css /// @source "../node_modules/my-lib";` + /// @source "../node_modules/my-lib/**/*";` /// ``` External { base: PathBuf }, } @@ -75,6 +77,164 @@ 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) -> Vec { + 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::>(); + + // Bases of restricted patterns. Each of these becomes its own walk root with its own + // `*` + `!` 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::>(); + + let mut restricted_roots: FxHashSet = FxHashSet::default(); + let mut expanded = vec![]; + + for source in sources { + let SourceEntry::Pattern { base, pattern } = &source else { + expanded.push(source); + continue; + }; + + // `base` is already included by another `@source` that we know should be walked. This + // includes the case where `base` is _nested_ inside such a root, because everything under + // an unrestricted root is walked already. Restricting it would incorrectly hide siblings + // that the broader source is supposed to pick up. + if unrestricted_roots.iter().any(|root| base.starts_with(root)) { + expanded.push(source); + continue; + } + + // Ignore everything in the directory. We will later add the specific patterns we are + // interested in. + if restricted_roots.insert(base.clone()) { + // When another source root is nested inside this base — an unrestricted root, or the + // base of another restricted pattern (which is walked from its own root with its own + // rules) — only ignore direct children so the nested root can still be walked. + let has_nested_root = unrestricted_roots.iter().any(|root| root.starts_with(base)) + || pattern_roots + .iter() + .any(|root| root != base && root.starts_with(base)); + + let pattern = if has_nested_root { "/*" } else { "*" }; + + expanded.push(SourceEntry::Ignored { + base: base.clone(), + pattern: pattern.to_owned(), + }); + } + + // Ensure to _include_ parent paths, otherwise the `*` from above would block walking the + // folders that need to be walked. + // + // ```css + // @source './src/ba*/*.html'; + // ``` + // + // ```gitignore + // * ← added by the above rule + // !/ba*/ ← this is what we're focusing on in this block + // !/ba*/*.html ← this is added later + // ``` + { + let mut dir = PathBuf::new(); + let mut components = Path::new(pattern).components().peekable(); + + while let Some(component) = components.next() { + if components.peek().is_none() { + break; + } + + match component { + Component::Prefix(_) | Component::RootDir | Component::CurDir => continue, + Component::ParentDir | Component::Normal(_) => dir.push(component), + } + + expanded.push(SourceEntry::Ignored { + base: base.clone(), + pattern: format!("!/{}/", path_to_posix_string(&dir).trim_start_matches('/')), + }); + } + } + + // Track the original source + expanded.push(source); + } + + expanded +} + impl PublicSourceEntry { /// Optimize the PublicSourceEntry by trying to move all the static parts of the pattern to the /// base of the PublicSourceEntry. @@ -102,72 +262,456 @@ impl PublicSourceEntry { /// resolved path. pub fn optimize(&mut self) { // Resolve base path immediately - let Ok(base) = dunce::canonicalize(&self.base) else { + let Ok(mut base) = dunce::canonicalize(&self.base) else { event!(Level::ERROR, "Failed to resolve base: {:?}", self.base); return; }; - self.base = base.to_string_lossy().to_string(); - // No dynamic part, figure out if we are dealing with a file or a directory. - if !self.pattern.contains('*') { - let combined_path = if self.pattern.starts_with("/") { - PathBuf::from(&self.pattern) - } else { - PathBuf::from(&self.base).join(&self.pattern) - }; - - match 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; + let mut new_pattern = PathBuf::new(); + enum ComponentStage { + Base, + Pattern, } + let mut stage = ComponentStage::Base; - // Contains dynamic part - let (static_part, dynamic_part) = split_pattern(&self.pattern); + 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 => {} - let base: PathBuf = self.base.clone().into(); - let base = match static_part { - Some(static_part) => { - // TODO: If the base does not exist on disk, try removing the last slash and try - // again. - match dunce::canonicalize(base.join(static_part)) { - Ok(base) => base, - Err(err) => { - event!(tracing::Level::ERROR, "Failed to resolve glob: {:?}", err); - return; + // Go up a directory, e.g. `..` + Component::ParentDir => { + base.pop(); + } + + // Once we hit a component that contains a wildcard character, then we + // can't change the base anymore and we must move to the pattern part. + Component::Normal(part) if part.to_string_lossy().contains("*") => { + new_pattern.push(component); + stage = ComponentStage::Pattern; + } + + // File or folder, but not the last component + Component::Normal(part) if components.peek().is_some() => { + base.push(part); + } + + // Last file or folder. If it's a folder, we move it to the base, + // otherwise we move it to the pattern. + Component::Normal(part) => { + let full_path = base.join(part); + if full_path.is_dir() { + base.push(part); + } else { + new_pattern.push(part); + } + } + + // When we're dealing with an absolute path, then we have to bypass the + // `base` entirely. + Component::Prefix(_) => { + base.clear(); + base.push(component); + } + Component::RootDir => { + #[cfg(not(windows))] + base.clear(); + base.push(component); + } } } - } - None => base, - }; - - let pattern = match dynamic_part { - Some(dynamic_part) => dynamic_part, - None => { - if base.is_dir() { - "**/*".to_owned() - } else { - "".to_owned() + ComponentStage::Pattern => { + new_pattern.push(component); } } - }; + } self.base = base.to_string_lossy().to_string(); - self.pattern = pattern; + self.pattern = path_to_posix_string(&new_pattern); + + // Ensure we have `**/*` when the base is a folder and we don't have a pattern at all + if self.pattern == "" { + self.pattern = "/**/*".to_owned(); + } + // Ensure that the pattern is pinned to the base path. + else if !self.pattern.starts_with("/") { + self.pattern = format!("/{}", self.pattern); + } + } +} + +fn path_to_posix_string(path: &Path) -> String { + let mut parts = Vec::new(); + let mut is_rooted = false; + + for component in path.components() { + match component { + Component::Prefix(prefix) => { + parts.push(prefix.as_os_str().to_string_lossy().to_string()); + } + Component::RootDir => { + is_rooted = true; + if parts.is_empty() { + parts.push(String::new()); + } + } + Component::CurDir => { + parts.push(".".to_string()); + } + Component::ParentDir => { + parts.push("..".to_string()); + } + Component::Normal(part) => { + parts.push(part.to_string_lossy().to_string()); + } + } + } + + let result = parts.join("/"); + if result.is_empty() && is_rooted { + "/".to_string() + } else { + result + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use std::fs; + use tempfile::tempdir; + + #[test] + fn path_to_posix_string_serializes_relative_paths() { + let path = PathBuf::from("src").join("**").join("*.html"); + + assert_eq!(path_to_posix_string(&path), "src/**/*.html"); + } + + #[test] + fn path_to_posix_string_serializes_rooted_paths() { + let path = PathBuf::from(std::path::MAIN_SEPARATOR.to_string()) + .join("src") + .join("**") + .join("*.html"); + + assert_eq!(path_to_posix_string(&path), "/src/**/*.html"); + } + + #[test] + fn path_to_posix_string_serializes_empty_paths() { + assert_eq!(path_to_posix_string(&PathBuf::new()), ""); + } + + #[test] + fn optimize_hoists_static_directories_and_keeps_files_in_the_pattern() { + let dir = tempdir().unwrap(); + fs::create_dir_all(dir.path().join("src").join("examples")).unwrap(); + + let mut source = PublicSourceEntry { + base: dir.path().to_string_lossy().to_string(), + pattern: "src/examples/index.html".to_string(), + negated: false, + }; + + source.optimize(); + + assert_eq!( + source.base, + dunce::canonicalize(dir.path().join("src").join("examples")) + .unwrap() + .to_string_lossy() + ); + assert_eq!(source.pattern, "/index.html"); + } + + #[test] + fn optimize_hoists_folder_patterns() { + let dir = tempdir().unwrap(); + fs::create_dir_all(dir.path().join("src").join("examples")).unwrap(); + + let mut source = PublicSourceEntry { + base: dir.path().to_string_lossy().to_string(), + pattern: "src/examples".to_string(), + negated: false, + }; + + source.optimize(); + + assert_eq!( + source.base, + dunce::canonicalize(dir.path().join("src").join("examples")) + .unwrap() + .to_string_lossy() + ); + assert_eq!(source.pattern, "/**/*"); + } + + #[test] + fn optimize_keeps_wildcards_in_the_pattern() { + let dir = tempdir().unwrap(); + fs::create_dir_all(dir.path().join("src")).unwrap(); + + let mut source = PublicSourceEntry { + base: dir.path().to_string_lossy().to_string(), + pattern: "src/**/*.html".to_string(), + negated: false, + }; + + source.optimize(); + + assert_eq!( + source.base, + dunce::canonicalize(dir.path().join("src")) + .unwrap() + .to_string_lossy() + ); + assert_eq!(source.pattern, "/**/*.html"); + } + + #[test] + fn concrete_patterns_are_expanded_to_restrict_their_base() { + let dir = tempdir().unwrap(); + fs::create_dir_all(dir.path().join("src")).unwrap(); + let base = dunce::canonicalize(dir.path().join("src")).unwrap(); + + let sources = public_source_entries_to_private_source_entries(vec![PublicSourceEntry { + base: dir.path().to_string_lossy().to_string(), + pattern: "src/foo.html".to_string(), + negated: false, + }]); + + assert_eq!( + sources, + vec![ + SourceEntry::Ignored { + base: base.clone(), + pattern: "*".to_string(), + }, + SourceEntry::Pattern { + base, + pattern: "/foo.html".to_string(), + }, + ] + ); + } + + #[test] + fn restricted_patterns_include_parent_directory_allow_rules() { + let dir = tempdir().unwrap(); + fs::create_dir_all(dir.path().join("src")).unwrap(); + let base = dunce::canonicalize(dir.path().join("src")).unwrap(); + + let sources = public_source_entries_to_private_source_entries(vec![PublicSourceEntry { + base: dir.path().to_string_lossy().to_string(), + pattern: "src/ef*/*.html".to_string(), + negated: false, + }]); + + assert_eq!( + sources, + vec![ + SourceEntry::Ignored { + base: base.clone(), + pattern: "*".to_string(), + }, + SourceEntry::Ignored { + base: base.clone(), + pattern: "!/ef*/".to_string(), + }, + SourceEntry::Pattern { + base, + pattern: "/ef*/*.html".to_string(), + }, + ] + ); + } + + #[test] + fn unrestricted_sources_do_not_expand_patterns_for_the_same_base() { + let dir = tempdir().unwrap(); + fs::create_dir_all(dir.path().join("src")).unwrap(); + let base = dunce::canonicalize(dir.path().join("src")).unwrap(); + + let sources = public_source_entries_to_private_source_entries(vec![ + PublicSourceEntry { + base: dir.path().to_string_lossy().to_string(), + pattern: "src".to_string(), + negated: false, + }, + PublicSourceEntry { + base: dir.path().to_string_lossy().to_string(), + pattern: "src/foo.html".to_string(), + negated: false, + }, + ]); + + assert_eq!( + sources, + vec![ + SourceEntry::Auto { base: base.clone() }, + SourceEntry::Pattern { + base, + pattern: "/foo.html".to_string(), + }, + ] + ); + } + + #[test] + fn restricted_parent_bases_do_not_open_unrelated_siblings() { + let dir = tempdir().unwrap(); + let project = dir.path().join("Users").join("robin").join("docus-test"); + fs::create_dir_all(&project).unwrap(); + + let users = dunce::canonicalize(dir.path().join("Users")).unwrap(); + let project = dunce::canonicalize(project).unwrap(); + + let sources = public_source_entries_to_private_source_entries(vec![ + PublicSourceEntry { + base: project.to_string_lossy().to_string(), + pattern: "**/*".to_string(), + negated: false, + }, + PublicSourceEntry { + base: project.to_string_lossy().to_string(), + pattern: "../../app.config.ts".to_string(), + negated: false, + }, + ]); + + assert_eq!( + sources, + vec![ + SourceEntry::Auto { + base: project.clone(), + }, + SourceEntry::Ignored { + base: users.clone(), + pattern: "/*".to_string(), + }, + SourceEntry::Pattern { + base: users, + pattern: "/app.config.ts".to_string(), + }, + ] + ); + } + + #[test] + fn restricted_patterns_preserve_source_order() { + let dir = tempdir().unwrap(); + fs::create_dir_all(dir.path().join("src")).unwrap(); + let base = dunce::canonicalize(dir.path().join("src")).unwrap(); + + let sources = public_source_entries_to_private_source_entries(vec![ + PublicSourceEntry { + base: dir.path().to_string_lossy().to_string(), + pattern: "src/foo.html".to_string(), + negated: false, + }, + PublicSourceEntry { + base: dir.path().to_string_lossy().to_string(), + pattern: "src/foo.html".to_string(), + negated: true, + }, + ]); + + assert_eq!( + sources, + vec![ + SourceEntry::Ignored { + base: base.clone(), + pattern: "*".to_string(), + }, + SourceEntry::Pattern { + base: base.clone(), + pattern: "/foo.html".to_string(), + }, + SourceEntry::Ignored { + base, + pattern: "/foo.html".to_string(), + }, + ] + ); + } + + /// Run the public-to-private conversion for an auto-detected source pointing at `base` and + /// return the resulting entry. + fn auto_source_entry(base: &Path) -> SourceEntry { + public_source_entries_to_private_source_entries(vec![PublicSourceEntry { + base: base.to_string_lossy().to_string(), + pattern: "**/*".to_string(), + negated: false, + }]) + .into_iter() + .next() + .unwrap() + } + + #[test] + fn auto_detected_folders_become_auto_sources() { + let dir = tempdir().unwrap(); + let base = dir.path().join("src"); + fs::create_dir_all(&base).unwrap(); + let base = dunce::canonicalize(&base).unwrap(); + + assert_eq!(auto_source_entry(&base), SourceEntry::Auto { base }); + } + + #[test] + fn folders_ignored_by_default_become_external_sources() { + let dir = tempdir().unwrap(); + let base = dir.path().join("node_modules").join("my-lib"); + fs::create_dir_all(&base).unwrap(); + let base = dunce::canonicalize(&base).unwrap(); + + assert_eq!(auto_source_entry(&base), SourceEntry::External { base }); + } + + #[test] + fn folders_ignored_by_gitignore_become_external_sources() { + let dir = tempdir().unwrap(); + // Pretend this is a git repository so the `.gitignore` search is bounded to it. + fs::create_dir_all(dir.path().join(".git")).unwrap(); + fs::write(dir.path().join(".gitignore"), "dist/\n").unwrap(); + + let base = dir.path().join("dist"); + fs::create_dir_all(&base).unwrap(); + let base = dunce::canonicalize(&base).unwrap(); + + assert_eq!(auto_source_entry(&base), SourceEntry::External { base }); + } + + #[test] + fn folders_ignored_by_a_parent_gitignore_become_external_sources() { + let dir = tempdir().unwrap(); + fs::create_dir_all(dir.path().join(".git")).unwrap(); + // A `.gitignore` higher up in the tree should still apply to nested directories. + fs::write(dir.path().join(".gitignore"), "generated/\n").unwrap(); + + let base = dir.path().join("packages").join("app").join("generated"); + fs::create_dir_all(&base).unwrap(); + let base = dunce::canonicalize(&base).unwrap(); + + assert_eq!(auto_source_entry(&base), SourceEntry::External { base }); + } + + #[test] + fn folders_not_ignored_by_gitignore_stay_auto_sources() { + let dir = tempdir().unwrap(); + fs::create_dir_all(dir.path().join(".git")).unwrap(); + fs::write(dir.path().join(".gitignore"), "dist/\n").unwrap(); + + let base = dir.path().join("src"); + fs::create_dir_all(&base).unwrap(); + let base = dunce::canonicalize(&base).unwrap(); + + assert_eq!(auto_source_entry(&base), SourceEntry::Auto { base }); } } @@ -218,18 +762,111 @@ pub fn public_source_entries_to_private_source_entries( }) .collect::>(); + // 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> = 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 - expanded_globs + let sources = expanded_globs .into_iter() - .map(Into::into) - .collect::>() + .map(|public_source| { + let mut source: SourceEntry = public_source.into(); + + // Promote auto-sources to external sources if they were gitignored + if let SourceEntry::Auto { ref base } = source { + let inside_git_repo = base.ancestors().any(|dir| dir.join(".git").exists()); + + // Walk up from the folder, applying each `.gitignore` relative to the directory + // that contains it (matching git), and stop at the git repository root so + // `.gitignore` files outside of the repo are not considered. + for dir in base.ancestors() { + let gitignore = gitignores.entry(dir.to_path_buf()).or_insert_with(|| { + let path = dir.join(".gitignore"); + + // `Gitignore::new` roots the matcher at the directory containing the file, + // so patterns match relative to it. + path.is_file().then(|| Gitignore::new(&path).0) + }); + + // Only `.gitignore` files in ancestors of `base` can ignore `base` itself. + // Patterns in `base`'s own `.gitignore` only match paths _inside_ `base`, never + // `base` itself (the file walker still applies them to `base`'s contents). + // + // Skipping `base`'s own `.gitignore` also prevents a false positive for + // whitelist style `.gitignore` files, because relativizing `base` against + // itself yields the empty path, which incorrectly matches `/*`. + // + // E.g.: + // + // ```gitignore + // /* + // !/.gitignore + // !/app + // !/public + // ``` + // + // Everything inside `base` except `.gitignore`, `app` and `public` is ignored, + // but `base` itself is not. + if dir != base { + if let Some(gitignore) = gitignore { + if gitignore + .matched_path_or_any_parents(&base, true) + .is_ignore() + { + source = SourceEntry::External { base: base.into() }; + break; + } + } + } + + // Stop at the git repository root. + if dir.join(".git").exists() { + break; + } + + // Without a git repository there is no repository root to stop at. Stop once + // the directory contains the current working directory instead, so `.gitignore` + // files outside of the project (e.g. in the user's home directory) can never + // promote a source to an external source. Note that the file walker still + // applies those `.gitignore` files when deciding which files to scan. + if !inside_git_repo && cwd.as_ref().is_some_and(|cwd| cwd.starts_with(dir)) { + break; + } + } + } + + source + }) + .collect::>(); + + expand_restricted_patterns(sources) } /// Convert a public source entry to a source entry impl From for SourceEntry { fn from(value: PublicSourceEntry) -> Self { - let auto = value.pattern.ends_with("**/*") - || PathBuf::from(&value.base).join(&value.pattern).is_dir(); + if value.negated { + return SourceEntry::Ignored { + base: value.base.into(), + pattern: value.pattern, + }; + } + + let auto = + value.pattern == "/**/*" || PathBuf::from(&value.base).join(&value.pattern).is_dir(); + + if !auto { + return SourceEntry::Pattern { + base: value.base.into(), + pattern: value.pattern, + }; + } let inside_ignored_content_dir = IGNORED_CONTENT_DIRS.iter().any(|dir| { value.base.contains(&format!( @@ -239,24 +876,16 @@ impl From for SourceEntry { std::path::MAIN_SEPARATOR )) || value .base - .ends_with(&format!("{}{}", std::path::MAIN_SEPARATOR, dir,)) + .ends_with(&format!("{}{}", std::path::MAIN_SEPARATOR, dir)) }); - match (value.negated, auto, inside_ignored_content_dir) { - (false, true, false) => SourceEntry::Auto { + match inside_ignored_content_dir { + false => SourceEntry::Auto { base: value.base.into(), }, - (false, true, true) => SourceEntry::External { + true => SourceEntry::External { base: value.base.into(), }, - (false, false, _) => SourceEntry::Pattern { - base: value.base.into(), - pattern: value.pattern, - }, - (true, _, _) => SourceEntry::Ignored { - base: value.base.into(), - pattern: value.pattern, - }, } } } diff --git a/crates/oxide/tests/scanner.rs b/crates/oxide/tests/scanner.rs index 4003ff253..fb5354a87 100644 --- a/crates/oxide/tests/scanner.rs +++ b/crates/oxide/tests/scanner.rs @@ -1,5 +1,6 @@ #[cfg(test)] mod scanner { + use insta::assert_snapshot; use pretty_assertions::assert_eq; use std::path::{Path, PathBuf}; use std::process::Command; @@ -20,6 +21,16 @@ mod scanner { result } + fn symlink_file, Q: AsRef>(original: P, link: Q) -> std::io::Result<()> { + #[cfg(not(windows))] + let result = std::os::unix::fs::symlink(original, link); + + #[cfg(windows)] + let result = std::os::windows::fs::symlink_file(original, link); + + result + } + fn public_source_entry_from_pattern(dir: PathBuf, pattern: &str) -> PublicSourceEntry { let mut parts = pattern.split_whitespace(); let _ = parts.next().unwrap_or_default(); @@ -45,6 +56,157 @@ mod scanner { globs: Vec, normalized_sources: Vec, candidates: Vec, + tree: String, + } + + /// Renders the directory as a tree, annotating every file and folder with an indicator that + /// shows whether the scanner picked it up: + /// + /// - `✓` — scanned + /// - `✗` — ignored / skipped + /// + /// Symlinks are rendered as `link → target`, where the target is shown relative to the folder + /// containing the symlink. The contents of every `.gitignore` file are printed right below the + /// file itself. + /// + /// Folders that are a git repository root (they contain a `.git` folder) are marked with + /// `(git)`, because ignore rules behave differently inside and outside of a repository. + fn fs_tree(root: &Path, scanned: &[String]) -> String { + /// Computes the relative path from `parent` to `target`, where both are relative to the + /// same root, e.g.: `b/c` seen from `b` is `c`, and the root itself seen from `b` is `..`. + fn relative_to(target: &Path, parent: &Path) -> String { + let target = target.components().collect::>(); + let parent = parent.components().collect::>(); + + let common = target + .iter() + .zip(&parent) + .take_while(|(a, b)| **a == **b) + .count(); + + let mut parts = vec![".."; parent.len() - common]; + parts.extend( + target[common..] + .iter() + .map(|component| component.as_os_str().to_str().unwrap()), + ); + + if parts.is_empty() { + // The symlink points to its own parent folder + ".".into() + } else { + parts.join("/") + } + } + + fn walk( + dir: &Path, + root: &Path, + prefix: &str, + scanned: &[String], + visited: &mut Vec, + out: &mut String, + ) { + let mut entries = fs::read_dir(dir) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| entry.file_name() != ".git") + .collect::>(); + entries.sort_by_key(|entry| entry.file_name()); + + for (i, entry) in entries.iter().enumerate() { + let last = i == entries.len() - 1; + let connector = if last { "└── " } else { "├── " }; + let child_prefix = if last { " " } else { "│ " }; + + let path = entry.path(); + let name = entry.file_name().to_string_lossy().to_string(); + let rel = path + .strip_prefix(root) + .unwrap() + .to_string_lossy() + .replace('\\', "/"); + + let file_type = entry.file_type().unwrap(); + + // a file is scanned when it shows up in the scanned files list. a folder is + // considered scanned when any scanned file lives inside of it. paths that go + // through a symlink count towards the symlink, not towards the target directory. + let dir_prefix = format!("{rel}/"); + let is_scanned = scanned + .iter() + .any(|file| file == &rel || file.starts_with(&dir_prefix)); + let indicator = if is_scanned { "✓" } else { "✗" }; + + let mut display_name = name.clone(); + if path.join(".git").exists() { + display_name = format!("{display_name} (git)"); + } + if file_type.is_symlink() { + if let Ok(target) = fs::read_link(&path) { + // Show the target relative to the folder containing the symlink, or as-is + // when it points outside of the tree. + let target = match (target.strip_prefix(root), dir.strip_prefix(root)) { + (Ok(target), Ok(parent)) => relative_to(target, parent), + _ => target.to_string_lossy().replace('\\', "/"), + }; + display_name = format!("{name} → {target}"); + + // The symlink points to a target that doesn't exist + if path.metadata().is_err() { + display_name = format!("{display_name} (broken)"); + } + } + } + + out.push_str(&format!("{prefix}{connector}{indicator} {display_name}\n")); + + // Print the contents of `.gitignore` files below the file itself, so the ignore + // rules are visible in the same output. + if name == ".gitignore" { + if let Ok(contents) = fs::read_to_string(&path) { + for line in contents.lines() { + let line = format!("{prefix}{child_prefix} {line}"); + out.push_str(line.trim_end()); + out.push('\n'); + } + } + } + + // Descend into directories, including symlinked ones. The paths inside a symlinked + // directory are computed through the symlink, so the indicators show what the + // scanner saw via that route. A visited stack prevents symlink cycles from + // recursing forever. + if path.metadata().map(|meta| meta.is_dir()).unwrap_or(false) { + let Ok(canonical) = dunce::canonicalize(&path) else { + continue; + }; + if visited.contains(&canonical) { + continue; + } + + visited.push(canonical); + walk( + &path, + root, + &format!("{prefix}{child_prefix}"), + scanned, + visited, + out, + ); + visited.pop(); + } + } + } + + let mut visited = vec![dunce::canonicalize(root).unwrap()]; + let mut out = if root.join(".git").exists() { + String::from(". (git)\n") + } else { + String::from(".\n") + }; + walk(root, root, "", scanned, &mut visited, &mut out); + out } fn create_files_in(dir: &path::Path, paths: &[(&str, &str)]) { @@ -61,6 +223,53 @@ mod scanner { } } + fn scanned_files(scanner: &mut Scanner, base: &Path) -> Vec { + let base_dir = + format!("{}{}", dunce::canonicalize(base).unwrap().display(), "/").replace('\\', "/"); + + let mut files = scanner + .get_files() + .iter() + // Normalize paths to use unix style separators + .map(|file| file.replace('\\', "/").replace(&base_dir, "")) + .collect::>(); + files.sort(); + files + } + + fn scanned_globs(scanner: &mut Scanner, base: &Path) -> Vec { + let base_dir = + format!("{}{}", dunce::canonicalize(base).unwrap().display(), "/").replace('\\', "/"); + + let mut globs = scanner + .get_globs() + .iter() + .map(|glob| { + if glob.pattern.starts_with('/') { + format!("{}{}", glob.base, glob.pattern) + } else { + format!("{}/{}", glob.base, glob.pattern) + } + }) + // Normalize paths to use unix style separators + .map(|file| file.replace('\\', "/").replace(&base_dir, "")) + .collect::>(); + globs.sort(); + globs + } + + fn normalize_files(files: Vec, base: &Path) -> Vec { + let base_dir = + format!("{}{}", dunce::canonicalize(base).unwrap().display(), "/").replace('\\', "/"); + + let mut files = files + .iter() + .map(|file| file.replace('\\', "/").replace(&base_dir, "")) + .collect::>(); + files.sort(); + files + } + fn scan_with_globs( paths_with_content: &[(&str, &str)], source_directives: Vec<&str>, @@ -85,34 +294,14 @@ mod scanner { let mut scanner = Scanner::new(sources); let candidates = scanner.scan(); - let base_dir = format!("{}{}", dunce::canonicalize(&base).unwrap().display(), "/").replace('\\', "/"); // Get all scanned files as strings relative to the base directory - let mut files = scanner - .get_files() - .iter() - // Normalize paths to use unix style separators - .map(|file| file.replace('\\', "/").replace(&base_dir, "")) - .collect::>(); - files.sort(); + let files = scanned_files(&mut scanner, Path::new(&base)); // Get all scanned globs as strings relative to the base directory - let mut globs = scanner - .get_globs() - .iter() - .map(|glob| { - if glob.pattern.starts_with('/') { - format!("{}{}", glob.base, glob.pattern) - } else { - format!("{}/{}", glob.base, glob.pattern) - } - }) - // Normalize paths to use unix style separators - .map(|file| file.replace('\\', "/").replace(&base_dir, "")) - .collect::>(); - globs.sort(); + let globs = scanned_globs(&mut scanner, Path::new(&base)); // Get all normalized sources as strings relative to the base directory let mut normalized_sources = scanner @@ -130,11 +319,14 @@ mod scanner { .collect::>(); normalized_sources.sort(); + let tree = fs_tree(&dir, &files); + ScanResult { files, globs, normalized_sources, candidates, + tree, } } @@ -148,6 +340,7 @@ mod scanner { files, globs, normalized_sources, + tree, .. } = scan(&[ ("index.html", ""), @@ -155,6 +348,14 @@ mod scanner { ("b.html", ""), ("c.html", ""), ]); + + assert_snapshot!(tree, @" + . (git) + ├── ✓ a.html + ├── ✓ b.html + ├── ✓ c.html + └── ✓ index.html + "); assert_eq!(files, vec!["a.html", "b.html", "c.html", "index.html"]); assert_eq!(globs, vec!["*"]); assert_eq!(normalized_sources, vec!["**/*"]); @@ -166,6 +367,7 @@ mod scanner { files, globs, normalized_sources, + tree, .. } = scan(&[ (".gitignore", "b.html"), @@ -174,6 +376,17 @@ mod scanner { ("b.html", ""), ("c.html", ""), ]); + + assert_snapshot!(tree, @" + . (git) + ├── ✗ .gitignore + │ b.html + ├── ✓ a.html + ├── ✗ b.html + ├── ✓ c.html + └── ✓ index.html + "); + assert_eq!(files, vec!["a.html", "c.html", "index.html"]); assert_eq!(globs, vec!["*"]); assert_eq!(normalized_sources, vec!["**/*"]); @@ -185,6 +398,7 @@ mod scanner { files, globs, normalized_sources, + tree, .. } = scan(&[ ("index.html", ""), @@ -195,6 +409,20 @@ mod scanner { ("public/deeply/nested/c.html", ""), ]); + assert_snapshot!(tree, @" + . (git) + ├── ✓ index.html + └── ✓ public + ├── ✓ a.html + ├── ✓ b.html + ├── ✓ c.html + ├── ✓ deeply + │ └── ✓ nested + │ └── ✓ c.html + └── ✓ nested + └── ✓ c.html + "); + assert_eq!( files, vec![ @@ -216,6 +444,7 @@ mod scanner { files, globs, normalized_sources, + tree, .. } = scan(&[ ("index.html", ""), @@ -229,6 +458,25 @@ mod scanner { ("public/very/deeply/nested/a.html", ""), ]); + assert_snapshot!(tree, @" + . (git) + ├── ✓ index.html + └── ✓ public + ├── ✓ a.html + ├── ✓ b.html + ├── ✓ c.html + ├── ✓ nested + │ ├── ✓ a.html + │ ├── ✓ again + │ │ └── ✓ a.html + │ ├── ✓ b.html + │ └── ✓ c.html + └── ✓ very + └── ✓ deeply + └── ✓ nested + └── ✓ a.html + "); + assert_eq!( files, vec![ @@ -253,6 +501,7 @@ mod scanner { files, globs, normalized_sources, + tree, .. } = scan(&[ (".gitignore", "public/b.html\na.html"), @@ -262,6 +511,18 @@ mod scanner { ("public/c.html", ""), ]); + assert_snapshot!(tree, @" + . (git) + ├── ✗ .gitignore + │ public/b.html + │ a.html + ├── ✓ index.html + └── ✓ public + ├── ✗ a.html + ├── ✗ b.html + └── ✓ c.html + "); + assert_eq!(files, vec!["index.html", "public/c.html",]); assert_eq!(globs, vec!["*"]); assert_eq!(normalized_sources, vec!["**/*"]); @@ -273,6 +534,7 @@ mod scanner { files, globs, normalized_sources, + tree, .. } = scan(&[ ("index.html", ""), @@ -281,6 +543,15 @@ mod scanner { ("src/c.html", ""), ]); + assert_snapshot!(tree, @" + . (git) + ├── ✓ index.html + └── ✓ src + ├── ✓ a.html + ├── ✓ b.html + └── ✓ c.html + "); + assert_eq!( files, vec!["index.html", "src/a.html", "src/b.html", "src/c.html"] @@ -298,6 +569,7 @@ mod scanner { files, globs, normalized_sources, + tree, .. } = scan(&[ ("index.html", ""), @@ -306,6 +578,14 @@ mod scanner { ("c.lock", ""), ]); + assert_snapshot!(tree, @" + . (git) + ├── ✗ a.mp4 + ├── ✗ b.png + ├── ✗ c.lock + └── ✓ index.html + "); + assert_eq!(files, vec!["index.html"]); assert_eq!(globs, vec!["*"]); assert_eq!(normalized_sources, vec!["**/*"]); @@ -318,6 +598,7 @@ mod scanner { files, globs, normalized_sources, + tree, .. } = scan(&[ // Looks like `.pages` binary extension, but it's a folder @@ -331,6 +612,16 @@ mod scanner { ), ]); + assert_snapshot!(tree, @" + . (git) + ├── ✗ .gitignore + │ other.pages + ├── ✗ other.pages + │ └── ✗ index.html + └── ✓ some.pages + └── ✓ index.html + "); + assert_eq!(files, vec!["some.pages/index.html"]); assert_eq!(globs, vec!["*", "some.pages/**/*.{aspx,astro,cjs,cts,eex,erb,gjs,gts,haml,handlebars,hbs,heex,html,jade,js,jsx,liquid,md,mdx,mjs,mts,mustache,njk,nunjucks,php,pug,py,razor,rb,rhtml,rs,slim,svelte,tpl,ts,tsx,twig,vue}"]); assert_eq!(normalized_sources, vec!["**/*"]); @@ -342,6 +633,7 @@ mod scanner { files, globs, normalized_sources, + tree, .. } = scan(&[ ("index.html", ""), @@ -350,6 +642,14 @@ mod scanner { ("c.less", ""), ]); + assert_snapshot!(tree, @" + . (git) + ├── ✓ a.css + ├── ✗ b.sass + ├── ✗ c.less + └── ✓ index.html + "); + assert_eq!(files, vec!["a.css", "index.html"]); assert_eq!(globs, vec!["*"]); assert_eq!(normalized_sources, vec!["**/*"]); @@ -361,9 +661,16 @@ mod scanner { files, globs, normalized_sources, + tree, .. } = scan(&[("src/index.my-extension", "")]); + assert_snapshot!(tree, @" + . (git) + └── ✓ src + └── ✓ index.my-extension + "); + assert_eq!(files, vec!["src/index.my-extension"]); assert_eq!(globs, vec!["*", "src/**/*.{aspx,astro,cjs,cts,eex,erb,gjs,gts,haml,handlebars,hbs,heex,html,jade,js,jsx,liquid,md,mdx,mjs,mts,mustache,my-extension,njk,nunjucks,php,pug,py,razor,rb,rhtml,rs,slim,svelte,tpl,ts,tsx,twig,vue}"]); assert_eq!(normalized_sources, vec!["**/*"]); @@ -375,6 +682,7 @@ mod scanner { files, globs, normalized_sources, + tree, .. } = scan(&[ ("index.html", ""), @@ -382,6 +690,13 @@ mod scanner { ("yarn.lock", ""), ]); + assert_snapshot!(tree, @" + . (git) + ├── ✓ index.html + ├── ✗ package-lock.json + └── ✗ yarn.lock + "); + assert_eq!(files, vec!["index.html"]); assert_eq!(globs, vec!["*"]); assert_eq!(normalized_sources, vec!["**/*"]); @@ -393,6 +708,7 @@ mod scanner { files, globs, normalized_sources, + tree, .. } = scan(&[ // Explicitly listed root files @@ -437,6 +753,58 @@ mod scanner { ("nested-d/very/deeply/nested/directory/again/foo.html", ""), ]); + assert_snapshot!(tree, @" + . (git) + ├── ✓ bar.html + ├── ✓ baz.html + ├── ✓ foo.html + ├── ✓ nested-a + │ ├── ✓ bar.html + │ ├── ✓ baz.html + │ └── ✓ foo.html + ├── ✓ nested-b + │ └── ✓ deeply-nested + │ ├── ✓ bar.html + │ ├── ✓ baz.html + │ └── ✓ foo.html + ├── ✓ nested-c + │ ├── ✗ .gitignore + │ │ ignored-folder/ + │ ├── ✓ bar.html + │ ├── ✓ baz.html + │ ├── ✓ foo.html + │ ├── ✗ ignored-folder + │ │ ├── ✗ bar.html + │ │ ├── ✗ baz.html + │ │ └── ✗ foo.html + │ └── ✓ sibling-folder + │ ├── ✓ bar.html + │ ├── ✓ baz.html + │ └── ✓ foo.html + └── ✓ nested-d + ├── ✗ .gitignore + │ deep/ + ├── ✓ bar.html + ├── ✓ baz.html + ├── ✓ foo.html + └── ✓ very + └── ✓ deeply + └── ✓ nested + ├── ✓ bar.html + ├── ✓ baz.html + ├── ✗ deep + │ ├── ✗ bar.html + │ ├── ✗ baz.html + │ └── ✗ foo.html + ├── ✓ directory + │ ├── ✓ again + │ │ └── ✓ foo.html + │ ├── ✓ bar.html + │ ├── ✓ baz.html + │ └── ✓ foo.html + └── ✓ foo.html + "); + assert_eq!( files, vec![ @@ -491,6 +859,7 @@ mod scanner { let ScanResult { candidates, normalized_sources, + tree, .. } = scan(&[ // The gitignore file is used to filter out files but not scanned for candidates @@ -513,6 +882,21 @@ mod scanner { ("index4.svelte", "
"), ]); + assert_snapshot!(tree, @" + . (git) + ├── ✗ .gitignore + │ # md:font-bold + │ foo.html + ├── ✗ foo.html + ├── ✗ foo.jpg + ├── ✓ index.angular.html + ├── ✓ index.html + ├── ✓ index.svelte + ├── ✓ index2.svelte + ├── ✓ index3.svelte + └── ✓ index4.svelte + "); + assert_eq!( candidates, vec![ @@ -536,28 +920,86 @@ mod scanner { let ScanResult { candidates, normalized_sources, + tree, .. } = scan_with_globs( &[("foo/bar/baz/foo.html", "content-['foo.html']")], vec!["@source '**/*'", "@source './foo/bar/baz/..'"], ); + assert_snapshot!(tree, @" + . (git) + └── ✓ foo + └── ✓ bar + └── ✓ baz + └── ✓ foo.html + "); + assert_eq!(candidates, vec!["content-['foo.html']"]); assert_eq!(normalized_sources, vec!["**/*", "foo/bar/**/*"]); } + #[test] + fn it_should_preserve_source_order_when_referencing_a_sibling_project() { + // Create a temporary working directory + let dir = tempdir().unwrap().into_path(); + + // Create files + create_files_in( + &dir, + &[ + ("project-a/src/index.css", ""), + ("project-b/keep/keep.html", "content-['GOOD-1']"), + ("project-b/ignored/ignored.html", "content-['BAD-1']"), + ("project-b/ignored/except.html", "content-['GOOD-2']"), + ], + ); + + let base = dir.join("project-a/src"); + let mut scanner = Scanner::new(vec![ + public_source_entry_from_pattern(base.clone(), "@source '../../project-b'"), + public_source_entry_from_pattern(base.clone(), "@source not '../../project-b/ignored'"), + public_source_entry_from_pattern( + base.clone(), + "@source '../../project-b/ignored/except.html'", + ), + ]); + let candidates = scanner.scan(); + + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✗ project-a + │ └── ✗ src + │ └── ✗ index.css + └── ✓ project-b + ├── ✓ ignored + │ ├── ✓ except.html + │ └── ✗ ignored.html + └── ✓ keep + └── ✓ keep.html + "); + + assert_eq!(candidates, vec!["content-['GOOD-1']", "content-['GOOD-2']"]); + } + #[test] fn it_should_scan_files_without_extensions() { // These look like folders, but they are files let ScanResult { candidates, normalized_sources, + tree, .. } = scan_with_globs( &[("my-file", "content-['my-file']")], vec!["@source '**/*'", "@source './my-file'"], ); + assert_snapshot!(tree, @" + . (git) + └── ✓ my-file + "); + assert_eq!(candidates, vec!["content-['my-file']"]); assert_eq!(normalized_sources, vec!["**/*", "my-file"]); } @@ -568,6 +1010,7 @@ mod scanner { let ScanResult { candidates, normalized_sources, + tree, .. } = scan_with_globs( &[ @@ -587,6 +1030,14 @@ mod scanner { ], ); + assert_snapshot!(tree, @" + . (git) + ├── ✓ my-folder.bin + │ └── ✓ foo.html + └── ✓ my-folder.templates + └── ✓ foo.html + "); + assert_eq!( candidates, vec![ @@ -605,6 +1056,7 @@ mod scanner { let ScanResult { candidates, normalized_sources, + tree, .. } = scan_with_globs( &[ @@ -615,15 +1067,161 @@ mod scanner { vec!["@source '**/*'", "@source '*.styl'"], ); + assert_snapshot!(tree, @" + . (git) + └── ✓ foo.styl + "); + assert_eq!(candidates, vec!["content-['foo.styl']"]); assert_eq!(normalized_sources, vec!["**/*", "*.styl"]); } + #[test] + fn it_should_drop_invalid_utf8_candidates() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("index.html"), b"flex bg-[\x80] block").unwrap(); + + let mut scanner = Scanner::new(vec![public_source_entry_from_pattern( + dir.path().to_path_buf(), + "@source '*.html'", + )]); + + let candidates = scanner + .scan() + .into_iter() + .map(String::into_bytes) + .collect::>(); + + assert_eq!(candidates, vec![b"block".to_vec(), b"flex".to_vec()]); + } + + #[test] + fn it_should_not_store_invalid_utf8_candidates_during_incremental_scans() { + let dir = tempdir().unwrap(); + let file = dir.path().join("index.html"); + fs::write(&file, b"flex bg-[\x80]").unwrap(); + + let mut scanner = Scanner::new(vec![public_source_entry_from_pattern( + dir.path().to_path_buf(), + "@source '*.html'", + )]); + + let candidates = scanner + .scan_content(vec![ChangedContent::File(file, "html".into())]) + .into_iter() + .map(String::into_bytes) + .collect::>(); + assert_eq!(candidates, vec![b"flex".to_vec()]); + + let candidates = + scanner.scan_content(vec![ChangedContent::Content("block".into(), "html".into())]); + assert_eq!(candidates, vec!["block"]); + + let candidates = scanner + .scan() + .into_iter() + .map(String::into_bytes) + .collect::>(); + assert_eq!(candidates, vec![b"block".to_vec(), b"flex".to_vec()]); + } + + #[test] + fn it_should_drop_invalid_utf8_candidates_with_positions() { + let dir = tempdir().unwrap(); + let file = dir.path().join("index.html"); + fs::write( + &file, + b"flex bg-[\x80] group-[]:block group-[]:bg-[\x80] grid", + ) + .unwrap(); + + let mut scanner = Scanner::new(vec![]); + let candidates = scanner + .get_candidates_with_positions(ChangedContent::File(file, "html".into())) + .into_iter() + .map(|(candidate, position)| (candidate.into_bytes(), position)) + .collect::>(); + + assert_eq!( + candidates, + vec![ + (b"flex".to_vec(), 0), + (b"group-[]:block".to_vec(), 12), + (b"grid".to_vec(), 43), + ] + ); + } + + #[test] + fn it_should_preserve_valid_utf8_candidates() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("index.html"), + "before:content-['💩'] bg-[é] font-[中文]".as_bytes(), + ) + .unwrap(); + + let mut scanner = Scanner::new(vec![public_source_entry_from_pattern( + dir.path().to_path_buf(), + "@source '*.html'", + )]); + + assert_eq!( + scanner.scan(), + vec!["before:content-['💩']", "bg-[é]", "font-[中文]"] + ); + } + + #[test] + fn it_should_preserve_paths_for_sources_ending_in_a_deep_glob() { + let ScanResult { + candidates, + files, + globs, + normalized_sources, + tree, + .. + } = scan_with_globs( + &[ + ( + "blog/2024/foo/bar/baz/index.html", + "content-['blog/2024/foo/bar/baz/index.html']", + ), + ( + "blog/2024/foo/bar/qux/index.html", + "content-['blog/2024/foo/bar/qux/index.html']", + ), + ], + vec!["@source './blog/*/foo/bar/baz/**/*'"], + ); + + assert_snapshot!(tree, @" + . (git) + └── ✓ blog + └── ✓ 2024 + └── ✓ foo + └── ✓ bar + ├── ✓ baz + │ └── ✓ index.html + └── ✗ qux + └── ✗ index.html + "); + + assert_eq!( + candidates, + vec!["content-['blog/2024/foo/bar/baz/index.html']"] + ); + assert_eq!(files, vec!["blog/2024/foo/bar/baz/index.html"]); + assert_eq!(globs, vec!["blog/*/foo/bar/baz/**/*"]); + assert_eq!(normalized_sources, vec!["blog/*/foo/bar/baz/**/*"]); + } + #[test] fn it_should_scan_next_dynamic_folders() { let ScanResult { candidates, normalized_sources, + tree, .. } = scan_with_globs( &[ @@ -637,6 +1235,19 @@ mod scanner { vec!["@source '**/*'", "@source './**/*.{styl}'"], ); + assert_snapshot!(tree, @" + . (git) + └── ✓ app + ├── ✓ (theme) + │ └── ✓ page.styl + ├── ✓ [...slug] + │ └── ✓ page.styl + ├── ✓ [[...slug]] + │ └── ✓ page.styl + └── ✓ [slug] + └── ✓ page.styl + "); + assert_eq!( candidates, vec![ @@ -678,6 +1289,14 @@ mod scanner { let mut scanner = Scanner::new(sources); let candidates = scanner.scan(); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . (git) + ├── ✓ project-a + │ └── ✓ index.html + └── ✓ project-b + └── ✓ index.html + "); + // We've done the initial scan and found the files assert_eq!( candidates, @@ -693,6 +1312,7 @@ mod scanner { let ScanResult { candidates, normalized_sources, + tree, .. } = scan_with_globs( &[ @@ -705,6 +1325,13 @@ mod scanner { vec!["@source '**/*'", "@source 'foo.styl'"], ); + assert_snapshot!(tree, @" + . (git) + ├── ✗ .gitignore + │ foo.styl + └── ✓ foo.styl + "); + assert_eq!(candidates, vec!["content-['foo.styl']"]); assert_eq!(normalized_sources, vec!["**/*", "foo.styl"]); } @@ -734,6 +1361,14 @@ mod scanner { let mut scanner = Scanner::new(sources); let candidates = scanner.scan(); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . (git) + ├── ✓ project-a + │ └── ✓ index.html + └── ✓ project-b + └── ✓ index.html + "); + // We've done the initial scan and found the files assert_eq!( candidates, @@ -838,6 +1473,238 @@ mod scanner { "content-['project-b/sub1/sub2/new.html']" ] ); + + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . (git) + ├── ✓ project-a + │ ├── ✓ index.html + │ ├── ✓ new.html + │ └── ✓ sub1 + │ └── ✓ sub2 + │ ├── ✓ index.html + │ └── ✓ new.html + └── ✓ project-b + ├── ✓ index.html + ├── ✓ new.html + └── ✓ sub1 + └── ✓ sub2 + ├── ✓ index.html + └── ✓ new.html + "); + } + + #[test] + fn it_should_remove_deleted_files_from_scanned_files() { + // Create a temporary working directory + let dir = tempdir().unwrap().into_path(); + + // Initialize this directory as a git repository + let _ = Command::new("git").arg("init").current_dir(&dir).output(); + + // Create files + create_files_in( + &dir, + &[ + ("src/index.html", "content-['src/index.html']"), + ("src/keep.html", "content-['src/keep.html']"), + ("src/remove.html", "content-['src/remove.html']"), + ], + ); + + let mut scanner = Scanner::new(vec![public_source_entry_from_pattern( + dir.clone(), + "@source '**/*'", + )]); + + scanner.scan(); + assert_eq!( + scanned_files(&mut scanner, &dir), + vec!["src/index.html", "src/keep.html", "src/remove.html"] + ); + + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . (git) + └── ✓ src + ├── ✓ index.html + ├── ✓ keep.html + └── ✓ remove.html + "); + + fs::remove_file(dir.join("src/remove.html")).unwrap(); + + scanner.scan(); + assert_eq!( + scanned_files(&mut scanner, &dir), + vec!["src/index.html", "src/keep.html"] + ); + + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . (git) + └── ✓ src + ├── ✓ index.html + └── ✓ keep.html + "); + } + + #[test] + fn it_should_remove_deleted_directories_from_scanned_files() { + // Create a temporary working directory + let dir = tempdir().unwrap().into_path(); + + // Initialize this directory as a git repository + let _ = Command::new("git").arg("init").current_dir(&dir).output(); + + // Create files + create_files_in( + &dir, + &[ + ("src/index.html", "content-['src/index.html']"), + ("src/keep/index.html", "content-['src/keep/index.html']"), + ("src/remove/index.html", "content-['src/remove/index.html']"), + ( + "src/remove/nested/index.html", + "content-['src/remove/nested/index.html']", + ), + ], + ); + + let mut scanner = Scanner::new(vec![public_source_entry_from_pattern( + dir.clone(), + "@source '**/*'", + )]); + + scanner.scan(); + assert_eq!( + scanned_files(&mut scanner, &dir), + vec![ + "src/index.html", + "src/keep/index.html", + "src/remove/index.html", + "src/remove/nested/index.html", + ] + ); + + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . (git) + └── ✓ src + ├── ✓ index.html + ├── ✓ keep + │ └── ✓ index.html + └── ✓ remove + ├── ✓ index.html + └── ✓ nested + └── ✓ index.html + "); + + fs::remove_dir_all(dir.join("src/remove")).unwrap(); + + scanner.scan(); + assert_eq!( + scanned_files(&mut scanner, &dir), + vec!["src/index.html", "src/keep/index.html"] + ); + + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . (git) + └── ✓ src + ├── ✓ index.html + └── ✓ keep + └── ✓ index.html + "); + } + + #[test] + fn it_should_remove_deleted_directories_from_scanned_globs() { + // Create a temporary working directory + let dir = tempdir().unwrap().into_path(); + + // Initialize this directory as a git repository + let _ = Command::new("git").arg("init").current_dir(&dir).output(); + + // Create files + create_files_in( + &dir, + &[ + ("index.html", "content-['index.html']"), + ("src/index.html", "content-['src/index.html']"), + ], + ); + + let mut scanner = Scanner::new(vec![public_source_entry_from_pattern( + dir.clone(), + "@source '**/*'", + )]); + + scanner.scan(); + + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . (git) + ├── ✓ index.html + └── ✓ src + └── ✓ index.html + "); + + let globs = scanned_globs(&mut scanner, &dir); + assert!(globs.iter().any(|glob| glob.starts_with("src/**/*"))); + + fs::remove_dir_all(dir.join("src")).unwrap(); + + scanner.scan(); + + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . (git) + └── ✓ index.html + "); + + let globs = scanned_globs(&mut scanner, &dir); + assert!(!globs.iter().any(|glob| glob.starts_with("src/**/*"))); + } + + #[test] + fn it_should_track_files_scanned_by_the_last_scan() { + let dir = tempdir().unwrap().into_path(); + + let _ = Command::new("git").arg("init").current_dir(&dir).output(); + + create_files_in( + &dir, + &[ + ("src/index.html", "content-['src/index.html']"), + ("src/keep.html", "content-['src/keep.html']"), + ], + ); + + let mut scanner = Scanner::new(vec![public_source_entry_from_pattern( + dir.clone(), + "@source '**/*'", + )]); + + assert_eq!( + scanner.scan(), + vec!["content-['src/index.html']", "content-['src/keep.html']"] + ); + + assert_eq!( + scanner.scan(), + vec!["content-['src/index.html']", "content-['src/keep.html']"] + ); + assert_eq!(scanner.get_scanned_files(), Vec::::new()); + + sleep(Duration::from_millis(10)); + fs::write(dir.join("src/index.html"), "content-['src/changed.html']").unwrap(); + + assert_eq!( + scanner.scan(), + vec![ + "content-['src/changed.html']", + "content-['src/index.html']", + "content-['src/keep.html']", + ] + ); + assert_eq!( + normalize_files(scanner.get_scanned_files(), &dir), + vec!["src/index.html"] + ); } #[test] @@ -847,6 +1714,8 @@ mod scanner { files, globs, normalized_sources, + tree, + .. } = scan_with_globs( &[ ("src/index.ts", "content-['src/index.ts']"), @@ -874,6 +1743,25 @@ mod scanner { ], ); + assert_snapshot!(tree, @" + . (git) + └── ✓ src + ├── ✓ admin + │ └── ✓ foo + │ └── ✓ template.html + ├── ✗ colors + │ ├── ✗ blue.tsx + │ ├── ✗ green.tsx + │ └── ✗ red.jsx + ├── ✗ index.ts + ├── ✓ templates + │ └── ✓ index.html + └── ✗ utils + ├── ✗ date.ts + ├── ✗ file.ts + └── ✗ string.ts + "); + assert_eq!( candidates, vec![ @@ -919,6 +1807,8 @@ mod scanner { files, globs, normalized_sources, + tree, + .. } = scan_with_globs( // Typically skipped &[ @@ -930,6 +1820,15 @@ mod scanner { vec!["@source '**/*'", "@source 'src/**/*.{exe,bin}'"], ); + assert_snapshot!(tree, @" + . (git) + ├── ✗ out + │ └── ✗ out.exe + └── ✓ src + ├── ✓ index.bin + └── ✓ index.exe + "); + assert_eq!( candidates, vec!["content-['src/index.bin']", "content-['src/index.exe']",] @@ -957,6 +1856,8 @@ mod scanner { files, globs, normalized_sources, + tree, + .. } = scan_with_globs( &[ ("index.html", "content-['index.html']"), @@ -981,6 +1882,21 @@ mod scanner { ], ); + assert_snapshot!(tree, @" + . (git) + ├── ✗ index.html + └── ✓ src + ├── ✓ admin + │ ├── ✗ ignore.html + │ └── ✓ index.html + ├── ✓ dashboard + │ ├── ✗ ignore.html + │ └── ✓ index.html + ├── ✗ ignore.html + ├── ✗ index.html + └── ✗ lib.ts + "); + assert_eq!( candidates, vec![ @@ -997,6 +1913,409 @@ mod scanner { assert_eq!(normalized_sources, vec!["src/**/*.html"]); } + #[test] + fn it_should_restrict_explicit_file_sources_to_the_matching_file() { + let ScanResult { + candidates, + files, + tree, + .. + } = scan_with_globs( + &[ + ("src/foo.html", "content-['src/foo.html']"), + ("src/bar.html", "content-['src/bar.html']"), + ], + vec!["@source './src/foo.html'"], + ); + + assert_snapshot!(tree, @" + . (git) + └── ✓ src + ├── ✗ bar.html + └── ✓ foo.html + "); + + assert_eq!(candidates, vec!["content-['src/foo.html']"]); + assert_eq!(files, vec!["src/foo.html"]); + } + + #[test] + fn it_should_combine_multiple_restricted_sources_for_the_same_base() { + let ScanResult { + candidates, + files, + tree, + .. + } = scan_with_globs( + &[ + ("src/foo.html", "content-['src/foo.html']"), + ("src/bar.html", "content-['src/bar.html']"), + ("src/baz.html", "content-['src/baz.html']"), + ], + vec!["@source './src/foo.html'", "@source './src/bar.html'"], + ); + + assert_snapshot!(tree, @" + . (git) + └── ✓ src + ├── ✓ bar.html + ├── ✗ baz.html + └── ✓ foo.html + "); + + assert_eq!( + candidates, + vec!["content-['src/bar.html']", "content-['src/foo.html']"] + ); + assert_eq!(files, vec!["src/bar.html", "src/foo.html"]); + } + + // https://github.com/tailwindlabs/tailwindcss/issues/20333 + #[test] + fn it_should_combine_nested_and_root_restricted_sources() { + // The restriction (`*`) added for the root-level file must not prevent walking into the + // `nested` folder that another explicit source points into. At the same time, relaxing + // that restriction to `/*` must not accidentally open up sibling folders (`ignore-me`) + // or files that no explicit source points at. + let paths_with_content = &[ + ("nested/component.html", "content-['nested/component.html']"), + ("nested/ignore-me.html", "content-['nested/ignore-me.html']"), + ("ignore-me/component.html", "content-['ignore-me']"), + ("component-sources.classes.txt", "content-['classes.txt']"), + ("ignore-me.txt", "content-['ignore-me.txt']"), + ]; + + let ScanResult { + candidates, + files, + tree, + .. + } = scan_with_globs( + paths_with_content, + vec![ + "@source './nested/component.html'", + "@source './component-sources.classes.txt'", + ], + ); + + assert_snapshot!(tree, @" + . (git) + ├── ✓ component-sources.classes.txt + ├── ✗ ignore-me + │ └── ✗ component.html + ├── ✗ ignore-me.txt + └── ✓ nested + ├── ✓ component.html + └── ✗ ignore-me.html + "); + + assert_eq!( + candidates, + vec![ + "content-['classes.txt']", + "content-['nested/component.html']" + ] + ); + assert_eq!( + files, + vec!["component-sources.classes.txt", "nested/component.html"] + ); + + // Same setup, but with the root-level source declared first + let ScanResult { + candidates, + files, + tree, + .. + } = scan_with_globs( + paths_with_content, + vec![ + "@source './component-sources.classes.txt'", + "@source './nested/component.html'", + ], + ); + + assert_snapshot!(tree, @" + . (git) + ├── ✓ component-sources.classes.txt + ├── ✗ ignore-me + │ └── ✗ component.html + ├── ✗ ignore-me.txt + └── ✓ nested + ├── ✓ component.html + └── ✗ ignore-me.html + "); + + assert_eq!( + candidates, + vec![ + "content-['classes.txt']", + "content-['nested/component.html']" + ] + ); + assert_eq!( + files, + vec!["component-sources.classes.txt", "nested/component.html"] + ); + } + + #[test] + fn it_should_allow_later_ignores_to_override_restricted_sources() { + let ScanResult { + candidates, + files, + tree, + .. + } = scan_with_globs( + &[("src/foo.html", "content-['src/foo.html']")], + vec!["@source './src/foo.html'", "@source not './src/foo.html'"], + ); + + assert_snapshot!(tree, @" + . (git) + └── ✗ src + └── ✗ foo.html + "); + + assert!(candidates.is_empty()); + assert!(files.is_empty()); + } + + #[test] + fn it_should_handle_sources_with_parent_patterns() { + { + let ScanResult { + candidates, + files, + tree, + .. + } = scan_with_globs( + &[ + ("src/foo.html", "content-['src/foo.html']"), + ("src/bar/index.html", "content-['src/bar/index.html']"), + ("src/bar/ignore.html", "content-['src/bar/ignore.html']"), + ], + vec!["@source './src/ba*/*.html'"], + ); + + assert_snapshot!(tree, @" + . (git) + └── ✓ src + ├── ✓ bar + │ ├── ✓ ignore.html + │ └── ✓ index.html + └── ✗ foo.html + "); + + assert_eq!( + candidates, + vec![ + "content-['src/bar/ignore.html']", + "content-['src/bar/index.html']" + ] + ); + assert_eq!(files, vec!["src/bar/ignore.html", "src/bar/index.html"]); + } + + { + let ScanResult { + candidates, + files, + tree, + .. + } = scan_with_globs( + &[ + ("src/foo.html", "content-['src/foo.html']"), + ("src/bar/index.html", "content-['src/bar/index.html']"), + ("src/bar/ignore.html", "content-['src/bar/ignore.html']"), + ], + vec![ + "@source './src/ba*/*.html'", + "@source not './src/bar/ignore.html'", // Ignore the ignore.html + ], + ); + + assert_snapshot!(tree, @" + . (git) + └── ✓ src + ├── ✓ bar + │ ├── ✗ ignore.html + │ └── ✓ index.html + └── ✗ foo.html + "); + + assert_eq!(candidates, vec!["content-['src/bar/index.html']"]); + assert_eq!(files, vec!["src/bar/index.html"]); + } + + { + let ScanResult { + candidates, + files, + tree, + .. + } = scan_with_globs( + &[ + ("src/foo.html", "content-['src/foo.html']"), + ("src/bar/index.html", "content-['src/bar/index.html']"), + ("src/bar/ignore.html", "content-['src/bar/ignore.html']"), + ], + vec!["@source '**/*'", "@source not './src/ba*/*.html'"], + ); + + assert_snapshot!(tree, @" + . (git) + └── ✓ src + ├── ✗ bar + │ ├── ✗ ignore.html + │ └── ✗ index.html + └── ✓ foo.html + "); + + assert_eq!(candidates, vec!["content-['src/foo.html']"]); + assert_eq!(files, vec!["src/foo.html"]); + } + + { + let ScanResult { + candidates, + files, + tree, + .. + } = scan_with_globs( + &[ + ("src/foo.html", "content-['src/foo.html']"), + ("src/bar/index.html", "content-['src/bar/index.html']"), + ("src/bar/ignore.html", "content-['src/bar/ignore.html']"), + ], + vec![ + "@source '**/*'", + "@source not './src/ba*/*.html'", + "@source './src/bar/index.html'", // + ], + ); + + assert_snapshot!(tree, @" + . (git) + └── ✓ src + ├── ✓ bar + │ ├── ✗ ignore.html + │ └── ✓ index.html + └── ✓ foo.html + "); + + assert_eq!( + candidates, + vec!["content-['src/bar/index.html']", "content-['src/foo.html']"] + ); + assert_eq!(files, vec!["src/bar/index.html", "src/foo.html"]); + } + } + + #[test] + fn nested_explicit_source_should_not_suppress_siblings_of_broad_source() { + // A broad `**/*` source should keep auto-detecting every file, even when a more + // specific `@source` points at a single file inside a subdirectory. The restriction + // added for the explicit file must not hide its siblings from the broad source. + let ScanResult { + candidates, + files, + tree, + .. + } = scan_with_globs( + &[ + ("src/components/button.html", "content-['button']"), + ("src/components/card.html", "content-['card']"), + ], + vec!["@source '**/*'", "@source './src/components/button.html'"], + ); + + assert_snapshot!(tree, @" + . (git) + └── ✓ src + └── ✓ components + ├── ✓ button.html + └── ✓ card.html + "); + + assert_eq!(candidates, vec!["content-['button']", "content-['card']"]); + assert_eq!( + files, + vec!["src/components/button.html", "src/components/card.html"] + ); + } + + #[test] + fn nested_explicit_source_should_not_suppress_siblings_of_auto_source() { + // Same as above, but the broad source is an auto-detected directory (`@source "src"`) + // and the explicit file lives in a nested subdirectory of it. + let ScanResult { + candidates, + files, + tree, + .. + } = scan_with_globs( + &[ + ("src/components/button.html", "content-['button']"), + ("src/components/card.html", "content-['card']"), + ], + vec!["@source 'src'", "@source './src/components/button.html'"], + ); + + assert_snapshot!(tree, @" + . (git) + └── ✓ src + └── ✓ components + ├── ✓ button.html + └── ✓ card.html + "); + + assert_eq!(candidates, vec!["content-['button']", "content-['card']"]); + assert_eq!( + files, + vec!["src/components/button.html", "src/components/card.html"] + ); + } + + #[test] + fn root_file_source_should_not_suppress_sibling_source_roots() { + let ScanResult { + candidates, tree, .. + } = scan_with_globs( + &[ + ("index.css", ""), + ("src/index.html", "content-['src/index.html']"), + ("pages/foo.html", "content-['pages/foo.html']"), + ("pages/nested/foo.html", "content-['pages/nested/foo.html']"), + ], + vec![ + "@source './src'", + "@source './pages/**/*.html'", + "@source './index.css'", + ], + ); + + assert_snapshot!(tree, @" + . (git) + ├── ✓ index.css + ├── ✓ pages + │ ├── ✓ foo.html + │ └── ✓ nested + │ └── ✓ foo.html + └── ✓ src + └── ✓ index.html + "); + + assert_eq!( + candidates, + vec![ + "content-['pages/foo.html']", + "content-['pages/nested/foo.html']", + "content-['src/index.html']", + ] + ); + } + #[test] fn it_respects_gitignore_in_workspace_root() { let ScanResult { @@ -1004,6 +2323,8 @@ mod scanner { files, globs, normalized_sources, + tree, + .. } = scan_with_globs( &[ (".gitignore", "ignore-1.html\nweb/ignore-2.html"), @@ -1015,6 +2336,19 @@ mod scanner { vec!["@source './src'", "@source './web'"], ); + assert_snapshot!(tree, @" + . (git) + ├── ✗ .gitignore + │ ignore-1.html + │ web/ignore-2.html + ├── ✓ src + │ └── ✓ index.html + └── ✓ web + ├── ✗ ignore-1.html + ├── ✗ ignore-2.html + └── ✓ index.html + "); + assert_eq!( candidates, vec!["content-['src/index.html']", "content-['web/index.html']",] @@ -1032,6 +2366,8 @@ mod scanner { files, globs, normalized_sources, + tree, + .. } = scan_with_globs( &[ ("src/logo.jpg", "content-['/src/logo.jpg']"), @@ -1040,6 +2376,13 @@ mod scanner { vec!["@source './src/logo.{jpg,png}'"], ); + assert_snapshot!(tree, @" + . (git) + └── ✓ src + ├── ✓ logo.jpg + └── ✓ logo.png + "); + assert_eq!( candidates, vec!["content-['/src/logo.jpg']", "content-['/src/logo.png']"] @@ -1056,6 +2399,8 @@ mod scanner { files, globs, normalized_sources, + tree, + .. } = scan_with_globs( &[ (".gitignore", "ignore-1.html\n/web/ignore-2.html"), @@ -1065,6 +2410,17 @@ mod scanner { ], vec!["@source './web'", "@source './web/ignore-1.html'"], ); + + assert_snapshot!(tree, @" + . (git) + ├── ✗ .gitignore + │ ignore-1.html + │ /web/ignore-2.html + └── ✓ web + ├── ✓ ignore-1.html + ├── ✗ ignore-2.html + └── ✓ index.html + "); assert_eq!( candidates, vec![ @@ -1078,6 +2434,201 @@ mod scanner { assert_eq!(normalized_sources, vec!["web/**/*", "web/ignore-1.html"]); } + #[test] + fn it_respects_gitignore_files_with_whitelist_patterns() { + // https://github.com/tailwindlabs/tailwindcss/discussions/20382 + // + // A `.gitignore` that ignores everything (`/*`) and then whitelists specific + // directories and files using negated patterns. + let ScanResult { + files, + candidates, + tree, + .. + } = scan(&[ + ( + ".gitignore", + "/*\n!/app\n!/public\n!/package.json\n!/.gitignore\n", + ), + ("app/index.html", "content-['app/index.html']"), + ("public/index.html", "content-['public/index.html']"), + ("package.json", ""), + // These are all ignored by the `/*` rule because they are not whitelisted + ("build/generated.html", "content-['build/generated.html']"), + ("logs/dev.log", "content-['logs/dev.log']"), + ( + "node_modules/my-ui-lib/index.html", + "content-['node_modules/my-ui-lib/index.html']", + ), + ]); + + assert_snapshot!(tree, @" + . (git) + ├── ✗ .gitignore + │ /* + │ !/app + │ !/public + │ !/package.json + │ !/.gitignore + ├── ✓ app + │ └── ✓ index.html + ├── ✗ build + │ └── ✗ generated.html + ├── ✗ logs + │ └── ✗ dev.log + ├── ✗ node_modules + │ └── ✗ my-ui-lib + │ └── ✗ index.html + ├── ✓ package.json + └── ✓ public + └── ✓ index.html + "); + + assert_eq!( + files, + vec!["app/index.html", "package.json", "public/index.html"] + ); + assert_eq!( + candidates, + vec![ + "content-['app/index.html']", + "content-['public/index.html']" + ] + ); + } + + #[test] + fn it_respects_gitignore_files_with_nested_whitelist_patterns() { + // Example from the official `.gitignore` documentation: + // + // ```gitignore + // # exclude everything except directory foo/bar + // /* + // !/foo + // /foo/* + // !/foo/bar + // ``` + let ScanResult { + files, + candidates, + tree, + .. + } = scan(&[ + ( + ".gitignore", + "# exclude everything except directory foo/bar\n/*\n!/foo\n/foo/*\n!/foo/bar\n", + ), + ("foo/bar/index.html", "content-['foo/bar/index.html']"), + ( + "foo/bar/nested/index.html", + "content-['foo/bar/nested/index.html']", + ), + // These are all ignored because they are not inside `foo/bar` + ("index.html", "content-['index.html']"), + ("foo/index.html", "content-['foo/index.html']"), + ("foo/baz/index.html", "content-['foo/baz/index.html']"), + ]); + + assert_snapshot!(tree, @" + . (git) + ├── ✗ .gitignore + │ # exclude everything except directory foo/bar + │ /* + │ !/foo + │ /foo/* + │ !/foo/bar + ├── ✓ foo + │ ├── ✓ bar + │ │ ├── ✓ index.html + │ │ └── ✓ nested + │ │ └── ✓ index.html + │ ├── ✗ baz + │ │ └── ✗ index.html + │ └── ✗ index.html + └── ✗ index.html + "); + + assert_eq!( + files, + vec!["foo/bar/index.html", "foo/bar/nested/index.html"] + ); + assert_eq!( + candidates, + vec![ + "content-['foo/bar/index.html']", + "content-['foo/bar/nested/index.html']" + ] + ); + } + + #[test] + fn explicit_source_directories_respect_their_own_gitignore() { + // A directory referenced via `@source` behaves like an auto source detection + // root. The `.gitignore` file _inside_ that directory still applies to its + // contents. Only when the directory itself is ignored (by an ancestor + // `.gitignore`, see the tests above) do we bypass the ignore rules. + let ScanResult { + files, + candidates, + tree, + .. + } = scan_with_globs( + &[ + ("vendor/.gitignore", "ignored.html"), + ("vendor/index.html", "content-['vendor/index.html']"), + ("vendor/ignored.html", "content-['vendor/ignored.html']"), + ], + vec!["@source 'vendor'"], + ); + + assert_snapshot!(tree, @" + . (git) + └── ✓ vendor + ├── ✗ .gitignore + │ ignored.html + ├── ✗ ignored.html + └── ✓ index.html + "); + + assert_eq!(files, vec!["vendor/index.html"]); + assert_eq!(candidates, vec!["content-['vendor/index.html']"]); + } + + #[test] + fn explicit_sources_can_include_files_inside_gitignored_parent_directories() { + let ScanResult { + candidates, + files, + globs, + tree, + .. + } = scan_with_globs( + &[ + (".gitignore", "src/efgh/"), + ("src/efgh/index.html", "content-['src/efgh/index.html']"), + ("src/efgh/ignore.js", "content-['src/efgh/ignore.js']"), + ("src/abcd/index.html", "content-['src/abcd/index.html']"), + ], + vec!["@source './src/ef*/*.html'"], + ); + + assert_snapshot!(tree, @" + . (git) + ├── ✗ .gitignore + │ src/efgh/ + └── ✓ src + ├── ✗ abcd + │ └── ✗ index.html + └── ✓ efgh + ├── ✗ ignore.js + └── ✓ index.html + "); + + assert_eq!(candidates, vec!["content-['src/efgh/index.html']"]); + assert_eq!(files, vec!["src/efgh/index.html"]); + assert_eq!(globs, vec!["src/ef*/*.html"]); + } + #[test] fn skips_ignore_files_outside_of_a_repo() { // Create a temporary working directory @@ -1148,7 +2699,33 @@ mod scanner { ), ]; - let candidates = Scanner::new(sources.clone()).scan(); + let mut scanner = Scanner::new(sources.clone()); + let candidates = scanner.scan(); + + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + └── ✓ home + ├── ✗ .gitignore + │ ignore-home.html + └── ✓ project + ├── ✗ .gitignore + │ ignore-project.html + └── ✓ apps + ├── ✗ .gitignore + │ ignore-apps.html + ├── ✓ admin + │ └── ✓ index.html + ├── ✓ dashboard + │ └── ✓ index.html + └── ✓ web + ├── ✗ .gitignore + │ ignore-web.html + ├── ✗ ignore-apps.html + ├── ✗ ignore-home.html + ├── ✗ ignore-project.html + ├── ✗ ignore-web.html + └── ✓ index.html + "); // All ignore files are applied because there's no git repo assert_eq!( @@ -1166,7 +2743,33 @@ mod scanner { .arg("init") .current_dir(dir.join("home")) .output(); - let candidates = Scanner::new(sources.clone()).scan(); + let mut scanner = Scanner::new(sources.clone()); + let candidates = scanner.scan(); + + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + └── ✓ home (git) + ├── ✗ .gitignore + │ ignore-home.html + └── ✓ project + ├── ✗ .gitignore + │ ignore-project.html + └── ✓ apps + ├── ✗ .gitignore + │ ignore-apps.html + ├── ✓ admin + │ └── ✓ index.html + ├── ✓ dashboard + │ └── ✓ index.html + └── ✓ web + ├── ✗ .gitignore + │ ignore-web.html + ├── ✗ ignore-apps.html + ├── ✗ ignore-home.html + ├── ✗ ignore-project.html + ├── ✗ ignore-web.html + └── ✓ index.html + "); assert_eq!( candidates, @@ -1185,7 +2788,33 @@ mod scanner { .arg("init") .current_dir(dir.join("home/project")) .output(); - let candidates = Scanner::new(sources.clone()).scan(); + let mut scanner = Scanner::new(sources.clone()); + let candidates = scanner.scan(); + + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + └── ✓ home + ├── ✗ .gitignore + │ ignore-home.html + └── ✓ project (git) + ├── ✗ .gitignore + │ ignore-project.html + └── ✓ apps + ├── ✗ .gitignore + │ ignore-apps.html + ├── ✓ admin + │ └── ✓ index.html + ├── ✓ dashboard + │ └── ✓ index.html + └── ✓ web + ├── ✗ .gitignore + │ ignore-web.html + ├── ✗ ignore-apps.html + ├── ✓ ignore-home.html + ├── ✗ ignore-project.html + ├── ✗ ignore-web.html + └── ✓ index.html + "); assert_eq!( candidates, @@ -1205,7 +2834,33 @@ mod scanner { .arg("init") .current_dir(dir.join("home/project/apps")) .output(); - let candidates = Scanner::new(sources.clone()).scan(); + let mut scanner = Scanner::new(sources.clone()); + let candidates = scanner.scan(); + + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + └── ✓ home + ├── ✗ .gitignore + │ ignore-home.html + └── ✓ project + ├── ✗ .gitignore + │ ignore-project.html + └── ✓ apps (git) + ├── ✗ .gitignore + │ ignore-apps.html + ├── ✓ admin + │ └── ✓ index.html + ├── ✓ dashboard + │ └── ✓ index.html + └── ✓ web + ├── ✗ .gitignore + │ ignore-web.html + ├── ✗ ignore-apps.html + ├── ✓ ignore-home.html + ├── ✓ ignore-project.html + ├── ✗ ignore-web.html + └── ✓ index.html + "); assert_eq!( candidates, @@ -1227,7 +2882,33 @@ mod scanner { .current_dir(dir.join("home/project/apps/web")) .output(); - let candidates = Scanner::new(sources.clone()).scan(); + let mut scanner = Scanner::new(sources.clone()); + let candidates = scanner.scan(); + + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + └── ✓ home + ├── ✗ .gitignore + │ ignore-home.html + └── ✓ project + ├── ✗ .gitignore + │ ignore-project.html + └── ✓ apps + ├── ✗ .gitignore + │ ignore-apps.html + ├── ✓ admin + │ └── ✓ index.html + ├── ✓ dashboard + │ └── ✓ index.html + └── ✓ web (git) + ├── ✗ .gitignore + │ ignore-web.html + ├── ✓ ignore-apps.html + ├── ✓ ignore-home.html + ├── ✓ ignore-project.html + ├── ✗ ignore-web.html + └── ✓ index.html + "); assert_eq!( candidates, @@ -1261,7 +2942,15 @@ mod scanner { public_source_entry_from_pattern(dir.clone(), "@source not 'src/ignore-me.html'"), ]; - let candidates = Scanner::new(sources.clone()).scan(); + let mut scanner = Scanner::new(sources.clone()); + let candidates = scanner.scan(); + + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + └── ✓ src + ├── ✗ ignore-me.html + └── ✓ keep-me.html + "); assert_eq!(candidates, vec!["content-['keep-me.html']"]); } @@ -1288,7 +2977,17 @@ mod scanner { ), ]; - let candidates = Scanner::new(sources.clone()).scan(); + let mut scanner = Scanner::new(sources.clone()); + let candidates = scanner.scan(); + + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + └── ✓ src + └── ✓ app + └── ✓ [foo] + ├── ✗ ignore-me.html + └── ✓ keep-me.html + "); assert_eq!(candidates, vec!["content-['keep-me.html']"]); } @@ -1315,6 +3014,12 @@ mod scanner { let candidates = scanner.scan(); assert_eq!(candidates, vec!["content-['src/keep-me.html']"]); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + └── ✓ src + └── ✓ keep-me.html + "); + // Create new files that should definitely be ignored create_files_in( &dir, @@ -1342,6 +3047,18 @@ mod scanner { let candidates = scanner.scan(); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✗ .gitignore + │ src/ignored-by-gitignore.html + └── ✓ src + ├── ✗ ignore-by-extension.bin + ├── ✓ ignored-by-gitignore.html + ├── ✗ ignored-by-source-not.html + ├── ✓ keep-me.html + └── ✓ new-file.html + "); + assert_eq!( candidates, vec![ @@ -1371,6 +3088,11 @@ mod scanner { let candidates = scanner.scan(); assert!(candidates.is_empty()); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + └── ✗ foo.styl + "); + // Explicitly allow `.styl` files let mut scanner = Scanner::new(vec![ public_source_entry_from_pattern(dir.clone(), "@source '**/*'"), @@ -1379,6 +3101,11 @@ mod scanner { let candidates = scanner.scan(); assert_eq!(candidates, vec!["content-['foo.styl']"]); + + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + └── ✓ foo.styl + "); } #[test] @@ -1403,6 +3130,13 @@ mod scanner { let candidates = scanner.scan(); assert!(candidates.is_empty()); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✗ .gitignore + │ index.html + └── ✗ index.html + "); + let mut scanner = Scanner::new(vec![ public_source_entry_from_pattern(dir.clone(), "@source '**/*'"), public_source_entry_from_pattern(dir.clone(), "@source './*.html'"), @@ -1410,6 +3144,13 @@ mod scanner { let candidates = scanner.scan(); assert_eq!(candidates, vec!["content-['index.html']"]); + + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✗ .gitignore + │ index.html + └── ✓ index.html + "); } #[test] @@ -1444,6 +3185,17 @@ mod scanner { let candidates = scanner.scan(); assert_eq!(candidates, vec!["content-['src/index.html']"]); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✗ .gitignore + │ node_modules + ├── ✗ node_modules + │ └── ✗ my-ui-lib + │ └── ✗ index.html + └── ✓ src + └── ✓ index.html + "); + // Explicitly listing all `*.html` files, should not include `node_modules` because it's // ignored let sources = vec![public_source_entry_from_pattern( @@ -1455,6 +3207,17 @@ mod scanner { let candidates = scanner.scan(); assert_eq!(candidates, vec!["content-['src/index.html']"]); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✗ .gitignore + │ node_modules + ├── ✗ node_modules + │ └── ✗ my-ui-lib + │ └── ✗ index.html + └── ✓ src + └── ✓ index.html + "); + // Explicitly listing all `*.html` files // Explicitly list the `node_modules/my-ui-lib` // @@ -1472,6 +3235,64 @@ mod scanner { "content-['src/index.html']" ] ); + + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✗ .gitignore + │ node_modules + ├── ✓ node_modules + │ └── ✓ my-ui-lib + │ └── ✓ index.html + └── ✓ src + └── ✓ index.html + "); + } + + // https://github.com/tailwindlabs/tailwindcss/issues/19844 + #[test] + fn test_allow_explicit_sources_ignored_by_allow_list_gitignore() { + let ScanResult { + candidates, tree, .. + } = scan_with_globs( + &[ + (".gitignore", "*\n!/app\n!/app/design\n!/app/design/**\n"), + ( + "app/design/frontend/theme/templates/component.phtml", + "content-['app/design/frontend/theme/templates/component.phtml']", + ), + ( + "vendor/acme/theme/module/templates/component.phtml", + "content-['vendor/acme/theme/module/templates/component.phtml']", + ), + ], + vec!["@source 'vendor/acme/theme'"], + ); + + assert_snapshot!(tree, @" + . (git) + ├── ✗ .gitignore + │ * + │ !/app + │ !/app/design + │ !/app/design/** + ├── ✗ app + │ └── ✗ design + │ └── ✗ frontend + │ └── ✗ theme + │ └── ✗ templates + │ └── ✗ component.phtml + └── ✓ vendor + └── ✓ acme + └── ✓ theme + └── ✓ module + └── ✓ templates + └── ✓ component.phtml + "); + + assert_eq!( + candidates, + vec!["content-['vendor/acme/theme/module/templates/component.phtml']"] + ); } #[test] @@ -1481,6 +3302,8 @@ mod scanner { files, globs, normalized_sources, + tree, + .. } = scan_with_globs( &[ ( @@ -1499,6 +3322,17 @@ mod scanner { vec!["@source '**/*'"], ); + assert_snapshot!(tree, @" + . (git) + ├── ✗ node_modules + │ └── ✗ index.html + └── ✓ packages + └── ✓ web + ├── ✓ index.html + └── ✗ node_modules + └── ✗ index.html + "); + assert_eq!(candidates, vec!["content-['packages/web/index.html']"]); assert_eq!(files, vec!["packages/web/index.html",]); @@ -1513,6 +3347,8 @@ mod scanner { files, globs, normalized_sources, + tree, + .. } = scan_with_globs( &[ (".gitignore", "node_modules\ndist"), @@ -1528,6 +3364,18 @@ mod scanner { vec!["@source 'node_modules/my-ui-lib'"], ); + assert_snapshot!(tree, @" + . (git) + ├── ✗ .gitignore + │ node_modules + │ dist + └── ✓ node_modules + └── ✓ my-ui-lib + ├── ✓ dist + │ └── ✓ index.html + └── ✗ node.exe + "); + assert_eq!( candidates, vec!["content-['node_modules/my-ui-lib/dist/index.html']"] @@ -1569,6 +3417,17 @@ mod scanner { let candidates = scanner.scan(); assert_eq!(candidates, vec!["content-['src/components/button.tsx']"]); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✗ .gitignore + │ *.jsx + │ generated/ + └── ✓ src + └── ✓ components + ├── ✗ button.jsx + └── ✓ button.tsx + "); + // Create 2 new files, one "good" and one "bad" file, and manually scan them. This should // only return the "good" file because the "bad" one is ignored by a `.gitignore` file. create_files_in( @@ -1629,6 +3488,8 @@ mod scanner { files, globs, normalized_sources, + tree, + .. } = scan_with_globs( &[ ("src/💩.js", "content-['src/💩.js']"), @@ -1638,6 +3499,15 @@ mod scanner { vec!["@source '**/*'", "@source not 'src/🤦‍♂️'"], ); + assert_snapshot!(tree, @" + . (git) + └── ✓ src + ├── ✓ 💩.js + ├── ✗ 🤦‍♂️ + │ └── ✗ foo.tsx + └── ✓ 🤦‍♂️.tsx + "); + assert_eq!( candidates, vec!["content-['src/💩.js']", "content-['src/🤦‍♂️.tsx']"] @@ -1674,6 +3544,23 @@ mod scanner { )]); let candidates = scanner.scan(); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✗ .gitignore + │ node_modules + │ dist + └── ✓ node_modules + ├── ✓ .pnpm + │ └── ✓ @org+my-ui-library + │ └── ✓ dist + │ └── ✓ index.ts + └── ✓ @org + ├── ✓ .gitkeep + └── ✓ my-ui-library → ../.pnpm/@org+my-ui-library + └── ✓ dist + └── ✓ index.ts + "); + assert_eq!( candidates, vec!["content-['node_modules/.pnpm/@org+my-ui-library/dist/index.ts']"] @@ -1685,6 +3572,23 @@ mod scanner { )]); let candidates = scanner.scan(); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✗ .gitignore + │ node_modules + │ dist + └── ✓ node_modules + ├── ✓ .pnpm + │ └── ✓ @org+my-ui-library + │ └── ✓ dist + │ └── ✓ index.ts + └── ✓ @org + ├── ✗ .gitkeep + └── ✓ my-ui-library → ../.pnpm/@org+my-ui-library + └── ✓ dist + └── ✓ index.ts + "); + assert_eq!( candidates, vec!["content-['node_modules/.pnpm/@org+my-ui-library/dist/index.ts']"] @@ -1696,6 +3600,23 @@ mod scanner { )]); let candidates = scanner.scan(); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✗ .gitignore + │ node_modules + │ dist + └── ✓ node_modules + ├── ✓ .pnpm + │ └── ✓ @org+my-ui-library + │ └── ✓ dist + │ └── ✓ index.ts + └── ✓ @org + ├── ✓ .gitkeep + └── ✓ my-ui-library → ../.pnpm/@org+my-ui-library + └── ✓ dist + └── ✓ index.ts + "); + assert_eq!( candidates, vec!["content-['node_modules/.pnpm/@org+my-ui-library/dist/index.ts']"] @@ -1713,11 +3634,14 @@ mod scanner { ], ); - // Create recursive symlinks - let _ = symlink(dir.join("a"), dir.join("b")); - let _ = symlink(dir.join("b/c"), dir.join("c")); - let _ = symlink(dir.join("b/root"), &dir); - let _ = symlink(dir.join("c"), dir.join("a")); + // Create recursive symlinks: + // + // - `a → b`, `b/c → c`, `c → a` form a cycle + // - `b/root → .` points back at the root directory + let _ = symlink(dir.join("b"), dir.join("a")); + let _ = symlink(dir.join("c"), dir.join("b/c")); + let _ = symlink(&dir, dir.join("b/root")); + let _ = symlink(dir.join("a"), dir.join("c")); let mut scanner = Scanner::new(vec![public_source_entry_from_pattern( dir.clone(), @@ -1725,6 +3649,24 @@ mod scanner { )]); let candidates = scanner.scan(); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✓ a → b + │ ├── ✗ c → ../c + │ ├── ✓ index.html + │ └── ✗ root → .. + ├── ✓ b + │ ├── ✗ c → ../c + │ ├── ✓ index.html + │ └── ✗ root → .. + ├── ✓ c → a + │ ├── ✗ c → . + │ ├── ✓ index.html + │ └── ✗ root → .. + └── ✓ z + └── ✓ index.html + "); + assert_eq!( candidates, vec!["content-['b/index.html']", "content-['z/index.html']"] @@ -1749,6 +3691,14 @@ mod scanner { )]); let candidates = scanner.scan(); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✓ abcd + │ └── ✓ xyz.html + └── ✓ efgh → abcd + └── ✓ xyz.html + "); + assert_eq!(candidates, vec!["content-['abcd/xyz.html']"]); // Partially referencing the symlinked folder with a glob, should find the file @@ -1761,6 +3711,333 @@ mod scanner { assert_eq!(candidates, vec!["content-['abcd/xyz.html']"]); } + #[test] + fn test_symlinked_sources_within_the_scanned_tree_can_be_ignored() { + let dir = tempdir().unwrap().into_path(); + create_files_in( + &dir, + &[ + ("src/keep.html", "content-['src/keep.html']"), + ( + "actual-dir/ignore.html", + "content-['actual-dir/ignore.html']", + ), + ("actual-file.html", "content-['actual-file.html']"), + ], + ); + + let _ = symlink(dir.join("actual-dir"), dir.join("linked-dir")); + let _ = symlink_file(dir.join("actual-file.html"), dir.join("linked-file.html")); + + let base = dir.join("src"); + let mut scanner = Scanner::new(vec![ + public_source_entry_from_pattern(base.clone(), "@source '../**/*'"), + public_source_entry_from_pattern(base.clone(), "@source not '../actual-dir'"), + public_source_entry_from_pattern(base.clone(), "@source not '../linked-dir'"), + public_source_entry_from_pattern(base.clone(), "@source not '../actual-file.html'"), + public_source_entry_from_pattern(base.clone(), "@source not '../linked-file.html'"), + ]); + let candidates = scanner.scan(); + + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✗ actual-dir + │ └── ✗ ignore.html + ├── ✗ actual-file.html + ├── ✗ linked-dir → actual-dir + │ └── ✗ ignore.html + ├── ✗ linked-file.html → actual-file.html + └── ✓ src + └── ✓ keep.html + "); + + assert_eq!(candidates, vec!["content-['src/keep.html']"]); + + let mut scanner = Scanner::new(vec![ + public_source_entry_from_pattern(base.clone(), "@source '../**/*'"), + public_source_entry_from_pattern(base.clone(), "@source not '../actual-dir/**/*.html'"), + public_source_entry_from_pattern(base.clone(), "@source not '../linked-dir/**/*.html'"), + public_source_entry_from_pattern(base.clone(), "@source not '../actual-file.html'"), + public_source_entry_from_pattern(base.clone(), "@source not '../linked-file.html'"), + ]); + let candidates = scanner.scan(); + + assert_eq!(candidates, vec!["content-['src/keep.html']"]); + } + + #[test] + fn test_symlinked_sources_outside_the_scanned_tree_can_be_ignored() { + let dir = tempdir().unwrap().into_path(); + create_files_in( + &dir, + &[ + ( + "actual-dir/ignore.html", + "content-['actual-dir/ignore.html']", + ), + ("project/keep.html", "content-['project/keep.html']"), + ], + ); + + let _ = symlink(dir.join("actual-dir"), dir.join("project/linked-dir")); + + let base = dir.join("project"); + let mut scanner = Scanner::new(vec![public_source_entry_from_pattern( + base.clone(), + "@source '**/*'", + )]); + let candidates = scanner.scan(); + + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✓ actual-dir + │ └── ✓ ignore.html + └── ✓ project + ├── ✓ keep.html + └── ✓ linked-dir → ../actual-dir + └── ✓ ignore.html + "); + + assert_eq!( + candidates, + vec![ + "content-['actual-dir/ignore.html']", + "content-['project/keep.html']", + ] + ); + + let mut scanner = Scanner::new(vec![ + public_source_entry_from_pattern(base.clone(), "@source '**/*'"), + public_source_entry_from_pattern(base.clone(), "@source not 'linked-dir'"), + ]); + let candidates = scanner.scan(); + + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✗ actual-dir + │ └── ✗ ignore.html + └── ✓ project + ├── ✓ keep.html + └── ✗ linked-dir → ../actual-dir + └── ✗ ignore.html + "); + + assert_eq!(candidates, vec!["content-['project/keep.html']"]); + } + + #[test] + fn test_files_behind_symlinks_are_tracked_with_their_canonical_paths() { + let dir = tempdir().unwrap().into_path(); + create_files_in( + &dir, + &[ + ( + "packages/repro/source.html", + "content-['packages/repro/source.html']", + ), + ( + "packages/repro/nested/deep.html", + "content-['packages/repro/nested/deep.html']", + ), + ( + "packages/other/index.html", + "content-['packages/other/index.html']", + ), + ], + ); + + // Mimic a pnpm workspace where `node_modules` contains a symlink to the actual package + fs::create_dir_all(dir.join("node_modules")).unwrap(); + let _ = symlink(dir.join("packages/repro"), dir.join("node_modules/repro")); + + // A directly symlinked file + let _ = symlink_file( + dir.join("packages/other/index.html"), + dir.join("linked.html"), + ); + + let mut scanner = Scanner::new(vec![ + public_source_entry_from_pattern( + dir.clone(), + "@source 'node_modules/repro/source.html'", + ), + // The symlink sits multiple levels up from the file + public_source_entry_from_pattern( + dir.clone(), + "@source 'node_modules/repro/nested/deep.html'", + ), + public_source_entry_from_pattern(dir.clone(), "@source 'linked.html'"), + ]); + + let candidates = scanner.scan(); + assert_eq!( + candidates, + vec![ + "content-['packages/other/index.html']", + "content-['packages/repro/nested/deep.html']", + "content-['packages/repro/source.html']", + ] + ); + + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✓ linked.html → packages/other/index.html + ├── ✓ node_modules + │ └── ✓ repro → ../packages/repro + │ ├── ✓ nested + │ │ └── ✓ deep.html + │ └── ✓ source.html + └── ✓ packages + ├── ✓ other + │ └── ✓ index.html + └── ✓ repro + ├── ✓ nested + │ └── ✓ deep.html + └── ✓ source.html + "); + + // Both the symlinked paths and the canonical paths should be tracked, such that file + // watchers watching the returned files also watch the real files on disk. + let files = scanned_files(&mut scanner, &dir); + assert_eq!( + files, + vec![ + "linked.html", + "node_modules/repro/nested/deep.html", + "node_modules/repro/source.html", + "packages/other/index.html", + "packages/repro/nested/deep.html", + "packages/repro/source.html", + ] + ); + } + + #[test] + fn test_changes_to_the_canonical_path_of_a_symlinked_file_are_detected() { + let dir = tempdir().unwrap().into_path(); + create_files_in(&dir, &[("packages/repro/source.html", "content-['v1']")]); + + // Mimic a pnpm workspace where `node_modules` contains a symlink to the actual package + fs::create_dir_all(dir.join("node_modules")).unwrap(); + let _ = symlink(dir.join("packages/repro"), dir.join("node_modules/repro")); + + let mut scanner = Scanner::new(vec![public_source_entry_from_pattern( + dir.clone(), + "@source 'node_modules/repro/source.html'", + )]); + + let candidates = scanner.scan(); + assert_eq!(candidates, vec!["content-['v1']"]); + + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✓ node_modules + │ └── ✓ repro → ../packages/repro + │ └── ✓ source.html + └── ✓ packages + └── ✓ repro + └── ✓ source.html + "); + + // Update the real file on disk. This is the path file watchers will report changes for. + create_files_in(&dir, &[("packages/repro/source.html", "content-['v2']")]); + + let candidates = scanner.scan_content(vec![ChangedContent::File( + dir.join("packages/repro/source.html"), + "html".into(), + )]); + assert_eq!(candidates, vec!["content-['v2']"]); + } + + #[test] + fn test_new_files_behind_symlinks_are_detected_at_their_canonical_path() { + let dir = tempdir().unwrap().into_path(); + create_files_in(&dir, &[("packages/repro/a.html", "content-['a']")]); + + // Mimic a pnpm workspace where `node_modules` contains a symlink to the actual package + fs::create_dir_all(dir.join("node_modules")).unwrap(); + let _ = symlink(dir.join("packages/repro"), dir.join("node_modules/repro")); + + let mut scanner = Scanner::new(vec![public_source_entry_from_pattern( + dir.clone(), + "@source 'node_modules/repro/*.html'", + )]); + + let candidates = scanner.scan(); + assert_eq!(candidates, vec!["content-['a']"]); + + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✓ node_modules + │ └── ✓ repro → ../packages/repro + │ └── ✓ a.html + └── ✓ packages + └── ✓ repro + └── ✓ a.html + "); + + // Create a new file in the real directory. File watchers watching the real directory + // will report the new file with its canonical path. + create_files_in(&dir, &[("packages/repro/b.html", "content-['b']")]); + + let candidates = scanner.scan_content(vec![ChangedContent::File( + dir.join("packages/repro/b.html"), + "html".into(), + )]); + assert_eq!(candidates, vec!["content-['b']"]); + + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✓ node_modules + │ └── ✓ repro → ../packages/repro + │ ├── ✓ a.html + │ └── ✓ b.html + └── ✓ packages + └── ✓ repro + ├── ✓ a.html + └── ✗ b.html + "); + } + + // https://github.com/tailwindlabs/tailwindcss/pull/20408 + #[test] + fn test_resolving_globs_does_not_traverse_gitignored_directories() { + let ScanResult { + files, globs, tree, .. + } = scan_with_globs( + &[ + (".gitignore", "/vendor\n"), + ("vendor/pkg/canary/index.html", ""), + ("src/index.html", ""), + ], + vec!["@source '**/*'", "@source './vendor/pkg/canary'"], + ); + + assert_snapshot!(tree, @" + . (git) + ├── ✗ .gitignore + │ /vendor + ├── ✓ src + │ └── ✓ index.html + └── ✓ vendor + └── ✓ pkg + └── ✓ canary + └── ✓ index.html + "); + + assert_eq!( + files, + vec!["src/index.html", "vendor/pkg/canary/index.html"] + ); + assert_eq!(globs, vec![ + "*", + "src/**/*.{aspx,astro,cjs,cts,eex,erb,gjs,gts,haml,handlebars,hbs,heex,html,jade,js,jsx,liquid,md,mdx,mjs,mts,mustache,njk,nunjucks,php,pug,py,razor,rb,rhtml,rs,slim,svelte,tpl,ts,tsx,twig,vue}", + + // This should not include `**` or `**.*.{aspx,...}` otherwise this might be scanned recursively. + "vendor/pkg/canary/*", + ]); + } + #[test] fn test_extract_used_css_variables_from_css() { let dir = tempdir().unwrap().into_path(); @@ -1786,6 +4063,11 @@ mod scanner { ("src/defined-at-start.css", "--color-defined-at-start: red;"), ], ); + fs::write( + dir.join("src/invalid.css"), + b".button { color: var(--color-\x80); }", + ) + .unwrap(); let mut scanner = Scanner::new(vec![public_source_entry_from_pattern( dir.clone(), diff --git a/integrations/cli/config.test.ts b/integrations/cli/config.test.ts index dd93ade64..dda70814b 100644 --- a/integrations/cli/config.test.ts +++ b/integrations/cli/config.test.ts @@ -126,6 +126,72 @@ 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` +
+ `, + 'tailwind.config.ts': ts` + import type { Config } from 'tailwindcss' + + function defineConfig(config: Config): Config { + return config + } + + export default defineConfig({ + theme: { + extend: { + colors: { + primary: 'blue', + }, + }, + }, + }) + `, + 'src/index.css': css` + @import 'tailwindcss'; + @config '../tailwind.config.ts'; + `, + }, + }, + async ({ fs, exec }) => { + // We expect that these commands don't crash: + await exec('pnpm tsc -b') + await exec('pnpm tailwindcss --input src/index.css --output dist/out.css') + + await fs.expectFileToContain('dist/out.css', [candidate`text-primary`]) + }, +) + test( 'Config files (CJS, watch mode)', { diff --git a/integrations/cli/index.test.ts b/integrations/cli/index.test.ts index be48a2ac4..07f8bcb68 100644 --- a/integrations/cli/index.test.ts +++ b/integrations/cli/index.test.ts @@ -1,8 +1,11 @@ import dedent from 'dedent' import os from 'node:os' import path from 'node:path' +import { fileURLToPath } from 'node:url' import { describe } from 'vitest' -import { candidate, css, html, js, json, test, ts, yaml } from '../utils' +import { candidate, css, html, js, json, retryAssertion, test, ts, txt, yaml } from '../utils' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) const STANDALONE_BINARY = (() => { switch (os.platform()) { @@ -372,6 +375,120 @@ 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` +
+ `, + }, + }, + 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` +
+ `) + + 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` +
+ `, + '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` +
+ `, + '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( 'production build (stdin)', { @@ -556,6 +673,15 @@ describe.each([ 'git ignore files outside of a repo are not considered', { 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 'home/.gitignore': '*', @@ -593,7 +719,7 @@ describe.each([ installDependencies: false, }, async ({ fs, root, exec }) => { - await exec(`pnpm install --ignore-workspace`, { + await exec(`pnpm install`, { cwd: path.join(root, 'home/project'), }) @@ -641,7 +767,7 @@ describe.each([ } } `, - 'ssrc/index.html': html` + 'src/index.html': html`
`, 'src/index.css': css` @@ -702,7 +828,7 @@ describe.each([ } } `, - 'ssrc/index.html': html` + 'src/index.html': html`
`, 'src/index.css': css` @@ -749,7 +875,7 @@ describe.each([ expect(map.at(4, 0)).toMatchObject({ source: null, original: '(none)', - generated: '}...', + generated: '}\n\n/*# sou...', }) }, ) @@ -768,7 +894,7 @@ describe.each([ } } `, - 'ssrc/index.html': html` + 'src/index.html': html`
`, 'src/index.css': css` @@ -809,11 +935,566 @@ describe.each([ generated: 'display: f...', }) + expect(map.at(4, 0)).toMatchObject({ + source: null, + original: '(none)', + generated: '}\n\n/*# sou...', + }) + }, + ) + + test( + 'watch mode + inline source maps', + { + fs: { + 'package.json': json` + { + "dependencies": { + "tailwindcss": "workspace:^", + "@tailwindcss/cli": "workspace:^" + } + } + `, + 'src/index.html': html` +
+ `, + 'src/index.css': css` + @import 'tailwindcss/utilities'; + /* */ + `, + }, + }, + async ({ spawn, expect, fs, parseSourceMap }) => { + let process = await spawn( + `${command} --input src/index.css --output dist/out.css --map --watch`, + ) + await process.onStderr((m) => m.includes('Done in')) + + await fs.expectFileToContain('dist/out.css', [candidate`flex`]) + + let originalCss = await fs.read('dist/out.css') + let currentCss = originalCss + + // Make sure we can find a source map + let map = parseSourceMap(currentCss) + + expect(map.at(1, 0)).toMatchObject({ + source: null, + original: '(none)', + generated: '/*! tailwi...', + }) + + expect(map.at(2, 0)).toMatchObject({ + source: + kind === 'CLI' + ? expect.stringContaining('utilities.css') + : expect.stringMatching(/\/utilities-\w+\.css$/), + original: '@tailwind...', + generated: '.flex {...', + }) + + expect(map.at(3, 2)).toMatchObject({ + source: + kind === 'CLI' + ? expect.stringContaining('utilities.css') + : expect.stringMatching(/\/utilities-\w+\.css$/), + original: '@tailwind...', + generated: 'display: f...', + }) + expect(map.at(4, 0)).toMatchObject({ source: null, original: '(none)', generated: '}...', }) + + // Write to project source files + await fs.write('src/index.html', html` +
+ `) + + // Wait for the CSS to be rebuilt + await retryAssertion(async () => { + currentCss = await fs.read('dist/out.css') + expect(currentCss).not.toEqual(originalCss) + originalCss = currentCss + }) + + // Make sure the source map was updated + map = parseSourceMap(currentCss) + + expect(map.at(1, 0)).toMatchObject({ + source: null, + original: '(none)', + generated: '/*! tailwi...', + }) + + expect(map.at(2, 0)).toMatchObject({ + source: + kind === 'CLI' + ? expect.stringContaining('utilities.css') + : expect.stringMatching(/\/utilities-\w+\.css$/), + original: '@tailwind...', + generated: '.flex {...', + }) + + expect(map.at(3, 2)).toMatchObject({ + source: + kind === 'CLI' + ? expect.stringContaining('utilities.css') + : expect.stringMatching(/\/utilities-\w+\.css$/), + original: '@tailwind...', + generated: 'display: f...', + }) + + expect(map.at(4, 0)).toMatchObject({ + source: null, + original: '(none)', + generated: '}\n.underli...', + }) + + expect(map.at(5, 0)).toMatchObject({ + source: + kind === 'CLI' + ? expect.stringContaining('utilities.css') + : expect.stringMatching(/\/utilities-\w+\.css$/), + original: '@tailwind...', + generated: '.underline...', + }) + + expect(map.at(6, 2)).toMatchObject({ + source: + kind === 'CLI' + ? expect.stringContaining('utilities.css') + : expect.stringMatching(/\/utilities-\w+\.css$/), + original: '@tailwind...', + generated: 'text-decor...', + }) + + expect(map.at(7, 0)).toMatchObject({ + source: null, + original: '(none)', + generated: '}...', + }) + + // Write to the main CSS file + await fs.write( + 'src/index.css', + css` + @import 'tailwindcss/utilities'; + @source inline("w-auto"); + `, + ) + + // Wait for the CSS to be rebuilt + await retryAssertion(async () => { + currentCss = await fs.read('dist/out.css') + expect(currentCss).not.toEqual(originalCss) + originalCss = currentCss + }) + + // Make sure the source map was updated + map = parseSourceMap(currentCss) + + expect(map.at(1, 0)).toMatchObject({ + source: null, + original: '(none)', + generated: '/*! tailwi...', + }) + + expect(map.at(2, 0)).toMatchObject({ + source: + kind === 'CLI' + ? expect.stringContaining('utilities.css') + : expect.stringMatching(/\/utilities-\w+\.css$/), + original: '@tailwind...', + generated: '.flex {...', + }) + + expect(map.at(3, 2)).toMatchObject({ + source: + kind === 'CLI' + ? expect.stringContaining('utilities.css') + : expect.stringMatching(/\/utilities-\w+\.css$/), + original: '@tailwind...', + generated: 'display: f...', + }) + + expect(map.at(4, 0)).toMatchObject({ + source: null, + original: '(none)', + generated: '}\n.w-auto...', + }) + + expect(map.at(5, 0)).toMatchObject({ + source: + kind === 'CLI' + ? expect.stringContaining('utilities.css') + : expect.stringMatching(/\/utilities-\w+\.css$/), + original: '@tailwind...', + generated: '.w-auto {...', + }) + + expect(map.at(6, 2)).toMatchObject({ + source: + kind === 'CLI' + ? expect.stringContaining('utilities.css') + : expect.stringMatching(/\/utilities-\w+\.css$/), + original: '@tailwind...', + generated: 'width: aut...', + }) + + expect(map.at(7, 0)).toMatchObject({ + source: null, + original: '(none)', + generated: '}\n.underli...', + }) + + expect(map.at(8, 0)).toMatchObject({ + source: + kind === 'CLI' + ? expect.stringContaining('utilities.css') + : expect.stringMatching(/\/utilities-\w+\.css$/), + original: '@tailwind...', + generated: '.underline...', + }) + + expect(map.at(9, 2)).toMatchObject({ + source: + kind === 'CLI' + ? expect.stringContaining('utilities.css') + : expect.stringMatching(/\/utilities-\w+\.css$/), + original: '@tailwind...', + generated: 'text-decor...', + }) + + expect(map.at(10, 0)).toMatchObject({ + source: null, + original: '(none)', + generated: '}...', + }) + }, + ) + + test( + 'watch mode + separate source maps', + { + fs: { + 'package.json': json` + { + "dependencies": { + "tailwindcss": "workspace:^", + "@tailwindcss/cli": "workspace:^" + } + } + `, + 'src/index.html': html` +
+ `, + 'src/index.css': css` + @import 'tailwindcss/utilities'; + /* */ + `, + }, + }, + async ({ spawn, expect, fs, parseSourceMap }) => { + let process = await spawn( + `${command} --input src/index.css --output dist/out.css --map dist/out.css.map --watch`, + ) + await process.onStderr((m) => m.includes('Done in')) + + await fs.expectFileToContain('dist/out.css', [candidate`flex`]) + + // Make sure we can find a source map + let originalCss = await fs.read('dist/out.css') + let originalMap = await fs.read('dist/out.css.map') + let currentCss = originalCss + let currentMap = originalMap + + // Make sure we can find a source map + let map = parseSourceMap({ map: currentMap, content: currentCss }) + + expect(map.at(1, 0)).toMatchObject({ + source: null, + original: '(none)', + generated: '/*! tailwi...', + }) + + expect(map.at(2, 0)).toMatchObject({ + source: + kind === 'CLI' + ? expect.stringContaining('utilities.css') + : expect.stringMatching(/\/utilities-\w+\.css$/), + original: '@tailwind...', + generated: '.flex {...', + }) + + expect(map.at(3, 2)).toMatchObject({ + source: + kind === 'CLI' + ? expect.stringContaining('utilities.css') + : expect.stringMatching(/\/utilities-\w+\.css$/), + original: '@tailwind...', + generated: 'display: f...', + }) + + expect(map.at(4, 0)).toMatchObject({ + source: null, + original: '(none)', + generated: '}\n\n/*# sou...', + }) + + // Write to project source files + await fs.write('src/index.html', html` +
+ `) + + // Wait for the CSS to be rebuilt + await retryAssertion(async () => { + currentCss = await fs.read('dist/out.css') + currentMap = await fs.read('dist/out.css.map') + expect(currentCss).not.toEqual(originalCss) + expect(currentMap).not.toEqual(originalMap) + originalCss = currentCss + originalMap = currentMap + }) + + // Make sure the source map was updated + map = parseSourceMap({ map: currentMap, content: currentCss }) + + expect(map.at(1, 0)).toMatchObject({ + source: null, + original: '(none)', + generated: '/*! tailwi...', + }) + + expect(map.at(2, 0)).toMatchObject({ + source: + kind === 'CLI' + ? expect.stringContaining('utilities.css') + : expect.stringMatching(/\/utilities-\w+\.css$/), + original: '@tailwind...', + generated: '.flex {...', + }) + + expect(map.at(3, 2)).toMatchObject({ + source: + kind === 'CLI' + ? expect.stringContaining('utilities.css') + : expect.stringMatching(/\/utilities-\w+\.css$/), + original: '@tailwind...', + generated: 'display: f...', + }) + + expect(map.at(4, 0)).toMatchObject({ + source: null, + original: '(none)', + generated: '}\n.underli...', + }) + + expect(map.at(5, 0)).toMatchObject({ + source: + kind === 'CLI' + ? expect.stringContaining('utilities.css') + : expect.stringMatching(/\/utilities-\w+\.css$/), + original: '@tailwind...', + generated: '.underline...', + }) + + expect(map.at(6, 2)).toMatchObject({ + source: + kind === 'CLI' + ? expect.stringContaining('utilities.css') + : expect.stringMatching(/\/utilities-\w+\.css$/), + original: '@tailwind...', + generated: 'text-decor...', + }) + + expect(map.at(7, 0)).toMatchObject({ + source: null, + original: '(none)', + generated: '}\n\n/*# sou...', + }) + + // Write to the main CSS file + await fs.write( + 'src/index.css', + css` + @import 'tailwindcss/utilities'; + @source inline("w-auto"); + `, + ) + + // Wait for the CSS to be rebuilt + await retryAssertion(async () => { + currentCss = await fs.read('dist/out.css') + currentMap = await fs.read('dist/out.css.map') + expect(currentCss).not.toEqual(originalCss) + expect(currentMap).not.toEqual(originalMap) + originalCss = currentCss + originalMap = currentMap + }) + + // Make sure the source map was updated + map = parseSourceMap({ map: currentMap, content: currentCss }) + + expect(map.at(1, 0)).toMatchObject({ + source: null, + original: '(none)', + generated: '/*! tailwi...', + }) + + expect(map.at(2, 0)).toMatchObject({ + source: + kind === 'CLI' + ? expect.stringContaining('utilities.css') + : expect.stringMatching(/\/utilities-\w+\.css$/), + original: '@tailwind...', + generated: '.flex {...', + }) + + expect(map.at(3, 2)).toMatchObject({ + source: + kind === 'CLI' + ? expect.stringContaining('utilities.css') + : expect.stringMatching(/\/utilities-\w+\.css$/), + original: '@tailwind...', + generated: 'display: f...', + }) + + expect(map.at(4, 0)).toMatchObject({ + source: null, + original: '(none)', + generated: '}\n.w-auto...', + }) + + expect(map.at(5, 0)).toMatchObject({ + source: + kind === 'CLI' + ? expect.stringContaining('utilities.css') + : expect.stringMatching(/\/utilities-\w+\.css$/), + original: '@tailwind...', + generated: '.w-auto {...', + }) + + expect(map.at(6, 2)).toMatchObject({ + source: + kind === 'CLI' + ? expect.stringContaining('utilities.css') + : expect.stringMatching(/\/utilities-\w+\.css$/), + original: '@tailwind...', + generated: 'width: aut...', + }) + + expect(map.at(7, 0)).toMatchObject({ + source: null, + original: '(none)', + generated: '}\n.underli...', + }) + + expect(map.at(8, 0)).toMatchObject({ + source: + kind === 'CLI' + ? expect.stringContaining('utilities.css') + : expect.stringMatching(/\/utilities-\w+\.css$/), + original: '@tailwind...', + generated: '.underline...', + }) + + expect(map.at(9, 2)).toMatchObject({ + source: + kind === 'CLI' + ? expect.stringContaining('utilities.css') + : expect.stringMatching(/\/utilities-\w+\.css$/), + original: '@tailwind...', + generated: 'text-decor...', + }) + + expect(map.at(10, 0)).toMatchObject({ + source: null, + original: '(none)', + generated: '}\n\n/*# sou...', + }) + }, + ) + + 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` +
+ `, + '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; + } + " + `) }, ) }) @@ -1332,6 +2013,394 @@ test( }, ) +test( + 'source(…) and `@source` are relative to the file they are in', + { + fs: { + 'package.json': json` + { + "dependencies": { + "tailwindcss": "workspace:^", + "@tailwindcss/cli": "workspace:^" + } + } + `, + 'index.css': css` @import './project-a/src/index.css'; `, + + 'project-a/src/index.css': css` + /* Run auto-content detection in ../../project-b */ + @import 'tailwindcss/utilities' source('../../project-b'); + + /* Explicitly using node_modules in the @source allows git ignored folders */ + @source '../../project-c'; + `, + + // Project A is the current folder, but we explicitly configured + // `source(project-b)`, therefore project-a should not be included in + // the output. + 'project-a/src/index.html': html` +
+ `, + + // Project B is the configured `source(…)`, therefore auto source + // detection should include known extensions and folders in the output. + 'project-b/src/index.html': html` +
+ `, + + // Project C should apply auto source detection, therefore known + // extensions and folders should be included in the output. + 'project-c/src/index.html': html` +
+ `, + }, + }, + async ({ fs, exec, root, expect }) => { + await exec('pnpm tailwindcss --input ./index.css --output dist/out.css', { cwd: root }) + + let content = await fs.dumpFiles('./dist/*.css') + + expect(content).not.toContain(candidate`content-['project-a/src/index.html']`) + expect(content).toContain(candidate`content-['project-b/src/index.html']`) + expect(content).toContain(candidate`content-['project-c/src/index.html']`) + }, +) + +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`
`, + 'project-b/ignored/ignored.html': html`
`, + 'project-b/ignored/except.html': html`
`, + }, + }, + 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`
`, + 'project-c/ignored/ignored.html': html`
`, + 'project-c/ignored/except.html': html`
`, + + // 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` +
+ `, + // 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` +
+ `, + }, + }, + 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` +
+ `, + // Included by the `!/public` pattern in `.gitignore` + // + // → Should be included + 'public/index.html': html` +
+ `, + // Ignored by the `/*` in `.gitignore` + // + // → Should be ignored + 'build/index.html': html` +
+ `, + // Ignored by the `/*` in `.gitignore` and the default `node_modules` rules + // + // → Should be ignored + 'node_modules/my-ui-lib/index.html': html` +
+ `, + }, + }, + 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`
`, + 'vendor/ignored/ignored.html': html`
`, + 'vendor/ignored/except.html': html`
`, + + // 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( 'auto source detection disabled', { @@ -1680,6 +2749,49 @@ 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`
`, + }, + }, + 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( 'changes to CSS files should pick up new CSS variables (if any)', { @@ -1783,8 +2895,8 @@ test( @layer theme, base, components, utilities; @layer theme { :root, :host { - --font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', - 'Noto Color Emoji'; + --font-sans: -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-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; --color-red-500: oklch(63.7% 0.237 25.331); @@ -1803,7 +2915,7 @@ test( line-height: 1.5; -webkit-text-size-adjust: 100%; tab-size: 4; - 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-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-feature-settings: var(--default-font-feature-settings, normal); font-variation-settings: var(--default-font-variation-settings, normal); -webkit-tap-highlight-color: transparent; @@ -1855,7 +2967,7 @@ test( border-color: inherit; border-collapse: collapse; } - :-moz-focusring { + :-moz-focusring:where(:not(iframe)) { outline: auto; } progress { @@ -2042,6 +3154,33 @@ test( }, ) +test( + 'CSS parse errors should include filename and line number', + { + fs: { + 'package.json': json` + { + "dependencies": { + "tailwindcss": "workspace:^", + "@tailwindcss/cli": "workspace:^" + } + } + `, + 'input.css': txt` + .test { + color: red; + */ + } + `, + }, + }, + async ({ exec, expect }) => { + await expect(exec('pnpm tailwindcss --input input.css --output dist/out.css')).rejects.toThrow( + /CssSyntaxError: .*input.css:3:3: Invalid declaration: `\*\/`/, + ) + }, +) + function withBOM(text: string): string { return '\uFEFF' + text } diff --git a/integrations/cli/plugins.test.ts b/integrations/cli/plugins.test.ts index 0597e247b..0b810c538 100644 --- a/integrations/cli/plugins.test.ts +++ b/integrations/cli/plugins.test.ts @@ -1,4 +1,4 @@ -import { candidate, css, html, json, test } from '../utils' +import { candidate, css, html, json, test, ts } from '../utils' test( 'builds the `@tailwindcss/typography` plugin utilities', @@ -42,12 +42,86 @@ test( await fs.expectFileToContain('dist/out.css', [ candidate`prose`, - ':where(h1):not(:where([class~="not-prose"],[class~="not-prose"] *))', - ':where(tbody td, tfoot td):not(:where([class~="not-prose"],[class~="not-prose"] *))', + ':where(h1):not(:where([class~="not-prose"], [class~="not-prose"] *))', + ':where(tbody td, tfoot td):not(:where([class~="not-prose"], [class~="not-prose"] *))', ]) }, ) +test( + 'builds the `@tailwindcss/typography` plugin utilities with `@variant` usages', + { + fs: { + 'package.json': json` + { + "dependencies": { + "@tailwindcss/typography": "^0.5.14", + "tailwindcss": "workspace:^", + "@tailwindcss/cli": "workspace:^" + } + } + `, + 'index.html': html` +
+

Headline

+

+ 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. +

+
+ `, + 'src/index.css': css` + @import 'tailwindcss/utilities'; + @theme { + --breakpoint-sm: 640px; + } + @plugin '@tailwindcss/typography'; + @config '../tailwind.config.js'; + @custom-variant custom (&.custom); + `, + 'tailwind.config.js': ts` + module.exports = { + theme: { + typography: ({ theme }) => ({ + custom: { + css: { + hr: { + '--x': '1', + '@variant sm:custom': { + '--x': '2', + }, + }, + }, + }, + }), + }, + } + `, + }, + }, + async ({ fs, exec, expect }) => { + await exec('pnpm tailwindcss --input src/index.css --output dist/out.css') + + // We don't want to see `@variant` in the output + let contents = await fs.read('dist/out.css') + expect(contents).not.toContain('@variant') + + expect(await fs.dumpFiles('dist/out.css')).toMatchInlineSnapshot(` + " + --- dist/out.css --- + .prose-custom :where(hr):not(:where([class~="not-prose"], [class~="not-prose"] *)) { + --x: 1; + @media (width >= 640px) { + &.custom { + --x: 2; + } + } + } + " + `) + }, +) + test( 'builds the `@tailwindcss/forms` plugin utilities', { @@ -117,7 +191,7 @@ test( await fs.expectFileToContain('dist/out.css', [ // `::-webkit-date-and-time-value`, - `[type='checkbox']:indeterminate`, + `input:where([type='checkbox']):indeterminate`, ]) // No classes are included even though they are used in the HTML @@ -207,3 +281,88 @@ 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` +
+ `, + '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`]) + }, +) diff --git a/integrations/cli/standalone.test.ts b/integrations/cli/standalone.test.ts index 07b8ebfee..f84bf4511 100644 --- a/integrations/cli/standalone.test.ts +++ b/integrations/cli/standalone.test.ts @@ -1,6 +1,7 @@ +import nodeFs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' -import { candidate, css, html, js, json, test } from '../utils' +import { candidate, css, html, IS_WINDOWS, js, json, test, ts } from '../utils' const STANDALONE_BINARY = (() => { switch (os.platform()) { @@ -15,6 +16,41 @@ const STANDALONE_BINARY = (() => { } })() +test( + 'does not scan itself for candidates', + { + fs: { + 'package.json': json` + { + "dependencies": {} + } + `, + 'src/index.css': css` @import 'tailwindcss'; `, + }, + }, + async ({ root, fs, exec }) => { + let sourceBinary = path.resolve( + __dirname, + `../../packages/@tailwindcss-standalone/dist/${STANDALONE_BINARY}`, + ) + let binary = IS_WINDOWS ? 'tailwindcss.exe' : 'tailwindcss' + let localBinary = path.join(root, binary) + await nodeFs.copyFile(sourceBinary, localBinary) + + if (!IS_WINDOWS) { + await nodeFs.chmod(localBinary, 0o755) + } + + await exec(`${IS_WINDOWS ? binary : `./${binary}`} --input src/index.css --output dist/out.css`) + + await fs.expectFileNotToContain('dist/out.css', [ + candidate`flex`, + candidate`grid`, + candidate`underline`, + ]) + }, +) + test( 'includes first-party plugins', { @@ -122,7 +158,7 @@ test( }) }) `, - 'src/plugin.ts': js` + 'src/plugin.ts': ts` import plugin from 'tailwindcss/plugin' // Make sure all available JS APIs can be imported and used @@ -132,8 +168,7 @@ test( import defaultTheme from 'tailwindcss/defaultTheme' import * as pkg from 'tailwindcss/package.json' - export interface PluginOptions { - } + export interface PluginOptions {} export default plugin(function ({ addUtilities }) { addUtilities({ diff --git a/integrations/oxide/postinstall.test.ts b/integrations/oxide/postinstall.test.ts deleted file mode 100644 index 70683c82e..000000000 --- a/integrations/oxide/postinstall.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import fs from 'node:fs/promises' -import path from 'node:path' -import { js, json, test } from '../utils' - -test( - '@tailwindcss/oxide will fail when architecture-specific packages are missing', - { - fs: { - 'package.json': json` - { - "dependencies": { - "@tailwindcss/oxide": "workspace:^" - } - } - `, - 'test.js': js` - try { - let Scanner = require('@tailwindcss/oxide') - console.log('SUCCESS: @tailwindcss/oxide loaded successfully', Scanner) - } catch (error) { - console.log('FAILURE: Failed to load @tailwindcss/oxide:', error.message) - } - `, - }, - }, - async ({ exec, root, expect, fs }) => { - await removePlatformSpecificExtensions(path.join(root, 'node_modules')) - - // Get last published version - let version = (await exec('npm show @tailwindcss/oxide version')).trim() - // Ensure that we don't depend on a specific version number in the download - // script in case we bump the version number in the repository and CI is run - // before a release - let packageJson = JSON.parse(await fs.read('node_modules/@tailwindcss/oxide/package.json')) - packageJson.version = version - await fs.write( - 'node_modules/@tailwindcss/oxide/package.json', - JSON.stringify(packageJson, null, 2), - ) - - let opts = { - // Ensure that we don't include any node paths from the test runner - env: { NODE_PATH: '' }, - } - - expect(await exec('node test.js', opts)).toMatch(/FAILURE/) - - // Now run the post-install script - await exec('node node_modules/@tailwindcss/oxide/scripts/install.js', opts) - - expect(await exec('node test.js', opts)).toMatch(/SUCCESS/) - }, -) - -async function removePlatformSpecificExtensions(directory: string) { - let entries = await fs.readdir(directory, { withFileTypes: true }) - - for (let entry of entries) { - let fullPath = path.join(directory, entry.name) - - if (entry.name.startsWith('oxide-')) { - if (entry.isSymbolicLink()) { - await fs.unlink(fullPath) - } else if (entry.isFile()) { - await fs.unlink(fullPath) - } else if (entry.isDirectory()) { - await fs.rm(fullPath, { recursive: true, force: true }) - } - } else if (entry.isDirectory()) { - await removePlatformSpecificExtensions(fullPath) - } - } -} diff --git a/integrations/oxide/wasm.test.ts b/integrations/oxide/wasm.test.ts index 94f44c066..2d63c707a 100644 --- a/integrations/oxide/wasm.test.ts +++ b/integrations/oxide/wasm.test.ts @@ -1,6 +1,6 @@ -import { css, js, json, test } from '../utils' +import { css, js, json, test, yaml } from '../utils' -// This test runs the the wasm build using the `node:wasi` runtine. +// This test runs the wasm build using the `node:wasi` runtime. // // There are currently a known problems that the Node WASI preview implementation does not properly // handle FS reads on macOS and it does not implement all APIs on Windows. Because of that, this @@ -57,3 +57,187 @@ 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", + ] + `) + }, +) diff --git a/integrations/package.json b/integrations/package.json index 6245abe3e..ae337b5e4 100644 --- a/integrations/package.json +++ b/integrations/package.json @@ -3,8 +3,9 @@ "version": "0.0.0", "private": true, "devDependencies": { - "dedent": "1.6.0", + "dedent": "catalog:", "fast-glob": "^3.3.3", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.1", + "yaml": "^2.9.0" } } diff --git a/integrations/postcss/core-as-postcss-plugin.test.ts b/integrations/postcss/core-as-postcss-plugin.test.ts index 17545fea3..839c80e7b 100644 --- a/integrations/postcss/core-as-postcss-plugin.test.ts +++ b/integrations/postcss/core-as-postcss-plugin.test.ts @@ -48,9 +48,9 @@ describe.each(Object.keys(variantConfig))('%s', (variant) => { }, }, async ({ exec, expect }) => { - expect( + await expect( exec('pnpm postcss src/index.css --output dist/out.css', undefined, { ignoreStdErr: true }), - ).rejects.toThrowError( + ).rejects.toThrow( `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.`, ) }, diff --git a/integrations/postcss/index.test.ts b/integrations/postcss/index.test.ts index a811cf637..80025f945 100644 --- a/integrations/postcss/index.test.ts +++ b/integrations/postcss/index.test.ts @@ -1,5 +1,5 @@ import path from 'node:path' -import { candidate, css, html, js, json, retryAssertion, test, ts, yaml } from '../utils' +import { candidate, css, html, js, json, test, ts, yaml } from '../utils' test( 'production build (string)', @@ -662,23 +662,54 @@ test( `, 'src/index.css': css` @import './tailwind.css'; `, 'src/tailwind.css': css` - @reference 'tailwindcss/does-not-exist'; + @reference 'tailwindcss/theme'; @import 'tailwindcss/utilities'; `, }, }, async ({ fs, expect, spawn }) => { + // 1. Start the watcher + // + // It must have valid CSS for the initial build let process = await spawn('pnpm postcss src/index.css --output dist/out.css --watch --verbose') - await process.onStderr((message) => - message.includes('does-not-exist is not exported from package'), - ) - - await retryAssertion(async () => expect(await fs.read('dist/out.css')).toEqual('')) - await process.onStderr((message) => message.includes('Waiting for file changes...')) - // Fix the CSS file + expect(await fs.dumpFiles('dist/*.css')).toMatchInlineSnapshot(` + " + --- dist/out.css --- + .underline { + text-decoration-line: underline; + } + " + `) + + // 2. Cause an error + await fs.write( + 'src/tailwind.css', + css` + @reference 'tailwindcss/does-not-exist'; + @import 'tailwindcss/utilities'; + `, + ) + + // 2.5 Write to a content file + await fs.write('src/index.html', html` +
+ `) + + await process.onStderr((message) => message.includes('"./does-not-exist" is not exported')) + + expect(await fs.dumpFiles('dist/*.css')).toMatchInlineSnapshot(` + " + --- dist/out.css --- + .underline { + text-decoration-line: underline; + } + " + `) + + // 3. Fix the CSS file await fs.write( 'src/tailwind.css', css` @@ -686,11 +717,15 @@ test( @import 'tailwindcss/utilities'; `, ) - await process.onStderr((message) => message.includes('Finished')) + + await process.onStderr((message) => message.includes('Waiting for file changes...')) expect(await fs.dumpFiles('dist/*.css')).toMatchInlineSnapshot(` " --- dist/out.css --- + .flex { + display: flex; + } .underline { text-decoration-line: underline; } @@ -705,11 +740,20 @@ test( @import 'tailwindcss/utilities'; `, ) - await process.onStderr((message) => - message.includes('does-not-exist is not exported from package'), - ) - await retryAssertion(async () => expect(await fs.read('dist/out.css')).toEqual('')) + await process.onStderr((message) => message.includes('"./does-not-exist" is not exported')) + + expect(await fs.dumpFiles('dist/*.css')).toMatchInlineSnapshot(` + " + --- dist/out.css --- + .flex { + display: flex; + } + .underline { + text-decoration-line: underline; + } + " + `) }, ) @@ -777,3 +821,73 @@ test( }) }, ) + +test( + 'source(…) and `@source` are relative to the file they are in', + { + fs: { + 'package.json': json` + { + "dependencies": { + "postcss": "^8", + "postcss-cli": "^10", + "tailwindcss": "workspace:^", + "@tailwindcss/postcss": "workspace:^" + } + } + `, + + 'postcss.config.js': js` + module.exports = { + plugins: { + '@tailwindcss/postcss': {}, + }, + } + `, + + 'index.css': css` @import './project-a/src/index.css'; `, + + 'project-a/src/index.css': css` + /* Run auto-content detection in ../../project-b */ + @import 'tailwindcss/utilities' source('../../project-b'); + + /* Explicitly using node_modules in the @source allows git ignored folders */ + @source '../../project-c'; + `, + + // Project A is the current folder, but we explicitly configured + // `source(project-b)`, therefore project-a should not be included in + // the output. + 'project-a/src/index.html': html` +
+ `, + + // Project B is the configured `source(…)`, therefore auto source + // detection should include known extensions and folders in the output. + 'project-b/src/index.html': html` +
+ `, + + // Project C should apply auto source detection, therefore known + // extensions and folders should be included in the output. + 'project-c/src/index.html': html` +
+ `, + }, + }, + async ({ fs, exec, root, expect }) => { + await exec('pnpm postcss ./index.css --output dist/out.css', { cwd: root }) + + let content = await fs.dumpFiles('./dist/*.css') + + expect(content).not.toContain(candidate`content-['project-a/src/index.html']`) + expect(content).toContain(candidate`content-['project-b/src/index.html']`) + expect(content).toContain(candidate`content-['project-c/src/index.html']`) + }, +) diff --git a/integrations/postcss/next.test.ts b/integrations/postcss/next.test.ts index 969254a75..7fc0ccdcf 100644 --- a/integrations/postcss/next.test.ts +++ b/integrations/postcss/next.test.ts @@ -468,7 +468,7 @@ test( // the CSS await fetchStyles(url) - // At this point, no changes should triger a compile step. If we see any + // At this point, no changes should trigger a compile step. If we see any // changes, there is an infinite loop because we (the user) didn't write any // files to disk. // diff --git a/integrations/turbopack/loader.test.ts b/integrations/turbopack/loader.test.ts new file mode 100644 index 000000000..755cd60df --- /dev/null +++ b/integrations/turbopack/loader.test.ts @@ -0,0 +1,83 @@ +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 ( + + {children} + + ) + } + `, + 'app/page.js': jsx` + export default function Page() { + return
Hello, Next.js!
+ } + `, + '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
Hello, Next.js!
+ } + `, + ) + + await retryAssertion(async () => { + let styles = await fetchStyles(url) + expect(styles).toContain(candidate`flex`) + expect(styles).toContain(candidate`underline`) + }) + }, +) diff --git a/integrations/upgrade/index.test.ts b/integrations/upgrade/index.test.ts index 75e21c590..efac411db 100644 --- a/integrations/upgrade/index.test.ts +++ b/integrations/upgrade/index.test.ts @@ -1,6 +1,6 @@ import path from 'node:path' import { isRepoDirty } from '../../packages/@tailwindcss-upgrade/src/utils/git' -import { candidate, css, html, js, json, test, ts, yaml } from '../utils' +import { candidate, css, html, js, json, test, ts, txt, yaml } from '../utils' test( 'error when no CSS file with @tailwind is used', @@ -28,7 +28,7 @@ test( }, }, async ({ fs, exec, expect }) => { - let output = await exec('npx @tailwindcss/upgrade') + let output = await exec('pnpm exec upgrade') expect(output).toContain('Cannot find any CSS files that reference Tailwind CSS.') // Files should not be modified @@ -100,7 +100,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('npx @tailwindcss/upgrade') + await exec('pnpm exec upgrade') expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(` " @@ -158,7 +158,7 @@ test( }) // Ensure the v4 project compiles correctly - await exec('npx tailwindcss --input src/input.css --output dist/out.css') + await exec('pnpm exec tailwindcss --input src/input.css --output dist/out.css') await fs.expectFileToContain('dist/out.css', [ candidate`flex!`, @@ -171,9 +171,62 @@ 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` +
+ `, + 'src/input.css': css` + @tailwind base; + @tailwind components; + @tailwind utilities; + `, + 'templates/email.php': html` +
+ `, + '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 --- +
+ + --- templates/email.php --- +
+ " + `) + }, +) + test( `upgrades a v3 project with prefixes to v4`, { + // Somehow this test takes *way* longer than the rest (but not always?) + timeout: 180_000, fs: { 'package.json': json` { @@ -210,7 +263,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('npx @tailwindcss/upgrade') + await exec('pnpm exec upgrade') expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(` " @@ -282,7 +335,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('npx @tailwindcss/upgrade') + await exec('pnpm exec upgrade') expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` " @@ -354,7 +407,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('npx @tailwindcss/upgrade') + await exec('pnpm exec upgrade') expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` " @@ -431,7 +484,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('npx @tailwindcss/upgrade') + await exec('pnpm exec upgrade') expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` " @@ -534,7 +587,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('npx @tailwindcss/upgrade') + await exec('pnpm exec upgrade') expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` " @@ -601,6 +654,7 @@ test( test( 'migrates a simple postcss setup', { + timeout: 120_000, fs: { 'package.json': json` { @@ -641,7 +695,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('npx @tailwindcss/upgrade') + await exec('pnpm exec upgrade') await fs.expectFileToContain( 'postcss.config.js', @@ -712,7 +766,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('npx @tailwindcss/upgrade') + await exec('pnpm exec upgrade') let packageJsonContent = await fs.read('package.json') let packageJson = JSON.parse(packageJsonContent) @@ -759,7 +813,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('npx @tailwindcss/upgrade') + await exec('pnpm exec upgrade') let packageJsonContent = await fs.read('package.json') let packageJson = JSON.parse(packageJsonContent) @@ -772,6 +826,7 @@ test( test( 'migrates a postcss setup using package.json config', { + timeout: 120_000, fs: { 'package.json': json` { @@ -810,7 +865,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('npx @tailwindcss/upgrade') + await exec('pnpm exec upgrade') await fs.expectFileToContain('src/index.css', css`@import 'tailwindcss';`) await fs.expectFileToContain( @@ -845,6 +900,7 @@ test( test( 'migrates a postcss setup using a json based config file', { + timeout: 120_000, fs: { 'package.json': json` { @@ -885,7 +941,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('npx @tailwindcss/upgrade') + await exec('pnpm exec upgrade') await fs.expectFileToContain('src/index.css', css`@import 'tailwindcss';`) await fs.expectFileToContain( @@ -952,7 +1008,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('npx @tailwindcss/upgrade') + await exec('pnpm exec upgrade') expect(await fs.dumpFiles('./src/**/*.html')).toMatchInlineSnapshot(` " @@ -999,7 +1055,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('npx @tailwindcss/upgrade') + await exec('pnpm exec upgrade') expect(await fs.dumpFiles('./src/**/*.html')).toMatchInlineSnapshot(` " @@ -1043,7 +1099,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('npx @tailwindcss/upgrade --force') + await exec('pnpm exec upgrade --force') expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` " @@ -1113,9 +1169,10 @@ test( } `, }, + retry: 0, }, async ({ exec, fs, expect }) => { - await exec('npx @tailwindcss/upgrade --force') + await exec('pnpm exec upgrade --force') expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` " @@ -1224,9 +1281,10 @@ test( } `, }, + retry: 0, }, async ({ exec, fs, expect }) => { - await exec('npx @tailwindcss/upgrade --force') + await exec('pnpm exec upgrade --force') expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` " @@ -1359,7 +1417,7 @@ test( }, }, async ({ exec, fs, expect }) => { - let output = await exec('npx @tailwindcss/upgrade --force') + let output = await exec('pnpm exec upgrade --force') expect(output).toMatch( /You have one or more stylesheets that are imported into a utility layer and non-utility layer./, @@ -1483,7 +1541,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('npx @tailwindcss/upgrade --force') + await exec('pnpm exec upgrade --force') expect(await fs.dumpFiles('./src/**/*.{html,css}')).toMatchInlineSnapshot(` " @@ -1708,8 +1766,8 @@ test( }, }, async ({ exec, expect }) => { - let output = await exec('npx @tailwindcss/upgrade --force', {}, { ignoreStdErr: true }).catch( - (e) => e.toString(), + let output = await exec('pnpm exec upgrade --force', {}, { ignoreStdErr: true }).catch((e) => + e.toString(), ) expect(output).toMatch('Could not determine configuration file for:') @@ -1780,7 +1838,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('npx @tailwindcss/upgrade --force') + await exec('pnpm exec upgrade --force') expect(await fs.dumpFiles('./src/**/*.{html,css}')).toMatchInlineSnapshot(` " @@ -1916,7 +1974,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('npx @tailwindcss/upgrade --force') + await exec('pnpm exec upgrade --force') expect(await fs.dumpFiles('./src/**/*.{html,css}')).toMatchInlineSnapshot(` " @@ -2036,7 +2094,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('npx @tailwindcss/upgrade --force') + await exec('pnpm exec upgrade --force') expect(await fs.dumpFiles('./src/**/*.{html,css}')).toMatchInlineSnapshot(` " @@ -2116,7 +2174,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('npx @tailwindcss/upgrade --force') + await exec('pnpm exec upgrade --force') expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` " @@ -2198,9 +2256,10 @@ test( } `, }, + retry: 0, }, async ({ exec, fs, expect }) => { - await exec('npx @tailwindcss/upgrade --force') + await exec('pnpm exec upgrade --force') expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` " @@ -2254,7 +2313,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('npx @tailwindcss/upgrade --force') + await exec('pnpm exec upgrade --force') let pkg = JSON.parse(await fs.read('package.json')) @@ -2333,7 +2392,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('npx @tailwindcss/upgrade --force') + await exec('pnpm exec upgrade --force') // Files should not be modified expect(await fs.dumpFiles('./*.{js,css,html}')).toMatchInlineSnapshot(` @@ -2345,8 +2404,7 @@ test( --shadow-*: initial; --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); - --ring-width-*: initial; - --ring-width: 4px; + --default-ring-width: 4px; --blur: var(--custom-default-blur); @@ -2432,7 +2490,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('npx @tailwindcss/upgrade --force') + await exec('pnpm exec upgrade --force') // Files should not be modified expect(await fs.dumpFiles('./*.{js,css,html,tsx}')).toMatchInlineSnapshot(` @@ -2499,7 +2557,7 @@ test( @import './utilities.css'; @import './generated/ignore-me.css'; `, - 'src/generated/.gitignore': ` + 'src/generated/.gitignore': txt` * !.gitignore `, @@ -2539,7 +2597,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('npx @tailwindcss/upgrade ./src/index.css') + await exec('pnpm exec upgrade ./src/index.css') expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(` " @@ -2665,7 +2723,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('npx @tailwindcss/upgrade') + await exec('pnpm exec upgrade') expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(` " @@ -2678,8 +2736,8 @@ test( text-red-500/10 text-red-500/semitransparent text-red-500/transparent - text-red-500/50 - text-red-500/50.5 + text-red-500/semitransparent + text-red-500/50.50 text-red-500/50.50 text-red-500/50% text-red-500/100%" @@ -2751,7 +2809,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('npx @tailwindcss/upgrade') + await exec('pnpm exec upgrade') expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(` " @@ -2759,7 +2817,7 @@ test( 
--- ./src/input.css --- - @import 'tailwindcss'; + @import 'tailwindcss'; /* The default border color has changed to \`currentcolor\` in Tailwind CSS v4, @@ -2818,8 +2876,8 @@ test( `, }, }, - async ({ exec, fs, expect }) => { - await exec('npx @tailwindcss/upgrade') + async ({ exec, root, fs, expect }) => { + await exec('pnpm exec upgrade') let before = await fs.dumpFiles('./src/**/*.{css,html}') expect(before).toMatchInlineSnapshot(` @@ -2855,13 +2913,13 @@ test( `) // Commit the changes - if (isRepoDirty()) { + if (isRepoDirty(root)) { await exec('git add .') await exec('git commit -m "upgrade"') } // Run the upgrade again - let output = await exec('npx @tailwindcss/upgrade') + let output = await exec('pnpm exec upgrade') expect(output).toContain('No changes were made to your repository') let after = await fs.dumpFiles('./src/**/*.{css,html}') @@ -2940,7 +2998,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('npx @tailwindcss/upgrade') + await exec('pnpm exec upgrade') expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(` " @@ -2968,6 +3026,202 @@ 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` +
+ `, + '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 --- +
+ + --- ./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` +
+ `, + + // Should be picked up by auto-content detection + 'templates/migrate-me.php': html` +
+ `, + + // 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` +
+ `, + }, + }, + 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 --- +
+ + --- ./src/migrate-me.less --- +
+ + --- ./templates/migrate-me.php --- +
+ " + `) + }, +) + +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` +
+ `, + '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': ` + 'this file should never be truncated', + ]; + `, + }, + }, + async ({ spawn, fs, expect }) => { + let repeatedCandidates = Array.from( + { length: 250 }, + () => '
', + ).join('\n') + + for (let i = 0; i < 100; i++) { + await fs.write( + `src/templates/template-${i}.php`, + ` ${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( 'upgrades can run in a pnpm workspace', { @@ -3011,7 +3265,7 @@ test( }, }, async ({ root, exec, fs, expect }) => { - let stdout = await exec('npx @tailwindcss/upgrade', { + let stdout = await exec('pnpm exec upgrade', { cwd: path.join(root, 'project-a'), }) @@ -3085,7 +3339,7 @@ test( }, }, async ({ exec, fs, expect }) => { - let stdout = await exec('npx @tailwindcss/upgrade') + let stdout = await exec('pnpm exec upgrade') expect(stdout).not.toContain( 'Running this command will add the dependency to the workspace root', @@ -3117,6 +3371,61 @@ 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`
Hi
`, + + // 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( 'upgrade + + +

Astro

+ + + `, + 'src/styles/global.css': css`@import 'tailwindcss';`, + }, + }, + async ({ fs, exec, expect }) => { + await exec('pnpm astro build') + + let files = await fs.glob('dist/**/*.css') + expect(files).toHaveLength(1) + + await fs.expectFileToContain(files[0][0], [candidate`underline`]) + }, +) diff --git a/integrations/vite/bundled-dev.test.ts b/integrations/vite/bundled-dev.test.ts new file mode 100644 index 000000000..8ba1b6b73 --- /dev/null +++ b/integrations/vite/bundled-dev.test.ts @@ -0,0 +1,193 @@ +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` + + + + +
Hello, world!
+ + `, + 'project-a/src/index.css': css` + @reference 'tailwindcss/theme'; + @import 'tailwindcss/utilities'; + @source '../../project-b/src/**/*.html'; + `, + 'project-b/src/index.html': html` +
+ `, + }, + }, + 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 { + 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(/]*\ssrc="([^"]+)"/g), + ...html.matchAll(/]*\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` + + + + +
Hello, world!
+ + `, + ) + + await fs.write('project-b/src/index.html', html` +
+ `) + + 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([]) + }, +) diff --git a/integrations/vite/index.test.ts b/integrations/vite/index.test.ts index 3afb057c5..c346f8e37 100644 --- a/integrations/vite/index.test.ts +++ b/integrations/vite/index.test.ts @@ -7,6 +7,7 @@ import { html, js, json, + jsx, retryAssertion, test, ts, @@ -293,6 +294,113 @@ 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` + + + + +
Hello, world!
+ + `, + + // 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` +
+ Hello, world! +
+ `, + }, + }, + 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` +
+ Hello, world! +
+ `, + ) + + let styles = await fetchStyles(url) + expect(styles).toContain(candidate`underline`) + expect(styles).toContain(candidate`content-['v1']`) + expect(styles).toContain(candidate`content-['v2']`) + }) + }, + ) + test( 'watch mode', { @@ -472,6 +580,352 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { }, ) + describe.each([['^6'], ['7.0.8'], ['7.1.12'], ['7.3.1'], ['8.0.0']])( + 'Using Vite %s', + (version) => { + test( + 'external source file changes update the CSS', + { + 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": "${version}" + } + } + `, + '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()], + logLevel: 'info', + }) + `, + 'project-a/index.html': html` + + + + + +
+ + + + `, + 'project-a/src/main.ts': jsx`import { classes } from './app'`, + 'project-a/src/app.ts': jsx`export let classes = "content-['project-a/src/app.ts']"`, + 'project-a/src/index.css': css` + @import 'tailwindcss'; + @source '../../project-b/**/*.php'; + `, + 'project-b/src/index.php': html` +
+ `, + }, + }, + async ({ root, spawn, fs, expect }) => { + let process = await spawn('pnpm vite dev --debug hmr', { + 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-['project-b/src/index.php']`) + }) + + // Flush all messages so that we can be sure the next messages are + // from the file changes we're about to make + process.flush() + + // Changing an external .php file hot-updates the generated CSS + { + await fs.write( + 'project-b/src/index.php', + txt`
`, + ) + + // On Vite < 7.1, Vite itself hard-invalidates watched files that + // 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') { + await process.onStdout((m) => m.includes('page reload') && m.includes('index.php')) + } else { + await process.onStdout((m) => m.includes('hmr update') && m.includes('index.css')) + } + + // Ensure the styles were regenerated with the new content + let styles = await fetchStyles(url, '/index.html') + expect(styles).toContain(candidate`content-['updated:project-b/src/index.php']`) + } + }, + ) + }, + ) + + // 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` + + + + + +
+ + + + `, + '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` +
+ `, + 'project-a/src/comp-a.custom': html` +
+ `, + 'project-a/src/comp-b.custom': html` +
+ `, + 'project-a/src/lazy.tsx': jsx` + export default function Lazy() { + return
+ } + `, + '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` +
+ `, + }, + }, + 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
+ } + `, + ) + + 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`
`, + ) + + 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`
`, + ) + + 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( `source(none) disables looking at the module graph`, { @@ -724,12 +1178,92 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { async ({ root, fs, exec, expect }) => { await expect(() => exec('pnpm vite build', { cwd: path.join(root, 'project-a') }, { ignoreStdErr: true }), - ).rejects.toThrowError('The `source(../i-do-not-exist)` does not exist') + ).rejects.toThrow('The `source(../i-do-not-exist)` does not exist') let files = await fs.glob('project-a/dist/**/*.css') expect(files).toHaveLength(0) }, ) + + test( + 'source(…) and `@source` are relative to the file they are in', + { + fs: { + 'package.json': json` + { + "type": "module", + "dependencies": { + "@tailwindcss/vite": "workspace:^", + "tailwindcss": "workspace:^" + }, + "devDependencies": { + ${transformer === 'lightningcss' ? `"lightningcss": "^1",` : ''} + "vite": "^7" + } + } + `, + '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()], + }) + `, + 'index.html': html` + + + + + `, + 'index.css': css` @import './project-a/src/index.css'; `, + + 'project-a/src/index.css': css` + /* Run auto-content detection in ../../project-b */ + @import 'tailwindcss/utilities' source('../../project-b'); + + /* Explicitly using node_modules in the @source allows git ignored folders */ + @source '../../project-c'; + `, + + // Project A is the current folder, but we explicitly configured + // `source(project-b)`, therefore project-a should not be included in + // the output. + 'project-a/src/index.html': html` +
+ `, + + // Project B is the configured `source(…)`, therefore auto source + // detection should include known extensions and folders in the output. + 'project-b/src/index.html': html` +
+ `, + + // Project C should apply auto source detection, therefore known + // extensions and folders should be included in the output. + 'project-c/src/index.html': html` +
+ `, + }, + }, + async ({ fs, exec, spawn, root, expect }) => { + await exec('pnpm vite build', { cwd: root }) + + let content = await fs.dumpFiles('./dist/assets/*.css') + + expect(content).not.toContain(candidate`content-['project-a/src/index.html']`) + expect(content).toContain(candidate`content-['project-b/src/index.html']`) + expect(content).toContain(candidate`content-['project-c/src/index.html']`) + }, + ) }) test( @@ -898,6 +1432,14 @@ test( `, '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 }) => { @@ -914,3 +1456,271 @@ test( function firstLine(str: string) { return str.split('\n')[0] } + +test( + 'optimize option: disabled', + { + fs: { + 'package.json': json` + { + "type": "module", + "dependencies": { + "@tailwindcss/vite": "workspace:^", + "tailwindcss": "workspace:^" + }, + "devDependencies": { + "vite": "^7" + } + } + `, + 'vite.config.ts': ts` + import tailwindcss from '@tailwindcss/vite' + import { defineConfig } from 'vite' + + export default defineConfig({ + build: { cssMinify: false }, + plugins: [tailwindcss({ optimize: false })], + }) + `, + 'index.html': html` + + + + +
Hello, world!
+ + `, + 'src/index.css': css` + @reference 'tailwindcss/theme'; + @import 'tailwindcss/utilities'; + `, + }, + }, + async ({ exec, expect, fs }) => { + await exec('pnpm vite build') + + // Should not be minified when optimize is disabled + expect((await fs.dumpFiles('dist/**/*.css')).replace(/-([-_a-zA-Z0-9]*?)\.css/g, '-.css')) + .toMatchInlineSnapshot(` + " + --- dist/assets/index-.css --- + .focus\\:text-\\[black\\]:focus { + color: black; + } + " + `) + }, +) + +test( + 'optimize option: enabled with minify disabled', + { + fs: { + 'package.json': json` + { + "type": "module", + "dependencies": { + "@tailwindcss/vite": "workspace:^", + "tailwindcss": "workspace:^" + }, + "devDependencies": { + "vite": "^7" + } + } + `, + 'vite.config.ts': ts` + import tailwindcss from '@tailwindcss/vite' + import { defineConfig } from 'vite' + + export default defineConfig({ + build: { cssMinify: false }, + plugins: [tailwindcss({ optimize: { minify: false } })], + }) + `, + 'index.html': html` + + + + +
Hello, world!
+ + `, + 'src/index.css': css` + @reference 'tailwindcss/theme'; + @import 'tailwindcss/utilities'; + `, + }, + }, + async ({ exec, expect, fs }) => { + await exec('pnpm vite build') + + expect((await fs.dumpFiles('dist/**/*.css')).replace(/-([-_a-zA-Z0-9]*?)\.css/g, '-.css')) + .toMatchInlineSnapshot(` + " + --- dist/assets/index-.css --- + @media (hover: hover) { + .hover\\:text-\\[black\\]:hover { + color: #000; + } + } + " + `) + }, +) + +test( + `the plugin works when using the environment API`, + { + fs: { + 'package.json': txt` + { + "type": "module", + "dependencies": { + "@tailwindcss/vite": "workspace:^", + "tailwindcss": "workspace:^" + }, + "devDependencies": { + "vite": "^7" + } + } + `, + 'vite.config.ts': ts` + import tailwindcss from '@tailwindcss/vite' + import { defineConfig } from 'vite' + + export default defineConfig({ + plugins: [tailwindcss()], + builder: {}, + environments: { + server: { + build: { + cssMinify: false, + emitAssets: true, + rollupOptions: { input: './src/server.ts' }, + }, + }, + }, + }) + `, + // Has to exist or the build fails + 'index.html': html` +
+ `, + 'src/server.ts': js` + // Import the stylesheet in the server build + import a from './index.css?url' + console.log(a) + `, + 'src/index.css': css` + @reference 'tailwindcss/theme'; + @import 'tailwindcss/utilities'; + `, + }, + }, + async ({ root, fs, exec, expect }) => { + await exec('pnpm vite build', { cwd: root }) + + let files = await fs.glob('dist/**/*.css') + expect(files).toHaveLength(1) + let [filename] = files[0] + + 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` + +
+ + + `, + '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` + + + + `, + }, + }, + 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) + }) + }, +) diff --git a/integrations/vite/nuxt.test.ts b/integrations/vite/nuxt.test.ts index 084e319d7..20f367020 100644 --- a/integrations/vite/nuxt.test.ts +++ b/integrations/vite/nuxt.test.ts @@ -1,4 +1,15 @@ -import { candidate, css, fetchStyles, html, json, retryAssertion, test, ts } from '../utils' +import { + candidate, + css, + fetchStyles, + getRandomPort, + html, + json, + retryAssertion, + test, + ts, + yaml, +} from '../utils' const SETUP = { fs: { @@ -7,13 +18,18 @@ const SETUP = { "type": "module", "dependencies": { "@tailwindcss/vite": "workspace:^", - "nuxt": "3.14.0", + "nuxt": "3.16.0", "nitropack": "2.11.0", "tailwindcss": "workspace:^", "vue": "latest" } } `, + 'pnpm-workspace.yaml': yaml` + # + overrides: + nuxi: 3.28.0 + `, 'nuxt.config.ts': ts` import tailwindcss from '@tailwindcss/vite' @@ -79,7 +95,8 @@ test('build', SETUP, async ({ spawn, exec, expect }) => { await exec('pnpm nuxt build') // The Nuxt preview server does not automatically assign a free port if 3000 // is taken, so we use a random port instead. - let process = await spawn(`pnpm nuxt preview --port 8724`, { + let port = await getRandomPort() + let process = await spawn(`pnpm nuxt preview --port ${port}`, { env: { TEST: 'false', NODE_ENV: 'development', diff --git a/integrations/vite/preact.test.ts b/integrations/vite/preact.test.ts new file mode 100644 index 000000000..8e4deec2b --- /dev/null +++ b/integrations/vite/preact.test.ts @@ -0,0 +1,165 @@ +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` + + + + + +
+ + + + `, + 'src/main.tsx': ts` + import { render } from 'preact' + import { App } from './app' + + render(, document.getElementById('app')!) + `, + 'src/app.tsx': ts` + import { useState } from 'preact/hooks' + + export function App() { + const [count, setCount] = useState(0) + return ( + + ) + } + `, + '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 ( + + ) + } + `, + ) + + 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') + } + }, +) diff --git a/integrations/vite/react-router.test.ts b/integrations/vite/react-router.test.ts index 5b9574fd6..98b0c928d 100644 --- a/integrations/vite/react-router.test.ts +++ b/integrations/vite/react-router.test.ts @@ -102,13 +102,101 @@ test('dev mode', { fs: WORKSPACE }, async ({ fs, spawn, expect }) => { }) }) +test( + // cf. https://github.com/remix-run/react-router/blob/00cb4d7b310663b2e84152700c05d3b503005e83/integration/vite-hmr-hdr-test.ts#L311-L318 + 'dev mode, editing a server-only loader dependency triggers HDR instead of a full reload', + { + fs: { + ...WORKSPACE, + 'package.json': json` + { + "type": "module", + "dependencies": { + "@react-router/dev": "^7", + "@react-router/node": "^7", + "@react-router/serve": "^7", + "@tailwindcss/vite": "workspace:^", + "@types/node": "^20", + "@types/react-dom": "^19", + "@types/react": "^19", + "isbot": "^5", + "react-dom": "^19", + "react-router": "^7", + "react": "^19", + "tailwindcss": "workspace:^", + "vite": "^7" + } + } + `, + 'app/routes/home.tsx': ts` + import type { Route } from './+types/home' + import { direct } from '../direct-hdr-dep' + + export async function loader() { + return { message: direct } + } + + export default function Home({ loaderData }: Route.ComponentProps) { + return ( +
+

{loaderData.message}

+ +
+ ) + } + `, + 'app/direct-hdr-dep.ts': ts` export const direct = 'HDR: 0' `, + }, + }, + async ({ fs, spawn, expect }) => { + let process = await spawn('pnpm react-router dev') + + let url = '' + await process.onStdout((m) => { + let match = /Local:\s*(http.*)\//.exec(m) + if (match) url = match[1] + return Boolean(url) + }) + + // check initial state + await retryAssertion(async () => { + let html = await (await fetch(url)).text() + expect(html).toContain('HDR: 0') + + let css = await fetchStyles(url) + expect(css).toContain(candidate`font-bold`) + }) + + // Flush stdout so we only see messages triggered by the edit below. + process.flush() + + // Edit the server-only module. The client environment watches this file + // but it only exists in the server module graph. Without the fix, the + // Tailwind CSS plugin would trigger a full page reload on the client + // instead of letting react-router handle HDR. + await fs.write('app/direct-hdr-dep.ts', ts` export const direct = 'HDR: 1' `) + + // check update + await retryAssertion(async () => { + let html = await (await fetch(url)).text() + expect(html).toContain('HDR: 1') + + let css = await fetchStyles(url) + expect(css).toContain(candidate`font-bold`) + }) + + // Assert the client receives an HMR update (not a full page reload). + await process.onStdout((m) => m.includes('(client) hmr update')) + }, +) + test('build mode', { fs: WORKSPACE }, async ({ spawn, exec, expect }) => { await exec('pnpm react-router build') let process = await spawn('pnpm react-router-serve ./build/server/index.js') let url = '' await process.onStdout((m) => { - let match = /\[react-router-serve\]\s*(http.*)\ \/?/.exec(m) + let match = /\[react-router-serve\]\s*(http.*) \/?/.exec(m) if (match) url = match[1] return url != '' }) diff --git a/integrations/vite/resolvers.test.ts b/integrations/vite/resolvers.test.ts index f8e389a7e..fc9aca5c7 100644 --- a/integrations/vite/resolvers.test.ts +++ b/integrations/vite/resolvers.test.ts @@ -1,5 +1,661 @@ import { describe } from 'vitest' -import { candidate, css, fetchStyles, html, js, retryAssertion, test, ts, txt } from '../utils' +import { + 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` + + + + +
Hello, world!
+ + `, + '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` + + + + +
Hello, world!
+ + `, + '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` + + + + +
Hello, world!
+ + `, + '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` + + + + +
Hello, world!
+ + `, + '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` + + + + + +
Hello, world!
+ + + `, + '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` + + + + + +
Hello, world!
+ + + `, + '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` + + + + + + + + + `, + '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` + + + + + + + `, + '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, '-.css'), + ).toMatchInlineSnapshot(` + " + --- ./dist/assets/index-.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` + + + + + + + `, + '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, '-.css'), + ).toMatchInlineSnapshot(` + " + --- ./dist/assets/index-.css --- + @layer base { + .do-include-me { + color: green; + } + } + " + `) + }, +) describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { test( diff --git a/integrations/vite/source-maps.test.ts b/integrations/vite/source-maps.test.ts index 0e2594690..fe26d9a7c 100644 --- a/integrations/vite/source-maps.test.ts +++ b/integrations/vite/source-maps.test.ts @@ -91,3 +91,67 @@ 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` + + +
Hello, world!
+ + `, + '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') + }, +) diff --git a/integrations/vite/ssr.test.ts b/integrations/vite/ssr.test.ts index 75442cfa2..a1d660816 100644 --- a/integrations/vite/ssr.test.ts +++ b/integrations/vite/ssr.test.ts @@ -1,168 +1,72 @@ +import { describe } from 'vitest' import { candidate, css, html, json, test, ts } from '../utils' -const WORKSPACE = { - 'index.html': html` - -
- - - `, - 'src/index.css': css`@import 'tailwindcss';`, - 'src/index.ts': ts` - import './index.css' - - document.querySelector('#app').innerHTML = \` -
Hello, world!
- \` - `, - 'server.ts': ts` - import css from './src/index.css?url' - - document.querySelector('#app').innerHTML = \` - -
Hello, world!
- \` - `, -} - -test( - 'Vite 5', - { - fs: { - 'package.json': json` - { - "type": "module", - "dependencies": { - "@tailwindcss/vite": "workspace:^", - "tailwindcss": "workspace:^" - }, - "_comment": "This test uses Vite 5.3 on purpose. Do not upgrade it to Vite 6.", - "devDependencies": { - "vite": "^5.3" +describe.each([['^5.3'], ['^6.0'], ['^7'], ['^8']])('Using Vite %s', (version) => { + test( + `SSR build`, + { + fs: { + 'package.json': json` + { + "type": "module", + "dependencies": { + "@tailwindcss/vite": "workspace:^", + "tailwindcss": "workspace:^" + }, + "devDependencies": { + "vite": "${version}" + } } - } - `, - 'vite.config.ts': ts` - import tailwindcss from '@tailwindcss/vite' - import { defineConfig } from 'vite' + `, + 'vite.config.ts': ts` + import tailwindcss from '@tailwindcss/vite' + import { defineConfig } from 'vite' - export default defineConfig({ - build: { - cssMinify: false, - ssrEmitAssets: true, - }, - plugins: [tailwindcss()], - }) - `, - ...WORKSPACE, + export default defineConfig({ + build: { + cssMinify: false, + ssrEmitAssets: true, + }, + plugins: [tailwindcss()], + }) + `, + 'index.html': html` + +
+ + + `, + 'src/index.css': css`@import 'tailwindcss';`, + 'src/index.ts': ts` + import './index.css' + + document.querySelector('#app').innerHTML = \` +
Hello, world!
+ \` + `, + 'server.ts': ts` + import css from './src/index.css?url' + + document.querySelector('#app').innerHTML = \` + +
Hello, world!
+ \` + `, + }, }, - }, - async ({ fs, exec, expect }) => { - await exec('pnpm vite build --ssr server.ts') + async ({ fs, exec, expect }) => { + await exec('pnpm vite build --ssr server.ts') - let files = await fs.glob('dist/**/*.css') - expect(files).toHaveLength(1) - let [filename] = files[0] + let files = await fs.glob('dist/**/*.css') + expect(files).toHaveLength(1) + let [filename] = files[0] - await fs.expectFileToContain(filename, [ - candidate`underline`, - candidate`m-2`, - candidate`overline`, - candidate`m-3`, - ]) - }, -) - -test( - `Vite 6`, - { - fs: { - 'package.json': json` - { - "type": "module", - "dependencies": { - "@tailwindcss/vite": "workspace:^", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - "vite": "^6.0" - } - } - `, - 'vite.config.ts': ts` - import tailwindcss from '@tailwindcss/vite' - import { defineConfig } from 'vite' - - export default defineConfig({ - build: { - cssMinify: false, - ssrEmitAssets: true, - }, - plugins: [tailwindcss()], - }) - `, - ...WORKSPACE, + await fs.expectFileToContain(filename, [ + candidate`underline`, + candidate`m-2`, + candidate`overline`, + candidate`m-3`, + ]) }, - }, - async ({ fs, exec, expect }) => { - await exec('pnpm vite build --ssr server.ts') - - let files = await fs.glob('dist/**/*.css') - expect(files).toHaveLength(1) - let [filename] = files[0] - - await fs.expectFileToContain(filename, [ - candidate`underline`, - candidate`m-2`, - candidate`overline`, - candidate`m-3`, - ]) - }, -) - -test( - `Vite 7`, - { - fs: { - 'package.json': json` - { - "type": "module", - "dependencies": { - "@tailwindcss/vite": "workspace:^", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - "vite": "^7" - } - } - `, - 'vite.config.ts': ts` - import tailwindcss from '@tailwindcss/vite' - import { defineConfig } from 'vite' - - export default defineConfig({ - build: { - cssMinify: false, - ssrEmitAssets: true, - }, - plugins: [tailwindcss()], - }) - `, - ...WORKSPACE, - }, - }, - async ({ fs, exec, expect }) => { - await exec('pnpm vite build --ssr server.ts') - - let files = await fs.glob('dist/**/*.css') - expect(files).toHaveLength(1) - let [filename] = files[0] - - await fs.expectFileToContain(filename, [ - candidate`underline`, - candidate`m-2`, - candidate`overline`, - candidate`m-3`, - ]) - }, -) + ) +}) diff --git a/integrations/vite/svelte.test.ts b/integrations/vite/svelte.test.ts index 107494207..e186c3f85 100644 --- a/integrations/vite/svelte.test.ts +++ b/integrations/vite/svelte.test.ts @@ -101,7 +101,7 @@ test( await fs.expectFileToContain(files[0][0], [ candidate`underline`, '.global{color:var(--color-green-500,oklch(72.3% .219 149.579));animation:2s ease-in-out infinite globalKeyframes}', - /\.local.svelte-.*\{color:var\(--color-red-500\,oklch\(63\.7% \.237 25\.331\)\);animation:2s ease-in-out infinite svelte-.*-localKeyframes\}/, + /\.local.svelte-.*\{color:var\(--color-red-500,oklch\(63\.7% \.237 25\.331\)\);animation:2s ease-in-out infinite svelte-.*-localKeyframes\}/, /@keyframes globalKeyframes\{/, /@keyframes svelte-.*-localKeyframes\{/, ]) diff --git a/integrations/vite/sveltekit.test.ts b/integrations/vite/sveltekit.test.ts new file mode 100644 index 000000000..92e855da9 --- /dev/null +++ b/integrations/vite/sveltekit.test.ts @@ -0,0 +1,80 @@ +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` + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + + `, + 'src/routes/+layout.svelte': html` + + + + `, + 'src/routes/+page.svelte': html` +
Hello, world!
+ `, + '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`]) + }, +) diff --git a/integrations/vite/vue.test.ts b/integrations/vite/vue.test.ts index 3fea0db9a..b04f57c6b 100644 --- a/integrations/vite/vue.test.ts +++ b/integrations/vite/vue.test.ts @@ -1,5 +1,5 @@ import { stripVTControlCharacters } from 'node:util' -import { candidate, html, json, test, ts } from '../utils' +import { candidate, css, fetchStyles, html, json, retryAssertion, test, ts } from '../utils' test( 'production build', @@ -73,6 +73,110 @@ test( }, ) +{ + const VUE_COMPONENT_COUNT = 1_000 + + let vueComponentsWithReferences = Object.fromEntries( + Array.from({ length: VUE_COMPONENT_COUNT }, (_, idx) => [ + `src/components/Component${idx}.vue`, + html` + + + + `, + ]), + ) + + 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) => ``, + ).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` + + + +
+ + + + `, + '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` + + + + `, + ...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( 'error when using `@apply` without `@reference`', { @@ -132,7 +236,7 @@ test( expect.assertions(1) try { - await exec('pnpm vite build') + await exec('pnpm vite build', {}, { ignoreStdErr: true }) } catch (error) { let [, message] = /error during build:([\s\S]*?)file:/g.exec( @@ -144,3 +248,133 @@ 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` + + + + + + +
+ + + + `, + '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` + + `, + + // 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` + + `, + }, + }, + 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` + + `, + ) + + 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') + }, +) diff --git a/integrations/vitest.config.ts b/integrations/vitest.config.mts similarity index 100% rename from integrations/vitest.config.ts rename to integrations/vitest.config.mts diff --git a/integrations/webpack/loader.test.ts b/integrations/webpack/loader.test.ts new file mode 100644 index 000000000..9c3d870af --- /dev/null +++ b/integrations/webpack/loader.test.ts @@ -0,0 +1,495 @@ +import { css, html, js, json, test } from '../utils' + +test( + '@tailwindcss/webpack loader (build)', + { + 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') + + module.exports = { + output: { + clean: true, + }, + plugins: [new MiniCssExtractPlugin()], + module: { + rules: [ + { + test: /.css$/i, + use: [MiniCssExtractPlugin.loader, 'css-loader', '@tailwindcss/webpack'], + }, + ], + }, + } + `, + 'src/index.js': js`import './index.css'`, + 'src/index.html': html` +
+ `, + 'src/index.css': css` + @import 'tailwindcss/theme'; + @import 'tailwindcss/utilities'; + `, + }, + }, + async ({ fs, exec, expect }) => { + await exec('pnpm webpack --mode=development') + + expect(await fs.dumpFiles('./dist/*.css')).toMatchInlineSnapshot(` + " + --- ./dist/main.css --- + .flex { + display: flex; + } + " + `) + }, +) + +test( + '@tailwindcss/webpack loader (watch)', + { + 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') + + module.exports = { + output: { + clean: true, + }, + plugins: [new MiniCssExtractPlugin()], + module: { + rules: [ + { + test: /.css$/i, + use: [MiniCssExtractPlugin.loader, 'css-loader', '@tailwindcss/webpack'], + }, + ], + }, + } + `, + 'src/index.js': js`import './index.css'`, + 'src/index.html': html` +
+ `, + 'src/index.css': css` + @import 'tailwindcss/theme'; + @import 'tailwindcss/utilities'; + `, + }, + }, + async ({ fs, spawn, exec, expect }) => { + // Generate the initial build so output CSS files exist on disk + await exec('pnpm webpack --mode=development') + + let process = await spawn('pnpm webpack --mode=development --watch') + await process.onStdout((m) => m.includes('compiled successfully in')) + + expect(await fs.dumpFiles('./dist/*.css')).toMatchInlineSnapshot(` + " + --- ./dist/main.css --- + .flex { + display: flex; + } + " + `) + + // Add a new Tailwind class to the HTML file + await fs.write('src/index.html', html` +
+ `) + await process.onStdout((m) => m.includes('compiled successfully in')) + + expect(await fs.dumpFiles('./dist/*.css')).toMatchInlineSnapshot(` + " + --- ./dist/main.css --- + .flex { + display: flex; + } + .underline { + text-decoration-line: underline; + } + " + `) + }, +) + +test( + '@tailwindcss/webpack loader with @apply', + { + 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') + + module.exports = { + output: { + clean: true, + }, + plugins: [new MiniCssExtractPlugin()], + module: { + rules: [ + { + test: /.css$/i, + use: [MiniCssExtractPlugin.loader, 'css-loader', '@tailwindcss/webpack'], + }, + ], + }, + } + `, + 'src/index.js': js`import './index.css'`, + 'src/index.css': css` + @import 'tailwindcss/theme'; + + .btn { + @apply flex items-center px-4 py-2 rounded-md; + } + `, + }, + }, + async ({ fs, exec, expect }) => { + await exec('pnpm webpack --mode=development') + + expect(await fs.dumpFiles('./dist/*.css')).toMatchInlineSnapshot(` + " + --- ./dist/main.css --- + :root, :host { + --spacing: 0.25rem; + --radius-md: 0.375rem; + } + .btn { + display: flex; + align-items: center; + border-radius: var(--radius-md); + padding-inline: calc(var(--spacing) * 4); + padding-block: calc(var(--spacing) * 2); + } + " + `) + }, +) + +test( + '@tailwindcss/webpack loader with optimization', + { + 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') + + module.exports = { + output: { + clean: true, + }, + plugins: [new MiniCssExtractPlugin()], + module: { + rules: [ + { + test: /.css$/i, + use: [ + MiniCssExtractPlugin.loader, + 'css-loader', + { + loader: '@tailwindcss/webpack', + options: { + optimize: true, + }, + }, + ], + }, + ], + }, + } + `, + 'src/index.js': js`import './index.css'`, + 'src/index.html': html` +
+ `, + 'src/index.css': css` + @import 'tailwindcss/theme'; + @import 'tailwindcss/utilities'; + `, + }, + }, + async ({ fs, exec, expect }) => { + await exec('pnpm webpack --mode=development') + + expect(await fs.dumpFiles('./dist/*.css')).toMatchInlineSnapshot(` + " + --- ./dist/main.css --- + .flex{display:flex} + " + `) + }, +) + +test( + '@tailwindcss/webpack loader with CSS @import', + { + 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') + + module.exports = { + output: { + clean: true, + }, + plugins: [new MiniCssExtractPlugin()], + module: { + rules: [ + { + test: /.css$/i, + use: [MiniCssExtractPlugin.loader, 'css-loader', '@tailwindcss/webpack'], + }, + ], + }, + } + `, + 'src/index.js': js`import './index.css'`, + 'src/index.html': html` +
+ `, + 'src/index.css': css` + @import './custom.css'; + @import 'tailwindcss/theme'; + @import 'tailwindcss/utilities'; + `, + 'src/custom.css': css` + /**/ + @utility custom-util { + color: var(--color-red-500); + } + `, + }, + }, + async ({ fs, exec, expect }) => { + await exec('pnpm webpack --mode=development') + + expect(await fs.dumpFiles('./dist/*.css')).toMatchInlineSnapshot(` + " + --- ./dist/main.css --- + :root, :host { + --color-red-500: oklch(63.7% 0.237 25.331); + } + .flex { + display: flex; + } + .custom-util { + color: var(--color-red-500); + } + " + `) + }, +) + +test( + '@tailwindcss/webpack loader with @plugin', + { + 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') + + module.exports = { + output: { + clean: true, + }, + plugins: [new MiniCssExtractPlugin()], + module: { + rules: [ + { + test: /.css$/i, + use: [MiniCssExtractPlugin.loader, 'css-loader', '@tailwindcss/webpack'], + }, + ], + }, + } + `, + 'src/index.js': js`import './index.css'`, + 'src/index.html': html` +
+ `, + 'src/index.css': css` + @import 'tailwindcss/utilities'; + @plugin './plugin.js'; + `, + 'src/plugin.js': js` + export default function ({ addUtilities }) { + addUtilities({ + '.custom-underline': { + 'border-bottom': '1px solid green', + }, + }) + } + `, + }, + }, + async ({ fs, exec, expect }) => { + await exec('pnpm webpack --mode=development') + + expect(await fs.dumpFiles('./dist/*.css')).toMatchInlineSnapshot(` + " + --- ./dist/main.css --- + .custom-underline { + border-bottom: 1px solid green; + } + " + `) + }, +) + +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']) + }, +) diff --git a/package.json b/package.json index bd07ccc49..7d41a469e 100644 --- a/package.json +++ b/package.json @@ -48,23 +48,17 @@ }, "license": "MIT", "devDependencies": { - "@playwright/test": "^1.54.2", + "@playwright/test": "^1.62.1", "@types/node": "catalog:", - "postcss": "8.5.6", + "postcss": "catalog:", "postcss-import": "^16.1.1", "prettier": "catalog:", - "prettier-plugin-embed": "^0.5.0", - "prettier-plugin-organize-imports": "^4.2.0", - "tsup": "^8.5.0", - "turbo": "^2.5.4", - "typescript": "^5.5.4", - "vitest": "^2.0.5" + "prettier-plugin-embed": "^0.5.1", + "prettier-plugin-organize-imports": "^4.3.0", + "tsup": "^8.5.1", + "turbo": "^2.10.8", + "typescript": "^5.9.3", + "vitest": "^4.1.10" }, - "packageManager": "pnpm@9.6.0", - "pnpm": { - "patchedDependencies": { - "lightningcss@1.30.1": "patches/lightningcss@1.30.1.patch", - "@parcel/watcher@2.5.1": "patches/@parcel__watcher@2.5.1.patch" - } - } + "packageManager": "pnpm@11.9.0" } diff --git a/packages/@tailwindcss-browser/README.md b/packages/@tailwindcss-browser/README.md index 7d21bd883..5f532607d 100644 --- a/packages/@tailwindcss-browser/README.md +++ b/packages/@tailwindcss-browser/README.md @@ -13,10 +13,10 @@

- Build Status + Build Status Total Downloads - Latest Release - License + Latest Release + License

--- @@ -29,8 +29,8 @@ For full documentation, visit [tailwindcss.com](https://tailwindcss.com). For help, discussion about best practices, or feature ideas: -[Discuss Tailwind CSS on GitHub](https://github.com/tailwindcss/tailwindcss/discussions) +[Discuss Tailwind CSS on GitHub](https://github.com/tailwindlabs/tailwindcss/discussions) ## Contributing -If you're interested in contributing to Tailwind CSS, please read our [contributing docs](https://github.com/tailwindcss/tailwindcss/blob/next/.github/CONTRIBUTING.md) **before submitting a pull request**. +If you're interested in contributing to Tailwind CSS, please read our [contributing docs](https://github.com/tailwindlabs/tailwindcss/blob/main/.github/CONTRIBUTING.md) **before submitting a pull request**. diff --git a/packages/@tailwindcss-browser/package.json b/packages/@tailwindcss-browser/package.json index 779ca60ec..70af78c1e 100644 --- a/packages/@tailwindcss-browser/package.json +++ b/packages/@tailwindcss-browser/package.json @@ -1,6 +1,6 @@ { "name": "@tailwindcss/browser", - "version": "4.1.12", + "version": "4.3.3", "description": "A utility-first CSS framework for rapidly building custom user interfaces.", "license": "MIT", "main": "./dist/index.global.js", @@ -30,8 +30,8 @@ "access": "public" }, "devDependencies": { - "h3": "^1.15.4", - "listhen": "^1.9.0", + "h3": "^1.15.11", + "listhen": "^1.10.1", "tailwindcss": "workspace:*" } } diff --git a/packages/@tailwindcss-browser/playwright.config.ts b/packages/@tailwindcss-browser/playwright.config.ts index c3ad05b91..c6059dd82 100644 --- a/packages/@tailwindcss-browser/playwright.config.ts +++ b/packages/@tailwindcss-browser/playwright.config.ts @@ -14,7 +14,7 @@ export default defineConfig({ /* Opt out of parallel tests on CI. */ workers: process.env.CI ? 1 : undefined, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: 'html', + reporter: [['html', { open: 'never' }]], /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Base URL to use in actions like `await page.goto('/')`. */ @@ -30,10 +30,11 @@ export default defineConfig({ name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, - { - name: 'webkit', - use: { ...devices['Desktop Safari'] }, - }, + // Playwright 1.60's Windows WebKit worker hangs on shutdown after all tests + // pass, causing CI to fail with a worker force-kill error. + ...(process.platform === 'win32' + ? [] + : [{ name: 'webkit', use: { ...devices['Desktop Safari'] } }]), { name: 'firefox', use: { diff --git a/packages/@tailwindcss-browser/src/index.ts b/packages/@tailwindcss-browser/src/index.ts index 74992157f..49c7e4675 100644 --- a/packages/@tailwindcss-browser/src/index.ts +++ b/packages/@tailwindcss-browser/src/index.ts @@ -2,12 +2,6 @@ import * as tailwindcss from 'tailwindcss' import * as assets from './assets' import { Instrumentation } from './instrumentation' -// Warn users about using the browser build in production as early as possible. -// It can take time for the script to do its work so this must be at the top. -console.warn( - 'The browser build of Tailwind CSS should not be used in production. To use Tailwind CSS in production, use the Tailwind CLI, Vite plugin, or PostCSS plugin: https://tailwindcss.com/docs/installation', -) - /** * The type used by `