diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6bcb279a4..5ae59e80f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,8 +27,10 @@ jobs: - name: Linux os: namespace-profile-default + # Playwright 1.62+ dropped WebKit support for macOS 14, and hangs + # instead of failing when launching WebKit on a macos-14 runner. - name: macOS - os: macos-14 + os: macos-15 # Exclude windows and macos from being built on feature branches run-all: diff --git a/CHANGELOG.md b/CHANGELOG.md index 65a121f9f..338a78eb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -- Nothing yet! +### Added + +- Add `@tailwindcss/turbopack` package to run Tailwind CSS with Next.js ([20367](https://github.com/tailwindlabs/tailwindcss/pull/20367)) + +### Fixed + +- Ensure watch mode detects changes to symlinked `@source` files whose real paths aren't otherwise scanned ([#20356](https://github.com/tailwindlabs/tailwindcss/pull/20356)) +- Ensure custom variants using `@scope` wrap the generated utilities instead of nesting inside them ([#20369](https://github.com/tailwindlabs/tailwindcss/pull/20369)) +- Fix flattening of `@scope` at-rules ([#20369](https://github.com/tailwindlabs/tailwindcss/pull/20369)) +- Fix standalone declarations in `@scope`, wrap them in `:where(:scope)` ([#20369](https://github.com/tailwindlabs/tailwindcss/pull/20369)) +- Always emit a space for empty fallback values in CSS variables (e.g. `var(--tw-blur,)` → `var(--tw-blur, )`) ([#20373](https://github.com/tailwindlabs/tailwindcss/pull/20373)) +- Canonicalization: convert arbitrary breakpoint and container query variants to named equivalents (e.g. `max-[64rem]` → `max-lg`) ([#20380](https://github.com/tailwindlabs/tailwindcss/pull/20380)) +- Prevent `@tailwindcss/vite` from crashing on every edit under Vite's experimental `bundledDev` mode ([#20379](https://github.com/tailwindlabs/tailwindcss/pull/20379)) +- Ensure `@tailwindcss/oxide` falls back to WASM on platforms without native bindings ([#20383](https://github.com/tailwindlabs/tailwindcss/pull/20383)) +- Detect classes in Ruby percent literals using angle brackets or custom delimiters (e.g. `%w`, `%w|flex|`), including in Slim and Haml templates ([#20387](https://github.com/tailwindlabs/tailwindcss/pull/20387)) +- Preserve whitespace in `--default(…)` values in custom functional utilities (e.g. `--default(box alphabetic)` no longer becomes `boxalphabetic`) ([#20392](https://github.com/tailwindlabs/tailwindcss/pull/20392)) +- Don't scan gitignored directories (e.g. `node_modules` and `.git`) when the project uses a safelist-style `.gitignore` (e.g. `/*` followed by `!/…` negations) ([#20397](https://github.com/tailwindlabs/tailwindcss/pull/20397)) +- Ensure root `theme('…')` namespace lookups in JavaScript plugins and config files return the full namespace object instead of the value of its `DEFAULT` key ([#20399](https://github.com/tailwindlabs/tailwindcss/pull/20399)) +- Skip ignored directories entirely when computing watch globs (`scanner.globs`), instead of walking their full contents on every rebuild ([#20408](https://github.com/tailwindlabs/tailwindcss/pull/20408)) +- Oxide: drop invalid UTF-8 candidates ([#20389](https://github.com/tailwindlabs/tailwindcss/pull/20389)) +- `@tailwindcss/vite` no longer forces a full page reload for external files (e.g.: `.php` files) ([#20414](https://github.com/tailwindlabs/tailwindcss/issues/20414)) +- Canonicalization: don't merge utilities that reference different theme variables set to CSS-wide keywords like `unset` ([#20417](https://github.com/tailwindlabs/tailwindcss/pull/20417)) +- Don't generate utilities when a modifier is used that would otherwise be silently ignored (e.g. `rounded-sm/[5]`, `shadow-sm/foo`, `stroke-2/50`) ([#20419](https://github.com/tailwindlabs/tailwindcss/pull/20419)) +- Only normalize top-level `and`, `or`, and `not` keywords in `supports-[…]` variants (e.g. `selector(a: not (.foo))` → `selector(a:not(.foo))`) ([#20420](https://github.com/tailwindlabs/tailwindcss/pull/20420)) +- Don't warn about Angular's `::ng-deep` and `:host-context()` when optimizing CSS ([#20434](https://github.com/tailwindlabs/tailwindcss/pull/20434)) +- Don't generate CSS for candidates containing an empty additional modifier (e.g. `bg-red-500/50/` and `group-hover/foo//bar:flex`) ([#20466](https://github.com/tailwindlabs/tailwindcss/pull/20466)) ## [4.3.3] - 2026-07-16 diff --git a/Cargo.lock b/Cargo.lock index d8459d235..73fd9e995 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -40,7 +40,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "531a9155a481e2ee699d4f98f43c0ca4ff8ee1bfd55c31e9e98fb29d2b176fe0" dependencies = [ "memchr", - "regex-automata 0.4.8", + "regex-automata 0.4.18", "serde", ] @@ -59,6 +59,17 @@ 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" @@ -104,19 +115,9 @@ checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" [[package]] name = "ctor" -version = "0.10.1" +version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83cf0d42651b16c6dfe68685716d18480d18a9c39c62d76e8cf3eb6ed5d8bcbf" -dependencies = [ - "ctor-proc-macro", - "dtor", -] - -[[package]] -name = "ctor-proc-macro" -version = "0.0.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a949c44fcacbbbb7ada007dc7acb34603dd97cd47de5d054f2b6493ecebb483" +checksum = "2d83cb7e7a873830708d6b02a78cd36a592c6fa14bf267b68725103b85c0d77f" [[package]] name = "diff" @@ -124,21 +125,6 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" -[[package]] -name = "dtor" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edf234dd1594d6dd434a8fb8cada51ddbbc593e40e4a01556a0b31c62da2775b" -dependencies = [ - "dtor-proc-macro", -] - -[[package]] -name = "dtor-proc-macro" -version = "0.0.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2647271c92754afcb174e758003cfd1cbf1e43e5a7853d7b1813e63e19e39a73" - [[package]] name = "dunce" version = "1.0.5" @@ -151,6 +137,12 @@ version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "errno" version = "0.3.9" @@ -266,14 +258,14 @@ dependencies = [ [[package]] name = "globset" -version = "0.4.17" +version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eab69130804d941f8075cfd713bf8848a2c3b3f201a9457a11e6f87e1ab62305" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" dependencies = [ "aho-corasick", "bstr", "log", - "regex-automata 0.4.8", + "regex-automata 0.4.18", "regex-syntax 0.8.5", ] @@ -298,7 +290,7 @@ dependencies = [ "globset", "log", "memchr", - "regex-automata 0.4.8", + "regex-automata 0.4.18", "same-file", "walkdir", "winapi-util", @@ -306,7 +298,7 @@ dependencies = [ [[package]] name = "ignore" -version = "0.4.24" +version = "0.4.33" dependencies = [ "bstr", "crossbeam-channel", @@ -315,12 +307,24 @@ dependencies = [ "globset", "log", "memchr", - "regex-automata 0.4.8", + "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" @@ -387,9 +391,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "napi" -version = "3.8.5" +version = "3.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa73b028610e2b26e9e40bd2c8ff8a98e6d7ed5d67d89ebf4bfd2f992616b024" +checksum = "de33522036981030a75c231829566bc63414e08101a6f5ff4ac6cef19c8e0941" dependencies = [ "bitflags", "ctor", @@ -402,15 +406,15 @@ dependencies = [ [[package]] name = "napi-build" -version = "2.3.1" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d376940fd5b723c6893cd1ee3f33abbfd86acb1cd1ec079f3ab04a2a3bc4d3b1" +checksum = "c9c366d2c8c60b86fa632df75f745509b52f9128f91a6bad4c796e44abb505e1" [[package]] name = "napi-derive" -version = "3.5.4" +version = "3.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7430702d3cc05cf55f0a2c9e41d991c3b7a53f91e6146a8f282b1bfc7f3fd133" +checksum = "a49c513341a61a16a10af6efcce46b30d0822ba2d4fb197d24d33dfc199c78d5" dependencies = [ "convert_case", "ctor", @@ -422,9 +426,9 @@ dependencies = [ [[package]] name = "napi-derive-backend" -version = "5.0.3" +version = "6.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ca5a083f2c9b49a0c7d33ec75c083498849c6fcc46f5497317faa39ea77f5d5" +checksum = "d60b5d773ad46c698c8cc2cd9fde0b283d39cbb7f71c04bee633c7bdba4423bd" dependencies = [ "convert_case", "proc-macro2", @@ -435,9 +439,9 @@ dependencies = [ [[package]] name = "napi-sys" -version = "3.2.1" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eb602b84d7c1edae45e50bbf1374696548f36ae179dfa667f577e384bb90c2b" +checksum = "85fbf1fa9f1babfe396d74bbbf52b3643770243e8f5b0b46715d4caf7f0dfc9a" dependencies = [ "libloading", ] @@ -470,9 +474,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.19.0" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "overload" @@ -516,9 +520,9 @@ dependencies = [ [[package]] name = "rayon" -version = "1.10.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ "either", "rayon-core", @@ -526,9 +530,9 @@ dependencies = [ [[package]] name = "rayon-core" -version = "1.12.1" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" dependencies = [ "crossbeam-deque", "crossbeam-utils", @@ -542,7 +546,7 @@ checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.8", + "regex-automata 0.4.18", "regex-syntax 0.8.5", ] @@ -557,9 +561,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.8" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -627,6 +631,12 @@ 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" @@ -671,7 +681,8 @@ dependencies = [ "dunce", "fast-glob", "globwalk", - "ignore 0.4.24", + "ignore 0.4.33", + "insta", "log", "pretty_assertions", "rayon", @@ -857,6 +868,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-targets" version = "0.52.6" diff --git a/crates/ignore/Cargo.toml b/crates/ignore/Cargo.toml index bd0352d15..d0af38ba9 100644 --- a/crates/ignore/Cargo.toml +++ b/crates/ignore/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ignore" -version = "0.4.24" #:version +version = "0.4.33" #:version authors = ["Andrew Gallant "] description = """ A fast library for efficiently matching ignore files such as `.gitignore` @@ -12,7 +12,10 @@ repository = "https://github.com/BurntSushi/ripgrep/tree/master/crates/ignore" readme = "README.md" keywords = ["glob", "ignore", "gitignore", "pattern", "file"] license = "Unlicense OR MIT" +# CHANGED: Use an explicit edition instead of `edition.workspace = true` since this crate is +# vendored into the Tailwind CSS workspace. edition = "2024" +rust-version = "1.88" [lib] name = "ignore" @@ -20,15 +23,17 @@ bench = false [dependencies] crossbeam-deque = "0.8.3" -globset = "0.4.17" +# CHANGED: Use the published globset crate instead of a path dependency. +globset = "0.4.20" log = "0.4.20" memchr = "2.6.3" same-file = "1.0.6" walkdir = "2.4.0" +# CHANGED: Added `dunce` to canonicalize paths without UNC prefixes on Windows. dunce = "1.0.5" [dependencies.regex-automata] -version = "0.4.0" +version = "0.4.18" default-features = false features = ["std", "perf", "syntax", "meta", "nfa", "hybrid", "dfa-onepass"] diff --git a/crates/ignore/examples/walk.rs b/crates/ignore/examples/walk.rs index 9c627dc3e..c61d0515e 100644 --- a/crates/ignore/examples/walk.rs +++ b/crates/ignore/examples/walk.rs @@ -18,9 +18,7 @@ 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(&Vec::from_path_lossy(dent.path())).unwrap(); stdout.write_all(b"\n").unwrap(); } }); diff --git a/crates/ignore/src/default_types.rs b/crates/ignore/src/default_types.rs index 4e060b76a..6b5bba0f0 100644 --- a/crates/ignore/src/default_types.rs +++ b/crates/ignore/src/default_types.rs @@ -47,6 +47,7 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[ (&["cml"], &["*.cml"]), (&["coffeescript"], &["*.coffee"]), (&["config"], &["*.cfg", "*.conf", "*.config", "*.ini"]), + (&["container"], &["*Containerfile*", "*Dockerfile*"]), (&["coq"], &["*.v"]), (&["cpp"], &[ "*.[ChH]", "*.cc", "*.[ch]pp", "*.[ch]xx", "*.hh", "*.inl", @@ -109,6 +110,7 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[ (&["hbs"], &["*.hbs"]), (&["hs"], &["*.hs", "*.lhs"]), (&["html"], &["*.htm", "*.html", "*.ejs"]), + (&["hurl"], &["*.hurl"]), (&["hy"], &["*.hy"]), (&["idris"], &["*.idr", "*.lidr"]), (&["janet"], &["*.janet"]), @@ -185,6 +187,7 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[ (&["mint"], &["*.mint"]), (&["mk"], &["mkfile"]), (&["ml"], &["*.ml"]), + (&["mojo"], &["*.mojo"]), (&["motoko"], &["*.mo"]), (&["msbuild"], &[ "*.csproj", "*.fsproj", "*.vcxproj", "*.proj", "*.props", "*.targets", @@ -206,11 +209,12 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[ "*.php", "*.php3", "*.php4", "*.php5", "*.php7", "*.php8", "*.pht", "*.phtml" ]), + (&["pkgbuild"], &["PKGBUILD"]), (&["po"], &["*.po"]), (&["pod"], &["*.pod"]), (&["postscript"], &["*.eps", "*.ps"]), (&["prolog"], &["*.pl", "*.pro", "*.prolog", "*.P"]), - (&["protobuf"], &["*.proto"]), + (&["proto", "protobuf"], &["*.proto"]), (&["ps"], &["*.cdxml", "*.ps1", "*.ps1xml", "*.psd1", "*.psm1"]), (&["puppet"], &["*.epp", "*.erb", "*.pp", "*.rb"]), (&["purs"], &["*.purs"]), @@ -231,6 +235,7 @@ pub(crate) const DEFAULT_TYPES: &[(&[&str], &[&str])] = &[ (&["red"], &["*.r", "*.red", "*.reds"]), (&["rescript"], &["*.res", "*.resi"]), (&["robot"], &["*.robot"]), + (&["rocq"], &["*.v"]), (&["rst"], &["*.rst"]), (&["ruby"], &[ // Idiomatic files @@ -274,6 +279,7 @@ 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"]), @@ -359,4 +365,14 @@ mod tests { previous_name = name; } } + + #[test] + fn default_types_aliases_are_sorted() { + for (aliases, _) in DEFAULT_TYPES.iter() { + assert!( + aliases.is_sorted(), + "this alias list is not sorted: {aliases:?}", + ); + } + } } diff --git a/crates/ignore/src/dir.rs b/crates/ignore/src/dir.rs index 11b58f8ca..6bee724c8 100644 --- a/crates/ignore/src/dir.rs +++ b/crates/ignore/src/dir.rs @@ -16,7 +16,7 @@ use std::{ collections::HashMap, ffi::{OsStr, OsString}, - fs::{File, FileType}, + fs::{self, File, FileType}, io::{self, BufRead}, path::{Path, PathBuf}, sync::{Arc, RwLock, Weak}, @@ -25,7 +25,7 @@ use std::{ use crate::{ gitignore::{self, Gitignore, GitignoreBuilder}, overrides::{self, Override}, - pathutil::{is_hidden, strip_prefix}, + pathutil::{is_hidden_entry, strip_prefix}, types::{self, Types}, walk::DirEntry, {Error, Match, PartialErrorBuilder}, @@ -91,7 +91,25 @@ struct IgnoreOptions { /// Ignore is a matcher useful for recursively walking one or more directories. #[derive(Clone, Debug)] -pub(crate) struct Ignore(Arc); +pub(crate) struct Ignore { + inner: Arc, + // Parent matchers are cached independently of the path being walked, but + // matching them still needs the canonicalized path originally passed to + // `add_parents`. For example, when walking `/tmp/project/src`, parent + // matchers use `/tmp/project/src` to rewrite `/tmp/project/src/foo.py` + // before matching it against ignore files from `/tmp/project` and its + // ancestors. + // + // For ripgrep itself, this means that `rg pat src tests` must rewrite + // `src/foo` relative to `.../src`, and not whatever root was prepared + // first. + // + // See: https://github.com/BurntSushi/ripgrep/pull/3420 + // See: https://github.com/BurntSushi/ripgrep/issues/3376 + // See: https://github.com/BurntSushi/ripgrep/issues/3419 + // See: https://github.com/BurntSushi/ripgrep/issues/3320 + absolute_base: Option>, +} #[derive(Clone, Debug)] struct IgnoreInner { @@ -112,12 +130,9 @@ struct IgnoreInner { /// /// If this is the root directory or there are otherwise no more /// directories to match, then `parent` is `None`. - parent: Option, + parent: Option>, /// Whether this is an absolute parent matcher, as added by add_parent. is_absolute_parent: bool, - /// The absolute base path of this matcher. Populated only if parent - /// directories are added. - absolute_base: Option>, /// The directory that gitignores should be interpreted relative to. /// /// Usually this is the directory containing the gitignore file. But in @@ -152,34 +167,36 @@ struct IgnoreInner { impl Ignore { /// Return the directory path of this matcher. + #[cfg(test)] pub(crate) fn path(&self) -> &Path { - &self.0.dir + &self.inner.dir } /// Return true if this matcher has no parent. pub(crate) fn is_root(&self) -> bool { - self.0.parent.is_none() - } - - /// Returns true if this matcher was added via the `add_parents` method. - pub(crate) fn is_absolute_parent(&self) -> bool { - self.0.is_absolute_parent + self.inner.parent.is_none() } /// Return this matcher's parent, if one exists. pub(crate) fn parent(&self) -> Option { - self.0.parent.clone() + self.inner.parent.as_ref().map(|parent| Ignore { + inner: parent.clone(), + absolute_base: self.absolute_base.clone(), + }) } /// Create a new `Ignore` matcher with the parent directories of `dir`. /// /// Note that this can only be called on an `Ignore` matcher with no /// parents (i.e., `is_root` returns `true`). This will panic otherwise. - pub(crate) fn add_parents>(&self, path: P) -> (Ignore, Option) { - if !self.0.opts.parents - && !self.0.opts.git_ignore - && !self.0.opts.git_exclude - && !self.0.opts.git_global + pub(crate) fn add_parents>( + &self, + path: P, + ) -> (Ignore, Option) { + if !self.inner.opts.parents + && !self.inner.opts.git_ignore + && !self.inner.opts.git_exclude + && !self.inner.opts.git_global { // If we never need info from parent directories, then don't do // anything. @@ -209,25 +226,34 @@ impl Ignore { let mut errs = PartialErrorBuilder::default(); let mut ig = self.clone(); for parent in parents.into_iter().rev() { - let mut compiled = self.0.compiled.write().unwrap(); + let mut compiled = self.inner.compiled.write().unwrap(); if let Some(weak) = compiled.get(parent.as_os_str()) { if let Some(prebuilt) = weak.upgrade() { - ig = Ignore(prebuilt); + ig = Ignore { + inner: prebuilt, + absolute_base: Some(absolute_base.clone()), + }; continue; } } let (mut igtmp, err) = ig.add_child_path(parent); errs.maybe_push(err); igtmp.is_absolute_parent = true; - igtmp.absolute_base = Some(absolute_base.clone()); - igtmp.has_git = if self.0.opts.require_git && self.0.opts.git_ignore { - parent.join(".git").exists() || parent.join(".jj").exists() - } else { - false - }; + igtmp.has_git = + if self.inner.opts.require_git && self.inner.opts.git_ignore { + parent.join(".git").exists() || parent.join(".jj").exists() + } else { + false + }; let ig_arc = Arc::new(igtmp); - ig = Ignore(ig_arc.clone()); - compiled.insert(parent.as_os_str().to_os_string(), Arc::downgrade(&ig_arc)); + ig = Ignore { + inner: ig_arc.clone(), + absolute_base: Some(absolute_base.clone()), + }; + compiled.insert( + parent.as_os_str().to_os_string(), + Arc::downgrade(&ig_arc), + ); } (ig, errs.into_error_option()) } @@ -240,60 +266,161 @@ impl Ignore { /// returned if it exists. /// /// Note that all I/O errors are completely ignored. - pub(crate) fn add_child>(&self, dir: P) -> (Ignore, Option) { + pub(crate) fn add_child>( + &self, + dir: P, + ) -> (Ignore, Option) { let (ig, err) = self.add_child_path(dir.as_ref()); - (Ignore(Arc::new(ig)), err) + ( + Ignore { + inner: Arc::new(ig), + absolute_base: self.absolute_base.clone(), + }, + err, + ) + } + + /// Like add_child, but uses successful read_dir entries to reduce + /// probing when discovering ignore files. + pub(crate) fn add_child_with_entries>( + &self, + dir: P, + entries: &[fs::DirEntry], + ) -> (Ignore, Option) { + let files = self.collect_ignore_files(entries); + let (ig, err) = self.add_child_path_with_found_ignore_files( + dir.as_ref(), + Some(&files), + ); + ( + Ignore { + inner: Arc::new(ig), + absolute_base: self.absolute_base.clone(), + }, + err, + ) } /// Like add_child, but takes a full path and returns an IgnoreInner. fn add_child_path(&self, dir: &Path) -> (IgnoreInner, Option) { - let git_type = - if self.0.opts.require_git && (self.0.opts.git_ignore || self.0.opts.git_exclude) { - dir.join(".git").metadata().ok().map(|md| md.file_type()) - } else { - None - }; - let has_git = git_type.is_some() || dir.join(".jj").exists(); + self.add_child_path_with_found_ignore_files(dir, None) + } + + fn collect_ignore_files( + &self, + entries: &[fs::DirEntry], + ) -> IgnoreFilesFound { + let custom_ignore_filenames = &self.inner.custom_ignore_filenames; + let mut files = IgnoreFilesFound { + has_ignore: false, + has_git_ignore: false, + has_git_dir: false, + has_jj_dir: false, + custom_ignore_files: vec![false; custom_ignore_filenames.len()], + }; + for entry in entries { + let file_name = entry.file_name(); + if file_name == OsStr::new(".ignore") { + files.has_ignore = true; + } else if file_name == OsStr::new(".gitignore") { + files.has_git_ignore = true; + } else if file_name == OsStr::new(".git") { + files.has_git_dir = true; + } else if file_name == OsStr::new(".jj") { + files.has_jj_dir = true; + } + for (i, name) in custom_ignore_filenames.iter().enumerate() { + if file_name == name.as_os_str() { + files.custom_ignore_files[i] = true; + } + } + } + files + } + + fn add_child_path_with_found_ignore_files( + &self, + dir: &Path, + ignore_files_list: Option<&IgnoreFilesFound>, + ) -> (IgnoreInner, Option) { + let check_vcs_dir = self.inner.opts.require_git + && (self.inner.opts.git_ignore || self.inner.opts.git_exclude); + let git_type = if check_vcs_dir + && ignore_files_list.is_none_or(|i| i.has_git_dir) + { + dir.join(".git").metadata().ok().map(|md| md.file_type()) + } else { + None + }; + let has_jj = check_vcs_dir + && ignore_files_list.is_none_or(|i| i.has_jj_dir) + && dir.join(".jj").exists(); + let has_git = check_vcs_dir && (git_type.is_some() || has_jj); let mut errs = PartialErrorBuilder::default(); - let custom_ig_matcher = if self.0.custom_ignore_filenames.is_empty() { + let custom_ig_matcher = if self + .inner + .custom_ignore_filenames + .is_empty() + { Gitignore::empty() } else { - let (m, err) = create_gitignore( - &dir, - &dir, - &self.0.custom_ignore_filenames, - self.0.opts.ignore_case_insensitive, - ); - errs.maybe_push(err); - m + let custom_ignore_names: Vec<&OsString> = match ignore_files_list { + None => self.inner.custom_ignore_filenames.iter().collect(), + Some(m) => self + .inner + .custom_ignore_filenames + .iter() + .zip(m.custom_ignore_files.iter()) + .filter(|&(_, &matched)| matched) + .map(|(name, _)| name) + .collect(), + }; + if custom_ignore_names.is_empty() { + Gitignore::empty() + } else { + let (m, err) = create_gitignore( + &dir, + &dir, + &custom_ignore_names, + self.inner.opts.ignore_case_insensitive, + ); + errs.maybe_push(err); + m + } }; - let ig_matcher = if !self.0.opts.ignore { + let ig_matcher = if !self.inner.opts.ignore + || !ignore_files_list.is_none_or(|i| i.has_ignore) + { Gitignore::empty() } else { let (m, err) = create_gitignore( &dir, &dir, &[".ignore"], - self.0.opts.ignore_case_insensitive, + self.inner.opts.ignore_case_insensitive, ); errs.maybe_push(err); m }; - let gi_matcher = if !self.0.opts.git_ignore { + let gi_matcher = if !self.inner.opts.git_ignore + || !ignore_files_list.is_none_or(|i| i.has_git_ignore) + { Gitignore::empty() } else { let (m, err) = create_gitignore( &dir, &dir, &[".gitignore"], - self.0.opts.ignore_case_insensitive, + self.inner.opts.ignore_case_insensitive, ); errs.maybe_push(err); m }; - let gi_exclude_matcher = if !self.0.opts.git_exclude { + let gi_exclude_matcher = if !self.inner.opts.git_exclude + || !ignore_files_list.is_none_or(|i| i.has_git_dir) + { Gitignore::empty() } else { match resolve_git_commondir(dir, git_type) { @@ -302,7 +429,7 @@ impl Ignore { &dir, &git_dir, &["info/exclude"], - self.0.opts.ignore_case_insensitive, + self.inner.opts.ignore_case_insensitive, ); errs.maybe_push(err); m @@ -314,32 +441,38 @@ impl Ignore { } }; let ig = IgnoreInner { - compiled: self.0.compiled.clone(), + compiled: self.inner.compiled.clone(), dir: dir.to_path_buf(), - overrides: self.0.overrides.clone(), - types: self.0.types.clone(), - parent: Some(self.clone()), + overrides: self.inner.overrides.clone(), + types: self.inner.types.clone(), + parent: Some(self.inner.clone()), is_absolute_parent: false, - absolute_base: self.0.absolute_base.clone(), - global_gitignores_relative_to: self.0.global_gitignores_relative_to.clone(), - explicit_ignores: self.0.explicit_ignores.clone(), - custom_ignore_filenames: self.0.custom_ignore_filenames.clone(), + global_gitignores_relative_to: self + .inner + .global_gitignores_relative_to + .clone(), + explicit_ignores: self.inner.explicit_ignores.clone(), + custom_ignore_filenames: self + .inner + .custom_ignore_filenames + .clone(), custom_ignore_matcher: custom_ig_matcher, ignore_matcher: ig_matcher, - git_global_matcher: self.0.git_global_matcher.clone(), + git_global_matcher: self.inner.git_global_matcher.clone(), git_ignore_matcher: gi_matcher, git_exclude_matcher: gi_exclude_matcher, has_git, - opts: self.0.opts, + opts: self.inner.opts, }; (ig, errs.into_error_option()) } /// Returns true if at least one type of ignore rule should be matched. fn has_any_ignore_rules(&self) -> bool { - let opts = self.0.opts; - let has_custom_ignore_files = !self.0.custom_ignore_filenames.is_empty(); - let has_explicit_ignores = !self.0.explicit_ignores.is_empty(); + let opts = self.inner.opts; + let has_custom_ignore_files = + !self.inner.custom_ignore_filenames.is_empty(); + let has_explicit_ignores = !self.inner.explicit_ignores.is_empty(); opts.ignore || opts.git_global @@ -350,9 +483,12 @@ impl Ignore { } /// Like `matched`, but works with a directory entry instead. - pub(crate) fn matched_dir_entry<'a>(&'a self, dent: &DirEntry) -> Match> { + pub(crate) fn matched_dir_entry<'a>( + &'a self, + dent: &DirEntry, + ) -> Match> { let m = self.matched(dent.path(), dent.is_dir()); - if m.is_none() && self.0.opts.hidden && is_hidden(dent) { + if m.is_none() && self.inner.opts.hidden && is_hidden_entry(dent) { return Match::Ignore(IgnoreMatch::hidden()); } m @@ -362,7 +498,11 @@ impl Ignore { /// ignored or not. /// /// The match contains information about its origin. - fn matched<'a, P: AsRef>(&'a self, path: P, is_dir: bool) -> Match> { + pub(crate) fn matched<'a, P: AsRef>( + &'a self, + path: P, + is_dir: bool, + ) -> Match> { // We need to be careful with our path. If it has a leading ./, then // strip it because it causes nothing but trouble. let mut path = path.as_ref(); @@ -373,9 +513,9 @@ impl Ignore { // regardless of whether it's whitelist/ignore, then we quit and // return that result immediately. Overrides have the highest // precedence. - if !self.0.overrides.is_empty() { + if !self.inner.overrides.is_empty() { let mat = self - .0 + .inner .overrides .matched(path, is_dir) .map(IgnoreMatch::overrides); @@ -392,8 +532,9 @@ impl Ignore { whitelisted = mat; } } - if !self.0.types.is_empty() { - let mat = self.0.types.matched(path, is_dir).map(IgnoreMatch::types); + if !self.inner.types.is_empty() { + let mat = + self.inner.types.matched(path, is_dir).map(IgnoreMatch::types); if mat.is_ignore() { return mat; } else if mat.is_whitelist() { @@ -405,98 +546,143 @@ impl Ignore { /// Performs matching only on the ignore files for this directory and /// all parent directories. - fn matched_ignore<'a>(&'a self, path: &Path, is_dir: bool) -> Match> { - let (mut m_custom_ignore, mut m_ignore, mut m_gi, mut m_gi_exclude, mut m_explicit) = ( - Match::None, - Match::None, - Match::None, - Match::None, - Match::None, - ); - let any_git = !self.0.opts.require_git || self.parents().any(|ig| ig.0.has_git); + pub(crate) fn matched_ignore<'a>( + &'a self, + path: &Path, + is_dir: bool, + ) -> Match> { + let ( + mut m_custom_ignore, + mut m_ignore, + mut m_gi, + mut m_gi_exclude, + mut m_explicit, + ) = (Match::None, Match::None, Match::None, Match::None, Match::None); + let any_git = !self.inner.opts.require_git + || self.parents().any(|ig| ig.inner.has_git); let mut saw_git = false; - for ig in self.parents().take_while(|ig| !ig.0.is_absolute_parent) { + for ig in self.parents().take_while(|ig| !ig.inner.is_absolute_parent) + { if m_custom_ignore.is_none() { - m_custom_ignore = - ig.0.custom_ignore_matcher - .matched(path, is_dir) - .map(IgnoreMatch::gitignore); + m_custom_ignore = ig + .inner + .custom_ignore_matcher + .matched(path, is_dir) + .map(IgnoreMatch::gitignore); } if m_ignore.is_none() { - m_ignore = - ig.0.ignore_matcher - .matched(path, is_dir) - .map(IgnoreMatch::gitignore); + m_ignore = ig + .inner + .ignore_matcher + .matched(path, is_dir) + .map(IgnoreMatch::gitignore); } if any_git && !saw_git && m_gi.is_none() { - m_gi = - ig.0.git_ignore_matcher - .matched(path, is_dir) - .map(IgnoreMatch::gitignore); + m_gi = ig + .inner + .git_ignore_matcher + .matched(path, is_dir) + .map(IgnoreMatch::gitignore); } if any_git && !saw_git && m_gi_exclude.is_none() { - m_gi_exclude = - ig.0.git_exclude_matcher - .matched(path, is_dir) - .map(IgnoreMatch::gitignore); + m_gi_exclude = ig + .inner + .git_exclude_matcher + .matched(path, is_dir) + .map(IgnoreMatch::gitignore); } - saw_git = saw_git || ig.0.has_git; + saw_git = saw_git || ig.inner.has_git; } - if self.0.opts.parents { - if let Some(_) = self.absolute_base() { - // 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 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.0.custom_ignore_matcher - .matched(&path, is_dir) - .map(IgnoreMatch::gitignore); + m_custom_ignore = ig + .inner + .custom_ignore_matcher + .matched(&path, is_dir) + .map(IgnoreMatch::gitignore); } if m_ignore.is_none() { - m_ignore = - ig.0.ignore_matcher - .matched(&path, is_dir) - .map(IgnoreMatch::gitignore); + m_ignore = ig + .inner + .ignore_matcher + .matched(&path, is_dir) + .map(IgnoreMatch::gitignore); } if any_git && !saw_git && m_gi.is_none() { - m_gi = - ig.0.git_ignore_matcher - .matched(&path, is_dir) - .map(IgnoreMatch::gitignore); + m_gi = ig + .inner + .git_ignore_matcher + .matched(&path, is_dir) + .map(IgnoreMatch::gitignore); } if any_git && !saw_git && m_gi_exclude.is_none() { - m_gi_exclude = - ig.0.git_exclude_matcher - .matched(&path, is_dir) - .map(IgnoreMatch::gitignore); + m_gi_exclude = ig + .inner + .git_exclude_matcher + .matched(&path, is_dir) + .map(IgnoreMatch::gitignore); } - saw_git = saw_git || ig.0.has_git; + saw_git = saw_git || ig.inner.has_git; } } } - for gi in self.0.explicit_ignores.iter().rev() { - // CHANGED: We need to make sure that the explicit gitignore rules apply to the path - // - // path = Is the current file/folder we are traversing - // gi.path() = Is the path of the custom gitignore file - // - // E.g.: If we have a custom rule for `/src/utils` with `**/*`, and we are looking at - // just `/src`, then the `**/*` rules do not apply to this folder, so we can - // ignore the current custom gitignore file. - // - if !path.starts_with(gi.path()) { - continue; - } + for gi in self.inner.explicit_ignores.iter().rev() { if !m_explicit.is_none() { break; } + // CHANGED: We need to make sure that the explicit gitignore rules + // apply to the path + // + // path = Is the current file/folder we are traversing + // gi.path() = Is the path of the custom gitignore file + // + // E.g.: If we have a custom rule for `/src/utils` with `**/*`, and + // we are looking at just `/src`, then the `**/*` rules do + // not apply to this folder, so we can ignore the current + // custom gitignore file. + if !path.starts_with(gi.path()) { + continue; + } m_explicit = gi.matched(&path, is_dir).map(IgnoreMatch::gitignore); } let m_global = if any_git { - self.0 + self.inner .git_global_matcher .matched(&path, is_dir) .map(IgnoreMatch::gitignore) @@ -504,59 +690,90 @@ impl Ignore { Match::None }; - // CHANGED: We added logic to configure an order in which the ignore files are respected and - // allowed a whitelist in a later file to overrule a block on an earlier file. + // CHANGED: We added logic to configure an order in which the ignore + // files are respected. Explicitly added ignores (via + // `WalkBuilder::add_gitignore`) take precedence over all ignore files + // found on disk, and the first source with a definitive answer wins. let order = [ // Manually added ignores - &m_explicit, + m_explicit, // .custom-ignore - &m_custom_ignore, + m_custom_ignore, // .ignore - &m_ignore, + m_ignore, // .gitignore - &m_gi, + m_gi, // .git/info/exclude - &m_gi_exclude, + m_gi_exclude, // Global gitignore - &m_global, + m_global, ]; - for check in order.into_iter() { - if check.is_none() { - continue; + if !check.is_none() { + return check; } - - return check.clone(); } - - m_explicit + Match::None } /// Returns an iterator over parent ignore matchers, including this one. pub(crate) fn parents(&self) -> Parents<'_> { - Parents(Some(self)) + Parents(Some(IgnoreRef { inner: &self.inner })) } /// Returns the first absolute path of the first absolute parent, if /// one exists. fn absolute_base(&self) -> Option<&Path> { - self.0.absolute_base.as_ref().map(|p| &***p) + self.absolute_base.as_ref().map(|p| &***p) + } +} + +/// State for tracking what kinds of files ripgrep is interested in for a +/// given directory. +/// +/// This is computed over the entire set of files in a directory instead of +/// trying to stat each file individually. If a file is present, it's only then +/// that we stat it for more information, instead of relying on the stat to +/// determine its existence. +#[derive(Debug)] +struct IgnoreFilesFound { + has_ignore: bool, + has_git_ignore: bool, + has_git_dir: bool, + has_jj_dir: bool, + custom_ignore_files: Vec, +} + +#[derive(Clone, Copy)] +pub(crate) struct IgnoreRef<'a> { + inner: &'a IgnoreInner, +} + +impl IgnoreRef<'_> { + pub(crate) fn path(&self) -> &Path { + &self.inner.dir + } + + pub(crate) fn is_absolute_parent(&self) -> bool { + self.inner.is_absolute_parent } } /// An iterator over all parents of an ignore matcher, including itself. -/// -/// The lifetime `'a` refers to the lifetime of the initial `Ignore` matcher. -pub(crate) struct Parents<'a>(Option<&'a Ignore>); +pub(crate) struct Parents<'a>(Option>); impl<'a> Iterator for Parents<'a> { - type Item = &'a Ignore; + type Item = IgnoreRef<'a>; - fn next(&mut self) -> Option<&'a Ignore> { + fn next(&mut self) -> Option> { match self.0.take() { None => None, Some(ig) => { - self.0 = ig.0.parent.as_ref(); + self.0 = ig + .inner + .parent + .as_deref() + .map(|inner| IgnoreRef { inner }); Some(ig) } } @@ -645,33 +862,42 @@ impl IgnoreBuilder { } gi } else { - log::debug!("ignoring global gitignore file because CWD is not known"); + log::debug!( + "ignoring global gitignore file because CWD is not known" + ); Gitignore::empty() }; - Ignore(Arc::new(IgnoreInner { - compiled: Arc::new(RwLock::new(HashMap::new())), - dir: self.dir.clone(), - overrides: self.overrides.clone(), - types: self.types.clone(), - parent: None, - is_absolute_parent: true, + Ignore { + inner: Arc::new(IgnoreInner { + compiled: Arc::new(RwLock::new(HashMap::new())), + dir: self.dir.clone(), + overrides: self.overrides.clone(), + types: self.types.clone(), + parent: None, + is_absolute_parent: true, + global_gitignores_relative_to, + explicit_ignores: Arc::new(self.explicit_ignores.clone()), + custom_ignore_filenames: Arc::new( + self.custom_ignore_filenames.clone(), + ), + custom_ignore_matcher: Gitignore::empty(), + ignore_matcher: Gitignore::empty(), + git_global_matcher: Arc::new(git_global_matcher), + git_ignore_matcher: Gitignore::empty(), + git_exclude_matcher: Gitignore::empty(), + has_git: false, + opts: self.opts, + }), absolute_base: None, - 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, - })) + } } /// Set the current directory used for matching global gitignores. - pub(crate) fn current_dir(&mut self, cwd: impl Into) -> &mut IgnoreBuilder { + pub(crate) fn current_dir( + &mut self, + cwd: impl Into, + ) -> &mut IgnoreBuilder { self.global_gitignores_relative_to = Some(cwd.into()); self } @@ -681,7 +907,10 @@ impl IgnoreBuilder { /// By default, no override matcher is used. /// /// This overrides any previous setting. - pub(crate) fn overrides(&mut self, overrides: Override) -> &mut IgnoreBuilder { + pub(crate) fn overrides( + &mut self, + overrides: Override, + ) -> &mut IgnoreBuilder { self.overrides = Arc::new(overrides); self } @@ -712,8 +941,7 @@ impl IgnoreBuilder { &mut self, file_name: S, ) -> &mut IgnoreBuilder { - self.custom_ignore_filenames - .push(file_name.as_ref().to_os_string()); + self.custom_ignore_filenames.push(file_name.as_ref().to_os_string()); self } @@ -725,6 +953,11 @@ impl IgnoreBuilder { self } + /// Whether ignoring hidden files is enabled or not. + pub(crate) fn is_hidden(&self) -> bool { + self.opts.hidden + } + /// Enables reading `.ignore` files. /// /// `.ignore` files have the same semantics as `gitignore` files and are @@ -795,7 +1028,10 @@ impl IgnoreBuilder { /// Process ignore files case insensitively /// /// This is disabled by default. - pub(crate) fn ignore_case_insensitive(&mut self, yes: bool) -> &mut IgnoreBuilder { + pub(crate) fn ignore_case_insensitive( + &mut self, + yes: bool, + ) -> &mut IgnoreBuilder { self.opts.ignore_case_insensitive = yes; self } @@ -854,7 +1090,10 @@ pub(crate) fn create_gitignore>( /// them when multiple repositories are searched. /// /// Some I/O errors are ignored. -fn resolve_git_commondir(dir: &Path, git_type: Option) -> Result> { +fn resolve_git_commondir( + dir: &Path, + git_type: Option, +) -> Result> { let git_dir_path = || dir.join(".git"); let git_dir = git_dir_path(); if !git_type.map_or(false, |ft| ft.is_file()) { @@ -899,15 +1138,20 @@ fn resolve_git_commondir(dir: &Path, git_type: Option) -> Result + ?Sized>(prefix: &'a P, path: &'a Path) -> &'a Path { +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}; + use std::{io::Write, path::Path, sync::Arc}; - use crate::{Error, dir::IgnoreBuilder, gitignore::Gitignore, tests::TempDir}; + use crate::{ + Error, dir::IgnoreBuilder, gitignore::Gitignore, tests::TempDir, + }; fn wfile>(path: P, contents: &str) { let mut file = std::fs::File::create(path).unwrap(); @@ -936,11 +1180,11 @@ mod tests { let (gi, err) = Gitignore::new(td.path().join("not-an-ignore")); assert!(err.is_none()); - let (ig, err) = IgnoreBuilder::new() - .add_ignore(gi) - .build() - .add_child(td.path()); + let (ig, err) = + IgnoreBuilder::new().add_ignore(gi).build().add_child(td.path()); assert!(err.is_none()); + // CHANGED: Explicit ignores only apply to paths inside the directory + // of the ignore file, so we have to match against full paths. assert!(ig.matched(td.path().join("foo"), false).is_ignore()); assert!(ig.matched(td.path().join("bar"), false).is_whitelist()); assert!(ig.matched(td.path().join("baz"), false).is_none()); @@ -1211,15 +1455,152 @@ mod tests { let (ig2, err) = ig1.add_child("src"); assert!(err.is_none()); - // CHANGED: These test cases do not make sense for us as we never call the Ignore with - // relative paths. - assert!(ig1.matched("llvm", true).is_ignore()); - assert!(ig2.matched("llvm", true).is_ignore()); + assert!(ig1.matched("llvm", true).is_none()); + assert!(ig2.matched("llvm", true).is_none()); assert!(ig2.matched("src/llvm", true).is_none()); assert!(ig2.matched("foo", false).is_ignore()); assert!(ig2.matched("src/foo", false).is_ignore()); } + #[test] + fn absolute_parent_matchers_are_cached_across_roots() { + let td = tmpdir(); + mkdirp(td.path().join(".git")); + mkdirp(td.path().join("src/build")); + mkdirp(td.path().join("tests/build")); + wfile(td.path().join(".gitignore"), "tests/**/build/\n"); + + let ig0 = IgnoreBuilder::new().build(); + let (src_parents, err) = ig0.add_parents(td.path().join("src")); + assert!(err.is_none()); + let (src, err) = src_parents.add_child(td.path().join("src")); + assert!(err.is_none()); + let (tests_parents, err) = ig0.add_parents(td.path().join("tests")); + assert!(err.is_none()); + let (tests, err) = tests_parents.add_child(td.path().join("tests")); + assert!(err.is_none()); + + assert!(Arc::ptr_eq(&src_parents.inner, &tests_parents.inner)); + assert!(src.matched("build", true).is_none()); + assert!(tests.matched("build", true).is_ignore()); + } + + /// Parent matchers are shared across search roots, but path rewriting for + /// absolute parents must use each root's own base path. Otherwise a rule + /// like `src/invalid` is matched against the wrong absolute path when + /// `src` is searched before a sibling root (e.g. `tests`). + /// + /// Paths passed to `matched` use the same relative layout as `Walk` when + /// roots are given as relative directory names. + /// + /// Regression for: https://github.com/BurntSushi/ripgrep/issues/3376 + /// and https://github.com/BurntSushi/ripgrep/issues/3419 + #[test] + fn multi_root_gitignore_order_independent() { + let td = tmpdir(); + let cwd = std::env::current_dir().unwrap(); + // Use paths relative to CWD like the CLI walk does for `rg pat src tests`. + let root = td.path().strip_prefix(&cwd).unwrap_or(td.path()); + let src_root = root.join("src"); + let tests_root = root.join("tests"); + + mkdirp(td.path().join(".git")); + mkdirp(td.path().join("src")); + mkdirp(td.path().join("tests")); + wfile(td.path().join(".gitignore"), "src/invalid\n"); + wfile(td.path().join("src/invalid"), "x"); + wfile(td.path().join("src/valid"), "x"); + wfile(td.path().join("tests/valid"), "x"); + + let ig0 = IgnoreBuilder::new().build(); + + // Historically buggy order: search `src` first, then `tests`. + let (src_parents, err) = ig0.add_parents(&src_root); + assert!(err.is_none()); + let (src, err) = src_parents.add_child(&src_root); + assert!(err.is_none()); + let (tests_parents, err) = ig0.add_parents(&tests_root); + assert!(err.is_none()); + let (tests, err) = tests_parents.add_child(&tests_root); + assert!(err.is_none()); + + assert!(Arc::ptr_eq(&src_parents.inner, &tests_parents.inner)); + // Each root must carry its own absolute_base even though inners are shared. + assert_ne!( + src.absolute_base.as_ref().unwrap().as_path(), + tests.absolute_base.as_ref().unwrap().as_path() + ); + assert!( + src.matched(src_root.join("invalid"), false).is_ignore(), + "parent .gitignore must apply for the src root even when \ + another root was prepared in the same process" + ); + assert!(src.matched(src_root.join("valid"), false).is_none()); + assert!(tests.matched(tests_root.join("valid"), false).is_none()); + + // Reverse order should behave the same way. + let ig0 = IgnoreBuilder::new().build(); + let (tests_parents, err) = ig0.add_parents(&tests_root); + assert!(err.is_none()); + let (tests, err) = tests_parents.add_child(&tests_root); + assert!(err.is_none()); + let (src_parents, err) = ig0.add_parents(&src_root); + assert!(err.is_none()); + let (src, err) = src_parents.add_child(&src_root); + assert!(err.is_none()); + + assert!(src.matched(src_root.join("invalid"), false).is_ignore()); + assert!(src.matched(src_root.join("valid"), false).is_none()); + assert!(tests.matched(tests_root.join("valid"), false).is_none()); + } + + /// Same multi-root / order issue for non-git ignore files (e.g. `.rgignore` + /// via custom ignore names). + /// + /// Regression for: https://github.com/BurntSushi/ripgrep/issues/3320 + #[test] + fn multi_root_custom_ignore_order_independent() { + let td = tmpdir(); + let cwd = std::env::current_dir().unwrap(); + let root = td.path().strip_prefix(&cwd).unwrap_or(td.path()); + let alpha_root = root.join("alpha"); + let beta_root = root.join("beta"); + + mkdirp(td.path().join("alpha")); + mkdirp(td.path().join("beta")); + wfile(td.path().join(".rgignore"), "beta/**/*.svg\n"); + wfile(td.path().join("alpha/a.txt"), "x"); + wfile(td.path().join("beta/x.svg"), "x"); + + let ig0 = IgnoreBuilder::new() + .add_custom_ignore_filename(".rgignore") + .ignore(false) + .git_ignore(false) + .git_global(false) + .git_exclude(false) + .build(); + + let (alpha_parents, err) = ig0.add_parents(&alpha_root); + assert!(err.is_none()); + let (alpha, err) = alpha_parents.add_child(&alpha_root); + assert!(err.is_none()); + let (beta_parents, err) = ig0.add_parents(&beta_root); + assert!(err.is_none()); + let (beta, err) = beta_parents.add_child(&beta_root); + assert!(err.is_none()); + + assert_ne!( + alpha.absolute_base.as_ref().unwrap().as_path(), + beta.absolute_base.as_ref().unwrap().as_path() + ); + assert!(alpha.matched(alpha_root.join("a.txt"), false).is_none()); + assert!( + beta.matched(beta_root.join("x.svg"), false).is_ignore(), + "parent .rgignore must apply for the beta root regardless of \ + which root was set up first" + ); + } + #[test] fn git_info_exclude_in_linked_worktree() { let td = tmpdir(); @@ -1227,16 +1608,14 @@ mod tests { mkdirp(git_dir.join("info")); wfile(git_dir.join("info/exclude"), "ignore_me"); mkdirp(git_dir.join("worktrees/linked-worktree")); - let commondir_path = || git_dir.join("worktrees/linked-worktree/commondir"); + let commondir_path = + || git_dir.join("worktrees/linked-worktree/commondir"); mkdirp(td.path().join("linked-worktree")); let worktree_git_dir_abs = format!( "gitdir: {}", git_dir.join("worktrees/linked-worktree").to_str().unwrap(), ); - wfile( - td.path().join("linked-worktree/.git"), - &worktree_git_dir_abs, - ); + wfile(td.path().join("linked-worktree/.git"), &worktree_git_dir_abs); // relative commondir wfile(commondir_path(), "../.."); diff --git a/crates/ignore/src/gitignore.rs b/crates/ignore/src/gitignore.rs index f822d8390..8824139b5 100644 --- a/crates/ignore/src/gitignore.rs +++ b/crates/ignore/src/gitignore.rs @@ -102,7 +102,9 @@ impl Gitignore { /// /// Note that I/O errors are ignored. For more granular control over /// errors, use `GitignoreBuilder`. - pub fn new>(gitignore_path: P) -> (Gitignore, Option) { + pub fn new>( + gitignore_path: P, + ) -> (Gitignore, Option) { let path = gitignore_path.as_ref(); let parent = path.parent().unwrap_or(Path::new("/")); let mut builder = GitignoreBuilder::new(parent); @@ -123,6 +125,17 @@ 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 @@ -145,7 +158,8 @@ impl Gitignore { num_ignores: 0, num_whitelists: 0, matches: None, - // CHANGED: Add a flag to have Gitignore rules that apply only to files. + // CHANGED: Add a flag to have Gitignore rules that apply only to + // files. only_on_files: false, } } @@ -190,7 +204,11 @@ impl Gitignore { /// determined by a common suffix of the directory containing this /// gitignore) is stripped. If there is no common suffix/prefix overlap, /// then `path` is assumed to be relative to this matcher. - pub fn matched>(&self, path: P, is_dir: bool) -> Match<&Glob> { + pub fn matched>( + &self, + path: P, + is_dir: bool, + ) -> Match<&Glob> { if self.is_empty() { return Match::None; } @@ -243,11 +261,16 @@ impl Gitignore { } /// Like matched, but takes a path that has already been stripped. - fn matched_stripped>(&self, path: P, is_dir: bool) -> Match<&Glob> { + fn matched_stripped>( + &self, + path: P, + is_dir: bool, + ) -> Match<&Glob> { if self.is_empty() { return Match::None; } - // CHANGED: Rules marked as only_on_files can not match against directories. + // CHANGED: Rules marked as only_on_files can not match against + // directories. if self.only_on_files && is_dir { return Match::None; } @@ -270,7 +293,10 @@ impl Gitignore { /// Strips the given path such that it's suitable for matching with this /// gitignore matcher. - fn strip<'a, P: 'a + AsRef + ?Sized>(&'a self, path: &'a P) -> &'a Path { + fn strip<'a, P: 'a + AsRef + ?Sized>( + &'a self, + path: &'a P, + ) -> &'a Path { let mut path = path.as_ref(); // A leading ./ is completely superfluous. We also strip it from // our gitignore root path, so we need to strip it from our candidate @@ -326,7 +352,8 @@ impl GitignoreBuilder { 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, } } @@ -337,18 +364,21 @@ impl GitignoreBuilder { pub fn build(&self) -> Result { let nignore = self.globs.iter().filter(|g| !g.is_whitelist()).count(); let nwhite = self.globs.iter().filter(|g| g.is_whitelist()).count(); - let set = self.builder.build().map_err(|err| Error::Glob { - glob: None, - err: err.to_string(), - })?; + let set = self + .builder + .build() + .map_err(|err| Error::Glob { glob: None, err: err.to_string() })?; Ok(Gitignore { set, root: self.root.clone(), globs: self.globs.clone(), num_ignores: nignore as u64, num_whitelists: nwhite as u64, - matches: Some(Arc::new(Pool::new(|| vec![]))), - // CHANGED: Add a flag to have Gitignore rules that apply only to files. + matches: Some(Arc::new( + Pool::with_available_parallelism_capacity(|| vec![]), + )), + // CHANGED: Add a flag to have Gitignore rules that apply only to + // files. only_on_files: self.only_on_files, }) } @@ -411,11 +441,8 @@ impl GitignoreBuilder { // 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 - }; + 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)); @@ -537,7 +564,10 @@ 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; @@ -556,7 +586,10 @@ impl GitignoreBuilder { /// 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 { + pub fn allow_unclosed_class( + &mut self, + yes: bool, + ) -> &mut GitignoreBuilder { self.allow_unclosed_class = yes; self } @@ -576,32 +609,56 @@ impl GitignoreBuilder { /// /// Note that the file path returned may not exist. pub fn gitconfig_excludes_path() -> Option { - // git supports $HOME/.gitconfig and $XDG_CONFIG_HOME/git/config. Notably, - // both can be active at the same time, where $HOME/.gitconfig takes - // precedent. So if $HOME/.gitconfig defines a `core.excludesFile`, then - // we're done. - match gitconfig_home_contents().and_then(|x| parse_excludes_file(&x)) { - Some(path) => return Some(path), - None => {} + // When GIT_CONFIG_GLOBAL is set, it replaces both $HOME/.gitconfig and + // $XDG_CONFIG_HOME/git/config (per git 2.32+). Otherwise, git supports + // $HOME/.gitconfig and $XDG_CONFIG_HOME/git/config simultaneously, where + // $HOME/.gitconfig takes precedent. + gitconfig_global_env_contents() + .and_then(|x| parse_excludes_file(&x)) + .or_else(|| { + gitconfig_home_contents().and_then(|x| parse_excludes_file(&x)) + }) + .or_else(|| { + gitconfig_xdg_contents().and_then(|x| parse_excludes_file(&x)) + }) + // System-level config has the lowest priority for core.excludesFile. + // GIT_CONFIG_SYSTEM overrides the default /etc/gitconfig path. + .or_else(|| { + gitconfig_system_contents().and_then(|x| parse_excludes_file(&x)) + }) + .or_else(excludes_file_default) +} + +/// Returns the file contents of git's global config file from the path +/// specified by the `GIT_CONFIG_GLOBAL` environment variable. +fn gitconfig_global_env_contents() -> Option> { + let path = std::env::var_os("GIT_CONFIG_GLOBAL").map(PathBuf::from)?; + if path.as_os_str().is_empty() { + return None; } - match gitconfig_xdg_contents().and_then(|x| parse_excludes_file(&x)) { - Some(path) => return Some(path), - None => {} - } - excludes_file_default() + let mut file = BufReader::new(File::open(path).ok()?); + let mut contents = vec![]; + file.read_to_end(&mut contents).ok().map(|_| contents) +} + +/// Returns the file contents of git's system-level config file. +/// +/// Checks `GIT_CONFIG_SYSTEM` first, then falls back to `/etc/gitconfig`. +fn gitconfig_system_contents() -> Option> { + let path = std::env::var_os("GIT_CONFIG_SYSTEM") + .map(PathBuf::from) + .filter(|x| !x.as_os_str().is_empty()) + .unwrap_or_else(|| PathBuf::from("/etc/gitconfig")); + let mut file = BufReader::new(File::open(path).ok()?); + let mut contents = vec![]; + file.read_to_end(&mut contents).ok().map(|_| contents) } /// Returns the file contents of git's global config file, if one exists, in /// the user's home directory. fn gitconfig_home_contents() -> Option> { - let home = match home_dir() { - None => return None, - Some(home) => home, - }; - let mut file = match File::open(home.join(".gitconfig")) { - Err(_) => return None, - Ok(file) => BufReader::new(file), - }; + let home = home_dir()?; + let mut file = BufReader::new(File::open(home.join(".gitconfig")).ok()?); let mut contents = vec![]; file.read_to_end(&mut contents).ok().map(|_| contents) } @@ -610,19 +667,11 @@ fn gitconfig_home_contents() -> Option> { /// the user's XDG_CONFIG_HOME directory. fn gitconfig_xdg_contents() -> Option> { let path = std::env::var_os("XDG_CONFIG_HOME") - .and_then(|x| { - if x.is_empty() { - None - } else { - Some(PathBuf::from(x)) - } - }) + .map(PathBuf::from) + .filter(|x| !x.as_os_str().is_empty()) .or_else(|| home_dir().map(|p| p.join(".config"))) - .map(|x| x.join("git/config")); - let mut file = match path.and_then(|p| File::open(p).ok()) { - None => return None, - Some(file) => BufReader::new(file), - }; + .map(|x| x.join("git/config"))?; + let mut file = BufReader::new(File::open(path).ok()?); let mut contents = vec![]; file.read_to_end(&mut contents).ok().map(|_| contents) } @@ -632,13 +681,8 @@ fn gitconfig_xdg_contents() -> Option> { /// Specifically, this respects XDG_CONFIG_HOME. fn excludes_file_default() -> Option { std::env::var_os("XDG_CONFIG_HOME") - .and_then(|x| { - if x.is_empty() { - None - } else { - Some(PathBuf::from(x)) - } - }) + .map(PathBuf::from) + .filter(|x| !x.as_os_str().is_empty()) .or_else(|| home_dir().map(|p| p.join(".config"))) .map(|x| x.join("git/ignore")) } @@ -667,9 +711,7 @@ fn parse_excludes_file(data: &[u8]) -> Option { re.captures(data, &mut caps); let span = caps.get_group(1)?; let candidate = &data[span]; - std::str::from_utf8(candidate) - .ok() - .map(|s| PathBuf::from(expand_tilde(s))) + std::str::from_utf8(candidate).ok().map(|s| PathBuf::from(expand_tilde(s))) } /// Expands ~ in file paths to the value of $HOME. @@ -831,7 +873,10 @@ mod tests { fn parse_excludes_file4() { let data = bytes("[core]\nexcludesFile = \"~/foo/bar\""); let got = super::parse_excludes_file(&data); - assert_eq!(path_string(got.unwrap()), super::expand_tilde("~/foo/bar")); + assert_eq!( + path_string(got.unwrap()), + super::expand_tilde("~/foo/bar") + ); } #[test] diff --git a/crates/ignore/src/incremental.rs b/crates/ignore/src/incremental.rs new file mode 100644 index 000000000..5030b0d00 --- /dev/null +++ b/crates/ignore/src/incremental.rs @@ -0,0 +1,1286 @@ +use std::{ + collections::HashMap, + path::{Path, PathBuf}, + sync::OnceLock, +}; + +use crate::{ + Error, Match, PartialErrorBuilder, dir::Ignore, pathutil::is_hidden_path, +}; + +/// A cached matcher for checking paths against hierarchical ignore files. +/// +/// An `IncrementalIgnore` is built from a [`crate::WalkBuilder`]. Unlike a +/// recursive walk, it can check individual paths while still respecting the +/// ignore files in every relevant parent directory. Matchers for directories +/// are compiled on first use and then retained for later queries. +/// Each matcher corresponds to exactly one root configured on the builder, +/// and paths passed to it are interpreted relative to that root. +/// A matcher for the special `-` root representing standard input is inert +/// and always returns a non-match. +/// +/// The matcher checks path-based filters in the same precedence order as +/// a traversal. This includes glob overrides, `.ignore`, `.gitignore`, +/// `.git/info/exclude`, global and explicitly added ignore files, custom +/// ignore file names and file type selections. It does not apply filters that +/// require a directory entry or other traversal state, such as custom entry +/// predicates. Hidden-file detection, minimum and maximum depth limits and the +/// maximum file size are applied. +/// +/// A matcher is a snapshot at directory granularity. Once the ignore files in +/// a directory have been loaded, edits to those files are not observed. Build +/// a new matcher to reload them. +/// +/// # Warning +/// +/// The incremental path checking here necessarily needs to do a lot more work +/// per path matched. Callers should _not_ use this to run directory traversal. +/// This is intended to avoid the work of re-traversing an entire directory +/// tree when only a few changes are detected. (For example, in response to +/// file additions or deletions.) +/// +/// # Example +/// +/// ```rust,no_run +/// use ignore::WalkBuilder; +/// +/// let mut builder = WalkBuilder::new("."); +/// builder.add_custom_ignore_filename(".rgignore"); +/// let mut matchers = builder.build_matchers(); +/// let matcher = &mut matchers[0]; +/// +/// if matcher.matched("src/generated.rs", false).is_ignore() { +/// println!("ignored"); +/// } +/// ``` +#[derive(Clone, Debug)] +pub struct IncrementalIgnore { + /// The root exactly as it was given to `WalkBuilder`. + root: PathBuf, + /// The normalized root used only by the opt-in normalization routine. + normalized_root: OnceLock>, + /// The matcher for the configured root directory, loaded on first use. + ignore: RootIgnore, + /// Directory paths relative to `root`, excluding the root itself. + dirs: HashMap, + /// Options for additional filtering beyond gitignore. + options: IncrementalIgnoreOptions, +} + +/// The options for a matcher, mostly meant to duplicate as much as we can from +/// `WalkParallel`. +#[derive(Clone, Debug)] +pub(crate) struct IncrementalIgnoreOptions { + pub(crate) min_depth: Option, + pub(crate) max_depth: Option, + pub(crate) max_filesize: Option, + pub(crate) hidden: bool, + pub(crate) follow_links: bool, +} + +#[derive(Clone, Debug)] +enum RootIgnore { + Unloaded(Ignore), + Loaded(Ignore), + NotDirectory, + Stdin, +} + +/// Cached traversal state for a directory relative to the configured root. +/// +/// The presence of an entry means that the directory and every ancestor +/// between it and the root have already been checked. +#[derive(Clone, Debug)] +enum CachedDir { + /// The directory may be descended into. The matcher includes the ignore + /// rules loaded through this directory and is therefore the matcher to use + /// for its children. + Allowed(Ignore), + /// The directory may not be descended into because it was ignored by a + /// path rule or hidden-file filtering. Every descendant is consequently + /// ignored, and ignore files inside this directory are not loaded. + Ignored, +} + +impl IncrementalIgnore { + pub(crate) fn new( + root: PathBuf, + ignore: Ignore, + options: IncrementalIgnoreOptions, + ) -> IncrementalIgnore { + // File traversal special cases `-` to search stdin, so we recognize + // it here for completeness too. In particular, we really want + // `WalkBuilder::build_matchers` to return a matcher for every root, + // even when it's a simple file (handled automatically) or when it's + // stdin (necessarily special cased). + // + // If callers need to search a file or directory named `-`, then they + // can use `./-`. As is the case for file traversal too. + let ignore = if root == Path::new("-") { + RootIgnore::Stdin + } else { + RootIgnore::Unloaded(ignore) + }; + IncrementalIgnore { + root, + normalized_root: OnceLock::new(), + ignore, + dirs: HashMap::new(), + options, + } + } + + /// Return the root that paths matched by this matcher are relative to. + pub fn root(&self) -> &Path { + &self.root + } + + /// Normalize `path` and return it relative to this matcher's root. + /// + /// This returns `None` when `path` cannot be made absolute or + /// when it is known to be outside this matcher's root. Unlike + /// [`IncrementalIgnore::matched`], this performs absolute path conversion, + /// lexical normalization and allocation. It is intended as an opt-in + /// convenience for callers that do not already have root-relative paths. + /// + /// Note that `.` is interpreted relative to the process level current + /// working directory. It is _not_ interpreted relative to the root of + /// this matcher. + /// + /// Note also that this may reject paths that only differ in casing. For + /// example, if the root path for this matcher is `/FOO` but the provided + /// path is `/foo/bar`, then this may return `None`. Callers must ensure + /// casing is consistent between the path provided and the root path for + /// this matcher. + pub fn normalize>(&self, path: P) -> Option { + if matches!(self.ignore, RootIgnore::Stdin) { + return None; + } + let path = normalize_absolute(path.as_ref())?; + let root = self + .normalized_root + .get_or_init(|| normalize_absolute(&self.root)) + .as_ref()?; + path.strip_prefix(root).ok().map(Path::to_path_buf) + } + + /// Match a root-relative path against ignore files in its directory and + /// all relevant parent directories. + /// + /// `is_dir` should be true when `path` should be matched as a directory. + /// + /// For the return value, use [`IncrementalMatch::is_ignore`], + /// [`IncrementalMatch::is_whitelist`] or [`IncrementalMatch::is_none`] to + /// inspect it. + /// + /// Matchers for previously unseen directories are loaded and cached during + /// this call. Errors encountered while loading ignore files are logged. To + /// receive those errors, use [`IncrementalIgnore::matched_with_errors`]. + /// + /// `path` must be relative to this matcher's root and must not contain a + /// parent directory (`..`) component. Behavior is unspecified when these + /// preconditions are violated. Callers with an absolute path or with a + /// path containing `.` or `..` may use [`IncrementalIgnore::normalize`] + /// to get a path satisfying these preconditions. In all cases, a relative + /// path is *assumed* to be relative to the root of this ignore matcher. + /// + /// In general, it is intended that callers doing recursive directory + /// traversal on the root of this matcher may provide relative paths to + /// this routine *without* calling [`IncrementalIgnore::normalize`]. + /// + /// The empty path represents the explicitly configured root and also + /// returns non-match, consistent with recursive traversal where a root is + /// always treated as being at depth zero. + pub fn matched>( + &mut self, + path: P, + is_dir: bool, + ) -> IncrementalMatch { + let (matched, err) = self.matched_with_errors(path, is_dir); + if let Some(err) = err { + log::debug!("error while loading ignore files: {err}"); + } + matched + } + + /// Match a root-relative path and return errors encountered while loading + /// ignore files. + /// + /// This is equivalent to [`IncrementalIgnore::matched`], except that + /// it returns any errors from newly loaded ignore files. Loading can + /// partially succeed, so valid rules are always applied to the returned + /// match even when an error is present. + pub fn matched_with_errors>( + &mut self, + path: P, + is_dir: bool, + ) -> (IncrementalMatch, Option) { + let mut errs = PartialErrorBuilder::default(); + let matched = + self.matched_with_errors_impl(path.as_ref(), is_dir, &mut errs); + (matched, errs.into_error_option()) + } + + fn matched_with_errors_impl( + &mut self, + relative: &Path, + is_dir: bool, + errs: &mut PartialErrorBuilder, + ) -> IncrementalMatch { + // We short-circuit here when our matcher corresponds to `Stdin` in + // order to always return a non-match. This is somewhat redundant with + // `root_ignore()` which does this too, but we do it here so that it + // always happens, e.g., before depth filtering. + if relative.is_absolute() || matches!(self.ignore, RootIgnore::Stdin) { + return IncrementalMatch::none(is_dir); + } + + let (mut satisfies_min, mut edge_max) = (true, false); + if self.options.min_depth.is_some() || self.options.max_depth.is_some() + { + let depth = relative + .components() + .filter_map(|component| match component { + std::path::Component::CurDir => None, + component => Some(component), + }) + .count(); + satisfies_min = + self.options.min_depth.is_none_or(|min| depth >= min); + let satisfies_max = + self.options.max_depth.is_none_or(|max| depth <= max); + edge_max = self.options.max_depth.is_some_and(|max| depth == max); + // When we have a file that isn't past our min depth, we can give + // up right away. + if !is_dir && !satisfies_min { + return IncrementalMatch::ignore().not_within_depth(); + } + // Same for *anything* that exceeds our max depth. + if !satisfies_max { + return IncrementalMatch::ignore().not_within_depth(); + } + } + + // When the path is invalid in some way, we bail out early to avoid + // potentially doing a stat call below. + let (mut mat, valid) = self + .matched_with_errors_ignore(relative, is_dir, errs) + .map(|mat| (mat, true)) + .unwrap_or_else(|| (IncrementalMatch::none(is_dir), false)); + + if !is_dir + && !mat.is_ignore() + && valid + && let Some(max_filesize) = self.options.max_filesize + { + let path = self.root().join(relative); + let result = if self.options.follow_links { + path.metadata() + } else { + path.symlink_metadata() + }; + match result { + Ok(md) if md.len() > max_filesize => { + return IncrementalMatch::ignore(); + } + Ok(_) => {} + Err(err) => { + // Record the error but otherwise fall through + let err = Error::from(err); + errs.push(err.with_path(path)); + } + } + } + + // We still need to tag our `mat` if it doesn't pass the depth filter, + // or if it's a directory on the edge of a depth filter. This can only + // happen when the path is reported as a directory. Otherwise regular + // files are always handled above. + if is_dir { + if !satisfies_min { + mat = mat.not_within_depth(); + } + if edge_max { + mat = mat.no_descent(); + } + } + mat + } + + fn matched_with_errors_ignore( + &mut self, + relative: &Path, + is_dir: bool, + errs: &mut PartialErrorBuilder, + ) -> Option { + let mut components = relative + .components() + .filter_map(|component| match component { + std::path::Component::CurDir => None, + component => Some(component), + }) + .peekable(); + components.peek()?; + + // If the exact parent is cached, then all of its ancestors have + // already been checked. An allowed cache entry is the matcher to use + // for children of that directory, while an ignored cache entry is + // terminal for every descendant. In the usual case, this avoids both + // walking every component and allocating a relative directory path. + // + // Only try the fast path when the final component is a normal path + // component. Invalid paths are handled by the component walk below. + let has_normal_final_component = + relative.components().next_back().is_some_and(|component| { + matches!(component, std::path::Component::Normal(_)) + }); + if has_normal_final_component && let Some(parent) = relative.parent() { + match self.dirs.get(parent) { + Some(CachedDir::Allowed(ignore)) => { + return Some(self.match_path(ignore, relative, is_dir)); + } + Some(CachedDir::Ignored) => { + return Some(IncrementalMatch::ignore()); + } + None => {} + } + } + + let mut ignore = self.root_ignore(errs)?; + let mut dir = PathBuf::new(); + while let Some(component) = components.next() { + match component { + std::path::Component::ParentDir + | std::path::Component::RootDir + | std::path::Component::Prefix(_) => { + return None; + } + std::path::Component::CurDir => continue, + std::path::Component::Normal(_) => {} + } + if components.peek().is_none() { + break; + } + dir.push(component.as_os_str()); + match self.dirs.get(&dir) { + Some(CachedDir::Allowed(cached)) => { + ignore = cached.clone(); + continue; + } + Some(CachedDir::Ignored) => { + return Some(IncrementalMatch::ignore()); + } + None => {} + } + + let path = self.root.join(&dir); + let mat = ignore.matched(&path, true); + let is_hidden = + self.options.hidden && mat.is_none() && is_hidden_path(&path); + if mat.is_ignore() || is_hidden { + self.dirs.insert(dir.clone(), CachedDir::Ignored); + return Some(IncrementalMatch::ignore()); + } + let (child, err) = ignore.add_child(&path); + errs.maybe_push(err); + self.dirs.insert(dir.clone(), CachedDir::Allowed(child.clone())); + ignore = child; + } + + Some(self.match_path(&ignore, relative, is_dir)) + } + + fn match_path( + &self, + ignore: &Ignore, + relative: &Path, + is_dir: bool, + ) -> IncrementalMatch { + let path = self.root.join(relative); + let mut mat = IncrementalMatch::from_match( + ignore.matched(&path, is_dir).map(|_| ()), + is_dir, + ); + // Whether a file is hidden or not has low precedence in filtering. We + // only check it if we haven't matched anything above. This permits + // callers to whitelist hidden files or directories. + if self.options.hidden && mat.is_none() && is_hidden_path(&path) { + mat = IncrementalMatch::ignore(); + } + mat + } + + fn root_ignore( + &mut self, + errs: &mut PartialErrorBuilder, + ) -> Option { + let ignore = match self.ignore { + RootIgnore::Unloaded(ref ignore) => ignore.clone(), + RootIgnore::Loaded(ref ignore) => return Some(ignore.clone()), + RootIgnore::NotDirectory | RootIgnore::Stdin => return None, + }; + if !self.root.is_dir() { + self.ignore = RootIgnore::NotDirectory; + return None; + } + + let (parents, err) = ignore.add_parents(&self.root); + errs.maybe_push(err); + let (root, err) = parents.add_child(&self.root); + errs.maybe_push(err); + self.ignore = RootIgnore::Loaded(root.clone()); + Some(root) + } +} + +/// The result of an incremental match. +/// +/// This is similar to [`Match`] in that it reports whether a file path should +/// be ignored, whitelisted or didn't match anything at all. It also has extra +/// data, such as whether a directory matched but should not be descended into. +/// +/// Generally speaking, callers that only care about specific files can stick +/// to the `is_none()`, `is_ignore()` or `is_whitelist()` predicates. Directory +/// entries are more complicated because we sometimes want to yield directories +/// to descend into, but not actually visit (e.g., for the minimum depth +/// filter). Or, we may want to yield a directory to visit but not descend into +/// (e.g., for the maximum depth filter). +#[derive(Clone, Debug)] +pub struct IncrementalMatch { + mat: Match<()>, + should_descend: bool, + is_within_depth: bool, +} + +impl IncrementalMatch { + fn none(is_dir: bool) -> IncrementalMatch { + IncrementalMatch { + mat: Match::None, + should_descend: is_dir, + is_within_depth: true, + } + } + + fn ignore() -> IncrementalMatch { + IncrementalMatch { + mat: Match::Ignore(()), + should_descend: false, + is_within_depth: true, + } + } + + fn from_match(mat: Match<()>, is_dir: bool) -> IncrementalMatch { + let should_descend = is_dir && !mat.is_ignore(); + IncrementalMatch { mat, should_descend, is_within_depth: true } + } + + fn no_descent(self) -> IncrementalMatch { + IncrementalMatch { should_descend: false, ..self } + } + + fn not_within_depth(self) -> IncrementalMatch { + IncrementalMatch { is_within_depth: false, ..self } + } + + /// Returns true if the match result didn't match anything. + pub fn is_none(&self) -> bool { + self.mat.is_none() + } + + /// Returns true if the match result implies the path should be ignored. + pub fn is_ignore(&self) -> bool { + self.mat.is_ignore() + } + + /// Returns true if the match result implies the path should be + /// whitelisted. + pub fn is_whitelist(&self) -> bool { + self.mat.is_whitelist() + } + + /// Returns true only when this match corresponds to a directory *and* + /// whether the caller should look inside this directory for additional + /// results. + /// + /// It is possible for a match to report `false` for + /// [`IncrementalMatch::is_ignore`] _and_ `false` for + /// [`IncrementalMatch::should_descend`]. This can occur, for example, when + /// a maximum depth setting allows a directory through, but where none of + /// its children entries should be visited. + /// + /// This is always `false` for a file path that does *not* correspond to a + /// directory. + pub fn should_descend(&self) -> bool { + self.should_descend + } + + /// Returns true only when this result corresponds to an entry that is + /// within the depth filter. + /// + /// This is `false` when a path corresponds to a directory and is less than + /// the minimum depth. In this case, callers should continue looking inside + /// that directory. + /// + /// This is always `true` for a match corresponding to a file path that + /// isn't a directory. + pub fn is_within_depth(&self) -> bool { + self.is_within_depth + } + + /// Inverts the match so that `Ignore` becomes `Whitelist` and + /// `Whitelist` becomes `Ignore`. A non-match remains the same. + pub fn invert(self) -> IncrementalMatch { + IncrementalMatch { mat: self.mat.invert(), ..self } + } +} + +/// Return a lexically normalized absolute representation of `path`. +/// +/// This collapses `.` and `..`, but intentionally does not canonicalize or +/// resolve symlinks. Ignore rules apply to the lexical path, and resolving a +/// symlink below a root could move the result outside of that root. +fn normalize_absolute(path: &Path) -> Option { + let absolute = std::path::absolute(path).ok()?; + let mut normalized = PathBuf::new(); + for component in absolute.components() { + match component { + std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + normalized.pop(); + } + _ => normalized.push(component.as_os_str()), + } + } + Some(normalized) +} + +#[cfg(test)] +mod tests { + use std::{ + fs::{self, File}, + io::Write, + path::{Path, PathBuf}, + }; + + use crate::{ + IncrementalIgnore, IncrementalMatch, WalkBuilder, + overrides::OverrideBuilder, tests::TempDir, types::TypesBuilder, + }; + + use super::CachedDir; + + fn wfile>(path: P, contents: &str) { + let mut file = File::create(path).unwrap(); + file.write_all(contents.as_bytes()).unwrap(); + } + + fn mkdirp>(path: P) { + fs::create_dir_all(path).unwrap(); + } + + fn tmpdir() -> TempDir { + TempDir::new().unwrap() + } + + fn builder>(path: P) -> WalkBuilder { + let mut builder = WalkBuilder::new(path); + builder.git_global(false); + builder + } + + fn one_matcher(builder: &WalkBuilder) -> IncrementalIgnore { + let mut matchers = builder.build_matchers(); + assert_eq!(matchers.len(), 1); + matchers.pop().unwrap() + } + + fn builders, P: AsRef>( + paths: I, + ) -> WalkBuilder { + let mut builder = WalkBuilder::from_iter(paths); + builder.git_global(false); + builder + } + + fn matchers(builder: &WalkBuilder) -> Vec { + builder.build_matchers() + } + + fn matchedf>( + matcher: &mut IncrementalIgnore, + path: P, + ) -> IncrementalMatch { + let (matched, err) = matcher.matched_with_errors(path, false); + assert!(err.is_none(), "unexpected matcher error: {err:?}"); + matched + } + + fn matchedd>( + matcher: &mut IncrementalIgnore, + path: P, + ) -> IncrementalMatch { + let (matched, err) = matcher.matched_with_errors(path, true); + assert!(err.is_none(), "unexpected matcher error: {err:?}"); + matched + } + + // Test that multiple parent ignore files, when nested, are respected. + #[test] + fn nested_parent_gitignores() { + let td = tmpdir(); + let root = td.path().join("project/work"); + mkdirp(td.path().join(".git")); + mkdirp(root.join("src")); + wfile(td.path().join(".gitignore"), "*.tmp\n"); + wfile(td.path().join("project/.gitignore"), "!keep.tmp\nnested.log\n"); + + let mut m = one_matcher(&builder(&root)); + assert_eq!(m.root(), root); + assert!(matchedf(&mut m, "src/drop.tmp").is_ignore()); + assert!(matchedf(&mut m, "src/keep.tmp").is_whitelist()); + assert!(matchedf(&mut m, "src/nested.log").is_ignore()); + assert!(matchedf(&mut m, "src/ok.rs").is_none()); + } + + // Test that an anchored rule in a child directory is matched relative to + // that directory, not relative to the configured root. + #[test] + fn anchored_child_rule_uses_child_root() { + let td = tmpdir(); + let root = td.path().join("root"); + mkdirp(root.join("a")); + wfile(root.join("a/.ignore"), "/foo\n"); + + let mut m = one_matcher(&builder(&root)); + assert!(matchedf(&mut m, "a/foo").is_ignore()); + assert!(matchedf(&mut m, "a/b/foo").is_none()); + } + + // Test that a leading `./` impacts how the rules are matched. + #[cfg(not(windows))] + #[test] + fn leading_dot_slash_impacts_matching() { + let td = tmpdir(); + let root = td.path().join("root"); + mkdirp(root.join("a")); + wfile(root.join(".ignore"), "/foo\n"); + wfile(root.join("a/.ignore"), "/foo\n"); + + let mut m = one_matcher(&builder(&root)); + assert!(matchedf(&mut m, "foo").is_ignore()); + assert!(matchedf(&mut m, "./foo").is_none()); + assert!(matchedf(&mut m, "a/allowed").is_none()); + assert!(matchedf(&mut m, "a/foo").is_ignore()); + assert!(matchedf(&mut m, "./a/foo").is_none()); + } + + // Test that custom ignore files are respected. + #[test] + fn parent_ignore_and_custom_ignore() { + let td = tmpdir(); + let root = td.path().join("project/work"); + mkdirp(root.join("src")); + wfile(td.path().join(".ignore"), "*.cache\n"); + wfile(td.path().join(".rgignore"), "*.svg\n"); + wfile(td.path().join("project/.ignore"), "!keep.cache\n"); + wfile(td.path().join("project/.rgignore"), "!keep.svg\n"); + + let mut builder = builder(&root); + builder.add_custom_ignore_filename(".rgignore"); + let mut m = one_matcher(&builder); + assert!(matchedf(&mut m, "src/drop.cache").is_ignore()); + assert!(matchedf(&mut m, "src/keep.cache").is_whitelist()); + assert!(matchedf(&mut m, "src/drop.svg").is_ignore()); + assert!(matchedf(&mut m, "src/keep.svg").is_whitelist()); + } + + // Test that glob overrides take precedence over ignore files. + #[test] + fn glob_overrides_are_applied() { + let td = tmpdir(); + wfile(td.path().join(".ignore"), "keep.rs\n!drop.rs\n"); + + let mut overrides = OverrideBuilder::new(td.path()); + overrides.add("keep.rs").unwrap(); + overrides.add("!drop.rs").unwrap(); + let mut b = builder(td.path()); + b.overrides(overrides.build().unwrap()); + let mut m = one_matcher(&b); + + assert!(matchedf(&mut m, "keep.rs").is_whitelist()); + assert!(matchedf(&mut m, "drop.rs").is_ignore()); + } + + // Test that an override-ignored directory prevents matching rules below + // it, just as it prevents a traversal from descending into the directory. + #[test] + fn glob_overrides_apply_to_ancestors() { + let td = tmpdir(); + mkdirp(td.path().join("blocked")); + wfile(td.path().join("blocked/.ignore"), "!keep.rs\n"); + + let mut overrides = OverrideBuilder::new(td.path()); + overrides.add("!blocked/").unwrap(); + let mut b = builder(td.path()); + b.overrides(overrides.build().unwrap()); + let mut m = one_matcher(&b); + + assert!(matchedf(&mut m, "blocked/keep.rs").is_ignore()); + } + + // Test that file type selections are applied to files, but not + // directories. + #[test] + fn file_types_are_applied() { + let td = tmpdir(); + mkdirp(td.path().join("src")); + + let mut types = TypesBuilder::new(); + types.add("rust", "*.rs").unwrap(); + types.select("rust"); + let mut b = builder(td.path()); + b.types(types.build().unwrap()); + let mut m = one_matcher(&b); + + assert!(matchedd(&mut m, "src").is_none()); + assert!(matchedf(&mut m, "src/lib.rs").is_whitelist()); + assert!(matchedf(&mut m, "README.md").is_ignore()); + } + + #[test] + fn directly_ignored_directory_is_not_descended() { + let td = tmpdir(); + mkdirp(td.path().join(".git")); + mkdirp(td.path().join("blocked")); + wfile(td.path().join(".gitignore"), "blocked/\n"); + + let mut m = one_matcher(&builder(td.path())); + let dir = matchedd(&mut m, "blocked"); + assert!(dir.is_ignore()); + assert!(!dir.should_descend()); + } + + // Test that when a directory is ignored, anything below it always ignored + // even when there are explicit whitelist rules. This matches directory + // traversal semantics. + #[test] + fn ignored_ancestor_wins() { + let td = tmpdir(); + mkdirp(td.path().join(".git")); + mkdirp(td.path().join("blocked")); + mkdirp(td.path().join("open")); + // This is the key line: since the entire `blocked` directory is + // ignored, a proper file traversal won't ever descend into it. So + // `blocked/keep.rs` should be ignored even if there are ignore rules + // "beneath" it that whitelist it. + wfile(td.path().join(".gitignore"), "blocked/\n!blocked/keep.rs\n"); + wfile(td.path().join("blocked/.gitignore"), "!keep.rs\n"); + + let mut m = one_matcher(&builder(td.path())); + assert!(matchedf(&mut m, "blocked/keep.rs").is_ignore()); + assert!(matchedf(&mut m, "blocked/other.rs").is_ignore()); + assert!(matchedf(&mut m, "open/keep.rs").is_none()); + } + + // Test that we respect git boundaries. And that we don't respect git + // boundaries when not configured to do so. + #[test] + fn respects_git_repository_boundaries() { + let td = tmpdir(); + let root = td.path().join("repo/src"); + mkdirp(td.path().join("repo/.git")); + mkdirp(&root); + wfile(td.path().join(".gitignore"), "outside-rule\n"); + wfile(td.path().join(".ignore"), "tool-rule\n"); + wfile(td.path().join("repo/.gitignore"), "inside-rule\n"); + + let mut m = one_matcher(&builder(&root)); + assert!(matchedf(&mut m, "inside-rule").is_ignore()); + assert!(matchedf(&mut m, "outside-rule").is_none()); + assert!(matchedf(&mut m, "tool-rule").is_ignore()); + + let mut no_git_required = builder(&root); + no_git_required.require_git(false); + let mut m = one_matcher(&no_git_required); + assert!(matchedf(&mut m, "outside-rule").is_ignore()); + } + + // Test that when a gitignore matcher in a parent directory is created, we + // reuse that matcher from memory even if it's changed on disk. + #[test] + fn compiled_matchers_are_reused() { + let td = tmpdir(); + mkdirp(td.path().join("a")); + wfile(td.path().join("a/.ignore"), "*.tmp\n"); + + let mut m = one_matcher(&builder(td.path())); + assert!(matchedf(&mut m, "a/first.tmp").is_ignore()); + + // Below demonstrates that this new ignore file contents + // aren't actually picked up because it was already loaded. + wfile(td.path().join("a/.ignore"), "!*.tmp\n*.rs\n"); + assert!(matchedf(&mut m, "a/first.tmp").is_ignore()); + assert!(matchedf(&mut m, "a/second.tmp").is_ignore()); + assert!(matchedf(&mut m, "a/keep.rs").is_none()); + // To get the new ignore file, we need to rebuild the matcher. + let mut m = one_matcher(&builder(td.path())); + assert!(matchedf(&mut m, "a/first.tmp").is_whitelist()); + assert!(matchedf(&mut m, "a/second.tmp").is_whitelist()); + assert!(matchedf(&mut m, "a/keep.rs").is_ignore()); + } + + #[test] + fn cached_allowed_parent_matches_children() { + let td = tmpdir(); + mkdirp(td.path().join("a/b")); + wfile(td.path().join("a/b/.ignore"), "ignored\n"); + + let mut m = one_matcher(&builder(td.path())); + assert!(matchedf(&mut m, "a/b/allowed").is_none()); + assert!(matches!( + m.dirs.get(Path::new("a/b")), + Some(CachedDir::Allowed(_)) + )); + assert!(matchedf(&mut m, "a/b/ignored").is_ignore()); + } + + #[test] + fn cached_ignored_parent_matches_children() { + let td = tmpdir(); + mkdirp(td.path().join("blocked")); + wfile(td.path().join(".ignore"), "blocked/\n"); + + let mut m = one_matcher(&builder(td.path())); + assert!(matchedf(&mut m, "blocked/first").is_ignore()); + assert!(matches!( + m.dirs.get(Path::new("blocked")), + Some(CachedDir::Ignored) + )); + assert!(matchedf(&mut m, "blocked/second").is_ignore()); + } + + #[test] + fn cached_parent_matcher_is_used_for_directory() { + let td = tmpdir(); + mkdirp(td.path().join("a/b")); + wfile(td.path().join("a/b/.ignore"), "**\n"); + + let mut m = one_matcher(&builder(td.path())); + assert!(matchedf(&mut m, "a/b/file").is_ignore()); + assert!(matches!( + m.dirs.get(Path::new("a/b")), + Some(CachedDir::Allowed(_)) + )); + let dir = matchedd(&mut m, "a/b"); + assert!(dir.is_none()); + assert!(dir.should_descend()); + } + + // Like `compiled_matchers_are_reused`, but with multiple roots. + #[test] + fn compiled_multi_matchers_are_reused() { + let td = tmpdir(); + mkdirp(td.path().join("a/b/c")); + + wfile(td.path().join("a/.ignore"), "*.tmp\n"); + let mut mats = matchers(&builders([ + td.path().join("a/b"), + td.path().join("a/b/c"), + ])); + assert!(matchedf(&mut mats[0], "first.tmp").is_ignore()); + assert!(matchedf(&mut mats[0], "c/first.tmp").is_ignore()); + + // Even though we haven't used the second matcher, it should still + // reuse the `a/.ignore` above. If it didn't, then `a/b/c/first.tmp` + // below would be whitelisted. + wfile(td.path().join("a/.ignore"), "!*.tmp\n"); + assert!(matchedf(&mut mats[1], "first.tmp").is_ignore()); + } + + #[test] + fn compiled_multi_child_matchers_are_not_reused() { + let td = tmpdir(); + mkdirp(td.path().join("a/b/c/d/e")); + + wfile(td.path().join("a/b/c/d/e/.ignore"), "*.tmp\n"); + let mut mats = matchers(&builders([ + td.path().join("a/b"), + td.path().join("a/b/c"), + ])); + // Some sanity checking first. + assert!(matchedf(&mut mats[0], "c/first.tmp").is_none()); + assert!(matchedf(&mut mats[0], "c/d/first.tmp").is_none()); + assert!(matchedf(&mut mats[0], "c/d/e/first.tmp").is_ignore()); + + // Now write a new ignore file at the same location as above + // and check that the other matcher still uses the "stale" data. + wfile(td.path().join("a/b/c/d/e/.ignore"), "!*.tmp\n"); + assert!(matchedf(&mut mats[1], "first.tmp").is_none()); + assert!(matchedf(&mut mats[1], "d/first.tmp").is_none()); + // This is the punch line: because we didn't load `mats[1]` before + // changing the ignore file, it loads it here and thus this path gets + // whitelisted. + assert!(matchedf(&mut mats[1], "d/e/first.tmp").is_whitelist()); + // ... but `mats[0]` still uses the stale gitignore matcher cached in + // memory! + assert!(matchedf(&mut mats[0], "c/d/e/first.tmp").is_ignore()); + + // If we rebuilder the matcher... then we force reloading and they're + // now consistent with one another. + let mut mats = matchers(&builders([ + td.path().join("a/b"), + td.path().join("a/b/c"), + ])); + assert!(matchedf(&mut mats[0], "c/d/e/first.tmp").is_whitelist()); + assert!(matchedf(&mut mats[1], "d/e/first.tmp").is_whitelist()); + } + + // Tests that even when there is an error with a glob pattern, we still + // respect other glob patterns that are valid. + #[test] + fn partial_errors_keep_valid_rules() { + let td = tmpdir(); + let root = td.path().join("work"); + mkdirp(&root); + wfile(td.path().join(".ignore"), "{bad\n*.tmp\n"); + + let mut builder = builder(&root); + builder.git_ignore(false).git_exclude(false); + let mut m = one_matcher(&builder); + let (matched, err) = m.matched_with_errors("drop.tmp", false); + assert!(err.is_some()); + assert!(matched.is_ignore()); + } + + // Tests that parent rules are not respected if the matcher is configured + // not to do so. + #[test] + fn parent_loading_can_be_disabled() { + let td = tmpdir(); + let root = td.path().join("work"); + mkdirp(&root); + wfile(td.path().join(".ignore"), "parent-rule\n"); + wfile(root.join(".ignore"), "root-rule\n"); + + let mut b = builder(&root); + b.parents(false); + let mut m = one_matcher(&b); + assert!(matchedf(&mut m, "parent-rule").is_none()); + assert!(matchedf(&mut m, "root-rule").is_ignore()); + + // Sanity check that without `parents(false)`, the parent rule is + // respected. + let mut m = one_matcher(&builder(&root)); + assert!(matchedf(&mut m, "parent-rule").is_ignore()); + assert!(matchedf(&mut m, "root-rule").is_ignore()); + } + + // Tests that we can normalize a file path that isn't already in "normal" + // relative form, and then use that to match on ignore files. + #[test] + fn paths_are_relative_to_the_root() { + let td = tmpdir(); + let root = td.path().join("root"); + let outside = td.path().join("outside"); + mkdirp(&root); + mkdirp(&outside); + wfile(root.join(".ignore"), "file\n"); + + let mut m = one_matcher(&builder(&root)); + assert!(matchedd(&mut m, "").is_none()); + assert!(matchedd(&mut m, ".").is_none()); + assert!(matchedf(&mut m, "file").is_ignore()); + // Doesn't work because it isn't relative to the root. It's absolute. + assert!(matchedf(&mut m, root.join("file")).is_none()); + // Also doesn't work because while it's relative, it contains `..`. + assert!(matchedf(&mut m, "dir/../file").is_none()); + + let norm = m.normalize(root.join("dir/../file")).unwrap(); + assert_eq!(norm, Path::new("file")); + assert!(matchedf(&mut m, "file").is_ignore()); + + // Doesn't normalize because its an absolute path outside of our root. + assert_eq!(m.normalize(outside.join("file")), None); + } + + // Tests that two matchers in two different directories correctly interpret + // the same parent gitignore file. + #[test] + fn multiple_roots_keep_their_own_context() { + let td = tmpdir(); + let root_a = td.path().join("a"); + let root_b = td.path().join("b"); + mkdirp(td.path().join(".git")); + mkdirp(&root_a); + mkdirp(&root_b); + wfile(td.path().join(".gitignore"), "/a/*.tmp\n/b/*.log\n"); + + let mut builder = + builders([root_a.as_path(), Path::new("-"), root_b.as_path()]); + builder.git_global(false); + let mut ms = matchers(&builder); + assert_eq!(ms.len(), 3); + assert_eq!(ms[0].root(), root_a); + assert_eq!(ms[1].root(), Path::new("-")); + assert_eq!(ms[2].root(), root_b); + assert!(matchedf(&mut ms[0], "drop.tmp").is_ignore()); + assert!(matchedf(&mut ms[0], "keep.log").is_none()); + assert!(matchedf(&mut ms[1], "anything").is_none()); + assert_eq!(ms[1].normalize("anything"), None); + assert!(matchedf(&mut ms[2], "keep.tmp").is_none()); + assert!(matchedf(&mut ms[2], "drop.log").is_ignore()); + } + + // Tests that only the exact `-` root represents standard input. + #[test] + fn dot_dash_root_is_not_stdin() { + let m = one_matcher(&builder("./-")); + assert_eq!(m.root(), Path::new("./-")); + assert_eq!(m.normalize("./-/file"), Some(PathBuf::from("file"))); + } + + #[test] + fn stdin_is_inert_with_depth_limits() { + let mut b = builder("-"); + b.min_depth(Some(2)); + let mut m = one_matcher(&b); + let mat = matchedf(&mut m, "file"); + assert!(mat.is_none()); + assert!(mat.is_within_depth()); + + let mut b = builder("-"); + b.max_depth(Some(0)); + let mut m = one_matcher(&b); + let mat = matchedd(&mut m, "dir"); + assert!(mat.is_none()); + assert!(mat.is_within_depth()); + assert!(mat.should_descend()); + } + + // Tests that ignore matching works lexically, and doesn't accidentally + // resolve symbolic links. + #[cfg(unix)] + #[test] + fn symlink_path_stays_under_lexical_root() { + use std::os::unix::fs::symlink; + + let td = tmpdir(); + let root = td.path().join("root"); + let outside = td.path().join("outside"); + mkdirp(&root); + mkdirp(&outside); + wfile(root.join(".ignore"), "link\n"); + wfile(outside.join("target"), ""); + symlink(outside.join("target"), root.join("link")).unwrap(); + + let mut m = one_matcher(&builder(&root)); + assert!(matchedf(&mut m, "link").is_ignore()); + } + + #[test] + fn depth_limits() { + let td = tmpdir(); + mkdirp(td.path().join("a/b/c/d")); + + let mut b = builder(td.path()); + b.min_depth(Some(2)).max_depth(Some(3)); + let mut m = one_matcher(&b); + + let dir = matchedd(&mut m, "a"); + assert!(!dir.is_within_depth()); + assert!(dir.should_descend()); + assert!(matchedf(&mut m, "file").is_ignore()); + + let dir = matchedd(&mut m, "a/b"); + assert!(dir.is_within_depth()); + assert!(dir.should_descend()); + assert!(!matchedf(&mut m, "a/file").is_ignore()); + + let dir = matchedd(&mut m, "a/b/c"); + assert!(dir.is_within_depth()); + assert!(!dir.should_descend()); + assert!(!matchedf(&mut m, "a/b/file").is_ignore()); + assert!(matchedf(&mut m, "a/b/c/file").is_ignore()); + + let dir = matchedd(&mut m, "a/b/c/d"); + assert!(dir.is_ignore()); + assert!(!dir.is_within_depth()); + assert!(!dir.should_descend()); + assert!(matchedf(&mut m, "a/b/c/d/file").is_ignore()); + } + + #[test] + fn depth_limits_apply_to_root() { + let td = tmpdir(); + + let mut b = builder(td.path()); + b.min_depth(Some(1)); + let mut m = one_matcher(&b); + for path in ["", "."] { + let root = matchedd(&mut m, path); + assert!(root.is_none()); + assert!(!root.is_within_depth()); + assert!(root.should_descend()); + } + + let mut b = builder(td.path()); + b.max_depth(Some(0)); + let mut m = one_matcher(&b); + for path in ["", "."] { + let root = matchedd(&mut m, path); + assert!(root.is_none()); + assert!(root.is_within_depth()); + assert!(!root.should_descend()); + } + } + + #[test] + fn max_filesize() { + let td = tmpdir(); + mkdirp(td.path().join("dir")); + wfile(td.path().join("empty"), ""); + wfile(td.path().join("at-limit"), "12345"); + wfile(td.path().join("over-limit"), "123456"); + + let mut b = builder(td.path()); + b.max_filesize(Some(5)); + let mut m = one_matcher(&b); + + assert!(matchedf(&mut m, "empty").is_none()); + assert!(matchedf(&mut m, "at-limit").is_none()); + assert!(matchedf(&mut m, "over-limit").is_ignore()); + + let dir = matchedd(&mut m, "dir"); + assert!(dir.is_none()); + assert!(dir.should_descend()); + } + + #[test] + fn max_filesize_does_not_stat_ignored_file() { + let td = tmpdir(); + wfile(td.path().join(".ignore"), "ignored\n"); + + let mut b = builder(td.path()); + b.max_filesize(Some(0)); + let mut m = one_matcher(&b); + let (mat, err) = m.matched_with_errors("ignored", false); + assert!(mat.is_ignore()); + assert!(err.is_none(), "ignored missing file was statted: {err:?}"); + } + + #[cfg(unix)] + #[test] + fn max_filesize_respects_follow_links() { + use std::os::unix::fs::symlink; + + let td = tmpdir(); + wfile( + td.path().join("target"), + "target contents are much longer than the size limit", + ); + symlink("target", td.path().join("link")).unwrap(); + + let mut b = builder(td.path()); + b.max_filesize(Some(10)); + let mut m = one_matcher(&b); + assert!(matchedf(&mut m, "link").is_none()); + + b.follow_links(true); + let mut m = one_matcher(&b); + assert!(matchedf(&mut m, "link").is_ignore()); + } + + #[test] + fn hidden_files_and_directories() { + let td = tmpdir(); + mkdirp(td.path().join(".hidden-dir")); + mkdirp(td.path().join("visible-dir")); + wfile(td.path().join(".hidden-file"), ""); + wfile(td.path().join("visible-file"), ""); + + let mut m = one_matcher(&builder(td.path())); + assert!(matchedf(&mut m, ".hidden-file").is_ignore()); + let dir = matchedd(&mut m, ".hidden-dir"); + assert!(dir.is_ignore()); + assert!(!dir.should_descend()); + assert!(matchedf(&mut m, "visible-file").is_none()); + let dir = matchedd(&mut m, "visible-dir"); + assert!(dir.is_none()); + assert!(dir.should_descend()); + + let mut b = builder(td.path()); + b.hidden(false); + let mut m = one_matcher(&b); + assert!(matchedf(&mut m, ".hidden-file").is_none()); + let dir = matchedd(&mut m, ".hidden-dir"); + assert!(dir.is_none()); + assert!(dir.should_descend()); + } + + #[test] + fn gitignore_whitelist_overrides_hidden_filter() { + let td = tmpdir(); + mkdirp(td.path().join(".git")); + mkdirp(td.path().join(".hidden-dir")); + wfile(td.path().join(".hidden-file"), ""); + wfile(td.path().join(".gitignore"), "!.hidden-file\n!.hidden-dir/\n"); + + let mut m = one_matcher(&builder(td.path())); + assert!(matchedf(&mut m, ".hidden-file").is_whitelist()); + let dir = matchedd(&mut m, ".hidden-dir"); + assert!(dir.is_whitelist()); + assert!(dir.should_descend()); + } + + #[test] + fn descendants_of_hidden_directories_are_ignored() { + let td = tmpdir(); + mkdirp(td.path().join(".git")); + mkdirp(td.path().join(".hidden/nested")); + mkdirp(td.path().join("visible/.hidden")); + wfile(td.path().join(".hidden/file"), ""); + wfile(td.path().join(".hidden/nested/file"), ""); + wfile(td.path().join("visible/.hidden/file"), ""); + wfile(td.path().join(".hidden/.gitignore"), "!file\n"); + + let mut m = one_matcher(&builder(td.path())); + assert!(matchedf(&mut m, ".hidden/file").is_ignore()); + assert!(matchedf(&mut m, ".hidden/nested/file").is_ignore()); + assert!(matchedf(&mut m, "visible/.hidden/file").is_ignore()); + } + + #[test] + fn whitelisted_hidden_directory_allows_descendants() { + let td = tmpdir(); + mkdirp(td.path().join(".git")); + mkdirp(td.path().join(".hidden")); + wfile(td.path().join(".hidden/file"), ""); + wfile(td.path().join(".gitignore"), "!.hidden/\n"); + + let mut m = one_matcher(&builder(td.path())); + let dir = matchedd(&mut m, ".hidden"); + assert!(dir.is_whitelist()); + assert!(dir.should_descend()); + assert!(matchedf(&mut m, ".hidden/file").is_none()); + } + + #[test] + fn hidden_directories_outside_depth_limits() { + let td = tmpdir(); + mkdirp(td.path().join("a/.hidden")); + + let mut b = builder(td.path()); + b.min_depth(Some(3)); + let mut m = one_matcher(&b); + let dir = matchedd(&mut m, "a/.hidden"); + assert!(dir.is_ignore()); + assert!(!dir.is_within_depth()); + + let mut b = builder(td.path()); + b.max_depth(Some(1)); + let mut m = one_matcher(&b); + let dir = matchedd(&mut m, "a/.hidden"); + assert!(dir.is_ignore()); + assert!(!dir.is_within_depth()); + } +} diff --git a/crates/ignore/src/lib.rs b/crates/ignore/src/lib.rs index 609004c4e..b9c9a3ece 100644 --- a/crates/ignore/src/lib.rs +++ b/crates/ignore/src/lib.rs @@ -48,13 +48,16 @@ See the documentation for `WalkBuilder` for many other options. use std::path::{Path, PathBuf}; +pub use crate::incremental::{IncrementalIgnore, IncrementalMatch}; pub use crate::walk::{ - DirEntry, ParallelVisitor, ParallelVisitorBuilder, Walk, WalkBuilder, WalkParallel, WalkState, + DirEntry, ParallelVisitor, ParallelVisitorBuilder, Walk, WalkBuilder, + WalkParallel, WalkState, }; mod default_types; mod dir; pub mod gitignore; +mod incremental; pub mod overrides; mod pathutil; pub mod types; @@ -120,34 +123,31 @@ impl Clone for Error { fn clone(&self) -> Error { match *self { Error::Partial(ref errs) => Error::Partial(errs.clone()), - Error::WithLineNumber { line, ref err } => Error::WithLineNumber { - line, - err: err.clone(), - }, - Error::WithPath { ref path, ref err } => Error::WithPath { - path: path.clone(), - err: err.clone(), - }, - Error::WithDepth { depth, ref err } => Error::WithDepth { - depth, - err: err.clone(), - }, - Error::Loop { - ref ancestor, - ref child, - } => Error::Loop { + Error::WithLineNumber { line, ref err } => { + Error::WithLineNumber { line, err: err.clone() } + } + Error::WithPath { ref path, ref err } => { + Error::WithPath { path: path.clone(), err: err.clone() } + } + Error::WithDepth { depth, ref err } => { + Error::WithDepth { depth, err: err.clone() } + } + Error::Loop { ref ancestor, ref child } => Error::Loop { ancestor: ancestor.clone(), child: child.clone(), }, Error::Io(ref err) => match err.raw_os_error() { Some(e) => Error::Io(std::io::Error::from_raw_os_error(e)), - None => Error::Io(std::io::Error::new(err.kind(), err.to_string())), + None => { + Error::Io(std::io::Error::new(err.kind(), err.to_string())) + } }, - Error::Glob { ref glob, ref err } => Error::Glob { - glob: glob.clone(), - err: err.clone(), - }, - Error::UnrecognizedFileType(ref err) => Error::UnrecognizedFileType(err.clone()), + Error::Glob { ref glob, ref err } => { + Error::Glob { glob: glob.clone(), err: err.clone() } + } + Error::UnrecognizedFileType(ref err) => { + Error::UnrecognizedFileType(err.clone()) + } Error::InvalidDefinition => Error::InvalidDefinition, } } @@ -269,19 +269,14 @@ impl Error { /// Turn an error into a tagged error with the given depth. fn with_depth(self, depth: usize) -> Error { - Error::WithDepth { - depth, - err: Box::new(self), - } + Error::WithDepth { depth, err: Box::new(self) } } /// Turn an error into a tagged error with the given file path and line /// number. If path is empty, then it is omitted from the error. fn tagged>(self, path: P, lineno: u64) -> Error { - let errline = Error::WithLineNumber { - line: lineno, - err: Box::new(self), - }; + let errline = + Error::WithLineNumber { line: lineno, err: Box::new(self) }; if path.as_ref().as_os_str().is_empty() { return errline; } @@ -301,12 +296,12 @@ impl Error { }; } let path = err.path().map(|p| p.to_path_buf()); - let mut ig_err = Error::Io(std::io::Error::from(err)); + let mut ig_err = Error::WithDepth { + depth, + err: Box::new(Error::Io(std::io::Error::from(err))), + }; if let Some(path) = path { - ig_err = Error::WithPath { - path, - err: Box::new(ig_err), - }; + ig_err = Error::WithPath { path, err: Box::new(ig_err) }; } ig_err } @@ -333,7 +328,8 @@ impl std::fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match *self { Error::Partial(ref errs) => { - let msgs: Vec = errs.iter().map(|err| err.to_string()).collect(); + let msgs: Vec = + errs.iter().map(|err| err.to_string()).collect(); write!(f, "{}", msgs.join("\n")) } Error::WithLineNumber { line, ref err } => { @@ -343,10 +339,7 @@ impl std::fmt::Display for Error { write!(f, "{}: {}", path.display(), err) } Error::WithDepth { ref err, .. } => err.fmt(f), - Error::Loop { - ref ancestor, - ref child, - } => write!( + Error::Loop { ref ancestor, ref child } => write!( f, "File system loop found: \ {} points to an ancestor {}", @@ -354,14 +347,8 @@ impl std::fmt::Display for Error { ancestor.display() ), Error::Io(ref err) => err.fmt(f), - Error::Glob { - glob: None, - ref err, - } => write!(f, "{}", err), - Error::Glob { - glob: Some(ref glob), - ref err, - } => { + Error::Glob { glob: None, ref err } => write!(f, "{}", err), + Error::Glob { glob: Some(ref glob), ref err } => { write!(f, "error parsing glob '{}': {}", glob, err) } Error::UnrecognizedFileType(ref ty) => { @@ -507,7 +494,8 @@ mod tests { }; /// A convenient result type alias. - pub(crate) type Result = std::result::Result>; + pub(crate) type Result = + std::result::Result>; macro_rules! err { ($($tt:tt)*) => { @@ -545,8 +533,9 @@ mod tests { 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 afb9f16ce..005cae8f2 100644 --- a/crates/ignore/src/overrides.rs +++ b/crates/ignore/src/overrides.rs @@ -94,7 +94,11 @@ impl Override { /// given) is stripped. If there is no common suffix/prefix overlap, then /// `path` is assumed to reside in the same directory as the root path for /// this set of overrides. - pub fn matched<'a, P: AsRef>(&'a self, path: P, is_dir: bool) -> Match> { + pub fn matched<'a, P: AsRef>( + &'a self, + path: P, + is_dir: bool, + ) -> Match> { if self.is_empty() { return Match::None; } @@ -146,7 +150,10 @@ impl OverrideBuilder { /// 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)?; @@ -276,11 +283,8 @@ mod tests { #[test] fn default_case_sensitive() { - let ov = OverrideBuilder::new(ROOT) - .add("*.html") - .unwrap() - .build() - .unwrap(); + let ov = + OverrideBuilder::new(ROOT).add("*.html").unwrap().build().unwrap(); assert!(ov.matched("foo.html", false).is_whitelist()); assert!(ov.matched("foo.HTML", false).is_ignore()); assert!(ov.matched("foo.htm", false).is_ignore()); diff --git a/crates/ignore/src/pathutil.rs b/crates/ignore/src/pathutil.rs index 0ceb5a356..5de8c106c 100644 --- a/crates/ignore/src/pathutil.rs +++ b/crates/ignore/src/pathutil.rs @@ -2,55 +2,89 @@ use std::{ffi::OsStr, path::Path}; use crate::walk::DirEntry; -/// Returns true if and only if this entry is considered to be hidden. +/// Returns true if and only if this path is considered to be hidden. /// -/// This only returns true if the base name of the path starts with a `.`. +/// # Platform behavior /// -/// On Unix, this implements a more optimized check. -#[cfg(unix)] -pub(crate) fn is_hidden(dent: &DirEntry) -> bool { - use std::os::unix::ffi::OsStrExt; - - if let Some(name) = file_name(dent.path()) { - name.as_bytes().get(0) == Some(&b'.') - } else { - false - } -} - -/// Returns true if and only if this entry is considered to be hidden. +/// ## Windows /// -/// On Windows, this returns true if one of the following is true: +/// This returns true if one of the following is true: /// /// * The base name of the path starts with a `.`. /// * The file attributes have the `HIDDEN` property set. -#[cfg(windows)] -pub(crate) fn is_hidden(dent: &DirEntry) -> bool { - use std::os::windows::fs::MetadataExt; - use winapi_util::file; - - // This looks like we're doing an extra stat call, but on Windows, the - // directory traverser reuses the metadata retrieved from each directory - // entry and stores it on the DirEntry itself. So this is "free." - if let Ok(md) = dent.metadata() { - if file::is_hidden(md.file_attributes() as u64) { - return true; - } - } - if let Some(name) = file_name(dent.path()) { - name.to_str().map(|s| s.starts_with(".")).unwrap_or(false) - } else { - false - } -} - -/// Returns true if and only if this entry is considered to be hidden. +/// +/// ## All other platforms /// /// This only returns true if the base name of the path starts with a `.`. -#[cfg(not(any(unix, windows)))] -pub(crate) fn is_hidden(dent: &DirEntry) -> bool { - if let Some(name) = file_name(dent.path()) { - name.to_str().map(|s| s.starts_with(".")).unwrap_or(false) +pub(crate) fn is_hidden_path(dent: &Path) -> bool { + #[cfg(not(windows))] + fn imp(path: &Path) -> bool { + is_hidden_path_only(path) + } + + #[cfg(windows)] + fn imp(path: &Path) -> bool { + use std::os::windows::fs::MetadataExt; + use winapi_util::file; + + if let Ok(md) = path.metadata() { + if file::is_hidden(md.file_attributes() as u64) { + return true; + } + } + is_hidden_path_only(path) + } + + imp(dent) +} + +/// Returns true if and only if this directory entry is considered to be +/// hidden. +/// +/// # Platform behavior +/// +/// ## Windows +/// +/// This returns true if one of the following is true: +/// +/// * The base name of the path starts with a `.`. +/// * The file attributes have the `HIDDEN` property set. +/// +/// ## All other platforms +/// +/// This only returns true if the base name of the path starts with a `.`. +pub(crate) fn is_hidden_entry(dent: &DirEntry) -> bool { + #[cfg(not(windows))] + fn imp(dent: &DirEntry) -> bool { + is_hidden_path_only(dent.path()) + } + + #[cfg(windows)] + fn imp(dent: &DirEntry) -> bool { + use std::os::windows::fs::MetadataExt; + use winapi_util::file; + + // This looks like we're doing an extra stat call, but on Windows, the + // directory traverser reuses the metadata retrieved from each directory + // entry and stores it on the DirEntry itself. So this is "free." + if let Ok(md) = dent.metadata() { + if file::is_hidden(md.file_attributes() as u64) { + return true; + } + } + is_hidden_path_only(dent.path()) + } + + imp(dent) +} + +/// Returns true if and only if this path is considered to be hidden from only +/// the path itself. +/// +/// This has the same behavior on all platforms. +fn is_hidden_path_only(path: &Path) -> bool { + if let Some(name) = file_name(path) { + name.as_encoded_bytes().starts_with(b".") } else { false } @@ -59,83 +93,79 @@ pub(crate) fn is_hidden(dent: &DirEntry) -> bool { /// Strip `prefix` from the `path` and return the remainder. /// /// If `path` doesn't have a prefix `prefix`, then return `None`. -#[cfg(unix)] pub(crate) fn strip_prefix<'a, P: AsRef + ?Sized>( prefix: &'a P, path: &'a Path, ) -> Option<&'a Path> { - use std::os::unix::ffi::OsStrExt; + #[cfg(unix)] + fn imp<'a>(prefix: &'a Path, path: &'a Path) -> Option<&'a Path> { + use std::os::unix::ffi::OsStrExt; - let prefix = prefix.as_ref().as_os_str().as_bytes(); - let path = path.as_os_str().as_bytes(); - if prefix.len() > path.len() || prefix != &path[0..prefix.len()] { - None - } else { - Some(&Path::new(OsStr::from_bytes(&path[prefix.len()..]))) + let prefix = prefix.as_os_str().as_bytes(); + let path = path.as_os_str().as_bytes(); + if prefix.len() > path.len() || prefix != &path[0..prefix.len()] { + None + } else { + Some(&Path::new(OsStr::from_bytes(&path[prefix.len()..]))) + } } -} -/// Strip `prefix` from the `path` and return the remainder. -/// -/// If `path` doesn't have a prefix `prefix`, then return `None`. -#[cfg(not(unix))] -pub(crate) fn strip_prefix<'a, P: AsRef + ?Sized>( - prefix: &'a P, - path: &'a Path, -) -> Option<&'a Path> { - path.strip_prefix(prefix).ok() + #[cfg(not(unix))] + fn imp<'a>(prefix: &'a Path, path: &'a Path) -> Option<&'a Path> { + path.strip_prefix(prefix).ok() + } + + imp(prefix.as_ref(), path) } /// Returns true if this file path is just a file name. i.e., Its parent is /// the empty string. -#[cfg(unix)] pub(crate) fn is_file_name>(path: P) -> bool { - use std::os::unix::ffi::OsStrExt; - - use memchr::memchr; - - let path = path.as_ref().as_os_str().as_bytes(); - memchr(b'/', path).is_none() -} - -/// Returns true if this file path is just a file name. i.e., Its parent is -/// the empty string. -#[cfg(not(unix))] -pub(crate) fn is_file_name>(path: P) -> bool { - path.as_ref() - .parent() - .map(|p| p.as_os_str().is_empty()) - .unwrap_or(false) -} - -/// The final component of the path, if it is a normal file. -/// -/// If the path terminates in ., .., or consists solely of a root of prefix, -/// file_name will return None. -#[cfg(unix)] -pub(crate) fn file_name<'a, P: AsRef + ?Sized>(path: &'a P) -> Option<&'a OsStr> { - use memchr::memrchr; - use std::os::unix::ffi::OsStrExt; - - let path = path.as_ref().as_os_str().as_bytes(); - if path.is_empty() { - return None; - } else if path.len() == 1 && path[0] == b'.' { - return None; - } else if path.last() == Some(&b'.') { - return None; - } else if path.len() >= 2 && &path[path.len() - 2..] == &b".."[..] { - return None; + #[cfg(unix)] + { + memchr::memchr(b'/', path.as_ref().as_os_str().as_encoded_bytes()) + .is_none() + } + #[cfg(not(unix))] + { + path.as_ref() + .parent() + .map(|p| p.as_os_str().is_empty()) + .unwrap_or(false) } - let last_slash = memrchr(b'/', path).map(|i| i + 1).unwrap_or(0); - Some(OsStr::from_bytes(&path[last_slash..])) } /// The final component of the path, if it is a normal file. /// -/// If the path terminates in ., .., or consists solely of a root of prefix, -/// file_name will return None. -#[cfg(not(unix))] -pub(crate) fn file_name<'a, P: AsRef + ?Sized>(path: &'a P) -> Option<&'a OsStr> { - path.as_ref().file_name() +/// If the path terminates in `.`, `..`, or consists solely of a root of +/// prefix, this will return `None`. +pub(crate) fn file_name<'a, P: AsRef + ?Sized>( + path: &'a P, +) -> Option<&'a OsStr> { + #[cfg(unix)] + fn imp(path: &Path) -> Option<&OsStr> { + use std::os::unix::ffi::OsStrExt; + + use memchr::memrchr; + + let path = path.as_os_str().as_bytes(); + if path.is_empty() { + return None; + } else if path.len() == 1 && path[0] == b'.' { + return None; + } else if path.last() == Some(&b'.') { + return None; + } else if path.len() >= 2 && &path[path.len() - 2..] == &b".."[..] { + return None; + } + let last_slash = memrchr(b'/', path).map(|i| i + 1).unwrap_or(0); + Some(OsStr::from_bytes(&path[last_slash..])) + } + + #[cfg(not(unix))] + fn imp(path: &Path) -> Option<&OsStr> { + path.file_name() + } + + imp(path.as_ref()) } diff --git a/crates/ignore/src/types.rs b/crates/ignore/src/types.rs index aa23999c0..313cf5c0e 100644 --- a/crates/ignore/src/types.rs +++ b/crates/ignore/src/types.rs @@ -204,8 +204,12 @@ impl Selection { fn map U>(self, f: F) -> Selection { match self { - Selection::Select(name, inner) => Selection::Select(name, f(inner)), - Selection::Negate(name, inner) => Selection::Negate(name, f(inner)), + Selection::Select(name, inner) => { + Selection::Select(name, f(inner)) + } + Selection::Negate(name, inner) => { + Selection::Negate(name, f(inner)) + } } } @@ -227,7 +231,9 @@ impl Types { has_selected: false, glob_to_selection: vec![], set: GlobSetBuilder::new().build().unwrap(), - matches: Arc::new(Pool::new(|| vec![])), + matches: Arc::new(Pool::with_available_parallelism_capacity( + || vec![], + )), } } @@ -254,7 +260,11 @@ impl Types { /// The path is considered ignored if it matches a negated file type. /// If at least one file type is selected and `path` doesn't match, then /// the path is also considered ignored. - pub fn matched<'a, P: AsRef>(&'a self, path: P, is_dir: bool) -> Match> { + pub fn matched<'a, P: AsRef>( + &'a self, + path: P, + is_dir: bool, + ) -> Match> { // File types don't apply to directories, and we can't do anything // if our glob set is empty. if is_dir || self.set.is_empty() { @@ -306,10 +316,7 @@ impl TypesBuilder { /// of default type definitions can be added with `add_defaults`, and /// additional type definitions can be added with `select` and `negate`. pub fn new() -> TypesBuilder { - TypesBuilder { - types: HashMap::new(), - selections: vec![], - } + TypesBuilder { types: HashMap::new(), selections: vec![] } } /// Build the current set of file type definitions *and* selections into @@ -343,17 +350,18 @@ impl TypesBuilder { } selections.push(selection.clone().map(move |_| def)); } - let set = build_set.build().map_err(|err| Error::Glob { - glob: None, - err: err.to_string(), - })?; + let set = build_set + .build() + .map_err(|err| Error::Glob { glob: None, err: err.to_string() })?; Ok(Types { defs, selections, has_selected, glob_to_selection, set, - matches: Arc::new(Pool::new(|| vec![])), + matches: Arc::new(Pool::with_available_parallelism_capacity( + || vec![], + )), }) } @@ -377,12 +385,10 @@ impl TypesBuilder { pub fn select(&mut self, name: &str) -> &mut TypesBuilder { if name == "all" { for name in self.types.keys() { - self.selections - .push(Selection::Select(name.to_string(), ())); + self.selections.push(Selection::Select(name.to_string(), ())); } } else { - self.selections - .push(Selection::Select(name.to_string(), ())); + self.selections.push(Selection::Select(name.to_string(), ())); } self } @@ -393,12 +399,10 @@ impl TypesBuilder { pub fn negate(&mut self, name: &str) -> &mut TypesBuilder { if name == "all" { for name in self.types.keys() { - self.selections - .push(Selection::Negate(name.to_string(), ())); + self.selections.push(Selection::Negate(name.to_string(), ())); } } else { - self.selections - .push(Selection::Negate(name.to_string(), ())); + self.selections.push(Selection::Negate(name.to_string(), ())); } self } @@ -453,7 +457,10 @@ impl TypesBuilder { 3 => { let name = parts[0]; let types_string = parts[2]; - if name.is_empty() || parts[1] != "include" || types_string.is_empty() { + if name.is_empty() + || parts[1] != "include" + || types_string.is_empty() + { return Err(Error::InvalidDefinition); } let types = types_string.split(','); @@ -463,7 +470,8 @@ impl TypesBuilder { return Err(Error::InvalidDefinition); } for type_name in types { - let globs = self.types.get(type_name).unwrap().globs.clone(); + let globs = + self.types.get(type_name).unwrap().globs.clone(); for glob in globs { self.add(name, &glob)?; } @@ -549,30 +557,9 @@ mod tests { matched!(not, matchnot1, types(), vec!["rust"], vec![], "index.html"); matched!(not, matchnot2, types(), vec![], vec!["rust"], "main.rs"); - matched!( - not, - matchnot3, - types(), - vec!["foo"], - vec!["rust"], - "main.rs" - ); - matched!( - not, - matchnot4, - types(), - vec!["rust"], - vec!["foo"], - "main.rs" - ); - matched!( - not, - matchnot5, - types(), - vec!["rust"], - vec!["foo"], - "main.foo" - ); + matched!(not, matchnot3, types(), vec!["foo"], vec!["rust"], "main.rs"); + matched!(not, matchnot4, types(), vec!["rust"], vec!["foo"], "main.rs"); + matched!(not, matchnot5, types(), vec!["rust"], vec!["foo"], "main.foo"); matched!(not, matchnot6, types(), vec!["combo"], vec![], "leftpad.js"); matched!(not, matchnot7, types(), vec!["py"], vec![], "index.html"); matched!(not, matchnot8, types(), vec!["python"], vec![], "doc.md"); diff --git a/crates/ignore/src/walk.rs b/crates/ignore/src/walk.rs index ebf20947a..2dff78852 100644 --- a/crates/ignore/src/walk.rs +++ b/crates/ignore/src/walk.rs @@ -17,7 +17,9 @@ 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, }; @@ -104,24 +106,15 @@ impl DirEntry { } fn new_stdin() -> DirEntry { - DirEntry { - dent: DirEntryInner::Stdin, - err: None, - } + DirEntry { dent: DirEntryInner::Stdin, err: None } } fn new_walkdir(dent: walkdir::DirEntry, err: Option) -> DirEntry { - DirEntry { - dent: DirEntryInner::Walkdir(dent), - err, - } + DirEntry { dent: DirEntryInner::Walkdir(dent), err } } fn new_raw(dent: DirEntryRaw, err: Option) -> DirEntry { - DirEntry { - dent: DirEntryInner::Raw(dent), - err, - } + DirEntry { dent: DirEntryInner::Raw(dent), err } } } @@ -187,9 +180,11 @@ impl DirEntryInner { )); Err(err.with_path("")) } - Walkdir(ref x) => x - .metadata() - .map_err(|err| Error::Io(io::Error::from(err)).with_path(x.path())), + Walkdir(ref x) => x.metadata().map_err(|err| { + Error::Io(io::Error::from(err)) + .with_depth(x.depth()) + .with_path(x.path()) + }), Raw(ref x) => x.metadata(), } } @@ -308,7 +303,9 @@ impl DirEntryRaw { } else { fs::symlink_metadata(&self.path) } - .map_err(|err| Error::Io(io::Error::from(err)).with_path(&self.path)) + .map_err(|err| { + Error::Io(err).with_depth(self.depth).with_path(&self.path) + }) } fn file_type(&self) -> FileType { @@ -316,9 +313,7 @@ impl DirEntryRaw { } fn file_name(&self) -> &OsStr { - self.path - .file_name() - .unwrap_or_else(|| self.path.as_os_str()) + self.path.file_name().unwrap_or_else(|| self.path.as_os_str()) } fn depth(&self) -> usize { @@ -330,13 +325,13 @@ impl DirEntryRaw { self.ino } - fn from_entry(depth: usize, ent: &fs::DirEntry) -> Result { + fn from_entry( + depth: usize, + ent: &fs::DirEntry, + ) -> Result { let ty = ent.file_type().map_err(|err| { - let err = Error::Io(io::Error::from(err)).with_path(ent.path()); - Error::WithDepth { - depth, - err: Box::new(err), - } + let err = Error::Io(err).with_depth(depth).with_path(ent.path()); + Error::WithDepth { depth, err: Box::new(err) } })?; DirEntryRaw::from_entry_os(depth, ent, ty) } @@ -348,11 +343,8 @@ impl DirEntryRaw { ty: fs::FileType, ) -> Result { let md = ent.metadata().map_err(|err| { - let err = Error::Io(io::Error::from(err)).with_path(ent.path()); - Error::WithDepth { - depth, - err: Box::new(err), - } + let err = Error::Io(err).with_depth(depth).with_path(ent.path()); + Error::WithDepth { depth, err: Box::new(err) } })?; Ok(DirEntryRaw { path: ent.path(), @@ -395,8 +387,13 @@ impl DirEntryRaw { } #[cfg(windows)] - fn from_path(depth: usize, pb: PathBuf, link: bool) -> Result { - let md = fs::metadata(&pb).map_err(|err| Error::Io(err).with_path(&pb))?; + fn from_path( + depth: usize, + pb: PathBuf, + link: bool, + ) -> Result { + let md = fs::metadata(&pb) + .map_err(|err| Error::Io(err).with_depth(depth).with_path(&pb))?; Ok(DirEntryRaw { path: pb, ty: md.file_type(), @@ -407,10 +404,15 @@ impl DirEntryRaw { } #[cfg(unix)] - fn from_path(depth: usize, pb: PathBuf, link: bool) -> Result { + fn from_path( + depth: usize, + pb: PathBuf, + link: bool, + ) -> Result { use std::os::unix::fs::MetadataExt; - let md = fs::metadata(&pb).map_err(|err| Error::Io(err).with_path(&pb))?; + let md = fs::metadata(&pb) + .map_err(|err| Error::Io(err).with_depth(depth).with_path(&pb))?; Ok(DirEntryRaw { path: pb, ty: md.file_type(), @@ -423,7 +425,11 @@ impl DirEntryRaw { // Placeholder implementation to allow compiling on non-standard platforms // (e.g. wasm32). #[cfg(not(any(windows, unix)))] - fn from_path(depth: usize, pb: PathBuf, link: bool) -> Result { + fn from_path( + depth: usize, + pb: PathBuf, + link: bool, + ) -> Result { Err(Error::Io(io::Error::new( io::ErrorKind::Other, "unsupported platform", @@ -502,7 +508,8 @@ pub struct WalkBuilder { /// /// 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>>, + global_gitignores_relative_to: + OnceLock>>, } #[derive(Clone)] @@ -544,8 +551,16 @@ impl WalkBuilder { /// is better to call `add` on this builder than to create multiple /// `Walk` values. pub fn new>(path: P) -> WalkBuilder { + WalkBuilder::from_iter([path]) + } + + /// Create an empty builder to which paths can be added. + /// + /// Note that if you call `build` on this instance before calling `add` + /// on it, it will return exactly zero items during iteration. + pub fn empty() -> WalkBuilder { WalkBuilder { - paths: vec![path.as_ref().to_path_buf()], + paths: vec![], ig_builder: IgnoreBuilder::new(), max_depth: None, min_depth: None, @@ -560,6 +575,21 @@ impl WalkBuilder { } } + /// Create a new builder for a recursive directory iterator from the + /// sequence of paths. + /// + /// Note that if the iterator is empty, this is the same as + /// `WalkBuilder::empty`. + pub fn from_iter, 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; @@ -585,10 +615,14 @@ impl WalkBuilder { 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()) + }); } } } @@ -597,31 +631,76 @@ impl WalkBuilder { }) .collect::>() .into_iter(); - let ig_root = self - .get_or_set_current_dir() - .map(|cwd| self.ig_builder.build_with_cwd(Some(cwd.to_path_buf()))) - .unwrap_or_else(|| self.ig_builder.build()); + let ig_root = self.build_ignore(); Walk { its, it: None, ig_root: ig_root.clone(), ig: ig_root.clone(), + max_depth: self.max_depth, max_filesize: self.max_filesize, skip: self.skip.clone(), filter: self.filter.clone(), } } + /// Build matchers for checking paths against ignore files without + /// recursively walking the configured roots. + /// + /// The returned matchers use the path-based filtering configuration + /// on this builder, including glob overrides, file type selections, + /// parent ignore files, `.ignore`, `.gitignore`, global Git + /// ignore files, explicitly added ignore files and custom ignore + /// file names. For example, ripgrep configures `.rgignore` via + /// [`WalkBuilder::add_custom_ignore_filename`]. Minimum and maximum depth + /// limits, maximum file size and hidden-file filtering are also applied. + /// Other options that only control traversal or require a directory entry, + /// such as custom entry predicates, are not applied. + /// + /// One matcher is returned for each configured path, in the same order as + /// the paths were added to this builder. Each matcher accepts paths + /// relative to its own [`IncrementalIgnore::root`]. The matcher for the + /// special `-` path representing standard input always returns a non-match + /// for all inputs. + /// + /// Ignore matchers are loaded lazily and cached by directory. + /// Thus, the first query may read ignore files from the root and + /// its parents, while later queries reuse the compiled matchers. + /// Errors encountered while loading ignore files are returned by + /// [`IncrementalIgnore::matched_with_errors`]. Once an ignore file has + /// been loaded, changes to it are not observed. Build new matchers to + /// reload changed ignore files. + /// + /// Matchers built together share the builder's base ignore configuration + /// and compiled parent matchers. + pub fn build_matchers(&self) -> Vec { + let ignore = self.build_ignore(); + let options = IncrementalIgnoreOptions { + min_depth: self.min_depth, + max_depth: self.max_depth, + max_filesize: self.max_filesize, + hidden: self.ig_builder.is_hidden(), + follow_links: self.follow_links, + }; + self.paths + .iter() + .map(move |path| { + IncrementalIgnore::new( + path.clone(), + ignore.clone(), + options.clone(), + ) + }) + .collect() + } + /// Build a new `WalkParallel` iterator. /// /// Note that this *doesn't* return something that implements `Iterator`. /// Instead, the returned value must be run with a closure. e.g., /// `builder.build_parallel().run(|| |path| { println!("{path:?}"); WalkState::Continue })`. pub fn build_parallel(&self) -> WalkParallel { - let ig_root = self - .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()); + let ig_root = self.build_ignore(); WalkParallel { paths: self.paths.clone().into_iter(), ig_root, @@ -651,7 +730,10 @@ 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 { + if self.min_depth.is_some() + && self.max_depth.is_some() + && self.max_depth < self.min_depth + { self.max_depth = self.min_depth; } self @@ -662,7 +744,10 @@ impl WalkBuilder { /// 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 { + if self.max_depth.is_some() + && self.min_depth.is_some() + && self.min_depth > self.max_depth + { self.min_depth = self.max_depth; } self @@ -705,7 +790,12 @@ impl WalkBuilder { /// 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: Dropped this code + // 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!( @@ -729,7 +819,11 @@ impl WalkBuilder { errs.into_error_option() } - /// CHANGED: Add a Gitignore to the builder. + /// CHANGED: Add a prebuilt Gitignore to the builder. + /// + /// Like the ignore file added via `add_ignore`, these rules are matched + /// against the full path of each walked entry, scoped to the `Gitignore`'s + /// root path. pub fn add_gitignore(&mut self, gi: Gitignore) { self.ig_builder.add_ignore(gi); } @@ -982,7 +1076,10 @@ impl WalkBuilder { /// /// 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 { + 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)) { @@ -1002,7 +1099,10 @@ impl WalkBuilder { let result = std::env::current_dir().map_err(Arc::new); match result { Ok(ref path) => { - log::trace!("automatically discovered CWD: {}", path.display()); + log::trace!( + "automatically discovered CWD: {}", + path.display() + ); } Err(ref err) => { log::debug!( @@ -1016,6 +1116,13 @@ impl WalkBuilder { }); 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 @@ -1029,6 +1136,7 @@ pub struct Walk { it: Option, ig_root: Ignore, ig: Ignore, + max_depth: Option, max_filesize: Option, skip: Option>, filter: Option, @@ -1044,6 +1152,17 @@ impl Walk { WalkBuilder::new(path).build() } + /// Create a new recursive directory iterator from the sequence of paths + /// given. + /// + /// Note that if the provided iterator is empty, then `Walk` is guaranteed + /// to yield zero entries. + pub fn from_iter, I: IntoIterator>( + paths: I, + ) -> Walk { + WalkBuilder::from_iter(paths).build() + } + fn skip_entry(&self, ent: &DirEntry) -> Result { if ent.depth() == 0 { return Ok(false); @@ -1128,12 +1247,17 @@ impl Iterator for Walk { self.it.as_mut().unwrap().it.skip_current_dir(); // Still need to push this on the stack because // we'll get a WalkEvent::Exit event for this dir. - // We don't care if it errors though. - let (igtmp, _) = self.ig.add_child(ent.path()); + // Its ignore files cannot apply to any visited entry. + let (igtmp, _) = + self.ig.add_child_with_entries(ent.path(), &[]); self.ig = igtmp; continue; } - let (igtmp, err) = self.ig.add_child(ent.path()); + let (igtmp, err) = if self.max_depth == Some(ent.depth()) { + self.ig.add_child_with_entries(ent.path(), &[]) + } else { + self.ig.add_child(ent.path()) + }; self.ig = igtmp; ent.err = err; return Some(Ok(ent)); @@ -1175,11 +1299,7 @@ enum WalkEvent { impl From for WalkEventIter { fn from(it: WalkDir) -> WalkEventIter { - WalkEventIter { - depth: 0, - it: it.into_iter(), - next: None, - } + WalkEventIter { depth: 0, it: it.into_iter(), next: None } } } @@ -1252,7 +1372,9 @@ pub trait ParallelVisitorBuilder<'s> { fn build(&mut self) -> Box; } -impl<'a, 's, P: ParallelVisitorBuilder<'s>> ParallelVisitorBuilder<'s> for &'a mut P { +impl<'a, 's, P: ParallelVisitorBuilder<'s>> ParallelVisitorBuilder<'s> + for &'a mut P +{ fn build(&mut self) -> Box { (**self).build() } @@ -1273,14 +1395,17 @@ struct FnBuilder { builder: F, } -impl<'s, F: FnMut() -> FnVisitor<'s>> ParallelVisitorBuilder<'s> for FnBuilder { +impl<'s, F: FnMut() -> FnVisitor<'s>> ParallelVisitorBuilder<'s> + for FnBuilder +{ fn build(&mut self) -> Box { let visitor = (self.builder)(); Box::new(FnVisitorImp { visitor }) } } -type FnVisitor<'s> = Box) -> WalkState + Send + 's>; +type FnVisitor<'s> = + Box) -> WalkState + Send + 's>; struct FnVisitorImp<'s> { visitor: FnVisitor<'s>, @@ -1370,7 +1495,9 @@ impl WalkParallel { } }; match DirEntryRaw::from_path(0, path, false) { - Ok(dent) => (DirEntry::new_raw(dent, None), root_device), + Ok(dent) => { + (DirEntry::new_raw(dent, None), root_device) + } Err(err) => { if visitor.visit(Err(err)).is_quit() { return; @@ -1394,21 +1521,28 @@ impl WalkParallel { let quit_now = Arc::new(AtomicBool::new(false)); let active_workers = Arc::new(AtomicUsize::new(threads)); let stacks = Stack::new_for_each_thread(threads, stack); + // Collect all of the workers first. In the case that + // `builder.build()` panics, we want that to happen and + // propagate before we actually start to run any of the + // workers. + let workers: Vec<_> = stacks + .into_iter() + .map(|stack| Worker { + visitor: builder.build(), + stack, + quit_now: quit_now.clone(), + active_workers: active_workers.clone(), + max_depth: self.max_depth, + min_depth: self.min_depth, + max_filesize: self.max_filesize, + follow_links: self.follow_links, + skip: self.skip.clone(), + filter: self.filter.clone(), + }) + .collect(); std::thread::scope(|s| { - let handles: Vec<_> = stacks + let handles: Vec<_> = workers .into_iter() - .map(|stack| Worker { - visitor: builder.build(), - stack, - quit_now: quit_now.clone(), - active_workers: active_workers.clone(), - max_depth: self.max_depth, - min_depth: self.min_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 { @@ -1419,9 +1553,7 @@ impl WalkParallel { fn threads(&self) -> usize { if self.threads == 0 { - std::thread::available_parallelism() - .map_or(1, |n| n.get()) - .min(12) + std::thread::available_parallelism().map_or(1, |n| n.get()).min(12) } else { self.threads } @@ -1452,6 +1584,12 @@ struct Work { root_device: Option, } +#[derive(Default)] +struct ReadDirResult { + entries: Vec, + errors: Vec, +} + impl Work { /// Returns true if and only if this work item is a directory. fn is_dir(&self) -> bool { @@ -1478,6 +1616,13 @@ impl Work { err } + /// Adds ignore rules for this directory without reading its contents. + fn add_ignore(&mut self) { + let (ig, err) = self.ignore.add_child(self.dent.path()); + self.ignore = ig; + self.dent.err = err; + } + /// Reads the directory contents of this work item and adds ignore /// rules for this directory. /// @@ -1485,7 +1630,7 @@ impl Work { /// an error is returned. If there was a problem reading the ignore /// rules for this directory, then the error is attached to this /// work item's directory entry. - fn read_dir(&mut self) -> Result { + fn read_dir(&mut self) -> Result { let readdir = match fs::read_dir(self.dent.path()) { Ok(readdir) => readdir, Err(err) => { @@ -1495,10 +1640,24 @@ impl Work { return Err(err); } }; - let (ig, err) = self.ignore.add_child(self.dent.path()); + // Actually descend into the directory and read its contents + let mut result = ReadDirResult::default(); + for entry in readdir { + match entry { + Ok(entry) => result.entries.push(entry), + Err(err) => result.errors.push( + Error::from(err) + .with_path(self.dent.path()) + .with_depth(self.dent.depth() + 1), + ), + } + } + let (ig, err) = self + .ignore + .add_child_with_entries(self.dent.path(), &result.entries); self.ignore = ig; self.dent.err = err; - Ok(readdir) + Ok(result) } } @@ -1522,11 +1681,11 @@ impl Stack { // breadth-first. We do depth-first because a breadth first traversal // on wide directories with a lot of gitignores is disastrous (for // example, searching a directory tree containing all of crates.io). - let deques: Vec> = std::iter::repeat_with(Deque::new_lifo) - .take(threads) - .collect(); - let stealers = - Arc::<[Stealer]>::from(deques.iter().map(Deque::stealer).collect::>()); + let deques: Vec> = + std::iter::repeat_with(Deque::new_lifo).take(threads).collect(); + let stealers = Arc::<[Stealer]>::from( + deques.iter().map(Deque::stealer).collect::>(), + ); let stacks: Vec = deques .into_iter() .enumerate() @@ -1668,8 +1827,13 @@ 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() { @@ -1680,6 +1844,10 @@ impl<'s> Worker<'s> { return WalkState::Skip; } + let readdir = match readdir { + Some(readdir) => readdir, + None => return WalkState::Skip, + }; let readdir = match readdir { Ok(readdir) => readdir, Err(err) => { @@ -1687,11 +1855,19 @@ impl<'s> Worker<'s> { } }; - if self.max_depth.map_or(false, |max| depth >= max) { - return WalkState::Skip; + for result in readdir.entries { + let state = self.generate_work( + &work.ignore, + depth + 1, + work.root_device, + result, + ); + if state.is_quit() { + return state; + } } - for result in readdir { - let state = self.generate_work(&work.ignore, depth + 1, work.root_device, result); + for err in readdir.errors { + let state = self.visitor.visit(Err(err)); if state.is_quit() { return state; } @@ -1717,14 +1893,8 @@ impl<'s> Worker<'s> { ig: &Ignore, depth: usize, root_device: Option, - result: Result, + fs_dent: fs::DirEntry, ) -> WalkState { - let fs_dent = match result { - Ok(fs_dent) => fs_dent, - Err(err) => { - return self.visitor.visit(Err(Error::from(err).with_depth(depth))); - } - }; let mut dent = match DirEntryRaw::from_entry(depth, &fs_dent) { Ok(dent) => DirEntry::new_raw(dent, None), Err(err) => { @@ -1760,26 +1930,24 @@ impl<'s> Worker<'s> { return WalkState::Continue; } } - let should_skip_filesize = if self.max_filesize.is_some() && !dent.is_dir() { - skip_filesize( - self.max_filesize.unwrap(), - dent.path(), - &dent.metadata().ok(), - ) - } else { - false - }; - let should_skip_filtered = if let Some(Filter(predicate)) = &self.filter { - !predicate(&dent) - } else { - false - }; + let should_skip_filesize = + if self.max_filesize.is_some() && !dent.is_dir() { + skip_filesize( + self.max_filesize.unwrap(), + dent.path(), + &dent.metadata().ok(), + ) + } else { + false + }; + let should_skip_filtered = + if let Some(Filter(predicate)) = &self.filter { + !predicate(&dent) + } else { + false + }; if !should_skip_filesize && !should_skip_filtered { - self.send(Work { - dent, - ignore: ig.clone(), - root_device, - }); + self.send(Work { dent, ignore: ig.clone(), root_device }); } WalkState::Continue } @@ -1820,6 +1988,9 @@ impl<'s> Worker<'s> { } // Wait for next `Work` or `Quit` message. loop { + if self.is_quit_now() { + return None; + } if let Some(v) = self.recv() { self.activate_worker(); value = Some(v); @@ -1873,24 +2044,25 @@ impl<'s> Worker<'s> { } } +impl<'s> Drop for Worker<'s> { + fn drop(&mut self) { + if std::thread::panicking() { + self.quit_now(); + } + } +} + fn check_symlink_loop( ig_parent: &Ignore, child_path: &Path, child_depth: usize, ) -> Result<(), Error> { let hchild = Handle::from_path(child_path).map_err(|err| { - Error::from(err) - .with_path(child_path) - .with_depth(child_depth) + Error::from(err).with_path(child_path).with_depth(child_depth) })?; - for ig in ig_parent - .parents() - .take_while(|ig| !ig.is_absolute_parent()) - { + for ig in ig_parent.parents().take_while(|ig| !ig.is_absolute_parent()) { let h = Handle::from_path(ig.path()).map_err(|err| { - Error::from(err) - .with_path(child_path) - .with_depth(child_depth) + Error::from(err).with_path(child_path).with_depth(child_depth) })?; if hchild == h { return Err(Error::Loop { @@ -1905,7 +2077,11 @@ fn check_symlink_loop( // Before calling this function, make sure that you ensure that is really // necessary as the arguments imply a file stat. -fn skip_filesize(max_filesize: u64, path: &Path, ent: &Option) -> bool { +fn skip_filesize( + max_filesize: u64, + path: &Path, + ent: &Option, +) -> bool { let filesize = match *ent { Some(ref md) => Some(md.len()), None => None, @@ -1977,9 +2153,9 @@ fn path_equals(dent: &DirEntry, handle: &Handle) -> Result { if dent.is_stdin() || never_equal(dent, handle) { return Ok(false); } - Handle::from_path(dent.path()) - .map(|h| &h == handle) - .map_err(|err| Error::Io(err).with_path(dent.path())) + Handle::from_path(dent.path()).map(|h| &h == handle).map_err(|err| { + Error::Io(err).with_depth(dent.depth()).with_path(dent.path()) + }) } /// Returns true if the given walkdir entry corresponds to a directory. @@ -1997,16 +2173,14 @@ fn walkdir_is_dir(dent: &walkdir::DirEntry) -> bool { if !dent.file_type().is_symlink() || dent.depth() > 0 { return false; } - dent.path() - .metadata() - .ok() - .map_or(false, |md| md.file_type().is_dir()) + dent.path().metadata().ok().map_or(false, |md| md.file_type().is_dir()) } /// Returns true if and only if the given path is on the same device as the /// given root device. fn is_same_file_system(root_device: u64, path: &Path) -> Result { - let dent_device = device_num(path).map_err(|err| Error::Io(err).with_path(path))?; + let dent_device = + device_num(path).map_err(|err| Error::Io(err).with_path(path))?; Ok(root_device == dent_device) } @@ -2065,11 +2239,7 @@ mod tests { } fn normal_path(unix: &str) -> String { - if cfg!(windows) { - unix.replace("\\", "/") - } else { - unix.to_string() - } + if cfg!(windows) { unix.replace("\\", "/") } else { unix.to_string() } } fn walk_collect(prefix: &Path, builder: &WalkBuilder) -> Vec { @@ -2089,7 +2259,10 @@ mod tests { paths } - fn walk_collect_parallel(prefix: &Path, builder: &WalkBuilder) -> Vec { + fn walk_collect_parallel( + prefix: &Path, + builder: &WalkBuilder, + ) -> Vec { let mut paths = vec![]; for dent in walk_collect_entries_parallel(builder) { let path = dent.path().strip_prefix(prefix).unwrap(); @@ -2278,6 +2451,27 @@ 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(); @@ -2388,7 +2582,9 @@ mod tests { assert_eq!(1, dents.len()); assert!(!dents[0].path_is_symlink()); - let dents = walk_collect_entries_parallel(&WalkBuilder::new(td.path().join("foo"))); + let dents = walk_collect_entries_parallel(&WalkBuilder::new( + td.path().join("foo"), + )); assert_eq!(1, dents.len()); assert!(!dents[0].path_is_symlink()); } @@ -2474,8 +2670,88 @@ mod tests { assert_paths( td.path(), - &WalkBuilder::new(td.path()).filter_entry(|entry| entry.file_name() != OsStr::new("a")), + &WalkBuilder::new(td.path()) + .filter_entry(|entry| entry.file_name() != OsStr::new("a")), &["x", "x/y", "x/y/foo"], ); } + + #[test] + fn empty() { + let td = tmpdir(); + assert_paths(td.path(), &WalkBuilder::empty(), &[]); + + let empty_paths: Vec<&OsStr> = Vec::new(); + assert_paths(td.path(), &WalkBuilder::from_iter(empty_paths), &[]); + } + + #[test] + fn from_iter() { + let td = tmpdir(); + mkdirp(td.path().join("a/b/c")); + mkdirp(td.path().join("d/e/f")); + mkdirp(td.path().join("x/y")); + wfile(td.path().join("a/b/foo"), ""); + wfile(td.path().join("d/e/f/foo"), ""); + wfile(td.path().join("x/y/foo"), ""); + + let paths = vec![ + td.path().join("a"), + td.path().join("d"), + td.path().join("x"), + ]; + + assert_paths( + td.path(), + &WalkBuilder::from_iter(paths), + &[ + "x", + "x/y", + "x/y/foo", + "d", + "d/e", + "d/e/f", + "d/e/f/foo", + "a", + "a/b", + "a/b/foo", + "a/b/c", + ], + ); + } + + // This should always panic and never hang. + // + // Ref: https://github.com/BurntSushi/ripgrep/issues/3009 + #[test] + #[should_panic] + fn panic_in_parallel() { + let td = tmpdir(); + wfile(td.path().join("foo.txt"), ""); + + WalkBuilder::new(td.path()) + .threads(40) + .build_parallel() + .run(|| Box::new(|_| panic!("oops!"))); + } + + // This should always panic and never hang. The first call to the visitor + // builder is used while processing the root paths. Previously, a panic on + // the third call occurred after the first worker had already been spawned, + // leaving it waiting indefinitely for workers that were never created. + #[test] + #[should_panic(expected = "builder panic")] + fn panic_in_parallel_builder() { + let td = tmpdir(); + wfile(td.path().join("foo.txt"), ""); + + let mut builds = 0; + WalkBuilder::new(td.path()).threads(2).build_parallel().run(|| { + builds += 1; + if builds == 3 { + panic!("builder panic"); + } + Box::new(|_| WalkState::Continue) + }); + } } diff --git a/crates/ignore/tests/gitignore_matched_path_or_any_parents_tests.rs b/crates/ignore/tests/gitignore_matched_path_or_any_parents_tests.rs index b7b7c6f95..ecb7b47e3 100644 --- a/crates/ignore/tests/gitignore_matched_path_or_any_parents_tests.rs +++ b/crates/ignore/tests/gitignore_matched_path_or_any_parents_tests.rs @@ -2,7 +2,8 @@ use std::path::Path; use ignore::gitignore::{Gitignore, GitignoreBuilder}; -const IGNORE_FILE: &'static str = "tests/gitignore_matched_path_or_any_parents_tests.gitignore"; +const IGNORE_FILE: &'static str = + "tests/gitignore_matched_path_or_any_parents_tests.gitignore"; fn get_gitignore() -> Gitignore { let mut builder = GitignoreBuilder::new("ROOT"); @@ -23,7 +24,9 @@ fn test_path_should_be_under_root() { #[test] fn test_files_in_root() { let gitignore = get_gitignore(); - let m = |path: &str| gitignore.matched_path_or_any_parents(Path::new(path), false); + let m = |path: &str| { + gitignore.matched_path_or_any_parents(Path::new(path), false) + }; // 0x assert!(m("ROOT/file_root_00").is_ignore()); @@ -53,7 +56,9 @@ fn test_files_in_root() { #[test] fn test_files_in_deep() { let gitignore = get_gitignore(); - let m = |path: &str| gitignore.matched_path_or_any_parents(Path::new(path), false); + let m = |path: &str| { + gitignore.matched_path_or_any_parents(Path::new(path), false) + }; // 0x assert!(m("ROOT/parent_dir/file_deep_00").is_ignore()); @@ -83,8 +88,9 @@ fn test_files_in_deep() { #[test] fn test_dirs_in_root() { let gitignore = get_gitignore(); - let m = - |path: &str, is_dir: bool| gitignore.matched_path_or_any_parents(Path::new(path), is_dir); + let m = |path: &str, is_dir: bool| { + gitignore.matched_path_or_any_parents(Path::new(path), is_dir) + }; // 00 assert!(m("ROOT/dir_root_00", true).is_ignore()); @@ -186,20 +192,25 @@ fn test_dirs_in_root() { #[test] fn test_dirs_in_deep() { let gitignore = get_gitignore(); - let m = - |path: &str, is_dir: bool| gitignore.matched_path_or_any_parents(Path::new(path), is_dir); + let m = |path: &str, is_dir: bool| { + gitignore.matched_path_or_any_parents(Path::new(path), is_dir) + }; // 00 assert!(m("ROOT/parent_dir/dir_deep_00", true).is_ignore()); assert!(m("ROOT/parent_dir/dir_deep_00/file", false).is_ignore()); assert!(m("ROOT/parent_dir/dir_deep_00/child_dir", true).is_ignore()); - assert!(m("ROOT/parent_dir/dir_deep_00/child_dir/file", false).is_ignore()); + assert!( + m("ROOT/parent_dir/dir_deep_00/child_dir/file", false).is_ignore() + ); // 01 assert!(m("ROOT/parent_dir/dir_deep_01", true).is_ignore()); assert!(m("ROOT/parent_dir/dir_deep_01/file", false).is_ignore()); assert!(m("ROOT/parent_dir/dir_deep_01/child_dir", true).is_ignore()); - assert!(m("ROOT/parent_dir/dir_deep_01/child_dir/file", false).is_ignore()); + assert!( + m("ROOT/parent_dir/dir_deep_01/child_dir/file", false).is_ignore() + ); // 02 assert!(m("ROOT/parent_dir/dir_deep_02", true).is_none()); @@ -241,51 +252,67 @@ fn test_dirs_in_deep() { assert!(m("ROOT/parent_dir/dir_deep_20", true).is_ignore()); assert!(m("ROOT/parent_dir/dir_deep_20/file", false).is_ignore()); assert!(m("ROOT/parent_dir/dir_deep_20/child_dir", true).is_ignore()); - assert!(m("ROOT/parent_dir/dir_deep_20/child_dir/file", false).is_ignore()); + assert!( + m("ROOT/parent_dir/dir_deep_20/child_dir/file", false).is_ignore() + ); // 21 assert!(m("ROOT/parent_dir/dir_deep_21", true).is_ignore()); assert!(m("ROOT/parent_dir/dir_deep_21/file", false).is_ignore()); assert!(m("ROOT/parent_dir/dir_deep_21/child_dir", true).is_ignore()); - assert!(m("ROOT/parent_dir/dir_deep_21/child_dir/file", false).is_ignore()); + assert!( + m("ROOT/parent_dir/dir_deep_21/child_dir/file", false).is_ignore() + ); // 22 // dir itself doesn't match assert!(m("ROOT/parent_dir/dir_deep_22", true).is_none()); assert!(m("ROOT/parent_dir/dir_deep_22/file", false).is_ignore()); assert!(m("ROOT/parent_dir/dir_deep_22/child_dir", true).is_ignore()); - assert!(m("ROOT/parent_dir/dir_deep_22/child_dir/file", false).is_ignore()); + assert!( + m("ROOT/parent_dir/dir_deep_22/child_dir/file", false).is_ignore() + ); // 23 // dir itself doesn't match assert!(m("ROOT/parent_dir/dir_deep_23", true).is_none()); assert!(m("ROOT/parent_dir/dir_deep_23/file", false).is_ignore()); assert!(m("ROOT/parent_dir/dir_deep_23/child_dir", true).is_ignore()); - assert!(m("ROOT/parent_dir/dir_deep_23/child_dir/file", false).is_ignore()); + assert!( + m("ROOT/parent_dir/dir_deep_23/child_dir/file", false).is_ignore() + ); // 30 assert!(m("ROOT/parent_dir/dir_deep_30", true).is_ignore()); assert!(m("ROOT/parent_dir/dir_deep_30/file", false).is_ignore()); assert!(m("ROOT/parent_dir/dir_deep_30/child_dir", true).is_ignore()); - assert!(m("ROOT/parent_dir/dir_deep_30/child_dir/file", false).is_ignore()); + assert!( + m("ROOT/parent_dir/dir_deep_30/child_dir/file", false).is_ignore() + ); // 31 assert!(m("ROOT/parent_dir/dir_deep_31", true).is_ignore()); assert!(m("ROOT/parent_dir/dir_deep_31/file", false).is_ignore()); assert!(m("ROOT/parent_dir/dir_deep_31/child_dir", true).is_ignore()); - assert!(m("ROOT/parent_dir/dir_deep_31/child_dir/file", false).is_ignore()); + assert!( + m("ROOT/parent_dir/dir_deep_31/child_dir/file", false).is_ignore() + ); // 32 // dir itself doesn't match assert!(m("ROOT/parent_dir/dir_deep_32", true).is_none()); assert!(m("ROOT/parent_dir/dir_deep_32/file", false).is_ignore()); assert!(m("ROOT/parent_dir/dir_deep_32/child_dir", true).is_ignore()); - assert!(m("ROOT/parent_dir/dir_deep_32/child_dir/file", false).is_ignore()); + assert!( + m("ROOT/parent_dir/dir_deep_32/child_dir/file", false).is_ignore() + ); // 33 // dir itself doesn't match assert!(m("ROOT/parent_dir/dir_deep_33", true).is_none()); assert!(m("ROOT/parent_dir/dir_deep_33/file", false).is_ignore()); assert!(m("ROOT/parent_dir/dir_deep_33/child_dir", true).is_ignore()); - assert!(m("ROOT/parent_dir/dir_deep_33/child_dir/file", false).is_ignore()); + assert!( + m("ROOT/parent_dir/dir_deep_33/child_dir/file", false).is_ignore() + ); } diff --git a/crates/node/.gitignore b/crates/node/.gitignore index d2200a62e..c3f2ec696 100644 --- a/crates/node/.gitignore +++ b/crates/node/.gitignore @@ -202,5 +202,6 @@ index.js browser.js tailwindcss-oxide.wasi-browser.js tailwindcss-oxide.wasi.cjs +tailwindcss-oxide.wasi.d.cts wasi-worker-browser.mjs wasi-worker.mjs diff --git a/crates/node/Cargo.toml b/crates/node/Cargo.toml index 610de5ccd..a3df7b813 100644 --- a/crates/node/Cargo.toml +++ b/crates/node/Cargo.toml @@ -8,10 +8,10 @@ crate-type = ["cdylib"] [dependencies] # Default enable napi4 feature, see https://nodejs.org/api/n-api.html#node-api-version-matrix -napi = { version = "3.8.5", default-features = false, features = ["napi4"] } -napi-derive = "3.5.4" +napi = { version = "3.11.0", default-features = false, features = ["napi4"] } +napi-derive = "3.6.0" tailwindcss-oxide = { path = "../oxide" } -rayon = "1.10.0" +rayon = "1.12.0" [build-dependencies] -napi-build = "2.3.1" +napi-build = "2.3.2" diff --git a/crates/node/npm/wasm32-wasi/README.md b/crates/node/npm/wasm32-wasi/README.md index 9ec3a7b3f..2a5368149 100644 --- a/crates/node/npm/wasm32-wasi/README.md +++ b/crates/node/npm/wasm32-wasi/README.md @@ -1,3 +1,3 @@ # `@tailwindcss/oxide-wasm32-wasi` -This is the **wasm32-wasip1-threads** binary for `@tailwindcss/oxide` +This is the **wasm32-wasip1-threads** build of `@tailwindcss/oxide` diff --git a/crates/node/npm/wasm32-wasi/package.json b/crates/node/npm/wasm32-wasi/package.json index 0b93cbf70..cee9fb05a 100644 --- a/crates/node/npm/wasm32-wasi/package.json +++ b/crates/node/npm/wasm32-wasi/package.json @@ -1,9 +1,6 @@ { "name": "@tailwindcss/oxide-wasm32-wasi", "version": "4.3.3", - "cpu": [ - "wasm32" - ], "main": "tailwindcss-oxide.wasi.cjs", "files": [ "tailwindcss-oxide.wasm32-wasi.wasm", @@ -14,7 +11,7 @@ ], "license": "MIT", "engines": { - "node": ">=14.0.0" + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" }, "publishConfig": { "provenance": true, @@ -27,11 +24,11 @@ }, "browser": "tailwindcss-oxide.wasi-browser.js", "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.4", - "@emnapi/core": "^1.11.1", - "@emnapi/runtime": "^1.11.1", - "@tybys/wasm-util": "^0.10.2", - "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.2.2", + "@emnapi/core": "^1.11.3", + "@emnapi/runtime": "^1.11.3", + "@tybys/wasm-util": "^0.10.3", + "@emnapi/wasi-threads": "^1.2.3", "tslib": "^2.8.1" }, "bundledDependencies": [ diff --git a/crates/node/package.json b/crates/node/package.json index 2d41d854a..a9c19aea8 100644 --- a/crates/node/package.json +++ b/crates/node/package.json @@ -34,9 +34,11 @@ }, "license": "MIT", "devDependencies": { - "@napi-rs/cli": "3.7.0", - "@napi-rs/wasm-runtime": "^1.1.5", - "emnapi": "1.11.1" + "@emnapi/core": "1.11.3", + "@emnapi/runtime": "1.11.3", + "@napi-rs/cli": "3.7.4", + "@napi-rs/wasm-runtime": "^1.2.2", + "emnapi": "1.11.3" }, "engines": { "node": ">= 20" diff --git a/crates/oxide/Cargo.toml b/crates/oxide/Cargo.toml index 0965320c9..f8a5f6137 100644 --- a/crates/oxide/Cargo.toml +++ b/crates/oxide/Cargo.toml @@ -20,6 +20,7 @@ ignore = { path = "../ignore" } regex = "1.11.1" [dev-dependencies] +insta = "1.48.0" tempfile = "3.13.0" pretty_assertions = "1.4.1" unicode-width = "0.2.0" diff --git a/crates/oxide/src/extractor/bracket_stack.rs b/crates/oxide/src/extractor/bracket_stack.rs index f34e56ed3..2b8d3a13f 100644 --- a/crates/oxide/src/extractor/bracket_stack.rs +++ b/crates/oxide/src/extractor/bracket_stack.rs @@ -25,6 +25,7 @@ impl BracketStack { b'(' => b')', b'[' => b']', b'{' => b'}', + b'<' => b'>', _ => std::hint::unreachable_unchecked(), }; } diff --git a/crates/oxide/src/extractor/pre_processors/haml.rs b/crates/oxide/src/extractor/pre_processors/haml.rs index 99b7bd052..ec8be0817 100644 --- a/crates/oxide/src/extractor/pre_processors/haml.rs +++ b/crates/oxide/src/extractor/pre_processors/haml.rs @@ -203,6 +203,81 @@ impl PreProcessor for Haml { } } + // Handle Ruby syntax with `%w[]` arrays embedded in Haml attribute hashes. E.g.: + // + // ```haml + // %div{class: %w[bg-blue-500 w-10 h-10]} + // ``` + // + // A `%` that follows a value is not a percent literal. E.g.: the `50%w` in + // `hit rate 50%w.` + b'%' if matches!(cursor.next(), b'w' | b'W') + && !cursor.prev().is_ascii_alphanumeric() + && !matches!(cursor.prev(), b'_' | b')' | b']' | b'}') => + { + // Boundary characters + let (open, close) = match cursor.input.get(cursor.pos + 2) { + Some(b'[') => (b'[', b']'), + Some(b'(') => (b'(', b')'), + Some(b'{') => (b'{', b'}'), + Some(b'<') => (b'<', b'>'), + + // Any other ASCII punctuation can be used as a custom delimiter + Some(&c) if c.is_ascii_punctuation() => (c, c), + + // Everything else is not a valid delimiter + _ => { + cursor.advance(); + continue; + } + }; + + result[cursor.pos] = b' '; // Replace `%` + cursor.advance(); + result[cursor.pos] = b' '; // Replace `w` + cursor.advance(); + result[cursor.pos] = b' '; // Replace the opening delimiter + cursor.advance(); + + // Paired delimiters can be nested as long as they are balanced. E.g.: + // `%w[foo[bar]baz]` produces a flat array. + let mut depth = 1_usize; + + while cursor.pos < len { + match cursor.curr() { + // Skip escaped characters, unless the backslash is the delimiter + // itself + b'\\' if close != b'\\' => { + // Use backslash to embed spaces in the strings. + if cursor.next() == b' ' { + result[cursor.pos] = b' '; + } + + cursor.advance(); + } + + // Start of a nested delimiter pair + c if c == open && open != close => depth += 1, + + // Closing delimiter + c if c == close => { + depth -= 1; + + // End of the literal, replace the closing delimiter with a space + if depth == 0 { + result[cursor.pos] = b' '; + break; + } + } + + // Everything else is valid content + _ => {} + } + + cursor.advance(); + } + } + // Replace following characters with spaces if they are not inside of brackets b'#' | b'=' if bracket_stack.is_empty() => { result[cursor.pos] = b' '; @@ -424,6 +499,81 @@ mod tests { ); } + // https://github.com/tailwindlabs/tailwindcss/issues/20386 + #[test] + fn test_embedded_ruby_percent_w_delimiters() { + for (input, expected) in [ + // %w[…] in an attribute hash + ( + "%div{class: %w[flex px-2.5]}", + "%div class: flex px-2.5 ", + ), + // %w<…> + ( + "%div{class: %w}", + "%div class: flex px-2.5 ", + ), + // Nested `<…>` does not end the literal + ( + "%div{class: %w px-2.5>}", + "%div class: flex px-2.5 ", + ), + // Custom delimiters + ( + "%div{class: %w|flex px-2.5|}", + "%div class: flex px-2.5 ", + ), + ( + "%div{class: %W!flex px-2.5!}", + "%div class: flex px-2.5 ", + ), + ( + "%div{class: %w#text-sm leading-6#}", + "%div class: text-sm leading-6 ", + ), + ( + "%div{class: %w=italic tracking-wide=}", + "%div class: italic tracking-wide ", + ), + // Nested paired delimiters stay balanced inside the literal + ( + "%div{class: %w[content-['[hello]'] p-4]}", + "%div class: content-['[hello]'] p-4 ", + ), + // Escaped spaces embed a space in a single array element + ( + r#"%div{class: %w[foo\ bar baz-1]}"#, + r#"%div class: foo bar baz-1 "#, + ), + // A `%` that follows a value is not a percent literal + ("%p hit rate 50%w.", "%p hit rate 50%w "), + ] { + Haml::test(input, expected); + } + + let input = r#" + %div{class: %w[bg-blue-500 w-10 h-10]} + %div{class: %w} + %div{class: %w|underline font-bold|} + - classes = %w + "#; + + Haml::test_extract_contains( + input, + vec![ + "bg-blue-500", + "w-10", + "h-10", + "flex", + "px-2.5", + "underline", + "font-bold", + "mt-4", + "grid", + ], + ); + } + // https://github.com/tailwindlabs/tailwindcss/pull/17051#issuecomment-2711181352 #[test] fn test_haml_full_file_17051() { diff --git a/crates/oxide/src/extractor/pre_processors/ruby.rs b/crates/oxide/src/extractor/pre_processors/ruby.rs index 4345f2a1e..d9a57ab77 100644 --- a/crates/oxide/src/extractor/pre_processors/ruby.rs +++ b/crates/oxide/src/extractor/pre_processors/ruby.rs @@ -158,6 +158,15 @@ impl PreProcessor for Ruby { continue; } + // A `%` that follows a value is a modulo operation, not a percent literal. E.g.: the + // `50%w` in `hit rate 50%w.` + if cursor.prev().is_ascii_alphanumeric() + || matches!(cursor.prev(), b'_' | b')' | b']' | b'}') + { + cursor.advance(); + continue; + } + cursor.advance_twice(); // Boundary character @@ -165,8 +174,13 @@ impl PreProcessor for Ruby { b'[' => b']', 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; @@ -183,8 +197,8 @@ impl PreProcessor for Ruby { while cursor.pos < len { match cursor.curr() { - // Skip escaped characters - b'\\' => { + // Skip escaped characters, unless the backslash is the delimiter itself + b'\\' if boundary != b'\\' => { // Use backslash to embed spaces in the strings. if cursor.next() == b' ' { result[cursor.pos] = b' '; @@ -198,6 +212,11 @@ impl PreProcessor for Ruby { bracket_stack.push(cursor.curr()); } + // Start of a nested `<…>`, which Ruby allows inside a `%w<…>` literal + b'<' if boundary == b'>' => { + bracket_stack.push(cursor.curr()); + } + // End of a nested bracket b']' | b')' | b'}' if !bracket_stack.is_empty() => { if !bracket_stack.pop(cursor.curr()) { @@ -206,6 +225,14 @@ impl PreProcessor for Ruby { } } + // End of a nested `<…>` + b'>' if boundary == b'>' && !bracket_stack.is_empty() => { + if !bracket_stack.pop(cursor.curr()) { + // Unbalanced + cursor.advance(); + } + } + // End of the pattern, replace the boundary character with a space _ if cursor.curr() == boundary => { if boundary != b'\n' { @@ -253,6 +280,24 @@ 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"), @@ -330,6 +375,20 @@ 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]", diff --git a/crates/oxide/src/extractor/pre_processors/slim.rs b/crates/oxide/src/extractor/pre_processors/slim.rs index eacfb55ee..5683858d5 100644 --- a/crates/oxide/src/extractor/pre_processors/slim.rs +++ b/crates/oxide/src/extractor/pre_processors/slim.rs @@ -65,17 +65,74 @@ 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') - && matches!(cursor.input.get(cursor.pos + 2), Some(b'[' | b'(' | b'{')) => + && !cursor.prev().is_ascii_alphanumeric() + && !matches!(cursor.prev(), b'_' | b')' | b']' | b'}') => { + // Boundary characters + let (open, close) = match cursor.input.get(cursor.pos + 2) { + Some(b'[') => (b'[', b']'), + Some(b'(') => (b'(', b')'), + Some(b'{') => (b'{', b'}'), + Some(b'<') => (b'<', b'>'), + + // Any other ASCII punctuation can be used as a custom delimiter + Some(&c) if c.is_ascii_punctuation() => (c, c), + + // Everything else is not a valid delimiter + _ => { + cursor.advance(); + continue; + } + }; + result[cursor.pos] = b' '; // Replace `%` cursor.advance(); result[cursor.pos] = b' '; // Replace `w` cursor.advance(); - result[cursor.pos] = b' '; // Replace `[` or `(` or `{` - bracket_stack.push(cursor.curr()); - cursor.advance(); // Move past the bracket - continue; + result[cursor.pos] = b' '; // Replace the opening delimiter + cursor.advance(); + + // Paired delimiters can be nested as long as they are balanced. E.g.: + // `%w[foo[bar]baz]` produces a flat array. + let mut depth = 1_usize; + + while cursor.pos < len { + match cursor.curr() { + // Skip escaped characters, unless the backslash is the delimiter + // itself + b'\\' if close != b'\\' => { + // Use backslash to embed spaces in the strings. + if cursor.next() == b' ' { + result[cursor.pos] = b' '; + } + + cursor.advance(); + } + + // Start of a nested delimiter pair + c if c == open && open != close => depth += 1, + + // Closing delimiter + c if c == close => { + depth -= 1; + + // End of the literal, replace the closing delimiter with a space + if depth == 0 { + result[cursor.pos] = b' '; + break; + } + } + + // Everything else is valid content + _ => {} + } + + cursor.advance(); + } } // Any `[` preceded by an alphanumeric value will not be part of a candidate. @@ -316,16 +373,77 @@ mod tests { ] "#; - let expected = r#" - div - class= bg-blue-500 w-10 h-10] - ] - div - class= w-10 bg-green-500 h-10] - ] - "#; + let expected = " + div \n class= bg-blue-500 w-10 h-10 \n ] + div \n class= w-10 bg-green-500 h-10 \n ] + "; Slim::test(input, expected); Slim::test_extract_contains(input, vec!["bg-blue-500", "bg-green-500", "w-10", "h-10"]); } + + // https://github.com/tailwindlabs/tailwindcss/issues/20386 + #[test] + fn test_embedded_ruby_percent_w_delimiters() { + for (input, expected) in [ + // %w<…>, Slim only counts `[({` nesting in attribute values, so the code must be + // wrapped in parentheses to contain spaces + ( + "div class=(%w)", + "div class= bg-blue-500 w-10 h-10 )", + ), + // Nested `<…>` does not end the literal + ( + "div class=(%w px-2.5>)", + "div class= flex px-2.5 )", + ), + // Custom delimiters + ("div class=(%w|flex px-2.5|)", "div class= flex px-2.5 )"), + ("div class=(%W!flex px-2.5!)", "div class= flex px-2.5 )"), + ( + "div class=(%w#text-sm leading-6#)", + "div class= text-sm leading-6 )", + ), + ( + "div class=(%w=italic tracking-wide=)", + "div class= italic tracking-wide )", + ), + // Nested paired delimiters stay balanced inside the literal + ( + "div class=(%w[content-['[hello]'] p-4])", + "div class= content-['[hello]'] p-4 )", + ), + // Escaped spaces embed a space in a single array element + ( + r#"div class=(%w[foo\ bar baz-1])"#, + r#"div class= foo bar baz-1 )"#, + ), + // Ruby control line, which is plain Ruby code + ("- classes = %w", "- classes = mt-4 flex "), + // A `%` that follows a value is not a percent literal + ("| hit rate 50%w.", "| hit rate 50%w "), + ] { + Slim::test(input, expected); + } + + let input = r#" + div[ + class=(%w) + ] + - classes = %w|w-10 bg-green-500 h-10| + = tag.div class: %W!px-2.5 flex! + "#; + + Slim::test_extract_contains( + input, + vec![ + "bg-blue-500", + "bg-green-500", + "w-10", + "h-10", + "px-2.5", + "flex", + ], + ); + } } diff --git a/crates/oxide/src/scanner/detect_sources.rs b/crates/oxide/src/scanner/detect_sources.rs index c519cd00d..6cfdd49cc 100644 --- a/crates/oxide/src/scanner/detect_sources.rs +++ b/crates/oxide/src/scanner/detect_sources.rs @@ -112,6 +112,7 @@ pub fn resolve_globs( } if !dirs.contains(path) { + it.skip_current_dir(); continue; } diff --git a/crates/oxide/src/scanner/init_tracing.rs b/crates/oxide/src/scanner/init_tracing.rs index 2a570ff20..dd4c1d071 100644 --- a/crates/oxide/src/scanner/init_tracing.rs +++ b/crates/oxide/src/scanner/init_tracing.rs @@ -37,20 +37,52 @@ pub fn init_tracing() { return; } - let file_path = format!("tailwindcss-{}.log", std::process::id()); - let file = OpenOptions::new() + 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) - .unwrap_or_else(|_| panic!("Failed to open {file_path}")); + { + Ok(file) => file, + Err(err) => { + eprintln!( + "{} Failed to create {}, skipping debug logs ({err})", + dim("[DEBUG]"), + highlight(&file_path.display().to_string()) + ); + return; + } + }; - let file_path = Path::new(&file_path); - let absolute_file_path = dunce::canonicalize(file_path) - .unwrap_or_else(|_| panic!("Failed to canonicalize {file_path:?}")); + 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.as_path().to_str().unwrap()) + highlight(&absolute_file_path.display().to_string()) ); let file = Arc::new(Mutex::new(file)); diff --git a/crates/oxide/src/scanner/mod.rs b/crates/oxide/src/scanner/mod.rs index 3d89fbaf6..b7da9aa17 100644 --- a/crates/oxide/src/scanner/mod.rs +++ b/crates/oxide/src/scanner/mod.rs @@ -181,6 +181,11 @@ impl Scanner { continue; } + // The walked path can contain symlinks, while the changed files have already + // been canonicalized. Lazily canonicalize the walked path so we can compare + // the real paths as well. + let mut canonical_path: Option = None; + let mut drop_file_indexes = vec![]; for (idx, changed_file) in new_unknown_files.iter().enumerate().rev() { let ChangedContent::File(file, _) = changed_file else { @@ -189,7 +194,15 @@ impl Scanner { // When the file is found on disk it means that all the rules pass. We can // extract the current file and remove it from the list of passed in files. - if file == path { + let matches = file == path + || (file.file_name() == path.file_name() && { + if canonical_path.is_none() { + canonical_path = dunce::canonicalize(path).ok(); + } + canonical_path.as_deref() == Some(file.as_path()) + }); + + if matches { self.files.insert(path.to_path_buf()); // Track for future use content_to_scan.push(changed_file.clone()); // Track for parsing drop_file_indexes.push(idx); @@ -343,15 +356,14 @@ impl Scanner { let i = s.as_ptr() as usize - offset; let original = &original_content[i..i + s.len()]; if original.contains_str("-[]") { - return Some(unsafe { - (String::from_utf8_unchecked(original.to_vec()), i) - }); + return String::from_utf8(original.to_vec()) + .ok() + .map(|candidate| (candidate, i)); } - // SAFETY: When we parsed the candidates, we already guaranteed that the byte - // slices are valid, therefore we don't have to re-check here when we want to - // convert it back to a string. - Some(unsafe { (String::from_utf8_unchecked(s.to_vec()), i) }) + String::from_utf8(s.to_vec()) + .ok() + .map(|candidate| (candidate, i)) } _ => None, @@ -388,47 +400,93 @@ impl Scanner { self.extensions.clear(); self.globs = None; - for (path, is_dir, extension, mtime) in all_entries { - if is_dir { - self.dirs.insert(path); - } else { - // Deduplicate: parallel walk can visit the same file from multiple threads - if !self.files.insert(path.clone()) { - continue; - } - self.extensions.insert(extension.clone()); + // 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(); - // 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) + 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); } - None => true, } - } else { - if let Some(mtime) = mtime { - self.mtimes.insert(path.clone(), mtime); + 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; } - true - }; + if let Ok(file) = path.clone().into_os_string().into_string() { + changed_files.push(file); + } - 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)), + 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)), + } } } } @@ -558,36 +616,50 @@ where a }) .into_iter() - .map(|s| unsafe { String::from_utf8_unchecked(s.to_vec()) }) + .filter_map(|s| String::from_utf8(s.to_vec()).ok()) .collect() } -type WalkEntry = (PathBuf, bool, String, Option); +#[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 { - let mut entries = vec![]; - - for entry in walker.build().filter_map(Result::ok) { - let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false); - let path = entry.into_path(); - - if is_dir { - entries.push((path, true, String::new(), None)); - } else { - let ext = path - .extension() - .and_then(|x| x.to_str()) - .unwrap_or_default() - .to_owned(); - let mtime = path.metadata().ok().and_then(|m| m.modified().ok()); - entries.push((path, false, ext, mtime)); - } - } - - entries + 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 @@ -620,20 +692,7 @@ fn walk_parallel(walker: &mut WalkBuilder) -> Vec { return ignore::WalkState::Continue; }; - let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false); - let path = entry.into_path(); - - if is_dir { - buf.local.push((path, true, String::new(), None)); - } else { - let ext = path - .extension() - .and_then(|x| x.to_str()) - .unwrap_or_default() - .to_owned(); - let mtime = path.metadata().ok().and_then(|m| m.modified().ok()); - buf.local.push((path, false, ext, mtime)); - } + buf.local.push(WalkEntry::from(entry)); if buf.local.len() >= 256 { buf.shared.lock().unwrap().append(&mut buf.local); diff --git a/crates/oxide/src/scanner/sources.rs b/crates/oxide/src/scanner/sources.rs index 4a8c6ddf5..53c09387f 100644 --- a/crates/oxide/src/scanner/sources.rs +++ b/crates/oxide/src/scanner/sources.rs @@ -763,12 +763,11 @@ 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. + // `.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). + // 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(); @@ -790,18 +789,39 @@ pub fn public_source_entries_to_private_source_entries( 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. + // `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) }); - if let Some(gitignore) = gitignore { - if gitignore - .matched_path_or_any_parents(&base, true) - .is_ignore() - { - source = SourceEntry::External { base: base.into() }; - break; + // 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; + } } } @@ -810,12 +830,11 @@ pub fn public_source_entries_to_private_source_entries( 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. + // 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; } diff --git a/crates/oxide/tests/scanner.rs b/crates/oxide/tests/scanner.rs index 65248cd14..fb5354a87 100644 --- a/crates/oxide/tests/scanner.rs +++ b/crates/oxide/tests/scanner.rs @@ -1,5 +1,6 @@ #[cfg(test)] mod scanner { + use insta::assert_snapshot; use pretty_assertions::assert_eq; use std::path::{Path, PathBuf}; use std::process::Command; @@ -55,6 +56,157 @@ mod scanner { globs: Vec, normalized_sources: Vec, candidates: Vec, + tree: String, + } + + /// Renders the directory as a tree, annotating every file and folder with an indicator that + /// shows whether the scanner picked it up: + /// + /// - `✓` — scanned + /// - `✗` — ignored / skipped + /// + /// Symlinks are rendered as `link → target`, where the target is shown relative to the folder + /// containing the symlink. The contents of every `.gitignore` file are printed right below the + /// file itself. + /// + /// Folders that are a git repository root (they contain a `.git` folder) are marked with + /// `(git)`, because ignore rules behave differently inside and outside of a repository. + fn fs_tree(root: &Path, scanned: &[String]) -> String { + /// Computes the relative path from `parent` to `target`, where both are relative to the + /// same root, e.g.: `b/c` seen from `b` is `c`, and the root itself seen from `b` is `..`. + fn relative_to(target: &Path, parent: &Path) -> String { + let target = target.components().collect::>(); + let parent = parent.components().collect::>(); + + let common = target + .iter() + .zip(&parent) + .take_while(|(a, b)| **a == **b) + .count(); + + let mut parts = vec![".."; parent.len() - common]; + parts.extend( + target[common..] + .iter() + .map(|component| component.as_os_str().to_str().unwrap()), + ); + + if parts.is_empty() { + // The symlink points to its own parent folder + ".".into() + } else { + parts.join("/") + } + } + + fn walk( + dir: &Path, + root: &Path, + prefix: &str, + scanned: &[String], + visited: &mut Vec, + out: &mut String, + ) { + let mut entries = fs::read_dir(dir) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| entry.file_name() != ".git") + .collect::>(); + entries.sort_by_key(|entry| entry.file_name()); + + for (i, entry) in entries.iter().enumerate() { + let last = i == entries.len() - 1; + let connector = if last { "└── " } else { "├── " }; + let child_prefix = if last { " " } else { "│ " }; + + let path = entry.path(); + let name = entry.file_name().to_string_lossy().to_string(); + let rel = path + .strip_prefix(root) + .unwrap() + .to_string_lossy() + .replace('\\', "/"); + + let file_type = entry.file_type().unwrap(); + + // a file is scanned when it shows up in the scanned files list. a folder is + // considered scanned when any scanned file lives inside of it. paths that go + // through a symlink count towards the symlink, not towards the target directory. + let dir_prefix = format!("{rel}/"); + let is_scanned = scanned + .iter() + .any(|file| file == &rel || file.starts_with(&dir_prefix)); + let indicator = if is_scanned { "✓" } else { "✗" }; + + let mut display_name = name.clone(); + if path.join(".git").exists() { + display_name = format!("{display_name} (git)"); + } + if file_type.is_symlink() { + if let Ok(target) = fs::read_link(&path) { + // Show the target relative to the folder containing the symlink, or as-is + // when it points outside of the tree. + let target = match (target.strip_prefix(root), dir.strip_prefix(root)) { + (Ok(target), Ok(parent)) => relative_to(target, parent), + _ => target.to_string_lossy().replace('\\', "/"), + }; + display_name = format!("{name} → {target}"); + + // The symlink points to a target that doesn't exist + if path.metadata().is_err() { + display_name = format!("{display_name} (broken)"); + } + } + } + + out.push_str(&format!("{prefix}{connector}{indicator} {display_name}\n")); + + // Print the contents of `.gitignore` files below the file itself, so the ignore + // rules are visible in the same output. + if name == ".gitignore" { + if let Ok(contents) = fs::read_to_string(&path) { + for line in contents.lines() { + let line = format!("{prefix}{child_prefix} {line}"); + out.push_str(line.trim_end()); + out.push('\n'); + } + } + } + + // Descend into directories, including symlinked ones. The paths inside a symlinked + // directory are computed through the symlink, so the indicators show what the + // scanner saw via that route. A visited stack prevents symlink cycles from + // recursing forever. + if path.metadata().map(|meta| meta.is_dir()).unwrap_or(false) { + let Ok(canonical) = dunce::canonicalize(&path) else { + continue; + }; + if visited.contains(&canonical) { + continue; + } + + visited.push(canonical); + walk( + &path, + root, + &format!("{prefix}{child_prefix}"), + scanned, + visited, + out, + ); + visited.pop(); + } + } + } + + let mut visited = vec![dunce::canonicalize(root).unwrap()]; + let mut out = if root.join(".git").exists() { + String::from(". (git)\n") + } else { + String::from(".\n") + }; + walk(root, root, "", scanned, &mut visited, &mut out); + out } fn create_files_in(dir: &path::Path, paths: &[(&str, &str)]) { @@ -167,11 +319,14 @@ mod scanner { .collect::>(); normalized_sources.sort(); + let tree = fs_tree(&dir, &files); + ScanResult { files, globs, normalized_sources, candidates, + tree, } } @@ -185,6 +340,7 @@ mod scanner { files, globs, normalized_sources, + tree, .. } = scan(&[ ("index.html", ""), @@ -192,6 +348,14 @@ mod scanner { ("b.html", ""), ("c.html", ""), ]); + + assert_snapshot!(tree, @" + . (git) + ├── ✓ a.html + ├── ✓ b.html + ├── ✓ c.html + └── ✓ index.html + "); assert_eq!(files, vec!["a.html", "b.html", "c.html", "index.html"]); assert_eq!(globs, vec!["*"]); assert_eq!(normalized_sources, vec!["**/*"]); @@ -203,6 +367,7 @@ mod scanner { files, globs, normalized_sources, + tree, .. } = scan(&[ (".gitignore", "b.html"), @@ -211,6 +376,17 @@ mod scanner { ("b.html", ""), ("c.html", ""), ]); + + assert_snapshot!(tree, @" + . (git) + ├── ✗ .gitignore + │ b.html + ├── ✓ a.html + ├── ✗ b.html + ├── ✓ c.html + └── ✓ index.html + "); + assert_eq!(files, vec!["a.html", "c.html", "index.html"]); assert_eq!(globs, vec!["*"]); assert_eq!(normalized_sources, vec!["**/*"]); @@ -222,6 +398,7 @@ mod scanner { files, globs, normalized_sources, + tree, .. } = scan(&[ ("index.html", ""), @@ -232,6 +409,20 @@ mod scanner { ("public/deeply/nested/c.html", ""), ]); + assert_snapshot!(tree, @" + . (git) + ├── ✓ index.html + └── ✓ public + ├── ✓ a.html + ├── ✓ b.html + ├── ✓ c.html + ├── ✓ deeply + │ └── ✓ nested + │ └── ✓ c.html + └── ✓ nested + └── ✓ c.html + "); + assert_eq!( files, vec![ @@ -253,6 +444,7 @@ mod scanner { files, globs, normalized_sources, + tree, .. } = scan(&[ ("index.html", ""), @@ -266,6 +458,25 @@ mod scanner { ("public/very/deeply/nested/a.html", ""), ]); + assert_snapshot!(tree, @" + . (git) + ├── ✓ index.html + └── ✓ public + ├── ✓ a.html + ├── ✓ b.html + ├── ✓ c.html + ├── ✓ nested + │ ├── ✓ a.html + │ ├── ✓ again + │ │ └── ✓ a.html + │ ├── ✓ b.html + │ └── ✓ c.html + └── ✓ very + └── ✓ deeply + └── ✓ nested + └── ✓ a.html + "); + assert_eq!( files, vec![ @@ -290,6 +501,7 @@ mod scanner { files, globs, normalized_sources, + tree, .. } = scan(&[ (".gitignore", "public/b.html\na.html"), @@ -299,6 +511,18 @@ mod scanner { ("public/c.html", ""), ]); + assert_snapshot!(tree, @" + . (git) + ├── ✗ .gitignore + │ public/b.html + │ a.html + ├── ✓ index.html + └── ✓ public + ├── ✗ a.html + ├── ✗ b.html + └── ✓ c.html + "); + assert_eq!(files, vec!["index.html", "public/c.html",]); assert_eq!(globs, vec!["*"]); assert_eq!(normalized_sources, vec!["**/*"]); @@ -310,6 +534,7 @@ mod scanner { files, globs, normalized_sources, + tree, .. } = scan(&[ ("index.html", ""), @@ -318,6 +543,15 @@ mod scanner { ("src/c.html", ""), ]); + assert_snapshot!(tree, @" + . (git) + ├── ✓ index.html + └── ✓ src + ├── ✓ a.html + ├── ✓ b.html + └── ✓ c.html + "); + assert_eq!( files, vec!["index.html", "src/a.html", "src/b.html", "src/c.html"] @@ -335,6 +569,7 @@ mod scanner { files, globs, normalized_sources, + tree, .. } = scan(&[ ("index.html", ""), @@ -343,6 +578,14 @@ mod scanner { ("c.lock", ""), ]); + assert_snapshot!(tree, @" + . (git) + ├── ✗ a.mp4 + ├── ✗ b.png + ├── ✗ c.lock + └── ✓ index.html + "); + assert_eq!(files, vec!["index.html"]); assert_eq!(globs, vec!["*"]); assert_eq!(normalized_sources, vec!["**/*"]); @@ -355,6 +598,7 @@ mod scanner { files, globs, normalized_sources, + tree, .. } = scan(&[ // Looks like `.pages` binary extension, but it's a folder @@ -368,6 +612,16 @@ mod scanner { ), ]); + assert_snapshot!(tree, @" + . (git) + ├── ✗ .gitignore + │ other.pages + ├── ✗ other.pages + │ └── ✗ index.html + └── ✓ some.pages + └── ✓ index.html + "); + assert_eq!(files, vec!["some.pages/index.html"]); assert_eq!(globs, vec!["*", "some.pages/**/*.{aspx,astro,cjs,cts,eex,erb,gjs,gts,haml,handlebars,hbs,heex,html,jade,js,jsx,liquid,md,mdx,mjs,mts,mustache,njk,nunjucks,php,pug,py,razor,rb,rhtml,rs,slim,svelte,tpl,ts,tsx,twig,vue}"]); assert_eq!(normalized_sources, vec!["**/*"]); @@ -379,6 +633,7 @@ mod scanner { files, globs, normalized_sources, + tree, .. } = scan(&[ ("index.html", ""), @@ -387,6 +642,14 @@ mod scanner { ("c.less", ""), ]); + assert_snapshot!(tree, @" + . (git) + ├── ✓ a.css + ├── ✗ b.sass + ├── ✗ c.less + └── ✓ index.html + "); + assert_eq!(files, vec!["a.css", "index.html"]); assert_eq!(globs, vec!["*"]); assert_eq!(normalized_sources, vec!["**/*"]); @@ -398,9 +661,16 @@ mod scanner { files, globs, normalized_sources, + tree, .. } = scan(&[("src/index.my-extension", "")]); + assert_snapshot!(tree, @" + . (git) + └── ✓ src + └── ✓ index.my-extension + "); + assert_eq!(files, vec!["src/index.my-extension"]); assert_eq!(globs, vec!["*", "src/**/*.{aspx,astro,cjs,cts,eex,erb,gjs,gts,haml,handlebars,hbs,heex,html,jade,js,jsx,liquid,md,mdx,mjs,mts,mustache,my-extension,njk,nunjucks,php,pug,py,razor,rb,rhtml,rs,slim,svelte,tpl,ts,tsx,twig,vue}"]); assert_eq!(normalized_sources, vec!["**/*"]); @@ -412,6 +682,7 @@ mod scanner { files, globs, normalized_sources, + tree, .. } = scan(&[ ("index.html", ""), @@ -419,6 +690,13 @@ mod scanner { ("yarn.lock", ""), ]); + assert_snapshot!(tree, @" + . (git) + ├── ✓ index.html + ├── ✗ package-lock.json + └── ✗ yarn.lock + "); + assert_eq!(files, vec!["index.html"]); assert_eq!(globs, vec!["*"]); assert_eq!(normalized_sources, vec!["**/*"]); @@ -430,6 +708,7 @@ mod scanner { files, globs, normalized_sources, + tree, .. } = scan(&[ // Explicitly listed root files @@ -474,6 +753,58 @@ mod scanner { ("nested-d/very/deeply/nested/directory/again/foo.html", ""), ]); + assert_snapshot!(tree, @" + . (git) + ├── ✓ bar.html + ├── ✓ baz.html + ├── ✓ foo.html + ├── ✓ nested-a + │ ├── ✓ bar.html + │ ├── ✓ baz.html + │ └── ✓ foo.html + ├── ✓ nested-b + │ └── ✓ deeply-nested + │ ├── ✓ bar.html + │ ├── ✓ baz.html + │ └── ✓ foo.html + ├── ✓ nested-c + │ ├── ✗ .gitignore + │ │ ignored-folder/ + │ ├── ✓ bar.html + │ ├── ✓ baz.html + │ ├── ✓ foo.html + │ ├── ✗ ignored-folder + │ │ ├── ✗ bar.html + │ │ ├── ✗ baz.html + │ │ └── ✗ foo.html + │ └── ✓ sibling-folder + │ ├── ✓ bar.html + │ ├── ✓ baz.html + │ └── ✓ foo.html + └── ✓ nested-d + ├── ✗ .gitignore + │ deep/ + ├── ✓ bar.html + ├── ✓ baz.html + ├── ✓ foo.html + └── ✓ very + └── ✓ deeply + └── ✓ nested + ├── ✓ bar.html + ├── ✓ baz.html + ├── ✗ deep + │ ├── ✗ bar.html + │ ├── ✗ baz.html + │ └── ✗ foo.html + ├── ✓ directory + │ ├── ✓ again + │ │ └── ✓ foo.html + │ ├── ✓ bar.html + │ ├── ✓ baz.html + │ └── ✓ foo.html + └── ✓ foo.html + "); + assert_eq!( files, vec![ @@ -528,6 +859,7 @@ mod scanner { let ScanResult { candidates, normalized_sources, + tree, .. } = scan(&[ // The gitignore file is used to filter out files but not scanned for candidates @@ -550,6 +882,21 @@ mod scanner { ("index4.svelte", ""), ]); + assert_snapshot!(tree, @" + . (git) + ├── ✗ .gitignore + │ # md:font-bold + │ foo.html + ├── ✗ foo.html + ├── ✗ foo.jpg + ├── ✓ index.angular.html + ├── ✓ index.html + ├── ✓ index.svelte + ├── ✓ index2.svelte + ├── ✓ index3.svelte + └── ✓ index4.svelte + "); + assert_eq!( candidates, vec![ @@ -573,12 +920,21 @@ 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/**/*"]); } @@ -610,6 +966,19 @@ mod scanner { ]); 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']"]); } @@ -619,12 +988,18 @@ mod scanner { 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"]); } @@ -635,6 +1010,7 @@ mod scanner { let ScanResult { candidates, normalized_sources, + tree, .. } = scan_with_globs( &[ @@ -654,6 +1030,14 @@ mod scanner { ], ); + assert_snapshot!(tree, @" + . (git) + ├── ✓ my-folder.bin + │ └── ✓ foo.html + └── ✓ my-folder.templates + └── ✓ foo.html + "); + assert_eq!( candidates, vec![ @@ -672,6 +1056,7 @@ mod scanner { let ScanResult { candidates, normalized_sources, + tree, .. } = scan_with_globs( &[ @@ -682,10 +1067,111 @@ 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 { @@ -693,6 +1179,8 @@ mod scanner { files, globs, normalized_sources, + tree, + .. } = scan_with_globs( &[ ( @@ -707,6 +1195,18 @@ mod scanner { 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']"] @@ -721,6 +1221,7 @@ mod scanner { let ScanResult { candidates, normalized_sources, + tree, .. } = scan_with_globs( &[ @@ -734,6 +1235,19 @@ mod scanner { vec!["@source '**/*'", "@source './**/*.{styl}'"], ); + assert_snapshot!(tree, @" + . (git) + └── ✓ app + ├── ✓ (theme) + │ └── ✓ page.styl + ├── ✓ [...slug] + │ └── ✓ page.styl + ├── ✓ [[...slug]] + │ └── ✓ page.styl + └── ✓ [slug] + └── ✓ page.styl + "); + assert_eq!( candidates, vec![ @@ -775,6 +1289,14 @@ mod scanner { let mut scanner = Scanner::new(sources); let candidates = scanner.scan(); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . (git) + ├── ✓ project-a + │ └── ✓ index.html + └── ✓ project-b + └── ✓ index.html + "); + // We've done the initial scan and found the files assert_eq!( candidates, @@ -790,6 +1312,7 @@ mod scanner { let ScanResult { candidates, normalized_sources, + tree, .. } = scan_with_globs( &[ @@ -802,6 +1325,13 @@ mod scanner { vec!["@source '**/*'", "@source 'foo.styl'"], ); + assert_snapshot!(tree, @" + . (git) + ├── ✗ .gitignore + │ foo.styl + └── ✓ foo.styl + "); + assert_eq!(candidates, vec!["content-['foo.styl']"]); assert_eq!(normalized_sources, vec!["**/*", "foo.styl"]); } @@ -831,6 +1361,14 @@ mod scanner { let mut scanner = Scanner::new(sources); let candidates = scanner.scan(); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . (git) + ├── ✓ project-a + │ └── ✓ index.html + └── ✓ project-b + └── ✓ index.html + "); + // We've done the initial scan and found the files assert_eq!( candidates, @@ -935,6 +1473,24 @@ 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] @@ -966,6 +1522,14 @@ mod scanner { 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(); @@ -973,6 +1537,13 @@ mod scanner { 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] @@ -1013,6 +1584,18 @@ mod scanner { ] ); + 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(); @@ -1020,6 +1603,14 @@ mod scanner { 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] @@ -1046,6 +1637,13 @@ mod scanner { 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/**/*"))); @@ -1053,6 +1651,11 @@ mod scanner { 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/**/*"))); } @@ -1111,6 +1714,8 @@ mod scanner { files, globs, normalized_sources, + tree, + .. } = scan_with_globs( &[ ("src/index.ts", "content-['src/index.ts']"), @@ -1138,6 +1743,25 @@ mod scanner { ], ); + assert_snapshot!(tree, @" + . (git) + └── ✓ src + ├── ✓ admin + │ └── ✓ foo + │ └── ✓ template.html + ├── ✗ colors + │ ├── ✗ blue.tsx + │ ├── ✗ green.tsx + │ └── ✗ red.jsx + ├── ✗ index.ts + ├── ✓ templates + │ └── ✓ index.html + └── ✗ utils + ├── ✗ date.ts + ├── ✗ file.ts + └── ✗ string.ts + "); + assert_eq!( candidates, vec![ @@ -1183,6 +1807,8 @@ mod scanner { files, globs, normalized_sources, + tree, + .. } = scan_with_globs( // Typically skipped &[ @@ -1194,6 +1820,15 @@ mod scanner { vec!["@source '**/*'", "@source 'src/**/*.{exe,bin}'"], ); + assert_snapshot!(tree, @" + . (git) + ├── ✗ out + │ └── ✗ out.exe + └── ✓ src + ├── ✓ index.bin + └── ✓ index.exe + "); + assert_eq!( candidates, vec!["content-['src/index.bin']", "content-['src/index.exe']",] @@ -1221,6 +1856,8 @@ mod scanner { files, globs, normalized_sources, + tree, + .. } = scan_with_globs( &[ ("index.html", "content-['index.html']"), @@ -1245,6 +1882,21 @@ mod scanner { ], ); + assert_snapshot!(tree, @" + . (git) + ├── ✗ index.html + └── ✓ src + ├── ✓ admin + │ ├── ✗ ignore.html + │ └── ✓ index.html + ├── ✓ dashboard + │ ├── ✗ ignore.html + │ └── ✓ index.html + ├── ✗ ignore.html + ├── ✗ index.html + └── ✗ lib.ts + "); + assert_eq!( candidates, vec![ @@ -1264,7 +1916,10 @@ mod scanner { #[test] fn it_should_restrict_explicit_file_sources_to_the_matching_file() { let ScanResult { - candidates, files, .. + candidates, + files, + tree, + .. } = scan_with_globs( &[ ("src/foo.html", "content-['src/foo.html']"), @@ -1273,6 +1928,13 @@ mod scanner { 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"]); } @@ -1280,7 +1942,10 @@ mod scanner { #[test] fn it_should_combine_multiple_restricted_sources_for_the_same_base() { let ScanResult { - candidates, files, .. + candidates, + files, + tree, + .. } = scan_with_globs( &[ ("src/foo.html", "content-['src/foo.html']"), @@ -1290,6 +1955,14 @@ mod scanner { 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']"] @@ -1313,7 +1986,10 @@ mod scanner { ]; let ScanResult { - candidates, files, .. + candidates, + files, + tree, + .. } = scan_with_globs( paths_with_content, vec![ @@ -1322,6 +1998,17 @@ mod scanner { ], ); + 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![ @@ -1336,7 +2023,10 @@ mod scanner { // Same setup, but with the root-level source declared first let ScanResult { - candidates, files, .. + candidates, + files, + tree, + .. } = scan_with_globs( paths_with_content, vec![ @@ -1345,6 +2035,17 @@ mod scanner { ], ); + 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![ @@ -1361,12 +2062,21 @@ mod scanner { #[test] fn it_should_allow_later_ignores_to_override_restricted_sources() { let ScanResult { - candidates, files, .. + 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()); } @@ -1375,7 +2085,10 @@ mod scanner { fn it_should_handle_sources_with_parent_patterns() { { let ScanResult { - candidates, files, .. + candidates, + files, + tree, + .. } = scan_with_globs( &[ ("src/foo.html", "content-['src/foo.html']"), @@ -1385,6 +2098,15 @@ mod scanner { vec!["@source './src/ba*/*.html'"], ); + assert_snapshot!(tree, @" + . (git) + └── ✓ src + ├── ✓ bar + │ ├── ✓ ignore.html + │ └── ✓ index.html + └── ✗ foo.html + "); + assert_eq!( candidates, vec![ @@ -1397,7 +2119,10 @@ mod scanner { { let ScanResult { - candidates, files, .. + candidates, + files, + tree, + .. } = scan_with_globs( &[ ("src/foo.html", "content-['src/foo.html']"), @@ -1410,13 +2135,25 @@ mod scanner { ], ); + 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, .. + candidates, + files, + tree, + .. } = scan_with_globs( &[ ("src/foo.html", "content-['src/foo.html']"), @@ -1426,13 +2163,25 @@ mod scanner { 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, .. + candidates, + files, + tree, + .. } = scan_with_globs( &[ ("src/foo.html", "content-['src/foo.html']"), @@ -1446,6 +2195,15 @@ mod scanner { ], ); + 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']"] @@ -1460,7 +2218,10 @@ mod scanner { // 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, .. + candidates, + files, + tree, + .. } = scan_with_globs( &[ ("src/components/button.html", "content-['button']"), @@ -1469,6 +2230,14 @@ mod scanner { 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, @@ -1481,7 +2250,10 @@ mod scanner { // 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, .. + candidates, + files, + tree, + .. } = scan_with_globs( &[ ("src/components/button.html", "content-['button']"), @@ -1490,6 +2262,14 @@ mod scanner { 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, @@ -1499,7 +2279,9 @@ mod scanner { #[test] fn root_file_source_should_not_suppress_sibling_source_roots() { - let ScanResult { candidates, .. } = scan_with_globs( + let ScanResult { + candidates, tree, .. + } = scan_with_globs( &[ ("index.css", ""), ("src/index.html", "content-['src/index.html']"), @@ -1513,6 +2295,17 @@ mod scanner { ], ); + assert_snapshot!(tree, @" + . (git) + ├── ✓ index.css + ├── ✓ pages + │ ├── ✓ foo.html + │ └── ✓ nested + │ └── ✓ foo.html + └── ✓ src + └── ✓ index.html + "); + assert_eq!( candidates, vec![ @@ -1530,6 +2323,8 @@ mod scanner { files, globs, normalized_sources, + tree, + .. } = scan_with_globs( &[ (".gitignore", "ignore-1.html\nweb/ignore-2.html"), @@ -1541,6 +2336,19 @@ mod scanner { vec!["@source './src'", "@source './web'"], ); + assert_snapshot!(tree, @" + . (git) + ├── ✗ .gitignore + │ ignore-1.html + │ web/ignore-2.html + ├── ✓ src + │ └── ✓ index.html + └── ✓ web + ├── ✗ ignore-1.html + ├── ✗ ignore-2.html + └── ✓ index.html + "); + assert_eq!( candidates, vec!["content-['src/index.html']", "content-['web/index.html']",] @@ -1558,6 +2366,8 @@ mod scanner { files, globs, normalized_sources, + tree, + .. } = scan_with_globs( &[ ("src/logo.jpg", "content-['/src/logo.jpg']"), @@ -1566,6 +2376,13 @@ mod scanner { vec!["@source './src/logo.{jpg,png}'"], ); + assert_snapshot!(tree, @" + . (git) + └── ✓ src + ├── ✓ logo.jpg + └── ✓ logo.png + "); + assert_eq!( candidates, vec!["content-['/src/logo.jpg']", "content-['/src/logo.png']"] @@ -1582,6 +2399,8 @@ mod scanner { files, globs, normalized_sources, + tree, + .. } = scan_with_globs( &[ (".gitignore", "ignore-1.html\n/web/ignore-2.html"), @@ -1591,6 +2410,17 @@ mod scanner { ], vec!["@source './web'", "@source './web/ignore-1.html'"], ); + + assert_snapshot!(tree, @" + . (git) + ├── ✗ .gitignore + │ ignore-1.html + │ /web/ignore-2.html + └── ✓ web + ├── ✓ ignore-1.html + ├── ✗ ignore-2.html + └── ✓ index.html + "); assert_eq!( candidates, vec![ @@ -1604,12 +2434,173 @@ 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( &[ @@ -1621,6 +2612,18 @@ mod scanner { 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"]); @@ -1696,7 +2699,33 @@ mod scanner { ), ]; - let candidates = Scanner::new(sources.clone()).scan(); + let mut scanner = Scanner::new(sources.clone()); + let candidates = scanner.scan(); + + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + └── ✓ home + ├── ✗ .gitignore + │ ignore-home.html + └── ✓ project + ├── ✗ .gitignore + │ ignore-project.html + └── ✓ apps + ├── ✗ .gitignore + │ ignore-apps.html + ├── ✓ admin + │ └── ✓ index.html + ├── ✓ dashboard + │ └── ✓ index.html + └── ✓ web + ├── ✗ .gitignore + │ ignore-web.html + ├── ✗ ignore-apps.html + ├── ✗ ignore-home.html + ├── ✗ ignore-project.html + ├── ✗ ignore-web.html + └── ✓ index.html + "); // All ignore files are applied because there's no git repo assert_eq!( @@ -1714,7 +2743,33 @@ mod scanner { .arg("init") .current_dir(dir.join("home")) .output(); - let candidates = Scanner::new(sources.clone()).scan(); + let mut scanner = Scanner::new(sources.clone()); + let candidates = scanner.scan(); + + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + └── ✓ home (git) + ├── ✗ .gitignore + │ ignore-home.html + └── ✓ project + ├── ✗ .gitignore + │ ignore-project.html + └── ✓ apps + ├── ✗ .gitignore + │ ignore-apps.html + ├── ✓ admin + │ └── ✓ index.html + ├── ✓ dashboard + │ └── ✓ index.html + └── ✓ web + ├── ✗ .gitignore + │ ignore-web.html + ├── ✗ ignore-apps.html + ├── ✗ ignore-home.html + ├── ✗ ignore-project.html + ├── ✗ ignore-web.html + └── ✓ index.html + "); assert_eq!( candidates, @@ -1733,7 +2788,33 @@ mod scanner { .arg("init") .current_dir(dir.join("home/project")) .output(); - let candidates = Scanner::new(sources.clone()).scan(); + let mut scanner = Scanner::new(sources.clone()); + let candidates = scanner.scan(); + + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + └── ✓ home + ├── ✗ .gitignore + │ ignore-home.html + └── ✓ project (git) + ├── ✗ .gitignore + │ ignore-project.html + └── ✓ apps + ├── ✗ .gitignore + │ ignore-apps.html + ├── ✓ admin + │ └── ✓ index.html + ├── ✓ dashboard + │ └── ✓ index.html + └── ✓ web + ├── ✗ .gitignore + │ ignore-web.html + ├── ✗ ignore-apps.html + ├── ✓ ignore-home.html + ├── ✗ ignore-project.html + ├── ✗ ignore-web.html + └── ✓ index.html + "); assert_eq!( candidates, @@ -1753,7 +2834,33 @@ mod scanner { .arg("init") .current_dir(dir.join("home/project/apps")) .output(); - let candidates = Scanner::new(sources.clone()).scan(); + let mut scanner = Scanner::new(sources.clone()); + let candidates = scanner.scan(); + + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + └── ✓ home + ├── ✗ .gitignore + │ ignore-home.html + └── ✓ project + ├── ✗ .gitignore + │ ignore-project.html + └── ✓ apps (git) + ├── ✗ .gitignore + │ ignore-apps.html + ├── ✓ admin + │ └── ✓ index.html + ├── ✓ dashboard + │ └── ✓ index.html + └── ✓ web + ├── ✗ .gitignore + │ ignore-web.html + ├── ✗ ignore-apps.html + ├── ✓ ignore-home.html + ├── ✓ ignore-project.html + ├── ✗ ignore-web.html + └── ✓ index.html + "); assert_eq!( candidates, @@ -1775,7 +2882,33 @@ mod scanner { .current_dir(dir.join("home/project/apps/web")) .output(); - let candidates = Scanner::new(sources.clone()).scan(); + let mut scanner = Scanner::new(sources.clone()); + let candidates = scanner.scan(); + + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + └── ✓ home + ├── ✗ .gitignore + │ ignore-home.html + └── ✓ project + ├── ✗ .gitignore + │ ignore-project.html + └── ✓ apps + ├── ✗ .gitignore + │ ignore-apps.html + ├── ✓ admin + │ └── ✓ index.html + ├── ✓ dashboard + │ └── ✓ index.html + └── ✓ web (git) + ├── ✗ .gitignore + │ ignore-web.html + ├── ✓ ignore-apps.html + ├── ✓ ignore-home.html + ├── ✓ ignore-project.html + ├── ✗ ignore-web.html + └── ✓ index.html + "); assert_eq!( candidates, @@ -1809,7 +2942,15 @@ mod scanner { public_source_entry_from_pattern(dir.clone(), "@source not 'src/ignore-me.html'"), ]; - let candidates = Scanner::new(sources.clone()).scan(); + let mut scanner = Scanner::new(sources.clone()); + let candidates = scanner.scan(); + + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + └── ✓ src + ├── ✗ ignore-me.html + └── ✓ keep-me.html + "); assert_eq!(candidates, vec!["content-['keep-me.html']"]); } @@ -1836,7 +2977,17 @@ mod scanner { ), ]; - let candidates = Scanner::new(sources.clone()).scan(); + let mut scanner = Scanner::new(sources.clone()); + let candidates = scanner.scan(); + + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + └── ✓ src + └── ✓ app + └── ✓ [foo] + ├── ✗ ignore-me.html + └── ✓ keep-me.html + "); assert_eq!(candidates, vec!["content-['keep-me.html']"]); } @@ -1863,6 +3014,12 @@ mod scanner { let candidates = scanner.scan(); assert_eq!(candidates, vec!["content-['src/keep-me.html']"]); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + └── ✓ src + └── ✓ keep-me.html + "); + // Create new files that should definitely be ignored create_files_in( &dir, @@ -1890,6 +3047,18 @@ mod scanner { let candidates = scanner.scan(); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✗ .gitignore + │ src/ignored-by-gitignore.html + └── ✓ src + ├── ✗ ignore-by-extension.bin + ├── ✓ ignored-by-gitignore.html + ├── ✗ ignored-by-source-not.html + ├── ✓ keep-me.html + └── ✓ new-file.html + "); + assert_eq!( candidates, vec![ @@ -1919,6 +3088,11 @@ mod scanner { let candidates = scanner.scan(); assert!(candidates.is_empty()); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + └── ✗ foo.styl + "); + // Explicitly allow `.styl` files let mut scanner = Scanner::new(vec![ public_source_entry_from_pattern(dir.clone(), "@source '**/*'"), @@ -1927,6 +3101,11 @@ mod scanner { let candidates = scanner.scan(); assert_eq!(candidates, vec!["content-['foo.styl']"]); + + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + └── ✓ foo.styl + "); } #[test] @@ -1951,6 +3130,13 @@ mod scanner { let candidates = scanner.scan(); assert!(candidates.is_empty()); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✗ .gitignore + │ index.html + └── ✗ index.html + "); + let mut scanner = Scanner::new(vec![ public_source_entry_from_pattern(dir.clone(), "@source '**/*'"), public_source_entry_from_pattern(dir.clone(), "@source './*.html'"), @@ -1958,6 +3144,13 @@ mod scanner { let candidates = scanner.scan(); assert_eq!(candidates, vec!["content-['index.html']"]); + + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✗ .gitignore + │ index.html + └── ✓ index.html + "); } #[test] @@ -1992,6 +3185,17 @@ mod scanner { let candidates = scanner.scan(); assert_eq!(candidates, vec!["content-['src/index.html']"]); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✗ .gitignore + │ node_modules + ├── ✗ node_modules + │ └── ✗ my-ui-lib + │ └── ✗ index.html + └── ✓ src + └── ✓ index.html + "); + // Explicitly listing all `*.html` files, should not include `node_modules` because it's // ignored let sources = vec![public_source_entry_from_pattern( @@ -2003,6 +3207,17 @@ mod scanner { let candidates = scanner.scan(); assert_eq!(candidates, vec!["content-['src/index.html']"]); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✗ .gitignore + │ node_modules + ├── ✗ node_modules + │ └── ✗ my-ui-lib + │ └── ✗ index.html + └── ✓ src + └── ✓ index.html + "); + // Explicitly listing all `*.html` files // Explicitly list the `node_modules/my-ui-lib` // @@ -2020,12 +3235,25 @@ 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, .. } = scan_with_globs( + let ScanResult { + candidates, tree, .. + } = scan_with_globs( &[ (".gitignore", "*\n!/app\n!/app/design\n!/app/design/**\n"), ( @@ -2040,6 +3268,27 @@ mod scanner { 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']"] @@ -2053,6 +3302,8 @@ mod scanner { files, globs, normalized_sources, + tree, + .. } = scan_with_globs( &[ ( @@ -2071,6 +3322,17 @@ mod scanner { vec!["@source '**/*'"], ); + assert_snapshot!(tree, @" + . (git) + ├── ✗ node_modules + │ └── ✗ index.html + └── ✓ packages + └── ✓ web + ├── ✓ index.html + └── ✗ node_modules + └── ✗ index.html + "); + assert_eq!(candidates, vec!["content-['packages/web/index.html']"]); assert_eq!(files, vec!["packages/web/index.html",]); @@ -2085,6 +3347,8 @@ mod scanner { files, globs, normalized_sources, + tree, + .. } = scan_with_globs( &[ (".gitignore", "node_modules\ndist"), @@ -2100,6 +3364,18 @@ mod scanner { vec!["@source 'node_modules/my-ui-lib'"], ); + assert_snapshot!(tree, @" + . (git) + ├── ✗ .gitignore + │ node_modules + │ dist + └── ✓ node_modules + └── ✓ my-ui-lib + ├── ✓ dist + │ └── ✓ index.html + └── ✗ node.exe + "); + assert_eq!( candidates, vec!["content-['node_modules/my-ui-lib/dist/index.html']"] @@ -2141,6 +3417,17 @@ mod scanner { let candidates = scanner.scan(); assert_eq!(candidates, vec!["content-['src/components/button.tsx']"]); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✗ .gitignore + │ *.jsx + │ generated/ + └── ✓ src + └── ✓ components + ├── ✗ button.jsx + └── ✓ button.tsx + "); + // Create 2 new files, one "good" and one "bad" file, and manually scan them. This should // only return the "good" file because the "bad" one is ignored by a `.gitignore` file. create_files_in( @@ -2201,6 +3488,8 @@ mod scanner { files, globs, normalized_sources, + tree, + .. } = scan_with_globs( &[ ("src/💩.js", "content-['src/💩.js']"), @@ -2210,6 +3499,15 @@ mod scanner { vec!["@source '**/*'", "@source not 'src/🤦‍♂️'"], ); + assert_snapshot!(tree, @" + . (git) + └── ✓ src + ├── ✓ 💩.js + ├── ✗ 🤦‍♂️ + │ └── ✗ foo.tsx + └── ✓ 🤦‍♂️.tsx + "); + assert_eq!( candidates, vec!["content-['src/💩.js']", "content-['src/🤦‍♂️.tsx']"] @@ -2246,6 +3544,23 @@ mod scanner { )]); let candidates = scanner.scan(); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✗ .gitignore + │ node_modules + │ dist + └── ✓ node_modules + ├── ✓ .pnpm + │ └── ✓ @org+my-ui-library + │ └── ✓ dist + │ └── ✓ index.ts + └── ✓ @org + ├── ✓ .gitkeep + └── ✓ my-ui-library → ../.pnpm/@org+my-ui-library + └── ✓ dist + └── ✓ index.ts + "); + assert_eq!( candidates, vec!["content-['node_modules/.pnpm/@org+my-ui-library/dist/index.ts']"] @@ -2257,6 +3572,23 @@ mod scanner { )]); let candidates = scanner.scan(); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✗ .gitignore + │ node_modules + │ dist + └── ✓ node_modules + ├── ✓ .pnpm + │ └── ✓ @org+my-ui-library + │ └── ✓ dist + │ └── ✓ index.ts + └── ✓ @org + ├── ✗ .gitkeep + └── ✓ my-ui-library → ../.pnpm/@org+my-ui-library + └── ✓ dist + └── ✓ index.ts + "); + assert_eq!( candidates, vec!["content-['node_modules/.pnpm/@org+my-ui-library/dist/index.ts']"] @@ -2268,6 +3600,23 @@ mod scanner { )]); let candidates = scanner.scan(); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✗ .gitignore + │ node_modules + │ dist + └── ✓ node_modules + ├── ✓ .pnpm + │ └── ✓ @org+my-ui-library + │ └── ✓ dist + │ └── ✓ index.ts + └── ✓ @org + ├── ✓ .gitkeep + └── ✓ my-ui-library → ../.pnpm/@org+my-ui-library + └── ✓ dist + └── ✓ index.ts + "); + assert_eq!( candidates, vec!["content-['node_modules/.pnpm/@org+my-ui-library/dist/index.ts']"] @@ -2285,11 +3634,14 @@ mod scanner { ], ); - // Create recursive symlinks - let _ = symlink(dir.join("a"), dir.join("b")); - let _ = symlink(dir.join("b/c"), dir.join("c")); - let _ = symlink(dir.join("b/root"), &dir); - let _ = symlink(dir.join("c"), dir.join("a")); + // Create recursive symlinks: + // + // - `a → b`, `b/c → c`, `c → a` form a cycle + // - `b/root → .` points back at the root directory + let _ = symlink(dir.join("b"), dir.join("a")); + let _ = symlink(dir.join("c"), dir.join("b/c")); + let _ = symlink(&dir, dir.join("b/root")); + let _ = symlink(dir.join("a"), dir.join("c")); let mut scanner = Scanner::new(vec![public_source_entry_from_pattern( dir.clone(), @@ -2297,6 +3649,24 @@ mod scanner { )]); let candidates = scanner.scan(); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✓ a → b + │ ├── ✗ c → ../c + │ ├── ✓ index.html + │ └── ✗ root → .. + ├── ✓ b + │ ├── ✗ c → ../c + │ ├── ✓ index.html + │ └── ✗ root → .. + ├── ✓ c → a + │ ├── ✗ c → . + │ ├── ✓ index.html + │ └── ✗ root → .. + └── ✓ z + └── ✓ index.html + "); + assert_eq!( candidates, vec!["content-['b/index.html']", "content-['z/index.html']"] @@ -2321,6 +3691,14 @@ mod scanner { )]); let candidates = scanner.scan(); + assert_snapshot!(fs_tree(&dir, &scanned_files(&mut scanner, &dir)), @" + . + ├── ✓ abcd + │ └── ✓ xyz.html + └── ✓ efgh → abcd + └── ✓ xyz.html + "); + assert_eq!(candidates, vec!["content-['abcd/xyz.html']"]); // Partially referencing the symlinked folder with a glob, should find the file @@ -2361,6 +3739,18 @@ mod scanner { ]); 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![ @@ -2398,6 +3788,16 @@ mod scanner { )]); 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![ @@ -2412,9 +3812,232 @@ mod scanner { ]); 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(); @@ -2440,6 +4063,11 @@ mod scanner { ("src/defined-at-start.css", "--color-defined-at-start: red;"), ], ); + fs::write( + dir.join("src/invalid.css"), + b".button { color: var(--color-\x80); }", + ) + .unwrap(); let mut scanner = Scanner::new(vec![public_source_entry_from_pattern( dir.clone(), diff --git a/integrations/cli/index.test.ts b/integrations/cli/index.test.ts index 310086802..07f8bcb68 100644 --- a/integrations/cli/index.test.ts +++ b/integrations/cli/index.test.ts @@ -2276,6 +2276,70 @@ test( }, ) +// 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)', { diff --git a/integrations/oxide/wasm.test.ts b/integrations/oxide/wasm.test.ts index ac2231672..2d63c707a 100644 --- a/integrations/oxide/wasm.test.ts +++ b/integrations/oxide/wasm.test.ts @@ -1,4 +1,4 @@ -import { css, js, json, test } from '../utils' +import { css, js, json, test, yaml } from '../utils' // This test runs the wasm build using the `node:wasi` runtime. // @@ -57,3 +57,187 @@ testFn( `) }, ) + +testFn( + '`@tailwindcss/oxide` falls back to the wasm build when no native binding is available', + { + fs: { + 'package.json': json` + { + "dependencies": { + "@tailwindcss/oxide": "workspace:^" + } + } + `, + 'pnpm-workspace.yaml': yaml` + # Trick pnpm in only supporting an architecture that @tailwindcss/oxide + # doesn't support, and therefore should fallback to the wasm version. + supportedArchitectures: + os: + - openbsd + cpu: + - x64 + `, + 'src/index.js': js` + const className = "content-['src/index.js']" + module.exports = { className } + `, + 'index.mjs': js` + import { createRequire } from 'node:module' + import { join } from 'node:path' + + let require = createRequire(import.meta.url) + let { Scanner } = require('@tailwindcss/oxide') + + let loaded = Object.keys(require.cache) + + let scanner = new Scanner({ + sources: [ + { + base: join(process.cwd(), 'src'), + pattern: '**/*', + negated: false, + }, + ], + }) + + console.log( + JSON.stringify({ + native: loaded.filter((file) => file.endsWith('.node')), + wasi: loaded.some((file) => file.endsWith('tailwindcss-oxide.wasi.cjs')), + candidates: scanner.scan(), + }), + ) + process.exit() + `, + }, + }, + async ({ expect, exec }) => { + // Since vitest runs under `pnpm run`, pnpm's bin shims export a NODE_PATH + // that includes the repository's hidden hoist directory + // (`node_modules/.pnpm/node_modules`), which links every workspace package, + // including all native `@tailwindcss/oxide-*` bindings. + // + // Node uses `NODE_PATH` exactly when the local `node_modules` lookup fails, + // which would defeat the simulated unsupported platform, so clear it. + let output = await exec(`node index.mjs`, { env: { NODE_PATH: '' } }) + let { native, wasi, candidates } = JSON.parse(output) + + // No native binding was installed or loaded, ... + expect(native).toEqual([]) + + // ... the wasm32-wasi binding is what actually loaded, ... + expect(wasi).toBe(true) + + // ... and scanning real files on disk works through it. + expect(candidates).toMatchInlineSnapshot(` + [ + "className", + "const", + "content-['src/index.js']", + "exports", + ] + `) + }, +) + +testFn( + 'the wasm build loads even when preopening the filesystem root is denied', + { + fs: { + 'package.json': json` + { + "dependencies": { + "@tailwindcss/oxide": "workspace:^" + } + } + `, + 'pnpm-workspace.yaml': yaml` + # Trick pnpm in only supporting an architecture that @tailwindcss/oxide + # doesn't support, and therefore should fallback to the wasm version. + supportedArchitectures: + os: + - openbsd + cpu: + - x64 + `, + // The wasm bindings generated by `@napi-rs/cli` preopen the filesystem + // root, which sandboxed platforms (e.g. OpenHarmony, Android) deny with + // `UVWASI_EACCES`, making the wasm fallback fail to load on exactly the + // platforms that need it. + // + // We patch `@napi-rs/cli`'s templates to retry with narrower preopens + // (see `patches/@napi-rs__cli@3.7.4.patch`). Simulate such a sandbox by + // denying `/` preopens. + 'preload.cjs': js` + const wasi = require('node:wasi') + const RealWASI = wasi.WASI + + wasi.WASI = class WASI extends RealWASI { + constructor(options) { + if (options?.preopens?.['/'] !== undefined) { + const error = new Error('UVWASI_EACCES, uvwasi_init') + error.code = 'UVWASI_EACCES' + error.syscall = 'uvwasi_init' + throw error + } + super(options) + } + } + `, + 'src/index.js': js` + const className = "content-['src/index.js']" + module.exports = { className } + `, + 'index.mjs': js` + import { createRequire } from 'node:module' + import { join } from 'node:path' + + let require = createRequire(import.meta.url) + let { Scanner } = require('@tailwindcss/oxide') + + let loaded = Object.keys(require.cache) + + let scanner = new Scanner({ + sources: [ + { + base: join(process.cwd(), 'src'), + pattern: '**/*', + negated: false, + }, + ], + }) + + console.log( + JSON.stringify({ + wasi: loaded.some((file) => file.endsWith('tailwindcss-oxide.wasi.cjs')), + candidates: scanner.scan(), + }), + ) + process.exit() + `, + }, + }, + async ({ expect, exec }) => { + // See the note about NODE_PATH in the test above. + let output = await exec(`node --require ./preload.cjs index.mjs`, { env: { NODE_PATH: '' } }) + + // Only parse the first line, because Node prints an `ExperimentalWarning` + // about WASI to stderr, which `exec` appends to the output. + let { wasi, candidates } = JSON.parse(output.trim().split('\n')[0]) + + // The wasm32-wasi binding loaded despite `/` being denied, ... + expect(wasi).toBe(true) + + // ... and scanning files under the current working directory still works + // through the narrower preopen. + expect(candidates).toMatchInlineSnapshot(` + [ + "className", + "const", + "content-['src/index.js']", + "exports", + ] + `) + }, +) diff --git a/integrations/turbopack/loader.test.ts b/integrations/turbopack/loader.test.ts new file mode 100644 index 000000000..755cd60df --- /dev/null +++ b/integrations/turbopack/loader.test.ts @@ -0,0 +1,83 @@ +import { candidate, css, fetchStyles, js, json, jsx, retryAssertion, test } from '../utils' + +test( + '@tailwindcss/turbopack loader (dev)', + { + timeout: 120_000, + fs: { + 'package.json': json` + { + "dependencies": { + "next": "^16.2.7", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "tailwindcss": "workspace:^", + "@tailwindcss/turbopack": "workspace:^" + } + } + `, + 'next.config.mjs': js` + export default { + turbopack: { + rules: { + '*.css': { + loaders: ['@tailwindcss/turbopack'], + as: '*.css', + }, + }, + }, + } + `, + 'app/layout.js': jsx` + import './globals.css' + + export default function RootLayout({ children }) { + return ( + + {children} + + ) + } + `, + 'app/page.js': jsx` + export default function Page() { + return
Hello, Next.js!
+ } + `, + 'app/globals.css': css` @import 'tailwindcss'; `, + }, + }, + async ({ fs, spawn, expect }) => { + let process = await spawn('pnpm next dev') + + let url = '' + await process.onStdout((message) => { + let match = /Local:\s*(http.*)/.exec(message) + if (match) url = match[1] + return Boolean(url) + }) + + await process.onStdout((message) => message.includes('Ready in')) + + await retryAssertion(async () => { + let styles = await fetchStyles(url) + expect(styles).toContain(candidate`flex`) + expect(styles).not.toContain(candidate`underline`) + }) + + await fs.write( + 'app/page.js', + jsx` + export default function Page() { + return
Hello, Next.js!
+ } + `, + ) + + await retryAssertion(async () => { + let styles = await fetchStyles(url) + expect(styles).toContain(candidate`flex`) + expect(styles).toContain(candidate`underline`) + }) + }, +) diff --git a/integrations/upgrade/index.test.ts b/integrations/upgrade/index.test.ts index c8fa4c86b..efac411db 100644 --- a/integrations/upgrade/index.test.ts +++ b/integrations/upgrade/index.test.ts @@ -2557,7 +2557,7 @@ test( @import './utilities.css'; @import './generated/ignore-me.css'; `, - 'src/generated/.gitignore': ` + 'src/generated/.gitignore': txt` * !.gitignore `, @@ -2817,7 +2817,7 @@ test( 
--- ./src/input.css --- - @import 'tailwindcss'; + @import 'tailwindcss'; /* The default border color has changed to \`currentcolor\` in Tailwind CSS v4, diff --git a/integrations/utils.ts b/integrations/utils.ts index fc073d201..9863918d1 100644 --- a/integrations/utils.ts +++ b/integrations/utils.ts @@ -612,6 +612,7 @@ function overwriteVersionsInPnpmWorkspace(content: string): string { workspace.overrides['@tailwindcss/upgrade>tailwindcss'] = resolveVersion(pkg) workspace.overrides['@tailwindcss/cli>tailwindcss'] = resolveVersion(pkg) workspace.overrides['@tailwindcss/postcss>tailwindcss'] = resolveVersion(pkg) + workspace.overrides['@tailwindcss/turbopack>tailwindcss'] = resolveVersion(pkg) workspace.overrides['@tailwindcss/vite>tailwindcss'] = resolveVersion(pkg) workspace.overrides['@tailwindcss/webpack>tailwindcss'] = resolveVersion(pkg) } else { diff --git a/integrations/vite/bundled-dev.test.ts b/integrations/vite/bundled-dev.test.ts new file mode 100644 index 000000000..8ba1b6b73 --- /dev/null +++ b/integrations/vite/bundled-dev.test.ts @@ -0,0 +1,193 @@ +import path from 'node:path' +import { candidate, css, html, json, retryAssertion, test, ts, txt, yaml } from '../utils' + +// Vite's experimental `bundledDev` mode invokes the `hotUpdate` hook without a +// `server`, which used to crash the plugin on every file edit. +// +// - https://github.com/tailwindlabs/tailwindcss/issues/20378 +// - https://vite.dev/blog/announcing-vite8-1#experimental-bundled-dev-mode +test( + 'dev mode (experimental `bundledDev`)', + { + fs: { + 'package.json': json`{}`, + 'pnpm-workspace.yaml': yaml` + # + packages: + - project-a + `, + 'project-a/package.json': txt` + { + "type": "module", + "dependencies": { + "@tailwindcss/vite": "workspace:^", + "tailwindcss": "workspace:^" + }, + "devDependencies": { + "vite": "^8.1" + } + } + `, + 'project-a/vite.config.ts': ts` + import tailwindcss from '@tailwindcss/vite' + import { defineConfig } from 'vite' + + export default defineConfig({ + experimental: { + bundledDev: true, + }, + plugins: [tailwindcss()], + }) + `, + 'project-a/index.html': html` + + + + +
Hello, world!
+ + `, + 'project-a/src/index.css': css` + @reference 'tailwindcss/theme'; + @import 'tailwindcss/utilities'; + @source '../../project-b/src/**/*.html'; + `, + 'project-b/src/index.html': html` +
+ `, + }, + }, + async ({ root, spawn, fs, expect }) => { + let process = await spawn('pnpm vite dev', { + cwd: path.join(root, 'project-a'), + env: { + // Some CI machines have an upstream rolldown bug where its file watcher + // never delivers a single event. The debug output is the only way to + // tell that apart from a real regression (see below). + DEBUG: 'vite:full-bundle-mode', + }, + }) + + // Any debug output beyond the "INITIAL:" startup lines proves rolldown's + // watcher delivered at least one file event. + let watcherAlive = false + + // `hotUpdate` errors don't kill the dev server, they are only printed to + // stderr. Track them explicitly so a crash fails the test even if the + // rebuild happens to succeed anyway. + let pluginErrors: string[] = [] + process.onStderr((message) => { + if (message.includes('vite:full-bundle-mode')) { + if (!message.includes('INITIAL:')) watcherAlive = true + } else if (message.includes('@tailwindcss/vite')) { + pluginErrors.push(message) + } + return false + }) + + let url = '' + await process.onStdout((m) => { + let match = /Local:\s*(http.*)\//.exec(m) + if (match) url = match[1] + return Boolean(url) + }) + + // In `bundledDev` mode the stylesheet is not served separately. Instead the + // generated CSS is embedded in the bundled JS and injected at runtime, so + // extract the bundle from the served HTML. While the bundle is being built, + // Vite serves a temporary fallback page instead. + // + // The bundle can be split into multiple chunks (e.g. the HMR client runtime + // and the app itself), and the chunk containing the CSS is not always the + // first one, so fetch every referenced script and stylesheet. + async function fetchBundledStyles(): Promise { + let index = await fetch(`${url}/`) + let html = await index.text() + if (html.includes('__vite_is_fallback_page__')) { + throw new Error('Bundling still in progress') + } + + let sources = [ + ...html.matchAll(/]*\ssrc="([^"]+)"/g), + ...html.matchAll(/]*\srel="stylesheet"[^>]*\shref="([^"]+)"/g), + ].map((match) => match[1]) + if (sources.length === 0) throw new Error(`No scripts or stylesheets found in:\n\n${html}`) + + let contents = await Promise.all( + sources.map(async (src) => { + let response = await fetch(new URL(src, `${url}/`)) + return await response.text() + }), + ) + return contents.join('\n') + } + + await retryAssertion( + async () => { + let styles = await fetchBundledStyles() + expect(styles).toContain(candidate`underline`) + expect(styles).toContain(candidate`flex`) + }, + { timeout: 10_000, delay: 100 }, + ) + + // Edit a module file and a manually `@source`d file. Without a connected + // HMR websocket client (this test only uses HTTP fetches), a file change + // does not trigger an eager rebuild — it only marks the bundle as stale, + // and the next fetch kicks off the rebuild. So poll with fetches without + // touching the files again: rewriting would re-mark the bundle stale right + // before each fetch and the rebuild could never be observed. + await fs.write( + 'project-a/index.html', + html` + + + + +
Hello, world!
+ + `, + ) + + await fs.write('project-b/src/index.html', html` +
+ `) + + try { + await retryAssertion( + async () => { + let styles = await fetchBundledStyles() + expect(styles).toContain(candidate`underline`) + expect(styles).toContain(candidate`flex`) + expect(styles).toContain(candidate`m-2`) + expect(styles).toContain(candidate`font-bold`) + }, + { timeout: 15_000, delay: 100 }, + ) + } catch (error) { + // If rolldown's watcher delivered any file event, the updates were + // genuinely dropped somewhere along the way — a real failure. + if (watcherAlive) throw error + + // Otherwise the watcher never delivered a single event, so the updates + // can never be observed no matter how long we wait — an upstream + // rolldown bug on some CI machines, not a plugin regression. The crash + // regression this test guards is still covered: `hotUpdate` is driven + // by Vite's own (working) chokidar watcher and asserted via + // `pluginErrors` below. + // + // Skipping is only sound while the server still healthily serves the + // original bundle. If it stopped serving (crashed, or wedged on the + // fallback page), that's real breakage a silent watcher must not mask. + console.log(error) + let styles = await fetchBundledStyles() + expect(styles).toContain(candidate`underline`) + expect(styles).toContain(candidate`flex`) + console.warn( + 'Skipping update assertions: rolldown’s file watcher delivered no events on this machine.', + ) + } + + expect(pluginErrors).toEqual([]) + }, +) diff --git a/integrations/vite/index.test.ts b/integrations/vite/index.test.ts index eba315e4c..c346f8e37 100644 --- a/integrations/vite/index.test.ts +++ b/integrations/vite/index.test.ts @@ -294,6 +294,113 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { }, ) + // https://github.com/tailwindlabs/tailwindcss/issues/20346 + test( + 'dev mode + symlinked `@source` files pick up changes to the real file', + { + fs: { + 'package.json': json`{}`, + 'pnpm-workspace.yaml': yaml` + # + packages: + - project-a + - packages/* + `, + 'project-a/package.json': txt` + { + "type": "module", + "dependencies": { + "@tailwindcss/vite": "workspace:^", + "repro-package": "workspace:*", + "tailwindcss": "workspace:^" + }, + "devDependencies": { + ${transformer === 'lightningcss' ? `"lightningcss": "^1",` : ''} + "vite": "^7" + } + } + `, + 'project-a/vite.config.ts': ts` + import tailwindcss from '@tailwindcss/vite' + import { defineConfig } from 'vite' + + export default defineConfig({ + css: ${transformer === 'postcss' ? '{}' : "{ transformer: 'lightningcss' }"}, + build: { cssMinify: false }, + plugins: [tailwindcss()], + }) + `, + 'project-a/index.html': html` + + + + +
Hello, world!
+ + `, + + // The `@source` points through the `node_modules` symlink that pnpm + // creates for the workspace package. Vite's file watcher ignores + // `node_modules` by default, so the symlinked path can not be watched. + // The real file (in `packages/repro-package`) has to be watched + // instead. + 'project-a/src/index.css': css` + @reference 'tailwindcss/theme'; + @import 'tailwindcss/utilities'; + @source '../node_modules/repro-package/source.html'; + `, + 'packages/repro-package/package.json': json` + { + "name": "repro-package", + "private": true, + "version": "1.0.0" + } + `, + 'packages/repro-package/source.html': html` +
+ Hello, world! +
+ `, + }, + }, + async ({ root, spawn, fs, expect }) => { + let process = await spawn('pnpm vite dev', { + cwd: path.join(root, 'project-a'), + }) + await process.onStdout((m) => m.includes('ready in')) + + let url = '' + await process.onStdout((m) => { + let match = /Local:\s*(http.*)\//.exec(m) + if (match) url = match[1] + return Boolean(url) + }) + + await retryAssertion(async () => { + let styles = await fetchStyles(url, '/index.html') + expect(styles).toContain(candidate`underline`) + expect(styles).toContain(candidate`content-['v1']`) + }) + + await retryAssertion(async () => { + // Changes to the real file (behind the symlink) should be picked up + await fs.write( + 'packages/repro-package/source.html', + html` +
+ Hello, world! +
+ `, + ) + + let styles = await fetchStyles(url) + expect(styles).toContain(candidate`underline`) + expect(styles).toContain(candidate`content-['v1']`) + expect(styles).toContain(candidate`content-['v2']`) + }) + }, + ) + test( 'watch mode', { @@ -473,11 +580,11 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { }, ) - describe.sequential.each([['^6'], ['7.0.8'], ['7.1.12'], ['7.3.1'], ['8.0.0']])( + describe.each([['^6'], ['7.0.8'], ['7.1.12'], ['7.3.1'], ['8.0.0']])( 'Using Vite %s', (version) => { test( - 'external source file changes trigger a full reload', + 'external source file changes update the CSS', { fs: { 'package.json': json`{}`, @@ -554,26 +661,33 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { 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 + // 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 should trigger a full reload + // Changing an external .php file hot-updates the generated CSS { await fs.write( 'project-b/src/index.php', txt`
`, ) - // Ensure the page reloaded + // 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.onStderr( - (m) => m.includes('vite:hmr (client)') && m.includes('index.php'), - ) + await process.onStdout((m) => m.includes('hmr update') && m.includes('index.css')) } - await process.onStderr((m) => m.includes('vite:hmr (ssr)') && m.includes('index.php')) // Ensure the styles were regenerated with the new content let styles = await fetchStyles(url, '/index.html') @@ -746,7 +860,6 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { let styles = await fetchStyles(url, '/index.html') expect(styles).toContain(candidate`content-['updated:src/lazy.tsx']`) }) - expect(await fs.read('project-a/hmr.log')).not.toContain('full-reload') } // The same holds for a custom file type as long as some file of the @@ -761,7 +874,6 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { let styles = await fetchStyles(url, '/index.html') expect(styles).toContain(candidate`content-['updated:src/comp-b.custom']`) }) - expect(await fs.read('project-a/hmr.log')).not.toContain('full-reload') } // Changing a scanned stylesheet that is not part of the module graph @@ -786,23 +898,26 @@ describe.each(['postcss', 'lightningcss'])('%s', (transformer) => { let log = await fs.read('project-a/hmr.log') expect(log.split('"type":"update"').length).toBeGreaterThan(updates) }) - expect(await fs.read('project-a/hmr.log')).not.toContain('full-reload') } - // Changing an external file (e.g. a PHP template) should still trigger - // a full reload. This must work even though `snippet.php` is part of - // the module graph via the `?raw` import: a query import only pulls - // the file's contents into the graph (and creates an untransformed - // module node for the underlying file), it is not evidence that Vite - // processes `.php` files as modules. + // 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 () => { - expect(await fs.read('project-a/hmr.log')).toContain('full-reload') + let log = await fs.read('project-a/hmr.log') + expect(log.split('"type":"update"').length).toBeGreaterThan(updates) }) let styles = await fetchStyles(url, '/index.html') diff --git a/integrations/vite/preact.test.ts b/integrations/vite/preact.test.ts new file mode 100644 index 000000000..8e4deec2b --- /dev/null +++ b/integrations/vite/preact.test.ts @@ -0,0 +1,165 @@ +import { candidate, css, fetchStyles, html, json, retryAssertion, test, ts, txt } from '../utils' + +test( + 'dev mode', + { + fs: { + 'package.json': json` + { + "type": "module", + "dependencies": { + "preact": "^10" + }, + "devDependencies": { + "@preact/preset-vite": "^2", + "@tailwindcss/vite": "workspace:^", + "tailwindcss": "workspace:^", + "vite": "^8" + } + } + `, + 'vite.config.ts': ts` + import fs from 'node:fs' + import path from 'node:path' + import preact from '@preact/preset-vite' + import tailwindcss from '@tailwindcss/vite' + import { defineConfig } from 'vite' + + export default defineConfig({ + plugins: [ + tailwindcss(), + preact(), + { + // Log all HMR payloads to a file so the test can assert on them + name: 'hmr-wiretap', + configureServer(server) { + let logFile = path.resolve('hmr.log') + fs.writeFileSync(logFile, '') + for (let environment of Object.values(server.environments)) { + let send = environment.hot.send.bind(environment.hot) + environment.hot.send = (payload) => { + fs.appendFileSync(logFile, JSON.stringify(payload) + '\\n') + return send(payload) + } + } + }, + }, + ], + }) + `, + 'index.html': html` + + + + + +
+ + + + `, + 'src/main.tsx': ts` + import { render } from 'preact' + import { App } from './app' + + render(, document.getElementById('app')!) + `, + 'src/app.tsx': ts` + import { useState } from 'preact/hooks' + + export function App() { + const [count, setCount] = useState(0) + return ( + + ) + } + `, + 'src/index.css': css`@import 'tailwindcss';`, + }, + }, + async ({ fs, spawn, expect }) => { + let process = await spawn('pnpm vite dev') + await process.onStdout((m) => m.includes('ready in')) + + let url = '' + await process.onStdout((m) => { + let match = /Local:\s*(http.*)\//.exec(m) + if (match) url = match[1] + return Boolean(url) + }) + + await retryAssertion(async () => { + let styles = await fetchStyles(url) + expect(styles).toContain(candidate`underline`) + }) + + // Load the component modules, like a browser visiting the page would + await fetch(`${url}/src/main.tsx`) + await fetch(`${url}/src/app.tsx`) + + // Editing a component keeps HMR intact: new classes are delivered through + // a regular update, not a full page reload (which would lose all state) + { + await fs.write( + 'src/app.tsx', + ts` + import { useState } from 'preact/hooks' + + export function App() { + const [count, setCount] = useState(0) + return ( + + ) + } + `, + ) + + await retryAssertion(async () => { + let styles = await fetchStyles(url) + expect(styles).toContain(candidate`underline`) + expect(styles).toContain(candidate`flex`) + }) + expect(await fs.read('hmr.log')).toContain('"type":"update"') + expect(await fs.read('hmr.log')).not.toContain('full-reload') + } + + // Changing a scanned file that is not part of the module graph (e.g. + // `package.json`, which package managers and other tooling write to while + // the dev server is running) should not trigger a full reload either — + // that would destroy client state. New candidates should still be picked + // up because the file is a watch dependency of the CSS root, so the CSS + // hot-updates through Vite's regular pipeline. + // + // https://github.com/tailwindlabs/tailwindcss/issues/20411 + { + await fs.write( + 'package.json', + txt` + { + "type": "module", + "description": "content-['package.json']", + "dependencies": { + "preact": "^10" + }, + "devDependencies": { + "@preact/preset-vite": "^2", + "@tailwindcss/vite": "workspace:^", + "tailwindcss": "workspace:^", + "vite": "^8" + } + } + `, + ) + + await retryAssertion(async () => { + let styles = await fetchStyles(url) + expect(styles).toContain(candidate`content-['package.json']`) + }) + expect(await fs.read('hmr.log')).not.toContain('full-reload') + } + }, +) diff --git a/integrations/vitest.config.ts b/integrations/vitest.config.mts similarity index 100% rename from integrations/vitest.config.ts rename to integrations/vitest.config.mts diff --git a/package.json b/package.json index f9430bdbe..7d41a469e 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ }, "license": "MIT", "devDependencies": { - "@playwright/test": "^1.61.1", + "@playwright/test": "^1.62.1", "@types/node": "catalog:", "postcss": "catalog:", "postcss-import": "^16.1.1", @@ -56,9 +56,9 @@ "prettier-plugin-embed": "^0.5.1", "prettier-plugin-organize-imports": "^4.3.0", "tsup": "^8.5.1", - "turbo": "^2.9.18", + "turbo": "^2.10.8", "typescript": "^5.9.3", - "vitest": "^4.1.9" + "vitest": "^4.1.10" }, "packageManager": "pnpm@11.9.0" } diff --git a/packages/@tailwindcss-browser/package.json b/packages/@tailwindcss-browser/package.json index 69148ed59..70af78c1e 100644 --- a/packages/@tailwindcss-browser/package.json +++ b/packages/@tailwindcss-browser/package.json @@ -31,7 +31,7 @@ }, "devDependencies": { "h3": "^1.15.11", - "listhen": "^1.10.0", + "listhen": "^1.10.1", "tailwindcss": "workspace:*" } } diff --git a/packages/@tailwindcss-browser/vitest.config.ts b/packages/@tailwindcss-browser/vitest.config.mts similarity index 100% rename from packages/@tailwindcss-browser/vitest.config.ts rename to packages/@tailwindcss-browser/vitest.config.mts diff --git a/packages/@tailwindcss-cli/package.json b/packages/@tailwindcss-cli/package.json index 58304ce9b..7fa5b5500 100644 --- a/packages/@tailwindcss-cli/package.json +++ b/packages/@tailwindcss-cli/package.json @@ -29,7 +29,7 @@ "access": "public" }, "dependencies": { - "@parcel/watcher": "2.5.1", + "@parcel/watcher": "catalog:", "@tailwindcss/node": "workspace:*", "@tailwindcss/oxide": "workspace:*", "enhanced-resolve": "catalog:", diff --git a/packages/@tailwindcss-node/package.json b/packages/@tailwindcss-node/package.json index 500c4f712..0eced7366 100644 --- a/packages/@tailwindcss-node/package.json +++ b/packages/@tailwindcss-node/package.json @@ -57,7 +57,7 @@ "enhanced-resolve": "catalog:", "jiti": "^2.7.0", "lightningcss": "catalog:", - "magic-string": "^0.30.21", + "magic-string": "^1.1.0", "source-map-js": "^1.2.1", "tailwindcss": "workspace:*" } diff --git a/packages/@tailwindcss-node/src/optimize.ts b/packages/@tailwindcss-node/src/optimize.ts index 5e0234432..2f0d39589 100644 --- a/packages/@tailwindcss-node/src/optimize.ts +++ b/packages/@tailwindcss-node/src/optimize.ts @@ -61,17 +61,14 @@ export function optimize( map = result.map?.toString() result.warnings = result.warnings.filter((warning) => { - // Ignore warnings about unknown pseudo-classes as they are likely caused - // by the use of `:deep()`, `:slotted()`, and `:global()` which are not - // standard CSS but are commonly used in frameworks like Vue. - if (/'(deep|slotted|global)' is not recognized as a valid pseudo-/.test(warning.message)) { - return false - } - - // Ignore warnings about unknown at-rules. - // - // TODO: drop `@position-try` once https://github.com/parcel-bundler/lightningcss/pull/1238 lands - if (/Unknown at rule: @position-try/.test(warning.message)) { + // Ignore warnings about unknown pseudo-classes that are used in frameworks + // such as Vue or Angular and are handled by their own compilers, before + // reaching the browser. + if ( + /'(deep|slotted|global|ng-deep|host-context)' is not recognized as a valid pseudo-/.test( + warning.message, + ) + ) { return false } diff --git a/packages/@tailwindcss-postcss/src/postcss-fix-relative-paths/index.test.ts b/packages/@tailwindcss-postcss/src/postcss-fix-relative-paths/index.test.ts index 31dd0edfa..fd90c483e 100644 --- a/packages/@tailwindcss-postcss/src/postcss-fix-relative-paths/index.test.ts +++ b/packages/@tailwindcss-postcss/src/postcss-fix-relative-paths/index.test.ts @@ -56,6 +56,7 @@ describe('fixRelativePathsPlugin', () => { @plugin './plugin-in-sibling.ts'; @plugin '../plugin-in-sibling.ts'; @plugin 'plugin-in-sibling'; + @plugin './plugin-in-root.ts'; @plugin '../plugin-in-root.ts'; @plugin 'plugin-in-root'; diff --git a/packages/@tailwindcss-standalone/package.json b/packages/@tailwindcss-standalone/package.json index d7159163f..24f8c93c7 100644 --- a/packages/@tailwindcss-standalone/package.json +++ b/packages/@tailwindcss-standalone/package.json @@ -35,13 +35,13 @@ }, "__notes": "These binary packages must be included so Bun can build the CLI for all supported platforms. We also rely on Lightning CSS and Parcel being patched so Bun can statically analyze the executables.", "devDependencies": { - "@parcel/watcher-darwin-arm64": "^2.5.6", - "@parcel/watcher-darwin-x64": "^2.5.6", - "@parcel/watcher-linux-arm64-glibc": "^2.5.6", - "@parcel/watcher-linux-arm64-musl": "^2.5.6", - "@parcel/watcher-linux-x64-glibc": "^2.5.6", - "@parcel/watcher-linux-x64-musl": "^2.5.6", - "@parcel/watcher-win32-x64": "^2.5.6", + "@parcel/watcher-darwin-arm64": "catalog:", + "@parcel/watcher-darwin-x64": "catalog:", + "@parcel/watcher-linux-arm64-glibc": "catalog:", + "@parcel/watcher-linux-arm64-musl": "catalog:", + "@parcel/watcher-linux-x64-glibc": "catalog:", + "@parcel/watcher-linux-x64-musl": "catalog:", + "@parcel/watcher-win32-x64": "catalog:", "@types/bun": "^1.3.14", "bun": "^1.3.14", "lightningcss-darwin-arm64": "catalog:", diff --git a/packages/@tailwindcss-turbopack/README.md b/packages/@tailwindcss-turbopack/README.md new file mode 100644 index 000000000..ee54b4d03 --- /dev/null +++ b/packages/@tailwindcss-turbopack/README.md @@ -0,0 +1,99 @@ +

+ + + + + Tailwind CSS + + +

+ +

+ A utility-first CSS framework for rapidly building custom user interfaces. +

+ +

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

+ +--- + +## Documentation + +For full documentation, visit [tailwindcss.com](https://tailwindcss.com). + +## Community + +For help, discussion about best practices, or feature ideas: + +[Discuss Tailwind CSS on GitHub](https://github.com/tailwindlabs/tailwindcss/discussions) + +## Contributing + +If you're interested in contributing to Tailwind CSS, please read our [contributing docs](https://github.com/tailwindlabs/tailwindcss/blob/main/.github/CONTRIBUTING.md) **before submitting a pull request**. + +--- + +## @tailwindcss/turbopack + +A Turbopack loader for Tailwind CSS v4. + +## Installation + +```sh +npm install @tailwindcss/turbopack +``` + +### Usage + +```javascript +// next.config.js +module.exports = { + turbopack: { + rules: { + '*.css': { + loaders: ['@tailwindcss/turbopack'], + as: '*.css', + }, + }, + }, +} +``` + +Then create a CSS file that imports Tailwind: + +```css +/* src/index.css */ +@import 'tailwindcss'; +``` + +### Options + +#### `base` + +The base directory to scan for class candidates. Defaults to the current working directory. + +```javascript +{ + loader: '@tailwindcss/turbopack', + options: { + base: process.cwd(), + }, +} +``` + +#### `optimize` + +Whether to optimize and minify the output CSS. Defaults to `true` in production mode. + +```javascript +{ + loader: '@tailwindcss/turbopack', + options: { + optimize: true, // or { minify: true } + }, +} +``` diff --git a/packages/@tailwindcss-turbopack/package.json b/packages/@tailwindcss-turbopack/package.json new file mode 100644 index 000000000..abdbbe90e --- /dev/null +++ b/packages/@tailwindcss-turbopack/package.json @@ -0,0 +1,43 @@ +{ + "name": "@tailwindcss/turbopack", + "version": "4.3.3", + "description": "A Turbopack loader for Tailwind CSS v4.", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/tailwindlabs/tailwindcss.git", + "directory": "packages/@tailwindcss-turbopack" + }, + "bugs": "https://github.com/tailwindlabs/tailwindcss/issues", + "homepage": "https://tailwindcss.com", + "scripts": { + "build": "tsup-node", + "dev": "pnpm run build -- --watch" + }, + "files": [ + "dist/" + ], + "publishConfig": { + "provenance": true, + "access": "public" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "workspace:*", + "@tailwindcss/oxide": "workspace:*", + "tailwindcss": "workspace:*" + }, + "devDependencies": { + "@types/node": "catalog:", + "webpack": "catalog:" + } +} diff --git a/packages/@tailwindcss-turbopack/src/index.cts b/packages/@tailwindcss-turbopack/src/index.cts new file mode 100644 index 000000000..dbdded748 --- /dev/null +++ b/packages/@tailwindcss-turbopack/src/index.cts @@ -0,0 +1,5 @@ +import tailwindLoader from './index.ts' + +// CommonJS export for webpack loaders - must be the function directly +// @ts-ignore +export = tailwindLoader diff --git a/packages/@tailwindcss-turbopack/src/index.ts b/packages/@tailwindcss-turbopack/src/index.ts new file mode 100644 index 000000000..a70f28871 --- /dev/null +++ b/packages/@tailwindcss-turbopack/src/index.ts @@ -0,0 +1,287 @@ +import QuickLRU from '@alloc/quick-lru' +import { + compile, + env, + Features, + Instrumentation, + normalizePath, + optimize, + Polyfills, +} from '@tailwindcss/node' +import { clearRequireCache } from '@tailwindcss/node/require-cache' +import { Scanner } from '@tailwindcss/oxide' +import fs from 'node:fs' +import path from 'node:path' +import type { LoaderContext } from 'webpack' + +const DEBUG = env.DEBUG + +export interface LoaderOptions { + /** + * The base directory to scan for class candidates. + * + * Defaults to the current working directory. + */ + base?: string + + /** + * Optimize and minify the output CSS. + */ + optimize?: boolean | { minify?: boolean } +} + +interface CacheEntry { + mtimes: Map + compiler: null | Awaited> + scanner: null | Scanner + candidates: Set + fullRebuildPaths: string[] +} + +const cache = new QuickLRU({ maxSize: 50 }) + +function getCacheKey(resourceId: string, opts: LoaderOptions): string { + return `${resourceId}:${opts.base ?? ''}:${JSON.stringify(opts.optimize)}` +} + +function getContextFromCache(resourceId: string, opts: LoaderOptions): CacheEntry { + let key = getCacheKey(resourceId, opts) + if (cache.has(key)) return cache.get(key)! + let entry: CacheEntry = { + mtimes: new Map(), + compiler: null, + scanner: null, + candidates: new Set(), + fullRebuildPaths: [], + } + cache.set(key, entry) + return entry +} + +export default async function tailwindLoader( + this: LoaderContext, + source: string, +): Promise { + let callback = this.async() + let options = this.getOptions() ?? {} + let inputFile = this.resourcePath + let resourceId = this.resource + let base = options.base ?? process.cwd() + let shouldOptimize = options.optimize ?? process.env.NODE_ENV === 'production' + let isCSSModuleFile = inputFile.endsWith('.module.css') + + using I = new Instrumentation() + + DEBUG && I.start(`[@tailwindcss/turbopack] ${path.relative(base, inputFile)}`) + + // Bail out early if this is guaranteed to be a non-Tailwind CSS file. + { + DEBUG && I.start('Quick bail check') + let canBail = !/@(import|reference|theme|variant|config|plugin|apply|tailwind)\b/.test(source) + if (canBail) { + DEBUG && I.end('Quick bail check') + DEBUG && I.end(`[@tailwindcss/turbopack] ${path.relative(base, inputFile)}`) + callback(null, source) + return + } + DEBUG && I.end('Quick bail check') + } + + try { + let context = getContextFromCache(resourceId, options) + let inputBasePath = path.dirname(path.resolve(inputFile)) + + // Whether this is the first build or not + let isInitialBuild = context.compiler === null + + async function createCompiler() { + DEBUG && I.start('Setup compiler') + if (context.fullRebuildPaths.length > 0 && !isInitialBuild) { + clearRequireCache(context.fullRebuildPaths) + } + + context.fullRebuildPaths = [] + + DEBUG && I.start('Create compiler') + let compiler = await compile(source, { + from: inputFile, + base: inputBasePath, + shouldRewriteUrls: true, + onDependency: (depPath) => context.fullRebuildPaths.push(depPath), + // In CSS Module files, we have to disable the `@property` polyfill since these will + // emit global `*` rules which are considered to be non-pure and will cause builds + // to fail. + polyfills: isCSSModuleFile ? Polyfills.All ^ Polyfills.AtProperty : Polyfills.All, + }) + DEBUG && I.end('Create compiler') + + DEBUG && I.end('Setup compiler') + return compiler + } + + // Setup the compiler if it doesn't exist yet + context.compiler ??= await createCompiler() + + // Early exit if no Tailwind features are used + if (context.compiler.features === Features.None) { + DEBUG && I.end(`[@tailwindcss/turbopack] ${path.relative(base, inputFile)}`) + callback(null, source) + return + } + + let rebuildStrategy: 'full' | 'incremental' = 'incremental' + + // Track file modification times to CSS files + DEBUG && I.start('Register full rebuild paths') + { + // Report dependencies for config files, plugins, etc. + for (let file of context.fullRebuildPaths) { + this.addDependency(path.resolve(file)) + } + + let files = [...context.fullRebuildPaths, inputFile] + + for (let file of files) { + let changedTime: number | null = null + try { + changedTime = fs.statSync(file)?.mtimeMs ?? null + } catch { + // File might not exist + } + + if (changedTime === null) { + if (file === inputFile) { + rebuildStrategy = 'full' + } + continue + } + + let prevTime = context.mtimes.get(file) + if (prevTime === changedTime) continue + + rebuildStrategy = 'full' + context.mtimes.set(file, changedTime) + } + } + DEBUG && I.end('Register full rebuild paths') + + if (rebuildStrategy === 'full' && !isInitialBuild) { + context.compiler = await createCompiler() + } + + let compiler = context.compiler + + // Check if we need to process this file at all + if ( + !( + compiler.features & + (Features.AtApply | Features.JsPluginCompat | Features.ThemeFunction | Features.Utilities) + ) + ) { + DEBUG && I.end(`[@tailwindcss/turbopack] ${path.relative(base, inputFile)}`) + callback(null, source) + return + } + + // Setup or update scanner if needed + if (context.scanner === null || rebuildStrategy === 'full') { + DEBUG && I.start('Setup scanner') + let sources = (() => { + // Disable auto source detection + if (compiler.root === 'none') { + return [] + } + + // No root specified, use the base directory + if (compiler.root === null) { + return [{ base, pattern: '**/*', negated: false }] + } + + // Use the specified root + return [{ ...compiler.root, negated: false }] + })().concat(compiler.sources) + + context.scanner = new Scanner({ sources }) + DEBUG && I.end('Setup scanner') + } + + // Scan for candidates if utilities are used + if (compiler.features & Features.Utilities) { + DEBUG && I.start('Scan for candidates') + for (let candidate of context.scanner.scan()) { + context.candidates.add(candidate) + } + DEBUG && I.end('Scan for candidates') + + DEBUG && I.start('Register dependency messages') + // Add all found files as direct dependencies + let resolvedInputFile = path.resolve(base, inputFile) + for (let file of context.scanner.files) { + let absolutePath = path.resolve(file) + // The CSS file cannot be a dependency of itself + if (absolutePath === resolvedInputFile) { + continue + } + this.addDependency(absolutePath) + } + + // Register context dependencies for glob patterns + for (let glob of context.scanner.globs) { + // Skip negated patterns + if (glob.pattern[0] === '!') continue + + // Avoid adding a dependency on the base directory itself + if (glob.pattern === '*' && base === glob.base) { + continue + } + + this.addContextDependency(path.resolve(glob.base)) + } + + // Validate that source(...) paths are directories + let root = compiler.root + if (root !== 'none' && root !== null) { + let basePath = normalizePath(path.resolve(root.base, root.pattern)) + try { + let stats = fs.statSync(basePath) + if (!stats.isDirectory()) { + throw new Error( + `The path given to \`source(…)\` must be a directory but got \`source(${basePath})\` instead.`, + ) + } + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + throw err + } + // Directory doesn't exist yet, which is fine + } + } + DEBUG && I.end('Register dependency messages') + } + + DEBUG && I.start('Build utilities') + let css = compiler.build([...context.candidates]) + DEBUG && I.end('Build utilities') + + // Optionally optimize the output + let result = css + if (shouldOptimize) { + DEBUG && I.start('Optimization') + let optimized = optimize(css, { + minify: typeof shouldOptimize === 'object' ? shouldOptimize.minify : true, + }) + result = optimized.code + DEBUG && I.end('Optimization') + } + + DEBUG && I.end(`[@tailwindcss/turbopack] ${path.relative(base, inputFile)}`) + callback(null, result) + } catch (error) { + // Clear the cache entry on error to force a full rebuild next time + let key = getCacheKey(resourceId, options) + cache.delete(key) + + DEBUG && I.end(`[@tailwindcss/turbopack] ${path.relative(base, inputFile)}`) + callback(error as Error) + } +} diff --git a/packages/@tailwindcss-turbopack/tsconfig.json b/packages/@tailwindcss-turbopack/tsconfig.json new file mode 100644 index 000000000..d2131cbeb --- /dev/null +++ b/packages/@tailwindcss-turbopack/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "verbatimModuleSyntax": false, + }, +} diff --git a/packages/@tailwindcss-turbopack/tsup.config.ts b/packages/@tailwindcss-turbopack/tsup.config.ts new file mode 100644 index 000000000..998eebe4f --- /dev/null +++ b/packages/@tailwindcss-turbopack/tsup.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'tsup' + +export default defineConfig([ + { + format: ['esm'], + clean: true, + minify: true, + cjsInterop: true, + dts: true, + entry: ['src/index.ts'], + }, + { + format: ['cjs'], + minify: true, + cjsInterop: true, + dts: true, + entry: ['src/index.cts'], + }, +]) diff --git a/packages/@tailwindcss-upgrade/package.json b/packages/@tailwindcss-upgrade/package.json index 323fb0ab5..e960d7677 100644 --- a/packages/@tailwindcss-upgrade/package.json +++ b/packages/@tailwindcss-upgrade/package.json @@ -31,7 +31,7 @@ "@tailwindcss/oxide": "workspace:*", "dedent": "catalog:", "enhanced-resolve": "catalog:", - "globby": "^16.2.0", + "globby": "^16.2.2", "jiti": "^2.7.0", "mri": "^1.2.0", "picocolors": "^1.1.1", @@ -39,14 +39,14 @@ "postcss-import": "^16.1.1", "postcss-selector-parser": "^7.1.4", "prettier": "catalog:", - "semver": "^7.8.4", + "semver": "^7.8.5", "tailwindcss": "workspace:*", - "tree-sitter": "^0.22.4", + "tree-sitter": "^0.25.1", "tree-sitter-typescript": "^0.23.2" }, "devDependencies": { "@types/node": "catalog:", "@types/postcss-import": "^14.0.3", - "@types/semver": "^7.7.1" + "@types/semver": "^7.8.0" } } diff --git a/packages/@tailwindcss-upgrade/src/codemods/css/migrate-at-layer-utilities.test.ts b/packages/@tailwindcss-upgrade/src/codemods/css/migrate-at-layer-utilities.test.ts index 8ca43ef31..e115951d3 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/css/migrate-at-layer-utilities.test.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/css/migrate-at-layer-utilities.test.ts @@ -769,6 +769,7 @@ describe('comments', () => { ).toMatchInlineSnapshot(` " /* Start of utilities: */ + @utility foo { /* Utility #1 */ /* Declarations: */ @@ -793,6 +794,7 @@ describe('comments', () => { ).toMatchInlineSnapshot(` " /* Start of utilities: */ + @utility foo { /* Foo & Bar */ & .bar { @@ -800,6 +802,7 @@ describe('comments', () => { color: red; } } + @utility bar { /* Foo & Bar */ .foo & { @@ -829,6 +832,7 @@ describe('comments', () => { ).toMatchInlineSnapshot(` " /* Start of utilities: */ + @utility foo { /* Mobile only */ @media (width <= 640px) { @@ -878,6 +882,7 @@ describe('comments', () => { ).toMatchInlineSnapshot(` " /* Tailwind Utilities: */ + @utility no-scrollbar { /* Chrome, Safari and Opera */ /* Second comment */ diff --git a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-variants.ts b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-variants.ts index 58efb0421..258ba1b33 100644 --- a/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-variants.ts +++ b/packages/@tailwindcss-upgrade/src/codemods/template/migrate-arbitrary-variants.ts @@ -16,8 +16,8 @@ export function migrateArbitraryVariants( rawCandidate: string, ): string { let designSystem = prepareDesignSystemStorage(baseDesignSystem) - let signatures = designSystem.storage[VARIANT_SIGNATURE_KEY] - let variants = designSystem.storage[PRE_COMPUTED_VARIANTS_KEY] + let signatures = designSystem.storage[VARIANT_SIGNATURE_KEY].get(null) + let variants = designSystem.storage[PRE_COMPUTED_VARIANTS_KEY].get(null) for (let readonlyCandidate of designSystem.parseCandidate(rawCandidate)) { // We are only interested in the variants @@ -35,7 +35,11 @@ export function migrateArbitraryVariants( if (typeof targetSignature !== 'string') continue let foundVariants = variants.get(targetSignature) - if (foundVariants.length !== 1) continue + if (foundVariants.length === 0) continue + + // The variant is already in a canonical form, e.g.: `min-lg` and `lg` + // produce the same CSS, but both are valid so we keep them as-is. + if (foundVariants.includes(targetString)) continue let foundVariant = foundVariants[0] let parsedVariant = designSystem.parseVariant(foundVariant) diff --git a/packages/@tailwindcss-vite/src/index.ts b/packages/@tailwindcss-vite/src/index.ts index bd38f5272..8fc660eaa 100644 --- a/packages/@tailwindcss-vite/src/index.ts +++ b/packages/@tailwindcss-vite/src/index.ts @@ -9,23 +9,15 @@ import { } from '@tailwindcss/node' import { clearRequireCache } from '@tailwindcss/node/require-cache' import { Scanner } from '@tailwindcss/oxide' -import { realpathSync } from 'node:fs' import fs from 'node:fs/promises' import path from 'node:path' -import type { - Environment, - InternalResolveOptions, - Plugin, - ResolvedConfig, - ViteDevServer, -} from 'vite' +import type { Environment, InternalResolveOptions, Plugin, ResolvedConfig } from 'vite' import * as vite from 'vite' const DEBUG = env.DEBUG const SPECIAL_QUERY_RE = /[?&](?:worker|sharedworker|raw|url)\b/ const COMMON_JS_PROXY_RE = /\?commonjs-proxy/ const INLINE_STYLE_ID_RE = /[?&]index=\d+\.css$/ -const JS_EXTENSIONS_RE = /^\.[cm]?[jt]sx?$/ export type PluginOptions = { /** @@ -73,17 +65,9 @@ function createCustomResolver( } export default function tailwindcss(opts: PluginOptions = {}): Plugin[] { - let servers: ViteDevServer[] = [] let config: ResolvedConfig | null = null let rootsByEnv = new DefaultMap>((env: string) => new Map()) - // File extensions that Vite (or one of its plugins) has been seen to process - // as a module. Plugins don't get added or removed while the dev server is - // running (changing the Vite config restarts the server), so once we've seen - // evidence for a file type we don't need to scan the module graphs for it - // again. - let viteProcessedExtensions = new Set() - let isSSR = false let shouldOptimize = true let minify = true @@ -196,10 +180,6 @@ export default function tailwindcss(opts: PluginOptions = {}): Plugin[] { name: '@tailwindcss/vite:scan', enforce: 'pre', - configureServer(server) { - servers.push(server) - }, - async configResolved(_config) { config = _config isSSR = config.build.ssr !== false && config.build.ssr !== undefined @@ -256,143 +236,6 @@ export default function tailwindcss(opts: PluginOptions = {}): Plugin[] { return result }, }, - - hotUpdate({ file, modules, timestamp, server }) { - // Ensure full-reloads are triggered for files that are being watched by - // Tailwind but aren't part of the module graph (like PHP or HTML - // files). If we don't do this, then changes to those files won't - // trigger a reload at all since Vite doesn't know about them. - { - // It's a little bit confusing, because due to the `addWatchFile` - // calls, it _is_ part of the module graph but nothing is really - // handling those files. These modules typically have an id of - // undefined and/or have a type of 'asset'. - // - // If we call `addWatchFile` on a file that is part of the actual - // module graph, then we will see a module for it with a type of `js` - // and a type of `asset`. We are only interested if _all_ of them are - // missing an id and/or have a type of 'asset', which is a strong - // signal that the changed file is not being handled by Vite or any of - // the plugins. - // - // Note: in Vite v7.0.6 the modules here will have a type of `js`, not - // 'asset'. But it will also have a `HARD_INVALIDATED` state and will - // do a full page reload already. - // - // Empty modules can be skipped since it means it's not - // `addWatchFile`d and thus irrelevant to Tailwind. - let isExternalFile = - modules.length > 0 && - modules.every((mod) => mod.type === 'asset' || mod.id === undefined) - if (!isExternalFile) return - - // Skip files that Vite (or one of its plugins) processes as a - // module — in this environment (e.g. a lazily-loaded route that - // hasn't been visited yet) or in another one (e.g. an SSR-only - // module). Such a file can only affect the page through Vite's own - // pipeline, so a full reload would only destroy client state. Any - // changes to the generated CSS still go through the regular - // `css-update` flow because the file is registered via - // `addWatchFile`. - // - // If the file exists as a real module in another environment, then - // that environment is responsible for it. E.g. an SSR framework - // has its own server side hmr/reload mechanism when handling - // server only modules. See https://v6.vite.dev/guide/migration.html - // > Updates to an SSR-only module no longer triggers a full page reload in the client. ... - for (let environment of Object.values(server.environments)) { - if (environment.name === this.environment.name) continue - - let modules = environment.moduleGraph.getModulesByFile(file) - if (modules) { - for (let mod of modules) { - if (mod.type !== 'asset') { - return - } - } - } - } - - // Otherwise the file is not loaded as a module anywhere, so - // determine whether its file _type_ would be processed by Vite - // when requested by the browser (in which case the file just isn't - // loaded yet, e.g. a lazily-loaded route that hasn't been visited). - // Vite has no API to answer this without actually running the - // plugin pipeline, so instead: - // - // Files Vite handles natively (the JS/TS and CSS families) are always - // processed by Vite. This includes stylesheets that never show up as - // their own module because a framework plugin compiles them into a - // component (e.g. Angular), in which case that plugin owns their HMR. - let extension = path.extname(file) - if (JS_EXTENSIONS_RE.test(extension) || vite.isCSSRequest(file)) return - - // For any other file type (e.g. `.vue`, `.svelte`, or `.md` with an - // SSG plugin), if a file with the same extension exists as a real - // module in any environment's module graph, then a plugin evidently - // handles this file type and the changed file just isn't loaded - // (yet). - if (extension !== '') { - if (viteProcessedExtensions.has(extension)) return - - for (let environment of Object.values(server.environments)) { - for (let mod of environment.moduleGraph.idToModuleMap.values()) { - if (!mod.file?.endsWith(extension)) continue - if (mod.type === 'asset') continue - - // Only count modules that the plugin pipeline actually - // transformed. Vite also creates untransformed placeholder - // nodes (e.g. for the file underlying a `?raw` import) that - // are not evidence that a plugin handles this file type. - if (mod.transformResult == null) continue - - // Similarly, ignore query imports (e.g. `./template.html?raw`, - // or the `?html-proxy` modules Vite creates for inline - // scripts): they pull a file's _contents_ into the graph - // without a plugin processing the file type. A scanned - // `.html` template must still trigger a full reload even if - // some other `.html` file is imported with `?raw`. - if (!mod.id || mod.id.includes('?')) continue - - viteProcessedExtensions.add(extension) - return - } - } - } - - for (let env of new Set([this.environment.name, 'client'])) { - let roots = rootsByEnv.get(env) - if (roots.size === 0) continue - - // If the file is not being watched by any of the roots, then we can - // skip the reload since it's not relevant to Tailwind CSS. - if (!isScannedFile(file, modules, roots)) { - continue - } - - // https://vite.dev/changes/hotupdate-hook#migration-guide - let invalidatedModules = new Set() - for (let mod of modules) { - this.environment.moduleGraph.invalidateModule( - mod, - invalidatedModules, - timestamp, - true, - ) - } - - if (env === this.environment.name) { - this.environment.hot.send({ type: 'full-reload' }) - } else if (server.hot.send) { - server.hot.send({ type: 'full-reload' }) - } else if (server.ws.send) { - server.ws.send({ type: 'full-reload' }) - } - - return [] - } - } - }, }, { @@ -513,10 +356,6 @@ class Root { private customJsResolver: (id: string, base: string) => Promise, ) {} - get scannedFiles() { - return this.scanner?.files ?? [] - } - // Generate the CSS for the root file. This can return false if the file is // not considered a Tailwind root. When this happened, the root can be GCed. public async generate( @@ -702,54 +541,3 @@ class Root { return false } } - -function isScannedFile( - file: string, - modules: vite.EnvironmentModuleNode[], - roots: Map, -) { - let seen = new Set() - let q = [...modules] - let checks = { - file, - get realpath() { - try { - let realpath = realpathSync(file) - Object.defineProperty(checks, 'realpath', { value: realpath }) - return realpath - } catch { - return null - } - }, - } - - while (q.length > 0) { - let module = q.shift()! - if (seen.has(module)) continue - seen.add(module) - - if (module.id) { - let root = roots.get(module.id) - - if (root) { - // If the file is part of the scanned files for this root, then we know - // for sure that it's being watched by any of the Tailwind CSS roots. It - // doesn't matter which root it is since it's only used to know whether - // we should trigger a full reload or not. - if ( - root.scannedFiles.includes(checks.file) || - (checks.realpath && root.scannedFiles.includes(checks.realpath)) - ) { - return true - } - } - } - - // Keep walking up the tree until we find a root. - for (let importer of module.importers) { - q.push(importer) - } - } - - return false -} diff --git a/packages/tailwindcss/package.json b/packages/tailwindcss/package.json index 6c5b5fe6a..d1288d6fa 100644 --- a/packages/tailwindcss/package.json +++ b/packages/tailwindcss/package.json @@ -132,7 +132,7 @@ "@types/node": "catalog:", "dedent": "catalog:", "lightningcss": "catalog:", - "magic-string": "^0.30.21", + "magic-string": "^1.1.0", "source-map-js": "^1.2.1" } } diff --git a/packages/tailwindcss/src/ast.test.ts b/packages/tailwindcss/src/ast.test.ts index f3499b350..14092c9f5 100644 --- a/packages/tailwindcss/src/ast.test.ts +++ b/packages/tailwindcss/src/ast.test.ts @@ -14,7 +14,7 @@ import * as CSS from './css-parser' import { buildDesignSystem } from './design-system' import { pretty } from './test-utils/run' import { Theme } from './theme' -import { walk } from './walk' +import { walk, WalkAction } from './walk' const css = String.raw const defaultDesignSystem = buildDesignSystem(new Theme()) @@ -308,7 +308,25 @@ it('should not emit color-mix() fallbacks inside @keyframes', () => { describe('optimization', () => { function optimize(input: string) { - return pretty(toCss(handleNesting(CSS.parse(input)))) + let ast = CSS.parse(input) + walk(ast, { + exit(node) { + // Depending on where we use `@scope`, it's handled slightly differently: + // + // 1. In user authored CSS, we perform the normal CSS nesting and + // hoisting of `@scope` rules + // 2. In a `@custom-variant` definition, we need to swap the `@scope` + // with the parent definition. Otherwise you'll never get the + // expected "sandwich" effect. + // + // This little `@in-variant` hack is just a different way of + // differentiating between both cases for these tests. + if (node.kind === 'at-rule' && node.name === '@in-variant') { + return WalkAction.Replace(context({ source: 'variant' }, node.nodes)) + } + }, + }) + return pretty(toCss(handleNesting(ast))) } // See: https://drafts.csswg.org/css-nesting-1/ @@ -1093,6 +1111,411 @@ describe('optimization', () => { `) }) + it('should hoist `@scope` at-rules', async () => { + expect( + optimize(css` + @layer utilities { + .parent { + @in-variant { + @scope (.from) to (.to) { + --x: 1; + } + } + } + } + `), + ).toMatchInlineSnapshot(` + " + @layer utilities { + @scope (.from) to (.to) { + .parent { + --x: 1; + } + } + } + " + `) + }) + + // We have an optimization where the moment we see a declaration that we + // stop handling the nesting and emit everything as-is. This won't work when + // we're dealing with `@scope` and we still want to hoist that out. + it('should hoist `@scope` at-rules, even when sibling declarations are present', async () => { + expect( + optimize(css` + @layer utilities { + .parent { + --x: 1; + @in-variant { + @scope (.from) { + .inside { + --y: 2; + } + } + } + } + } + `), + ).toMatchInlineSnapshot(` + " + @layer utilities { + .parent { + --x: 1; + } + @scope (.from) { + .parent .inside { + --y: 2; + } + } + } + " + `) + }) + + it('should resolve `&` in the `@scope` prelude when hoisting', async () => { + // `&` in the `` selector refers to the elements matched by + // the nearest ancestor style rule, while `&` in the `` + // selector refers to the scoping root. + // + // See: https://drafts.csswg.org/css-nesting-1/#nesting-at-scope + expect( + optimize(css` + .parent { + @scope (& > .scope) to (& .limit) { + .content { + --x: 1; + } + } + } + `), + ).toMatchInlineSnapshot(` + " + @scope (.parent > .scope) to (:where(:scope) .limit) { + .content { + --x: 1; + } + } + " + `) + + // Selector lists need `:is(…)` to keep their meaning + expect( + optimize(css` + .parent-a, + .parent-b { + @scope (& > .from) { + .inside { + --x: 1; + } + } + } + `), + ).toMatchInlineSnapshot(` + " + @scope (:is(.parent-a, .parent-b) > .from) { + .inside { + --x: 1; + } + } + " + `) + + // The `` selector is optional, so the first group in the + // prelude is not necessarily the `` selector. + expect( + optimize(css` + .parent { + @scope to (& .to) { + .inside { + --x: 1; + } + } + } + `), + ).toMatchInlineSnapshot(` + " + @scope to (:where(:scope) .to) { + .parent .inside { + --x: 1; + } + } + " + `) + + // The parent selector is substituted with the same logic as the nesting + // selector in regular style rules: `:is(…)` is used when substituting the + // parent as-is would change the meaning (e.g. a type selector cannot + // appear after a class selector in a compound selector), … + expect( + optimize(css` + main { + @scope (.card&) { + .inside { + --x: 1; + } + } + } + `), + ).toMatchInlineSnapshot(` + " + @scope (.card:is(main)) { + .inside { + --x: 1; + } + } + " + `) + + // … and dropped when the substitution is provably equivalent, even for + // complex parent selectors + expect( + optimize(css` + .a .b { + @scope (& > .from) { + .inside { + --x: 1; + } + } + } + `), + ).toMatchInlineSnapshot(` + " + @scope (.a .b > .from) { + .inside { + --x: 1; + } + } + " + `) + }) + + it('should wrap bare declarations inside `@scope` in a `&` rule', async () => { + // A bug in Lightning CSS causes an error when there are declarations + // directly in the `@scope`. To fix this we will ensure a rule with + // `:where(:scope)` is available. + expect( + optimize(css` + @scope (.from) to (.to) { + --x: 1; + } + `), + ).toMatchInlineSnapshot(` + " + @scope (.from) to (.to) { + :where(:scope) { + --x: 1; + } + } + " + `) + + // When generated by a variant, the declarations resolve against the + // parent rule: the `@scope` rule scopes _where the utility applies_ + // (like nesting `@media` does, and like variants do). + // + // Note: this deviates from native CSS nesting, where the `` + // selector would be relative to `.parent` and the declarations would + // apply to the scoping root. User-authored `@scope` rules (without the + // `@in-variant` marker) keep those native semantics. + expect( + optimize(css` + .parent { + @in-variant { + @scope (.from) { + --x: 1; + } + } + } + `), + ).toMatchInlineSnapshot(` + " + @scope (.from) { + .parent { + --x: 1; + } + } + " + `) + }) + + it('should replace `&` with `:where(:scope)` for rules directly inside `@scope`', async () => { + // Inside an `@scope` rule, the nesting selector behaves like + // `:where(:scope)` and matches the scoping root with zero specificity. + expect( + optimize(css` + @scope (.from) { + & > p { + --x: 1; + } + } + `), + ).toMatchInlineSnapshot(` + " + @scope (.from) { + :where(:scope) > p { + --x: 1; + } + } + " + `) + }) + + it('should handle `&` deeply nested in `@scope` as normal references to a parent', async () => { + expect( + optimize(css` + @scope (.from) { + &, + .inside { + --x: 1; + + &:hover { + --x: 2; + } + + &:focus { + --x: 3; + } + } + } + `), + ).toMatchInlineSnapshot(` + " + @scope (.from) { + :where(:scope), .inside { + --x: 1; + &:hover { + --x: 2; + } + &:focus { + --x: 3; + } + } + } + " + `) + }) + + it('should compile user-authored `@scope` rules with their native CSS nesting semantics', async () => { + // The `` selector is relative to the parent rule (like + // nested style rule selectors are), the rules inside are relative to + // the scoping root, and `&` refers to the scoping root + expect( + optimize(css` + .parent { + @scope (.from) to (.to) { + .inside { + --x: 1; + } + + & > .child { + --x: 2; + } + } + } + `), + ).toMatchInlineSnapshot(` + " + @scope (.parent .from) to (.to) { + .inside { + --x: 1; + } + :where(:scope) > .child { + --x: 2; + } + } + " + `) + + // A trailing `&` makes the parent rule the scoping root: the scoping + // roots are `.parent` elements inside `.from` elements + expect( + optimize(css` + .parent { + @scope (.from &) to (.to) { + .inside { + --x: 1; + } + } + } + `), + ).toMatchInlineSnapshot(` + " + @scope (.from .parent) to (.to) { + .inside { + --x: 1; + } + } + " + `) + + // Bare declarations apply to the scoping root. They are wrapped in a + // `:where(:scope)` rule, which is the spec's exact equivalent, because + // Lightning CSS drops bare declarations inside `@scope` entirely. + expect( + optimize(css` + .parent { + @scope (.from) { + --x: 1; + } + } + `), + ).toMatchInlineSnapshot(` + " + @scope (.parent .from) { + :where(:scope) { + --x: 1; + } + } + " + `) + + // Also at the top level + expect( + optimize(css` + @scope (.from) to (.to) { + --x: 1; + + .inside { + --x: 2; + } + } + `), + ).toMatchInlineSnapshot(` + " + @scope (.from) to (.to) { + :where(:scope) { + --x: 1; + } + .inside { + --x: 2; + } + } + " + `) + + // Every selector in a `` selector list is relative to the + // parent rule, whether it uses `&` or not + expect( + optimize(css` + .parent { + @scope (.a, & > .b) { + .inside { + --x: 1; + } + } + } + `), + ).toMatchInlineSnapshot(` + " + @scope (.parent .a, .parent > .b) { + .inside { + --x: 1; + } + } + " + `) + }) + it('should leave `@property` and `@apply` alone', async () => { expect( optimize(css` diff --git a/packages/tailwindcss/src/ast.ts b/packages/tailwindcss/src/ast.ts index e22c13e83..09c3ea051 100644 --- a/packages/tailwindcss/src/ast.ts +++ b/packages/tailwindcss/src/ast.ts @@ -401,6 +401,21 @@ export function optimizeAst( // Remove reference imports from printing if (node.context.reference) { return + } + + // Preserve context nodes that track the source of their subtree (variant + // bodies and the user content slotted into them), so `handleNesting` + // knows whether to compose `@scope` rules with the variant semantics or + // their native CSS nesting semantics + else if (node.context.source) { + let copy = { ...node, nodes: [] as AstNode[] } + for (let child of node.nodes) { + transform(child, copy.nodes, { ...context, ...node.context }, depth) + } + + if (copy.nodes.length > 0) { + parent.push(copy) + } } else { for (let child of node.nodes) { transform(child, parent, { ...context, ...node.context }, depth) @@ -658,6 +673,27 @@ export function optimizeAst( export function handleNesting(ast: AstNode[]): AstNode[] { let parseSelectorCache = new DefaultMap(SelectorParser.parse) + // Nodes with an `@scope` rule somewhere in their subtree. These cannot use + // the "emit as-is" fast path for rules containing declarations, because + // `@scope` rules change meaning when they stay nested inside a style rule and + // must go through the hoisting machinery instead. + let containsAtScope = new Set() + { + function scan(nodes: AstNode[]): boolean { + let found = false + for (let node of nodes) { + if (node.kind === 'declaration' || node.kind === 'comment') continue + + if (scan(node.nodes) || (node.kind === 'at-rule' && node.name === '@scope')) { + containsAtScope.add(node) + found = true + } + } + return found + } + scan(ast) + } + // Track `rule` selectors as we go let selectorStack: [ selector: string, @@ -690,6 +726,45 @@ export function handleNesting(ast: AstNode[]): AstNode[] { // Track whether we should skip a node in the `exit` phase let skipExit = new Set() + // Track the source of the subtree we are currently in. Variant bodies are + // wrapped in a `context` node with `source: 'variant'`, and the user content + // slotted into them is wrapped in `source: 'user'`. `@scope` rules whose + // nearest source is a variant are hoisted with the variant semantics, all + // other `@scope` rules use their native CSS nesting semantics. + let sourceStack: (string | boolean)[] = [] + + // Selector stacks that are masked while inside an `@scope` rule whose + // `` selector consumed the parent selector via `&`. + // + // Once the parent selector is part of the `` selector, the rules + // inside the `@scope` are relative to the scoping root, not to the parent + // rule. Selectors in scoped style rules only match elements in scope, and the + // parent matches ancestors of the scoping root, so prefixing the rules with + // the parent selector would make them match nothing at all. + // + // E.g.: + // + // ```css + // .parent { + // @scope (& > .scope) { + // .content { + // color: red; + // } + // } + // } + // ``` + // + // becomes: + // + // ```css + // @scope (.parent > .scope) { + // .content { /* ← not `.parent .content` */ + // color: red; + // } + // } + // ``` + let maskedSelectorStacks = new Map() + walk(ast, { enter(node) { switch (node.kind) { @@ -705,13 +780,40 @@ export function handleNesting(ast: AstNode[]): AstNode[] { // part of a string inside an attribute selector. But the // SelectorParser will take care of that. if (node.selector.includes('&')) { + // Inside an `@scope` rule, the nesting selector behaves like + // `:where(:scope)` and matches the scoping root with zero + // specificity. E.g.: + // + // ```css + // @scope (.checkout) { + // & > p { + // color: red; + // } + // } + // ``` + // + // becomes: + // + // ```css + // @scope (.checkout) { + // :where(:scope) > p { + // color: red; + // } + // } + // ``` + // + // See: https://drafts.csswg.org/css-nesting-1/#nesting-at-scope + let replacement = atRuleStack.some(([name]) => name === '@scope') + ? ':where(:scope)' + : ':scope' + let ast = SelectorParser.parse(node.selector) let changed = false walk(ast, (node) => { if (node.kind === 'selector' && node.value === '&') { changed = true - node.value = ':scope' + node.value = replacement } }) @@ -740,280 +842,7 @@ export function handleNesting(ast: AstNode[]): AstNode[] { let lastSelector = selectorStack[selectorStack.length - 1][0] let selector = segment(node.selector, ',') - .map((selector) => { - // Fast path: we know there isn't an `&` so we can prepend the - // parent selector immediately. - if (!selector.includes('&')) { - let lastAst = parseSelectorCache.get(lastSelector) - return `${lastAst.length === 1 && lastAst[0].kind === 'list' ? `:is(${lastSelector})` : lastSelector} ${selector}` - } - - // Slow path: we need to replace the `&` with the parent - // selector. A simple `replaceAll(…)` won't work because a `&` - // could be escaped, or could be part of an attribute selector. - // - // Much safer to parse the selector and replace the `&` that way - { - let ast = SelectorParser.parse(selector) - let changed = false - walk(ast, { - enter(node, ctx) { - if (node.kind !== 'selector' || node.value !== '&') return - - changed = true - - // Safest option: use `is(…)` semantics when - // substituting `&` for the parent selector. - node.value = `:is(${lastSelector})` - - // We should always have a parent, so this shouldn't happen - if (ctx.parent === null) return - - // Optimizations: - let parentAst = parseSelectorCache.get(lastSelector) - - // 1. If we're dealing with multiple selectors, then we - // know that the `:is(…)` needs to stay. Nothing to optimize. - if (parentAst.length === 1 && parentAst[0].kind === 'list') { - return // Keep `:is(…)` semantics - } - - // 2. We know that `&` is standalone when it's inside of - // a complex selector. E.g. `[before] & [after]` - if (ctx.parent.kind === 'complex') { - // `& [after]` - // - // `:is(…)` semantics are not required, because these - // are equivalent: - // - // - `:is(div) [after]` → `div [after]` - // - `:is(.x) [after]` → `.x [after]` - // - `:is([before]) [after]` → `[before] [after]` - // - `:is(.a > .b) [after]` → `.a > .b [after]` - if (ctx.index === 0) { - node.value = lastSelector - return - } - - // `[before] &` - // - // `:is(…)` semantics are required if we're dealing - // with a complex parent selector. Otherwise the - // meaning of the selector could change: - // - // - `[before] :is(div)` → `[before] div` - // - `[before] :is(.x)` → `[before] .x` - // - `[before] :is([after])` → `[before] [after]` - // - `[before] :is(.a > .b)` → `[before] :is(.a > .b)` (!) - else if (ctx.index === ctx.siblings.length - 1) { - if (parentAst[0].kind === 'complex') { - return // Keep `:is(…)` semantics - } - - node.value = lastSelector - return - } - - // `[before] & [after]` - // - // `:is(…)` semantics are required if we're dealing - // with a complex parent selector. Otherwise the - // meaning of the selector could change: - // - // - `[before] :is(div) [after]` → `[before] div [after]` - // - `[before] :is(.x) [after]` → `[before] .x [after]` - // - `[before] :is(.a > .b) [after]` → `[before] :is(.a > .b) [after]` (!) - else { - if (parentAst[0].kind === 'complex') { - return // Keep `:is(…)` semantics - } - - node.value = lastSelector - return - } - } - - // 3. We know that `&` is attached to some other - // selector when it's inside of a compound selector. - // - // E.g. `[before]&[after]` - // - // We have to be careful that our parent, when it's - // part of a complex selector, that the same rules apply - // - // E.g.: `[before] &[after]` - // ^ current node - // ^^^^^^^^ compound selector - // ^^^^^^^^^^^^^^^^^ complex selector - // - else if (ctx.parent.kind === 'compound') { - if (parentAst[0].kind === 'complex') { - let path = ctx.path() - let grandParent = path[path.length - 2] - - // When our compound parent is part of a complex - // selector, and it's not the very first node, then we - // can't safely get rid of the `:is(…)` if the last - // selector is a complex selector as well, unless the - // `&` maps to a single selector or compound selector. - // - // ```css - // .foo .bar { /* Complex selector */ - // .system &:focus { /* Complex selector + compound selecto*/ - // --x: 1; - // } - // } - // .foo:hover { /* Compound selector */ - // .system &:focus { /* Complex selector + compound selector */ - // --x: 2; - // } - // } - // ``` - // - // ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓ - // - // ```css - // .system :is(.foo .bar):focus { /* Cannot drop the `:is(…)`, otherwise `.system` and `.foo` can be swapped in the DOM */ - // --x: 1; - // } - // .system .foo:hover:focus { - // --x: 2; - // } - // ``` - if ( - grandParent && - grandParent.kind === 'complex' && - grandParent.nodes[0] !== ctx.parent - ) { - return // Keep `:is(…)` semantics - } - } - - // `&*` and `&div` are invalid CSS so these should stay - // invalid. They should be written as `*&` and `div&` instead. - if ( - ctx.siblings - .slice(ctx.index + 1) - .some( - (sibling) => - SelectorParser.isUniversalSelector(sibling) || - SelectorParser.isTypeSelector(sibling), - ) - ) { - return // Keep `:is(…)` semantics - } - - // `&[after]` - // - // `:is(…)` semantics are not required, because these - // are equivalent: - // - // - `:is(div)[after]` → `div[after]` - // - `:is(.x)[after]` → `.x[after]` - // - `:is([before])[after]` → `[before][after]` - // - `:is(.a > .b)[after]` → `.a > .b[after]` - if (ctx.index === 0) { - node.value = lastSelector - return - } - - // `[before]&` - // - // `:is(…)` semantics are required if we're dealing with a - // - // - complex parent selector, to prevent changing the meaning of the selector - // - a universal selector, because `*` needs to be first - // - a type selector, because `div` needs to be first - // - // - `[before]:is(div)` → `[before]:is(div)` (!) - // - `[before]:is(*)` → `[before]:is(*)` (!) - // - `[before]:is(.a > .b)` → `[before]:is(.a > .b)` (!) - // - `[before]:is(.x)` → `[before].x` - else if (ctx.index === ctx.siblings.length - 1) { - if ( - parentAst[0].kind === 'complex' || - SelectorParser.isUniversalSelector(parentAst[0]) || - SelectorParser.isTypeSelector(parentAst[0]) - ) { - return // Keep `:is(…)` semantics - } - - node.value = lastSelector - return - } - - // `[before]&[after]` - // - // `:is(…)` semantics are required if we're dealing with a - // - // - complex parent selector, to prevent changing the meaning of the selector - // - a universal selector, because `*` needs to be first - // - a type selector, because `div` needs to be first - // - // - `[before]:is(div)[after]` → `[before]:is(div)[after]` (!) - // - `[before]:is(*)[after]` → `[before]:is(*)[after]` (!) - // - `[before]:is(.a > .b)[after]` → `[before]:is(.a > .b)[after]` (!) - // - `[before]:is(.x)[after]` → `[before].x[after]` - else { - if ( - parentAst[0].kind === 'complex' || - SelectorParser.isUniversalSelector(parentAst[0]) || - SelectorParser.isTypeSelector(parentAst[0]) - ) { - return // Keep `:is(…)` semantics - } - - node.value = lastSelector - return - } - } - - // 4. When the current node is a function argument (e.g. - // `:not(&))`, then we can drop the `:is(…)` entirely. - // - // The only exception is when the parent has multiple - // selectors because then we would introduce multiple - // arguments. Multiple selectors are already handled. - else if (ctx.parent.kind === 'function') { - node.value = lastSelector - return - } - }, - exit(node, ctx) { - // Optimization: We can remove the universal selector `*` if - // they are part of a compound selector. E.g.: - // - // - `*:hover` → `:hover` - // - `*[attribute]` → `[attribute]` - // - // Except when the `*` is a namespace, e.g.: `*|div`, - // because `*|div` (any namespace) and `|div` (no - // namespace) have different meanings. - if ( - ctx.index === 0 && - ctx.siblings.length > 1 && - ctx.parent?.kind === 'compound' && - SelectorParser.isUniversalSelector(node) - ) { - let next = ctx.siblings[1] - if (next.kind === 'selector' && next.value.charCodeAt(0) === PIPE) { - return - } - return WalkAction.ReplaceSkip([]) - } - }, - }) - - if (changed) { - return SelectorParser.toCss(ast) - } - - // It could be that `&` was not found as an actual selector, - // in that case we still have to prepend the parent selector. - let lastAst = parseSelectorCache.get(lastSelector) - return `${lastAst.length === 1 && lastAst[0].kind === 'list' ? `:is(${lastSelector})` : lastSelector} ${selector}` - } - }) + .map((selector) => substituteNestingSelector(selector, lastSelector)) .join(', ') selectorStack.push([selector, node.src, node.dst]) } @@ -1048,7 +877,14 @@ export function handleNesting(ast: AstNode[]): AstNode[] { // } // } // ``` - if (node.nodes.some((child) => child.kind === 'declaration')) { + // + // The exception is when the rule contains an `@scope` rule somewhere, + // because it must go through the normal hoisting machinery instead. + // See `containsAtScope` for more details. + if ( + node.nodes.some((child) => child.kind === 'declaration') && + !containsAtScope.has(node) + ) { // Emitting each child instead of the node itself because we do want // to flatten the current node and its selector that is already // pushed to the stack. @@ -1072,7 +908,196 @@ export function handleNesting(ast: AstNode[]): AstNode[] { // Hoist at-rules else if (HOISTABLE_AT_RULES.has(node.name)) { - atRuleStack.push([node.name, node.params, node.src, node.dst]) + let params = node.params + + // `@scope` is the only hoistable at-rule with selectors in its + // prelude, and the only one that changes meaning when it stays + // nested inside a style rule. Resolve the prelude and its contents + // before hoisting, using one of two composition modes: + // + // 1. Variant-generated `@scope` rules attach the parent selector + // (the utility) to the rules _inside_ the scope: + // + // ```css + // .scoped\:underline { + // @scope (.from) to (.to) { + // text-decoration-line: underline; + // } + // } + // ``` + // + // becomes: + // + // ```css + // @scope (.from) to (.to) { + // .scoped\:underline { + // text-decoration-line: underline; + // } + // } + // ``` + // This way the utility is properly sandwiched between the + // `.from` and `.to` boundaries. + // + // 2. User-authored `@scope` rules keep their native CSS nesting + // semantics: the `` selector is relative to the parent + // rule, and the rules inside are relative to the scoping root: + // + // ```css + // .parent { + // @scope (.from) { + // color: red; + // } + // } + // ``` + // + // becomes: + // + // ```css + // @scope (.parent .from) { + // :where(:scope) { + // color: red; + // } + // } + // ``` + // + // In a perfect world we leave this alone, but Lightning CSS + // seems to incorrectly transform this into a version where the + // `.parent` class is gone entirely: + // + // ```css + // @scope (.from) { + // color: red; + // } + // ``` + // + // See: https://drafts.csswg.org/css-nesting-1/#nesting-at-scope + if (node.name === '@scope') { + let isVariant = sourceStack[sourceStack.length - 1] === 'variant' + let hasParent = selectorStack.length > 0 + + // Lightning CSS bug: This snippet will error + // + // ```css + // @scope { + // --x: 1; + // } + // ``` + // + // We will wrap these bare-declarations in a `&` rule to fix the crash: + // ```css + // @scope { + // & { + // --x: 1; + // } + // } + // ``` + { + let queue: (AtRule | Context)[] = [node] + + for (let parent of queue) { + let newNodes: AstNode[] = [] + let group: AstNode[] | null = null + + for (let child of parent.nodes) { + if (child.kind === 'context') queue.push(child) + + if (child.kind === 'declaration' || child.kind === 'comment') { + if (group === null) { + group = [] + newNodes.push(styleRule('&', group)) + } + group.push(child) + } else { + group = null + newNodes.push(child) + } + } + + parent.nodes = newNodes + } + } + + if (params.includes('&') || (!isVariant && hasParent)) { + // The selector to substitute for `&` in the `` + // selector: the elements matched by the nearest ancestor style + // rule, or `:scope` when there is none. + let parentSelector = hasParent ? selectorStack[selectorStack.length - 1][0] : null + + let paramsAst = ValueParser.parse(params) + + // Whether the current `(…)` group is a `` selector. + // + // ```css + // @scope (.from) to (.to) + // ^^^^^^^ scope-start (optional) + // ^^^^^ scope-end + // + // @scope to (.to) + // ^^^^^ scope-end + // ``` + let isScopeEnd = false + + // Whether the `` selector consumed the parent + // selector + let consumedParent = false + + for (let paramNode of paramsAst) { + if (paramNode.kind === 'word' && paramNode.value === 'to') { + isScopeEnd = true + continue + } + + // A `(…)` group parses as a function call without a name + if (paramNode.kind !== 'function' || paramNode.value !== '') continue + + let selector = ValueParser.toCss(paramNode.nodes) + + // A user-authored `` selector is relative to the + // parent rule, and resolves exactly like nested style rule + // selectors do: `&` refers to the elements matched by the + // parent rule, and selectors without `&` are prefixed with + // it. + if (!isScopeEnd && !isVariant && parentSelector !== null) { + paramNode.nodes = [ + ValueParser.word( + segment(selector, ',') + .map((part) => substituteNestingSelector(part.trim(), parentSelector)) + .join(', '), + ), + ] + consumedParent = true + } + + // Otherwise, only an explicit `&` is resolved. In the + // `` selector, `&` refers to the elements + // matched by the nearest ancestor style rule (or `:scope` + // when there is none). In the `` selector, `&` + // refers to the scoping root and behaves like + // `:where(:scope)`. + else if (selector.includes('&')) { + let replacement = isScopeEnd ? ':where(:scope)' : (parentSelector ?? ':scope') + + paramNode.nodes = [ + ValueParser.word(substituteNestingSelector(selector, replacement)), + ] + if (!isScopeEnd && hasParent) consumedParent = true + } + } + + params = ValueParser.toCss(paramsAst) + + // Mask the selector stack for the `@scope` rule's subtree, so + // its rules resolve like top-level rules within the scope (and + // `&` resolves to `:where(:scope)`). The stack is restored in + // the exit phase. See `maskedSelectorStacks` for more details. + if (consumedParent) { + maskedSelectorStacks.set(node, selectorStack) + selectorStack = [] + } + } + } + + atRuleStack.push([node.name, params, node.src, node.dst]) } // If we can't hoist them, emit them immediately as-is @@ -1091,6 +1116,9 @@ export function handleNesting(ast: AstNode[]): AstNode[] { } case 'context': + if (node.context.source) sourceStack.push(node.context.source) + break + case 'at-root': break @@ -1112,12 +1140,22 @@ export function handleNesting(ast: AstNode[]): AstNode[] { case 'at-rule': { nodes = null atRuleStack.pop() + + // Restore the selector stack that was masked by this `@scope` rule + let maskedSelectorStack = maskedSelectorStacks.get(node) + if (maskedSelectorStack) { + selectorStack = maskedSelectorStack + maskedSelectorStacks.delete(node) + } break } + case 'context': + if (node.context.source) sourceStack.pop() + break + case 'declaration': case 'comment': - case 'context': case 'at-root': break @@ -1301,6 +1339,274 @@ export function handleNesting(ast: AstNode[]): AstNode[] { } } } + + // Substitutes the `&` selector with the parent selector with `:is(…)` semantics. + // + // If the `&` is not found, we will prepend it using a descendant combinator. + // E.g.: `& ${selector}` → `${parentSelector} ${selector}` + // + // In some situations we can get rid of the `:is(…)` wrapper, but only if it's safe to do so. + function substituteNestingSelector(selector: string, parentSelector: string): string { + // Slow path: we need to replace the `&` with the parent selector. A simple + // `replaceAll(…)` won't work because a `&` could be escaped, or could be + // part of an attribute selector. + // + // Much safer to parse the selector and replace the `&` that way + if (selector.includes('&')) { + let ast = SelectorParser.parse(selector) + let changed = false + walk(ast, { + enter(node, ctx) { + if (node.kind !== 'selector' || node.value !== '&') return + + changed = true + + // Safest option: use `is(…)` semantics when substituting `&` for the + // parent selector. + node.value = `:is(${parentSelector})` + + // We should always have a parent, so this shouldn't happen + if (ctx.parent === null) return + + // Optimizations: + let parentAst = parseSelectorCache.get(parentSelector) + + // 1. If we're dealing with multiple selectors, then we know that the + // `:is(…)` needs to stay. Nothing to optimize. + if (parentAst.length === 1 && parentAst[0].kind === 'list') { + return // Keep `:is(…)` semantics + } + + // 2. We know that `&` is standalone when it's inside of a complex + // selector. E.g. `[before] & [after]` + if (ctx.parent.kind === 'complex') { + // `& [after]` + // + // `:is(…)` semantics are not required, because these are equivalent: + // + // - `:is(div) [after]` → `div [after]` + // - `:is(.x) [after]` → `.x [after]` + // - `:is([before]) [after]` → `[before] [after]` + // - `:is(.a > .b) [after]` → `.a > .b [after]` + if (ctx.index === 0) { + node.value = parentSelector + return + } + + // `[before] &` + // + // `:is(…)` semantics are required if we're dealing with a complex + // parent selector. Otherwise the meaning of the selector could + // change: + // + // - `[before] :is(div)` → `[before] div` + // - `[before] :is(.x)` → `[before] .x` + // - `[before] :is([after])` → `[before] [after]` + // - `[before] :is(.a > .b)` → `[before] :is(.a > .b)` (!) + else if (ctx.index === ctx.siblings.length - 1) { + if (parentAst[0].kind === 'complex') { + return // Keep `:is(…)` semantics + } + + node.value = parentSelector + return + } + + // `[before] & [after]` + // + // `:is(…)` semantics are required if we're dealing with a complex + // parent selector. Otherwise the meaning of the selector could + // change: + // + // - `[before] :is(div) [after]` → `[before] div [after]` + // - `[before] :is(.x) [after]` → `[before] .x [after]` + // - `[before] :is(.a > .b) [after]` → `[before] :is(.a > .b) [after]` (!) + else { + if (parentAst[0].kind === 'complex') { + return // Keep `:is(…)` semantics + } + + node.value = parentSelector + return + } + } + + // 3. We know that `&` is attached to some other selector when it's + // inside of a compound selector. + // + // E.g. `[before]&[after]` + // + // We have to be careful that our parent, when it's part of a complex + // selector, that the same rules apply + // + // E.g.: `[before] &[after]` + // ^ current node + // ^^^^^^^^ compound selector + // ^^^^^^^^^^^^^^^^^ complex selector + // + else if (ctx.parent.kind === 'compound') { + if (parentAst[0].kind === 'complex') { + let path = ctx.path() + let grandParent = path[path.length - 2] + + // When our compound parent is part of a complex selector, and it's + // not the very first node, then we can't safely get rid of the + // `:is(…)` if the last selector is a complex selector as well, + // unless the `&` maps to a single selector or compound selector. + // + // ```css + // .foo .bar { /* Complex selector */ + // .system &:focus { /* Complex selector + compound selecto*/ + // --x: 1; + // } + // } + // .foo:hover { /* Compound selector */ + // .system &:focus { /* Complex selector + compound selector */ + // --x: 2; + // } + // } + // ``` + // + // ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓ + // + // ```css + // .system :is(.foo .bar):focus { /* Cannot drop the `:is(…)`, otherwise `.system` and `.foo` can be swapped in the DOM */ + // --x: 1; + // } + // .system .foo:hover:focus { + // --x: 2; + // } + // ``` + if ( + grandParent && + grandParent.kind === 'complex' && + grandParent.nodes[0] !== ctx.parent + ) { + return // Keep `:is(…)` semantics + } + } + + // `&*` and `&div` are invalid CSS so these should stay invalid. They + // should be written as `*&` and `div&` instead. + if ( + ctx.siblings + .slice(ctx.index + 1) + .some( + (sibling) => + SelectorParser.isUniversalSelector(sibling) || + SelectorParser.isTypeSelector(sibling), + ) + ) { + return // Keep `:is(…)` semantics + } + + // `&[after]` + // + // `:is(…)` semantics are not required, because these are equivalent: + // + // - `:is(div)[after]` → `div[after]` + // - `:is(.x)[after]` → `.x[after]` + // - `:is([before])[after]` → `[before][after]` + // - `:is(.a > .b)[after]` → `.a > .b[after]` + if (ctx.index === 0) { + node.value = parentSelector + return + } + + // `[before]&` + // + // `:is(…)` semantics are required if we're dealing with a + // + // - complex parent selector, to prevent changing the meaning of the selector + // - a universal selector, because `*` needs to be first + // - a type selector, because `div` needs to be first + // + // - `[before]:is(div)` → `[before]:is(div)` (!) + // - `[before]:is(*)` → `[before]:is(*)` (!) + // - `[before]:is(.a > .b)` → `[before]:is(.a > .b)` (!) + // - `[before]:is(.x)` → `[before].x` + else if (ctx.index === ctx.siblings.length - 1) { + if ( + parentAst[0].kind === 'complex' || + SelectorParser.isUniversalSelector(parentAst[0]) || + SelectorParser.isTypeSelector(parentAst[0]) + ) { + return // Keep `:is(…)` semantics + } + + node.value = parentSelector + return + } + + // `[before]&[after]` + // + // `:is(…)` semantics are required if we're dealing with a + // + // - complex parent selector, to prevent changing the meaning of the selector + // - a universal selector, because `*` needs to be first + // - a type selector, because `div` needs to be first + // + // - `[before]:is(div)[after]` → `[before]:is(div)[after]` (!) + // - `[before]:is(*)[after]` → `[before]:is(*)[after]` (!) + // - `[before]:is(.a > .b)[after]` → `[before]:is(.a > .b)[after]` (!) + // - `[before]:is(.x)[after]` → `[before].x[after]` + else { + if ( + parentAst[0].kind === 'complex' || + SelectorParser.isUniversalSelector(parentAst[0]) || + SelectorParser.isTypeSelector(parentAst[0]) + ) { + return // Keep `:is(…)` semantics + } + + node.value = parentSelector + return + } + } + + // 4. When the current node is a function argument (e.g. `:not(&))`, + // then we can drop the `:is(…)` entirely. + // + // The only exception is when the parent has multiple selectors + // because then we would introduce multiple arguments. Multiple + // selectors are already handled. + else if (ctx.parent.kind === 'function') { + node.value = parentSelector + return + } + }, + exit(node, ctx) { + // Optimization: We can remove the universal selector `*` if they are + // part of a compound selector. E.g.: + // + // - `*:hover` → `:hover` + // - `*[attribute]` → `[attribute]` + // + // Except when the `*` is a namespace, e.g.: `*|div`, because `*|div` + // (any namespace) and `|div` (no namespace) have different meanings. + if ( + ctx.index === 0 && + ctx.siblings.length > 1 && + ctx.parent?.kind === 'compound' && + SelectorParser.isUniversalSelector(node) + ) { + let next = ctx.siblings[1] + if (next.kind === 'selector' && next.value.charCodeAt(0) === PIPE) { + return + } + return WalkAction.ReplaceSkip([]) + } + }, + }) + + if (changed) return SelectorParser.toCss(ast) + } + + // Fast path: we know there isn't an `&` so we can prepend the parent + // selector immediately. + let parentAst = parseSelectorCache.get(parentSelector) + return `${parentAst.length === 1 && parentAst[0].kind === 'list' ? `:is(${parentSelector})` : parentSelector} ${selector}` + } } // A set of at-rules that can be hoisted to the top without any repercussions. @@ -1311,6 +1617,7 @@ const HOISTABLE_AT_RULES = new Set([ '@layer', '@media', '@page', + '@scope', '@starting-style', '@supports', '@view-transition', @@ -1323,6 +1630,7 @@ const DROPPABLE_IF_EMPTY_AT_RULES = new Set([ '@container', '@media', '@page', + '@scope', '@starting-style', '@supports', '@view-transition', diff --git a/packages/tailwindcss/src/candidate.test.ts b/packages/tailwindcss/src/candidate.test.ts index d66bfcd66..b8f914e1b 100644 --- a/packages/tailwindcss/src/candidate.test.ts +++ b/packages/tailwindcss/src/candidate.test.ts @@ -516,6 +516,8 @@ it('should not parse functional utilities with multiple modifiers', () => { utilities.functional('bg', () => []) expect(run('bg-red-1/2/3', { utilities })).toMatchInlineSnapshot(`[]`) + expect(run('bg-red-500/50/', { utilities })).toMatchInlineSnapshot(`[]`) + expect(run('bg-red-500/50//foo', { utilities })).toMatchInlineSnapshot(`[]`) }) it('should parse a utility with an arbitrary value', () => { @@ -1416,6 +1418,19 @@ it('should parse a functional variant with a modifier', () => { `) }) +it('should not parse variants with multiple modifiers', () => { + let utilities = new Utilities() + utilities.static('flex', () => []) + + let variants = new Variants() + variants.static('hover', () => {}) + variants.compound('group', Compounds.StyleRules, () => {}) + + expect(run('group-hover/foo:flex', { utilities, variants })).toHaveLength(1) + expect(run('group-hover/foo/:flex', { utilities, variants })).toEqual([]) + expect(run('group-hover/foo//bar:flex', { utilities, variants })).toEqual([]) +}) + it('should parse a functional variant starting with @', () => { let utilities = new Utilities() utilities.static('flex', () => []) diff --git a/packages/tailwindcss/src/candidate.ts b/packages/tailwindcss/src/candidate.ts index b63588ce7..b4a5988e0 100644 --- a/packages/tailwindcss/src/candidate.ts +++ b/packages/tailwindcss/src/candidate.ts @@ -372,6 +372,14 @@ export function* parseCandidate(input: string, designSystem: DesignSystem): Iter } } + // If there's more than one modifier, the utility is invalid. + // + // E.g.: + // + // - `bg-red-500/50/50` + let parts = segment(base, '/') + if (parts.length > 2) return + // Figure out the new base and the modifier segment if present. // // E.g.: @@ -381,14 +389,7 @@ export function* parseCandidate(input: string, designSystem: DesignSystem): Iter // ^^^^^^^^^^ -> Base without modifier // ^^ -> Modifier segment // ``` - let [baseWithoutModifier, modifierSegment = null, additionalModifier] = segment(base, '/') - - // If there's more than one modifier, the utility is invalid. - // - // E.g.: - // - // - `bg-red-500/50/50` - if (additionalModifier) return + let [baseWithoutModifier, modifierSegment = null] = parts let parsedModifier = modifierSegment === null ? null : parseModifier(modifierSegment) @@ -707,17 +708,18 @@ export function parseVariant(variant: string, designSystem: DesignSystem): Varia // Static, functional and compound variants { - // group-hover/group-name - // ^^^^^^^^^^^ -> Variant without modifier - // ^^^^^^^^^^ -> Modifier - let [variantWithoutModifier, modifier = null, additionalModifier] = segment(variant, '/') - // If there's more than one modifier, the variant is invalid. // // E.g.: // // - `group-hover/foo/bar` - if (additionalModifier) return null + let parts = segment(variant, '/') + if (parts.length > 2) return null + + // group-hover/group-name + // ^^^^^^^^^^^ -> Variant without modifier + // ^^^^^^^^^^ -> Modifier + let [variantWithoutModifier, modifier = null] = parts let roots = findRoots(variantWithoutModifier, (root) => { return designSystem.variants.has(root) diff --git a/packages/tailwindcss/src/canonicalize-candidates.test.ts b/packages/tailwindcss/src/canonicalize-candidates.test.ts index 03229f450..567f0ad0d 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.test.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.test.ts @@ -414,9 +414,11 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', // This should still migrate the `theme(…)` syntax to the modern syntax. ['bg-[theme(colors.red.500/50%)]/50', 'bg-[--theme(--color-red-500/50%)]/50'], - // Variants, we can't use `var(…)` especially inside of `@media(…)`. We can - // still upgrade the `theme(…)` to the modern syntax. - ['max-[theme(screens.lg)]:flex', 'max-[--theme(--breakpoint-lg)]:flex'], + // Variants, we can't use `var(…)` especially inside of `@media(…)`. But + // since the value matches the `lg` breakpoint exactly, we can migrate to + // the `max-lg` variant. + ['max-[theme(screens.lg)]:flex', 'max-lg:flex'], + ['min-[theme(screens.lg)]:flex', 'lg:flex'], // We could also map to `min-lg:flex` // There are no variables for `--spacing` multiples, so we can't convert this ['max-[theme(spacing.4)]:flex', 'max-[theme(spacing.4)]:flex'], @@ -719,6 +721,7 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', @import 'tailwindcss'; @theme { --*: initial; + --breakpoint-lg: 64rem; } ` @@ -735,6 +738,11 @@ describe.each([['default'], ['with-variant'], ['important'], ['prefix']])('%s', ['[@media(scripting:_none)]:flex', 'noscript:flex'], ['[@media_(scripting:_none)]:flex', 'noscript:flex'], + // Arbitrary variants with media queries cannot use `var(…)`, but if an + // equivalent variant exists we can use that one instead. + ['[@media(width>=theme(screens.lg))]:flex', 'lg:flex'], // `min-lg:flex` would also be valid + ['[@media(width { + test.each([ + // Breakpoints (`--breakpoint-lg: 64rem`, `--breakpoint-md: 48rem`) + ['min-[64rem]:flex', 'lg:flex'], + ['max-[64rem]:flex', 'max-lg:flex'], + ['min-[48rem]:flex', 'md:flex'], + ['max-[48rem]:flex', 'max-md:flex'], + + // With the `rem` option set, equivalent `px` values should also be + // canonicalized to the breakpoint variants. + ['min-[1024px]:flex', 'lg:flex'], + ['max-[1024px]:flex', 'max-lg:flex'], + + // Container queries (`--container-md: 28rem`) + ['@min-[28rem]:flex', '@md:flex'], + ['@max-[28rem]:flex', '@max-md:flex'], + ['@min-[448px]:flex', '@md:flex'], + ['@max-[448px]:flex', '@max-md:flex'], + + // Values that don't map to a known breakpoint or container size should + // stay as-is. + ['min-[123px]:flex', 'min-[123px]:flex'], + ['max-[123px]:flex', 'max-[123px]:flex'], + ['@min-[123px]:flex', '@min-[123px]:flex'], + ['@max-[123px]:flex', '@max-[123px]:flex'], + + // Named variants that produce the same CSS should be kept as-is + ['min-lg:flex', 'min-lg:flex'], + ['@min-md:flex', '@min-md:flex'], + ])(testName, { timeout }, async (candidate, expected) => { + let input = css` + @import 'tailwindcss'; + ` + + await expectCanonicalization(input, candidate, expected) + }) + + test.each([ + // Without the `rem` option, `px` values cannot be safely converted to + // `rem` based breakpoints. + ['min-[1024px]:flex', 'min-[1024px]:flex'], + ['@min-[448px]:flex', '@min-[448px]:flex'], + + // Same unit still works without the `rem` option + ['min-[64rem]:flex', 'lg:flex'], + ['max-[64rem]:flex', 'max-lg:flex'], + ['@min-[28rem]:flex', '@md:flex'], + ['@max-[28rem]:flex', '@max-md:flex'], + ])(`${testName} (no rem option)`, { timeout }, async (candidate, expected) => { + let input = css` + @import 'tailwindcss'; + ` + + await expectCanonicalization(input, candidate, expected, { + collapse: true, + logicalToPhysical: true, + }) + }) + }) + describe('modernize arbitrary variants', () => { test.each([ // Arbitrary variants @@ -1546,4 +1615,49 @@ describe('regressions', () => { 'bg-brand-purple', ]) }) + + // https://github.com/tailwindlabs/tailwindcss/issues/20365 + test('canonicalizes min/max media query and container query variants', async () => { + let designSystem = await __unstable__loadDesignSystem( + css` + @tailwind utilities; + @theme { + --breakpoint-lg: 64rem; + --container-lg: 32rem; + --spacing: 0.25rem; + } + `, + { base: __dirname }, + ) + + expect(designSystem.canonicalizeCandidates(['min-[64rem]:flex'])).toEqual(['lg:flex']) + expect(designSystem.canonicalizeCandidates(['max-[64rem]:flex'])).toEqual(['max-lg:flex']) + expect(designSystem.canonicalizeCandidates(['@min-[32rem]:flex'])).toEqual(['@lg:flex']) + expect(designSystem.canonicalizeCandidates(['@max-[32rem]:flex'])).toEqual(['@max-lg:flex']) + expect(designSystem.canonicalizeCandidates(['min-[1024px]:flex'], { rem: 16 })).toEqual([ + 'lg:flex', + ]) + }) + + // https://github.com/tailwindlabs/tailwindcss-intellisense/issues/1610 + test('does not merge utilities whose theme variables resolve to CSS-wide keywords', async () => { + let designSystem = await __unstable__loadDesignSystem( + css` + @tailwind utilities; + @theme { + --foreground: unset; + --default: unset; + } + @theme inline { + --color-foreground: var(--foreground); + --color-default-soft-hover: color-mix(in oklab, var(--default) 60%, transparent); + } + `, + { base: __dirname }, + ) + + expect( + designSystem.canonicalizeCandidates(['text-foreground/60', 'text-default-soft-hover']), + ).toEqual(['text-foreground/60', 'text-default-soft-hover']) + }) }) diff --git a/packages/tailwindcss/src/canonicalize-candidates.ts b/packages/tailwindcss/src/canonicalize-candidates.ts index eb9d8697c..d1dfc09de 100644 --- a/packages/tailwindcss/src/canonicalize-candidates.ts +++ b/packages/tailwindcss/src/canonicalize-candidates.ts @@ -72,12 +72,11 @@ interface InternalCanonicalizeOptions { signatureOptions: SignatureOptions } +type RemValue = number | null + interface DesignSystem extends BaseDesignSystem { storage: { - [SIGNATURE_OPTIONS_KEY]: DefaultMap< - number | null, // Rem value - DefaultMap - > + [SIGNATURE_OPTIONS_KEY]: DefaultMap> [COMPARE_CANDIDATES_KEY]: DefaultMap< SignatureOptions, (a: Candidate | string, b: Candidate | string) => boolean @@ -107,8 +106,8 @@ interface DesignSystem extends BaseDesignSystem { DefaultMap>> > [PRE_COMPUTED_UTILITIES_KEY]: DefaultMap> - [VARIANT_SIGNATURE_KEY]: DefaultMap - [PRE_COMPUTED_VARIANTS_KEY]: DefaultMap + [VARIANT_SIGNATURE_KEY]: DefaultMap> + [PRE_COMPUTED_VARIANTS_KEY]: DefaultMap> } } @@ -1537,8 +1536,8 @@ function arbitraryVariants( options: InternalCanonicalizeOptions, ): Variant | Variant[] { let designSystem = options.designSystem - let signatures = designSystem.storage[VARIANT_SIGNATURE_KEY] - let variants = designSystem.storage[PRE_COMPUTED_VARIANTS_KEY] + let signatures = designSystem.storage[VARIANT_SIGNATURE_KEY].get(options.signatureOptions.rem) + let variants = designSystem.storage[PRE_COMPUTED_VARIANTS_KEY].get(options.signatureOptions.rem) let iterator = walkVariants(variant) for (let [variant] of iterator) { @@ -1549,7 +1548,11 @@ function arbitraryVariants( if (typeof targetSignature !== 'string') continue let foundVariants = variants.get(targetSignature) - if (foundVariants.length !== 1) continue + if (foundVariants.length === 0) continue + + // The variant is already in a canonical form, e.g.: `min-lg` and `lg` + // produce the same CSS, but both are valid so we keep them as-is. + if (foundVariants.includes(targetString)) continue let foundVariant = foundVariants[0] let parsedVariant = designSystem.parseVariant(foundVariant) @@ -1769,7 +1772,7 @@ function modernizeArbitraryValuesVariant( ): Variant | Variant[] { let result = [variant] let designSystem = options.designSystem - let signatures = designSystem.storage[VARIANT_SIGNATURE_KEY] + let signatures = designSystem.storage[VARIANT_SIGNATURE_KEY].get(options.signatureOptions.rem) let iterator = walkVariants(variant) for (let [variant, parent] of iterator) { @@ -2560,6 +2563,25 @@ function canonicalizeAst(designSystem: DesignSystem, ast: AstNode[], options: Si return ast } +// Variables whose theme value is a CSS-wide keyword (e.g.: `unset`) are never +// inlined. These are typically registered as a placeholder to be re-assigned at +// runtime, so two variables that share such a value are not interchangeable. +// +// E.g.: +// +// ```css +// @theme { +// --foreground: unset; +// --background: unset; +// } +// ``` +// +// Inlining would make `text-(--foreground)` and `text-(--background)` produce +// the same signature `color: unset`, even though they are different at runtime. +// +// https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/Data_types#css-wide_keywords +const CSS_WIDE_KEYWORDS = ['initial', 'inherit', 'revert', 'revert-layer', 'revert-rule', 'unset'] + // Resolve theme values to their inlined value. // // E.g.: @@ -2575,8 +2597,8 @@ function canonicalizeAst(designSystem: DesignSystem, ast: AstNode[], options: Si // } // ``` // -// Which conveniently will be equivalent to: `text-red-500` when we inline -// the value. +// Which conveniently will be equivalent to: `text-red-500` when we inline the +// value. // // Without inlining: // ```css @@ -2592,13 +2614,13 @@ function canonicalizeAst(designSystem: DesignSystem, ast: AstNode[], options: Si // } // ``` // -// Recently we made sure that utilities like `text-red-500` also generate -// the fallback value for usage in `@reference` mode. +// Recently we made sure that utilities like `text-red-500` also generate the +// fallback value for usage in `@reference` mode. // -// The second assumption is that if you use `var(--key, fallback)` that -// happens to match a known variable _and_ its inlined value. Then we can -// replace it with the inlined variable. This allows us to handle custom -// `@theme` and `@theme inline` definitions. +// The second assumption is that if you use `var(--key, fallback)` that happens +// to match a known variable _and_ its inlined value. Then we can replace it +// with the inlined variable. This allows us to handle custom `@theme` and +// `@theme inline` definitions. function resolveVariablesInValue(value: string, designSystem: DesignSystem): string { let changed = false let valueAst = ValueParser.parse(value) @@ -2627,6 +2649,9 @@ function resolveVariablesInValue(value: string, designSystem: DesignSystem): str seen.add(variable) if (variableValue === undefined) return // Couldn't resolve the variable + // CSS-wide keywords are never inlined + if (CSS_WIDE_KEYWORDS.includes(variableValue.toLowerCase())) return + // Inject variable fallbacks when no fallback is present yet. // // A fallback could consist of multiple values. @@ -2785,100 +2810,110 @@ export const VARIANT_SIGNATURE_KEY = Symbol() function createVariantSignatureCache( designSystem: DesignSystem, ): DesignSystem['storage'][typeof VARIANT_SIGNATURE_KEY] { - return new DefaultMap((variant) => { - try { - // Ensure the prefix is added to the utility if it is not already present. - variant = - designSystem.theme.prefix && !variant.startsWith(designSystem.theme.prefix) - ? `${designSystem.theme.prefix}:${variant}` - : variant + return new DefaultMap((rem: number | null) => { + return new DefaultMap((variant) => { + try { + // Ensure the prefix is added to the utility if it is not already present. + variant = + designSystem.theme.prefix && !variant.startsWith(designSystem.theme.prefix) + ? `${designSystem.theme.prefix}:${variant}` + : variant - // Use `@apply` to normalize the selector to `.x` - let ast: AstNode[] = [styleRule('.x', [atRule('@apply', `${variant}:flex`)])] - substituteAtApply(ast, designSystem) + // Use `@apply` to normalize the selector to `.x` + let ast: AstNode[] = [styleRule('.x', [atRule('@apply', `${variant}:flex`)])] + substituteAtApply(ast, designSystem) - // Canonicalize selectors to their minimal form - walk(ast, (node) => { - // At-rules - if (node.kind === 'at-rule' && node.params.includes(' ')) { - node.params = node.params.replaceAll(' ', '') - } + // Canonicalize selectors to their minimal form + walk(ast, (node) => { + // At-rules + if (node.kind === 'at-rule') { + // Normalize dimensions such that equivalent values in different + // units are considered the same. E.g.: with a root font size of + // `16px`, `@media (width >= 64rem)` and `@media (width >= 1024px)` + // are equivalent. + node.params = constantFoldDeclaration(node.params, rem) - // Style rules - else if (node.kind === 'rule') { - let selectorAst = SelectorParser.parse(node.selector) - let changed = false - walk(selectorAst, (node) => { - // Assumption: when we have a list of selectors: `.foo, .bar` we - // want to mark this as changed because this will be re-printed as - // `.foo,.bar`. - // - // Similarly, when we receive a combinator like `.foo + .bar`, this - // will be printed as `.foo+.bar`. - // - // It could be that this was already optimal, but then this would be - // a no-op situation. - if (node.kind === 'list' || node.kind === 'combinator') { - changed = true + if (node.params.includes(' ')) { + node.params = node.params.replaceAll(' ', '') } + } - // Remove unnecessary `:is(…)` selectors - else if (node.kind === 'function' && node.value === ':is') { - // A single selector inside of `:is(…)` can be replaced with the - // selector itself. + // Style rules + else if (node.kind === 'rule') { + let selectorAst = SelectorParser.parse(node.selector) + let changed = false + walk(selectorAst, (node) => { + // Assumption: when we have a list of selectors: `.foo, .bar` we + // want to mark this as changed because this will be re-printed as + // `.foo,.bar`. // - // E.g.: `:is(.foo)` → `.foo` - if (node.nodes.length === 1) { + // Similarly, when we receive a combinator like `.foo + .bar`, this + // will be printed as `.foo+.bar`. + // + // It could be that this was already optimal, but then this would be + // a no-op situation. + if (node.kind === 'list' || node.kind === 'combinator') { changed = true - return WalkAction.Replace(node.nodes) } - // A selector with the universal selector `*` followed by a pseudo - // class, can be replaced with the pseudo class itself. + // Remove unnecessary `:is(…)` selectors + else if (node.kind === 'function' && node.value === ':is') { + // A single selector inside of `:is(…)` can be replaced with the + // selector itself. + // + // E.g.: `:is(.foo)` → `.foo` + if (node.nodes.length === 1) { + changed = true + return WalkAction.Replace(node.nodes) + } + + // A selector with the universal selector `*` followed by a pseudo + // class, can be replaced with the pseudo class itself. + else if ( + node.nodes.length === 2 && + node.nodes[0].kind === 'selector' && + node.nodes[0].value === '*' && + node.nodes[1].kind === 'selector' && + node.nodes[1].value[0] === ':' + ) { + changed = true + return WalkAction.Replace(node.nodes[1]) + } + } + + // Ensure `*` exists before pseudo selectors inside of `:not(…)`, + // `:where(…)`, … + // + // E.g.: + // + // `:not(:first-child)` → `:not(*:first-child)` + // else if ( - node.nodes.length === 2 && - node.nodes[0].kind === 'selector' && - node.nodes[0].value === '*' && - node.nodes[1].kind === 'selector' && - node.nodes[1].value[0] === ':' + node.kind === 'function' && + node.value[0] === ':' && + node.nodes[0]?.kind === 'selector' && + node.nodes[0]?.value[0] === ':' ) { changed = true - return WalkAction.Replace(node.nodes[1]) + node.nodes.unshift({ kind: 'selector', value: '*' }) } - } + }) - // Ensure `*` exists before pseudo selectors inside of `:not(…)`, - // `:where(…)`, … - // - // E.g.: - // - // `:not(:first-child)` → `:not(*:first-child)` - // - else if ( - node.kind === 'function' && - node.value[0] === ':' && - node.nodes[0]?.kind === 'selector' && - node.nodes[0]?.value[0] === ':' - ) { - changed = true - node.nodes.unshift({ kind: 'selector', value: '*' }) + if (changed) { + node.selector = SelectorParser.toCss(selectorAst, true) } - }) - - if (changed) { - node.selector = SelectorParser.toCss(selectorAst, true) } - } - }) + }) - // Compute the final signature, by generating the CSS for the variant - let signature = toCss(ast) - return signature - } catch { - // A unique symbol is returned to ensure that 2 signatures resulting in - // `null` are not considered equal. - return Symbol() - } + // Compute the final signature, by generating the CSS for the variant + let signature = toCss(ast) + return signature + } catch { + // A unique symbol is returned to ensure that 2 signatures resulting in + // `null` are not considered equal. + return Symbol() + } + }) }) } @@ -2886,19 +2921,31 @@ export const PRE_COMPUTED_VARIANTS_KEY = Symbol() function createPreComputedVariantsCache( designSystem: DesignSystem, ): DesignSystem['storage'][typeof PRE_COMPUTED_VARIANTS_KEY] { - let signatures = designSystem.storage[VARIANT_SIGNATURE_KEY] - let lookup = new DefaultMap(() => []) + return new DefaultMap((rem: number | null) => { + let signatures = designSystem.storage[VARIANT_SIGNATURE_KEY].get(rem) + let lookup = new DefaultMap(() => []) - // Actual static variants - for (let [root, variant] of designSystem.variants.entries()) { - if (variant.kind === 'static') { - let signature = signatures.get(root) - if (typeof signature !== 'string') continue - lookup.get(signature).push(root) + for (let [root, variant] of designSystem.variants.entries()) { + // Actual static variants + if (variant.kind === 'static') { + let signature = signatures.get(root) + if (typeof signature !== 'string') continue + lookup.get(signature).push(root) + } + + // Functional variants with known values, e.g.: `max-lg`, `@min-md`, … + else if (variant.kind === 'functional') { + for (let value of designSystem.variants.getCompletions(root)) { + let name = root === '@' ? `@${value}` : `${root}-${value}` + let signature = signatures.get(name) + if (typeof signature !== 'string') continue + lookup.get(signature).push(name) + } + } } - } - return lookup + return lookup + }) } function temporarilyDisableThemeInline(designSystem: DesignSystem, cb: () => T): T { diff --git a/packages/tailwindcss/src/compat/apply-compat-hooks.ts b/packages/tailwindcss/src/compat/apply-compat-hooks.ts index 6a9f580b6..036b9d88e 100644 --- a/packages/tailwindcss/src/compat/apply-compat-hooks.ts +++ b/packages/tailwindcss/src/compat/apply-compat-hooks.ts @@ -309,6 +309,19 @@ function upgradeToFullPluginSupport({ let resolvedValue = sharedPluginApi.theme(path, undefined) + // If we're dealing with an object that has the `DEFAULT` key, unwrap it to + // the default value first. This happens for namespace root lookups where + // the CSS theme defines a bare value, e.g. `--shadow` resolved via + // `theme('shadow')`. + if ( + typeof resolvedValue === 'object' && + resolvedValue !== null && + !Array.isArray(resolvedValue) && + 'DEFAULT' in resolvedValue + ) { + resolvedValue = resolvedValue.DEFAULT + } + // When a tuple is returned, return the first element if (Array.isArray(resolvedValue) && resolvedValue.length === 2) { return resolvedValue[0] @@ -319,16 +332,6 @@ function upgradeToFullPluginSupport({ return resolvedValue.join(', ') } - // If we're dealing with an object that has the `DEFAULT` key, return the - // default value - else if ( - typeof resolvedValue === 'object' && - resolvedValue !== null && - 'DEFAULT' in resolvedValue - ) { - return resolvedValue.DEFAULT - } - // Otherwise only allow string values here, objects (and namespace maps) // are treated as non-resolved values for the CSS `theme()` function. else if (typeof resolvedValue === 'string') { diff --git a/packages/tailwindcss/src/compat/config.test.ts b/packages/tailwindcss/src/compat/config.test.ts index 296848010..1f69ae446 100644 --- a/packages/tailwindcss/src/compat/config.test.ts +++ b/packages/tailwindcss/src/compat/config.test.ts @@ -1784,7 +1784,7 @@ test('The theme() function does not try indexing into strings', async () => { ).toMatchInlineSnapshot(` " :root, :host { - --color-what: light-dark(red, red); + --color-what: red; } .text-what { diff --git a/packages/tailwindcss/src/compat/config/create-compat-config.test.ts b/packages/tailwindcss/src/compat/config/create-compat-config.test.ts new file mode 100644 index 000000000..aa1b21cb7 --- /dev/null +++ b/packages/tailwindcss/src/compat/config/create-compat-config.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, test } from 'vitest' +import { buildDesignSystem } from '../../design-system' +import { Theme } from '../../theme' +import { createCompatConfig } from './create-compat-config' +import { resolveConfig } from './resolve-config' + +function buildCompatConfig(cssValues: Record) { + let theme = new Theme() + for (let [key, value] of Object.entries(cssValues)) { + theme.add(key, value) + } + let design = buildDesignSystem(theme) + + let { resolvedConfig } = resolveConfig(design, [ + { config: createCompatConfig(design.theme), base: '/root', reference: true, src: undefined }, + ]) + + return resolvedConfig +} + +describe('theme namespace lookups', () => { + test('a namespace that only defines a bare value resolves to `{ DEFAULT: … }`, not a string', () => { + // When the CSS theme only defines `--text` (a single bare value), + // `theme('text', {})` used to return the string `'1rem'`. + // + // Spreading that string, like the compat config does, produced char-indexed + // garbage (`{ '0': '1', '1': 'r', … }`) instead of `{ DEFAULT: '1rem' }`. + let config = buildCompatConfig({ '--text': '1rem' }) + + expect(config.theme?.fontSize?.DEFAULT).toBe('1rem') + expect(config.theme?.fontSize).not.toHaveProperty('0') + }) + + test('a bare value is kept alongside suffixed values in the same namespace', () => { + let config = buildCompatConfig({ + '--text': '1rem', + '--text-sm': '0.875rem', + }) + + expect(config.theme?.fontSize?.DEFAULT).toBe('1rem') + expect(config.theme?.fontSize?.sm).toBe('0.875rem') + }) + + test('a naive spread of a theme namespace in a user config is safe', () => { + let theme = new Theme() + theme.add('--text', '1rem') + let design = buildDesignSystem(theme) + + let { resolvedConfig } = resolveConfig(design, [ + { + config: { + theme: { + extend: { + fontSize: ({ theme }: any) => ({ ...theme('text', {}) }), + }, + }, + }, + base: '/root', + reference: true, + src: undefined, + }, + ]) + + expect(resolvedConfig.theme?.fontSize?.DEFAULT).toBe('1rem') + expect(resolvedConfig.theme?.fontSize).not.toHaveProperty('0') + }) + + test('namespace root lookups resolve to an object, key lookups resolve to the value', () => { + let theme = new Theme() + theme.add('--text', '1rem') + theme.add('--animate-spin', 'spin 1s linear infinite') + let design = buildDesignSystem(theme) + + let root: unknown + let rootWithDefault: unknown + let leaf: unknown + + resolveConfig(design, [ + { + config: { + theme: { + extend: { + fontSize: ({ theme }: any) => { + root = theme('text') + rootWithDefault = theme('text', {}) + leaf = theme('animate.spin') + return {} + }, + }, + }, + }, + base: '/root', + reference: true, + src: undefined, + }, + ]) + + // A namespace root lookup always resolves to an object, like in v3, even + // when the namespace only contains a bare value + expect(root).toMatchObject({ DEFAULT: '1rem' }) + expect(rootWithDefault).toMatchObject({ DEFAULT: '1rem' }) + + // Lookups of a specific key resolve to the value itself + expect(leaf).toBe('spin 1s linear infinite') + }) + + test('lookups of a specific key still resolve to the value itself', () => { + let theme = new Theme() + theme.add('--animate-spin', 'spin 1s linear infinite') + let design = buildDesignSystem(theme) + + let { resolvedConfig } = resolveConfig(design, [ + { + config: { + theme: { + extend: { + animation: ({ theme }: any) => ({ spin: theme('animate.spin') }), + }, + }, + }, + base: '/root', + reference: true, + src: undefined, + }, + ]) + + expect(resolvedConfig.theme?.animation?.spin).toBe('spin 1s linear infinite') + }) +}) + +describe('createCompatConfig namespace mappings', () => { + test('fontSize maps from CSS text namespace', () => { + let config = buildCompatConfig({ '--text-base': '1rem' }) + expect(config.theme?.fontSize?.base).toBe('1rem') + }) + + test('boxShadow maps from CSS shadow namespace', () => { + let config = buildCompatConfig({ '--shadow-md': '0 4px 6px #000' }) + expect(config.theme?.boxShadow?.md).toBe('0 4px 6px #000') + }) + + test('animation maps from CSS animate namespace', () => { + let config = buildCompatConfig({ '--animate-spin': 'spin 1s linear infinite' }) + expect(config.theme?.animation?.spin).toBe('spin 1s linear infinite') + }) + + test('aspectRatio maps from CSS aspect namespace', () => { + let config = buildCompatConfig({ '--aspect-square': '1 / 1' }) + expect(config.theme?.aspectRatio?.square).toBe('1 / 1') + }) + + test('borderRadius maps from CSS radius namespace', () => { + let config = buildCompatConfig({ '--radius-lg': '0.5rem' }) + expect(config.theme?.borderRadius?.lg).toBe('0.5rem') + }) + + test('screens maps from CSS breakpoint namespace', () => { + let config = buildCompatConfig({ '--breakpoint-sm': '640px' }) + expect(config.theme?.screens?.sm).toBe('640px') + }) + + test('letterSpacing maps from CSS tracking namespace', () => { + let config = buildCompatConfig({ '--tracking-wide': '0.025em' }) + expect(config.theme?.letterSpacing?.wide).toBe('0.025em') + }) + + test('lineHeight maps from CSS leading namespace', () => { + let config = buildCompatConfig({ '--leading-relaxed': '1.75' }) + expect(config.theme?.lineHeight?.relaxed).toBe('1.75') + }) + + test('maxWidth maps from the container namespace in the default theme', () => { + let config = buildCompatConfig({}) + // The default theme has container.sm = '24rem' + expect(config.theme?.maxWidth?.sm).toBe('24rem') + }) + + test('transitionDuration gets DEFAULT from --default-transition-duration', () => { + let config = buildCompatConfig({ '--default-transition-duration': '150ms' }) + expect(config.theme?.transitionDuration?.DEFAULT).toBe('150ms') + }) + + test('transitionTimingFunction gets DEFAULT from --default-transition-timing-function', () => { + let config = buildCompatConfig({ + '--default-transition-timing-function': 'cubic-bezier(0.4, 0, 0.2, 1)', + }) + expect(config.theme?.transitionTimingFunction?.DEFAULT).toBe('cubic-bezier(0.4, 0, 0.2, 1)') + }) +}) diff --git a/packages/tailwindcss/src/compat/plugin-api.test.ts b/packages/tailwindcss/src/compat/plugin-api.test.ts index bf2265fa6..dd6928b23 100644 --- a/packages/tailwindcss/src/compat/plugin-api.test.ts +++ b/packages/tailwindcss/src/compat/plugin-api.test.ts @@ -1642,6 +1642,83 @@ describe('addVariant', () => { `) }) + test('addVariant with `@scope`', async () => { + expect( + await run( + ['blue:underline', 'green:underline'], + css` + @plugin "my-plugin"; + @layer utilities { + @tailwind utilities; + } + `, + { + loadModule: async (_id, base) => { + return { + path: '', + base, + module: ({ addVariant }: PluginAPI) => { + addVariant('blue', '@scope ([data-theme=blue]) to ([data-theme])') + addVariant('green', '@scope ([data-theme=green]) to ([data-theme]) { & }') + }, + } + }, + }, + ), + ).toMatchInlineSnapshot(` + " + @layer utilities { + @scope ([data-theme="blue"]) to ([data-theme]) { + .blue\\:underline { + text-decoration-line: underline; + } + } + + @scope ([data-theme="green"]) to ([data-theme]) { + .green\\:underline { + text-decoration-line: underline; + } + } + } + " + `) + }) + + test('matchVariant with `@scope`', async () => { + expect( + await run( + ['theme-[blue]:underline'], + css` + @plugin "my-plugin"; + @layer utilities { + @tailwind utilities; + } + `, + { + loadModule: async (_id, base) => { + return { + path: '', + base, + module: ({ matchVariant }: PluginAPI) => { + matchVariant('theme', (value) => `@scope ([data-theme=${value}]) to ([data-theme])`) + }, + } + }, + }, + ), + ).toMatchInlineSnapshot(` + " + @layer utilities { + @scope ([data-theme="blue"]) to ([data-theme]) { + .theme-\\[blue\\]\\:underline { + text-decoration-line: underline; + } + } + } + " + `) + }) + test('addVariant with array of selectors', async () => { expect( await run( diff --git a/packages/tailwindcss/src/compat/plugin-api.ts b/packages/tailwindcss/src/compat/plugin-api.ts index 4ea62f864..11878fb30 100644 --- a/packages/tailwindcss/src/compat/plugin-api.ts +++ b/packages/tailwindcss/src/compat/plugin-api.ts @@ -1,6 +1,6 @@ import type { Features } from '..' import { substituteAtApply } from '../apply' -import { atRule, cloneAstNode, decl, rule, type AstNode } from '../ast' +import { atRule, cloneAstNode, context, decl, rule, type AstNode } from '../ast' import type { Candidate, CandidateModifier, NamedUtilityValue } from '../candidate' import { substituteFunctions } from '../css-functions' import * as CSS from '../css-parser' @@ -608,14 +608,28 @@ export function objectToAst(rules: CssInJs | CssInJs[]): AstNode[] { function parseVariantValue(resolved: string | string[], nodes: AstNode[]): AstNode[] { let resolvedArray = typeof resolved === 'string' ? [resolved] : resolved - return resolvedArray.flatMap((r) => { + return resolvedArray.flatMap((r): AstNode | AstNode[] => { if (r.trim().endsWith('}')) { + let usesAtScope = r.includes('@scope') let updatedCSS = r.replace('}', '{@slot}}') let ast = CSS.parse(updatedCSS) + + if (usesAtScope) { + substituteAtSlot(ast, [context({ source: 'user' }, nodes)]) + return context({ source: 'variant' }, ast) + } + substituteAtSlot(ast, nodes) return ast } else { - return rule(r, nodes) + let node = rule(r, nodes) + + if (node.kind === 'at-rule' && node.name === '@scope') { + node.nodes = [context({ source: 'user' }, nodes)] + return context({ source: 'variant' }, [node]) + } + + return node } }) } diff --git a/packages/tailwindcss/src/compat/plugin-functions.ts b/packages/tailwindcss/src/compat/plugin-functions.ts index ee795c40f..bbd5fac46 100644 --- a/packages/tailwindcss/src/compat/plugin-functions.ts +++ b/packages/tailwindcss/src/compat/plugin-functions.ts @@ -108,7 +108,12 @@ export function createThemeFn( // ``` // // Prefer `DEFAULT` instead of exposing the object to plugin code. + // + // This only applies to lookups of a specific key (e.g. `colors.foo`). A + // namespace root lookup (e.g. `theme('shadow')`) resolves to the whole + // namespace as an object, like in v3. if ( + keypath.length > 1 && cssValue !== null && typeof cssValue === 'object' && !Array.isArray(cssValue) && @@ -225,7 +230,12 @@ function readFromCss( // The request looked like `theme('animation.spin')` and was turned into a // lookup for `--animation-spin-*` which had only one entry which means it // should be returned directly. - if ('DEFAULT' in obj && Object.keys(obj).length === 1) { + // + // This only applies to lookups of a specific key (`path.length > 1`). A + // namespace root lookup like `theme('shadow')` resolves to the whole + // namespace as an object, like in v3, so it must keep the object shape (`{ + // DEFAULT: … }`). + if (path.length > 1 && 'DEFAULT' in obj && Object.keys(obj).length === 1) { return [obj.DEFAULT as any, optionsObj.DEFAULT ?? ThemeOptions.NONE] as const } diff --git a/packages/tailwindcss/src/compile.ts b/packages/tailwindcss/src/compile.ts index 9bfb3fb18..46f562918 100644 --- a/packages/tailwindcss/src/compile.ts +++ b/packages/tailwindcss/src/compile.ts @@ -1,4 +1,4 @@ -import { atRule, decl, rule, type AstNode, type Rule, type StyleRule } from './ast' +import { atRule, context, decl, rule, type AstNode, type Rule, type StyleRule } from './ast' import { type Candidate, type Variant } from './candidate' import { CompileAstFlags, type DesignSystem } from './design-system' import GLOBAL_PROPERTY_ORDER from './property-order' @@ -187,7 +187,18 @@ export function applyVariant( // E.g. `[>img]:flex` is not valid, but `has-[>img]:flex` is if (variant.relative && depth === 0) return null - node.nodes = [rule(variant.selector, node.nodes)] + let child: AstNode = rule(variant.selector, node.nodes) + + // Wrap variant-generated `@scope` rules in a `context` marker, so they are + // hoisted with the variant semantics instead of their native CSS nesting + // semantics. The slotted content is marked as `user` content, so `@scope` + // rules defined there keep their native CSS nesting semantics. + if (child.kind === 'at-rule' && child.name === '@scope') { + child.nodes = [context({ source: 'user' }, node.nodes)] + child = context({ source: 'variant' }, [child]) + } + + node.nodes = [child] return } diff --git a/packages/tailwindcss/src/css-parser.test.ts b/packages/tailwindcss/src/css-parser.test.ts index c169e92ec..8df19f96c 100644 --- a/packages/tailwindcss/src/css-parser.test.ts +++ b/packages/tailwindcss/src/css-parser.test.ts @@ -203,7 +203,7 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => { ]) }) - it('should parse declarations with with strings and escaped string endings', () => { + it('should parse declarations with strings and escaped string endings', () => { expect( parse(css` content: 'These are not the end "\' of the string'; diff --git a/packages/tailwindcss/src/design-system.ts b/packages/tailwindcss/src/design-system.ts index 71d506938..7786790f6 100644 --- a/packages/tailwindcss/src/design-system.ts +++ b/packages/tailwindcss/src/design-system.ts @@ -75,6 +75,8 @@ export function buildDesignSystem( let variants = createVariants(theme) let parsedVariants = new DefaultMap((variant) => parseVariant(variant, designSystem)) + let cachedVariantOrder: Map | null = null + let cachedVariantOrderSize = -1 let parsedCandidates = new DefaultMap((candidate) => Array.from(parseCandidate(candidate, designSystem)), ) @@ -188,6 +190,11 @@ export function buildDesignSystem( }, getVariantOrder() { + // parsedVariants only grows, so the cache should be reset only when the size changes + if (cachedVariantOrder !== null && cachedVariantOrderSize === parsedVariants.size) { + return cachedVariantOrder + } + let variants = Array.from(parsedVariants.values()) variants.sort((a, z) => this.variants.compare(a, z)) @@ -209,6 +216,9 @@ export function buildDesignSystem( prevVariant = variant } + cachedVariantOrder = order + cachedVariantOrderSize = parsedVariants.size + return order }, diff --git a/packages/tailwindcss/src/index.test.ts b/packages/tailwindcss/src/index.test.ts index 2a72336fb..5eebd1716 100644 --- a/packages/tailwindcss/src/index.test.ts +++ b/packages/tailwindcss/src/index.test.ts @@ -904,42 +904,17 @@ describe('@apply', () => { } ` - // TODO: once Lightning CSS properly supports it, then we can drop this section: - { - let compiler = await compile(input) - expect(compiler.build([])).toMatchInlineSnapshot(` - ".foo { - text-decoration-line: underline; - @apply --my-mixin-1; - @apply --my-mixin-1(); - @apply --my-mixin-1 --my-mixin-2; - @apply --my-mixin-1() --my-mixin-2(); - @apply --my-mixin-3 { - color: red; - } - } - " - `) - } - - // TODO: this output is currently broken because Lightning CSS doesn't - // handle this case correctly yet expect(await compileCss(input)).toMatchInlineSnapshot(` " .foo { text-decoration-line: underline; - } - - @apply --my-mixin-1; - - @apply --my-mixin-1(); - - @apply --my-mixin-1 --my-mixin-2; - - @apply --my-mixin-1() --my-mixin-2(); - - @apply --my-mixin-3 { - color: red; + @apply --my-mixin-1; + @apply --my-mixin-1(); + @apply --my-mixin-1 --my-mixin-2; + @apply --my-mixin-1() --my-mixin-2(); + @apply --my-mixin-3 { + color: red; + } } " `) @@ -4685,6 +4660,319 @@ describe('@custom-variant', () => { }) }) + describe('@scope', () => { + test('arbitrary variant', async () => { + expect(await run(['[@scope_(.from)_to_(.to)]:underline'])).toMatchInlineSnapshot(` + " + @scope (.from) to (.to) { + .\\[\\@scope_\\(\\.from\\)_to_\\(\\.to\\)\\]\\:underline { + text-decoration-line: underline; + } + } + " + `) + }) + + test('shorthand syntax', async () => { + expect( + await run( + ['scoped:underline'], + css` + @custom-variant scoped (@scope (.from) to (.to)); + + @layer utilities { + @tailwind utilities; + } + `, + ), + ).toMatchInlineSnapshot(` + " + @layer utilities { + @scope (.from) to (.to) { + .scoped\\:underline { + text-decoration-line: underline; + } + } + } + " + `) + }) + + test('with @slot syntax', async () => { + expect( + await run( + ['scoped:underline'], + css` + @custom-variant scoped { + @scope (.from) to (.to) { + @slot; + } + } + + @layer utilities { + @tailwind utilities; + } + `, + ), + ).toMatchInlineSnapshot(` + " + @layer utilities { + @scope (.from) to (.to) { + .scoped\\:underline { + text-decoration-line: underline; + } + } + } + " + `) + }) + + test('@scope in a custom variant combined with user-authored `@scope` rules', async () => { + expect( + await run( + ['blue:bg-blue-500'], + css` + @layer utilities { + @tailwind utilities; + } + + @theme inline { + --color-blue-500: blue; + } + + /* In a variant */ + @custom-variant blue { + @scope ([data-theme='blue']) to ([data-theme]) { + @slot; + } + } + + /* User CSS */ + .parent { + /* @scope with bare declarations */ + @scope (.from) to (.to) { + --x: 1; + } + + .child { + .a { + /* Variant with @scope used by @apply */ + @apply blue:[--x:2]; + } + .b { + /* Variant with @scope used by @apply combined with another variant */ + @apply blue:hover:[--x:3]; + } + .c { + /* Variant with @scoped used by @variant */ + @variant blue { + --x: 4; + } + } + } + } + `, + ), + ).toMatchInlineSnapshot(` + " + @layer utilities { + @scope ([data-theme="blue"]) to ([data-theme]) { + .blue\\:bg-blue-500 { + background-color: #00f; + } + } + } + + @scope (.parent .from) to (.to) { + :where(:scope) { + --x: 1; + } + } + + @scope ([data-theme="blue"]) to ([data-theme]) { + .parent .child .a { + --x: 2; + } + + @media (hover: hover) { + .parent .child .b:hover { + --x: 3; + } + } + + .parent .child .c { + --x: 4; + } + } + " + `) + }) + + test('`&` in the `@scope` prelude of a custom variant controls the composition', async () => { + expect( + await run( + ['in-blue:flex', 'blue-self:flex', 'scoped-panel:flex', 'in-blue:hover:flex'], + css` + /* Default: the utility applies to elements inside the scope */ + @custom-variant in-blue { + @scope ([data-theme='blue']) to ([data-theme]) { + @slot; + } + } + + /* Trailing &: the utility itself becomes the scoping root */ + @custom-variant blue-self { + @scope ([data-theme='blue'] &) to ([data-theme]) { + @slot; + } + } + + /* Leading &: the scoping roots are found inside the utility */ + @custom-variant scoped-panel { + @scope (& .panel) { + @slot; + } + } + + @layer utilities { + @tailwind utilities; + } + `, + ), + ).toMatchInlineSnapshot(` + " + @layer utilities { + @scope ([data-theme="blue"]) to ([data-theme]) { + .in-blue\\:flex { + display: flex; + } + + @media (hover: hover) { + .in-blue\\:hover\\:flex:hover { + display: flex; + } + } + } + + @scope ([data-theme="blue"] .blue-self\\:flex) to ([data-theme]) { + :where(:scope) { + display: flex; + } + } + + @scope (.scoped-panel\\:flex .panel) { + :where(:scope) { + display: flex; + } + } + } + " + `) + }) + + test('user-authored `@scope` inside `@variant` keeps its native semantics', async () => { + // The `hocus` variant does not use `@scope` itself, so the inner `@scope` + // is user CSS and compiles with the native CSS nesting semantics: the + // parent selector moves into the prelude, and bare declarations apply to + // the scoping root. + expect( + await compileCss(css` + @custom-variant hocus (&:hover, &:focus); + + .btn { + @variant hocus { + @scope (.from) to (.to) { + --x: 1; + } + } + } + `), + ).toMatchInlineSnapshot(` + " + @scope (:is(.btn:hover, .btn:focus) .from) to (.to) { + :where(:scope) { + --x: 1; + } + } + " + `) + }) + + test('user-authored `@scope` inside a `@variant` that uses `@scope` keeps its native semantics', async () => { + // The `blue` variant uses `@scope` itself, so its own `@scope` takes the + // variant composition. But the user CSS slotted into its body is marked + // as `user` content, so the inner `@scope` keeps its native CSS nesting + // semantics: the parent selector moves into the prelude, and bare + // declarations apply to the scoping root. + expect( + await compileCss(css` + @custom-variant blue { + @scope ([data-theme='blue']) to ([data-theme]) { + @slot; + } + } + + .btn { + @variant blue { + @scope (.from) to (.to) { + --x: 1; + } + } + } + `), + ).toMatchInlineSnapshot(` + " + @scope ([data-theme="blue"]) to ([data-theme]) { + @scope (.btn .from) to (.to) { + :where(:scope) { + --x: 1; + } + } + } + " + `) + }) + + test('@scope nested in a rule, nested in another @scope, with @slot', async () => { + // Both `@scope` rules take the variant composition: neither prelude is + // made relative to a parent, the outer/inner nesting order is preserved, + // and the rule between them composes into the body with the utility's + // selector. + expect( + await run( + ['foo:underline'], + css` + @custom-variant foo { + @scope (.outer) to (.outer-limit) { + .middle { + @scope (.inner) to (.inner-limit) { + @slot; + } + } + } + } + + @layer utilities { + @tailwind utilities; + } + `, + ), + ).toMatchInlineSnapshot(` + " + @layer utilities { + @scope (.outer) to (.outer-limit) { + @scope (.inner) to (.inner-limit) { + .foo\\:underline .middle { + text-decoration-line: underline; + } + } + } + } + " + `) + }) + }) + test('built-in variants can be overridden while keeping their order', async () => { expect( await run( diff --git a/packages/tailwindcss/src/index.ts b/packages/tailwindcss/src/index.ts index dc1742d1e..bc279e624 100644 --- a/packages/tailwindcss/src/index.ts +++ b/packages/tailwindcss/src/index.ts @@ -392,21 +392,25 @@ async function parseCss( } } + let usesAtScope = atRuleParams.some((param) => param.startsWith('@scope')) + customVariants.set(name, (designSystem) => { designSystem.variants.static( name, (r) => { + let slot = usesAtScope ? [contextNode({ source: 'user' }, r.nodes)] : r.nodes + let nodes: AstNode[] = [] if (styleRuleSelectors.length > 0) { - nodes.push(styleRule(styleRuleSelectors.join(', '), r.nodes)) + nodes.push(styleRule(styleRuleSelectors.join(', '), slot)) } for (let selector of atRuleParams) { - nodes.push(rule(selector, r.nodes)) + nodes.push(rule(selector, slot)) } - r.nodes = nodes + r.nodes = usesAtScope ? [contextNode({ source: 'variant' }, nodes)] : nodes }, { compounds: compoundsForSelectors([...styleRuleSelectors, ...atRuleParams]), diff --git a/packages/tailwindcss/src/utilities.test.ts b/packages/tailwindcss/src/utilities.test.ts index 60d5daf59..66b051d57 100644 --- a/packages/tailwindcss/src/utilities.test.ts +++ b/packages/tailwindcss/src/utilities.test.ts @@ -12120,7 +12120,7 @@ test('rounded', async () => { } .rounded-full { - border-radius: 3.40282e38px; + border-radius: 2147483647px; } .rounded-none { @@ -12154,18 +12154,28 @@ test('rounded', async () => { " `) expect( - await run([ - '-rounded', - '-rounded-full', - '-rounded-none', - '-rounded-sm', - '-rounded-[4px]', - 'rounded/foo', - 'rounded-full/foo', - 'rounded-none/foo', - 'rounded-sm/foo', - 'rounded-[4px]/foo', - ]), + await run( + [ + '-rounded', + '-rounded-full', + '-rounded-none', + '-rounded-sm', + '-rounded-[4px]', + 'rounded/foo', + 'rounded-full/foo', + 'rounded-none/foo', + 'rounded-sm/foo', + 'rounded-[4px]/foo', + 'rounded-sm/[5]', + 'rounded-sm/(--foo)', + ], + css` + @theme { + --radius-sm: 0.125rem; + } + @tailwind utilities; + `, + ), ).toEqual('') }) @@ -22373,6 +22383,11 @@ test('stroke', async () => { // Width '-stroke-0', 'stroke--1', + 'stroke-0/foo', + 'stroke-1/50', + 'stroke-2/[0.5]', + 'stroke-2/(--my-opacity)', + 'stroke-[12px]/foo', ]), ).toEqual('') }) @@ -24398,82 +24413,90 @@ test('filter', async () => { " `) expect( - await run([ - '-filter', - '-filter-none', - '-filter-[var(--value)]', - '-blur-xl', - '-blur-[4px]', - 'brightness--50', - '-brightness-50', - '-brightness-[1.23]', - 'brightness-unknown', - 'contrast--50', - '-contrast-50', - '-contrast-[1.23]', - 'contrast-unknown', - '-grayscale', - '-grayscale-0', - 'grayscale--1', - '-grayscale-[var(--value)]', - 'grayscale-unknown', - 'hue-rotate--5', - 'hue-rotate-unknown', - '-invert', - 'invert--5', - '-invert-0', - '-invert-[var(--value)]', - 'invert-unknown', - '-drop-shadow-xl', - '-drop-shadow-[0_0_red]', + await run( + [ + '-filter', + '-filter-none', + '-filter-[var(--value)]', + '-blur-xl', + '-blur-[4px]', + 'brightness--50', + '-brightness-50', + '-brightness-[1.23]', + 'brightness-unknown', + 'contrast--50', + '-contrast-50', + '-contrast-[1.23]', + 'contrast-unknown', + '-grayscale', + '-grayscale-0', + 'grayscale--1', + '-grayscale-[var(--value)]', + 'grayscale-unknown', + 'hue-rotate--5', + 'hue-rotate-unknown', + '-invert', + 'invert--5', + '-invert-0', + '-invert-[var(--value)]', + 'invert-unknown', + '-drop-shadow-xl', + '-drop-shadow-[0_0_red]', - 'drop-shadow/foo', - '-drop-shadow/foo', - '-drop-shadow/25', - '-drop-shadow-red-500', - 'drop-shadow-red-500/foo', - '-drop-shadow-red-500/foo', - '-drop-shadow-red-500/50', + 'drop-shadow/foo', + '-drop-shadow/foo', + '-drop-shadow/25', + '-drop-shadow-red-500', + 'drop-shadow-red-500/foo', + '-drop-shadow-red-500/foo', + '-drop-shadow-red-500/50', - '-saturate-0', - 'saturate--5', - '-saturate-[1.75]', - '-saturate-[var(--value)]', - 'saturate-saturate', - '-sepia', - 'sepia--50', - '-sepia-0', - '-sepia-[50%]', - '-sepia-[var(--value)]', - 'sepia-unknown', - 'filter/foo', - 'filter-none/foo', - 'filter-[var(--value)]/foo', - 'blur-xl/foo', - 'blur-none/foo', - 'blur-[4px]/foo', - 'brightness-50/foo', - 'brightness-[1.23]/foo', - 'contrast-50/foo', - 'contrast-[1.23]/foo', - 'grayscale/foo', - 'grayscale-0/foo', - 'grayscale-[var(--value)]/foo', - 'hue-rotate-15/foo', - 'hue-rotate-[45deg]/foo', - 'invert/foo', - 'invert-0/foo', - 'invert-[var(--value)]/foo', - 'drop-shadow-xl/foo', - 'drop-shadow-[0_0_red]/foo', - 'saturate-0/foo', - 'saturate-[1.75]/foo', - 'saturate-[var(--value)]/foo', - 'sepia/foo', - 'sepia-0/foo', - 'sepia-[50%]/foo', - 'sepia-[var(--value)]/foo', - ]), + '-saturate-0', + 'saturate--5', + '-saturate-[1.75]', + '-saturate-[var(--value)]', + 'saturate-saturate', + '-sepia', + 'sepia--50', + '-sepia-0', + '-sepia-[50%]', + '-sepia-[var(--value)]', + 'sepia-unknown', + 'filter/foo', + 'filter-none/foo', + 'filter-[var(--value)]/foo', + 'blur-xl/foo', + 'blur-none/foo', + 'blur-[4px]/foo', + 'brightness-50/foo', + 'brightness-[1.23]/foo', + 'contrast-50/foo', + 'contrast-[1.23]/foo', + 'grayscale/foo', + 'grayscale-0/foo', + 'grayscale-[var(--value)]/foo', + 'hue-rotate-15/foo', + 'hue-rotate-[45deg]/foo', + 'invert/foo', + 'invert-0/foo', + 'invert-[var(--value)]/foo', + 'drop-shadow-xl/foo', + 'drop-shadow-[0_0_red]/foo', + 'saturate-0/foo', + 'saturate-[1.75]/foo', + 'saturate-[var(--value)]/foo', + 'sepia/foo', + 'sepia-0/foo', + 'sepia-[50%]/foo', + 'sepia-[var(--value)]/foo', + ], + css` + @theme { + --drop-shadow: 0 1px 1px rgb(0 0 0 / 0.05); + } + @tailwind utilities; + `, + ), ).toEqual('') expect( @@ -27243,25 +27266,36 @@ test('text-shadow', async () => { " `) expect( - await run([ - '-shadow-xl', - '-shadow-none', - '-shadow-red-500', - '-shadow-red-500/50', - '-shadow-red-500/[0.5]', - '-shadow-red-500/[50%]', - '-shadow-current', - '-shadow-current/50', - '-shadow-current/[0.5]', - '-shadow-current/[50%]', - '-shadow-inherit', - '-shadow-transparent', - '-shadow-[#0088cc]', - '-shadow-[#0088cc]/50', - '-shadow-[#0088cc]/[0.5]', - '-shadow-[#0088cc]/[50%]', - '-shadow-[var(--value)]', - ]), + await run( + [ + '-text-shadow-sm', + '-text-shadow-none', + '-text-shadow-red-500', + '-text-shadow-red-500/50', + '-text-shadow-red-500/[0.5]', + '-text-shadow-red-500/[50%]', + '-text-shadow-current', + '-text-shadow-current/50', + '-text-shadow-current/[0.5]', + '-text-shadow-current/[50%]', + '-text-shadow-inherit', + '-text-shadow-transparent', + '-text-shadow-[#0088cc]', + '-text-shadow-[#0088cc]/50', + '-text-shadow-[#0088cc]/[0.5]', + '-text-shadow-[#0088cc]/[50%]', + '-text-shadow-[var(--value)]', + 'text-shadow/foo', + 'text-shadow-sm/foo', + ], + css` + @theme { + --text-shadow: 0 1px 1px rgb(0 0 0 / 0.05); + --text-shadow-sm: 0 1px 2px rgb(0 0 0 / 0.06); + } + @tailwind utilities; + `, + ), ).toEqual('') }) @@ -27725,25 +27759,38 @@ test('shadow', async () => { " `) expect( - await run([ - '-shadow-xl', - '-shadow-none', - '-shadow-red-500', - '-shadow-red-500/50', - '-shadow-red-500/[0.5]', - '-shadow-red-500/[50%]', - '-shadow-current', - '-shadow-current/50', - '-shadow-current/[0.5]', - '-shadow-current/[50%]', - '-shadow-inherit', - '-shadow-transparent', - '-shadow-[#0088cc]', - '-shadow-[#0088cc]/50', - '-shadow-[#0088cc]/[0.5]', - '-shadow-[#0088cc]/[50%]', - '-shadow-[var(--value)]', - ]), + await run( + [ + '-shadow-sm', + '-shadow-none', + '-shadow-red-500', + '-shadow-red-500/50', + '-shadow-red-500/[0.5]', + '-shadow-red-500/[50%]', + '-shadow-current', + '-shadow-current/50', + '-shadow-current/[0.5]', + '-shadow-current/[50%]', + '-shadow-inherit', + '-shadow-transparent', + '-shadow-[#0088cc]', + '-shadow-[#0088cc]/50', + '-shadow-[#0088cc]/[0.5]', + '-shadow-[#0088cc]/[50%]', + '-shadow-[var(--value)]', + 'shadow/foo', + 'shadow-sm/foo', + 'shadow-[10px_10px]/foo', + 'shadow-[#0088cc]/foo', + ], + css` + @theme { + --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1); + --shadow-sm: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); + } + @tailwind utilities; + `, + ), ).toEqual('') }) @@ -28216,25 +28263,37 @@ test('inset-shadow', async () => { " `) expect( - await run([ - '-inset-shadow-sm', - '-inset-shadow-none', - '-inset-shadow-red-500', - '-inset-shadow-red-500/50', - '-inset-shadow-red-500/[0.5]', - '-inset-shadow-red-500/[50%]', - '-inset-shadow-current', - '-inset-shadow-current/50', - '-inset-shadow-current/[0.5]', - '-inset-shadow-current/[50%]', - '-inset-shadow-inherit', - '-inset-shadow-transparent', - '-inset-shadow-[#0088cc]', - '-inset-shadow-[#0088cc]/50', - '-inset-shadow-[#0088cc]/[0.5]', - '-inset-shadow-[#0088cc]/[50%]', - '-inset-shadow-[var(--value)]', - ]), + await run( + [ + '-inset-shadow-sm', + '-inset-shadow-none', + '-inset-shadow-red-500', + '-inset-shadow-red-500/50', + '-inset-shadow-red-500/[0.5]', + '-inset-shadow-red-500/[50%]', + '-inset-shadow-current', + '-inset-shadow-current/50', + '-inset-shadow-current/[0.5]', + '-inset-shadow-current/[50%]', + '-inset-shadow-inherit', + '-inset-shadow-transparent', + '-inset-shadow-[#0088cc]', + '-inset-shadow-[#0088cc]/50', + '-inset-shadow-[#0088cc]/[0.5]', + '-inset-shadow-[#0088cc]/[50%]', + '-inset-shadow-[var(--value)]', + 'inset-shadow/foo', + 'inset-shadow-sm/foo', + 'inset-shadow-[10px_10px]/foo', + ], + css` + @theme { + --inset-shadow: inset 0 1px 3px 0 rgb(0 0 0 / 0.1); + --inset-shadow-sm: inset 0 1px 1px rgb(0 0 0 / 0.05); + } + @tailwind utilities; + `, + ), ).toEqual('') }) @@ -31000,6 +31059,34 @@ describe('custom utilities', () => { expect(await run(['example-123/foo'], input)).toEqual('') }) + test('the value of `--default(…)` is emitted as-is', async () => { + let input = css` + @theme reference { + --text-box-trim-trim-both: trim-both; + --text-box-edge-cap: cap alphabetic; + } + + @utility trim-* { + text-box: --value(--text-box-trim-*) + --modifier(--text-box-edge-*, --default(box alphabetic)); + } + + @tailwind utilities; + ` + + expect(await run(['trim-trim-both', 'trim-trim-both/cap'], input)).toMatchInlineSnapshot(` + " + .trim-trim-both { + text-box: var(--text-box-trim-trim-both, trim-both) box alphabetic; + } + + .trim-trim-both\\/cap { + text-box: var(--text-box-trim-trim-both, trim-both) var(--text-box-edge-cap, cap alphabetic); + } + " + `) + }) + test('modifiers', async () => { let input = css` @theme reference { diff --git a/packages/tailwindcss/src/utilities.ts b/packages/tailwindcss/src/utilities.ts index 5da9e63e1..477559d7e 100644 --- a/packages/tailwindcss/src/utilities.ts +++ b/packages/tailwindcss/src/utilities.ts @@ -421,6 +421,13 @@ export function createUtilities(theme: Theme) { desc.themeKeys ?? [], ) + // If the theme value resolved without consuming the modifier, then + // the modifier would be silently ignored and `w-4` and `w-4/foo` + // would generate the same CSS. + // + // The modifier would be consumed if it's a fraction, e.g.: `w-1/2` + if (value !== null && candidate.modifier && !candidate.value.fraction) return + // Automatically handle things like `w-1/2` without requiring `1/2` to // exist as a theme value. if (value === null && desc.supportsFractions && candidate.value.fraction) { @@ -1561,11 +1568,11 @@ export function createUtilities(theme: Theme) { { let transformValue = [ - 'var(--tw-rotate-x,)', - 'var(--tw-rotate-y,)', - 'var(--tw-rotate-z,)', - 'var(--tw-skew-x,)', - 'var(--tw-skew-y,)', + 'var(--tw-rotate-x, )', + 'var(--tw-rotate-y, )', + 'var(--tw-rotate-z, )', + 'var(--tw-skew-x, )', + 'var(--tw-skew-y, )', ].join(' ') let transformProperties = () => @@ -1807,7 +1814,7 @@ export function createUtilities(theme: Theme) { staticUtility(`touch-pan-${value}`, [ touchProperties, ['--tw-pan-x', `pan-${value}`], - ['touch-action', 'var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)'], + ['touch-action', 'var(--tw-pan-x, ) var(--tw-pan-y, ) var(--tw-pinch-zoom, )'], ]) } @@ -1815,14 +1822,14 @@ export function createUtilities(theme: Theme) { staticUtility(`touch-pan-${value}`, [ touchProperties, ['--tw-pan-y', `pan-${value}`], - ['touch-action', 'var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)'], + ['touch-action', 'var(--tw-pan-x, ) var(--tw-pan-y, ) var(--tw-pinch-zoom, )'], ]) } staticUtility('touch-pinch-zoom', [ touchProperties, ['--tw-pinch-zoom', `pinch-zoom`], - ['touch-action', 'var(--tw-pan-x,) var(--tw-pan-y,) var(--tw-pinch-zoom,)'], + ['touch-action', 'var(--tw-pan-x, ) var(--tw-pan-y, ) var(--tw-pinch-zoom, )'], ]) /** @@ -3888,6 +3895,10 @@ export function createUtilities(theme: Theme) { } { + // A modifier is only valid as the opacity of a stroke color. The value + // did not resolve to a color, so a modifier would be silently ignored. + if (candidate.modifier) return + let value = theme.resolve(candidate.value.value, ['--stroke-width']) if (value) { return [decl('stroke-width', value)] @@ -4178,27 +4189,27 @@ export function createUtilities(theme: Theme) { { let cssFilterValue = [ - 'var(--tw-blur,)', - 'var(--tw-brightness,)', - 'var(--tw-contrast,)', - 'var(--tw-grayscale,)', - 'var(--tw-hue-rotate,)', - 'var(--tw-invert,)', - 'var(--tw-saturate,)', - 'var(--tw-sepia,)', - 'var(--tw-drop-shadow,)', + 'var(--tw-blur, )', + 'var(--tw-brightness, )', + 'var(--tw-contrast, )', + 'var(--tw-grayscale, )', + 'var(--tw-hue-rotate, )', + 'var(--tw-invert, )', + 'var(--tw-saturate, )', + 'var(--tw-sepia, )', + 'var(--tw-drop-shadow, )', ].join(' ') let cssBackdropFilterValue = [ - 'var(--tw-backdrop-blur,)', - 'var(--tw-backdrop-brightness,)', - 'var(--tw-backdrop-contrast,)', - 'var(--tw-backdrop-grayscale,)', - 'var(--tw-backdrop-hue-rotate,)', - 'var(--tw-backdrop-invert,)', - 'var(--tw-backdrop-opacity,)', - 'var(--tw-backdrop-saturate,)', - 'var(--tw-backdrop-sepia,)', + 'var(--tw-backdrop-blur, )', + 'var(--tw-backdrop-brightness, )', + 'var(--tw-backdrop-contrast, )', + 'var(--tw-backdrop-grayscale, )', + 'var(--tw-backdrop-hue-rotate, )', + 'var(--tw-backdrop-invert, )', + 'var(--tw-backdrop-opacity, )', + 'var(--tw-backdrop-saturate, )', + 'var(--tw-backdrop-sepia, )', ].join(' ') let filterProperties = () => { @@ -4628,6 +4639,7 @@ export function createUtilities(theme: Theme) { let value = theme.get(['--drop-shadow']) let resolved = theme.resolve(null, ['--drop-shadow']) if (value === null || resolved === null) return + if (candidate.modifier && !alpha) return return [ filterProperties(), @@ -4941,7 +4953,7 @@ export function createUtilities(theme: Theme) { { let cssContainValue = - 'var(--tw-contain-size,) var(--tw-contain-layout,) var(--tw-contain-paint,) var(--tw-contain-style,)' + 'var(--tw-contain-size, ) var(--tw-contain-layout, ) var(--tw-contain-paint, ) var(--tw-contain-style, )' let cssContainProperties = () => { return atRoot([ property('--tw-contain-size'), @@ -5035,7 +5047,7 @@ export function createUtilities(theme: Theme) { { let cssFontVariantNumericValue = - 'var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)' + 'var(--tw-ordinal, ) var(--tw-slashed-zero, ) var(--tw-numeric-figure, ) var(--tw-numeric-spacing, ) var(--tw-numeric-fraction, )' let fontVariantNumericProperties = () => { return atRoot([ property('--tw-ordinal'), @@ -5436,6 +5448,7 @@ export function createUtilities(theme: Theme) { if (!candidate.value) { let value = theme.get(['--text-shadow']) if (value === null) return + if (candidate.modifier && !alpha) return return [ textShadowProperties(), @@ -5463,6 +5476,8 @@ export function createUtilities(theme: Theme) { ] } default: { + if (candidate.modifier && !alpha) return + return [ textShadowProperties(), decl('--tw-text-shadow-alpha', alpha), @@ -5491,6 +5506,8 @@ export function createUtilities(theme: Theme) { { let value = theme.get([`--text-shadow-${candidate.value.value}`]) if (value) { + if (candidate.modifier && !alpha) return + return [ textShadowProperties(), decl('--tw-text-shadow-alpha', alpha), @@ -5582,6 +5599,7 @@ export function createUtilities(theme: Theme) { if (!candidate.value) { let value = theme.get(['--shadow']) if (value === null) return + if (candidate.modifier && !alpha) return return [ boxShadowProperties(), @@ -5611,6 +5629,8 @@ export function createUtilities(theme: Theme) { ] } default: { + if (candidate.modifier && !alpha) return + return [ boxShadowProperties(), decl('--tw-shadow-alpha', alpha), @@ -5644,6 +5664,8 @@ export function createUtilities(theme: Theme) { { let value = theme.get([`--shadow-${candidate.value.value}`]) if (value) { + if (candidate.modifier && !alpha) return + return [ boxShadowProperties(), decl('--tw-shadow-alpha', alpha), @@ -5708,6 +5730,7 @@ export function createUtilities(theme: Theme) { if (!candidate.value) { let value = theme.get(['--inset-shadow']) if (value === null) return + if (candidate.modifier && !alpha) return return [ boxShadowProperties(), @@ -5737,6 +5760,8 @@ export function createUtilities(theme: Theme) { ] } default: { + if (candidate.modifier && !alpha) return + return [ boxShadowProperties(), decl('--tw-inset-shadow-alpha', alpha), @@ -5772,6 +5797,8 @@ export function createUtilities(theme: Theme) { let value = theme.get([`--inset-shadow-${candidate.value.value}`]) if (value) { + if (candidate.modifier && !alpha) return + return [ boxShadowProperties(), decl('--tw-inset-shadow-alpha', alpha), @@ -5819,7 +5846,7 @@ export function createUtilities(theme: Theme) { let defaultRingColor = theme.get(['--default-ring-color']) ?? 'currentcolor' function ringShadowValue(value: string) { - return `var(--tw-ring-inset,) 0 0 0 calc(${value} + var(--tw-ring-offset-width)) var(--tw-ring-color, ${defaultRingColor})` + return `var(--tw-ring-inset, ) 0 0 0 calc(${value} + var(--tw-ring-offset-width)) var(--tw-ring-color, ${defaultRingColor})` } utilities.functional('ring', (candidate) => { if (!candidate.value) { @@ -5968,7 +5995,7 @@ export function createUtilities(theme: Theme) { ]) let ringOffsetShadowValue = - 'var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)' + 'var(--tw-ring-inset, ) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)' utilities.functional('ring-offset', (candidate) => { if (!candidate.value) return @@ -6193,6 +6220,15 @@ export function createCssUtility(node: AtRule) { let args = segment(ValueParser.toCss(fn.nodes), ',') for (let [idx, arg] of args.entries()) { + arg = arg.trim() + + // The value of `--default(…)` is emitted as-is, so it should not be + // normalized. + if (arg.startsWith('--default(')) { + args[idx] = arg + continue + } + // Transform escaped `\\*` -> `*` arg = arg.replace(/\\\*/g, '*') diff --git a/packages/tailwindcss/src/variants.test.ts b/packages/tailwindcss/src/variants.test.ts index 56b069b31..454f4c1bf 100644 --- a/packages/tailwindcss/src/variants.test.ts +++ b/packages/tailwindcss/src/variants.test.ts @@ -1435,6 +1435,17 @@ test('supports', async () => { 'supports-[font-tech(color-COLRv1)]:flex', 'supports-[var(--test)]:flex', 'supports-[--test]:flex', + + // Only top-level and/or/not should have spaces around them. We should + // ignore and/or/not inside of `selector(…)` + 'supports-[selector(a:not(.foo))]:flex', + 'supports-[selector(a:is(.and,.or))]:flex', + 'supports-[(display:grid)or(display:flex)]:grid', + 'supports-[not(display:grid)]:flex', + 'supports-[foo-not(display:grid)]:flex', + 'supports-[selector([data-foo="("])or(display:grid)]:flex', + 'supports-[selector(.foo\\(bar)or(display:grid)]:flex', + 'supports-[((display:grid)or(display:flex))]:grid', ]), ).toMatchInlineSnapshot(` " @@ -1444,12 +1455,24 @@ test('supports', async () => { } } + @supports (display: grid) or (display: flex) { + .supports-\\[\\(\\(display\\:grid\\)or\\(display\\:flex\\)\\)\\]\\:grid { + display: grid; + } + } + @supports (display: grid) and font-format(opentype) { .supports-\\[\\(display\\:grid\\)_and_font-format\\(opentype\\)\\]\\:grid { display: grid; } } + @supports (display: grid) or (display: flex) { + .supports-\\[\\(display\\:grid\\)or\\(display\\:flex\\)\\]\\:grid { + display: grid; + } + } + @supports (--test: var(--tw)) { .supports-\\[--test\\]\\:flex { display: flex; @@ -1474,12 +1497,48 @@ test('supports', async () => { } } + @supports foo-not(display:grid) { + .supports-\\[foo-not\\(display\\:grid\\)\\]\\:flex { + display: flex; + } + } + + @supports not (display: grid) { + .supports-\\[not\\(display\\:grid\\)\\]\\:flex { + display: flex; + } + } + + @supports selector(.foo\\(bar) or (display: grid) { + .supports-\\[selector\\(\\.foo\\\\\\(bar\\)or\\(display\\:grid\\)\\]\\:flex { + display: flex; + } + } + @supports selector(A > B) { .supports-\\[selector\\(A_\\>_B\\)\\]\\:flex { display: flex; } } + @supports selector([data-foo="("]) or (display: grid) { + .supports-\\[selector\\(\\[data-foo\\=\\"\\(\\"\\]\\)or\\(display\\:grid\\)\\]\\:flex { + display: flex; + } + } + + @supports selector(a:is(.and,.or)) { + .supports-\\[selector\\(a\\:is\\(\\.and\\,\\.or\\)\\)\\]\\:flex { + display: flex; + } + } + + @supports selector(a:not(.foo)) { + .supports-\\[selector\\(a\\:not\\(\\.foo\\)\\)\\]\\:flex { + display: flex; + } + } + @supports var(--test) { .supports-\\[var\\(--test\\)\\]\\:flex { display: flex; diff --git a/packages/tailwindcss/src/variants.ts b/packages/tailwindcss/src/variants.ts index d62f13c8f..c13c3ae12 100644 --- a/packages/tailwindcss/src/variants.ts +++ b/packages/tailwindcss/src/variants.ts @@ -3,6 +3,7 @@ import { atRoot, atRule, cloneAstNode, + context, decl, rule, styleRule, @@ -89,12 +90,14 @@ export class Variants { let selectors: string[] = [] let usesAtVariant = false + let usesAtScope = false walk(ast, (node) => { if (node.kind === 'rule') { selectors.push(node.selector) } else if (node.kind === 'at-rule' && node.name === '@variant') { usesAtVariant = true } else if (node.kind === 'at-rule' && node.name !== '@slot') { + if (node.name === '@scope') usesAtScope = true selectors.push(`${node.name} ${node.params}`) } }) @@ -104,8 +107,14 @@ export class Variants { (r) => { let body = ast.map(cloneAstNode) if (usesAtVariant) substituteAtVariant(body, designSystem) - substituteAtSlot(body, r.nodes) - r.nodes = body + + if (usesAtScope) { + substituteAtSlot(body, [context({ source: 'user' }, r.nodes)]) + r.nodes = [context({ source: 'variant' }, body)] + } else { + substituteAtSlot(body, r.nodes) + r.nodes = body + } }, { compounds: compoundsForSelectors(selectors) }, ) @@ -929,7 +938,22 @@ export function createVariants(theme: Theme): Variants { if (/^[\w-]*\s*\(/.test(value)) { // Chrome has a bug where `(condition1)or(condition2)` is not valid, but // `(condition1) or (condition2)` is supported. - let query = value.replace(/\b(and|or|not)\b/g, ' $1 ') + let changed = false + let ast = ValueParser.parse(value) + walk(ast, (node) => { + if (node.kind !== 'function') return + + // Leave selectors as-is, they could contain `selector(a:not(b))`, and + // in this case we don't want the space around the `not`. + if (node.value === 'selector') return WalkAction.Skip + + if (node.value === 'and' || node.value === 'or' || node.value === 'not') { + changed = true + node.value = ` ${node.value} ` + } + }) + + let query = changed ? ValueParser.toCss(ast) : value ruleNode.nodes = [atRule('@supports', query, ruleNode.nodes)] return diff --git a/packages/tailwindcss/tests/ui.spec.ts b/packages/tailwindcss/tests/ui.spec.ts index 889848c5c..70ba7bd0f 100644 --- a/packages/tailwindcss/tests/ui.spec.ts +++ b/packages/tailwindcss/tests/ui.spec.ts @@ -2177,6 +2177,216 @@ test('shadow DOM has access to variables', async ({ page }) => { expect(gap).toBe('8px') }) +// @scope related tests +{ + test('custom variant using `@scope`', async ({ page }) => { + let { getPropertyValue } = await render( + page, + html` +
+
In scope
+
+
Beyond the limit
+
+
+
Not in scope
+ `, + css` + @custom-variant in-checkout { + @scope (.checkout) to (.payment) { + @slot; + } + } + `, + ) + + test.skip(!(await supportsAtScope(page)), '`@scope` is not supported in this browser') + + expect(await getPropertyValue('#in', 'text-decoration-line')).toEqual('underline') + expect(await getPropertyValue('#beyond', 'text-decoration-line')).toEqual('none') + expect(await getPropertyValue('#out', 'text-decoration-line')).toEqual('none') + }) + + test('custom variant using `@scope` behaves like the equivalent authored CSS', async ({ + page, + }) => { + // Render the same structure twice: + // - Once styled by authored CSS using `@scope`, like the "normal CSS" + // reference in https://github.com/tailwindlabs/tailwindcss/issues/18961 + // - Once styled by utilities using an equivalent custom variant + function structure(prefix: string, probe: string) { + return html` +
+
In scope
+
+
Beyond the limit
+
+
+
Not in scope
+ ` + } + + let { getPropertyValue } = await render( + page, + structure('a', 'a-probe') + structure('c', 'in-checkout:underline'), + css` + @custom-variant in-checkout { + @scope (.checkout) to (.payment) { + @slot; + } + } + `, + ) + + // The `a-*` structure is styled by the authored CSS as-is + await page.addStyleTag({ + content: css` + @scope (.checkout) to (.payment) { + .a-probe { + text-decoration-line: underline; + } + } + `, + }) + + test.skip(!(await supportsAtScope(page)), '`@scope` is not supported in this browser') + + for (let prefix of ['a', 'c']) { + expect(await getPropertyValue(`#${prefix}-in`, 'text-decoration-line')).toEqual('underline') + expect(await getPropertyValue(`#${prefix}-beyond`, 'text-decoration-line')).toEqual('none') + expect(await getPropertyValue(`#${prefix}-out`, 'text-decoration-line')).toEqual('none') + } + }) + + test('user-authored nested `@scope` behaves like the authored CSS', async ({ page }) => { + // User-authored `@scope` rules are compiled to their native CSS nesting + // semantics. Rendering the same structure twice: + // - Once styled by the authored (nested) CSS as-is + // - Once styled by the compiled output + function structure(prefix: string) { + return html` +
+
+ in scope +
+ beyond the limit +
+
+
+
+ scope not inside parent +
+ ` + } + + function scopeCss(prefix: string) { + return css` + .${prefix}-parent { + @scope (& > .${prefix}-scope) to (& .${prefix}-limit) { + .${prefix}-content { + text-decoration-line: underline; + } + } + } + ` + } + + // The `c-*` structure is styled by the compiled CSS + let { getPropertyValue } = await render(page, structure('a') + structure('c'), scopeCss('c')) + + // The `a-*` structure is styled by the authored CSS as-is + await page.addStyleTag({ content: scopeCss('a') }) + + test.skip(!(await supportsAtScope(page)), '`@scope` is not supported in this browser') + + // The compiled output only applies within the scope + expect(await getPropertyValue('#c-in', 'text-decoration-line')).toEqual('underline') + expect(await getPropertyValue('#c-beyond', 'text-decoration-line')).toEqual('none') + expect(await getPropertyValue('#c-out', 'text-decoration-line')).toEqual('none') + + // The authored CSS behaves the same + expect(await getPropertyValue('#a-in', 'text-decoration-line')).toEqual('underline') + expect(await getPropertyValue('#a-out', 'text-decoration-line')).toEqual('none') + + // WebKit currently drops the `` limit entirely when it contains + // `&`. Since we resolve the `&` at build time, the compiled output works + // even though the authored CSS doesn't. + if (test.info().project.name !== 'webkit') { + expect(await getPropertyValue('#a-beyond', 'text-decoration-line')).toEqual('none') + } + }) + + test('user-authored `@scope` nested inside another `@scope`', async ({ page }) => { + function structure(prefix: string) { + return html` + +
+
+
ctrl
+
+
+ + +
+
+
ancestor
+
+
+ + +
+
+
+
beyond
+
+
+
+ ` + } + + function scopeCss(prefix: string) { + return css` + @scope (.${prefix}-outer) to (.${prefix}-outer-limit) { + .${prefix}-middle { + @scope (.${prefix}-inner) to (.${prefix}-inner-limit) { + text-decoration-line: underline; + } + } + } + ` + } + + // The `c-*` structure is styled by the compiled CSS + let { getPropertyValue } = await render(page, structure('a') + structure('c'), scopeCss('c')) + + // The `a-*` structure is styled by the authored CSS as-is + await page.addStyleTag({ content: scopeCss('a') }) + + test.skip(!(await supportsAtScope(page)), '`@scope` is not supported in this browser') + + // The compiled output behaves the same in every browser + expect(await getPropertyValue('#c-ctrl', 'text-decoration-line')).toEqual('underline') + expect(await getPropertyValue('#c-ancestor', 'text-decoration-line')).toEqual('none') + expect(await getPropertyValue('#c-beyond', 'text-decoration-line')).toEqual('none') + + // The authored CSS behaves the same … + expect(await getPropertyValue('#a-ctrl', 'text-decoration-line')).toEqual('underline') + expect(await getPropertyValue('#a-beyond', 'text-decoration-line')).toEqual('none') + + // … except in Chromium, which resolves the nesting context of a + // rule-nested `@scope` without the enclosing scope's constraint (Safari + // and Firefox both constrain it). The compiled output normalizes this + // browser difference. + if (test.info().project.name !== 'chromium') { + expect(await getPropertyValue('#a-ancestor', 'text-decoration-line')).toEqual('none') + } + }) + + function supportsAtScope(page: Page) { + return page.evaluate(() => typeof CSSScopeRule !== 'undefined') + } +} + // --- const preflight = fs.readFileSync(path.resolve(__dirname, '..', 'preflight.css'), 'utf-8') diff --git a/packages/tailwindcss/vitest.config.ts b/packages/tailwindcss/vitest.config.mts similarity index 100% rename from packages/tailwindcss/vitest.config.ts rename to packages/tailwindcss/vitest.config.mts diff --git a/patches/@napi-rs__cli@3.7.4.patch b/patches/@napi-rs__cli@3.7.4.patch new file mode 100644 index 000000000..e72212499 --- /dev/null +++ b/patches/@napi-rs__cli@3.7.4.patch @@ -0,0 +1,80 @@ +diff --git a/dist/cli.js b/dist/cli.js +index c4c2d568bc070c07dbf3a1f9f4a0c946466668cf..353bbca86e3261a2ba9a8fc56dc612c7c84dab6d 100755 +--- a/dist/cli.js ++++ b/dist/cli.js +@@ -1,4 +1,13 @@ + #!/usr/bin/env node ++// PATCHED (see patches/@napi-rs__cli@3.7.4.patch): the embedded wasi loader ++// and worker templates below retry `new WASI(...)` with narrower preopens, ++// because preopening `/` throws `UVWASI_EACCES` on sandboxed platforms (e.g. ++// OpenHarmony), which would make the generated wasm binding fail to load. ++// ++// The same templates also exist in `dist/index.js` and `dist/index.cjs` (the ++// programmatic API). Those are intentionally NOT patched because we only ++// build through the `napi` bin, which runs this file. If we ever start using ++// the programmatic API, update the patch to cover those bundles too. + import { createRequire } from "node:module"; + import { Cli, Command, Option } from "clipanion"; + import path, { basename, dirname, isAbsolute, join, parse, resolve } from "node:path"; +@@ -1021,13 +1030,25 @@ const { + + const __rootDir = __nodePath.parse(process.cwd()).root + +-const __wasi = new __nodeWASI({ +- version: 'preview1', +- env: process.env, +- preopens: { +- [__rootDir]: __rootDir, ++const __wasi = (() => { ++ // Preopening '/' fails with UVWASI_EACCES in sandboxed environments (e.g. ++ // OpenHarmony, Android), which would prevent the wasm binding from loading ++ // at all. Retry with narrower preopens instead. Without any preopens the ++ // binding still loads; only file system access is unavailable. ++ let lastError = null ++ for (const dir of [__rootDir, process.cwd(), null]) { ++ try { ++ return new __nodeWASI({ ++ version: 'preview1', ++ env: process.env, ++ preopens: dir === null ? {} : { [dir]: dir }, ++ }) ++ } catch (error) { ++ lastError = error ++ } + } +-}) ++ throw lastError ++})() + + const __emnapiContext = __emnapiGetDefaultContext() + +@@ -1151,13 +1172,22 @@ const __rootDir = parse(process.cwd()).root; + + const handler = new MessageHandler({ + onLoad({ wasmModule, wasmMemory }) { +- const wasi = new WASI({ +- version: 'preview1', +- env: process.env, +- preopens: { +- [__rootDir]: __rootDir, +- }, +- }); ++ // Keep in sync with the preopen fallback in the main-thread loader. ++ const wasi = (() => { ++ let lastError = null; ++ for (const dir of [__rootDir, process.cwd(), null]) { ++ try { ++ return new WASI({ ++ version: 'preview1', ++ env: process.env, ++ preopens: dir === null ? {} : { [dir]: dir }, ++ }); ++ } catch (error) { ++ lastError = error; ++ } ++ } ++ throw lastError; ++ })(); + + return instantiateNapiModuleSync(wasmModule, { + childThread: true, diff --git a/patches/@parcel__watcher@2.5.1.patch b/patches/@parcel__watcher@2.6.0.patch similarity index 79% rename from patches/@parcel__watcher@2.5.1.patch rename to patches/@parcel__watcher@2.6.0.patch index 9060d212c..37ff83f04 100644 --- a/patches/@parcel__watcher@2.5.1.patch +++ b/patches/@parcel__watcher@2.6.0.patch @@ -1,13 +1,14 @@ diff --git a/index.js b/index.js -index 8afb2b1126dcc687b7ff9b631589da252c1f9c22..f28fd092644ccfb8aff2618b966cb5219a181a02 100644 +index 78ee744d74900c39a839cafa46b295258fc86307..f28fd092644ccfb8aff2618b966cb5219a181a02 100644 --- a/index.js +++ b/index.js -@@ -1,40 +1,32 @@ +@@ -1,43 +1,32 @@ const {createWrapper} = require('./wrapper'); -let name = `@parcel/watcher-${process.platform}-${process.arch}`; -if (process.platform === 'linux') { -- const { MUSL, family } = require('detect-libc'); +- const {MUSL, familySync} = require('detect-libc'); +- const family = familySync(); - if (family === MUSL) { - name += '-musl'; - } else { @@ -39,7 +40,9 @@ index 8afb2b1126dcc687b7ff9b631589da252c1f9c22..f28fd092644ccfb8aff2618b966cb521 - binding = require('./build/Debug/watcher.node'); - } catch (err) { - handleError(err); -- throw new Error(`No prebuild or local build of @parcel/watcher found. Tried ${name}. Please ensure it is installed (don't use --no-optional when installing with npm). Otherwise it is possible we don't support your platform yet. If this is the case, please report an issue to https://github.com/parcel-bundler/watcher.`); +- throw new Error( +- `No prebuild or local build of @parcel/watcher found. Tried ${name}. Please ensure it is installed (don't use --no-optional when installing with npm). Otherwise it is possible we don't support your platform yet. If this is the case, please report an issue to https://github.com/parcel-bundler/watcher.`, +- ); - } - } -} diff --git a/patches/lightningcss@1.32.0.patch b/patches/lightningcss@1.33.0.patch similarity index 100% rename from patches/lightningcss@1.32.0.patch rename to patches/lightningcss@1.33.0.patch diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cd6a476fc..5936e1bdb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,105 +6,136 @@ settings: catalogs: default: + '@parcel/watcher': + specifier: 2.6.0 + version: 2.6.0 + '@parcel/watcher-darwin-arm64': + specifier: 2.6.0 + version: 2.6.0 + '@parcel/watcher-darwin-x64': + specifier: 2.6.0 + version: 2.6.0 + '@parcel/watcher-linux-arm64-glibc': + specifier: 2.6.0 + version: 2.6.0 + '@parcel/watcher-linux-arm64-musl': + specifier: 2.6.0 + version: 2.6.0 + '@parcel/watcher-linux-x64-glibc': + specifier: 2.6.0 + version: 2.6.0 + '@parcel/watcher-linux-x64-musl': + specifier: 2.6.0 + version: 2.6.0 + '@parcel/watcher-win32-x64': + specifier: 2.6.0 + version: 2.6.0 '@types/node': - specifier: 22.19.21 - version: 22.19.21 + specifier: 22.20.1 + version: 22.20.1 dedent: specifier: 1.7.2 version: 1.7.2 enhanced-resolve: - specifier: ^5.24.1 - version: 5.24.1 + specifier: ^5.24.5 + version: 5.24.5 lightningcss: - specifier: 1.32.0 - version: 1.32.0 + specifier: 1.33.0 + version: 1.33.0 lightningcss-darwin-arm64: - specifier: 1.32.0 - version: 1.32.0 + specifier: 1.33.0 + version: 1.33.0 lightningcss-darwin-x64: - specifier: 1.32.0 - version: 1.32.0 + specifier: 1.33.0 + version: 1.33.0 lightningcss-linux-arm64-gnu: - specifier: 1.32.0 - version: 1.32.0 + specifier: 1.33.0 + version: 1.33.0 lightningcss-linux-arm64-musl: - specifier: 1.32.0 - version: 1.32.0 + specifier: 1.33.0 + version: 1.33.0 lightningcss-linux-x64-gnu: - specifier: 1.32.0 - version: 1.32.0 + specifier: 1.33.0 + version: 1.33.0 lightningcss-linux-x64-musl: - specifier: 1.32.0 - version: 1.32.0 + specifier: 1.33.0 + version: 1.33.0 lightningcss-win32-x64-msvc: - specifier: 1.32.0 - version: 1.32.0 + specifier: 1.33.0 + version: 1.33.0 postcss: - specifier: ^8.5.16 - version: 8.5.16 + specifier: ^8.5.25 + version: 8.5.25 prettier: - specifier: 3.9.4 - version: 3.9.4 + specifier: 3.9.6 + version: 3.9.6 vite: - specifier: ^8.1.2 - version: 8.1.2 + specifier: ^8.2.0 + version: 8.2.0 webpack: - specifier: ^5.108.3 - version: 5.108.3 + specifier: ^5.109.2 + version: 5.109.2 patchedDependencies: - '@parcel/watcher@2.5.1': c22241764997c5af4980d3be22550ae47858f14849dca486726653fa127eb69c - lightningcss@1.32.0: 1d4a8800d60d13d42887b88b3a86576df4b451670308145fb432ec8abbf40930 + '@napi-rs/cli@3.7.4': 9912bf0a9c2cef8329d11c41fbce4d33ce2bdcf660a630b258c962c0a3c44bed + '@parcel/watcher@2.6.0': 705ce75ccea54337110c4d7fe0a8b421658ea0c23e1a77c1fa890a86041179da + lightningcss@1.33.0: 1d4a8800d60d13d42887b88b3a86576df4b451670308145fb432ec8abbf40930 importers: .: devDependencies: '@playwright/test': - specifier: ^1.61.1 - version: 1.61.1 + specifier: ^1.62.1 + version: 1.62.1 '@types/node': specifier: 'catalog:' - version: 22.19.21 + version: 22.20.1 postcss: specifier: 'catalog:' - version: 8.5.16 + version: 8.5.25 postcss-import: specifier: ^16.1.1 - version: 16.1.1(postcss@8.5.16) + version: 16.1.1(postcss@8.5.25) prettier: specifier: 'catalog:' - version: 3.9.4 + version: 3.9.6 prettier-plugin-embed: specifier: ^0.5.1 version: 0.5.1 prettier-plugin-organize-imports: specifier: ^4.3.0 - version: 4.3.0(prettier@3.9.4)(typescript@5.9.3) + version: 4.3.0(prettier@3.9.6)(typescript@5.9.3) tsup: specifier: ^8.5.1 - version: 8.5.1(jiti@2.7.0)(postcss@8.5.16)(typescript@5.9.3)(yaml@2.9.0) + version: 8.5.1(jiti@2.7.0)(postcss@8.5.25)(typescript@5.9.3)(yaml@2.9.0) turbo: - specifier: ^2.9.18 - version: 2.9.18 + specifier: ^2.10.8 + version: 2.10.8 typescript: specifier: ^5.9.3 version: 5.9.3 vitest: - specifier: ^4.1.9 - version: 4.1.9(@types/node@22.19.21)(vite@8.1.2(@types/node@22.19.21)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) + specifier: ^4.1.10 + version: 4.1.10(@types/node@22.20.1)(vite@8.2.0(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) crates/node: devDependencies: + '@emnapi/core': + specifier: 1.11.3 + version: 1.11.3 + '@emnapi/runtime': + specifier: 1.11.3 + version: 1.11.3 '@napi-rs/cli': - specifier: 3.7.0 - version: 3.7.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@25.9.1)(node-addon-api@8.7.0) + specifier: 3.7.4 + version: 3.7.4(patch_hash=9912bf0a9c2cef8329d11c41fbce4d33ce2bdcf660a630b258c962c0a3c44bed)(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@25.9.1)(node-addon-api@8.7.0) '@napi-rs/wasm-runtime': - specifier: ^1.1.5 - version: 1.1.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + specifier: ^1.2.2 + version: 1.2.2(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) emnapi: - specifier: 1.11.1 - version: 1.11.1(node-addon-api@8.7.0) + specifier: 1.11.3 + version: 1.11.3(node-addon-api@8.7.0) optionalDependencies: '@tailwindcss/oxide-android-arm64': specifier: workspace:* @@ -171,20 +202,20 @@ importers: crates/node/npm/wasm32-wasi: dependencies: '@emnapi/core': - specifier: ^1.11.1 - version: 1.11.1 + specifier: ^1.11.3 + version: 1.11.3 '@emnapi/runtime': - specifier: ^1.11.1 - version: 1.11.1 + specifier: ^1.11.3 + version: 1.11.3 '@emnapi/wasi-threads': - specifier: ^1.2.2 - version: 1.2.2 + specifier: ^1.2.3 + version: 1.2.3 '@napi-rs/wasm-runtime': - specifier: ^1.1.4 - version: 1.1.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + specifier: ^1.2.2 + version: 1.2.2(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) '@tybys/wasm-util': - specifier: ^0.10.2 - version: 0.10.2 + specifier: ^0.10.3 + version: 0.10.3 tslib: specifier: ^2.8.1 version: 2.8.1 @@ -214,8 +245,8 @@ importers: specifier: ^1.15.11 version: 1.15.11 listhen: - specifier: ^1.10.0 - version: 1.10.0 + specifier: ^1.10.1 + version: 1.10.1(@parcel/watcher@2.6.0(patch_hash=705ce75ccea54337110c4d7fe0a8b421658ea0c23e1a77c1fa890a86041179da)) tailwindcss: specifier: workspace:* version: link:../tailwindcss @@ -223,8 +254,8 @@ importers: packages/@tailwindcss-cli: dependencies: '@parcel/watcher': - specifier: 2.5.1 - version: 2.5.1(patch_hash=c22241764997c5af4980d3be22550ae47858f14849dca486726653fa127eb69c) + specifier: 'catalog:' + version: 2.6.0(patch_hash=705ce75ccea54337110c4d7fe0a8b421658ea0c23e1a77c1fa890a86041179da) '@tailwindcss/node': specifier: workspace:* version: link:../@tailwindcss-node @@ -233,7 +264,7 @@ importers: version: link:../../crates/node enhanced-resolve: specifier: 'catalog:' - version: 5.24.1 + version: 5.24.5 mri: specifier: ^1.2.0 version: 1.2.0 @@ -251,16 +282,16 @@ importers: version: 2.3.5 enhanced-resolve: specifier: 'catalog:' - version: 5.24.1 + version: 5.24.5 jiti: specifier: ^2.7.0 version: 2.7.0 lightningcss: specifier: 'catalog:' - version: 1.32.0(patch_hash=1d4a8800d60d13d42887b88b3a86576df4b451670308145fb432ec8abbf40930) + version: 1.33.0(patch_hash=1d4a8800d60d13d42887b88b3a86576df4b451670308145fb432ec8abbf40930) magic-string: - specifier: ^0.30.21 - version: 0.30.21 + specifier: ^1.1.0 + version: 1.1.0 source-map-js: specifier: ^1.2.1 version: 1.2.1 @@ -281,14 +312,14 @@ importers: version: link:../../crates/node postcss: specifier: 'catalog:' - version: 8.5.16 + version: 8.5.25 tailwindcss: specifier: workspace:* version: link:../tailwindcss devDependencies: '@types/node': specifier: 'catalog:' - version: 22.19.21 + version: 22.20.1 '@types/postcss-import': specifier: 14.0.3 version: 14.0.3 @@ -300,7 +331,7 @@ importers: version: link:../internal-example-plugin postcss-import: specifier: ^16.1.1 - version: 16.1.1(postcss@8.5.16) + version: 16.1.1(postcss@8.5.25) packages/@tailwindcss-standalone: dependencies: @@ -321,32 +352,32 @@ importers: version: 1.0.3 enhanced-resolve: specifier: 'catalog:' - version: 5.24.1 + version: 5.24.5 tailwindcss: specifier: workspace:* version: link:../tailwindcss devDependencies: '@parcel/watcher-darwin-arm64': - specifier: ^2.5.6 - version: 2.5.6 + specifier: 'catalog:' + version: 2.6.0 '@parcel/watcher-darwin-x64': - specifier: ^2.5.6 - version: 2.5.6 + specifier: 'catalog:' + version: 2.6.0 '@parcel/watcher-linux-arm64-glibc': - specifier: ^2.5.6 - version: 2.5.6 + specifier: 'catalog:' + version: 2.6.0 '@parcel/watcher-linux-arm64-musl': - specifier: ^2.5.6 - version: 2.5.6 + specifier: 'catalog:' + version: 2.6.0 '@parcel/watcher-linux-x64-glibc': - specifier: ^2.5.6 - version: 2.5.6 + specifier: 'catalog:' + version: 2.6.0 '@parcel/watcher-linux-x64-musl': - specifier: ^2.5.6 - version: 2.5.6 + specifier: 'catalog:' + version: 2.6.0 '@parcel/watcher-win32-x64': - specifier: ^2.5.6 - version: 2.5.6 + specifier: 'catalog:' + version: 2.6.0 '@types/bun': specifier: ^1.3.14 version: 1.3.14 @@ -355,25 +386,47 @@ importers: version: 1.3.14 lightningcss-darwin-arm64: specifier: 'catalog:' - version: 1.32.0 + version: 1.33.0 lightningcss-darwin-x64: specifier: 'catalog:' - version: 1.32.0 + version: 1.33.0 lightningcss-linux-arm64-gnu: specifier: 'catalog:' - version: 1.32.0 + version: 1.33.0 lightningcss-linux-arm64-musl: specifier: 'catalog:' - version: 1.32.0 + version: 1.33.0 lightningcss-linux-x64-gnu: specifier: 'catalog:' - version: 1.32.0 + version: 1.33.0 lightningcss-linux-x64-musl: specifier: 'catalog:' - version: 1.32.0 + version: 1.33.0 lightningcss-win32-x64-msvc: specifier: 'catalog:' - version: 1.32.0 + version: 1.33.0 + + packages/@tailwindcss-turbopack: + dependencies: + '@alloc/quick-lru': + specifier: ^5.2.0 + version: 5.2.0 + '@tailwindcss/node': + specifier: workspace:* + version: link:../@tailwindcss-node + '@tailwindcss/oxide': + specifier: workspace:* + version: link:../../crates/node + tailwindcss: + specifier: workspace:* + version: link:../tailwindcss + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 22.20.1 + webpack: + specifier: 'catalog:' + version: 5.109.2(esbuild@0.27.7)(postcss@8.5.25) packages/@tailwindcss-upgrade: dependencies: @@ -388,10 +441,10 @@ importers: version: 1.7.2 enhanced-resolve: specifier: 'catalog:' - version: 5.24.1 + version: 5.24.5 globby: - specifier: ^16.2.0 - version: 16.2.0 + specifier: ^16.2.2 + version: 16.2.2 jiti: specifier: ^2.7.0 version: 2.7.0 @@ -403,38 +456,38 @@ importers: version: 1.1.1 postcss: specifier: 'catalog:' - version: 8.5.16 + version: 8.5.25 postcss-import: specifier: ^16.1.1 - version: 16.1.1(postcss@8.5.16) + version: 16.1.1(postcss@8.5.25) postcss-selector-parser: specifier: ^7.1.4 version: 7.1.4 prettier: specifier: 'catalog:' - version: 3.9.4 + version: 3.9.6 semver: - specifier: ^7.8.4 - version: 7.8.4 + specifier: ^7.8.5 + version: 7.8.5 tailwindcss: specifier: workspace:* version: link:../tailwindcss tree-sitter: - specifier: ^0.22.4 - version: 0.22.4 + specifier: ^0.25.1 + version: 0.25.1 tree-sitter-typescript: specifier: ^0.23.2 - version: 0.23.2(tree-sitter@0.22.4) + version: 0.23.2(tree-sitter@0.25.1) devDependencies: '@types/node': specifier: 'catalog:' - version: 22.19.21 + version: 22.20.1 '@types/postcss-import': specifier: ^14.0.3 version: 14.0.3 '@types/semver': - specifier: ^7.7.1 - version: 7.7.1 + specifier: ^7.8.0 + version: 7.8.0 packages/@tailwindcss-vite: dependencies: @@ -450,10 +503,10 @@ importers: devDependencies: '@types/node': specifier: 'catalog:' - version: 22.19.21 + version: 22.20.1 vite: specifier: 'catalog:' - version: 8.1.2(@types/node@22.19.21)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) + version: 8.2.0(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) packages/@tailwindcss-webpack: dependencies: @@ -475,10 +528,10 @@ importers: devDependencies: '@types/node': specifier: 'catalog:' - version: 22.19.21 + version: 22.20.1 webpack: specifier: 'catalog:' - version: 5.108.3(esbuild@0.27.7)(postcss@8.5.16) + version: 5.109.2(esbuild@0.27.7)(postcss@8.5.25) packages/internal-example-plugin: {} @@ -492,16 +545,16 @@ importers: version: link:../../crates/node '@types/node': specifier: 'catalog:' - version: 22.19.21 + version: 22.20.1 dedent: specifier: 'catalog:' version: 1.7.2 lightningcss: specifier: 'catalog:' - version: 1.32.0(patch_hash=1d4a8800d60d13d42887b88b3a86576df4b451670308145fb432ec8abbf40930) + version: 1.33.0(patch_hash=1d4a8800d60d13d42887b88b3a86576df4b451670308145fb432ec8abbf40930) magic-string: - specifier: ^0.30.21 - version: 0.30.21 + specifier: ^1.1.0 + version: 1.1.0 source-map-js: specifier: ^1.2.1 version: 1.2.1 @@ -516,7 +569,7 @@ importers: version: 3.3.3 next: specifier: 16.2.7 - version: 16.2.7(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 16.2.7(@playwright/test@1.62.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: specifier: 19.2.7 version: 19.2.7 @@ -529,7 +582,7 @@ importers: devDependencies: '@types/node': specifier: 'catalog:' - version: 22.19.21 + version: 22.20.1 '@types/react': specifier: 19.2.15 version: 19.2.15 @@ -544,7 +597,7 @@ importers: dependencies: next: specifier: 16.2.7 - version: 16.2.7(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 16.2.7(@playwright/test@1.62.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react: specifier: 19.2.7 version: 19.2.7 @@ -557,7 +610,7 @@ importers: devDependencies: '@types/node': specifier: 'catalog:' - version: 22.19.21 + version: 22.20.1 '@types/react': specifier: 19.2.15 version: 19.2.15 @@ -566,7 +619,7 @@ importers: version: 19.2.3(@types/react@19.2.15) autoprefixer: specifier: ^10.5.0 - version: 10.5.0(postcss@8.5.16) + version: 10.5.0(postcss@8.5.25) typescript: specifier: ^6.0.3 version: 6.0.3 @@ -578,7 +631,7 @@ importers: version: link:../../packages/@tailwindcss-vite '@vitejs/plugin-react': specifier: ^6.0.2 - version: 6.0.2(vite@8.1.2(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) + version: 6.0.2(vite@8.2.0(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) react: specifier: ^19.2.6 version: 19.2.6 @@ -600,7 +653,7 @@ importers: version: 1.3.14 vite: specifier: 'catalog:' - version: 8.1.2(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) + version: 8.2.0(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) packages: @@ -611,20 +664,29 @@ packages: '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} - '@emnapi/core@1.11.1': - resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + '@emnapi/core@1.11.3': + resolution: {integrity: sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==} + + '@emnapi/core@2.0.0-alpha.3': + resolution: {integrity: sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==} '@emnapi/runtime@1.10.0': resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} - '@emnapi/runtime@1.11.1': - resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + + '@emnapi/runtime@2.0.0-alpha.3': + resolution: {integrity: sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==} '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} - '@emnapi/wasi-threads@1.2.2': - resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@emnapi/wasi-threads@1.2.3': + resolution: {integrity: sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==} + + '@emnapi/wasi-threads@2.0.1': + resolution: {integrity: sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==} '@esbuild/aix-ppc64@0.27.7': resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} @@ -935,134 +997,134 @@ packages: cpu: [x64] os: [win32] - '@inquirer/ansi@2.0.5': - resolution: {integrity: sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + '@inquirer/ansi@2.0.7': + resolution: {integrity: sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - '@inquirer/checkbox@5.1.5': - resolution: {integrity: sha512-Jmf9tgBHIEK5SAOB7swYfStqmtkZb00xOTpSQmkoGEpdxOTpJi9RS0A8bkfDPHTTItZRJrRdZrEMu25wyj0VfQ==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + '@inquirer/checkbox@5.2.1': + resolution: {integrity: sha512-b6xmA/VlTe0ZgDQHDui+Nav470u7u49nRd8/iuhOcQPO9Ch7lGuogydhi2VOmNlZ+zXcM8IcPuNSwQcdJaF/kw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@types/node': optional: true - '@inquirer/confirm@6.0.13': - resolution: {integrity: sha512-wkGPC7yJ5WJk1DJ5SX7fzk+gfj4BM8cf5dDDi71B/551xHrdsZVRJOC0WyikXd0pEsb/9cLniuE4atbsMqmFkw==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + '@inquirer/confirm@6.1.1': + resolution: {integrity: sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@types/node': optional: true - '@inquirer/core@11.1.10': - resolution: {integrity: sha512-a4Q5BXHQAHa9eO202sTaFCHFYVB3x5fauDuThEAdZ9gfn76pSxiKU7wWcEH0N1O0XmQvNfQNU6QXpiRxmYQx+A==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + '@inquirer/core@11.2.1': + resolution: {integrity: sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@types/node': optional: true - '@inquirer/editor@5.1.2': - resolution: {integrity: sha512-Y3Nor7S/DhIPo+8Ym/dSY4efwKI4BsflKDwXh0jNeXJsSF3dteS/3Yf+z4wkibVZDvYMyCgknSTQlNahfunGHg==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + '@inquirer/editor@5.2.2': + resolution: {integrity: sha512-ZRVd/oD+sYsUd5zVm0NflqEzlqfYCyHNsqkHl2oWXEUHs12tCbcSFi+wVFEvD8+LGRaMUsVrE7qeo6lSG/S1Vg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@types/node': optional: true - '@inquirer/expand@5.0.14': - resolution: {integrity: sha512-qyY9zcIX2eKYwaAUiQo9zORd61Lc3sXeM72fVbeHkYnDkqfr8/armcRbmVAIrExeJhI2puk+uomeKtWrpUVUmQ==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + '@inquirer/expand@5.1.1': + resolution: {integrity: sha512-YmQpenjbFSHAK3sOd44puHh3V1KXXr+JiNpUztoSQ4drLh2rTVzTap/YtlAVu/5xavifIlBfNEzJ/neZJ1a/1g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@types/node': optional: true - '@inquirer/external-editor@3.0.0': - resolution: {integrity: sha512-lDSwMgg+M5rq6JKBYaJwSX6T9e/HK2qqZ1oxmOwn4AQoJE5D+7TumsxLGC02PWS//rkIVqbZv3XA3ejsc9FYvg==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + '@inquirer/external-editor@3.0.3': + resolution: {integrity: sha512-6thf5I8q7lZwzGLAxPaaGEREEkZ3nyePPDQ1oyobblxmEE8mqTLguScP7pDjUTAibiyb4hfXl+qjUEJ+di/aNA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@types/node': optional: true - '@inquirer/figures@2.0.5': - resolution: {integrity: sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + '@inquirer/figures@2.0.7': + resolution: {integrity: sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} - '@inquirer/input@5.0.13': - resolution: {integrity: sha512-0l0jCHlJnXIV8CTxwQC0C+5Ziq8WP22edWgmciW2xYvoeoSck4v5FvCS1ctKdqLLR0dUo93uAHgWHywgBSoRyw==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + '@inquirer/input@5.1.2': + resolution: {integrity: sha512-9K/DDBSQpOyZSkt6sOVP9Vo0TR7atX2kuILsUu0x3wVcVbe97lJwIJKMLdMw25tDYuXl/qp6erT0Xs1rfmcfZg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@types/node': optional: true - '@inquirer/number@4.0.13': - resolution: {integrity: sha512-WHmkYnnJAou5gx7RgcvAfUggnHNM1zWfoh0dFPl3dxVssuqt+dK5rIbaOYQXNyOegvFnopbKupjnhw2O8gANNg==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + '@inquirer/number@4.1.1': + resolution: {integrity: sha512-XF4IXAbPnGPgw0wsbC/i2tPcyfdZgDpUlhsqU0SfT4IRIGWha6Xm9VRgN5yYxJq+jnyXlfXI/nQ3ulfk0iEICA==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@types/node': optional: true - '@inquirer/password@5.0.13': - resolution: {integrity: sha512-XDGu64ROHZjOOXLAANvJN7iIxWKhOSCG5VakrZ5kaScVR+snVJCFglD/hL3/677awtWcu4pXoWa280CDIYcBeg==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + '@inquirer/password@5.1.1': + resolution: {integrity: sha512-3XBfF7DAsp5qeDsvN5Rd1HmbNokVvEQoUM0QLrRcybC9nX96w3Pbmu7qUsb3IT3J3jBvs2+mTXaKHOUsgHMLzg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@types/node': optional: true - '@inquirer/prompts@8.4.3': - resolution: {integrity: sha512-ai5LseTw9HhegupIgmo4cn7RpnCGznjjXu4OI+7jMR8vu7T1ZCCNMzFFAovUCjL1fl0cceksIN1++yQE59SmZw==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + '@inquirer/prompts@8.5.2': + resolution: {integrity: sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@types/node': optional: true - '@inquirer/rawlist@5.2.9': - resolution: {integrity: sha512-a1ErXEfgjfPYpyQ89dp+7n2IISjH9oQg3ygvF5adz8B7aHn4n2PjEgu1wpVTp69K3bj3lVLxP0qJ2b1clk1Whw==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + '@inquirer/rawlist@5.3.1': + resolution: {integrity: sha512-QqdTqQddL3qPX/PPrjobpsO25NZ4dWXgTLenrR445L2ptLEYE6Z+PD5c5CNDJNx4ugRgELAIpSIJxZaO2jJ2Og==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@types/node': optional: true - '@inquirer/search@4.1.9': - resolution: {integrity: sha512-ZlbM28Q9lmLkFPNAIv+ZuY530n5Km8U1WW48oYEvDhe9yc2uL3m3t+JSdRUkQlk5fuIuskgiIVjcb7czFzQpuA==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + '@inquirer/search@4.2.1': + resolution: {integrity: sha512-xJj8QWKRSrfKoBIITLZK61dD3zwo0Rz11fgDImku30/Oe81zMdIdGgrLY2h6RkJ+KZ/GhNYIRMKnH/62qBTA5g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@types/node': optional: true - '@inquirer/select@5.1.5': - resolution: {integrity: sha512-6SRg6kHfK/sjLXOsuqNebuir+sjwrf/iWuRUnXgB2slzEewppI1WfzeS16XxDcOQmXBruMmmB9Cgrz7wsAxqMg==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + '@inquirer/select@5.2.1': + resolution: {integrity: sha512-FlDndEUww8m7BfukO2nJa25vhD+H5jxxCv4oGioKqzyWz3nPHhhw4LKdYRSlXuAx7DsdWia7iyaBPKKS95Evfw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@types/node': optional: true - '@inquirer/type@4.0.5': - resolution: {integrity: sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q==} - engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} + '@inquirer/type@4.0.7': + resolution: {integrity: sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: @@ -1088,8 +1150,8 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@napi-rs/cli@3.7.0': - resolution: {integrity: sha512-3d3+rmxlOIV/G1zPWeX4PCxuYnhcCQM2BvY9rtimC8RO0dFR9gtYP+Grov+WoduZtfWRj5N1XvytWeRxxCk5zw==} + '@napi-rs/cli@3.7.4': + resolution: {integrity: sha512-idELIUceJ9zPgx/shcMt1fSSsteFG68v56RIXi4qUm7OYuJOt7CoMj2KnHVmbOqXgUHWQU1lCULrtQdZDG4F5A==} engines: {node: '>= 16'} hasBin: true peerDependencies: @@ -1356,17 +1418,12 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 - '@napi-rs/wasm-runtime@1.1.5': - resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} + '@napi-rs/wasm-runtime@1.2.2': + resolution: {integrity: sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} peerDependencies: - '@emnapi/core': ^1.7.1 - '@emnapi/runtime': ^1.7.1 - - '@napi-rs/wasm-runtime@1.1.6': - resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} - peerDependencies: - '@emnapi/core': ^1.7.1 - '@emnapi/runtime': ^1.7.1 + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.3 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.3 '@napi-rs/wasm-tools-android-arm-eabi@1.0.1': resolution: {integrity: sha512-lr07E/l571Gft5v4aA1dI8koJEmF1F0UigBbsqg9OWNzg80H3lDPO+auv85y3T/NHE3GirDk7x/D3sLO57vayw==} @@ -1652,133 +1709,67 @@ packages: cpu: [x64] os: [win32] - '@oxc-project/types@0.137.0': - resolution: {integrity: sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==} + '@oxc-project/types@0.142.0': + resolution: {integrity: sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==} - '@parcel/watcher-android-arm64@2.5.1': - resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==} + '@parcel/watcher-android-arm64@2.6.0': + resolution: {integrity: sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [android] - '@parcel/watcher-android-arm64@2.5.6': - resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [android] - - '@parcel/watcher-darwin-arm64@2.5.1': - resolution: {integrity: sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==} + '@parcel/watcher-darwin-arm64@2.6.0': + resolution: {integrity: sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [darwin] - '@parcel/watcher-darwin-arm64@2.5.6': - resolution: {integrity: sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [darwin] - - '@parcel/watcher-darwin-x64@2.5.1': - resolution: {integrity: sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==} + '@parcel/watcher-darwin-x64@2.6.0': + resolution: {integrity: sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [darwin] - '@parcel/watcher-darwin-x64@2.5.6': - resolution: {integrity: sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [darwin] - - '@parcel/watcher-freebsd-x64@2.5.1': - resolution: {integrity: sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==} + '@parcel/watcher-freebsd-x64@2.6.0': + resolution: {integrity: sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [freebsd] - '@parcel/watcher-freebsd-x64@2.5.6': - resolution: {integrity: sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [freebsd] - - '@parcel/watcher-linux-arm-glibc@2.5.1': - resolution: {integrity: sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==} + '@parcel/watcher-linux-arm-glibc@2.6.0': + resolution: {integrity: sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] libc: [glibc] - '@parcel/watcher-linux-arm-glibc@2.5.6': - resolution: {integrity: sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==} - engines: {node: '>= 10.0.0'} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@parcel/watcher-linux-arm-musl@2.5.1': - resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==} + '@parcel/watcher-linux-arm-musl@2.6.0': + resolution: {integrity: sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] libc: [musl] - '@parcel/watcher-linux-arm-musl@2.5.6': - resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} - engines: {node: '>= 10.0.0'} - cpu: [arm] - os: [linux] - libc: [musl] - - '@parcel/watcher-linux-arm64-glibc@2.5.1': - resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@parcel/watcher-linux-arm64-glibc@2.5.6': - resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} + '@parcel/watcher-linux-arm64-glibc@2.6.0': + resolution: {integrity: sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - '@parcel/watcher-linux-arm64-musl@2.5.1': - resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@parcel/watcher-linux-arm64-musl@2.5.6': - resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} + '@parcel/watcher-linux-arm64-musl@2.6.0': + resolution: {integrity: sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - '@parcel/watcher-linux-x64-glibc@2.5.1': - resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@parcel/watcher-linux-x64-glibc@2.5.6': - resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} + '@parcel/watcher-linux-x64-glibc@2.6.0': + resolution: {integrity: sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - '@parcel/watcher-linux-x64-musl@2.5.1': - resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [linux] - libc: [musl] - - '@parcel/watcher-linux-x64-musl@2.5.6': - resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} + '@parcel/watcher-linux-x64-musl@2.6.0': + resolution: {integrity: sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] @@ -1789,146 +1780,117 @@ packages: bundledDependencies: - napi-wasm - '@parcel/watcher-win32-arm64@2.5.1': - resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==} + '@parcel/watcher-win32-arm64@2.6.0': + resolution: {integrity: sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [win32] - '@parcel/watcher-win32-arm64@2.5.6': - resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} - engines: {node: '>= 10.0.0'} - cpu: [arm64] - os: [win32] - - '@parcel/watcher-win32-ia32@2.5.1': - resolution: {integrity: sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==} - engines: {node: '>= 10.0.0'} - cpu: [ia32] - os: [win32] - - '@parcel/watcher-win32-ia32@2.5.6': - resolution: {integrity: sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==} - engines: {node: '>= 10.0.0'} - cpu: [ia32] - os: [win32] - - '@parcel/watcher-win32-x64@2.5.1': - resolution: {integrity: sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==} + '@parcel/watcher-win32-x64@2.6.0': + resolution: {integrity: sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [win32] - '@parcel/watcher-win32-x64@2.5.6': - resolution: {integrity: sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==} - engines: {node: '>= 10.0.0'} - cpu: [x64] - os: [win32] - - '@parcel/watcher@2.5.1': - resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==} + '@parcel/watcher@2.6.0': + resolution: {integrity: sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==} engines: {node: '>= 10.0.0'} - '@parcel/watcher@2.5.6': - resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} - engines: {node: '>= 10.0.0'} - - '@playwright/test@1.61.1': - resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} - engines: {node: '>=18'} + '@playwright/test@1.62.1': + resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} + engines: {node: '>=20'} hasBin: true - '@rolldown/binding-android-arm64@1.1.3': - resolution: {integrity: sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==} + '@rolldown/binding-android-arm64@1.2.1': + resolution: {integrity: sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.1.3': - resolution: {integrity: sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==} + '@rolldown/binding-darwin-arm64@1.2.1': + resolution: {integrity: sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.1.3': - resolution: {integrity: sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==} + '@rolldown/binding-darwin-x64@1.2.1': + resolution: {integrity: sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.1.3': - resolution: {integrity: sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==} + '@rolldown/binding-freebsd-x64@1.2.1': + resolution: {integrity: sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.1.3': - resolution: {integrity: sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==} + '@rolldown/binding-linux-arm-gnueabihf@1.2.1': + resolution: {integrity: sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.1.3': - resolution: {integrity: sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==} + '@rolldown/binding-linux-arm64-gnu@1.2.1': + resolution: {integrity: sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.1.3': - resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==} + '@rolldown/binding-linux-arm64-musl@1.2.1': + resolution: {integrity: sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.1.3': - resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==} + '@rolldown/binding-linux-ppc64-gnu@1.2.1': + resolution: {integrity: sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.1.3': - resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==} + '@rolldown/binding-linux-s390x-gnu@1.2.1': + resolution: {integrity: sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.1.3': - resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==} + '@rolldown/binding-linux-x64-gnu@1.2.1': + resolution: {integrity: sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.1.3': - resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==} + '@rolldown/binding-linux-x64-musl@1.2.1': + resolution: {integrity: sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.1.3': - resolution: {integrity: sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==} + '@rolldown/binding-openharmony-arm64@1.2.1': + resolution: {integrity: sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.1.3': - resolution: {integrity: sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==} - engines: {node: ^20.19.0 || >=22.12.0} - cpu: [wasm32] + '@rolldown/binding-wasm32-wasi@1.2.1': + resolution: {integrity: sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} - '@rolldown/binding-win32-arm64-msvc@1.1.3': - resolution: {integrity: sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==} + '@rolldown/binding-win32-arm64-msvc@1.2.1': + resolution: {integrity: sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.1.3': - resolution: {integrity: sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==} + '@rolldown/binding-win32-x64-msvc@1.2.1': + resolution: {integrity: sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -2167,39 +2129,36 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || >=4.0.0 || insiders' - '@turbo/darwin-64@2.9.18': - resolution: {integrity: sha512-9f27peFu16ur8c0v9nUFUEyBnbKuuFsUTjHFWfmwGfzySBXbHwzU44QhZon6Mznz0cHsIr3984NQj/bVrnGSRw==} + '@turbo/darwin-64@2.10.8': + resolution: {integrity: sha512-po+7rfJfUnFXjWlcoN2RwhErgzCdRtBc1T26vYPcywHlggmCQiQe1uWaE4j+BibI2uY9/2pDoFzMN0rmSaPFOw==} cpu: [x64] os: [darwin] - '@turbo/darwin-arm64@2.9.18': - resolution: {integrity: sha512-9A6TMRq/Ib+QnbhLlgkhOm+624wO4pzSQ/yQviQfWHOlFvaYxdnIAYmu2H6TS6y7kSVL0DvzNe04NbESTOzFVQ==} + '@turbo/darwin-arm64@2.10.8': + resolution: {integrity: sha512-+zB2btDJ00lnPRuqOvpVvgl4x34k/djZQGZTTCfjn7JgNCl8QFY5Njo5+dqkY1g/+9gbbsnAvWm9CmJg9ebcXA==} cpu: [arm64] os: [darwin] - '@turbo/linux-64@2.9.18': - resolution: {integrity: sha512-zCdIDtz69AnbYh913elJRRoF3QY5aa2HNnf+4rAkc7bQ+tWujiDkCNV7stazOUPggaDvhKIf2Z87qHftTeXSkw==} + '@turbo/linux-64@2.10.8': + resolution: {integrity: sha512-K1dxqiVisyN7cViVsfQLs6xscQbYuI8aO2nbUhFURDACgEDfZRdP/b4CCxeosBJpcMfhYyiibWqJorCnvz9kKg==} cpu: [x64] - os: [linux] + os: [android, linux] - '@turbo/linux-arm64@2.9.18': - resolution: {integrity: sha512-Va1kXI04naMgYwqv/5Dfa36dTDx8015U7oaQAjrXa45ua9OoFjSV4OmvkML4EmXvUclQHCiBRbY8bvd0jV7eAg==} + '@turbo/linux-arm64@2.10.8': + resolution: {integrity: sha512-Gi77ibVnrE1fEmvr+/wBD/yvRqhwp/RQuCp2+//lv1U1wNFFyVg0V7Wj8FG9FXPFAw5QHReo8rxc9+wBSDZjzA==} cpu: [arm64] - os: [linux] + os: [android, linux] - '@turbo/windows-64@2.9.18': - resolution: {integrity: sha512-m0kDhZANxSNz9ck1ybogFscHabriAsp4eDFNrN/1H5WrgTF7b3VlcPZnhuO3v2+E2KnCbeAc+UUT10BZZHdDKw==} + '@turbo/windows-64@2.10.8': + resolution: {integrity: sha512-znnLO1haJPYTHoKMKwlAvlkjRiYbbhBzME6wIGaMd+fwir23U6jVd1ecaTWWi1fbnRVqxMfgDBKseQ/hLKb83g==} cpu: [x64] os: [win32] - '@turbo/windows-arm64@2.9.18': - resolution: {integrity: sha512-nUdR8WqoomUys9iIQmG45TMiizJ+5BV8egSeLLZba/AWblyp3fVBcIH1kSE58OtK4g2YzbMJEth6Ttv9w5rqMA==} + '@turbo/windows-arm64@2.10.8': + resolution: {integrity: sha512-VN30vh3b3Czh2WzYHNTfF1FE0YMZ5aHsLO8dBMGHJewA6792wX6iJR8ZxlzFW6WdOu0gEAKIvlYhfyT81Wkm4Q==} cpu: [arm64] os: [win32] - '@tybys/wasm-util@0.10.2': - resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} - '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} @@ -2221,8 +2180,8 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - '@types/node@22.19.21': - resolution: {integrity: sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==} + '@types/node@22.20.1': + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} '@types/node@25.9.1': resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==} @@ -2238,8 +2197,8 @@ packages: '@types/react@19.2.15': resolution: {integrity: sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==} - '@types/semver@7.7.1': - resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + '@types/semver@7.8.0': + resolution: {integrity: sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==} '@vitejs/plugin-react@6.0.2': resolution: {integrity: sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==} @@ -2254,11 +2213,11 @@ packages: babel-plugin-react-compiler: optional: true - '@vitest/expect@4.1.9': - resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} - '@vitest/mocker@4.1.9': - resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==} + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -2268,20 +2227,20 @@ packages: vite: optional: true - '@vitest/pretty-format@4.1.9': - resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==} + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} - '@vitest/runner@4.1.9': - resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==} + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} - '@vitest/snapshot@4.1.9': - resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==} + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} - '@vitest/spy@4.1.9': - resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==} + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} - '@vitest/utils@4.1.9': - resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} '@webassemblyjs/ast@1.14.1': resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} @@ -2334,12 +2293,6 @@ packages: '@xtuc/long@4.2.2': resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} - acorn-import-phases@1.0.4: - resolution: {integrity: sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==} - engines: {node: '>=10.13.0'} - peerDependencies: - acorn: ^8.14.0 - acorn@8.16.0: resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} engines: {node: '>=0.4.0'} @@ -2454,9 +2407,6 @@ packages: resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} engines: {node: '>=6.0'} - citty@0.1.6: - resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} - citty@0.2.2: resolution: {integrity: sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==} @@ -2502,8 +2452,8 @@ packages: crossws@0.3.5: resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} - crossws@0.4.5: - resolution: {integrity: sha512-wUR89x/Rw7/8t+vn0CmGDYM9TD6VtARGb0LD5jq2wjtMy1vCP4M+sm6N6TigWeTYvnA8MoW29NqqXD0ep0rfBA==} + crossws@0.4.10: + resolution: {integrity: sha512-pz3oubH/dt12KjqsUB0IuXW4nwRDQ583iDsP4555Cpdqx0NoU7pGlWBcayyFI8f/l/idRpgjMEfwuOxSWJYlIA==} peerDependencies: srvx: '>=0.11.5' peerDependenciesMeta: @@ -2559,16 +2509,16 @@ packages: electron-to-chromium@1.5.361: resolution: {integrity: sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==} - emnapi@1.11.1: - resolution: {integrity: sha512-kSRjhIcxjMFsBqk7ORvoc9aA5SBKDmecrtF5RMcmOTao0kD/zamaxsuTxMI8C1//wGUuvE7a+19pCE7AEhGVnA==} + emnapi@1.11.3: + resolution: {integrity: sha512-+/ZS90YK/rYfVOHtGLHkGffVsnmD/MAKaBHio+Y4XAtg75RLr4cveV/w0jTkUdLM1CcAlaRgG76mpIemWAlk0A==} peerDependencies: node-addon-api: '>= 6.1.0' peerDependenciesMeta: node-addon-api: optional: true - enhanced-resolve@5.24.1: - resolution: {integrity: sha512-7DdUaTjmNwMcH2gLr1qycesKII3BK4RLy/mdAb7x10Lq7bR4aNKHt1BR1ZALSv0rPM/hF5wYF0PhGop/rJm8vw==} + enhanced-resolve@5.24.5: + resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==} engines: {node: '>=10.13.0'} es-errors@1.3.0: @@ -2578,8 +2528,8 @@ packages: es-module-lexer@2.1.0: resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} - es-toolkit@1.46.1: - resolution: {integrity: sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==} + es-toolkit@1.50.0: + resolution: {integrity: sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==} esbuild@0.27.7: resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} @@ -2696,8 +2646,8 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - globby@16.2.0: - resolution: {integrity: sha512-QrJia2qDf5BB/V6HYlDTs0I0lBahyjLzpGQg3KT7FnCdTonAyPy2RtY802m2k4ALx6Dp752f82WsOczEVr3l6Q==} + globby@16.2.2: + resolution: {integrity: sha512-NLvV9ubZ6NDsJaOpKPy3cQeJpKi9DcWiyCiFUpJPA0YihRqiE6RWaLUmgNNPr8MgPpLZjnBjSmou7uZBRJv9wA==} engines: {node: '>=20'} graceful-fs@4.2.11: @@ -2769,8 +2719,8 @@ packages: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + js-yaml@4.3.1: + resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true json-schema-traverse@1.0.0: @@ -2779,74 +2729,74 @@ packages: json-with-bigint@3.5.8: resolution: {integrity: sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==} - lightningcss-android-arm64@1.32.0: - resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [android] - lightningcss-darwin-arm64@1.32.0: - resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] - lightningcss-darwin-x64@1.32.0: - resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] - lightningcss-freebsd-x64@1.32.0: - resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] - lightningcss-linux-arm-gnueabihf@1.32.0: - resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] - lightningcss-linux-arm64-gnu@1.32.0: - resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - lightningcss-linux-arm64-musl@1.32.0: - resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - lightningcss-linux-x64-gnu@1.32.0: - resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - lightningcss-linux-x64-musl@1.32.0: - resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - lightningcss-win32-arm64-msvc@1.32.0: - resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] - lightningcss-win32-x64-msvc@1.32.0: - resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] - lightningcss@1.32.0: - resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} engines: {node: '>= 12.0.0'} lilconfig@3.1.3: @@ -2856,21 +2806,25 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} - listhen@1.10.0: - resolution: {integrity: sha512-kfz4C0OrC6IpaVMtYDJtf6PFjurxe9NBBoDAh/o2p587INryFOO4DQ9OetbCdDrWFt1m1CJKvYrzkGsuPHw8nQ==} + listhen@1.10.1: + resolution: {integrity: sha512-6nt/86SkqUQSLW1ofz8MxC6RhRMqOl3ONISe6qqvJ3xj09aJWQx6DhgSZpugs3PX4PXdOas/WD6A9jx6J2N19A==} hasBin: true + peerDependencies: + '@parcel/watcher': ^2.5.6 + peerDependenciesMeta: + '@parcel/watcher': + optional: true load-tsconfig@0.2.5: resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - loader-runner@4.3.2: - resolution: {integrity: sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==} - engines: {node: '>=6.11.5'} - magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magic-string@1.1.0: + resolution: {integrity: sha512-kS3VHe0nEPST2saQV4Rbkchcd3UBRkVTQHo1D3h/ZTwFDhai/mfKkmtPAtD129EOI7K3HlHIsFOt0WrI2/oU9g==} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -2958,6 +2912,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} @@ -3019,6 +2978,10 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + package-up@5.0.0: resolution: {integrity: sha512-MQEgDUvXCa3sGvqHg3pzHO8e9gqTCMPVrWUko3vPQGntwegmFo52mZb2abIVTjFnUcW0BcPz0D93jV5Cas1DWA==} engines: {node: '>=18'} @@ -3026,9 +2989,6 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - pathe@1.1.2: - resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} - pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -3043,6 +3003,10 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + pify@2.3.0: resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} engines: {node: '>=0.10.0'} @@ -3054,14 +3018,14 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} - playwright-core@1.61.1: - resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} - engines: {node: '>=18'} + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} hasBin: true - playwright@1.61.1: - resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} - engines: {node: '>=18'} + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} hasBin: true postcss-import@15.1.0: @@ -3125,14 +3089,14 @@ packages: resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} engines: {node: ^10 || ^12 || >=14} - postcss@8.5.15: - resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} - engines: {node: ^10 || ^12 || >=14} - postcss@8.5.16: resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.25: + resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} + engines: {node: ^10 || ^12 || >=14} + prettier-plugin-embed@0.5.1: resolution: {integrity: sha512-2Ege8gIlLNTvHElUeU5XcFsD7/dbDXkQA6H9TczHSJAGxB58nNjjifk/dlRMS5E29eqQx/z+ToA4ZVMWzjME/A==} @@ -3146,8 +3110,8 @@ packages: vue-tsc: optional: true - prettier@3.9.4: - resolution: {integrity: sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==} + prettier@3.9.6: + resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} engines: {node: '>=14'} hasBin: true @@ -3203,8 +3167,8 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - rolldown@1.1.3: - resolution: {integrity: sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==} + rolldown@1.2.1: + resolution: {integrity: sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -3226,13 +3190,8 @@ packages: resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} engines: {node: '>= 10.13.0'} - semver@7.8.1: - resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==} - engines: {node: '>=10'} - hasBin: true - - semver@7.8.4: - resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} hasBin: true @@ -3272,6 +3231,9 @@ packages: std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + styled-jsx@5.1.6: resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} engines: {node: '>= 12.0.0'} @@ -3329,8 +3291,8 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - tinyclip@0.1.12: - resolution: {integrity: sha512-Ae3OVUqifDw0wBriIBS7yVaW44Dp6eSHQcyq4Igc7eN2TJH/2YsicswaW+J/OuMvhpDPOKEgpAZCjkb4hpoyeA==} + tinyclip@0.1.15: + resolution: {integrity: sha512-uo33abH+Ays0xYaDysoBt494Hb3hsEczMpcC0MwFl773pazORx4fmvKhclhR1wonUbB6vvpRsvVMwnhfqeMc+A==} engines: {node: ^16.14.0 || >= 17.3.0} tinyexec@0.3.2: @@ -3376,8 +3338,8 @@ packages: tree-sitter: optional: true - tree-sitter@0.22.4: - resolution: {integrity: sha512-usbHZP9/oxNsUY65MQUsduGRqDHQOou1cagUSwjhoSYAmSahjQDAVsh9s+SlZkn8X8+O1FULRGwHu7AFP3kjzg==} + tree-sitter@0.25.1: + resolution: {integrity: sha512-mrcEdkYtHfrK1A6fs3O6FxkBo0Qig5XUXqHhxUOQu0bmPo00QF4XaSx4edpazdHwxnSCjlGKGgIqWdaN4dvTLA==} ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} @@ -3404,8 +3366,8 @@ packages: typescript: optional: true - turbo@2.9.18: - resolution: {integrity: sha512-bwabv6PupzeavybzEoArBAkwq5fnzwf8OFnRtpHwnviFWuwJPFxtyH+aVp36TmIqK3aYYgtTJ3J0m2ysxxSzQg==} + turbo@2.10.8: + resolution: {integrity: sha512-9+8YX5QOkGXzZxcIykTHgaooRHGMWO+jfdyRK0o+rN0U7hBIig2MrJ8r/aNzIPDPhdA73SGb0O+tIztaModTMg==} hasBin: true typanion@3.14.0: @@ -3444,8 +3406,8 @@ packages: universal-user-agent@7.0.3: resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} - untun@0.1.3: - resolution: {integrity: sha512-4luGP9LMYszMRZwsvyUd9MrxgEGZdZuZgpVQHEEX0lCYFESasVRvZd0EYpCkOIbJKHMuv0LskpXc/8Un+MJzEQ==} + untun@0.2.2: + resolution: {integrity: sha512-+NnOJcSiEtYsVgJmXUzQbJeRAFXJC4yPJYuh6kF9B0Rm6zunXcs/3GZOTllyocSbUDIxD6Bj7e/4ATw7sph1Sw==} hasBin: true update-browserslist-db@1.2.3: @@ -3460,13 +3422,13 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - vite@8.1.2: - resolution: {integrity: sha512-6YYPbRXTxx6bRXmOn7XdnQAy5DQNHhDgtjhDHI13oe4pY93kkcdGJWxpGwOm++/Wh0QpQhDrpIoVMrmrsI5AGQ==} + vite@8.2.0: + resolution: {integrity: sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: '@types/node': ^20.19.0 || >=22.12.0 - '@vitejs/devtools': ^0.3.0 + '@vitejs/devtools': ^0.4.0 esbuild: ^0.27.0 || ^0.28.0 jiti: '>=1.21.0' less: ^4.0.0 @@ -3503,20 +3465,20 @@ packages: yaml: optional: true - vitest@4.1.9: - resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==} + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.9 - '@vitest/browser-preview': 4.1.9 - '@vitest/browser-webdriverio': 4.1.9 - '@vitest/coverage-istanbul': 4.1.9 - '@vitest/coverage-v8': 4.1.9 - '@vitest/ui': 4.1.9 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 happy-dom: '*' jsdom: '*' vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -3548,12 +3510,12 @@ packages: resolution: {integrity: sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==} engines: {node: '>=10.13.0'} - webpack-sources@3.5.0: - resolution: {integrity: sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==} + webpack-sources@3.5.1: + resolution: {integrity: sha512-jyuiGJdtvY434z5bUZrjz67v76/ePNvFZTp9Mdz29IlH4+GPsgyGjiv0fKI+M7BdkU6ADjulUcKAd3tUK3WlEw==} engines: {node: '>=10.13.0'} - webpack@5.108.3: - resolution: {integrity: sha512-hOpaCHmQVVY66IVTjofnH14IgSdmod2aquSGHGuYig/OIdWge01Hk2Wt988DZcwXumFUT4+FvJY5N+ikl8o/ww==} + webpack@5.109.2: + resolution: {integrity: sha512-U9/cvLzxObKNEZ9+TtdqrHM5/9z3lgl2c+c4BzbqGxFQvQvBAq87yql5A8pQ+rrMbS496MZJeF5enVBndIy2hw==} engines: {node: '>=10.13.0'} hasBin: true peerDependencies: @@ -3582,29 +3544,45 @@ snapshots: tslib: 2.8.1 optional: true - '@emnapi/core@1.11.1': + '@emnapi/core@1.11.3': dependencies: - '@emnapi/wasi-threads': 1.2.2 + '@emnapi/wasi-threads': 1.2.3 tslib: 2.8.1 + '@emnapi/core@2.0.0-alpha.3': + dependencies: + '@emnapi/wasi-threads': 2.0.1 + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.10.0': dependencies: tslib: 2.8.1 optional: true - '@emnapi/runtime@1.11.1': + '@emnapi/runtime@1.11.3': dependencies: tslib: 2.8.1 + '@emnapi/runtime@2.0.0-alpha.3': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/wasi-threads@1.2.1': dependencies: tslib: 2.8.1 optional: true - '@emnapi/wasi-threads@1.2.2': + '@emnapi/wasi-threads@1.2.3': dependencies: tslib: 2.8.1 + '@emnapi/wasi-threads@2.0.1': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild/aix-ppc64@0.27.7': optional: true @@ -3768,7 +3746,7 @@ snapshots: '@img/sharp-wasm32@0.34.5': dependencies: - '@emnapi/runtime': 1.11.1 + '@emnapi/runtime': 1.11.3 optional: true '@img/sharp-win32-arm64@0.34.5': @@ -3780,29 +3758,29 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true - '@inquirer/ansi@2.0.5': {} + '@inquirer/ansi@2.0.7': {} - '@inquirer/checkbox@5.1.5(@types/node@25.9.1)': + '@inquirer/checkbox@5.2.1(@types/node@25.9.1)': dependencies: - '@inquirer/ansi': 2.0.5 - '@inquirer/core': 11.1.10(@types/node@25.9.1) - '@inquirer/figures': 2.0.5 - '@inquirer/type': 4.0.5(@types/node@25.9.1) + '@inquirer/ansi': 2.0.7 + '@inquirer/core': 11.2.1(@types/node@25.9.1) + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@25.9.1) optionalDependencies: '@types/node': 25.9.1 - '@inquirer/confirm@6.0.13(@types/node@25.9.1)': + '@inquirer/confirm@6.1.1(@types/node@25.9.1)': dependencies: - '@inquirer/core': 11.1.10(@types/node@25.9.1) - '@inquirer/type': 4.0.5(@types/node@25.9.1) + '@inquirer/core': 11.2.1(@types/node@25.9.1) + '@inquirer/type': 4.0.7(@types/node@25.9.1) optionalDependencies: '@types/node': 25.9.1 - '@inquirer/core@11.1.10(@types/node@25.9.1)': + '@inquirer/core@11.2.1(@types/node@25.9.1)': dependencies: - '@inquirer/ansi': 2.0.5 - '@inquirer/figures': 2.0.5 - '@inquirer/type': 4.0.5(@types/node@25.9.1) + '@inquirer/ansi': 2.0.7 + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@25.9.1) cli-width: 4.1.0 fast-wrap-ansi: 0.2.2 mute-stream: 3.0.0 @@ -3810,92 +3788,92 @@ snapshots: optionalDependencies: '@types/node': 25.9.1 - '@inquirer/editor@5.1.2(@types/node@25.9.1)': + '@inquirer/editor@5.2.2(@types/node@25.9.1)': dependencies: - '@inquirer/core': 11.1.10(@types/node@25.9.1) - '@inquirer/external-editor': 3.0.0(@types/node@25.9.1) - '@inquirer/type': 4.0.5(@types/node@25.9.1) + '@inquirer/core': 11.2.1(@types/node@25.9.1) + '@inquirer/external-editor': 3.0.3(@types/node@25.9.1) + '@inquirer/type': 4.0.7(@types/node@25.9.1) optionalDependencies: '@types/node': 25.9.1 - '@inquirer/expand@5.0.14(@types/node@25.9.1)': + '@inquirer/expand@5.1.1(@types/node@25.9.1)': dependencies: - '@inquirer/core': 11.1.10(@types/node@25.9.1) - '@inquirer/type': 4.0.5(@types/node@25.9.1) + '@inquirer/core': 11.2.1(@types/node@25.9.1) + '@inquirer/type': 4.0.7(@types/node@25.9.1) optionalDependencies: '@types/node': 25.9.1 - '@inquirer/external-editor@3.0.0(@types/node@25.9.1)': + '@inquirer/external-editor@3.0.3(@types/node@25.9.1)': dependencies: chardet: 2.1.1 iconv-lite: 0.7.2 optionalDependencies: '@types/node': 25.9.1 - '@inquirer/figures@2.0.5': {} + '@inquirer/figures@2.0.7': {} - '@inquirer/input@5.0.13(@types/node@25.9.1)': + '@inquirer/input@5.1.2(@types/node@25.9.1)': dependencies: - '@inquirer/core': 11.1.10(@types/node@25.9.1) - '@inquirer/type': 4.0.5(@types/node@25.9.1) + '@inquirer/core': 11.2.1(@types/node@25.9.1) + '@inquirer/type': 4.0.7(@types/node@25.9.1) optionalDependencies: '@types/node': 25.9.1 - '@inquirer/number@4.0.13(@types/node@25.9.1)': + '@inquirer/number@4.1.1(@types/node@25.9.1)': dependencies: - '@inquirer/core': 11.1.10(@types/node@25.9.1) - '@inquirer/type': 4.0.5(@types/node@25.9.1) + '@inquirer/core': 11.2.1(@types/node@25.9.1) + '@inquirer/type': 4.0.7(@types/node@25.9.1) optionalDependencies: '@types/node': 25.9.1 - '@inquirer/password@5.0.13(@types/node@25.9.1)': + '@inquirer/password@5.1.1(@types/node@25.9.1)': dependencies: - '@inquirer/ansi': 2.0.5 - '@inquirer/core': 11.1.10(@types/node@25.9.1) - '@inquirer/type': 4.0.5(@types/node@25.9.1) + '@inquirer/ansi': 2.0.7 + '@inquirer/core': 11.2.1(@types/node@25.9.1) + '@inquirer/type': 4.0.7(@types/node@25.9.1) optionalDependencies: '@types/node': 25.9.1 - '@inquirer/prompts@8.4.3(@types/node@25.9.1)': + '@inquirer/prompts@8.5.2(@types/node@25.9.1)': dependencies: - '@inquirer/checkbox': 5.1.5(@types/node@25.9.1) - '@inquirer/confirm': 6.0.13(@types/node@25.9.1) - '@inquirer/editor': 5.1.2(@types/node@25.9.1) - '@inquirer/expand': 5.0.14(@types/node@25.9.1) - '@inquirer/input': 5.0.13(@types/node@25.9.1) - '@inquirer/number': 4.0.13(@types/node@25.9.1) - '@inquirer/password': 5.0.13(@types/node@25.9.1) - '@inquirer/rawlist': 5.2.9(@types/node@25.9.1) - '@inquirer/search': 4.1.9(@types/node@25.9.1) - '@inquirer/select': 5.1.5(@types/node@25.9.1) + '@inquirer/checkbox': 5.2.1(@types/node@25.9.1) + '@inquirer/confirm': 6.1.1(@types/node@25.9.1) + '@inquirer/editor': 5.2.2(@types/node@25.9.1) + '@inquirer/expand': 5.1.1(@types/node@25.9.1) + '@inquirer/input': 5.1.2(@types/node@25.9.1) + '@inquirer/number': 4.1.1(@types/node@25.9.1) + '@inquirer/password': 5.1.1(@types/node@25.9.1) + '@inquirer/rawlist': 5.3.1(@types/node@25.9.1) + '@inquirer/search': 4.2.1(@types/node@25.9.1) + '@inquirer/select': 5.2.1(@types/node@25.9.1) optionalDependencies: '@types/node': 25.9.1 - '@inquirer/rawlist@5.2.9(@types/node@25.9.1)': + '@inquirer/rawlist@5.3.1(@types/node@25.9.1)': dependencies: - '@inquirer/core': 11.1.10(@types/node@25.9.1) - '@inquirer/type': 4.0.5(@types/node@25.9.1) + '@inquirer/core': 11.2.1(@types/node@25.9.1) + '@inquirer/type': 4.0.7(@types/node@25.9.1) optionalDependencies: '@types/node': 25.9.1 - '@inquirer/search@4.1.9(@types/node@25.9.1)': + '@inquirer/search@4.2.1(@types/node@25.9.1)': dependencies: - '@inquirer/core': 11.1.10(@types/node@25.9.1) - '@inquirer/figures': 2.0.5 - '@inquirer/type': 4.0.5(@types/node@25.9.1) + '@inquirer/core': 11.2.1(@types/node@25.9.1) + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@25.9.1) optionalDependencies: '@types/node': 25.9.1 - '@inquirer/select@5.1.5(@types/node@25.9.1)': + '@inquirer/select@5.2.1(@types/node@25.9.1)': dependencies: - '@inquirer/ansi': 2.0.5 - '@inquirer/core': 11.1.10(@types/node@25.9.1) - '@inquirer/figures': 2.0.5 - '@inquirer/type': 4.0.5(@types/node@25.9.1) + '@inquirer/ansi': 2.0.7 + '@inquirer/core': 11.2.1(@types/node@25.9.1) + '@inquirer/figures': 2.0.7 + '@inquirer/type': 4.0.7(@types/node@25.9.1) optionalDependencies: '@types/node': 25.9.1 - '@inquirer/type@4.0.5(@types/node@25.9.1)': + '@inquirer/type@4.0.7(@types/node@25.9.1)': optionalDependencies: '@types/node': 25.9.1 @@ -3923,22 +3901,22 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@napi-rs/cli@3.7.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)(@types/node@25.9.1)(node-addon-api@8.7.0)': + '@napi-rs/cli@3.7.4(patch_hash=9912bf0a9c2cef8329d11c41fbce4d33ce2bdcf660a630b258c962c0a3c44bed)(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(@types/node@25.9.1)(node-addon-api@8.7.0)': dependencies: - '@inquirer/prompts': 8.4.3(@types/node@25.9.1) - '@napi-rs/cross-toolchain': 1.0.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) - '@napi-rs/wasm-tools': 1.0.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@inquirer/prompts': 8.5.2(@types/node@25.9.1) + '@napi-rs/cross-toolchain': 1.0.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) + '@napi-rs/wasm-tools': 1.0.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) '@octokit/rest': 22.0.1 clipanion: 4.0.0-rc.4(typanion@3.14.0) colorette: 2.0.20 - emnapi: 1.11.1(node-addon-api@8.7.0) - es-toolkit: 1.46.1 - js-yaml: 4.1.1 - obug: 2.1.1 - semver: 7.8.1 + emnapi: 1.11.3(node-addon-api@8.7.0) + es-toolkit: 1.50.0 + js-yaml: 4.3.1 + obug: 2.1.4 + semver: 7.8.5 typanion: 3.14.0 optionalDependencies: - '@emnapi/runtime': 1.11.1 + '@emnapi/runtime': 1.11.3 transitivePeerDependencies: - '@emnapi/core' - '@napi-rs/cross-toolchain-arm64-target-aarch64' @@ -3955,10 +3933,10 @@ snapshots: - node-addon-api - supports-color - '@napi-rs/cross-toolchain@1.0.3(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + '@napi-rs/cross-toolchain@1.0.3(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)': dependencies: - '@napi-rs/lzma': 1.4.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) - '@napi-rs/tar': 1.1.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@napi-rs/lzma': 1.4.5(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) + '@napi-rs/tar': 1.1.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) debug: 4.4.3 transitivePeerDependencies: - '@emnapi/core' @@ -4004,9 +3982,9 @@ snapshots: '@napi-rs/lzma-linux-x64-musl@1.4.5': optional: true - '@napi-rs/lzma-wasm32-wasi@1.4.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + '@napi-rs/lzma-wasm32-wasi@1.4.5(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)': dependencies: - '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -4021,7 +3999,7 @@ snapshots: '@napi-rs/lzma-win32-x64-msvc@1.4.5': optional: true - '@napi-rs/lzma@1.4.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + '@napi-rs/lzma@1.4.5(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)': optionalDependencies: '@napi-rs/lzma-android-arm-eabi': 1.4.5 '@napi-rs/lzma-android-arm64': 1.4.5 @@ -4036,7 +4014,7 @@ snapshots: '@napi-rs/lzma-linux-s390x-gnu': 1.4.5 '@napi-rs/lzma-linux-x64-gnu': 1.4.5 '@napi-rs/lzma-linux-x64-musl': 1.4.5 - '@napi-rs/lzma-wasm32-wasi': 1.4.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@napi-rs/lzma-wasm32-wasi': 1.4.5(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) '@napi-rs/lzma-win32-arm64-msvc': 1.4.5 '@napi-rs/lzma-win32-ia32-msvc': 1.4.5 '@napi-rs/lzma-win32-x64-msvc': 1.4.5 @@ -4080,9 +4058,9 @@ snapshots: '@napi-rs/tar-linux-x64-musl@1.1.0': optional: true - '@napi-rs/tar-wasm32-wasi@1.1.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + '@napi-rs/tar-wasm32-wasi@1.1.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)': dependencies: - '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -4097,7 +4075,7 @@ snapshots: '@napi-rs/tar-win32-x64-msvc@1.1.0': optional: true - '@napi-rs/tar@1.1.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + '@napi-rs/tar@1.1.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)': optionalDependencies: '@napi-rs/tar-android-arm-eabi': 1.1.0 '@napi-rs/tar-android-arm64': 1.1.0 @@ -4111,7 +4089,7 @@ snapshots: '@napi-rs/tar-linux-s390x-gnu': 1.1.0 '@napi-rs/tar-linux-x64-gnu': 1.1.0 '@napi-rs/tar-linux-x64-musl': 1.1.0 - '@napi-rs/tar-wasm32-wasi': 1.1.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@napi-rs/tar-wasm32-wasi': 1.1.0(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) '@napi-rs/tar-win32-arm64-msvc': 1.1.0 '@napi-rs/tar-win32-ia32-msvc': 1.1.0 '@napi-rs/tar-win32-x64-msvc': 1.1.0 @@ -4123,25 +4101,19 @@ snapshots: dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 - '@tybys/wasm-util': 0.10.2 + '@tybys/wasm-util': 0.10.3 optional: true - '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)': dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 - '@tybys/wasm-util': 0.10.2 + '@emnapi/core': 1.11.3 + '@emnapi/runtime': 1.11.3 + '@tybys/wasm-util': 0.10.3 - '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3)': dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 - '@tybys/wasm-util': 0.10.2 - - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': - dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 + '@emnapi/core': 2.0.0-alpha.3 + '@emnapi/runtime': 2.0.0-alpha.3 '@tybys/wasm-util': 0.10.3 optional: true @@ -4172,9 +4144,9 @@ snapshots: '@napi-rs/wasm-tools-linux-x64-musl@1.0.1': optional: true - '@napi-rs/wasm-tools-wasm32-wasi@1.0.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + '@napi-rs/wasm-tools-wasm32-wasi@1.0.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)': dependencies: - '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -4189,7 +4161,7 @@ snapshots: '@napi-rs/wasm-tools-win32-x64-msvc@1.0.1': optional: true - '@napi-rs/wasm-tools@1.0.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + '@napi-rs/wasm-tools@1.0.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)': optionalDependencies: '@napi-rs/wasm-tools-android-arm-eabi': 1.0.1 '@napi-rs/wasm-tools-android-arm64': 1.0.1 @@ -4200,7 +4172,7 @@ snapshots: '@napi-rs/wasm-tools-linux-arm64-musl': 1.0.1 '@napi-rs/wasm-tools-linux-x64-gnu': 1.0.1 '@napi-rs/wasm-tools-linux-x64-musl': 1.0.1 - '@napi-rs/wasm-tools-wasm32-wasi': 1.0.1(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@napi-rs/wasm-tools-wasm32-wasi': 1.0.1(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) '@napi-rs/wasm-tools-win32-arm64-msvc': 1.0.1 '@napi-rs/wasm-tools-win32-ia32-msvc': 1.0.1 '@napi-rs/wasm-tools-win32-x64-msvc': 1.0.1 @@ -4358,177 +4330,113 @@ snapshots: '@oven/bun-windows-x64@1.3.14': optional: true - '@oxc-project/types@0.137.0': {} + '@oxc-project/types@0.142.0': {} - '@parcel/watcher-android-arm64@2.5.1': + '@parcel/watcher-android-arm64@2.6.0': optional: true - '@parcel/watcher-android-arm64@2.5.6': + '@parcel/watcher-darwin-arm64@2.6.0': {} + + '@parcel/watcher-darwin-x64@2.6.0': {} + + '@parcel/watcher-freebsd-x64@2.6.0': optional: true - '@parcel/watcher-darwin-arm64@2.5.1': + '@parcel/watcher-linux-arm-glibc@2.6.0': optional: true - '@parcel/watcher-darwin-arm64@2.5.6': {} - - '@parcel/watcher-darwin-x64@2.5.1': + '@parcel/watcher-linux-arm-musl@2.6.0': optional: true - '@parcel/watcher-darwin-x64@2.5.6': {} + '@parcel/watcher-linux-arm64-glibc@2.6.0': {} - '@parcel/watcher-freebsd-x64@2.5.1': - optional: true + '@parcel/watcher-linux-arm64-musl@2.6.0': {} - '@parcel/watcher-freebsd-x64@2.5.6': - optional: true + '@parcel/watcher-linux-x64-glibc@2.6.0': {} - '@parcel/watcher-linux-arm-glibc@2.5.1': - optional: true - - '@parcel/watcher-linux-arm-glibc@2.5.6': - optional: true - - '@parcel/watcher-linux-arm-musl@2.5.1': - optional: true - - '@parcel/watcher-linux-arm-musl@2.5.6': - optional: true - - '@parcel/watcher-linux-arm64-glibc@2.5.1': - optional: true - - '@parcel/watcher-linux-arm64-glibc@2.5.6': {} - - '@parcel/watcher-linux-arm64-musl@2.5.1': - optional: true - - '@parcel/watcher-linux-arm64-musl@2.5.6': {} - - '@parcel/watcher-linux-x64-glibc@2.5.1': - optional: true - - '@parcel/watcher-linux-x64-glibc@2.5.6': {} - - '@parcel/watcher-linux-x64-musl@2.5.1': - optional: true - - '@parcel/watcher-linux-x64-musl@2.5.6': {} + '@parcel/watcher-linux-x64-musl@2.6.0': {} '@parcel/watcher-wasm@2.5.6': dependencies: is-glob: 4.0.3 - picomatch: 4.0.4 + picomatch: 4.0.5 - '@parcel/watcher-win32-arm64@2.5.1': + '@parcel/watcher-win32-arm64@2.6.0': optional: true - '@parcel/watcher-win32-arm64@2.5.6': - optional: true + '@parcel/watcher-win32-x64@2.6.0': {} - '@parcel/watcher-win32-ia32@2.5.1': - optional: true - - '@parcel/watcher-win32-ia32@2.5.6': - optional: true - - '@parcel/watcher-win32-x64@2.5.1': - optional: true - - '@parcel/watcher-win32-x64@2.5.6': {} - - '@parcel/watcher@2.5.1(patch_hash=c22241764997c5af4980d3be22550ae47858f14849dca486726653fa127eb69c)': - dependencies: - detect-libc: 1.0.3 - is-glob: 4.0.3 - micromatch: 4.0.8 - node-addon-api: 7.1.1 - optionalDependencies: - '@parcel/watcher-android-arm64': 2.5.1 - '@parcel/watcher-darwin-arm64': 2.5.1 - '@parcel/watcher-darwin-x64': 2.5.1 - '@parcel/watcher-freebsd-x64': 2.5.1 - '@parcel/watcher-linux-arm-glibc': 2.5.1 - '@parcel/watcher-linux-arm-musl': 2.5.1 - '@parcel/watcher-linux-arm64-glibc': 2.5.1 - '@parcel/watcher-linux-arm64-musl': 2.5.1 - '@parcel/watcher-linux-x64-glibc': 2.5.1 - '@parcel/watcher-linux-x64-musl': 2.5.1 - '@parcel/watcher-win32-arm64': 2.5.1 - '@parcel/watcher-win32-ia32': 2.5.1 - '@parcel/watcher-win32-x64': 2.5.1 - - '@parcel/watcher@2.5.6': + '@parcel/watcher@2.6.0(patch_hash=705ce75ccea54337110c4d7fe0a8b421658ea0c23e1a77c1fa890a86041179da)': dependencies: detect-libc: 2.1.2 is-glob: 4.0.3 node-addon-api: 7.1.1 - picomatch: 4.0.4 + picomatch: 4.0.5 optionalDependencies: - '@parcel/watcher-android-arm64': 2.5.6 - '@parcel/watcher-darwin-arm64': 2.5.6 - '@parcel/watcher-darwin-x64': 2.5.6 - '@parcel/watcher-freebsd-x64': 2.5.6 - '@parcel/watcher-linux-arm-glibc': 2.5.6 - '@parcel/watcher-linux-arm-musl': 2.5.6 - '@parcel/watcher-linux-arm64-glibc': 2.5.6 - '@parcel/watcher-linux-arm64-musl': 2.5.6 - '@parcel/watcher-linux-x64-glibc': 2.5.6 - '@parcel/watcher-linux-x64-musl': 2.5.6 - '@parcel/watcher-win32-arm64': 2.5.6 - '@parcel/watcher-win32-ia32': 2.5.6 - '@parcel/watcher-win32-x64': 2.5.6 + '@parcel/watcher-android-arm64': 2.6.0 + '@parcel/watcher-darwin-arm64': 2.6.0 + '@parcel/watcher-darwin-x64': 2.6.0 + '@parcel/watcher-freebsd-x64': 2.6.0 + '@parcel/watcher-linux-arm-glibc': 2.6.0 + '@parcel/watcher-linux-arm-musl': 2.6.0 + '@parcel/watcher-linux-arm64-glibc': 2.6.0 + '@parcel/watcher-linux-arm64-musl': 2.6.0 + '@parcel/watcher-linux-x64-glibc': 2.6.0 + '@parcel/watcher-linux-x64-musl': 2.6.0 + '@parcel/watcher-win32-arm64': 2.6.0 + '@parcel/watcher-win32-x64': 2.6.0 - '@playwright/test@1.61.1': + '@playwright/test@1.62.1': dependencies: - playwright: 1.61.1 + playwright: 1.62.1 - '@rolldown/binding-android-arm64@1.1.3': + '@rolldown/binding-android-arm64@1.2.1': optional: true - '@rolldown/binding-darwin-arm64@1.1.3': + '@rolldown/binding-darwin-arm64@1.2.1': optional: true - '@rolldown/binding-darwin-x64@1.1.3': + '@rolldown/binding-darwin-x64@1.2.1': optional: true - '@rolldown/binding-freebsd-x64@1.1.3': + '@rolldown/binding-freebsd-x64@1.2.1': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.1.3': + '@rolldown/binding-linux-arm-gnueabihf@1.2.1': optional: true - '@rolldown/binding-linux-arm64-gnu@1.1.3': + '@rolldown/binding-linux-arm64-gnu@1.2.1': optional: true - '@rolldown/binding-linux-arm64-musl@1.1.3': + '@rolldown/binding-linux-arm64-musl@1.2.1': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.1.3': + '@rolldown/binding-linux-ppc64-gnu@1.2.1': optional: true - '@rolldown/binding-linux-s390x-gnu@1.1.3': + '@rolldown/binding-linux-s390x-gnu@1.2.1': optional: true - '@rolldown/binding-linux-x64-gnu@1.1.3': + '@rolldown/binding-linux-x64-gnu@1.2.1': optional: true - '@rolldown/binding-linux-x64-musl@1.1.3': + '@rolldown/binding-linux-x64-musl@1.2.1': optional: true - '@rolldown/binding-openharmony-arm64@1.1.3': + '@rolldown/binding-openharmony-arm64@1.2.1': optional: true - '@rolldown/binding-wasm32-wasi@1.1.3': + '@rolldown/binding-wasm32-wasi@1.2.1': dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@emnapi/core': 2.0.0-alpha.3 + '@emnapi/runtime': 2.0.0-alpha.3 + '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3) optional: true - '@rolldown/binding-win32-arm64-msvc@1.1.3': + '@rolldown/binding-win32-arm64-msvc@1.2.1': optional: true - '@rolldown/binding-win32-x64-msvc@1.1.3': + '@rolldown/binding-win32-x64-msvc@1.2.1': optional: true '@rolldown/pluginutils@1.0.1': {} @@ -4683,32 +4591,27 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: link:packages/tailwindcss - '@turbo/darwin-64@2.9.18': + '@turbo/darwin-64@2.10.8': optional: true - '@turbo/darwin-arm64@2.9.18': + '@turbo/darwin-arm64@2.10.8': optional: true - '@turbo/linux-64@2.9.18': + '@turbo/linux-64@2.10.8': optional: true - '@turbo/linux-arm64@2.9.18': + '@turbo/linux-arm64@2.10.8': optional: true - '@turbo/windows-64@2.9.18': + '@turbo/windows-64@2.10.8': optional: true - '@turbo/windows-arm64@2.9.18': + '@turbo/windows-arm64@2.10.8': optional: true - '@tybys/wasm-util@0.10.2': - dependencies: - tslib: 2.8.1 - '@tybys/wasm-util@0.10.3': dependencies: tslib: 2.8.1 - optional: true '@types/bun@1.3.14': dependencies: @@ -4727,7 +4630,7 @@ snapshots: '@types/json-schema@7.0.15': {} - '@types/node@22.19.21': + '@types/node@22.20.1': dependencies: undici-types: 6.21.0 @@ -4737,7 +4640,7 @@ snapshots: '@types/postcss-import@14.0.3': dependencies: - postcss: 8.5.16 + postcss: 8.5.25 '@types/react-dom@19.2.3(@types/react@19.2.15)': dependencies: @@ -4747,51 +4650,51 @@ snapshots: dependencies: csstype: 3.2.3 - '@types/semver@7.7.1': {} + '@types/semver@7.8.0': {} - '@vitejs/plugin-react@6.0.2(vite@8.1.2(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))': + '@vitejs/plugin-react@6.0.2(vite@8.2.0(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.1.2(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) + vite: 8.2.0(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) - '@vitest/expect@4.1.9': + '@vitest/expect@4.1.10': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.1.9 - '@vitest/utils': 4.1.9 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.9(vite@8.1.2(@types/node@22.19.21)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))': + '@vitest/mocker@4.1.10(vite@8.2.0(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))': dependencies: - '@vitest/spy': 4.1.9 + '@vitest/spy': 4.1.10 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.2(@types/node@22.19.21)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) + vite: 8.2.0(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) - '@vitest/pretty-format@4.1.9': + '@vitest/pretty-format@4.1.10': dependencies: tinyrainbow: 3.1.0 - '@vitest/runner@4.1.9': + '@vitest/runner@4.1.10': dependencies: - '@vitest/utils': 4.1.9 + '@vitest/utils': 4.1.10 pathe: 2.0.3 - '@vitest/snapshot@4.1.9': + '@vitest/snapshot@4.1.10': dependencies: - '@vitest/pretty-format': 4.1.9 - '@vitest/utils': 4.1.9 + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.1.9': {} + '@vitest/spy@4.1.10': {} - '@vitest/utils@4.1.9': + '@vitest/utils@4.1.10': dependencies: - '@vitest/pretty-format': 4.1.9 + '@vitest/pretty-format': 4.1.10 convert-source-map: 2.0.0 tinyrainbow: 3.1.0 @@ -4875,10 +4778,6 @@ snapshots: '@xtuc/long@4.2.2': {} - acorn-import-phases@1.0.4(acorn@8.16.0): - dependencies: - acorn: 8.16.0 - acorn@8.16.0: {} ajv-formats@2.1.1(ajv@8.20.0): @@ -4910,13 +4809,13 @@ snapshots: assertion-error@2.0.1: {} - autoprefixer@10.5.0(postcss@8.5.16): + autoprefixer@10.5.0(postcss@8.5.25): dependencies: browserslist: 4.28.2 caniuse-lite: 1.0.30001793 fraction.js: 5.3.4 picocolors: 1.1.1 - postcss: 8.5.16 + postcss: 8.5.25 postcss-value-parser: 4.2.0 baseline-browser-mapping@2.10.31: {} @@ -4995,10 +4894,6 @@ snapshots: chrome-trace-event@1.0.4: {} - citty@0.1.6: - dependencies: - consola: 3.4.2 - citty@0.2.2: {} cli-width@4.1.0: {} @@ -5029,7 +4924,7 @@ snapshots: dependencies: uncrypto: 0.1.3 - crossws@0.4.5: {} + crossws@0.4.10: {} cssesc@3.0.0: {} @@ -5055,11 +4950,11 @@ snapshots: electron-to-chromium@1.5.361: {} - emnapi@1.11.1(node-addon-api@8.7.0): + emnapi@1.11.3(node-addon-api@8.7.0): optionalDependencies: node-addon-api: 8.7.0 - enhanced-resolve@5.24.1: + enhanced-resolve@5.24.5: dependencies: graceful-fs: 4.2.11 tapable: 2.3.3 @@ -5068,7 +4963,7 @@ snapshots: es-module-lexer@2.1.0: {} - es-toolkit@1.46.1: {} + es-toolkit@1.50.0: {} esbuild@0.27.7: optionalDependencies: @@ -5158,6 +5053,10 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -5190,7 +5089,7 @@ snapshots: dependencies: is-glob: 4.0.3 - globby@16.2.0: + globby@16.2.2: dependencies: '@sindresorhus/merge-streams': 4.0.0 fast-glob: 3.3.3 @@ -5259,7 +5158,7 @@ snapshots: joycon@3.1.1: {} - js-yaml@4.1.1: + js-yaml@4.3.1: dependencies: argparse: 2.0.1 @@ -5267,83 +5166,85 @@ snapshots: json-with-bigint@3.5.8: {} - lightningcss-android-arm64@1.32.0: + lightningcss-android-arm64@1.33.0: optional: true - lightningcss-darwin-arm64@1.32.0: {} + lightningcss-darwin-arm64@1.33.0: {} - lightningcss-darwin-x64@1.32.0: {} + lightningcss-darwin-x64@1.33.0: {} - lightningcss-freebsd-x64@1.32.0: + lightningcss-freebsd-x64@1.33.0: optional: true - lightningcss-linux-arm-gnueabihf@1.32.0: + lightningcss-linux-arm-gnueabihf@1.33.0: optional: true - lightningcss-linux-arm64-gnu@1.32.0: {} + lightningcss-linux-arm64-gnu@1.33.0: {} - lightningcss-linux-arm64-musl@1.32.0: {} + lightningcss-linux-arm64-musl@1.33.0: {} - lightningcss-linux-x64-gnu@1.32.0: {} + lightningcss-linux-x64-gnu@1.33.0: {} - lightningcss-linux-x64-musl@1.32.0: {} + lightningcss-linux-x64-musl@1.33.0: {} - lightningcss-win32-arm64-msvc@1.32.0: + lightningcss-win32-arm64-msvc@1.33.0: optional: true - lightningcss-win32-x64-msvc@1.32.0: {} + lightningcss-win32-x64-msvc@1.33.0: {} - lightningcss@1.32.0(patch_hash=1d4a8800d60d13d42887b88b3a86576df4b451670308145fb432ec8abbf40930): + lightningcss@1.33.0(patch_hash=1d4a8800d60d13d42887b88b3a86576df4b451670308145fb432ec8abbf40930): dependencies: detect-libc: 2.1.2 optionalDependencies: - lightningcss-android-arm64: 1.32.0 - lightningcss-darwin-arm64: 1.32.0 - lightningcss-darwin-x64: 1.32.0 - lightningcss-freebsd-x64: 1.32.0 - lightningcss-linux-arm-gnueabihf: 1.32.0 - lightningcss-linux-arm64-gnu: 1.32.0 - lightningcss-linux-arm64-musl: 1.32.0 - lightningcss-linux-x64-gnu: 1.32.0 - lightningcss-linux-x64-musl: 1.32.0 - lightningcss-win32-arm64-msvc: 1.32.0 - lightningcss-win32-x64-msvc: 1.32.0 + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} - listhen@1.10.0: + listhen@1.10.1(@parcel/watcher@2.6.0(patch_hash=705ce75ccea54337110c4d7fe0a8b421658ea0c23e1a77c1fa890a86041179da)): dependencies: - '@parcel/watcher': 2.5.6 '@parcel/watcher-wasm': 2.5.6 citty: 0.2.2 consola: 3.4.2 - crossws: 0.4.5 + crossws: 0.4.10 defu: 6.1.7 get-port-please: 3.2.0 h3: 1.15.11 http-shutdown: 1.2.2 jiti: 2.7.0 - mlly: 1.8.2 node-forge: 1.4.0 pathe: 2.0.3 - std-env: 4.1.0 - tinyclip: 0.1.12 + std-env: 4.2.0 + tinyclip: 0.1.15 ufo: 1.6.4 - untun: 0.1.3 + untun: 0.2.2 uqr: 0.1.3 + optionalDependencies: + '@parcel/watcher': 2.6.0(patch_hash=705ce75ccea54337110c4d7fe0a8b421658ea0c23e1a77c1fa890a86041179da) transitivePeerDependencies: - srvx load-tsconfig@0.2.5: {} - loader-runner@4.3.2: {} - magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magic-string@1.1.0: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + merge-stream@2.0.0: {} merge2@1.4.1: {} @@ -5362,16 +5263,16 @@ snapshots: mini-svg-data-uri@1.4.4: {} - minimizer-webpack-plugin@5.6.1(esbuild@0.27.7)(postcss@8.5.16)(webpack@5.108.3(esbuild@0.27.7)(postcss@8.5.16)): + minimizer-webpack-plugin@5.6.1(esbuild@0.27.7)(postcss@8.5.25)(webpack@5.109.2(esbuild@0.27.7)(postcss@8.5.25)): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 terser: 5.48.0 - webpack: 5.108.3(esbuild@0.27.7)(postcss@8.5.16) + webpack: 5.109.2(esbuild@0.27.7)(postcss@8.5.25) optionalDependencies: esbuild: 0.27.7 - postcss: 8.5.16 + postcss: 8.5.25 mlly@1.8.2: dependencies: @@ -5394,9 +5295,11 @@ snapshots: nanoid@3.3.12: {} + nanoid@3.3.16: {} + neo-async@2.6.2: {} - next@16.2.7(@playwright/test@1.61.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + next@16.2.7(@playwright/test@1.62.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: '@next/env': 16.2.7 '@swc/helpers': 0.5.15 @@ -5415,7 +5318,7 @@ snapshots: '@next/swc-linux-x64-musl': 16.2.7 '@next/swc-win32-arm64-msvc': 16.2.7 '@next/swc-win32-x64-msvc': 16.2.7 - '@playwright/test': 1.61.1 + '@playwright/test': 1.62.1 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -5441,14 +5344,14 @@ snapshots: obug@2.1.1: {} + obug@2.1.4: {} + package-up@5.0.0: dependencies: find-up-simple: 1.0.1 path-parse@1.0.7: {} - pathe@1.1.2: {} - pathe@2.0.3: {} picocolors@1.1.1: {} @@ -5457,6 +5360,8 @@ snapshots: picomatch@4.0.4: {} + picomatch@4.0.5: {} + pify@2.3.0: {} pirates@4.0.7: {} @@ -5467,52 +5372,52 @@ snapshots: mlly: 1.8.2 pathe: 2.0.3 - playwright-core@1.61.1: {} + playwright-core@1.62.1: {} - playwright@1.61.1: + playwright@1.62.1: dependencies: - playwright-core: 1.61.1 + playwright-core: 1.62.1 optionalDependencies: fsevents: 2.3.2 - postcss-import@15.1.0(postcss@8.5.15): - dependencies: - postcss: 8.5.15 - postcss-value-parser: 4.2.0 - read-cache: 1.0.0 - resolve: 1.22.12 - - postcss-import@16.1.1(postcss@8.5.16): + postcss-import@15.1.0(postcss@8.5.16): dependencies: postcss: 8.5.16 postcss-value-parser: 4.2.0 read-cache: 1.0.0 resolve: 1.22.12 - postcss-js@4.1.0(postcss@8.5.15): + postcss-import@16.1.1(postcss@8.5.25): + dependencies: + postcss: 8.5.25 + postcss-value-parser: 4.2.0 + read-cache: 1.0.0 + resolve: 1.22.12 + + postcss-js@4.1.0(postcss@8.5.16): dependencies: camelcase-css: 2.0.1 - postcss: 8.5.15 + postcss: 8.5.16 - postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.15)(yaml@2.9.0): + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.16)(yaml@2.9.0): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 1.21.7 - postcss: 8.5.15 + postcss: 8.5.16 yaml: 2.9.0 - postcss-load-config@6.0.1(jiti@2.7.0)(postcss@8.5.16)(yaml@2.9.0): + postcss-load-config@6.0.1(jiti@2.7.0)(postcss@8.5.25)(yaml@2.9.0): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 2.7.0 - postcss: 8.5.16 + postcss: 8.5.25 yaml: 2.9.0 - postcss-nested@6.2.0(postcss@8.5.15): + postcss-nested@6.2.0(postcss@8.5.16): dependencies: - postcss: 8.5.15 + postcss: 8.5.16 postcss-selector-parser: 6.1.2 postcss-selector-parser@6.0.10: @@ -5538,15 +5443,15 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - postcss@8.5.15: + postcss@8.5.16: dependencies: nanoid: 3.3.12 picocolors: 1.1.1 source-map-js: 1.2.1 - postcss@8.5.16: + postcss@8.5.25: dependencies: - nanoid: 3.3.12 + nanoid: 3.3.16 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -5561,12 +5466,12 @@ snapshots: transitivePeerDependencies: - babel-plugin-macros - prettier-plugin-organize-imports@4.3.0(prettier@3.9.4)(typescript@5.9.3): + prettier-plugin-organize-imports@4.3.0(prettier@3.9.6)(typescript@5.9.3): dependencies: - prettier: 3.9.4 + prettier: 3.9.6 typescript: 5.9.3 - prettier@3.9.4: {} + prettier@3.9.6: {} queue-microtask@1.2.3: {} @@ -5609,26 +5514,26 @@ snapshots: reusify@1.1.0: {} - rolldown@1.1.3: + rolldown@1.2.1: dependencies: - '@oxc-project/types': 0.137.0 + '@oxc-project/types': 0.142.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm64': 1.1.3 - '@rolldown/binding-darwin-arm64': 1.1.3 - '@rolldown/binding-darwin-x64': 1.1.3 - '@rolldown/binding-freebsd-x64': 1.1.3 - '@rolldown/binding-linux-arm-gnueabihf': 1.1.3 - '@rolldown/binding-linux-arm64-gnu': 1.1.3 - '@rolldown/binding-linux-arm64-musl': 1.1.3 - '@rolldown/binding-linux-ppc64-gnu': 1.1.3 - '@rolldown/binding-linux-s390x-gnu': 1.1.3 - '@rolldown/binding-linux-x64-gnu': 1.1.3 - '@rolldown/binding-linux-x64-musl': 1.1.3 - '@rolldown/binding-openharmony-arm64': 1.1.3 - '@rolldown/binding-wasm32-wasi': 1.1.3 - '@rolldown/binding-win32-arm64-msvc': 1.1.3 - '@rolldown/binding-win32-x64-msvc': 1.1.3 + '@rolldown/binding-android-arm64': 1.2.1 + '@rolldown/binding-darwin-arm64': 1.2.1 + '@rolldown/binding-darwin-x64': 1.2.1 + '@rolldown/binding-freebsd-x64': 1.2.1 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.1 + '@rolldown/binding-linux-arm64-gnu': 1.2.1 + '@rolldown/binding-linux-arm64-musl': 1.2.1 + '@rolldown/binding-linux-ppc64-gnu': 1.2.1 + '@rolldown/binding-linux-s390x-gnu': 1.2.1 + '@rolldown/binding-linux-x64-gnu': 1.2.1 + '@rolldown/binding-linux-x64-musl': 1.2.1 + '@rolldown/binding-openharmony-arm64': 1.2.1 + '@rolldown/binding-wasm32-wasi': 1.2.1 + '@rolldown/binding-win32-arm64-msvc': 1.2.1 + '@rolldown/binding-win32-x64-msvc': 1.2.1 rollup@4.60.4: dependencies: @@ -5676,15 +5581,13 @@ snapshots: ajv-formats: 2.1.1(ajv@8.20.0) ajv-keywords: 5.1.0(ajv@8.20.0) - semver@7.8.1: {} - - semver@7.8.4: {} + semver@7.8.5: {} sharp@0.34.5: dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 - semver: 7.8.4 + semver: 7.8.5 optionalDependencies: '@img/sharp-darwin-arm64': 0.34.5 '@img/sharp-darwin-x64': 0.34.5 @@ -5733,6 +5636,8 @@ snapshots: std-env@4.1.0: {} + std-env@4.2.0: {} + styled-jsx@5.1.6(react@19.2.7): dependencies: client-only: 0.0.1 @@ -5772,11 +5677,11 @@ snapshots: normalize-path: 3.0.0 object-hash: 3.0.0 picocolors: 1.1.1 - postcss: 8.5.15 - postcss-import: 15.1.0(postcss@8.5.15) - postcss-js: 4.1.0(postcss@8.5.15) - postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.15)(yaml@2.9.0) - postcss-nested: 6.2.0(postcss@8.5.15) + postcss: 8.5.16 + postcss-import: 15.1.0(postcss@8.5.16) + postcss-js: 4.1.0(postcss@8.5.16) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.16)(yaml@2.9.0) + postcss-nested: 6.2.0(postcss@8.5.16) postcss-selector-parser: 6.1.2 resolve: 1.22.12 sucrase: 3.35.1 @@ -5805,7 +5710,7 @@ snapshots: tinybench@2.9.0: {} - tinyclip@0.1.12: {} + tinyclip@0.1.15: {} tinyexec@0.3.2: {} @@ -5818,8 +5723,8 @@ snapshots: tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 tinyrainbow@3.1.0: {} @@ -5829,22 +5734,22 @@ snapshots: tree-kill@1.2.2: {} - tree-sitter-javascript@0.23.1(tree-sitter@0.22.4): + tree-sitter-javascript@0.23.1(tree-sitter@0.25.1): dependencies: node-addon-api: 8.7.0 node-gyp-build: 4.8.4 optionalDependencies: - tree-sitter: 0.22.4 + tree-sitter: 0.25.1 - tree-sitter-typescript@0.23.2(tree-sitter@0.22.4): + tree-sitter-typescript@0.23.2(tree-sitter@0.25.1): dependencies: node-addon-api: 8.7.0 node-gyp-build: 4.8.4 - tree-sitter-javascript: 0.23.1(tree-sitter@0.22.4) + tree-sitter-javascript: 0.23.1(tree-sitter@0.25.1) optionalDependencies: - tree-sitter: 0.22.4 + tree-sitter: 0.25.1 - tree-sitter@0.22.4: + tree-sitter@0.25.1: dependencies: node-addon-api: 8.7.0 node-gyp-build: 4.8.4 @@ -5853,7 +5758,7 @@ snapshots: tslib@2.8.1: {} - tsup@8.5.1(jiti@2.7.0)(postcss@8.5.16)(typescript@5.9.3)(yaml@2.9.0): + tsup@8.5.1(jiti@2.7.0)(postcss@8.5.25)(typescript@5.9.3)(yaml@2.9.0): dependencies: bundle-require: 5.1.0(esbuild@0.27.7) cac: 6.7.14 @@ -5864,7 +5769,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@2.7.0)(postcss@8.5.16)(yaml@2.9.0) + postcss-load-config: 6.0.1(jiti@2.7.0)(postcss@8.5.25)(yaml@2.9.0) resolve-from: 5.0.0 rollup: 4.60.4 source-map: 0.7.6 @@ -5873,7 +5778,7 @@ snapshots: tinyglobby: 0.2.16 tree-kill: 1.2.2 optionalDependencies: - postcss: 8.5.16 + postcss: 8.5.25 typescript: 5.9.3 transitivePeerDependencies: - jiti @@ -5881,14 +5786,14 @@ snapshots: - tsx - yaml - turbo@2.9.18: + turbo@2.10.8: optionalDependencies: - '@turbo/darwin-64': 2.9.18 - '@turbo/darwin-arm64': 2.9.18 - '@turbo/linux-64': 2.9.18 - '@turbo/linux-arm64': 2.9.18 - '@turbo/windows-64': 2.9.18 - '@turbo/windows-arm64': 2.9.18 + '@turbo/darwin-64': 2.10.8 + '@turbo/darwin-arm64': 2.10.8 + '@turbo/linux-64': 2.10.8 + '@turbo/linux-arm64': 2.10.8 + '@turbo/windows-64': 2.10.8 + '@turbo/windows-arm64': 2.10.8 typanion@3.14.0: {} @@ -5912,11 +5817,7 @@ snapshots: universal-user-agent@7.0.3: {} - untun@0.1.3: - dependencies: - citty: 0.1.6 - consola: 3.4.2 - pathe: 1.1.2 + untun@0.2.2: {} update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: @@ -5928,27 +5829,27 @@ snapshots: util-deprecate@1.0.2: {} - vite@8.1.2(@types/node@22.19.21)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0): + vite@8.2.0(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0): dependencies: - lightningcss: 1.32.0(patch_hash=1d4a8800d60d13d42887b88b3a86576df4b451670308145fb432ec8abbf40930) - picomatch: 4.0.4 - postcss: 8.5.16 - rolldown: 1.1.3 + lightningcss: 1.33.0(patch_hash=1d4a8800d60d13d42887b88b3a86576df4b451670308145fb432ec8abbf40930) + picomatch: 4.0.5 + postcss: 8.5.25 + rolldown: 1.2.1 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 22.19.21 + '@types/node': 22.20.1 esbuild: 0.27.7 fsevents: 2.3.3 jiti: 2.7.0 terser: 5.48.0 yaml: 2.9.0 - vite@8.1.2(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0): + vite@8.2.0(@types/node@25.9.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0): dependencies: - lightningcss: 1.32.0(patch_hash=1d4a8800d60d13d42887b88b3a86576df4b451670308145fb432ec8abbf40930) - picomatch: 4.0.4 - postcss: 8.5.16 - rolldown: 1.1.3 + lightningcss: 1.33.0(patch_hash=1d4a8800d60d13d42887b88b3a86576df4b451670308145fb432ec8abbf40930) + picomatch: 4.0.5 + postcss: 8.5.25 + rolldown: 1.2.1 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 25.9.1 @@ -5958,15 +5859,15 @@ snapshots: terser: 5.48.0 yaml: 2.9.0 - vitest@4.1.9(@types/node@22.19.21)(vite@8.1.2(@types/node@22.19.21)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)): + vitest@4.1.10(@types/node@22.20.1)(vite@8.2.0(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)): dependencies: - '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(vite@8.1.2(@types/node@22.19.21)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) - '@vitest/pretty-format': 4.1.9 - '@vitest/runner': 4.1.9 - '@vitest/snapshot': 4.1.9 - '@vitest/spy': 4.1.9 - '@vitest/utils': 4.1.9 + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.2.0(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 es-module-lexer: 2.1.0 expect-type: 1.3.0 magic-string: 0.30.21 @@ -5976,12 +5877,12 @@ snapshots: std-env: 4.1.0 tinybench: 2.9.0 tinyexec: 1.1.2 - tinyglobby: 0.2.16 + tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.1.2(@types/node@22.19.21)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) + vite: 8.2.0(@types/node@22.20.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 22.19.21 + '@types/node': 22.20.1 transitivePeerDependencies: - msw @@ -5989,9 +5890,9 @@ snapshots: dependencies: graceful-fs: 4.2.11 - webpack-sources@3.5.0: {} + webpack-sources@3.5.1: {} - webpack@5.108.3(esbuild@0.27.7)(postcss@8.5.16): + webpack@5.109.2(esbuild@0.27.7)(postcss@8.5.25): dependencies: '@types/estree': 1.0.9 '@types/json-schema': 7.0.15 @@ -5999,22 +5900,20 @@ snapshots: '@webassemblyjs/wasm-edit': 1.14.1 '@webassemblyjs/wasm-parser': 1.14.1 acorn: 8.16.0 - acorn-import-phases: 1.0.4(acorn@8.16.0) browserslist: 4.28.2 chrome-trace-event: 1.0.4 - enhanced-resolve: 5.24.1 + enhanced-resolve: 5.24.5 es-module-lexer: 2.1.0 eslint-scope: 5.1.1 events: 3.3.0 graceful-fs: 4.2.11 - loader-runner: 4.3.2 mime-db: 1.54.0 - minimizer-webpack-plugin: 5.6.1(esbuild@0.27.7)(postcss@8.5.16)(webpack@5.108.3(esbuild@0.27.7)(postcss@8.5.16)) + minimizer-webpack-plugin: 5.6.1(esbuild@0.27.7)(postcss@8.5.25)(webpack@5.109.2(esbuild@0.27.7)(postcss@8.5.25)) neo-async: 2.6.2 schema-utils: 4.3.3 tapable: 2.3.3 watchpack: 2.5.2 - webpack-sources: 3.5.0 + webpack-sources: 3.5.1 transitivePeerDependencies: - '@minify-html/node' - '@swc/core' diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f3c46a980..bcddef126 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,25 +6,34 @@ packages: - 'integrations' patchedDependencies: - '@parcel/watcher@2.5.1': patches/@parcel__watcher@2.5.1.patch - lightningcss@1.32.0: patches/lightningcss@1.32.0.patch + '@napi-rs/cli@3.7.4': patches/@napi-rs__cli@3.7.4.patch + '@parcel/watcher@2.6.0': patches/@parcel__watcher@2.6.0.patch + lightningcss@1.33.0: patches/lightningcss@1.33.0.patch catalog: - '@types/node': 22.19.21 + '@types/node': 22.20.1 dedent: 1.7.2 - enhanced-resolve: ^5.24.1 - lightningcss-darwin-arm64: 1.32.0 - lightningcss-darwin-x64: 1.32.0 - lightningcss-linux-arm64-gnu: 1.32.0 - lightningcss-linux-arm64-musl: 1.32.0 - lightningcss-linux-x64-gnu: 1.32.0 - lightningcss-linux-x64-musl: 1.32.0 - lightningcss-win32-x64-msvc: 1.32.0 - lightningcss: 1.32.0 - postcss: ^8.5.16 - prettier: 3.9.4 - vite: ^8.1.2 - webpack: ^5.108.3 + enhanced-resolve: ^5.24.5 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + lightningcss: 1.33.0 + '@parcel/watcher-darwin-arm64': 2.6.0 + '@parcel/watcher-darwin-x64': 2.6.0 + '@parcel/watcher-linux-arm64-glibc': 2.6.0 + '@parcel/watcher-linux-arm64-musl': 2.6.0 + '@parcel/watcher-linux-x64-glibc': 2.6.0 + '@parcel/watcher-linux-x64-musl': 2.6.0 + '@parcel/watcher-win32-x64': 2.6.0 + '@parcel/watcher': 2.6.0 + postcss: ^8.5.25 + prettier: 3.9.6 + vite: ^8.2.0 + webpack: ^5.109.2 onlyBuiltDependencies: - '@parcel/watcher' diff --git a/turbo.json b/turbo.json index 87eba2732..c48410b29 100644 --- a/turbo.json +++ b/turbo.json @@ -1,5 +1,5 @@ { - "$schema": "https://turbo.build/schema.json", + "$schema": "https://v2-10-8.turborepo.dev/schema.json", "ui": "tui", "tasks": { "@tailwindcss/oxide#build": { diff --git a/vitest.config.ts b/vitest.config.mts similarity index 100% rename from vitest.config.ts rename to vitest.config.mts