diff --git a/.github/workflows/build_release.yml b/.github/workflows/build_release.yml index cc30112..6f2e436 100644 --- a/.github/workflows/build_release.yml +++ b/.github/workflows/build_release.yml @@ -53,6 +53,13 @@ jobs: with: profile: minimal toolchain: stable + components: clippy,rustfmt + + - name: Print Rust toolchain versions + run: | + rustup show active-toolchain + rustc --version + cargo --version - name: Install llvm-tools-preview run: rustup component add llvm-tools-preview @@ -134,6 +141,13 @@ jobs: profile: minimal toolchain: stable target: ${{ matrix.target }} + components: clippy,rustfmt + + - name: Print Rust toolchain versions + run: | + rustup show active-toolchain + rustc --version + cargo --version - name: Cache Cargo registry uses: actions/cache@v4 @@ -187,6 +201,13 @@ jobs: with: profile: minimal toolchain: stable + components: clippy,rustfmt + + - name: Print Rust toolchain versions + run: | + rustup show active-toolchain + rustc --version + cargo --version - name: Cache Cargo registry uses: actions/cache@v4 diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 050bb1b..6ee3df5 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -21,6 +21,13 @@ jobs: with: profile: minimal toolchain: stable + components: clippy,rustfmt + + - name: Print Rust toolchain versions + run: | + rustup show active-toolchain + rustc --version + cargo --version - name: Cache cargo registry uses: actions/cache@v4 diff --git a/Cargo.lock b/Cargo.lock index 3a39f11..11c6a8a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,21 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "ahash" version = "0.7.8" @@ -107,6 +122,30 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "backtrace-ext" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" +dependencies = [ + "backtrace", +] + [[package]] name = "base64-simd" version = "0.7.0" @@ -216,7 +255,7 @@ dependencies = [ "android-tzdata", "iana-time-zone", "num-traits", - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -250,7 +289,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.87", ] [[package]] @@ -274,7 +313,7 @@ dependencies = [ "encode_unicode", "lazy_static", "libc", - "unicode-width", + "unicode-width 0.1.14", "windows-sys 0.52.0", ] @@ -367,7 +406,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "quote", - "syn 2.0.79", + "syn 2.0.87", ] [[package]] @@ -433,12 +472,12 @@ checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] name = "errno" -version = "0.3.9" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -473,6 +512,12 @@ dependencies = [ "wasi", ] +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + [[package]] name = "glob" version = "0.3.1" @@ -489,11 +534,13 @@ dependencies = [ "indicatif", "lazy_static", "lightningcss", + "miette", "once_cell", "regex", "serde", "serde_json", "tempfile", + "thiserror", ] [[package]] @@ -570,7 +617,7 @@ dependencies = [ "instant", "number_prefix", "portable-atomic", - "unicode-width", + "unicode-width 0.1.14", ] [[package]] @@ -582,6 +629,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -629,9 +682,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.161" +version = "0.2.178" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9489c2807c139ffd9c1794f4af0ebe86a828db53ecdc7fea2111d0fed085d1" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" [[package]] name = "lightningcss" @@ -678,6 +731,12 @@ version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + [[package]] name = "lock_api" version = "0.4.12" @@ -706,12 +765,51 @@ version = "2.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5486aed0026218e61b8a01d5fbd5a0a134649abb71a0e53b7bc088529dced86e" +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "backtrace", + "backtrace-ext", + "cfg-if", + "miette-derive", + "owo-colors", + "supports-color", + "supports-hyperlinks", + "supports-unicode", + "terminal_size", + "textwrap", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.87", +] + [[package]] name = "minimal-lexical" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + [[package]] name = "nom" version = "7.1.3" @@ -737,6 +835,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.20.2" @@ -755,6 +862,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f222829ae9293e33a9f5e9f440c6760a3d450a64affe1846486b140db81c1f4" +[[package]] +name = "owo-colors" +version = "4.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" + [[package]] name = "parcel_selectors" version = "0.27.0" @@ -795,7 +908,7 @@ dependencies = [ "libc", "redox_syscall", "smallvec", - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -869,7 +982,7 @@ dependencies = [ "phf_shared 0.11.2", "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.87", ] [[package]] @@ -1081,6 +1194,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "rustc-demangle" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" + [[package]] name = "rustix" version = "0.38.38" @@ -1090,10 +1209,23 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.4.14", "windows-sys 0.52.0", ] +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.11.0", + "windows-sys 0.59.0", +] + [[package]] name = "ryu" version = "1.0.15" @@ -1129,7 +1261,7 @@ checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.87", ] [[package]] @@ -1182,6 +1314,27 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + +[[package]] +name = "supports-hyperlinks" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91" + +[[package]] +name = "supports-unicode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" + [[package]] name = "syn" version = "1.0.109" @@ -1195,9 +1348,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.79" +version = "2.0.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89132cd0bf050864e1d38dc3bbc07a0eb8e7530af26344d3d2bbbef83499f590" +checksum = "25aa4ce346d03a6dcd68dd8b4010bcb74e54e62c90c573f394c46eae99aba32d" dependencies = [ "proc-macro2", "quote", @@ -1219,10 +1372,30 @@ dependencies = [ "cfg-if", "fastrand", "once_cell", - "rustix", + "rustix 0.38.38", "windows-sys 0.59.0", ] +[[package]] +name = "terminal_size" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" +dependencies = [ + "rustix 1.1.2", + "windows-sys 0.60.2", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "unicode-linebreak", + "unicode-width 0.2.2", +] + [[package]] name = "thiserror" version = "1.0.64" @@ -1240,7 +1413,7 @@ checksum = "08904e7672f5eb876eaaf87e0ce17857500934f4981c4a0ab2b4aa98baac7fc3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.87", ] [[package]] @@ -1264,6 +1437,12 @@ version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "301abaae475aa91687eb82514b328ab47a211a533026cb25fc3e519b86adfc3c" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + [[package]] name = "unicode-segmentation" version = "1.12.0" @@ -1276,6 +1455,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "utf8parse" version = "0.2.2" @@ -1328,7 +1513,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.87", "wasm-bindgen-shared", ] @@ -1350,7 +1535,7 @@ checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.87", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -1367,16 +1552,22 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -1385,7 +1576,16 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", ] [[package]] @@ -1394,14 +1594,31 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "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", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] @@ -1410,48 +1627,96 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "wyz" version = "0.5.1" @@ -1479,5 +1744,5 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.87", ] diff --git a/Cargo.toml b/Cargo.toml index a43291e..06a3045 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,10 +34,12 @@ grimoire_css_color_toolkit = "1.0.0" indicatif = "0.17.8" lazy_static = "1.5.0" lightningcss = { version = "1.0.0-alpha.59", features = ["browserslist"] } +miette = { version = "7.2.0", features = ["fancy"] } once_cell = "1.20" regex = "1.11.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +thiserror = "1.0.61" [dev-dependencies] tempfile = "3.13.0" diff --git a/error_scenarios/.browserslistrc b/error_scenarios/.browserslistrc new file mode 100644 index 0000000..496d1ef --- /dev/null +++ b/error_scenarios/.browserslistrc @@ -0,0 +1 @@ +defaults \ No newline at end of file diff --git a/error_scenarios/1.html b/error_scenarios/1.html new file mode 100644 index 0000000..ccbea38 --- /dev/null +++ b/error_scenarios/1.html @@ -0,0 +1 @@ +
diff --git a/error_scenarios/2.html b/error_scenarios/2.html new file mode 100644 index 0000000..23998ba --- /dev/null +++ b/error_scenarios/2.html @@ -0,0 +1 @@ +
diff --git a/error_scenarios/3.html b/error_scenarios/3.html new file mode 100644 index 0000000..02b496f --- /dev/null +++ b/error_scenarios/3.html @@ -0,0 +1 @@ +
diff --git a/error_scenarios/4.html b/error_scenarios/4.html new file mode 100644 index 0000000..860b17f --- /dev/null +++ b/error_scenarios/4.html @@ -0,0 +1 @@ +
diff --git a/error_scenarios/5.html b/error_scenarios/5.html new file mode 100644 index 0000000..583da83 --- /dev/null +++ b/error_scenarios/5.html @@ -0,0 +1 @@ +
diff --git a/error_scenarios/6.html b/error_scenarios/6.html new file mode 100644 index 0000000..2d70f06 --- /dev/null +++ b/error_scenarios/6.html @@ -0,0 +1 @@ +
diff --git a/error_scenarios/grimoire/config/grimoire.config.json b/error_scenarios/grimoire/config/grimoire.config.json new file mode 100644 index 0000000..bab9c00 --- /dev/null +++ b/error_scenarios/grimoire/config/grimoire.config.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://raw.githubusercontent.com/persevie/grimoire-css/main/src/core/config/config-schema.json", + "variables": null, + "scrolls": [{ "name": "complex-card", "spells": ["h=$", "c=$", "w=$"] }], + "projects": [ + { + "projectName": "1", + "inputPaths": ["1.html"], + "outputDirPath": ".", + "singleOutputFileName": "1.css" + }, + { + "projectName": "2", + "inputPaths": ["2.html"], + "outputDirPath": ".", + "singleOutputFileName": "2.css" + }, + { + "projectName": "3", + "inputPaths": ["3.html"], + "outputDirPath": ".", + "singleOutputFileName": "3.css" + }, + { + "projectName": "4", + "inputPaths": ["4.html"], + "outputDirPath": ".", + "singleOutputFileName": "4.css" + }, + { + "projectName": "5", + "inputPaths": ["5.html"], + "outputDirPath": ".", + "singleOutputFileName": "5.css" + }, + { + "projectName": "6", + "inputPaths": ["6.html"], + "outputDirPath": ".", + "singleOutputFileName": "6.css" + } + ], + "shared": null, + "critical": null, + "lock": null +} diff --git a/src/commands/shorten.rs b/src/commands/shorten.rs index cf214f6..85020f2 100644 --- a/src/commands/shorten.rs +++ b/src/commands/shorten.rs @@ -43,9 +43,14 @@ pub fn shorten(current_dir: &Path) -> Result<(), GrimoireCssError> { let mut new_parts = Vec::with_capacity(parts.len()); let mut changed = false; for part in parts { - if let Ok(Some(spell)) = - Spell::new(part, &config.shared_spells, &config.scrolls) - { + if let Ok(Some(spell)) = Spell::new( + part, + &config.shared_spells, + &config.scrolls, + (0, 0), + None, + None, + ) { if let Some(short) = get_shorten_component(&spell.component) { let short_part = part.replacen(&spell.component, short, 1); if short_part != part { @@ -68,9 +73,14 @@ pub fn shorten(current_dir: &Path) -> Result<(), GrimoireCssError> { replaced_count += count; } } - } else if let Ok(Some(spell)) = - Spell::new(raw_spell, &config.shared_spells, &config.scrolls) - && let Some(short) = get_shorten_component(&spell.component) + } else if let Ok(Some(spell)) = Spell::new( + raw_spell, + &config.shared_spells, + &config.scrolls, + (0, 0), + None, + None, + ) && let Some(short) = get_shorten_component(&spell.component) { let short_spell = raw_spell.replacen(&spell.component, short, 1); if raw_spell != &short_spell && new_content.contains(raw_spell) { diff --git a/src/core/css_builder/css_builder_base.rs b/src/core/css_builder/css_builder_base.rs index 8583dfe..36dc0f7 100644 --- a/src/core/css_builder/css_builder_base.rs +++ b/src/core/css_builder/css_builder_base.rs @@ -50,10 +50,10 @@ impl<'a> CssBuilder<'a> { /// /// Returns a `GrimoireCSSError` if CSS generation fails. pub fn combine_spells_to_css(&self, spells: &[Spell]) -> Result, GrimoireCssError> { - let mut base_rules = Vec::new(); - let mut media_queries = Vec::new(); + let mut base_rules: Vec<(String, usize)> = Vec::new(); + let mut media_queries: Vec<(String, usize)> = Vec::new(); - for spell in spells { + for (spell_index, spell) in spells.iter().enumerate() { match &spell.scroll_spells { Some(ss) if !ss.is_empty() => { let mut local_scroll_css_vec = Vec::new(); @@ -89,27 +89,26 @@ impl<'a> CssBuilder<'a> { self.css_generator .wrap_base_css_with_media_query(&spell.area, &combined_css) }; - if wrapped_css.trim_start().starts_with("@media") { - media_queries.push(wrapped_css); + media_queries.push((wrapped_css, spell_index)); } else { - base_rules.push(wrapped_css); + base_rules.push((wrapped_css, spell_index)); } for add_css in local_scroll_additional_css_vec { - base_rules.push(add_css); + base_rules.push((add_css, spell_index)); } } _ => { if let Some(css) = self.css_generator.generate_css(spell)? { if css.0.trim_start().starts_with("@media") { - media_queries.push(css.0); + media_queries.push((css.0, spell_index)); } else { - base_rules.push(css.0); + base_rules.push((css.0, spell_index)); } if let Some(additional_css) = css.2 { - base_rules.push(additional_css); + base_rules.push((additional_css, spell_index)); } } } @@ -123,15 +122,29 @@ impl<'a> CssBuilder<'a> { .and_then(|cap| cap.get(1)) .and_then(|m| m.as_str().parse::().ok()) } - match (extract_min_width(a), extract_min_width(b)) { + match (extract_min_width(&a.0), extract_min_width(&b.0)) { (Some(aw), Some(bw)) => aw.cmp(&bw), (Some(_), None) => std::cmp::Ordering::Less, (None, Some(_)) => std::cmp::Ordering::Greater, - (None, None) => a.cmp(b), + (None, None) => a.0.cmp(&b.0), } }); base_rules.extend(media_queries); - Ok(base_rules) + + // Validate the combined output once (fast path). + if let Err(e) = self.validate_joined_css(&base_rules) { + if let Some((spell_index, rule_error)) = self.find_first_invalid_rule(&base_rules) { + return Err(self.create_compile_error(&spells[spell_index], rule_error)); + } + + // Fallback: no rule isolated (should be rare), attach to first spell if available. + if let Some(first) = spells.first() { + return Err(self.create_compile_error(first, e)); + } + return Err(e); + } + + Ok(base_rules.into_iter().map(|(css, _)| css).collect()) } /// Optimizes and minifies CSS. @@ -146,4 +159,69 @@ impl<'a> CssBuilder<'a> { pub fn optimize_css(&self, raw_css: &str) -> Result { self.optimizer.optimize(raw_css) } + + fn create_compile_error(&self, spell: &Spell, error: GrimoireCssError) -> GrimoireCssError { + GrimoireCssError::CompileError { + message: format!("Invalid CSS generated: {}", error), + span: spell.span, + label: "This spell generated invalid CSS".to_string(), + help: Some( + "This usually means the spell value is not valid CSS after Grimoire transformations.\n\ +If you intended spaces inside a value, encode them as '_' (underscores)." + .to_string(), + ), + source_file: spell.source.clone(), + } + } + + fn validate_joined_css(&self, rules: &[(String, usize)]) -> Result<(), GrimoireCssError> { + if rules.is_empty() { + return Ok(()); + } + let mut joined = String::new(); + for (css, _) in rules { + joined.push_str(css); + } + self.optimizer.validate(&joined) + } + + fn validate_rules_slice(&self, rules: &[(String, usize)]) -> Result<(), GrimoireCssError> { + if rules.is_empty() { + return Ok(()); + } + let mut joined = String::new(); + for (css, _) in rules { + joined.push_str(css); + } + self.optimizer.validate(&joined) + } + + /// Returns the first invalid rule in source order (by spell index), if any. + fn find_first_invalid_rule( + &self, + rules: &[(String, usize)], + ) -> Option<(usize, GrimoireCssError)> { + if rules.is_empty() { + return None; + } + + // If the entire set validates, nothing to isolate. + if self.validate_rules_slice(rules).is_ok() { + return None; + } + + if rules.len() == 1 { + let rule_error = self.optimizer.validate(&rules[0].0).err()?; + return Some((rules[0].1, rule_error)); + } + + let mid = rules.len() / 2; + let (left, right) = rules.split_at(mid); + + if self.validate_rules_slice(left).is_err() { + return self.find_first_invalid_rule(left); + } + + self.find_first_invalid_rule(right) + } } diff --git a/src/core/css_builder/css_builder_fs.rs b/src/core/css_builder/css_builder_fs.rs index 4d5fea6..b9b2467 100644 --- a/src/core/css_builder/css_builder_fs.rs +++ b/src/core/css_builder/css_builder_fs.rs @@ -13,7 +13,8 @@ use crate::{ buffer::add_message, core::{ ConfigFs, ConfigFsCssCustomProperties, CssOptimizer, GrimoireCssError, - build_info::BuildInfo, file_tracker::FileTracker, parser::ParserFs, spell::Spell, + build_info::BuildInfo, file_tracker::FileTracker, parser::ParserFs, + source_file::SourceFile, spell::Spell, }, }; use regex::Regex; @@ -21,6 +22,7 @@ use std::{ collections::HashSet, fs, path::{Path, PathBuf}, + sync::Arc, }; use super::CssBuilder; @@ -82,35 +84,63 @@ impl<'a> CssBuilderFs<'a> { .unwrap_or_else(|| self.current_dir.join("grimoire/dist")); if let Some(single_output_file_name) = &project.single_output_file_name { - let classes = self + let parsing_results = self .parser .collect_classes_single_output(&project.input_paths)?; let bundle_output_full_path = project_output_dir_path.join(single_output_file_name); - let spells = Spell::generate_spells_from_classes( - classes, - &self.config.shared_spells, - &self.config.scrolls, - )?; + let mut all_spells = Vec::new(); + let mut seen_spells = std::collections::HashSet::new(); + + for (file_path, content, classes) in parsing_results { + let source = Arc::new(SourceFile::new( + Some(file_path.clone()), + file_path.to_string_lossy().to_string(), + content, + )); + let spells = Spell::generate_spells_from_classes( + classes, + &self.config.shared_spells, + &self.config.scrolls, + Some(file_path), + Some(source), + )?; + + for spell in spells { + if seen_spells.insert(spell.clone()) { + all_spells.push(spell); + } + } + } project_build_info.push(BuildInfo { file_path: bundle_output_full_path, - spells, + spells: all_spells, }); } else { - let classes = self.parser.collect_classes_multiple_output( + let parsing_results = self.parser.collect_classes_multiple_output( &project.input_paths, &project_output_dir_path, )?; - for (file_path, classes) in classes { + for (output_file_path, source_path, content, classes) in parsing_results { + let source = Arc::new(SourceFile::new( + Some(source_path.clone()), + source_path.to_string_lossy().to_string(), + content, + )); let spells = Spell::generate_spells_from_classes( classes, &self.config.shared_spells, &self.config.scrolls, + Some(source_path), + Some(source), )?; - project_build_info.push(BuildInfo { file_path, spells }); + project_build_info.push(BuildInfo { + file_path: output_file_path, + spells, + }); } } } @@ -379,9 +409,14 @@ impl<'a> CssBuilderFs<'a> { ))); } } - } else if let Some(spell) = - Spell::new(item, &self.config.shared_spells, &self.config.scrolls)? - { + } else if let Some(spell) = Spell::new( + item, + &self.config.shared_spells, + &self.config.scrolls, + (0, 0), + None, + None, + )? { spells.push(spell); } } @@ -463,6 +498,10 @@ mod tests { fn optimize(&self, css: &str) -> Result { Ok(css.to_string() + "_optimized") } + + fn validate(&self, _raw_css: &str) -> Result<(), GrimoireCssError> { + Ok(()) + } } fn create_test_config() -> ConfigFs { @@ -489,6 +528,9 @@ mod tests { let build_info = BuildInfo { file_path: PathBuf::from("test_output.css"), spells: vec![Spell { + file_path: None, + span: (0, 0), + source: None, raw_spell: "d=grid".to_string(), component: "display".to_string(), component_target: "grid".to_string(), @@ -515,6 +557,9 @@ mod tests { let builder = CssBuilderFs::new(&config, current_dir, &optimizer).unwrap(); let spells = vec![Spell { + file_path: None, + span: (0, 0), + source: None, raw_spell: "d=grid".to_string(), component: "display".to_string(), component_target: "grid".to_string(), diff --git a/src/core/css_builder/css_builder_in_memory.rs b/src/core/css_builder/css_builder_in_memory.rs index b7833ba..e488fb3 100644 --- a/src/core/css_builder/css_builder_in_memory.rs +++ b/src/core/css_builder/css_builder_in_memory.rs @@ -4,10 +4,12 @@ //! and is suitable for environments where file I/O is not desired. use std::collections::HashSet; +use std::sync::Arc; use crate::core::{ CssOptimizer, GrimoireCssError, compiled_css::CompiledCssInMemory, - config::config_in_memory::ConfigInMemory, parser::Parser, spell::Spell, + config::config_in_memory::ConfigInMemory, parser::Parser, source_file::SourceFile, + spell::Spell, }; use super::CssBuilder; @@ -63,11 +65,15 @@ impl<'a> CssBuilderInMemory<'a> { self.parser .collect_candidates(&content, &mut class_names, &mut seen_class_names)?; + let source = Arc::new(SourceFile::new(None, project.name.clone(), content)); + // Generate spells using empty shared_spells set since we're working in memory let spells = Spell::generate_spells_from_classes( class_names, &HashSet::new(), &self.config.scrolls, + None, + Some(source), )?; // Combine spells into CSS @@ -97,6 +103,10 @@ mod tests { fn optimize(&self, css: &str) -> Result { Ok(css.to_string()) } + + fn validate(&self, _css: &str) -> Result<(), GrimoireCssError> { + Ok(()) + } } #[test] diff --git a/src/core/css_generator/color_functions.rs b/src/core/css_generator/color_functions.rs index b272845..ea17559 100644 --- a/src/core/css_generator/color_functions.rs +++ b/src/core/css_generator/color_functions.rs @@ -45,21 +45,62 @@ static SPELL_COLOR_FUNCTIONS: &[(&str, SpellColorFunc)] = &[ /// * `Some(String)` containing the resulting color in hex form (e.g., `"#808080"`), /// if parsing and the color transformation succeed. /// * `None` if the string is not in a valid format, or the color transformation failed. -pub fn try_handle_color_function(adapted_target: &str) -> Option { - let (func_name, args_str) = parse_function_call(adapted_target)?; - // Split arguments by spaces. - let args: Vec<&str> = args_str.split(' ').map(|s| s.trim()).collect(); +pub fn try_handle_color_function( + adapted_target: &str, +) -> Result, crate::core::GrimoireCssError> { + let Some((func_name, args_str)) = parse_function_call(adapted_target) else { + return Ok(None); + }; - // Find the corresponding handler. - if let Some((_, handler)) = SPELL_COLOR_FUNCTIONS + let Some((_, handler)) = SPELL_COLOR_FUNCTIONS .iter() .find(|(name, _)| *name == func_name) - { - let result_color = handler(&args)?; - Some(result_color.to_hex_string()) + else { + return Ok(None); + }; + + let args: Vec<&str> = if args_str.is_empty() { + Vec::new() } else { - None + args_str + .split(' ') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .collect() + }; + + if let Some(result_color) = handler(&args) { + return Ok(Some(result_color.to_hex_string())); } + + let expected_usage = match func_name { + "g-grayscale" => "g-grayscale(color)", + "g-complement" => "g-complement(color)", + "g-invert" => "g-invert(color [weight])", + "g-mix" => "g-mix(color1 color2 weight)", + "g-adjust-hue" => "g-adjust-hue(color degrees)", + "g-adjust-color" => { + "g-adjust-color(color [red_delta green_delta blue_delta hue_delta sat_delta light_delta alpha_delta])" + } + "g-change-color" => "g-change-color(color [red green blue hue sat light alpha])", + "g-scale-color" => { + "g-scale-color(color [red_scale green_scale blue_scale saturation_scale lightness_scale alpha_scale])" + } + "g-rgba" => "g-rgba(color alpha)", + "g-lighten" => "g-lighten(color amount)", + "g-darken" => "g-darken(color amount)", + "g-saturate" => "g-saturate(color amount)", + "g-desaturate" => "g-desaturate(color amount)", + "g-opacify" => "g-opacify(color amount)", + "g-fade-in" => "g-fade-in(color amount)", + "g-transparentize" => "g-transparentize(color amount)", + "g-fade-out" => "g-fade-out(color amount)", + _ => "(see docs)", + }; + + Err(crate::core::GrimoireCssError::InvalidInput(format!( + "Invalid arguments for Grimoire color function '{func_name}'.\nExpected: {expected_usage}\nGot: '{adapted_target}'" + ))) } /// Parses a string like `"g-func(arg1 arg2)"` and returns a tuple `( "g-func", "arg1 arg2" )`. @@ -356,8 +397,11 @@ mod tests { /// A helper to compare Option equality with Some("#rrggbb"). /// This is just to reduce boilerplate in our tests. - fn assert_hex_eq(got: Option, expected_hex: &str) { - assert_eq!(got, Some(expected_hex.to_string())); + fn assert_hex_eq( + got: Result, crate::core::GrimoireCssError>, + expected_hex: &str, + ) { + assert_eq!(got.unwrap(), Some(expected_hex.to_string())); } #[test] @@ -405,13 +449,13 @@ mod tests { #[test] fn test_invalid_spell_function() { let res = try_handle_color_function("g-unknown(#fff)"); - assert_eq!(res, None); + assert_eq!(res.unwrap(), None); } #[test] fn test_invalid_args() { // e.g. "g-grayscale()" with no args let res = try_handle_color_function("g-grayscale()"); - assert_eq!(res, None); + assert!(res.is_err()); } } diff --git a/src/core/css_generator/css_generator_base.rs b/src/core/css_generator/css_generator_base.rs index 7f830d0..cdc4c40 100644 --- a/src/core/css_generator/css_generator_base.rs +++ b/src/core/css_generator/css_generator_base.rs @@ -18,7 +18,6 @@ //! The module also includes internal helper functions to manage specific CSS-related tasks such as //! unit stripping, handling of regex patterns, and combining base CSS with media queries. -use crate::buffer::add_message; use crate::core::GrimoireCssError; use crate::core::animations::ANIMATIONS; use crate::core::component::get_css_property; @@ -92,14 +91,73 @@ impl<'a> CssGenerator<'a> { /// * `Ok(Some(String, String))` 0: containing the generated CSS string if the spell's component is recognized; 1: css class name /// * `Ok(None)` if the spell's component is not recognized. /// * `Err(GrimoireCSSError)` if there is an error during CSS generation. + fn create_compile_error(&self, spell: &Spell, error: GrimoireCssError) -> GrimoireCssError { + if let GrimoireCssError::InvalidSpellFormat { message, help, .. } = error { + return GrimoireCssError::InvalidSpellFormat { + message, + span: spell.span, + label: "Error in this spell".to_string(), + help, + source_file: spell.source.clone(), + }; + } + + if let GrimoireCssError::CompileError { + message, + label, + help, + .. + } = error + { + return GrimoireCssError::CompileError { + message, + span: spell.span, + label, + help, + source_file: spell.source.clone(), + }; + } + + if let GrimoireCssError::InvalidInput(msg) = &error + && msg.starts_with("Unknown animation") + { + return GrimoireCssError::CompileError { + message: format!("Invalid input: {msg}"), + span: spell.span, + label: "Error in this spell".to_string(), + help: Some( + "The animation name is not known.\n\ +\ +Fix options:\n\ +- Use a built-in animation name supported by Grimoire CSS\n\ +- Or define a custom animation in config -> custom_animations\n" + .to_string(), + ), + source_file: spell.source.clone(), + }; + } + + let message = error.to_string(); + + GrimoireCssError::CompileError { + message, + span: spell.span, + label: "Error in this spell".to_string(), + help: None, + source_file: spell.source.clone(), + } + } + pub fn generate_css(&self, spell: &Spell) -> Result, GrimoireCssError> { // generate css class name - let css_class_name = self.generate_css_class_name( - &spell.raw_spell, - &spell.effects, - &spell.focus, - spell.with_template, - )?; + let css_class_name = self + .generate_css_class_name( + &spell.raw_spell, + &spell.effects, + &spell.focus, + spell.with_template, + ) + .map_err(|e| self.create_compile_error(spell, e))?; let component_str = spell.component.as_str(); @@ -114,13 +172,17 @@ impl<'a> CssGenerator<'a> { match css_property { Some(css_property) => { // adapt target - let adapted_target = self.adapt_targets(&spell.component_target, self.variables)?; + let adapted_target = self + .adapt_targets(&spell.component_target, self.variables) + .map_err(|e| self.create_compile_error(spell, e))?; // generate base css without any media queries (except for the mrs function) - let (base_css, additional_css) = self.generate_base_and_additional_css( - &adapted_target, - &css_class_name.0, - css_property, - )?; + let (base_css, additional_css) = self + .generate_base_and_additional_css( + &adapted_target, + &css_class_name.0, + css_property, + ) + .map_err(|e| self.create_compile_error(spell, e))?; if !spell.area.is_empty() { return Ok(Some(( @@ -212,17 +274,43 @@ impl<'a> CssGenerator<'a> { .map(|c| match c { '!' | '"' | '#' | '$' | '%' | '&' | '\'' | '(' | ')' | '*' | '+' | ',' | '.' | '/' | ':' | ';' | '<' | '=' | '>' | '?' | '@' | '[' | '\\' | ']' | '^' | '_' - | '`' | '{' | '|' | '}' | '~' => format!("\\{c}"), + | '`' | '{' | '|' | '}' | '~' => Ok(format!("\\{c}")), ' ' => { - add_message("HTML does not support spaces. To separate values use underscore ('_') instead".to_string()); - c.to_string() + // IMPORTANT: + // - In HTML attributes, spaces are class separators, not part of a single class token. + // - If a user tries to encode a value that contains spaces (e.g. calc(100vh - 50px)), + // the correct Grimoire convention is to use underscores instead: calc(100vh_-_50px). + // + // We intentionally return a "context-carrying" error variant here. The outer layer + // (generate_css/create_compile_error) will attach the actual source and span. + Err(GrimoireCssError::CompileError { + message: "Spaces are not allowed inside a single spell token.".to_string(), + span: (0, 0), + label: "Error in this spell".to_string(), + help: Some(format!( + "You likely wrote a value with spaces inside a class attribute (HTML treats spaces as class separators).\n\ +Fix: replace spaces with '_' inside the value, e.g.:\n\ + h=calc(100vh - 50px) -> h=calc(100vh_-_50px)\n\n\ +Offending spell: '{class_name}'" + )), + source_file: None, + }) } - _ => c.to_string(), + _ => Ok(c.to_string()), }) - .collect::(); + .collect::>()?; if escaped.is_empty() { - return Err(GrimoireCssError::InvalidSpellFormat(class_name.to_string())); + return Err(GrimoireCssError::CompileError { + message: format!("Empty spell token: '{class_name}'"), + span: (0, 0), + label: "Error in this spell".to_string(), + help: Some( + "Spell tokens must not be empty. Check for extra spaces or malformed templates in class/className." + .to_string(), + ), + source_file: None, + }); } Ok(escaped) @@ -305,7 +393,7 @@ impl<'a> CssGenerator<'a> { self.handle_animation_name(adapted_target, css_class_name) } _ => { - if let Some(css_str) = try_handle_color_function(adapted_target) { + if let Some(css_str) = try_handle_color_function(adapted_target)? { self.handle_generic_css(&css_str, css_class_name, property) } else { self.handle_generic_css(adapted_target, css_class_name, property) @@ -347,9 +435,10 @@ impl<'a> CssGenerator<'a> { return Ok((base_css, Some(keyframes))); } - Err(GrimoireCssError::InvalidSpellFormat( - adapted_target.to_string(), - )) + Err(GrimoireCssError::InvalidInput(format!( + "Unknown animation: {}", + adapted_target + ))) } /// Handles CSS generation for the `animation` property. @@ -1106,6 +1195,9 @@ mod tests { let generator = CssGenerator::new(&config.variables, &config.custom_animations).unwrap(); let spell = Spell { + file_path: None, + span: (0, 0), + source: None, raw_spell: "bg-c=pink".to_string(), component: "bg-c".to_string(), component_target: "pink".to_string(), @@ -1131,6 +1223,9 @@ mod tests { // --- COMPLEX --- let spell_complex = Spell { + file_path: None, + span: (0, 0), + source: None, raw_spell: "{[data-theme='light']_p}font-sz=mrs(14px_16px_380px_800px)".to_string(), component: "font-sz".to_string(), component_target: "mrs(14px_16px_380px_800px)".to_string(), diff --git a/src/core/css_optimizer.rs b/src/core/css_optimizer.rs index e8c3e84..04a2c05 100644 --- a/src/core/css_optimizer.rs +++ b/src/core/css_optimizer.rs @@ -39,4 +39,16 @@ pub trait CssOptimizer: Sync + Send { /// * `Ok(String)` - The optimized and minified CSS string. /// * `Err(GrimoireCSSError)` - An error indicating that the optimization process failed. fn optimize(&self, raw_css: &str) -> Result; + + /// Validates a given raw CSS string. + /// + /// # Arguments + /// + /// * `raw_css` - A string containing the raw CSS code that needs to be validated. + /// + /// # Returns + /// + /// * `Ok(())` - If the CSS is valid. + /// * `Err(GrimoireCssError)` - An error indicating that the validation failed. + fn validate(&self, raw_css: &str) -> Result<(), GrimoireCssError>; } diff --git a/src/core/grimoire_css_error.rs b/src/core/grimoire_css_error.rs index 3770efa..9406873 100644 --- a/src/core/grimoire_css_error.rs +++ b/src/core/grimoire_css_error.rs @@ -5,66 +5,108 @@ //! serialization/deserialization processes, and custom application-specific errors related to //! invalid input or spell formats. -use regex; -use serde_json; -use std::fmt; -use std::io; +use std::sync::Arc; +use thiserror::Error; + +use super::source_file::SourceFile; /// Represents all possible errors that can occur in the Grimoire CSS system. /// /// This enum consolidates different error types from various operations into /// a single error type, making error handling consistent throughout the application. -#[derive(Debug)] +#[derive(Debug, Error)] pub enum GrimoireCssError { /// IO errors during file operations - Io(io::Error), + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + /// Regular expression parsing or execution errors - Regex(regex::Error), + #[error("Regex error: {0}")] + Regex(#[from] regex::Error), + /// JSON serialization/deserialization errors - Serde(serde_json::Error), + #[error("Serialization/Deserialization error: {0}")] + Serde(#[from] serde_json::Error), + /// Invalid spell format (e.g., malformed class names or templates) - InvalidSpellFormat(String), + #[error("Invalid spell format: {message}")] + InvalidSpellFormat { + message: String, + span: (usize, usize), + label: String, + help: Option, + source_file: Option>, + }, + /// General input validation errors + #[error("Invalid input: {0}")] InvalidInput(String), + /// Invalid file or directory path errors + #[error("Invalid path: {0}")] InvalidPath(String), + /// Errors in glob pattern syntax or matching + #[error("Glob pattern error: {0}")] GlobPatternError(String), + /// Runtime errors that don't fit other categories + #[error("Runtime error: {0}")] RuntimeError(String), -} - -impl fmt::Display for GrimoireCssError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - GrimoireCssError::Io(e) => write!(f, "IO error: {e}"), - GrimoireCssError::Regex(e) => write!(f, "Regex error: {e}"), - GrimoireCssError::Serde(e) => write!(f, "Serialization/Deserialization error: {e}"), - GrimoireCssError::InvalidSpellFormat(s) => write!(f, "Invalid spell format: {s}"), - GrimoireCssError::InvalidInput(s) => write!(f, "Invalid input: {s}"), - GrimoireCssError::InvalidPath(s) => write!(f, "Invalid path: {s}"), - GrimoireCssError::GlobPatternError(s) => write!(f, "Glob pattern error: {s}"), - GrimoireCssError::RuntimeError(s) => write!(f, "Runtime error: {s}"), - } - } -} -impl std::error::Error for GrimoireCssError {} + /// CSS Optimization errors (e.g. from LightningCSS) + #[error("CSS Optimization failed: {0}")] + OptimizationError(String), -impl From for GrimoireCssError { - fn from(error: io::Error) -> Self { - GrimoireCssError::Io(error) - } + /// Error with source context for better reporting + #[error("{message}")] + CompileError { + message: String, + span: (usize, usize), + label: String, + help: Option, + source_file: Option>, + }, } -impl From for GrimoireCssError { - fn from(error: regex::Error) -> Self { - GrimoireCssError::Regex(error) +impl GrimoireCssError { + pub fn with_source(self, source: Arc) -> Self { + match self { + GrimoireCssError::InvalidSpellFormat { + message, + span, + label, + help, + source_file: existing, + } => GrimoireCssError::InvalidSpellFormat { + message, + span, + label, + help, + source_file: existing.or(Some(source)), + }, + GrimoireCssError::CompileError { + message, + span, + label, + help, + source_file: existing, + } => GrimoireCssError::CompileError { + message, + span, + label, + help, + source_file: existing.or(Some(source)), + }, + other => other, + } } -} -impl From for GrimoireCssError { - fn from(error: serde_json::Error) -> Self { - GrimoireCssError::Serde(error) + pub fn source(&self) -> Option<&Arc> { + match self { + GrimoireCssError::InvalidSpellFormat { source_file, .. } => source_file.as_ref(), + GrimoireCssError::CompileError { source_file, .. } => source_file.as_ref(), + _ => None, + } } } diff --git a/src/core/mod.rs b/src/core/mod.rs index d8c6238..ee8d0f0 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -18,7 +18,9 @@ pub mod css_builder; pub mod css_optimizer; pub mod grimoire_css_error; pub mod parser; +pub mod source_file; pub mod spell; +pub mod spell_value_validator; pub use compiled_css::*; pub use config::*; @@ -26,5 +28,6 @@ pub use css_builder::*; pub use css_optimizer::*; pub use filesystem::*; pub use grimoire_css_error::*; +pub use source_file::*; // Exception: This external dependency was part of the Grimoire CSS and is now included as a separate crate, but should still be part of the main module and available for use. pub use grimoire_css_color_toolkit_lib::*; diff --git a/src/core/parser/parser_base.rs b/src/core/parser/parser_base.rs index 9f28ca7..ad14ccb 100644 --- a/src/core/parser/parser_base.rs +++ b/src/core/parser/parser_base.rs @@ -99,66 +99,90 @@ impl Parser { result.into_iter().collect() } - /// Collects class names from content based on the given regular expression and optional predicate/splitter functions. + /// Collects class names from content based on the given regular expression. /// /// # Arguments /// /// * `content` - The content to be parsed. /// * `regex` - A regular expression used to search for class names. - /// * `predicate` - An optional function used to filter the results. - /// * `splitter` - An optional function used to split the result into multiple class names. - /// * `class_names` - A mutable reference to a vector to store the collected class names. + /// * `split_by_whitespace` - Whether to split the matched value by whitespace. + /// * `class_names` - A mutable reference to a vector to store the collected class names and their spans. /// * `seen_class_names` - A mutable reference to a `HashSet` used to track seen class names. + /// * `collection_type` - The type of collection being performed. /// /// # Errors /// - /// Returns a `GrimoireCSSError` if there is an issue during processing. - fn collect_classes( + /// Returns a `GrimoireCssError` if there is an issue during processing. + fn collect_classes( content: &str, regex: &Regex, - mut predicate: Option

, - mut splitter: Option, - class_names: &mut Vec, + split_by_whitespace: bool, + class_names: &mut Vec<(String, (usize, usize))>, seen_class_names: &mut HashSet, collection_type: CollectionType, - ) -> Result<(), GrimoireCssError> - where - P: FnMut(&str) -> bool, - S: FnMut(&str) -> Vec, - { + ) -> Result<(), GrimoireCssError> { for cap in regex.captures_iter(content) { - let class_value = match collection_type { - CollectionType::TemplatedSpell => cap.get(1).map(|m| m.as_str()).unwrap_or(""), - CollectionType::CurlyClass => cap.get(1).map(|m| m.as_str()).unwrap_or(""), - CollectionType::RegularClass => cap - .get(2) - .or_else(|| cap.get(3)) - .or_else(|| cap.get(4)) - .map(|m| m.as_str()) - .unwrap_or(""), + let match_obj = match collection_type { + CollectionType::TemplatedSpell => cap.get(1), + CollectionType::CurlyClass => cap.get(1), + CollectionType::RegularClass => { + cap.get(2).or_else(|| cap.get(3)).or_else(|| cap.get(4)) + } }; - let classes = if let Some(splitter_fn) = &mut splitter { - let splitted = splitter_fn(class_value); + if let Some(m) = match_obj { + let full_value = m.as_str(); + let base_offset = m.start(); - if matches!(collection_type, CollectionType::CurlyClass) { - splitted - .into_iter() - .map(|s| Self::clean_unpaired_brackets(&s)) - .collect() - } else { - splitted - } - } else { - vec![class_value.to_string()] - }; + if split_by_whitespace { + for part in full_value.split_whitespace() { + // Calculate the offset of the part within the full content + let part_start = part.as_ptr() as usize - full_value.as_ptr() as usize; + let start = base_offset + part_start; + let length = part.len(); + + let mut class_string = part.to_string(); + + if matches!(collection_type, CollectionType::CurlyClass) { + class_string = Self::clean_unpaired_brackets(&class_string); + } - for class in classes { - let should_include = predicate.as_mut().is_none_or(|p| p(&class)); + if !class_string.is_empty() && !seen_class_names.contains(&class_string) { + seen_class_names.insert(class_string.clone()); + class_names.push((class_string, (start, length))); + } + } + } else { + let start = base_offset; + let length = full_value.len(); + let class_string = full_value.to_string(); + + if class_string.contains(' ') { + // IMPORTANT: + // - In HTML attributes, spaces are class separators, not part of a single class token. + // - If a user tries to encode a value that contains spaces (e.g. calc(100vh - 50px)), + // the correct Grimoire convention is to use underscores instead: calc(100vh_-_50px). + // + // We return a Diagnostic-style error so the CLI can render it like rustc. + return Err(GrimoireCssError::CompileError { + message: "Spaces are not allowed inside a single spell token." + .to_string(), + span: (start, length), + label: "Error in this spell".to_string(), + help: Some(format!( + "You likely wrote a value with spaces inside a class attribute (HTML treats spaces as class separators).\n\ +Fix: replace spaces with '_' inside the value, e.g.:\n\ + h=calc(100vh - 50px) -> h=calc(100vh_-_50px)\n\n\ +Offending spell: '{class_string}'" + )), + source_file: None, + }); + } - if should_include && !seen_class_names.contains(&class) { - seen_class_names.insert(class.clone()); - class_names.push(class); + if !class_string.is_empty() && !seen_class_names.contains(&class_string) { + seen_class_names.insert(class_string.clone()); + class_names.push((class_string, (start, length))); + } } } } @@ -171,7 +195,7 @@ impl Parser { /// # Arguments /// /// * `content` - The content to parse - /// * `class_names` - A mutable reference to a vector that stores the collected class names + /// * `class_names` - A mutable reference to a vector that stores the collected class names and their spans /// * `seen_class_names` - A mutable reference to a HashSet for tracking seen class names /// /// # Returns @@ -180,66 +204,54 @@ impl Parser { pub fn collect_candidates( &self, content: &str, - class_names: &mut Vec, + class_names: &mut Vec<(String, (usize, usize))>, seen_class_names: &mut HashSet, ) -> Result<(), GrimoireCssError> { - let whitespace_splitter = |input: &str| { - input - .split_whitespace() - .map(String::from) - .collect::>() - }; - // Collect all 'className' matches - Self::collect_classes:: bool, fn(&str) -> Vec>( + Self::collect_classes( content, &self.class_name_regex, - None, - Some(whitespace_splitter), + true, class_names, seen_class_names, CollectionType::RegularClass, )?; // Collect all 'class' matches - Self::collect_classes:: bool, fn(&str) -> Vec>( + Self::collect_classes( content, &self.class_regex, - None, - Some(whitespace_splitter), + true, class_names, seen_class_names, CollectionType::RegularClass, )?; // Collect all 'templated class' (starts with 'g!', ends with ';') matches - Self::collect_classes:: bool, fn(&str) -> Vec>( + Self::collect_classes( content, &self.tepmplated_spell_regex, - None, - None, + false, class_names, seen_class_names, CollectionType::TemplatedSpell, )?; // Collect all curly 'className' matches - Self::collect_classes:: bool, fn(&str) -> Vec>( + Self::collect_classes( content, &self.curly_class_name_regex, - None, - Some(whitespace_splitter), + true, class_names, seen_class_names, CollectionType::CurlyClass, )?; // Collect all curly 'class' matches - Self::collect_classes:: bool, fn(&str) -> Vec>( + Self::collect_classes( content, &self.curly_class_regex, - None, - Some(whitespace_splitter), + true, class_names, seen_class_names, CollectionType::CurlyClass, @@ -271,11 +283,13 @@ mod tests { .unwrap(); assert_eq!(class_names.len(), 5); - assert!(class_names.contains(&"test1".to_string())); - assert!(class_names.contains(&"test2".to_string())); - assert!(class_names.contains(&"test3".to_string())); - assert!(class_names.contains(&"test4".to_string())); - assert!(class_names.contains(&"g!display=block;".to_string())); + + let names: Vec = class_names.iter().map(|(n, _)| n.clone()).collect(); + assert!(names.contains(&"test1".to_string())); + assert!(names.contains(&"test2".to_string())); + assert!(names.contains(&"test3".to_string())); + assert!(names.contains(&"test4".to_string())); + assert!(names.contains(&"g!display=block;".to_string())); } #[test] @@ -295,8 +309,9 @@ mod tests { .unwrap(); assert_eq!(class_names.len(), 2); - assert!(class_names.contains(&"g!display=flex;".to_string())); - assert!(class_names.contains(&"g!color=red;".to_string())); + let names: Vec = class_names.iter().map(|(n, _)| n.clone()).collect(); + assert!(names.contains(&"g!display=flex;".to_string())); + assert!(names.contains(&"g!color=red;".to_string())); } #[test] @@ -319,8 +334,9 @@ mod tests { .unwrap(); assert_eq!(class_names.len(), 6); + let names: Vec = class_names.iter().map(|(n, _)| n.clone()).collect(); for i in 1..=6 { - assert!(class_names.contains(&format!("test{i}"))); + assert!(names.contains(&format!("test{i}"))); } } @@ -341,15 +357,16 @@ mod tests { assert_eq!(class_names.len(), 9); - assert!(class_names.contains(&"isError".to_string())); - assert!(class_names.contains(&"?".to_string())); - assert!(class_names.contains(&"color=red".to_string())); - assert!(class_names.contains(&"regular-class-error".to_string())); - assert!(class_names.contains(&":".to_string())); - assert!(class_names.contains(&"color=green".to_string())); - assert!(class_names.contains(&"regular-class-success".to_string())); - assert!(class_names.contains(&"display=grid".to_string())); - assert!(class_names.contains(&"state-${state}".to_string())); + let names: Vec = class_names.iter().map(|(n, _)| n.clone()).collect(); + assert!(names.contains(&"isError".to_string())); + assert!(names.contains(&"?".to_string())); + assert!(names.contains(&"color=red".to_string())); + assert!(names.contains(&"regular-class-error".to_string())); + assert!(names.contains(&":".to_string())); + assert!(names.contains(&"color=green".to_string())); + assert!(names.contains(&"regular-class-success".to_string())); + assert!(names.contains(&"display=grid".to_string())); + assert!(names.contains(&"state-${state}".to_string())); } #[test] @@ -368,13 +385,38 @@ mod tests { .unwrap(); // Should clean unpaired brackets and quotes - assert!(class_names.contains(&"class-with-{unpaired}".to_string())); - assert!(class_names.contains(&"brackets".to_string())); - assert!(class_names.contains(&"and".to_string())); - assert!(class_names.contains(&"quotes".to_string())); - assert!(class_names.contains(&"normal-class".to_string())); - assert!(class_names.contains(&"{paired}".to_string())); - assert!(class_names.contains(&"[brackets]".to_string())); - assert!(class_names.contains(&"(work)".to_string())); + let names: Vec = class_names.iter().map(|(n, _)| n.clone()).collect(); + assert!(names.contains(&"class-with-{unpaired}".to_string())); + assert!(names.contains(&"brackets".to_string())); + assert!(names.contains(&"and".to_string())); + assert!(names.contains(&"quotes".to_string())); + assert!(names.contains(&"normal-class".to_string())); + assert!(names.contains(&"{paired}".to_string())); + assert!(names.contains(&"[brackets]".to_string())); + assert!(names.contains(&"(work)".to_string())); + } + + #[test] + fn test_spans() { + let parser = Parser::new(); + let mut class_names = Vec::new(); + let mut seen_class_names = HashSet::new(); + + let content = r#"

"#; + // 012345678901234567890123456 + // foo is at 12..15 + // bar is at 16..19 + + parser + .collect_candidates(content, &mut class_names, &mut seen_class_names) + .unwrap(); + + assert_eq!(class_names.len(), 2); + + let foo = class_names.iter().find(|(n, _)| n == "foo").unwrap(); + assert_eq!(foo.1, (12, 3)); + + let bar = class_names.iter().find(|(n, _)| n == "bar").unwrap(); + assert_eq!(bar.1, (16, 3)); } } diff --git a/src/core/parser/parser_fs.rs b/src/core/parser/parser_fs.rs index c7a294f..639f736 100644 --- a/src/core/parser/parser_fs.rs +++ b/src/core/parser/parser_fs.rs @@ -2,13 +2,21 @@ //! with filesystem-specific functionality for collecting CSS classes from files and directories. use super::Parser; +use crate::core::SourceFile; use crate::{buffer::add_message, core::GrimoireCssError}; use std::{ collections::HashSet, fs, path::{Path, PathBuf}, + sync::Arc, }; +type Span = (usize, usize); +type ClassWithSpan = (String, Span); + +type SingleOutputFileResult = (PathBuf, String, Vec); +type MultiOutputFileResult = (PathBuf, PathBuf, String, Vec); + /// `ParserFs` extends the base `Parser` with filesystem-specific functionality. /// It handles file reading, directory traversal, and path resolution. pub struct ParserFs { @@ -37,7 +45,7 @@ impl ParserFs { /// /// # Returns /// - /// A vector of unique class names found in the input files. + /// A vector of tuples containing file path, file content, and found classes with spans. /// /// # Errors /// @@ -45,16 +53,16 @@ impl ParserFs { pub fn collect_classes_single_output( &self, input_paths: &Vec, - ) -> Result, GrimoireCssError> { - let mut class_names: Vec = Vec::new(); + ) -> Result, GrimoireCssError> { + let mut results = Vec::new(); let mut seen_class_names: HashSet = HashSet::new(); for input_path in input_paths { let path = self.current_dir.join(input_path); - self.collect_spells_from_path(&path, &mut class_names, &mut seen_class_names)?; + self.collect_spells_from_path(&path, &mut results, &mut seen_class_names)?; } - Ok(class_names) + Ok(results) } /// Collects class names or templated spells from multiple input paths, producing multiple outputs. @@ -66,7 +74,7 @@ impl ParserFs { /// /// # Returns /// - /// A vector of tuples, where each tuple contains the path to the output CSS file and a vector of class names. + /// A vector of tuples: (OutputCssPath, InputSourcePath, InputSourceContent, ClassesWithSpans). /// /// # Errors /// @@ -75,14 +83,14 @@ impl ParserFs { &self, input_paths: &Vec, output_dir_path: &Path, - ) -> Result)>, GrimoireCssError> { - let mut res: Vec<(PathBuf, Vec)> = Vec::new(); + ) -> Result, GrimoireCssError> { + let mut res = Vec::new(); for input_path_string in input_paths { let path = self.current_dir.join(input_path_string); if path.is_file() { - let mut class_names: Vec = Vec::new(); + let mut class_names = Vec::new(); let mut seen_class_names: HashSet = HashSet::new(); let output_file_path = path.with_extension("css"); @@ -92,13 +100,20 @@ impl ParserFs { })?); let file_content = fs::read_to_string(&path)?; - self.base_parser.collect_candidates( + if let Err(e) = self.base_parser.collect_candidates( &file_content, &mut class_names, &mut seen_class_names, - )?; - - res.push((bundle_output_full_path, class_names)); + ) { + let src = Arc::new(SourceFile::new( + Some(path.clone()), + path.to_string_lossy().to_string(), + file_content.clone(), + )); + return Err(e.with_source(src)); + } + + res.push((bundle_output_full_path, path, file_content, class_names)); } else if path.is_dir() { let entries = &self.get_sorted_directory_entries(&path)?; @@ -128,7 +143,7 @@ impl ParserFs { /// # Arguments /// /// * `path` - The path of the file or directory to process. - /// * `class_names` - A mutable reference to a vector that stores the collected class names. + /// * `results` - A mutable reference to a vector that stores the collected results. /// * `seen_class_names` - A mutable reference to a HashSet for tracking seen class names. /// /// # Errors @@ -137,18 +152,34 @@ impl ParserFs { fn collect_spells_from_path( &self, path: &Path, - class_names: &mut Vec, + results: &mut Vec, seen_class_names: &mut HashSet, ) -> Result<(), GrimoireCssError> { if path.is_file() { let file_content = fs::read_to_string(path)?; - self.base_parser - .collect_candidates(&file_content, class_names, seen_class_names)?; + let mut class_names = Vec::new(); + + if let Err(e) = self.base_parser.collect_candidates( + &file_content, + &mut class_names, + seen_class_names, + ) { + let src = Arc::new(SourceFile::new( + Some(path.to_path_buf()), + path.to_string_lossy().to_string(), + file_content.clone(), + )); + return Err(e.with_source(src)); + } + + if !class_names.is_empty() { + results.push((path.to_path_buf(), file_content, class_names)); + } } else if path.is_dir() { let entries = &self.get_sorted_directory_entries(path)?; for entry in entries { - self.collect_spells_from_path(entry, class_names, seen_class_names)?; + self.collect_spells_from_path(entry, results, seen_class_names)?; } } else { add_message(format!("Invalid path: {}", path.display())); @@ -188,7 +219,7 @@ impl ParserFs { let mut seen = std::collections::HashSet::new(); self.base_parser .collect_candidates(content, &mut raw_spells, &mut seen)?; - Ok(raw_spells) + Ok(raw_spells.into_iter().map(|(s, _)| s).collect()) } } @@ -213,11 +244,16 @@ mod tests { parser.collect_classes_single_output(&vec![test_file.to_str().unwrap().to_string()]); assert!(result.is_ok()); - let classes = result.unwrap(); + let results = result.unwrap(); + assert_eq!(results.len(), 1); + let (path, _, classes) = &results[0]; + assert_eq!(path, &test_file); assert_eq!(classes.len(), 3); - assert!(classes.contains(&"test1".to_string())); - assert!(classes.contains(&"test2".to_string())); - assert!(classes.contains(&"test3".to_string())); + + let class_names: Vec = classes.iter().map(|(n, _)| n.clone()).collect(); + assert!(class_names.contains(&"test1".to_string())); + assert!(class_names.contains(&"test2".to_string())); + assert!(class_names.contains(&"test3".to_string())); } #[test] @@ -246,12 +282,14 @@ mod tests { assert_eq!(outputs.len(), 2); // Check first file output - assert_eq!(outputs[0].1.len(), 1); - assert!(outputs[0].1.contains(&"file1-class".to_string())); + let (_, _, _, classes1) = &outputs[0]; + assert_eq!(classes1.len(), 1); + assert_eq!(classes1[0].0, "file1-class"); // Check second file output - assert_eq!(outputs[1].1.len(), 1); - assert!(outputs[1].1.contains(&"file2-class".to_string())); + let (_, _, _, classes2) = &outputs[1]; + assert_eq!(classes2.len(), 1); + assert_eq!(classes2[0].0, "file2-class"); } #[test] @@ -271,9 +309,11 @@ mod tests { parser.collect_classes_single_output(&vec![sub_dir.to_str().unwrap().to_string()]); assert!(result.is_ok()); - let classes = result.unwrap(); + let results = result.unwrap(); + assert_eq!(results.len(), 1); + let (_, _, classes) = &results[0]; assert_eq!(classes.len(), 1); - assert!(classes.contains(&"nested-class".to_string())); + assert_eq!(classes[0].0, "nested-class"); } #[test] diff --git a/src/core/source_file.rs b/src/core/source_file.rs new file mode 100644 index 0000000..473f35b --- /dev/null +++ b/src/core/source_file.rs @@ -0,0 +1,18 @@ +use std::{path::PathBuf, sync::Arc}; + +#[derive(Debug, Clone)] +pub struct SourceFile { + pub name: String, + pub path: Option, + pub content: Arc, +} + +impl SourceFile { + pub fn new(path: Option, name: String, content: String) -> Self { + Self { + name, + path, + content: Arc::new(content), + } + } +} diff --git a/src/core/spell.rs b/src/core/spell.rs index fd7e807..32180f4 100644 --- a/src/core/spell.rs +++ b/src/core/spell.rs @@ -26,10 +26,15 @@ //! if the string format is invalid. use std::collections::{HashMap, HashSet}; +use std::hash::{Hash, Hasher}; +use std::path::PathBuf; +use std::sync::Arc; -use super::{GrimoireCssError, component::get_css_property}; +use super::{ + GrimoireCssError, component::get_css_property, source_file::SourceFile, spell_value_validator, +}; -#[derive(Eq, Hash, PartialEq, Debug, Clone)] +#[derive(Debug, Clone)] pub struct Spell { pub raw_spell: String, pub component: String, @@ -39,6 +44,37 @@ pub struct Spell { pub focus: String, pub with_template: bool, pub scroll_spells: Option>, + pub span: (usize, usize), + pub file_path: Option, + pub source: Option>, +} + +impl PartialEq for Spell { + fn eq(&self, other: &Self) -> bool { + self.raw_spell == other.raw_spell + && self.component == other.component + && self.component_target == other.component_target + && self.effects == other.effects + && self.area == other.area + && self.focus == other.focus + && self.with_template == other.with_template + && self.scroll_spells == other.scroll_spells + } +} + +impl Eq for Spell {} + +impl Hash for Spell { + fn hash(&self, state: &mut H) { + self.raw_spell.hash(state); + self.component.hash(state); + self.component_target.hash(state); + self.effects.hash(state); + self.area.hash(state); + self.focus.hash(state); + self.with_template.hash(state); + self.scroll_spells.hash(state); + } } impl Spell { @@ -47,9 +83,12 @@ impl Spell { raw_spell: &str, shared_spells: &HashSet, scrolls: &Option>>, + span: (usize, usize), + file_path: Option, + source: Option>, ) -> Result, GrimoireCssError> { let with_template = Self::check_for_template(raw_spell); - let raw_spell = if with_template { + let raw_spell_cleaned = if with_template { raw_spell .strip_prefix("g!") .and_then(|s| s.strip_suffix(";")) @@ -58,18 +97,28 @@ impl Spell { raw_spell }; - let raw_spell_split: Vec<&str> = raw_spell.split("--").filter(|s| !s.is_empty()).collect(); + let raw_spell_split: Vec<&str> = raw_spell_cleaned + .split("--") + .filter(|s| !s.is_empty()) + .collect(); if with_template && !raw_spell_split.is_empty() { let mut scroll_spells: Vec = Vec::new(); for rs in raw_spell_split { - if let Some(spell) = Spell::new(rs, shared_spells, scrolls)? { + if let Some(spell) = Spell::new( + rs, + shared_spells, + scrolls, + span, + file_path.clone(), + source.clone(), + )? { scroll_spells.push(spell); } } return Ok(Some(Spell { - raw_spell: raw_spell.to_string(), + raw_spell: raw_spell_cleaned.to_string(), component: String::new(), component_target: String::new(), effects: String::new(), @@ -77,11 +126,16 @@ impl Spell { focus: String::new(), with_template, scroll_spells: Some(scroll_spells), + span, + file_path, + source, })); } // Split the input string by "__" to separate the area (screen size) and the rest - let (area, rest) = raw_spell.split_once("__").unwrap_or(("", raw_spell)); + let (area, rest) = raw_spell_cleaned + .split_once("__") + .unwrap_or(("", raw_spell_cleaned)); // Split the raw spell by "}" to get the focus and the rest let (focus, rest) = rest @@ -93,8 +147,43 @@ impl Spell { // Split the rest by "=" to separate the component (property) and component_target (value) if let Some((component, component_target)) = rest.split_once("=") { + if let Some(err) = spell_value_validator::validate_component_target(component_target) { + let message = match err { + spell_value_validator::SpellValueValidationError::UnexpectedClosingParen => { + format!( + "Invalid value '{component_target}': unexpected ')'.\n\n\ +If you intended a CSS function (e.g. calc(...)), ensure parentheses are balanced." + ) + } + spell_value_validator::SpellValueValidationError::UnclosedParen => { + format!( + "Invalid value '{component_target}': unclosed '('.\n\n\ +Common cause: spaces inside a class attribute split the spell into multiple tokens.\n\ +Fix: replace spaces with '_' inside the value, e.g.:\n\ + h=calc(100vh - 50px) -> h=calc(100vh_-_50px)" + ) + } + }; + + if let Some(src) = &source { + return Err(GrimoireCssError::CompileError { + message, + span, + label: "invalid spell value".to_string(), + help: Some( + "In HTML class attributes, spaces split classes.\n\ +Use '_' inside spell values to represent spaces." + .to_string(), + ), + source_file: Some(src.clone()), + }); + } + + return Err(GrimoireCssError::InvalidInput(message)); + } + let mut spell = Spell { - raw_spell: raw_spell.to_string(), + raw_spell: raw_spell_cleaned.to_string(), component: component.to_string(), component_target: component_target.to_string(), effects: effects.to_string(), @@ -102,6 +191,9 @@ impl Spell { focus: focus.to_string(), with_template, scroll_spells: None, + span, + file_path: file_path.clone(), + source: source.clone(), }; if let Some(raw_scroll_spells) = @@ -113,13 +205,34 @@ impl Spell { &spell.component_target, shared_spells, scrolls, + span, + &file_path, + source.clone(), )?; + } else if !spell.component.starts_with("--") + && get_css_property(&spell.component).is_none() + { + let message = format!("Unknown component or scroll: '{}'", spell.component); + if let Some(src) = &source { + return Err(GrimoireCssError::InvalidSpellFormat { + message, + span, + label: "Error in this spell".to_string(), + help: Some( + "Check that the component name exists (built-in CSS property alias) or that the scroll is defined in config.scrolls." + .to_string(), + ), + source_file: Some(src.clone()), + }); + } else { + return Err(GrimoireCssError::InvalidInput(message)); + } } return Ok(Some(spell)); } else if let Some(raw_scroll_spells) = Self::check_raw_scroll_spells(rest, scrolls) { return Ok(Some(Spell { - raw_spell: raw_spell.to_string(), + raw_spell: raw_spell_cleaned.to_string(), component: rest.to_string(), component_target: String::new(), effects: effects.to_string(), @@ -132,7 +245,13 @@ impl Spell { "", shared_spells, scrolls, + span, + &file_path, + source.clone(), )?, + span, + file_path, + source, })); } @@ -158,12 +277,16 @@ impl Spell { None } + #[allow(clippy::too_many_arguments)] fn parse_scroll( scroll_name: &str, raw_scroll_spells: &[String], component_target: &str, shared_spells: &HashSet, scrolls: &Option>>, + span: (usize, usize), + file_path: &Option, + source: Option>, ) -> Result>, GrimoireCssError> { if raw_scroll_spells.is_empty() { return Ok(None); @@ -190,20 +313,50 @@ impl Spell { format!("={}", scroll_variables[count_of_used_variables]).as_str(), ); - if let Ok(Some(spell)) = Spell::new(&variabled_raw_spell, shared_spells, scrolls) { + if let Ok(Some(spell)) = Spell::new( + &variabled_raw_spell, + shared_spells, + scrolls, + span, + file_path.clone(), + source.clone(), + ) { spells.push(spell); } count_of_used_variables += 1; - } else if let Ok(Some(spell)) = Spell::new(raw_spell, shared_spells, scrolls) { + } else if let Ok(Some(spell)) = Spell::new( + raw_spell, + shared_spells, + scrolls, + span, + file_path.clone(), + source.clone(), + ) { spells.push(spell); } } if count_of_used_variables != count_of_variables { - return Err(GrimoireCssError::InvalidInput(format!( - "Not all variables used in scroll '{scroll_name}'. Expected {count_of_variables}, but used {count_of_used_variables}", - ))); + let message = format!( + "Variable count mismatch for scroll '{scroll_name}'. Provided {count_of_variables} arguments, but scroll definition uses {count_of_used_variables}", + ); + + if let Some(src) = &source { + return Err(GrimoireCssError::InvalidSpellFormat { + message, + span, + label: "Error in this spell".to_string(), + help: Some( + "Pass exactly N arguments separated by '_' (underscores).\n\ +Example: complex-card=arg1_arg2_arg3" + .to_string(), + ), + source_file: Some(src.clone()), + }); + } else { + return Err(GrimoireCssError::InvalidInput(message)); + } } if spells.is_empty() { @@ -214,15 +367,24 @@ impl Spell { } pub fn generate_spells_from_classes( - css_classes: Vec, + css_classes: Vec<(String, (usize, usize))>, shared_spells: &HashSet, scrolls: &Option>>, + file_path: Option, + source: Option>, ) -> Result, GrimoireCssError> { let mut spells = Vec::with_capacity(css_classes.len()); - for cs in css_classes { + for (cs, span) in css_classes { if !shared_spells.contains(&cs) - && let Some(spell) = Spell::new(&cs, shared_spells, scrolls)? + && let Some(spell) = Spell::new( + &cs, + shared_spells, + scrolls, + span, + file_path.clone(), + source.clone(), + )? { spells.push(spell); } @@ -234,15 +396,17 @@ impl Spell { #[cfg(test)] mod tests { + use crate::core::source_file::SourceFile; use crate::core::spell::Spell; use std::collections::{HashMap, HashSet}; + use std::sync::Arc; #[test] fn test_multiple_raw_spells_in_template() { let shared_spells = HashSet::new(); let scrolls: Option>> = None; let raw = "g!color=red--display=flex;"; - let spell = Spell::new(raw, &shared_spells, &scrolls) + let spell = Spell::new(raw, &shared_spells, &scrolls, (0, 0), None, None) .expect("parse ok") .expect("not None"); assert!(spell.with_template); @@ -254,4 +418,27 @@ mod tests { assert_eq!(spells[1].component, "display"); assert_eq!(spells[1].component_target, "flex"); } + + #[test] + fn test_non_grimoire_plain_class_is_ignored() { + let shared_spells = HashSet::new(); + let scrolls: Option>> = None; + + // Plain CSS class (no '=') must not be treated as a spell. + let spell = Spell::new( + "red", + &shared_spells, + &scrolls, + (12, 3), + None, + Some(Arc::new(SourceFile::new( + None, + "test".to_string(), + "
".to_string(), + ))), + ) + .expect("parsing must not fail"); + + assert!(spell.is_none()); + } } diff --git a/src/core/spell_value_validator.rs b/src/core/spell_value_validator.rs new file mode 100644 index 0000000..8ed0027 --- /dev/null +++ b/src/core/spell_value_validator.rs @@ -0,0 +1,51 @@ +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SpellValueValidationError { + UnexpectedClosingParen, + UnclosedParen, +} + +pub fn validate_component_target(component_target: &str) -> Option { + // Intentionally minimal and cheap validation to surface common HTML class tokenization mistakes + // (e.g. h=calc(100vh - 50px) becomes h=calc(100vh). + // + // We only treat parentheses as syntax when NOT inside CSS string literals. + // In CSS, string literals are only single/double quotes. + + let mut depth: i32 = 0; + let mut in_single_quote = false; + let mut in_double_quote = false; + let mut escape_next = false; + + for ch in component_target.chars() { + if escape_next { + escape_next = false; + continue; + } + + match ch { + '\\' if in_single_quote || in_double_quote => { + escape_next = true; + } + '\'' if !in_double_quote => { + in_single_quote = !in_single_quote; + } + '"' if !in_single_quote => { + in_double_quote = !in_double_quote; + } + '(' if !in_single_quote && !in_double_quote => depth += 1, + ')' if !in_single_quote && !in_double_quote => { + depth -= 1; + if depth < 0 { + return Some(SpellValueValidationError::UnexpectedClosingParen); + } + } + _ => {} + } + } + + if depth != 0 { + return Some(SpellValueValidationError::UnclosedParen); + } + + None +} diff --git a/src/infrastructure/diagnostics.rs b/src/infrastructure/diagnostics.rs new file mode 100644 index 0000000..0620441 --- /dev/null +++ b/src/infrastructure/diagnostics.rs @@ -0,0 +1,167 @@ +use crate::core::{GrimoireCssError, SourceFile}; +use miette::{Diagnostic, LabeledSpan, SourceCode}; +use std::sync::Arc; +use thiserror::Error; + +fn named_source_from(source: &Arc) -> miette::NamedSource { + miette::NamedSource::new(source.name.clone(), (*source.content).clone()) +} + +#[derive(Debug, Error)] +pub enum GrimoireCssDiagnostic { + #[error("IO error: {0}")] + Io(String), + + #[error("Regex error: {0}")] + Regex(String), + + #[error("Serialization/Deserialization error: {0}")] + Serde(String), + + #[error("Invalid input: {0}")] + InvalidInput(String), + + #[error("Invalid path: {0}")] + InvalidPath(String), + + #[error("Glob pattern error: {0}")] + GlobPatternError(String), + + #[error("Runtime error: {0}")] + RuntimeError(String), + + #[error("CSS Optimization failed: {0}")] + OptimizationError(String), + + #[error("Invalid spell format: {message}")] + InvalidSpellFormat { + message: String, + src: miette::NamedSource, + span: (usize, usize), + label: String, + help: Option, + }, + + #[error("{message}")] + CompileError { + message: String, + src: miette::NamedSource, + span: (usize, usize), + label: String, + help: Option, + }, +} + +impl Diagnostic for GrimoireCssDiagnostic { + fn code<'a>(&'a self) -> Option> { + match self { + GrimoireCssDiagnostic::Io(_) => Some(Box::new("grimoire_css::io")), + GrimoireCssDiagnostic::Regex(_) => Some(Box::new("grimoire_css::regex")), + GrimoireCssDiagnostic::Serde(_) => Some(Box::new("grimoire_css::serde")), + GrimoireCssDiagnostic::InvalidInput(_) => Some(Box::new("grimoire_css::invalid_input")), + GrimoireCssDiagnostic::InvalidPath(_) => Some(Box::new("grimoire_css::invalid_path")), + GrimoireCssDiagnostic::GlobPatternError(_) => { + Some(Box::new("grimoire_css::glob_pattern")) + } + GrimoireCssDiagnostic::RuntimeError(_) => Some(Box::new("grimoire_css::runtime")), + GrimoireCssDiagnostic::OptimizationError(_) => { + Some(Box::new("grimoire_css::optimization")) + } + GrimoireCssDiagnostic::InvalidSpellFormat { .. } => { + Some(Box::new("grimoire_css::invalid_spell_format")) + } + GrimoireCssDiagnostic::CompileError { .. } => { + Some(Box::new("grimoire_css::compile_error")) + } + } + } + + fn help<'a>(&'a self) -> Option> { + match self { + GrimoireCssDiagnostic::InvalidSpellFormat { help, .. } + | GrimoireCssDiagnostic::CompileError { help, .. } => help + .as_deref() + .map(|h| Box::new(h) as Box), + _ => None, + } + } + + fn source_code(&self) -> Option<&dyn SourceCode> { + match self { + GrimoireCssDiagnostic::InvalidSpellFormat { src, .. } + | GrimoireCssDiagnostic::CompileError { src, .. } => Some(src), + _ => None, + } + } + + fn labels(&self) -> Option + '_>> { + match self { + GrimoireCssDiagnostic::InvalidSpellFormat { span, label, .. } + | GrimoireCssDiagnostic::CompileError { span, label, .. } => { + Some(Box::new(std::iter::once( + LabeledSpan::new_primary_with_span(Some(label.clone()), *span), + ))) + } + _ => None, + } + } +} + +impl From<&GrimoireCssError> for GrimoireCssDiagnostic { + fn from(value: &GrimoireCssError) -> Self { + match value { + GrimoireCssError::Io(e) => GrimoireCssDiagnostic::Io(e.to_string()), + GrimoireCssError::Regex(e) => GrimoireCssDiagnostic::Regex(e.to_string()), + GrimoireCssError::Serde(e) => GrimoireCssDiagnostic::Serde(e.to_string()), + GrimoireCssError::InvalidInput(msg) => GrimoireCssDiagnostic::InvalidInput(msg.clone()), + GrimoireCssError::InvalidPath(msg) => GrimoireCssDiagnostic::InvalidPath(msg.clone()), + GrimoireCssError::GlobPatternError(msg) => { + GrimoireCssDiagnostic::GlobPatternError(msg.clone()) + } + GrimoireCssError::RuntimeError(msg) => GrimoireCssDiagnostic::RuntimeError(msg.clone()), + GrimoireCssError::OptimizationError(msg) => { + GrimoireCssDiagnostic::OptimizationError(msg.clone()) + } + GrimoireCssError::InvalidSpellFormat { + message, + span, + label, + help, + source_file, + } => { + let src = source_file + .as_ref() + .map(named_source_from) + .unwrap_or_else(|| miette::NamedSource::new("unknown", "".to_string())); + + GrimoireCssDiagnostic::InvalidSpellFormat { + message: message.clone(), + src, + span: *span, + label: label.clone(), + help: help.clone(), + } + } + GrimoireCssError::CompileError { + message, + span, + label, + help, + source_file, + } => { + let src = source_file + .as_ref() + .map(named_source_from) + .unwrap_or_else(|| miette::NamedSource::new("unknown", "".to_string())); + + GrimoireCssDiagnostic::CompileError { + message: message.clone(), + src, + span: *span, + label: label.clone(), + help: help.clone(), + } + } + } + } +} diff --git a/src/infrastructure/lightning_css_optimizer.rs b/src/infrastructure/lightning_css_optimizer.rs index 4036753..775dc46 100644 --- a/src/infrastructure/lightning_css_optimizer.rs +++ b/src/infrastructure/lightning_css_optimizer.rs @@ -79,8 +79,9 @@ impl CssOptimizer for LightningCssOptimizer { /// /// Returns a `Result` containing the optimized CSS string or a `GrimoireCSSError` if optimization fails. fn optimize(&self, raw_css: &str) -> Result { - let mut stylesheet = StyleSheet::parse(raw_css, ParserOptions::default()) - .map_err(|e| GrimoireCssError::InvalidInput(format!("Failed to parse CSS: {e}")))?; + let mut stylesheet = StyleSheet::parse(raw_css, ParserOptions::default()).map_err(|e| { + GrimoireCssError::OptimizationError(format!("Failed to parse CSS: {e}")) + })?; // Apply minification and optimization based on the browser targets. stylesheet @@ -88,7 +89,9 @@ impl CssOptimizer for LightningCssOptimizer { targets: self.targets, unused_symbols: Default::default(), }) - .map_err(|e| GrimoireCssError::InvalidInput(format!("Failed to minify CSS: {e}")))?; + .map_err(|e| { + GrimoireCssError::OptimizationError(format!("Failed to minify CSS: {e}")) + })?; // Generate the final CSS as a string. stylesheet @@ -97,6 +100,14 @@ impl CssOptimizer for LightningCssOptimizer { ..Default::default() }) .map(|res| res.code) - .map_err(|e| GrimoireCssError::InvalidInput(format!("Failed to generate CSS: {e}"))) + .map_err(|e| { + GrimoireCssError::OptimizationError(format!("Failed to generate CSS: {e}")) + }) + } + + fn validate(&self, raw_css: &str) -> Result<(), GrimoireCssError> { + StyleSheet::parse(raw_css, ParserOptions::default()) + .map(|_| ()) + .map_err(|e| GrimoireCssError::OptimizationError(format!("Failed to parse CSS: {e}"))) } } diff --git a/src/infrastructure/mod.rs b/src/infrastructure/mod.rs index a954821..61d32a3 100644 --- a/src/infrastructure/mod.rs +++ b/src/infrastructure/mod.rs @@ -1,6 +1,8 @@ //! The `infrastructure` module provides integration with external libraries and services //! that power Grimoire CSS's core functionality. This includes CSS optimization, //! minification, and other low-level operations that require external dependencies. +pub mod diagnostics; pub mod lightning_css_optimizer; +pub use diagnostics::*; pub use lightning_css_optimizer::*; diff --git a/src/lib.rs b/src/lib.rs index b7fdbe0..de7ceef 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,7 +19,8 @@ use commands::{handle_in_memory, process_mode_and_handle}; use console::style; use core::{compiled_css::CompiledCssInMemory, config::ConfigInMemory}; use indicatif::{ProgressBar, ProgressStyle}; -use infrastructure::LightningCssOptimizer; +use infrastructure::{GrimoireCssDiagnostic, LightningCssOptimizer}; +use miette::GraphicalReportHandler; use std::time::{Duration, Instant}; pub use core::{GrimoireCssError, color, component, config, spell::Spell}; @@ -192,7 +193,18 @@ pub fn start_as_cli(args: Vec) -> Result<(), GrimoireCssError> { print!("\r\x1b[2K{GRIMM_CURSED}\n"); println!(); - println!("{} {}", style(" Cursed! ").white().on_red().bright(), e); + println!( + "{} Something went wrong...", + style(" Cursed! ").white().on_red().bright() + ); + println!(); + + let diagnostic: GrimoireCssDiagnostic = (&e).into(); + let mut out = String::new(); + GraphicalReportHandler::new() + .render_report(&mut out, &diagnostic) + .unwrap(); + println!("{out}"); Err(e) }