From 5d82f5b472b73a08a8d54a5bde6605af8f345343 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Sat, 12 Oct 2024 20:55:29 -0400 Subject: [PATCH 01/19] Use Rust 1.81 toolchain --- rust-toolchain.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 7f466bd2d..6f4d5bc31 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "1.80.1" +channel = "1.81" profile = "default" From 7d2b791efb8a0b74e97b5efaa95f7a53b375a5a3 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Sat, 12 Oct 2024 19:13:55 -0400 Subject: [PATCH 02/19] Reduce data dependencies in `fast_skip` The CPU can compute `1 | 2` in parallel with `3 | 4` --- crates/oxide/src/fast_skip.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/oxide/src/fast_skip.rs b/crates/oxide/src/fast_skip.rs index 488e61bd6..5bffd18e1 100644 --- a/crates/oxide/src/fast_skip.rs +++ b/crates/oxide/src/fast_skip.rs @@ -80,10 +80,10 @@ fn is_ascii_whitespace(value: [u8; STRIDE]) -> [bool; STRIDE] { let whitespace_5 = eq(value, b' '); or( - or( - or(or(whitespace_1, whitespace_2), whitespace_3), - whitespace_4, - ), - whitespace_5, + or( + or(whitespace_1, whitespace_2), + or(whitespace_3, whitespace_4) + ), + whitespace_5 ) } From 045887cd6211927cbe84d400f0c474506f2a8e8b Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Sat, 12 Oct 2024 19:14:00 -0400 Subject: [PATCH 03/19] Cleanup --- crates/oxide/src/cursor.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/oxide/src/cursor.rs b/crates/oxide/src/cursor.rs index 0c422c6ff..8aff6efb0 100644 --- a/crates/oxide/src/cursor.rs +++ b/crates/oxide/src/cursor.rs @@ -2,10 +2,10 @@ use std::{ascii::escape_default, fmt::Display}; #[derive(Debug, Clone)] pub struct Cursor<'a> { - // The input we're scanning + /// The input we're scanning pub input: &'a [u8], - // The location of the cursor in the input + /// The location of the cursor in the input pub pos: usize, /// Is the cursor at the start of the input From 191bc824c27fe26736b6a25bc07bbbe2e44ce8f8 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Sat, 12 Oct 2024 19:15:13 -0400 Subject: [PATCH 04/19] add: Fast Stack This is an implementation of a stack that does minimal bookeeping, has zero heap allocations, and is guaranteed to not panic --- crates/core/src/util/fast_stack.rs | 60 ++++++++++++++++++++++++++++++ crates/core/src/util/gurantee.rs | 11 ++++++ crates/core/src/util/mod.rs | 8 ++++ 3 files changed, 79 insertions(+) create mode 100644 crates/core/src/util/fast_stack.rs create mode 100644 crates/core/src/util/gurantee.rs create mode 100644 crates/core/src/util/mod.rs diff --git a/crates/core/src/util/fast_stack.rs b/crates/core/src/util/fast_stack.rs new file mode 100644 index 000000000..c78cae18a --- /dev/null +++ b/crates/core/src/util/fast_stack.rs @@ -0,0 +1,60 @@ +use super::gurantee; + +pub struct FastStack { + storage: [u8; 256], + pos: usize +} + +impl FastStack { + #[inline(always)] + pub fn new() -> FastStack { + FastStack { + storage: [0; 256], + pos: 0 + } + } + + #[inline(always)] + pub fn push(&mut self, value: u8) { + gurantee(!self.overgrown()); + + self.storage[self.pos] = value; + self.pos += 1; + } + + #[inline(always)] + pub fn peek(&self) -> u8 { + gurantee(!self.overgrown()); + + return self.storage[self.pos - 1]; + } + + #[inline(always)] + pub fn last(&self) -> Option { + if self.is_empty() || self.overgrown() { + return None; + } + + return Some(self.peek()); + } + + #[inline(always)] + pub fn pop(&mut self) { + // SAFETY: The buffer does not need to be mutated because the stack is + // only ever read from or written to its current position. Its current + // position is only ever incremented after writing to it. Meaning that + // the buffer can be dirty for the next use and still be correct since + // reading/writing always starts at position `0`. + self.pos = self.pos.saturating_sub(1); + } + + #[inline(always)] + pub fn is_empty(&self) -> bool { + return self.pos == 0; + } + + #[inline(always)] + pub fn overgrown(&self) -> bool { + self.pos > 256 + } +} diff --git a/crates/core/src/util/gurantee.rs b/crates/core/src/util/gurantee.rs new file mode 100644 index 000000000..e49e0b962 --- /dev/null +++ b/crates/core/src/util/gurantee.rs @@ -0,0 +1,11 @@ +#[inline(always)] +pub const fn gurantee(expr: bool) { + #[cfg(debug_assertions)] + if !expr { + panic!("gurantee failed") + } + + unsafe { + std::hint::assert_unchecked(expr) + } +} diff --git a/crates/core/src/util/mod.rs b/crates/core/src/util/mod.rs new file mode 100644 index 000000000..9d12c9626 --- /dev/null +++ b/crates/core/src/util/mod.rs @@ -0,0 +1,8 @@ +mod fast_stack; +mod gurantee; + +#[allow(unused_imports)] +pub use crate::util::fast_stack::*; + +#[allow(unused_imports)] +pub use crate::util::gurantee::*; From 2dda2982726a86af77bcbd793834137923e4b6c0 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Sat, 12 Oct 2024 19:28:31 -0400 Subject: [PATCH 05/19] port: CSS AST --- Cargo.lock | 518 ++++++++++++++++++++++++++++++----- crates/core/Cargo.toml | 24 ++ crates/core/src/css/ast.rs | 156 +++++++++++ crates/core/src/css/mod.rs | 2 + crates/core/src/css/visit.rs | 192 +++++++++++++ crates/core/src/lib.rs | 1 + 6 files changed, 820 insertions(+), 73 deletions(-) create mode 100644 crates/core/Cargo.toml create mode 100644 crates/core/src/css/ast.rs create mode 100644 crates/core/src/css/mod.rs create mode 100644 crates/core/src/css/visit.rs create mode 100644 crates/core/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index f3ecb7a58..6a17fb136 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + [[package]] name = "bitflags" version = "2.6.0" @@ -28,6 +34,18 @@ dependencies = [ "serde", ] +[[package]] +name = "bumpalo" +version = "3.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "cfg-if" version = "1.0.0" @@ -121,6 +139,12 @@ version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91" +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + [[package]] name = "errno" version = "0.3.9" @@ -137,6 +161,118 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6" +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "glob" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" + [[package]] name = "glob-match" version = "0.2.1" @@ -167,6 +303,12 @@ dependencies = [ "walkdir", ] +[[package]] +name = "hashbrown" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e087f84d4f86bf4b218b927129862374b72199ae7d8657835f1e89000eea4fb" + [[package]] name = "ignore" version = "0.4.23" @@ -183,6 +325,22 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "indexmap" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" + [[package]] name = "lazy_static" version = "1.4.0" @@ -202,7 +360,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4979f22fdb869068da03c9f7528f8297c6fd2606bc3a4affe42e6a823fdb8da4" dependencies = [ "cfg-if", - "windows-targets 0.48.0", + "windows-targets", ] [[package]] @@ -317,6 +475,30 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0a7ae3ac2f1173085d398531c705756c94a4c56843785df85a60c1a0afac116" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "ppv-lite86" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-crate" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecf48c7ca261d60b74ab1a7b20da18bede46776b2e55535cb958eb595c5fa7b" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.86" @@ -328,13 +510,43 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.28" +version = "1.0.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ab9c7eadfd8df19006f1cf1a4aed13540ed5cbc047010ece5826e10825488" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" dependencies = [ "proc-macro2", ] +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + [[package]] name = "rayon" version = "1.10.0" @@ -404,12 +616,68 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + +[[package]] +name = "rstest" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b423f0e62bdd61734b67cd21ff50871dfaeb9cc74f869dcd6af974fbcb19936" +dependencies = [ + "futures", + "futures-timer", + "rstest_macros", + "rustc_version", +] + +[[package]] +name = "rstest_macros" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e1711e7d14f74b12a58411c542185ef7fb7f2e7f8ee6e2940a883628522b42" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn", + "unicode-ident", +] + +[[package]] +name = "rstest_reuse" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a8fb4672e840a587a66fc577a5491375df51ddb88f2a2c2a792598c326fe14" +dependencies = [ + "quote", + "rand", + "syn", +] + [[package]] name = "rustc-hash" version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "583034fd73374156e66797ed8e5b0d5690409c9226b22d87cb7f19821c05d152" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.37" @@ -423,6 +691,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + [[package]] name = "same-file" version = "1.0.6" @@ -440,9 +714,35 @@ checksum = "bebd363326d05ec3e2f532ab7660680f3b02130d780c299bca73469d521bc0ed" [[package]] name = "serde" -version = "1.0.163" +version = "1.0.210" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2113ab51b87a539ae008b5c6c02dc020ffa39afd2d83cffcb3f4eb2722cebec2" +checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.210" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] [[package]] name = "sharded-slab" @@ -453,6 +753,15 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + [[package]] name = "smallvec" version = "1.10.0" @@ -461,9 +770,9 @@ checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0" [[package]] name = "syn" -version = "2.0.18" +version = "2.0.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32d41677bcbe24c20c52e7c70b0d8db04134c5d1066bf98662e2871ad200ea3e" +checksum = "89132cd0bf050864e1d38dc3bbc07a0eb8e7530af26344d3d2bbbef83499f590" dependencies = [ "proc-macro2", "quote", @@ -481,6 +790,18 @@ dependencies = [ "tailwindcss-oxide", ] +[[package]] +name = "tailwindcss-core" +version = "0.1.0" +dependencies = [ + "bstr", + "log", + "rstest", + "rstest_reuse", + "serde_json", + "wasm-bindgen", +] + [[package]] name = "tailwindcss-oxide" version = "0.1.0" @@ -523,6 +844,23 @@ dependencies = [ "once_cell", ] +[[package]] +name = "toml_datetime" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" + +[[package]] +name = "toml_edit" +version = "0.22.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" +dependencies = [ + "indexmap", + "toml_datetime", + "winnow", +] + [[package]] name = "tracing" version = "0.1.40" @@ -612,6 +950,67 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasm-bindgen" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128d1e363af62632b8eb57219c8fd7877144af57558fb2ef0368d0087bddeb2e" +dependencies = [ + "cfg-if", + "once_cell", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb6dd4d3ca0ddffd1dd1c9c04f94b868c37ff5fac97c30b97cff2d74fce3a358" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79384be7f8f5a9dd5d7167216f022090cf1f9ec128e6e6a482a2cb5c5422c56" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d" + [[package]] name = "winapi" version = "0.3.9" @@ -649,7 +1048,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -658,22 +1057,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b1eb6f0cd7c80c79759c929114ef071b87354ce476d9d94271031c0497adfd5" -dependencies = [ - "windows_aarch64_gnullvm 0.48.0", - "windows_aarch64_msvc 0.48.0", - "windows_i686_gnu 0.48.0", - "windows_i686_msvc 0.48.0", - "windows_x86_64_gnu 0.48.0", - "windows_x86_64_gnullvm 0.48.0", - "windows_x86_64_msvc 0.48.0", + "windows-targets", ] [[package]] @@ -682,46 +1066,28 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", "windows_i686_gnullvm", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_i686_gnu" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -734,50 +1100,56 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_msvc" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.6.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" +dependencies = [ + "memchr", +] + +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "byteorder", + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml new file mode 100644 index 000000000..ef2d27aed --- /dev/null +++ b/crates/core/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "tailwindcss-core" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[profile.release] +lto = true +opt-level = "s" +panic = "abort" +strip = true +codegen-units = 1 + +[dependencies] +serde_json = "1.0.127" +log = "0.4.22" +wasm-bindgen = "0.2.93" +bstr = "1.10.0" + +[dev-dependencies] +rstest = "0.22.0" +rstest_reuse = "0.7.0" diff --git a/crates/core/src/css/ast.rs b/crates/core/src/css/ast.rs new file mode 100644 index 000000000..30274408a --- /dev/null +++ b/crates/core/src/css/ast.rs @@ -0,0 +1,156 @@ +use std::{collections::HashMap, fmt, unreachable}; + +/// Represents the AST of a CSS stylesheet +#[derive(Clone, PartialEq)] +pub struct Stylesheet { + pub(crate) rules: CssNode, +} + +/// A node in a CSS Stylesheet +#[derive(Clone, PartialEq)] +pub enum CssNode { + /// A context block used to provide shared data to subtrees + Context { + data: HashMap, + nodes: Vec, + }, + + /// A CSS at rule + AtRule { + name: Vec, + params: Vec, + nodes: Vec, + }, + + /// A CSS style rule + StyleRule { + selector: Vec, + nodes: Vec, + }, + + /// A CSS declaration + Declaration { + property: Vec, + value: Vec, + important: bool, + }, + + /// A comment + Comment { + value: Vec, + }, + + /// Represents multiple CSS nodes + /// This is used when replacing a single node with multiple nodes + Contents { + nodes: Vec, + }, +} + +pub fn style_rule(selector: impl Into>, nodes: impl Into>) -> CssNode { + CssNode::StyleRule { + selector: selector.into(), + nodes: nodes.into(), + } +} + +pub fn at_rule(name: impl Into>, params: impl Into>, nodes: impl Into>) -> CssNode { + CssNode::AtRule { + name: name.into(), + params: params.into(), + nodes: nodes.into() + } +} + + +pub fn decl(property: impl Into>, value: impl Into>, important: bool) -> CssNode { + CssNode::Declaration { + property: property.into(), + value: value.into(), + important, + } +} + +pub fn comment(value: impl Into>) -> CssNode { + CssNode::Comment { value: value.into() } +} + +impl From for CssNode where T: Into> { + fn from(nodes: T) -> Self { + CssNode::Contents { nodes: nodes.into() } + } +} + +impl From for Stylesheet where T: Into> { + fn from(nodes: T) -> Self { + Stylesheet { + rules: CssNode::from(nodes.into()) + } + } +} + +impl CssNode { + pub fn empty() -> Self { + CssNode::from([]) + } +} + +impl fmt::Debug for Stylesheet { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Stylesheet {{ rules: {:?} }}", self.rules) + } +} + +impl fmt::Debug for CssNode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + CssNode::Context { data, nodes } => { + write!(f, "Context {{ data: {:?}, nodes: {:?} }}", data, nodes) + }, + + CssNode::AtRule { name, params, nodes } => { + write!(f, "AtRule {{ name: {:?}, params: {:?}, nodes: {:?} }}", String::from_utf8_lossy(name), String::from_utf8_lossy(params), nodes) + }, + + CssNode::StyleRule { selector, nodes } => { + write!(f, "StyleRule {{ selector: {:?}, nodes: {:?} }}", String::from_utf8_lossy(selector), nodes) + }, + + CssNode::Declaration { property, value, important } => { + write!(f, "Declaration {{ property: {:?}, value: {:?}, important: {:?} }}", String::from_utf8_lossy(property), String::from_utf8_lossy(value), important) + }, + + CssNode::Comment { value } => { + write!(f, "Comment {{ value: {:?} }}", String::from_utf8_lossy(value)) + }, + + CssNode::Contents { nodes } => { + write!(f, "Contents {{ nodes: {:?} }}", nodes) + }, + } + } +} + +impl CssNode { + #[inline(always)] + pub fn push(&mut self, node: CssNode) { + match self { + CssNode::AtRule { nodes, .. } => { + nodes.push(node); + }, + CssNode::StyleRule { nodes, .. } => { + nodes.push(node); + }, + CssNode::Contents { nodes, .. } => { + nodes.push(node); + }, + _ => { + if cfg!(debug_assertions) { + panic!("Cannot push to a non-container node."); + } else { + unreachable!(); + } + } + } + } +} diff --git a/crates/core/src/css/mod.rs b/crates/core/src/css/mod.rs new file mode 100644 index 000000000..ff50e5d1f --- /dev/null +++ b/crates/core/src/css/mod.rs @@ -0,0 +1,2 @@ +pub mod ast; +pub mod visit; diff --git a/crates/core/src/css/visit.rs b/crates/core/src/css/visit.rs new file mode 100644 index 000000000..edd639156 --- /dev/null +++ b/crates/core/src/css/visit.rs @@ -0,0 +1,192 @@ +use super::ast::{CssNode, Stylesheet}; + +#[derive(Debug, Clone, PartialEq)] +pub enum WalkAction { + // Continue walking, which is the default + Continue, + + // Skip visiting the children of this node + Skip, + + // Stop the walk entirely + Stop, +} + +impl Stylesheet { + pub fn walk(&self, cb: &V) + where + V: Fn(&CssNode) -> WalkAction + { + self.rules.walk(cb) + } +} + +impl CssNode { + pub fn walk(&self, cb: &V) + where + V: Fn(&CssNode) -> WalkAction + { + _ = self.walk_impl(cb); + } + + fn walk_impl(&self, cb: &V) -> WalkAction + where + V: Fn(&CssNode) -> WalkAction + { + match cb(self) { + WalkAction::Stop => return WalkAction::Stop, + WalkAction::Skip => return WalkAction::Skip, + WalkAction::Continue => {} + } + + for node in self.children() { + match node.walk_impl(cb) { + WalkAction::Stop => return WalkAction::Stop, + WalkAction::Skip => {} + WalkAction::Continue => {} + } + } + + WalkAction::Continue + } + + fn children(&self) -> impl Iterator { + match self { + CssNode::Context { nodes, .. } => nodes.iter(), + CssNode::AtRule { nodes, .. } => nodes.iter(), + CssNode::StyleRule { nodes, .. } => nodes.iter(), + CssNode::Contents { nodes, .. } => nodes.iter(), + CssNode::Declaration { .. } => [].iter(), + CssNode::Comment { .. } => [].iter(), + } + } +} + +impl Stylesheet { + pub fn walk_mut(&mut self, cb: &mut V) + where + V: FnMut(&mut CssNode) -> WalkAction + { + self.rules.walk_mut(cb) + } +} + +impl CssNode { + pub fn walk_mut(&mut self, cb: &mut V) + where + V: FnMut(&mut CssNode) -> WalkAction + { + _ = self.walk_mut_impl(cb); + } + + fn walk_mut_impl(&mut self, cb: &mut V) -> WalkAction + where + V: FnMut(&mut CssNode) -> WalkAction + { + match cb(self) { + WalkAction::Stop => return WalkAction::Stop, + WalkAction::Skip => return WalkAction::Skip, + WalkAction::Continue => {} + } + + for node in self.children_mut() { + match node.walk_mut_impl(cb) { + WalkAction::Stop => return WalkAction::Stop, + WalkAction::Skip => {} + WalkAction::Continue => {} + } + } + + WalkAction::Continue + } + + pub fn children_mut(&mut self) -> impl Iterator { + match self { + CssNode::Context { nodes, .. } => nodes.iter_mut(), + CssNode::AtRule { nodes, .. } => nodes.iter_mut(), + CssNode::StyleRule { nodes, .. } => nodes.iter_mut(), + CssNode::Contents { nodes, .. } => nodes.iter_mut(), + CssNode::Declaration { .. } => [].iter_mut(), + CssNode::Comment { .. } => [].iter_mut(), + } + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::css::ast::*; + use std::cell::Cell; + + #[test] + fn test_walk() { + let ast = Stylesheet::from([ + style_rule("h1", [ + decl("color", "red", false), + decl("font-size", "2em", false), + ]), + style_rule("h2", [ + decl("color", "blue", false), + decl("font-size", "1.5em", false), + ]), + ]); + + let count = Cell::new(0); + + ast.walk(&|node| { + if let CssNode::Declaration { property, .. } = node { + if property == b"color" { + count.set(count.get() + 1); + } + } + + WalkAction::Continue + }); + + assert_eq!(count.get(), 2); + } + + #[test] + fn test_walk_mut() { + let mut ast = Stylesheet::from([ + style_rule("h1", [ + decl("color", "red", false), + decl("font-size", "2em", false), + ]), + style_rule("h2", [ + decl("color", "blue", false), + decl("font-size", "1.5em", false), + ]), + ]); + + // 1. Change all color properties to green + ast.walk_mut(&mut |node| { + let CssNode::Declaration { property, value, .. } = node else { + return WalkAction::Continue; + }; + + if property != b"color" { + return WalkAction::Continue; + } + + *value = "green".into(); + + return WalkAction::Continue; + }); + + // 2. Re-walk the AST and check that all color properties are green + let count = Cell::new(0); + + ast.walk(&|node| { + if let CssNode::Declaration { property, value, .. } = node { + if property == b"color" && value == b"green" { + count.set(count.get() + 1); + } + } + + WalkAction::Continue + }); + + assert_eq!(count.get(), 2); + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs new file mode 100644 index 000000000..389dd3264 --- /dev/null +++ b/crates/core/src/lib.rs @@ -0,0 +1 @@ +mod css; From 17598c5c2c4b7f1e6e638cf495c0f73efff1901b Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Sun, 1 Sep 2024 20:53:49 -0400 Subject: [PATCH 06/19] port: CSS serializer --- crates/core/src/css/mod.rs | 1 + crates/core/src/css/serializer.rs | 159 ++++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 crates/core/src/css/serializer.rs diff --git a/crates/core/src/css/mod.rs b/crates/core/src/css/mod.rs index ff50e5d1f..8881bd4c6 100644 --- a/crates/core/src/css/mod.rs +++ b/crates/core/src/css/mod.rs @@ -1,2 +1,3 @@ pub mod ast; +pub mod serializer; pub mod visit; diff --git a/crates/core/src/css/serializer.rs b/crates/core/src/css/serializer.rs new file mode 100644 index 000000000..99d055351 --- /dev/null +++ b/crates/core/src/css/serializer.rs @@ -0,0 +1,159 @@ +use super::ast::{CssNode, Stylesheet}; + +impl Stylesheet { + pub fn to_css(&self) -> Vec { + self.rules.to_css() + } +} + +impl CssNode { + fn to_css(&self) -> Vec { + let mut css: Vec = vec![]; + self.write_css_to(&mut css, 0); + return css; + } + + fn write_css_to(&self, css: &mut Vec, depth: usize) { + let indent = b" ".repeat(depth); + + match self { + CssNode::Comment { value } => { + css.extend(&indent); + css.extend(b"/*"); + css.extend(value); + css.extend(b"*/\n"); + }, + + CssNode::Declaration { property, value, important } => { + css.extend(&indent); + css.extend(property); + css.extend(b": "); + css.extend(value); + if *important { + css.extend(b" !important"); + } + css.extend(b";\n"); + }, + + CssNode::Context { nodes, .. } => { + for child in nodes { + child.write_css_to(css, depth); + } + }, + + CssNode::Contents { nodes } => { + for child in nodes { + child.write_css_to(css, depth); + } + }, + + CssNode::AtRule { name, params, nodes } => { + css.extend(&indent); + css.extend(b"@"); + css.extend(name); + css.extend(b" "); + css.extend(params); + + // Print at-rules without nodes with a `;` instead of an empty block. + // + // E.g.: + // + // ```css + // @layer base, components, utilities; + // ``` + if nodes.is_empty() { + css.extend(b";\n"); + } else { + css.extend(b" {\n"); + + for child in nodes { + child.write_css_to(css, depth + 1); + } + + css.extend(&indent); + css.extend(b"}\n"); + } + }, + + CssNode::StyleRule { selector, nodes } => { + css.extend(&indent); + css.extend(selector); + css.extend(b" {\n"); + + for child in nodes { + child.write_css_to(css, depth + 1); + } + + css.extend(&indent); + css.extend(b"}\n"); + } + } + } +} + +#[cfg(test)] +mod test { + use crate::css::{ast::{comment, decl}, parser::parse}; + + #[test] + fn should_pretty_print_an_ast() { + let css = parse(b".foo{color:red;&:hover{color:blue;}}").unwrap(); + + assert_eq!(css.to_css(), b".foo {\n color: red;\n &:hover {\n color: blue;\n }\n}\n"); + } + + #[test] + fn should_print_decls() { + let css = decl(b"color", "red", false); + assert_eq!(css.to_css(), b"color: red;\n"); + } + + #[test] + fn should_print_decls_important() { + let css = decl(b"color", "red", true); + assert_eq!(css.to_css(), b"color: red !important;\n"); + } + + #[test] + fn should_print_comments() { + let css = comment(b" hello world "); + + assert_eq!(css.to_css(), b"/* hello world */\n"); + } + + #[test] + fn should_print_at_rules_without_a_body() { + let css = parse(b"@layer base, components, utilities;").unwrap(); + assert_eq!(css.to_css(), b"@layer base, components, utilities;\n"); + } + + #[test] + fn should_print_at_rules_with_a_body() { + let css = parse(b"@layer base { color: red; }").unwrap(); + assert_eq!(css.to_css(), b"@layer base {\n color: red;\n}\n"); + } + + #[test] + fn should_print_at_rules_with_a_body_and_nested_rules() { + let css = parse(b"@layer base { color: red; &:hover { color: blue; } }").unwrap(); + assert_eq!(css.to_css(), b"@layer base {\n color: red;\n &:hover {\n color: blue;\n }\n}\n"); + } + + #[test] + fn should_print_style_rules() { + let css = parse(b".foo { color: red; }").unwrap(); + assert_eq!(css.to_css(), b".foo {\n color: red;\n}\n"); + } + + #[test] + fn should_print_style_rules_with_nested_style_rules() { + let css = parse(b".foo { color: red; &:hover { color: blue; } }").unwrap(); + assert_eq!(css.to_css(), b".foo {\n color: red;\n &:hover {\n color: blue;\n }\n}\n"); + } + + #[test] + fn should_print_style_rules_with_nested_at_rules() { + let css = parse(b".foo { color: red; @layer base { color: blue; } }").unwrap(); + assert_eq!(css.to_css(), b".foo {\n color: red;\n @layer base {\n color: blue;\n }\n}\n"); + } +} From 1491289ac05ad59141ad9538c354dd10a772a31a Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Sat, 12 Oct 2024 19:28:36 -0400 Subject: [PATCH 07/19] port: CSS parser --- crates/core/src/css/mod.rs | 2 + crates/core/src/css/parser.rs | 1704 +++++++++++++++++++++++++++++++++ crates/core/src/css/syntax.rs | 56 ++ 3 files changed, 1762 insertions(+) create mode 100644 crates/core/src/css/parser.rs create mode 100644 crates/core/src/css/syntax.rs diff --git a/crates/core/src/css/mod.rs b/crates/core/src/css/mod.rs index 8881bd4c6..081e4f7d8 100644 --- a/crates/core/src/css/mod.rs +++ b/crates/core/src/css/mod.rs @@ -1,3 +1,5 @@ pub mod ast; +pub mod parser; pub mod serializer; +pub mod syntax; pub mod visit; diff --git a/crates/core/src/css/parser.rs b/crates/core/src/css/parser.rs new file mode 100644 index 000000000..974b49673 --- /dev/null +++ b/crates/core/src/css/parser.rs @@ -0,0 +1,1704 @@ +use crate::util::FastStack; + +use super::{ast::{at_rule, comment, decl, style_rule, CssNode, Stylesheet}, syntax}; +use bstr::ByteSlice; + +#[inline(never)] +pub fn parse(input: &[u8]) -> Result { + type CssNodeRef = Box; + + let input = input.replace(b"\r\n", b"\n"); + + let mut rules = Vec::::new(); + let mut buffer = Vec::::new(); + let mut license_comments = Vec::::new(); + + let mut closing_bracket_stack = FastStack::new(); + + let mut parent: Option = None; + let mut stack: Vec> = vec![]; + + let mut i = 0; + + while i < input.len() { + let current_char = input[i]; + let peek_char = *input.get(i+1).unwrap_or(&0x00); + + // Current character is a `\` therefore the next character is escaped, + // consume it together with the next character and continue. + // + // E.g.: + // + // ```css + // .hover\:foo:hover {} + // ^ + // ``` + // + if current_char == b'\\' { + buffer.extend(&input[i..i+2]); + i += 1; + } + + // Start of a comment. + // + // E.g.: + // + // ```css + // /* Example */ + // ^^^^^^^^^^^^^ + // .foo { + // color: red; /* Example */ + // ^^^^^^^^^^^^^ + // } + // .bar { + // color: /* Example */ red; + // ^^^^^^^^^^^^^ + // } + // ``` + else if current_char == b'/' && peek_char == b'*' { + let start = i; + + let mut j = i + 2; + + while j < input.len() { + let current_char = input[j]; + let peek_char = *input.get(j + 1).unwrap_or(&0x00); + + // Current character is a `\` therefore the next character is escaped. + if current_char == b'\\' { + j += 1; + } + + // End of the comment + else if current_char == b'*' && peek_char == b'/' { + i = j + 1; + break; + } + + j += 1; + } + + let comment_str = &input[start+2..i - 1]; + + if comment_str.is_empty() { + i += 1; + continue; + } + + if comment_str[0] != b'!' { + i += 1; + continue; + } + + // Collect all license comments so that we can hoist them to the top of + // the AST. + license_comments.push(comment(comment_str[1..].to_vec())); + } + + // Start of a string. + else if current_char == b'\'' || current_char == b'"' { + let start = i; + + // We need to ensure that the closing quote is the same as the opening + // quote. + // + // E.g.: + // + // ```css + // .foo { + // content: "This is a string with a 'quote' in it"; + // ^ ^ -> These are not the end of the string. + // } + // ``` + let mut j = i + 1; + while j < input.len() { + let peek_char = input[j]; + + // Current character is a `\` therefore the next character is escaped. + if peek_char == b'\\' { + j += 1; + } + + // End of the string. + else if peek_char == current_char { + i = j; + break; + } + + // End of the line without ending the string but with a `;` at the end. + // + // E.g.: + // + // ```css + // .foo { + // content: "This is a string with a; + // ^ Missing " + // } + // ``` + // else if peek_char == b';' && input.charCodeAt(j + 1) == b'\n' { + else if peek_char == b';' && *input.get(j + 1).unwrap_or(&0x00) == b'\n' { + let mut data = input[start..j+1].to_vec(); + data.push(current_char); + + return Err(format!( + "Unterminated string: {:}", + String::from_utf8_lossy(&data) + )); + } + + // End of the line without ending the string. + // + // E.g.: + // + // ```css + // .foo { + // content: "This is a string with a + // ^ Missing " + // } + // ``` + else if peek_char == b'\n' { + let mut data = input[start..j].to_vec(); + data.push(current_char); + + return Err(format!( + "Unterminated string: {:}", + String::from_utf8_lossy(&data) + )); + } + + j += 1; + } + + // Adjust `buffer` to include the string. + buffer.extend(&input[start..i + 1]); + } + + // Skip whitespace if the next character is also whitespace. This allows us + // to reduce the amount of whitespace in the AST. + else if + (current_char == b' ' || current_char == b'\n' || current_char == b'\t') && + (peek_char == b' ' || peek_char == b'\n' || peek_char == b'\t') + { + i += 1; + continue + } + + // Replace new lines with spaces. + else if current_char == b'\n' { + if buffer.is_empty() { + i += 1; + continue + } + + let peek_char = *buffer.last().unwrap_or(&0x00); + if peek_char != b' ' && peek_char != b'\n' && peek_char != b'\t' { + buffer.push(b' '); + } + } + + // Start of a custom property. + // + // Custom properties are very permissive and can contain almost any + // character, even `;` and `}`. Therefore we have to make sure that we are + // at the correct "end" of the custom property by making sure everything is + // balanced. + else if current_char == b'-' && peek_char == b'-' && buffer.is_empty() { + let mut closing_bracket_stack = FastStack::new(); + + let mut colon_idx: Option = None; + let start = i; + + let mut j = i + 2; + + while j < input.len() { + let peek_char = input[j]; + let peek2_char = *input.get(j + 1).unwrap_or(&0x00); + + // Current character is a `\` therefore the next character is escaped. + if peek_char == b'\\' { + j += 1 + } + + // Start of a comment. + else if peek_char == b'/' && peek2_char == b'*' { + let mut k = j + 2; + while k < input.len() { + let peek_char = input[k]; + let peek2_char = *input.get(k + 1).unwrap_or(&0x00); + + // Current character is a `\` therefore the next character is escaped. + if peek_char == b'\\' { + k += 1 + } + + // End of the comment + else if peek_char == b'*' && peek2_char == b'/' { + j = k + 1; + break + } + + k += 1; + } + } + + // End of the "property" of the property-value pair. + else if colon_idx.is_none() && peek_char == b':' { + colon_idx = Some(buffer.len() + j - start); + } + + // End of the custom property. + else if peek_char == b';' && closing_bracket_stack.is_empty() { + buffer.extend(&input[start..j]); + i = j; + break + } + + // Start of a block. + else if peek_char == b'(' { + closing_bracket_stack.push(b')') + } else if peek_char == b'[' { + closing_bracket_stack.push(b']') + } else if peek_char == b'{' { + closing_bracket_stack.push(b'}') + } + + // End of the custom property if didn't use a `;` to end the custom + // property. + // + // E.g.: + // + // ```css + // .foo { + // --custom: value + // ^ + // } + // ``` + else if (peek_char == b'}' || input.len() - 1 == j) && closing_bracket_stack.is_empty() { + i = j - 1; + buffer.extend(&input[start..j+1]); // TODO: ??? + break + } + + // End of a block. + else if peek_char == b')' || peek_char == b']' || peek_char == b'}' { + if closing_bracket_stack.last() == Some(input[j]) { + closing_bracket_stack.pop(); + } + } + + j += 1; + } + + if colon_idx.is_none() { + return Err(format!( + "Expected `:` in custom property: {:}", + String::from_utf8_lossy(&buffer) + )); + } + + let node = parse_decl(&buffer, colon_idx.unwrap()); + + if let Some(parent) = parent.as_mut() { + parent.push(node); + } else { + rules.push(node); + } + + buffer.clear(); + } + + // End of a body-less at-rule. + // + // E.g.: + // + // ```css + // @charset "UTF-8"; + // ^ + // ``` + else if current_char == b';' && buffer.first() == Some(&b'@') { + let node = parse_rule_header(&buffer); + + if let Some(parent) = parent.as_mut() { + // At-rule is nested inside of a rule, attach it to the parent. + parent.push(node); + } else { + // We are the root node which means we are done with the current node. + rules.push(node); + } + + // Reset the state for the next node. + buffer.clear(); + } + + // End of a declaration. + // + // E.g.: + // + // ```css + // .foo { + // color: red; + // ^ + // } + // ``` + // + else if current_char == b';' { + let colon_idx = buffer.find(b":"); + if colon_idx.is_none() { + return Err(format!( + "Expected `:` in declaration: {:}", + String::from_utf8_lossy(&buffer) + )); + } + + let node = parse_decl(&buffer, colon_idx.unwrap()); + + if let Some(parent) = parent.as_mut() { + parent.push(node); + } else { + rules.push(node); + } + + buffer.clear(); + } + + // Start of a block. + else if current_char == b'{' { + closing_bracket_stack.push(b'}'); + + // Push the parent node to the stack, so that we can go back once the + // nested nodes are done. + stack.push(parent); + + // At this point `buffer` should resemble a selector or an at-rule. + let node = parse_rule_header(&buffer); + + // Make the current node the new parent, so that nested nodes can be + // attached to it. + parent = Some(Box::new(node)); + + // Reset the state for the next node. + buffer.clear(); + } + + // End of a block. + else if current_char == b'}' { + if closing_bracket_stack.is_empty() { + return Err("Missing opening {".to_string()); + } + + closing_bracket_stack.pop(); + + // When we hit a `}` and `buffer` is filled in, then it means that we did + // not complete the previous node yet. This means that we hit a + // declaration without a `;` at the end. + if buffer.len() > 0 { + // This can happen for nested at-rules. + // + // E.g.: + // + // ```css + // @layer foo { + // @tailwind utilities + // ^ + // } + // ``` + if buffer[0] == b'@' { + let node = parse_rule_header(&buffer); + + // At-rule is nested inside of a rule, attach it to the parent. + if let Some(parent) = parent.as_mut() { + parent.push(node) + } + + // We are the root node which means we are done with the current node. + else { + rules.push(node); + } + + // Reset the state for the next node. + buffer.clear(); + } + + // But it can also happen for declarations. + // + // E.g.: + // + // ```css + // .foo { + // color: red + // ^ + // } + // ``` + else { + // Split `buffer` into a `property` and a `value`. At this point the + // comments are already removed which means that we don't have to worry + // about `:` inside of comments. + let colon_idx = buffer.find(b":"); + + // Attach the declaration to the parent. + if let Some(parent) = parent.as_mut() { + parent.push(parse_decl(&buffer, colon_idx.unwrap())); + } + } + } + + // We are done with the current node, which means we can go up one level + // in the stack. + let mut grand_parent = stack.pop().flatten(); + + match (grand_parent.as_deref_mut(), parent.take()) { + // Push the parent node (if it existed) into the grand parent + (Some(grand_parent), Some(parent)) => { + grand_parent.push(*parent); + } + + // We are the root node which means we are done and continue with the next + // node. + (None, Some(parent)) => { + rules.push(*parent); + } + + _ => {} + } + + // Go up one level in the stack. + parent = grand_parent; + + // Reset the state for the next node. + buffer.clear(); + } + + // Any other character is part of the current node. + else { + // Skip whitespace at the start of a new node. + if buffer.is_empty() && (current_char == b' ' || current_char == b'\n' || current_char == b'\t') { + i += 1; + continue; + } + + buffer.push(current_char); + } + + i += 1; + } + + // If we have a leftover `buffer` that happens to start with an `@` then it + // means that we have an at-rule that is not terminated with a semicolon at + // the end of the input. + if *buffer.get(0).unwrap_or(&0x00) == b'@' { + rules.push(parse_rule_header(&buffer)); + } + + // When we are done parsing then everything should be balanced. If we still + // have a leftover `parent`, then it means that we have an unterminated block. + if !closing_bracket_stack.is_empty() { + match parent.as_deref() { + Some(CssNode::AtRule { name, params, .. }) => { + return Err(format!("Missing closing }} at @{:} {:}", String::from_utf8_lossy(name), String::from_utf8_lossy(params))); + }, + Some(CssNode::StyleRule { selector, .. }) => { + return Err(format!("Missing closing }} at {:}", String::from_utf8_lossy(selector))); + }, + _ => {} + } + } + + let mut ast = vec![]; + + ast.extend(license_comments); + ast.extend(rules); + + return Ok(Stylesheet::from(ast)); +} + +pub fn parse_rule_header(buffer: &[u8]) -> CssNode { + let buffer = buffer.trim_ascii(); + + // Check if the buffer is an at-rule. + if buffer.starts_with(b"@") { + let name_end_idx = syntax::read_ident_token(&buffer[1..]) + 1; + let name = buffer[1..name_end_idx].trim_ascii(); + let params = buffer[name_end_idx..].trim_ascii(); + + return at_rule(name, params, []); + } + + return style_rule(buffer, []); +} + +pub fn parse_decl(buffer: &[u8], colon_idx: usize) -> CssNode { + let important_idx = buffer[colon_idx + 1..].find(b"!important"); + let important_idx = important_idx.map(|idx| colon_idx + idx + 1); + + let property = &buffer[..colon_idx]; + let value = buffer[colon_idx + 1..important_idx.unwrap_or(buffer.len())].trim_ascii(); + + return decl(property, value, important_idx.is_some()); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::css::ast::{style_rule, Stylesheet}; + use rstest::*; + + #[derive(Debug)] + enum LineEnding { + Unix, + Windows, + } + + use LineEnding::*; + + fn test_parse(line_ending: LineEnding, css: &[u8]) -> Result { + let input = css + .replace("\r\n", "\n") + .replace("\n", match line_ending { + LineEnding::Unix => "\n", + LineEnding::Windows => "\r\n", + }); + + return parse(&input); + } + + // + // Comments + // + + #[rstest] + fn should_parse_a_comment_and_ignore_it( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + /*Hello, world!*/ + "; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([])); + } + + #[rstest] + fn should_parse_a_comment_with_an_escaped_ending_and_ignore_it( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + /*Hello, \\*\\/ world!*/ + "; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([])); + } + + #[rstest] + fn should_parse_a_comment_inside_of_a_selector_and_ignore_it( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + .foo { + /*Example comment*/ + } + "; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([ + style_rule(b".foo", []), + ])); + } + + #[rstest] + fn should_remove_comments_in_between_selectors_while_maintaining_the_correct_whitespace( + #[values(Unix)] + line_ending: LineEnding + ) { + let input = b" + .foo/*.bar*/.baz { + } + .foo/*.bar*//*.baz*/.qux + { + } + .foo/*.bar*/ /*.baz*/.qux { + /* ^ whitespace */ + } + .foo /*.bar*/.baz { + /*^ whitespace */ + } + .foo/*.bar*/ .baz { + /* ^ whitespace */ + } + .foo/*.bar*/ + .baz { + } + "; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([ + style_rule(b".foo.baz", []), + style_rule(b".foo.qux", []), + style_rule(b".foo .qux", []), + style_rule(b".foo .baz", []), + style_rule(b".foo .baz", []), + style_rule(b".foo .baz", []), + ])); + } + + #[rstest] + fn collects_license_comments( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + /*! License #1 */ + /*! + * License #2 + */ + "; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([ + comment(b" License #1 ".to_vec()), + comment(b"\n * License #2\n ".to_vec()), + ])); + } + + #[rstest] + fn should_hoist_all_license_comments( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + /*! License #1 */ + .foo { + color: red; /*! License #1.5 */ + } + /*! License #2 */ + .bar { + /*! License #2.5 */ + color: blue; + } + /*! License #3 */ + "; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([ + comment(b" License #1 ".to_vec()), + comment(b" License #1.5 ".to_vec()), + comment(b" License #2 ".to_vec()), + comment(b" License #2.5 ".to_vec()), + comment(b" License #3 ".to_vec()), + style_rule(b".foo", [ + decl(b"color", b"red", false), + ]), + style_rule(b".bar", [ + decl(b"color", b"blue", false), + ]), + ])); + } + + #[rstest] + fn should_handle_comments_before_element_selectors( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + .dark /* comment */p { + color: black; + } + "; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([ + style_rule(b".dark p", [ + decl(b"color", b"black", false), + ]), + ])); + } + + // + // Declarations + // + + #[rstest] + fn should_parse_a_simple_declaration( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + color: red; + "; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([ + decl(b"color", b"red", false), + ])); + } + + #[rstest] + fn should_parse_declarations_with_strings( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + content: 'Hello, world!'; + "; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([ + decl(b"content", b"'Hello, world!'", false), + ])); + } + + #[rstest] + fn should_parse_declarations_with_nested_strings( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + content: 'Good, \"monday\", morning!'; + "; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([ + decl(b"content", b"'Good, \"monday\", morning!'", false), + ])); + } + + #[rstest] + fn should_parse_declarations_with_nested_strings_that_are_not_balanced( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + content: \"It's a beautiful day!\"; + "; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([ + decl(b"content", b"\"It's a beautiful day!\"", false), + ])); + } + + #[rstest] + fn should_parse_declarations_with_with_strings_and_escaped_string_endings( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + content: 'These are not the end \"\\' of the string'; + "; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([ + decl(b"content", b"'These are not the end \"\\' of the string'", false), + ])); + } + + /// + /// Declarations: Important + /// + + #[rstest] + fn should_parse_declarations_with_important( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + color: red !important; + "; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([ + decl(b"color", b"red", true), + ])); + } + + #[rstest] + fn should_parse_declarations_with_important_and_trailing_comment( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + color: red !important; /* Very important */ + "; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([ + decl(b"color", b"red", true), + ])); + } + + /// + /// Declarations: Custom properties + /// + + #[rstest] + fn should_parse_a_custom_property( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + --foo: bar; + "; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([ + decl(b"--foo", b"bar", false), + ])); + } + + #[rstest] + fn should_parse_a_minified_custom_property( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b"--foo:bar;"; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([ + decl(b"--foo", b"bar", false), + ])); + } + + #[rstest] + fn should_parse_a_minified_custom_property_with_no_semicolon( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b"--foo:bar"; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([ + decl(b"--foo", b"bar", false), + ])); + } + + #[rstest] + fn should_parse_a_custom_property_with_a_missing_semicolon( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + --foo: bar + "; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([ + decl(b"--foo", b"bar", false), + ])); + } + + #[rstest] + fn should_parse_a_custom_property_with_a_missing_semicolon_and_important( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + --foo: bar !important + "; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([ + decl(b"--foo", b"bar", true), + ])); + } + + #[rstest] + fn should_parse_a_custom_property_with_an_embedded_programming_language( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + --foo: if(x > 5) this.width = 10; + "; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([ + decl(b"--foo", b"if(x > 5) this.width = 10", false), + ])); + } + + #[rstest] + fn should_parse_a_custom_property_with_an_empty_block_as_the_value( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + --foo: {}; + "; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([ + decl(b"--foo", b"{}", false), + ])); + } + + #[rstest] + fn should_parse_a_custom_property_with_a_block_including_nested_css( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + --foo: { + background-color: red; + /* A comment */ + content: 'Hello, world!'; + }; + "; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([ + decl(b"--foo", b"{ + background-color: red; + /* A comment */ + content: 'Hello, world!'; + }", false), + ])); + } + + #[rstest] + fn should_parse_a_custom_property_with_a_block_including_nested_css_and_comments_with_end_characters_inside_them( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + --foo: { + background-color: red; + /* A comment ; */ + content: 'Hello, world!'; + }; + --bar: { + background-color: red; + /* A comment } */ + content: 'Hello, world!'; + }; + "; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([ + decl(b"--foo", b"{ + background-color: red; + /* A comment ; */ + content: 'Hello, world!'; + }", false), + decl(b"--bar", b"{ + background-color: red; + /* A comment } */ + content: 'Hello, world!'; + }", false), + ])); + } + + #[rstest] + fn should_parse_a_custom_property_with_escaped_characters_in_the_value( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + --foo: This is not the end \\;, but this is; + "; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([ + decl(b"--foo", b"This is not the end \\;, but this is", false), + ])); + } + + #[rstest] + fn should_parse_a_custom_property_with_escaped_characters_inside_a_comment_in_the_value( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + --foo: /* This is not the end \\; this is also not the end ; */ but this is; + "; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([ + decl(b"--foo", b"/* This is not the end \\; this is also not the end ; */ but this is", false), + ])); + } + + #[rstest] + fn should_parse_empty_custom_properties( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + --foo: ; + "; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([ + decl(b"--foo", b"", false), + ])); + } + + #[rstest] + fn should_parse_custom_properties_with_important( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + --foo: bar !important; + "; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([ + decl(b"--foo", b"bar", true), + ])); + } + + #[rstest] + fn should_parse_multiple_declarations( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + color: red; + background-color: blue; + "; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([ + decl(b"color", b"red", false), + decl(b"background-color", b"blue", false), + ])); + } + + #[rstest] + fn should_correctly_parse_comments_with_colon_inside_of_them( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + color/* color: #f00; */: red; + font-weight:/* font-size: 12px */ bold; + + .foo { + background-color/* background-color: #f00; */: red; + } + "; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([ + decl(b"color", b"red", false), + decl(b"font-weight", b"bold", false), + style_rule(b".foo", [ + decl(b"background-color", b"red", false), + ]), + ])); + } + + #[rstest] + fn should_parse_multi_line_declarations( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + .foo { + grid-template-areas: + 'header header header' + 'sidebar main main' + 'footer footer footer'; + } + "; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([ + style_rule(b".foo", [ + decl(b"grid-template-areas", b"'header header header' 'sidebar main main' 'footer footer footer'", false), + ]), + ])); + } + + // + // Selectors (Style Rules) + // + + #[rstest] + fn should_parse_a_simple_style_rule( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + .foo { + color: red; + } + "; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([ + style_rule(b".foo", [ + decl(b"color", b"red", false), + ]), + ])); + } + + #[rstest] + fn should_parse_selectors_with_escaped_characters( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + .hover\\:foo:hover { + } + .\\32 xl\\:foo { + } + "; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([ + style_rule(b".hover\\:foo:hover", []), + style_rule(b".\\32 xl\\:foo", []), + ])); + } + + #[rstest] + fn should_parse_multiple_simple_selectors( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + .foo, + .bar { + } + "; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([ + style_rule(b".foo, .bar", []), + ])); + } + + #[rstest] + fn should_parse_multiple_declarations_inside_of_a_selector( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + .foo { + color: red; + font-size: 16px; + } + "; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([ + style_rule(b".foo", [ + decl(b"color", b"red", false), + decl(b"font-size", b"16px", false), + ]), + ])); + } + + #[rstest] + fn should_parse_rules_with_declarations_that_end_with_a_missing_semicolon( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + .foo { + color: red; + font-size: 16px + } + "; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([ + style_rule(b".foo", [ + decl(b"color", b"red", false), + decl(b"font-size", b"16px", false), + ]), + ])); + } + + #[rstest] + fn should_parse_rules_with_declarations_that_end_with_a_missing_semicolon_and_important( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + .foo { + color: red; + font-size: 16px !important + } + "; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([ + style_rule(b".foo", [ + decl(b"color", b"red", false), + decl(b"font-size", b"16px", true), + ]), + ])); + } + + #[rstest] + fn should_parse_a_multi_line_selector( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + .foo, + .bar, + .baz + { + color: red; + } + "; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([ + style_rule(b".foo, .bar, .baz", [ + decl(b"color", b"red", false), + ]), + ])); + } + + #[rstest] + fn should_parse_a_multi_line_selector_and_preserves_important_whitespace( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + .foo, + .bar, + .baz\t\n \n .qux + { + color: red; + } + "; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([ + style_rule(b".foo, .bar, .baz .qux", [ + decl(b"color", b"red", false), + ]), + ])); + } + + // + // At-Rules + // + + #[rstest] + fn should_parse_an_at_rule_without_a_block( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + @charset \"UTF-8\"; + "; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([ + at_rule(b"charset", b"\"UTF-8\"", []), + ])); + } + + #[rstest] + fn should_parse_an_at_rule_without_a_block_or_semicolon( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + @tailwind utilities + "; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([ + at_rule(b"tailwind", "utilities", []), + ])); + } + + #[rstest] + fn should_parse_an_at_rule_without_a_block_or_semicolon_when_its_the_last_rule_in_a_block( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + @layer utilities { + @tailwind utilities + } + "; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([ + at_rule(b"layer", b"utilities", [ + at_rule(b"tailwind", b"utilities", []), + ]), + ])); + } + + #[rstest] + fn should_parse_a_nested_at_rule_without_a_block( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + @layer utilities { + @charset \"UTF-8\"; + } + + .foo { + @apply font-bold hover:text-red-500; + } + "; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([ + at_rule(b"layer", b"utilities", [ + at_rule(b"charset", b"\"UTF-8\"", []), + ]), + style_rule(b".foo", [ + at_rule(b"apply", b"font-bold hover:text-red-500", []), + ]), + ])); + } + + #[rstest] + fn should_parse_custom_at_rules_without_a_block( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + @tailwind; + @tailwind base; + "; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([ + at_rule(b"tailwind", b"", []), + at_rule(b"tailwind", b"base", []), + ])); + } + + #[rstest] + fn should_parse_nested_media_queries( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + @media (width >= 600px) { + .foo { + color: red; + @media (width >= 800px) { + color: blue; + } + @media (width >= 1000px) { + color: green; + } + } + } + "; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([ + at_rule(b"media", b"(width >= 600px)", [ + style_rule(b".foo", [ + decl(b"color", b"red", false), + at_rule(b"media", b"(width >= 800px)", [ + decl(b"color", b"blue", false), + ]), + at_rule(b"media", b"(width >= 1000px)", [ + decl(b"color", b"green", false), + ]), + ]), + ]), + ])); + } + + #[rstest] + fn should_parse_at_rules_that_span_multiple_lines( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + .foo { + @apply hover:text-red-100 + sm:hover:text-red-200 + md:hover:text-red-300 + lg:hover:text-red-400 + xl:hover:text-red-500; + } + "; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([ + style_rule(b".foo", [ + at_rule(b"apply", b"hover:text-red-100 sm:hover:text-red-200 md:hover:text-red-300 lg:hover:text-red-400 xl:hover:text-red-500", []), + ]), + ])); + } + + // + // Nesting + // + + #[rstest] + fn should_parse_nested_rules( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + .foo { + .bar { + .baz { + color: red; + } + } + } + "; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([ + style_rule(b".foo", [ + style_rule(b".bar", [ + style_rule(b".baz", [ + decl(b"color", b"red", false), + ]), + ]), + ]), + ])); + } + + #[rstest] + fn should_parse_nested_selector_with_ampersand( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + .foo { + color: red; + + &:hover { + color: blue; + } + } + "; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([ + style_rule(b".foo", [ + decl(b"color", b"red", false), + style_rule(b"&:hover", [ + decl(b"color", b"blue", false), + ]), + ]), + ])); + } + + #[rstest] + fn should_parse_nested_sibling_selector( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + .foo { + .bar { + color: red; + } + + .baz { + color: blue; + } + } + "; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([ + style_rule(b".foo", [ + style_rule(b".bar", [ + decl(b"color", b"red", false), + ]), + style_rule(b".baz", [ + decl(b"color", b"blue", false), + ]), + ]), + ])); + } + + #[rstest] + fn should_parse_nested_sibling_selectors_and_sibling_declaration( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + .foo { + font-weight: bold; + text-decoration-line: underline; + + .bar { + color: red; + } + + --in-between: 1; + + .baz { + color: blue; + } + + --at-the-end: 2; + } + "; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([ + style_rule(b".foo", [ + decl(b"font-weight", b"bold", false), + decl(b"text-decoration-line", b"underline", false), + style_rule(b".bar", [ + decl(b"color", b"red", false), + ]), + decl(b"--in-between", b"1", false), + style_rule(b".baz", [ + decl(b"color", b"blue", false), + ]), + decl(b"--at-the-end", b"2", false), + ]), + ])); + } + + // + // Complex + // + + #[rstest] + fn should_parse_complex_examples( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + @custom \\{ { + foo: bar; + } + "; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([ + at_rule(b"custom", b"\\{", [ + decl(b"foo", b"bar", false), + ]), + ])); + } + + #[rstest] + fn should_parse_minified_nested_css( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b".foo{color:red;@media(width>=600px){.bar{color:blue;font-weight:bold}}}"; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([ + style_rule(b".foo", [ + decl(b"color", b"red", false), + at_rule(b"media", b"(width>=600px)", [ + style_rule(b".bar", [ + decl(b"color", b"blue", false), + decl(b"font-weight", b"bold", false), + ]), + ]), + ]), + ])); + } + + #[rstest] + fn should_ignore_everything_inside_of_comments( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + .foo:has(.bar /* instead \\*\\/ of .baz { */) { + color: red; + } + "; + + let output = test_parse(line_ending, input).unwrap(); + assert_eq!(output, Stylesheet::from([ + style_rule(b".foo:has(.bar )", [ + decl(b"color", b"red", false), + ]), + ])); + } + + // + // Errors + // + + #[rstest] + fn should_error_when_curly_brackets_are_unbalanced_opening( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + .foo { + color: red; + } + + .bar + /* ^ Missing opening { */ + color: blue; + } + "; + + let output = test_parse(line_ending, input); + assert_eq!(output, Err("Missing opening {".to_owned())); + } + + #[rstest] + fn should_error_when_curly_brackets_are_unbalanced_closing( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + .foo { + color: red; + } + + .bar { + color: blue; + + /* ^ Missing closing } */ + "; + + let output = test_parse(line_ending, input); + assert_eq!(output, Err("Missing closing } at .bar".to_owned())); + } + + #[rstest] + fn should_error_when_an_unterminated_string_is_used( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + .foo { + content: \"Hello world! + /* ^ missing \" */ + font-weight: bold; + } + "; + + let output = test_parse(line_ending, input); + assert_eq!(output, Err("Unterminated string: \"Hello world!\"".to_owned())); + } + + #[rstest] + fn should_error_when_an_unterminated_string_is_used_with_a_semicolon( + #[values(Unix, Windows)] + line_ending: LineEnding + ) { + let input = b" + .foo { + content: \"Hello world!; + /* ^ missing \" */ + font-weight: bold; + } + "; + + let output = test_parse(line_ending, input); + assert_eq!(output, Err("Unterminated string: \"Hello world!;\"".to_owned())); + } +} diff --git a/crates/core/src/css/syntax.rs b/crates/core/src/css/syntax.rs new file mode 100644 index 000000000..e892b1ad5 --- /dev/null +++ b/crates/core/src/css/syntax.rs @@ -0,0 +1,56 @@ +// The order of these cases is important for performance +// please do not change it without significant profiling +#[derive(Copy, Clone)] +enum Case { + Escape, + Ident, + Other, +} + +const __CASES: [Case; 256] = { + let mut table = [Case::Other; 256]; + + let mut i = b'a'; + while i <= b'z' { + table[i as usize] = Case::Ident; + i+=1; + } + + let mut i = b'A'; + while i <= b'Z' { + table[i as usize] = Case::Ident; + i+=1; + } + + let mut i = b'0'; + while i <= b'9' { + table[i as usize] = Case::Ident; + i+=1; + } + + table[b'-' as usize] = Case::Ident; + table[b'_' as usize] = Case::Ident; + table[b'\\' as usize] = Case::Escape; + + table +}; + +/// Consume an from the buffer +/// +/// Not intended to be as strict as the [CSS spec][diagram] but merely good enough. +/// [diagram]: https://drafts.csswg.org/css-syntax-3/#ident-token-diagram +#[inline(never)] +#[no_mangle] +pub fn read_ident_token(buffer: &[u8]) -> usize { + let mut i = 0; + + while i < buffer.len() { + match __CASES[buffer[i] as usize] { + Case::Ident => i += 1, + Case::Escape => i += 2, + Case::Other => break, + } + } + + return i; +} From 5b078a975aca69c710007afcc8cc39b9125175c8 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Sun, 1 Sep 2024 20:53:40 -0400 Subject: [PATCH 08/19] add: CSS AST optimizer --- crates/core/src/css/mod.rs | 1 + crates/core/src/css/optimize.rs | 306 ++++++++++++++++++++++++++++++++ 2 files changed, 307 insertions(+) create mode 100644 crates/core/src/css/optimize.rs diff --git a/crates/core/src/css/mod.rs b/crates/core/src/css/mod.rs index 081e4f7d8..9a12bb323 100644 --- a/crates/core/src/css/mod.rs +++ b/crates/core/src/css/mod.rs @@ -1,4 +1,5 @@ pub mod ast; +pub mod optimize; pub mod parser; pub mod serializer; pub mod syntax; diff --git a/crates/core/src/css/optimize.rs b/crates/core/src/css/optimize.rs new file mode 100644 index 000000000..6a3b95761 --- /dev/null +++ b/crates/core/src/css/optimize.rs @@ -0,0 +1,306 @@ +// Performs an optimization pass on the AST to (usually) reduce the size of the output CSS + +use std::{collections::HashSet, mem}; + +use super::{ast::{at_rule, decl, style_rule, CssNode, Stylesheet}, visit::WalkAction}; + +pub fn optimize_ast(ast: &mut Stylesheet) { + remove_duplicate_at_properties(ast); + add_property_fallbacks(ast); + hoist_at_roots(ast); + flatten_utilities(ast); +} + +/// Remove duplicate `@property` rules appearing in the AST +/// They're replaced with empty nodes that print nothing +fn remove_duplicate_at_properties(ast: &mut Stylesheet) { + let mut seen = HashSet::>::new(); + + ast.walk_mut(&mut |node| { + let CssNode::AtRule { name, params, .. } = node else { + return WalkAction::Continue; + }; + + if name != b"property" { + return WalkAction::Continue; + } + + if !seen.contains(params) { + seen.insert(params.clone()); + + return WalkAction::Continue; + } + + *node = CssNode::empty(); + + return WalkAction::Skip; + }) +} + +/// Collect fallbacks for `@property` rules for Firefox support +/// We turn these into rules on `:root` or `*` and some pseudo-elements +/// based on the value of `inherits` +fn add_property_fallbacks(ast: &mut Stylesheet) { + let mut fallbacks_root = Vec::::new(); + let mut fallbacks_universal = Vec::::new(); + + // Create fallback rules for defined properties + ast.walk_mut(&mut |node| { + let CssNode::AtRule { name, params, nodes, .. } = node else { + return WalkAction::Continue; + }; + + if name != b"property" { + return WalkAction::Continue; + } + + let property_name = params.clone(); + let mut initial_value: Option> = None; + let mut inherits = false; + + for child in nodes { + let CssNode::Declaration { property, value, .. } = child else { + continue; + }; + + if property == b"initial-value" { + initial_value = Some(value.clone()); + } else if property == b"inherits" { + inherits = value == b"true"; + } + } + + let initial_value = initial_value.unwrap_or(b"initial".to_vec()); + + if inherits { + fallbacks_root.push(decl(property_name, initial_value, false)); + } else { + fallbacks_universal.push(decl(property_name, initial_value, false)); + } + + return WalkAction::Skip; + }); + + let CssNode::Contents { nodes } = &mut ast.rules else { + return; + }; + + let mut fallback_ast = vec![]; + + if !fallbacks_root.is_empty() { + fallback_ast.push(style_rule(b":root", fallbacks_root)); + } + + if !fallbacks_universal.is_empty() { + fallback_ast.push(style_rule( + b"*, ::before, ::after, ::backdrop", + fallbacks_universal + )); + } + + if !fallback_ast.is_empty() { + fallback_ast = vec![ + at_rule(b"supports", b"(-moz-orient: inline)", [ + at_rule(b"layer", b"base", fallback_ast), + ]), + ]; + } + + nodes.extend(fallback_ast); +} + +/// Collect fallbacks for `@property` rules for Firefox support +/// We turn these into rules on `:root` or `*` and some pseudo-elements +/// based on the value of `inherits` +fn hoist_at_roots(ast: &mut Stylesheet) { + let mut roots = Vec::::new(); + + ast.walk_mut(&mut |node| { + let CssNode::AtRule { name, nodes, .. } = node else { + return WalkAction::Continue; + }; + + if name != b"at-root" { + return WalkAction::Continue; + } + + // Pull the nodes out of the at-root rule + roots.extend(mem::take(nodes)); + + // Replace the at-root rule with an empty node + *node = CssNode::empty(); + + return WalkAction::Skip; + }); + + let CssNode::Contents { nodes } = &mut ast.rules else { + return; + }; + + nodes.extend(roots); +} + +/// Collect fallbacks for `@property` rules for Firefox support +/// We turn these into rules on `:root` or `*` and some pseudo-elements +/// based on the value of `inherits` +fn flatten_utilities(ast: &mut Stylesheet) { + ast.walk_mut(&mut |node| { + let CssNode::AtRule { name, params, nodes, .. } = node else { + return WalkAction::Continue; + }; + + if name != b"tailwind" { + return WalkAction::Continue; + } + + if params != b"utilities" { + return WalkAction::Continue; + } + + *node = CssNode::from(mem::take(nodes)); + + return WalkAction::Skip; + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_remove_duplicate_at_properties() { + let mut css = Stylesheet::from([ + at_rule("property", "--foo", [ + decl("syntax", "", false), + decl("inherits", "false", false), + decl("initial-value", "0", false), + ]), + + at_rule("property", "--foo", [ + decl("syntax", "", false), + decl("inherits", "false", false), + decl("initial-value", "0", false), + ]), + ]); + + remove_duplicate_at_properties(&mut css); + + let expected = Stylesheet::from([ + at_rule("property", "--foo", [ + decl("syntax", "", false), + decl("inherits", "false", false), + decl("initial-value", "0", false), + ]), + + CssNode::empty(), + ]); + + assert_eq!(css, expected); + } + + #[test] + fn test_add_property_fallbacks() { + let mut css = Stylesheet::from([ + at_rule("property", "--foo", [ + decl("syntax", "", false), + decl("inherits", "true", false), + decl("initial-value", "0", false), + ]), + + at_rule("property", "--bar", [ + decl("syntax", "", false), + decl("inherits", "false", false), + decl("initial-value", "0", false), + ]), + ]); + + add_property_fallbacks(&mut css); + + let expected = Stylesheet::from([ + at_rule("property", "--foo", [ + decl("syntax", "", false), + decl("inherits", "true", false), + decl("initial-value", "0", false), + ]), + + at_rule("property", "--bar", [ + decl("syntax", "", false), + decl("inherits", "false", false), + decl("initial-value", "0", false), + ]), + + at_rule("supports", "(-moz-orient: inline)", [ + at_rule("layer", "base", [ + style_rule(":root", [ + decl("--foo", "0", false), + ]), + + style_rule("*, ::before, ::after, ::backdrop", [ + decl("--bar", "0", false), + ]), + ]), + ]), + ]); + + assert_eq!(css, expected); + } + + #[test] + fn test_hoist_at_roots() { + let mut css = Stylesheet::from([ + at_rule("layer", "base", [ + at_rule("at-root", "", [ + at_rule("keyframes", "spin", [ + style_rule("from", [ + decl("transform", "rotate(0deg)", false), + ]), + + style_rule("to", [ + decl("transform", "rotate(360deg)", false), + ]), + ]), + ]), + + at_rule("layer", "defaults", [ + at_rule("at-root", "", [ + at_rule("keyframes", "pulse", [ + style_rule("50%", [ + decl("opacity", "0", false), + ]), + ]), + ]), + ]), + ]) + ]); + + hoist_at_roots(&mut css); + + let expected = Stylesheet::from([ + at_rule("layer", "base", [ + CssNode::empty(), + + at_rule("layer", "defaults", [ + CssNode::empty(), + ]), + ]), + + at_rule("keyframes", "spin", [ + style_rule("from", [ + decl("transform", "rotate(0deg)", false), + ]), + + style_rule("to", [ + decl("transform", "rotate(360deg)", false), + ]), + ]), + + at_rule("keyframes", "pulse", [ + style_rule("50%", [ + decl("opacity", "0", false), + ]), + ]), + ]); + + assert_eq!(css, expected); + } +} From 50bb8dbfe1463efc6380b50a14ee84a7fed9aef6 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Sat, 12 Oct 2024 18:35:46 -0400 Subject: [PATCH 09/19] port: `segment` --- crates/core/src/util/mod.rs | 4 + crates/core/src/util/segment.rs | 241 ++++++++++++++++++++++++++++++++ 2 files changed, 245 insertions(+) create mode 100644 crates/core/src/util/segment.rs diff --git a/crates/core/src/util/mod.rs b/crates/core/src/util/mod.rs index 9d12c9626..ef87f17f6 100644 --- a/crates/core/src/util/mod.rs +++ b/crates/core/src/util/mod.rs @@ -1,8 +1,12 @@ mod fast_stack; mod gurantee; +mod segment; #[allow(unused_imports)] pub use crate::util::fast_stack::*; #[allow(unused_imports)] pub use crate::util::gurantee::*; + +#[allow(unused_imports)] +pub use crate::util::segment::*; diff --git a/crates/core/src/util/segment.rs b/crates/core/src/util/segment.rs new file mode 100644 index 000000000..b48b952e7 --- /dev/null +++ b/crates/core/src/util/segment.rs @@ -0,0 +1,241 @@ +use crate::util::gurantee::gurantee; + +use super::fast_stack::FastStack; + +// The order of these cases is important for performance +// please do not change it without significant profiling +#[derive(Copy, Clone)] +enum Case { + Other, + + // This makes things faster + // It has to be the 2nd case + Dummy1, + + Close, // b')' + ParenL, // b'(' + BracketL, // b'[' + CurlyL, // b'{' + + Escape, // b'\' + QuoteDouble, // b'"' + QuoteSingle, // b'\'' + Separator, // other +} + +const fn generate_cases(separator: u8) -> [Case; 256] { + let mut table = [Case::Other; 256]; + + table[b'\\' as usize] = Case::Escape; + table[b'"' as usize] = Case::QuoteDouble; + table[b'\'' as usize] = Case::QuoteSingle; + table[b'(' as usize] = Case::ParenL; + table[b'[' as usize] = Case::BracketL; + table[b'{' as usize] = Case::CurlyL; + table[b')' as usize] = Case::Close; + table[b']' as usize] = Case::Close; + table[b'}' as usize] = Case::Close; + table[separator as usize] = Case::Separator; + + return table; +} + +/** + * This splits a string on a top-level character. + * + * Regex doesn't support recursion (at least not the JS-flavored version), + * so we have to use a tiny state machine to keep track of paren placement. + * + * Expected behavior using commas: + * var(--a, 0 0 1px rgb(0, 0, 0)), 0 0 1px rgb(0, 0, 0) + * ┬ ┬ ┬ ┬ + * x x x ╰──────── Split because top-level + * ╰──────────────┴──┴───────────── Ignored b/c inside >= 1 levels of parens + */ +#[inline(never)] +#[no_mangle] +pub fn segment(input: &[u8], separator: u8) -> Vec<&[u8]> { + return segment_table(input, generate_cases(separator)) +} + +#[inline(always)] +fn segment_table(input: &[u8], cases: [Case; 256]) -> Vec<&[u8]> { + let mut closing_bracket_stack = FastStack::new(); + + let mut parts: Vec<&[u8]> = vec![]; + let mut last_pos = 0; + + let mut idx = 0; + while idx < input.len() { + // SAFETY: last_pos can never be greater than idx at the start of the loop + // body. It's set to one greater than idx on a separator but idx is always + // incremented before the next iteration. + gurantee(last_pos <= idx); + + match cases[input[idx] as usize] { + Case::Separator => { + if closing_bracket_stack.is_empty() { + parts.push(&input[last_pos..idx]); + last_pos = idx + 1; + } + } + + // The next character is escaped, so we skip it. + Case::Escape => idx += 1, + + // Strings should be handled as-is until the end of the string. No need to + // worry about balancing parens, brackets, or curlies inside a string. + Case::QuoteDouble => { + loop { + idx += 1; + + // Ensure we don't go out of bounds. + if idx >= input.len() { + break + } + + match cases[input[idx] as usize] { + Case::Escape => idx += 1, + Case::QuoteDouble => break, + _ => {}, + } + } + } + + // Strings should be handled as-is until the end of the string. No need to + // worry about balancing parens, brackets, or curlies inside a string. + Case::QuoteSingle => { + loop { + idx += 1; + + // Ensure we don't go out of bounds. + if idx >= input.len() { + break + } + + match cases[input[idx] as usize] { + Case::Escape => idx += 1, + Case::QuoteSingle => break, + _ => {}, + } + } + } + + Case::ParenL => { + closing_bracket_stack.push(b')'); + + if closing_bracket_stack.overgrown() { + break + } + } + + Case::BracketL => { + closing_bracket_stack.push(b']'); + + if closing_bracket_stack.overgrown() { + break + } + } + + Case::CurlyL => { + closing_bracket_stack.push(b'}'); + + if closing_bracket_stack.overgrown() { + break + } + } + + Case::Close => { + if !closing_bracket_stack.is_empty() && closing_bracket_stack.peek() == input[idx] { + closing_bracket_stack.pop(); + } + } + + Case::Other => {}, + Case::Dummy1 => {}, + } + + idx += 1; + } + + // SAFETY: last_pos will be at most `input.len() - 1` ensuring that the slice + // is always within bounds. + gurantee(last_pos < input.len()); + + parts.push(&input[last_pos..]); + + return parts +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_result_in_a_single_segment_when_the_separator_is_not_present() { + assert_eq!(segment(b"foo", b':'), vec![b"foo"]) + } + + #[test] + fn should_split_by_the_separator() { + assert_eq!(segment(b"foo:bar:baz", b':'), vec![b"foo" as &[u8], b"bar", b"baz"]) + } + + #[test] + fn should_not_split_inside_of_parens() { + assert_eq!(segment(b"a:(b:c):d", b':'), vec![b"a" as &[u8], b"(b:c)", b"d"]) + } + + #[test] + fn should_not_split_inside_of_brackets() { + assert_eq!(segment(b"a:[b:c]:d", b':'), vec![b"a" as &[u8], b"[b:c]", b"d"]) + } + + #[test] + fn should_not_split_inside_of_curlies() { + assert_eq!(segment(b"a:{b:c}:d", b':'), vec![b"a" as &[u8], b"{b:c}", b"d"]) + } + + #[test] + fn should_not_split_inside_of_double_quotes() { + assert_eq!(segment(b"a:\"b:c\":d", b':'), vec![b"a" as &[u8], b"\"b:c\"", b"d"]) + } + + #[test] + fn should_not_split_inside_of_single_quotes() { + assert_eq!(segment(b"a:'b:c':d", b':'), vec![b"a" as &[u8], b"'b:c'", b"d"]) + } + + #[test] + fn should_not_crash_when_double_quotes_are_unbalanced() { + assert_eq!(segment(b"a:\"b:c:d", b':'), vec![b"a" as &[u8], b"\"b:c:d"]) + } + + #[test] + fn should_not_crash_when_single_quotes_are_unbalanced() { + assert_eq!(segment(b"a:'b:c:d", b':'), vec![b"a" as &[u8], b"'b:c:d"]) + } + + #[test] + fn should_skip_escaped_double_quotes() { + assert_eq!(segment(b"a:\"b:c\\\":d\":e", b':'), vec![b"a" as &[u8], b"\"b:c\\\":d\"", b"e"]) + } + + #[test] + fn should_skip_escaped_single_quotes() { + assert_eq!(segment(b"a:'b:c\\':d':e", b':'), vec![b"a" as &[u8], b"'b:c\\':d'", b"e"]) + } + + #[test] + fn should_skip_escaped_separators() { + assert_eq!(segment(b"a:b\\:c:d", b':'), vec![b"a" as &[u8], b"b\\:c", b"d"]) + } + + #[test] + fn should_split_by_the_escape_sequence_which_is_escape_as_well() { + assert_eq!(segment(b"a\\b\\c\\d", b'\\'), vec![b"a" as &[u8], b"b", b"c", b"d"]); + assert_eq!(segment(b"a\\(b\\c)\\d", b'\\'), vec![b"a" as &[u8], b"(b\\c)", b"d"]); + assert_eq!(segment(b"a\\[b\\c]\\d", b'\\'), vec![b"a" as &[u8], b"[b\\c]", b"d"]); + assert_eq!(segment(b"a\\{b\\c}\\d", b'\\'), vec![b"a" as &[u8], b"{b\\c}", b"d"]); + } +} From 25a88c57bb972936835aec9a80f3cff634506b7a Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Mon, 9 Sep 2024 09:36:12 -0400 Subject: [PATCH 10/19] port: `CSS.escape` --- crates/core/src/util/escape.rs | 216 +++++++++++++++++++++++++++++++++ crates/core/src/util/mod.rs | 4 + 2 files changed, 220 insertions(+) create mode 100644 crates/core/src/util/escape.rs diff --git a/crates/core/src/util/escape.rs b/crates/core/src/util/escape.rs new file mode 100644 index 000000000..fdab01f5a --- /dev/null +++ b/crates/core/src/util/escape.rs @@ -0,0 +1,216 @@ +#[derive(Clone, Copy)] +enum Case { + Other, + Ident, + Nul, + Control, +} + +// This list allows us to quickly identify what kind of byte we are looking at +// and jump to the right block of code to handle it producing a roughly 2x +// speedup in the general case. +static __CASES: [Case; 256] = { + let mut cases = [Case::Other; 256]; + + cases[0x00] = Case::Nul; + + let mut i = 0x01; + while i <= 0x1f { + cases[i] = Case::Control; + i+=1; + } + + cases[0x7f] = Case::Control; + + let mut i = b'0'; + while i <= b'9' { + cases[i as usize] = Case::Ident; + i+=1; + } + + let mut i = b'A'; + while i <= b'Z' { + cases[i as usize] = Case::Ident; + i+=1; + } + + let mut i = b'a'; + while i <= b'z' { + cases[i as usize] = Case::Ident; + i+=1; + } + + cases[b'-' as usize] = Case::Ident; + cases[b'_' as usize] = Case::Ident; + + let mut i = 0x80; + while i <= 0xff { + cases[i] = Case::Ident; + i+=1; + } + + cases +}; + +// https://drafts.csswg.org/cssom/#serialize-an-identifier +pub fn escape(value: &[u8]) -> Vec { + if value.len() == 0 { + return vec![]; + } + + if value == b"-" { + return b"\\-".to_vec(); + } + + // While the worst-case is 4x the size of the input, the "average" worst case + // is actually 2x the size of the input. This would happen for a string + // consisting of all printable, non-ident characters. If we pre-allocate + // for this case we can avoid all re-allocations during the loop unless we + // happen to have enough control characters to trigger the worst-case. + let mut result: Vec = Vec::with_capacity(2 * value.len()); + + let mut value = value; + + if value[0] == b'-' { + result.push(b'-'); + value = &value[1..]; + } + + // SAFETY: We're guaranteed to have at least one byte in `value` at this point + // because if len() > 0 AND the only byte is `-` then we've already handled + // that case. + if let digit @ b'0'..=b'9' = *unsafe { value.get_unchecked(0) } { + write_hex_digit(digit, &mut result); + value = &value[1..]; + } + + write_escape_scalar(value, &mut result); + + return result; +} + +#[inline(always)] +fn write_escape_scalar(value: &[u8], result: &mut Vec) { + let replacement = "\u{FFFD}".as_bytes(); + + // Note: there’s no need to special-case astral symbols, surrogate + // pairs, or lone surrogates. + for &code_unit in value.iter() { + match __CASES[code_unit as usize] { + // Every character in /[0-9a-zA-Z-_]/ can be included directly + Case::Ident => result.push(code_unit), + + // Other printable ASCII characters should be printed with an escape + // https://drafts.csswg.org/cssom/#escape-a-character + Case::Other => result.extend([ + b'\\', + code_unit, + ]), + + // The NUL character (U+0000) becomes the REPLACEMENT CHARACTER (U+FFFD) + Case::Nul => result.extend(replacement), + + // Control characters (U+0001–U+001F and U+007F) are written in hex + // https://drafts.csswg.org/cssom/#escape-a-character-as-code-point + Case::Control => write_hex_digit(code_unit, result), + } + } +} + +pub fn write_hex_digit(value: u8, s: &mut Vec) { + static HEX: &[u8; 16] = b"0123456789abcdef"; + + if value > 0x0F { + let hi = value >> 4 & 0x0F; + let lo = value >> 0 & 0x0F; + s.extend([ + b'\\', + HEX[hi as usize], + HEX[lo as usize], + b' ', + ]); + } else { + s.extend([ + b'\\', + HEX[value as usize], + b' ', + ]); + }; +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test() { + assert_eq!(escape(b"\0"), "\u{FFFD}".as_bytes()); + assert_eq!(escape(b"a\0"), "a\u{FFFD}".as_bytes()); + assert_eq!(escape(b"\0b"), "\u{FFFD}b".as_bytes()); + assert_eq!(escape(b"a\0b"), "a\u{FFFD}b".as_bytes()); + + assert_eq!(escape("\u{FFFD}".as_bytes()), "\u{FFFD}".as_bytes()); + assert_eq!(escape("a\u{FFFD}".as_bytes()), "a\u{FFFD}".as_bytes()); + assert_eq!(escape("\u{FFFD}b".as_bytes()), "\u{FFFD}b".as_bytes()); + assert_eq!(escape("a\u{FFFD}b".as_bytes()), "a\u{FFFD}b".as_bytes()); + + assert_eq!(escape(b""), b""); + + assert_eq!(escape(b"\x01\x02\x1E\x1F"), b"\\1 \\2 \\1e \\1f "); + + assert_eq!(escape(b"0a"), b"\\30 a"); + assert_eq!(escape(b"1a"), b"\\31 a"); + assert_eq!(escape(b"2a"), b"\\32 a"); + assert_eq!(escape(b"3a"), b"\\33 a"); + assert_eq!(escape(b"4a"), b"\\34 a"); + assert_eq!(escape(b"5a"), b"\\35 a"); + assert_eq!(escape(b"6a"), b"\\36 a"); + assert_eq!(escape(b"7a"), b"\\37 a"); + assert_eq!(escape(b"8a"), b"\\38 a"); + assert_eq!(escape(b"9a"), b"\\39 a"); + + assert_eq!(escape(b"a0b"), b"a0b"); + assert_eq!(escape(b"a1b"), b"a1b"); + assert_eq!(escape(b"a2b"), b"a2b"); + assert_eq!(escape(b"a3b"), b"a3b"); + assert_eq!(escape(b"a4b"), b"a4b"); + assert_eq!(escape(b"a5b"), b"a5b"); + assert_eq!(escape(b"a6b"), b"a6b"); + assert_eq!(escape(b"a7b"), b"a7b"); + assert_eq!(escape(b"a8b"), b"a8b"); + assert_eq!(escape(b"a9b"), b"a9b"); + + assert_eq!(escape(b"-0a"), b"-\\30 a"); + assert_eq!(escape(b"-1a"), b"-\\31 a"); + assert_eq!(escape(b"-2a"), b"-\\32 a"); + assert_eq!(escape(b"-3a"), b"-\\33 a"); + assert_eq!(escape(b"-4a"), b"-\\34 a"); + assert_eq!(escape(b"-5a"), b"-\\35 a"); + assert_eq!(escape(b"-6a"), b"-\\36 a"); + assert_eq!(escape(b"-7a"), b"-\\37 a"); + assert_eq!(escape(b"-8a"), b"-\\38 a"); + assert_eq!(escape(b"-9a"), b"-\\39 a"); + + assert_eq!(escape(b"-"), b"\\-"); + assert_eq!(escape(b"-a"), b"-a"); + assert_eq!(escape(b"--"), b"--"); + assert_eq!(escape(b"--a"), b"--a"); + + assert_eq!(escape(b"\x80\x2D\x5F\xA9"), b"\x80\x2D\x5F\xA9"); + assert_eq!(escape(b"\x7F\x80\x81\x82\x83\x84\x85\x86\x87\x88\x89\x8A\x8B\x8C\x8D\x8E\x8F\x90\x91\x92\x93\x94\x95\x96\x97\x98\x99\x9A\x9B\x9C\x9D\x9E\x9F"), b"\\7f \x80\x81\x82\x83\x84\x85\x86\x87\x88\x89\x8A\x8B\x8C\x8D\x8E\x8F\x90\x91\x92\x93\x94\x95\x96\x97\x98\x99\x9A\x9B\x9C\x9D\x9E\x9F"); + assert_eq!(escape(b"\xA0\xA1\xA2"), b"\xA0\xA1\xA2"); + assert_eq!(escape(b"a0123456789b"), b"a0123456789b"); + assert_eq!(escape(b"abcdefghijklmnopqrstuvwxyz"), b"abcdefghijklmnopqrstuvwxyz"); + assert_eq!(escape(b"ABCDEFGHIJKLMNOPQRSTUVWXYZ"), b"ABCDEFGHIJKLMNOPQRSTUVWXYZ"); + + assert_eq!(escape(b"\x20\x21\x78\x79"), b"\\ \\!xy"); + + // astral symbol (U+1D306 TETRAGRAM FOR CENTRE) + assert_eq!(escape("\u{1D306}".as_bytes()), "\u{1D306}".as_bytes()); + + // surrogates + // assert_eq!(escape("\u{D834}\u{DF06}".as_bytes()), "\u{D834}\u{DF06}".as_bytes()); + // assert_eq!(escape("\u{DF06}".as_bytes()), "\u{DF06}".as_bytes()); + // assert_eq!(escape("\u{D834}".as_bytes()), "\u{D834}".as_bytes()); + } +} diff --git a/crates/core/src/util/mod.rs b/crates/core/src/util/mod.rs index ef87f17f6..085d04c3b 100644 --- a/crates/core/src/util/mod.rs +++ b/crates/core/src/util/mod.rs @@ -1,7 +1,11 @@ +mod escape; mod fast_stack; mod gurantee; mod segment; +#[allow(unused_imports)] +pub use crate::util::escape::*; + #[allow(unused_imports)] pub use crate::util::fast_stack::*; From 8c5aec02c421c9aa7dba8c3e4aca58d02b3d322f Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Sat, 12 Oct 2024 18:36:00 -0400 Subject: [PATCH 11/19] add: Throughput testing helpers --- crates/core/src/util/mod.rs | 4 +++ crates/core/src/util/throughput.rs | 47 ++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 crates/core/src/util/throughput.rs diff --git a/crates/core/src/util/mod.rs b/crates/core/src/util/mod.rs index 085d04c3b..82934ee25 100644 --- a/crates/core/src/util/mod.rs +++ b/crates/core/src/util/mod.rs @@ -2,6 +2,7 @@ mod escape; mod fast_stack; mod gurantee; mod segment; +mod throughput; #[allow(unused_imports)] pub use crate::util::escape::*; @@ -14,3 +15,6 @@ pub use crate::util::gurantee::*; #[allow(unused_imports)] pub use crate::util::segment::*; + +#[allow(unused_imports)] +pub use crate::util::throughput::*; diff --git a/crates/core/src/util/throughput.rs b/crates/core/src/util/throughput.rs new file mode 100644 index 000000000..17dcda8f4 --- /dev/null +++ b/crates/core/src/util/throughput.rs @@ -0,0 +1,47 @@ +use std::fmt::Display; + +pub struct Throughput { + rate: f64, + elapsed: std::time::Duration, +} + +impl Display for Throughput { + #[inline(always)] + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "{}/s over {:.2}s", format_byte_size(self.rate), self.elapsed.as_secs_f64()) + } +} + +impl Throughput { + #[inline(always)] + pub fn compute(iterations: usize, memory_baseline: usize, cb: F) -> Self + where + F: Fn(), + { + let now = std::time::Instant::now(); + for _ in 0..iterations { + cb(); + } + let elapsed = now.elapsed(); + let memory_size = iterations * memory_baseline; + + Self { + rate: memory_size as f64 / elapsed.as_secs_f64(), + elapsed, + } + } +} + +#[inline(always)] +fn format_byte_size(size: f64) -> String { + let units = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; + let unit = 1000; + let mut size = size; + let mut i = 0; + while size > unit as f64 { + size /= unit as f64; + i += 1; + } + + format!("{:.2} {}", size, units[i]) +} From ff0a5e3f7bec87b22db6d9e3bc576c1c9ff88d36 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Sat, 12 Oct 2024 18:46:33 -0400 Subject: [PATCH 12/19] wip: decode --- crates/core/src/util/decode.rs | 122 ++++++++++++++++++++++++++ crates/core/src/util/math.rs | 154 +++++++++++++++++++++++++++++++++ crates/core/src/util/mod.rs | 8 ++ 3 files changed, 284 insertions(+) create mode 100644 crates/core/src/util/decode.rs create mode 100644 crates/core/src/util/math.rs diff --git a/crates/core/src/util/decode.rs b/crates/core/src/util/decode.rs new file mode 100644 index 000000000..3501011d4 --- /dev/null +++ b/crates/core/src/util/decode.rs @@ -0,0 +1,122 @@ +// import { addWhitespaceAroundMathOperators } from './math-operators' + +use std::mem; + +pub fn decode_arbitrary_value(input: &[u8]) -> Vec { + // We do not want to normalize anything inside of a url() because if we + // replace `_` with ` `, then it will very likely break the url. + if input.starts_with(b"url(") { + return input.to_vec() + } + + let input = convert_underscores_to_whitespace(input); + // let input = addWhitespaceAroundMathOperators(input); + + return input.to_vec(); +} + +/// Convert `_` to ` ` unless escaped (`\_`) in which case they +/// should be converted to `_` instead. +pub fn convert_underscores_to_whitespace(input: &[u8]) -> Vec { + let mut result = Vec::::with_capacity(input.len()); + + let input = write_decoded_8(input, &mut result); + write_decoded_scalar(input, &mut result); + + result +} + +pub fn write_decoded_8<'a, 'b>(input: &'a [u8], result: &'b mut Vec) -> &'a [u8] { + const CHUNK_SIZE: usize = mem::size_of::(); + const NUL_8: [u8; CHUNK_SIZE] = [0x00; CHUNK_SIZE]; + const SPACE_8: [u8; CHUNK_SIZE] = [b' '; CHUNK_SIZE]; + const ESCAPE_8: [u8; CHUNK_SIZE] = [b'\\'; CHUNK_SIZE]; + const UNDERSCORE_8: [u8; CHUNK_SIZE] = [b'_'; CHUNK_SIZE]; + + let mut chunks = input.chunks_exact(CHUNK_SIZE); + + while let Some(chunk) = chunks.next() { + let mut chunk: [u8; CHUNK_SIZE] = chunk.try_into().unwrap(); + let mut chunk: u64 = u64::from_ne_bytes(chunk); + + let mut is_escape = [false; CHUNK_SIZE]; + for j in 0..CHUNK_SIZE { + is_escape[j] = chunk[j] == b'\\'; + } + + let mut is_underscore = [false; CHUNK_SIZE]; + for j in 0..CHUNK_SIZE { + is_underscore[j] = chunk[j] == b'_'; + } + + // Replace underscores with spaces in the chunk + for j in 0..CHUNK_SIZE { + chunk[j] = if is_underscore[j] { + SPACE_8[j] + } else { + chunk[j] + }; + } + + // Replace escaped underscores with underscores + for j in 0..(CHUNK_SIZE - 1) { + chunk[j] = if is_escape[j] && is_underscore[j + 1] { + UNDERSCORE_8[j] + } else { + chunk[j] + }; + } + + // Replace escapes with NUL bytes + for j in 0..CHUNK_SIZE { + chunk[j] = if is_escape[j] { + NUL_8[j] + } else { + chunk[j] + }; + } + + result.extend(&chunk); + } + + return chunks.remainder(); +} + + +pub fn write_decoded_scalar(input: &[u8], result: &mut Vec) { + let mut i = 0; + + while i < input.len() { + match input[i] { + b'\\' => { + if i + 1 == input.len() { + // We've hit the end of the string and there's no character to escape + result.extend(b"\\"); + } else if input[i + 1] == b'_' { + // We've hit an escaped underscore + result.extend(b"_"); + } else { + // We've hit an "escaped" character that isn't an underscore + // which means its not actually escaped and should be treated + // as a literal character. Since we've already read the next + // character, we'll just write both of them out here. + result.extend(&input[i..i+1]); + } + + i += 2; + }, + + b'_' => { + result.push(b' '); + i += 1; + }, + + _ => { + result.push(input[i]); + i += 1; + } + } + } +} + +// a\b\c diff --git a/crates/core/src/util/math.rs b/crates/core/src/util/math.rs new file mode 100644 index 000000000..58dba1625 --- /dev/null +++ b/crates/core/src/util/math.rs @@ -0,0 +1,154 @@ +// const mathFunctions = [ +// 'calc', +// 'min', +// 'max', +// 'clamp', +// 'mod', +// 'rem', +// 'sin', +// 'cos', +// 'tan', +// 'asin', +// 'acos', +// 'atan', +// 'atan2', +// 'pow', +// 'sqrt', +// 'hypot', +// 'log', +// 'exp', +// 'round', +// ] + +// export function hasMathFn(input: string) { +// return input.indexOf('(') !== -1 && mathFunctions.some((fn) => input.includes(`${fn}(`)) +// } + +// export function addWhitespaceAroundMathOperators(input: string) { +// // There's definitely no functions in the input, so bail early +// if (input.indexOf('(') === -1) { +// return input +// } + +// // Bail early if there are no math functions in the input +// if (!mathFunctions.some((fn) => input.includes(fn))) { +// return input +// } + +// let result = '' +// let formattable: boolean[] = [] + +// for (let i = 0; i < input.length; i++) { +// let char = input[i] + +// // Determine if we're inside a math function +// if (char === '(') { +// result += char + +// // Scan backwards to determine the function name. This assumes math +// // functions are named with lowercase alphanumeric characters. +// let start = i + +// for (let j = i - 1; j >= 0; j--) { +// let inner = input.charCodeAt(j) + +// if (inner >= 48 && inner <= 57) { +// start = j // 0-9 +// } else if (inner >= 97 && inner <= 122) { +// start = j // a-z +// } else { +// break +// } +// } + +// let fn = input.slice(start, i) + +// // This is a known math function so start formatting +// if (mathFunctions.includes(fn)) { +// formattable.unshift(true) +// continue +// } + +// // We've encountered nested parens inside a math function, record that and +// // keep formatting until we've closed all parens. +// else if (formattable[0] && fn === '') { +// formattable.unshift(true) +// continue +// } + +// // This is not a known math function so don't format it +// formattable.unshift(false) +// continue +// } + +// // We've exited the function so format according to the parent function's +// // type. +// else if (char === ')') { +// result += char +// formattable.shift() +// } + +// // Add spaces after commas in math functions +// else if (char === ',' && formattable[0]) { +// result += `, ` +// continue +// } + +// // Skip over consecutive whitespace +// else if (char === ' ' && formattable[0] && result[result.length - 1] === ' ') { +// continue +// } + +// // Add whitespace around operators inside math functions +// else if ((char === '+' || char === '*' || char === '/' || char === '-') && formattable[0]) { +// let trimmed = result.trimEnd() +// let prev = trimmed[trimmed.length - 1] + +// // If we're preceded by an operator don't add spaces +// if (prev === '+' || prev === '*' || prev === '/' || prev === '-') { +// result += char +// continue +// } + +// // If we're at the beginning of an argument don't add spaces +// else if (prev === '(' || prev === ',') { +// result += char +// continue +// } + +// // Add spaces only after the operator if we already have spaces before it +// else if (input[i - 1] === ' ') { +// result += `${char} ` +// } + +// // Add spaces around the operator +// else { +// result += ` ${char} ` +// } +// } + +// // Skip over `to-zero` when in a math function. +// // +// // This is specifically to handle this value in the round(…) function: +// // +// // ``` +// // round(to-zero, 1px) +// // ^^^^^^^ +// // ``` +// // +// // This is because the first argument is optionally a keyword and `to-zero` +// // contains a hyphen and we want to avoid adding spaces inside it. +// else if (formattable[0] && input.startsWith('to-zero', i)) { +// let start = i +// i += 7 +// result += input.slice(start, i + 1) +// } + +// // Handle all other characters +// else { +// result += char +// } +// } + +// return result +// } diff --git a/crates/core/src/util/mod.rs b/crates/core/src/util/mod.rs index 82934ee25..a5b9de7d6 100644 --- a/crates/core/src/util/mod.rs +++ b/crates/core/src/util/mod.rs @@ -1,9 +1,17 @@ +mod decode; mod escape; mod fast_stack; mod gurantee; +mod math; mod segment; mod throughput; +#[allow(unused_imports)] +pub use crate::util::decode::*; + +#[allow(unused_imports)] +pub use crate::util::math::*; + #[allow(unused_imports)] pub use crate::util::escape::*; From eeb38e7399f27aed555ae6225555fb0f2756eef9 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Sat, 12 Oct 2024 18:52:52 -0400 Subject: [PATCH 13/19] wip: compat layer --- crates/core/src/compat/config.rs | 5 + crates/core/src/compat/mod.rs | 1 + crates/core/src/compat/plugin.rs | 7 + crates/plugin-example/Cargo.toml | 15 +++ crates/plugin-example/node/package-lock.json | 58 +++++++++ crates/plugin-example/node/package.json | 15 +++ crates/plugin-example/node/src/client.ts | 14 ++ crates/plugin-example/node/src/plugin-api.ts | 127 +++++++++++++++++++ crates/plugin-example/node/src/plugin.ts | 27 ++++ crates/plugin-example/node/src/server.ts | 33 +++++ crates/plugin-example/src/app.rs | 111 ++++++++++++++++ crates/plugin-example/src/main.rs | 73 +++++++++++ crates/plugin-example/src/pipes.rs | 79 ++++++++++++ 13 files changed, 565 insertions(+) create mode 100644 crates/core/src/compat/config.rs create mode 100644 crates/core/src/compat/mod.rs create mode 100644 crates/core/src/compat/plugin.rs create mode 100644 crates/plugin-example/Cargo.toml create mode 100644 crates/plugin-example/node/package-lock.json create mode 100644 crates/plugin-example/node/package.json create mode 100644 crates/plugin-example/node/src/client.ts create mode 100644 crates/plugin-example/node/src/plugin-api.ts create mode 100644 crates/plugin-example/node/src/plugin.ts create mode 100644 crates/plugin-example/node/src/server.ts create mode 100644 crates/plugin-example/src/app.rs create mode 100644 crates/plugin-example/src/main.rs create mode 100644 crates/plugin-example/src/pipes.rs diff --git a/crates/core/src/compat/config.rs b/crates/core/src/compat/config.rs new file mode 100644 index 000000000..7d2fd45d9 --- /dev/null +++ b/crates/core/src/compat/config.rs @@ -0,0 +1,5 @@ +use serde_json::Value; + +pub struct UserConfig { + internal: Value, +} diff --git a/crates/core/src/compat/mod.rs b/crates/core/src/compat/mod.rs new file mode 100644 index 000000000..ef68c3694 --- /dev/null +++ b/crates/core/src/compat/mod.rs @@ -0,0 +1 @@ +pub mod config; diff --git a/crates/core/src/compat/plugin.rs b/crates/core/src/compat/plugin.rs new file mode 100644 index 000000000..cec1366dc --- /dev/null +++ b/crates/core/src/compat/plugin.rs @@ -0,0 +1,7 @@ +pub struct Plugin { + /// An internal identifier that the server uses to identify the plugin. + /// This identifier is guaranteed to be unique for the lifetime of the + /// plugin server but is not guaranteed to be unique across multiple + /// invocations of the server. + handle: u64 +} diff --git a/crates/plugin-example/Cargo.toml b/crates/plugin-example/Cargo.toml new file mode 100644 index 000000000..ddc45a584 --- /dev/null +++ b/crates/plugin-example/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "tailwindcss-plugin-example" +version = "0.1.0" +edition = "2021" + +[dependencies] +lsp-server = "0.7.6" +serde = "1.0.209" +serde_json = "1.0.127" +tracing = "0.1.40" +tailwindcss-core = { path = "../core" } +crossbeam-channel = "0.5.13" +log = "0.4.22" +tokio = { version = "1.39.3", features = ["full"] } +mio = "1.0.2" diff --git a/crates/plugin-example/node/package-lock.json b/crates/plugin-example/node/package-lock.json new file mode 100644 index 000000000..b80ce0abd --- /dev/null +++ b/crates/plugin-example/node/package-lock.json @@ -0,0 +1,58 @@ +{ + "name": "node", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "node", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "vscode-jsonrpc": "^8.2.1", + "vscode-languageserver": "^9.0.1" + } + }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", + "integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz", + "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==", + "dependencies": { + "vscode-languageserver-protocol": "3.17.5" + }, + "bin": { + "installServerIntoExtension": "bin/installServerIntoExtension" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver-protocol/node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==" + } + } +} diff --git a/crates/plugin-example/node/package.json b/crates/plugin-example/node/package.json new file mode 100644 index 000000000..951b95664 --- /dev/null +++ b/crates/plugin-example/node/package.json @@ -0,0 +1,15 @@ +{ + "name": "node", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "description": "", + "dependencies": { + "vscode-jsonrpc": "^8.2.1", + "vscode-languageserver": "^9.0.1" + } +} diff --git a/crates/plugin-example/node/src/client.ts b/crates/plugin-example/node/src/client.ts new file mode 100644 index 000000000..8fd3a9d1c --- /dev/null +++ b/crates/plugin-example/node/src/client.ts @@ -0,0 +1,14 @@ +import * as net from 'node:net' +import * as rpc from 'vscode-jsonrpc/node' + +let client = net.connect(12345) + +// Use stdin and stdout for communication: +let connection = rpc.createMessageConnection(client, client) + +connection.onNotification('loaded', () => console.log('server loaded')) +connection.onNotification('ping', () => console.log('ping')) +connection.onNotification('@/plugins/loaded', () => console.log('plugins loaded')) + +connection.listen() +connection.sendRequest('@/plugins/load', 'foo') diff --git a/crates/plugin-example/node/src/plugin-api.ts b/crates/plugin-example/node/src/plugin-api.ts new file mode 100644 index 000000000..8740bf828 --- /dev/null +++ b/crates/plugin-example/node/src/plugin-api.ts @@ -0,0 +1,127 @@ +export type PluginFn = (api: PluginAPI) => void +export type PluginWithConfig = { handler: PluginFn } +export type PluginWithOptions = { + (options?: T): PluginWithConfig + __isOptionsFunction: true +} + +export type Plugin = PluginFn | PluginWithConfig | PluginWithOptions +export type CssInJs = { [key: string]: string | CssInJs | CssInJs[] } + +export type NamedUtilityValue = { + kind: 'named' + + /** + * bg-red-500 + * ^^^^^^^ + * + * w-1/2 + * ^ + */ + value: string + + /** + * w-1/2 + * ^^^ + */ + fraction: string | null +} + +export type PluginAPI = { + addBase(base: CssInJs): void + addVariant(name: string, variant: string | string[] | CssInJs): void + + addUtilities( + utilities: Record | Record[], + options?: {}, + ): void + + matchUtilities( + utilities: Record< + string, + (value: string, extra: { modifier: string | null }) => CssInJs | CssInJs[] + >, + options?: Partial<{ + type: string | string[] + supportsNegativeValues: boolean + values: Record & { + __BARE_VALUE__?: (value: NamedUtilityValue) => string | undefined + } + modifiers: 'any' | Record + }>, + ): void + + // addComponents(utilities: Record | Record[], options?: {}): void + // matchComponents( + // utilities: Record CssInJs>, + // options?: Partial<{ + // type: string | string[] + // supportsNegativeValues: boolean + // values: Record & { + // __BARE_VALUE__?: (value: NamedUtilityValue) => string | undefined + // } + // modifiers: 'any' | Record + // }>, + // ): void + + // theme(path: string, defaultValue?: any): any + // prefix(className: string): string +} + +import * as rpc from 'vscode-jsonrpc/node' + +export function buildPluginController(server: rpc.MessageConnection) { + let utilityMap = new Map< + string, + (value: string, extra: { modifier: string | null }) => CssInJs | CssInJs[] + >() + + let api = buildPluginApi(server, utilityMap) + + return { + api, + + matchUtility(id: string, value: string, modifier: string | null) { + let fn = utilityMap.get(id) + if (!fn) return + + return fn(value, { modifier }) + }, + } +} + +function buildPluginApi( + server: rpc.MessageConnection, + utilityMap: Map< + string, + (value: string, extra: { modifier: string | null }) => CssInJs | CssInJs[] + >, +): PluginAPI { + return { + addBase(base) { + server.sendNotification('@/plugin/add-base', { ast: base }) + }, + + addVariant(name, variant) { + server.sendNotification('@/plugin/add-variant', { name, format: variant }) + }, + + addUtilities(utilities, options) { + server.sendNotification('@/plugin/add-utilities', { ast: utilities, options }) + }, + + matchUtilities(utilities, options) { + let namesToIds: Record = {} + + for (let [name, fn] of Object.entries(utilities)) { + let id = Math.random().toString(36).slice(2) + namesToIds[name] = id + utilityMap.set(id, fn) + } + + server.sendNotification('@/plugin/match-utility', { + utilities: namesToIds, + }) + }, + } +} diff --git a/crates/plugin-example/node/src/plugin.ts b/crates/plugin-example/node/src/plugin.ts new file mode 100644 index 000000000..ee1d4f803 --- /dev/null +++ b/crates/plugin-example/node/src/plugin.ts @@ -0,0 +1,27 @@ +import type { PluginAPI } from './plugin-api' + +module.exports = function ({ addBase, addVariant, addUtilities, matchUtilities }: PluginAPI) { + addBase({ + div: { + transform: 'skewY(-10deg)', + }, + }) + + addVariant('hover', ':hover') + addVariant('marker', ['::marker', '* ::marker']) + + addUtilities({ + '.skew-10deg': { + transform: 'skewY(-10deg)', + }, + }) + + matchUtilities( + { skew: (value) => ({ transform: `skewY(${value})` }) }, + { + values: { + f10: '10deg', + }, + }, + ) +} diff --git a/crates/plugin-example/node/src/server.ts b/crates/plugin-example/node/src/server.ts new file mode 100644 index 000000000..b7529b3de --- /dev/null +++ b/crates/plugin-example/node/src/server.ts @@ -0,0 +1,33 @@ +import * as net from 'node:net' +import * as rpc from 'vscode-jsonrpc/node' +import { buildPluginController, PluginFn } from './plugin-api' + +let socket = await new Promise((resolve) => { + net.createServer(resolve).listen(12345) +}) + +let server = rpc.createMessageConnection(socket, socket) +let controller = buildPluginController(server) + +async function loadPlugin(path: string): Promise { + return import(path).then((m) => m.default ?? m) +} + +server.onRequest('@/plugins/load', async ({ plugins: paths }) => { + console.log('Loading…') + let plugins: PluginFn[] = await Promise.all(paths.map(loadPlugin)) + + for (let plugin of plugins) { + plugin(controller.api) + } + console.log('Loaded…') +}) + +server.onRequest('@/plugins/match-utility', async ({ id, value, modifier }) => { + let ast = controller.matchUtility(id, value, modifier) + + return { ast } +}) + +server.listen() +server.sendNotification('loaded') diff --git a/crates/plugin-example/src/app.rs b/crates/plugin-example/src/app.rs new file mode 100644 index 000000000..7d31668d0 --- /dev/null +++ b/crates/plugin-example/src/app.rs @@ -0,0 +1,111 @@ +use std::{collections::HashMap, error::Error}; + +use lsp_server::{Connection, Message, Notification, ReqQueue, Request, RequestId, Response}; +use serde_json::Value; +use tokio::sync::oneshot; + +type AppResult = Result>; +type ReqHandler = Box ()>; + +pub struct App { + connection: Connection, + queue: ReqQueue::, + utilities: HashMap::>, +} + +impl App { + pub fn new(connection: Connection) -> Self { + Self { + connection, + queue: ReqQueue::default(), + utilities: HashMap::new(), + } + } + + pub async fn main_loop(&mut self) -> AppResult<()> { + for msg in self.connection.receiver.clone() { + match msg { + Message::Request(req) => self.on_request(req).await?, + Message::Response(res) => self.on_response(res).await?, + Message::Notification(note) => self.on_note(note).await?, + _ => {}, + }; + } + + Ok(()) + } + + async fn on_request(&mut self, req: Request) -> AppResult<()> { + // + Ok(()) + } + + async fn on_response(&mut self, res: Response) -> AppResult<()> { + self.queue.outgoing.complete(res.id); + + Ok(()) + } + + async fn on_note(&mut self, note: Notification) -> AppResult<()> { + match ¬e.method[..] { + "@/plugin/match-utility" => { + self.on_match_utility(note.params)?; + }, + _ => {}, + }; + + Ok(()) + } + + pub async fn load_plugins(&mut self, paths: Vec) -> AppResult<()> { + let (tx, rx) = oneshot::channel::(); + + let request = self.queue.outgoing.register( + "@/plugins/load".to_owned(), + serde_json::json!({ + "plugins": paths, + }), + Box::new(move |app, res| { + tx.send(0).unwrap(); + }) + ); + + let id = request.id.clone(); + + self.connection.sender.send(Message::Request(request))?; + + rx.await?; + + Ok(()) + } + + fn on_match_utility(&mut self, params: Value) -> AppResult<()> { + let params = params["utilities"].as_object().unwrap(); + + for (key, value) in params { + let id = value.as_str().unwrap().to_owned(); + + self.utilities.entry(key.clone()).or_default().push(id); + } + + Ok(()) + } + + pub async fn call_match_utility(&mut self, name: &str, value: &str) -> AppResult<()> { + let utility_id = self.utilities.get(name).unwrap().first().unwrap().clone(); + + let request = self.queue.outgoing.register( + "@/plugins/match-utility".to_owned(), + serde_json::json!({ + "id": utility_id, + "value": value, + "modifier": null, + }), + 0 + ); + + self.connection.sender.send(Message::Request(request))?; + + Ok(()) + } +} diff --git a/crates/plugin-example/src/main.rs b/crates/plugin-example/src/main.rs new file mode 100644 index 000000000..a1d82e28a --- /dev/null +++ b/crates/plugin-example/src/main.rs @@ -0,0 +1,73 @@ +mod app; +mod pipes; + +use std::{collections::HashMap, error::Error, net::SocketAddr, process::{Command, Stdio}, thread, time::Duration}; + +use app::App; +use lsp_server::{Connection, Message, ReqQueue, Request}; +use tailwindcss_css::ast::{rule, AstNode, WalkAction}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let mut ast: Vec = vec![ + rule(b"@plugin \"./plugin.ts\"".to_vec(), vec![]), + ]; + + // 1. Collect paths of all plugins in the CSS + let mut plugins_paths: Vec = vec![]; + + for node in ast.iter_mut() { + node.walk(&mut |node, _| { + let AstNode::Rule { selector, .. } = node else { + return WalkAction::Continue; + }; + + if !selector.starts_with(b"@plugin") { + return WalkAction::Continue; + } + + let path = selector.split_at(7).1; + let path = &path[2..path.len()-1]; + let path = path.to_vec(); + + plugins_paths.push(unsafe { + String::from_utf8_unchecked(path) + }); + + WalkAction::Continue + }); + } + + // 2. Spawn node server + let mut node_server = Command::new("bun") + .args(["node/src/server.ts"]) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .spawn() + .unwrap(); + + // 3. Wait for the server to start + thread::sleep(Duration::from_millis(500)); + + let (conn, io_threads) = Connection::connect("127.0.0.1:12345".parse::()?)?; + + let mut app = App::new(conn); + + _ = app.main_loop().await; + + // 4. Load Plugins + app.load_plugins(plugins_paths).await?; + + // 6. Ask server to match utilities + app.call_match_utility("skew", "f10").await?; + + // 7. Wait for the server to respond + thread::sleep(Duration::from_millis(100)); + + // 7. Update the AST with the matched utilities + + io_threads.join()?; + node_server.kill()?; + + Ok(()) +} diff --git a/crates/plugin-example/src/pipes.rs b/crates/plugin-example/src/pipes.rs new file mode 100644 index 000000000..2fb7d16fe --- /dev/null +++ b/crates/plugin-example/src/pipes.rs @@ -0,0 +1,79 @@ +use std::{ + io::{self, stdin, stdout, BufReader, BufWriter, Write}, process::{Child, ChildStdin, ChildStdout}, thread +}; + +use log::debug; + +use crossbeam_channel::{bounded, Receiver, Sender}; +use lsp_server::Message; + +/// Creates an LSP connection via stdio. +pub fn process_transport( + child: &mut Child, +) -> (Sender, Receiver, IoThreads) { + let stdout = child.stdin.take().expect("failed to get child stdin"); + let stdin = child.stdout.take().expect("failed to get child stdout"); + + pipes_transport(stdout, stdin) +} + +/// Creates an LSP connection via stdio. +pub fn pipes_transport( + stdout: ChildStdin, + stdin: ChildStdout, +) -> (Sender, Receiver, IoThreads) { + // Send messages to the child process + let mut writer = stdout; + let (writer_sender, writer_receiver) = bounded::(0); + let writer = thread::spawn(move || { + writer_receiver.into_iter().try_for_each(|it| it.write(&mut writer)) + }); + + // Recieve messages from the child process + let mut reader = BufReader::with_capacity(1, stdin); + let (reader_sender, reader_receiver) = bounded::(0); + let reader = thread::spawn(move || { + loop { + let Some(msg) = Message::read(&mut reader)? else { + break; + }; + + let is_exit = matches!(&msg, Message::Notification(n) if n.method == "exit"); + + debug!("sending message {:#?}", msg); + reader_sender.send(msg).expect("receiver was dropped, failed to send a message"); + + if is_exit { + break; + } + } + Ok(()) + }); + + let threads = IoThreads { reader, writer }; + (writer_sender, reader_receiver, threads) +} + +pub struct IoThreads { + reader: thread::JoinHandle>, + writer: thread::JoinHandle>, +} + +impl IoThreads { + pub fn join(self) -> io::Result<()> { + match self.reader.join() { + Ok(r) => r?, + Err(err) => { + println!("reader panicked!"); + std::panic::panic_any(err) + } + } + match self.writer.join() { + Ok(r) => r, + Err(err) => { + println!("writer panicked!"); + std::panic::panic_any(err); + } + } + } +} From 7f19f7ef7529fd1f5ddc37398d852a536e8c5c6b Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Sat, 12 Oct 2024 19:14:04 -0400 Subject: [PATCH 14/19] wip: rebuilt extractor --- crates/oxide/src/extractor.rs | 116 ++++++++++++++++++++++++++++++++++ crates/oxide/src/lib.rs | 1 + 2 files changed, 117 insertions(+) create mode 100644 crates/oxide/src/extractor.rs diff --git a/crates/oxide/src/extractor.rs b/crates/oxide/src/extractor.rs new file mode 100644 index 000000000..bbe289b86 --- /dev/null +++ b/crates/oxide/src/extractor.rs @@ -0,0 +1,116 @@ +/// Tailwind CSS Candidate Extractor +/// +/// Core assumptions: +/// - The extractor is intended to scan data that is valid UTF-8. +/// - Scanning invalid UTF-8 may result in incorrect output. +/// - No code should **ever** panic even in the presence of invalid data. +/// +/// The extractor is designed to operate on a tuple of two pieces of data: +/// - The current state +/// - The current byte +/// +/// A byte-to-fn-pointer table is used per-state such that the CPU can +/// accurately predict upcoming branches. This is a critical optimization +/// that allows the extractor to run at maximum speed without requiring +/// SIMD-like parallelism for every operation. +/// + +/// This represents the current "state" of the extractor. +enum ParseState { + Start, + Candidate, + Arbitrary, +} + + +// Enter arbitrary value mode +// b'[' => { +// trace!("Arbitrary::Start\t"); +// self.in_arbitrary = true; +// self.idx_arbitrary_start = self.cursor.pos; + +// ParseAction::Consume +// } + +// // Allowed first characters. +// b'@' | b'!' | b'-' | b'<' | b'>' | b'0'..=b'9' | b'a'..=b'z' | b'A'..=b'Z' | b'*' => { +// // TODO: A bunch of characters that we currently support but maybe we only want it behind +// // a flag. E.g.: ` ParseAction<'a> { +// if self.in_arbitrary { +// self.parse_arbitrary() +// } else if self.in_candidate { +// self.parse_continue() +// } else if self.parse_start() == ParseAction::Consume { +// self.in_candidate = true; +// self.idx_start = self.cursor.pos; +// self.idx_end = self.cursor.pos; + +// ParseAction::Consume +// } else { +// ParseAction::Skip +// } +// } diff --git a/crates/oxide/src/lib.rs b/crates/oxide/src/lib.rs index 79c2bcb9e..2f493680e 100644 --- a/crates/oxide/src/lib.rs +++ b/crates/oxide/src/lib.rs @@ -16,6 +16,7 @@ pub mod fast_skip; pub mod glob; pub mod parser; pub mod scanner; +pub mod extractor; static SHOULD_TRACE: sync::LazyLock = sync::LazyLock::new( || matches!(std::env::var("DEBUG"), Ok(value) if value.eq("*") || value.eq("1") || value.eq("true") || value.contains("tailwind")), From bbcf9ecfe0189fc2587e1fbb5b4f28ca68cb1543 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Sat, 12 Oct 2024 19:37:07 -0400 Subject: [PATCH 15/19] wip: testing --- crates/core/Cargo.toml | 3 + crates/core/src/lib.rs | 136 ++++++++++++++ crates/core/src/main.rs | 380 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 519 insertions(+) create mode 100644 crates/core/src/main.rs diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index ef2d27aed..c321d22b0 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -18,6 +18,9 @@ serde_json = "1.0.127" log = "0.4.22" wasm-bindgen = "0.2.93" bstr = "1.10.0" +memmem = "0.1.1" +memchr = "2.7.4" +tinyvec = { version = "1.8.0", features = ["alloc"] } [dev-dependencies] rstest = "0.22.0" diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 389dd3264..03a336535 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1 +1,137 @@ +mod compat; mod css; +mod util; + +use css::optimize::optimize_ast; +use css::parser::parse; +use wasm_bindgen::prelude::*; +use std::cell::Cell; +use css::ast::CssNode; +use css::visit::WalkAction; + +#[wasm_bindgen] +pub fn wip() { + // 1. Parse the CSS + let mut ast = parse(b"h1 { color: red; font-size: 2em; } h2 { color: blue; font-size: 1.5em; }").unwrap(); + + // 2. Walk the AST and mutate all color properties to green + ast.walk_mut(&mut |node| { + let CssNode::Declaration { property, value, .. } = node else { + return WalkAction::Continue; + }; + + if property != b"color" { + return WalkAction::Continue; + } + + *value = "green".into(); + + return WalkAction::Continue; + }); + + // 3. Walk the AST and count the number of font-size properties + let count = Cell::new(0); + + ast.walk(&|node| { + let CssNode::Declaration { property, .. } = node else { + return WalkAction::Continue; + }; + + if property != b"font-size" { + return WalkAction::Continue; + } + + count.set(count.get() + 1); + + WalkAction::Continue + }); + + // 4. Optimize the AST + optimize_ast(&mut ast); + + // 5. Serialize the AST back to CSS + let css = ast.to_css(); + + // 6. Print the CSS + println!("{}", String::from_utf8_lossy(&css)); + println!("{}", count.get()); +} + +// use css::ast::{decl, rule, Ast, AstNode, WalkAction}; + +// pub struct Compiler { +// ast: Stylesheet, +// config_paths: Vec, +// plugin_paths: Vec, +// } + +// impl Compiler { +// fn new(css: &[u8]) -> Compiler { +// let mut ast = parse_css(css); +// let mut plugin_paths = vec![]; +// let mut config_paths = vec![]; + +// ast.walk(&mut |node, _| { +// let AstNode::Rule { selector, .. } = node else { +// return WalkAction::Continue; +// }; + +// if selector.starts_with(b"@plugin") { +// let path = selector.split_at(7).1; +// let path = &path[2..path.len()-1]; +// let path = path.to_vec(); + +// plugin_paths.push(unsafe { +// String::from_utf8_unchecked(path) +// }); +// } + +// if selector.starts_with(b"@config") { +// let path = selector.split_at(7).1; +// let path = &path[2..path.len()-1]; +// let path = path.to_vec(); + +// config_paths.push(unsafe { +// String::from_utf8_unchecked(path) +// }); +// } + +// WalkAction::Continue +// }); + +// Compiler { +// ast, +// } +// } + +// // fn plugin_paths(&self) -> Vec { +// // vec![] +// // } + +// // fn config_paths(&self) -> Vec { +// // vec![] +// // } +// } + +// fn parse_css(css: &[u8]) -> Ast { +// // TODO: Parse the CSS into an AST +// _ = css; + +// Ast::from(vec![ +// rule(b"body", vec![ +// decl(b"color", Some(b"red"), false), +// ]), +// ]) +// } + +// fn foo() { +// let css = b"body { color: red; }"; +// let compiler = Compiler::new(css); +// } + +// enum UtilityDescriptor { +// Simple { +// name: String, +// ast: Ast, +// }, +// } diff --git a/crates/core/src/main.rs b/crates/core/src/main.rs new file mode 100644 index 000000000..187fdcc4d --- /dev/null +++ b/crates/core/src/main.rs @@ -0,0 +1,380 @@ +use std::hint::black_box; + +const PREFLIGHT: &'static str = r#"/* + 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) + 2. Remove default margins and padding + 3. Reset all borders. +*/ + +*, +::after, +::before, +::backdrop, +::file-selector-button { + box-sizing: border-box; /* 1 */ + margin: 0; /* 2 */ + padding: 0; /* 2 */ + border: 0 solid; /* 3 */ +} + +/* + 1. Use a consistent sensible line-height in all browsers. + 2. Prevent adjustments of font size after orientation changes in iOS. + 3. Use a more readable tab size. + 4. Use the user's configured `sans` font-family by default. + 5. Use the user's configured `sans` font-feature-settings by default. + 6. Use the user's configured `sans` font-variation-settings by default. + 7. Disable tap highlights on iOS. +*/ + +html, +:host { + line-height: 1.5; /* 1 */ + -webkit-text-size-adjust: 100%; /* 2 */ + tab-size: 4; /* 3 */ + font-family: var( + --default-font-family, + ui-sans-serif, + system-ui, + sans-serif, + 'Apple Color Emoji', + 'Segoe UI Emoji', + 'Segoe UI Symbol', + 'Noto Color Emoji' + ); /* 4 */ + font-feature-settings: var(--default-font-feature-settings, normal); /* 5 */ + font-variation-settings: var(--default-font-variation-settings, normal); /* 6 */ + -webkit-tap-highlight-color: transparent; /* 7 */ +} + +/* + Inherit line-height from `html` so users can set them as a class directly on the `html` element. +*/ + +body { + line-height: inherit; +} + +/* + 1. Add the correct height in Firefox. + 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) + 3. Reset the default border style to a 1px solid border. +*/ + +hr { + height: 0; /* 1 */ + color: inherit; /* 2 */ + border-top-width: 1px; /* 3 */ +} + +/* + Add the correct text decoration in Chrome, Edge, and Safari. +*/ + +abbr:where([title]) { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; +} + +/* + Remove the default font size and weight for headings. +*/ + +h1, +h2, +h3, +h4, +h5, +h6 { + font-size: inherit; + font-weight: inherit; +} + +/* + Reset links to optimize for opt-in styling instead of opt-out. +*/ + +a { + color: inherit; + -webkit-text-decoration: inherit; + text-decoration: inherit; +} + +/* + Add the correct font weight in Edge and Safari. +*/ + +b, +strong { + font-weight: bolder; +} + +/* + 1. Use the user's configured `mono` font-family by default. + 2. Use the user's configured `mono` font-feature-settings by default. + 3. Use the user's configured `mono` font-variation-settings by default. + 4. Correct the odd `em` font sizing in all browsers. +*/ + +code, +kbd, +samp, +pre { + font-family: var( + --default-mono-font-family, + ui-monospace, + SFMono-Regular, + Menlo, + Monaco, + Consolas, + 'Liberation Mono', + 'Courier New', + monospace + ); /* 4 */ + font-feature-settings: var(--default-mono-font-feature-settings, normal); /* 5 */ + font-variation-settings: var(--default-mono-font-variation-settings, normal); /* 6 */ + font-size: 1em; /* 4 */ +} + +/* + Add the correct font size in all browsers. +*/ + +small { + font-size: 80%; +} + +/* + Prevent `sub` and `sup` elements from affecting the line height in all browsers. +*/ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* + 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) + 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) + 3. Remove gaps between table borders by default. +*/ + +table { + text-indent: 0; /* 1 */ + border-color: inherit; /* 2 */ + border-collapse: collapse; /* 3 */ +} + +/* + 1. Inherit the font styles in all browsers. + 2. Remove the default background color. +*/ + +button, +input, +optgroup, +select, +textarea, +::file-selector-button { + font: inherit; /* 1 */ + font-feature-settings: inherit; /* 1 */ + font-variation-settings: inherit; /* 1 */ + letter-spacing: inherit; /* 1 */ + color: inherit; /* 1 */ + background: transparent; /* 2 */ +} + +/* + Reset the default inset border style for form controls to solid. +*/ + +input:where(:not([type='button'], [type='reset'], [type='submit'])), +select, +textarea { + border: 1px solid; +} + +/* + Correct the inability to style the border radius in iOS Safari. +*/ +button, +input:where([type='button'], [type='reset'], [type='submit']), +::file-selector-button { + appearance: button; +} + +/* + Use the modern Firefox focus style for all focusable elements. +*/ + +:-moz-focusring { + outline: auto; +} + +/* + Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) +*/ + +:-moz-ui-invalid { + box-shadow: none; +} + +/* + Add the correct vertical alignment in Chrome and Firefox. +*/ + +progress { + vertical-align: baseline; +} + +/* + Correct the cursor style of increment and decrement buttons in Safari. +*/ + +::-webkit-inner-spin-button, +::-webkit-outer-spin-button { + height: auto; +} + +/* + Remove the inner padding in Chrome and Safari on macOS. +*/ + +::-webkit-search-decoration { + -webkit-appearance: none; +} + +/* + Add the correct display in Chrome and Safari. +*/ + +summary { + display: list-item; +} + +/* + Make lists unstyled by default. +*/ + +ol, +ul, +menu { + list-style: none; +} + +/* + Prevent resizing textareas horizontally by default. +*/ + +textarea { + resize: vertical; +} + +/* + 1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) + 2. Set the default placeholder color to a semi-transparent version of the current text color. +*/ + +::placeholder { + opacity: 1; /* 1 */ + color: color-mix(in srgb, currentColor 50%, transparent); /* 2 */ +} + +/* + 1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) + 2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) + This can trigger a poorly considered lint error in some tools but is included by design. +*/ + +img, +svg, +video, +canvas, +audio, +iframe, +embed, +object { + display: block; /* 1 */ + vertical-align: middle; /* 2 */ +} + +/* + Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) +*/ + +img, +video { + max-width: 100%; + height: auto; +} + +/* + Make elements with the HTML hidden attribute stay hidden by default. +*/ + +[hidden] { + display: none !important; +} +"#; + +mod css; +mod util; + +pub fn main() { + let throughput = util::Throughput::compute(100_000, PREFLIGHT.len(), || { + _ = black_box(css::parse(PREFLIGHT.as_bytes())); + }); + + eprintln!("css::parse: {:}", throughput); + + let input = &b"var(--a, 0 0 1px rgb(0, 0, 0)), 0 0 1px rgb(0, 0, 0), var(--a, 0 0 1px rgb(0, 0, 0)), 0 0 1px rgb(0, 0, 0), ".repeat(500)[..]; + + let throughput = util::Throughput::compute(100_000, input.len(), || { + _ = black_box(util::segment(input, b',')); + }); + + eprintln!("util::segment: {:}", throughput); + + let input = &b"s\\o\\m\\e\\_\\identifier_token ".repeat(500)[..]; + + let throughput = util::Throughput::compute(100_000_000, input.len(), || { + _ = black_box(css::syntax::read_ident_token(input)); + }); + + eprintln!("css::syntax::read_ident_token: {:}", throughput); + + let input = &b"@media (min-width: 1280px) ".repeat(500)[..]; + + let throughput = util::Throughput::compute(2_000_000, input.len(), || { + _ = black_box(css::parser::parse_rule_header(input)); + }); + + eprintln!("css::parser::parse_rule_header (at rule): {:}", throughput); + + let input = &b".foo.bar:is(.baz:has(.qux:where(.foo + .bar))) + .thing::before".repeat(500)[..]; + + let throughput = util::Throughput::compute(2_000_000, input.len(), || { + _ = black_box(css::parser::parse_rule_header(input)); + }); + + eprintln!("css::parser::parse_rule_header (style rule): {:}", throughput); + + let input = &b"[content-start]_calc(100%-1px)_[content-end]_minmax(1rem,1fr)".repeat(500)[..]; + + let throughput = util::Throughput::compute(100_000, input.len(), || { + _ = black_box(util::convert_underscores_to_whitespace(input)); + }); + + eprintln!("util::convert_underscores_to_whitespace: {:}", throughput); +} From 86e824aeee3ed35f5fc23779a8d31dd96b84ab13 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Sat, 12 Oct 2024 19:36:11 -0400 Subject: [PATCH 16/19] wip: candidate wip wip: candidate --- crates/core/src/engine/candidate.rs | 329 ++++++++++++++++++++++++++++ crates/core/src/engine/mod.rs | 10 + crates/core/src/lib.rs | 1 + 3 files changed, 340 insertions(+) create mode 100644 crates/core/src/engine/candidate.rs create mode 100644 crates/core/src/engine/mod.rs diff --git a/crates/core/src/engine/candidate.rs b/crates/core/src/engine/candidate.rs new file mode 100644 index 000000000..a0527d2dc --- /dev/null +++ b/crates/core/src/engine/candidate.rs @@ -0,0 +1,329 @@ +use std::{iter::empty, rc::Rc, sync::Arc}; +use bstr::ByteSlice; + +use crate::util::segment; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Candidate { + raw: Vec, + important: bool, + variants: Vec, + utilities: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Variant { + /// Arbitrary variants are variants that take a selector and generate a variant + /// on the fly. + /// + /// E.g.: `[&_p]` + Arbitrary { + selector: Vec, + + /// If true, it can be applied as a child of a compound variant + compounds: bool, + + /// Whether or not the selector is a relative selector + /// @see https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_selectors/Selector_structure#relative_selector + relative: bool, + }, + + /// Static variants are variants that don't take any arguments. + /// + /// E.g.: `hover` + Static { + root: Vec, + compounds: bool, + }, + + /// Functional variants are variants that can take an argument. The argument is + /// either a named variant value or an arbitrary variant value. + /// + /// E.g.: + /// + /// - `aria-disabled` + /// - `aria-[disabled]` + /// - `@container-size` -> @container, with named value `size` + /// - `@container-[inline-size]` -> @container, with arbitrary variant value `inline-size` + /// - `@container` -> @container, with no value + Functional { + root: Vec, + value: Option, + modifier: Option, + + /// If true, it can be applied as a child of a compound variant + compounds: bool, + }, + + /// Compound variants are variants that take another variant as an argument. + /// + /// E.g.: + /// + /// - `has-[&_p]` + /// - `group-*` + /// - `peer-*` + Compound { + root: Vec, + variant: Box, + modifier: Option, + + /// If true, it can be applied as a child of a compound variant + compounds: bool, + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Utility { + /// Arbitrary candidates are candidates that register utilities on the fly with + /// a property and a value. + /// + /// Examples: + /// - `[color:red]` + /// - `[color:red]/50` + /// - `[color:red]/50!` + Arbitrary { + property: Vec, + value: Vec, + modifier: Option, + }, + + /// Static candidates are candidates that don't take any arguments. + /// + /// Examples: + /// - `underline` + /// - `flex` + Static { + root: Vec, + }, + + /// Static candidates are candidates that don't take any arguments. + /// + /// Examples: + /// - `underline` + /// - `flex` + Functional { + root: Vec, + value: Option, + modifier: Option, + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum UtilityValue { + Arbitrary { + /// bg-[color:--my-color] + /// ^^^^^ + data_type: Option>, + + /// bg-[#0088cc] + /// ^^^^^^^ + /// bg-[var(--my_variable)] + /// ^^^^^^^^^^^^^^^^^^ + value: Vec, + }, + + Named { + /// bg-red-500 + /// ^^^^^^^ + /// + /// w-1/2 + /// ^ + value: Vec, + + /// w-1/2 + /// ^^^ + fraction: Option> + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum VariantValue { + Arbitrary { + value: Vec, + }, + + Named { + value: Vec, + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CandidateModifier { + Arbitrary { + /// bg-red-500/[50%] + /// ^^^ + value: Vec + }, + + Named { + /// bg-red-500/50 + /// ^^ + value: Vec, + } +} + +pub struct DesignSystem { + prefix: Option>, + utilities: Utilities, +} + +pub struct Utilities { + // +} + +impl Utilities { + pub fn has(&self, utility: &[u8]) -> bool { + false + } +} + +pub fn parse_candidate(input: &[u8], design: Rc) -> Option { + let raw = input.to_vec(); + + // hover:focus:underline + // ^^^^^ ^^^^^^ -> Variants + // ^^^^^^^^^ -> Base + let mut raw_variants = segment(input, b':'); + + if let Some(prefix) = &design.prefix { + let Some(new_variants) = raw_variants.strip_prefix(prefix) else { + return None; + }; + + if new_variants.is_empty() { + return None; + } + + raw_variants = new_variants.to_vec(); + } + + // Safety: At this point it is safe to use TypeScript's non-null assertion + // operator because even if the `input` was an empty string, splitting an + // empty string by `:` will always result in an array with at least one + // element. + let mut base = raw_variants.pop().unwrap(); + + let mut parsed_variants: Vec = Vec::with_capacity(raw_variants.len()); + + for i in (0..raw_variants.len()).rev() { + let parsed_variant = parse_variant(raw_variants[i]); + if parsed_variant.is_none() { + return None; + } + + parsed_variants.push(parsed_variant.unwrap()) + } + + let mut important = false; + let mut negative = false; + + // Candidates that end with an exclamation mark are the important version with + // higher specificity of the non-important candidate, e.g. `mx-4!`. + if let Some(new_base) = base.strip_suffix(b"!") { + important = true; + base = new_base; + } + + // Legacy syntax with leading `!`, e.g. `!mx-4`. + else if let Some(new_base) = base.strip_prefix(b"!") { + important = true; + base = new_base; + } + + // Candidates that start with a dash are the negative versions of another + // candidate, e.g. `-mx-4`. + if let Some(new_base) = base.strip_prefix(b"-") { + negative = true; + base = new_base; + } + + let mut utilities: Vec = vec![]; + + // Check for an exact match of a static utility first as long as it does not + // look like an arbitrary value. + if design.utilities.has(base) && !base.contains(&b'[') { + utilities.push(Utility::Static { + root: base.to_vec(), + }); + } + + // Figure out the new base and the modifier segment if present. + // + // E.g.: + // + // ``` + // bg-red-500/50 + // ^^^^^^^^^^ -> Base without modifier + // ^^ -> Modifier segment + // ``` + let parts = segment(base, b'/'); + + // If there's more than one modifier, the utility is invalid. + // + // E.g.: + // + // - `bg-red-500/50/50` + if parts.len() > 2 { + return None; + } + + // let [baseWithoutModifier, modifierSegment = null, additionalModifier] = segment(base, '/') + + + Some(Candidate { + raw: raw.to_vec(), + important, + variants: parsed_variants.to_vec(), + utilities, + }) +} + +fn parse_variant(input: &[u8]) -> Option { + return Some(Variant::Static { root: vec![], compounds: false }) +} + +fn parse_arbitrary_property(base: &[u8]) -> Option { + // Arbitrary properties must start and end with square brackets. + let Some(base) = base.strip_prefix(b"[") else { + return None; + }; + + let Some(base) = base.strip_suffix(b"]") else { + return None; + }; + + // The property part of the arbitrary property can only start with a-z + // lowercase or a dash `-` in case of vendor prefixes such as `-webkit-` + // or `-moz-`. + // + // Otherwise, it is an invalid candidate, and skip continue parsing. + if base[0] != b'-' && !(base[0] >= b'a' && base[0] <= b'z') { + return None + } + + // Arbitrary properties consist of a property and a value separated by a + // `:`. If the `:` cannot be found, then it is an invalid candidate, and we + // can skip continue parsing. + // + // Since the property and the value should be separated by a `:`, we can + // also verify that the colon is not the first or last character in the + // candidate, because that would make it invalid as well. + let Some(idx) = base.find(b":") else { + return None; + }; + + if idx == 0 || idx == base.len() - 1 { + return None; + } + + let property = base[..idx].to_vec(); + let value = base[idx+1..].to_vec(); + + // let value = decodeArbitraryValue(base.slice(idx + 1)) + + Some(Utility::Arbitrary { + property, + value, + modifier: None, + }) +} diff --git a/crates/core/src/engine/mod.rs b/crates/core/src/engine/mod.rs new file mode 100644 index 000000000..bccbf9095 --- /dev/null +++ b/crates/core/src/engine/mod.rs @@ -0,0 +1,10 @@ +// mod candidate; + +/// This represents the core logic of: +/// - Parsing a candidate +/// - Matching that against a list of known utilities and known variants +/// - Returning the appropriate "functions" to be called + +struct Engine { + // +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 03a336535..0e7cdbff0 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,6 +1,7 @@ mod compat; mod css; mod util; +mod engine; use css::optimize::optimize_ast; use css::parser::parse; From 3f199d0745d5ca07d07b0052125f6454e9315813 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Sat, 12 Oct 2024 19:36:25 -0400 Subject: [PATCH 17/19] wip: compiler --- crates/core/src/compiler.rs | 18 ++++++++++++++++++ crates/core/src/lib.rs | 1 + 2 files changed, 19 insertions(+) create mode 100644 crates/core/src/compiler.rs diff --git a/crates/core/src/compiler.rs b/crates/core/src/compiler.rs new file mode 100644 index 000000000..05f2eca48 --- /dev/null +++ b/crates/core/src/compiler.rs @@ -0,0 +1,18 @@ +use std::error::Error; + +use crate::css::parser::parse; +use crate::css::ast::Stylesheet; + +struct Compiler { + ast: Stylesheet, +} + +impl Compiler { + fn new(css: &[u8]) -> Result> { + let ast = parse(css)?; + + Ok(Compiler { + ast, + }) + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 0e7cdbff0..d666b6ef8 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,6 +1,7 @@ mod compat; mod css; mod util; +mod compiler; mod engine; use css::optimize::optimize_ast; From a88d3f2043ed22e1f9c09ee1106428fa8e13e3e4 Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Sat, 12 Oct 2024 18:52:48 -0400 Subject: [PATCH 18/19] wip --- packages/tailwindcss/src/css-parser.test.ts | 47 +++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/packages/tailwindcss/src/css-parser.test.ts b/packages/tailwindcss/src/css-parser.test.ts index fa3f122ce..9e4540109 100644 --- a/packages/tailwindcss/src/css-parser.test.ts +++ b/packages/tailwindcss/src/css-parser.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import * as CSS from './css-parser' +import { segment } from './utils/segment' const css = String.raw @@ -1036,3 +1037,49 @@ describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => { }) }) }) + +it.only('wip css-parser on preflight.css', async () => { + const { fileURLToPath } = await import('node:url') + const { readFile } = await import('node:fs/promises') + const currentFolder = fileURLToPath(new URL('..', import.meta.url)) + const cssFile = await readFile(currentFolder + './preflight.css', 'utf-8') + + function formatByteSize(size: number) { + const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] + const unit = 1000 + let i = 0 + while (size > unit) { + size /= unit + i++ + } + return `${size.toFixed(2)} ${units[i]}` + } + + function throughput(iterations: number, memoryBaseline: number, callback: () => void) { + let now = process.hrtime.bigint() + let len = 0 + for (let i = 0; i < iterations; ++i) { + len += CSS.parse(cssFile).length + } + let elapsed = Number(process.hrtime.bigint() - now) / 1e9 + let memorySize = iterations * memoryBaseline + let rate = memorySize / elapsed + + return { rate, elapsed, len } + } + + let t1 = throughput(100_000, cssFile.length, () => CSS.parse(cssFile).length) + console.log(`CSS.parse: ${formatByteSize(t1.rate)}/s over ${t1.elapsed}s`) + + let input = + 'var(--a, 0 0 1px rgb(0, 0, 0)), 0 0 1px rgb(0, 0, 0), var(--a, 0 0 1px rgb(0, 0, 0)), 0 0 1px rgb(0, 0, 0), '.repeat( + 500, + ) + + let t2 = throughput(100_000, input.length, () => segment(input, ',')) + console.log(`segment: ${formatByteSize(t2.rate)}/s over ${t2.elapsed}s`) + + console.log({ t1, t2 }) + + throw new Error('stop here') +}) From f1ec791afdb6439abcfe4e2062a140ad78d1f46a Mon Sep 17 00:00:00 2001 From: Jordan Pittman Date: Sat, 12 Oct 2024 19:36:28 -0400 Subject: [PATCH 19/19] wip: lockfile --- Cargo.lock | 311 +++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 269 insertions(+), 42 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6a17fb136..0d24e0f9d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,21 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + [[package]] name = "aho-corasick" version = "1.1.3" @@ -17,6 +32,21 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +[[package]] +name = "backtrace" +version = "0.3.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets", +] + [[package]] name = "bitflags" version = "2.6.0" @@ -46,6 +76,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "bytes" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "428d9aa8fbc0670b7b8d6030a7fadd0f86151cae55e4dbbece15f3780a3dfaf3" + [[package]] name = "cfg-if" version = "1.0.0" @@ -119,9 +155,9 @@ checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" [[package]] name = "ctor" -version = "0.2.1" +version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990a40740adf249724a6000c0fc4bd574712f50bb17c2d6f6cec837ae2f0ee75" +checksum = "edb49164822f3ee45b17acd4a208cfc1251410cf0cad9a833234c9890774dd9f" dependencies = [ "quote", "syn", @@ -135,9 +171,9 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "either" -version = "1.8.1" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" [[package]] name = "equivalent" @@ -267,6 +303,12 @@ dependencies = [ "wasi", ] +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + [[package]] name = "glob" version = "0.3.1" @@ -309,6 +351,12 @@ version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e087f84d4f86bf4b218b927129862374b72199ae7d8657835f1e89000eea4fb" +[[package]] +name = "hermit-abi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + [[package]] name = "ignore" version = "0.4.23" @@ -343,9 +391,9 @@ checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" [[package]] name = "lazy_static" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" @@ -369,12 +417,34 @@ version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" +[[package]] +name = "lock_api" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +dependencies = [ + "autocfg", + "scopeguard", +] + [[package]] name = "log" version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +[[package]] +name = "lsp-server" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "550446e84739dcaf6d48a4a093973850669e13e8a34d8f8d64851041be267cd9" +dependencies = [ + "crossbeam-channel", + "log", + "serde", + "serde_json", +] + [[package]] name = "matchers" version = "0.1.0" @@ -390,6 +460,34 @@ version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "miniz_oxide" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" +dependencies = [ + "hermit-abi", + "libc", + "log", + "wasi", + "windows-sys 0.52.0", +] + [[package]] name = "napi" version = "2.16.11" @@ -405,9 +503,9 @@ dependencies = [ [[package]] name = "napi-build" -version = "2.0.1" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "882a73d9ef23e8dc2ebbffb6a6ae2ef467c0f18ac10711e4cc59c5485d41df0e" +checksum = "e1c0f5d67ee408a4685b61f5ab7e58605c8ae3f2b4189f0127d804ff13d5560a" [[package]] name = "napi-derive" @@ -458,10 +556,19 @@ dependencies = [ ] [[package]] -name = "once_cell" -version = "1.19.0" +name = "object" +version = "0.36.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +checksum = "aedf0a2d09c573ed1d8d85b30c119153926a2b36dce0ab28322c09a117a4683e" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" [[package]] name = "overload" @@ -470,10 +577,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" [[package]] -name = "pin-project-lite" -version = "0.2.9" +name = "parking_lot" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0a7ae3ac2f1173085d398531c705756c94a4c56843785df85a60c1a0afac116" +checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" [[package]] name = "pin-utils" @@ -501,9 +631,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.86" +version = "1.0.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" +checksum = "b3e4daa0dcf6feba26f985457cdf104d4b4256fc5a09547140f3631bb076b19a" dependencies = [ "unicode-ident", ] @@ -568,14 +698,24 @@ dependencies = [ ] [[package]] -name = "regex" -version = "1.8.3" +name = "redox_syscall" +version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81ca098a9821bd52d6b24fd8b10bd081f47d39c22778cafaa75a2857a62c6390" +checksum = "9b6dfecf2c74bce2466cabf93f6664d6998a69eb21e39f4207930065b27b771f" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38200e5ee88914975b69f657f0801b6f6dccafd44fd9326302a4aaeecfacb1d8" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.7.2", + "regex-automata 0.4.8", + "regex-syntax 0.8.5", ] [[package]] @@ -604,12 +744,6 @@ version = "0.6.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" -[[package]] -name = "regex-syntax" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436b050e76ed2903236f032a59761c1eb99e1b0aead2c257922771dab1fc8c78" - [[package]] name = "regex-syntax" version = "0.8.5" @@ -663,6 +797,12 @@ dependencies = [ "syn", ] +[[package]] +name = "rustc-demangle" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" + [[package]] name = "rustc-hash" version = "2.0.0" @@ -707,10 +847,16 @@ dependencies = [ ] [[package]] -name = "semver" -version = "1.0.17" +name = "scopeguard" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bebd363326d05ec3e2f532ab7660680f3b02130d780c299bca73469d521bc0ed" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" [[package]] name = "serde" @@ -746,13 +892,22 @@ dependencies = [ [[package]] name = "sharded-slab" -version = "0.1.4" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "900fba806f70c630b0a382d0d825e17a0f19fcd059a2ade1ff237bcddf446b31" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" dependencies = [ "lazy_static", ] +[[package]] +name = "signal-hook-registry" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +dependencies = [ + "libc", +] + [[package]] name = "slab" version = "0.4.9" @@ -764,9 +919,19 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.10.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a507befe795404456341dfab10cef66ead4c041f62b8b11bbb92bffe5d0953e0" +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" + +[[package]] +name = "socket2" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] [[package]] name = "syn" @@ -796,9 +961,12 @@ version = "0.1.0" dependencies = [ "bstr", "log", + "memchr", + "memmem", "rstest", "rstest_reuse", "serde_json", + "tinyvec", "wasm-bindgen", ] @@ -821,6 +989,21 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tailwindcss-plugin-example" +version = "0.1.0" +dependencies = [ + "crossbeam-channel", + "log", + "lsp-server", + "mio", + "serde", + "serde_json", + "tailwindcss-core", + "tokio", + "tracing", +] + [[package]] name = "tempfile" version = "3.13.0" @@ -836,14 +1019,58 @@ dependencies = [ [[package]] name = "thread_local" -version = "1.1.7" +version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdd6f064ccff2d6567adcb3873ca630700f00b5ad3f060c25b5dcfd9a4ce152" +checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" dependencies = [ "cfg-if", "once_cell", ] +[[package]] +name = "tinyvec" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "445e881f4f6d382d5f27c034e25eb92edd7c784ceab92a0937db7f2e9471b938" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2b070231665d27ad9ec9b8df639893f46727666c6767db40317fbe920a5d998" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.52.0", +] + +[[package]] +name = "tokio-macros" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "toml_datetime" version = "0.6.8" @@ -924,15 +1151,15 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.9" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b15811caf2415fb889178633e7724bad2509101cde276048e013b9def5e51fa0" +checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" [[package]] name = "unicode-segmentation" -version = "1.10.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] name = "valuable" @@ -1029,11 +1256,11 @@ checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] name = "winapi-util" -version = "0.1.5" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "winapi", + "windows-sys 0.59.0", ] [[package]]