diff --git a/.github/workflows/mean_bean_deploy.yml b/.github/workflows/mean_bean_deploy.yml index 7e8e81ef..f273dc14 100644 --- a/.github/workflows/mean_bean_deploy.yml +++ b/.github/workflows/mean_bean_deploy.yml @@ -170,6 +170,35 @@ 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 + with: + persist-credentials: false + + - name: Setup Rust toolchain + run: rustup toolchain install stable --profile minimal --target wasm32-unknown-unknown + + # 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: + 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..6045bd5c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -16,3 +16,18 @@ jobs: - name: Test all packages in the workspace run: cargo test --workspace + + 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 + + - 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..0193fe1a 100644 --- a/README.md +++ b/README.md @@ -202,6 +202,58 @@ 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. + +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 +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..25cd088f --- /dev/null +++ b/ci/dprint_smoke.bash @@ -0,0 +1,29 @@ +#!/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 +# 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" + +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..4e901060 --- /dev/null +++ b/rustywind-dprint/src/lib.rs @@ -0,0 +1,166 @@ +//! 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 +/// +/// 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 + if has_ignore_directive(text) { + return None; + } + + let language = SourceLanguage::from_path(file_path); + let sorted = config + .rustywind + .sort_document(SourceDocument::new(text, language)); + + (sorted != text).then(|| sorted.into_owned()) +} + +/// Block comment openers across the supported languages, where `*` also covers +/// `/*`, JSX `{/*`, and block comment continuation lines +const BLOCK_COMMENT_OPENERS: &[&str] = &["*", "\n
\n", + ), + ( + "card.blade.php", + "{{-- dprint-ignore --}}\n
", + ), + ( + "card.twig", + "{# dprint-ignore #}\n
", + ), + ( + "page.html", + "\n
", + ), + ( + "Card.tsx", + "/*\n * dprint-ignore-file\n */\nexport const Card = () =>
;", + ), + ] { + 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
", + "
", + "

\n comments start with //\n dprint-ignore skips a node\n

\n
", + ] { + assert!(format(&[], "page.html", text).is_some(), "{text}"); + } +} + +// 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