diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 63f79fa10..42645aa49 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/tailwindlabs/tailwindcss/blob/main/package.json). You can check your code against these standards by running: +Our code formatting rules are defined in the `"prettier"` section of [package.json](https://github.com/tailwindcss/tailwindcss/blob/main/package.json). You can check your code against these standards by running: ```sh pnpm run lint @@ -76,7 +76,7 @@ To run the integration tests, use: pnpm build && pnpm test:integrations ``` -Additionally, some features require testing in browsers (i.e. to ensure CSS variable resolution works as expected). These can be run via: +Additionally, some features require testing in browsers (i.e to ensure CSS variable resolution works as expected). These can be run via: ```sh pnpm build && pnpm test:ui diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index aa0793aa3..ee9d3f9ab 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/tailwindlabs/tailwindcss/blob/main/.github/CONTRIBUTING.md +https://github.com/tailwindcss/tailwindcss/blob/main/.github/CONTRIBUTING.md --> diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ae59e80f..b0c8f483e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,18 +8,12 @@ on: permissions: contents: read -env: - NODE_VERSION: 24 - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - jobs: tests: strategy: fail-fast: false matrix: + node-version: [20] runner: - name: Windows os: windows-latest @@ -27,14 +21,12 @@ jobs: - name: Linux os: namespace-profile-default - # Playwright 1.62+ dropped WebKit support for macOS 14, and hangs - # instead of failing when launching WebKit on a macos-14 runner. - name: macOS - os: macos-15 + os: macos-14 # Exclude windows and macos from being built on feature branches run-all: - - ${{ github.ref == 'refs/heads/main' || contains(github.event.pull_request.body, '[ci-all]') || github.event.pull_request.user.login == 'depfu[bot]' }} + - ${{ github.ref == 'refs/heads/main' || contains(github.event.pull_request.body, '[ci-all]') }} exclude: - run-all: false runner: @@ -49,21 +41,21 @@ jobs: name: ${{ matrix.runner.name }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - persist-credentials: false - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 - - name: Use Node.js ${{ env.NODE_VERSION }} - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 with: - node-version: ${{ env.NODE_VERSION }} + node-version: ${{ matrix.node-version }} + cache: 'pnpm' # Cargo already skips downloading dependencies if they already exist - name: Cache cargo - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache@v4 with: path: | + ~/.cargo/bin/ ~/.cargo/registry/index/ ~/.cargo/registry/cache/ ~/.cargo/git/db/ @@ -72,7 +64,7 @@ jobs: # Cache the `oxide` Rust build - name: Cache oxide build - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache@v4 with: path: | ./crates/node/*.node @@ -90,7 +82,7 @@ jobs: run: rustup target add wasm32-wasip1-threads - name: Install dependencies - run: pnpm install --frozen-lockfile + run: pnpm install - name: Build run: pnpm run build @@ -112,13 +104,9 @@ jobs: - name: Run Playwright tests run: npm run test:ui - notify: - if: ${{ always() && github.ref == 'refs/heads/main' && needs.tests.result == 'failure' }} - needs: tests - runs-on: ubuntu-latest - steps: - name: Notify Discord - uses: discord-actions/message@5c7149c81a83146e5d01f142be1bf87a61831c4d # v2 + if: failure() && github.ref == 'refs/heads/main' + uses: discord-actions/message@v2 with: webhookUrl: ${{ secrets.DISCORD_WEBHOOK_URL }} - message: 'The [most recent ${{ github.workflow }} workflow](<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}>) on the `main` branch has failed.' + message: 'The [most recent build](<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}>) on the `main` branch has failed.' diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 457c38424..0148dd983 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -8,18 +8,13 @@ on: permissions: contents: read -env: - NODE_VERSION: 24 - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - jobs: tests: strategy: fail-fast: false matrix: + node-version: [20] + runner: - name: Windows os: windows-latest @@ -55,25 +50,21 @@ jobs: name: ${{ matrix.runner.name }} / ${{ matrix.integration }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - persist-credentials: false - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 - - run: | - git config --global user.name "github-actions[bot]" - git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" - - - name: Use Node.js ${{ env.NODE_VERSION }} - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 with: - node-version: ${{ env.NODE_VERSION }} + node-version: ${{ matrix.node-version }} + cache: 'pnpm' # Cargo already skips downloading dependencies if they already exist - name: Cache cargo - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache@v4 with: path: | + ~/.cargo/bin/ ~/.cargo/registry/index/ ~/.cargo/registry/cache/ ~/.cargo/git/db/ @@ -82,7 +73,7 @@ jobs: # Cache the `oxide` Rust build - name: Cache oxide build - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + uses: actions/cache@v4 with: path: | ./crates/node/*.node @@ -108,18 +99,24 @@ jobs: CARGO_PROFILE_RELEASE_LTO: 'off' CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_LINKER: 'lld-link' - - name: Test ${{ matrix.integration }} - run: pnpm run test:integrations ./integrations/${{ matrix.integration }} + - name: Test ${{ matrix.integration }} 1/3 + run: pnpm run test:integrations ./integrations/${{ matrix.integration }} --shard 1/3 + env: + GITHUB_WORKSPACE: ${{ github.workspace }} + + - name: Test ${{ matrix.integration }} 2/3 + run: pnpm run test:integrations ./integrations/${{ matrix.integration }} --shard 2/3 + env: + GITHUB_WORKSPACE: ${{ github.workspace }} + + - name: Test ${{ matrix.integration }} 3/3 + run: pnpm run test:integrations ./integrations/${{ matrix.integration }} --shard 3/3 env: GITHUB_WORKSPACE: ${{ github.workspace }} - notify: - if: ${{ always() && github.ref == 'refs/heads/main' && needs.tests.result == 'failure' }} - needs: tests - runs-on: ubuntu-latest - steps: - name: Notify Discord - uses: discord-actions/message@5c7149c81a83146e5d01f142be1bf87a61831c4d # v2 + if: failure() && github.ref == 'refs/heads/main' + uses: discord-actions/message@v2 with: webhookUrl: ${{ secrets.DISCORD_WEBHOOK_URL }} - message: 'The [most recent ${{ github.workflow }} workflow](<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}>) on the `main` branch has failed.' + message: 'The [most recent build](<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}>) on the `main` branch has failed.' diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 24e829afd..be1050b03 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -2,29 +2,18 @@ name: Prepare Release on: workflow_dispatch: - inputs: - dry_run: - description: Skip creating the draft GitHub release - required: false - default: true - type: boolean push: tags: - 'v*' env: APP_NAME: tailwindcss-oxide - NODE_VERSION: 24 - PNPM_VERSION: '11.9.0' + NODE_VERSION: 20 OXIDE_LOCATION: ./crates/node permissions: contents: read -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - jobs: build: strategy: @@ -54,40 +43,44 @@ jobs: - os: ubuntu-latest target: x86_64-unknown-linux-gnu strip: strip - build-flags: --use-napi-cross + container: + image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian - os: ubuntu-latest target: aarch64-unknown-linux-gnu - strip: aarch64-linux-gnu-strip - build-flags: --use-napi-cross + strip: llvm-strip + container: + image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian-aarch64 - os: ubuntu-latest target: armv7-unknown-linux-gnueabihf - strip: arm-linux-gnueabihf-strip - build-flags: --use-napi-cross + strip: llvm-strip + container: + image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian-zig - os: ubuntu-latest target: aarch64-unknown-linux-musl - strip-zig: true - build-flags: -x + strip: aarch64-linux-musl-strip + download: true + container: + image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-alpine - os: ubuntu-latest target: x86_64-unknown-linux-musl strip: strip - build-flags: -x + download: true + container: + image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-alpine name: Build ${{ matrix.target }} (oxide) runs-on: ${{ matrix.os }} + container: ${{ matrix.container }} timeout-minutes: 15 steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - persist-credentials: false - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - with: - version: ${{ env.PNPM_VERSION }} + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 - name: Use Node.js ${{ env.NODE_VERSION }} - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} - package-manager-cache: false + cache: 'pnpm' - name: Install gcc-arm-linux-gnueabihf if: ${{ matrix.target == 'armv7-unknown-linux-gnueabihf' }} @@ -95,56 +88,62 @@ jobs: sudo apt-get update sudo apt-get install gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf -y - - name: Install binutils-aarch64-linux-gnu - if: ${{ matrix.target == 'aarch64-unknown-linux-gnu' }} + # Cargo already skips downloading dependencies if they already exist + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }} + + # Cache the `oxide` Rust build + - name: Cache oxide build + uses: actions/cache@v4 + with: + path: | + ./crates/node/*.node + ./crates/node/*.wasm + ./crates/node/index.d.ts + ./crates/node/index.js + ./crates/node/browser.js + ./crates/node/tailwindcss-oxide.wasi-browser.js + ./crates/node/tailwindcss-oxide.wasi.cjs + ./crates/node/wasi-worker-browser.mjs + ./crates/node/wasi-worker.mjs + key: ${{ runner.os }}-${{ matrix.target }}-oxide-${{ hashFiles('./crates/**/*') }} + + - name: Install Node.JS + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Install Rust (Stable) + if: ${{ matrix.download }} run: | - sudo apt-get update - sudo apt-get install binutils-aarch64-linux-gnu -y - - - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 - if: ${{ contains(matrix.target, 'musl') }} - with: - version: 0.14.1 - use-cache: false - - - name: Install cargo-zigbuild - uses: taiki-e/install-action@65851e10cd6c377f11a60e600abc07cb08643468 # v2 - if: ${{ contains(matrix.target, 'musl') }} - env: - GITHUB_TOKEN: ${{ github.token }} - with: - tool: cargo-zigbuild + rustup default stable - name: Setup rust target run: rustup target add ${{ matrix.target }} - name: Install dependencies - run: pnpm install --ignore-scripts --frozen-lockfile --filter=!./playgrounds/* + run: pnpm install --ignore-scripts --filter=!./playgrounds/* - name: Build release - run: pnpm run --filter ${{ env.OXIDE_LOCATION }} build:platform --target=${{ matrix.target }} ${{ matrix.build-flags }} + run: pnpm run --filter ${{ env.OXIDE_LOCATION }} build:platform --target=${{ matrix.target }} env: RUST_TARGET: ${{ matrix.target }} JEMALLOC_SYS_WITH_LG_PAGE: ${{ matrix.page-size }} - name: Strip debug symbols # https://github.com/rust-lang/rust/issues/46034 - if: ${{ matrix.strip || matrix.strip-zig }} - env: - STRIP_COMMAND: ${{ matrix.strip }} - STRIP_ZIG: ${{ matrix.strip-zig }} - run: | - if [ "$STRIP_ZIG" = "true" ]; then - for file in ${{ env.OXIDE_LOCATION }}/*.node; do - zig objcopy --strip-all "$file" "$file.stripped" - mv "$file.stripped" "$file" - done - exit 0 - fi - - eval "$STRIP_COMMAND ${{ env.OXIDE_LOCATION }}/*.node" + if: ${{ matrix.strip }} + run: ${{ matrix.strip }} ${{ env.OXIDE_LOCATION }}/*.node - name: Upload artifacts - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + uses: actions/upload-artifact@v4 with: name: bindings-${{ matrix.target }} path: ${{ env.OXIDE_LOCATION }}/*.node @@ -154,11 +153,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - persist-credentials: false + - uses: actions/checkout@v4 - name: Build FreeBSD - uses: cross-platform-actions/action@cdc9ee69ef84a5f2e59c9058335d9c57bcb4ac86 # v0.25.0 + uses: cross-platform-actions/action@v0.25.0 env: DEBUG: napi:* RUSTUP_HOME: /usr/local/rustup @@ -174,12 +171,12 @@ jobs: shell: bash run: | sudo pkg install -y -f curl node libnghttp2 npm - sudo npm install -g pnpm@${{ env.PNPM_VERSION }} --unsafe-perm=true + sudo npm install -g pnpm@9.6.0 --unsafe-perm=true curl -sSf https://static.rust-lang.org/rustup/archive/1.27.1/x86_64-unknown-freebsd/rustup-init --output rustup-init chmod +x rustup-init ./rustup-init -y --profile minimal source "$HOME/.cargo/env" - pnpm install --ignore-scripts --frozen-lockfile --filter=!./playgrounds/* || true + pnpm install --ignore-scripts --filter=!./playgrounds/* || true echo "~~~~ rustc --version ~~~~" rustc --version echo "~~~~ node -v ~~~~" @@ -190,7 +187,7 @@ jobs: strip -x ${{ env.OXIDE_LOCATION }}/*.node ls -la ${{ env.OXIDE_LOCATION }} - name: Upload artifacts - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + uses: actions/upload-artifact@v4 with: name: bindings-x86_64-unknown-freebsd path: ${{ env.OXIDE_LOCATION }}/*.node @@ -201,17 +198,18 @@ jobs: name: Build and release Tailwind CSS permissions: - contents: write # Required for creating releases + contents: write # for softprops/action-gh-release to create GitHub release + # https://docs.npmjs.com/generating-provenance-statements#publishing-packages-with-provenance-via-github-actions + id-token: write needs: - build - build-freebsd steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@v4 with: fetch-depth: 20 - persist-credentials: false - run: git fetch --tags -f @@ -220,25 +218,51 @@ jobs: run: | echo "TAG_NAME=$(git describe --tags --abbrev=0)" >> $GITHUB_ENV - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - with: - version: ${{ env.PNPM_VERSION }} + - uses: pnpm/action-setup@v4 - name: Use Node.js ${{ env.NODE_VERSION }} - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} + cache: 'pnpm' registry-url: 'https://registry.npmjs.org' - package-manager-cache: false + + # Cargo already skips downloading dependencies if they already exist + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }} + + # Cache the `oxide` Rust build + - name: Cache oxide build + uses: actions/cache@v4 + with: + path: | + ./crates/node/*.node + ./crates/node/*.wasm + ./crates/node/index.d.ts + ./crates/node/index.js + ./crates/node/browser.js + ./crates/node/tailwindcss-oxide.wasi-browser.js + ./crates/node/tailwindcss-oxide.wasi.cjs + ./crates/node/wasi-worker-browser.mjs + ./crates/node/wasi-worker.mjs + key: ${{ runner.os }}-${{ matrix.target }}-oxide-${{ hashFiles('./crates/**/*') }} - name: Setup WASM target run: rustup target add wasm32-wasip1-threads - name: Install dependencies - run: pnpm --filter=!./playgrounds/* install --frozen-lockfile + run: pnpm --filter=!./playgrounds/* install - name: Download artifacts - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + uses: actions/download-artifact@v4 with: path: ${{ env.OXIDE_LOCATION }} @@ -277,31 +301,30 @@ jobs: echo "EOF" >> $GITHUB_ENV - name: Upload standalone artifacts - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + uses: actions/upload-artifact@v4 with: name: tailwindcss-standalone path: packages/@tailwindcss-standalone/dist/ - name: Upload npm package tarballs - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + uses: actions/upload-artifact@v4 with: name: npm-package-tarballs path: dist/*.tgz - name: Prepare GitHub Release - if: ${{ !inputs.dry_run }} - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh release create "${TAG_NAME}" \ - --draft \ - --title "${TAG_NAME}" \ - --notes "${RELEASE_NOTES}" \ - packages/@tailwindcss-standalone/dist/sha256sums.txt \ - packages/@tailwindcss-standalone/dist/tailwindcss-linux-arm64 \ - packages/@tailwindcss-standalone/dist/tailwindcss-linux-arm64-musl \ - packages/@tailwindcss-standalone/dist/tailwindcss-linux-x64 \ - packages/@tailwindcss-standalone/dist/tailwindcss-linux-x64-musl \ - packages/@tailwindcss-standalone/dist/tailwindcss-macos-arm64 \ - packages/@tailwindcss-standalone/dist/tailwindcss-macos-x64 \ - packages/@tailwindcss-standalone/dist/tailwindcss-windows-x64.exe + uses: softprops/action-gh-release@v2 + with: + draft: true + tag_name: ${{ env.TAG_NAME }} + body: | + ${{ env.RELEASE_NOTES }} + files: | + packages/@tailwindcss-standalone/dist/sha256sums.txt + packages/@tailwindcss-standalone/dist/tailwindcss-linux-arm64 + packages/@tailwindcss-standalone/dist/tailwindcss-linux-arm64-musl + packages/@tailwindcss-standalone/dist/tailwindcss-linux-x64 + packages/@tailwindcss-standalone/dist/tailwindcss-linux-x64-musl + packages/@tailwindcss-standalone/dist/tailwindcss-macos-arm64 + packages/@tailwindcss-standalone/dist/tailwindcss-macos-x64 + packages/@tailwindcss-standalone/dist/tailwindcss-windows-x64.exe diff --git a/.github/workflows/release-insiders.yml b/.github/workflows/release-insiders.yml new file mode 100644 index 000000000..53478a227 --- /dev/null +++ b/.github/workflows/release-insiders.yml @@ -0,0 +1,318 @@ +name: Release Insiders + +on: + push: + branches: [main] + +permissions: + contents: read + +env: + APP_NAME: tailwindcss-oxide + NODE_VERSION: 20 + OXIDE_LOCATION: ./crates/node + RELEASE_CHANNEL: insiders + +jobs: + build: + strategy: + matrix: + include: + # Windows + - os: windows-latest + target: x86_64-pc-windows-msvc + - os: windows-latest + target: aarch64-pc-windows-msvc + # macOS + - os: macos-latest + target: x86_64-apple-darwin + strip: strip -x # Must use -x on macOS. This produces larger results on linux. + - os: macos-latest + target: aarch64-apple-darwin + page-size: 14 + strip: strip -x # Must use -x on macOS. This produces larger results on linux. + # Android + - os: ubuntu-latest + target: aarch64-linux-android + strip: ${ANDROID_NDK_LATEST_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip + - os: ubuntu-latest + target: armv7-linux-androideabi + strip: ${ANDROID_NDK_LATEST_HOME}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip + # Linux + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + strip: strip + container: + image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian + - os: ubuntu-latest + target: aarch64-unknown-linux-gnu + strip: llvm-strip + container: + image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian-aarch64 + - os: ubuntu-latest + target: armv7-unknown-linux-gnueabihf + strip: llvm-strip + container: + image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian-zig + - os: ubuntu-latest + target: aarch64-unknown-linux-musl + strip: aarch64-linux-musl-strip + download: true + container: + image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-alpine + - os: ubuntu-latest + target: x86_64-unknown-linux-musl + strip: strip + download: true + container: + image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-alpine + + name: Build ${{ matrix.target }} (oxide) + runs-on: ${{ matrix.os }} + container: ${{ matrix.container }} + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + + - name: Use Node.js ${{ env.NODE_VERSION }} + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'pnpm' + + - name: Install gcc-arm-linux-gnueabihf + if: ${{ matrix.target == 'armv7-unknown-linux-gnueabihf' }} + run: | + sudo apt-get update + sudo apt-get install gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf -y + + # Cargo already skips downloading dependencies if they already exist + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }} + + # Cache the `oxide` Rust build + - name: Cache oxide build + uses: actions/cache@v4 + with: + path: | + ./crates/node/*.node + ./crates/node/*.wasm + ./crates/node/index.d.ts + ./crates/node/index.js + ./crates/node/browser.js + ./crates/node/tailwindcss-oxide.wasi-browser.js + ./crates/node/tailwindcss-oxide.wasi.cjs + ./crates/node/wasi-worker-browser.mjs + ./crates/node/wasi-worker.mjs + key: ${{ runner.os }}-${{ matrix.target }}-oxide-${{ hashFiles('./crates/**/*') }} + + - name: Install Node.JS + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Install Rust (Stable) + if: ${{ matrix.download }} + run: | + rustup default stable + + - name: Setup rust target + run: rustup target add ${{ matrix.target }} + + - name: Install dependencies + run: pnpm install --ignore-scripts --filter=!./playgrounds/* + + - name: Build release + run: pnpm run --filter ${{ env.OXIDE_LOCATION }} build:platform --target=${{ matrix.target }} + env: + RUST_TARGET: ${{ matrix.target }} + JEMALLOC_SYS_WITH_LG_PAGE: ${{ matrix.page-size }} + + - name: Strip debug symbols # https://github.com/rust-lang/rust/issues/46034 + if: ${{ matrix.strip }} + run: ${{ matrix.strip }} ${{ env.OXIDE_LOCATION }}/*.node + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: bindings-${{ matrix.target }} + path: ${{ env.OXIDE_LOCATION }}/*.node + + build-freebsd: + name: Build x86_64-unknown-freebsd (OXIDE) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - name: Build FreeBSD + uses: cross-platform-actions/action@v0.25.0 + env: + DEBUG: napi:* + RUSTUP_HOME: /usr/local/rustup + CARGO_HOME: /usr/local/cargo + RUSTUP_IO_THREADS: 1 + RUST_TARGET: x86_64-unknown-freebsd + with: + operating_system: freebsd + version: '14.0' + memory: 13G + cpu_count: 3 + environment_variables: 'DEBUG RUSTUP_IO_THREADS' + shell: bash + run: | + sudo pkg install -y -f curl node libnghttp2 npm + sudo npm install -g pnpm@9.6.0 --unsafe-perm=true + curl -sSf https://static.rust-lang.org/rustup/archive/1.27.1/x86_64-unknown-freebsd/rustup-init --output rustup-init + chmod +x rustup-init + ./rustup-init -y --profile minimal + source "$HOME/.cargo/env" + echo "~~~~ rustc --version ~~~~" + rustc --version + echo "~~~~ node -v ~~~~" + node -v + echo "~~~~ pnpm --version ~~~~" + pnpm --version + pnpm install --ignore-scripts --filter=!./playgrounds/* || true + pnpm run --filter ${{ env.OXIDE_LOCATION }} build:platform + strip -x ${{ env.OXIDE_LOCATION }}/*.node + ls -la ${{ env.OXIDE_LOCATION }} + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: bindings-x86_64-unknown-freebsd + path: ${{ env.OXIDE_LOCATION }}/*.node + + release: + runs-on: macos-14 + timeout-minutes: 15 + name: Build and release Tailwind CSS insiders + + permissions: + contents: write # for softprops/action-gh-release to create GitHub release + # https://docs.npmjs.com/generating-provenance-statements#publishing-packages-with-provenance-via-github-actions + id-token: write + + needs: + - build + - build-freebsd + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 20 + + - name: Resolve version + id: vars + run: | + echo "SHA_SHORT=$(git rev-parse --short HEAD)" >> $GITHUB_ENV + + - uses: pnpm/action-setup@v4 + + - name: Use Node.js ${{ env.NODE_VERSION }} + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'pnpm' + registry-url: 'https://registry.npmjs.org' + + # Cargo already skips downloading dependencies if they already exist + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }} + + # Cache the `oxide` Rust build + - name: Cache oxide build + uses: actions/cache@v4 + with: + path: | + ./crates/node/*.node + ./crates/node/*.wasm + ./crates/node/index.d.ts + ./crates/node/index.js + ./crates/node/browser.js + ./crates/node/tailwindcss-oxide.wasi-browser.js + ./crates/node/tailwindcss-oxide.wasi.cjs + ./crates/node/wasi-worker-browser.mjs + ./crates/node/wasi-worker.mjs + key: ${{ runner.os }}-${{ matrix.target }}-oxide-${{ hashFiles('./crates/**/*') }} + + - name: Setup WASM target + run: rustup target add wasm32-wasip1-threads + + - name: Install dependencies + run: pnpm --filter=!./playgrounds/* install + + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + path: ${{ env.OXIDE_LOCATION }} + + - name: Move artifacts + run: | + cd ${{ env.OXIDE_LOCATION }} + cp bindings-x86_64-pc-windows-msvc/* ./npm/win32-x64-msvc/ + cp bindings-aarch64-pc-windows-msvc/* ./npm/win32-arm64-msvc/ + cp bindings-x86_64-apple-darwin/* ./npm/darwin-x64/ + cp bindings-aarch64-apple-darwin/* ./npm/darwin-arm64/ + cp bindings-aarch64-linux-android/* ./npm/android-arm64/ + cp bindings-armv7-linux-androideabi/* ./npm/android-arm-eabi/ + cp bindings-aarch64-unknown-linux-gnu/* ./npm/linux-arm64-gnu/ + cp bindings-aarch64-unknown-linux-musl/* ./npm/linux-arm64-musl/ + cp bindings-armv7-unknown-linux-gnueabihf/* ./npm/linux-arm-gnueabihf/ + cp bindings-x86_64-unknown-linux-gnu/* ./npm/linux-x64-gnu/ + cp bindings-x86_64-unknown-linux-musl/* ./npm/linux-x64-musl/ + cp bindings-x86_64-unknown-freebsd/* ./npm/freebsd-x64/ + + - name: 'Version based on commit: 0.0.0-${{ env.RELEASE_CHANNEL }}.${{ env.SHA_SHORT }}' + run: pnpm run version-packages 0.0.0-${{ env.RELEASE_CHANNEL }}.${{ env.SHA_SHORT }} + + - name: Build Tailwind CSS + run: pnpm run build + + - name: Run pre-publish optimizations scripts + run: node ./scripts/pre-publish-optimizations.mjs + + - name: Lock pre-release versions + run: node ./scripts/lock-pre-release-versions.mjs + + - name: Upload npm package tarballs + uses: actions/upload-artifact@v4 + with: + name: npm-package-tarballs + path: dist/*.tgz + + - name: Publish + run: | + pnpm --recursive --filter="!@tailwindcss/oxide-wasm32-wasi" publish --tag ${{ env.RELEASE_CHANNEL }} --no-git-checks + # The wasm package needs a special npm config that isn't read when pnpm --recursive is used + pushd crates/node/npm/wasm32-wasi; pnpm publish --tag ${{ env.RELEASE_CHANNEL }} --no-git-checks; popd; + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Trigger Tailwind Play update + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.TAILWIND_PLAY_TOKEN }} + script: | + await github.rest.actions.createWorkflowDispatch({ + owner: 'tailwindlabs', + repo: 'upgrades', + ref: 'main', + workflow_id: 'upgrade-tailwindcss.yml' + }) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2c957d3af..8b27773b3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,34 +1,18 @@ name: Release on: - push: - branches: [main] release: types: [published] workflow_dispatch: - inputs: - channel: - description: Release channel to publish - required: true - default: insiders - type: choice - options: - - insiders - - release permissions: contents: read env: APP_NAME: tailwindcss-oxide - NODE_VERSION: 24 - PNPM_VERSION: '11.9.0' + NODE_VERSION: 20 OXIDE_LOCATION: ./crates/node -concurrency: - group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} - cancel-in-progress: true - jobs: build: strategy: @@ -58,40 +42,44 @@ jobs: - os: ubuntu-latest target: x86_64-unknown-linux-gnu strip: strip - build-flags: --use-napi-cross + container: + image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian - os: ubuntu-latest target: aarch64-unknown-linux-gnu - strip: aarch64-linux-gnu-strip - build-flags: --use-napi-cross + strip: llvm-strip + container: + image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian-aarch64 - os: ubuntu-latest target: armv7-unknown-linux-gnueabihf - strip: arm-linux-gnueabihf-strip - build-flags: --use-napi-cross + strip: llvm-strip + container: + image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian-zig - os: ubuntu-latest target: aarch64-unknown-linux-musl - strip-zig: true - build-flags: -x + strip: aarch64-linux-musl-strip + download: true + container: + image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-alpine - os: ubuntu-latest target: x86_64-unknown-linux-musl strip: strip - build-flags: -x + download: true + container: + image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-alpine name: Build ${{ matrix.target }} (oxide) runs-on: ${{ matrix.os }} + container: ${{ matrix.container }} timeout-minutes: 15 steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - persist-credentials: false - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - with: - version: ${{ env.PNPM_VERSION }} + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 - name: Use Node.js ${{ env.NODE_VERSION }} - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} - package-manager-cache: false + cache: 'pnpm' - name: Install gcc-arm-linux-gnueabihf if: ${{ matrix.target == 'armv7-unknown-linux-gnueabihf' }} @@ -99,56 +87,62 @@ jobs: sudo apt-get update sudo apt-get install gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf -y - - name: Install binutils-aarch64-linux-gnu - if: ${{ matrix.target == 'aarch64-unknown-linux-gnu' }} + # Cargo already skips downloading dependencies if they already exist + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }} + + # Cache the `oxide` Rust build + - name: Cache oxide build + uses: actions/cache@v4 + with: + path: | + ./crates/node/*.node + ./crates/node/*.wasm + ./crates/node/index.d.ts + ./crates/node/index.js + ./crates/node/browser.js + ./crates/node/tailwindcss-oxide.wasi-browser.js + ./crates/node/tailwindcss-oxide.wasi.cjs + ./crates/node/wasi-worker-browser.mjs + ./crates/node/wasi-worker.mjs + key: ${{ runner.os }}-${{ matrix.target }}-oxide-${{ hashFiles('./crates/**/*') }} + + - name: Install Node.JS + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Install Rust (Stable) + if: ${{ matrix.download }} run: | - sudo apt-get update - sudo apt-get install binutils-aarch64-linux-gnu -y - - - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 - if: ${{ contains(matrix.target, 'musl') }} - with: - version: 0.14.1 - use-cache: false - - - name: Install cargo-zigbuild - uses: taiki-e/install-action@65851e10cd6c377f11a60e600abc07cb08643468 # v2 - if: ${{ contains(matrix.target, 'musl') }} - env: - GITHUB_TOKEN: ${{ github.token }} - with: - tool: cargo-zigbuild + rustup default stable - name: Setup rust target run: rustup target add ${{ matrix.target }} - name: Install dependencies - run: pnpm install --ignore-scripts --frozen-lockfile --filter=!./playgrounds/* + run: pnpm install --ignore-scripts --filter=!./playgrounds/* - name: Build release - run: pnpm run --filter ${{ env.OXIDE_LOCATION }} build:platform --target=${{ matrix.target }} ${{ matrix.build-flags }} + run: pnpm run --filter ${{ env.OXIDE_LOCATION }} build:platform --target=${{ matrix.target }} env: RUST_TARGET: ${{ matrix.target }} JEMALLOC_SYS_WITH_LG_PAGE: ${{ matrix.page-size }} - name: Strip debug symbols # https://github.com/rust-lang/rust/issues/46034 - if: ${{ matrix.strip || matrix.strip-zig }} - env: - STRIP_COMMAND: ${{ matrix.strip }} - STRIP_ZIG: ${{ matrix.strip-zig }} - run: | - if [ "$STRIP_ZIG" = "true" ]; then - for file in ${{ env.OXIDE_LOCATION }}/*.node; do - zig objcopy --strip-all "$file" "$file.stripped" - mv "$file.stripped" "$file" - done - exit 0 - fi - - eval "$STRIP_COMMAND ${{ env.OXIDE_LOCATION }}/*.node" + if: ${{ matrix.strip }} + run: ${{ matrix.strip }} ${{ env.OXIDE_LOCATION }}/*.node - name: Upload artifacts - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + uses: actions/upload-artifact@v4 with: name: bindings-${{ matrix.target }} path: ${{ env.OXIDE_LOCATION }}/*.node @@ -158,11 +152,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - persist-credentials: false + - uses: actions/checkout@v4 - name: Build FreeBSD - uses: cross-platform-actions/action@cdc9ee69ef84a5f2e59c9058335d9c57bcb4ac86 # v0.25.0 + uses: cross-platform-actions/action@v0.25.0 env: DEBUG: napi:* RUSTUP_HOME: /usr/local/rustup @@ -178,7 +170,7 @@ jobs: shell: bash run: | sudo pkg install -y -f curl node libnghttp2 npm - sudo npm install -g pnpm@${{ env.PNPM_VERSION }} --unsafe-perm=true + sudo npm install -g pnpm@9.6.0 --unsafe-perm=true curl -sSf https://static.rust-lang.org/rustup/archive/1.27.1/x86_64-unknown-freebsd/rustup-init --output rustup-init chmod +x rustup-init ./rustup-init -y --profile minimal @@ -189,12 +181,12 @@ jobs: node -v echo "~~~~ pnpm --version ~~~~" pnpm --version - pnpm install --ignore-scripts --frozen-lockfile --filter=!./playgrounds/* || true + pnpm install --ignore-scripts --filter=!./playgrounds/* || true pnpm run --filter ${{ env.OXIDE_LOCATION }} build:platform strip -x ${{ env.OXIDE_LOCATION }}/*.node ls -la ${{ env.OXIDE_LOCATION }} - name: Upload artifacts - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + uses: actions/upload-artifact@v4 with: name: bindings-x86_64-unknown-freebsd path: ${{ env.OXIDE_LOCATION }}/*.node @@ -202,10 +194,10 @@ jobs: release: runs-on: macos-14 timeout-minutes: 15 - name: Build and publish Tailwind CSS + name: Build and release Tailwind CSS permissions: - contents: read + contents: write # for softprops/action-gh-release to create GitHub release # https://docs.npmjs.com/generating-provenance-statements#publishing-packages-with-provenance-via-github-actions id-token: write @@ -214,51 +206,55 @@ jobs: - build-freebsd steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@v4 with: fetch-depth: 20 - persist-credentials: false - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - with: - version: ${{ env.PNPM_VERSION }} + - uses: pnpm/action-setup@v4 - name: Use Node.js ${{ env.NODE_VERSION }} - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} + cache: 'pnpm' registry-url: 'https://registry.npmjs.org' - package-manager-cache: false - # npm trusted publishing validates the caller workflow filename, so all npm publishes live here. - # This workflow rebuilds the publish artifacts instead of depending on prepare-release.yml. - - name: Resolve release metadata - env: - INPUT_CHANNEL: ${{ github.event.inputs.channel || '' }} - run: | - if [[ "${{ github.event_name }}" == "release" || "$INPUT_CHANNEL" == "release" ]]; then - release_channel=$(node ./scripts/release-channel.js) + # Cargo already skips downloading dependencies if they already exist + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }} - echo "RELEASE_KIND=release" >> $GITHUB_ENV - echo "RELEASE_CHANNEL=$release_channel" >> $GITHUB_ENV - echo "FEATURES_ENV=stable" >> $GITHUB_ENV - else - sha_short=$(git rev-parse --short HEAD) - - echo "RELEASE_KIND=insiders" >> $GITHUB_ENV - echo "RELEASE_CHANNEL=insiders" >> $GITHUB_ENV - echo "SHA_SHORT=$sha_short" >> $GITHUB_ENV - echo "INSIDERS_VERSION=0.0.0-insiders.$sha_short" >> $GITHUB_ENV - fi + # Cache the `oxide` Rust build + - name: Cache oxide build + uses: actions/cache@v4 + with: + path: | + ./crates/node/*.node + ./crates/node/*.wasm + ./crates/node/index.d.ts + ./crates/node/index.js + ./crates/node/browser.js + ./crates/node/tailwindcss-oxide.wasi-browser.js + ./crates/node/tailwindcss-oxide.wasi.cjs + ./crates/node/wasi-worker-browser.mjs + ./crates/node/wasi-worker.mjs + key: ${{ runner.os }}-${{ matrix.target }}-oxide-${{ hashFiles('./crates/**/*') }} - name: Setup WASM target run: rustup target add wasm32-wasip1-threads - name: Install dependencies - run: pnpm --filter=!./playgrounds/* install --frozen-lockfile + run: pnpm --filter=!./playgrounds/* install - name: Download artifacts - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + uses: actions/download-artifact@v4 with: path: ${{ env.OXIDE_LOCATION }} @@ -278,19 +274,10 @@ jobs: cp bindings-x86_64-unknown-linux-musl/* ./npm/linux-x64-musl/ cp bindings-x86_64-unknown-freebsd/* ./npm/freebsd-x64/ - - name: 'Version based on commit: ${{ env.INSIDERS_VERSION }}' - if: env.RELEASE_KIND == 'insiders' - run: pnpm run version-packages ${INSIDERS_VERSION} - - name: Build Tailwind CSS - if: env.RELEASE_KIND == 'insiders' - run: pnpm run build - - - name: Build Tailwind CSS - if: env.RELEASE_KIND == 'release' run: pnpm run build env: - FEATURES_ENV: ${{ env.FEATURES_ENV }} + FEATURES_ENV: stable - name: Run pre-publish optimizations scripts run: node ./scripts/pre-publish-optimizations.mjs @@ -298,20 +285,22 @@ jobs: - name: Lock pre-release versions run: node ./scripts/lock-pre-release-versions.mjs - - name: Upload npm package tarballs - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 - with: - name: npm-package-tarballs - path: dist/*.tgz + - name: Calculate environment variables + run: | + echo "RELEASE_CHANNEL=$(node ./scripts/release-channel.js)" >> $GITHUB_ENV + echo "TAILWINDCSS_VERSION=$(node -e 'console.log(require(`./packages/tailwindcss/package.json`).version);')" >> $GITHUB_ENV - name: Publish run: | - pnpm --recursive --filter="!@tailwindcss/oxide-wasm32-wasi" publish --tag ${RELEASE_CHANNEL} --no-git-checks + pnpm --recursive --filter="!@tailwindcss/oxide-wasm32-wasi" publish --tag ${{ env.RELEASE_CHANNEL }} --no-git-checks # The wasm package needs a special npm config that isn't read when pnpm --recursive is used - pushd crates/node/npm/wasm32-wasi; pnpm publish --tag ${RELEASE_CHANNEL} --no-git-checks --config.node-linker=hoisted; popd; + pushd crates/node/npm/wasm32-wasi; pnpm publish --tag ${{ env.RELEASE_CHANNEL }} --no-git-checks; popd; + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Trigger Tailwind Play update - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + if: env.RELEASE_CHANNEL == 'latest' + uses: actions/github-script@v7 with: github-token: ${{ secrets.TAILWIND_PLAY_TOKEN }} script: | @@ -321,38 +310,3 @@ jobs: ref: 'main', workflow_id: 'upgrade-tailwindcss.yml' }) - - notify: - if: ${{ always() && (needs.build.result == 'failure' || needs.build-freebsd.result == 'failure' || needs.release.result == 'failure') }} - needs: - - build - - build-freebsd - - release - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - persist-credentials: false - - - name: Resolve release label - id: release - env: - INPUT_CHANNEL: ${{ github.event.inputs.channel || '' }} - RELEASE_TAG: ${{ github.event.release.tag_name || '' }} - run: | - if [[ "${{ github.event_name }}" == "release" ]]; then - tag_name="${RELEASE_TAG:-${GITHUB_REF_NAME}}" - echo "label=release ${tag_name}" >> $GITHUB_OUTPUT - elif [[ "$INPUT_CHANNEL" == "release" ]]; then - version=$(node -p "require('./packages/tailwindcss/package.json').version") - echo "label=release v${version}" >> $GITHUB_OUTPUT - else - sha_short=$(git rev-parse --short HEAD) - echo "label=insiders release 0.0.0-insiders.${sha_short}" >> $GITHUB_OUTPUT - fi - - - name: Notify Discord - uses: discord-actions/message@5c7149c81a83146e5d01f142be1bf87a61831c4d # v2 - with: - webhookUrl: ${{ secrets.DISCORD_WEBHOOK_URL }} - message: 'The [most recent ${{ github.workflow }} workflow](<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}>) for `${{ steps.release.outputs.label }}` has failed.' diff --git a/.npmrc b/.npmrc new file mode 100644 index 000000000..ded82e2f6 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +auto-install-peers = true diff --git a/.prettierignore b/.prettierignore index 4f50b932f..3de4530dd 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,6 +4,5 @@ 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 e5e6f6f89..eee51b99a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,343 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### 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)) +- Nothing yet! ## [4.1.12] - 2025-08-13 @@ -4123,24 +3787,7 @@ No release notes - Everything! -[unreleased]: https://github.com/tailwindlabs/tailwindcss/compare/v4.3.3...HEAD -[4.3.3]: https://github.com/tailwindlabs/tailwindcss/compare/v4.3.2...v4.3.3 -[4.3.2]: https://github.com/tailwindlabs/tailwindcss/compare/v4.3.1...v4.3.2 -[4.3.1]: https://github.com/tailwindlabs/tailwindcss/compare/v4.3.0...v4.3.1 -[4.3.0]: https://github.com/tailwindlabs/tailwindcss/compare/v4.2.4...v4.3.0 -[4.2.4]: https://github.com/tailwindlabs/tailwindcss/compare/v4.2.3...v4.2.4 -[4.2.3]: https://github.com/tailwindlabs/tailwindcss/compare/v4.2.2...v4.2.3 -[4.2.2]: https://github.com/tailwindlabs/tailwindcss/compare/v4.2.1...v4.2.2 -[4.2.1]: https://github.com/tailwindlabs/tailwindcss/compare/v4.2.0...v4.2.1 -[4.2.0]: https://github.com/tailwindlabs/tailwindcss/compare/v4.1.18...v4.2.0 -[4.1.18]: https://github.com/tailwindlabs/tailwindcss/compare/v4.1.17...v4.1.18 -[3.4.19]: https://github.com/tailwindlabs/tailwindcss/compare/v3.4.18...v3.4.19 -[4.1.17]: https://github.com/tailwindlabs/tailwindcss/compare/v4.1.16...v4.1.17 -[4.1.16]: https://github.com/tailwindlabs/tailwindcss/compare/v4.1.15...v4.1.16 -[4.1.15]: https://github.com/tailwindlabs/tailwindcss/compare/v4.1.14...v4.1.15 -[4.1.14]: https://github.com/tailwindlabs/tailwindcss/compare/v4.1.13...v4.1.14 -[3.4.18]: https://github.com/tailwindlabs/tailwindcss/compare/v3.4.17...v3.4.18 -[4.1.13]: https://github.com/tailwindlabs/tailwindcss/compare/v4.1.12...v4.1.13 +[unreleased]: https://github.com/tailwindlabs/tailwindcss/compare/v4.1.12...HEAD [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 73fd9e995..694700fcb 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.18", + "regex-automata 0.4.8", "serde", ] @@ -59,31 +59,33 @@ dependencies = [ "syn", ] -[[package]] -name = "console" -version = "0.16.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" -dependencies = [ - "encode_unicode", - "libc", - "windows-sys 0.61.2", -] - [[package]] name = "convert_case" -version = "0.11.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" dependencies = [ "unicode-segmentation", ] [[package]] -name = "crossbeam-channel" -version = "0.5.15" +name = "crossbeam" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33480d6946193aa8033910124896ca395333cae7e2d1113d1fef6c3272217df2" dependencies = [ "crossbeam-utils", ] @@ -107,6 +109,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-queue" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df0346b5d5e76ac2fe4e327c5fd1118d6be7c51dfb18f9b7922923f287471e35" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.20" @@ -115,9 +126,13 @@ checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" [[package]] name = "ctor" -version = "1.0.12" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d83cb7e7a873830708d6b02a78cd36a592c6fa14bf267b68725103b85c0d77f" +checksum = "990a40740adf249724a6000c0fc4bd574712f50bb17c2d6f6cec837ae2f0ee75" +dependencies = [ + "quote", + "syn", +] [[package]] name = "diff" @@ -137,12 +152,6 @@ version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91" -[[package]] -name = "encode_unicode" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" - [[package]] name = "errno" version = "0.3.9" @@ -168,104 +177,16 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6" -[[package]] -name = "futures" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" -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" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" +checksum = "54a1028dfc5f5df5da8a56a73e6c153c9a9708ec57232470703592a3f18e49f5" dependencies = [ "aho-corasick", "bstr", "log", - "regex-automata 0.4.18", + "regex-automata 0.4.8", "regex-syntax 0.8.5", ] @@ -276,10 +197,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" dependencies = [ "bitflags", - "ignore 0.4.23", + "ignore 0.4.23 (registry+https://github.com/rust-lang/crates.io-index)", "walkdir", ] +[[package]] +name = "ignore" +version = "0.4.23" +dependencies = [ + "bstr", + "crossbeam-channel", + "crossbeam-deque", + "dunce", + "globset", + "log", + "memchr", + "regex-automata 0.4.8", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "ignore" version = "0.4.23" @@ -290,41 +228,12 @@ dependencies = [ "globset", "log", "memchr", - "regex-automata 0.4.18", + "regex-automata 0.4.8", "same-file", "walkdir", "winapi-util", ] -[[package]] -name = "ignore" -version = "0.4.33" -dependencies = [ - "bstr", - "crossbeam-channel", - "crossbeam-deque", - "dunce", - "globset", - "log", - "memchr", - "regex-automata 0.4.18", - "same-file", - "walkdir", - "winapi-util", -] - -[[package]] -name = "insta" -version = "1.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" -dependencies = [ - "console", - "once_cell", - "similar", - "tempfile", -] - [[package]] name = "itertools" version = "0.11.0" @@ -348,12 +257,12 @@ checksum = "561d97a539a36e26a9a5fad1ea11a3039a67714694aaa379433e580854bc3dc5" [[package]] name = "libloading" -version = "0.9.0" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +checksum = "4979f22fdb869068da03c9f7528f8297c6fd2606bc3a4affe42e6a823fdb8da4" dependencies = [ "cfg-if", - "windows-link", + "windows-targets", ] [[package]] @@ -391,33 +300,31 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "napi" -version = "3.11.0" +version = "2.16.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de33522036981030a75c231829566bc63414e08101a6f5ff4ac6cef19c8e0941" +checksum = "55740c4ae1d8696773c78fdafd5d0e5fe9bc9f1b071c7ba493ba5c413a9184f3" dependencies = [ "bitflags", "ctor", - "futures", - "napi-build", + "napi-derive", "napi-sys", - "nohash-hasher", - "rustc-hash", + "once_cell", ] [[package]] name = "napi-build" -version = "2.3.2" +version = "2.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9c366d2c8c60b86fa632df75f745509b52f9128f91a6bad4c796e44abb505e1" +checksum = "e28acfa557c083f6e254a786e01ba253fc56f18ee000afcd4f79af735f73a6da" [[package]] name = "napi-derive" -version = "3.6.0" +version = "2.16.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a49c513341a61a16a10af6efcce46b30d0822ba2d4fb197d24d33dfc199c78d5" +checksum = "7cbe2585d8ac223f7d34f13701434b9d5f4eb9c332cccce8dee57ea18ab8ab0c" dependencies = [ + "cfg-if", "convert_case", - "ctor", "napi-derive-backend", "proc-macro2", "quote", @@ -426,32 +333,28 @@ dependencies = [ [[package]] name = "napi-derive-backend" -version = "6.1.1" +version = "1.0.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d60b5d773ad46c698c8cc2cd9fde0b283d39cbb7f71c04bee633c7bdba4423bd" +checksum = "1639aaa9eeb76e91c6ae66da8ce3e89e921cd3885e99ec85f4abacae72fc91bf" dependencies = [ "convert_case", + "once_cell", "proc-macro2", "quote", + "regex", "semver", "syn", ] [[package]] name = "napi-sys" -version = "3.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85fbf1fa9f1babfe396d74bbbf52b3643770243e8f5b0b46715d4caf7f0dfc9a" +checksum = "427802e8ec3a734331fec1035594a210ce1ff4dc5bc1950530920ab717964ea3" 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" @@ -474,9 +377,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.4" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" [[package]] name = "overload" @@ -511,18 +414,18 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.45" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "1b9ab9c7eadfd8df19006f1cf1a4aed13540ed5cbc047010ece5826e10825488" dependencies = [ "proc-macro2", ] [[package]] name = "rayon" -version = "1.12.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" dependencies = [ "either", "rayon-core", @@ -530,9 +433,9 @@ dependencies = [ [[package]] name = "rayon-core" -version = "1.13.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" dependencies = [ "crossbeam-deque", "crossbeam-utils", @@ -546,7 +449,7 @@ checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.18", + "regex-automata 0.4.8", "regex-syntax 0.8.5", ] @@ -561,9 +464,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.18" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" dependencies = [ "aho-corasick", "memchr", @@ -631,18 +534,6 @@ dependencies = [ "lazy_static", ] -[[package]] -name = "similar" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - [[package]] name = "smallvec" version = "1.10.0" @@ -651,9 +542,9 @@ checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0" [[package]] name = "syn" -version = "2.0.87" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25aa4ce346d03a6dcd68dd8b4010bcb74e54e62c90c573f394c46eae99aba32d" +checksum = "32d41677bcbe24c20c52e7c70b0d8db04134c5d1066bf98662e2871ad200ea3e" dependencies = [ "proc-macro2", "quote", @@ -678,11 +569,11 @@ dependencies = [ "bexpand", "bstr", "classification-macros", + "crossbeam", "dunce", "fast-glob", "globwalk", - "ignore 0.4.33", - "insta", + "ignore 0.4.23", "log", "pretty_assertions", "rayon", @@ -844,12 +735,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - [[package]] name = "windows-sys" version = "0.52.0" @@ -868,15 +753,6 @@ dependencies = [ "windows-targets", ] -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - [[package]] name = "windows-targets" version = "0.52.6" diff --git a/README.md b/README.md index 5f532607d..7d21bd883 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/tailwindlabs/tailwindcss/discussions) +[Discuss Tailwind CSS on GitHub](https://github.com/tailwindcss/tailwindcss/discussions) ## Contributing -If you're interested in contributing to Tailwind CSS, please read our [contributing docs](https://github.com/tailwindlabs/tailwindcss/blob/main/.github/CONTRIBUTING.md) **before submitting a pull request**. +If you're interested in contributing to Tailwind CSS, please read our [contributing docs](https://github.com/tailwindcss/tailwindcss/blob/next/.github/CONTRIBUTING.md) **before submitting a pull request**. diff --git a/crates/ignore/Cargo.toml b/crates/ignore/Cargo.toml index d0af38ba9..b8ae1b1bf 100644 --- a/crates/ignore/Cargo.toml +++ b/crates/ignore/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ignore" -version = "0.4.33" #:version +version = "0.4.23" #:version authors = ["Andrew Gallant "] description = """ A fast library for efficiently matching ignore files such as `.gitignore` @@ -12,10 +12,7 @@ repository = "https://github.com/BurntSushi/ripgrep/tree/master/crates/ignore" readme = "README.md" keywords = ["glob", "ignore", "gitignore", "pattern", "file"] license = "Unlicense OR MIT" -# CHANGED: Use an explicit edition instead of `edition.workspace = true` since this crate is -# vendored into the Tailwind CSS workspace. -edition = "2024" -rust-version = "1.88" +edition = "2021" [lib] name = "ignore" @@ -23,17 +20,15 @@ bench = false [dependencies] crossbeam-deque = "0.8.3" -# CHANGED: Use the published globset crate instead of a path dependency. -globset = "0.4.20" +globset = "0.4.16" log = "0.4.20" memchr = "2.6.3" same-file = "1.0.6" walkdir = "2.4.0" -# CHANGED: Added `dunce` to canonicalize paths without UNC prefixes on Windows. dunce = "1.0.5" [dependencies.regex-automata] -version = "0.4.18" +version = "0.4.0" default-features = false features = ["std", "perf", "syntax", "meta", "nfa", "hybrid", "dfa-onepass"] @@ -42,7 +37,7 @@ version = "0.1.2" [dev-dependencies] bstr = { version = "1.6.2", default-features = false, features = ["std"] } -crossbeam-channel = "0.5.15" +crossbeam-channel = "0.5.8" [features] # DEPRECATED. It is a no-op. SIMD is done automatically through runtime diff --git a/crates/ignore/README.md b/crates/ignore/README.md index 72258e6b5..a4c34e505 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,7 +29,6 @@ recursively traverse the current directory while automatically filtering out files and directories according to ignore globs found in files like `.ignore` and `.gitignore`: - ```rust,no_run use ignore::Walk; diff --git a/crates/ignore/examples/walk.rs b/crates/ignore/examples/walk.rs index c61d0515e..5bbd10f2b 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_all(&Vec::from_path_lossy(dent.path())).unwrap(); - stdout.write_all(b"\n").unwrap(); + stdout.write(&*Vec::from_path_lossy(dent.path())).unwrap(); + stdout.write(b"\n").unwrap(); } }); diff --git a/crates/ignore/src/default_types.rs b/crates/ignore/src/default_types.rs index 6b5bba0f0..2cf8ad807 100644 --- a/crates/ignore/src/default_types.rs +++ b/crates/ignore/src/default_types.rs @@ -27,10 +27,9 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[ (&["bat", "batch"], &["*.bat"]), (&["bazel"], &[ "*.bazel", "*.bzl", "*.BUILD", "*.bazelrc", "BUILD", "MODULE.bazel", - "WORKSPACE", "WORKSPACE.bazel", "WORKSPACE.bzlmod", + "WORKSPACE", "WORKSPACE.bazel", ]), (&["bitbake"], &["*.bb", "*.bbappend", "*.bbclass", "*.conf", "*.inc"]), - (&["boxlang"], &["*.bx", "*.bxm", "*.bxs"]), (&["brotli"], &["*.br"]), (&["buildstream"], &["*.bst"]), (&["bzip2"], &["*.bz2", "*.tbz2"]), @@ -40,14 +39,12 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[ (&["carp"], &["*.carp"]), (&["cbor"], &["*.cbor"]), (&["ceylon"], &["*.ceylon"]), - (&["cfml"], &["*.cfc", "*.cfm"]), (&["clojure"], &["*.clj", "*.cljc", "*.cljs", "*.cljx"]), (&["cmake"], &["*.cmake", "CMakeLists.txt"]), (&["cmd"], &["*.bat", "*.cmd"]), (&["cml"], &["*.cml"]), (&["coffeescript"], &["*.coffee"]), (&["config"], &["*.cfg", "*.conf", "*.config", "*.ini"]), - (&["container"], &["*Containerfile*", "*Dockerfile*"]), (&["coq"], &["*.v"]), (&["cpp"], &[ "*.[ChH]", "*.cc", "*.[ch]pp", "*.[ch]xx", "*.hh", "*.inl", @@ -65,7 +62,7 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[ (&["cython"], &["*.pyx", "*.pxi", "*.pxd"]), (&["d"], &["*.d"]), (&["dart"], &["*.dart"]), - (&["devicetree"], &["*.dts", "*.dtsi", "*.dtso"]), + (&["devicetree"], &["*.dts", "*.dtsi"]), (&["dhall"], &["*.dhall"]), (&["diff"], &["*.patch", "*.diff"]), (&["dita"], &["*.dita", "*.ditamap", "*.ditaval"]), @@ -91,8 +88,6 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[ (&["fsharp"], &["*.fs", "*.fsx", "*.fsi"]), (&["fut"], &["*.fut"]), (&["gap"], &["*.g", "*.gap", "*.gi", "*.gd", "*.tst"]), - (&["gdscript"], &["*.gd"]), - (&["gleam"], &["*.gleam"]), (&["gn"], &["*.gn", "*.gni"]), (&["go"], &["*.go"]), (&["gprbuild"], &["*.gpr"]), @@ -110,7 +105,6 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[ (&["hbs"], &["*.hbs"]), (&["hs"], &["*.hs", "*.lhs"]), (&["html"], &["*.htm", "*.html", "*.ejs"]), - (&["hurl"], &["*.hurl"]), (&["hy"], &["*.hy"]), (&["idris"], &["*.idr", "*.lidr"]), (&["janet"], &["*.janet"]), @@ -123,7 +117,6 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[ (&["julia"], &["*.jl"]), (&["jupyter"], &["*.ipynb", "*.jpynb"]), (&["k"], &["*.k"]), - (&["kconfig"], &["Kconfig", "Kconfig.*"]), (&["kotlin"], &["*.kt", "*.kts"]), (&["lean"], &["*.lean"]), (&["less"], &["*.less"]), @@ -156,7 +149,6 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[ ]), (&["lilypond"], &["*.ly", "*.ily"]), (&["lisp"], &["*.el", "*.jl", "*.lisp", "*.lsp", "*.sc", "*.scm"]), - (&["llvm"], &["*.ll"]), (&["lock"], &["*.lock", "package-lock.json"]), (&["log"], &["*.log"]), (&["lua"], &["*.lua"]), @@ -167,7 +159,6 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[ "[Gg][Nn][Uu]makefile", "[Mm]akefile", "[Gg][Nn][Uu]makefile.am", "[Mm]akefile.am", "[Gg][Nn][Uu]makefile.in", "[Mm]akefile.in", - "Makefile.*", "*.mk", "*.mak" ]), (&["mako"], &["*.mako", "*.mao"]), @@ -187,11 +178,10 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[ (&["mint"], &["*.mint"]), (&["mk"], &["mkfile"]), (&["ml"], &["*.ml"]), - (&["mojo"], &["*.mojo"]), (&["motoko"], &["*.mo"]), (&["msbuild"], &[ "*.csproj", "*.fsproj", "*.vcxproj", "*.proj", "*.props", "*.targets", - "*.sln", "*.slnf" + "*.sln", ]), (&["nim"], &["*.nim", "*.nimf", "*.nimble", "*.nims"]), (&["nix"], &["*.nix"]), @@ -209,21 +199,18 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[ "*.php", "*.php3", "*.php4", "*.php5", "*.php7", "*.php8", "*.pht", "*.phtml" ]), - (&["pkgbuild"], &["PKGBUILD"]), (&["po"], &["*.po"]), (&["pod"], &["*.pod"]), (&["postscript"], &["*.eps", "*.ps"]), (&["prolog"], &["*.pl", "*.pro", "*.prolog", "*.P"]), - (&["proto", "protobuf"], &["*.proto"]), + (&["protobuf"], &["*.proto"]), (&["ps"], &["*.cdxml", "*.ps1", "*.ps1xml", "*.psd1", "*.psm1"]), (&["puppet"], &["*.epp", "*.erb", "*.pp", "*.rb"]), (&["purs"], &["*.purs"]), (&["py", "python"], &["*.py", "*.pyi"]), (&["qmake"], &["*.pro", "*.pri", "*.prf"]), (&["qml"], &["*.qml"]), - (&["qrc"], &["*.qrc"]), - (&["qui"], &["*.ui"]), - (&["r"], &["*.R", "*.r", "*.Rmd", "*.rmd", "*.Rnw", "*.rnw"]), + (&["r"], &["*.R", "*.r", "*.Rmd", "*.Rnw"]), (&["racket"], &["*.rkt"]), (&["raku"], &[ "*.raku", "*.rakumod", "*.rakudoc", "*.rakutest", @@ -235,22 +222,19 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[ (&["red"], &["*.r", "*.red", "*.reds"]), (&["rescript"], &["*.res", "*.resi"]), (&["robot"], &["*.robot"]), - (&["rocq"], &["*.v"]), (&["rst"], &["*.rst"]), (&["ruby"], &[ // Idiomatic files "config.ru", "Gemfile", ".irbrc", "Rakefile", // Extensions - "*.gemspec", "*.rb", "*.rbw", "*.rake" + "*.gemspec", "*.rb", "*.rbw" ]), (&["rust"], &["*.rs"]), (&["sass"], &["*.sass", "*.scss"]), (&["scala"], &["*.scala", "*.sbt"]), - (&["scdoc"], &["*.scd", "*.scdoc"]), - (&["seed7"], &["*.sd7", "*.s7i"]), (&["sh"], &[ // Portable/misc. init files - ".env", ".login", ".logout", ".profile", "profile", + ".login", ".logout", ".profile", "profile", // bash-specific init files ".bash_login", "bash_login", ".bash_logout", "bash_logout", @@ -269,7 +253,7 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[ ".zprofile", "zprofile", ".zshrc", "zshrc", // Extensions - "*.bash", "*.csh", "*.env", "*.ksh", "*.sh", "*.tcsh", "*.zsh", + "*.bash", "*.csh", "*.ksh", "*.sh", "*.tcsh", "*.zsh", ]), (&["slim"], &["*.skim", "*.slim", "*.slime"]), (&["smarty"], &["*.tpl"]), @@ -279,10 +263,9 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[ (&["spark"], &["*.spark"]), (&["spec"], &["*.spec"]), (&["sql"], &["*.sql", "*.psql"]), - (&["ssa"], &["*.ssa"]), (&["stylus"], &["*.styl"]), (&["sv"], &["*.v", "*.vg", "*.sv", "*.svh", "*.h"]), - (&["svelte"], &["*.svelte", "*.svelte.ts"]), + (&["svelte"], &["*.svelte"]), (&["svg"], &["*.svg"]), (&["swift"], &["*.swift"]), (&["swig"], &["*.def", "*.i"]), @@ -297,8 +280,9 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[ (&["texinfo"], &["*.texi"]), (&["textile"], &["*.textile"]), (&["tf"], &[ - "*.tf", "*.tf.json", "*.tfvars", "*.tfvars.json", - "*.terraformrc", "terraform.rc", "*.tfrc", "*.terraform.lock.hcl", + "*.tf", "*.auto.tfvars", "terraform.tfvars", "*.tf.json", + "*.auto.tfvars.json", "terraform.tfvars.json", "*.terraformrc", + "terraform.rc", "*.tfrc", "*.terraform.lock.hcl", ]), (&["thrift"], &["*.thrift"]), (&["toml"], &["*.toml", "Cargo.lock"]), @@ -306,7 +290,6 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[ (&["twig"], &["*.twig"]), (&["txt"], &["*.txt"]), (&["typoscript"], &["*.typoscript", "*.ts"]), - (&["typst"], &["*.typ"]), (&["usd"], &["*.usd", "*.usda", "*.usdc"]), (&["v"], &["*.v", "*.vsh"]), (&["vala"], &["*.vala"]), @@ -365,14 +348,4 @@ mod tests { previous_name = name; } } - - #[test] - fn default_types_aliases_are_sorted() { - for (aliases, _) in DEFAULT_TYPES.iter() { - assert!( - aliases.is_sorted(), - "this alias list is not sorted: {aliases:?}", - ); - } - } } diff --git a/crates/ignore/src/dir.rs b/crates/ignore/src/dir.rs index 6bee724c8..9bbf1442b 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::{self, File, FileType}, + fs::{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_entry, strip_prefix}, + pathutil::{is_hidden, strip_prefix}, types::{self, Types}, walk::DirEntry, {Error, Match, PartialErrorBuilder}, @@ -91,25 +91,7 @@ struct IgnoreOptions { /// Ignore is a matcher useful for recursively walking one or more directories. #[derive(Clone, Debug)] -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>, -} +pub(crate) struct Ignore(Arc); #[derive(Clone, Debug)] struct IgnoreInner { @@ -130,21 +112,12 @@ 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 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, + /// The absolute base path of this matcher. Populated only if parent + /// directories are added. + absolute_base: Option>, /// Explicit global ignore matchers specified by the caller. explicit_ignores: Arc>, /// Ignore files used in addition to `.ignore` @@ -167,36 +140,34 @@ struct IgnoreInner { impl Ignore { /// Return the directory path of this matcher. - #[cfg(test)] pub(crate) fn path(&self) -> &Path { - &self.inner.dir + &self.0.dir } /// Return true if this matcher has no parent. pub(crate) fn is_root(&self) -> bool { - self.inner.parent.is_none() + 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 } /// Return this matcher's parent, if one exists. pub(crate) fn parent(&self) -> Option { - self.inner.parent.as_ref().map(|parent| Ignore { - inner: parent.clone(), - absolute_base: self.absolute_base.clone(), - }) + self.0.parent.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.inner.opts.parents - && !self.inner.opts.git_ignore - && !self.inner.opts.git_exclude - && !self.inner.opts.git_global + 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 { // If we never need info from parent directories, then don't do // anything. @@ -226,34 +197,25 @@ impl Ignore { let mut errs = PartialErrorBuilder::default(); let mut ig = self.clone(); for parent in parents.into_iter().rev() { - let mut compiled = self.inner.compiled.write().unwrap(); + let mut compiled = self.0.compiled.write().unwrap(); if let Some(weak) = compiled.get(parent.as_os_str()) { if let Some(prebuilt) = weak.upgrade() { - ig = Ignore { - inner: prebuilt, - absolute_base: Some(absolute_base.clone()), - }; + ig = Ignore(prebuilt); continue; } } let (mut igtmp, err) = ig.add_child_path(parent); errs.maybe_push(err); igtmp.is_absolute_parent = true; - 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 { - inner: ig_arc.clone(), - absolute_base: Some(absolute_base.clone()), + 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 }; - compiled.insert( - parent.as_os_str().to_os_string(), - Arc::downgrade(&ig_arc), - ); + 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, errs.into_error_option()) } @@ -266,161 +228,59 @@ 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 { - 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, - ) + (Ignore(Arc::new(ig)), err) } /// Like add_child, but takes a full path and returns an IgnoreInner. fn add_child_path(&self, dir: &Path) -> (IgnoreInner, Option) { - 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 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); let mut errs = PartialErrorBuilder::default(); - let custom_ig_matcher = if self - .inner - .custom_ignore_filenames - .is_empty() - { + let custom_ig_matcher = if self.0.custom_ignore_filenames.is_empty() { Gitignore::empty() } else { - 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 (m, err) = create_gitignore( + &dir, + &dir, + &self.0.custom_ignore_filenames, + self.0.opts.ignore_case_insensitive, + ); + errs.maybe_push(err); + m }; - let ig_matcher = if !self.inner.opts.ignore - || !ignore_files_list.is_none_or(|i| i.has_ignore) - { + let ig_matcher = if !self.0.opts.ignore { Gitignore::empty() } else { let (m, err) = create_gitignore( &dir, &dir, &[".ignore"], - self.inner.opts.ignore_case_insensitive, + self.0.opts.ignore_case_insensitive, ); errs.maybe_push(err); m }; - let gi_matcher = if !self.inner.opts.git_ignore - || !ignore_files_list.is_none_or(|i| i.has_git_ignore) - { + let gi_matcher = if !self.0.opts.git_ignore { Gitignore::empty() } else { let (m, err) = create_gitignore( &dir, &dir, &[".gitignore"], - self.inner.opts.ignore_case_insensitive, + self.0.opts.ignore_case_insensitive, ); errs.maybe_push(err); m }; - - let gi_exclude_matcher = if !self.inner.opts.git_exclude - || !ignore_files_list.is_none_or(|i| i.has_git_dir) - { + let gi_exclude_matcher = if !self.0.opts.git_exclude { Gitignore::empty() } else { match resolve_git_commondir(dir, git_type) { @@ -429,7 +289,7 @@ impl Ignore { &dir, &git_dir, &["info/exclude"], - self.inner.opts.ignore_case_insensitive, + self.0.opts.ignore_case_insensitive, ); errs.maybe_push(err); m @@ -441,38 +301,31 @@ impl Ignore { } }; let ig = IgnoreInner { - compiled: self.inner.compiled.clone(), + compiled: self.0.compiled.clone(), dir: dir.to_path_buf(), - overrides: self.inner.overrides.clone(), - types: self.inner.types.clone(), - parent: Some(self.inner.clone()), + overrides: self.0.overrides.clone(), + types: self.0.types.clone(), + parent: Some(self.clone()), is_absolute_parent: false, - 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(), + absolute_base: self.0.absolute_base.clone(), + explicit_ignores: self.0.explicit_ignores.clone(), + custom_ignore_filenames: self.0.custom_ignore_filenames.clone(), custom_ignore_matcher: custom_ig_matcher, ignore_matcher: ig_matcher, - git_global_matcher: self.inner.git_global_matcher.clone(), + git_global_matcher: self.0.git_global_matcher.clone(), git_ignore_matcher: gi_matcher, git_exclude_matcher: gi_exclude_matcher, has_git, - opts: self.inner.opts, + opts: self.0.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.inner.opts; - let has_custom_ignore_files = - !self.inner.custom_ignore_filenames.is_empty(); - let has_explicit_ignores = !self.inner.explicit_ignores.is_empty(); + 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(); opts.ignore || opts.git_global @@ -483,12 +336,9 @@ 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.inner.opts.hidden && is_hidden_entry(dent) { + if m.is_none() && self.0.opts.hidden && is_hidden(dent) { return Match::Ignore(IgnoreMatch::hidden()); } m @@ -498,11 +348,7 @@ impl Ignore { /// ignored or not. /// /// The match contains information about its origin. - pub(crate) fn matched<'a, P: AsRef>( - &'a self, - path: P, - is_dir: bool, - ) -> Match> { + 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(); @@ -513,9 +359,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.inner.overrides.is_empty() { + if !self.0.overrides.is_empty() { let mat = self - .inner + .0 .overrides .matched(path, is_dir) .map(IgnoreMatch::overrides); @@ -532,9 +378,8 @@ impl Ignore { whitelisted = mat; } } - if !self.inner.types.is_empty() { - let mat = - self.inner.types.matched(path, is_dir).map(IgnoreMatch::types); + if !self.0.types.is_empty() { + let mat = self.0.types.matched(path, is_dir).map(IgnoreMatch::types); if mat.is_ignore() { return mat; } else if mat.is_whitelist() { @@ -546,143 +391,96 @@ impl Ignore { /// Performs matching only on the ignore files for this directory and /// all parent directories. - 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); + 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); let mut saw_git = false; - for ig in self.parents().take_while(|ig| !ig.inner.is_absolute_parent) - { + for ig in self.parents().take_while(|ig| !ig.0.is_absolute_parent) { if m_custom_ignore.is_none() { - m_custom_ignore = ig - .inner - .custom_ignore_matcher - .matched(path, is_dir) - .map(IgnoreMatch::gitignore); + m_custom_ignore = + ig.0.custom_ignore_matcher + .matched(path, is_dir) + .map(IgnoreMatch::gitignore); } if m_ignore.is_none() { - m_ignore = ig - .inner - .ignore_matcher - .matched(path, is_dir) - .map(IgnoreMatch::gitignore); + m_ignore = + ig.0.ignore_matcher + .matched(path, is_dir) + .map(IgnoreMatch::gitignore); } if any_git && !saw_git && m_gi.is_none() { - m_gi = ig - .inner - .git_ignore_matcher - .matched(path, is_dir) - .map(IgnoreMatch::gitignore); + m_gi = + ig.0.git_ignore_matcher + .matched(path, is_dir) + .map(IgnoreMatch::gitignore); } 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); + m_gi_exclude = + ig.0.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; } - 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 + 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 .matched(&path, is_dir) .map(IgnoreMatch::gitignore); - } - 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 - .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 - .inner - .git_exclude_matcher - .matched(&path, is_dir) - .map(IgnoreMatch::gitignore); - } - saw_git = saw_git || ig.inner.has_git; } + if m_ignore.is_none() { + m_ignore = + ig.0.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); + } + 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); + } + saw_git = saw_git || ig.0.has_git; } } - 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 + 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 + // 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. // - // 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; } + if !m_explicit.is_none() { + break; + } m_explicit = gi.matched(&path, is_dir).map(IgnoreMatch::gitignore); } let m_global = if any_git { - self.inner + self.0 .git_global_matcher .matched(&path, is_dir) .map(IgnoreMatch::gitignore) @@ -690,90 +488,53 @@ impl Ignore { Match::None }; - // 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. + // 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. 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() { - return check; + if check.is_none() { + continue; } + + return check.clone(); } - Match::None + + m_explicit } /// Returns an iterator over parent ignore matchers, including this one. pub(crate) fn parents(&self) -> Parents<'_> { - 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 + Parents(Some(self)) } } /// An iterator over all parents of an ignore matcher, including itself. -pub(crate) struct Parents<'a>(Option>); +/// +/// The lifetime `'a` refers to the lifetime of the initial `Ignore` matcher. +pub(crate) struct Parents<'a>(Option<&'a Ignore>); impl<'a> Iterator for Parents<'a> { - type Item = IgnoreRef<'a>; + type Item = &'a Ignore; - fn next(&mut self) -> Option> { + fn next(&mut self) -> Option<&'a Ignore> { match self.0.take() { None => None, Some(ig) => { - self.0 = ig - .inner - .parent - .as_deref() - .map(|inner| IgnoreRef { inner }); + self.0 = ig.0.parent.as_ref(); Some(ig) } } @@ -793,16 +554,6 @@ 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, } @@ -810,9 +561,8 @@ pub(crate) struct IgnoreBuilder { impl IgnoreBuilder { /// Create a new builder for an `Ignore` matcher. /// - /// 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. + /// All relative file paths are resolved with respect to the current + /// working directory. pub(crate) fn new() -> IgnoreBuilder { IgnoreBuilder { dir: Path::new("").to_path_buf(), @@ -820,7 +570,6 @@ 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, @@ -839,20 +588,10 @@ 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 if let Some(ref cwd) = global_gitignores_relative_to { - let mut builder = GitignoreBuilder::new(cwd); + } else { + let mut builder = GitignoreBuilder::new(""); builder .case_insensitive(self.opts.ignore_case_insensitive) .unwrap(); @@ -861,45 +600,26 @@ impl IgnoreBuilder { log::debug!("{}", err); } gi - } else { - log::debug!( - "ignoring global gitignore file because CWD is not known" - ); - Gitignore::empty() }; - 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, - }), + 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, absolute_base: None, - } - } - - /// 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 + 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, + })) } /// Add an override matcher. @@ -907,10 +627,7 @@ 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 } @@ -941,7 +658,8 @@ 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 } @@ -953,11 +671,6 @@ 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 @@ -1028,10 +741,7 @@ 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 } @@ -1090,10 +800,7 @@ 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()) { @@ -1136,22 +843,11 @@ fn resolve_git_commondir( Ok(commondir_abs) } -/// Strips `prefix` from `path` if it's a prefix, otherwise returns `path` -/// unchanged. -fn strip_if_is_prefix<'a, P: AsRef + ?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, sync::Arc}; + use std::{io::Write, path::Path}; - use crate::{ - Error, dir::IgnoreBuilder, gitignore::Gitignore, tests::TempDir, - }; + use crate::{dir::IgnoreBuilder, gitignore::Gitignore, tests::TempDir, Error}; fn wfile>(path: P, contents: &str) { let mut file = std::fs::File::create(path).unwrap(); @@ -1180,11 +876,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()); @@ -1217,19 +913,6 @@ 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(); @@ -1455,152 +1138,15 @@ mod tests { let (ig2, err) = ig1.add_child("src"); assert!(err.is_none()); - assert!(ig1.matched("llvm", true).is_none()); - assert!(ig2.matched("llvm", true).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!(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(); @@ -1608,14 +1154,16 @@ 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 8824139b5..7da86153c 100644 --- a/crates/ignore/src/gitignore.rs +++ b/crates/ignore/src/gitignore.rs @@ -20,8 +20,8 @@ use { }; use crate::{ - Error, Match, PartialErrorBuilder, pathutil::{is_file_name, strip_prefix}, + Error, Match, PartialErrorBuilder, }; /// Glob represents a single glob in a gitignore file. @@ -102,9 +102,7 @@ impl Gitignore { /// /// Note that I/O errors are ignored. For more granular control over /// errors, use `GitignoreBuilder`. - pub fn new>( - 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); @@ -125,26 +123,12 @@ impl Gitignore { /// The global config file path is specified by git's `core.excludesFile` /// config option. /// - /// # Behavior - /// - /// This routine does its best to discover any global git exclude files. - /// This will try to parse out the `excludesFile` config option in your - /// global git configuration, if necessary. - /// - /// The specific things this routine tries (which are subject to change - /// based on how git behaves) are: - /// - /// - /// /// Git's config file location is `$HOME/.gitconfig`. If `$HOME/.gitconfig` /// does not exist or does not specify `core.excludesFile`, then /// `$XDG_CONFIG_HOME/git/ignore` is read. If `$XDG_CONFIG_HOME` is not /// set or is empty, then `$HOME/.config/git/ignore` is used instead. pub fn global() -> (Gitignore, Option) { - match std::env::current_dir() { - Ok(cwd) => GitignoreBuilder::new(cwd).build_global(), - Err(err) => (Gitignore::empty(), Some(err.into())), - } + GitignoreBuilder::new("").build_global() } /// Creates a new empty gitignore matcher that never matches anything. @@ -158,8 +142,7 @@ impl Gitignore { num_ignores: 0, num_whitelists: 0, matches: None, - // CHANGED: Add a flag to have Gitignore rules that apply only to - // files. + // CHANGED: Add a flag to have Gitignore rules that apply only to files. only_on_files: false, } } @@ -204,11 +187,7 @@ impl Gitignore { /// determined by a common suffix of the directory containing this /// gitignore) is stripped. If there is no common suffix/prefix overlap, /// then `path` is assumed to be relative to this matcher. - pub fn matched>( - &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; } @@ -261,16 +240,11 @@ 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; } @@ -293,10 +267,7 @@ impl Gitignore { /// Strips the given path such that it's suitable for matching with this /// gitignore matcher. - fn strip<'a, P: 'a + AsRef + ?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 @@ -332,7 +303,6 @@ 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, } @@ -351,9 +321,7 @@ impl GitignoreBuilder { root: strip_prefix("./", root).unwrap_or(root).to_path_buf(), globs: vec![], case_insensitive: false, - allow_unclosed_class: true, - // CHANGED: Add a flag to have Gitignore rules that apply only to - // files. + // CHANGED: Add a flag to have Gitignore rules that apply only to files. only_on_files: false, } } @@ -364,21 +332,18 @@ impl GitignoreBuilder { pub fn build(&self) -> Result { let nignore = self.globs.iter().filter(|g| !g.is_whitelist()).count(); let nwhite = self.globs.iter().filter(|g| g.is_whitelist()).count(); - let set = self - .builder - .build() - .map_err(|err| Error::Glob { glob: None, err: err.to_string() })?; + let set = self.builder.build().map_err(|err| Error::Glob { + glob: None, + err: err.to_string(), + })?; Ok(Gitignore { set, root: self.root.clone(), globs: self.globs.clone(), num_ignores: nignore as u64, num_whitelists: nwhite as u64, - matches: Some(Arc::new( - Pool::with_available_parallelism_capacity(|| vec![]), - )), - // CHANGED: Add a flag to have Gitignore rules that apply only to - // files. + matches: Some(Arc::new(Pool::new(|| vec![]))), + // CHANGED: Add a flag to have Gitignore rules that apply only to files. only_on_files: self.only_on_files, }) } @@ -438,12 +403,6 @@ impl GitignoreBuilder { break; } }; - - // Match Git's handling of .gitignore files that begin with the Unicode BOM - const UTF8_BOM: &str = "\u{feff}"; - let line = - if i == 0 { line.trim_start_matches(UTF8_BOM) } else { &line }; - if let Err(err) = self.add_line(Some(path.to_path_buf()), &line) { errs.push(err.tagged(path, lineno)); } @@ -547,7 +506,6 @@ impl GitignoreBuilder { .literal_separator(true) .case_insensitive(self.case_insensitive) .backslash_escape(true) - .allow_unclosed_class(self.allow_unclosed_class) .build() .map_err(|err| Error::Glob { glob: Some(glob.original.clone()), @@ -564,36 +522,13 @@ impl GitignoreBuilder { /// affected. /// /// This is disabled by default. - pub fn case_insensitive( - &mut self, - yes: bool, - ) -> Result<&mut GitignoreBuilder, Error> { + pub fn case_insensitive(&mut self, yes: bool) -> Result<&mut GitignoreBuilder, Error> { // TODO: This should not return a `Result`. Fix this in the next semver // release. self.case_insensitive = yes; Ok(self) } - /// Toggle whether unclosed character classes are allowed. When allowed, - /// a `[` without a matching `]` is treated literally instead of resulting - /// in a parse error. - /// - /// For example, if this is set then the glob `[abc` will be treated as the - /// literal string `[abc` instead of returning an error. - /// - /// By default, this is true in order to match established `gitignore` - /// semantics. Generally speaking, enabling this leads to worse failure - /// modes since the glob parser becomes more permissive. You might want to - /// enable this when compatibility (e.g., with POSIX glob implementations) - /// is more important than good error messages. - pub fn allow_unclosed_class( - &mut self, - yes: bool, - ) -> &mut GitignoreBuilder { - self.allow_unclosed_class = yes; - self - } - /// CHANGED: Add a flag to have Gitignore rules that apply only to files. /// /// If this is set, then the globs will only be matched against file paths. @@ -609,56 +544,32 @@ impl GitignoreBuilder { /// /// Note that the file path returned may not exist. pub fn gitconfig_excludes_path() -> Option { - // 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; + // git supports $HOME/.gitconfig and $XDG_CONFIG_HOME/git/config. Notably, + // both can be active at the same time, where $HOME/.gitconfig takes + // precedent. So if $HOME/.gitconfig defines a `core.excludesFile`, then + // we're done. + match gitconfig_home_contents().and_then(|x| parse_excludes_file(&x)) { + Some(path) => return Some(path), + None => {} } - let mut file = BufReader::new(File::open(path).ok()?); - let mut contents = vec![]; - file.read_to_end(&mut contents).ok().map(|_| contents) -} - -/// Returns the file contents of git's system-level config file. -/// -/// Checks `GIT_CONFIG_SYSTEM` first, then falls back to `/etc/gitconfig`. -fn gitconfig_system_contents() -> Option> { - let path = std::env::var_os("GIT_CONFIG_SYSTEM") - .map(PathBuf::from) - .filter(|x| !x.as_os_str().is_empty()) - .unwrap_or_else(|| PathBuf::from("/etc/gitconfig")); - let mut file = BufReader::new(File::open(path).ok()?); - let mut contents = vec![]; - file.read_to_end(&mut contents).ok().map(|_| contents) + match gitconfig_xdg_contents().and_then(|x| parse_excludes_file(&x)) { + Some(path) => return Some(path), + None => {} + } + excludes_file_default() } /// Returns the file contents of git's global config file, if one exists, in /// the user's home directory. fn gitconfig_home_contents() -> Option> { - let home = home_dir()?; - let mut file = BufReader::new(File::open(home.join(".gitconfig")).ok()?); + let home = match home_dir() { + None => return None, + Some(home) => home, + }; + let mut file = match File::open(home.join(".gitconfig")) { + Err(_) => return None, + Ok(file) => BufReader::new(file), + }; let mut contents = vec![]; file.read_to_end(&mut contents).ok().map(|_| contents) } @@ -667,11 +578,19 @@ fn gitconfig_home_contents() -> Option> { /// the user's XDG_CONFIG_HOME directory. fn gitconfig_xdg_contents() -> Option> { let path = std::env::var_os("XDG_CONFIG_HOME") - .map(PathBuf::from) - .filter(|x| !x.as_os_str().is_empty()) + .and_then(|x| { + if x.is_empty() { + None + } else { + Some(PathBuf::from(x)) + } + }) .or_else(|| home_dir().map(|p| p.join(".config"))) - .map(|x| x.join("git/config"))?; - let mut file = BufReader::new(File::open(path).ok()?); + .map(|x| x.join("git/config")); + let mut file = match path.and_then(|p| File::open(p).ok()) { + None => return None, + Some(file) => BufReader::new(file), + }; let mut contents = vec![]; file.read_to_end(&mut contents).ok().map(|_| contents) } @@ -681,8 +600,13 @@ fn gitconfig_xdg_contents() -> Option> { /// Specifically, this respects XDG_CONFIG_HOME. fn excludes_file_default() -> Option { std::env::var_os("XDG_CONFIG_HOME") - .map(PathBuf::from) - .filter(|x| !x.as_os_str().is_empty()) + .and_then(|x| { + if x.is_empty() { + None + } else { + Some(PathBuf::from(x)) + } + }) .or_else(|| home_dir().map(|p| p.join(".config"))) .map(|x| x.join("git/ignore")) } @@ -711,7 +635,9 @@ fn parse_excludes_file(data: &[u8]) -> Option { re.captures(data, &mut caps); let span = caps.get_group(1)?; let candidate = &data[span]; - std::str::from_utf8(candidate).ok().map(|s| PathBuf::from(expand_tilde(s))) + std::str::from_utf8(candidate) + .ok() + .map(|s| PathBuf::from(expand_tilde(s))) } /// Expands ~ in file paths to the value of $HOME. @@ -873,10 +799,7 @@ mod tests { fn parse_excludes_file4() { let data = bytes("[core]\nexcludesFile = \"~/foo/bar\""); let got = super::parse_excludes_file(&data); - assert_eq!( - path_string(got.unwrap()), - super::expand_tilde("~/foo/bar") - ); + assert_eq!(path_string(got.unwrap()), super::expand_tilde("~/foo/bar")); } #[test] diff --git a/crates/ignore/src/incremental.rs b/crates/ignore/src/incremental.rs deleted file mode 100644 index 5030b0d00..000000000 --- a/crates/ignore/src/incremental.rs +++ /dev/null @@ -1,1286 +0,0 @@ -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 b9c9a3ece..cd0af7ad1 100644 --- a/crates/ignore/src/lib.rs +++ b/crates/ignore/src/lib.rs @@ -48,16 +48,13 @@ See the documentation for `WalkBuilder` for many other options. use std::path::{Path, PathBuf}; -pub use crate::incremental::{IncrementalIgnore, IncrementalMatch}; pub use crate::walk::{ - DirEntry, ParallelVisitor, ParallelVisitorBuilder, Walk, WalkBuilder, - WalkParallel, WalkState, + DirEntry, ParallelVisitor, ParallelVisitorBuilder, Walk, WalkBuilder, WalkParallel, WalkState, }; mod default_types; mod dir; pub mod gitignore; -mod incremental; pub mod overrides; mod pathutil; pub mod types; @@ -123,31 +120,34 @@ impl Clone for Error { fn clone(&self) -> Error { match *self { Error::Partial(ref errs) => Error::Partial(errs.clone()), - Error::WithLineNumber { line, ref err } => { - Error::WithLineNumber { line, err: err.clone() } - } - Error::WithPath { ref path, ref err } => { - Error::WithPath { path: path.clone(), err: err.clone() } - } - Error::WithDepth { depth, ref err } => { - Error::WithDepth { depth, err: err.clone() } - } - Error::Loop { ref ancestor, ref child } => Error::Loop { + Error::WithLineNumber { line, ref err } => Error::WithLineNumber { + line, + err: err.clone(), + }, + Error::WithPath { ref path, ref err } => Error::WithPath { + path: path.clone(), + err: err.clone(), + }, + Error::WithDepth { depth, ref err } => Error::WithDepth { + depth, + err: err.clone(), + }, + Error::Loop { + ref ancestor, + ref child, + } => Error::Loop { ancestor: ancestor.clone(), child: child.clone(), }, Error::Io(ref err) => match err.raw_os_error() { Some(e) => Error::Io(std::io::Error::from_raw_os_error(e)), - None => { - Error::Io(std::io::Error::new(err.kind(), err.to_string())) - } + None => Error::Io(std::io::Error::new(err.kind(), err.to_string())), }, - Error::Glob { ref glob, ref err } => { - Error::Glob { glob: glob.clone(), err: err.clone() } - } - Error::UnrecognizedFileType(ref err) => { - Error::UnrecognizedFileType(err.clone()) - } + Error::Glob { ref glob, ref err } => Error::Glob { + glob: glob.clone(), + err: err.clone(), + }, + Error::UnrecognizedFileType(ref err) => Error::UnrecognizedFileType(err.clone()), Error::InvalidDefinition => Error::InvalidDefinition, } } @@ -269,14 +269,19 @@ impl Error { /// Turn an error into a tagged error with the given depth. fn with_depth(self, depth: usize) -> Error { - Error::WithDepth { depth, err: Box::new(self) } + Error::WithDepth { + depth, + err: Box::new(self), + } } /// Turn an error into a tagged error with the given file path and line /// number. If path is empty, then it is omitted from the error. fn tagged>(self, path: P, lineno: u64) -> Error { - let errline = - Error::WithLineNumber { line: lineno, err: Box::new(self) }; + let errline = Error::WithLineNumber { + line: lineno, + err: Box::new(self), + }; if path.as_ref().as_os_str().is_empty() { return errline; } @@ -296,12 +301,12 @@ impl Error { }; } let path = err.path().map(|p| p.to_path_buf()); - let mut ig_err = Error::WithDepth { - depth, - err: Box::new(Error::Io(std::io::Error::from(err))), - }; + let mut ig_err = Error::Io(std::io::Error::from(err)); if let Some(path) = path { - ig_err = Error::WithPath { path, err: Box::new(ig_err) }; + ig_err = Error::WithPath { + path, + err: Box::new(ig_err), + }; } ig_err } @@ -328,8 +333,7 @@ impl std::fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match *self { Error::Partial(ref errs) => { - let msgs: Vec = - 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 } => { @@ -339,7 +343,10 @@ impl std::fmt::Display for Error { write!(f, "{}: {}", path.display(), err) } Error::WithDepth { ref err, .. } => err.fmt(f), - Error::Loop { ref ancestor, ref child } => write!( + Error::Loop { + ref ancestor, + ref child, + } => write!( f, "File system loop found: \ {} points to an ancestor {}", @@ -347,8 +354,14 @@ impl std::fmt::Display for Error { ancestor.display() ), Error::Io(ref err) => err.fmt(f), - Error::Glob { glob: None, ref err } => write!(f, "{}", err), - Error::Glob { glob: Some(ref glob), ref err } => { + Error::Glob { + glob: None, + ref err, + } => write!(f, "{}", err), + Error::Glob { + glob: Some(ref glob), + ref err, + } => { write!(f, "error parsing glob '{}': {}", glob, err) } Error::UnrecognizedFileType(ref ty) => { @@ -482,7 +495,11 @@ impl 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 + } } } @@ -494,8 +511,7 @@ 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)*) => { @@ -528,14 +544,13 @@ mod tests { let tmpdir = env::temp_dir(); for _ in 0..TRIES { - let count = COUNTER.fetch_add(1, Ordering::Relaxed); + let count = COUNTER.fetch_add(1, Ordering::SeqCst); let path = tmpdir.join("rust-ignore").join(count.to_string()); if path.is_dir() { continue; } - fs::create_dir_all(&path).map_err(|e| { - err!("failed to create {}: {}", path.display(), e) - })?; + fs::create_dir_all(&path) + .map_err(|e| err!("failed to create {}: {}", path.display(), e))?; return Ok(TempDir(path)); } Err(err!("failed to create temp dir after {} tries", TRIES)) diff --git a/crates/ignore/src/overrides.rs b/crates/ignore/src/overrides.rs index 005cae8f2..693c7dd0a 100644 --- a/crates/ignore/src/overrides.rs +++ b/crates/ignore/src/overrides.rs @@ -1,6 +1,5 @@ /*! The overrides module provides a way to specify a set of override globs. - This provides functionality similar to `--include` or `--exclude` in command line tools. */ @@ -8,8 +7,8 @@ line tools. use std::path::Path; use crate::{ - Error, Match, gitignore::{self, Gitignore, GitignoreBuilder}, + Error, Match, }; /// Glob represents a single glob in an override matcher. @@ -94,11 +93,7 @@ impl Override { /// given) is stripped. If there is no common suffix/prefix overlap, then /// `path` is assumed to reside in the same directory as the root path for /// this set of overrides. - pub fn matched<'a, P: AsRef>( - &'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; } @@ -121,9 +116,9 @@ impl OverrideBuilder { /// /// Matching is done relative to the directory path provided. pub fn new>(path: P) -> OverrideBuilder { - let mut builder = GitignoreBuilder::new(path); - builder.allow_unclosed_class(false); - OverrideBuilder { builder } + OverrideBuilder { + builder: GitignoreBuilder::new(path), + } } /// Builds a new override matcher from the globs added so far. @@ -146,41 +141,15 @@ impl OverrideBuilder { /// Toggle whether the globs should be matched case insensitively or not. /// - /// When this option is changed, only globs added after the change will be - /// affected. + /// When this option is changed, only globs added after the change will be affected. /// /// This is disabled by default. - pub fn case_insensitive( - &mut self, - yes: bool, - ) -> Result<&mut OverrideBuilder, Error> { + pub fn case_insensitive(&mut self, yes: bool) -> Result<&mut OverrideBuilder, Error> { // TODO: This should not return a `Result`. Fix this in the next semver // release. self.builder.case_insensitive(yes)?; Ok(self) } - - /// Toggle whether unclosed character classes are allowed. When allowed, - /// a `[` without a matching `]` is treated literally instead of resulting - /// in a parse error. - /// - /// For example, if this is set then the glob `[abc` will be treated as the - /// literal string `[abc` instead of returning an error. - /// - /// By default, this is false. Generally speaking, enabling this leads to - /// worse failure modes since the glob parser becomes more permissive. You - /// might want to enable this when compatibility (e.g., with POSIX glob - /// implementations) is more important than good error messages. - /// - /// This default is different from the default for [`Gitignore`]. Namely, - /// [`Gitignore`] is intended to match git's behavior as-is. But this - /// abstraction for "override" globs does not necessarily conform to any - /// other known specification and instead prioritizes better error - /// messages. - pub fn allow_unclosed_class(&mut self, yes: bool) -> &mut OverrideBuilder { - self.builder.allow_unclosed_class(yes); - self - } } #[cfg(test)] @@ -283,8 +252,11 @@ mod tests { #[test] fn default_case_sensitive() { - let ov = - OverrideBuilder::new(ROOT).add("*.html").unwrap().build().unwrap(); + let ov = OverrideBuilder::new(ROOT) + .add("*.html") + .unwrap() + .build() + .unwrap(); assert!(ov.matched("foo.html", false).is_whitelist()); assert!(ov.matched("foo.HTML", false).is_ignore()); assert!(ov.matched("foo.htm", false).is_ignore()); diff --git a/crates/ignore/src/pathutil.rs b/crates/ignore/src/pathutil.rs index 5de8c106c..0ceb5a356 100644 --- a/crates/ignore/src/pathutil.rs +++ b/crates/ignore/src/pathutil.rs @@ -2,89 +2,55 @@ use std::{ffi::OsStr, path::Path}; use crate::walk::DirEntry; -/// Returns true if and only if this path is considered to be hidden. +/// Returns true if and only if this entry is considered to be hidden. /// -/// # Platform behavior +/// This only returns true if the base name of the path starts with a `.`. /// -/// ## Windows +/// On Unix, this implements a more optimized check. +#[cfg(unix)] +pub(crate) fn is_hidden(dent: &DirEntry) -> bool { + use std::os::unix::ffi::OsStrExt; + + if let Some(name) = file_name(dent.path()) { + name.as_bytes().get(0) == Some(&b'.') + } else { + false + } +} + +/// Returns true if and only if this entry is considered to be hidden. /// -/// This returns true if one of the following is true: +/// On Windows, this returns true if one of the following is true: /// /// * The base name of the path starts with a `.`. /// * The file attributes have the `HIDDEN` property set. -/// -/// ## All other platforms -/// -/// This only returns true if the base name of the path starts with a `.`. -pub(crate) fn is_hidden_path(dent: &Path) -> bool { - #[cfg(not(windows))] - fn imp(path: &Path) -> bool { - is_hidden_path_only(path) - } +#[cfg(windows)] +pub(crate) fn is_hidden(dent: &DirEntry) -> bool { + use std::os::windows::fs::MetadataExt; + use winapi_util::file; - #[cfg(windows)] - fn imp(path: &Path) -> bool { - use std::os::windows::fs::MetadataExt; - use winapi_util::file; - - if let Ok(md) = path.metadata() { - if file::is_hidden(md.file_attributes() as u64) { - return true; - } + // This looks like we're doing an extra stat call, but on Windows, the + // directory traverser reuses the metadata retrieved from each directory + // entry and stores it on the DirEntry itself. So this is "free." + if let Ok(md) = dent.metadata() { + if file::is_hidden(md.file_attributes() as u64) { + return true; } - is_hidden_path_only(path) } - - imp(dent) + if let Some(name) = file_name(dent.path()) { + name.to_str().map(|s| s.starts_with(".")).unwrap_or(false) + } else { + false + } } -/// Returns true if and only if this directory entry is considered to be -/// hidden. -/// -/// # Platform behavior -/// -/// ## Windows -/// -/// This returns true if one of the following is true: -/// -/// * The base name of the path starts with a `.`. -/// * The file attributes have the `HIDDEN` property set. -/// -/// ## All other platforms +/// Returns true if and only if this entry is considered to be hidden. /// /// This only returns true if the base name of the path starts with a `.`. -pub(crate) fn is_hidden_entry(dent: &DirEntry) -> bool { - #[cfg(not(windows))] - fn imp(dent: &DirEntry) -> bool { - is_hidden_path_only(dent.path()) - } - - #[cfg(windows)] - fn imp(dent: &DirEntry) -> bool { - use std::os::windows::fs::MetadataExt; - use winapi_util::file; - - // This looks like we're doing an extra stat call, but on Windows, the - // directory traverser reuses the metadata retrieved from each directory - // entry and stores it on the DirEntry itself. So this is "free." - if let Ok(md) = dent.metadata() { - if file::is_hidden(md.file_attributes() as u64) { - return true; - } - } - is_hidden_path_only(dent.path()) - } - - imp(dent) -} - -/// Returns true if and only if this path is considered to be hidden from only -/// the path itself. -/// -/// This has the same behavior on all platforms. -fn is_hidden_path_only(path: &Path) -> bool { - if let Some(name) = file_name(path) { - name.as_encoded_bytes().starts_with(b".") +#[cfg(not(any(unix, windows)))] +pub(crate) fn is_hidden(dent: &DirEntry) -> bool { + if let Some(name) = file_name(dent.path()) { + name.to_str().map(|s| s.starts_with(".")).unwrap_or(false) } else { false } @@ -93,79 +59,83 @@ fn is_hidden_path_only(path: &Path) -> bool { /// Strip `prefix` from the `path` and return the remainder. /// /// If `path` doesn't have a prefix `prefix`, then return `None`. +#[cfg(unix)] pub(crate) fn strip_prefix<'a, P: AsRef + ?Sized>( prefix: &'a P, path: &'a Path, ) -> Option<&'a Path> { - #[cfg(unix)] - fn imp<'a>(prefix: &'a Path, path: &'a Path) -> Option<&'a Path> { - use std::os::unix::ffi::OsStrExt; + use std::os::unix::ffi::OsStrExt; - let prefix = prefix.as_os_str().as_bytes(); - let path = path.as_os_str().as_bytes(); - if prefix.len() > path.len() || prefix != &path[0..prefix.len()] { - None - } else { - Some(&Path::new(OsStr::from_bytes(&path[prefix.len()..]))) - } + let prefix = prefix.as_ref().as_os_str().as_bytes(); + let path = path.as_os_str().as_bytes(); + if prefix.len() > path.len() || prefix != &path[0..prefix.len()] { + None + } else { + Some(&Path::new(OsStr::from_bytes(&path[prefix.len()..]))) } +} - #[cfg(not(unix))] - fn imp<'a>(prefix: &'a Path, path: &'a Path) -> Option<&'a Path> { - path.strip_prefix(prefix).ok() - } - - imp(prefix.as_ref(), path) +/// Strip `prefix` from the `path` and return the remainder. +/// +/// If `path` doesn't have a prefix `prefix`, then return `None`. +#[cfg(not(unix))] +pub(crate) fn strip_prefix<'a, P: AsRef + ?Sized>( + prefix: &'a P, + path: &'a Path, +) -> Option<&'a Path> { + path.strip_prefix(prefix).ok() } /// Returns true if this file path is just a file name. i.e., Its parent is /// the empty string. +#[cfg(unix)] pub(crate) fn is_file_name>(path: P) -> bool { - #[cfg(unix)] - { - memchr::memchr(b'/', path.as_ref().as_os_str().as_encoded_bytes()) - .is_none() - } - #[cfg(not(unix))] - { - path.as_ref() - .parent() - .map(|p| p.as_os_str().is_empty()) - .unwrap_or(false) - } + use std::os::unix::ffi::OsStrExt; + + use memchr::memchr; + + let path = path.as_ref().as_os_str().as_bytes(); + memchr(b'/', path).is_none() +} + +/// Returns true if this file path is just a file name. i.e., Its parent is +/// the empty string. +#[cfg(not(unix))] +pub(crate) fn is_file_name>(path: P) -> bool { + path.as_ref() + .parent() + .map(|p| p.as_os_str().is_empty()) + .unwrap_or(false) } /// The final component of the path, if it is a normal file. /// -/// If the path terminates in `.`, `..`, or consists solely of a root of -/// prefix, this will return `None`. -pub(crate) fn file_name<'a, P: AsRef + ?Sized>( - path: &'a P, -) -> Option<&'a OsStr> { - #[cfg(unix)] - fn imp(path: &Path) -> Option<&OsStr> { - use std::os::unix::ffi::OsStrExt; +/// If the path terminates in ., .., or consists solely of a root of prefix, +/// file_name will return None. +#[cfg(unix)] +pub(crate) fn file_name<'a, P: AsRef + ?Sized>(path: &'a P) -> Option<&'a OsStr> { + use memchr::memrchr; + use std::os::unix::ffi::OsStrExt; - use memchr::memrchr; - - let path = path.as_os_str().as_bytes(); - if path.is_empty() { - return None; - } else if path.len() == 1 && path[0] == b'.' { - return None; - } else if path.last() == Some(&b'.') { - return None; - } else if path.len() >= 2 && &path[path.len() - 2..] == &b".."[..] { - return None; - } - let last_slash = memrchr(b'/', path).map(|i| i + 1).unwrap_or(0); - Some(OsStr::from_bytes(&path[last_slash..])) + let path = path.as_ref().as_os_str().as_bytes(); + if path.is_empty() { + return None; + } else if path.len() == 1 && path[0] == b'.' { + return None; + } else if path.last() == Some(&b'.') { + return None; + } else if path.len() >= 2 && &path[path.len() - 2..] == &b".."[..] { + return None; } - - #[cfg(not(unix))] - fn imp(path: &Path) -> Option<&OsStr> { - path.file_name() - } - - imp(path.as_ref()) + let last_slash = memrchr(b'/', path).map(|i| i + 1).unwrap_or(0); + Some(OsStr::from_bytes(&path[last_slash..])) +} + +/// The final component of the path, if it is a normal file. +/// +/// If the path terminates in ., .., or consists solely of a root of prefix, +/// file_name will return None. +#[cfg(not(unix))] +pub(crate) fn file_name<'a, P: AsRef + ?Sized>(path: &'a P) -> Option<&'a OsStr> { + path.as_ref().file_name() } diff --git a/crates/ignore/src/types.rs b/crates/ignore/src/types.rs index 313cf5c0e..814ee4a0a 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::{Error, Match, default_types::DEFAULT_TYPES, pathutil::file_name}; +use crate::{default_types::DEFAULT_TYPES, pathutil::file_name, Error, Match}; /// Glob represents a single glob in a set of file type definitions. /// @@ -204,12 +204,8 @@ impl 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)), } } @@ -231,9 +227,7 @@ impl Types { has_selected: false, glob_to_selection: vec![], set: GlobSetBuilder::new().build().unwrap(), - matches: Arc::new(Pool::with_available_parallelism_capacity( - || vec![], - )), + matches: Arc::new(Pool::new(|| vec![])), } } @@ -260,11 +254,7 @@ impl Types { /// The path is considered ignored if it matches a negated file type. /// If at least one file type is selected and `path` doesn't match, then /// the path is also considered ignored. - pub fn matched<'a, P: AsRef>( - &'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() { @@ -316,7 +306,10 @@ impl TypesBuilder { /// of default type definitions can be added with `add_defaults`, and /// additional type definitions can be added with `select` and `negate`. pub fn new() -> TypesBuilder { - TypesBuilder { types: HashMap::new(), selections: vec![] } + TypesBuilder { + types: HashMap::new(), + selections: vec![], + } } /// Build the current set of file type definitions *and* selections into @@ -350,18 +343,17 @@ impl TypesBuilder { } selections.push(selection.clone().map(move |_| def)); } - let set = build_set - .build() - .map_err(|err| Error::Glob { glob: None, err: err.to_string() })?; + let set = build_set.build().map_err(|err| Error::Glob { + glob: None, + err: err.to_string(), + })?; Ok(Types { defs, selections, has_selected, glob_to_selection, set, - matches: Arc::new(Pool::with_available_parallelism_capacity( - || vec![], - )), + matches: Arc::new(Pool::new(|| vec![])), }) } @@ -385,10 +377,12 @@ impl TypesBuilder { pub fn select(&mut self, name: &str) -> &mut TypesBuilder { if name == "all" { for name in self.types.keys() { - self.selections.push(Selection::Select(name.to_string(), ())); + self.selections + .push(Selection::Select(name.to_string(), ())); } } else { - self.selections.push(Selection::Select(name.to_string(), ())); + self.selections + .push(Selection::Select(name.to_string(), ())); } self } @@ -399,10 +393,12 @@ impl TypesBuilder { pub fn negate(&mut self, name: &str) -> &mut TypesBuilder { if name == "all" { for name in self.types.keys() { - self.selections.push(Selection::Negate(name.to_string(), ())); + self.selections + .push(Selection::Negate(name.to_string(), ())); } } else { - self.selections.push(Selection::Negate(name.to_string(), ())); + self.selections + .push(Selection::Negate(name.to_string(), ())); } self } @@ -457,10 +453,7 @@ impl TypesBuilder { 3 => { let name = parts[0]; let types_string = parts[2]; - if name.is_empty() - || parts[1] != "include" - || types_string.is_empty() - { + if name.is_empty() || parts[1] != "include" || types_string.is_empty() { return Err(Error::InvalidDefinition); } let types = types_string.split(','); @@ -470,8 +463,7 @@ impl TypesBuilder { return Err(Error::InvalidDefinition); } for type_name in types { - let globs = - self.types.get(type_name).unwrap().globs.clone(); + let globs = self.types.get(type_name).unwrap().globs.clone(); for glob in globs { self.add(name, &glob)?; } @@ -557,9 +549,30 @@ mod tests { matched!(not, matchnot1, types(), vec!["rust"], vec![], "index.html"); matched!(not, matchnot2, types(), vec![], vec!["rust"], "main.rs"); - matched!(not, matchnot3, types(), vec!["foo"], vec!["rust"], "main.rs"); - matched!(not, matchnot4, types(), vec!["rust"], vec!["foo"], "main.rs"); - matched!(not, matchnot5, types(), vec!["rust"], vec!["foo"], "main.foo"); + matched!( + not, + matchnot3, + types(), + vec!["foo"], + vec!["rust"], + "main.rs" + ); + matched!( + not, + matchnot4, + types(), + vec!["rust"], + vec!["foo"], + "main.rs" + ); + matched!( + not, + matchnot5, + types(), + vec!["rust"], + vec!["foo"], + "main.foo" + ); matched!(not, matchnot6, types(), vec!["combo"], vec![], "leftpad.js"); matched!(not, matchnot7, types(), vec!["py"], vec![], "index.html"); matched!(not, matchnot8, types(), vec!["python"], vec![], "doc.md"); diff --git a/crates/ignore/src/walk.rs b/crates/ignore/src/walk.rs index 2dff78852..9c1f7413d 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, OnceLock}, + sync::Arc, }; use { @@ -15,13 +15,11 @@ 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. @@ -106,15 +104,24 @@ 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, + } } } @@ -180,11 +187,9 @@ impl DirEntryInner { )); Err(err.with_path("")) } - Walkdir(ref x) => x.metadata().map_err(|err| { - Error::Io(io::Error::from(err)) - .with_depth(x.depth()) - .with_path(x.path()) - }), + Walkdir(ref x) => x + .metadata() + .map_err(|err| Error::Io(io::Error::from(err)).with_path(x.path())), Raw(ref x) => x.metadata(), } } @@ -303,9 +308,7 @@ impl DirEntryRaw { } else { fs::symlink_metadata(&self.path) } - .map_err(|err| { - Error::Io(err).with_depth(self.depth).with_path(&self.path) - }) + .map_err(|err| Error::Io(io::Error::from(err)).with_path(&self.path)) } fn file_type(&self) -> FileType { @@ -313,7 +316,9 @@ 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 { @@ -325,13 +330,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(err).with_depth(depth).with_path(ent.path()); - Error::WithDepth { depth, err: Box::new(err) } + let err = Error::Io(io::Error::from(err)).with_path(ent.path()); + Error::WithDepth { + depth, + err: Box::new(err), + } })?; DirEntryRaw::from_entry_os(depth, ent, ty) } @@ -343,8 +348,11 @@ impl DirEntryRaw { ty: fs::FileType, ) -> Result { let md = ent.metadata().map_err(|err| { - let err = Error::Io(err).with_depth(depth).with_path(ent.path()); - Error::WithDepth { depth, err: Box::new(err) } + let err = Error::Io(io::Error::from(err)).with_path(ent.path()); + Error::WithDepth { + depth, + err: Box::new(err), + } })?; Ok(DirEntryRaw { path: ent.path(), @@ -387,13 +395,8 @@ 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_depth(depth).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_path(&pb))?; Ok(DirEntryRaw { path: pb, ty: md.file_type(), @@ -404,15 +407,10 @@ 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_depth(depth).with_path(&pb))?; + let md = fs::metadata(&pb).map_err(|err| Error::Io(err).with_path(&pb))?; Ok(DirEntryRaw { path: pb, ty: md.file_type(), @@ -425,11 +423,7 @@ 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", @@ -490,7 +484,6 @@ pub struct WalkBuilder { paths: Vec, ig_builder: IgnoreBuilder, max_depth: Option, - min_depth: Option, max_filesize: Option, follow_links: bool, same_file_system: bool, @@ -498,18 +491,6 @@ 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)] @@ -527,18 +508,10 @@ 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() } } @@ -551,19 +524,10 @@ 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![], + paths: vec![path.as_ref().to_path_buf()], ig_builder: IgnoreBuilder::new(), max_depth: None, - min_depth: None, max_filesize: None, follow_links: false, same_file_system: false, @@ -571,30 +535,13 @@ 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 @@ -609,20 +556,13 @@ 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())); } } } @@ -631,81 +571,28 @@ impl WalkBuilder { }) .collect::>() .into_iter(); - let ig_root = self.build_ignore(); + let ig_root = self.ig_builder.build(); 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, + ig_root: self.ig_builder.build(), 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, @@ -730,26 +617,6 @@ 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 } @@ -780,31 +647,11 @@ 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)); @@ -819,11 +666,7 @@ impl WalkBuilder { errs.into_error_option() } - /// 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. + /// CHANGED: Add a Gitignore to the builder. pub fn add_gitignore(&mut self, gi: Gitignore) { self.ig_builder.add_ignore(gi); } @@ -956,10 +799,6 @@ 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 @@ -1056,10 +895,6 @@ 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, @@ -1067,62 +902,6 @@ 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 @@ -1136,7 +915,6 @@ pub struct Walk { it: Option, ig_root: Ignore, ig: Ignore, - max_depth: Option, max_filesize: Option, skip: Option>, filter: Option, @@ -1152,17 +930,6 @@ 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); @@ -1247,17 +1014,12 @@ 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. - // Its ignore files cannot apply to any visited entry. - let (igtmp, _) = - self.ig.add_child_with_entries(ent.path(), &[]); + // We don't care if it errors though. + let (igtmp, _) = self.ig.add_child(ent.path()); self.ig = igtmp; continue; } - 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()) - }; + let (igtmp, err) = self.ig.add_child(ent.path()); self.ig = igtmp; ent.err = err; return Some(Ok(ent)); @@ -1299,7 +1061,11 @@ 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, + } } } @@ -1372,9 +1138,7 @@ 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() } @@ -1395,17 +1159,14 @@ 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>, @@ -1430,7 +1191,6 @@ pub struct WalkParallel { ig_root: Ignore, max_filesize: Option, max_depth: Option, - min_depth: Option, follow_links: bool, same_file_system: bool, threads: usize, @@ -1495,9 +1255,7 @@ 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; @@ -1521,28 +1279,20 @@ 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<_> = workers + let handles: 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, + 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 { @@ -1553,7 +1303,7 @@ impl WalkParallel { fn threads(&self) -> usize { if self.threads == 0 { - std::thread::available_parallelism().map_or(1, |n| n.get()).min(12) + 2 } else { self.threads } @@ -1584,12 +1334,6 @@ 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 { @@ -1616,13 +1360,6 @@ 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. /// @@ -1630,7 +1367,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) => { @@ -1640,24 +1377,10 @@ impl Work { return Err(err); } }; - // 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); + let (ig, err) = self.ignore.add_child(self.dent.path()); self.ignore = ig; self.dent.err = err; - Ok(result) + Ok(readdir) } } @@ -1681,11 +1404,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() @@ -1695,11 +1418,8 @@ impl Stack { stealers: stealers.clone(), }) .collect(); - // 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. + // Distribute the initial messages. init.into_iter() - .rev() .zip(stacks.iter().cycle()) .for_each(|(m, s)| s.push(m)); stacks @@ -1754,8 +1474,6 @@ 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, @@ -1784,19 +1502,10 @@ 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 if should_visit { - self.visitor.visit(Ok(work.dent)) - } else { - WalkState::Continue - }; + return self.visitor.visit(Ok(work.dent)); } if let Some(err) = work.add_parents() { let state = self.visitor.visit(Err(err)); @@ -1827,27 +1536,16 @@ 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 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; - } + 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) => { @@ -1855,19 +1553,11 @@ impl<'s> Worker<'s> { } }; - for result in readdir.entries { - let state = self.generate_work( - &work.ignore, - depth + 1, - work.root_device, - result, - ); - if state.is_quit() { - return state; - } + if self.max_depth.map_or(false, |max| depth >= max) { + return WalkState::Skip; } - for err in readdir.errors { - let state = self.visitor.visit(Err(err)); + for result in readdir { + let state = self.generate_work(&work.ignore, depth + 1, work.root_device, result); if state.is_quit() { return state; } @@ -1893,8 +1583,14 @@ impl<'s> Worker<'s> { ig: &Ignore, depth: usize, root_device: Option, - fs_dent: fs::DirEntry, + result: Result, ) -> 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) => { @@ -1930,24 +1626,26 @@ 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 } @@ -1988,9 +1686,6 @@ 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); @@ -2044,25 +1739,24 @@ impl<'s> Worker<'s> { } } -impl<'s> Drop for Worker<'s> { - fn drop(&mut self) { - if std::thread::panicking() { - self.quit_now(); - } - } -} - fn check_symlink_loop( 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 { @@ -2077,11 +1771,7 @@ 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, @@ -2153,9 +1843,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_depth(dent.depth()).with_path(dent.path()) - }) + Handle::from_path(dent.path()) + .map(|h| &h == handle) + .map_err(|err| Error::Io(err).with_path(dent.path())) } /// Returns true if the given walkdir entry corresponds to a directory. @@ -2173,14 +1863,16 @@ 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) } @@ -2193,7 +1885,7 @@ fn device_num>(path: P) -> io::Result { #[cfg(windows)] fn device_num>(path: P) -> io::Result { - use winapi_util::{Handle, file}; + use winapi_util::{file, Handle}; let h = Handle::from_path_any(path)?; file::information(h).map(|info| info.volume_serial_number()) @@ -2239,7 +1931,11 @@ 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 { @@ -2259,10 +1955,7 @@ 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(); @@ -2451,72 +2144,6 @@ mod tests { ); } - #[test] - fn max_depth_does_not_load_unreachable_ignore_files() { - let td = tmpdir(); - let leaf = td.path().join("leaf"); - mkdirp(&leaf); - wfile(leaf.join(".ignore"), "{invalid\n"); - - let mut builder = WalkBuilder::new(td.path()); - builder.max_depth(Some(1)); - let entry = builder - .build() - .find_map(|result| { - let entry = result.unwrap(); - (entry.path() == leaf).then_some(entry) - }) - .unwrap(); - - assert!(entry.error().is_none()); - assert_paths(td.path(), &builder, &["leaf"]); - } - - #[test] - 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(); @@ -2582,9 +2209,7 @@ 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()); } @@ -2670,88 +2295,8 @@ 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 ecb7b47e3..b7b7c6f95 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,8 +2,7 @@ use std::path::Path; use ignore::gitignore::{Gitignore, GitignoreBuilder}; -const IGNORE_FILE: &'static str = - "tests/gitignore_matched_path_or_any_parents_tests.gitignore"; +const IGNORE_FILE: &'static str = "tests/gitignore_matched_path_or_any_parents_tests.gitignore"; fn get_gitignore() -> Gitignore { let mut builder = GitignoreBuilder::new("ROOT"); @@ -24,9 +23,7 @@ fn test_path_should_be_under_root() { #[test] fn test_files_in_root() { let gitignore = get_gitignore(); - let m = |path: &str| { - gitignore.matched_path_or_any_parents(Path::new(path), false) - }; + let m = |path: &str| gitignore.matched_path_or_any_parents(Path::new(path), false); // 0x assert!(m("ROOT/file_root_00").is_ignore()); @@ -56,9 +53,7 @@ fn test_files_in_root() { #[test] fn test_files_in_deep() { let gitignore = get_gitignore(); - let m = |path: &str| { - gitignore.matched_path_or_any_parents(Path::new(path), false) - }; + let m = |path: &str| gitignore.matched_path_or_any_parents(Path::new(path), false); // 0x assert!(m("ROOT/parent_dir/file_deep_00").is_ignore()); @@ -88,9 +83,8 @@ fn test_files_in_deep() { #[test] fn test_dirs_in_root() { let gitignore = get_gitignore(); - let m = |path: &str, is_dir: bool| { - gitignore.matched_path_or_any_parents(Path::new(path), is_dir) - }; + let m = + |path: &str, is_dir: bool| gitignore.matched_path_or_any_parents(Path::new(path), is_dir); // 00 assert!(m("ROOT/dir_root_00", true).is_ignore()); @@ -192,25 +186,20 @@ fn test_dirs_in_root() { #[test] fn test_dirs_in_deep() { let gitignore = get_gitignore(); - let m = |path: &str, is_dir: bool| { - gitignore.matched_path_or_any_parents(Path::new(path), is_dir) - }; + let m = + |path: &str, is_dir: bool| gitignore.matched_path_or_any_parents(Path::new(path), is_dir); // 00 assert!(m("ROOT/parent_dir/dir_deep_00", true).is_ignore()); assert!(m("ROOT/parent_dir/dir_deep_00/file", false).is_ignore()); assert!(m("ROOT/parent_dir/dir_deep_00/child_dir", true).is_ignore()); - assert!( - m("ROOT/parent_dir/dir_deep_00/child_dir/file", false).is_ignore() - ); + assert!(m("ROOT/parent_dir/dir_deep_00/child_dir/file", false).is_ignore()); // 01 assert!(m("ROOT/parent_dir/dir_deep_01", true).is_ignore()); assert!(m("ROOT/parent_dir/dir_deep_01/file", false).is_ignore()); assert!(m("ROOT/parent_dir/dir_deep_01/child_dir", true).is_ignore()); - assert!( - m("ROOT/parent_dir/dir_deep_01/child_dir/file", false).is_ignore() - ); + assert!(m("ROOT/parent_dir/dir_deep_01/child_dir/file", false).is_ignore()); // 02 assert!(m("ROOT/parent_dir/dir_deep_02", true).is_none()); @@ -252,67 +241,51 @@ fn test_dirs_in_deep() { assert!(m("ROOT/parent_dir/dir_deep_20", true).is_ignore()); assert!(m("ROOT/parent_dir/dir_deep_20/file", false).is_ignore()); assert!(m("ROOT/parent_dir/dir_deep_20/child_dir", true).is_ignore()); - assert!( - m("ROOT/parent_dir/dir_deep_20/child_dir/file", false).is_ignore() - ); + assert!(m("ROOT/parent_dir/dir_deep_20/child_dir/file", false).is_ignore()); // 21 assert!(m("ROOT/parent_dir/dir_deep_21", true).is_ignore()); assert!(m("ROOT/parent_dir/dir_deep_21/file", false).is_ignore()); assert!(m("ROOT/parent_dir/dir_deep_21/child_dir", true).is_ignore()); - assert!( - m("ROOT/parent_dir/dir_deep_21/child_dir/file", false).is_ignore() - ); + assert!(m("ROOT/parent_dir/dir_deep_21/child_dir/file", false).is_ignore()); // 22 // dir itself doesn't match assert!(m("ROOT/parent_dir/dir_deep_22", true).is_none()); assert!(m("ROOT/parent_dir/dir_deep_22/file", false).is_ignore()); assert!(m("ROOT/parent_dir/dir_deep_22/child_dir", true).is_ignore()); - assert!( - m("ROOT/parent_dir/dir_deep_22/child_dir/file", false).is_ignore() - ); + assert!(m("ROOT/parent_dir/dir_deep_22/child_dir/file", false).is_ignore()); // 23 // dir itself doesn't match assert!(m("ROOT/parent_dir/dir_deep_23", true).is_none()); assert!(m("ROOT/parent_dir/dir_deep_23/file", false).is_ignore()); assert!(m("ROOT/parent_dir/dir_deep_23/child_dir", true).is_ignore()); - assert!( - m("ROOT/parent_dir/dir_deep_23/child_dir/file", false).is_ignore() - ); + assert!(m("ROOT/parent_dir/dir_deep_23/child_dir/file", false).is_ignore()); // 30 assert!(m("ROOT/parent_dir/dir_deep_30", true).is_ignore()); assert!(m("ROOT/parent_dir/dir_deep_30/file", false).is_ignore()); assert!(m("ROOT/parent_dir/dir_deep_30/child_dir", true).is_ignore()); - assert!( - m("ROOT/parent_dir/dir_deep_30/child_dir/file", false).is_ignore() - ); + assert!(m("ROOT/parent_dir/dir_deep_30/child_dir/file", false).is_ignore()); // 31 assert!(m("ROOT/parent_dir/dir_deep_31", true).is_ignore()); assert!(m("ROOT/parent_dir/dir_deep_31/file", false).is_ignore()); assert!(m("ROOT/parent_dir/dir_deep_31/child_dir", true).is_ignore()); - assert!( - m("ROOT/parent_dir/dir_deep_31/child_dir/file", false).is_ignore() - ); + assert!(m("ROOT/parent_dir/dir_deep_31/child_dir/file", false).is_ignore()); // 32 // dir itself doesn't match assert!(m("ROOT/parent_dir/dir_deep_32", true).is_none()); assert!(m("ROOT/parent_dir/dir_deep_32/file", false).is_ignore()); assert!(m("ROOT/parent_dir/dir_deep_32/child_dir", true).is_ignore()); - assert!( - m("ROOT/parent_dir/dir_deep_32/child_dir/file", false).is_ignore() - ); + assert!(m("ROOT/parent_dir/dir_deep_32/child_dir/file", false).is_ignore()); // 33 // dir itself doesn't match assert!(m("ROOT/parent_dir/dir_deep_33", true).is_none()); assert!(m("ROOT/parent_dir/dir_deep_33/file", false).is_ignore()); assert!(m("ROOT/parent_dir/dir_deep_33/child_dir", true).is_ignore()); - assert!( - m("ROOT/parent_dir/dir_deep_33/child_dir/file", false).is_ignore() - ); + assert!(m("ROOT/parent_dir/dir_deep_33/child_dir/file", false).is_ignore()); } diff --git a/crates/ignore/tests/gitignore_skip_bom.gitignore b/crates/ignore/tests/gitignore_skip_bom.gitignore deleted file mode 100644 index a78c13a9b..000000000 --- a/crates/ignore/tests/gitignore_skip_bom.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index 14d533d63..000000000 --- a/crates/ignore/tests/gitignore_skip_bom.rs +++ /dev/null @@ -1,17 +0,0 @@ -use ignore::gitignore::GitignoreBuilder; - -const IGNORE_FILE: &'static str = "tests/gitignore_skip_bom.gitignore"; - -/// Skip a Byte-Order Mark (BOM) at the beginning of the file, matching Git's -/// behavior. -/// -/// Ref: -#[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 c3f2ec696..d2200a62e 100644 --- a/crates/node/.gitignore +++ b/crates/node/.gitignore @@ -202,6 +202,5 @@ index.js browser.js tailwindcss-oxide.wasi-browser.js tailwindcss-oxide.wasi.cjs -tailwindcss-oxide.wasi.d.cts wasi-worker-browser.mjs wasi-worker.mjs diff --git a/crates/node/Cargo.toml b/crates/node/Cargo.toml index a3df7b813..87fa3b559 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 = "3.11.0", default-features = false, features = ["napi4"] } -napi-derive = "3.6.0" +napi = { version = "2.16.17", default-features = false, features = ["napi4"] } +napi-derive = "2.16.13" tailwindcss-oxide = { path = "../oxide" } -rayon = "1.12.0" +rayon = "1.10.0" [build-dependencies] -napi-build = "2.3.2" +napi-build = "2.1.6" diff --git a/crates/node/npm/android-arm-eabi/package.json b/crates/node/npm/android-arm-eabi/package.json index d46ceb237..9b3606683 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.3.3", + "version": "4.1.12", "repository": { "type": "git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git", @@ -22,6 +22,6 @@ }, "license": "MIT", "engines": { - "node": ">= 20" + "node": ">= 10" } } diff --git a/crates/node/npm/android-arm64/package.json b/crates/node/npm/android-arm64/package.json index e6e9d577d..82c922f09 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.3.3", + "version": "4.1.12", "repository": { "type": "git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git", @@ -22,6 +22,6 @@ }, "license": "MIT", "engines": { - "node": ">= 20" + "node": ">= 10" } } diff --git a/crates/node/npm/darwin-arm64/package.json b/crates/node/npm/darwin-arm64/package.json index 1cb737c2e..579e26a95 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.3.3", + "version": "4.1.12", "repository": { "type": "git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git", @@ -22,6 +22,6 @@ }, "license": "MIT", "engines": { - "node": ">= 20" + "node": ">= 10" } } diff --git a/crates/node/npm/darwin-x64/package.json b/crates/node/npm/darwin-x64/package.json index ffd75e54b..9d90be470 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.3.3", + "version": "4.1.12", "repository": { "type": "git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git", @@ -22,6 +22,6 @@ }, "license": "MIT", "engines": { - "node": ">= 20" + "node": ">= 10" } } diff --git a/crates/node/npm/freebsd-x64/package.json b/crates/node/npm/freebsd-x64/package.json index e248f39d1..f5fc01409 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.3.3", + "version": "4.1.12", "repository": { "type": "git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git", @@ -22,6 +22,6 @@ }, "license": "MIT", "engines": { - "node": ">= 20" + "node": ">= 10" } } diff --git a/crates/node/npm/linux-arm-gnueabihf/package.json b/crates/node/npm/linux-arm-gnueabihf/package.json index 50d2aef73..37e0dfe38 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.3.3", + "version": "4.1.12", "repository": { "type": "git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git", @@ -22,6 +22,6 @@ }, "license": "MIT", "engines": { - "node": ">= 20" + "node": ">= 10" } } diff --git a/crates/node/npm/linux-arm64-gnu/package.json b/crates/node/npm/linux-arm64-gnu/package.json index 5875213f8..74e4d9ec7 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.3.3", + "version": "4.1.12", "repository": { "type": "git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git", @@ -22,7 +22,7 @@ }, "license": "MIT", "engines": { - "node": ">= 20" + "node": ">= 10" }, "libc": [ "glibc" diff --git a/crates/node/npm/linux-arm64-musl/package.json b/crates/node/npm/linux-arm64-musl/package.json index 9da37f3fd..784da745b 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.3.3", + "version": "4.1.12", "repository": { "type": "git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git", @@ -22,7 +22,7 @@ }, "license": "MIT", "engines": { - "node": ">= 20" + "node": ">= 10" }, "libc": [ "musl" diff --git a/crates/node/npm/linux-x64-gnu/package.json b/crates/node/npm/linux-x64-gnu/package.json index d409743fa..03743935b 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.3.3", + "version": "4.1.12", "repository": { "type": "git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git", @@ -22,7 +22,7 @@ }, "license": "MIT", "engines": { - "node": ">= 20" + "node": ">= 10" }, "libc": [ "glibc" diff --git a/crates/node/npm/linux-x64-musl/package.json b/crates/node/npm/linux-x64-musl/package.json index 5b404fd70..818e666ae 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.3.3", + "version": "4.1.12", "repository": { "type": "git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git", @@ -22,7 +22,7 @@ }, "license": "MIT", "engines": { - "node": ">= 20" + "node": ">= 10" }, "libc": [ "musl" diff --git a/crates/node/npm/wasm32-wasi/.npmrc b/crates/node/npm/wasm32-wasi/.npmrc new file mode 100644 index 000000000..d67f37488 --- /dev/null +++ b/crates/node/npm/wasm32-wasi/.npmrc @@ -0,0 +1 @@ +node-linker=hoisted diff --git a/crates/node/npm/wasm32-wasi/README.md b/crates/node/npm/wasm32-wasi/README.md index 2a5368149..9ec3a7b3f 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** build of `@tailwindcss/oxide` +This is the **wasm32-wasip1-threads** binary for `@tailwindcss/oxide` diff --git a/crates/node/npm/wasm32-wasi/package.json b/crates/node/npm/wasm32-wasi/package.json index cee9fb05a..8d9424d32 100644 --- a/crates/node/npm/wasm32-wasi/package.json +++ b/crates/node/npm/wasm32-wasi/package.json @@ -1,6 +1,9 @@ { "name": "@tailwindcss/oxide-wasm32-wasi", - "version": "4.3.3", + "version": "4.1.12", + "cpu": [ + "wasm32" + ], "main": "tailwindcss-oxide.wasi.cjs", "files": [ "tailwindcss-oxide.wasm32-wasi.wasm", @@ -11,7 +14,7 @@ ], "license": "MIT", "engines": { - "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + "node": ">=14.0.0" }, "publishConfig": { "provenance": true, @@ -24,12 +27,12 @@ }, "browser": "tailwindcss-oxide.wasi-browser.js", "dependencies": { - "@napi-rs/wasm-runtime": "^1.2.2", - "@emnapi/core": "^1.11.3", - "@emnapi/runtime": "^1.11.3", - "@tybys/wasm-util": "^0.10.3", - "@emnapi/wasi-threads": "^1.2.3", - "tslib": "^2.8.1" + "@napi-rs/wasm-runtime": "^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" }, "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 991727a32..aa0d2b4e9 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.3.3", + "version": "4.1.12", "repository": { "type": "git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git", @@ -22,6 +22,6 @@ }, "license": "MIT", "engines": { - "node": ">= 20" + "node": ">= 10" } } diff --git a/crates/node/npm/win32-x64-msvc/package.json b/crates/node/npm/win32-x64-msvc/package.json index 82d75c300..c60d85bae 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.3.3", + "version": "4.1.12", "repository": { "type": "git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git", @@ -22,6 +22,6 @@ }, "license": "MIT", "engines": { - "node": ">= 20" + "node": ">= 10" } } diff --git a/crates/node/package.json b/crates/node/package.json index be09427fb..20e4273e8 100644 --- a/crates/node/package.json +++ b/crates/node/package.json @@ -1,6 +1,6 @@ { "name": "@tailwindcss/oxide", - "version": "4.3.3", + "version": "4.1.12", "repository": { "type": "git", "url": "git+https://github.com/tailwindlabs/tailwindcss.git", @@ -32,33 +32,38 @@ } }, "license": "MIT", + "dependencies": { + "tar": "^7.4.3", + "detect-libc": "^2.0.4" + }, "devDependencies": { - "@emnapi/core": "1.11.3", - "@emnapi/runtime": "1.11.3", - "@napi-rs/cli": "3.7.4", - "@napi-rs/wasm-runtime": "^1.2.2", - "emnapi": "1.11.3" + "@napi-rs/cli": "^3.0.0-alpha.78", + "@napi-rs/wasm-runtime": "^0.2.12", + "emnapi": "1.4.5" }, "engines": { - "node": ">= 20" + "node": ">= 10" }, "files": [ "index.js", - "index.d.ts" + "index.d.ts", + "scripts/install.js" ], "publishConfig": { "provenance": true, "access": "public" }, "scripts": { + "artifacts": "napi artifacts", "build": "pnpm run build:platform && pnpm run build:wasm", - "build:platform": "napi build --platform --release", + "build:platform": "napi build --platform --release --no-const-enum", "postbuild:platform": "node ./scripts/move-artifacts.mjs", - "build:wasm": "napi build --release --target wasm32-wasip1-threads", + "build:wasm": "napi build --release --target wasm32-wasip1-threads --no-const-enum", "postbuild:wasm": "node ./scripts/move-artifacts.mjs", "dev": "cargo watch --quiet --shell 'npm run build'", - "build:debug": "napi build --platform", - "version": "napi version" + "build:debug": "napi build --platform --no-const-enum", + "version": "napi version", + "postinstall": "node ./scripts/install.js" }, "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "workspace:*", diff --git a/crates/node/scripts/install.js b/crates/node/scripts/install.js new file mode 100644 index 000000000..f9cefe01c --- /dev/null +++ b/crates/node/scripts/install.js @@ -0,0 +1,143 @@ +#!/usr/bin/env node + +/** + * @tailwindcss/oxide postinstall script + * + * This script ensures that the correct binary for the current platform and + * architecture is downloaded and available. + */ + +const fs = require('fs') +const path = require('path') +const https = require('https') +const { extract } = require('tar') +const packageJson = require('../package.json') +const detectLibc = require('detect-libc') + +const version = packageJson.version + +function getPlatformPackageName() { + let platform = process.platform + let arch = process.arch + + let libc = '' + if (platform === 'linux') { + libc = detectLibc.isNonGlibcLinuxSync() ? 'musl' : 'gnu' + } + + // Map to our package naming conventions + switch (platform) { + case 'darwin': + return arch === 'arm64' ? '@tailwindcss/oxide-darwin-arm64' : '@tailwindcss/oxide-darwin-x64' + case 'win32': + if (arch === 'arm64') return '@tailwindcss/oxide-win32-arm64-msvc' + if (arch === 'ia32') return '@tailwindcss/oxide-win32-ia32-msvc' + return '@tailwindcss/oxide-win32-x64-msvc' + case 'linux': + if (arch === 'x64') { + return libc === 'musl' + ? '@tailwindcss/oxide-linux-x64-musl' + : '@tailwindcss/oxide-linux-x64-gnu' + } else if (arch === 'arm64') { + return libc === 'musl' + ? '@tailwindcss/oxide-linux-arm64-musl' + : '@tailwindcss/oxide-linux-arm64-gnu' + } else if (arch === 'arm') { + return '@tailwindcss/oxide-linux-arm-gnueabihf' + } + break + case 'freebsd': + return '@tailwindcss/oxide-freebsd-x64' + case 'android': + return '@tailwindcss/oxide-android-arm64' + default: + return '@tailwindcss/oxide-wasm32-wasi' + } +} + +function isPackageAvailable(packageName) { + try { + require.resolve(packageName) + return true + } catch (e) { + return false + } +} + +// Extract all files from a tarball to a destination directory +async function extractTarball(tarballStream, destDir) { + if (!fs.existsSync(destDir)) { + fs.mkdirSync(destDir, { recursive: true }) + } + + return new Promise((resolve, reject) => { + tarballStream + .pipe(extract({ cwd: destDir, strip: 1 })) + .on('error', (err) => reject(err)) + .on('end', () => resolve()) + }) +} + +async function downloadAndExtractBinary(packageName) { + let tarballUrl = `https://registry.npmjs.org/${packageName}/-/${packageName.replace('@tailwindcss/', '')}-${version}.tgz` + console.log(`Downloading ${tarballUrl}...`) + + return new Promise((resolve) => { + https + .get(tarballUrl, (response) => { + if (response.statusCode === 302 || response.statusCode === 301) { + // Handle redirects + https.get(response.headers.location, handleResponse).on('error', (err) => { + console.error('Download error:', err) + resolve() + }) + return + } + + handleResponse(response) + + async function handleResponse(response) { + try { + if (response.statusCode !== 200) { + throw new Error(`Download failed with status code: ${response.statusCode}`) + } + + await extractTarball( + response, + path.join(__dirname, '..', 'node_modules', ...packageName.split('/')), + ) + console.log(`Successfully downloaded and installed ${packageName}`) + } catch (error) { + console.error('Error during extraction:', error) + resolve() + } finally { + resolve() + } + } + }) + .on('error', (err) => { + console.error('Download error:', err) + resolve() + }) + }) +} + +async function main() { + // Don't run this script in the package source + try { + if (fs.existsSync(path.join(__dirname, '..', 'build.rs'))) { + return + } + + let packageName = getPlatformPackageName() + if (!packageName) return + if (isPackageAvailable(packageName)) return + + await downloadAndExtractBinary(packageName) + } catch (error) { + console.error(error) + return + } +} + +main() diff --git a/crates/node/src/lib.rs b/crates/node/src/lib.rs index fb32a818c..11ff25b0a 100644 --- a/crates/node/src/lib.rs +++ b/crates/node/src/lib.rs @@ -165,11 +165,6 @@ 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 f8a5f6137..9b800b049 100644 --- a/crates/oxide/Cargo.toml +++ b/crates/oxide/Cargo.toml @@ -9,6 +9,7 @@ globwalk = "0.9.1" log = "0.4.22" rayon = "1.10.0" fxhash = { package = "rustc-hash", version = "2.1.1" } +crossbeam = "0.8.4" tracing = { version = "0.1.40", features = [] } tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } walkdir = "2.5.0" @@ -20,7 +21,6 @@ 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 71cdccdf6..818a0ef1b 100644 --- a/crates/oxide/src/cursor.rs +++ b/crates/oxide/src/cursor.rs @@ -1,49 +1,44 @@ use std::{ascii::escape_default, fmt::Display}; -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone)] pub struct Cursor<'a> { // The input we're scanning pub input: &'a [u8], // The location of the cursor in the input pub pos: usize, + + /// Is the cursor at the start of the input + pub at_start: bool, + + /// Is the cursor at the end of the input + pub at_end: bool, + + /// The previously consumed character + /// If `at_start` is true, this will be NUL + pub prev: u8, + + /// The current character + pub curr: u8, + + /// The upcoming character (if any) + /// If `at_end` is true, this will be NUL + pub next: u8, } impl<'a> Cursor<'a> { - #[inline(always)] pub fn new(input: &'a [u8]) -> Self { - Self { input, pos: 0 } - } - - /// The current byte at `pos`, or 0x00 if past the end. - #[inline(always)] - pub fn curr(&self) -> u8 { - if self.pos < self.input.len() { - unsafe { *self.input.get_unchecked(self.pos) } - } else { - 0x00 - } - } - - /// The next byte at `pos + 1`, or 0x00 if past the end. - #[inline(always)] - pub fn next(&self) -> u8 { - let next_pos = self.pos + 1; - if next_pos < self.input.len() { - unsafe { *self.input.get_unchecked(next_pos) } - } else { - 0x00 - } - } - - /// The previous byte at `pos - 1`, or 0x00 if at the start. - #[inline(always)] - pub fn prev(&self) -> u8 { - if self.pos > 0 { - unsafe { *self.input.get_unchecked(self.pos - 1) } - } else { - 0x00 - } + let mut cursor = Self { + input, + pos: 0, + at_start: true, + at_end: false, + prev: 0x00, + curr: 0x00, + next: 0x00, + }; + cursor.move_to(0); + cursor } pub fn advance_by(&mut self, amount: usize) { @@ -53,15 +48,38 @@ impl<'a> Cursor<'a> { #[inline(always)] pub fn advance(&mut self) { self.pos += 1; + + self.prev = self.curr; + self.curr = self.next; + self.next = *self + .input + .get(self.pos.saturating_add(1)) + .unwrap_or(&0x00u8); } #[inline(always)] pub fn advance_twice(&mut self) { self.pos += 2; + + self.prev = self.next; + self.curr = *self.input.get(self.pos).unwrap_or(&0x00u8); + self.next = *self + .input + .get(self.pos.saturating_add(1)) + .unwrap_or(&0x00u8); } pub fn move_to(&mut self, pos: usize) { - self.pos = pos.min(self.input.len()); + let len = self.input.len(); + let pos = pos.clamp(0, len); + + self.pos = pos; + self.at_start = pos == 0; + self.at_end = pos + 1 >= len; + + self.prev = *self.input.get(pos.wrapping_sub(1)).unwrap_or(&0x00u8); + self.curr = *self.input.get(pos).unwrap_or(&0x00u8); + self.next = *self.input.get(pos.saturating_add(1)).unwrap_or(&0x00u8); } } @@ -72,9 +90,9 @@ impl Display for Cursor<'_> { let pos = format!("{: >len_count$}", self.pos, len_count = len.len()); write!(f, "{}/{} ", pos, len)?; - if self.pos == 0 { + if self.at_start { write!(f, "S ")?; - } else if self.pos + 1 >= self.input.len() { + } else if self.at_end { write!(f, "E ")?; } else { write!(f, "M ")?; @@ -91,9 +109,9 @@ impl Display for Cursor<'_> { write!( f, "[{} {} {}]", - to_str(self.prev()), - to_str(self.curr()), - to_str(self.next()) + to_str(self.prev), + to_str(self.curr), + to_str(self.next) ) } } @@ -107,28 +125,36 @@ mod test { fn test_cursor() { let mut cursor = Cursor::new(b"hello world"); assert_eq!(cursor.pos, 0); - assert_eq!(cursor.prev(), 0x00); - assert_eq!(cursor.curr(), b'h'); - assert_eq!(cursor.next(), b'e'); + assert!(cursor.at_start); + assert!(!cursor.at_end); + assert_eq!(cursor.prev, 0x00); + assert_eq!(cursor.curr, b'h'); + assert_eq!(cursor.next, b'e'); cursor.advance_by(1); assert_eq!(cursor.pos, 1); - assert_eq!(cursor.prev(), b'h'); - assert_eq!(cursor.curr(), b'e'); - assert_eq!(cursor.next(), b'l'); + assert!(!cursor.at_start); + assert!(!cursor.at_end); + assert_eq!(cursor.prev, b'h'); + assert_eq!(cursor.curr, b'e'); + assert_eq!(cursor.next, b'l'); // Advancing too far should stop at the end cursor.advance_by(10); assert_eq!(cursor.pos, 11); - assert_eq!(cursor.prev(), b'd'); - assert_eq!(cursor.curr(), 0x00); - assert_eq!(cursor.next(), 0x00); + assert!(!cursor.at_start); + assert!(cursor.at_end); + assert_eq!(cursor.prev, b'd'); + assert_eq!(cursor.curr, 0x00); + assert_eq!(cursor.next, 0x00); // Can't advance past the end cursor.advance_by(1); assert_eq!(cursor.pos, 11); - assert_eq!(cursor.prev(), b'd'); - assert_eq!(cursor.curr(), 0x00); - assert_eq!(cursor.next(), 0x00); + assert!(!cursor.at_start); + assert!(cursor.at_end); + assert_eq!(cursor.prev, b'd'); + assert_eq!(cursor.curr, 0x00); + assert_eq!(cursor.next, 0x00); } } diff --git a/crates/oxide/src/extractor/arbitrary_property_machine.rs b/crates/oxide/src/extractor/arbitrary_property_machine.rs index a43253339..8ed3f1763 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,16 +227,10 @@ 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(), }; @@ -299,9 +293,6 @@ enum Class { #[bytes(b'/')] Slash, - #[bytes(b'!')] - Exclamation, - #[bytes(b' ', b'\t', b'\n', b'\r', b'\x0C')] Whitespace, @@ -378,9 +369,6 @@ mod tests { "[background:url(https://example.com?q={[{[([{[[2]]}])]}]})]", vec!["[background:url(https://example.com?q={[{[([{[[2]]}])]}]})]"], ), - // A property containing `!` at the top-level is invalid - ("[color:red!]", vec![]), - ("[color:red!important]", vec![]), ] { for wrapper in [ // No wrapper diff --git a/crates/oxide/src/extractor/arbitrary_value_machine.rs b/crates/oxide/src/extractor/arbitrary_value_machine.rs index cbef62c42..f5a612df1 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 e8d475557..6990e110b 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 2b8d3a13f..f34e56ed3 100644 --- a/crates/oxide/src/extractor/bracket_stack.rs +++ b/crates/oxide/src/extractor/bracket_stack.rs @@ -25,7 +25,6 @@ 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 a755cf02a..c82cc3707 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 7beb827ee..4e19c7111 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 a3d806a27..add0de4ac 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().to_vec(), extension); + let input = crate::scanner::pre_process_input(input.as_bytes(), extension); String::from_utf8(input).unwrap() } @@ -908,18 +908,6 @@ mod tests { ); } - // https://github.com/tailwindlabs/tailwindcss/issues/20233 - #[test] - fn test_template_toolkit_syntax() { - assert_extract_candidates_contains( - &pre_process_input( - r#"

"#, - "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 811bc9ded..22668a828 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 24dc027b2..7c847a2e2 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,19 +278,18 @@ 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 @@ -314,7 +313,7 @@ impl Machine for NamedUtilityMachine { // ^^ // ``` Class::Percent => { - if !matches!(cursor.prev().into(), Class::Number) { + if !matches!(cursor.prev.into(), Class::Number) { return self.restart(); } @@ -426,8 +425,6 @@ mod tests { // With number followed by dash or underscore ("text-title1-strong", vec!["text-title1-strong"]), ("text-title1_strong", vec!["text-title1_strong"]), - // With capital letter followed by number - ("text-titleV1-strong", vec!["text-titleV1-strong"]), // With trailing % sign ("from-15%", vec!["from-15%"]), // Arbitrary value with bracket notation diff --git a/crates/oxide/src/extractor/named_variant_machine.rs b/crates/oxide/src/extractor/named_variant_machine.rs index 285fe6ab5..b6de8220b 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 3fb4ce601..c53afd552 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,76 +95,7 @@ impl PreProcessor for Clojure { result[cursor.pos] = b' '; } // End of keyword. - _ 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()) => { + _ if !is_keyword_character(cursor.curr) => { result[cursor.pos] = b' '; break; } @@ -350,87 +281,4 @@ 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 7737a6b2d..87b89a2a9 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 ec8be0817..a131618ab 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.to_vec(), "rb"); + let replaced = pre_process_input(ruby_code, "rb"); result.replace_range(start..end, replaced); } @@ -190,7 +190,7 @@ impl PreProcessor for Haml { // digit. // E.g.: `bg-red-500.2xl:flex` // ^^^ - if cursor.prev().is_ascii_digit() && cursor.next().is_ascii_digit() { + if cursor.prev.is_ascii_digit() && cursor.next.is_ascii_digit() { let mut next_cursor = cursor.clone(); next_cursor.advance(); @@ -203,81 +203,6 @@ impl PreProcessor for Haml { } } - // Handle Ruby syntax with `%w[]` arrays embedded in Haml attribute hashes. E.g.: - // - // ```haml - // %div{class: %w[bg-blue-500 w-10 h-10]} - // ``` - // - // A `%` that follows a value is not a percent literal. E.g.: the `50%w` in - // `hit rate 50%w.` - b'%' if matches!(cursor.next(), b'w' | b'W') - && !cursor.prev().is_ascii_alphanumeric() - && !matches!(cursor.prev(), b'_' | b')' | b']' | b'}') => - { - // Boundary characters - let (open, close) = match cursor.input.get(cursor.pos + 2) { - Some(b'[') => (b'[', b']'), - Some(b'(') => (b'(', b')'), - Some(b'{') => (b'{', b'}'), - Some(b'<') => (b'<', b'>'), - - // Any other ASCII punctuation can be used as a custom delimiter - Some(&c) if c.is_ascii_punctuation() => (c, c), - - // Everything else is not a valid delimiter - _ => { - cursor.advance(); - continue; - } - }; - - result[cursor.pos] = b' '; // Replace `%` - cursor.advance(); - result[cursor.pos] = b' '; // Replace `w` - cursor.advance(); - result[cursor.pos] = b' '; // Replace the opening delimiter - cursor.advance(); - - // Paired delimiters can be nested as long as they are balanced. E.g.: - // `%w[foo[bar]baz]` produces a flat array. - let mut depth = 1_usize; - - while cursor.pos < len { - match cursor.curr() { - // Skip escaped characters, unless the backslash is the delimiter - // itself - b'\\' if close != b'\\' => { - // Use backslash to embed spaces in the strings. - if cursor.next() == b' ' { - result[cursor.pos] = b' '; - } - - cursor.advance(); - } - - // Start of a nested delimiter pair - c if c == open && open != close => depth += 1, - - // Closing delimiter - c if c == close => { - depth -= 1; - - // End of the literal, replace the closing delimiter with a space - if depth == 0 { - result[cursor.pos] = b' '; - break; - } - } - - // Everything else is valid content - _ => {} - } - - cursor.advance(); - } - } - // Replace following characters with spaces if they are not inside of brackets b'#' | b'=' if bracket_stack.is_empty() => { result[cursor.pos] = b' '; @@ -288,11 +213,11 @@ impl PreProcessor for Haml { if bracket_stack.is_empty() { result[cursor.pos] = b' '; } - bracket_stack.push(cursor.curr()); + bracket_stack.push(cursor.curr); } b')' | b']' | b'}' if !bracket_stack.is_empty() => { - bracket_stack.pop(cursor.curr()); + bracket_stack.pop(cursor.curr); // Replace closing bracket with a space if bracket_stack.is_empty() { @@ -332,7 +257,7 @@ impl Haml { // :url => { :action => "add", :id => product.id }, // :update => { :success => "cart", :failure => "error" } // ``` - let evaluation_type = cursor.curr(); + let evaluation_type = cursor.curr; let block_indentation_level = cursor .pos @@ -342,17 +267,17 @@ impl Haml { let mut last_newline_position = last_known_newline_position; // Consume until the end of the line first - while cursor.pos < len && cursor.curr() != b'\n' { + while cursor.pos < len && cursor.curr != b'\n' { cursor.advance(); } // Block is already done, aka just a line - if evaluation_type == b'=' && cursor.prev() != b',' { + if evaluation_type == b'=' && cursor.prev != b',' { return cursor.pos; } 'outer: while cursor.pos < len { - match cursor.curr() { + match cursor.curr { // Escape the next character b'\\' => { cursor.advance_twice(); @@ -364,7 +289,7 @@ impl Haml { last_newline_position = cursor.pos; // We are done with this block - if evaluation_type == b'=' && cursor.prev() != b',' { + if evaluation_type == b'=' && cursor.prev != b',' { break; } @@ -375,11 +300,11 @@ impl Haml { // Skip whitespace and compute the indentation level x if x.is_ascii_whitespace() => { // Find first non-whitespace character - while cursor.pos < len && cursor.curr().is_ascii_whitespace() { - if cursor.curr() == b'\n' { + while cursor.pos < len && cursor.curr.is_ascii_whitespace() { + if cursor.curr == b'\n' { last_newline_position = cursor.pos; - if evaluation_type == b'=' && cursor.prev() != b',' { + if evaluation_type == b'=' && cursor.prev != b',' { // We are done with this block break 'outer; } @@ -499,81 +424,6 @@ mod tests { ); } - // https://github.com/tailwindlabs/tailwindcss/issues/20386 - #[test] - fn test_embedded_ruby_percent_w_delimiters() { - for (input, expected) in [ - // %w[…] in an attribute hash - ( - "%div{class: %w[flex px-2.5]}", - "%div class: flex px-2.5 ", - ), - // %w<…> - ( - "%div{class: %w}", - "%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 cd457050c..809eb501e 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 deleted file mode 100644 index 488614ee5..000000000 --- a/crates/oxide/src/extractor/pre_processors/markdown.rs +++ /dev/null @@ -1,83 +0,0 @@ -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 1a04af5c9..7435c349c 100644 --- a/crates/oxide/src/extractor/pre_processors/mod.rs +++ b/crates/oxide/src/extractor/pre_processors/mod.rs @@ -2,30 +2,22 @@ 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 8e02b8f69..8d42e744a 100644 --- a/crates/oxide/src/extractor/pre_processors/pre_processor.rs +++ b/crates/oxide/src/extractor/pre_processors/pre_processor.rs @@ -25,33 +25,7 @@ pub trait PreProcessor: Sized + Default { } #[cfg(test)] - fn test_extract_exact(input: &str, expected: Vec<&str>) { - use crate::extractor::{Extracted, Extractor}; - - let input = input.as_bytes(); - - let processor = Self::default(); - let transformed = processor.process(input); - - let extracted = Extractor::new(&transformed).extract(); - - // Extract all candidates and css variables. - let candidates = extracted - .iter() - .filter_map(|x| match x { - Extracted::Candidate(bytes) => std::str::from_utf8(bytes).ok(), - Extracted::CssVariable(bytes) => std::str::from_utf8(bytes).ok(), - }) - .collect::>(); - - if candidates != expected { - dbg!(&candidates, &expected); - panic!("Extracted candidates do not match expected candidates"); - } - } - - #[cfg(test)] - fn test_extract_contains(input: &str, expected: Vec<&str>) { + fn test_extract_contains(input: &str, items: Vec<&str>) { use crate::extractor::{Extracted, Extractor}; let input = input.as_bytes(); @@ -72,7 +46,7 @@ pub trait PreProcessor: Sized + Default { // Ensure all items are present in the candidates. let mut missing = vec![]; - for item in &expected { + for item in &items { if !candidates.contains(item) { missing.push(item); } diff --git a/crates/oxide/src/extractor/pre_processors/pug.rs b/crates/oxide/src/extractor/pre_processors/pug.rs index 70dfe5683..7b44fcf5b 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 d9a57ab77..6bb1d4923 100644 --- a/crates/oxide/src/extractor/pre_processors/ruby.rs +++ b/crates/oxide/src/extractor/pre_processors/ruby.rs @@ -34,135 +34,51 @@ impl PreProcessor for Ruby { // Extract embedded template languages // https://viewcomponent.org/guide/templates.html#interpolations - // Only process if content is valid UTF-8, otherwise skip HEREDOC extraction - // but still perform the byte-level Ruby processing below - if let Ok(content_as_str) = std::str::from_utf8(content) { - let starts = TEMPLATE_START_REGEX - .captures_iter(content_as_str) - .collect::>(); - let ends = TEMPLATE_END_REGEX - .captures_iter(content_as_str) - .collect::>(); + let content_as_str = std::str::from_utf8(content).unwrap(); - for start in starts.iter() { - // The language for this block - let lang = start.get(1).unwrap().as_str(); + let starts = TEMPLATE_START_REGEX + .captures_iter(content_as_str) + .collect::>(); + let ends = TEMPLATE_END_REGEX + .captures_iter(content_as_str) + .collect::>(); - // The HEREDOC delimiter - let delimiter_start = start.get(2).unwrap().as_str(); + for start in starts.iter() { + // The language for this block + let lang = start.get(1).unwrap().as_str(); - // Where the "body" starts for the HEREDOC block - let body_start = start.get(0).unwrap().end(); + // The HEREDOC delimiter + let delimiter_start = start.get(2).unwrap().as_str(); - // Look through all of the ends to find a matching language - for end in ends.iter() { - // 1. This must appear after the start - let body_end = end.get(0).unwrap().start(); - if body_end < body_start { - continue; - } + // Where the "body" starts for the HEREDOC block + let body_start = start.get(0).unwrap().end(); - // The languages must match otherwise we haven't found the end - let delimiter_end = end.get(1).unwrap().as_str(); - if delimiter_end != delimiter_start { - continue; - } - - let body = &content_as_str[body_start..body_end]; - let replaced = - pre_process_input(body.as_bytes().to_vec(), &lang.to_ascii_lowercase()); - - result.replace_range(body_start..body_end, replaced); - break; + // Look through all of the ends to find a matching language + for end in ends.iter() { + // 1. This must appear after the start + let body_end = end.get(0).unwrap().start(); + if body_end < body_start { + continue; } + + // The languages must match otherwise we haven't found the end + let delimiter_end = end.get(1).unwrap().as_str(); + if delimiter_end != delimiter_start { + continue; + } + + let body = &content_as_str[body_start..body_end]; + let replaced = pre_process_input(body.as_bytes(), &lang.to_ascii_lowercase()); + + result.replace_range(body_start..body_end, replaced); + break; } } // Ruby extraction while cursor.pos < len { - match cursor.curr() { - b'"' => { - cursor.advance(); - - while cursor.pos < len { - match cursor.curr() { - // Escaped character, skip ahead to the next character - b'\\' => cursor.advance_twice(), - - // End of the string - b'"' => break, - - // Everything else is valid - _ => cursor.advance(), - }; - } - - cursor.advance(); - continue; - } - - b'\'' => { - cursor.advance(); - - while cursor.pos < len { - match cursor.curr() { - // Escaped character, skip ahead to the next character - b'\\' => cursor.advance_twice(), - - // End of the string - b'\'' => break, - - // Everything else is valid - _ => cursor.advance(), - }; - } - - cursor.advance(); - continue; - } - - // Replace comments in Ruby files - // - // Except for strict locals, these are defined in a `<%# locals: … %>`. Checking if - // the comment is preceded by a `%` should be enough without having to perform more - // parsing logic. Worst case we _do_ scan a few comments. - // - // We also want to skip interpolation syntax, which look like `#{…}`. - b'#' if !matches!(cursor.prev(), b'%') && !matches!(cursor.next(), b'{') => { - result[cursor.pos] = b' '; - cursor.advance(); - - while cursor.pos < len { - match cursor.curr() { - // End of the comment - b'\n' => break, - - // Everything else is part of the comment and replaced - _ => { - result[cursor.pos] = b' '; - cursor.advance(); - } - }; - } - - cursor.advance(); - continue; - } - - _ => {} - } - - // Looking for `%w`, `%W`, or `%p` - if cursor.curr() != b'%' || !matches!(cursor.next(), b'w' | b'W' | b'p') { - cursor.advance(); - continue; - } - - // A `%` that follows a value is a modulo operation, not a percent literal. E.g.: the - // `50%w` in `hit rate 50%w.` - if cursor.prev().is_ascii_alphanumeric() - || matches!(cursor.prev(), b'_' | b')' | b']' | b'}') - { + // Looking for `%w` or `%W` + if cursor.curr != b'%' && !matches!(cursor.next, b'w' | b'W') { cursor.advance(); continue; } @@ -170,17 +86,10 @@ impl PreProcessor for Ruby { cursor.advance_twice(); // Boundary character - let boundary = match cursor.curr() { + let boundary = match cursor.curr { b'[' => b']', b'(' => b')', b'{' => b'}', - b'<' => b'>', - b' ' => b'\n', - - // Any other ASCII punctuation can be used as a custom delimiter - c if c.is_ascii_punctuation() => c, - - // Everything else is not a valid delimiter _ => { cursor.advance(); continue; @@ -196,11 +105,11 @@ impl PreProcessor for Ruby { cursor.advance(); while cursor.pos < len { - match cursor.curr() { - // Skip escaped characters, unless the backslash is the delimiter itself - b'\\' if boundary != b'\\' => { + match cursor.curr { + // Skip escaped characters + b'\\' => { // Use backslash to embed spaces in the strings. - if cursor.next() == b' ' { + if cursor.next == b' ' { result[cursor.pos] = b' '; } @@ -209,36 +118,20 @@ impl PreProcessor for Ruby { // Start of a nested bracket b'[' | b'(' | b'{' => { - bracket_stack.push(cursor.curr()); - } - - // Start of a nested `<…>`, which Ruby allows inside a `%w<…>` literal - b'<' if boundary == b'>' => { - bracket_stack.push(cursor.curr()); + bracket_stack.push(cursor.curr); } // End of a nested bracket b']' | b')' | b'}' if !bracket_stack.is_empty() => { - if !bracket_stack.pop(cursor.curr()) { - // Unbalanced - cursor.advance(); - } - } - - // End of a nested `<…>` - b'>' if boundary == b'>' && !bracket_stack.is_empty() => { - if !bracket_stack.pop(cursor.curr()) { + if !bracket_stack.pop(cursor.curr) { // Unbalanced cursor.advance(); } } // End of the pattern, replace the boundary character with a space - _ if cursor.curr() == boundary => { - if boundary != b'\n' { - result[cursor.pos] = b' '; - } - + _ if cursor.curr == boundary => { + result[cursor.pos] = b' '; break; } @@ -280,69 +173,12 @@ mod tests { "%w(flex data-[state=pending]:bg-(--my-color) flex-col)", "%w flex data-[state=pending]:bg-(--my-color) flex-col ", ), - // %w<…> - ("%w", "%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); } @@ -375,30 +211,6 @@ mod tests { "%w(flex data-[state=pending]:bg-(--my-color) flex-col)", vec!["flex", "data-[state=pending]:bg-(--my-color)", "flex-col"], ), - // %w<…> - ("%w", 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); } @@ -450,79 +262,4 @@ mod tests { "#; Ruby::test_extract_contains(input, vec!["z-1", "z-2", "z-3"]); } - - // https://github.com/tailwindlabs/tailwindcss/issues/19728 - #[test] - fn test_interpolated_expressions() { - let input = r#" - def width_class(width = nil) - <<~STYLE_CLASS - #{width || 'w-100'} - STYLE_CLASS - end - "#; - - Ruby::test_extract_contains(input, vec!["w-100"]); - } - - // https://github.com/tailwindlabs/tailwindcss/issues/19239 - #[test] - fn test_skip_comments() { - let input = r#" - # From activerecord-8.1.1/lib/active_record/errors.rb:147 - # Rails uses RDoc cross-reference syntax in inline documentation: - # {ActiveRecord::Base#save!}[rdoc-ref:Persistence#save!] - "#; - - // Nothing should be extracted from comments, so expect an empty array. - Ruby::test_extract_exact(input, vec![]); - } - - // https://github.com/tailwindlabs/tailwindcss/issues/19481 - #[test] - fn test_strict_locals() { - // Strict locals are defined in a `<%# locals: … %>`, but the `#` looks like a comment - // which we should not ignore in this case. - let input = r#" - <%# locals: (css: "text-amber-600") %> - <% more_css = "text-sky-500" %> - -

- 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 deleted file mode 100644 index 4fa53f793..000000000 --- a/crates/oxide/src/extractor/pre_processors/rust.rs +++ /dev/null @@ -1,238 +0,0 @@ -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 5683858d5..6d41e5a09 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,74 +65,17 @@ impl PreProcessor for Slim { // class=%w[bg-blue-500 w-10 h-10] // ] // ``` - // - // A `%` that follows a value is not a percent literal. E.g.: the `50%w` in - // `hit rate 50%w.` - b'%' if matches!(cursor.next(), b'w' | b'W') - && !cursor.prev().is_ascii_alphanumeric() - && !matches!(cursor.prev(), b'_' | b')' | b']' | b'}') => + b'%' if matches!(cursor.next, b'w' | b'W') + && matches!(cursor.input.get(cursor.pos + 2), Some(b'[' | b'(' | b'{')) => { - // Boundary characters - let (open, close) = match cursor.input.get(cursor.pos + 2) { - Some(b'[') => (b'[', b']'), - Some(b'(') => (b'(', b')'), - Some(b'{') => (b'{', b'}'), - Some(b'<') => (b'<', b'>'), - - // Any other ASCII punctuation can be used as a custom delimiter - Some(&c) if c.is_ascii_punctuation() => (c, c), - - // Everything else is not a valid delimiter - _ => { - cursor.advance(); - continue; - } - }; - result[cursor.pos] = b' '; // Replace `%` cursor.advance(); result[cursor.pos] = b' '; // Replace `w` cursor.advance(); - result[cursor.pos] = b' '; // Replace the opening delimiter - cursor.advance(); - - // Paired delimiters can be nested as long as they are balanced. E.g.: - // `%w[foo[bar]baz]` produces a flat array. - let mut depth = 1_usize; - - while cursor.pos < len { - match cursor.curr() { - // Skip escaped characters, unless the backslash is the delimiter - // itself - b'\\' if close != b'\\' => { - // Use backslash to embed spaces in the strings. - if cursor.next() == b' ' { - result[cursor.pos] = b' '; - } - - cursor.advance(); - } - - // Start of a nested delimiter pair - c if c == open && open != close => depth += 1, - - // Closing delimiter - c if c == close => { - depth -= 1; - - // End of the literal, replace the closing delimiter with a space - if depth == 0 { - result[cursor.pos] = b' '; - break; - } - } - - // Everything else is valid content - _ => {} - } - - cursor.advance(); - } + result[cursor.pos] = b' '; // Replace `[` or `(` or `{` + bracket_stack.push(cursor.curr); + cursor.advance(); // Move past the bracket + continue; } // Any `[` preceded by an alphanumeric value will not be part of a candidate. @@ -153,10 +96,10 @@ impl PreProcessor for Slim { // Instead of listing all boundary characters, let's list the characters we know // will be invalid instead. b'[' if bracket_stack.is_empty() - && matches!(cursor.prev(), b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9') => + && matches!(cursor.prev, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9') => { result[cursor.pos] = b' '; - bracket_stack.push(cursor.curr()); + bracket_stack.push(cursor.curr); } // In Slim the class name shorthand can be followed by a parenthesis. E.g.: @@ -171,17 +114,17 @@ impl PreProcessor for Slim { // // However, we also need to make sure that we keep the parens that are part of the // utility class. E.g.: `bg-(--my-color)`. - b'(' if bracket_stack.is_empty() && !matches!(cursor.prev(), b'-' | b'/') => { + b'(' if bracket_stack.is_empty() && !matches!(cursor.prev, b'-' | b'/') => { result[cursor.pos] = b' '; - bracket_stack.push(cursor.curr()); + bracket_stack.push(cursor.curr); } b'(' | b'[' | b'{' => { - bracket_stack.push(cursor.curr()); + bracket_stack.push(cursor.curr); } b')' | b']' | b'}' if !bracket_stack.is_empty() => { - bracket_stack.pop(cursor.curr()); + bracket_stack.pop(cursor.curr); } // Consume everything else @@ -373,77 +316,16 @@ mod tests { ] "#; - let expected = " - div \n class= bg-blue-500 w-10 h-10 \n ] - div \n class= w-10 bg-green-500 h-10 \n ] - "; + let expected = r#" + div + class= bg-blue-500 w-10 h-10] + ] + div + class= w-10 bg-green-500 h-10] + ] + "#; Slim::test(input, expected); Slim::test_extract_contains(input, vec!["bg-blue-500", "bg-green-500", "w-10", "h-10"]); } - - // https://github.com/tailwindlabs/tailwindcss/issues/20386 - #[test] - fn test_embedded_ruby_percent_w_delimiters() { - for (input, expected) in [ - // %w<…>, Slim only counts `[({` nesting in attribute values, so the code must be - // wrapped in parentheses to contain spaces - ( - "div class=(%w)", - "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 deleted file mode 100644 index 6456a2694..000000000 --- a/crates/oxide/src/extractor/pre_processors/template_toolkit.rs +++ /dev/null @@ -1,52 +0,0 @@ -use crate::cursor; -use crate::extractor::pre_processors::pre_processor::PreProcessor; - -#[derive(Debug, Default)] -pub struct TemplateToolkit; - -impl PreProcessor for TemplateToolkit { - fn process(&self, content: &[u8]) -> Vec { - 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 56a38cbe3..b233f3b8b 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,6 +7,7 @@ .relative ^^^^^^^^ - # Blurred background star + ^^^^^^^^^^ ^^^^ .absolute.left-0.z-0{ class: "-top-[400px] -right-[400px]" } ^^^^^^^^ ^^^^^^ ^^^ ^^^^^ ^^^^^^^^^^^^ ^^^^^^^^^^^^^^ .flex.justify-end.blur-3xl @@ -74,8 +75,8 @@ ^^^^^^^^^^ ^^^^^^ libraries, our components are designed to be fast, flexible, and easy to ^^^ ^^^^^^^^^^ ^^^ ^^^^^^^^ ^^ ^^ ^^^ ^^^^ ^^ - use directly in Ruby on Rails! - ^^^ ^^^^^^^^ ^^ ^^ + use directy in Ruby on Rails! + ^^^ ^^^^^^^ ^^ ^^ = doc_example(css: "mt-8 lg:mt-0 lg:basis-3/5 h-44") do ^^^ ^^^^ ^^^^^^^ ^^^^^^^^^^^^ ^^^^ ^^ @@ -101,7 +102,7 @@ %p.mt-2 ^^^^ - Utilize + Utlize = daisy_link("HAML", "https://haml.info/", target: "_blank") ^^^^^^ so your views are simple, concise, and easy to understand. @@ -119,8 +120,8 @@ %p.mt-2 ^^^^ :markdown - **PLUS!** You can utilize filters like _Markdown_, CoffeeScript, - ^^^ ^^^^^^^ ^^^^^^^ ^^^^ + **PLUS!** You can utlize filters like _Markdown_, CoffeeScript, + ^^^ ^^^^^^ ^^^^^^^ ^^^^ Textile, and many more! ^^^ ^^^^ ^^^^^ @@ -195,6 +196,7 @@ ^^^^^^^^ ^^^^ ^^^ ^^^^ ^^ :escaped - # app/components/character_component.html.haml + ^^^^ = part(:component) do ^^ = part(:head) 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 8f9b61941..38e17b134 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 directly in Ruby on Rails! + use directy in Ruby on Rails! = doc_example(css: "mt-8 lg:mt-0 lg:basis-3/5 h-44") do .flex.flex-col.sm:flex-row.items-center.gap-4 @@ -58,7 +58,7 @@ Simple, Concise Views %p.mt-2 - Utilize + Utlize = daisy_link("HAML", "https://haml.info/", target: "_blank") so your views are simple, concise, and easy to understand. @@ -69,7 +69,7 @@ %p.mt-2 :markdown - **PLUS!** You can utilize filters like _Markdown_, CoffeeScript, + **PLUS!** You can utlize filters like _Markdown_, CoffeeScript, Textile, and many more! .mt-8.xl:mt-0 diff --git a/crates/oxide/src/extractor/pre_processors/twig.rs b/crates/oxide/src/extractor/pre_processors/twig.rs deleted file mode 100644 index b6f4a3319..000000000 --- a/crates/oxide/src/extractor/pre_processors/twig.rs +++ /dev/null @@ -1,179 +0,0 @@ -use crate::cursor; -use crate::extractor::pre_processors::pre_processor::PreProcessor; - -#[derive(Debug, Default)] -pub struct Twig; - -impl PreProcessor for Twig { - fn process(&self, content: &[u8]) -> Vec { - 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 ae037435c..119e2a3d2 100644 --- a/crates/oxide/src/extractor/pre_processors/vue.rs +++ b/crates/oxide/src/extractor/pre_processors/vue.rs @@ -15,15 +15,13 @@ impl PreProcessor for Vue { fn process(&self, content: &[u8]) -> Vec { let mut result = content.to_vec(); - // Only process template tags if content is valid UTF-8 - if let Ok(content_as_str) = std::str::from_utf8(content) { - for (_, [lang, body]) in TEMPLATE_REGEX - .captures_iter(content_as_str) - .map(|c| c.extract()) - { - let replaced = pre_process_input(body.as_bytes().to_vec(), lang); - result = result.replace(body, replaced); - } + let content_as_str = std::str::from_utf8(content).unwrap(); + for (_, [lang, body]) in TEMPLATE_REGEX + .captures_iter(content_as_str) + .map(|c| c.extract()) + { + let replaced = pre_process_input(body.as_bytes(), lang); + result = result.replace(body, replaced); } result @@ -45,16 +43,4 @@ mod tests { Vue::test_extract_contains(input, vec!["bg-neutral-900", "text-red-500"]); } - - #[test] - fn test_invalid_utf8_does_not_panic() { - // Invalid UTF-8 sequence: 0x80 is a continuation byte without a leading byte - let invalid_utf8: &[u8] = &[0x80, 0x81, 0x82]; - - let processor = Vue::default(); - - // Should not panic, just return the input unchanged - let result = processor.process(invalid_utf8); - assert_eq!(result, invalid_utf8); - } } diff --git a/crates/oxide/src/extractor/string_machine.rs b/crates/oxide/src/extractor/string_machine.rs index 4fad49faa..9148a1fbf 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 c116ec4e4..b9b1038cd 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 9ec2adc09..23aa61798 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 54930548c..488e61bd6 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 6cfdd49cc..9dc340fba 100644 --- a/crates/oxide/src/scanner/detect_sources.rs +++ b/crates/oxide/src/scanner/detect_sources.rs @@ -30,9 +30,11 @@ fn sort_by_dir_and_name(a: &DirEntry, z: &DirEntry) -> Ordering { pub fn resolve_globs( base: PathBuf, - dirs: &FxHashSet, + dirs: &[PathBuf], 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())); @@ -70,7 +72,7 @@ pub fn resolve_globs( continue; } - if !dirs.contains(path) { + if !allowed_paths.contains(path) { let mut path = path; while let Some(parent) = path.parent() { if parent == base { @@ -111,8 +113,7 @@ pub fn resolve_globs( continue; } - if !dirs.contains(path) { - it.skip_current_dir(); + if !allowed_paths.contains(path) { continue; } diff --git a/crates/oxide/src/scanner/fixtures/ignored-content-dirs.txt b/crates/oxide/src/scanner/fixtures/ignored-content-dirs.txt index 79f01ecc1..e802039a3 100644 --- a/crates/oxide/src/scanner/fixtures/ignored-content-dirs.txt +++ b/crates/oxide/src/scanner/fixtures/ignored-content-dirs.txt @@ -1,15 +1,13 @@ .git .hg -.jj -.next -.parcel-cache -.pnpm-store -.svelte-kit .svn -.turbo -.venv -.vercel -.yarn -__pycache__ node_modules +.yarn +.venv venv +.next +.turbo +.parcel-cache +__pycache__ +.svelte-kit +.pnpm-store diff --git a/crates/oxide/src/scanner/fixtures/ignored-files.txt b/crates/oxide/src/scanner/fixtures/ignored-files.txt index 1e8f1029f..d2d231ec7 100644 --- a/crates/oxide/src/scanner/fixtures/ignored-files.txt +++ b/crates/oxide/src/scanner/fixtures/ignored-files.txt @@ -2,5 +2,3 @@ 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 deleted file mode 100644 index dd4c1d071..000000000 --- a/crates/oxide/src/scanner/init_tracing.rs +++ /dev/null @@ -1,102 +0,0 @@ -use std::fs::OpenOptions; -use std::io::{self, Write}; -use std::path::Path; -use std::sync::{self, Arc, Mutex}; -use tracing_subscriber::fmt::writer::BoxMakeWriter; - -pub static SHOULD_TRACE: sync::LazyLock = 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 b7da9aa17..469c58146 100644 --- a/crates/oxide/src/scanner/mod.rs +++ b/crates/oxide/src/scanner/mod.rs @@ -1,6 +1,5 @@ pub mod auto_source_detection; pub mod detect_sources; -pub mod init_tracing; pub mod sources; use crate::extractor::{Extracted, Extractor}; @@ -15,12 +14,15 @@ use bstr::ByteSlice; use fast_glob::glob_match; use fxhash::{FxHashMap, FxHashSet}; use ignore::{gitignore::GitignoreBuilder, WalkBuilder}; -use init_tracing::{init_tracing, SHOULD_TRACE}; use rayon::prelude::*; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs::OpenOptions; +use std::io::{self, Write}; use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex}; +use std::sync::{self, Arc, Mutex}; use std::time::SystemTime; use tracing::event; +use tracing_subscriber::fmt::writer::BoxMakeWriter; // @source "some/folder"; // This is auto source detection // @source "some/folder/**/*"; // This is auto source detection @@ -32,6 +34,70 @@ use tracing::event; // // @source "do-include-me.bin"; // `.bin` is typically ignored, but now it's explicit so should be included // @source "git-ignored.html"; // A git ignored file that is listed explicitly, should be scanned +static SHOULD_TRACE: sync::LazyLock = 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 { @@ -63,33 +129,26 @@ 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: FxHashSet, + files: Vec, /// All directories, sub-directories, etc… we saw during source detection - dirs: FxHashSet, + dirs: Vec, /// 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 { @@ -104,6 +163,7 @@ impl Scanner { } let sources = Sources::new(public_source_entries_to_private_source_entries(sources)); + if *SHOULD_TRACE { event!(tracing::Level::INFO, "Optimized sources:"); for source in sources.iter() { @@ -111,27 +171,29 @@ impl Scanner { } } - let walker = create_walker(&sources); - Self { - sources, - walker, + sources: sources.clone(), + walker: create_walker(sources), ..Default::default() } } pub fn scan(&mut self) -> Vec { - self.sources_scanned = false; + self.scan_sources(); - let (scanned_blobs, css_files, files) = self.discover_sources(); + // TODO: performance improvement, bail early if we don't have any changed content + // if self.changed_content.is_empty() { + // return vec![]; + // } - self.extract_candidates(scanned_blobs, css_files); - self.scanned_files = files; + let _new_candidates = self.extract_candidates(); - // Return all candidates sorted - let mut result = self.candidates.iter().cloned().collect::>(); - result.par_sort_unstable(); - result + // 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 } #[tracing::instrument(skip_all)] @@ -146,7 +208,7 @@ impl Scanner { // Raw content can be parsed directly, no need to verify if the file exists and is allowed // to be scanned. - let mut content_to_scan: Vec = changed_contents; + self.changed_content.extend(changed_contents); // Fully resolve all files let changed_files = changed_files @@ -170,7 +232,7 @@ impl Scanner { }); // All known files are allowed to be scanned - content_to_scan.extend(known_files); + self.changed_content.extend(known_files); // Figure out if the new unknown files are allowed to be scanned if !new_unknown_files.is_empty() { @@ -181,11 +243,6 @@ impl Scanner { continue; } - // The walked path can contain symlinks, while the changed files have already - // been canonicalized. Lazily canonicalize the walked path so we can compare - // the real paths as well. - let mut canonical_path: Option = None; - let mut drop_file_indexes = vec![]; for (idx, changed_file) in new_unknown_files.iter().enumerate().rev() { let ChangedContent::File(file, _) = changed_file else { @@ -194,17 +251,9 @@ impl Scanner { // When the file is found on disk it means that all the rules pass. We can // extract the current file and remove it from the list of passed in files. - let matches = file == path - || (file.file_name() == path.file_name() && { - if canonical_path.is_none() { - canonical_path = dunce::canonicalize(path).ok(); - } - canonical_path.as_deref() == Some(file.as_path()) - }); - - if matches { - self.files.insert(path.to_path_buf()); // Track for future use - content_to_scan.push(changed_file.clone()); // Track for parsing + if file == path { + self.files.push(path.to_path_buf()); // Track for future use + self.changed_content.push(changed_file.clone()); // Track for parsing drop_file_indexes.push(idx); } } @@ -225,17 +274,18 @@ impl Scanner { } } - // Read all content into blobs for extraction - let blobs = read_all_files(content_to_scan); - self.extract_candidates(blobs, vec![]) + self.extract_candidates() } #[tracing::instrument(skip_all)] - fn extract_candidates(&mut self, blobs: Vec>, css_files: Vec) -> Vec { - // Extract all candidates from the pre-read blobs - let mut new_candidates = parse_all_blobs(blobs); + 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)); // 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 @@ -247,23 +297,63 @@ impl Scanner { new_candidates.extend(css_variables); } - // Only keep candidates we haven't seen before - for existing in self.candidates.iter() { - new_candidates.remove(existing); - } + // Only compute the new candidates and ignore the ones we already have. This is for + // subsequent calls to prevent serializing the entire set of candidates every time. + let mut new_candidates = new_candidates + .into_par_iter() + .filter(|candidate| !self.candidates.contains(candidate)) + .collect::>(); + + new_candidates.par_sort_unstable(); // Track new candidates for subsequent calls - self.candidates.extend(new_candidates.iter().cloned()); + self.candidates.par_extend(new_candidates.clone()); - let mut result: Vec = new_candidates.into_iter().collect(); - result.par_sort_unstable(); + new_candidates + } - result + #[tracing::instrument(skip_all)] + fn scan_sources(&mut self) { + let Some(walker) = &mut self.walker else { + return; + }; + + for entry in walker.build().filter_map(Result::ok) { + let path = entry.into_path(); + let Ok(metadata) = path.metadata() else { + continue; + }; + if metadata.is_dir() { + self.dirs.push(path); + } else if metadata.is_file() { + let extension = path + .extension() + .and_then(|x| x.to_str()) + .unwrap_or_default(); // In case the file has no extension + + match extension { + // Special handing for CSS files, we don't want to extract candidates from + // these files, but we do want to extract used CSS variables. + "css" => { + self.css_files.push(path.clone()); + } + _ => { + self.changed_content.push(ChangedContent::File( + path.to_path_buf(), + extension.to_owned(), + )); + } + } + + self.extensions.insert(extension.to_owned()); + self.files.push(path); + } + } } #[tracing::instrument(skip_all)] pub fn get_files(&mut self) -> Vec { - let _ = self.discover_sources(); + self.scan_sources(); self.files .par_iter() @@ -271,18 +361,13 @@ impl Scanner { .collect() } - #[tracing::instrument(skip_all)] - pub fn get_scanned_files(&self) -> Vec { - self.scanned_files.clone() - } - #[tracing::instrument(skip_all)] pub fn get_globs(&mut self) -> Vec { if let Some(globs) = &self.globs { return globs.clone(); } - let _ = self.discover_sources(); + self.scan_sources(); let mut globs = vec![]; for source in self.sources.iter() { @@ -356,167 +441,21 @@ impl Scanner { let i = s.as_ptr() as usize - offset; let original = &original_content[i..i + s.len()]; if original.contains_str("-[]") { - return String::from_utf8(original.to_vec()) - .ok() - .map(|candidate| (candidate, i)); + return Some(unsafe { + (String::from_utf8_unchecked(original.to_vec()), i) + }); } - String::from_utf8(s.to_vec()) - .ok() - .map(|candidate| (candidate, i)) + // SAFETY: When we parsed the candidates, we already guaranteed that the byte + // slices are valid, therefore we don't have to re-check here when we want to + // convert it back to a string. + Some(unsafe { (String::from_utf8_unchecked(s.to_vec()), i) }) } _ => None, }) .collect() } - - #[tracing::instrument(skip_all)] - fn discover_sources(&mut self) -> (Vec>, Vec, 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> { @@ -535,28 +474,24 @@ 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: Vec, extension: &str) -> Vec { +pub fn pre_process_input(content: &[u8], 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" | "jsonl" | "ndjson" => Json.process(&content), - "md" | "mdx" => Markdown.process(&content), - "pug" => Pug.process(&content), - "rb" | "erb" => Ruby.process(&content), - "slim" | "slang" => Slim.process(&content), - "svelte" => Svelte.process(&content), - "rs" => Rust.process(&content), - "tt" | "tt2" | "tx" => TemplateToolkit.process(&content), - "twig" => Twig.process(&content), - "vue" => Vue.process(&content), - _ => content, + "clj" | "cljs" | "cljc" => Clojure.process(content), + "heex" | "eex" | "ex" | "exs" => Elixir.process(content), + "cshtml" | "razor" => Razor.process(content), + "haml" => Haml.process(content), + "json" => Json.process(content), + "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(), } } @@ -575,23 +510,23 @@ fn read_all_files(changed_content: Vec) -> Vec> { } #[tracing::instrument(skip_all)] -fn extract_css_variables(blobs: Vec>) -> FxHashSet { +fn extract_css_variables(blobs: Vec>) -> Vec { extract(blobs, |mut extractor| { extractor.extract_variables_from_css() }) } #[tracing::instrument(skip_all)] -fn parse_all_blobs(blobs: Vec>) -> FxHashSet { +fn parse_all_blobs(blobs: Vec>) -> Vec { extract(blobs, |mut extractor| extractor.extract()) } #[tracing::instrument(skip_all)] -fn extract(blobs: Vec>, handle: H) -> FxHashSet +fn extract(blobs: Vec>, handle: H) -> Vec where H: Fn(Extractor) -> Vec + std::marker::Sync, { - blobs + let mut result: Vec<_> = blobs .par_iter() .flat_map(|blob| blob.par_split(|x| *x == b'\n')) .filter_map(|blob| { @@ -616,113 +551,26 @@ where a }) .into_iter() - .filter_map(|s| String::from_utf8(s.to_vec()).ok()) - .collect() + .map(|s| unsafe { String::from_utf8_unchecked(s.to_vec()) }) + .collect(); + + // SAFETY: Unstable sort is faster and in this scenario it's also safe because we are + // guaranteed to have unique candidates. + result.par_sort_unstable(); + + result } -#[derive(Debug)] -enum WalkEntry { - Dir(PathBuf), - File { - path: PathBuf, - mtime: Option, - - /// 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. +/// Create a walker for the given sources to detect all the files that we have to scan. /// -/// This is the common setup shared between the full walker (with mtime tracking for re-scans) -/// and the parallel walker (without mtime tracking for the initial scan). -fn create_walker(sources: &Sources) -> Option { +/// 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(); let mut other_roots: FxHashSet<&PathBuf> = FxHashSet::default(); let mut first_root: Option<&PathBuf> = None; - - 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])); - } - }; + let mut ignores: BTreeMap<&PathBuf, BTreeSet> = Default::default(); for source in sources.iter() { match source { @@ -734,7 +582,7 @@ fn create_walker(sources: &Sources) -> Option { } } SourceEntry::Pattern { base, pattern } => { - let pattern = pattern.to_owned(); + let mut pattern = pattern.to_string(); if first_root.is_none() { first_root = Some(base); @@ -743,19 +591,35 @@ 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: - emit(base, format!("!{}", pattern)); + ignores + .entry(base) + .or_default() + .insert(format!("!{}", pattern)); } else { // Assumption: the pattern we receive will already be brace expanded. So // `*.{html,jsx}` will result in two separate patterns: `*.html` and `*.jsx`. if let Some(extension) = Path::new(&pattern).extension() { // Extend auto source detection to include the extension - emit(base, format!("!*.{}", extension.to_string_lossy())); + ignores + .entry(base) + .or_default() + .insert(format!("!*.{}", extension.to_string_lossy())); } } } SourceEntry::Ignored { base, pattern } => { - emit(base, pattern.to_owned()); + let mut pattern = pattern.to_string(); + // Ensure that the pattern is pinned to the base path. + if !pattern.starts_with("/") { + pattern = format!("/{pattern}"); + } + ignores.entry(base).or_default().insert(pattern); } SourceEntry::External { base } => { if first_root.is_none() { @@ -765,10 +629,16 @@ fn create_walker(sources: &Sources) -> Option { } // External sources should take precedence even over git-ignored files: - emit(base, "!/**/*".to_owned()); + ignores + .entry(base) + .or_default() + .insert(format!("!{}", "/**/*")); // External sources should still disallow binary extensions: - emit(base, BINARY_EXTENSIONS_GLOB.clone()); + ignores + .entry(base) + .or_default() + .insert(BINARY_EXTENSIONS_GLOB.clone()); } } } @@ -845,61 +715,75 @@ fn create_walker(sources: &Sources) -> Option { builder.add_gitignore(ignore); } - // 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(); + builder.filter_entry({ + move |entry| { + let path = entry.path(); - let pattern_sources: Vec<(PathBuf, String)> = sources - .iter() - .filter_map(|source| match source { - SourceEntry::Pattern { base, pattern } => Some((base.into(), pattern.into())), - _ => None, - }) - .collect(); - - // Source pattern matching filter (lock-free, safe for parallel walking) - builder.filter_entry(move |entry| { - let path = entry.path(); - - // Ensure the entries are matching any of the provided source patterns (this is - // necessary for manual-patterns that can filter the file extension) - if path.is_file() { - let mut matches = false; - - for base in &auto_bases { - if path.starts_with(base) { - matches = true; - break; - } - } - - if !matches { - for (base, pattern) in &pattern_sources { - let remainder = path.strip_prefix(base); - if remainder.is_ok_and(|remainder| { - let mut path_str = remainder.to_string_lossy().to_string(); - if !path_str.starts_with("/") { - path_str = format!("/{path_str}"); + // Ensure the entries are matching any of the provided source patterns (this is + // necessary for manual-patterns that can filter the file extension) + if path.is_file() { + let mut matches = false; + for source in sources.iter() { + match source { + SourceEntry::Auto { base } | SourceEntry::External { base } => { + if path.starts_with(base) { + matches = true; + break; + } } - glob_match(pattern, path_str.as_bytes()) - }) { - matches = true; - break; + SourceEntry::Pattern { base, pattern } => { + let mut pattern = pattern.to_string(); + // Ensure that the pattern is pinned to the base path. + if !pattern.starts_with("/") { + pattern = format!("/{pattern}"); + } + + // Check if path starts with base, if so, remove the prefix and check the remainder against the pattern + let remainder = path.strip_prefix(base); + if remainder.is_ok_and(|remainder| { + let mut path_str = remainder.to_string_lossy().to_string(); + if !path_str.starts_with("/") { + path_str = format!("/{path_str}"); + } + glob_match(pattern, path_str.as_bytes()) + }) { + matches = true; + break; + } + } + _ => {} } } + + if !matches { + return false; + } } - if !matches { - return false; + let mut mtimes = mtimes.lock().unwrap(); + let current_time = match entry.metadata() { + Ok(metadata) if metadata.is_file() => { + if let Ok(time) = metadata.modified() { + Some(time) + } else { + None + } + } + _ => None, + }; + + let previous_time = + current_time.and_then(|time| mtimes.insert(entry.clone().into_path(), time)); + + match (current_time, previous_time) { + (Some(current), Some(prev)) if prev == current => false, + _ => { + event!(tracing::Level::INFO, "Discovering {:?}", path); + + true + } } } - - true }); Some(builder) diff --git a/crates/oxide/src/scanner/sources.rs b/crates/oxide/src/scanner/sources.rs index 53c09387f..f28b16a49 100644 --- a/crates/oxide/src/scanner/sources.rs +++ b/crates/oxide/src/scanner/sources.rs @@ -1,8 +1,7 @@ +use crate::glob::split_pattern; use crate::GlobEntry; use bexpand::Expression; -use fxhash::{FxHashMap, FxHashSet}; -use ignore::gitignore::Gitignore; -use std::path::{Component, Path, PathBuf}; +use std::path::PathBuf; use tracing::{event, Level}; use super::auto_source_detection::IGNORED_CONTENT_DIRS; @@ -50,14 +49,13 @@ pub enum SourceEntry { /// ``` Ignored { base: PathBuf, pattern: String }, - /// External sources are directories that are ignored (by us or .gitignore rules), but should be - /// included bypassing the default ignore rules. + /// External sources are sources outside of your git root which should not + /// follow gitignore rules. /// /// Represented by: /// /// ```css /// @source "../node_modules/my-lib";` - /// @source "../node_modules/my-lib/**/*";` /// ``` External { base: PathBuf }, } @@ -77,164 +75,6 @@ impl Sources { } } -/// When dealing with a pattern, then it could be that we end up with: -/// -/// ```json -/// { base: '/some/folder', pattern: 'foo.ts' } -/// ``` -/// -/// If we just emit `!foo.ts` for the `/some/folder` path, then _everything_ else in that folder -/// would still be walked (but the result will be ignored). -/// -/// Instead, we have to ensure that we ignore everything in that folder _except_ for the `foo.ts` -/// pattern. -/// -/// This should be equivalent to: -/// ```gitignore -/// * -/// !foo.ts -/// ``` -/// -/// However, we have to be careful that we don't start ignoring files/folders that already exist. -/// ```css -/// @source "./some/folder/foo.ts"; -/// @source "./some/folder/bar.ts"; -/// ``` -/// Would result in: -/// ```json -/// { base: '/some/folder', pattern: 'foo.ts' } -/// { base: '/some/folder', pattern: 'bar.ts' } -/// ``` -/// -/// If we were to blindly emit `*` for each pattern, then the `.gitignore` equivalent would look like -/// this: -/// ```gitignore -/// * -/// !foo.ts -/// * -/// !bar.ts -/// ``` -/// -/// This would result in ignoring the `foo.ts` file as well. Therefore we only want to insert -/// this `*` pattern when nothing else exists yet. -/// -/// There is another problem that we need to solve. Let's say you have a pattern that contains a `*` -/// in the pattern: -/// ```css -/// @source './src/ba*/*.html'; -/// ``` -/// -/// This would result in -/// ```json -/// { base: '/src', pattern: '/ba*/*.html' } -/// ``` -/// -/// If we now inject the `*` pattern for the `/src` folder, then we wouldn't scan any `ba*` folders -/// (e.g. `bar` or `baz`). For this, we have to make sure that we add inverse patterns for these -/// folders. This would essentially result in: -/// ```gitignore -/// * ← ignore everything -/// !/ba*/ ← except for the `ba*/` pattern, so we scan these folders -/// !/ba*/*.html ← then ensure we scan the `*.html` files in it as well -/// ``` -/// -fn expand_restricted_patterns(sources: Vec) -> 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. @@ -262,456 +102,72 @@ impl PublicSourceEntry { /// resolved path. pub fn optimize(&mut self) { // Resolve base path immediately - let Ok(mut base) = dunce::canonicalize(&self.base) else { + let Ok(base) = dunce::canonicalize(&self.base) else { event!(Level::ERROR, "Failed to resolve base: {:?}", self.base); return; }; + self.base = base.to_string_lossy().to_string(); - let mut new_pattern = PathBuf::new(); - enum ComponentStage { - Base, - Pattern, + // No dynamic part, figure out if we are dealing with a file or a directory. + if !self.pattern.contains('*') { + let combined_path = if self.pattern.starts_with("/") { + PathBuf::from(&self.pattern) + } else { + PathBuf::from(&self.base).join(&self.pattern) + }; + + match 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 stage = ComponentStage::Base; - let mut components = Path::new(&self.pattern).components().peekable(); - while let Some(component) = components.next() { - match stage { - ComponentStage::Base => { - match component { - // Ignore the current dir, e.g. `.` - Component::CurDir => {} + // Contains dynamic part + let (static_part, dynamic_part) = split_pattern(&self.pattern); - // Go up a directory, e.g. `..` - Component::ParentDir => { - base.pop(); - } - - // Once we hit a component that contains a wildcard character, then we - // can't change the base anymore and we must move to the pattern part. - Component::Normal(part) if part.to_string_lossy().contains("*") => { - new_pattern.push(component); - stage = ComponentStage::Pattern; - } - - // File or folder, but not the last component - Component::Normal(part) if components.peek().is_some() => { - base.push(part); - } - - // Last file or folder. If it's a folder, we move it to the base, - // otherwise we move it to the pattern. - Component::Normal(part) => { - let full_path = base.join(part); - if full_path.is_dir() { - base.push(part); - } else { - new_pattern.push(part); - } - } - - // When we're dealing with an absolute path, then we have to bypass the - // `base` entirely. - Component::Prefix(_) => { - base.clear(); - base.push(component); - } - Component::RootDir => { - #[cfg(not(windows))] - base.clear(); - base.push(component); - } + let base: PathBuf = self.base.clone().into(); + let base = match static_part { + Some(static_part) => { + // TODO: If the base does not exist on disk, try removing the last slash and try + // again. + match dunce::canonicalize(base.join(static_part)) { + Ok(base) => base, + Err(err) => { + event!(tracing::Level::ERROR, "Failed to resolve glob: {:?}", err); + return; } } - ComponentStage::Pattern => { - new_pattern.push(component); + } + None => base, + }; + + let pattern = match dynamic_part { + Some(dynamic_part) => dynamic_part, + None => { + if base.is_dir() { + "**/*".to_owned() + } else { + "".to_owned() } } - } + }; self.base = base.to_string_lossy().to_string(); - self.pattern = path_to_posix_string(&new_pattern); - - // Ensure we have `**/*` when the base is a folder and we don't have a pattern at all - if self.pattern == "" { - self.pattern = "/**/*".to_owned(); - } - // Ensure that the pattern is pinned to the base path. - else if !self.pattern.starts_with("/") { - self.pattern = format!("/{}", self.pattern); - } - } -} - -fn path_to_posix_string(path: &Path) -> String { - let mut parts = Vec::new(); - let mut is_rooted = false; - - for component in path.components() { - match component { - Component::Prefix(prefix) => { - parts.push(prefix.as_os_str().to_string_lossy().to_string()); - } - Component::RootDir => { - is_rooted = true; - if parts.is_empty() { - parts.push(String::new()); - } - } - Component::CurDir => { - parts.push(".".to_string()); - } - Component::ParentDir => { - parts.push("..".to_string()); - } - Component::Normal(part) => { - parts.push(part.to_string_lossy().to_string()); - } - } - } - - let result = parts.join("/"); - if result.is_empty() && is_rooted { - "/".to_string() - } else { - result - } -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - use std::fs; - use tempfile::tempdir; - - #[test] - fn path_to_posix_string_serializes_relative_paths() { - let path = PathBuf::from("src").join("**").join("*.html"); - - assert_eq!(path_to_posix_string(&path), "src/**/*.html"); - } - - #[test] - fn path_to_posix_string_serializes_rooted_paths() { - let path = PathBuf::from(std::path::MAIN_SEPARATOR.to_string()) - .join("src") - .join("**") - .join("*.html"); - - assert_eq!(path_to_posix_string(&path), "/src/**/*.html"); - } - - #[test] - fn path_to_posix_string_serializes_empty_paths() { - assert_eq!(path_to_posix_string(&PathBuf::new()), ""); - } - - #[test] - fn optimize_hoists_static_directories_and_keeps_files_in_the_pattern() { - let dir = tempdir().unwrap(); - fs::create_dir_all(dir.path().join("src").join("examples")).unwrap(); - - let mut source = PublicSourceEntry { - base: dir.path().to_string_lossy().to_string(), - pattern: "src/examples/index.html".to_string(), - negated: false, - }; - - source.optimize(); - - assert_eq!( - source.base, - dunce::canonicalize(dir.path().join("src").join("examples")) - .unwrap() - .to_string_lossy() - ); - assert_eq!(source.pattern, "/index.html"); - } - - #[test] - fn optimize_hoists_folder_patterns() { - let dir = tempdir().unwrap(); - fs::create_dir_all(dir.path().join("src").join("examples")).unwrap(); - - let mut source = PublicSourceEntry { - base: dir.path().to_string_lossy().to_string(), - pattern: "src/examples".to_string(), - negated: false, - }; - - source.optimize(); - - assert_eq!( - source.base, - dunce::canonicalize(dir.path().join("src").join("examples")) - .unwrap() - .to_string_lossy() - ); - assert_eq!(source.pattern, "/**/*"); - } - - #[test] - fn optimize_keeps_wildcards_in_the_pattern() { - let dir = tempdir().unwrap(); - fs::create_dir_all(dir.path().join("src")).unwrap(); - - let mut source = PublicSourceEntry { - base: dir.path().to_string_lossy().to_string(), - pattern: "src/**/*.html".to_string(), - negated: false, - }; - - source.optimize(); - - assert_eq!( - source.base, - dunce::canonicalize(dir.path().join("src")) - .unwrap() - .to_string_lossy() - ); - assert_eq!(source.pattern, "/**/*.html"); - } - - #[test] - fn concrete_patterns_are_expanded_to_restrict_their_base() { - let dir = tempdir().unwrap(); - fs::create_dir_all(dir.path().join("src")).unwrap(); - let base = dunce::canonicalize(dir.path().join("src")).unwrap(); - - let sources = public_source_entries_to_private_source_entries(vec![PublicSourceEntry { - base: dir.path().to_string_lossy().to_string(), - pattern: "src/foo.html".to_string(), - negated: false, - }]); - - assert_eq!( - sources, - vec![ - SourceEntry::Ignored { - base: base.clone(), - pattern: "*".to_string(), - }, - SourceEntry::Pattern { - base, - pattern: "/foo.html".to_string(), - }, - ] - ); - } - - #[test] - fn restricted_patterns_include_parent_directory_allow_rules() { - let dir = tempdir().unwrap(); - fs::create_dir_all(dir.path().join("src")).unwrap(); - let base = dunce::canonicalize(dir.path().join("src")).unwrap(); - - let sources = public_source_entries_to_private_source_entries(vec![PublicSourceEntry { - base: dir.path().to_string_lossy().to_string(), - pattern: "src/ef*/*.html".to_string(), - negated: false, - }]); - - assert_eq!( - sources, - vec![ - SourceEntry::Ignored { - base: base.clone(), - pattern: "*".to_string(), - }, - SourceEntry::Ignored { - base: base.clone(), - pattern: "!/ef*/".to_string(), - }, - SourceEntry::Pattern { - base, - pattern: "/ef*/*.html".to_string(), - }, - ] - ); - } - - #[test] - fn unrestricted_sources_do_not_expand_patterns_for_the_same_base() { - let dir = tempdir().unwrap(); - fs::create_dir_all(dir.path().join("src")).unwrap(); - let base = dunce::canonicalize(dir.path().join("src")).unwrap(); - - let sources = public_source_entries_to_private_source_entries(vec![ - PublicSourceEntry { - base: dir.path().to_string_lossy().to_string(), - pattern: "src".to_string(), - negated: false, - }, - PublicSourceEntry { - base: dir.path().to_string_lossy().to_string(), - pattern: "src/foo.html".to_string(), - negated: false, - }, - ]); - - assert_eq!( - sources, - vec![ - SourceEntry::Auto { base: base.clone() }, - SourceEntry::Pattern { - base, - pattern: "/foo.html".to_string(), - }, - ] - ); - } - - #[test] - fn restricted_parent_bases_do_not_open_unrelated_siblings() { - let dir = tempdir().unwrap(); - let project = dir.path().join("Users").join("robin").join("docus-test"); - fs::create_dir_all(&project).unwrap(); - - let users = dunce::canonicalize(dir.path().join("Users")).unwrap(); - let project = dunce::canonicalize(project).unwrap(); - - let sources = public_source_entries_to_private_source_entries(vec![ - PublicSourceEntry { - base: project.to_string_lossy().to_string(), - pattern: "**/*".to_string(), - negated: false, - }, - PublicSourceEntry { - base: project.to_string_lossy().to_string(), - pattern: "../../app.config.ts".to_string(), - negated: false, - }, - ]); - - assert_eq!( - sources, - vec![ - SourceEntry::Auto { - base: project.clone(), - }, - SourceEntry::Ignored { - base: users.clone(), - pattern: "/*".to_string(), - }, - SourceEntry::Pattern { - base: users, - pattern: "/app.config.ts".to_string(), - }, - ] - ); - } - - #[test] - fn restricted_patterns_preserve_source_order() { - let dir = tempdir().unwrap(); - fs::create_dir_all(dir.path().join("src")).unwrap(); - let base = dunce::canonicalize(dir.path().join("src")).unwrap(); - - let sources = public_source_entries_to_private_source_entries(vec![ - PublicSourceEntry { - base: dir.path().to_string_lossy().to_string(), - pattern: "src/foo.html".to_string(), - negated: false, - }, - PublicSourceEntry { - base: dir.path().to_string_lossy().to_string(), - pattern: "src/foo.html".to_string(), - negated: true, - }, - ]); - - assert_eq!( - sources, - vec![ - SourceEntry::Ignored { - base: base.clone(), - pattern: "*".to_string(), - }, - SourceEntry::Pattern { - base: base.clone(), - pattern: "/foo.html".to_string(), - }, - SourceEntry::Ignored { - base, - pattern: "/foo.html".to_string(), - }, - ] - ); - } - - /// Run the public-to-private conversion for an auto-detected source pointing at `base` and - /// return the resulting entry. - fn auto_source_entry(base: &Path) -> SourceEntry { - public_source_entries_to_private_source_entries(vec![PublicSourceEntry { - base: base.to_string_lossy().to_string(), - pattern: "**/*".to_string(), - negated: false, - }]) - .into_iter() - .next() - .unwrap() - } - - #[test] - fn auto_detected_folders_become_auto_sources() { - let dir = tempdir().unwrap(); - let base = dir.path().join("src"); - fs::create_dir_all(&base).unwrap(); - let base = dunce::canonicalize(&base).unwrap(); - - assert_eq!(auto_source_entry(&base), SourceEntry::Auto { base }); - } - - #[test] - fn folders_ignored_by_default_become_external_sources() { - let dir = tempdir().unwrap(); - let base = dir.path().join("node_modules").join("my-lib"); - fs::create_dir_all(&base).unwrap(); - let base = dunce::canonicalize(&base).unwrap(); - - assert_eq!(auto_source_entry(&base), SourceEntry::External { base }); - } - - #[test] - fn folders_ignored_by_gitignore_become_external_sources() { - let dir = tempdir().unwrap(); - // Pretend this is a git repository so the `.gitignore` search is bounded to it. - fs::create_dir_all(dir.path().join(".git")).unwrap(); - fs::write(dir.path().join(".gitignore"), "dist/\n").unwrap(); - - let base = dir.path().join("dist"); - fs::create_dir_all(&base).unwrap(); - let base = dunce::canonicalize(&base).unwrap(); - - assert_eq!(auto_source_entry(&base), SourceEntry::External { base }); - } - - #[test] - fn folders_ignored_by_a_parent_gitignore_become_external_sources() { - let dir = tempdir().unwrap(); - fs::create_dir_all(dir.path().join(".git")).unwrap(); - // A `.gitignore` higher up in the tree should still apply to nested directories. - fs::write(dir.path().join(".gitignore"), "generated/\n").unwrap(); - - let base = dir.path().join("packages").join("app").join("generated"); - fs::create_dir_all(&base).unwrap(); - let base = dunce::canonicalize(&base).unwrap(); - - assert_eq!(auto_source_entry(&base), SourceEntry::External { base }); - } - - #[test] - fn folders_not_ignored_by_gitignore_stay_auto_sources() { - let dir = tempdir().unwrap(); - fs::create_dir_all(dir.path().join(".git")).unwrap(); - fs::write(dir.path().join(".gitignore"), "dist/\n").unwrap(); - - let base = dir.path().join("src"); - fs::create_dir_all(&base).unwrap(); - let base = dunce::canonicalize(&base).unwrap(); - - assert_eq!(auto_source_entry(&base), SourceEntry::Auto { base }); + self.pattern = pattern; } } @@ -762,111 +218,18 @@ pub fn public_source_entries_to_private_source_entries( }) .collect::>(); - // 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 - let sources = expanded_globs + expanded_globs .into_iter() - .map(|public_source| { - let mut source: SourceEntry = public_source.into(); - - // Promote auto-sources to external sources if they were gitignored - if let SourceEntry::Auto { ref base } = source { - let inside_git_repo = base.ancestors().any(|dir| dir.join(".git").exists()); - - // Walk up from the folder, applying each `.gitignore` relative to the directory - // that contains it (matching git), and stop at the git repository root so - // `.gitignore` files outside of the repo are not considered. - for dir in base.ancestors() { - let gitignore = gitignores.entry(dir.to_path_buf()).or_insert_with(|| { - let path = dir.join(".gitignore"); - - // `Gitignore::new` roots the matcher at the directory containing the file, - // so patterns match relative to it. - path.is_file().then(|| Gitignore::new(&path).0) - }); - - // Only `.gitignore` files in ancestors of `base` can ignore `base` itself. - // Patterns in `base`'s own `.gitignore` only match paths _inside_ `base`, never - // `base` itself (the file walker still applies them to `base`'s contents). - // - // Skipping `base`'s own `.gitignore` also prevents a false positive for - // whitelist style `.gitignore` files, because relativizing `base` against - // itself yields the empty path, which incorrectly matches `/*`. - // - // E.g.: - // - // ```gitignore - // /* - // !/.gitignore - // !/app - // !/public - // ``` - // - // Everything inside `base` except `.gitignore`, `app` and `public` is ignored, - // but `base` itself is not. - if dir != base { - if let Some(gitignore) = gitignore { - if gitignore - .matched_path_or_any_parents(&base, true) - .is_ignore() - { - source = SourceEntry::External { base: base.into() }; - break; - } - } - } - - // Stop at the git repository root. - if dir.join(".git").exists() { - break; - } - - // Without a git repository there is no repository root to stop at. Stop once - // the directory contains the current working directory instead, so `.gitignore` - // files outside of the project (e.g. in the user's home directory) can never - // promote a source to an external source. Note that the file walker still - // applies those `.gitignore` files when deciding which files to scan. - if !inside_git_repo && cwd.as_ref().is_some_and(|cwd| cwd.starts_with(dir)) { - break; - } - } - } - - source - }) - .collect::>(); - - expand_restricted_patterns(sources) + .map(Into::into) + .collect::>() } /// Convert a public source entry to a source entry impl From for SourceEntry { fn from(value: PublicSourceEntry) -> Self { - if value.negated { - return SourceEntry::Ignored { - base: value.base.into(), - pattern: value.pattern, - }; - } - - let auto = - value.pattern == "/**/*" || PathBuf::from(&value.base).join(&value.pattern).is_dir(); - - if !auto { - return SourceEntry::Pattern { - base: value.base.into(), - pattern: value.pattern, - }; - } + let auto = value.pattern.ends_with("**/*") + || PathBuf::from(&value.base).join(&value.pattern).is_dir(); let inside_ignored_content_dir = IGNORED_CONTENT_DIRS.iter().any(|dir| { value.base.contains(&format!( @@ -876,16 +239,24 @@ impl From for SourceEntry { std::path::MAIN_SEPARATOR )) || value .base - .ends_with(&format!("{}{}", std::path::MAIN_SEPARATOR, dir)) + .ends_with(&format!("{}{}", std::path::MAIN_SEPARATOR, dir,)) }); - match inside_ignored_content_dir { - false => SourceEntry::Auto { + match (value.negated, auto, inside_ignored_content_dir) { + (false, true, false) => SourceEntry::Auto { base: value.base.into(), }, - true => SourceEntry::External { + (false, true, true) => SourceEntry::External { base: value.base.into(), }, + (false, false, _) => SourceEntry::Pattern { + base: value.base.into(), + pattern: value.pattern, + }, + (true, _, _) => SourceEntry::Ignored { + base: value.base.into(), + pattern: value.pattern, + }, } } } diff --git a/crates/oxide/tests/scanner.rs b/crates/oxide/tests/scanner.rs index fb5354a87..4003ff253 100644 --- a/crates/oxide/tests/scanner.rs +++ b/crates/oxide/tests/scanner.rs @@ -1,6 +1,5 @@ #[cfg(test)] mod scanner { - use insta::assert_snapshot; use pretty_assertions::assert_eq; use std::path::{Path, PathBuf}; use std::process::Command; @@ -21,16 +20,6 @@ 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(); @@ -56,157 +45,6 @@ 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)]) { @@ -223,53 +61,6 @@ 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>, @@ -294,14 +85,34 @@ 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 files = scanned_files(&mut scanner, Path::new(&base)); + let mut files = scanner + .get_files() + .iter() + // Normalize paths to use unix style separators + .map(|file| file.replace('\\', "/").replace(&base_dir, "")) + .collect::>(); + files.sort(); // Get all scanned globs as strings relative to the base directory - let globs = scanned_globs(&mut scanner, Path::new(&base)); + 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(); // Get all normalized sources as strings relative to the base directory let mut normalized_sources = scanner @@ -319,14 +130,11 @@ mod scanner { .collect::>(); normalized_sources.sort(); - let tree = fs_tree(&dir, &files); - ScanResult { files, globs, normalized_sources, candidates, - tree, } } @@ -340,7 +148,6 @@ mod scanner { files, globs, normalized_sources, - tree, .. } = scan(&[ ("index.html", ""), @@ -348,14 +155,6 @@ 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!["**/*"]); @@ -367,7 +166,6 @@ mod scanner { files, globs, normalized_sources, - tree, .. } = scan(&[ (".gitignore", "b.html"), @@ -376,17 +174,6 @@ 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!["**/*"]); @@ -398,7 +185,6 @@ mod scanner { files, globs, normalized_sources, - tree, .. } = scan(&[ ("index.html", ""), @@ -409,20 +195,6 @@ 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![ @@ -444,7 +216,6 @@ mod scanner { files, globs, normalized_sources, - tree, .. } = scan(&[ ("index.html", ""), @@ -458,25 +229,6 @@ 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![ @@ -501,7 +253,6 @@ mod scanner { files, globs, normalized_sources, - tree, .. } = scan(&[ (".gitignore", "public/b.html\na.html"), @@ -511,18 +262,6 @@ 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!["**/*"]); @@ -534,7 +273,6 @@ mod scanner { files, globs, normalized_sources, - tree, .. } = scan(&[ ("index.html", ""), @@ -543,15 +281,6 @@ 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"] @@ -569,7 +298,6 @@ mod scanner { files, globs, normalized_sources, - tree, .. } = scan(&[ ("index.html", ""), @@ -578,14 +306,6 @@ 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!["**/*"]); @@ -598,7 +318,6 @@ mod scanner { files, globs, normalized_sources, - tree, .. } = scan(&[ // Looks like `.pages` binary extension, but it's a folder @@ -612,16 +331,6 @@ 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!["**/*"]); @@ -633,7 +342,6 @@ mod scanner { files, globs, normalized_sources, - tree, .. } = scan(&[ ("index.html", ""), @@ -642,14 +350,6 @@ 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!["**/*"]); @@ -661,16 +361,9 @@ 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!["**/*"]); @@ -682,7 +375,6 @@ mod scanner { files, globs, normalized_sources, - tree, .. } = scan(&[ ("index.html", ""), @@ -690,13 +382,6 @@ 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!["**/*"]); @@ -708,7 +393,6 @@ mod scanner { files, globs, normalized_sources, - tree, .. } = scan(&[ // Explicitly listed root files @@ -753,58 +437,6 @@ 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![ @@ -859,7 +491,6 @@ mod scanner { let ScanResult { candidates, normalized_sources, - tree, .. } = scan(&[ // The gitignore file is used to filter out files but not scanned for candidates @@ -882,21 +513,6 @@ 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![ @@ -920,86 +536,28 @@ 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"]); } @@ -1010,7 +568,6 @@ mod scanner { let ScanResult { candidates, normalized_sources, - tree, .. } = scan_with_globs( &[ @@ -1030,14 +587,6 @@ mod scanner { ], ); - assert_snapshot!(tree, @" - . (git) - ├── ✓ my-folder.bin - │ └── ✓ foo.html - └── ✓ my-folder.templates - └── ✓ foo.html - "); - assert_eq!( candidates, vec![ @@ -1056,7 +605,6 @@ mod scanner { let ScanResult { candidates, normalized_sources, - tree, .. } = scan_with_globs( &[ @@ -1067,161 +615,15 @@ 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( &[ @@ -1235,19 +637,6 @@ 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![ @@ -1289,14 +678,6 @@ 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, @@ -1312,7 +693,6 @@ mod scanner { let ScanResult { candidates, normalized_sources, - tree, .. } = scan_with_globs( &[ @@ -1325,13 +705,6 @@ 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"]); } @@ -1361,14 +734,6 @@ 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, @@ -1473,238 +838,6 @@ 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] @@ -1714,8 +847,6 @@ mod scanner { files, globs, normalized_sources, - tree, - .. } = scan_with_globs( &[ ("src/index.ts", "content-['src/index.ts']"), @@ -1743,25 +874,6 @@ 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![ @@ -1807,8 +919,6 @@ mod scanner { files, globs, normalized_sources, - tree, - .. } = scan_with_globs( // Typically skipped &[ @@ -1820,15 +930,6 @@ 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']",] @@ -1856,8 +957,6 @@ mod scanner { files, globs, normalized_sources, - tree, - .. } = scan_with_globs( &[ ("index.html", "content-['index.html']"), @@ -1882,21 +981,6 @@ 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![ @@ -1913,409 +997,6 @@ 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 { @@ -2323,8 +1004,6 @@ mod scanner { files, globs, normalized_sources, - tree, - .. } = scan_with_globs( &[ (".gitignore", "ignore-1.html\nweb/ignore-2.html"), @@ -2336,19 +1015,6 @@ 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']",] @@ -2366,8 +1032,6 @@ mod scanner { files, globs, normalized_sources, - tree, - .. } = scan_with_globs( &[ ("src/logo.jpg", "content-['/src/logo.jpg']"), @@ -2376,13 +1040,6 @@ 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']"] @@ -2399,8 +1056,6 @@ mod scanner { files, globs, normalized_sources, - tree, - .. } = scan_with_globs( &[ (".gitignore", "ignore-1.html\n/web/ignore-2.html"), @@ -2410,17 +1065,6 @@ 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![ @@ -2434,201 +1078,6 @@ 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 @@ -2699,33 +1148,7 @@ mod scanner { ), ]; - 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 - "); + let candidates = Scanner::new(sources.clone()).scan(); // All ignore files are applied because there's no git repo assert_eq!( @@ -2743,33 +1166,7 @@ mod scanner { .arg("init") .current_dir(dir.join("home")) .output(); - 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 - "); + let candidates = Scanner::new(sources.clone()).scan(); assert_eq!( candidates, @@ -2788,33 +1185,7 @@ mod scanner { .arg("init") .current_dir(dir.join("home/project")) .output(); - 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 - "); + let candidates = Scanner::new(sources.clone()).scan(); assert_eq!( candidates, @@ -2834,33 +1205,7 @@ mod scanner { .arg("init") .current_dir(dir.join("home/project/apps")) .output(); - 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 - "); + let candidates = Scanner::new(sources.clone()).scan(); assert_eq!( candidates, @@ -2882,33 +1227,7 @@ mod scanner { .current_dir(dir.join("home/project/apps/web")) .output(); - 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 - "); + let candidates = Scanner::new(sources.clone()).scan(); assert_eq!( candidates, @@ -2942,15 +1261,7 @@ mod scanner { public_source_entry_from_pattern(dir.clone(), "@source not 'src/ignore-me.html'"), ]; - 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 - "); + let candidates = Scanner::new(sources.clone()).scan(); assert_eq!(candidates, vec!["content-['keep-me.html']"]); } @@ -2977,17 +1288,7 @@ mod scanner { ), ]; - 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 - "); + let candidates = Scanner::new(sources.clone()).scan(); assert_eq!(candidates, vec!["content-['keep-me.html']"]); } @@ -3014,12 +1315,6 @@ 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, @@ -3047,18 +1342,6 @@ 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![ @@ -3088,11 +1371,6 @@ 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 '**/*'"), @@ -3101,11 +1379,6 @@ 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] @@ -3130,13 +1403,6 @@ 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'"), @@ -3144,13 +1410,6 @@ 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] @@ -3185,17 +1444,6 @@ 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( @@ -3207,17 +1455,6 @@ 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` // @@ -3235,64 +1472,6 @@ 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] @@ -3302,8 +1481,6 @@ mod scanner { files, globs, normalized_sources, - tree, - .. } = scan_with_globs( &[ ( @@ -3322,17 +1499,6 @@ 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",]); @@ -3347,8 +1513,6 @@ mod scanner { files, globs, normalized_sources, - tree, - .. } = scan_with_globs( &[ (".gitignore", "node_modules\ndist"), @@ -3364,18 +1528,6 @@ 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']"] @@ -3417,17 +1569,6 @@ 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( @@ -3488,8 +1629,6 @@ mod scanner { files, globs, normalized_sources, - tree, - .. } = scan_with_globs( &[ ("src/💩.js", "content-['src/💩.js']"), @@ -3499,15 +1638,6 @@ 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']"] @@ -3544,23 +1674,6 @@ 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']"] @@ -3572,23 +1685,6 @@ 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']"] @@ -3600,23 +1696,6 @@ 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']"] @@ -3634,14 +1713,11 @@ mod scanner { ], ); - // 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")); + // 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")); let mut scanner = Scanner::new(vec![public_source_entry_from_pattern( dir.clone(), @@ -3649,24 +1725,6 @@ 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']"] @@ -3691,14 +1749,6 @@ 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 @@ -3711,333 +1761,6 @@ 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(); @@ -4063,11 +1786,6 @@ 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 dda70814b..dd93ade64 100644 --- a/integrations/cli/config.test.ts +++ b/integrations/cli/config.test.ts @@ -126,72 +126,6 @@ test( }, ) -// https://github.com/tailwindlabs/tailwindcss/issues/19706 -test( - 'Config files (TS, valid types)', - { - fs: { - 'package.json': json` - { - "type": "module", - "dependencies": { - "tailwindcss": "workspace:^", - "@tailwindcss/cli": "workspace:^" - }, - "devDependencies": { - "typescript": "^5.9.3" - } - } - `, - 'tsconfig.json': json` - { - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "declaration": true, - "composite": true, - "outDir": "./.build", - "skipLibCheck": true - }, - "include": ["tailwind.config.ts"] - } - `, - 'index.html': html` -
- `, - '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 07f8bcb68..be48a2ac4 100644 --- a/integrations/cli/index.test.ts +++ b/integrations/cli/index.test.ts @@ -1,11 +1,8 @@ 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, retryAssertion, test, ts, txt, yaml } from '../utils' - -const __dirname = path.dirname(fileURLToPath(import.meta.url)) +import { candidate, css, html, js, json, test, ts, yaml } from '../utils' const STANDALONE_BINARY = (() => { switch (os.platform()) { @@ -375,120 +372,6 @@ describe.each([ }, ) - test( - 'watch mode with polling', - { - fs: { - 'package.json': json` - { - "dependencies": { - "tailwindcss": "workspace:^", - "@tailwindcss/cli": "workspace:^" - } - } - `, - 'src/index.css': css`@import 'tailwindcss/utilities';`, - 'src/index.html': html` -
- `, - }, - }, - 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)', { @@ -673,15 +556,6 @@ 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': '*', @@ -719,7 +593,7 @@ describe.each([ installDependencies: false, }, async ({ fs, root, exec }) => { - await exec(`pnpm install`, { + await exec(`pnpm install --ignore-workspace`, { cwd: path.join(root, 'home/project'), }) @@ -767,7 +641,7 @@ describe.each([ } } `, - 'src/index.html': html` + 'ssrc/index.html': html`
`, 'src/index.css': css` @@ -828,7 +702,7 @@ describe.each([ } } `, - 'src/index.html': html` + 'ssrc/index.html': html`
`, 'src/index.css': css` @@ -875,7 +749,7 @@ describe.each([ expect(map.at(4, 0)).toMatchObject({ source: null, original: '(none)', - generated: '}\n\n/*# sou...', + generated: '}...', }) }, ) @@ -894,7 +768,7 @@ describe.each([ } } `, - 'src/index.html': html` + 'ssrc/index.html': html`
`, 'src/index.css': css` @@ -935,566 +809,11 @@ 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; - } - " - `) }, ) }) @@ -2013,394 +1332,6 @@ 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', { @@ -2749,49 +1680,6 @@ test( }, ) -test( - 'suppress output by using the --silent flag', - { - fs: { - 'package.json': json` - { - "dependencies": { - "tailwindcss": "workspace:^", - "@tailwindcss/cli": "workspace:^" - } - } - `, - 'index.css': css` @import 'tailwindcss/utilities';`, - 'index.html': html`
`, - }, - }, - 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)', { @@ -2895,8 +1783,8 @@ test( @layer theme, base, components, utilities; @layer theme { :root, :host { - --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-sans: ui-sans-serif, system-ui, 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); @@ -2915,7 +1803,7 @@ test( line-height: 1.5; -webkit-text-size-adjust: 100%; tab-size: 4; - font-family: var(--default-font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', 'Noto Sans', Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'); + font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'); font-feature-settings: var(--default-font-feature-settings, normal); font-variation-settings: var(--default-font-variation-settings, normal); -webkit-tap-highlight-color: transparent; @@ -2967,7 +1855,7 @@ test( border-color: inherit; border-collapse: collapse; } - :-moz-focusring:where(:not(iframe)) { + :-moz-focusring { outline: auto; } progress { @@ -3154,33 +2042,6 @@ 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 0b810c538..0597e247b 100644 --- a/integrations/cli/plugins.test.ts +++ b/integrations/cli/plugins.test.ts @@ -1,4 +1,4 @@ -import { candidate, css, html, json, test, ts } from '../utils' +import { candidate, css, html, json, test } from '../utils' test( 'builds the `@tailwindcss/typography` plugin utilities', @@ -42,86 +42,12 @@ test( await fs.expectFileToContain('dist/out.css', [ candidate`prose`, - ':where(h1):not(:where([class~="not-prose"], [class~="not-prose"] *))', - ':where(tbody td, tfoot td):not(:where([class~="not-prose"], [class~="not-prose"] *))', + ':where(h1):not(:where([class~="not-prose"],[class~="not-prose"] *))', + ':where(tbody td, tfoot td):not(:where([class~="not-prose"],[class~="not-prose"] *))', ]) }, ) -test( - 'builds the `@tailwindcss/typography` plugin utilities with `@variant` usages', - { - fs: { - 'package.json': json` - { - "dependencies": { - "@tailwindcss/typography": "^0.5.14", - "tailwindcss": "workspace:^", - "@tailwindcss/cli": "workspace:^" - } - } - `, - 'index.html': html` -
-

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', { @@ -191,7 +117,7 @@ test( await fs.expectFileToContain('dist/out.css', [ // `::-webkit-date-and-time-value`, - `input:where([type='checkbox']):indeterminate`, + `[type='checkbox']:indeterminate`, ]) // No classes are included even though they are used in the HTML @@ -281,88 +207,3 @@ test( ]) }, ) - -// https://github.com/tailwindlabs/tailwindcss/issues/15844 -test( - 'builds CSS with a custom plugin compiled from TypeScript', - { - fs: { - 'package.json': json` - { - "type": "module", - "dependencies": { - "tailwindcss": "workspace:^", - "@tailwindcss/cli": "workspace:^" - }, - "devDependencies": { - "typescript": "^5.7.2" - } - } - `, - 'tsconfig.json': json` - { - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "declaration": true, - "composite": true, - "rootDir": "./src", - "outDir": "./.build", - "skipLibCheck": true - }, - "include": ["src/**/*.ts"] - } - `, - 'index.html': html` -
- `, - '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 f84bf4511..07b8ebfee 100644 --- a/integrations/cli/standalone.test.ts +++ b/integrations/cli/standalone.test.ts @@ -1,7 +1,6 @@ -import nodeFs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' -import { candidate, css, html, IS_WINDOWS, js, json, test, ts } from '../utils' +import { candidate, css, html, js, json, test } from '../utils' const STANDALONE_BINARY = (() => { switch (os.platform()) { @@ -16,41 +15,6 @@ const STANDALONE_BINARY = (() => { } })() -test( - 'does not scan itself for candidates', - { - fs: { - 'package.json': json` - { - "dependencies": {} - } - `, - 'src/index.css': css` @import 'tailwindcss'; `, - }, - }, - async ({ root, fs, exec }) => { - let sourceBinary = path.resolve( - __dirname, - `../../packages/@tailwindcss-standalone/dist/${STANDALONE_BINARY}`, - ) - let binary = IS_WINDOWS ? 'tailwindcss.exe' : 'tailwindcss' - let localBinary = path.join(root, binary) - await nodeFs.copyFile(sourceBinary, localBinary) - - if (!IS_WINDOWS) { - await nodeFs.chmod(localBinary, 0o755) - } - - await exec(`${IS_WINDOWS ? binary : `./${binary}`} --input src/index.css --output dist/out.css`) - - await fs.expectFileNotToContain('dist/out.css', [ - candidate`flex`, - candidate`grid`, - candidate`underline`, - ]) - }, -) - test( 'includes first-party plugins', { @@ -158,7 +122,7 @@ test( }) }) `, - 'src/plugin.ts': ts` + 'src/plugin.ts': js` import plugin from 'tailwindcss/plugin' // Make sure all available JS APIs can be imported and used @@ -168,7 +132,8 @@ test( import defaultTheme from 'tailwindcss/defaultTheme' import * as pkg from 'tailwindcss/package.json' - export interface PluginOptions {} + export interface PluginOptions { + } export default plugin(function ({ addUtilities }) { addUtilities({ diff --git a/integrations/oxide/postinstall.test.ts b/integrations/oxide/postinstall.test.ts new file mode 100644 index 000000000..70683c82e --- /dev/null +++ b/integrations/oxide/postinstall.test.ts @@ -0,0 +1,73 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { js, json, test } from '../utils' + +test( + '@tailwindcss/oxide will fail when architecture-specific packages are missing', + { + fs: { + 'package.json': json` + { + "dependencies": { + "@tailwindcss/oxide": "workspace:^" + } + } + `, + 'test.js': js` + try { + let Scanner = require('@tailwindcss/oxide') + console.log('SUCCESS: @tailwindcss/oxide loaded successfully', Scanner) + } catch (error) { + console.log('FAILURE: Failed to load @tailwindcss/oxide:', error.message) + } + `, + }, + }, + async ({ exec, root, expect, fs }) => { + await removePlatformSpecificExtensions(path.join(root, 'node_modules')) + + // Get last published version + let version = (await exec('npm show @tailwindcss/oxide version')).trim() + // Ensure that we don't depend on a specific version number in the download + // script in case we bump the version number in the repository and CI is run + // before a release + let packageJson = JSON.parse(await fs.read('node_modules/@tailwindcss/oxide/package.json')) + packageJson.version = version + await fs.write( + 'node_modules/@tailwindcss/oxide/package.json', + JSON.stringify(packageJson, null, 2), + ) + + let opts = { + // Ensure that we don't include any node paths from the test runner + env: { NODE_PATH: '' }, + } + + expect(await exec('node test.js', opts)).toMatch(/FAILURE/) + + // Now run the post-install script + await exec('node node_modules/@tailwindcss/oxide/scripts/install.js', opts) + + expect(await exec('node test.js', opts)).toMatch(/SUCCESS/) + }, +) + +async function removePlatformSpecificExtensions(directory: string) { + let entries = await fs.readdir(directory, { withFileTypes: true }) + + for (let entry of entries) { + let fullPath = path.join(directory, entry.name) + + if (entry.name.startsWith('oxide-')) { + if (entry.isSymbolicLink()) { + await fs.unlink(fullPath) + } else if (entry.isFile()) { + await fs.unlink(fullPath) + } else if (entry.isDirectory()) { + await fs.rm(fullPath, { recursive: true, force: true }) + } + } else if (entry.isDirectory()) { + await removePlatformSpecificExtensions(fullPath) + } + } +} diff --git a/integrations/oxide/wasm.test.ts b/integrations/oxide/wasm.test.ts index 2d63c707a..94f44c066 100644 --- a/integrations/oxide/wasm.test.ts +++ b/integrations/oxide/wasm.test.ts @@ -1,6 +1,6 @@ -import { css, js, json, test, yaml } from '../utils' +import { css, js, json, test } from '../utils' -// This test runs the wasm build using the `node:wasi` runtime. +// This test runs the the wasm build using the `node:wasi` runtine. // // There are currently a known problems that the Node WASI preview implementation does not properly // handle FS reads on macOS and it does not implement all APIs on Windows. Because of that, this @@ -57,187 +57,3 @@ testFn( `) }, ) - -testFn( - '`@tailwindcss/oxide` falls back to the wasm build when no native binding is available', - { - fs: { - 'package.json': json` - { - "dependencies": { - "@tailwindcss/oxide": "workspace:^" - } - } - `, - 'pnpm-workspace.yaml': yaml` - # Trick pnpm in only supporting an architecture that @tailwindcss/oxide - # doesn't support, and therefore should fallback to the wasm version. - supportedArchitectures: - os: - - openbsd - cpu: - - x64 - `, - 'src/index.js': js` - const className = "content-['src/index.js']" - module.exports = { className } - `, - 'index.mjs': js` - import { createRequire } from 'node:module' - import { join } from 'node:path' - - let require = createRequire(import.meta.url) - let { Scanner } = require('@tailwindcss/oxide') - - let loaded = Object.keys(require.cache) - - let scanner = new Scanner({ - sources: [ - { - base: join(process.cwd(), 'src'), - pattern: '**/*', - negated: false, - }, - ], - }) - - console.log( - JSON.stringify({ - native: loaded.filter((file) => file.endsWith('.node')), - wasi: loaded.some((file) => file.endsWith('tailwindcss-oxide.wasi.cjs')), - candidates: scanner.scan(), - }), - ) - process.exit() - `, - }, - }, - async ({ expect, exec }) => { - // Since vitest runs under `pnpm run`, pnpm's bin shims export a NODE_PATH - // that includes the repository's hidden hoist directory - // (`node_modules/.pnpm/node_modules`), which links every workspace package, - // including all native `@tailwindcss/oxide-*` bindings. - // - // Node uses `NODE_PATH` exactly when the local `node_modules` lookup fails, - // which would defeat the simulated unsupported platform, so clear it. - let output = await exec(`node index.mjs`, { env: { NODE_PATH: '' } }) - let { native, wasi, candidates } = JSON.parse(output) - - // No native binding was installed or loaded, ... - expect(native).toEqual([]) - - // ... the wasm32-wasi binding is what actually loaded, ... - expect(wasi).toBe(true) - - // ... and scanning real files on disk works through it. - expect(candidates).toMatchInlineSnapshot(` - [ - "className", - "const", - "content-['src/index.js']", - "exports", - ] - `) - }, -) - -testFn( - 'the wasm build loads even when preopening the filesystem root is denied', - { - fs: { - 'package.json': json` - { - "dependencies": { - "@tailwindcss/oxide": "workspace:^" - } - } - `, - 'pnpm-workspace.yaml': yaml` - # Trick pnpm in only supporting an architecture that @tailwindcss/oxide - # doesn't support, and therefore should fallback to the wasm version. - supportedArchitectures: - os: - - openbsd - cpu: - - x64 - `, - // The wasm bindings generated by `@napi-rs/cli` preopen the filesystem - // root, which sandboxed platforms (e.g. OpenHarmony, Android) deny with - // `UVWASI_EACCES`, making the wasm fallback fail to load on exactly the - // platforms that need it. - // - // We patch `@napi-rs/cli`'s templates to retry with narrower preopens - // (see `patches/@napi-rs__cli@3.7.4.patch`). Simulate such a sandbox by - // denying `/` preopens. - 'preload.cjs': js` - const wasi = require('node:wasi') - const RealWASI = wasi.WASI - - wasi.WASI = class WASI extends RealWASI { - constructor(options) { - if (options?.preopens?.['/'] !== undefined) { - const error = new Error('UVWASI_EACCES, uvwasi_init') - error.code = 'UVWASI_EACCES' - error.syscall = 'uvwasi_init' - throw error - } - super(options) - } - } - `, - 'src/index.js': js` - const className = "content-['src/index.js']" - module.exports = { className } - `, - 'index.mjs': js` - import { createRequire } from 'node:module' - import { join } from 'node:path' - - let require = createRequire(import.meta.url) - let { Scanner } = require('@tailwindcss/oxide') - - let loaded = Object.keys(require.cache) - - let scanner = new Scanner({ - sources: [ - { - base: join(process.cwd(), 'src'), - pattern: '**/*', - negated: false, - }, - ], - }) - - console.log( - JSON.stringify({ - wasi: loaded.some((file) => file.endsWith('tailwindcss-oxide.wasi.cjs')), - candidates: scanner.scan(), - }), - ) - process.exit() - `, - }, - }, - async ({ expect, exec }) => { - // See the note about NODE_PATH in the test above. - let output = await exec(`node --require ./preload.cjs index.mjs`, { env: { NODE_PATH: '' } }) - - // Only parse the first line, because Node prints an `ExperimentalWarning` - // about WASI to stderr, which `exec` appends to the output. - let { wasi, candidates } = JSON.parse(output.trim().split('\n')[0]) - - // The wasm32-wasi binding loaded despite `/` being denied, ... - expect(wasi).toBe(true) - - // ... and scanning files under the current working directory still works - // through the narrower preopen. - expect(candidates).toMatchInlineSnapshot(` - [ - "className", - "const", - "content-['src/index.js']", - "exports", - ] - `) - }, -) diff --git a/integrations/package.json b/integrations/package.json index ae337b5e4..6245abe3e 100644 --- a/integrations/package.json +++ b/integrations/package.json @@ -3,9 +3,8 @@ "version": "0.0.0", "private": true, "devDependencies": { - "dedent": "catalog:", + "dedent": "1.6.0", "fast-glob": "^3.3.3", - "source-map-js": "^1.2.1", - "yaml": "^2.9.0" + "source-map-js": "^1.2.1" } } diff --git a/integrations/postcss/core-as-postcss-plugin.test.ts b/integrations/postcss/core-as-postcss-plugin.test.ts index 839c80e7b..17545fea3 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 }) => { - await expect( + expect( exec('pnpm postcss src/index.css --output dist/out.css', undefined, { ignoreStdErr: true }), - ).rejects.toThrow( + ).rejects.toThrowError( `It looks like you're trying to use \`tailwindcss\` directly as a PostCSS plugin. The PostCSS plugin has moved to a separate package, so to continue using Tailwind CSS with PostCSS you'll need to install \`@tailwindcss/postcss\` and update your PostCSS configuration.`, ) }, diff --git a/integrations/postcss/index.test.ts b/integrations/postcss/index.test.ts index 80025f945..a811cf637 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, test, ts, yaml } from '../utils' +import { candidate, css, html, js, json, retryAssertion, test, ts, yaml } from '../utils' test( 'production build (string)', @@ -662,54 +662,23 @@ test( `, 'src/index.css': css` @import './tailwind.css'; `, 'src/tailwind.css': css` - @reference 'tailwindcss/theme'; + @reference 'tailwindcss/does-not-exist'; @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...')) - 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 + // Fix the CSS file await fs.write( 'src/tailwind.css', css` @@ -717,15 +686,11 @@ test( @import 'tailwindcss/utilities'; `, ) - - await process.onStderr((message) => message.includes('Waiting for file changes...')) + await process.onStderr((message) => message.includes('Finished')) expect(await fs.dumpFiles('dist/*.css')).toMatchInlineSnapshot(` " --- dist/out.css --- - .flex { - display: flex; - } .underline { text-decoration-line: underline; } @@ -740,20 +705,11 @@ test( @import 'tailwindcss/utilities'; `, ) + await process.onStderr((message) => + message.includes('does-not-exist is not exported from package'), + ) - 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; - } - " - `) + await retryAssertion(async () => expect(await fs.read('dist/out.css')).toEqual('')) }, ) @@ -821,73 +777,3 @@ 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 7fc0ccdcf..969254a75 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 trigger a compile step. If we see any + // At this point, no changes should triger 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 deleted file mode 100644 index 755cd60df..000000000 --- a/integrations/turbopack/loader.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { candidate, css, fetchStyles, js, json, jsx, retryAssertion, test } from '../utils' - -test( - '@tailwindcss/turbopack loader (dev)', - { - timeout: 120_000, - fs: { - 'package.json': json` - { - "dependencies": { - "next": "^16.2.7", - "react": "^19.2.7", - "react-dom": "^19.2.7", - "tailwindcss": "workspace:^", - "@tailwindcss/turbopack": "workspace:^" - } - } - `, - 'next.config.mjs': js` - export default { - turbopack: { - rules: { - '*.css': { - loaders: ['@tailwindcss/turbopack'], - as: '*.css', - }, - }, - }, - } - `, - 'app/layout.js': jsx` - import './globals.css' - - export default function RootLayout({ children }) { - return ( - - {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 efac411db..75e21c590 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, txt, yaml } from '../utils' +import { candidate, css, html, js, json, test, ts, 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('pnpm exec upgrade') + let output = await exec('npx @tailwindcss/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('pnpm exec upgrade') + await exec('npx @tailwindcss/upgrade') expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(` " @@ -158,7 +158,7 @@ test( }) // Ensure the v4 project compiles correctly - await exec('pnpm exec tailwindcss --input src/input.css --output dist/out.css') + await exec('npx tailwindcss --input src/input.css --output dist/out.css') await fs.expectFileToContain('dist/out.css', [ candidate`flex!`, @@ -171,62 +171,9 @@ 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` { @@ -263,7 +210,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade') + await exec('npx @tailwindcss/upgrade') expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(` " @@ -335,7 +282,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade') + await exec('npx @tailwindcss/upgrade') expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` " @@ -407,7 +354,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade') + await exec('npx @tailwindcss/upgrade') expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` " @@ -484,7 +431,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade') + await exec('npx @tailwindcss/upgrade') expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` " @@ -587,7 +534,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade') + await exec('npx @tailwindcss/upgrade') expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` " @@ -654,7 +601,6 @@ test( test( 'migrates a simple postcss setup', { - timeout: 120_000, fs: { 'package.json': json` { @@ -695,7 +641,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade') + await exec('npx @tailwindcss/upgrade') await fs.expectFileToContain( 'postcss.config.js', @@ -766,7 +712,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade') + await exec('npx @tailwindcss/upgrade') let packageJsonContent = await fs.read('package.json') let packageJson = JSON.parse(packageJsonContent) @@ -813,7 +759,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade') + await exec('npx @tailwindcss/upgrade') let packageJsonContent = await fs.read('package.json') let packageJson = JSON.parse(packageJsonContent) @@ -826,7 +772,6 @@ test( test( 'migrates a postcss setup using package.json config', { - timeout: 120_000, fs: { 'package.json': json` { @@ -865,7 +810,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade') + await exec('npx @tailwindcss/upgrade') await fs.expectFileToContain('src/index.css', css`@import 'tailwindcss';`) await fs.expectFileToContain( @@ -900,7 +845,6 @@ test( test( 'migrates a postcss setup using a json based config file', { - timeout: 120_000, fs: { 'package.json': json` { @@ -941,7 +885,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade') + await exec('npx @tailwindcss/upgrade') await fs.expectFileToContain('src/index.css', css`@import 'tailwindcss';`) await fs.expectFileToContain( @@ -1008,7 +952,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade') + await exec('npx @tailwindcss/upgrade') expect(await fs.dumpFiles('./src/**/*.html')).toMatchInlineSnapshot(` " @@ -1055,7 +999,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade') + await exec('npx @tailwindcss/upgrade') expect(await fs.dumpFiles('./src/**/*.html')).toMatchInlineSnapshot(` " @@ -1099,7 +1043,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade --force') + await exec('npx @tailwindcss/upgrade --force') expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` " @@ -1169,10 +1113,9 @@ test( } `, }, - retry: 0, }, async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade --force') + await exec('npx @tailwindcss/upgrade --force') expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` " @@ -1281,10 +1224,9 @@ test( } `, }, - retry: 0, }, async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade --force') + await exec('npx @tailwindcss/upgrade --force') expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` " @@ -1417,7 +1359,7 @@ test( }, }, async ({ exec, fs, expect }) => { - let output = await exec('pnpm exec upgrade --force') + let output = await exec('npx @tailwindcss/upgrade --force') expect(output).toMatch( /You have one or more stylesheets that are imported into a utility layer and non-utility layer./, @@ -1541,7 +1483,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade --force') + await exec('npx @tailwindcss/upgrade --force') expect(await fs.dumpFiles('./src/**/*.{html,css}')).toMatchInlineSnapshot(` " @@ -1766,8 +1708,8 @@ test( }, }, async ({ exec, expect }) => { - let output = await exec('pnpm exec upgrade --force', {}, { ignoreStdErr: true }).catch((e) => - e.toString(), + let output = await exec('npx @tailwindcss/upgrade --force', {}, { ignoreStdErr: true }).catch( + (e) => e.toString(), ) expect(output).toMatch('Could not determine configuration file for:') @@ -1838,7 +1780,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade --force') + await exec('npx @tailwindcss/upgrade --force') expect(await fs.dumpFiles('./src/**/*.{html,css}')).toMatchInlineSnapshot(` " @@ -1974,7 +1916,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade --force') + await exec('npx @tailwindcss/upgrade --force') expect(await fs.dumpFiles('./src/**/*.{html,css}')).toMatchInlineSnapshot(` " @@ -2094,7 +2036,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade --force') + await exec('npx @tailwindcss/upgrade --force') expect(await fs.dumpFiles('./src/**/*.{html,css}')).toMatchInlineSnapshot(` " @@ -2174,7 +2116,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade --force') + await exec('npx @tailwindcss/upgrade --force') expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` " @@ -2256,10 +2198,9 @@ test( } `, }, - retry: 0, }, async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade --force') + await exec('npx @tailwindcss/upgrade --force') expect(await fs.dumpFiles('./src/**/*.css')).toMatchInlineSnapshot(` " @@ -2313,7 +2254,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade --force') + await exec('npx @tailwindcss/upgrade --force') let pkg = JSON.parse(await fs.read('package.json')) @@ -2392,7 +2333,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade --force') + await exec('npx @tailwindcss/upgrade --force') // Files should not be modified expect(await fs.dumpFiles('./*.{js,css,html}')).toMatchInlineSnapshot(` @@ -2404,7 +2345,8 @@ test( --shadow-*: initial; --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); - --default-ring-width: 4px; + --ring-width-*: initial; + --ring-width: 4px; --blur: var(--custom-default-blur); @@ -2490,7 +2432,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade --force') + await exec('npx @tailwindcss/upgrade --force') // Files should not be modified expect(await fs.dumpFiles('./*.{js,css,html,tsx}')).toMatchInlineSnapshot(` @@ -2557,7 +2499,7 @@ test( @import './utilities.css'; @import './generated/ignore-me.css'; `, - 'src/generated/.gitignore': txt` + 'src/generated/.gitignore': ` * !.gitignore `, @@ -2597,7 +2539,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade ./src/index.css') + await exec('npx @tailwindcss/upgrade ./src/index.css') expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(` " @@ -2723,7 +2665,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade') + await exec('npx @tailwindcss/upgrade') expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(` " @@ -2736,8 +2678,8 @@ test( text-red-500/10 text-red-500/semitransparent text-red-500/transparent - text-red-500/semitransparent - text-red-500/50.50 + text-red-500/50 + text-red-500/50.5 text-red-500/50.50 text-red-500/50% text-red-500/100%" @@ -2809,7 +2751,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade') + await exec('npx @tailwindcss/upgrade') expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(` " @@ -2817,7 +2759,7 @@ test( 
--- ./src/input.css --- - @import 'tailwindcss'; + @import 'tailwindcss'; /* The default border color has changed to \`currentcolor\` in Tailwind CSS v4, @@ -2876,8 +2818,8 @@ test( `, }, }, - async ({ exec, root, fs, expect }) => { - await exec('pnpm exec upgrade') + async ({ exec, fs, expect }) => { + await exec('npx @tailwindcss/upgrade') let before = await fs.dumpFiles('./src/**/*.{css,html}') expect(before).toMatchInlineSnapshot(` @@ -2913,13 +2855,13 @@ test( `) // Commit the changes - if (isRepoDirty(root)) { + if (isRepoDirty()) { await exec('git add .') await exec('git commit -m "upgrade"') } // Run the upgrade again - let output = await exec('pnpm exec upgrade') + let output = await exec('npx @tailwindcss/upgrade') expect(output).toContain('No changes were made to your repository') let after = await fs.dumpFiles('./src/**/*.{css,html}') @@ -2998,7 +2940,7 @@ test( }, }, async ({ exec, fs, expect }) => { - await exec('pnpm exec upgrade') + await exec('npx @tailwindcss/upgrade') expect(await fs.dumpFiles('./src/**/*.{css,html}')).toMatchInlineSnapshot(` " @@ -3026,202 +2968,6 @@ test( }, ) -test( - 'v4 ignores .env files during template migration', - { - fs: { - 'package.json': json` - { - "dependencies": { - "tailwindcss": "^4", - "@tailwindcss/upgrade": "workspace:^" - } - } - `, - 'src/app.css': css`@import 'tailwindcss';`, - 'src/index.html': html` -
- `, - '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', { @@ -3265,7 +3011,7 @@ test( }, }, async ({ root, exec, fs, expect }) => { - let stdout = await exec('pnpm exec upgrade', { + let stdout = await exec('npx @tailwindcss/upgrade', { cwd: path.join(root, 'project-a'), }) @@ -3339,7 +3085,7 @@ test( }, }, async ({ exec, fs, expect }) => { - let stdout = await exec('pnpm exec upgrade') + let stdout = await exec('npx @tailwindcss/upgrade') expect(stdout).not.toContain( 'Running this command will add the dependency to the workspace root', @@ -3371,61 +3117,6 @@ test( }, ) -// https://github.com/tailwindlabs/tailwindcss/issues/20328 -test( - 'ignored files should not be touched when upgrading from a nested directory', - { - fs: { - 'pnpm-workspace.yaml': yaml` - # - packages: - - packages/* - `, - 'package.json': json` - { - "name": "root", - "private": true - } - `, - 'packages/css/package.json': json` - { - "name": "css-pkg", - "private": true, - "devDependencies": { - "tailwindcss": "^4", - "@tailwindcss/upgrade": "workspace:^" - } - } - `, - 'packages/css/src/input.css': css` - @import 'tailwindcss/utilities.css' layer(utilities) source(none); - `, - 'packages/css/src/index.html': html`
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 deleted file mode 100644 index 8ba1b6b73..000000000 --- a/integrations/vite/bundled-dev.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -import path from 'node:path' -import { candidate, css, html, json, retryAssertion, test, ts, txt, yaml } from '../utils' - -// Vite's experimental `bundledDev` mode invokes the `hotUpdate` hook without a -// `server`, which used to crash the plugin on every file edit. -// -// - https://github.com/tailwindlabs/tailwindcss/issues/20378 -// - https://vite.dev/blog/announcing-vite8-1#experimental-bundled-dev-mode -test( - 'dev mode (experimental `bundledDev`)', - { - fs: { - 'package.json': json`{}`, - 'pnpm-workspace.yaml': yaml` - # - packages: - - project-a - `, - 'project-a/package.json': txt` - { - "type": "module", - "dependencies": { - "@tailwindcss/vite": "workspace:^", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - "vite": "^8.1" - } - } - `, - 'project-a/vite.config.ts': ts` - import tailwindcss from '@tailwindcss/vite' - import { defineConfig } from 'vite' - - export default defineConfig({ - experimental: { - bundledDev: true, - }, - plugins: [tailwindcss()], - }) - `, - 'project-a/index.html': html` - - - - -
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 c346f8e37..3afb057c5 100644 --- a/integrations/vite/index.test.ts +++ b/integrations/vite/index.test.ts @@ -7,7 +7,6 @@ import { html, js, json, - jsx, retryAssertion, test, ts, @@ -294,113 +293,6 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { }, ) - // https://github.com/tailwindlabs/tailwindcss/issues/20346 - test( - 'dev mode + symlinked `@source` files pick up changes to the real file', - { - fs: { - 'package.json': json`{}`, - 'pnpm-workspace.yaml': yaml` - # - packages: - - project-a - - packages/* - `, - 'project-a/package.json': txt` - { - "type": "module", - "dependencies": { - "@tailwindcss/vite": "workspace:^", - "repro-package": "workspace:*", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - ${transformer === 'lightningcss' ? `"lightningcss": "^1",` : ''} - "vite": "^7" - } - } - `, - 'project-a/vite.config.ts': ts` - import tailwindcss from '@tailwindcss/vite' - import { defineConfig } from 'vite' - - export default defineConfig({ - css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"}, - build: { cssMinify: false }, - plugins: [tailwindcss()], - }) - `, - 'project-a/index.html': html` - - - - -
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', { @@ -580,352 +472,6 @@ 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`, { @@ -1178,92 +724,12 @@ 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.toThrow('The `source(../i-do-not-exist)` does not exist') + ).rejects.toThrowError('The `source(../i-do-not-exist)` does not exist') let files = await fs.glob('project-a/dist/**/*.css') 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( @@ -1432,14 +898,6 @@ 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 }) => { @@ -1456,271 +914,3 @@ 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 20f367020..084e319d7 100644 --- a/integrations/vite/nuxt.test.ts +++ b/integrations/vite/nuxt.test.ts @@ -1,15 +1,4 @@ -import { - candidate, - css, - fetchStyles, - getRandomPort, - html, - json, - retryAssertion, - test, - ts, - yaml, -} from '../utils' +import { candidate, css, fetchStyles, html, json, retryAssertion, test, ts } from '../utils' const SETUP = { fs: { @@ -18,18 +7,13 @@ const SETUP = { "type": "module", "dependencies": { "@tailwindcss/vite": "workspace:^", - "nuxt": "3.16.0", + "nuxt": "3.14.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' @@ -95,8 +79,7 @@ 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 port = await getRandomPort() - let process = await spawn(`pnpm nuxt preview --port ${port}`, { + let process = await spawn(`pnpm nuxt preview --port 8724`, { env: { TEST: 'false', NODE_ENV: 'development', diff --git a/integrations/vite/preact.test.ts b/integrations/vite/preact.test.ts deleted file mode 100644 index 8e4deec2b..000000000 --- a/integrations/vite/preact.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { candidate, css, fetchStyles, html, json, retryAssertion, test, ts, txt } from '../utils' - -test( - 'dev mode', - { - fs: { - 'package.json': json` - { - "type": "module", - "dependencies": { - "preact": "^10" - }, - "devDependencies": { - "@preact/preset-vite": "^2", - "@tailwindcss/vite": "workspace:^", - "tailwindcss": "workspace:^", - "vite": "^8" - } - } - `, - 'vite.config.ts': ts` - import fs from 'node:fs' - import path from 'node:path' - import preact from '@preact/preset-vite' - import tailwindcss from '@tailwindcss/vite' - import { defineConfig } from 'vite' - - export default defineConfig({ - plugins: [ - tailwindcss(), - preact(), - { - // Log all HMR payloads to a file so the test can assert on them - name: 'hmr-wiretap', - configureServer(server) { - let logFile = path.resolve('hmr.log') - fs.writeFileSync(logFile, '') - for (let environment of Object.values(server.environments)) { - let send = environment.hot.send.bind(environment.hot) - environment.hot.send = (payload) => { - fs.appendFileSync(logFile, JSON.stringify(payload) + '\\n') - return send(payload) - } - } - }, - }, - ], - }) - `, - 'index.html': html` - - - - - -
- - - - `, - '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 98b0c928d..5b9574fd6 100644 --- a/integrations/vite/react-router.test.ts +++ b/integrations/vite/react-router.test.ts @@ -102,101 +102,13 @@ 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 fc9aca5c7..f8e389a7e 100644 --- a/integrations/vite/resolvers.test.ts +++ b/integrations/vite/resolvers.test.ts @@ -1,661 +1,5 @@ import { describe } from 'vitest' -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; - } - } - " - `) - }, -) +import { candidate, css, fetchStyles, html, js, retryAssertion, test, ts, txt } from '../utils' describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { test( diff --git a/integrations/vite/source-maps.test.ts b/integrations/vite/source-maps.test.ts index fe26d9a7c..0e2594690 100644 --- a/integrations/vite/source-maps.test.ts +++ b/integrations/vite/source-maps.test.ts @@ -91,67 +91,3 @@ test( }) }, ) - -// https://github.com/tailwindlabs/tailwindcss/issues/19930 -test( - 'production build without Tailwind roots should not result in source map warnings', - { - fs: { - 'package.json': json` - { - "type": "module", - "dependencies": { - "@tailwindcss/vite": "workspace:^", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - "vite": "^7" - } - } - `, - 'vite.config.ts': ts` - import { defineConfig } from 'vite' - import tailwindcss from '@tailwindcss/vite' - - export default defineConfig({ - plugins: [ - tailwindcss(), - { - name: 'inspect-source-map-chain', - enforce: 'pre', - transform(_, id) { - if (id.includes('.css')) { - // Force Rollup to collapse the sourcemap chain during this build. - this.getCombinedSourcemap() - } - }, - }, - ], - css: { - devSourcemap: true, - }, - build: { - sourcemap: true, - }, - }) - `, - 'index.html': html` - - -
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 a1d660816..75442cfa2 100644 --- a/integrations/vite/ssr.test.ts +++ b/integrations/vite/ssr.test.ts @@ -1,72 +1,168 @@ -import { describe } from 'vitest' import { candidate, css, html, json, test, ts } from '../utils' -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}" - } +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" } - `, - '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()], - }) - `, - '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!
- \` - `, - }, + export default defineConfig({ + build: { + cssMinify: false, + ssrEmitAssets: true, + }, + plugins: [tailwindcss()], + }) + `, + ...WORKSPACE, }, - 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`, - ]) + 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, }, - ) -}) + }, + 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 e186c3f85..107494207 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 deleted file mode 100644 index 92e855da9..000000000 --- a/integrations/vite/sveltekit.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { candidate, css, html, js, json, test, ts } from '../utils' - -test( - 'sveltekit build resolves package plugins', - { - fs: { - 'package.json': json` - { - "type": "module", - "scripts": { - "prepare": "svelte-kit sync || echo ''" - }, - "dependencies": { - "svelte": "^5", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - "@sveltejs/adapter-auto": "^6", - "@sveltejs/kit": "^2", - "@tailwindcss/forms": "^0.5.11", - "@tailwindcss/vite": "workspace:^", - "vite": "^8" - } - } - `, - 'svelte.config.js': js` - import adapter from '@sveltejs/adapter-auto' - - export default { - kit: { - adapter: adapter(), - }, - } - `, - 'vite.config.ts': ts` - import tailwindcss from '@tailwindcss/vite' - import { sveltekit } from '@sveltejs/kit/vite' - import { defineConfig } from 'vite' - - export default defineConfig({ - build: { cssMinify: false }, - plugins: [tailwindcss(), sveltekit()], - }) - `, - 'src/app.html': html` - - - - %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 b04f57c6b..3fea0db9a 100644 --- a/integrations/vite/vue.test.ts +++ b/integrations/vite/vue.test.ts @@ -1,5 +1,5 @@ import { stripVTControlCharacters } from 'node:util' -import { candidate, css, fetchStyles, html, json, retryAssertion, test, ts } from '../utils' +import { candidate, html, json, test, ts } from '../utils' test( 'production build', @@ -73,110 +73,6 @@ test( }, ) -{ - const VUE_COMPONENT_COUNT = 1_000 - - let vueComponentsWithReferences = Object.fromEntries( - Array.from({ length: VUE_COMPONENT_COUNT }, (_, idx) => [ - `src/components/Component${idx}.vue`, - html` - - - - `, - ]), - ) - - 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`', { @@ -236,7 +132,7 @@ test( expect.assertions(1) try { - await exec('pnpm vite build', {}, { ignoreStdErr: true }) + await exec('pnpm vite build') } catch (error) { let [, message] = /error during build:([\s\S]*?)file:/g.exec( @@ -248,133 +144,3 @@ test( } }, ) - -// https://github.com/tailwindlabs/tailwindcss/issues/20320 -test( - 'editing a scanned `.vue` file that is not loaded as a module does not trigger a full reload', - { - fs: { - 'package.json': json` - { - "type": "module", - "dependencies": { - "vue": "^3.4.37", - "tailwindcss": "workspace:^" - }, - "devDependencies": { - "@vitejs/plugin-vue": "^6", - "@tailwindcss/vite": "workspace:^", - "vite": "^8" - } - } - `, - 'vite.config.ts': ts` - import fs from 'node:fs' - import path from 'node:path' - import { defineConfig } from 'vite' - import vue from '@vitejs/plugin-vue' - import tailwindcss from '@tailwindcss/vite' - - export default defineConfig({ - plugins: [ - vue(), - tailwindcss(), - { - // Log update and full-reload HMR payloads to a file so the - // test can assert on them. Custom events are not logged - // because \`@vitejs/plugin-vue\` sends a \`file-changed\` event - // for every file change, including changes to the log file - // itself, which would cause an infinite feedback loop. - name: 'hmr-wiretap', - configureServer(server) { - let logFile = path.resolve('hmr.log') - fs.writeFileSync(logFile, '') - for (let environment of Object.values(server.environments)) { - let send = environment.hot.send.bind(environment.hot) - environment.hot.send = (payload) => { - if (payload.type === 'update' || payload.type === 'full-reload') { - fs.appendFileSync(logFile, JSON.stringify(payload) + '\\n') - } - return send(payload) - } - } - }, - }, - ], - }) - `, - 'index.html': html` - - - - - - -
- - - - `, - '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.mts b/integrations/vitest.config.ts similarity index 100% rename from integrations/vitest.config.mts rename to integrations/vitest.config.ts diff --git a/integrations/webpack/loader.test.ts b/integrations/webpack/loader.test.ts deleted file mode 100644 index 9c3d870af..000000000 --- a/integrations/webpack/loader.test.ts +++ /dev/null @@ -1,495 +0,0 @@ -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 7d41a469e..bd07ccc49 100644 --- a/package.json +++ b/package.json @@ -48,17 +48,23 @@ }, "license": "MIT", "devDependencies": { - "@playwright/test": "^1.62.1", + "@playwright/test": "^1.54.2", "@types/node": "catalog:", - "postcss": "catalog:", + "postcss": "8.5.6", "postcss-import": "^16.1.1", "prettier": "catalog:", - "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" + "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" }, - "packageManager": "pnpm@11.9.0" + "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" + } + } } diff --git a/packages/@tailwindcss-browser/README.md b/packages/@tailwindcss-browser/README.md index 5f532607d..7d21bd883 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/tailwindlabs/tailwindcss/discussions) +[Discuss Tailwind CSS on GitHub](https://github.com/tailwindcss/tailwindcss/discussions) ## Contributing -If you're interested in contributing to Tailwind CSS, please read our [contributing docs](https://github.com/tailwindlabs/tailwindcss/blob/main/.github/CONTRIBUTING.md) **before submitting a pull request**. +If you're interested in contributing to Tailwind CSS, please read our [contributing docs](https://github.com/tailwindcss/tailwindcss/blob/next/.github/CONTRIBUTING.md) **before submitting a pull request**. diff --git a/packages/@tailwindcss-browser/package.json b/packages/@tailwindcss-browser/package.json index 70af78c1e..779ca60ec 100644 --- a/packages/@tailwindcss-browser/package.json +++ b/packages/@tailwindcss-browser/package.json @@ -1,6 +1,6 @@ { "name": "@tailwindcss/browser", - "version": "4.3.3", + "version": "4.1.12", "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.11", - "listhen": "^1.10.1", + "h3": "^1.15.4", + "listhen": "^1.9.0", "tailwindcss": "workspace:*" } } diff --git a/packages/@tailwindcss-browser/playwright.config.ts b/packages/@tailwindcss-browser/playwright.config.ts index c6059dd82..c3ad05b91 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', { open: 'never' }]], + reporter: 'html', /* 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,11 +30,10 @@ export default defineConfig({ name: 'chromium', use: { ...devices['Desktop Chrome'] }, }, - // Playwright 1.60's Windows WebKit worker hangs on shutdown after all tests - // pass, causing CI to fail with a worker force-kill error. - ...(process.platform === 'win32' - ? [] - : [{ name: 'webkit', use: { ...devices['Desktop Safari'] } }]), + { + 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 49c7e4675..74992157f 100644 --- a/packages/@tailwindcss-browser/src/index.ts +++ b/packages/@tailwindcss-browser/src/index.ts @@ -2,6 +2,12 @@ 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 `