diff --git a/.changeset/flat-beers-battle.md b/.changeset/flat-beers-battle.md deleted file mode 100644 index 3e65d504f931..000000000000 --- a/.changeset/flat-beers-battle.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -"@biomejs/biome": minor ---- - -Added the `sortBareImports` option to [`organizeImports`](https://biomejs.dev/assist/actions/organize-imports/), -which allows bare imports to be sorted within other imports when set to `false`. - -```json -{ - "assist": { - "actions": { - "source": { - "organizeImports": { - "level": "on", - "options": { "sortBareImports": true } - } - } - } - } -} -``` - -```diff -- import "b"; - import "a"; -+ import "b"; - import { A } from "a"; -+ import "./file"; - import { Local } from "./file"; -- import "./file"; -``` diff --git a/.changeset/wasm-plugin-system.md b/.changeset/wasm-plugin-system.md new file mode 100644 index 000000000000..96f3e113658b --- /dev/null +++ b/.changeset/wasm-plugin-system.md @@ -0,0 +1,5 @@ +--- +"@biomejs/biome": minor +--- + +Added an opt-in WASM Component Model plugin system behind the `wasm_plugin` feature flag. Users can write custom lint rules in Rust (or any language targeting `wasm32-wasip2`), compile them to `.wasm` modules, and load them at runtime via `wasmtime`. Plugins support full AST traversal, semantic model access, type inference, configurable options, and suppression comments. This complements the existing GritQL plugin system with a high-performance alternative for complex rules that need stateful analysis. diff --git a/.gitignore b/.gitignore index 2c4aeac03e7a..3a9d604613f6 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,6 @@ dhat-heap.json !/crates/biome_json_formatter/tests/specs/prettier/**/* !/crates/biome_css_formatter/tests/specs/prettier/**/* !/crates/biome_graphql_formatter/tests/specs/prettier/**/* + +# Compiled WASM plugin test fixtures (built by biome_plugin_loader/build.rs) +crates/biome_plugin_loader/tests/fixtures/*.wasm diff --git a/Cargo.lock b/Cargo.lock index 2e11f43950d1..5ca19ce1a369 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,7 +8,16 @@ version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" dependencies = [ - "gimli", + "gimli 0.32.3", +] + +[[package]] +name = "addr2line" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9698bf0769c641b18618039fe2ebd41eb3541f98433000f64e663fab7cea2c87" +dependencies = [ + "gimli 0.33.0", ] [[package]] @@ -41,6 +50,21 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "ambient-authority" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anes" version = "0.1.6" @@ -68,6 +92,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + [[package]] name = "arrayvec" version = "0.7.6" @@ -80,6 +110,17 @@ version = "4.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4ef8ee0b4eedc5a877a8a768fecfabbe4c8b9791e17763225139a858dabd8e7" +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "autocfg" version = "1.1.0" @@ -92,7 +133,7 @@ version = "0.3.76" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" dependencies = [ - "addr2line", + "addr2line 0.25.1", "cfg-if", "libc", "miniz_oxide", @@ -127,6 +168,7 @@ dependencies = [ "biome_parser", "biome_rowan", "biome_suppression", + "biome_text_edit", "camino", "enumflags2", "indexmap", @@ -1499,6 +1541,7 @@ version = "0.0.1" dependencies = [ "biome_analyze", "biome_console", + "biome_css_parser", "biome_css_syntax", "biome_deserialize", "biome_deserialize_macros", @@ -1507,13 +1550,16 @@ dependencies = [ "biome_grit_patterns", "biome_js_parser", "biome_js_runtime", + "biome_js_semantic", "biome_js_syntax", "biome_json_parser", "biome_json_syntax", + "biome_module_graph", "biome_parser", "biome_resolver", "biome_rowan", "biome_text_size", + "biome_wasm_plugin", "boa_engine", "camino", "grit-pattern-matcher", @@ -1524,9 +1570,22 @@ dependencies = [ "rustc-hash 2.1.1", "schemars", "serde", + "serde_json", + "wasmtime", "windows 0.62.2", ] +[[package]] +name = "biome_plugin_sdk" +version = "0.0.1" +dependencies = [ + "biome_plugin_sdk_macros", +] + +[[package]] +name = "biome_plugin_sdk_macros" +version = "0.0.1" + [[package]] name = "biome_project_layout" version = "0.0.1" @@ -1841,6 +1900,35 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "biome_wasm_plugin" +version = "0.0.1" +dependencies = [ + "biome_analyze", + "biome_aria", + "biome_aria_metadata", + "biome_console", + "biome_css_parser", + "biome_css_syntax", + "biome_diagnostics", + "biome_fs", + "biome_js_parser", + "biome_js_semantic", + "biome_js_syntax", + "biome_js_type_info", + "biome_json_parser", + "biome_json_syntax", + "biome_module_graph", + "biome_plugin_sdk", + "biome_project_layout", + "biome_rowan", + "biome_text_size", + "camino", + "regex", + "wasmtime", + "wasmtime-wasi", +] + [[package]] name = "biome_yaml_factory" version = "0.0.1" @@ -1902,9 +1990,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.3" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34efbcccd345379ca2868b2b2c9d3782e9cc58ba87bc7d79d5b53d9c9ae6f25d" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" [[package]] name = "bitvec" @@ -1924,7 +2012,7 @@ version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc119a5ad34c3f459062a96907f53358989b173d104258891bb74f95d93747e8" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.11.0", "boa_interner", "boa_macros", "boa_string", @@ -1941,7 +2029,7 @@ checksum = "e637ec52ea66d76b0ca86180c259d6c7bb6e6a6e14b2f36b85099306d8b00cc3" dependencies = [ "aligned-vec", "arrayvec", - "bitflags 2.9.3", + "bitflags 2.11.0", "boa_ast", "boa_gc", "boa_interner", @@ -2033,7 +2121,7 @@ version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02f99bf5b684f0de946378fcfe5f38c3a0fbd51cbf83a0f39ff773a0e218541f" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.11.0", "boa_ast", "boa_interner", "boa_macros", @@ -2106,9 +2194,12 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.12.0" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d261e256854913907f67ed06efbc3338dfe6179796deefc1ff763fc1aee5535" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +dependencies = [ + "allocator-api2", +] [[package]] name = "bytemuck" @@ -2151,6 +2242,84 @@ dependencies = [ "serde_core", ] +[[package]] +name = "cap-fs-ext" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5528f85b1e134ae811704e41ef80930f56e795923f866813255bc342cc20654" +dependencies = [ + "cap-primitives", + "cap-std", + "io-lifetimes 2.0.4", + "windows-sys 0.59.0", +] + +[[package]] +name = "cap-net-ext" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20a158160765c6a7d0d8c072a53d772e4cb243f38b04bfcf6b4939cfbe7482e7" +dependencies = [ + "cap-primitives", + "cap-std", + "rustix 1.1.4", + "smallvec", +] + +[[package]] +name = "cap-primitives" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6cf3aea8a5081171859ef57bc1606b1df6999df4f1110f8eef68b30098d1d3a" +dependencies = [ + "ambient-authority", + "fs-set-times", + "io-extras", + "io-lifetimes 2.0.4", + "ipnet", + "maybe-owned", + "rustix 1.1.4", + "rustix-linux-procfs", + "windows-sys 0.59.0", + "winx", +] + +[[package]] +name = "cap-rand" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8144c22e24bbcf26ade86cb6501a0916c46b7e4787abdb0045a467eb1645a1d" +dependencies = [ + "ambient-authority", + "rand 0.8.5", +] + +[[package]] +name = "cap-std" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6dc3090992a735d23219de5c204927163d922f42f575a0189b005c62d37549a" +dependencies = [ + "cap-primitives", + "io-extras", + "io-lifetimes 2.0.4", + "rustix 1.1.4", +] + +[[package]] +name = "cap-time-ext" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "def102506ce40c11710a9b16e614af0cde8e76ae51b1f48c04b8d79f4b671a80" +dependencies = [ + "ambient-authority", + "cap-primitives", + "iana-time-zone", + "once_cell", + "rustix 1.1.4", + "winx", +] + [[package]] name = "case" version = "1.0.0" @@ -2174,10 +2343,11 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.10" +version = "1.2.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13208fcbb66eaeffe09b99fffbe1af420f00a7b35aa99ad683dfc1aa76145229" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" dependencies = [ + "find-msvc-tools", "jobserver", "libc", "shlex", @@ -2257,6 +2427,15 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd7cc57abe963c6d3b9d8be5b06ba7c8957a930305ca90304f24ef040aa6f961" +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.17", +] + [[package]] name = "codspeed" version = "3.0.5" @@ -2472,6 +2651,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "countme" version = "3.0.1" @@ -2489,6 +2674,147 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "417bef24afe1460300965a25ff4a24b8b45ad011948302ec221e8a0a81eb2c79" +[[package]] +name = "cranelift-assembler-x64" +version = "0.129.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40630d663279bc855bff805d6f5e8a0b6a1867f9df95b010511ac6dc894e9395" +dependencies = [ + "cranelift-assembler-x64-meta", +] + +[[package]] +name = "cranelift-assembler-x64-meta" +version = "0.129.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ee6aec5ceb55e5fdbcf7ef677d7c7195531360ff181ce39b2b31df11d57305f" +dependencies = [ + "cranelift-srcgen", +] + +[[package]] +name = "cranelift-bforest" +version = "0.129.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a92d78cc3f087d7e7073828f08d98c7074a3a062b6b29a1b7783ce74305685e" +dependencies = [ + "cranelift-entity", +] + +[[package]] +name = "cranelift-bitset" +version = "0.129.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edcc73d756f2e0d7eda6144fe64a2bc69c624de893cb1be51f1442aed77881d2" +dependencies = [ + "serde", + "serde_derive", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-codegen" +version = "0.129.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d94c2cd0d73b41369b88da1129589bc3a2d99cf49979af1d14751f35b7a1b" +dependencies = [ + "bumpalo", + "cranelift-assembler-x64", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-codegen-meta", + "cranelift-codegen-shared", + "cranelift-control", + "cranelift-entity", + "cranelift-isle", + "gimli 0.33.0", + "hashbrown 0.15.5", + "libm", + "log", + "pulley-interpreter", + "regalloc2", + "rustc-hash 2.1.1", + "serde", + "smallvec", + "target-lexicon", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-codegen-meta" +version = "0.129.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "235da0e52ee3a0052d0e944c3470ff025b1f4234f6ec4089d3109f2d2ffa6cbd" +dependencies = [ + "cranelift-assembler-x64-meta", + "cranelift-codegen-shared", + "cranelift-srcgen", + "heck", + "pulley-interpreter", +] + +[[package]] +name = "cranelift-codegen-shared" +version = "0.129.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c07c6c440bd1bf920ff7597a1e743ede1f68dcd400730bd6d389effa7662af" + +[[package]] +name = "cranelift-control" +version = "0.129.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8797c022e02521901e1aee483dea3ed3c67f2bf0a26405c9dd48e8ee7a70944b" +dependencies = [ + "arbitrary", +] + +[[package]] +name = "cranelift-entity" +version = "0.129.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59d8e72637246edd2cba337939850caa8b201f6315925ec4c156fdd089999699" +dependencies = [ + "cranelift-bitset", + "serde", + "serde_derive", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-frontend" +version = "0.129.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c31db0085c3dfa131e739c3b26f9f9c84d69a9459627aac1ac4ef8355e3411b" +dependencies = [ + "cranelift-codegen", + "log", + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cranelift-isle" +version = "0.129.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524d804c1ebd8c542e6f64e71aa36934cec17c5da4a9ae3799796220317f5d23" + +[[package]] +name = "cranelift-native" +version = "0.129.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc9598f02540e382e1772416eba18e93c5275b746adbbf06ac1f3cf149415270" +dependencies = [ + "cranelift-codegen", + "libc", + "target-lexicon", +] + +[[package]] +name = "cranelift-srcgen" +version = "0.129.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d953932541249c91e3fa70a75ff1e52adc62979a2a8132145d4b9b3e6d1a9b6a" + [[package]] name = "crc32fast" version = "1.3.2" @@ -2771,6 +3097,18 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + [[package]] name = "encode_unicode" version = "0.3.6" @@ -2783,6 +3121,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "enumflags2" version = "0.7.12" @@ -2862,6 +3209,17 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix 1.1.4", + "windows-sys 0.59.0", +] + [[package]] name = "filetime" version = "0.2.27" @@ -2873,6 +3231,12 @@ dependencies = [ "libredox", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "fixedbitset" version = "0.5.7" @@ -2945,6 +3309,17 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs-set-times" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" +dependencies = [ + "io-lifetimes 2.0.4", + "rustix 1.1.4", + "windows-sys 0.59.0", +] + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -3143,13 +3518,25 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +[[package]] +name = "gimli" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf7f043f89559805f8c7cacc432749b2fa0d0a0a9ee46ce47164ed5ba7f126c" +dependencies = [ + "fnv", + "hashbrown 0.16.1", + "indexmap", + "stable_deref_trait", +] + [[package]] name = "git2" version = "0.20.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.11.0", "libc", "libgit2-sys", "log", @@ -3181,7 +3568,7 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.11.0", "ignore", "walkdir", ] @@ -3233,6 +3620,7 @@ dependencies = [ "allocator-api2", "equivalent", "foldhash 0.1.5", + "serde", ] [[package]] @@ -3246,6 +3634,12 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.3.9" @@ -3288,6 +3682,30 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71a816c97c42258aa5834d07590b718b4c9a598944cd39a52dc25b351185d678" +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "1.5.0" @@ -3494,6 +3912,12 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -3545,6 +3969,8 @@ checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -3567,7 +3993,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.11.0", "inotify-sys", "libc", ] @@ -3605,15 +4031,37 @@ dependencies = [ ] [[package]] -name = "io-lifetimes" -version = "1.0.6" +name = "io-extras" +version = "0.18.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfa919a82ea574332e2de6e74b4c36e74d41982b335080fa59d4ef31be20fdf3" +checksum = "2285ddfe3054097ef4b2fe909ef8c3bcd1ea52a8f0d274416caebeef39f04a65" +dependencies = [ + "io-lifetimes 2.0.4", + "windows-sys 0.59.0", +] + +[[package]] +name = "io-lifetimes" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfa919a82ea574332e2de6e74b4c36e74d41982b335080fa59d4ef31be20fdf3" dependencies = [ "libc", "windows-sys 0.45.0", ] +[[package]] +name = "io-lifetimes" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + [[package]] name = "is-terminal" version = "0.4.7" @@ -3621,7 +4069,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adcf93614601c8129ddf72e2d5633df827ba6551541c6d8c59520a371475be1f" dependencies = [ "hermit-abi", - "io-lifetimes", + "io-lifetimes 1.0.6", "rustix 0.37.7", "windows-sys 0.48.0", ] @@ -3748,6 +4196,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.183" @@ -3766,6 +4220,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libmimalloc-sys" version = "0.1.44" @@ -3782,7 +4242,7 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.11.0", "libc", "redox_syscall", ] @@ -3807,9 +4267,15 @@ checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519" [[package]] name = "linux-raw-sys" -version = "0.9.2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db9c683daf087dc577b7506e9695b3d556a9f3849903fa28186283afd6809e9" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" @@ -3840,9 +4306,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.26" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30bde2b3dc3671ae49d8e2e9f044c7c005836e7a023ee57cffa25ab82764bb9e" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "loom" @@ -3863,13 +4329,22 @@ version = "0.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a7deb98ef9daaa7500324351a5bab7c80c644cfb86b4be0c4433b582af93510" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.11.0", "fluent-uri", "percent-encoding", "serde", "serde_json", ] +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + [[package]] name = "matchers" version = "0.2.0" @@ -3879,12 +4354,27 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + [[package]] name = "memchr" version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +[[package]] +name = "memfd" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" +dependencies = [ + "rustix 1.1.4", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -3940,7 +4430,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.11.0", "cfg-if", "cfg_aliases", "libc", @@ -3952,7 +4442,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.11.0", "cfg-if", "cfg_aliases", "libc", @@ -3964,7 +4454,7 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.11.0", "crossbeam-channel", "fsevent-sys", "inotify", @@ -4064,6 +4554,9 @@ version = "0.37.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" dependencies = [ + "crc32fast", + "hashbrown 0.15.5", + "indexmap", "memchr", ] @@ -4334,6 +4827,18 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + [[package]] name = "potential_utf" version = "0.1.3" @@ -4410,7 +4915,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.11.0", "getopts", "memchr", "pulldown-cmark-escape", @@ -4423,6 +4928,29 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" +[[package]] +name = "pulley-interpreter" +version = "42.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc2d61e068654529dc196437f8df0981db93687fdc67dec6a5de92363120b9da" +dependencies = [ + "cranelift-bitset", + "log", + "pulley-macros", + "wasmtime-internal-core", +] + +[[package]] +name = "pulley-macros" +version = "42.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f210c61b6ecfaebbba806b6d9113a222519d4e5cc4ab2d5ecca047bb7927ae" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "quick-junit" version = "0.5.2" @@ -4575,7 +5103,7 @@ version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82b568323e98e49e2a0899dcee453dd679fae22d69adf9b11dd508d1549b7e2f" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.11.0", ] [[package]] @@ -4609,6 +5137,20 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "regalloc2" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08effbc1fa53aaebff69521a5c05640523fab037b34a4a2c109506bc938246fa" +dependencies = [ + "allocator-api2", + "bumpalo", + "hashbrown 0.15.5", + "log", + "rustc-hash 2.1.1", + "smallvec", +] + [[package]] name = "regex" version = "1.12.3" @@ -4748,7 +5290,7 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a" dependencies = [ - "semver", + "semver 0.9.0", ] [[package]] @@ -4759,7 +5301,7 @@ checksum = "2aae838e49b3d63e9274e1c01833cc8139d3fec468c3b84688c628f44b1ae11d" dependencies = [ "bitflags 1.3.2", "errno", - "io-lifetimes", + "io-lifetimes 1.0.6", "libc", "linux-raw-sys 0.3.8", "windows-sys 0.45.0", @@ -4767,17 +5309,40 @@ dependencies = [ [[package]] name = "rustix" -version = "1.0.1" +version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dade4812df5c384711475be5fcd8c162555352945401aed22a35bffeab61f657" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.11.0", "errno", "libc", - "linux-raw-sys 0.9.2", + "linux-raw-sys 0.4.15", "windows-sys 0.59.0", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustix-linux-procfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" +dependencies = [ + "once_cell", + "rustix 1.1.4", +] + [[package]] name = "rustls" version = "0.23.23" @@ -4910,6 +5475,16 @@ dependencies = [ "semver-parser", ] +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "semver-parser" version = "0.7.0" @@ -5229,6 +5804,22 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "system-interface" +version = "0.27.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4592f674ce18521c2a81483873a49596655b179f71c5e05d10c1fe66c78745" +dependencies = [ + "bitflags 2.11.0", + "cap-fs-ext", + "cap-std", + "fd-lock", + "io-lifetimes 2.0.4", + "rustix 0.38.44", + "windows-sys 0.59.0", + "winx", +] + [[package]] name = "tag_ptr" version = "0.1.0" @@ -5241,6 +5832,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + [[package]] name = "target-triple" version = "1.0.0" @@ -5256,7 +5853,7 @@ dependencies = [ "fastrand", "getrandom 0.3.2", "once_cell", - "rustix 1.0.1", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -5285,7 +5882,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" dependencies = [ - "rustix 1.0.1", + "rustix 1.1.4", "windows-sys 0.60.2", ] @@ -5762,6 +6359,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "unit-prefix" version = "0.5.1" @@ -5952,6 +6555,299 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap", + "semver 1.0.27", + "serde", +] + +[[package]] +name = "wasmprinter" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09390d7b2bd7b938e563e4bff10aa345ef2e27a3bc99135697514ef54495e68f" +dependencies = [ + "anyhow", + "termcolor", + "wasmparser", +] + +[[package]] +name = "wasmtime" +version = "42.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39bef52be4fb4c5b47d36f847172e896bc94b35c9c6a6f07117686bd16ed89a7" +dependencies = [ + "addr2line 0.26.0", + "async-trait", + "bitflags 2.11.0", + "bumpalo", + "cc", + "cfg-if", + "encoding_rs", + "libc", + "log", + "mach2", + "memfd", + "object", + "once_cell", + "postcard", + "pulley-interpreter", + "rustix 1.1.4", + "semver 1.0.27", + "serde", + "serde_derive", + "smallvec", + "target-lexicon", + "wasmparser", + "wasmtime-environ", + "wasmtime-internal-component-macro", + "wasmtime-internal-component-util", + "wasmtime-internal-core", + "wasmtime-internal-cranelift", + "wasmtime-internal-fiber", + "wasmtime-internal-jit-debug", + "wasmtime-internal-jit-icache-coherence", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", + "wasmtime-internal-winch", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-environ" +version = "42.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb637d5aa960ac391ca5a4cbf3e45807632e56beceeeb530e14dfa67fdfccc62" +dependencies = [ + "anyhow", + "cranelift-bitset", + "cranelift-entity", + "gimli 0.33.0", + "hashbrown 0.15.5", + "indexmap", + "log", + "object", + "postcard", + "semver 1.0.27", + "serde", + "serde_derive", + "smallvec", + "target-lexicon", + "wasm-encoder", + "wasmparser", + "wasmprinter", + "wasmtime-internal-component-util", + "wasmtime-internal-core", +] + +[[package]] +name = "wasmtime-internal-component-macro" +version = "42.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca768b11d5e7de017e8c3d4d444da6b4ce3906f565bcbc253d76b4ecbb5d2869" +dependencies = [ + "anyhow", + "proc-macro2", + "quote", + "syn 2.0.106", + "wasmtime-internal-component-util", + "wasmtime-internal-wit-bindgen", + "wit-parser", +] + +[[package]] +name = "wasmtime-internal-component-util" +version = "42.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "763f504faf96c9b409051e96a1434655eea7f56a90bed9cb1e22e22c941253fd" + +[[package]] +name = "wasmtime-internal-core" +version = "42.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03a4a3f055a804a2f3d86e816a9df78a8fa57762212a8506164959224a40cd48" +dependencies = [ + "libm", +] + +[[package]] +name = "wasmtime-internal-cranelift" +version = "42.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55154a91d22ad51f9551124ce7fb49ddddc6a82c4910813db4c790c97c9ccf32" +dependencies = [ + "cfg-if", + "cranelift-codegen", + "cranelift-control", + "cranelift-entity", + "cranelift-frontend", + "cranelift-native", + "gimli 0.33.0", + "itertools 0.14.0", + "log", + "object", + "pulley-interpreter", + "smallvec", + "target-lexicon", + "thiserror 2.0.17", + "wasmparser", + "wasmtime-environ", + "wasmtime-internal-core", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-fiber" +version = "42.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05decfad1021ad2efcca5c1be9855acb54b6ee7158ac4467119b30b7481508e3" +dependencies = [ + "cc", + "cfg-if", + "libc", + "rustix 1.1.4", + "wasmtime-environ", + "wasmtime-internal-versioned-export-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-internal-jit-debug" +version = "42.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924980c50427885fd4feed2049b88380178e567768aaabf29045b02eb262eaa7" +dependencies = [ + "cc", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-jit-icache-coherence" +version = "42.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c57d24e8d1334a0e5a8b600286ffefa1fc4c3e8176b110dff6fbc1f43c4a599b" +dependencies = [ + "cfg-if", + "libc", + "wasmtime-internal-core", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-internal-unwinder" +version = "42.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a1a144bd4393593a868ba9df09f34a6a360cb5db6e71815f20d3f649c6e6735" +dependencies = [ + "cfg-if", + "cranelift-codegen", + "log", + "object", + "wasmtime-environ", +] + +[[package]] +name = "wasmtime-internal-versioned-export-macros" +version = "42.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a6948b56bb00c62dbd205ea18a4f1ceccbe1e4b8479651fdb0bab2553790f20" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "wasmtime-internal-winch" +version = "42.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9130b3ab6fb01be80b27b9a2c84817af29ae8224094f2503d2afa9fea5bf9d00" +dependencies = [ + "cranelift-codegen", + "gimli 0.33.0", + "log", + "object", + "target-lexicon", + "wasmparser", + "wasmtime-environ", + "wasmtime-internal-cranelift", + "winch-codegen", +] + +[[package]] +name = "wasmtime-internal-wit-bindgen" +version = "42.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "102d0d70dbfede00e4cc9c24e86df6d32c03bf6f5ad06b5d6c76b0a4a5004c4a" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "heck", + "indexmap", + "wit-parser", +] + +[[package]] +name = "wasmtime-wasi" +version = "42.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea938f6f4f11e5ffe6d8b6f34c9a994821db9511c3e9c98e535896f27d06bb92" +dependencies = [ + "async-trait", + "bitflags 2.11.0", + "bytes", + "cap-fs-ext", + "cap-net-ext", + "cap-rand", + "cap-std", + "cap-time-ext", + "fs-set-times", + "futures", + "io-extras", + "io-lifetimes 2.0.4", + "rustix 1.1.4", + "system-interface", + "thiserror 2.0.17", + "tokio", + "tracing", + "url", + "wasmtime", + "wasmtime-wasi-io", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-wasi-io" +version = "42.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71cb16a88d0443b509d6eca4298617233265179090abf03e0a8042b9b251e9da" +dependencies = [ + "async-trait", + "bytes", + "futures", + "tracing", + "wasmtime", +] + [[package]] name = "web-sys" version = "0.3.61" @@ -6012,6 +6908,25 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "winch-codegen" +version = "42.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1977857998e4dd70d26e2bfc0618a9684a2fb65b1eca174dc13f3b3e9c2159ca" +dependencies = [ + "cranelift-assembler-x64", + "cranelift-codegen", + "gimli 0.33.0", + "regalloc2", + "smallvec", + "target-lexicon", + "thiserror 2.0.17", + "wasmparser", + "wasmtime-environ", + "wasmtime-internal-core", + "wasmtime-internal-cranelift", +] + [[package]] name = "windows" version = "0.58.0" @@ -6506,13 +7421,41 @@ dependencies = [ "memchr", ] +[[package]] +name = "winx" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" +dependencies = [ + "bitflags 2.11.0", + "windows-sys 0.59.0", +] + [[package]] name = "wit-bindgen-rt" version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" dependencies = [ - "bitflags 2.9.3", + "bitflags 2.11.0", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver 1.0.27", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 4bf5218a4075..98c2df98759c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,11 @@ resolver = "2" # Use the newer version of the cargo resolver # https://doc.rust-lang.org/cargo/reference/resolver.html#resolver-versions members = ["crates/*", "xtask/codegen", "xtask/coverage", "xtask/glue", "xtask/rules_check"] +exclude = [ + "e2e-tests/wasm-plugins/plugins/boolean-naming", + "e2e-tests/wasm-plugins/plugins/css-style-conventions", + "e2e-tests/wasm-plugins/plugins/json-naming" +] [workspace.package] authors = ["Biome Developers and Contributors"] @@ -89,6 +94,8 @@ biome_module_graph = { path = "./crates/biome_module_graph", version = biome_package = { path = "./crates/biome_package", version = "0.5.7" } biome_parser = { path = "./crates/biome_parser", version = "0.5.7" } biome_plugin_loader = { path = "./crates/biome_plugin_loader", version = "0.0.1" } +biome_plugin_sdk = { path = "./crates/biome_plugin_sdk" } +biome_plugin_sdk_macros = { path = "./crates/biome_plugin_sdk_macros" } biome_project_layout = { path = "./crates/biome_project_layout", version = "0.0.1" } biome_resolver = { path = "./crates/biome_resolver", version = "0.1.0" } biome_rowan = { path = "./crates/biome_rowan", version = "0.5.7" } @@ -105,6 +112,7 @@ biome_text_edit = { path = "./crates/biome_text_edit", version = "0 biome_text_size = { path = "./crates/biome_text_size", version = "0.5.7" } biome_ungrammar = { path = "./crates/biome_ungrammar", version = "0.3.1" } biome_unicode_table = { path = "./crates/biome_unicode_table", version = "0.5.7" } +biome_wasm_plugin = { path = "./crates/biome_wasm_plugin", version = "0.0.1" } biome_yaml_factory = { path = "./crates/biome_yaml_factory", version = "0.0.1" } biome_yaml_parser = { path = "./crates/biome_yaml_parser", version = "0.0.1" } biome_yaml_syntax = { path = "./crates/biome_yaml_syntax", version = "0.0.1" } @@ -166,8 +174,15 @@ ureq = "3.1.4" url = "2.5.8" uuid = "1.18.1" walkdir = "2.5.0" +wasmtime = { version = "42", default-features = false, features = [ + "component-model", + "cranelift", + "std" +] } +wasmtime-wasi = { version = "42", default-features = false } web-time = "1.1.0" windows = "0.62.2" +wit-bindgen = "0.53" xtask_codegen = { path = "xtask/codegen" } xtask_coverage = { path = "xtask/coverage" } xtask_glue = { path = "xtask/glue" } diff --git a/crates/biome_analyze/CONTRIBUTING.md b/crates/biome_analyze/CONTRIBUTING.md index 8a5378c15480..df99254642d2 100644 --- a/crates/biome_analyze/CONTRIBUTING.md +++ b/crates/biome_analyze/CONTRIBUTING.md @@ -1595,6 +1595,78 @@ Stage and commit your changes: Then push your changes to your forked repository and open a pull request. +### Sidenote: WASM Plugin Rules + +In addition to native rules implemented in Rust, Biome supports external lint +rules written as WASM Component Model plugins. These are loaded at runtime and +participate in the same analyzer pipeline as built-in rules. + +#### How Plugin Rules Differ from Native Rules + +| Aspect | Native rules | Plugin rules | +|---|---|---| +| Implementation | Rust, compiled into Biome | Rust (or any language) → WASM, loaded at runtime | +| Category | Static (e.g. `lint/style/useConst`) | `plugin` + subcategory (e.g. `plugin/booleanNaming`) | +| Suppression format | `biome-ignore lint/style/useConst` | `biome-ignore lint/plugin/booleanNaming` | +| Configuration | `linter.rules..` | `plugins` array in `biome.json` | +| Languages | One rule type per language crate | One plugin can target JS, CSS, or JSON | + +#### Plugin Diagnostic Headers + +Plugin diagnostics use the `subcategory` field on `RuleDiagnostic` to display +the rule name in diagnostic headers. The WASM engine sets `category!("plugin")` +and calls `.subcategory(rule_name)`, resulting in headers like: + +``` +demo.js:9:7 plugin/booleanNaming ━━━━━━━━━━━━━━━ +``` + +#### Suppression Comments + +Plugin rules are suppressed using the `lint/plugin` prefix: + +```js +// biome-ignore lint/plugin/booleanNaming: this is intentional +const isActive = x == 1; +``` + +The suppression system parses `lint/plugin/ruleName` into +`category!("lint/plugin")` + subcategory `"ruleName"`, which is separate from +the diagnostic's own `category!("plugin")`. + +#### Multi-Language Support + +WASM plugins declare their target language via `target-language()`: +- `"javascript"` — access to JS syntax kinds, semantic model, and type inference +- `"css"` — access to CSS syntax kinds +- `"json"` — access to JSON syntax kinds + +Each language has a corresponding syntax kind module in `biome_plugin_sdk` +(`js_kinds`, `css_kinds`, `json_kinds`). + +#### Configuration in biome.json + +Plugins are configured in the `plugins` array. Each entry can be a simple path +or an object with options: + +```json +{ + "plugins": [ + "./plugins/simple-rule.wasm", + { + "path": "./plugins/configurable-rule.wasm", + "options": { "convention": "camelCase" }, + "rules": { + "myRule": "warn" + } + } + ] +} +``` + +See `crates/biome_plugin_sdk` for the guest SDK documentation and +`e2e-tests/wasm-plugins/` for working examples. + ### Sidenote: Deprecating a rule There are occasions when a rule must be deprecated to avoid breaking changes. diff --git a/crates/biome_analyze/Cargo.toml b/crates/biome_analyze/Cargo.toml index bf09f589f532..82ab054d9089 100644 --- a/crates/biome_analyze/Cargo.toml +++ b/crates/biome_analyze/Cargo.toml @@ -21,6 +21,7 @@ biome_diagnostics = { workspace = true } biome_parser = { workspace = true } biome_rowan = { workspace = true } biome_suppression = { workspace = true } +biome_text_edit = { workspace = true } camino = { workspace = true } enumflags2 = { workspace = true } indexmap = { workspace = true } diff --git a/crates/biome_analyze/src/analyzer_plugin.rs b/crates/biome_analyze/src/analyzer_plugin.rs index 870db3c9589a..37bb10b94853 100644 --- a/crates/biome_analyze/src/analyzer_plugin.rs +++ b/crates/biome_analyze/src/analyzer_plugin.rs @@ -1,28 +1,106 @@ +use biome_diagnostics::Applicability; +use biome_rowan::TextRange; use camino::{Utf8Path, Utf8PathBuf}; use rustc_hash::{FxHashMap, FxHashSet}; use std::hash::Hash; use std::{fmt::Debug, sync::Arc}; -use biome_rowan::{AnySyntaxNode, Language, RawSyntaxKind, SyntaxKind, SyntaxNode, WalkEvent}; +use biome_rowan::{ + AnySyntaxNode, AstNode, Language, RawSyntaxKind, SyntaxKind, SyntaxNode, WalkEvent, +}; use crate::matcher::SignalRuleKey; +use crate::options::PluginDomainFilter; +use crate::registry::Phases; +use crate::rule::RuleDomain; +use crate::services::ServiceBag; use crate::{ PluginSignal, RuleCategory, RuleDiagnostic, SignalEntry, Visitor, VisitorContext, profiling, }; +/// A single text replacement edit from a plugin code action. +#[derive(Debug, Clone)] +pub struct PluginTextEdit { + pub range: TextRange, + pub replacement: String, +} + +/// A code action (fix) produced by a plugin rule. +#[derive(Debug, Clone)] +pub struct PluginCodeAction { + pub message: String, + /// How safe it is to automatically apply this action. + pub applicability: Applicability, + pub edits: Vec, +} + +/// A diagnostic paired with code actions from a plugin. +#[derive(Debug)] +pub struct PluginDiagnosticEntry { + pub diagnostic: RuleDiagnostic, + pub actions: Vec, +} + +/// Result returned by [`AnalyzerPlugin::evaluate`]. +#[derive(Debug, Default)] +pub struct PluginEvaluationResult { + pub diagnostics: Vec, +} + +impl PluginEvaluationResult { + /// Create a result with diagnostics only (no code actions). + pub fn from_diagnostics(diagnostics: Vec) -> Self { + Self { + diagnostics: diagnostics + .into_iter() + .map(|d| PluginDiagnosticEntry { + diagnostic: d, + actions: vec![], + }) + .collect(), + } + } +} + /// Slice of analyzer plugins that can be cheaply cloned. -pub type AnalyzerPluginSlice<'a> = &'a [Arc>]; +pub type AnalyzerPluginSlice<'a> = &'a [Arc]; /// Vector of analyzer plugins that can be cheaply cloned. -pub type AnalyzerPluginVec = Vec>>; +pub type AnalyzerPluginVec = Vec>; /// Definition of an analyzer plugin. +/// +/// Implemented by WASM, GritQL, and JavaScript plugin loaders. Each plugin +/// exposes one or more lint rules that participate in the analyzer pipeline +/// alongside native rules. +/// +/// The analyzer calls [`query`](Self::query) to learn which syntax node kinds +/// the plugin cares about, then invokes [`evaluate`](Self::evaluate) for each +/// matching node during traversal. Diagnostics returned by `evaluate` are +/// displayed with the category `plugin/`. pub trait AnalyzerPlugin: Debug + Send + Sync { + /// The target language this plugin analyzes (JavaScript, CSS, or JSON). fn language(&self) -> PluginTargetLanguage; + /// The analysis phase this plugin runs in. + /// Override to [`Phases::Semantic`] to access the semantic model via services. + fn phase(&self) -> Phases { + Phases::Syntax + } + + /// Syntax node kinds this plugin wants to inspect, as raw `u32` values. + /// The analyzer only calls [`evaluate`](Self::evaluate) for nodes whose + /// kind is in this list. fn query(&self) -> Vec; - fn evaluate(&self, node: AnySyntaxNode, path: Arc) -> Vec; + /// Evaluate a matched syntax node and return diagnostics with optional + /// code actions. Called once per matched node during tree traversal. + fn evaluate( + &self, + node: AnySyntaxNode, + path: Arc, + services: &ServiceBag, + ) -> PluginEvaluationResult; /// Returns true if this plugin should run on the given file path. /// @@ -31,126 +109,64 @@ pub trait AnalyzerPlugin: Debug + Send + Sync { fn applies_to_file(&self, _path: &Utf8Path) -> bool { true } -} - -#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] -pub enum PluginTargetLanguage { - JavaScript, - Css, - Json, -} -/// A syntax visitor that queries nodes and evaluates in a plugin. -/// Based on [`crate::SyntaxVisitor`]. -pub struct PluginVisitor { - query: FxHashSet, - plugin: Arc>, + /// The rule name used for diagnostic headers, suppression comments, and + /// per-rule configuration overrides. For example, `"booleanNaming"` results + /// in the header `plugin/booleanNaming` and suppression comment + /// `biome-ignore lint/plugin/booleanNaming`. + fn rule_name(&self) -> &str; - /// When set, all nodes in this subtree are skipped until we leave it. - /// Used to skip subtrees that fall entirely outside the analysis range - /// (see the `ctx.range` check in `visit`). - skip_subtree: Option>, -} - -impl PluginVisitor -where - L: Language + 'static, - L::Kind: Eq + Hash, -{ - /// Creates a syntax visitor from the plugin. - /// - /// # Safety - /// Do not forget to check the plugin is targeted for the language `L`. - pub unsafe fn new_unchecked(plugin: Arc>) -> Self { - let query = plugin.query().into_iter().map(L::Kind::from_raw).collect(); - - Self { - query, - plugin, - skip_subtree: None, - } + /// The rule category (lint, action, syntax, transformation). Defaults to Lint. + fn category(&self) -> RuleCategory { + RuleCategory::Lint } -} - -impl Visitor for PluginVisitor -where - L: Language + 'static, - L::Kind: Eq + Hash, -{ - type Language = L; - - fn visit( - &mut self, - event: &WalkEvent>, - ctx: VisitorContext, - ) { - let node = match event { - WalkEvent::Enter(node) => node, - WalkEvent::Leave(node) => { - if let Some(skip_subtree) = &self.skip_subtree - && skip_subtree == node - { - self.skip_subtree = None; - } - return; - } - }; - - if self.skip_subtree.is_some() { - return; - } - - if let Some(range) = ctx.range - && node.text_range_with_trivia().ordering(range).is_ne() - { - self.skip_subtree = Some(node.clone()); - return; - } + /// Domains this rule belongs to (e.g. React, Test). When a rule has + /// domains, it is only enabled when the user opts into those domains. + /// Defaults to empty (always enabled if recommended). + fn domains(&self) -> &[RuleDomain] { + &[] + } - // TODO: Integrate to [`VisitorContext::match_query`]? - let kind = node.kind(); - if !self.query.contains(&kind) { - return; - } + /// Whether this rule is recommended (enabled by default). Defaults to `true`. + fn is_recommended(&self) -> bool { + true + } - if !self.plugin.applies_to_file(&ctx.options.file_path) { - return; - } + /// If the rule is deprecated, returns the deprecation reason string. + fn deprecated(&self) -> Option<&str> { + None + } - let rule_timer = profiling::start_plugin_rule("plugin"); - let diagnostics = self - .plugin - .evaluate(node.clone().into(), ctx.options.file_path.clone()); - rule_timer.stop(); - - let signals = diagnostics.into_iter().map(|diagnostic| { - let name = diagnostic - .subcategory - .clone() - .unwrap_or_else(|| "anonymous".into()); - - SignalEntry { - text_range: diagnostic.span().unwrap_or_default(), - signal: Box::new(PluginSignal::::new(diagnostic)), - rule: SignalRuleKey::Plugin(name.into()), - category: RuleCategory::Lint, - instances: Default::default(), - } - }); + /// GitHub issue number for rules still under development. When set, + /// a footnote with a link to the issue is added to diagnostics. + fn issue_number(&self) -> Option<&str> { + None + } - ctx.signal_queue.extend(signals); + /// Trigger strings for host-side pre-filtering. If non-empty, the host + /// skips calling [`evaluate`](Self::evaluate) when the file source text + /// doesn't contain any of these strings (case-insensitive ASCII). + /// Return an empty slice to always be called (the default). + fn source_triggers(&self) -> &[String] { + &[] } } -/// A batched syntax visitor that evaluates multiple plugins in a single visitor. +#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] +pub enum PluginTargetLanguage { + JavaScript, + Css, + Json, +} + +/// Syntax visitor that evaluates analyzer plugins during tree traversal. /// -/// Instead of registering N separate `PluginVisitor` instances (one per plugin), -/// this holds all plugins together and dispatches using a kind-to-plugin lookup -/// map. This reduces visitor-dispatch overhead and enables O(1) kind matching -/// per node instead of iterating all plugins. +/// Holds all plugins together and dispatches using a kind-to-plugin lookup +/// map. This enables O(1) kind matching per node instead of iterating all +/// plugins. pub struct BatchPluginVisitor { - plugins: Vec>>, + plugins: Vec>, /// Maps each syntax kind to the indices of plugins that query for it. kind_to_plugins: FxHashMap>, @@ -163,6 +179,11 @@ pub struct BatchPluginVisitor { /// Cached per-plugin results of `applies_to_file`. Populated lazily on /// first `WalkEvent::Enter` — the file path is constant for the entire walk. applicable: Option>, + + /// Cached per-plugin results of source-trigger pre-filtering. `true` means + /// the file source contains at least one trigger string (or the plugin has + /// no triggers). Populated lazily on first `WalkEvent::Enter`. + trigger_matched: Option>, } impl BatchPluginVisitor @@ -196,6 +217,7 @@ where kind_to_plugins, skip_subtree: None, applicable: None, + trigger_matched: None, } } } @@ -249,30 +271,131 @@ where .collect() }); + // Lazily compute source-trigger pre-filter results for each plugin. + // Uses the file root's source text for a one-time check per file. + let trigger_matched = self.trigger_matched.get_or_insert_with(|| { + // Navigate to the root node to get the full file source text. + let root_node = { + let mut n = node.clone(); + while let Some(parent) = n.parent() { + n = parent; + } + n + }; + let source = root_node.text_with_trivia().to_string(); + self.plugins + .iter() + .map(|p| { + let triggers = p.source_triggers(); + if triggers.is_empty() { + return true; // no triggers = always run + } + triggers.iter().any(|t| { + let t_bytes = t.as_bytes(); + source + .as_bytes() + .windows(t_bytes.len()) + .any(|w| w.eq_ignore_ascii_case(t_bytes)) + }) + }) + .collect() + }); + for &idx in plugin_indices { - if !applicable[idx] { + if !applicable[idx] || !trigger_matched[idx] { continue; } let plugin = &self.plugins[idx]; + + // Per-rule disable check via configuration overrides. + let rule_name = plugin.rule_name(); + if let Some(ovr) = ctx.options.plugin_rule_override(rule_name) + && ovr.disabled + { + continue; + } + + // Domain filtering: skip plugin if its domain is disabled or + // restricted to recommended-only and the plugin is not recommended. + let plugin_domains = plugin.domains(); + if !plugin_domains.is_empty() { + let domain_cfg = ctx.options.linter_domains(); + if !domain_cfg.is_empty() { + let mut skip = false; + for domain in plugin_domains { + match domain_cfg.get(domain) { + Some(&PluginDomainFilter::Disabled) => { + skip = true; + break; + } + Some(&PluginDomainFilter::Recommended) if !plugin.is_recommended() => { + skip = true; + break; + } + _ => {} + } + } + if skip { + continue; + } + } + } + let rule_timer = profiling::start_plugin_rule("plugin"); - let diagnostics = plugin.evaluate(node.clone().into(), ctx.options.file_path.clone()); + let result = plugin.evaluate( + node.clone().into(), + ctx.options.file_path.clone(), + ctx.services, + ); rule_timer.stop(); - let signals = diagnostics.into_iter().map(|diagnostic| { - let name = diagnostic - .subcategory - .clone() - .unwrap_or_else(|| "anonymous".into()); - - SignalEntry { - text_range: diagnostic.span().unwrap_or_default(), - signal: Box::new(PluginSignal::::new(diagnostic)), - rule: SignalRuleKey::Plugin(name.into()), - category: RuleCategory::Lint, + if result.diagnostics.is_empty() { + continue; + } + + // Obtain the full source text from the file root — needed for + // constructing TextEdit diffs for plugin code actions. + let has_any_action = result.diagnostics.iter().any(|e| !e.actions.is_empty()); + let source_text: Arc = if has_any_action { + ctx.root.syntax().text_with_trivia().to_string().into() + } else { + Arc::from("") + }; + + let root = ctx.root; + let suppression_action = ctx.suppression_action; + let options = ctx.options; + let plugin_category = plugin.category(); + let plugin_deprecated: Option> = plugin.deprecated().map(Arc::from); + let plugin_issue_number: Option> = plugin.issue_number().map(Arc::from); + + // Compute a stable rule id once — used for SignalRuleKey, + // suppression text, and fixKind lookups so they never drift apart. + let canonical_id: Arc = Arc::from(rule_name); + + let signals = result + .diagnostics + .into_iter() + .map(move |entry| SignalEntry { + text_range: entry.diagnostic.span().unwrap_or_default(), + signal: Box::new( + PluginSignal::::new( + entry.diagnostic, + entry.actions, + Arc::clone(&source_text), + root, + suppression_action, + options, + ) + .with_category(plugin_category) + .with_deprecated(plugin_deprecated.clone()) + .with_issue_number(plugin_issue_number.clone()), + ), + rule: SignalRuleKey::Plugin(Box::from(&*canonical_id)), + category: plugin_category, instances: Default::default(), - } - }); + }); ctx.signal_queue.extend(signals); } diff --git a/crates/biome_analyze/src/diagnostics.rs b/crates/biome_analyze/src/diagnostics.rs index 5622716a5122..3f20e317dac9 100644 --- a/crates/biome_analyze/src/diagnostics.rs +++ b/crates/biome_analyze/src/diagnostics.rs @@ -51,6 +51,14 @@ impl Diagnostic for AnalyzerDiagnostic { DiagnosticKind::Raw(error) => error.category(), } } + + fn subcategory(&self) -> Option<&str> { + match &self.kind { + DiagnosticKind::Rule(rule_diagnostic) => rule_diagnostic.subcategory.as_deref(), + DiagnosticKind::Raw(error) => error.subcategory(), + } + } + fn description(&self, fmt: &mut Formatter<'_>) -> std::fmt::Result { match &self.kind { DiagnosticKind::Rule(rule_diagnostic) => Debug::fmt(&rule_diagnostic.message, fmt), @@ -168,7 +176,7 @@ impl AnalyzerDiagnostic { pub fn add_code_suggestion(mut self, suggestion: CodeSuggestionAdvice) -> Self { self.kind = match self.kind { DiagnosticKind::Rule(mut rule_diagnostic) => { - rule_diagnostic.tags = DiagnosticTags::FIXABLE; + rule_diagnostic.tags |= DiagnosticTags::FIXABLE; DiagnosticKind::Rule(rule_diagnostic) } DiagnosticKind::Raw(error) => { @@ -197,6 +205,14 @@ impl AnalyzerDiagnostic { pub const fn is_raw(&self) -> bool { matches!(self.kind, DiagnosticKind::Raw(_)) } + + /// Returns the subcategory (plugin rule name) for Rule diagnostics. + pub fn subcategory(&self) -> Option<&str> { + match &self.kind { + DiagnosticKind::Rule(rule_diagnostic) => rule_diagnostic.subcategory.as_deref(), + DiagnosticKind::Raw(error) => error.subcategory(), + } + } } #[derive(Debug, Diagnostic, Clone)] diff --git a/crates/biome_analyze/src/lib.rs b/crates/biome_analyze/src/lib.rs index 06f8d42cd354..127acc281104 100644 --- a/crates/biome_analyze/src/lib.rs +++ b/crates/biome_analyze/src/lib.rs @@ -30,8 +30,8 @@ mod visitor; pub use biome_diagnostics::category_concat; pub use crate::analyzer_plugin::{ - AnalyzerPlugin, AnalyzerPluginSlice, AnalyzerPluginVec, BatchPluginVisitor, - PluginTargetLanguage, PluginVisitor, + AnalyzerPlugin, AnalyzerPluginSlice, AnalyzerPluginVec, BatchPluginVisitor, PluginCodeAction, + PluginDiagnosticEntry, PluginEvaluationResult, PluginTargetLanguage, PluginTextEdit, }; pub use crate::categories::{ ActionCategory, OtherActionCategory, RefactorKind, RuleCategories, RuleCategoriesBuilder, @@ -41,7 +41,9 @@ pub use crate::categories::{ pub use crate::diagnostics::{AnalyzerDiagnostic, AnalyzerSuppressionDiagnostic, RuleError}; use crate::matcher::SignalRuleKey; pub use crate::matcher::{InspectMatcher, MatchQueryParams, QueryMatcher, RuleKey, SignalEntry}; -pub use crate::options::{AnalyzerConfiguration, AnalyzerOptions, AnalyzerRules}; +pub use crate::options::{ + AnalyzerConfiguration, AnalyzerOptions, AnalyzerRules, PluginDomainFilter, PluginRuleOverride, +}; pub use crate::query::{AddVisitor, QueryKey, QueryMatch, Queryable}; pub use crate::registry::{ LanguageRoot, MetadataRegistry, Phase, Phases, RegistryRuleMetadata, RegistryVisitor, diff --git a/crates/biome_analyze/src/options.rs b/crates/biome_analyze/src/options.rs index ed793c559b80..c67d6370e3e3 100644 --- a/crates/biome_analyze/src/options.rs +++ b/crates/biome_analyze/src/options.rs @@ -1,11 +1,35 @@ +use biome_diagnostics::Severity; use camino::Utf8PathBuf; use rustc_hash::FxHashMap; +use crate::rule::RuleDomain; use crate::{FixKind, Rule, RuleKey}; use std::any::{Any, TypeId}; use std::borrow::Cow; use std::sync::Arc; +/// Override configuration for a single plugin rule. +#[derive(Debug, Clone)] +pub struct PluginRuleOverride { + /// When `true`, the rule is disabled and should produce no diagnostics. + pub disabled: bool, + /// Severity override. When `Some`, replaces the plugin's default severity. + pub severity: Option, + /// Fix kind override. When `Some`, replaces the plugin's default fix kind. + pub fix_kind: Option, +} + +/// Controls which plugin rules are active within a linter domain. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PluginDomainFilter { + /// All rules in the domain are active. + All, + /// Only recommended rules in the domain are active. + Recommended, + /// No rules in the domain are active. + Disabled, +} + /// A convenient new type data structure to store the options that belong to a rule #[derive(Debug)] pub struct RuleOptions(TypeId, Box, Option); @@ -82,6 +106,12 @@ pub struct AnalyzerConfiguration { /// The JSX fragment factory function identifier (e.g., "Fragment") /// Only applies when jsx_runtime is ReactClassic. jsx_fragment_factory: Option>, + + /// Per-rule overrides for plugin rules, keyed by rule name. + plugin_rule_overrides: FxHashMap, PluginRuleOverride>, + + /// Domain-level filtering for linter domains that affects plugin rules. + linter_domains: FxHashMap, } impl AnalyzerConfiguration { @@ -129,6 +159,21 @@ impl AnalyzerConfiguration { self.preferred_indentation = preferred_indentation; self } + + /// Adds a per-rule override for a plugin rule. + pub fn push_plugin_rule_override( + &mut self, + name: impl Into>, + rule_override: PluginRuleOverride, + ) { + self.plugin_rule_overrides + .insert(name.into(), rule_override); + } + + /// Sets the domain-level filtering map for plugin rules. + pub fn set_linter_domains(&mut self, domains: FxHashMap) { + self.linter_domains = domains; + } } /// A set of information useful to the analyzer infrastructure @@ -230,6 +275,31 @@ impl AnalyzerOptions { pub fn preferred_indentation(&self) -> PreferredIndentation { self.configuration.preferred_indentation } + + /// Returns the override for the given plugin rule, if configured. + pub fn plugin_rule_override(&self, name: &str) -> Option<&PluginRuleOverride> { + self.configuration.plugin_rule_overrides.get(name) + } + + /// Returns the domain-level filtering map for plugin rules. + pub fn linter_domains(&self) -> &FxHashMap { + &self.configuration.linter_domains + } + + /// Returns the raw plugin rule overrides map. + pub fn plugin_rule_overrides_map(&self) -> &FxHashMap, PluginRuleOverride> { + &self.configuration.plugin_rule_overrides + } + + /// Adds a per-rule override for a plugin rule. + pub fn push_plugin_rule_override( + &mut self, + name: impl Into>, + rule_override: PluginRuleOverride, + ) { + self.configuration + .push_plugin_rule_override(name, rule_override); + } } #[derive(Clone, Copy, Debug, Default)] diff --git a/crates/biome_analyze/src/rule.rs b/crates/biome_analyze/src/rule.rs index eb45fcf475af..26d09e8aadcd 100644 --- a/crates/biome_analyze/src/rule.rs +++ b/crates/biome_analyze/src/rule.rs @@ -1,9 +1,8 @@ use crate::categories::{ActionCategory, RuleCategory}; use crate::context::RuleContext; use crate::registry::{RegistryVisitor, RuleLanguage, RuleSuppressions}; -use crate::{ - Phase, Phases, Queryable, SourceActionKind, SuppressionAction, SuppressionCommentEmitterPayload, -}; +use crate::suppression_action::{make_inline_suppression, make_top_level_suppression}; +use crate::{Phase, Phases, Queryable, SourceActionKind, SuppressionAction}; use biome_console::fmt::{Display, Formatter}; use biome_console::{MarkupBuf, markup}; use biome_diagnostics::location::AsSpan; @@ -12,7 +11,7 @@ use biome_diagnostics::{ Visit, }; use biome_diagnostics::{Applicability, Severity}; -use biome_rowan::{AstNode, BatchMutation, BatchMutationExt, Language, TextRange, TextSize}; +use biome_rowan::{BatchMutation, Language, TextRange, TextSize}; use std::borrow::Cow; use std::cmp::Ordering; use std::fmt::Debug; @@ -306,8 +305,7 @@ impl<'a> RuleSource<'a> { Self::EslintJson(_) => 41, Self::EslintMarkdown(_) => 42, Self::EslintYml(_) => 43, - Self::SortPackageJson => 44, - + Self::SortPackageJson => 44, } } @@ -1339,32 +1337,15 @@ pub trait Rule: RuleMeta + Sized { ::NAME, Self::METADATA.name ); - let suppression_text = format!("biome-ignore-all {rule_category}"); - let root = ctx.root(); - - if let Some(first_token) = root.syntax().first_token() { - let mut mutation = root.begin(); - let comment = - suppression_action.suppression_top_level_comment(suppression_text.as_str()); - suppression_action.apply_top_level_suppression( - &mut mutation, - first_token, - comment.as_str(), - ); - let message = if category == RuleCategory::Action { - "action" - } else { - "rule" - }; - return Some(SuppressAction { - mutation, - message: - markup! { "Suppress " {message} " " {rule_category} " for the whole file."} - .to_owned(), - }); - } + let kind_label = if category == RuleCategory::Action { + "action" + } else { + "rule" + }; + make_top_level_suppression(&rule_category, kind_label, &ctx.root(), suppression_action) + } else { + None } - None } /// Create a code action that allows to suppress the rule. The function @@ -1390,29 +1371,19 @@ pub trait Rule: RuleMeta + Sized { ::NAME, Self::METADATA.name ); - let suppression_text = format!("biome-ignore {rule_category}"); - let root = ctx.root(); - let token = root.syntax().token_at_offset(text_range.start()); - let mut mutation = root.begin(); - suppression_action.inline_suppression(SuppressionCommentEmitterPayload { - suppression_text: suppression_text.as_str(), - mutation: &mut mutation, - token_offset: token, - diagnostic_text_range: text_range, - suppression_reason: suppression_reason.unwrap_or(""), - }); - - let message = if category == RuleCategory::Action { + let kind_label = if category == RuleCategory::Action { "action" } else { "rule" }; - - Some(SuppressAction { - mutation, - message: markup! { "Suppress " {message} " " {rule_category} " for this line."} - .to_owned(), - }) + Some(make_inline_suppression( + &rule_category, + kind_label, + &ctx.root(), + text_range, + suppression_action, + suppression_reason.unwrap_or(""), + )) } else { None } @@ -1450,6 +1421,10 @@ impl Diagnostic for RuleDiagnostic { Some(self.category) } + fn subcategory(&self) -> Option<&str> { + self.subcategory.as_deref() + } + fn message(&self, fmt: &mut Formatter<'_>) -> std::io::Result<()> { fmt.write_markup(markup! {{self.message}}) } diff --git a/crates/biome_analyze/src/signals.rs b/crates/biome_analyze/src/signals.rs index e4dcd71db327..6628fc4b2e42 100644 --- a/crates/biome_analyze/src/signals.rs +++ b/crates/biome_analyze/src/signals.rs @@ -1,19 +1,24 @@ +use crate::analyzer_plugin::{PluginCodeAction, PluginTextEdit}; use crate::categories::{ SUPPRESSION_INLINE_ACTION_CATEGORY, SUPPRESSION_TOP_LEVEL_ACTION_CATEGORY, }; +use crate::registry::LanguageRoot; use crate::{ - AnalyzerDiagnostic, AnalyzerOptions, OtherActionCategory, Queryable, RuleDiagnostic, RuleGroup, - ServiceBag, SuppressionAction, + AnalyzerDiagnostic, AnalyzerOptions, OtherActionCategory, Queryable, RuleCategory, + RuleDiagnostic, RuleGroup, ServiceBag, SuppressionAction, categories::ActionCategory, context::RuleContext, registry::{RuleLanguage, RuleRoot}, rule::Rule, + suppression_action::{make_inline_suppression, make_top_level_suppression}, }; use biome_console::{MarkupBuf, markup}; use biome_diagnostics::{Applicability, CodeSuggestion, Error, advice::CodeSuggestionAdvice}; -use biome_rowan::{BatchMutation, Language}; +use biome_rowan::{AstNode, BatchMutation, Language, TextRange}; +use biome_text_edit::TextEdit; use std::iter::FusedIterator; use std::marker::PhantomData; +use std::sync::Arc; use std::vec::IntoIter; /// Event raised by the analyzer when a [Rule](crate::Rule) @@ -109,27 +114,248 @@ where /// Unlike [DiagnosticSignal] which converts through [Error] into /// [DiagnosticKind::Raw](crate::diagnostics::DiagnosticKind::Raw), this type /// directly converts via `AnalyzerDiagnostic::from(RuleDiagnostic)`. -pub struct PluginSignal { +pub struct PluginSignal<'phase, L: Language> { diagnostic: RuleDiagnostic, - _phantom: PhantomData, -} - -impl PluginSignal { - pub fn new(diagnostic: RuleDiagnostic) -> Self { + actions: Vec, + /// Full source text of the file being analyzed — needed to construct + /// `TextEdit` diffs for plugin code actions. Wrapped in `Arc` to avoid + /// cloning the full source string for each diagnostic signal. + source_text: Arc, + root: &'phase LanguageRoot, + suppression_action: &'phase dyn SuppressionAction, + options: &'phase AnalyzerOptions, + category: RuleCategory, + deprecated: Option>, + issue_number: Option>, +} + +impl<'phase, L: Language> PluginSignal<'phase, L> { + pub fn new( + diagnostic: RuleDiagnostic, + actions: Vec, + source_text: Arc, + root: &'phase LanguageRoot, + suppression_action: &'phase dyn SuppressionAction, + options: &'phase AnalyzerOptions, + ) -> Self { Self { diagnostic, - _phantom: PhantomData, + actions, + source_text, + root, + suppression_action, + options, + category: RuleCategory::Lint, + deprecated: None, + issue_number: None, + } + } + + pub fn with_category(mut self, category: RuleCategory) -> Self { + self.category = category; + self + } + + pub fn with_deprecated(mut self, deprecated: Option>) -> Self { + self.deprecated = deprecated; + self + } + + pub fn with_issue_number(mut self, issue_number: Option>) -> Self { + self.issue_number = issue_number; + self + } +} + +/// Apply plugin text edits to a source string, returning the (spanning range, TextEdit diff). +/// +/// The edits are sorted by start position and applied from last to first so that +/// earlier offsets remain valid. The spanning range covers the union of all edits. +fn apply_plugin_edits(source: &str, edits: &[PluginTextEdit]) -> Option<(TextRange, TextEdit)> { + if edits.is_empty() { + return None; + } + + // Sort by start offset (ascending). + let mut sorted: Vec<&PluginTextEdit> = edits.iter().collect(); + sorted.sort_by_key(|e| e.range.start()); + + // Bail out if any edits overlap. + for pair in sorted.windows(2) { + if pair[0].range.end() > pair[1].range.start() { + return None; + } + } + + // Compute the spanning range over all edits. + let span_start = sorted.first()?.range.start(); + let span_end = sorted.iter().map(|e| e.range.end()).max()?; + let span = TextRange::new(span_start, span_end); + + let old_slice = source.get(usize::from(span_start)..usize::from(span_end))?; + + // Build the new text by applying edits within the span. + let mut new_text = String::new(); + let mut cursor = span_start; + for edit in &sorted { + // Copy text before this edit. + if edit.range.start() > cursor { + let before = source.get(usize::from(cursor)..usize::from(edit.range.start()))?; + new_text.push_str(before); } + new_text.push_str(&edit.replacement); + cursor = edit.range.end(); } + // Copy remaining text after the last edit within the span. + if cursor < span_end { + let after = source.get(usize::from(cursor)..usize::from(span_end))?; + new_text.push_str(after); + } + + let text_edit = TextEdit::from_unicode_words(old_slice, &new_text); + Some((span, text_edit)) } -impl AnalyzerSignal for PluginSignal { +impl AnalyzerSignal for PluginSignal<'_, L> { fn diagnostic(&self) -> Option { - Some(AnalyzerDiagnostic::from(self.diagnostic.clone())) + let mut rule_diag = self.diagnostic.clone(); + + if let Some(issue_number) = &self.issue_number { + let url = format!("https://github.com/biomejs/biome/issues/{issue_number}"); + rule_diag = rule_diag.note(markup! { + "This rule is still under development. Visit "{url.as_str()}" for details." + }); + } + if let Some(reason) = &self.deprecated { + let reason: &str = reason; + rule_diag = rule_diag.note(markup! { "Deprecated: " {reason} }); + } + + let mut diag = AnalyzerDiagnostic::from(rule_diag); + + let plugin_name = self + .diagnostic + .subcategory + .as_deref() + .unwrap_or("anonymous"); + let fix_kind_override = self + .options + .plugin_rule_override(plugin_name) + .and_then(|o| o.fix_kind); + + if fix_kind_override != Some(crate::FixKind::None) { + for action in &self.actions { + if let Some((_span, text_edit)) = + apply_plugin_edits(&self.source_text, &action.edits) + { + let applicability = match fix_kind_override { + Some(crate::FixKind::Safe) => Applicability::Always, + Some(crate::FixKind::Unsafe) => Applicability::MaybeIncorrect, + _ => action.applicability, + }; + let suggestion = CodeSuggestionAdvice { + applicability, + msg: markup!({ action.message }).to_owned(), + suggestion: text_edit, + }; + diag = diag.add_code_suggestion(suggestion); + } + } + } + + Some(diag) } fn actions(&self) -> AnalyzerActionIter { - AnalyzerActionIter::new(vec![]) + let mut actions = Vec::new(); + + let plugin_name = self + .diagnostic + .subcategory + .as_deref() + .unwrap_or("anonymous"); + let suppression_prefix = self.category.as_suppression_category(); + let rule_category = format!("{suppression_prefix}/plugin/{plugin_name}"); + let kind_label = if self.category == RuleCategory::Action { + "action" + } else { + "rule" + }; + let suppression_reason = self + .options + .suppression_reason + .as_deref() + .unwrap_or(""); + + // Inline suppression + if let Some(text_range) = self.diagnostic.span() { + let suppress = make_inline_suppression( + &rule_category, + kind_label, + self.root, + &text_range, + self.suppression_action, + suppression_reason, + ); + actions.push(AnalyzerAction { + rule_name: None, + category: ActionCategory::Other(OtherActionCategory::InlineSuppression), + applicability: Applicability::Always, + mutation: suppress.mutation, + message: suppress.message, + text_edit: None, + }); + } + + // Top-level suppression + if let Some(suppress) = make_top_level_suppression( + &rule_category, + kind_label, + self.root, + self.suppression_action, + ) { + actions.push(AnalyzerAction { + rule_name: None, + category: ActionCategory::Other(OtherActionCategory::ToplevelSuppression), + applicability: Applicability::Always, + mutation: suppress.mutation, + message: suppress.message, + text_edit: None, + }); + } + + // Plugin fix actions (quick-fixes from code actions) + let fix_kind_override = self + .options + .plugin_rule_override(plugin_name) + .and_then(|o| o.fix_kind); + + if fix_kind_override != Some(crate::FixKind::None) && !self.actions.is_empty() { + let noop_mutation = BatchMutation::new(self.root.syntax().clone()); + for plugin_action in &self.actions { + if let Some((span, text_edit)) = + apply_plugin_edits(&self.source_text, &plugin_action.edits) + { + let applicability = match fix_kind_override { + Some(crate::FixKind::Safe) => Applicability::Always, + Some(crate::FixKind::Unsafe) => Applicability::MaybeIncorrect, + _ => plugin_action.applicability, + }; + actions.push(AnalyzerAction { + rule_name: None, + category: ActionCategory::QuickFix(std::borrow::Cow::Borrowed( + "quickfix.plugin", + )), + applicability, + message: markup!({ plugin_action.message }).to_owned(), + mutation: noop_mutation.clone(), + text_edit: Some((span, text_edit)), + }); + } + } + } + + AnalyzerActionIter::new(actions) } fn transformations(&self) -> AnalyzerTransformationIter { @@ -149,6 +375,9 @@ pub struct AnalyzerAction { pub applicability: Applicability, pub message: MarkupBuf, pub mutation: BatchMutation, + /// Plugin text edit — when `Some`, `process_action` applies this instead of + /// `mutation`. The tuple contains the spanning range and the text edit diff. + pub text_edit: Option<(TextRange, TextEdit)>, } impl AnalyzerAction { @@ -179,7 +408,12 @@ impl Default for AnalyzerActionIter { impl From> for CodeSuggestionAdvice { fn from(action: AnalyzerAction) -> Self { - let (_, suggestion) = action.mutation.to_text_range_and_edit().unwrap_or_default(); + let suggestion = if let Some((_range, text_edit)) = action.text_edit { + text_edit + } else { + let (_, suggestion) = action.mutation.to_text_range_and_edit().unwrap_or_default(); + suggestion + }; Self { applicability: action.applicability, msg: action.message, @@ -190,7 +424,11 @@ impl From> for CodeSuggestionAdvice { impl From> for CodeSuggestionItem { fn from(action: AnalyzerAction) -> Self { - let (range, suggestion) = action.mutation.to_text_range_and_edit().unwrap_or_default(); + let (range, suggestion) = if let Some((range, text_edit)) = action.text_edit { + (range, text_edit) + } else { + action.mutation.to_text_range_and_edit().unwrap_or_default() + }; Self { rule_name: action.rule_name, @@ -471,6 +709,7 @@ where category: action.category, mutation: action.mutation, message: action.message, + text_edit: None, }); }; if let Some(text_range) = R::text_range(&ctx, &self.state) @@ -487,6 +726,7 @@ where applicability: Applicability::Always, mutation: suppression_action.mutation, message: suppression_action.message, + text_edit: None, }; actions.push(action); } @@ -500,6 +740,7 @@ where applicability: Applicability::Always, mutation: suppression_action.mutation, message: suppression_action.message, + text_edit: None, }; actions.push(action); } diff --git a/crates/biome_analyze/src/suppression_action.rs b/crates/biome_analyze/src/suppression_action.rs index d3cfd5f2cdae..f0305d3b4a24 100644 --- a/crates/biome_analyze/src/suppression_action.rs +++ b/crates/biome_analyze/src/suppression_action.rs @@ -1,7 +1,9 @@ use crate::SuppressionCommentEmitterPayload; +use crate::rule::SuppressAction; +use biome_console::markup; use biome_rowan::{ - BatchMutation, Language, SyntaxToken, TextLen, TextRange, TokenAtOffset, TriviaPiece, - TriviaPieceKind, + AstNode, BatchMutation, BatchMutationExt, Language, SyntaxToken, TextLen, TextRange, + TokenAtOffset, TriviaPiece, TriviaPieceKind, }; pub trait SuppressionAction { @@ -174,3 +176,283 @@ fn new_trivia_for_top_suppression( text.push_str(token.text_trimmed()); new_trivia } + +/// Build an inline suppression (`biome-ignore`) action for the given rule category. +/// +/// This is a shared helper used by both the native `Rule` trait and the plugin +/// signal path so the suppression text/message format is defined in one place. +pub(crate) fn make_inline_suppression( + rule_category: &str, + kind_label: &str, + root: &::Root, + text_range: &TextRange, + suppression_action: &dyn SuppressionAction, + suppression_reason: &str, +) -> SuppressAction { + let suppression_text = format!("biome-ignore {rule_category}"); + let root = root.clone(); + let token = root.syntax().token_at_offset(text_range.start()); + let mut mutation = root.begin(); + suppression_action.inline_suppression(SuppressionCommentEmitterPayload { + suppression_text: suppression_text.as_str(), + mutation: &mut mutation, + token_offset: token, + diagnostic_text_range: text_range, + suppression_reason, + }); + SuppressAction { + mutation, + message: markup! { "Suppress " {kind_label} " " {rule_category} " for this line." } + .to_owned(), + } +} + +/// Build a top-level suppression (`biome-ignore-all`) action for the given rule category. +/// +/// Returns `None` when the root has no tokens (empty file). +pub(crate) fn make_top_level_suppression( + rule_category: &str, + kind_label: &str, + root: &::Root, + suppression_action: &dyn SuppressionAction, +) -> Option> { + let suppression_text = format!("biome-ignore-all {rule_category}"); + let root = root.clone(); + let first_token = root.syntax().first_token()?; + let mut mutation = root.begin(); + let comment = suppression_action.suppression_top_level_comment(suppression_text.as_str()); + suppression_action.apply_top_level_suppression(&mut mutation, first_token, comment.as_str()); + Some(SuppressAction { + mutation, + message: markup! { "Suppress " {kind_label} " " {rule_category} " for the whole file." } + .to_owned(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use biome_console::MarkupBuf; + use biome_rowan::TriviaPiece; + use biome_rowan::raw_language::{ + RawLanguage, RawLanguageKind, RawLanguageRoot, RawSyntaxTreeBuilder, + }; + use std::cell::Cell; + + /// Extract the plain text content from a `MarkupBuf` (ignoring markup tags). + fn markup_to_string(markup: &MarkupBuf) -> String { + markup.0.iter().map(|node| node.content.as_str()).collect() + } + + /// A stub `SuppressionAction` for `RawLanguage` that records whether its + /// methods were called but otherwise performs no real mutations. + struct RecordingAction { + top_level_comment_called: Cell, + top_level_comment_arg: Cell>, + } + + impl RecordingAction { + fn new() -> Self { + Self { + top_level_comment_called: Cell::new(false), + top_level_comment_arg: Cell::new(None), + } + } + } + + impl SuppressionAction for RecordingAction { + type Language = RawLanguage; + + fn find_token_for_inline_suppression( + &self, + _: SyntaxToken, + ) -> Option> { + // Return None — inline suppression is a no-op at the mutation level + // but the helper still exercises the code path. + None + } + + fn apply_inline_suppression( + &self, + _: &mut BatchMutation, + _: ApplySuppression, + _: &str, + _: &str, + ) { + unreachable!("apply_inline_suppression should not be called when find returns None") + } + + fn apply_top_level_suppression( + &self, + _: &mut BatchMutation, + _: SyntaxToken, + _: &str, + ) { + // no-op — we only care that the method was reached + } + + fn suppression_top_level_comment(&self, suppression_text: &str) -> String { + self.top_level_comment_called.set(true); + self.top_level_comment_arg + .set(Some(suppression_text.to_string())); + format!("// {suppression_text}") + } + } + + /// Build a minimal `RawLanguageRoot` with a single token. + fn make_root_with_token() -> RawLanguageRoot { + let mut builder = RawSyntaxTreeBuilder::new(); + builder.start_node(RawLanguageKind::ROOT); + builder.start_node(RawLanguageKind::EXPRESSION_LIST); + builder.start_node(RawLanguageKind::LITERAL_EXPRESSION); + builder.token_with_trivia( + RawLanguageKind::STRING_TOKEN, + "\n\"hello\"", + &[TriviaPiece::newline(1)], + &[], + ); + builder.finish_node(); + builder.finish_node(); + builder.finish_node(); + RawLanguageRoot::unwrap_cast(builder.finish()) + } + + /// Build an empty `RawLanguageRoot` with no tokens. + fn make_empty_root() -> RawLanguageRoot { + let mut builder = RawSyntaxTreeBuilder::new(); + builder.start_node(RawLanguageKind::ROOT); + builder.start_node(RawLanguageKind::EXPRESSION_LIST); + builder.finish_node(); + builder.finish_node(); + RawLanguageRoot::unwrap_cast(builder.finish()) + } + + #[test] + fn inline_suppression_message_contains_rule_category() { + let root = make_root_with_token(); + let action = RecordingAction::new(); + let range = TextRange::new(1.into(), 8.into()); + + let result = make_inline_suppression::( + "lint/complexity/useWhile", + "rule", + &root, + &range, + &action, + "", + ); + + let msg = markup_to_string(&result.message); + assert!( + msg.contains("lint/complexity/useWhile"), + "expected category in message, got: {msg}" + ); + assert!( + msg.contains("for this line"), + "expected 'for this line' in message, got: {msg}" + ); + } + + #[test] + fn inline_suppression_message_uses_action_label() { + let root = make_root_with_token(); + let action = RecordingAction::new(); + let range = TextRange::new(1.into(), 8.into()); + + let result = make_inline_suppression::( + "action/source/organizeImports", + "action", + &root, + &range, + &action, + "", + ); + + let msg = markup_to_string(&result.message); + assert!( + msg.contains("Suppress action"), + "expected 'Suppress action' in message, got: {msg}" + ); + } + + #[test] + fn inline_suppression_plugin_category() { + let root = make_root_with_token(); + let action = RecordingAction::new(); + let range = TextRange::new(1.into(), 8.into()); + + let result = make_inline_suppression::( + "lint/plugin/myRule", + "rule", + &root, + &range, + &action, + "", + ); + + let msg = markup_to_string(&result.message); + assert!( + msg.contains("lint/plugin/myRule"), + "expected plugin category in message, got: {msg}" + ); + } + + #[test] + fn top_level_suppression_message_format() { + let root = make_root_with_token(); + let action = RecordingAction::new(); + + let result = make_top_level_suppression::( + "lint/complexity/useWhile", + "rule", + &root, + &action, + ); + + let result = result.expect("should return Some for non-empty root"); + let msg = markup_to_string(&result.message); + assert!( + msg.contains("for the whole file"), + "expected 'for the whole file' in message, got: {msg}" + ); + assert!( + msg.contains("lint/complexity/useWhile"), + "expected category in message, got: {msg}" + ); + } + + #[test] + fn top_level_suppression_returns_none_for_empty_root() { + let root = make_empty_root(); + let action = RecordingAction::new(); + + let result = make_top_level_suppression::( + "lint/complexity/useWhile", + "rule", + &root, + &action, + ); + + assert!(result.is_none(), "expected None for empty root"); + } + + #[test] + fn top_level_suppression_calls_suppression_action() { + let root = make_root_with_token(); + let action = RecordingAction::new(); + let category = "lint/style/useConst"; + + let _ = make_top_level_suppression::(category, "rule", &root, &action); + + assert!( + action.top_level_comment_called.get(), + "suppression_top_level_comment should have been called" + ); + let arg = action.top_level_comment_arg.take().unwrap(); + assert_eq!( + arg, + format!("biome-ignore-all {category}"), + "suppression_top_level_comment was called with wrong text" + ); + } +} diff --git a/crates/biome_cli/Cargo.toml b/crates/biome_cli/Cargo.toml index 23776062c8de..ceccba96961b 100644 --- a/crates/biome_cli/Cargo.toml +++ b/crates/biome_cli/Cargo.toml @@ -103,8 +103,9 @@ tokio = { workspace = true, features = ["process"] } mimalloc = { workspace = true } [features] -docgen = ["bpaf/docgen"] -js_plugin = ["biome_service/js_plugin"] +docgen = ["bpaf/docgen"] +js_plugin = ["biome_service/js_plugin"] +wasm_plugin = ["biome_service/wasm_plugin"] [lints] workspace = true diff --git a/crates/biome_cli/tests/snapshots/main_cases_monorepo/plugins_from_root_config_work_in_child_config_extends_root.snap b/crates/biome_cli/tests/snapshots/main_cases_monorepo/plugins_from_root_config_work_in_child_config_extends_root.snap index a027c4725e48..d84825a5c693 100644 --- a/crates/biome_cli/tests/snapshots/main_cases_monorepo/plugins_from_root_config_work_in_child_config_extends_root.snap +++ b/crates/biome_cli/tests/snapshots/main_cases_monorepo/plugins_from_root_config_work_in_child_config_extends_root.snap @@ -1,5 +1,6 @@ --- source: crates/biome_cli/tests/snap_test.rs +assertion_line: 549 expression: redactor(content) --- ## `packages/mobile/biome.json` @@ -54,7 +55,7 @@ const merged = Object.assign({}, a, b); # Emitted Messages ```block -packages/mobile/src/file.js:1:16 plugin ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +packages/mobile/src/file.js:1:16 plugin/no-object-assign ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ! Prefer object spread instead of Object.assign() diff --git a/crates/biome_cli/tests/snapshots/main_cases_monorepo/plugins_in_child_config_with_extends_root.snap b/crates/biome_cli/tests/snapshots/main_cases_monorepo/plugins_in_child_config_with_extends_root.snap index f538a1e7349b..6dc695bdc63b 100644 --- a/crates/biome_cli/tests/snapshots/main_cases_monorepo/plugins_in_child_config_with_extends_root.snap +++ b/crates/biome_cli/tests/snapshots/main_cases_monorepo/plugins_in_child_config_with_extends_root.snap @@ -1,5 +1,6 @@ --- source: crates/biome_cli/tests/snap_test.rs +assertion_line: 549 expression: redactor(content) --- ## `packages/mobile/biome.json` @@ -50,7 +51,7 @@ const merged = Object.assign({}, a, b); # Emitted Messages ```block -packages/mobile/src/file.js:1:16 plugin ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +packages/mobile/src/file.js:1:16 plugin/no-object-assign ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ! Prefer object spread instead of Object.assign() diff --git a/crates/biome_cli/tests/snapshots/main_commands_check/check_json_plugin.snap b/crates/biome_cli/tests/snapshots/main_commands_check/check_json_plugin.snap index b152ed9d4416..b8e1d5cb8d70 100644 --- a/crates/biome_cli/tests/snapshots/main_commands_check/check_json_plugin.snap +++ b/crates/biome_cli/tests/snapshots/main_commands_check/check_json_plugin.snap @@ -1,5 +1,6 @@ --- source: crates/biome_cli/tests/snap_test.rs +assertion_line: 549 expression: redactor(content) --- ## `biome.json` @@ -47,7 +48,7 @@ check ━━━━━━━━━━━━━━━━━━━━━━━━ # Emitted Messages ```block -package.json:2:13 plugin ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +package.json:2:13 plugin/noTestName ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ × Avoid using 'test' as package name. diff --git a/crates/biome_cli/tests/snapshots/main_commands_check/check_plugin_diagnostic_offset_in_vue_file.snap b/crates/biome_cli/tests/snapshots/main_commands_check/check_plugin_diagnostic_offset_in_vue_file.snap index c97c56f733db..2e8571429a7a 100644 --- a/crates/biome_cli/tests/snapshots/main_commands_check/check_plugin_diagnostic_offset_in_vue_file.snap +++ b/crates/biome_cli/tests/snapshots/main_commands_check/check_plugin_diagnostic_offset_in_vue_file.snap @@ -1,5 +1,6 @@ --- source: crates/biome_cli/tests/snap_test.rs +assertion_line: 549 expression: redactor(content) --- ## `biome.json` @@ -89,7 +90,7 @@ file.vue:15:7 lint/correctness/noUnusedVariables FIXABLE ━━━━━━━ ``` ```block -file.vue:15:7 plugin ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +file.vue:15:7 plugin/noFoo ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ × Avoid using 'foo' as a variable name. diff --git a/crates/biome_cli/tests/snapshots/main_commands_check/check_plugin_suppressions.snap b/crates/biome_cli/tests/snapshots/main_commands_check/check_plugin_suppressions.snap index b3dd274d8f46..8f40492904e2 100644 --- a/crates/biome_cli/tests/snapshots/main_commands_check/check_plugin_suppressions.snap +++ b/crates/biome_cli/tests/snapshots/main_commands_check/check_plugin_suppressions.snap @@ -1,5 +1,6 @@ --- source: crates/biome_cli/tests/snap_test.rs +assertion_line: 549 expression: redactor(content) --- ## `biome.json` @@ -53,7 +54,7 @@ check ━━━━━━━━━━━━━━━━━━━━━━━━ # Emitted Messages ```block -style.css:2:14 plugin ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +style.css:2:14 plugin/noManualZIndex ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ × company/plugin/noManualZIndex :: z-index values should be set using the design library. diff --git a/crates/biome_css_analyze/tests/spec_tests.rs b/crates/biome_css_analyze/tests/spec_tests.rs index f980f2285795..967b844979f8 100644 --- a/crates/biome_css_analyze/tests/spec_tests.rs +++ b/crates/biome_css_analyze/tests/spec_tests.rs @@ -1,6 +1,6 @@ use biome_analyze::{ - AnalysisFilter, AnalyzerAction, AnalyzerPluginSlice, ControlFlow, Never, Queryable, - RegistryVisitor, Rule, RuleDomain, RuleFilter, RuleGroup, + AnalysisFilter, AnalyzerAction, AnalyzerPlugin, AnalyzerPluginSlice, ControlFlow, Never, + Queryable, RegistryVisitor, Rule, RuleDomain, RuleFilter, RuleGroup, }; use biome_css_analyze::CssAnalyzerServices; use biome_css_parser::{CssParserOptions, parse_css}; @@ -362,7 +362,7 @@ fn run_plugin_test(input: &'static str, _: &str, _: &str, _: &str) { &input_path, CheckActionType::Lint, CssParserOptions::default(), - &[Arc::new(Box::new(plugin))], + &[Arc::new(plugin) as Arc], ); insta::with_settings!({ diff --git a/crates/biome_diagnostics/CONTRIBUTING.md b/crates/biome_diagnostics/CONTRIBUTING.md index b86805915e22..9fe4d5c63eec 100644 --- a/crates/biome_diagnostics/CONTRIBUTING.md +++ b/crates/biome_diagnostics/CONTRIBUTING.md @@ -138,3 +138,33 @@ add it to `crates/biome_diagnostics_categories/src/categories.rs` `#[derive(Diagnostic)]` also works in enums. This assumes every variant of the enum contains a type that is themselves a diagnostic. + +## Subcategories (Plugin Rule Names) + +The `Diagnostic` trait has an optional `subcategory()` method that allows +diagnostics to extend their category name dynamically. This is used by the +plugin system to display rule names in diagnostic headers. + +For example, a plugin diagnostic with `category!("plugin")` and subcategory +`"booleanNaming"` will render as: + +``` +demo.js:9:7 plugin/booleanNaming ━━━━━━━━━━━━━━━ +``` + +The `subcategory()` method returns `Option<&str>` and defaults to `None`. +When present, the display layer concatenates `category/subcategory` in the +header. Native built-in rules do not use this — their full path is encoded +in the static category (e.g. `lint/style/useConst`). + +When implementing a diagnostic wrapper type that delegates to an inner +diagnostic, remember to propagate `subcategory()`: + +```rust +fn subcategory(&self) -> Option<&str> { + self.inner.as_diagnostic().subcategory() +} +``` + +The wrapper types in `context.rs` (e.g. `FilePathDiagnostic`, +`SeverityDiagnostic`) already do this. diff --git a/crates/biome_diagnostics/src/context.rs b/crates/biome_diagnostics/src/context.rs index 90aa75fb9004..813d5df89f72 100644 --- a/crates/biome_diagnostics/src/context.rs +++ b/crates/biome_diagnostics/src/context.rs @@ -283,6 +283,10 @@ mod internal { self.source.as_diagnostic().category() } + fn subcategory(&self) -> Option<&str> { + self.source.as_diagnostic().subcategory() + } + fn severity(&self) -> Severity { self.source.as_diagnostic().severity() } @@ -382,6 +386,17 @@ mod internal { ) } + fn subcategory(&self) -> Option<&str> { + // Only forward the source's subcategory when it also has its own + // category. When `with_category()` injects a synthetic category + // the source's original subcategory would be stale/mismatched. + if self.source.as_diagnostic().category().is_some() { + self.source.as_diagnostic().subcategory() + } else { + None + } + } + fn severity(&self) -> Severity { self.source.as_diagnostic().severity() } @@ -432,6 +447,10 @@ mod internal { self.source.as_diagnostic().category() } + fn subcategory(&self) -> Option<&str> { + self.source.as_diagnostic().subcategory() + } + fn severity(&self) -> Severity { self.source.as_diagnostic().severity() } @@ -498,6 +517,10 @@ mod internal { self.source.as_diagnostic().category() } + fn subcategory(&self) -> Option<&str> { + self.source.as_diagnostic().subcategory() + } + fn severity(&self) -> Severity { self.source.as_diagnostic().severity() } @@ -554,6 +577,10 @@ mod internal { self.source.as_diagnostic().category() } + fn subcategory(&self) -> Option<&str> { + self.source.as_diagnostic().subcategory() + } + fn severity(&self) -> Severity { self.source.as_diagnostic().severity() } @@ -676,6 +703,10 @@ mod internal { self.source.as_diagnostic().category() } + fn subcategory(&self) -> Option<&str> { + self.source.as_diagnostic().subcategory() + } + fn severity(&self) -> Severity { self.source.as_diagnostic().severity() } @@ -726,6 +757,10 @@ mod internal { self.source.as_diagnostic().category() } + fn subcategory(&self) -> Option<&str> { + self.source.as_diagnostic().subcategory() + } + fn severity(&self) -> Severity { self.severity } diff --git a/crates/biome_diagnostics/src/diagnostic.rs b/crates/biome_diagnostics/src/diagnostic.rs index e8e16239ce79..ade84a1bb892 100644 --- a/crates/biome_diagnostics/src/diagnostic.rs +++ b/crates/biome_diagnostics/src/diagnostic.rs @@ -103,6 +103,14 @@ pub trait Diagnostic: Debug { DiagnosticTags::empty() } + /// An optional subcategory string for diagnostics that need to extend + /// their category name dynamically. For example, plugin rules use this + /// to append the rule name (e.g. `booleanNaming`) to the category + /// `plugin`, resulting in `plugin/booleanNaming` in the diagnostic header. + fn subcategory(&self) -> Option<&str> { + None + } + /// Similarly to the `source` method of the [std::error::Error] trait, this /// returns another diagnostic that's the logical "cause" for this issue. /// For instance, a "request failed" diagnostic may have been cause by a diff --git a/crates/biome_diagnostics/src/display.rs b/crates/biome_diagnostics/src/display.rs index 2114a0d024be..b94cc9680884 100644 --- a/crates/biome_diagnostics/src/display.rs +++ b/crates/biome_diagnostics/src/display.rs @@ -146,9 +146,20 @@ impl fmt::Display for PrintHeader<'_, D> { } // Print the category of the diagnostic, with a hyperlink if - // the category has an associated link + // the category has an associated link. + // If a subcategory is present (e.g. plugin rule name), append it + // to the category: "plugin/booleanNaming" if let Some(category) = diagnostic.category() { - if let Some(link) = category.link() { + if let Some(subcategory) = diagnostic.subcategory() { + let full_name = format!("{}/{}", category.name(), subcategory); + if let Some(link) = category.link() { + fmt.write_markup(markup! { + {full_name.as_str()}" " + })?; + } else { + fmt.write_markup(markup! { {full_name.as_str()}" " })?; + } + } else if let Some(link) = category.link() { fmt.write_markup(markup! { {category.name()}" " })?; diff --git a/crates/biome_diagnostics/src/error.rs b/crates/biome_diagnostics/src/error.rs index 7e82ab05920e..e10b4302fae2 100644 --- a/crates/biome_diagnostics/src/error.rs +++ b/crates/biome_diagnostics/src/error.rs @@ -32,6 +32,11 @@ impl Error { self.as_diagnostic().category() } + /// Calls [Diagnostic::subcategory] on the [Diagnostic] wrapped by this [Error]. + pub fn subcategory(&self) -> Option<&str> { + self.as_diagnostic().subcategory() + } + /// Calls [Diagnostic::severity] on the [Diagnostic] wrapped by this [Error]. pub fn severity(&self) -> Severity { self.as_diagnostic().severity() diff --git a/crates/biome_diagnostics/src/serde.rs b/crates/biome_diagnostics/src/serde.rs index c83ec12fc6f3..63d6f27b2785 100644 --- a/crates/biome_diagnostics/src/serde.rs +++ b/crates/biome_diagnostics/src/serde.rs @@ -21,6 +21,8 @@ use crate::{ #[cfg_attr(test, derive(Eq, PartialEq))] pub struct Diagnostic { category: Option<&'static Category>, + #[serde(default, skip_serializing_if = "Option::is_none")] + subcategory: Option, severity: Severity, description: String, message: MarkupBuf, @@ -38,6 +40,7 @@ impl Diagnostic { fn new_impl(diag: &D) -> Self { let category = diag.category(); + let subcategory = diag.subcategory().map(String::from); let severity = diag.severity(); @@ -64,6 +67,7 @@ impl Diagnostic { Self { category, + subcategory, severity, description, message, @@ -89,6 +93,10 @@ impl super::Diagnostic for Diagnostic { self.category } + fn subcategory(&self) -> Option<&str> { + self.subcategory.as_deref() + } + fn severity(&self) -> Severity { self.severity } diff --git a/crates/biome_js_analyze/Cargo.toml b/crates/biome_js_analyze/Cargo.toml index 9deacca8e9aa..1f42e67cb94a 100644 --- a/crates/biome_js_analyze/Cargo.toml +++ b/crates/biome_js_analyze/Cargo.toml @@ -59,7 +59,7 @@ smallvec = { workspace = true } [dev-dependencies] biome_fs = { workspace = true } biome_js_parser = { path = "../biome_js_parser", features = ["tests"] } -biome_plugin_loader = { workspace = true } +biome_plugin_loader = { workspace = true, features = ["wasm_plugin"] } biome_service = { workspace = true } biome_test_utils = { path = "../biome_test_utils" } criterion = { package = "codspeed-criterion-compat", version = "*" } diff --git a/crates/biome_js_analyze/tests/spec_tests.rs b/crates/biome_js_analyze/tests/spec_tests.rs index 497c4a595cb4..976c233e83a2 100644 --- a/crates/biome_js_analyze/tests/spec_tests.rs +++ b/crates/biome_js_analyze/tests/spec_tests.rs @@ -1,6 +1,6 @@ use biome_analyze::{ - AnalysisFilter, AnalyzerAction, AnalyzerPluginSlice, ControlFlow, Never, Queryable, - RegistryVisitor, Rule, RuleDomain, RuleFilter, RuleGroup, + AnalysisFilter, AnalyzerAction, AnalyzerPlugin, AnalyzerPluginSlice, ControlFlow, Never, + Queryable, RegistryVisitor, Rule, RuleDomain, RuleFilter, RuleGroup, }; use biome_diagnostics::advice::CodeSuggestionAdvice; use biome_fs::OsFileSystem; @@ -521,7 +521,7 @@ fn run_plugin_test(input: &'static str, _: &str, _: &str, _: &str) { &input_path, CheckActionType::Lint, JsParserOptions::default(), - &[Arc::new(Box::new(plugin))], + &[Arc::new(plugin) as Arc], ); insta::with_settings!({ diff --git a/crates/biome_js_analyze/tests/wasm_plugin_tests.rs b/crates/biome_js_analyze/tests/wasm_plugin_tests.rs new file mode 100644 index 000000000000..229f975a1106 --- /dev/null +++ b/crates/biome_js_analyze/tests/wasm_plugin_tests.rs @@ -0,0 +1,152 @@ +//! Analyzer pipeline integration tests for the booleanNaming WASM plugin. +//! +//! These tests load the compiled `.wasm` plugin fixture, run it through the +//! full `biome_js_analyze::analyze` pipeline, and verify diagnostics. + +use biome_analyze::{AnalysisFilter, AnalyzerPluginSlice, ControlFlow, Never, RuleFilter}; +use biome_diagnostics::advice::CodeSuggestionAdvice; +use biome_js_analyze::JsAnalyzerServices; +use biome_js_parser::{JsParserOptions, parse}; +use biome_js_syntax::{JsFileSource, JsLanguage}; +use biome_plugin_loader::AnalyzerWasmPlugin; +use biome_test_utils::{create_analyzer_options, diagnostic_to_string}; +use camino::Utf8Path; +use std::path::Path; +use std::slice; +use std::sync::Arc; + +fn fixture_path(name: &str) -> camino::Utf8PathBuf { + let p = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../biome_plugin_loader/tests/fixtures") + .join(name); + camino::Utf8PathBuf::try_from(p).unwrap() +} + +/// Run the analyzer with a WASM plugin and return (diagnostics, code_fixes). +fn run_wasm_plugin(wasm_name: &str, source: &str) -> (Vec, Vec) { + let plugin_path = fixture_path(wasm_name); + let loaded = AnalyzerWasmPlugin::load(plugin_path.as_ref(), wasm_name, None) + .unwrap_or_else(|e| panic!("Failed to load {wasm_name}: {e:?}")); + + let source_type = JsFileSource::js_module(); + let parsed = parse(source, source_type, JsParserOptions::default()); + let root = parsed.tree(); + + // Enable at least one rule so the PhaseRunner is activated (needed for + // suppression comment parsing). + let rule_filter = RuleFilter::Rule("nursery", "noCommonJs"); + let filter = AnalysisFilter { + enabled_rules: Some(slice::from_ref(&rule_filter)), + ..AnalysisFilter::default() + }; + + let plugin_arcs: Vec> = + loaded.into_iter().map(|p| Arc::new(p) as _).collect(); + let plugins: AnalyzerPluginSlice = &plugin_arcs; + let mut diagnostics = Vec::new(); + let input_file = Utf8Path::new("test.js"); + let mut diag_options = Vec::new(); + let working_directory = input_file.parent().unwrap_or(input_file); + let options = + create_analyzer_options::(input_file, working_directory, &mut diag_options); + let services = JsAnalyzerServices::from((Default::default(), Default::default(), source_type)); + + let mut code_fixes = Vec::new(); + + let (_, errors) = + biome_js_analyze::analyze(&root, filter, &options, plugins, services, |event| { + if let Some(mut diag) = event.diagnostic() { + for action in event.actions() { + if !action.is_suppression() { + diag = diag.add_code_suggestion(CodeSuggestionAdvice::from(action)); + } + } + diagnostics.push(diagnostic_to_string("test.js", source, diag.into())); + return ControlFlow::Continue(()); + } + + for action in event.actions() { + if !action.category.matches("quickfix.suppressRule") { + code_fixes.push(biome_test_utils::code_fix_to_string(source, action)); + } + } + + ControlFlow::::Continue(()) + }); + + for error in errors { + diagnostics.push(diagnostic_to_string("test.js", source, error)); + } + + (diagnostics, code_fixes) +} + +// --------------------------------------------------------------- +// booleanNaming: detection +// --------------------------------------------------------------- + +#[test] +fn wasm_plugin_boolean_naming_detects_bad_names() { + let source = "const enabled = true;\nconst isReady = false;\nconst count = 42;\n"; + let (diags, _fixes) = run_wasm_plugin("boolean_naming.wasm", source); + + // Should detect 1 diagnostic for `enabled = true` (bad name). + // `isReady = false` has a good prefix. `count = 42` is not boolean. + assert_eq!( + diags.len(), + 1, + "Expected 1 diagnostic for 'enabled = true', got {}:\n{}", + diags.len(), + diags.join("\n"), + ); + assert!( + diags[0].contains("enabled"), + "Diagnostic should mention 'enabled':\n{}", + diags[0], + ); +} + +#[test] +fn wasm_plugin_boolean_naming_detects_comparison() { + let source = "const equal = a === b;\nconst isSmaller = x < y;\n"; + let (diags, _fixes) = run_wasm_plugin("boolean_naming.wasm", source); + + // Only `equal = a === b` should trigger; `isSmaller` has a valid prefix. + assert_eq!( + diags.len(), + 1, + "Expected 1 diagnostic for 'equal = a === b', got {}:\n{}", + diags.len(), + diags.join("\n"), + ); +} + +#[test] +fn wasm_plugin_boolean_naming_no_false_positives() { + let source = "const isActive = true;\nconst hasPermission = false;\nconst count = 42;\nconst name = \"hello\";\n"; + let (diags, _fixes) = run_wasm_plugin("boolean_naming.wasm", source); + + assert!( + diags.is_empty(), + "Expected no diagnostics for valid names, got {}:\n{}", + diags.len(), + diags.join("\n"), + ); +} + +// --------------------------------------------------------------- +// Suppression comments +// --------------------------------------------------------------- + +#[test] +fn wasm_plugin_suppression_comment_does_not_crash() { + // Verify that a suppression comment targeting a plugin rule doesn't crash + // the analyzer. Full suppression support for plugin rules may be wired in + // a future change. + let source = "// biome-ignore plugin/booleanNaming: reason\nconst enabled = true;\n"; + let (diags, _fixes) = run_wasm_plugin("boolean_naming.wasm", source); + + // The analyzer should not crash. If we get here without panicking, the test + // passes. Suppression may or may not be effective depending on wiring. + let _ = &diags; +} diff --git a/crates/biome_js_semantic/src/semantic_model/model.rs b/crates/biome_js_semantic/src/semantic_model/model.rs index 9c8dd81a027a..842fd7e75cb0 100644 --- a/crates/biome_js_semantic/src/semantic_model/model.rs +++ b/crates/biome_js_semantic/src/semantic_model/model.rs @@ -481,6 +481,43 @@ impl SemanticModel { } } + /// Resolve a reference given its raw syntax node. + /// + /// Returns the [`Binding`] that the reference points to, if found. + /// This is a handle-friendly alternative to [`SemanticModel::binding`] + /// that does not require a typed AST node. + pub fn resolve_reference_node(&self, node: &JsSyntaxNode) -> Option { + let range = node.text_trimmed_range(); + let id = *self.data.declared_at_by_start.get(&range.start())?; + Some(Binding { + data: self.data.clone(), + id, + }) + } + + /// Look up a [`Binding`] by its declaration syntax node. + /// + /// Returns `None` if the node does not correspond to a known binding. + pub fn binding_by_node(&self, node: &JsSyntaxNode) -> Option { + let range = node.text_trimmed_range(); + let id = *self.data.bindings_by_start.get(&range.start())?; + // Verify the full range matches — start-only lookup may collide when + // multiple nodes share the same start offset. + let binding_data = self.data.binding(id); + if binding_data.range != range { + return None; + } + Some(Binding { + data: self.data.clone(), + id, + }) + } + + /// Check whether a binding (identified by its declaration node) is exported. + pub fn is_binding_exported(&self, node: &JsSyntaxNode) -> bool { + self.data.is_exported(node.text_trimmed_range()) + } + /// Returns all [FunctionCall] of a [AnyJsFunction]. /// /// ```rust diff --git a/crates/biome_lsp/src/utils.rs b/crates/biome_lsp/src/utils.rs index 6e8e4a5ec513..fbda7e18ead3 100644 --- a/crates/biome_lsp/src/utils.rs +++ b/crates/biome_lsp/src/utils.rs @@ -195,9 +195,17 @@ pub(crate) fn diagnostic_to_lsp( Severity::Hint => lsp::DiagnosticSeverity::HINT, }; - let code = diagnostic - .category() - .map(|category| lsp::NumberOrString::String(category.name().to_string())); + let code = diagnostic.category().map(|category| { + // For plugin diagnostics, show the subcategory prefixed with "@" + // (e.g. "@wirex-biome-plugin/fileNamingConvention") so the editor + // renders it like ESLint: "(biome @wirex-biome-plugin/fileNamingConvention)". + if category.name() == "plugin" + && let Some(sub) = diagnostic.subcategory() + { + return lsp::NumberOrString::String(format!("@{sub}")); + } + lsp::NumberOrString::String(category.name().to_string()) + }); let code_description = diagnostic .category() diff --git a/crates/biome_plugin_loader/Cargo.toml b/crates/biome_plugin_loader/Cargo.toml index 4f14221ed983..07574d2e9621 100644 --- a/crates/biome_plugin_loader/Cargo.toml +++ b/crates/biome_plugin_loader/Cargo.toml @@ -21,13 +21,16 @@ biome_diagnostics = { workspace = true } biome_fs = { workspace = true } biome_grit_patterns = { workspace = true } biome_js_runtime = { workspace = true, optional = true } +biome_js_semantic = { workspace = true, optional = true } biome_js_syntax = { workspace = true } biome_json_parser = { workspace = true } biome_json_syntax = { workspace = true } +biome_module_graph = { workspace = true, optional = true } biome_parser = { workspace = true } biome_resolver = { workspace = true } biome_rowan = { workspace = true } biome_text_size = { workspace = true } +biome_wasm_plugin = { workspace = true, optional = true } boa_engine = { workspace = true, optional = true } camino = { workspace = true } grit-pattern-matcher = { workspace = true } @@ -36,10 +39,13 @@ papaya = { workspace = true } rustc-hash = { workspace = true } schemars = { workspace = true, optional = true } serde = { workspace = true } +serde_json = { workspace = true } +wasmtime = { workspace = true, optional = true } [dev-dependencies] -biome_js_parser = { workspace = true } -insta = { workspace = true } +biome_css_parser = { workspace = true } +biome_js_parser = { workspace = true } +insta = { workspace = true } [target.'cfg(unix)'.dependencies] libc = { workspace = true, optional = true } @@ -48,9 +54,10 @@ libc = { workspace = true, optional = true } windows = { workspace = true, features = ["Win32_System_Threading"], optional = true } [features] -default = [] -js_plugin = ["dep:biome_js_runtime", "dep:boa_engine", "dep:libc", "dep:windows"] -schema = ["dep:schemars"] +default = [] +js_plugin = ["dep:biome_js_runtime", "dep:boa_engine", "dep:libc", "dep:windows"] +schema = ["dep:schemars"] +wasm_plugin = ["dep:biome_js_semantic", "dep:biome_module_graph", "dep:biome_wasm_plugin", "dep:wasmtime"] [lints] workspace = true diff --git a/crates/biome_plugin_loader/README.md b/crates/biome_plugin_loader/README.md new file mode 100644 index 000000000000..f9fe03b1aa8c --- /dev/null +++ b/crates/biome_plugin_loader/README.md @@ -0,0 +1,52 @@ +# biome_plugin_loader + +Host-side plugin loading, caching, and evaluation for Biome's analyzer. + +This crate is responsible for: + +- **Loading** plugin files from disk (WASM, GritQL, or JavaScript) +- **Caching** compiled plugins to avoid repeated recompilation +- **Evaluating** plugins against syntax nodes during analysis +- **Configuration** parsing for the `plugins` array in `biome.json` + +## Plugin Types + +| Type | File extension | Engine | +| --- | --- | --- | +| WASM | `.wasm` | `wasmtime` (Component Model) via `biome_wasm_plugin` | +| GritQL | `.grit` | `biome_grit_patterns` | +| JavaScript | `.js` / `.mjs` | `boa_engine` | + +## Configuration + +Plugins are configured in the `plugins` array of `biome.json`. Each entry can +be a simple path string or an object with options: + +```json +{ + "plugins": [ + "./plugins/my-rule.wasm", + { + "path": "./plugins/configurable.wasm", + "options": { "convention": "camelCase" }, + "rules": { + "myRule": "warn" + } + } + ] +} +``` + +The `options` object is passed as a JSON string to the plugin's `configure()` +export. The `rules` map allows per-rule configuration using severity levels +(`off`, `on`, `info`, `warn`, `error`) or an object with `level` and `fix`. + +## Architecture + +The `AnalyzerPlugin` trait (defined in `biome_analyze`) is the common interface +implemented by all plugin types. Each plugin type has its own loader +(`AnalyzerWasmPlugin`, `AnalyzerGritPlugin`, `AnalyzerJsPlugin`) that handles +the specifics of compilation and evaluation. + +`PluginCache` stores loaded plugins keyed by path and is shared across files in +a workspace session. diff --git a/crates/biome_plugin_loader/build.rs b/crates/biome_plugin_loader/build.rs new file mode 100644 index 000000000000..e9a71d5f7559 --- /dev/null +++ b/crates/biome_plugin_loader/build.rs @@ -0,0 +1,98 @@ +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn main() { + let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); + let repo_root = manifest_dir.parent().unwrap().parent().unwrap(); + let fixtures_dir = manifest_dir.join("tests/fixtures"); + + let plugins = ["boolean-naming", "css-style-conventions", "json-naming"]; + + // Register rerun triggers for each plugin's source and manifest + for plugin in &plugins { + let plugin_dir = repo_root + .join("e2e-tests/wasm-plugins/plugins") + .join(plugin); + println!( + "cargo:rerun-if-changed={}", + plugin_dir.join("src/lib.rs").display() + ); + println!( + "cargo:rerun-if-changed={}", + plugin_dir.join("Cargo.toml").display() + ); + } + // Also rerun if the SDK or WIT definition changes + println!( + "cargo:rerun-if-changed={}", + repo_root + .join("crates/biome_plugin_sdk/wit/biome-plugin.wit") + .display() + ); + println!( + "cargo:rerun-if-changed={}", + repo_root.join("crates/biome_plugin_sdk/src").display() + ); + + // Check if wasm32-wasip2 target is installed + if !is_wasm_target_installed() { + println!( + "cargo:warning=wasm32-wasip2 target not installed; WASM fixtures will not be built. \ + Install with: rustup target add wasm32-wasip2" + ); + return; + } + + std::fs::create_dir_all(&fixtures_dir).ok(); + + let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()); + + for plugin in &plugins { + let plugin_dir = repo_root + .join("e2e-tests/wasm-plugins/plugins") + .join(plugin); + build_plugin(&cargo, &plugin_dir, &fixtures_dir, plugin); + } +} + +fn is_wasm_target_installed() -> bool { + Command::new("rustup") + .args(["target", "list", "--installed"]) + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).contains("wasm32-wasip2")) + .unwrap_or(false) +} + +fn build_plugin(cargo: &str, plugin_dir: &Path, fixtures_dir: &Path, name: &str) { + let status = Command::new(cargo) + .args([ + "build", + "--manifest-path", + &plugin_dir.join("Cargo.toml").to_string_lossy(), + "--target", + "wasm32-wasip2", + "--release", + ]) + .env_remove("CARGO_ENCODED_RUSTFLAGS") + .status(); + + match status { + Ok(s) if s.success() => { + // Copy the .wasm to fixtures dir + let wasm_name = name.replace('-', "_"); + let src = plugin_dir + .join("target/wasm32-wasip2/release") + .join(format!("{wasm_name}.wasm")); + let dst = fixtures_dir.join(format!("{wasm_name}.wasm")); + if let Err(e) = std::fs::copy(&src, &dst) { + println!("cargo:warning=Failed to copy {}: {e}", src.display()); + } + } + Ok(s) => { + println!("cargo:warning=Failed to build WASM plugin {name} (exit code: {s})"); + } + Err(e) => { + println!("cargo:warning=Failed to run cargo for {name}: {e}"); + } + } +} diff --git a/crates/biome_plugin_loader/src/analyzer_grit_plugin.rs b/crates/biome_plugin_loader/src/analyzer_grit_plugin.rs index 5ec8d837aae1..9b5d9a7d3e40 100644 --- a/crates/biome_plugin_loader/src/analyzer_grit_plugin.rs +++ b/crates/biome_plugin_loader/src/analyzer_grit_plugin.rs @@ -1,5 +1,5 @@ use crate::{AnalyzerPlugin, PluginDiagnostic}; -use biome_analyze::{PluginTargetLanguage, RuleDiagnostic}; +use biome_analyze::{PluginEvaluationResult, PluginTargetLanguage, RuleDiagnostic, ServiceBag}; use biome_console::markup; use biome_css_syntax::{CssRoot, CssSyntaxNode}; use biome_diagnostics::{Severity, category}; @@ -22,6 +22,8 @@ use std::{borrow::Cow, fmt::Debug, str::FromStr, sync::Arc}; #[derive(Debug)] pub struct AnalyzerGritPlugin { grit_query: GritQuery, + /// Fallback name derived from the file stem when the query has no name. + fallback_name: String, } impl AnalyzerGritPlugin { @@ -38,12 +40,23 @@ impl AnalyzerGritPlugin { ]) .with_path(path); let grit_query = compile_pattern_with_options(&source, options)?; + let fallback_name = path.file_stem().unwrap_or("anonymous").to_string(); - Ok(Self { grit_query }) + Ok(Self { + grit_query, + fallback_name, + }) } } impl AnalyzerPlugin for AnalyzerGritPlugin { + fn rule_name(&self) -> &str { + self.grit_query + .name + .as_deref() + .unwrap_or(&self.fallback_name) + } + fn language(&self) -> PluginTargetLanguage { match &self.grit_query.language { GritTargetLanguage::JsTargetLanguage(_) => PluginTargetLanguage::JavaScript, @@ -68,8 +81,13 @@ impl AnalyzerPlugin for AnalyzerGritPlugin { } } - fn evaluate(&self, node: AnySyntaxNode, path: Arc) -> Vec { - let name: &str = self.grit_query.name.as_deref().unwrap_or("anonymous"); + fn evaluate( + &self, + node: AnySyntaxNode, + path: Arc, + _services: &ServiceBag, + ) -> PluginEvaluationResult { + let name: &str = self.rule_name(); let root = match self.language() { PluginTargetLanguage::JavaScript => node @@ -107,24 +125,30 @@ impl AnalyzerPlugin for AnalyzerGritPlugin { .iter() .any(|diagnostic| diagnostic.span().is_none()) { - diagnostics.push(RuleDiagnostic::new( - category!("plugin"), - None::, - markup!( - "Plugin "{name}" reported one or more diagnostics, " - "but it didn't specify a valid ""span"". " - "Diagnostics have been shown without context." - ), - )); + diagnostics.push( + RuleDiagnostic::new( + category!("plugin"), + None::, + markup!( + "Plugin "{name}" reported one or more diagnostics, " + "but it didn't specify a valid ""span"". " + "Diagnostics have been shown without context." + ), + ) + .subcategory(name.to_string()), + ); } - diagnostics + PluginEvaluationResult::from_diagnostics(diagnostics) } - Err(error) => vec![RuleDiagnostic::new( - category!("plugin"), - None::, - markup!({name}" errored: "{error.to_string()}), - )], + Err(error) => PluginEvaluationResult::from_diagnostics(vec![ + RuleDiagnostic::new( + category!("plugin"), + None::, + markup!({name}" errored: "{error.to_string()}), + ) + .subcategory(name.to_string()), + ]), } } } diff --git a/crates/biome_plugin_loader/src/analyzer_js_plugin.rs b/crates/biome_plugin_loader/src/analyzer_js_plugin.rs index 4013f672f8b6..28d834deddf3 100644 --- a/crates/biome_plugin_loader/src/analyzer_js_plugin.rs +++ b/crates/biome_plugin_loader/src/analyzer_js_plugin.rs @@ -6,7 +6,9 @@ use boa_engine::object::builtins::JsFunction; use boa_engine::{JsNativeError, JsResult, JsString, JsValue}; use camino::{Utf8Path, Utf8PathBuf}; -use biome_analyze::{AnalyzerPlugin, PluginTargetLanguage, RuleDiagnostic}; +use biome_analyze::{ + AnalyzerPlugin, PluginEvaluationResult, PluginTargetLanguage, RuleDiagnostic, ServiceBag, +}; use biome_console::markup; use biome_diagnostics::category; use biome_js_runtime::JsExecContext; @@ -73,6 +75,10 @@ impl AnalyzerJsPlugin { } impl AnalyzerPlugin for AnalyzerJsPlugin { + fn rule_name(&self) -> &str { + self.path.file_stem().unwrap_or("anonymous") + } + fn language(&self) -> PluginTargetLanguage { PluginTargetLanguage::JavaScript } @@ -85,25 +91,30 @@ impl AnalyzerPlugin for AnalyzerJsPlugin { .collect() } - fn evaluate(&self, _node: AnySyntaxNode, path: Arc) -> Vec { + fn evaluate( + &self, + _node: AnySyntaxNode, + path: Arc, + _services: &ServiceBag, + ) -> PluginEvaluationResult { let mut plugin = match self .loaded .get_mut_or_try_init(|| load_plugin(self.fs.clone(), &self.path)) { Ok(plugin) => plugin, Err(err) => { - return vec![RuleDiagnostic::new( + return PluginEvaluationResult::from_diagnostics(vec![RuleDiagnostic::new( category!("plugin"), None::, markup!("Could not load the plugin: "{err.to_string()}), - )]; + )]); } }; let plugin = plugin.deref_mut(); // TODO: pass the AST to the plugin - plugin + let diagnostics = plugin .ctx .call_function( &plugin.entrypoint, @@ -119,13 +130,15 @@ impl AnalyzerPlugin for AnalyzerJsPlugin { )] }, |_| plugin.ctx.pull_diagnostics(), - ) + ); + PluginEvaluationResult::from_diagnostics(diagnostics) } } #[cfg(test)] mod tests { use super::*; + use biome_analyze::ServiceBag; use biome_diagnostics::{Error, print_diagnostic_to_string}; use biome_fs::MemoryFileSystem; use biome_js_parser::{JsFileSource, JsParserOptions}; @@ -172,7 +185,11 @@ mod tests { JsParserOptions::default(), ); - plugin.evaluate(parse.syntax().into(), Arc::new("/foo.js".into())) + plugin.evaluate( + parse.syntax().into(), + Arc::new("/foo.js".into()), + &ServiceBag::default(), + ) }) }; @@ -186,17 +203,24 @@ mod tests { JsParserOptions::default(), ); - plugin.evaluate(parse.syntax().into(), Arc::new("/bar.js".into())) + plugin.evaluate( + parse.syntax().into(), + Arc::new("/bar.js".into()), + &ServiceBag::default(), + ) }) }; - let mut diagnostics = worker1.join().unwrap(); - diagnostics.extend(worker2.join().unwrap()); + let mut diagnostics = worker1.join().unwrap().diagnostics; + diagnostics.extend(worker2.join().unwrap().diagnostics); assert_eq!(diagnostics.len(), 2); snap_diagnostics( "evaluate_in_worker_threads", - diagnostics.into_iter().map(|diag| diag.into()).collect(), + diagnostics + .into_iter() + .map(|diag| diag.diagnostic.into()) + .collect(), ); } } diff --git a/crates/biome_plugin_loader/src/analyzer_wasm_plugin.rs b/crates/biome_plugin_loader/src/analyzer_wasm_plugin.rs new file mode 100644 index 000000000000..80e21925785b --- /dev/null +++ b/crates/biome_plugin_loader/src/analyzer_wasm_plugin.rs @@ -0,0 +1,327 @@ +use std::cell::RefCell; +use std::collections::HashMap; +use std::str::FromStr; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +use biome_analyze::{ + Phases, PluginDiagnosticEntry, PluginEvaluationResult, PluginTargetLanguage, RuleCategory, + RuleDiagnostic, RuleDomain, ServiceBag, +}; +use biome_console::markup; +use biome_diagnostics::{MessageAndDescription, category}; +use biome_js_semantic::SemanticModel; +use biome_module_graph::ModuleResolver; +use biome_rowan::{AnySyntaxNode, RawSyntaxKind, TextRange}; +use biome_wasm_plugin::{WasmPluginEngine, WasmPluginSession}; +use camino::{Utf8Path, Utf8PathBuf}; + +use crate::diagnostics::CompileDiagnostic; +use crate::{AnalyzerPlugin, PluginDiagnostic}; + +/// Global counter for assigning unique IDs to plugin instances. +static NEXT_PLUGIN_ID: AtomicU64 = AtomicU64::new(0); + +/// Generation counter. Bumped on each `load_plugins()` call to invalidate +/// stale thread-local sessions from previous plugin configurations. +static GENERATION: AtomicU64 = AtomicU64::new(0); + +/// Bump the generation counter. Call this when plugins are reloaded (e.g. on +/// config change) so that stale sessions are discarded on next access. +pub fn bump_generation() { + GENERATION.fetch_add(1, Ordering::Relaxed); +} + +thread_local! { + /// Per-thread WASM session cache. Each thread maintains its own sessions + /// so that parallel file analysis doesn't contend on a shared lock. + /// Entries store `(generation, session)` — stale generations are discarded. + static SESSIONS: RefCell> = RefCell::new(HashMap::new()); +} + +/// An analyzer plugin backed by a WASM Component Model module. +/// +/// A single WASM module may expose multiple rules. Each `AnalyzerWasmPlugin` +/// represents one rule and shares the engine via `Arc`. +#[derive(Debug)] +pub struct AnalyzerWasmPlugin { + engine: Arc, + /// Unique ID for this plugin instance, used as the key in the thread-local + /// session cache. + plugin_id: u64, + rule_name: String, + /// Human-readable plugin name derived from the plugin path (e.g. + /// `"wirex-biome-plugin"`). Used to namespace the rule in diagnostics so + /// editors render it similarly to ESLint: `(biome pluginName/ruleName)`. + plugin_name: String, + target_language: PluginTargetLanguage, + query_kinds: Vec, + /// JSON-serialized options string, passed to the guest `configure` export. + options_json: Option, + /// Rule category parsed from metadata. + rule_category: RuleCategory, + /// Domains this rule belongs to. + rule_domains: Vec, + /// Deprecation reason, if the rule is deprecated. + rule_deprecated: Option, + /// GitHub issue number for WIP rules. + rule_issue_number: Option, + /// Whether this rule is recommended (defaults to true if not specified). + rule_recommended: bool, + /// Trigger strings for host-side pre-filtering. If non-empty, `evaluate` + /// skips the WASM `check` call when the file source doesn't contain any of + /// these strings (case-insensitive ASCII comparison). + source_triggers: Vec, +} + +impl AnalyzerWasmPlugin { + /// Load a WASM plugin and return one `AnalyzerWasmPlugin` per rule it exposes. + /// + /// `plugin_name` is a human-readable identifier for the plugin (typically + /// derived from the directory or file name) used to namespace diagnostics. + pub fn load( + path: &Utf8Path, + plugin_name: &str, + options_json: Option, + ) -> Result, PluginDiagnostic> { + let bytes = std::fs::read(path.as_std_path()).map_err(|err| { + PluginDiagnostic::Compile(CompileDiagnostic { + message: MessageAndDescription::from( + markup! { + "Failed to read WASM plugin: "{err.to_string()} + } + .to_owned(), + ), + source: None, + }) + })?; + + let engine = Arc::new(WasmPluginEngine::new_cached(&bytes, path.as_std_path())?); + let metadata = engine.metadata()?; + + let target_language = match metadata.language.as_str() { + "javascript" => PluginTargetLanguage::JavaScript, + "css" => PluginTargetLanguage::Css, + "json" => PluginTargetLanguage::Json, + other => { + return Err(PluginDiagnostic::Compile(CompileDiagnostic { + message: MessageAndDescription::from( + markup! { + "WASM plugin declared unsupported target language: " + {other} + } + .to_owned(), + ), + source: None, + })); + } + }; + + // Treat empty or trivial "{}" options as None to skip the per-node + // configure() WASM call when no real options are provided. + let options_json = options_json.filter(|s| !s.is_empty() && s != "{}"); + + let mut plugins = Vec::with_capacity(metadata.rule_names.len()); + + for rule_name in &metadata.rule_names { + let raw_kinds = metadata + .query_kinds_by_rule + .get(rule_name) + .cloned() + .unwrap_or_default(); + + let query_kinds = raw_kinds + .into_iter() + .map(|k| { + u16::try_from(k).map(RawSyntaxKind).map_err(|_| { + PluginDiagnostic::Compile(CompileDiagnostic { + message: MessageAndDescription::from( + markup! { + "WASM plugin query kind exceeds u16::MAX: "{k.to_string()} + } + .to_owned(), + ), + source: None, + }) + }) + }) + .collect::>()?; + + // Parse per-rule metadata fields. + let rule_meta = metadata.rule_metadata_by_rule.get(rule_name); + + let rule_category = rule_meta + .and_then(|m| m.category.as_deref()) + .and_then(|s| RuleCategory::from_str(s).ok()) + .unwrap_or(RuleCategory::Lint); + + let rule_domains: Vec = rule_meta + .map(|m| { + m.domains + .iter() + .filter_map(|s| RuleDomain::from_str(s).ok()) + .collect() + }) + .unwrap_or_default(); + + let rule_deprecated = rule_meta.and_then(|m| m.deprecated.clone()); + let rule_issue_number = rule_meta.and_then(|m| m.issue_number.clone()); + + let rule_recommended = rule_meta.is_none_or(|m| m.recommended); + + let source_triggers = metadata + .source_triggers_by_rule + .get(rule_name) + .cloned() + .unwrap_or_default(); + + plugins.push(Self { + engine: Arc::clone(&engine), + plugin_id: NEXT_PLUGIN_ID.fetch_add(1, Ordering::Relaxed), + rule_name: rule_name.clone(), + plugin_name: plugin_name.to_string(), + target_language, + query_kinds, + options_json: options_json.clone(), + rule_category, + rule_domains, + rule_deprecated, + rule_issue_number, + rule_recommended, + source_triggers, + }); + } + + Ok(plugins) + } +} + +impl AnalyzerPlugin for AnalyzerWasmPlugin { + fn language(&self) -> PluginTargetLanguage { + self.target_language + } + + fn phase(&self) -> Phases { + // JS plugins run in the Semantic phase to access the semantic model. + // CSS/JSON plugins stay in Syntax since they have no semantic model support yet. + match self.target_language { + PluginTargetLanguage::JavaScript => Phases::Semantic, + _ => Phases::Syntax, + } + } + + fn query(&self) -> Vec { + self.query_kinds.clone() + } + + fn rule_name(&self) -> &str { + &self.rule_name + } + + fn category(&self) -> RuleCategory { + self.rule_category + } + + fn domains(&self) -> &[RuleDomain] { + &self.rule_domains + } + + fn is_recommended(&self) -> bool { + self.rule_recommended + } + + fn deprecated(&self) -> Option<&str> { + self.rule_deprecated.as_deref() + } + + fn issue_number(&self) -> Option<&str> { + self.rule_issue_number.as_deref() + } + + fn source_triggers(&self) -> &[String] { + &self.source_triggers + } + + fn evaluate( + &self, + node: AnySyntaxNode, + path: Arc, + services: &ServiceBag, + ) -> PluginEvaluationResult { + let file_path = path.as_str().to_string(); + let qualified_name = format!("{}/{}", self.plugin_name, self.rule_name); + + let current_gen = GENERATION.load(Ordering::Relaxed); + + let result = SESSIONS.with(|sessions| { + let mut map = sessions.borrow_mut(); + + // Evict stale sessions from previous generations. + map.retain(|_, (g, _)| *g == current_gen); + + // Check if we have a cached session for this plugin on this thread. + let needs_new_session = match map.get(&self.plugin_id) { + Some((_, session)) => session.file_path() != file_path, + None => true, + }; + + if needs_new_session { + let semantic_model = services.get_service::().cloned(); + let module_resolver: Option> = services + .get_service::>>() + .and_then(|opt| opt.as_ref().map(Arc::clone)); + + match self.engine.create_session( + node, + self.target_language, + semantic_model, + module_resolver, + file_path, + ) { + Ok(mut session) => { + let result = + session.check_current(&self.rule_name, self.options_json.as_deref()); + map.insert(self.plugin_id, (current_gen, session)); + result + } + Err(e) => Err(e), + } + } else { + let (_, session) = map.get_mut(&self.plugin_id).unwrap(); + session.check_node(node, &self.rule_name, self.options_json.as_deref()) + } + }); + + match result { + Ok(entries) => PluginEvaluationResult { + diagnostics: entries + .into_iter() + .map(|entry| PluginDiagnosticEntry { + diagnostic: entry.diagnostic.subcategory(qualified_name.clone()), + actions: entry.actions, + }) + .collect(), + }, + Err(error) => { + // Session is likely corrupted after an error — remove it. + SESSIONS.with(|sessions| { + sessions.borrow_mut().remove(&self.plugin_id); + }); + PluginEvaluationResult { + diagnostics: vec![PluginDiagnosticEntry { + diagnostic: RuleDiagnostic::new( + category!("plugin"), + None::, + markup!( + {&self.rule_name} + " errored: "{error.to_string()} + ), + ) + .subcategory(qualified_name), + actions: vec![], + }], + } + } + } + } +} diff --git a/crates/biome_plugin_loader/src/configuration.rs b/crates/biome_plugin_loader/src/configuration.rs index 486998735d53..5b7bcf855008 100644 --- a/crates/biome_plugin_loader/src/configuration.rs +++ b/crates/biome_plugin_loader/src/configuration.rs @@ -1,9 +1,36 @@ +//! Plugin configuration types for the `plugins` array in `biome.json`. +//! +//! Each entry in the `plugins` array is a [`PluginConfiguration`], which can be +//! either a simple path string or a [`PluginPathWithOptions`] object: +//! +//! ```json +//! { +//! "plugins": [ +//! "./simple-rule.wasm", +//! { +//! "path": "./configurable.wasm", +//! "options": { "convention": "camelCase" }, +//! "rules": { "myRule": "warn" } +//! } +//! ] +//! } +//! ``` +//! +//! The `options` object is serialized to JSON and passed to the plugin's +//! `configure()` export. The `rules` map allows per-rule configuration using +//! severity levels (`off`, `on`, `info`, `warn`, `error`) or an object with +//! `level` and `fix`. + use biome_deserialize::{ - Deserializable, DeserializableType, DeserializableValue, DeserializationContext, + Deserializable, DeserializableType, DeserializableTypes, DeserializableValue, + DeserializationContext, DeserializationVisitor, }; use biome_deserialize_macros::{Deserializable, Merge}; use biome_fs::normalize_path; +use biome_rowan::Text; +use biome_text_size::TextRange; use camino::Utf8Path; +use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; use std::{ ops::{Deref, DerefMut}, @@ -26,17 +53,18 @@ impl Plugins { /// `.` / `..` segments (without resolving symlinks). pub fn normalize_relative_paths(&mut self, base_dir: &Utf8Path) { for plugin_config in self.0.iter_mut() { - match plugin_config { - PluginConfiguration::Path(plugin_path) => { - let plugin_path_buf = Utf8Path::new(plugin_path.as_str()); - if plugin_path_buf.is_absolute() { - continue; - } - - let normalized = normalize_path(&base_dir.join(plugin_path_buf)); - *plugin_path = normalized.to_string(); - } + let plugin_path = match plugin_config { + PluginConfiguration::Path(p) => p, + PluginConfiguration::PathWithOptions(opts) => &mut opts.path, + }; + + let plugin_path_buf = Utf8Path::new(plugin_path.as_str()); + if plugin_path_buf.is_absolute() { + continue; } + + let normalized = normalize_path(&base_dir.join(plugin_path_buf)); + *plugin_path = normalized.to_string(); } } } @@ -68,7 +96,192 @@ impl DerefMut for Plugins { #[serde(rename_all = "camelCase", deny_unknown_fields, untagged)] pub enum PluginConfiguration { Path(String), - // TODO: PathWithOptions(PluginPathWithOptions), + PathWithOptions(PluginPathWithOptions), +} + +impl PluginConfiguration { + /// Returns the plugin path regardless of variant. + pub fn path(&self) -> &str { + match self { + Self::Path(p) => p, + Self::PathWithOptions(opts) => &opts.path, + } + } + + /// Returns the options JSON string, if any. + pub fn options_json(&self) -> Option<&str> { + match self { + Self::Path(_) => None, + Self::PathWithOptions(opts) => Some(&opts.options), + } + } + + /// Returns per-rule configuration overrides, if any. + pub fn rules(&self) -> Option<&FxHashMap> { + match self { + Self::Path(_) => None, + Self::PathWithOptions(opts) => opts.rules.as_ref(), + } + } +} + +/// Per-rule configuration for a plugin rule. +/// +/// Can be a short string (`"off"`, `"warn"`, `"error"`) or an object +/// with `level` and optional `fix` fields. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(untagged)] +pub enum PluginRuleConfiguration { + Plain(PluginRulePlainConfiguration), + WithOptions(PluginRuleWithOptions), +} + +impl PluginRuleConfiguration { + /// Returns `true` if this rule is disabled. + pub fn is_disabled(&self) -> bool { + self.level() == PluginRulePlainConfiguration::Off + } + + /// Returns the configured severity level. + pub fn level(&self) -> PluginRulePlainConfiguration { + match self { + Self::Plain(level) => *level, + Self::WithOptions(opts) => opts.level, + } + } + + /// Returns the configured fix kind string, if any. + pub fn fix_kind_str(&self) -> Option<&str> { + match self { + Self::Plain(_) => None, + Self::WithOptions(opts) => opts.fix.as_deref(), + } + } +} + +/// Short-form severity level for a plugin rule. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub enum PluginRulePlainConfiguration { + Off, + On, + Info, + Warn, + Error, +} + +/// Object form with a severity level and an optional fix kind. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase")] +pub struct PluginRuleWithOptions { + pub level: PluginRulePlainConfiguration, + /// Fix kind: `"safe"`, `"unsafe"`, or `"none"`. + #[serde(skip_serializing_if = "Option::is_none")] + pub fix: Option, +} + +impl Deserializable for PluginRuleConfiguration { + fn deserialize( + ctx: &mut impl DeserializationContext, + value: &impl DeserializableValue, + name: &str, + ) -> Option { + if value.visitable_type()? == DeserializableType::Str { + Deserializable::deserialize(ctx, value, name).map(Self::Plain) + } else { + Deserializable::deserialize(ctx, value, name).map(Self::WithOptions) + } + } +} + +impl Deserializable for PluginRulePlainConfiguration { + fn deserialize( + ctx: &mut impl DeserializationContext, + value: &impl DeserializableValue, + name: &str, + ) -> Option { + let text: Text = Deserializable::deserialize(ctx, value, name)?; + match text.text() { + "off" => Some(Self::Off), + "on" => Some(Self::On), + "info" => Some(Self::Info), + "warn" => Some(Self::Warn), + "error" => Some(Self::Error), + _ => None, + } + } +} + +impl Deserializable for PluginRuleWithOptions { + fn deserialize( + ctx: &mut impl DeserializationContext, + value: &impl DeserializableValue, + name: &str, + ) -> Option { + value.deserialize(ctx, PluginRuleWithOptionsVisitor, name) + } +} + +struct PluginRuleWithOptionsVisitor; + +impl DeserializationVisitor for PluginRuleWithOptionsVisitor { + type Output = PluginRuleWithOptions; + + const EXPECTED_TYPE: DeserializableTypes = DeserializableTypes::MAP; + + fn visit_map( + self, + ctx: &mut impl DeserializationContext, + members: impl ExactSizeIterator< + Item = Option<(impl DeserializableValue, impl DeserializableValue)>, + >, + _range: TextRange, + _name: &str, + ) -> Option { + let mut level = None; + let mut fix = None; + + for (key, value) in members.flatten() { + let Some(key_text) = Text::deserialize(ctx, &key, "") else { + continue; + }; + match key_text.text() { + "level" => { + level = Deserializable::deserialize(ctx, &value, &key_text); + } + "fix" => { + fix = Deserializable::deserialize(ctx, &value, &key_text); + } + _ => {} + } + } + + Some(PluginRuleWithOptions { level: level?, fix }) + } +} + +/// A plugin path paired with a JSON string of configuration options. +/// +/// Example biome.json: +/// ```json +/// { "path": "./my-plugin.wasm", "options": { "maxLength": 100 }, "rules": { "noConsoleLog": "off" } } +/// ``` +/// +/// The `options` field is stored as a raw JSON string because plugin options +/// are untyped — the guest receives them via the `configure(options-json)` export. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PluginPathWithOptions { + pub path: String, + /// Raw JSON string representing the plugin options. + pub options: String, + /// Per-rule configuration overrides (severity, fix kind, enable/disable). + #[serde(skip_serializing_if = "Option::is_none")] + pub rules: Option>, } impl Deserializable for PluginConfiguration { @@ -80,15 +293,69 @@ impl Deserializable for PluginConfiguration { if value.visitable_type()? == DeserializableType::Str { Deserializable::deserialize(ctx, value, rule_name).map(Self::Path) } else { - // TODO: Fix this to allow plugins to receive options. - // We probably need to pass them as `AnyJsonValue` or - // `biome_json_value::JsonValue`, since plugin options are - // untyped. - // Also, we don't have a way to configure Grit plugins yet. - /*Deserializable::deserialize(value, rule_name, diagnostics) - .map(|plugin| Self::PathWithOptions(plugin))*/ - None + Deserializable::deserialize(ctx, value, rule_name).map(Self::PathWithOptions) + } + } +} + +impl Deserializable for PluginPathWithOptions { + fn deserialize( + ctx: &mut impl DeserializationContext, + value: &impl DeserializableValue, + name: &str, + ) -> Option { + value.deserialize(ctx, PluginPathWithOptionsVisitor, name) + } +} + +struct PluginPathWithOptionsVisitor; + +impl DeserializationVisitor for PluginPathWithOptionsVisitor { + type Output = PluginPathWithOptions; + + const EXPECTED_TYPE: DeserializableTypes = DeserializableTypes::MAP; + + fn visit_map( + self, + ctx: &mut impl DeserializationContext, + members: impl ExactSizeIterator< + Item = Option<(impl DeserializableValue, impl DeserializableValue)>, + >, + _range: TextRange, + _name: &str, + ) -> Option { + let mut path: Option = None; + let mut options: Option = None; + let mut rules: Option> = None; + + for (key, value) in members.flatten() { + let Some(key_text) = Text::deserialize(ctx, &key, "") else { + continue; + }; + match key_text.text() { + "path" => { + path = Deserializable::deserialize(ctx, &value, &key_text); + } + "options" => { + // Deserialize as serde_json::Value to handle arbitrary JSON, + // then serialize back to a JSON string for the WASM guest. + let json_value: Option = + Deserializable::deserialize(ctx, &value, &key_text); + options = json_value.and_then(|v| serde_json::to_string(&v).ok()); + } + "rules" => { + rules = Deserializable::deserialize(ctx, &value, &key_text); + } + _ => {} + } } + + let path = path?; + Some(PluginPathWithOptions { + path, + options: options.unwrap_or_else(|| "{}".to_string()), + rules, + }) } } @@ -106,12 +373,16 @@ mod tests { plugins.normalize_relative_paths(base_dir); - let PluginConfiguration::Path(first) = &plugins.0[0]; + let PluginConfiguration::Path(first) = &plugins.0[0] else { + panic!("expected Path variant"); + }; assert!(Utf8Path::new(first).starts_with(base_dir)); let expected_suffix = Utf8Path::new("biome").join("my-plugin.grit"); assert!(Utf8Path::new(first).ends_with(expected_suffix.as_path())); - let PluginConfiguration::Path(second) = &plugins.0[1]; + let PluginConfiguration::Path(second) = &plugins.0[1] else { + panic!("expected Path variant"); + }; assert!(Utf8Path::new(second).starts_with(base_dir)); assert!(Utf8Path::new(second).ends_with("other.grit")); } @@ -125,7 +396,146 @@ mod tests { plugins.normalize_relative_paths(base_dir); - let PluginConfiguration::Path(result) = &plugins.0[0]; + let PluginConfiguration::Path(result) = &plugins.0[0] else { + panic!("expected Path variant"); + }; assert_eq!(result, &absolute); } + + #[test] + fn normalize_relative_paths_handles_path_with_options() { + let base_dir = Utf8Path::new("base"); + let mut plugins = Plugins(vec![PluginConfiguration::PathWithOptions( + PluginPathWithOptions { + path: "./my-plugin.wasm".into(), + options: r#"{"maxLength": 100}"#.into(), + rules: None, + }, + )]); + + plugins.normalize_relative_paths(base_dir); + + let PluginConfiguration::PathWithOptions(opts) = &plugins.0[0] else { + panic!("expected PathWithOptions variant"); + }; + assert!(Utf8Path::new(&opts.path).starts_with(base_dir)); + assert!(Utf8Path::new(&opts.path).ends_with("my-plugin.wasm")); + // Options should be unchanged + assert_eq!(opts.options, r#"{"maxLength": 100}"#); + } + + #[test] + fn plugin_configuration_path_accessors() { + let path_config = PluginConfiguration::Path("./foo.grit".into()); + assert_eq!(path_config.path(), "./foo.grit"); + assert_eq!(path_config.options_json(), None); + + let opts_config = PluginConfiguration::PathWithOptions(PluginPathWithOptions { + path: "./bar.wasm".into(), + options: r#"{"key": "value"}"#.into(), + rules: None, + }); + assert_eq!(opts_config.path(), "./bar.wasm"); + assert_eq!(opts_config.options_json(), Some(r#"{"key": "value"}"#)); + } + + #[test] + fn deserialize_path_with_options_from_json() { + use biome_deserialize::json::deserialize_from_json_str; + use biome_json_parser::JsonParserOptions; + + let json = r#"[{"path": "./plugin.wasm", "options": {"maxLength": 100}}]"#; + let (plugins, errors) = + deserialize_from_json_str::(json, JsonParserOptions::default(), "").consume(); + + assert!(errors.is_empty(), "Unexpected errors: {errors:?}"); + let plugins = plugins.unwrap(); + assert_eq!(plugins.0.len(), 1); + + let PluginConfiguration::PathWithOptions(opts) = &plugins.0[0] else { + panic!("expected PathWithOptions variant, got: {:?}", plugins.0[0]); + }; + assert_eq!(opts.path, "./plugin.wasm"); + // The options should be a JSON string containing the object + assert!(opts.options.contains("maxLength")); + } + + #[test] + fn deserialize_path_only_from_json() { + use biome_deserialize::json::deserialize_from_json_str; + use biome_json_parser::JsonParserOptions; + + let json = r#"["./plugin.grit"]"#; + let (plugins, errors) = + deserialize_from_json_str::(json, JsonParserOptions::default(), "").consume(); + + assert!(errors.is_empty(), "Unexpected errors: {errors:?}"); + let plugins = plugins.unwrap(); + assert_eq!(plugins.0.len(), 1); + + let PluginConfiguration::Path(path) = &plugins.0[0] else { + panic!("expected Path variant, got: {:?}", plugins.0[0]); + }; + assert_eq!(path, "./plugin.grit"); + } + + #[test] + fn deserialize_path_with_rules_from_json() { + use biome_deserialize::json::deserialize_from_json_str; + use biome_json_parser::JsonParserOptions; + + let json = r#"[{ + "path": "./plugin.wasm", + "options": {}, + "rules": { + "noConsoleLog": "off", + "useStrictEquality": "error", + "booleanNaming": { "level": "warn", "fix": "safe" } + } + }]"#; + let (plugins, errors) = + deserialize_from_json_str::(json, JsonParserOptions::default(), "").consume(); + + assert!(errors.is_empty(), "Unexpected errors: {errors:?}"); + let plugins = plugins.unwrap(); + assert_eq!(plugins.0.len(), 1); + + let PluginConfiguration::PathWithOptions(opts) = &plugins.0[0] else { + panic!("expected PathWithOptions variant, got: {:?}", plugins.0[0]); + }; + + let rules = opts.rules.as_ref().expect("rules should be present"); + assert_eq!(rules.len(), 3); + + let no_console = &rules["noConsoleLog"]; + assert!(no_console.is_disabled()); + assert_eq!(no_console.level(), PluginRulePlainConfiguration::Off); + + let strict_eq = &rules["useStrictEquality"]; + assert!(!strict_eq.is_disabled()); + assert_eq!(strict_eq.level(), PluginRulePlainConfiguration::Error); + + let bool_naming = &rules["booleanNaming"]; + assert_eq!(bool_naming.level(), PluginRulePlainConfiguration::Warn); + assert_eq!(bool_naming.fix_kind_str(), Some("safe")); + } + + #[test] + fn plugin_configuration_rules_accessor() { + let path_config = PluginConfiguration::Path("./foo.grit".into()); + assert!(path_config.rules().is_none()); + + let mut rules_map = FxHashMap::default(); + rules_map.insert( + "someRule".into(), + PluginRuleConfiguration::Plain(PluginRulePlainConfiguration::Off), + ); + let opts_config = PluginConfiguration::PathWithOptions(PluginPathWithOptions { + path: "./bar.wasm".into(), + options: "{}".into(), + rules: Some(rules_map), + }); + let rules = opts_config.rules().unwrap(); + assert!(rules["someRule"].is_disabled()); + } } diff --git a/crates/biome_plugin_loader/src/diagnostics.rs b/crates/biome_plugin_loader/src/diagnostics.rs index 4d8fcec5d3b1..50b3355107d6 100644 --- a/crates/biome_plugin_loader/src/diagnostics.rs +++ b/crates/biome_plugin_loader/src/diagnostics.rs @@ -63,6 +63,18 @@ impl From for PluginDiagnostic { } } +#[cfg(feature = "wasm_plugin")] +impl From for PluginDiagnostic { + fn from(value: wasmtime::Error) -> Self { + Self::Compile(CompileDiagnostic { + message: MessageAndDescription::from( + markup! {"Failed to load WASM plugin: "{value.to_string()}}.to_owned(), + ), + source: None, + }) + } +} + impl From for PluginDiagnostic { fn from(value: DeserializationDiagnostic) -> Self { Self::Deserialization(value) @@ -147,11 +159,11 @@ impl From for biome_diagnostics::serde::Diagnostic { pub struct CompileDiagnostic { #[message] #[description] - message: MessageAndDescription, + pub(crate) message: MessageAndDescription, #[serde(skip)] #[source] - source: Option, + pub(crate) source: Option, } #[derive(Debug, Serialize, Deserialize, Diagnostic)] diff --git a/crates/biome_plugin_loader/src/lib.rs b/crates/biome_plugin_loader/src/lib.rs index 8d7e31838e9f..e750c031683c 100644 --- a/crates/biome_plugin_loader/src/lib.rs +++ b/crates/biome_plugin_loader/src/lib.rs @@ -10,10 +10,15 @@ mod analyzer_js_plugin; #[cfg(feature = "js_plugin")] mod thread_local; +#[cfg(feature = "wasm_plugin")] +mod analyzer_wasm_plugin; + mod configuration; #[cfg(feature = "js_plugin")] pub use analyzer_js_plugin::AnalyzerJsPlugin; +#[cfg(feature = "wasm_plugin")] +pub use analyzer_wasm_plugin::{AnalyzerWasmPlugin, bump_generation as bump_wasm_generation}; pub use analyzer_grit_plugin::AnalyzerGritPlugin; pub use configuration::*; @@ -40,13 +45,23 @@ impl BiomePlugin { /// Loads a plugin from the given `plugin_path`. /// /// The base path is used to resolve relative paths. + /// `options_json` is an optional JSON string of rule options for WASM plugins. pub fn load( fs: Arc, plugin_path: &str, base_path: &Utf8Path, + options_json: Option, ) -> Result<(Self, Utf8PathBuf), PluginDiagnostic> { let plugin_path = normalize_path(&base_path.join(plugin_path)); + // Derive a human-readable plugin name from the path for diagnostic + // display. For a directory (manifest-based) plugin this is the + // directory name; for a single-file plugin it is the file stem. + let plugin_name = plugin_path + .file_stem() + .unwrap_or(plugin_path.as_str()) + .to_string(); + // If the plugin path references a `.grit` file directly, treat it as // a single-rule plugin instead of going through the manifest process: if plugin_path @@ -56,7 +71,7 @@ impl BiomePlugin { let plugin = AnalyzerGritPlugin::load(fs.as_ref(), &plugin_path)?; return Ok(( Self { - analyzer_plugins: vec![Arc::new(Box::new(plugin) as Box)], + analyzer_plugins: vec![Arc::new(plugin) as Arc], }, plugin_path, )); @@ -71,12 +86,36 @@ impl BiomePlugin { let plugin = AnalyzerJsPlugin::load(fs.clone(), &plugin_path)?; return Ok(( Self { - analyzer_plugins: vec![Arc::new(Box::new(plugin) as Box)], + analyzer_plugins: vec![Arc::new(plugin) as Arc], }, plugin_path, )); } + #[cfg(feature = "wasm_plugin")] + if plugin_path + .extension() + .is_some_and(|extension| extension == "wasm") + { + let plugins = AnalyzerWasmPlugin::load(&plugin_path, &plugin_name, options_json)?; + return Ok(( + Self { + analyzer_plugins: plugins + .into_iter() + .map(|p| Arc::new(p) as Arc) + .collect(), + }, + plugin_path, + )); + } + + // options_json and plugin_name are only used by the wasm_plugin feature. + #[cfg(not(feature = "wasm_plugin"))] + { + let _ = options_json; + let _ = plugin_name; + } + let manifest_path = plugin_path.join("biome-manifest.jsonc"); if !fs.path_is_file(&manifest_path) { return Err(PluginDiagnostic::cant_resolve(manifest_path, None)); @@ -106,7 +145,27 @@ impl BiomePlugin { if rule.as_os_str().as_encoded_bytes().ends_with(b".grit") { let plugin = AnalyzerGritPlugin::load(fs.as_ref(), &plugin_path.join(rule))?; - Ok(Arc::new(Box::new(plugin) as Box)) + Ok(vec![Arc::new(plugin) as Arc]) + } else if rule.as_os_str().as_encoded_bytes().ends_with(b".wasm") { + #[cfg(feature = "wasm_plugin")] + { + let plugins = AnalyzerWasmPlugin::load( + &plugin_path.join(rule), + &plugin_name, + options_json.clone(), + )?; + Ok(plugins + .into_iter() + .map(|p| Arc::new(p) as Arc) + .collect::>()) + } + #[cfg(not(feature = "wasm_plugin"))] + { + Err(PluginDiagnostic::unsupported_rule_format(markup!( + "WASM plugins require the wasm_plugin feature. Rule: " + {rule.to_string()} + ))) + } } else { Err(PluginDiagnostic::unsupported_rule_format(markup!( "Unsupported rule format for plugin rule " @@ -114,7 +173,10 @@ impl BiomePlugin { ))) } }) - .collect::>()?, + .collect::, _>>()? + .into_iter() + .flatten() + .collect(), }; Ok((plugin, plugin_path)) @@ -154,8 +216,8 @@ mod test { fs.insert("/my-plugin/rules/1.grit".into(), r#"`hello`"#); let fs = Arc::new(fs) as Arc; - let (plugin, _) = - BiomePlugin::load(fs, "./my-plugin", Utf8Path::new("/")).expect("Couldn't load plugin"); + let (plugin, _) = BiomePlugin::load(fs, "./my-plugin", Utf8Path::new("/"), None) + .expect("Couldn't load plugin"); assert_eq!(plugin.analyzer_plugins.len(), 1); } @@ -165,7 +227,7 @@ mod test { fs.insert("/my-plugin/rules/1.grit".into(), r#"`hello`"#); let fs = Arc::new(fs) as Arc; - let error = BiomePlugin::load(fs, "./my-plugin", Utf8Path::new("/")) + let error = BiomePlugin::load(fs, "./my-plugin", Utf8Path::new("/"), None) .expect_err("Plugin loading should've failed"); snap_diagnostic("load_plugin_without_manifest", error.into()); } @@ -182,7 +244,7 @@ mod test { ); let fs = Arc::new(fs) as Arc; - let error = BiomePlugin::load(fs, "./my-plugin", Utf8Path::new("/")) + let error = BiomePlugin::load(fs, "./my-plugin", Utf8Path::new("/"), None) .expect_err("Plugin loading should've failed"); snap_diagnostic("load_plugin_with_wrong_version", error.into()); } @@ -199,7 +261,7 @@ mod test { ); let fs = Arc::new(fs) as Arc; - let error = BiomePlugin::load(fs, "./my-plugin", Utf8Path::new("/")) + let error = BiomePlugin::load(fs, "./my-plugin", Utf8Path::new("/"), None) .expect_err("Plugin loading should've failed"); snap_diagnostic("load_plugin_with_wrong_rule_extension", error.into()); } @@ -210,7 +272,7 @@ mod test { fs.insert("/my-plugin.grit".into(), r#"`hello`"#); let fs = Arc::new(fs) as Arc; - let (plugin, _) = BiomePlugin::load(fs, "./my-plugin.grit", Utf8Path::new("/")) + let (plugin, _) = BiomePlugin::load(fs, "./my-plugin.grit", Utf8Path::new("/"), None) .expect("Couldn't load plugin"); assert_eq!(plugin.analyzer_plugins.len(), 1); } @@ -225,7 +287,7 @@ mod test { ); let fs = Arc::new(fs) as Arc; - let (plugin, _) = BiomePlugin::load(fs, "./my-plugin.js", Utf8Path::new("/")) + let (plugin, _) = BiomePlugin::load(fs, "./my-plugin.js", Utf8Path::new("/"), None) .expect("Couldn't load plugin"); assert_eq!(plugin.analyzer_plugins.len(), 1); diff --git a/crates/biome_plugin_loader/src/plugin_cache.rs b/crates/biome_plugin_loader/src/plugin_cache.rs index 836cbf47ef3c..639b95dbec65 100644 --- a/crates/biome_plugin_loader/src/plugin_cache.rs +++ b/crates/biome_plugin_loader/src/plugin_cache.rs @@ -3,7 +3,7 @@ use camino::Utf8PathBuf; use papaya::HashMap; use rustc_hash::{FxBuildHasher, FxHashSet}; -use crate::configuration::{PluginConfiguration, Plugins}; +use crate::configuration::Plugins; use crate::{BiomePlugin, PluginDiagnostic}; /// Cache for storing loaded plugins in memory. @@ -19,7 +19,7 @@ impl PluginCache { self.0.pin().insert(path, plugin); } - /// Returns the loaded and matched analyzer plugins, deduped + /// Returns the loaded and matched analyzer plugins, deduped. pub fn get_analyzer_plugins( &self, plugin_configs: &Plugins, @@ -30,21 +30,22 @@ impl PluginCache { let map = self.0.pin(); for plugin_config in plugin_configs.iter() { - match plugin_config { - PluginConfiguration::Path(plugin_path) => { - if seen.insert(plugin_path) { - let path_buf = Utf8PathBuf::from(plugin_path); - match map - .iter() - .find(|(path, _)| path.ends_with(path_buf.as_path())) - { - Some((_, plugin)) => { - result.extend_from_slice(&plugin.analyzer_plugins); - } - None => { - diagnostics.push(PluginDiagnostic::not_loaded(path_buf)); - } - } + let plugin_path = plugin_config.path(); + if seen.insert(plugin_path) { + let path_buf = Utf8PathBuf::from(plugin_path); + // Fast path: try exact key match first (O(1)). + // Fall back to suffix match only if needed (e.g. relative vs absolute paths). + let found = map.get(&path_buf).or_else(|| { + map.iter() + .find(|(path, _)| path.ends_with(path_buf.as_path())) + .map(|(_, plugin)| plugin) + }); + match found { + Some(plugin) => { + result.extend_from_slice(&plugin.analyzer_plugins); + } + None => { + diagnostics.push(PluginDiagnostic::not_loaded(path_buf)); } } } @@ -57,3 +58,46 @@ impl PluginCache { Ok(result) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::configuration::{PluginConfiguration, PluginPathWithOptions}; + + #[test] + fn cache_lookup_with_path_with_options() { + let cache = PluginCache::default(); + + // Insert a plugin keyed by its resolved path + let path = Utf8PathBuf::from("/resolved/my-plugin.wasm"); + let plugin = BiomePlugin { + analyzer_plugins: vec![], + }; + cache.insert_plugin(path.clone(), plugin); + + // Look it up via a PathWithOptions configuration + let plugins = Plugins(vec![PluginConfiguration::PathWithOptions( + PluginPathWithOptions { + path: "/resolved/my-plugin.wasm".into(), + options: r#"{"maxLength": 100}"#.into(), + rules: None, + }, + )]); + + let result = cache.get_analyzer_plugins(&plugins); + assert!(result.is_ok(), "Expected cache hit for PathWithOptions"); + assert!(result.unwrap().is_empty()); // empty because our test plugin has 0 analyzer_plugins + } + + #[test] + fn cache_miss_returns_error() { + let cache = PluginCache::default(); + + let plugins = Plugins(vec![PluginConfiguration::Path( + "/missing/plugin.grit".into(), + )]); + + let result = cache.get_analyzer_plugins(&plugins); + assert!(result.is_err()); + } +} diff --git a/crates/biome_plugin_loader/tests/wasm_plugin_integration.rs b/crates/biome_plugin_loader/tests/wasm_plugin_integration.rs new file mode 100644 index 000000000000..37c2564c702a --- /dev/null +++ b/crates/biome_plugin_loader/tests/wasm_plugin_integration.rs @@ -0,0 +1,306 @@ +//! Integration tests for `AnalyzerWasmPlugin` using compiled WASM fixtures. +//! +//! These tests exercise the `biome_plugin_loader` layer that wraps +//! `WasmPluginEngine` with analyzer-compatible types. + +#![cfg(feature = "wasm_plugin")] + +use biome_analyze::{AnalyzerPlugin, Phases, PluginTargetLanguage, ServiceBag}; +use biome_css_parser::CssParserOptions; +use biome_css_syntax::{CssFileSource, CssSyntaxKind}; +use biome_js_parser::JsParserOptions; +use biome_js_syntax::{JsFileSource, JsSyntaxKind}; +use biome_json_parser::JsonParserOptions; +use biome_json_syntax::JsonSyntaxKind; +use biome_plugin_loader::AnalyzerWasmPlugin; +use biome_rowan::{AstNode, RawSyntaxKind}; +use camino::Utf8Path; +use std::path::Path; +use std::sync::Arc; + +fn fixture_dir() -> camino::Utf8PathBuf { + let p = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures") + .canonicalize() + .unwrap(); + camino::Utf8PathBuf::try_from(p).unwrap() +} + +fn fixture_path(name: &str) -> camino::Utf8PathBuf { + fixture_dir().join(name) +} + +fn parse_js(source: &str) -> biome_js_syntax::JsSyntaxNode { + biome_js_parser::parse( + source, + JsFileSource::js_module(), + JsParserOptions::default(), + ) + .tree() + .syntax() + .clone() +} + +fn find_node( + root: &biome_js_syntax::JsSyntaxNode, + kind: JsSyntaxKind, +) -> biome_js_syntax::JsSyntaxNode { + root.descendants() + .find(|n| n.kind() == kind) + .unwrap_or_else(|| panic!("Could not find node of kind {kind:?}")) +} + +// --------------------------------------------------------------- +// Loading +// --------------------------------------------------------------- + +#[test] +fn load_wasm_plugin() { + let path = fixture_path("boolean_naming.wasm"); + let plugins = AnalyzerWasmPlugin::load(path.as_ref(), "boolean_naming", None) + .expect("should load WASM plugin"); + assert_eq!(plugins.len(), 1, "Expected 1 rule from boolean_naming"); +} + +#[test] +fn load_invalid_path() { + let result = AnalyzerWasmPlugin::load( + Utf8Path::new("/nonexistent/plugin.wasm"), + "nonexistent", + None, + ); + assert!(result.is_err()); +} + +// --------------------------------------------------------------- +// Metadata / trait methods +// --------------------------------------------------------------- + +#[test] +fn phase_is_semantic_for_js() { + let plugins = AnalyzerWasmPlugin::load( + fixture_path("boolean_naming.wasm").as_ref(), + "boolean_naming", + None, + ) + .unwrap(); + assert_eq!(plugins[0].phase(), Phases::Semantic); +} + +#[test] +fn language_is_javascript() { + let plugins = AnalyzerWasmPlugin::load( + fixture_path("boolean_naming.wasm").as_ref(), + "boolean_naming", + None, + ) + .unwrap(); + assert_eq!(plugins[0].language(), PluginTargetLanguage::JavaScript); +} + +#[test] +fn query_returns_correct_kinds() { + let plugins = AnalyzerWasmPlugin::load( + fixture_path("boolean_naming.wasm").as_ref(), + "boolean_naming", + None, + ) + .unwrap(); + let kinds = plugins[0].query(); + assert!( + kinds.contains(&RawSyntaxKind(JsSyntaxKind::JS_VARIABLE_DECLARATOR as u16)), + "Expected JS_VARIABLE_DECLARATOR in query kinds: {kinds:?}", + ); +} + +// --------------------------------------------------------------- +// Evaluate +// --------------------------------------------------------------- + +#[test] +fn evaluate_matching_node() { + let plugins = AnalyzerWasmPlugin::load( + fixture_path("boolean_naming.wasm").as_ref(), + "boolean_naming", + None, + ) + .unwrap(); + + let root = parse_js("const enabled = true;"); + let declarator = find_node(&root, JsSyntaxKind::JS_VARIABLE_DECLARATOR); + + let services = ServiceBag::default(); + let path = Arc::new(camino::Utf8PathBuf::from("test.js")); + let result = plugins[0].evaluate(declarator.into(), path, &services); + assert_eq!(result.diagnostics.len(), 1, "Expected 1 diagnostic"); + assert!( + result.diagnostics[0].actions.is_empty(), + "booleanNaming should not produce an autofix" + ); +} + +#[test] +fn evaluate_non_matching_node() { + let plugins = AnalyzerWasmPlugin::load( + fixture_path("boolean_naming.wasm").as_ref(), + "boolean_naming", + None, + ) + .unwrap(); + + let root = parse_js("const isEnabled = true;"); + let declarator = find_node(&root, JsSyntaxKind::JS_VARIABLE_DECLARATOR); + + let services = ServiceBag::default(); + let path = Arc::new(camino::Utf8PathBuf::from("test.js")); + let result = plugins[0].evaluate(declarator.into(), path, &services); + assert!(result.diagnostics.is_empty(), "Should not flag 'isEnabled'"); +} + +#[test] +fn evaluate_with_options() { + let plugins = AnalyzerWasmPlugin::load( + fixture_path("boolean_naming.wasm").as_ref(), + "boolean_naming", + Some(r#"{"pattern": "^(is)[A-Z]"}"#.to_string()), + ) + .unwrap(); + + // "hasFeature" matches default but not ^(is)[A-Z] + let root = parse_js("const hasFeature = true;"); + let declarator = find_node(&root, JsSyntaxKind::JS_VARIABLE_DECLARATOR); + + let services = ServiceBag::default(); + let path = Arc::new(camino::Utf8PathBuf::from("test.js")); + let result = plugins[0].evaluate(declarator.into(), path, &services); + assert_eq!(result.diagnostics.len(), 1); +} + +// =============================================================== +// CSS plugin tests +// =============================================================== + +fn parse_css(source: &str) -> biome_css_syntax::CssSyntaxNode { + biome_css_parser::parse_css(source, CssFileSource::css(), CssParserOptions::default()) + .tree() + .syntax() + .clone() +} + +fn find_css_node( + root: &biome_css_syntax::CssSyntaxNode, + kind: CssSyntaxKind, +) -> biome_css_syntax::CssSyntaxNode { + root.descendants() + .find(|n| n.kind() == kind) + .unwrap_or_else(|| panic!("Could not find CSS node of kind {kind:?}")) +} + +#[test] +fn load_css_plugin() { + let path = fixture_path("css_style_conventions.wasm"); + let plugins = AnalyzerWasmPlugin::load(path.as_ref(), "css_style_conventions", None) + .expect("should load CSS plugin"); + assert_eq!( + plugins.len(), + 2, + "Expected 2 rules from css-style-conventions" + ); +} + +#[test] +fn css_plugin_metadata() { + let plugins = AnalyzerWasmPlugin::load( + fixture_path("css_style_conventions.wasm").as_ref(), + "css_style_conventions", + None, + ) + .unwrap(); + + // Check that both rules have correct language and phase + for plugin in &plugins { + assert_eq!(plugin.language(), PluginTargetLanguage::Css); + assert_eq!(plugin.phase(), Phases::Syntax); + } + + // Check that query kinds include CSS_DECLARATION + let kinds = plugins[0].query(); + assert!( + kinds.contains(&RawSyntaxKind(CssSyntaxKind::CSS_DECLARATION as u16)), + "Expected CSS_DECLARATION in query kinds: {kinds:?}", + ); +} + +#[test] +fn css_plugin_evaluate() { + let plugins = AnalyzerWasmPlugin::load( + fixture_path("css_style_conventions.wasm").as_ref(), + "css_style_conventions", + None, + ) + .unwrap(); + + // Find the customPropertyPattern rule + let pattern_rule = plugins + .iter() + .find(|p| p.rule_name() == "customPropertyPattern") + .expect("should have customPropertyPattern rule"); + + let root = parse_css(":root { --camelCase: red; }"); + let decl = find_css_node(&root, CssSyntaxKind::CSS_DECLARATION); + + let services = ServiceBag::default(); + let path = Arc::new(camino::Utf8PathBuf::from("test.css")); + let result = pattern_rule.evaluate(decl.into(), path, &services); + assert_eq!(result.diagnostics.len(), 1, "Expected 1 diagnostic"); +} + +// =============================================================== +// JSON plugin tests +// =============================================================== + +fn parse_json(source: &str) -> biome_json_syntax::JsonSyntaxNode { + biome_json_parser::parse_json(source, JsonParserOptions::default()) + .tree() + .syntax() + .clone() +} + +fn find_json_node( + root: &biome_json_syntax::JsonSyntaxNode, + kind: JsonSyntaxKind, +) -> biome_json_syntax::JsonSyntaxNode { + root.descendants() + .find(|n| n.kind() == kind) + .unwrap_or_else(|| panic!("Could not find JSON node of kind {kind:?}")) +} + +#[test] +fn load_json_plugin() { + let path = fixture_path("json_naming.wasm"); + let plugins = AnalyzerWasmPlugin::load(path.as_ref(), "json_naming", None) + .expect("should load JSON plugin"); + assert_eq!(plugins.len(), 1, "Expected 1 rule from json-naming"); +} + +#[test] +fn json_plugin_evaluate() { + let plugins = AnalyzerWasmPlugin::load( + fixture_path("json_naming.wasm").as_ref(), + "json_naming", + None, + ) + .unwrap(); + + let root = parse_json(r#"{ "another_key": "value" }"#); + let member_name = find_json_node(&root, JsonSyntaxKind::JSON_MEMBER_NAME); + + let services = ServiceBag::default(); + let path = Arc::new(camino::Utf8PathBuf::from("test.json")); + let result = plugins[0].evaluate(member_name.into(), path, &services); + assert_eq!( + result.diagnostics.len(), + 1, + "Expected 1 diagnostic for snake_case key" + ); +} diff --git a/crates/biome_plugin_sdk/Cargo.toml b/crates/biome_plugin_sdk/Cargo.toml new file mode 100644 index 000000000000..4dcf0986cd6d --- /dev/null +++ b/crates/biome_plugin_sdk/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "biome_plugin_sdk" +version = "0.0.1" +authors.workspace = true +edition.workspace = true +description = "Guest SDK for writing Biome WASM plugins" +homepage.workspace = true +repository.workspace = true +license.workspace = true +keywords.workspace = true +categories.workspace = true +publish = false + +[dependencies] +biome_plugin_sdk_macros = { workspace = true } + +[lints] +workspace = true diff --git a/crates/biome_plugin_sdk/README.md b/crates/biome_plugin_sdk/README.md new file mode 100644 index 000000000000..f5e6c202dcef --- /dev/null +++ b/crates/biome_plugin_sdk/README.md @@ -0,0 +1,103 @@ +# biome_plugin_sdk + +Guest SDK for writing Biome WASM plugins. + +This crate provides everything a plugin author needs to build a Biome lint rule +as a WASM Component Model module: + +- **Syntax kind constants** for JavaScript (`js_kinds`), CSS (`css_kinds`), and + JSON (`json_kinds`) so plugins can match specific AST node types. +- **`generate_plugin!()`** macro that generates WIT bindings without requiring + the plugin to reference the WIT file path. +- **`options`** module with lightweight JSON parsing helpers for reading + per-rule configuration without pulling in a full JSON library. + +## Quick Start + +```toml +[package] +name = "my-plugin" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +biome_plugin_sdk = "0.0.1" +wit-bindgen = "0.53" +``` + +```rust +use biome_plugin_sdk::js_kinds; + +biome_plugin_sdk::generate_plugin!(); + +struct MyPlugin; + +impl Guest for MyPlugin { + fn target_language() -> String { "javascript".into() } + fn rule_names() -> Vec { vec!["myRule".into()] } + fn query_kinds_for_rule(_rule: String) -> Vec { + vec![js_kinds::JS_CALL_EXPRESSION] + } + fn configure(_rule: String, _options_json: String) {} + fn rule_metadata(_rule: String) -> RuleMetadata { + RuleMetadata { + version: "0.1.0".into(), + sources: vec![], + recommended: true, + fix_kind: None, + category: None, + domains: vec![], + deprecated: None, + severity: None, + issue_number: None, + } + } + fn check(node: u32, _rule: String) -> Vec { + vec![] + } +} + +export!(MyPlugin); +``` + +Build with: + +```sh +cargo build --target wasm32-wasip2 --release +``` + +## Supported Languages + +| Language | Module | Target string | Semantic model | +| --- | --- | --- | --- | +| JavaScript/TypeScript | `js_kinds` | `"javascript"` | Full (scopes, references, types) | +| CSS | `css_kinds` | `"css"` | None | +| JSON | `json_kinds` | `"json"` | None | + +## Options + +The `options` module provides `get_string`, `get_number`, `get_bool`, and +`get_string_array` functions for parsing the JSON options string passed to +`configure()`. These avoid pulling in `serde_json` in the WASM binary. + +## WIT Interface + +The full plugin interface is defined in `wit/biome-plugin.wit`. Key exports +that a plugin must implement: + +- `target-language()` — which language this plugin analyzes +- `rule-names()` — list of rule names (used in suppressions and config) +- `query-kinds-for-rule(rule)` — syntax node kinds to match +- `configure(rule, options-json)` — receive per-rule options +- `check(node, rule)` — analyze a matched node and return diagnostics +- `rule-metadata(rule)` — version, sources, fix kind, etc. + +## Examples + +See `e2e-tests/wasm-plugins/plugins/` for working examples: + +- `boolean-naming/` — JavaScript rule checking boolean variable naming +- `css-style-conventions/` — CSS rule checking custom property patterns +- `json-naming/` — JSON rule checking key naming conventions diff --git a/crates/biome_plugin_sdk/build.rs b/crates/biome_plugin_sdk/build.rs new file mode 100644 index 000000000000..2066c5afa7ad --- /dev/null +++ b/crates/biome_plugin_sdk/build.rs @@ -0,0 +1,163 @@ +//! Build script for `biome_plugin_sdk`. +//! +//! Parses the generated `kind.rs` files from `biome_js_syntax`, +//! `biome_css_syntax`, and `biome_json_syntax`, extracts all enum variant +//! names and their implicit `#[repr(u16)]` discriminants, and emits +//! `pub const NAME: u32 = N;` constants into the OUT_DIR. +//! +//! This replaces the hand-maintained curated subsets with auto-generated +//! complete constant files that stay in sync automatically. + +use std::env; +use std::fmt::Write as _; +use std::fs; +use std::path::{Path, PathBuf}; + +fn main() { + let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); + + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); + + generate_kinds( + &manifest_dir.join("../biome_js_syntax/src/generated/kind.rs"), + "JsSyntaxKind", + &out_dir.join("js_kinds_generated.rs"), + ); + generate_kinds( + &manifest_dir.join("../biome_css_syntax/src/generated/kind.rs"), + "CssSyntaxKind", + &out_dir.join("css_kinds_generated.rs"), + ); + generate_kinds( + &manifest_dir.join("../biome_json_syntax/src/generated/kind.rs"), + "JsonSyntaxKind", + &out_dir.join("json_kinds_generated.rs"), + ); + + // Re-run when source kind files change. + println!("cargo:rerun-if-changed=../biome_js_syntax/src/generated/kind.rs"); + println!("cargo:rerun-if-changed=../biome_css_syntax/src/generated/kind.rs"); + println!("cargo:rerun-if-changed=../biome_json_syntax/src/generated/kind.rs"); +} + +/// Parse a `kind.rs` file and emit `pub const` definitions. +fn generate_kinds(source_path: &Path, enum_name: &str, output_path: &Path) { + let source = fs::read_to_string(source_path) + .unwrap_or_else(|e| panic!("failed to read {}: {e}", source_path.display())); + + let variants = parse_enum_variants(&source, enum_name); + let mut output = String::new(); + writeln!( + output, + "// Generated file, do not edit by hand, see `biome_plugin_sdk/build.rs`" + ) + .unwrap(); + writeln!(output).unwrap(); + + for (name, discriminant) in &variants { + writeln!(output, "pub const {name}: u32 = {discriminant};").unwrap(); + } + + fs::write(output_path, output).unwrap(); +} + +/// Parse `pub enum { ... }` and extract variant names with their +/// implicit `#[repr(u16)]` discriminant values. Skips `#[doc(hidden)]` +/// variants (TOMBSTONE, `__LAST`). +fn parse_enum_variants(source: &str, enum_name: &str) -> Vec<(String, u32)> { + let mut variants = Vec::new(); + + // Find the start of the enum body. + let enum_header = format!("pub enum {enum_name}"); + let Some(header_pos) = source.find(&enum_header) else { + panic!("could not find `{enum_header}` in source"); + }; + + let after_header = &source[header_pos..]; + let Some(brace_pos) = after_header.find('{') else { + panic!("could not find opening brace for `{enum_header}`"); + }; + + let body_start = header_pos + brace_pos + 1; + + // Find the matching closing brace. We need to handle nested braces + // from doc comments and attributes. + let body = &source[body_start..]; + let mut depth = 1u32; + let mut end_pos = 0; + for (i, ch) in body.char_indices() { + match ch { + '{' => depth += 1, + '}' => { + depth -= 1; + if depth == 0 { + end_pos = i; + break; + } + } + _ => {} + } + } + + let enum_body = &body[..end_pos]; + + let mut discriminant: u32 = 0; + let mut skip_next = false; + + for line in enum_body.lines() { + let trimmed = line.trim(); + + // Detect `#[doc(hidden)]` — skip the next variant. + if trimmed == "#[doc(hidden)]" { + skip_next = true; + continue; + } + + // Skip doc comments and other attributes. + if trimmed.starts_with("#[") || trimmed.starts_with("///") || trimmed.starts_with("//") { + continue; + } + + // Skip empty lines. + if trimmed.is_empty() { + continue; + } + + // Extract variant name. Lines look like: + // VARIANT_NAME, + // VARIANT_NAME = 42, + let variant_name = trimmed.split([',', ' ', '=']).next().unwrap_or("").trim(); + + if variant_name.is_empty() { + continue; + } + + // Check for explicit discriminant assignment: `VARIANT = N,` + if let Some(eq_rest) = trimmed.strip_prefix(variant_name) { + let eq_rest = eq_rest.trim(); + if let Some(after_eq) = eq_rest.strip_prefix('=') { + let val_str = after_eq.trim().trim_end_matches(',').trim(); + if let Ok(val) = val_str.parse::() { + discriminant = val; + } + } + } + + if skip_next { + skip_next = false; + discriminant += 1; + continue; + } + + // Skip variants that start with `__` (internal markers). + if variant_name.starts_with("__") { + discriminant += 1; + continue; + } + + variants.push((variant_name.to_string(), discriminant)); + discriminant += 1; + } + + variants +} diff --git a/crates/biome_plugin_sdk/src/css_kinds.rs b/crates/biome_plugin_sdk/src/css_kinds.rs new file mode 100644 index 000000000000..39170e8a4293 --- /dev/null +++ b/crates/biome_plugin_sdk/src/css_kinds.rs @@ -0,0 +1,9 @@ +//! CSS syntax kind constants. +//! +//! These values correspond to the `#[repr(u16)]` discriminants of +//! `biome_css_syntax::CssSyntaxKind`. +//! +//! This file is auto-generated from the canonical `kind.rs` by +//! `biome_plugin_sdk/build.rs`. All variants are included. + +include!(concat!(env!("OUT_DIR"), "/css_kinds_generated.rs")); diff --git a/crates/biome_plugin_sdk/src/js_kinds.rs b/crates/biome_plugin_sdk/src/js_kinds.rs new file mode 100644 index 000000000000..193fd5970eac --- /dev/null +++ b/crates/biome_plugin_sdk/src/js_kinds.rs @@ -0,0 +1,10 @@ +//! JavaScript/TypeScript syntax kind constants. +//! +//! These values correspond to the `#[repr(u16)]` discriminants of +//! `biome_js_syntax::JsSyntaxKind`. They are used in `query_kinds()` +//! to tell the host which node kinds the plugin wants to inspect. +//! +//! This file is auto-generated from the canonical `kind.rs` by +//! `biome_plugin_sdk/build.rs`. All variants are included. + +include!(concat!(env!("OUT_DIR"), "/js_kinds_generated.rs")); diff --git a/crates/biome_plugin_sdk/src/json_kinds.rs b/crates/biome_plugin_sdk/src/json_kinds.rs new file mode 100644 index 000000000000..4833093a409f --- /dev/null +++ b/crates/biome_plugin_sdk/src/json_kinds.rs @@ -0,0 +1,9 @@ +//! JSON syntax kind constants. +//! +//! These values correspond to the `#[repr(u16)]` discriminants of +//! `biome_json_syntax::JsonSyntaxKind`. +//! +//! This file is auto-generated from the canonical `kind.rs` by +//! `biome_plugin_sdk/build.rs`. All variants are included. + +include!(concat!(env!("OUT_DIR"), "/json_kinds_generated.rs")); diff --git a/crates/biome_plugin_sdk/src/lib.rs b/crates/biome_plugin_sdk/src/lib.rs new file mode 100644 index 000000000000..14d5a27f8146 --- /dev/null +++ b/crates/biome_plugin_sdk/src/lib.rs @@ -0,0 +1,77 @@ +//! Guest SDK for writing Biome WASM plugins. +//! +//! This crate provides everything needed to build a Biome lint rule as a WASM +//! Component Model module: +//! +//! - **Syntax kind constants** — [`js_kinds`], [`css_kinds`], and [`json_kinds`] +//! modules contain `u32` constants for every syntax node kind in each language. +//! Use these in `query_kinds_for_rule()` to tell Biome which nodes your rule +//! inspects, and in `check()` to identify node types via `node-kind`. +//! +//! - **[`generate_plugin!()`]** — proc macro that generates WIT bindings without +//! requiring plugin authors to reference the WIT file path. Expands to a +//! `wit_bindgen::generate!` call with the interface inlined. +//! +//! - **[`options`]** — lightweight JSON parsing helpers (`get_string`, +//! `get_number`, `get_bool`, `get_string_array`) for reading per-rule +//! configuration from the JSON string passed to `configure()`. +//! +//! # Supported Languages +//! +//! | Language | Module | `target_language()` return value | +//! |---|---|---| +//! | JavaScript/TypeScript | [`js_kinds`] | `"javascript"` | +//! | CSS | [`css_kinds`] | `"css"` | +//! | JSON | [`json_kinds`] | `"json"` | +//! +//! JavaScript plugins have access to the full semantic model (scopes, +//! references, type inference). CSS and JSON plugins only have syntax tree +//! navigation. +//! +//! # Quick Start +//! +//! ```toml +//! [package] +//! name = "my-plugin" +//! edition = "2021" +//! +//! [lib] +//! crate-type = ["cdylib"] +//! +//! [dependencies] +//! biome_plugin_sdk = "0.0.1" +//! wit-bindgen = "0.53" +//! ``` +//! +//! ```ignore +//! use biome_plugin_sdk::js_kinds; +//! +//! biome_plugin_sdk::generate_plugin!(); +//! +//! struct MyPlugin; +//! +//! impl Guest for MyPlugin { +//! fn target_language() -> String { "javascript".into() } +//! fn rule_names() -> Vec { vec!["myRule".into()] } +//! fn query_kinds_for_rule(_rule: String) -> Vec { vec![js_kinds::JS_CALL_EXPRESSION] } +//! fn configure(_rule: String, _options_json: String) {} +//! fn rule_metadata(_rule: String) -> RuleMetadata { todo!() } +//! fn check(node: u32, _rule: String) -> Vec { vec![] } +//! } +//! +//! export!(MyPlugin); +//! ``` +//! +//! Build with: `cargo build --target wasm32-wasip2 --release` +//! +//! # Examples +//! +//! See `e2e-tests/wasm-plugins/plugins/` for working examples covering +//! JavaScript, CSS, and JSON plugins. + +pub use biome_plugin_sdk_macros::generate_plugin; + +pub mod css_kinds; +pub mod js_kinds; +pub mod json_kinds; +pub mod options; diff --git a/crates/biome_plugin_sdk/src/options.rs b/crates/biome_plugin_sdk/src/options.rs new file mode 100644 index 000000000000..55d50988b664 --- /dev/null +++ b/crates/biome_plugin_sdk/src/options.rs @@ -0,0 +1,288 @@ +//! Lightweight JSON option extractors for WASM plugins. +//! +//! Since `serde` is not available in the guest environment, these helpers +//! provide simple extraction of primitive values from a JSON options string. +//! +//! All functions take a JSON string (expected to be a top-level object) and +//! a key name, returning `None` if the key is not found or has an +//! incompatible type. +//! +//! # Example +//! +//! ```ignore +//! use biome_plugin_sdk::options; +//! +//! fn configure(options_json: String) { +//! let pattern = options::get_string(&options_json, "pattern") +//! .unwrap_or_else(|| "default".to_string()); +//! let max_depth = options::get_number(&options_json, "maxDepth") +//! .unwrap_or(3.0); +//! let strict = options::get_bool(&options_json, "strict") +//! .unwrap_or(false); +//! let prefixes = options::get_string_array(&options_json, "prefixes") +//! .unwrap_or_default(); +//! } +//! ``` + +/// Extract a string value for a given key from a JSON object. +/// +/// Handles `{"key": "value"}` with proper escaped-quote handling. +/// Only `\"` and `\\` escapes are interpreted; other JSON escapes +/// (e.g. `\n`, `\uXXXX`) are passed through literally. +pub fn get_string(json: &str, key: &str) -> Option { + let value_start = find_value_start(json, key)?; + let rest = &json[value_start..]; + let rest = rest.strip_prefix('"')?; + parse_json_string(rest) +} + +/// Extract a number value for a given key from a JSON object. +/// +/// Returns the value as `f64`. Handles integers and floats, including +/// negative numbers and scientific notation. +pub fn get_number(json: &str, key: &str) -> Option { + let value_start = find_value_start(json, key)?; + let rest = &json[value_start..]; + let end = rest + .find(|c: char| c == ',' || c == '}' || c == ']' || c.is_whitespace()) + .unwrap_or(rest.len()); + rest[..end].trim().parse::().ok() +} + +/// Extract a boolean value for a given key from a JSON object. +pub fn get_bool(json: &str, key: &str) -> Option { + let value_start = find_value_start(json, key)?; + let rest = &json[value_start..]; + if rest.starts_with("true") { + Some(true) + } else if rest.starts_with("false") { + Some(false) + } else { + None + } +} + +/// Extract a string array value for a given key from a JSON object. +/// +/// Returns `None` if the key is not found. Returns an empty `Vec` if +/// the value is `[]`. +pub fn get_string_array(json: &str, key: &str) -> Option> { + let value_start = find_value_start(json, key)?; + let rest = &json[value_start..]; + let rest = rest.strip_prefix('[')?; + + let mut result = Vec::new(); + let mut pos = 0; + let bytes = rest.as_bytes(); + + while pos < bytes.len() { + match bytes[pos] { + b'"' => { + // Parse a string element. + pos += 1; + let s = parse_json_string(&rest[pos..])?; + // Advance past the string content + closing quote. + pos += count_json_string_bytes(&rest[pos..])? + 1; + result.push(s); + } + b']' => return Some(result), + b',' | b' ' | b'\t' | b'\n' | b'\r' => pos += 1, + _ => return None, + } + } + + None +} + +/// Locate the byte offset where the value for `key` starts in a JSON object. +/// +/// Scans through the JSON, skipping over string values to avoid matching +/// the key inside a nested string value. +fn find_value_start(json: &str, key: &str) -> Option { + let search = format!("\"{key}\""); + let bytes = json.as_bytes(); + let search_bytes = search.as_bytes(); + + let mut i = 0; + let mut depth: u32 = 0; + while i < bytes.len() { + match bytes[i] { + b'{' | b'[' => { + depth += 1; + i += 1; + } + b'}' | b']' => { + depth = depth.saturating_sub(1); + i += 1; + } + b'"' => { + // Only match keys at depth 1 (inside the top-level object). + if depth == 1 + && i + search_bytes.len() <= bytes.len() + && &bytes[i..i + search_bytes.len()] == search_bytes + { + // Found the key — skip past `"key"`, then whitespace and `:`. + let after_key = &json[i + search.len()..]; + let after_colon = after_key.trim_start().strip_prefix(':')?; + let trimmed = after_colon.trim_start(); + let offset = json.len() - trimmed.len(); + return Some(offset); + } + + // Skip this entire string (it's not our key, or it's a value). + i += 1; + while i < bytes.len() { + if bytes[i] == b'\\' { + i += 2; + } else if bytes[i] == b'"' { + i += 1; + break; + } else { + i += 1; + } + } + } + _ => { + i += 1; + } + } + } + None +} + +/// Parse a JSON string starting after the opening quote. +/// Returns the unescaped content. +fn parse_json_string(s: &str) -> Option { + let mut result = String::new(); + let mut chars = s.chars(); + while let Some(c) = chars.next() { + match c { + '"' => return Some(result), + '\\' => match chars.next()? { + '"' => result.push('"'), + '\\' => result.push('\\'), + '/' => result.push('/'), + 'n' => result.push('\n'), + 'r' => result.push('\r'), + 't' => result.push('\t'), + 'b' => result.push('\u{0008}'), + 'f' => result.push('\u{000C}'), + other => { + result.push('\\'); + result.push(other); + } + }, + _ => result.push(c), + } + } + None +} + +/// Count the number of bytes in a JSON string body (excluding the closing quote). +/// +/// Byte-level scanning is safe here: we only look for `"` (0x22) and `\` (0x5C), +/// both ASCII. Valid UTF-8 continuation bytes never collide with these. +fn count_json_string_bytes(s: &str) -> Option { + let bytes = s.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'\\' && i + 1 < bytes.len() { + i += 2; + } else if bytes[i] == b'"' { + return Some(i); + } else { + i += 1; + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_string() { + let json = r#"{"pattern": "^(is|has)[A-Z]", "other": "val"}"#; + assert_eq!( + get_string(json, "pattern"), + Some("^(is|has)[A-Z]".to_string()) + ); + assert_eq!(get_string(json, "other"), Some("val".to_string())); + assert_eq!(get_string(json, "missing"), None); + } + + #[test] + fn test_get_string_with_escapes() { + let json = r#"{"msg": "hello \"world\""}"#; + assert_eq!(get_string(json, "msg"), Some("hello \"world\"".to_string())); + } + + #[test] + fn test_get_number() { + let json = r#"{"count": 42, "rate": 2.72, "neg": -5}"#; + assert_eq!(get_number(json, "count"), Some(42.0)); + assert_eq!(get_number(json, "rate"), Some(2.72)); + assert_eq!(get_number(json, "neg"), Some(-5.0)); + assert_eq!(get_number(json, "missing"), None); + } + + #[test] + fn test_get_bool() { + let json = r#"{"strict": true, "verbose": false}"#; + assert_eq!(get_bool(json, "strict"), Some(true)); + assert_eq!(get_bool(json, "verbose"), Some(false)); + assert_eq!(get_bool(json, "missing"), None); + } + + #[test] + fn test_get_string_array() { + let json = r#"{"prefixes": ["is", "has", "should"]}"#; + assert_eq!( + get_string_array(json, "prefixes"), + Some(vec![ + "is".to_string(), + "has".to_string(), + "should".to_string() + ]) + ); + } + + #[test] + fn test_get_string_array_empty() { + let json = r#"{"items": []}"#; + assert_eq!(get_string_array(json, "items"), Some(vec![])); + } + + #[test] + fn test_get_string_array_missing() { + let json = r#"{"other": "val"}"#; + assert_eq!(get_string_array(json, "items"), None); + } + + #[test] + fn test_key_not_in_value() { + // The key "a" appears inside a value string — should not match. + let json = r#"{"b": "key a is here", "a": "correct"}"#; + assert_eq!(get_string(json, "a"), Some("correct".to_string())); + } + + #[test] + fn test_whitespace_variations() { + let json = r#"{ "key" : "value" }"#; + assert_eq!(get_string(json, "key"), Some("value".to_string())); + } + + #[test] + fn test_nested_key_not_matched() { + // "name" at depth 2 should not be matched. + let json = r#"{"nested": {"name": "inner"}, "name": "outer"}"#; + assert_eq!(get_string(json, "name"), Some("outer".to_string())); + } + + #[test] + fn test_utf8_string_value() { + let json = r#"{"greeting": "こんにちは"}"#; + assert_eq!(get_string(json, "greeting"), Some("こんにちは".to_string())); + } +} diff --git a/crates/biome_plugin_sdk/wit/biome-plugin.wit b/crates/biome_plugin_sdk/wit/biome-plugin.wit new file mode 100644 index 000000000000..322ffdba6976 --- /dev/null +++ b/crates/biome_plugin_sdk/wit/biome-plugin.wit @@ -0,0 +1,328 @@ +package biome:plugin; + +/// Shared types used by both host and guest. +interface types { + /// Diagnostic severity levels. + enum severity { + error, + warning, + information, + hint, + } + + /// A text replacement. + record text-edit { + start: u32, + end: u32, + replacement: string, + } + + /// A code fix that can be applied to resolve a diagnostic. + record code-action { + message: string, + /// Whether this fix is safe to apply automatically. + safe: bool, + edits: list, + } + + /// A secondary label pointing to a related location in the source. + record label { + start: u32, + end: u32, + message: string, + } + + /// A diagnostic result returned by a check. + record check-result { + start: u32, + end: u32, + message: string, + sev: severity, + /// Code actions to fix the diagnostic. + actions: list, + /// Secondary labels pointing to related locations. + labels: list