From c06b7a9f19034777d52ffc9766aa19e492c7b1ce Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 20 Oct 2026 11:55:02 -0500 Subject: [PATCH 1/7] Add a dprint Wasm plugin dprint users have no way to sort Tailwind classes without shelling out to the CLI through the exec plugin (#48). The new dprint-plugin-rustywind crate wraps rustywind_core in dprint's Wasm plugin interface and exposes the CLI options that work without file or network access. The plugin matches files additively, so it sorts classes after the TypeScript or markup plugin formats a file instead of competing with it for the file. Each tagged release uploads the .wasm next to the CLI binaries, and a smoke test formats fixtures with the real dprint CLI to cover the Wasm exports and additive matching. --- .github/workflows/mean_bean_deploy.yml | 25 ++ .github/workflows/tests.yml | 11 + CHANGELOG.md | 7 + Cargo.lock | 75 +++++- Cargo.toml | 2 +- README.md | 48 ++++ RELEASE.md | 7 +- ci/dprint_smoke.bash | 28 +++ justfile | 5 + rustywind-dprint/Cargo.toml | 26 ++ rustywind-dprint/src/configuration.rs | 164 +++++++++++++ rustywind-dprint/src/lib.rs | 131 ++++++++++ rustywind-dprint/tests/plugin.rs | 225 ++++++++++++++++++ .../tests/smoke/expected/card.tsx | 1 + .../tests/smoke/expected/dprint.json | 1 + .../tests/smoke/expected/page.html | 1 + rustywind-dprint/tests/smoke/input/card.tsx | 1 + .../tests/smoke/input/dprint.json | 1 + rustywind-dprint/tests/smoke/input/page.html | 1 + 19 files changed, 747 insertions(+), 13 deletions(-) create mode 100755 ci/dprint_smoke.bash create mode 100644 rustywind-dprint/Cargo.toml create mode 100644 rustywind-dprint/src/configuration.rs create mode 100644 rustywind-dprint/src/lib.rs create mode 100644 rustywind-dprint/tests/plugin.rs create mode 100644 rustywind-dprint/tests/smoke/expected/card.tsx create mode 100644 rustywind-dprint/tests/smoke/expected/dprint.json create mode 100644 rustywind-dprint/tests/smoke/expected/page.html create mode 100644 rustywind-dprint/tests/smoke/input/card.tsx create mode 100644 rustywind-dprint/tests/smoke/input/dprint.json create mode 100644 rustywind-dprint/tests/smoke/input/page.html diff --git a/.github/workflows/mean_bean_deploy.yml b/.github/workflows/mean_bean_deploy.yml index 7e8e81ef..adb21c57 100644 --- a/.github/workflows/mean_bean_deploy.yml +++ b/.github/workflows/mean_bean_deploy.yml @@ -170,6 +170,31 @@ jobs: ASSET="$BIN-$TAG-${{ matrix.target }}.tar.gz" cp "$BIN.tar.gz" "$ASSET" gh release upload "$TAG" "$ASSET" + dprint-plugin: + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - uses: actions/checkout@v7 + + - name: Setup Rust toolchain + run: rustup toolchain install stable --profile minimal --target wasm32-unknown-unknown + + - name: Build and smoke test the wasm plugin + run: bash ci/dprint_smoke.bash + + - name: Create release and upload asset + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + run: | + # sibling jobs race to create the release, so tolerate losing + gh release view "$TAG" > /dev/null 2>&1 \ + || gh release create "$TAG" --title "$TAG" --notes "" \ + || gh release view "$TAG" > /dev/null + # dprint loads plugins from a url ending in .wasm, so keep the name stable across releases + cp target/wasm32-unknown-unknown/release/dprint_plugin_rustywind.wasm dprint-plugin-rustywind.wasm + gh release upload "$TAG" dprint-plugin-rustywind.wasm docker: needs: linux runs-on: ubuntu-24.04 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 475c3a32..ac52081e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -16,3 +16,14 @@ jobs: - name: Test all packages in the workspace run: cargo test --workspace + + dprint-plugin: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + + - name: Setup Rust toolchain + run: rustup toolchain install stable --profile minimal --target wasm32-unknown-unknown + + - name: Format smoke fixtures with the wasm plugin + run: bash ci/dprint_smoke.bash diff --git a/CHANGELOG.md b/CHANGELOG.md index 85aae0f4..4beeb138 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ ## [Unreleased] +### Added + +- Add a [dprint](https://dprint.dev) Wasm plugin, published as + `dprint-plugin-rustywind.wasm` on each GitHub release. It sorts classes in + addition to the plugin that formats each file, + [#48](https://github.com/avencera/rustywind/issues/48) + ## [0.28.0] - 2026-08-25 ### Added diff --git a/Cargo.lock b/Cargo.lock index 2c7769d2..17bfcd1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -339,7 +339,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -587,6 +587,29 @@ dependencies = [ "zeroize", ] +[[package]] +name = "dprint-core" +version = "0.71.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "670d3c17cf65d37f5324b4fc198993148beb8c44e56fa4fe3676dd3c033d4e8c" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "dprint-plugin-rustywind" +version = "0.28.0" +dependencies = [ + "dprint-core", + "pretty_assertions", + "regex", + "rustywind_core", + "serde", +] + [[package]] name = "duct" version = "1.1.1" @@ -896,6 +919,8 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown", + "serde", + "serde_core", ] [[package]] @@ -971,7 +996,7 @@ checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1576,7 +1601,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1585,6 +1610,7 @@ version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ + "indexmap", "itoa", "memchr", "serde", @@ -1724,6 +1750,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "tar" version = "0.4.46" @@ -1772,7 +1809,7 @@ dependencies = [ "cfg-if", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1783,10 +1820,30 @@ checksum = "5c89e72a01ed4c579669add59014b9a524d609c0c88c6a585ce37485879f6ffb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "test-case-core", ] +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "thread_local" version = "1.1.9" @@ -2042,7 +2099,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -2145,7 +2202,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2156,7 +2213,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2411,7 +2468,7 @@ checksum = "8fd425244944f4ab65ccff928e7323354c5a018c75838362fdce749dfad2ee1e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 77974e2a..e001ae5f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["rustywind-cli", "rustywind-core", "rustywind-vite", "xtask"] +members = ["rustywind-cli", "rustywind-core", "rustywind-dprint", "rustywind-vite", "xtask"] default-members = ["rustywind-cli"] resolver = "2" diff --git a/README.md b/README.md index 356d41a6..59f3c343 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,54 @@ Options: Print version ``` +## dprint plugin + +RustyWind is also available as a [dprint](https://dprint.dev) Wasm plugin. Each GitHub release +includes `dprint-plugin-rustywind.wasm`; add it to the `plugins` in your `dprint.json`: + +```json +{ + "rustywind": {}, + "plugins": [ + "https://plugins.dprint.dev/typescript-0.95.13.wasm", + "https://github.com/avencera/rustywind/releases/download/v0.29.0/dprint-plugin-rustywind.wasm" + ] +} +``` + +The plugin sorts classes in addition to whichever plugin formats the file, so `.tsx` files can be +formatted by the TypeScript plugin and then have their classes sorted. Plugins run in the order +they are listed, so list RustyWind last. This requires dprint 0.58.0 or later; older versions treat +it as a regular plugin that competes with other plugins for the same files. + +`dprint config update` does not update this plugin, so change the release tag in the URL to +upgrade. + +The plugin sorts files with these extensions: `astro`, `blade`, `django`, `djhtml`, `ejs`, `erb`, +`handlebars`, `hbs`, `htm`, `html`, `j2`, `jinja`, `jinja2`, `jsx`, `liquid`, `lit`, `php`, +`phtml`, `rb`, `svelte`, `tsx`, and `twig`. The template language is inferred from the file name +the same way as the CLI. Add `associations` to sort other files, such as `"associations": +["**/*.{js,vue}"]`. + +All options are optional: + +```json +{ + "rustywind": { + "allowDuplicates": false, + "preserveWhitespace": false, + "tailwindPrefix": "tw-", + "customRegex": "classes\\(([^)]*)\\)", + "classWrapping": "comma-single-quotes", + "sortOrder": ["flex", "p-4"] + } +} +``` + +These match the CLI's `--allow-duplicates`, `--preserve-whitespace`, `--tailwind-prefix`, +`--custom-regex`, `--class-wrapping`, and the `sortOrder` of `--config-file`. The plugin cannot +read files or make network requests, so `--output-css-file` and `--vite-css` are not available. + ## What Inspired by [Ryan Heybourn's](https://github.com/heybourn) [headwind](https://github.com/heybourn/headwind) diff --git a/RELEASE.md b/RELEASE.md index 4aa1ee28..9bc87d73 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -10,7 +10,7 @@ tag=v$version ## Checklist 1. Update versions and changelogs. - - Bump `rustywind-cli/Cargo.toml`. + - Bump `rustywind-cli/Cargo.toml` and `rustywind-dprint/Cargo.toml` to the same version. - Bump `rustywind-core/Cargo.toml` and `rustywind-vite/Cargo.toml` only when those crates change. - Keep path dependency versions in sync. - Update `CHANGELOG.md`. @@ -27,13 +27,14 @@ cargo xtask npm update-version "$version" just fmt just clippy cargo test -p xtask +just dprint-plugin ``` 3. Commit the release prep. ```sh git status --short -git add CHANGELOG.md Cargo.lock rustywind-cli/Cargo.toml rustywind-core/Cargo.toml rustywind-core/CHANGELOG.md rustywind-vite/Cargo.toml npm/packages +git add CHANGELOG.md Cargo.lock rustywind-cli/Cargo.toml rustywind-dprint/Cargo.toml rustywind-core/Cargo.toml rustywind-core/CHANGELOG.md rustywind-vite/Cargo.toml npm/packages git commit -m "Release $tag" git push ``` @@ -59,7 +60,7 @@ git tag "$tag" git push origin "$tag" ``` -Pushing the tag starts `.github/workflows/mean_bean_deploy.yml`, which creates the GitHub release, uploads binaries, builds Docker images, and publishes npm packages. +Pushing the tag starts `.github/workflows/mean_bean_deploy.yml`, which creates the GitHub release, uploads binaries and the dprint plugin, builds Docker images, and publishes npm packages. 6. Watch the GitHub release workflow. diff --git a/ci/dprint_smoke.bash b/ci/dprint_smoke.bash new file mode 100755 index 00000000..999aeb5e --- /dev/null +++ b/ci/dprint_smoke.bash @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Builds the dprint plugin for wasm and formats the smoke fixtures with the real dprint CLI, +# which checks the wasm exports and additive file matching that native tests can't reach. +# The TypeScript plugin claims card.tsx, so its expected output needs both plugins to run +set -euo pipefail + +DPRINT_VERSION=0.63.0 +TYPESCRIPT_PLUGIN=https://plugins.dprint.dev/typescript-0.95.13.wasm +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SMOKE="$ROOT/rustywind-dprint/tests/smoke" + +cargo build -p dprint-plugin-rustywind --release --target wasm32-unknown-unknown + +# cargo puts the wasm under the workspace target dir, which CARGO_TARGET_DIR can move +TARGET_DIR="$(cargo metadata --format-version 1 --no-deps | sed -n 's/.*"target_directory":"\([^"]*\)".*/\1/p')" +PLUGIN="$TARGET_DIR/wasm32-unknown-unknown/release/dprint_plugin_rustywind.wasm" + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +cp -R "$SMOKE/input/." "$WORK" + +cd "$WORK" +npx --yes "dprint@$DPRINT_VERSION" fmt --plugins "$TYPESCRIPT_PLUGIN" "$PLUGIN" +diff -r "$SMOKE/expected" "$WORK" + +# a second pass must be a no-op, or dprint would keep reformatting the file +npx --yes "dprint@$DPRINT_VERSION" check --plugins "$TYPESCRIPT_PLUGIN" "$PLUGIN" +echo "dprint plugin smoke test passed" diff --git a/justfile b/justfile index 8867ba72..cc995550 100644 --- a/justfile +++ b/justfile @@ -8,6 +8,8 @@ fmt: clippy: cargo clippy -- -D warnings + cargo clippy -p dprint-plugin-rustywind --all-targets -- -D warnings + cargo clippy -p dprint-plugin-rustywind --target wasm32-unknown-unknown -- -D warnings compare-tailwind *args: cargo xtask compare run {{ args }} @@ -17,3 +19,6 @@ update: npm-publish: cargo xtask npm publish + +dprint-plugin: + bash ci/dprint_smoke.bash diff --git a/rustywind-dprint/Cargo.toml b/rustywind-dprint/Cargo.toml new file mode 100644 index 00000000..b1cf8699 --- /dev/null +++ b/rustywind-dprint/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "dprint-plugin-rustywind" +version = "0.28.0" +description = "A dprint plugin to sort tailwind CSS classes" +authors.workspace = true +edition.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true +# distributed as a .wasm release asset rather than through crates.io +publish = false + +[lib] +crate-type = ["lib", "cdylib"] + +[dependencies] +# the runtime-rng default needs getrandom, which doesn't build on wasm32-unknown-unknown +rustywind_core = { path = "../rustywind-core", version = "=0.7.0", default-features = false } + +dprint-core = { version = "0.71", default-features = false, features = ["wasm"] } +serde = { version = "1.0", features = ["derive"] } + +regex = { workspace = true } + +[dev-dependencies] +pretty_assertions = "1.4" diff --git a/rustywind-dprint/src/configuration.rs b/rustywind-dprint/src/configuration.rs new file mode 100644 index 00000000..e2f5e311 --- /dev/null +++ b/rustywind-dprint/src/configuration.rs @@ -0,0 +1,164 @@ +use dprint_core::configuration::{ + ConfigKeyMap, ConfigKeyValue, ConfigurationDiagnostic, ResolveConfigurationResult, + get_nullable_value, get_nullable_vec, get_unknown_property_diagnostics, get_value, +}; +use regex::Regex; +use rustywind_core::RustyWind; +use rustywind_core::class_wrapping::ClassWrapping; +use rustywind_core::sorter::{CustomClassExtractor, FinderRegex, Sorter}; +use serde::Serialize; + +const CLASS_WRAPPINGS: [ClassWrapping; 3] = [ + ClassWrapping::NoWrapping, + ClassWrapping::CommaSingleQuotes, + ClassWrapping::CommaDoubleQuotes, +]; + +/// Resolved plugin configuration +/// +/// Mirrors the `rustywind` CLI options that make sense without filesystem or +/// network access, using camelCase keys in the `rustywind` section of a dprint +/// config file +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Configuration { + /// Keep repeated classes instead of removing duplicates + pub allow_duplicates: bool, + /// How classes captured by `customRegex` are wrapped + pub class_wrapping: &'static str, + /// Regex whose `classes` or first capture group contains a class list + pub custom_regex: Option, + /// Tailwind prefix, such as `tw-`, ignored while computing sort order + pub tailwind_prefix: Option, + /// Keep the original whitespace between classes + pub preserve_whitespace: bool, + /// Explicit class order that replaces the built-in Tailwind ordering + pub sort_order: Option>, + // dprint caches the resolved configuration per config id, so building the + // sorter here compiles a custom regex once instead of once per file + #[serde(skip)] + pub(crate) rustywind: RustyWind, +} + +/// Resolves a dprint `rustywind` config section into a [`Configuration`] +/// +/// Invalid values fall back to their defaults and are reported as diagnostics +pub fn resolve_config(mut config: ConfigKeyMap) -> ResolveConfigurationResult { + let mut diagnostics = Vec::new(); + + let allow_duplicates = get_value(&mut config, "allowDuplicates", false, &mut diagnostics); + let preserve_whitespace = get_value(&mut config, "preserveWhitespace", false, &mut diagnostics); + let tailwind_prefix: Option = + get_nullable_value(&mut config, "tailwindPrefix", &mut diagnostics); + let class_wrapping = resolve_class_wrapping(&mut config, &mut diagnostics); + let (custom_regex, regex) = resolve_custom_regex(&mut config, &mut diagnostics); + // `get_nullable_vec` rejects null, while every other option treats it as unset + if matches!(config.get("sortOrder"), Some(ConfigKeyValue::Null)) { + config.shift_remove("sortOrder"); + } + let sort_order = get_nullable_vec(&mut config, "sortOrder", sort_order_item, &mut diagnostics); + + diagnostics.extend(get_unknown_property_diagnostics(config)); + + let sorter = match &sort_order { + Some(classes) => Sorter::new( + classes + .iter() + .enumerate() + .map(|(index, class)| (class.clone(), index)) + .collect(), + ), + None => Sorter::PatternSorter, + }; + + let rustywind = RustyWind { + regex, + sorter, + allow_duplicates, + class_wrapping, + tailwind_prefix: tailwind_prefix.clone(), + preserve_whitespace, + }; + + ResolveConfigurationResult { + config: Configuration { + allow_duplicates, + class_wrapping: class_wrapping.as_str(), + custom_regex, + tailwind_prefix, + preserve_whitespace, + sort_order, + rustywind, + }, + diagnostics, + } +} + +// validated here because `ClassWrapping::from` silently maps unknown values to +// `no-wrapping`, which would hide a typo in the config file +fn resolve_class_wrapping( + config: &mut ConfigKeyMap, + diagnostics: &mut Vec, +) -> ClassWrapping { + let Some(value) = get_nullable_value::(config, "classWrapping", diagnostics) else { + return ClassWrapping::NoWrapping; + }; + + if let Some(wrapping) = CLASS_WRAPPINGS + .into_iter() + .find(|wrapping| wrapping.as_str() == value) + { + return wrapping; + } + + let expected = CLASS_WRAPPINGS.map(|wrapping| format!("'{}'", wrapping.as_str())); + diagnostics.push(ConfigurationDiagnostic { + property_name: "classWrapping".to_string(), + message: format!("Expected one of {}", expected.join(", ")), + }); + + ClassWrapping::NoWrapping +} + +fn resolve_custom_regex( + config: &mut ConfigKeyMap, + diagnostics: &mut Vec, +) -> (Option, FinderRegex) { + let Some(pattern) = get_nullable_value::(config, "customRegex", diagnostics) else { + return (None, FinderRegex::DefaultRegex); + }; + + let extractor = Regex::new(&pattern) + .map_err(|error| error.to_string()) + .and_then(|regex| CustomClassExtractor::new(regex).map_err(|error| error.to_string())); + + match extractor { + Ok(extractor) => (Some(pattern), FinderRegex::CustomRegex(extractor)), + Err(message) => { + diagnostics.push(ConfigurationDiagnostic { + property_name: "customRegex".to_string(), + message, + }); + + (None, FinderRegex::DefaultRegex) + } + } +} + +fn sort_order_item( + value: ConfigKeyValue, + index: usize, + diagnostics: &mut Vec, +) -> Option { + match value { + ConfigKeyValue::String(class) => Some(class), + _ => { + diagnostics.push(ConfigurationDiagnostic { + property_name: format!("sortOrder[{index}]"), + message: "Expected a class name string".to_string(), + }); + + None + } + } +} diff --git a/rustywind-dprint/src/lib.rs b/rustywind-dprint/src/lib.rs new file mode 100644 index 00000000..2b8238b0 --- /dev/null +++ b/rustywind-dprint/src/lib.rs @@ -0,0 +1,131 @@ +//! A dprint Wasm plugin that sorts Tailwind CSS classes with RustyWind +//! +//! The plugin is additive: it sorts classes in files that another plugin, +//! such as dprint-plugin-typescript or dprint-plugin-markup, already formats +mod configuration; + +use std::path::Path; + +use dprint_core::configuration::{ConfigKeyMap, GlobalConfiguration}; +use dprint_core::plugins::{ + CheckConfigUpdatesMessage, ConfigChange, FileMatchingInfo, FormatError, FormatResult, + PluginInfo, PluginResolveConfigurationResult, SyncFormatRequest, SyncHostFormatRequest, + SyncPluginHandler, +}; +use rustywind_core::{SourceDocument, SourceLanguage}; + +pub use configuration::{Configuration, resolve_config}; + +/// Extensions with a RustyWind language profile, matching `SourceLanguage::from_path` +/// +/// Compound names such as `.blade.php` and `.html.erb` are covered by their +/// final extension, and the full file name still selects the profile +pub const FILE_EXTENSIONS: &[&str] = &[ + "astro", + "blade", + "django", + "djhtml", + "ejs", + "erb", + "handlebars", + "hbs", + "htm", + "html", + "j2", + "jinja", + "jinja2", + "jsx", + "liquid", + "lit", + "php", + "phtml", + "rb", + "svelte", + "tsx", + "twig", +]; + +/// The dprint plugin handler for RustyWind +#[derive(Debug, Default)] +pub struct RustyWindPluginHandler; + +impl SyncPluginHandler for RustyWindPluginHandler { + fn resolve_config( + &mut self, + config: ConfigKeyMap, + _global_config: &GlobalConfiguration, + ) -> PluginResolveConfigurationResult { + let resolved = resolve_config(config); + + PluginResolveConfigurationResult { + config: resolved.config, + diagnostics: resolved.diagnostics, + file_matching: FileMatchingInfo { + file_extensions: FILE_EXTENSIONS.iter().map(ToString::to_string).collect(), + file_names: Vec::new(), + // sorting runs alongside the plugin that claims the file + // instead of competing with it for the file + additive: true, + }, + } + } + + fn plugin_info(&mut self) -> PluginInfo { + PluginInfo { + name: env!("CARGO_PKG_NAME").to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + config_key: "rustywind".to_string(), + help_url: "https://github.com/avencera/rustywind#dprint-plugin".to_string(), + config_schema_url: String::new(), + update_url: None, + } + } + + fn license_text(&mut self) -> String { + include_str!("../../LICENSE").to_string() + } + + fn check_config_updates( + &self, + _message: CheckConfigUpdatesMessage, + ) -> Result, FormatError> { + Ok(Vec::new()) + } + + fn format( + &mut self, + request: SyncFormatRequest, + _format_with_host: impl FnMut(SyncHostFormatRequest) -> FormatResult, + ) -> FormatResult { + // class order is a whole-attribute property, so a partial range can't + // be sorted on its own + if request.range.is_some() { + return Ok(None); + } + + // skipped like the CLI does, since an additive plugin erroring would fail + // `dprint check` on legacy-encoded templates no other plugin touches + let Ok(text) = String::from_utf8(request.file_bytes) else { + return Ok(None); + }; + + Ok(format_text(request.file_path, &text, request.config).map(String::into_bytes)) + } +} + +/// Sorts the classes in `text`, returning `None` when nothing changes +pub fn format_text(file_path: &Path, text: &str, config: &Configuration) -> Option { + let language = SourceLanguage::from_path(file_path); + let sorted = config + .rustywind + .sort_document(SourceDocument::new(text, language)); + + (sorted != text).then(|| sorted.into_owned()) +} + +#[cfg(all(target_arch = "wasm32", target_os = "unknown"))] +dprint_core::generate_plugin_code!( + RustyWindPluginHandler, + RustyWindPluginHandler, + Configuration +); diff --git a/rustywind-dprint/tests/plugin.rs b/rustywind-dprint/tests/plugin.rs new file mode 100644 index 00000000..9127634b --- /dev/null +++ b/rustywind-dprint/tests/plugin.rs @@ -0,0 +1,225 @@ +use std::path::Path; + +use dprint_core::configuration::{ConfigKeyMap, ConfigKeyValue, GlobalConfiguration}; +use dprint_core::plugins::{ + FormatConfigId, FormatRange, FormatResult, NullCancellationToken, + PluginResolveConfigurationResult, SyncFormatRequest, SyncPluginHandler, +}; +use dprint_plugin_rustywind::{Configuration, RustyWindPluginHandler}; +use pretty_assertions::assert_eq; + +fn config(entries: &[(&str, ConfigKeyValue)]) -> ConfigKeyMap { + entries + .iter() + .map(|(key, value)| (key.to_string(), value.clone())) + .collect() +} + +fn resolve(entries: &[(&str, ConfigKeyValue)]) -> PluginResolveConfigurationResult { + RustyWindPluginHandler.resolve_config(config(entries), &GlobalConfiguration::default()) +} + +fn format_with( + config: &Configuration, + file_path: &str, + bytes: &[u8], + range: FormatRange, +) -> Option { + let result: FormatResult = RustyWindPluginHandler.format( + SyncFormatRequest { + file_path: Path::new(file_path), + file_bytes: bytes.to_vec(), + config_id: FormatConfigId::from_raw(1), + config, + range, + token: &NullCancellationToken, + }, + |_| unreachable!("the plugin never formats through the host"), + ); + + result + .expect("formatting should succeed") + .map(|bytes| String::from_utf8(bytes).expect("output should be UTF-8")) +} + +fn format(entries: &[(&str, ConfigKeyValue)], file_path: &str, text: &str) -> Option { + let resolved = resolve(entries); + assert!( + resolved.diagnostics.is_empty(), + "{:?}", + resolved.diagnostics + ); + + format_with(&resolved.config, file_path, text.as_bytes(), None) +} + +#[test] +fn sorts_language_aware_documents_and_reports_unchanged_files() { + let tsx = r#"export const Card = () =>
hi
;"#; + assert_eq!( + format(&[], "src/Card.tsx", tsx).as_deref(), + Some( + r#"export const Card = () =>
hi
;"# + ) + ); + + // the file path selects the template profile: Blade sorts around `{{ }}`, + // while plain HTML leaves template-looking attributes alone + let template = r#"
"#; + assert_eq!( + format(&[], "views/card.blade.php", template).as_deref(), + Some(r#"
"#) + ); + assert_eq!(format(&[], "views/card.html", template), None); + + assert_eq!( + format(&[], "page.html", r#"
"#), + None + ); +} + +#[test] +fn applies_cli_equivalent_options() { + assert_eq!( + format( + &[ + ("tailwindPrefix", ConfigKeyValue::String("tw-".into())), + ("allowDuplicates", ConfigKeyValue::Bool(true)), + ], + "page.html", + r#"
"#, + ) + .as_deref(), + Some(r#"
"#) + ); + + assert_eq!( + format( + &[( + "sortOrder", + ConfigKeyValue::Array(vec![ + ConfigKeyValue::String("p-4".into()), + ConfigKeyValue::String("flex".into()), + ]), + )], + "page.html", + r#"
"#, + ) + .as_deref(), + Some(r#"
"#) + ); + + assert_eq!( + format( + &[ + ( + "customRegex", + ConfigKeyValue::String(r"classes\(([^)]*)\)".into()) + ), + ( + "classWrapping", + ConfigKeyValue::String("comma-single-quotes".into()) + ), + ], + "app.py", + "classes('p-4', 'flex')", + ) + .as_deref(), + Some("classes('flex', 'p-4')") + ); +} + +#[test] +fn reports_invalid_options_and_matches_files_additively() { + let resolved = resolve(&[ + ("classWrapping", ConfigKeyValue::String("comma".into())), + ( + "customRegex", + ConfigKeyValue::String("class=\"[^\"]*\"".into()), + ), + ( + "sortOrder", + ConfigKeyValue::Array(vec![ + ConfigKeyValue::String("flex".into()), + ConfigKeyValue::Number(1), + ]), + ), + ("allowDuplicates", ConfigKeyValue::String("yes".into())), + ("lineWidth", ConfigKeyValue::Number(80)), + ]); + + let properties: Vec<_> = resolved + .diagnostics + .iter() + .map(|diagnostic| diagnostic.property_name.as_str()) + .collect(); + assert_eq!( + properties, + [ + "allowDuplicates", + "classWrapping", + "customRegex", + "sortOrder[1]", + "lineWidth" + ] + ); + + assert!(resolved.file_matching.additive); + assert!( + resolved + .file_matching + .file_extensions + .contains(&"tsx".to_string()) + ); +} + +#[test] +fn skips_ranges_unreadable_input_and_null_options() { + let resolved = resolve(&[ + ("sortOrder", ConfigKeyValue::Null), + ("tailwindPrefix", ConfigKeyValue::Null), + ]); + assert!( + resolved.diagnostics.is_empty(), + "{:?}", + resolved.diagnostics + ); + + let text = r#"
"#; + assert_eq!( + format_with(&resolved.config, "page.html", text.as_bytes(), None).as_deref(), + Some(r#"
"#) + ); + + // class order is per attribute, so partial ranges are left alone + assert_eq!( + format_with( + &resolved.config, + "page.html", + text.as_bytes(), + Some(0..text.len()) + ), + None + ); + + // Latin-1 bytes are skipped rather than failing the whole dprint run + let latin1 = b"
caf\xe9
"; + assert_eq!( + format_with(&resolved.config, "page.html", latin1, None), + None + ); +} + +// the wasm is uploaded to the CLI's GitHub release, so the plugin version +// dprint reports must match that release tag +#[test] +fn plugin_version_matches_cli_release() { + let cli_manifest = include_str!("../../rustywind-cli/Cargo.toml"); + let cli_version = cli_manifest + .lines() + .find_map(|line| line.strip_prefix("version = \"")) + .and_then(|rest| rest.strip_suffix('"')) + .expect("CLI manifest should declare a version"); + + assert_eq!(RustyWindPluginHandler.plugin_info().version, cli_version); +} diff --git a/rustywind-dprint/tests/smoke/expected/card.tsx b/rustywind-dprint/tests/smoke/expected/card.tsx new file mode 100644 index 00000000..8bbb6772 --- /dev/null +++ b/rustywind-dprint/tests/smoke/expected/card.tsx @@ -0,0 +1 @@ +export const Card = () =>
hi
; diff --git a/rustywind-dprint/tests/smoke/expected/dprint.json b/rustywind-dprint/tests/smoke/expected/dprint.json new file mode 100644 index 00000000..7bcda590 --- /dev/null +++ b/rustywind-dprint/tests/smoke/expected/dprint.json @@ -0,0 +1 @@ +{ "rustywind": {} } diff --git a/rustywind-dprint/tests/smoke/expected/page.html b/rustywind-dprint/tests/smoke/expected/page.html new file mode 100644 index 00000000..dd199b32 --- /dev/null +++ b/rustywind-dprint/tests/smoke/expected/page.html @@ -0,0 +1 @@ +
hi
diff --git a/rustywind-dprint/tests/smoke/input/card.tsx b/rustywind-dprint/tests/smoke/input/card.tsx new file mode 100644 index 00000000..7f482bc5 --- /dev/null +++ b/rustywind-dprint/tests/smoke/input/card.tsx @@ -0,0 +1 @@ +export const Card = ( ) =>
hi
diff --git a/rustywind-dprint/tests/smoke/input/dprint.json b/rustywind-dprint/tests/smoke/input/dprint.json new file mode 100644 index 00000000..7bcda590 --- /dev/null +++ b/rustywind-dprint/tests/smoke/input/dprint.json @@ -0,0 +1 @@ +{ "rustywind": {} } diff --git a/rustywind-dprint/tests/smoke/input/page.html b/rustywind-dprint/tests/smoke/input/page.html new file mode 100644 index 00000000..c2d5402a --- /dev/null +++ b/rustywind-dprint/tests/smoke/input/page.html @@ -0,0 +1 @@ +
hi
From a693860d784a42122ec0e3726324bcddeb34d9e0 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 20 Oct 2026 11:56:10 -0500 Subject: [PATCH 2/7] Keep network tooling out of the release job The release job holds a contents: write token, so running npx dprint and a downloaded TypeScript plugin there gave fetched code access to it. The job now only builds the wasm, and the smoke test stays in the read-only tests workflow. Pin the TypeScript plugin by checksum so a changed upstream plugin fails the smoke test. --- .github/workflows/mean_bean_deploy.yml | 8 ++++++-- .github/workflows/tests.yml | 4 ++++ ci/dprint_smoke.bash | 3 ++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/.github/workflows/mean_bean_deploy.yml b/.github/workflows/mean_bean_deploy.yml index adb21c57..f273dc14 100644 --- a/.github/workflows/mean_bean_deploy.yml +++ b/.github/workflows/mean_bean_deploy.yml @@ -176,12 +176,16 @@ jobs: contents: write steps: - uses: actions/checkout@v7 + with: + persist-credentials: false - name: Setup Rust toolchain run: rustup toolchain install stable --profile minimal --target wasm32-unknown-unknown - - name: Build and smoke test the wasm plugin - run: bash ci/dprint_smoke.bash + # the smoke test runs in tests.yml instead, so this job with write access + # never executes the npm and dprint plugin downloads it needs + - name: Build the wasm plugin + run: cargo build -p dprint-plugin-rustywind --release --target wasm32-unknown-unknown - name: Create release and upload asset env: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ac52081e..6045bd5c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -19,8 +19,12 @@ jobs: dprint-plugin: runs-on: ubuntu-24.04 + permissions: + contents: read steps: - uses: actions/checkout@v7 + with: + persist-credentials: false - name: Setup Rust toolchain run: rustup toolchain install stable --profile minimal --target wasm32-unknown-unknown diff --git a/ci/dprint_smoke.bash b/ci/dprint_smoke.bash index 999aeb5e..25cd088f 100755 --- a/ci/dprint_smoke.bash +++ b/ci/dprint_smoke.bash @@ -5,7 +5,8 @@ set -euo pipefail DPRINT_VERSION=0.63.0 -TYPESCRIPT_PLUGIN=https://plugins.dprint.dev/typescript-0.95.13.wasm +# the checksum makes dprint reject a plugin that changed upstream +TYPESCRIPT_PLUGIN=https://plugins.dprint.dev/typescript-0.95.13.wasm@d353247b160c1e81eb043930de6f940adcd3d713651221d3a0284d4c30ea43c4 ROOT="$(cd "$(dirname "$0")/.." && pwd)" SMOKE="$ROOT/rustywind-dprint/tests/smoke" From 064a412bc41409a76c31aac0459501127fc3c377 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 20 Oct 2026 11:58:10 -0500 Subject: [PATCH 3/7] Skip files with dprint ignore comments The plugin sorted classes the user had excluded with dprint-ignore or dprint-ignore-file, so adding it changed content other plugins leave alone. Only the claiming plugin's parser knows which node an ignore comment covers, so skip any file with an ignore directive instead. --- README.md | 4 ++++ rustywind-dprint/src/lib.rs | 9 +++++++++ rustywind-dprint/tests/plugin.rs | 20 ++++++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/README.md b/README.md index 59f3c343..0193fe1a 100644 --- a/README.md +++ b/README.md @@ -225,6 +225,10 @@ it as a regular plugin that competes with other plugins for the same files. `dprint config update` does not update this plugin, so change the release tag in the URL to upgrade. +Files that contain a `dprint-ignore`, `dprint-ignore-file`, or `dprint-ignore-start` comment are +left unchanged. The plugin can't tell which part of the file such a comment excludes, so it skips +the whole file. + The plugin sorts files with these extensions: `astro`, `blade`, `django`, `djhtml`, `ejs`, `erb`, `handlebars`, `hbs`, `htm`, `html`, `j2`, `jinja`, `jinja2`, `jsx`, `liquid`, `lit`, `php`, `phtml`, `rb`, `svelte`, `tsx`, and `twig`. The template language is inferred from the file name diff --git a/rustywind-dprint/src/lib.rs b/rustywind-dprint/src/lib.rs index 2b8238b0..36fbec61 100644 --- a/rustywind-dprint/src/lib.rs +++ b/rustywind-dprint/src/lib.rs @@ -114,7 +114,16 @@ impl SyncPluginHandler for RustyWindPluginHandler { } /// Sorts the classes in `text`, returning `None` when nothing changes +/// +/// Files containing a dprint ignore directive are left unchanged pub fn format_text(file_path: &Path, text: &str, config: &Configuration) -> Option { + // `dprint-ignore` exempts the next node, which only the claiming plugin's + // parser can delimit, so the whole file is skipped rather than risk sorting + // excluded classes. The prefix also covers `-file`, `-start`, and `-end` + if text.contains("dprint-ignore") { + return None; + } + let language = SourceLanguage::from_path(file_path); let sorted = config .rustywind diff --git a/rustywind-dprint/tests/plugin.rs b/rustywind-dprint/tests/plugin.rs index 9127634b..f772a980 100644 --- a/rustywind-dprint/tests/plugin.rs +++ b/rustywind-dprint/tests/plugin.rs @@ -210,6 +210,26 @@ fn skips_ranges_unreadable_input_and_null_options() { ); } +#[test] +fn leaves_files_with_dprint_ignore_directives_unchanged() { + for (file_path, text) in [ + ( + "Card.tsx", + "// dprint-ignore-file\nexport const Card = () =>
hi
;", + ), + ( + "Card.tsx", + "// dprint-ignore\nexport const Card = () =>
hi
;", + ), + ( + "page.html", + "\n
\n", + ), + ] { + assert_eq!(format(&[], file_path, text), None, "{text}"); + } +} + // the wasm is uploaded to the CLI's GitHub release, so the plugin version // dprint reports must match that release tag #[test] From ab035725ead049ed2f096cf53be334dc9816381c Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 20 Oct 2026 12:02:07 -0500 Subject: [PATCH 4/7] Test that preserveWhitespace reaches the sorter --- rustywind-dprint/tests/plugin.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/rustywind-dprint/tests/plugin.rs b/rustywind-dprint/tests/plugin.rs index f772a980..89193c01 100644 --- a/rustywind-dprint/tests/plugin.rs +++ b/rustywind-dprint/tests/plugin.rs @@ -80,6 +80,21 @@ fn sorts_language_aware_documents_and_reports_unchanged_files() { #[test] fn applies_cli_equivalent_options() { + let multiline = "
"; + assert_eq!( + format(&[], "page.html", multiline).as_deref(), + Some("
") + ); + assert_eq!( + format( + &[("preserveWhitespace", ConfigKeyValue::Bool(true))], + "page.html", + multiline, + ) + .as_deref(), + Some("
") + ); + assert_eq!( format( &[ From e0caeadcdbc347f3d2dfa3f824bed3dd385aa078 Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 20 Oct 2026 12:06:51 -0500 Subject: [PATCH 5/7] Only honor dprint-ignore when it opens a comment Matching the bare substring meant prose or a class named dprint-ignore turned off sorting for the whole file. Require the directive to follow a comment opener from one of the supported languages. --- rustywind-dprint/src/lib.rs | 20 ++++++++++++++++++-- rustywind-dprint/tests/plugin.rs | 20 ++++++++++++++++++-- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/rustywind-dprint/src/lib.rs b/rustywind-dprint/src/lib.rs index 36fbec61..370b343e 100644 --- a/rustywind-dprint/src/lib.rs +++ b/rustywind-dprint/src/lib.rs @@ -119,8 +119,8 @@ impl SyncPluginHandler for RustyWindPluginHandler { pub fn format_text(file_path: &Path, text: &str, config: &Configuration) -> Option { // `dprint-ignore` exempts the next node, which only the claiming plugin's // parser can delimit, so the whole file is skipped rather than risk sorting - // excluded classes. The prefix also covers `-file`, `-start`, and `-end` - if text.contains("dprint-ignore") { + // excluded classes + if has_ignore_directive(text) { return None; } @@ -132,6 +132,22 @@ pub fn format_text(file_path: &Path, text: &str, config: &Configuration) -> Opti (sorted != text).then(|| sorted.into_owned()) } +/// Comment openers across the supported languages, where `*` also covers `/*`, +/// JSX `{/*`, and block comment continuation lines, and `#` covers Ruby, ERB +/// `<%#`, and Jinja or Twig `{#` +const COMMENT_OPENERS: &[&str] = &["//", "*", "\n
\n", ), + ( + "card.blade.php", + "{{-- dprint-ignore --}}\n
", + ), + ( + "card.twig", + "{# dprint-ignore #}\n
", + ), ] { assert_eq!(format(&[], file_path, text), None, "{text}"); } + + // only a directive that opens a comment disables sorting + for text in [ + "

Add dprint-ignore to skip a node

\n
", + "
", + ] { + assert!(format(&[], "page.html", text).is_some(), "{text}"); + } } // the wasm is uploaded to the CLI's GitHub release, so the plugin version From 746cff2584d38ee48e12ca2b636c0c622835648c Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 20 Oct 2026 12:26:41 -0500 Subject: [PATCH 6/7] Accept line breaks before a dprint-ignore directive A block comment such as still sorted classes, because only spaces and tabs were trimmed between the opener and the directive. --- rustywind-dprint/src/lib.rs | 3 ++- rustywind-dprint/tests/plugin.rs | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/rustywind-dprint/src/lib.rs b/rustywind-dprint/src/lib.rs index 370b343e..af51046d 100644 --- a/rustywind-dprint/src/lib.rs +++ b/rustywind-dprint/src/lib.rs @@ -141,7 +141,8 @@ const COMMENT_OPENERS: &[&str] = &["//", "*", "\n
", + ), + ( + "Card.tsx", + "/*\n * dprint-ignore-file\n */\nexport const Card = () =>
;", + ), ] { assert_eq!(format(&[], file_path, text), None, "{text}"); } From d41906da91473a8f5c8478bd37c832ab1968826e Mon Sep 17 00:00:00 2001 From: Praveen Perera Date: Tue, 20 Oct 2026 12:32:52 -0500 Subject: [PATCH 7/7] Keep line comment openers on the directive's line Trimming line breaks let a // that ended one line pair with plain text dprint-ignore on the next, skipping a file with no directive. Only block comment openers may now precede the directive across a line break. --- rustywind-dprint/src/lib.rs | 25 +++++++++++++++++-------- rustywind-dprint/tests/plugin.rs | 1 + 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/rustywind-dprint/src/lib.rs b/rustywind-dprint/src/lib.rs index af51046d..4e901060 100644 --- a/rustywind-dprint/src/lib.rs +++ b/rustywind-dprint/src/lib.rs @@ -132,20 +132,29 @@ pub fn format_text(file_path: &Path, text: &str, config: &Configuration) -> Opti (sorted != text).then(|| sorted.into_owned()) } -/// Comment openers across the supported languages, where `*` also covers `/*`, -/// JSX `{/*`, and block comment continuation lines, and `#` covers Ruby, ERB -/// `<%#`, and Jinja or Twig `{#` -const COMMENT_OPENERS: &[&str] = &["//", "*", "