diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f2cd4489f51..73dd8ca38485 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,14 @@ Read our [guidelines for writing a good changelog entry](https://github.com/biom it.only("test", () => {}); ``` +- Add rule [noSortedClasses](https://biomejs.dev/linter/rules/use-sorted-classes), to sort CSS utility classes: + + ```diff + -
+ +
+ ``` + Contributed by @DaniGuardiola + ### Parser ## 1.5.3 (2024-01-22) @@ -130,7 +138,7 @@ Read our [guidelines for writing a good changelog entry](https://github.com/biom Contributed by @magic-akari -- Correctly parse type arguments in expression([#1184](https://github.com/biomejs/biome/issues/1184)). +- Correctly parse type arguments in expression([#1184](https://github.com/biomejs/biome/issues/1184)). The following code is now correctly parsed in typescript: diff --git a/crates/biome_diagnostics_categories/src/categories.rs b/crates/biome_diagnostics_categories/src/categories.rs index aed9bb70b566..5be1a68f0f6b 100644 --- a/crates/biome_diagnostics_categories/src/categories.rs +++ b/crates/biome_diagnostics_categories/src/categories.rs @@ -130,6 +130,7 @@ define_categories! { "lint/nursery/useNodejsImportProtocol": "https://biomejs.dev/linter/rules/use-nodejs-import-protocol", "lint/nursery/useNumberNamespace": "https://biomejs.dev/linter/rules/use-number-namespace", "lint/nursery/useShorthandFunctionType": "https://biomejs.dev/linter/rules/use-shorthand-function-type", + "lint/nursery/useSortedClasses": "https://biomejs.dev/linter/rules/use-sorted-classes", "lint/performance/noAccumulatingSpread": "https://biomejs.dev/linter/rules/no-accumulating-spread", "lint/performance/noDelete": "https://biomejs.dev/linter/rules/no-delete", "lint/security/noDangerouslySetInnerHtml": "https://biomejs.dev/linter/rules/no-dangerously-set-inner-html", diff --git a/crates/biome_js_analyze/src/options.rs b/crates/biome_js_analyze/src/options.rs index 4deab070fcdd..5b6af44b806e 100644 --- a/crates/biome_js_analyze/src/options.rs +++ b/crates/biome_js_analyze/src/options.rs @@ -4,6 +4,7 @@ use crate::analyzers::nursery::use_consistent_array_type::ConsistentArrayTypeOpt use crate::analyzers::nursery::use_filenaming_convention::FilenamingConventionOptions; use crate::semantic_analyzers::correctness::use_exhaustive_dependencies::HooksOptions; use crate::semantic_analyzers::correctness::use_hook_at_top_level::DeprecatedHooksOptions; +use crate::semantic_analyzers::nursery::use_sorted_classes::UtilityClassSortingOptions; use crate::semantic_analyzers::style::no_restricted_globals::RestrictedGlobalsOptions; use crate::semantic_analyzers::style::use_naming_convention::NamingConventionOptions; use crate::{ @@ -38,6 +39,8 @@ pub enum PossibleOptions { RestrictedGlobals(RestrictedGlobalsOptions), /// Options for `useValidAriaRole` rule ValidAriaRole(ValidAriaRoleOptions), + /// Options for `useSortedClasses` rule + UtilityClassSorting(UtilityClassSortingOptions), } impl Default for PossibleOptions { @@ -105,6 +108,13 @@ impl PossibleOptions { }; RuleOptions::new(options) } + "useSortedClasses" => { + let options = match self { + PossibleOptions::UtilityClassSorting(options) => options.clone(), + _ => UtilityClassSortingOptions::default(), + }; + RuleOptions::new(options) + } // TODO: review error _ => panic!("This rule {:?} doesn't have options", rule_key), } @@ -137,6 +147,8 @@ impl Deserializable for PossibleOptions { "useValidAriaRole" => { Deserializable::deserialize(value, "options", diagnostics).map(Self::ValidAriaRole) } + "useSortedClasses" => Deserializable::deserialize(value, "options", diagnostics) + .map(Self::UtilityClassSorting), _ => { diagnostics.push( DeserializationDiagnostic::new(markup! { diff --git a/crates/biome_js_analyze/src/semantic_analyzers/nursery.rs b/crates/biome_js_analyze/src/semantic_analyzers/nursery.rs index 01e3f753974f..95bf2c4fbe3c 100644 --- a/crates/biome_js_analyze/src/semantic_analyzers/nursery.rs +++ b/crates/biome_js_analyze/src/semantic_analyzers/nursery.rs @@ -12,6 +12,7 @@ pub(crate) mod use_export_type; pub(crate) mod use_for_of; pub(crate) mod use_import_type; pub(crate) mod use_number_namespace; +pub(crate) mod use_sorted_classes; declare_group! { pub (crate) Nursery { @@ -27,6 +28,7 @@ declare_group! { self :: use_for_of :: UseForOf , self :: use_import_type :: UseImportType , self :: use_number_namespace :: UseNumberNamespace , + self :: use_sorted_classes :: UseSortedClasses , ] } } diff --git a/crates/biome_js_analyze/src/semantic_analyzers/nursery/use_sorted_classes.rs b/crates/biome_js_analyze/src/semantic_analyzers/nursery/use_sorted_classes.rs new file mode 100644 index 000000000000..abc1102a27a1 --- /dev/null +++ b/crates/biome_js_analyze/src/semantic_analyzers/nursery/use_sorted_classes.rs @@ -0,0 +1,202 @@ +mod any_class_string_like; +mod class_info; +mod class_lexer; +mod options; +mod presets; +mod sort; +mod sort_config; + +use biome_analyze::{ + context::RuleContext, declare_rule, ActionCategory, FixKind, Rule, RuleDiagnostic, +}; +use biome_console::markup; +use biome_diagnostics::Applicability; +use biome_js_factory::make::{js_string_literal, js_string_literal_expression, jsx_string}; +use biome_rowan::{AstNode, BatchMutationExt}; + +use crate::JsRuleAction; + +pub use self::options::UtilityClassSortingOptions; +use self::{ + any_class_string_like::AnyClassStringLike, + presets::{get_utilities_preset, UseSortedClassesPreset}, + sort::sort_class_name, + sort_config::SortConfig, +}; + +declare_rule! { + /// Enforce the sorting of CSS utility classes. + /// + /// This rule implements the same sorting algorithm as [Tailwind CSS](https://tailwindcss.com/blog/automatic-class-sorting-with-prettier#how-classes-are-sorted), but supports any utility class framework including [UnoCSS](https://unocss.dev/). + /// + /// It is analogous to [`prettier-plugin-tailwindcss`](https://github.com/tailwindlabs/prettier-plugin-tailwindcss). + /// + /// + /// :::caution + /// ## Important notes + /// + /// This rule is a work in progress, and is only partially implemented. Progress is being tracked in the following GitHub issue: https://github.com/biomejs/biome/issues/1274 + /// + /// Currently, utility class sorting is **not part of the formatter**, and is implemented as a linter rule instead, with an automatic fix. The fix is, at this stage, classified as unsafe. This means that **it won't be applied automatically** as part of IDE actions such as "fix on save". + /// + /// We appreciate any feedback on this rule, and encourage you to try it out and report any issues you find. + /// + /// **Please read this entire documentation page before reporting an issue.** + /// + /// Notably, keep in mind that the following features are not supported yet: + /// + /// - Variant sorting. + /// - Custom utilitites and variants (such as ones introduced by Tailwind CSS plugins). Only the default Tailwind CSS configuration is supported. + /// - Options such as `prefix` and `separator`. + /// - Tagged template literals. + /// - Object properties (e.g. in `clsx` calls). + /// + /// Please don't report issues about these features. + /// ::: + /// + /// ## Examples + /// + /// ### Invalid + /// + /// ```jsx,expect_diagnostic + ///
; + /// ``` + /// + /// ## Options + /// + /// ### Code-related + /// + /// ```json + /// { + /// "options": { + /// "attributes": ["classList"], + /// "functions": ["clsx", "cva", "tw"] + /// } + /// } + /// ``` + /// + /// #### attributes + /// + /// Classes in the `class` and `className` JSX attributes are always sorted. Use this option to add more attributes that should be sorted. + /// + /// #### functions + /// + /// If specified, strings in the indicated functions will be sorted. This is useful when working with libraries like [`clsx`](https://github.com/lukeed/clsx) or [`cva`](https://cva.style/). + /// + /// ```js,ignore + /// clsx("px-2 foo p-4 bar", { + /// "block mx-4": condition, + /// }); + /// ``` + /// + /// Tagged template literals are also supported, for example: + /// + /// ```js,ignore + /// tw`px-2`; + /// tw.div`px-2`; + /// ``` + /// + /// :::caution + /// Tagged template literal support has not been implemented yet. + /// ::: + /// + /// ### Sort-related + /// + /// :::caution + /// At the moment, this rule does not support customizing the sort options. Instead, the default Tailwind CSS configuration is hard-coded. + /// ::: + /// + /// ## Differences with [Prettier](https://github.com/tailwindlabs/prettier-plugin-tailwindcss) + /// + /// The main key difference is that Tailwind CSS and its Prettier plugin read and execute the `tailwind.config.js` JavaScript file, which Biome can't do. Instead, Biome implements a simpler version of the configuration. The trade-offs are explained below. + /// + /// ### Values are not known + /// + /// The rule has no knowledge of values such as colors, font sizes, or spacing values, which are normally defined in a configuration file like `tailwind.config.js`. Instead, the rule matches utilities that support values in a simpler way: if they start with a known utility prefix, such as `px-` or `text-`, they're considered valid. + /// + /// This has two implications: + /// + /// - False positives: classes can be wrongly recognized as utilities even though their values are incorrect. For example, if there's a `px-` utility defined in the configuration, it will match all of the following classes: `px-2`, `px-1337`, `px-[not-actually-valid]`, `px-literally-anything`. + /// - No distinction between different utilities that share the same prefix: for example, `text-red-500` and `text-lg` are both interpreted as the same type of utility by this rule, even though the former refers to a color and the latter to a font size. This results in all utilities that share the same prefix being sorted together, regardless of their actual values. + /// + /// ### Custom additions must be specified + /// + /// The built-in Tailwind CSS preset (enabled by default) contains the set of utilities and variants that are available with the default configuration. More utilities and variants can be added through Tailwind CSS plugins. In Biome, these need to be manually specified in the Biome configuration file in order to "extend" the preset. + /// + /// ### Presets can't be modified + /// + /// In Tailwind CSS, core plugins (which provide the default utilities and variants) can be disabled. In Biome, however, there is no way to disable parts of a preset: it's all or nothing. A work-around is to, instead of using a preset, manually specify all utilities and variants in the Biome configuration file. + /// + /// ### Whitespace is collapsed + /// + /// The Tailwind CSS Prettier plugin preserves all original whitespace. This rule, however, collapses all whitespace (including newlines) into single spaces. + /// + /// This is a deliberate decision. We're unsure about this behavior, and would appreciate feedback on it. If this is a problem for you, please share a detailed explanation of your use case in [the GitHub issue](https://github.com/biomejs/biome/issues/1274). + /// + pub(crate) UseSortedClasses { + version: "next", + name: "useSortedClasses", + recommended: false, + fix_kind: FixKind::Unsafe, + } +} + +impl Rule for UseSortedClasses { + type Query = AnyClassStringLike; + type State = String; + type Signals = Option; + type Options = UtilityClassSortingOptions; + + fn run(ctx: &RuleContext) -> Option { + // TODO: unsure if options are needed here. The sort config should ideally be created once + // from the options and then reused for all queries. + // let options = &ctx.options(); + // TODO: the sort config should already exist at this point, and be generated from the options, + // including the preset and extended options as well. + let sort_config = SortConfig::new( + get_utilities_preset(&UseSortedClassesPreset::default()), + Vec::new(), + ); + + let value = ctx.query().value()?; + let sorted_value = sort_class_name(&value, &sort_config); + if value.text() != sorted_value { + Some(sorted_value) + } else { + None + } + } + + fn diagnostic(ctx: &RuleContext, _: &Self::State) -> Option { + Some(RuleDiagnostic::new( + rule_category!(), + ctx.query().range(), + "These CSS classes should be sorted.", + )) + } + + fn action(ctx: &RuleContext, state: &Self::State) -> Option { + let mut mutation = ctx.root().begin(); + match ctx.query() { + AnyClassStringLike::JsStringLiteralExpression(string_literal) => { + let replacement = js_string_literal_expression(js_string_literal(state)); + mutation.replace_node(string_literal.clone(), replacement); + } + AnyClassStringLike::JsxString(jsx_string_node) => { + let replacement = jsx_string(js_string_literal(state)); + mutation.replace_node(jsx_string_node.clone(), replacement); + } + AnyClassStringLike::JsTemplateChunkElement(_) => return None, + }; + + Some(JsRuleAction { + category: ActionCategory::QuickFix, + applicability: Applicability::MaybeIncorrect, + message: markup! { + "Sort the classes." + } + .to_owned(), + mutation, + }) + } +} diff --git a/crates/biome_js_analyze/src/semantic_analyzers/nursery/use_sorted_classes/any_class_string_like.rs b/crates/biome_js_analyze/src/semantic_analyzers/nursery/use_sorted_classes/any_class_string_like.rs new file mode 100644 index 000000000000..d2e5e8d3258e --- /dev/null +++ b/crates/biome_js_analyze/src/semantic_analyzers/nursery/use_sorted_classes/any_class_string_like.rs @@ -0,0 +1,237 @@ +use biome_analyze::{ + AddVisitor, AnalyzerOptions, Phases, QueryMatch, Queryable, RuleKey, ServiceBag, Visitor, + VisitorContext, +}; +use biome_js_syntax::{ + JsCallArguments, JsCallExpression, JsLanguage, JsStringLiteralExpression, + JsTemplateChunkElement, JsxAttribute, JsxString, +}; +use biome_rowan::{ + declare_node_union, AstNode, Language, SyntaxNode, TextRange, TokenText, WalkEvent, +}; + +use super::UtilityClassSortingOptions; + +// utils +// ----- + +fn get_options_from_analyzer(analyzer_options: &AnalyzerOptions) -> UtilityClassSortingOptions { + analyzer_options + .configuration + .rules + .get_rule_options::(&RuleKey::new( + "nursery", + "useSortedClasses", + )) + .cloned() + .unwrap_or_default() +} + +fn get_callee_name(call_expression: &JsCallExpression) -> Option { + call_expression + .callee() + .ok()? + .as_js_identifier_expression()? + .name() + .ok()? + .name() + .ok() +} + +fn is_call_expression_of_target_function( + call_expression: &JsCallExpression, + target_functions: &[String], +) -> bool { + get_callee_name(call_expression) + .is_some_and(|name| target_functions.contains(&name.to_string())) +} + +fn get_attribute_name(attribute: &JsxAttribute) -> Option { + Some( + attribute + .name() + .ok()? + .as_jsx_name()? + .value_token() + .ok()? + .token_text_trimmed(), + ) +} + +fn is_target_attribute(attribute: &JsxAttribute, target_attributes: &[String]) -> bool { + match get_attribute_name(attribute) { + Some(name) => target_attributes.contains(&name.to_string()), + None => false, + } +} + +// attributes visitor +// ------------------ + +#[derive(Default)] +struct StringLiteralInAttributeVisitor { + in_target_attribute: bool, +} + +// Finds class-like strings in JSX attributes, including class, className, and others defined in the options. +// TODO: match object properties too +impl Visitor for StringLiteralInAttributeVisitor { + type Language = JsLanguage; + fn visit( + &mut self, + event: &WalkEvent>, + mut ctx: VisitorContext, + ) { + let options = get_options_from_analyzer(ctx.options); + let attributes = match &options.attributes { + Some(attributes) => attributes, + None => return, + }; + match event { + WalkEvent::Enter(node) => { + // When entering an attribute node, track if we are in a target attribute. + if let Some(attribute) = JsxAttribute::cast_ref(node) { + self.in_target_attribute = is_target_attribute(&attribute, attributes); + } + + // When entering a JSX string node, and we are in a target attribute, emit. + if let Some(jsx_string) = JsxString::cast_ref(node) { + if self.in_target_attribute { + ctx.match_query(AnyClassStringLike::JsxString(jsx_string)); + } + } + + // When entering a string literal node, and we are in a target attribute, emit. + if let Some(string_literal) = JsStringLiteralExpression::cast_ref(node) { + if self.in_target_attribute { + ctx.match_query(AnyClassStringLike::JsStringLiteralExpression( + string_literal, + )); + } + } + } + WalkEvent::Leave(node) => { + // When leaving an attribute node, reset in_target_attribute. + if JsxAttribute::cast_ref(node).is_some() { + self.in_target_attribute = false; + } + } + } + } +} + +// functions (call expression) visitor +// ----------------------------------- + +#[derive(Default)] +struct StringLiteralInCallExpressionVisitor { + in_target_function: bool, + in_arguments: bool, +} + +// Finds class-like strings inside function calls defined in the options, e.g. clsx(classes). +// TODO: match object properties too +impl Visitor for StringLiteralInCallExpressionVisitor { + type Language = JsLanguage; + + fn visit( + &mut self, + event: &WalkEvent>, + mut ctx: VisitorContext, + ) { + let options = get_options_from_analyzer(ctx.options); + let functions = match &options.functions { + Some(functions) => functions, + None => return, + }; + match event { + WalkEvent::Enter(node) => { + // When entering a call expression node, track if we are in a target function and reset + // in_arguments. + if let Some(call_expression) = JsCallExpression::cast_ref(node) { + self.in_target_function = + is_call_expression_of_target_function(&call_expression, functions); + self.in_arguments = false; + } + + // When entering a call arguments node, set in_arguments. + if JsCallArguments::cast_ref(node).is_some() { + self.in_arguments = true; + } + + // When entering a string literal node, and we are in a target function and in arguments, emit. + if let Some(string_literal) = JsStringLiteralExpression::cast_ref(node) { + if self.in_target_function && self.in_arguments { + ctx.match_query(AnyClassStringLike::JsStringLiteralExpression( + string_literal, + )); + } + } + } + WalkEvent::Leave(node) => { + // When leaving a call arguments node, reset in_arguments. + if JsCallArguments::cast_ref(node).is_some() { + self.in_arguments = false; + } + } + } + } +} + +// functions (template chunk) visitor +// ---------------------------------- + +// Finds class-like template chunks in tagged template calls defined in the options, e.g. tw`classes`. +// TODO: template chunk visitor + +// query +// ----- + +declare_node_union! { + /// A string literal, JSX string, or template chunk representing a CSS class string. + pub AnyClassStringLike = JsStringLiteralExpression | JsxString | JsTemplateChunkElement +} + +impl AnyClassStringLike { + /// Returns the value of the string literal, JSX string, or template chunk. + pub fn value(&self) -> Option { + match self { + Self::JsStringLiteralExpression(string_literal) => { + Some(string_literal.inner_string_text().ok()?) + } + Self::JsxString(jsx_string) => Some(jsx_string.inner_string_text().ok()?), + Self::JsTemplateChunkElement(template_chunk) => { + Some(template_chunk.template_chunk_token().ok()?.token_text()) + } + } + } +} + +impl QueryMatch for AnyClassStringLike { + fn text_range(&self) -> TextRange { + self.range() + } +} + +impl Queryable for AnyClassStringLike { + type Input = Self; + type Language = JsLanguage; + type Output = AnyClassStringLike; + type Services = (); + + fn build_visitor( + analyzer: &mut impl AddVisitor, + _: &::Root, + ) { + analyzer.add_visitor(Phases::Syntax, || { + StringLiteralInAttributeVisitor::default() + }); + analyzer.add_visitor(Phases::Syntax, || { + StringLiteralInCallExpressionVisitor::default() + }); + } + + fn unwrap_match(_: &ServiceBag, query: &Self::Input) -> Self::Output { + query.clone() + } +} diff --git a/crates/biome_js_analyze/src/semantic_analyzers/nursery/use_sorted_classes/class_info.rs b/crates/biome_js_analyze/src/semantic_analyzers/nursery/use_sorted_classes/class_info.rs new file mode 100644 index 000000000000..7bd2b438b65e --- /dev/null +++ b/crates/biome_js_analyze/src/semantic_analyzers/nursery/use_sorted_classes/class_info.rs @@ -0,0 +1,392 @@ +//! Each CSS class needs to be processed to determine the information that will be used to sort it. +//! This information includes: +//! - The layer it belongs to (e.g. `components` or `utilities`). +//! - The index of the utility within the layer. +//! - The total variants weight that results from the combination of all the variants. +//! - The text of the class itself. +//! It is generated according to the information contained in a `SortConfig`, which includes: +//! - The list of layers, in order. +//! - The list of utilities, in order, for each layer. +//! - The list of variants, in order of importance (which is used to compute the variants weight). +//! - Other options, such as prefix and separator. + +use super::{ + class_lexer::{tokenize_class, ClassSegmentStructure}, + sort_config::{SortConfig, UtilitiesConfig}, +}; + +// utilities +// --------- + +/// The result of matching a utility against a target. +#[derive(Debug, Eq, PartialEq)] +enum UtilityMatch { + /// The utility matches an exact target. + Exact, + /// The utility matches a partial target. + Partial, + /// The utility does not match the target. + None, +} + +impl UtilityMatch { + /// Checks if a utility matches a target, and returns the result. + fn from(target: &str, utility_text: &str) -> UtilityMatch { + // If the target ends with `$`, then it's an exact target. + if target.ends_with('$') { + // Check if the utility matches the target (without the final `$`) exactly. + if utility_text == &target[..target.len() - 1] { + return UtilityMatch::Exact; + } + return UtilityMatch::None; + } + // Check if the utility starts with the (partial) target. + if utility_text.starts_with(target) && utility_text != target { + return UtilityMatch::Partial; + } + // If all of the above checks fail, there is no match. + UtilityMatch::None + } +} + +#[cfg(test)] +mod utility_match_tests { + use super::*; + + #[test] + fn test_exact_match() { + assert_eq!(UtilityMatch::from("px-2$", "px-2"), UtilityMatch::Exact); + // TODO: support negative values + // assert_eq!(UtilityMatch::from("px-2$", "-px-2"), UtilityMatch::Exact); + assert_eq!(UtilityMatch::from("px-2$", "not-px-2"), UtilityMatch::None); + assert_eq!(UtilityMatch::from("px-2$", "px-2-"), UtilityMatch::None); + assert_eq!(UtilityMatch::from("px-2$", "px-4"), UtilityMatch::None); + assert_eq!(UtilityMatch::from("px-2$", "px-2$"), UtilityMatch::None); + assert_eq!(UtilityMatch::from("px-2$", "px-2-"), UtilityMatch::None); + assert_eq!(UtilityMatch::from("px-2$", "px-2.5"), UtilityMatch::None); + assert_eq!(UtilityMatch::from("px-2$", "px-2.5$"), UtilityMatch::None); + assert_eq!(UtilityMatch::from("px-2$", "px-2.5-"), UtilityMatch::None); + } + + #[test] + fn test_partial_match() { + assert_eq!(UtilityMatch::from("px-", "px-2"), UtilityMatch::Partial); + // TODO: support negative values + // assert_eq!(UtilityMatch::from("px-", "-px-2"), UtilityMatch::Partial); + assert_eq!(UtilityMatch::from("px-", "px-2.5"), UtilityMatch::Partial); + assert_eq!( + UtilityMatch::from("px-", "px-anything"), + UtilityMatch::Partial + ); + assert_eq!( + UtilityMatch::from("px-", "px-%$>?+=-"), + UtilityMatch::Partial + ); + assert_eq!(UtilityMatch::from("px-", "px-"), UtilityMatch::None); + // TODO: support negative values + // assert_eq!(UtilityMatch::from("px-", "-px-"), UtilityMatch::None); + assert_eq!(UtilityMatch::from("px-", "not-px-2"), UtilityMatch::None); + } +} + +/// Sort-related information about a utility. +#[derive(Debug, Eq, PartialEq)] +struct UtilityInfo { + /// The layer the utility belongs to. + layer: String, + /// The index of the utility within the layer. + index: usize, +} + +/// Computes sort-related information about a CSS utility. If the utility is not recognized, +/// `None` is returned. +fn get_utility_info( + utility_config: &UtilitiesConfig, + utility_data: &ClassSegmentStructure, +) -> Option { + // Arbitrary CSS utilities always go in the "arbitrary" layer, at index 0. + // This layer is always at the end, and the order of the utilities in it is not + // determined at this point, so they all have the same index. + if utility_data.arbitrary { + return Some(UtilityInfo { + layer: "arbitrary".to_string(), + index: 0, + }); + } + + let utility_text = utility_data.text.as_str(); + let mut layer: &str = ""; + let mut match_index: usize = 0; + let mut last_size: usize = 0; + + // Iterate over each layer, looking for a match. + for layer_data in utility_config.iter() { + // Iterate over each target in the layer, looking for a match. + for (index, target) in layer_data.classes.iter().enumerate() { + match UtilityMatch::from(target, utility_text) { + UtilityMatch::Exact => { + // Exact matches can be returned immediately. + return Some(UtilityInfo { + layer: layer_data.name.clone(), + index, + }); + } + UtilityMatch::Partial => { + // Multiple partial matches can occur, so we need to keep looking to find + // the longest target that matches. For example, if the utility text is + // `gap-x-4`, and there are targets like `gap-` and `gap-x-`, we want to + // make sure that the `gap-x-` target is matched as it is more specific, + // regardless of the order in which the targets are defined. + let target_size = target.chars().count(); + if target_size > last_size { + layer = &layer_data.name; + match_index = index; + last_size = target_size; + } + } + _ => {} + } + } + } + if layer != "" { + return Some(UtilityInfo { + layer: layer.to_string(), + index: match_index, + }); + } + None +} + +#[cfg(test)] +mod get_utility_info_tests { + use super::*; + use crate::semantic_analyzers::nursery::use_sorted_classes::sort_config::UtilityLayer; + + #[test] + fn test_exact_match() { + let utility_config = vec![UtilityLayer { + name: "layer".to_string(), + classes: &["px-2$"], + }]; + let utility_data = ClassSegmentStructure { + text: "px-2".to_string(), + arbitrary: false, + }; + assert_eq!( + get_utility_info(&utility_config, &utility_data), + Some(UtilityInfo { + layer: "layer".to_string(), + index: 0, + }) + ); + let utility_data = ClassSegmentStructure { + text: "px-4".to_string(), + arbitrary: false, + }; + assert_eq!(get_utility_info(&utility_config, &utility_data), None); + } + + #[test] + fn test_partial_match() { + let utility_config = vec![UtilityLayer { + name: "layer".to_string(), + classes: &["px-"], + }]; + let utility_data = ClassSegmentStructure { + text: "px-2".to_string(), + arbitrary: false, + }; + assert_eq!( + get_utility_info(&utility_config, &utility_data), + Some(UtilityInfo { + layer: "layer".to_string(), + index: 0, + }) + ); + let utility_data = ClassSegmentStructure { + text: "not-px-2".to_string(), + arbitrary: false, + }; + assert_eq!(get_utility_info(&utility_config, &utility_data), None); + } + + #[test] + fn test_partial_match_longest() { + let utility_config = vec![UtilityLayer { + name: "layer".to_string(), + classes: &["border-", "border-t-"], + }]; + let utility_data = ClassSegmentStructure { + text: "border-t-2".to_string(), + arbitrary: false, + }; + assert_eq!( + get_utility_info(&utility_config, &utility_data), + Some(UtilityInfo { + layer: "layer".to_string(), + index: 1, + }) + ); + } + + #[test] + fn test_partial_match_longest_first() { + let utility_config = vec![UtilityLayer { + name: "layer".to_string(), + classes: &["border-t-", "border-"], + }]; + let utility_data = ClassSegmentStructure { + text: "border-t-2".to_string(), + arbitrary: false, + }; + assert_eq!( + get_utility_info(&utility_config, &utility_data), + Some(UtilityInfo { + layer: "layer".to_string(), + index: 0, + }) + ); + } + + #[test] + fn test_arbitrary_layer() { + let utility_config = vec![UtilityLayer { + name: "layer".to_string(), + classes: &["border-t-", "border-"], + }]; + let utility_data = ClassSegmentStructure { + text: "[arbitrary:css]".to_string(), + arbitrary: true, + }; + assert_eq!( + get_utility_info(&utility_config, &utility_data), + Some(UtilityInfo { + layer: "arbitrary".to_string(), + index: 0, + }) + ); + } +} + +// classes +// ------- + +/// Sort-related information about a CSS class. +#[derive(Debug, Eq, PartialEq)] +pub struct ClassInfo { + /// The full text of the class itself. + pub text: String, + /// The total variants weight that results from the combination of all the variants. + pub variant_weight: Option, // TODO: this will need to be Option + /// The layer the utility belongs to. + pub layer_index: usize, + /// The index of the utility within the layer. + pub utility_index: usize, +} + +/// Computes sort-related information about a CSS class. If the class is not recognized as a utility, +/// it is considered a custom class instead and `None` is returned. +pub fn get_class_info(class_name: &str, sort_config: &SortConfig) -> Option { + let utility_data = tokenize_class(class_name)?; + let utility_info = get_utility_info(&sort_config.utilities, &utility_data.utility); + if let Some(utility_info) = utility_info { + return Some(ClassInfo { + text: class_name.to_string(), + variant_weight: if utility_data.variants.is_empty() { + None + } else { + // TODO: return None if there is an unknown variant. + Some(0) // TODO: actually compute variant weight + }, + layer_index: *sort_config.layer_index_map.get(&utility_info.layer)?, + utility_index: utility_info.index, + }); + } + // If there is no utility info, the class is not recognized. + None +} + +#[cfg(test)] +mod get_class_info_tests { + use super::*; + use crate::semantic_analyzers::nursery::use_sorted_classes::sort_config::UtilityLayer; + + #[test] + fn test_get_class_info() { + let utilities_config = vec![ + UtilityLayer { + name: "layer0".to_string(), + classes: &["px-", "py-", "block$"], + }, + UtilityLayer { + name: "layer1".to_string(), + classes: &["mx-", "my-", "inline$"], + }, + ]; + let sort_config = SortConfig::new(utilities_config, vec![]); + assert_eq!( + get_class_info("px-2", &sort_config), + Some(ClassInfo { + text: "px-2".to_string(), + variant_weight: None, + layer_index: 0, + utility_index: 0, + }) + ); + assert_eq!( + get_class_info("py-2", &sort_config), + Some(ClassInfo { + text: "py-2".to_string(), + variant_weight: None, + layer_index: 0, + utility_index: 1, + }) + ); + assert_eq!( + get_class_info("block", &sort_config), + Some(ClassInfo { + text: "block".to_string(), + variant_weight: None, + layer_index: 0, + utility_index: 2, + }) + ); + assert_eq!( + get_class_info("mx-2", &sort_config), + Some(ClassInfo { + text: "mx-2".to_string(), + variant_weight: None, + layer_index: 1, + utility_index: 0, + }) + ); + assert_eq!( + get_class_info("my-2", &sort_config), + Some(ClassInfo { + text: "my-2".to_string(), + variant_weight: None, + layer_index: 1, + utility_index: 1, + }) + ); + assert_eq!( + get_class_info("inline", &sort_config), + Some(ClassInfo { + text: "inline".to_string(), + variant_weight: None, + layer_index: 1, + utility_index: 2, + }) + ); + assert_eq!( + get_class_info("[arbitrary:css]", &sort_config), + Some(ClassInfo { + text: "[arbitrary:css]".to_string(), + variant_weight: None, + layer_index: 2, + utility_index: 0, + }) + ); + assert_eq!(get_class_info("unknown", &sort_config), None); + } +} diff --git a/crates/biome_js_analyze/src/semantic_analyzers/nursery/use_sorted_classes/class_lexer.rs b/crates/biome_js_analyze/src/semantic_analyzers/nursery/use_sorted_classes/class_lexer.rs new file mode 100644 index 000000000000..b3ff66197a48 --- /dev/null +++ b/crates/biome_js_analyze/src/semantic_analyzers/nursery/use_sorted_classes/class_lexer.rs @@ -0,0 +1,393 @@ +//! CSS utility classes need to be lexed into segments, which represent the variants and the utility, +//! and whether they are arbitrary or not. Some examples: +//! - `px-2`: utility `px-2`. +//! - `hover:px-2`: variant `hover`, utility `px-2`. +//! - `sm:hover:px-2`: variant `sm`, variant `hover`, utility `px-2`. +//! - `hover:[mask:circle]`: variant `hover`, utility `[mask:circle]` (arbitrary). +//! - `[&:nth-child(3)]:px-2`: variant `[&:nth-child(3)]` (arbitrary), utility `px-2`. +//! The results of the lexer are then used to process classes into `ClassInfo` structs, which are, in +//! turn, used to sort the classes. + +/// Splits a string into segments based on a list of indexes. The characters at the indexes are not +/// included in the segments, as they are considered delimiters. +fn split_at_indexes<'a>(s: &'a str, indexes: &[usize]) -> Vec<&'a str> { + let mut segments = Vec::new(); + let mut start_offset = 0; + let mut start = 0; + + for &index in indexes { + if index > s.len() { + break; // Avoid panicking on out-of-bounds indexes + } + if index > start { + segments.push(&s[start + start_offset..index]); + } + start_offset = 1; + start = index; + } + + if start + start_offset < s.len() { + segments.push(&s[start + start_offset..]); + } + + segments +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_split_at_indexes() { + assert_eq!( + split_at_indexes("foo:bar:baz", &[3, 7]), + vec!["foo", "bar", "baz"] + ); + assert_eq!(split_at_indexes("foobar:baz", &[6]), vec!["foobar", "baz"]); + assert_eq!(split_at_indexes("foobarbaz", &[]), vec!["foobarbaz"]); + assert_eq!( + split_at_indexes("foo_bar_baz", &[3, 7]), + vec!["foo", "bar", "baz"] + ); + assert_eq!(split_at_indexes(":", &[0]), Vec::<&str>::new()); + assert_eq!(split_at_indexes(":::", &[0]), vec!["::"]); + assert_eq!(split_at_indexes(":::", &[1]), vec![":", ":"]); + } +} + +#[derive(Debug, Clone, PartialEq)] +enum Quote { + Single, + Double, + Backtick, +} + +impl Quote { + fn from_char(c: char) -> Option { + match c { + '\'' => Some(Quote::Single), + '"' => Some(Quote::Double), + '`' => Some(Quote::Backtick), + _ => None, + } + } +} + +#[derive(Debug)] +enum CharKind { + Other, + Quote(Quote), + Backslash, +} + +/// Information about the structure of a segment of a CSS class (variant or utility). +#[derive(Debug, Eq, PartialEq)] +pub struct ClassSegmentStructure { + pub arbitrary: bool, + pub text: String, +} + +/// Information about the structure of a CSS class. +#[derive(Debug, Eq, PartialEq)] +pub struct ClassStructure { + pub variants: Vec, + pub utility: ClassSegmentStructure, +} + +/// Processes a CSS class into a class structure, containing a list of variants and the +/// utility itself. +pub fn tokenize_class(class_name: &str) -> Option { + // TODO: add custom separator argument (currently hardcoded to `:`). + let mut arbitrary_block_depth = 0; + let mut at_arbitrary_block_start = false; + let mut quoted_arbitrary_block_type: Option = None; + let mut last_char = CharKind::Other; + let mut delimiter_indexes: Vec = Vec::new(); + + for (index, c) in class_name.char_indices() { + let mut next_last_char = CharKind::Other; + let mut is_start_of_arbitrary_block = false; + + match c { + '[' => { + if arbitrary_block_depth == 0 { + arbitrary_block_depth = 1; + at_arbitrary_block_start = true; + is_start_of_arbitrary_block = true; + } else if quoted_arbitrary_block_type.is_none() { + arbitrary_block_depth += 1; + } + } + '\'' | '"' | '`' => { + if at_arbitrary_block_start { + quoted_arbitrary_block_type = Quote::from_char(c); + } else if let CharKind::Backslash = last_char { + // Escaped, ignore. + } else { + let quote = Quote::from_char(c)?; + next_last_char = CharKind::Quote(quote); + } + } + '\\' => { + if let CharKind::Backslash = last_char { + // Consider escaped backslashes as other characters. + } else { + next_last_char = CharKind::Backslash; + } + } + ']' => { + if arbitrary_block_depth > 0 { + match "ed_arbitrary_block_type { + // If in quoted arbitrary block... + Some(quote_type) => { + // and the last character was a quote... + if let CharKind::Quote(last_quote) = &last_char { + // of the same type as the current quote... + if quote_type == last_quote { + // then we are no longer in an arbitrary block. + arbitrary_block_depth = 0; + quoted_arbitrary_block_type = None; + } + } + } + None => { + arbitrary_block_depth -= 1; + quoted_arbitrary_block_type = None; + } + } + } else { + return None; + } + } + ':' => { + if arbitrary_block_depth == 0 { + delimiter_indexes.push(index); + } + } + _ => {} + }; + if at_arbitrary_block_start && !is_start_of_arbitrary_block { + at_arbitrary_block_start = false; + }; + last_char = next_last_char; + } + let mut variants: Vec = split_at_indexes(class_name, &delimiter_indexes) + .iter() + .map(|&s| ClassSegmentStructure { + arbitrary: s.starts_with('['), + text: s.to_string(), + }) + .collect(); + let utility = variants.pop()?; + + Some(ClassStructure { variants, utility }) +} + +#[cfg(test)] +mod tests_tokenize_class { + use super::*; + + #[test] + fn test_tokenize_class() { + assert_eq!( + tokenize_class("px-2"), + Some(ClassStructure { + variants: Vec::new(), + utility: ClassSegmentStructure { + arbitrary: false, + text: "px-2".to_string(), + }, + }) + ); + assert_eq!( + tokenize_class("hover:px-2"), + Some(ClassStructure { + variants: vec![ClassSegmentStructure { + arbitrary: false, + text: "hover".to_string(), + }], + utility: ClassSegmentStructure { + arbitrary: false, + text: "px-2".to_string(), + }, + }) + ); + assert_eq!( + tokenize_class("sm:hover:px-2"), + Some(ClassStructure { + variants: vec![ + ClassSegmentStructure { + arbitrary: false, + text: "sm".to_string(), + }, + ClassSegmentStructure { + arbitrary: false, + text: "hover".to_string(), + }, + ], + utility: ClassSegmentStructure { + arbitrary: false, + text: "px-2".to_string(), + }, + }) + ); + assert_eq!( + tokenize_class("hover:[mask:circle]"), + Some(ClassStructure { + variants: vec![ClassSegmentStructure { + arbitrary: false, + text: "hover".to_string(), + }], + utility: ClassSegmentStructure { + arbitrary: true, + text: "[mask:circle]".to_string(), + }, + }) + ); + assert_eq!( + tokenize_class("[&:nth-child(3)]:px-2"), + Some(ClassStructure { + variants: vec![ClassSegmentStructure { + arbitrary: true, + text: "[&:nth-child(3)]".to_string(), + }], + utility: ClassSegmentStructure { + arbitrary: false, + text: "px-2".to_string(), + }, + }) + ); + assert_eq!( + tokenize_class("hover:[mask:circle]"), + Some(ClassStructure { + variants: vec![ClassSegmentStructure { + arbitrary: false, + text: "hover".to_string(), + },], + utility: ClassSegmentStructure { + arbitrary: true, + text: "[mask:circle]".to_string(), + }, + }) + ); + assert_eq!( + tokenize_class("[&:nth-child(3)]:[mask:circle]"), + Some(ClassStructure { + variants: vec![ClassSegmentStructure { + arbitrary: true, + text: "[&:nth-child(3)]".to_string(), + },], + utility: ClassSegmentStructure { + arbitrary: true, + text: "[mask:circle]".to_string(), + }, + }) + ); + assert_eq!( + tokenize_class("font-[Roboto]:[mask:circle]"), + Some(ClassStructure { + variants: vec![ClassSegmentStructure { + arbitrary: false, + text: "font-[Roboto]".to_string(), + },], + utility: ClassSegmentStructure { + arbitrary: true, + text: "[mask:circle]".to_string(), + }, + }) + ); + assert_eq!( + tokenize_class("font-['Roboto']:[mask:circle]"), + Some(ClassStructure { + variants: vec![ClassSegmentStructure { + arbitrary: false, + text: "font-['Roboto']".to_string(), + },], + utility: ClassSegmentStructure { + arbitrary: true, + text: "[mask:circle]".to_string(), + }, + }) + ); + assert_eq!( + tokenize_class("quotes-['Ro'b\"`oto']:block"), + Some(ClassStructure { + variants: vec![ClassSegmentStructure { + arbitrary: false, + text: "quotes-['Ro'b\"`oto']".to_string(), + },], + utility: ClassSegmentStructure { + arbitrary: false, + text: "block".to_string(), + }, + }) + ); + assert_eq!( + tokenize_class("quotes-[']']:block"), + Some(ClassStructure { + variants: vec![ClassSegmentStructure { + arbitrary: false, + text: "quotes-[']']".to_string(), + },], + utility: ClassSegmentStructure { + arbitrary: false, + text: "block".to_string(), + }, + }) + ); + assert_eq!( + tokenize_class("quotes-[\"]\"]"), + Some(ClassStructure { + variants: Vec::new(), + utility: ClassSegmentStructure { + arbitrary: false, + text: "quotes-[\"]\"]".to_string(), + }, + }) + ); + assert_eq!( + tokenize_class("quotes-[`]`]"), + Some(ClassStructure { + variants: Vec::new(), + utility: ClassSegmentStructure { + arbitrary: false, + text: "quotes-[`]`]".to_string(), + }, + }) + ); + assert_eq!(tokenize_class("no-quotes-[]]:block"), None); + assert_eq!( + tokenize_class("escaped-quotes-[']\\']:block"), + Some(ClassStructure { + variants: Vec::new(), + utility: ClassSegmentStructure { + arbitrary: false, + text: "escaped-quotes-[']\\']:block".to_string(), + }, + }) + ); + assert_eq!( + tokenize_class("double-escaped-quotes-[']\\\\']:block"), + Some(ClassStructure { + variants: vec![ClassSegmentStructure { + arbitrary: false, + text: "double-escaped-quotes-[']\\\\']".to_string(), + },], + utility: ClassSegmentStructure { + arbitrary: false, + text: "block".to_string(), + }, + }) + ); + assert_eq!( + tokenize_class("triple-escaped-quotes-[']\\\\\\']:block"), + Some(ClassStructure { + variants: Vec::new(), + utility: ClassSegmentStructure { + arbitrary: false, + text: "triple-escaped-quotes-[']\\\\\\']:block".to_string(), + }, + }) + ); + } +} diff --git a/crates/biome_js_analyze/src/semantic_analyzers/nursery/use_sorted_classes/options.rs b/crates/biome_js_analyze/src/semantic_analyzers/nursery/use_sorted_classes/options.rs new file mode 100644 index 000000000000..dfb0ff3adf0d --- /dev/null +++ b/crates/biome_js_analyze/src/semantic_analyzers/nursery/use_sorted_classes/options.rs @@ -0,0 +1,89 @@ +use biome_deserialize::{ + Deserializable, DeserializableValue, DeserializationDiagnostic, DeserializationVisitor, Text, + VisitableType, +}; +use biome_rowan::TextRange; +#[cfg(feature = "schemars")] +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Attributes that are always targets. +const CLASS_ATTRIBUTES: [&str; 2] = ["class", "className"]; + +#[derive(Deserialize, Serialize, Eq, PartialEq, Debug, Clone)] +#[cfg_attr(feature = "schemars", derive(JsonSchema))] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct UtilityClassSortingOptions { + /// Additional attributes that will be sorted. + #[serde(skip_serializing_if = "Option::is_none")] + pub attributes: Option>, + /// Names of the functions or tagged templates that will be sorted. + #[serde(skip_serializing_if = "Option::is_none")] + pub functions: Option>, +} + +impl Default for UtilityClassSortingOptions { + fn default() -> Self { + UtilityClassSortingOptions { + attributes: Some(CLASS_ATTRIBUTES.iter().map(|&s| s.to_string()).collect()), + functions: None, + } + } +} + +const ALLOWED_OPTIONS: &[&str] = &["attributes", "functions"]; + +impl Deserializable for UtilityClassSortingOptions { + fn deserialize( + value: &impl DeserializableValue, + name: &str, + diagnostics: &mut Vec, + ) -> Option { + value.deserialize(UtilityClassSortingOptionsVisitor, name, diagnostics) + } +} + +struct UtilityClassSortingOptionsVisitor; +impl DeserializationVisitor for UtilityClassSortingOptionsVisitor { + type Output = UtilityClassSortingOptions; + + const EXPECTED_TYPE: VisitableType = VisitableType::MAP; + + fn visit_map( + self, + members: impl Iterator>, + _range: TextRange, + _name: &str, + diagnostics: &mut Vec, + ) -> Option { + let mut result = UtilityClassSortingOptions::default(); + + for (key, value) in members.flatten() { + let Some(key_text) = Text::deserialize(&key, "", diagnostics) else { + continue; + }; + match key_text.text() { + "attributes" => { + if let Some(attributes_option) = + Deserializable::deserialize(&value, &key_text, diagnostics) + { + result + .attributes + .get_or_insert_with(Vec::new) + .extend::>(attributes_option); + } + } + "functions" => { + result.functions = Deserializable::deserialize(&value, &key_text, diagnostics) + } + unknown_key => diagnostics.push(DeserializationDiagnostic::new_unknown_key( + unknown_key, + key.range(), + ALLOWED_OPTIONS, + )), + } + } + + Some(result) + } +} diff --git a/crates/biome_js_analyze/src/semantic_analyzers/nursery/use_sorted_classes/presets.rs b/crates/biome_js_analyze/src/semantic_analyzers/nursery/use_sorted_classes/presets.rs new file mode 100644 index 000000000000..6693a0a72e93 --- /dev/null +++ b/crates/biome_js_analyze/src/semantic_analyzers/nursery/use_sorted_classes/presets.rs @@ -0,0 +1,614 @@ +//! Presets contain pre-defined sort configurations, notably from Tailwind CSS. They are a +//! starting point that can be extended (e.g. by adding custom utilities or variants). + +use super::sort_config::{UtilitiesConfig, UtilityLayer}; + +#[derive(Default)] +pub enum UseSortedClassesPreset { + #[allow(unused)] + None, + #[default] + TailwindCSS, +} + +// TAILWIND-COMPONENTS-LAYER-CLASSES-START +const UTILITIES_COMPONENTS_CLASSES: [&str; 1] = [ + // TODO: auto-generated message + "container$", +]; +// TAILWIND-COMPONENTS-LAYER-CLASSES-END + +// TAILWIND-UTILITIES-LAYER-CLASSES-START +const UTILITIES_LAYER_CLASSES: [&str; 567] = [ + // TODO: auto-generated message + "sr-only$", + "not-sr-only$", + "pointer-events-none$", + "pointer-events-auto$", + "visible$", + "invisible$", + "collapse$", + "static$", + "fixed$", + "absolute$", + "relative$", + "sticky$", + "inset-", + "inset-x-", + "inset-y-", + "start-", + "end-", + "top-", + "right-", + "bottom-", + "left-", + "isolate$", + "isolation-auto$", + "z-", + "order-", + "col-", + "col-start-", + "col-end-", + "row-", + "row-start-", + "row-end-", + "float-start$", + "float-end$", + "float-right$", + "float-left$", + "float-none$", + "clear-start$", + "clear-end$", + "clear-left$", + "clear-right$", + "clear-both$", + "clear-none$", + "m-", + "mx-", + "my-", + "ms-", + "me-", + "mt-", + "mr-", + "mb-", + "ml-", + "box-border$", + "box-content$", + "line-clamp-", + "line-clamp-none$", + "block$", + "inline-block$", + "inline$", + "flex$", + "inline-flex$", + "table$", + "inline-table$", + "table-caption$", + "table-cell$", + "table-column$", + "table-column-group$", + "table-footer-group$", + "table-header-group$", + "table-row-group$", + "table-row$", + "flow-root$", + "grid$", + "inline-grid$", + "contents$", + "list-item$", + "hidden$", + "aspect-", + "size-", + "h-", + "max-h-", + "min-h-", + "w-", + "min-w-", + "max-w-", + "flex-shrink$", + "flex-shrink-", + "shrink$", + "shrink-", + "flex-grow$", + "flex-grow-", + "grow$", + "grow-", + "basis-", + "table-auto$", + "table-fixed$", + "caption-top$", + "caption-bottom$", + "border-collapse$", + "border-separate$", + "border-spacing-", + "border-spacing-x-", + "border-spacing-y-", + "origin-", + "translate-x-", + "translate-y-", + "rotate-", + "skew-x-", + "skew-y-", + "scale-", + "scale-x-", + "scale-y-", + "transform$", + "transform-cpu$", + "transform-gpu$", + "transform-none$", + "animate-", + "cursor-", + "touch-auto$", + "touch-none$", + "touch-pan-x$", + "touch-pan-left$", + "touch-pan-right$", + "touch-pan-y$", + "touch-pan-up$", + "touch-pan-down$", + "touch-pinch-zoom$", + "touch-manipulation$", + "select-none$", + "select-text$", + "select-all$", + "select-auto$", + "resize-none$", + "resize-y$", + "resize-x$", + "resize$", + "snap-none$", + "snap-x$", + "snap-y$", + "snap-both$", + "snap-mandatory$", + "snap-proximity$", + "snap-start$", + "snap-end$", + "snap-center$", + "snap-align-none$", + "snap-normal$", + "snap-always$", + "scroll-m-", + "scroll-mx-", + "scroll-my-", + "scroll-ms-", + "scroll-me-", + "scroll-mt-", + "scroll-mr-", + "scroll-mb-", + "scroll-ml-", + "scroll-p-", + "scroll-px-", + "scroll-py-", + "scroll-ps-", + "scroll-pe-", + "scroll-pt-", + "scroll-pr-", + "scroll-pb-", + "scroll-pl-", + "list-inside$", + "list-outside$", + "list-", + "list-image-", + "appearance-none$", + "appearance-auto$", + "columns-", + "break-before-auto$", + "break-before-avoid$", + "break-before-all$", + "break-before-avoid-page$", + "break-before-page$", + "break-before-left$", + "break-before-right$", + "break-before-column$", + "break-inside-auto$", + "break-inside-avoid$", + "break-inside-avoid-page$", + "break-inside-avoid-column$", + "break-after-auto$", + "break-after-avoid$", + "break-after-all$", + "break-after-avoid-page$", + "break-after-page$", + "break-after-left$", + "break-after-right$", + "break-after-column$", + "auto-cols-", + "grid-flow-row$", + "grid-flow-col$", + "grid-flow-dense$", + "grid-flow-row-dense$", + "grid-flow-col-dense$", + "auto-rows-", + "grid-cols-", + "grid-rows-", + "flex-row$", + "flex-row-reverse$", + "flex-col$", + "flex-col-reverse$", + "flex-wrap$", + "flex-wrap-reverse$", + "flex-nowrap$", + "place-content-center$", + "place-content-start$", + "place-content-end$", + "place-content-between$", + "place-content-around$", + "place-content-evenly$", + "place-content-baseline$", + "place-content-stretch$", + "place-items-start$", + "place-items-end$", + "place-items-center$", + "place-items-baseline$", + "place-items-stretch$", + "content-normal$", + "content-center$", + "content-start$", + "content-end$", + "content-between$", + "content-around$", + "content-evenly$", + "content-baseline$", + "content-stretch$", + "items-start$", + "items-end$", + "items-center$", + "items-baseline$", + "items-stretch$", + "justify-normal$", + "justify-start$", + "justify-end$", + "justify-center$", + "justify-between$", + "justify-around$", + "justify-evenly$", + "justify-stretch$", + "justify-items-start$", + "justify-items-end$", + "justify-items-center$", + "justify-items-stretch$", + "gap-", + "gap-x-", + "gap-y-", + "space-x-", + "space-y-", + "space-y-reverse$", + "space-x-reverse$", + "divide-x$", + "divide-x-", + "divide-y$", + "divide-y-", + "divide-y-reverse$", + "divide-x-reverse$", + "divide-solid$", + "divide-dashed$", + "divide-dotted$", + "divide-double$", + "divide-none$", + "divide-", + "divide-opacity-", + "place-self-auto$", + "place-self-start$", + "place-self-end$", + "place-self-center$", + "place-self-stretch$", + "self-auto$", + "self-start$", + "self-end$", + "self-center$", + "self-stretch$", + "self-baseline$", + "justify-self-auto$", + "justify-self-start$", + "justify-self-end$", + "justify-self-center$", + "justify-self-stretch$", + "overflow-auto$", + "overflow-hidden$", + "overflow-clip$", + "overflow-visible$", + "overflow-scroll$", + "overflow-x-auto$", + "overflow-y-auto$", + "overflow-x-hidden$", + "overflow-y-hidden$", + "overflow-x-clip$", + "overflow-y-clip$", + "overflow-x-visible$", + "overflow-y-visible$", + "overflow-x-scroll$", + "overflow-y-scroll$", + "overscroll-auto$", + "overscroll-contain$", + "overscroll-none$", + "overscroll-y-auto$", + "overscroll-y-contain$", + "overscroll-y-none$", + "overscroll-x-auto$", + "overscroll-x-contain$", + "overscroll-x-none$", + "scroll-auto$", + "scroll-smooth$", + "truncate$", + "overflow-ellipsis$", + "text-ellipsis$", + "text-clip$", + "hyphens-none$", + "hyphens-manual$", + "hyphens-auto$", + "whitespace-normal$", + "whitespace-nowrap$", + "whitespace-pre$", + "whitespace-pre-line$", + "whitespace-pre-wrap$", + "whitespace-break-spaces$", + "text-wrap$", + "text-nowrap$", + "text-balance$", + "text-pretty$", + "break-normal$", + "break-words$", + "break-all$", + "break-keep$", + "rounded$", + "rounded-", + "rounded-s$", + "rounded-s-", + "rounded-e$", + "rounded-e-", + "rounded-t$", + "rounded-t-", + "rounded-r$", + "rounded-r-", + "rounded-b$", + "rounded-b-", + "rounded-l$", + "rounded-l-", + "rounded-ss$", + "rounded-ss-", + "rounded-se$", + "rounded-se-", + "rounded-ee$", + "rounded-ee-", + "rounded-es$", + "rounded-es-", + "rounded-tl$", + "rounded-tl-", + "rounded-tr$", + "rounded-tr-", + "rounded-br$", + "rounded-br-", + "rounded-bl$", + "rounded-bl-", + "border$", + "border-", + "border-x$", + "border-x-", + "border-y$", + "border-y-", + "border-s$", + "border-s-", + "border-e$", + "border-e-", + "border-t$", + "border-t-", + "border-r$", + "border-r-", + "border-b$", + "border-b-", + "border-l$", + "border-l-", + "border-solid$", + "border-dashed$", + "border-dotted$", + "border-double$", + "border-hidden$", + "border-none$", + "border-opacity-", + "bg-", + "bg-opacity-", + "from-", + "via-", + "to-", + "decoration-slice$", + "decoration-clone$", + "box-decoration-slice$", + "box-decoration-clone$", + "bg-fixed$", + "bg-local$", + "bg-scroll$", + "bg-clip-border$", + "bg-clip-padding$", + "bg-clip-content$", + "bg-clip-text$", + "bg-repeat$", + "bg-no-repeat$", + "bg-repeat-x$", + "bg-repeat-y$", + "bg-repeat-round$", + "bg-repeat-space$", + "bg-origin-border$", + "bg-origin-padding$", + "bg-origin-content$", + "fill-", + "stroke-", + "object-contain$", + "object-cover$", + "object-fill$", + "object-none$", + "object-scale-down$", + "object-", + "p-", + "px-", + "py-", + "ps-", + "pe-", + "pt-", + "pr-", + "pb-", + "pl-", + "text-left$", + "text-center$", + "text-right$", + "text-justify$", + "text-start$", + "text-end$", + "indent-", + "align-baseline$", + "align-top$", + "align-middle$", + "align-bottom$", + "align-text-top$", + "align-text-bottom$", + "align-sub$", + "align-super$", + "align-", + "font-", + "text-", + "uppercase$", + "lowercase$", + "capitalize$", + "normal-case$", + "italic$", + "not-italic$", + "normal-nums$", + "ordinal$", + "slashed-zero$", + "lining-nums$", + "oldstyle-nums$", + "proportional-nums$", + "tabular-nums$", + "diagonal-fractions$", + "stacked-fractions$", + "leading-", + "tracking-", + "text-opacity-", + "underline$", + "overline$", + "line-through$", + "no-underline$", + "decoration-", + "decoration-solid$", + "decoration-double$", + "decoration-dotted$", + "decoration-dashed$", + "decoration-wavy$", + "underline-offset-", + "antialiased$", + "subpixel-antialiased$", + "placeholder-", + "placeholder-opacity-", + "caret-", + "accent-", + "opacity-", + "bg-blend-normal$", + "bg-blend-multiply$", + "bg-blend-screen$", + "bg-blend-overlay$", + "bg-blend-darken$", + "bg-blend-lighten$", + "bg-blend-color-dodge$", + "bg-blend-color-burn$", + "bg-blend-hard-light$", + "bg-blend-soft-light$", + "bg-blend-difference$", + "bg-blend-exclusion$", + "bg-blend-hue$", + "bg-blend-saturation$", + "bg-blend-color$", + "bg-blend-luminosity$", + "mix-blend-normal$", + "mix-blend-multiply$", + "mix-blend-screen$", + "mix-blend-overlay$", + "mix-blend-darken$", + "mix-blend-lighten$", + "mix-blend-color-dodge$", + "mix-blend-color-burn$", + "mix-blend-hard-light$", + "mix-blend-soft-light$", + "mix-blend-difference$", + "mix-blend-exclusion$", + "mix-blend-hue$", + "mix-blend-saturation$", + "mix-blend-color$", + "mix-blend-luminosity$", + "mix-blend-plus-lighter$", + "shadow$", + "shadow-", + "outline-none$", + "outline$", + "outline-dashed$", + "outline-dotted$", + "outline-double$", + "outline-offset-", + "ring$", + "ring-", + "ring-inset$", + "ring-opacity-", + "ring-offset-", + "blur$", + "blur-", + "brightness-", + "contrast-", + "drop-shadow$", + "drop-shadow-", + "grayscale$", + "grayscale-", + "hue-rotate-", + "invert$", + "invert-", + "saturate-", + "sepia$", + "sepia-", + "filter$", + "filter-none$", + "backdrop-blur$", + "backdrop-blur-", + "backdrop-brightness-", + "backdrop-contrast-", + "backdrop-grayscale$", + "backdrop-grayscale-", + "backdrop-hue-rotate-", + "backdrop-invert$", + "backdrop-invert-", + "backdrop-opacity-", + "backdrop-saturate-", + "backdrop-sepia$", + "backdrop-sepia-", + "backdrop-filter$", + "backdrop-filter-none$", + "transition$", + "transition-", + "delay-", + "duration-", + "ease-", + "will-change-", + "content-", + "forced-color-adjust-auto$", + "forced-color-adjust-none$", +]; +// TAILWIND-UTILITIES-LAYER-CLASSES-END + +pub fn get_utilities_preset(preset: &UseSortedClassesPreset) -> UtilitiesConfig { + match preset { + UseSortedClassesPreset::None => { + vec![] + } + UseSortedClassesPreset::TailwindCSS => { + // TAILWIND-UTILITIES-PRESET-START + vec![ + UtilityLayer { + name: String::from("components"), + classes: UTILITIES_COMPONENTS_CLASSES.as_slice(), + }, + UtilityLayer { + name: String::from("utilities"), + classes: UTILITIES_LAYER_CLASSES.as_slice(), + }, + ] + // TAILWIND-UTILITIES-PRESET-END + } + } +} diff --git a/crates/biome_js_analyze/src/semantic_analyzers/nursery/use_sorted_classes/sort.rs b/crates/biome_js_analyze/src/semantic_analyzers/nursery/use_sorted_classes/sort.rs new file mode 100644 index 000000000000..9efa87347ea6 --- /dev/null +++ b/crates/biome_js_analyze/src/semantic_analyzers/nursery/use_sorted_classes/sort.rs @@ -0,0 +1,123 @@ +use std::cmp::Ordering; + +use biome_rowan::TokenText; + +use super::{ + class_info::{get_class_info, ClassInfo}, + sort_config::SortConfig, +}; + +impl ClassInfo { + /// Compare based on the existence of variants. Classes with variants go last. + /// Returns `None` if both or none of the classes has variants. + fn cmp_has_variants(&self, other: &ClassInfo) -> Option { + if self.variant_weight.is_some() && other.variant_weight.is_some() { + return None; + } + if self.variant_weight.is_some() { + return Some(Ordering::Greater); + } + if other.variant_weight.is_some() { + return Some(Ordering::Less); + } + None + } + + /// Compare based on layer indexes. Classes with lower indexes go first. + /// Returns `None` if the indexes are equal. + fn cmp_layers(&self, other: &ClassInfo) -> Option { + let result = self.layer_index.cmp(&other.layer_index); + if result != Ordering::Equal { + return Some(result); + } + None + } + + /// Compare based on variants weight. Classes with higher weight go first. + /// Returns `None` if they have the same weight. + fn cmp_variants_weight(&self, _other: &ClassInfo) -> Option { + // TODO: implement variant weight comparison. + None + } + + /// Compare based on utility index. Classes with lower indexes go first. + /// Returns `None` if the indexes are equal. + fn cmp_utilities(&self, other: &ClassInfo) -> Option { + let result = self.utility_index.cmp(&other.utility_index); + if result != Ordering::Equal { + return Some(result); + } + None + } +} + +// TODO: implement through Ord/PartialOrd trait. + +// See: https://github.com/tailwindlabs/tailwindcss/blob/970f2ca704dda95cf328addfe67b81d6679c8755/src/lib/offsets.js#L206 +// This comparison function follows a very similar logic to the one in Tailwind CSS, with some +// simplifications and necessary differences. +fn compare_classes(a: &ClassInfo, b: &ClassInfo) -> Ordering { + // Classes with variants go last. + if let Some(has_variants_order) = a.cmp_has_variants(b) { + return has_variants_order; + } + + // Compare utility layers. + if let Some(layers_order) = a.cmp_layers(b) { + return layers_order; + } + + // TODO: sort screens at this point. + + // Compare variant weights. + if let Some(variants_weight_order) = a.cmp_variants_weight(b) { + return variants_weight_order; + } + + // Compare utility indexes. + if let Some(utilities_order) = a.cmp_utilities(b) { + return utilities_order; + } + + Ordering::Equal +} + +/// Sort the given class string according to the given sort config. +pub fn sort_class_name(class_name: &TokenText, sort_config: &SortConfig) -> String { + // Obtain classes by splitting the class string by whitespace. + let classes = class_name.split_whitespace().collect::>(); + + // Separate custom classes from recognized classes, and compute the recognized classes' info. + // Custom classes always go first, in the order that they appear in. + let mut sorted_classes: Vec<&str> = Vec::new(); + let mut classes_info: Vec = Vec::new(); + classes + .iter() + .for_each(|&class| match get_class_info(class, sort_config) { + Some(class_info) => { + classes_info.push(class_info); + } + None => { + sorted_classes.push(class); + } + }); + + // TODO: make this the last step of compare instead? + + // Pre-sort the recognized classes lexico-graphically. + classes_info.sort_unstable_by(|a, b| a.text.cmp(&b.text)); + + // Sort recognized classes using the compare function. Needs to be a stable sort to + // preserve the lexico-graphical order as a fallback. + classes_info.sort_by(compare_classes); + + // Add the sorted classes to the result. + sorted_classes.extend( + classes_info + .iter() + .map(|class_info| class_info.text.as_str()), + ); + + // Join the classes back into a string. + sorted_classes.join(" ") +} diff --git a/crates/biome_js_analyze/src/semantic_analyzers/nursery/use_sorted_classes/sort_config.rs b/crates/biome_js_analyze/src/semantic_analyzers/nursery/use_sorted_classes/sort_config.rs new file mode 100644 index 000000000000..c333818a9b6a --- /dev/null +++ b/crates/biome_js_analyze/src/semantic_analyzers/nursery/use_sorted_classes/sort_config.rs @@ -0,0 +1,47 @@ +//! The following structures define the config required to compute sort-related information about +//! CSS classes (`ClassInfo`) that is later used to compare and sort them. A sort config includes: +//! - The list of layers, in order. +//! - The list of utilities, in order, for each layer. +//! - The list of variants, in order of importance (which is used to compute the variants weight). +//! - Other options, such as prefix and separator. + +use std::collections::HashMap; + +/// A utility layer, containing its name and an ordered list of classes. +pub struct UtilityLayer { + pub name: String, + pub classes: &'static [&'static str], +} + +/// The utilities config, contains an ordered list of utility layers. +pub type UtilitiesConfig = Vec; + +/// The variants config, contains an ordered list of variants. +pub type VariantsConfig = Vec; + +/// The sort config, containing the utility config and the variant config. +pub struct SortConfig { + pub utilities: UtilitiesConfig, + pub variants: VariantsConfig, + pub layer_index_map: HashMap, +} + +impl SortConfig { + /// Creates a new sort config. + pub fn new(utilities_config: UtilitiesConfig, variants: VariantsConfig) -> Self { + // Compute the layer index map. + let mut layer_index_map: HashMap = HashMap::new(); + let mut index = 0; + for layer in utilities_config.iter() { + layer_index_map.insert(layer.name.clone(), index); + index += 1; + } + layer_index_map.insert("arbitrary".to_string(), index); + + Self { + utilities: utilities_config, + variants, + layer_index_map, + } + } +} diff --git a/crates/biome_js_analyze/tests/specs/correctness/useHookAtTopLevel/deprecatedConfig.options.json b/crates/biome_js_analyze/tests/specs/correctness/useHookAtTopLevel/deprecatedConfig.options.json index 68bf415d8699..778daef0f0eb 100644 --- a/crates/biome_js_analyze/tests/specs/correctness/useHookAtTopLevel/deprecatedConfig.options.json +++ b/crates/biome_js_analyze/tests/specs/correctness/useHookAtTopLevel/deprecatedConfig.options.json @@ -1,14 +1,14 @@ { "$schema": "../../../../../../packages/@biomejs/biome/configuration_schema.json", "linter": { - "rules": { - "correctness": { - "useHookAtTopLevel": { - "level": "error", - "options": { - "hooks": [ + "rules": { + "correctness": { + "useHookAtTopLevel": { + "level": "error", + "options": { + "hooks": [ { - "name": "useCustomHook" + "name": "useCustomHook" } ] } @@ -16,4 +16,4 @@ } } } -} \ No newline at end of file +} diff --git a/crates/biome_js_analyze/tests/specs/nursery/useSortedClasses/codeOptionsSorted.jsx b/crates/biome_js_analyze/tests/specs/nursery/useSortedClasses/codeOptionsSorted.jsx new file mode 100644 index 000000000000..57f81b39a222 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useSortedClasses/codeOptionsSorted.jsx @@ -0,0 +1,46 @@ +<> + {/* attributes */} +
+
+
+
+ {/* utility sorting */} +
+
+
+
+
+
+
+
+
+
+; + +// functions +clsx("foo bar p-4 px-2"); +// TODO: tagged template literals are not supported yet +tw`foo bar p-4 px-2`; +tw.div`foo bar p-4 px-2`; +notClassFunction("px-2 foo p-4 bar"); +notTemplateFunction`px-2 foo p-4 bar`; +notTemplateFunction.div`px-2 foo p-4 bar`; + +// nested values +
; +
; +
; +clsx(["foo bar p-4 px-2"]); +clsx({ + "foo bar p-4 px-2": [ + "foo bar p-4 px-2", + { "foo bar p-4 px-2": "foo bar p-4 px-2", custom: ["foo bar p-4 px-2"] }, + ], +}); diff --git a/crates/biome_js_analyze/tests/specs/nursery/useSortedClasses/codeOptionsSorted.jsx.snap b/crates/biome_js_analyze/tests/specs/nursery/useSortedClasses/codeOptionsSorted.jsx.snap new file mode 100644 index 000000000000..cb5d3ca8ee98 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useSortedClasses/codeOptionsSorted.jsx.snap @@ -0,0 +1,56 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +expression: codeOptionsSorted.jsx +--- +# Input +```jsx +<> + {/* attributes */} +
+
+
+
+ {/* utility sorting */} +
+
+
+
+
+
+
+
+
+
+; + +// functions +clsx("foo bar p-4 px-2"); +// TODO: tagged template literals are not supported yet +tw`foo bar p-4 px-2`; +tw.div`foo bar p-4 px-2`; +notClassFunction("px-2 foo p-4 bar"); +notTemplateFunction`px-2 foo p-4 bar`; +notTemplateFunction.div`px-2 foo p-4 bar`; + +// nested values +
; +
; +
; +clsx(["foo bar p-4 px-2"]); +clsx({ + "foo bar p-4 px-2": [ + "foo bar p-4 px-2", + { "foo bar p-4 px-2": "foo bar p-4 px-2", custom: ["foo bar p-4 px-2"] }, + ], +}); + +``` + + diff --git a/crates/biome_js_analyze/tests/specs/nursery/useSortedClasses/codeOptionsSorted.options.json b/crates/biome_js_analyze/tests/specs/nursery/useSortedClasses/codeOptionsSorted.options.json new file mode 100644 index 000000000000..b9e8a6b22d0c --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useSortedClasses/codeOptionsSorted.options.json @@ -0,0 +1,16 @@ +{ + "$schema": "../../../../../../packages/@biomejs/biome/configuration_schema.json", + "linter": { + "rules": { + "nursery": { + "useSortedClasses": { + "level": "error", + "options": { + "attributes": ["customClassAttribute"], + "functions": ["clsx", "tw"] + } + } + } + } + } +} diff --git a/crates/biome_js_analyze/tests/specs/nursery/useSortedClasses/codeOptionsUnsorted.jsx b/crates/biome_js_analyze/tests/specs/nursery/useSortedClasses/codeOptionsUnsorted.jsx new file mode 100644 index 000000000000..88ba1bfdc798 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useSortedClasses/codeOptionsUnsorted.jsx @@ -0,0 +1,55 @@ +<> + {/* attributes */} + {/* SHOULD emit diagnostics (class/className attributes supported by default) */} +
+
+ {/* SHOULD emit diagnostics (customClassAttribute attribute specified in options) */} +
+ {/* SHOULD NOT emit diagnostics (notClassAttribute attribute NOT specified in options) */} +
+ {/* utility sorting */} + {/* SHOULD emit diagnostics (class attribute supported by default) */} +
+
+
+
+
+
+
+
+
+
+; + +// functions +/* SHOULD emit diagnostics (functions specified in options) */ +clsx("px-2 foo p-4 bar"); +// TODO: tagged template literals are not supported yet +tw`px-2 foo p-4 bar`; +tw.div`px-2 foo p-4 bar`; +notClassFunction("px-2 foo p-4 bar"); +notTemplateFunction`px-2 foo p-4 bar`; +notTemplateFunction.div`px-2 foo p-4 bar`; + +// nested values +/* SHOULD emit diagnostics (class attribute supported by default) */ +
; +
; +
; +/* SHOULD emit diagnostics (clsx function specified in options) */ +clsx(["px-2 foo p-4 bar"]); +clsx({ + "px-2 foo p-4 bar": [ + "px-2 foo p-4 bar", + { "px-2 foo p-4 bar": "px-2 foo p-4 bar", custom: ["px-2 foo p-4 bar"] }, + ], +}); diff --git a/crates/biome_js_analyze/tests/specs/nursery/useSortedClasses/codeOptionsUnsorted.jsx.snap b/crates/biome_js_analyze/tests/specs/nursery/useSortedClasses/codeOptionsUnsorted.jsx.snap new file mode 100644 index 000000000000..6d68e3f59076 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useSortedClasses/codeOptionsUnsorted.jsx.snap @@ -0,0 +1,618 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +expression: codeOptionsUnsorted.jsx +--- +# Input +```jsx +<> + {/* attributes */} + {/* SHOULD emit diagnostics (class/className attributes supported by default) */} +
+
+ {/* SHOULD emit diagnostics (customClassAttribute attribute specified in options) */} +
+ {/* SHOULD NOT emit diagnostics (notClassAttribute attribute NOT specified in options) */} +
+ {/* utility sorting */} + {/* SHOULD emit diagnostics (class attribute supported by default) */} +
+
+
+
+
+
+
+
+
+
+; + +// functions +/* SHOULD emit diagnostics (functions specified in options) */ +clsx("px-2 foo p-4 bar"); +// TODO: tagged template literals are not supported yet +tw`px-2 foo p-4 bar`; +tw.div`px-2 foo p-4 bar`; +notClassFunction("px-2 foo p-4 bar"); +notTemplateFunction`px-2 foo p-4 bar`; +notTemplateFunction.div`px-2 foo p-4 bar`; + +// nested values +/* SHOULD emit diagnostics (class attribute supported by default) */ +
; +
; +
; +/* SHOULD emit diagnostics (clsx function specified in options) */ +clsx(["px-2 foo p-4 bar"]); +clsx({ + "px-2 foo p-4 bar": [ + "px-2 foo p-4 bar", + { "px-2 foo p-4 bar": "px-2 foo p-4 bar", custom: ["px-2 foo p-4 bar"] }, + ], +}); + +``` + +# Diagnostics +``` +codeOptionsUnsorted.jsx:4:13 lint/nursery/useSortedClasses FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! These CSS classes should be sorted. + + 2 │ {/* attributes */} + 3 │ {/* SHOULD emit diagnostics (class/className attributes supported by default) */} + > 4 │
+ │ ^^^^^^^^^^^^^^^^^^ + 5 │
+ 6 │ {/* SHOULD emit diagnostics (customClassAttribute attribute specified in options) */} + + i Unsafe fix: Sort the classes. + + 2 2 │ {/* attributes */} + 3 3 │ {/* SHOULD emit diagnostics (class/className attributes supported by default) */} + 4 │ - → + 4 │ + → + 5 5 │
+ 6 6 │ {/* SHOULD emit diagnostics (customClassAttribute attribute specified in options) */} + + +``` + +``` +codeOptionsUnsorted.jsx:5:17 lint/nursery/useSortedClasses FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! These CSS classes should be sorted. + + 3 │ {/* SHOULD emit diagnostics (class/className attributes supported by default) */} + 4 │
+ > 5 │
+ │ ^^^^^^^^^^^^^^^^^^ + 6 │ {/* SHOULD emit diagnostics (customClassAttribute attribute specified in options) */} + 7 │
+ + i Unsafe fix: Sort the classes. + + 3 3 │ {/* SHOULD emit diagnostics (class/className attributes supported by default) */} + 4 4 │
+ 5 │ - → + 5 │ + → + 6 6 │ {/* SHOULD emit diagnostics (customClassAttribute attribute specified in options) */} + 7 7 │
+ + +``` + +``` +codeOptionsUnsorted.jsx:7:28 lint/nursery/useSortedClasses FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! These CSS classes should be sorted. + + 5 │
+ 6 │ {/* SHOULD emit diagnostics (customClassAttribute attribute specified in options) */} + > 7 │
+ │ ^^^^^^^^^^^^^^^^^^ + 8 │ {/* SHOULD NOT emit diagnostics (notClassAttribute attribute NOT specified in options) */} + 9 │
+ + i Unsafe fix: Sort the classes. + + 5 5 │
+ 6 6 │ {/* SHOULD emit diagnostics (customClassAttribute attribute specified in options) */} + 7 │ - → + 7 │ + → + 8 8 │ {/* SHOULD NOT emit diagnostics (notClassAttribute attribute NOT specified in options) */} + 9 9 │
+ + +``` + +``` +codeOptionsUnsorted.jsx:12:13 lint/nursery/useSortedClasses FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! These CSS classes should be sorted. + + 10 │ {/* utility sorting */} + 11 │ {/* SHOULD emit diagnostics (class attribute supported by default) */} + > 12 │
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 13 │
+ 14 │
+ + i Unsafe fix: Sort the classes. + + 10 10 │ {/* utility sorting */} + 11 11 │ {/* SHOULD emit diagnostics (class attribute supported by default) */} + 12 │ - → + 12 │ + → + 13 13 │
+ 14 14 │
+ + +``` + +``` +codeOptionsUnsorted.jsx:13:13 lint/nursery/useSortedClasses FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! These CSS classes should be sorted. + + 11 │ {/* SHOULD emit diagnostics (class attribute supported by default) */} + 12 │
+ > 13 │
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 14 │
+ 15 │
+ + i Unsafe fix: Sort the classes. + + 11 11 │ {/* SHOULD emit diagnostics (class attribute supported by default) */} + 12 12 │
+ 13 │ - → + 13 │ + → + 14 14 │
+ 15 15 │
+ + +``` + +``` +codeOptionsUnsorted.jsx:14:13 lint/nursery/useSortedClasses FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! These CSS classes should be sorted. + + 12 │
+ 13 │
+ > 14 │
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 15 │
+ 16 │
+ + i Unsafe fix: Sort the classes. + + 12 12 │
+ 13 13 │
+ 14 │ - → + 14 │ + → + 15 15 │
+ 16 16 │
+ + +``` + +``` +codeOptionsUnsorted.jsx:15:13 lint/nursery/useSortedClasses FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! These CSS classes should be sorted. + + 13 │
+ 14 │
+ > 15 │
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 16 │
+ 17 │
+ + i Unsafe fix: Sort the classes. + + 13 13 │
+ 14 14 │
+ 15 │ - → + 15 │ + → + 16 16 │
+ 17 17 │
+ + +``` + +``` +codeOptionsUnsorted.jsx:16:13 lint/nursery/useSortedClasses FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! These CSS classes should be sorted. + + 14 │
+ 15 │
+ > 16 │
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 17 │
+ 18 │
+ + i Unsafe fix: Sort the classes. + + 14 14 │
+ 15 15 │
+ 16 │ - → + 16 │ + → + 17 17 │
+ 18 18 │
+ + +``` + +``` +codeOptionsUnsorted.jsx:17:13 lint/nursery/useSortedClasses FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! These CSS classes should be sorted. + + 15 │
+ 16 │
+ > 17 │
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 18 │
+ 19 │
+ + i Unsafe fix: Sort the classes. + + 15 15 │
+ 16 16 │
+ 17 │ - → + 17 │ + → + 18 18 │
+ 19 19 │
+ + +``` + +``` +codeOptionsUnsorted.jsx:18:13 lint/nursery/useSortedClasses FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! These CSS classes should be sorted. + + 16 │
+ 17 │
+ > 18 │
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 19 │
+ 20 │
+ + i Unsafe fix: Sort the classes. + + 16 16 │
+ 17 17 │
+ 18 │ - → + 18 │ + → + 19 19 │
+ 20 20 │
+ + +``` + +``` +codeOptionsUnsorted.jsx:19:13 lint/nursery/useSortedClasses FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! These CSS classes should be sorted. + + 17 │
+ 18 │
+ > 19 │
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 20 │
+ 21 │
+ + i Unsafe fix: Sort the classes. + + 17 17 │
+ 18 18 │
+ 19 │ - → + 19 │ + → + 20 20 │
+ 21 21 │
+ + +``` + +``` +codeOptionsUnsorted.jsx:20:13 lint/nursery/useSortedClasses FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! These CSS classes should be sorted. + + 18 │
+ 19 │
+ > 20 │
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 21 │
+ 22 │ ; + + i Unsafe fix: Sort the classes. + + 18 18 │
+ 19 19 │
+ 20 │ - → + 20 │ + → + 21 21 │
+ 22 22 │ ; + + +``` + +``` +codeOptionsUnsorted.jsx:21:13 lint/nursery/useSortedClasses FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! These CSS classes should be sorted. + + 19 │
+ 20 │
+ > 21 │
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 22 │ ; + 23 │ + + i Unsafe fix: Sort the classes. + + 19 19 │
+ 20 20 │
+ 21 │ - → + 21 │ + → + 22 22 │ ; + 23 23 │ + + +``` + +``` +codeOptionsUnsorted.jsx:26:6 lint/nursery/useSortedClasses FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! These CSS classes should be sorted. + + 24 │ // functions + 25 │ /* SHOULD emit diagnostics (functions specified in options) */ + > 26 │ clsx("px-2 foo p-4 bar"); + │ ^^^^^^^^^^^^^^^^^^ + 27 │ // TODO: tagged template literals are not supported yet + 28 │ tw`px-2 foo p-4 bar`; + + i Unsafe fix: Sort the classes. + + 24 24 │ // functions + 25 25 │ /* SHOULD emit diagnostics (functions specified in options) */ + 26 │ - clsx("px-2·foo·p-4·bar"); + 26 │ + clsx("foo·bar·p-4·px-2"); + 27 27 │ // TODO: tagged template literals are not supported yet + 28 28 │ tw`px-2 foo p-4 bar`; + + +``` + +``` +codeOptionsUnsorted.jsx:36:13 lint/nursery/useSortedClasses FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! These CSS classes should be sorted. + + 34 │ // nested values + 35 │ /* SHOULD emit diagnostics (class attribute supported by default) */ + > 36 │
; + │ ^^^^^^^^^^^^^^^^^^ + 37 │
; + 38 │
; + 36 │ + ; + 37 37 │
; + 38 38 │
; + > 37 │
; + │ ^^^^^^^^^^^^^^^^^^ + 38 │
; + 37 │ - ; + 37 │ + ; + 38 38 │
42 │ "px-2 foo p-4 bar", + │ ^^^^^^^^^^^^^^^^^^ + 43 │ // TODO: property should be sorted + 44 │ { "px-2 foo p-4 bar": "px-2 foo p-4 bar", custom: ["px-2 foo p-4 bar"] }, + + i Unsafe fix: Sort the classes. + + 40 40 │ // TODO: property should be sorted + 41 41 │ "px-2 foo p-4 bar": [ + 42 │ - → → → "px-2·foo·p-4·bar", + 42 │ + → → → "foo·bar·p-4·px-2", + 43 43 │ // TODO: property should be sorted + 44 44 │ { "px-2 foo p-4 bar": "px-2 foo p-4 bar", custom: ["px-2 foo p-4 bar"] }, + + +``` + +``` +codeOptionsUnsorted.jsx:44:26 lint/nursery/useSortedClasses FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! These CSS classes should be sorted. + + 42 │ "px-2 foo p-4 bar", + 43 │ // TODO: property should be sorted + > 44 │ { "px-2 foo p-4 bar": "px-2 foo p-4 bar", custom: ["px-2 foo p-4 bar"] }, + │ ^^^^^^^^^^^^^^^^^^ + 45 │ ], + 46 │ }} + + i Unsafe fix: Sort the classes. + + 42 42 │ "px-2 foo p-4 bar", + 43 43 │ // TODO: property should be sorted + 44 │ - → → → {·"px-2·foo·p-4·bar":·"px-2·foo·p-4·bar",·custom:·["px-2·foo·p-4·bar"]·}, + 44 │ + → → → {·"px-2·foo·p-4·bar":·"foo·bar·p-4·px-2",·custom:·["px-2·foo·p-4·bar"]·}, + 45 45 │ ], + 46 46 │ }} + + +``` + +``` +codeOptionsUnsorted.jsx:44:55 lint/nursery/useSortedClasses FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! These CSS classes should be sorted. + + 42 │ "px-2 foo p-4 bar", + 43 │ // TODO: property should be sorted + > 44 │ { "px-2 foo p-4 bar": "px-2 foo p-4 bar", custom: ["px-2 foo p-4 bar"] }, + │ ^^^^^^^^^^^^^^^^^^ + 45 │ ], + 46 │ }} + + i Unsafe fix: Sort the classes. + + 42 42 │ "px-2 foo p-4 bar", + 43 43 │ // TODO: property should be sorted + 44 │ - → → → {·"px-2·foo·p-4·bar":·"px-2·foo·p-4·bar",·custom:·["px-2·foo·p-4·bar"]·}, + 44 │ + → → → {·"px-2·foo·p-4·bar":·"px-2·foo·p-4·bar",·custom:·["foo·bar·p-4·px-2"]·}, + 45 45 │ ], + 46 46 │ }} + + +``` + +``` +codeOptionsUnsorted.jsx:49:7 lint/nursery/useSortedClasses FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! These CSS classes should be sorted. + + 47 │ />; + 48 │ /* SHOULD emit diagnostics (clsx function specified in options) */ + > 49 │ clsx(["px-2 foo p-4 bar"]); + │ ^^^^^^^^^^^^^^^^^^ + 50 │ clsx({ + 51 │ "px-2 foo p-4 bar": [ + + i Unsafe fix: Sort the classes. + + 47 47 │ />; + 48 48 │ /* SHOULD emit diagnostics (clsx function specified in options) */ + 49 │ - clsx(["px-2·foo·p-4·bar"]); + 49 │ + clsx(["foo·bar·p-4·px-2"]); + 50 50 │ clsx({ + 51 51 │ "px-2 foo p-4 bar": [ + + +``` + +``` +codeOptionsUnsorted.jsx:52:3 lint/nursery/useSortedClasses FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! These CSS classes should be sorted. + + 50 │ clsx({ + 51 │ "px-2 foo p-4 bar": [ + > 52 │ "px-2 foo p-4 bar", + │ ^^^^^^^^^^^^^^^^^^ + 53 │ { "px-2 foo p-4 bar": "px-2 foo p-4 bar", custom: ["px-2 foo p-4 bar"] }, + 54 │ ], + + i Unsafe fix: Sort the classes. + + 50 50 │ clsx({ + 51 51 │ "px-2 foo p-4 bar": [ + 52 │ - → → "px-2·foo·p-4·bar", + 52 │ + → → "foo·bar·p-4·px-2", + 53 53 │ { "px-2 foo p-4 bar": "px-2 foo p-4 bar", custom: ["px-2 foo p-4 bar"] }, + 54 54 │ ], + + +``` + +``` +codeOptionsUnsorted.jsx:53:25 lint/nursery/useSortedClasses FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! These CSS classes should be sorted. + + 51 │ "px-2 foo p-4 bar": [ + 52 │ "px-2 foo p-4 bar", + > 53 │ { "px-2 foo p-4 bar": "px-2 foo p-4 bar", custom: ["px-2 foo p-4 bar"] }, + │ ^^^^^^^^^^^^^^^^^^ + 54 │ ], + 55 │ }); + + i Unsafe fix: Sort the classes. + + 51 51 │ "px-2 foo p-4 bar": [ + 52 52 │ "px-2 foo p-4 bar", + 53 │ - → → {·"px-2·foo·p-4·bar":·"px-2·foo·p-4·bar",·custom:·["px-2·foo·p-4·bar"]·}, + 53 │ + → → {·"px-2·foo·p-4·bar":·"foo·bar·p-4·px-2",·custom:·["px-2·foo·p-4·bar"]·}, + 54 54 │ ], + 55 55 │ }); + + +``` + +``` +codeOptionsUnsorted.jsx:53:54 lint/nursery/useSortedClasses FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! These CSS classes should be sorted. + + 51 │ "px-2 foo p-4 bar": [ + 52 │ "px-2 foo p-4 bar", + > 53 │ { "px-2 foo p-4 bar": "px-2 foo p-4 bar", custom: ["px-2 foo p-4 bar"] }, + │ ^^^^^^^^^^^^^^^^^^ + 54 │ ], + 55 │ }); + + i Unsafe fix: Sort the classes. + + 51 51 │ "px-2 foo p-4 bar": [ + 52 52 │ "px-2 foo p-4 bar", + 53 │ - → → {·"px-2·foo·p-4·bar":·"px-2·foo·p-4·bar",·custom:·["px-2·foo·p-4·bar"]·}, + 53 │ + → → {·"px-2·foo·p-4·bar":·"px-2·foo·p-4·bar",·custom:·["foo·bar·p-4·px-2"]·}, + 54 54 │ ], + 55 55 │ }); + + +``` + + diff --git a/crates/biome_js_analyze/tests/specs/nursery/useSortedClasses/codeOptionsUnsorted.options.json b/crates/biome_js_analyze/tests/specs/nursery/useSortedClasses/codeOptionsUnsorted.options.json new file mode 100644 index 000000000000..b9e8a6b22d0c --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useSortedClasses/codeOptionsUnsorted.options.json @@ -0,0 +1,16 @@ +{ + "$schema": "../../../../../../packages/@biomejs/biome/configuration_schema.json", + "linter": { + "rules": { + "nursery": { + "useSortedClasses": { + "level": "error", + "options": { + "attributes": ["customClassAttribute"], + "functions": ["clsx", "tw"] + } + } + } + } + } +} diff --git a/crates/biome_js_analyze/tests/specs/nursery/useSortedClasses/sorted.jsx b/crates/biome_js_analyze/tests/specs/nursery/useSortedClasses/sorted.jsx new file mode 100644 index 000000000000..f9b77761f85a --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useSortedClasses/sorted.jsx @@ -0,0 +1,46 @@ +<> + {/* attributes */} +
+
+
+
+ {/* utility sorting */} +
+
+
+
+
+
+
+
+
+
+; + +// functions +clsx("px-2 foo p-4 bar"); +// TODO: tagged template literals are not supported yet +tw`px-2 foo p-4 bar`; +tw.div`px-2 foo p-4 bar`; +notClassFunction("px-2 foo p-4 bar"); +notTemplateFunction`px-2 foo p-4 bar`; +notTemplateFunction.div`px-2 foo p-4 bar`; + +// nested values +
; +
; +
; +clsx(["px-2 foo p-4 bar"]); +clsx({ + "px-2 foo p-4 bar": [ + "px-2 foo p-4 bar", + { "px-2 foo p-4 bar": "px-2 foo p-4 bar", custom: ["px-2 foo p-4 bar"] }, + ], +}); diff --git a/crates/biome_js_analyze/tests/specs/nursery/useSortedClasses/sorted.jsx.snap b/crates/biome_js_analyze/tests/specs/nursery/useSortedClasses/sorted.jsx.snap new file mode 100644 index 000000000000..2dab37850fd8 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useSortedClasses/sorted.jsx.snap @@ -0,0 +1,56 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +expression: sorted.jsx +--- +# Input +```jsx +<> + {/* attributes */} +
+
+
+
+ {/* utility sorting */} +
+
+
+
+
+
+
+
+
+
+; + +// functions +clsx("px-2 foo p-4 bar"); +// TODO: tagged template literals are not supported yet +tw`px-2 foo p-4 bar`; +tw.div`px-2 foo p-4 bar`; +notClassFunction("px-2 foo p-4 bar"); +notTemplateFunction`px-2 foo p-4 bar`; +notTemplateFunction.div`px-2 foo p-4 bar`; + +// nested values +
; +
; +
; +clsx(["px-2 foo p-4 bar"]); +clsx({ + "px-2 foo p-4 bar": [ + "px-2 foo p-4 bar", + { "px-2 foo p-4 bar": "px-2 foo p-4 bar", custom: ["px-2 foo p-4 bar"] }, + ], +}); + +``` + + diff --git a/crates/biome_js_analyze/tests/specs/nursery/useSortedClasses/unsorted.jsx b/crates/biome_js_analyze/tests/specs/nursery/useSortedClasses/unsorted.jsx new file mode 100644 index 000000000000..533637c9d43c --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useSortedClasses/unsorted.jsx @@ -0,0 +1,54 @@ +<> + {/* attributes */} + {/* SHOULD emit diagnostics (class/className attributes supported by default) */} +
+
+ {/* SHOULD NOT emit diagnostics (custom attributes not specified in options) */} +
+
+ {/* utility sorting */} + {/* SHOULD emit diagnostics (class attribute supported by default) */} +
+
+
+
+
+
+
+
+
+
+; + +// functions +/* SHOULD NOT emit diagnostics (functions not specified in options) */ +clsx("px-2 foo p-4 bar"); +// TODO: tagged template literals are not supported yet +tw`px-2 foo p-4 bar`; +tw.div`px-2 foo p-4 bar`; +notClassFunction("px-2 foo p-4 bar"); +notTemplateFunction`px-2 foo p-4 bar`; +notTemplateFunction.div`px-2 foo p-4 bar`; + +// nested values +/* SHOULD emit diagnostics (class attribute supported by default) */ +
; +
; +
; +/* SHOULD NOT emit diagnostics (clsx function not specified in options) */ +clsx(["px-2 foo p-4 bar"]); +clsx({ + "px-2 foo p-4 bar": [ + "px-2 foo p-4 bar", + { "px-2 foo p-4 bar": "px-2 foo p-4 bar", custom: ["px-2 foo p-4 bar"] }, + ], +}); diff --git a/crates/biome_js_analyze/tests/specs/nursery/useSortedClasses/unsorted.jsx.snap b/crates/biome_js_analyze/tests/specs/nursery/useSortedClasses/unsorted.jsx.snap new file mode 100644 index 000000000000..7e03037abcc7 --- /dev/null +++ b/crates/biome_js_analyze/tests/specs/nursery/useSortedClasses/unsorted.jsx.snap @@ -0,0 +1,473 @@ +--- +source: crates/biome_js_analyze/tests/spec_tests.rs +expression: unsorted.jsx +--- +# Input +```jsx +<> + {/* attributes */} + {/* SHOULD emit diagnostics (class/className attributes supported by default) */} +
+
+ {/* SHOULD NOT emit diagnostics (custom attributes not specified in options) */} +
+
+ {/* utility sorting */} + {/* SHOULD emit diagnostics (class attribute supported by default) */} +
+
+
+
+
+
+
+
+
+
+; + +// functions +/* SHOULD NOT emit diagnostics (functions not specified in options) */ +clsx("px-2 foo p-4 bar"); +// TODO: tagged template literals are not supported yet +tw`px-2 foo p-4 bar`; +tw.div`px-2 foo p-4 bar`; +notClassFunction("px-2 foo p-4 bar"); +notTemplateFunction`px-2 foo p-4 bar`; +notTemplateFunction.div`px-2 foo p-4 bar`; + +// nested values +/* SHOULD emit diagnostics (class attribute supported by default) */ +
; +
; +
; +/* SHOULD NOT emit diagnostics (clsx function not specified in options) */ +clsx(["px-2 foo p-4 bar"]); +clsx({ + "px-2 foo p-4 bar": [ + "px-2 foo p-4 bar", + { "px-2 foo p-4 bar": "px-2 foo p-4 bar", custom: ["px-2 foo p-4 bar"] }, + ], +}); + +``` + +# Diagnostics +``` +unsorted.jsx:4:13 lint/nursery/useSortedClasses FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! These CSS classes should be sorted. + + 2 │ {/* attributes */} + 3 │ {/* SHOULD emit diagnostics (class/className attributes supported by default) */} + > 4 │
+ │ ^^^^^^^^^^^^^^^^^^ + 5 │
+ 6 │ {/* SHOULD NOT emit diagnostics (custom attributes not specified in options) */} + + i Unsafe fix: Sort the classes. + + 2 2 │ {/* attributes */} + 3 3 │ {/* SHOULD emit diagnostics (class/className attributes supported by default) */} + 4 │ - → + 4 │ + → + 5 5 │
+ 6 6 │ {/* SHOULD NOT emit diagnostics (custom attributes not specified in options) */} + + +``` + +``` +unsorted.jsx:5:17 lint/nursery/useSortedClasses FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! These CSS classes should be sorted. + + 3 │ {/* SHOULD emit diagnostics (class/className attributes supported by default) */} + 4 │
+ > 5 │
+ │ ^^^^^^^^^^^^^^^^^^ + 6 │ {/* SHOULD NOT emit diagnostics (custom attributes not specified in options) */} + 7 │
+ + i Unsafe fix: Sort the classes. + + 3 3 │ {/* SHOULD emit diagnostics (class/className attributes supported by default) */} + 4 4 │
+ 5 │ - → + 5 │ + → + 6 6 │ {/* SHOULD NOT emit diagnostics (custom attributes not specified in options) */} + 7 7 │
+ + +``` + +``` +unsorted.jsx:11:13 lint/nursery/useSortedClasses FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! These CSS classes should be sorted. + + 9 │ {/* utility sorting */} + 10 │ {/* SHOULD emit diagnostics (class attribute supported by default) */} + > 11 │
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 12 │
+ 13 │
+ + i Unsafe fix: Sort the classes. + + 9 9 │ {/* utility sorting */} + 10 10 │ {/* SHOULD emit diagnostics (class attribute supported by default) */} + 11 │ - → + 11 │ + → + 12 12 │
+ 13 13 │
+ + +``` + +``` +unsorted.jsx:12:13 lint/nursery/useSortedClasses FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! These CSS classes should be sorted. + + 10 │ {/* SHOULD emit diagnostics (class attribute supported by default) */} + 11 │
+ > 12 │
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 13 │
+ 14 │
+ + i Unsafe fix: Sort the classes. + + 10 10 │ {/* SHOULD emit diagnostics (class attribute supported by default) */} + 11 11 │
+ 12 │ - → + 12 │ + → + 13 13 │
+ 14 14 │
+ + +``` + +``` +unsorted.jsx:13:13 lint/nursery/useSortedClasses FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! These CSS classes should be sorted. + + 11 │
+ 12 │
+ > 13 │
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 14 │
+ 15 │
+ + i Unsafe fix: Sort the classes. + + 11 11 │
+ 12 12 │
+ 13 │ - → + 13 │ + → + 14 14 │
+ 15 15 │
+ + +``` + +``` +unsorted.jsx:14:13 lint/nursery/useSortedClasses FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! These CSS classes should be sorted. + + 12 │
+ 13 │
+ > 14 │
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 15 │
+ 16 │
+ + i Unsafe fix: Sort the classes. + + 12 12 │
+ 13 13 │
+ 14 │ - → + 14 │ + → + 15 15 │
+ 16 16 │
+ + +``` + +``` +unsorted.jsx:15:13 lint/nursery/useSortedClasses FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! These CSS classes should be sorted. + + 13 │
+ 14 │
+ > 15 │
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 16 │
+ 17 │
+ + i Unsafe fix: Sort the classes. + + 13 13 │
+ 14 14 │
+ 15 │ - → + 15 │ + → + 16 16 │
+ 17 17 │
+ + +``` + +``` +unsorted.jsx:16:13 lint/nursery/useSortedClasses FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! These CSS classes should be sorted. + + 14 │
+ 15 │
+ > 16 │
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 17 │
+ 18 │
+ + i Unsafe fix: Sort the classes. + + 14 14 │
+ 15 15 │
+ 16 │ - → + 16 │ + → + 17 17 │
+ 18 18 │
+ + +``` + +``` +unsorted.jsx:17:13 lint/nursery/useSortedClasses FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! These CSS classes should be sorted. + + 15 │
+ 16 │
+ > 17 │
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 18 │
+ 19 │
+ + i Unsafe fix: Sort the classes. + + 15 15 │
+ 16 16 │
+ 17 │ - → + 17 │ + → + 18 18 │
+ 19 19 │
+ + +``` + +``` +unsorted.jsx:18:13 lint/nursery/useSortedClasses FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! These CSS classes should be sorted. + + 16 │
+ 17 │
+ > 18 │
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 19 │
+ 20 │
+ + i Unsafe fix: Sort the classes. + + 16 16 │
+ 17 17 │
+ 18 │ - → + 18 │ + → + 19 19 │
+ 20 20 │
+ + +``` + +``` +unsorted.jsx:19:13 lint/nursery/useSortedClasses FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! These CSS classes should be sorted. + + 17 │
+ 18 │
+ > 19 │
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 20 │
+ 21 │ ; + + i Unsafe fix: Sort the classes. + + 17 17 │
+ 18 18 │
+ 19 │ - → + 19 │ + → + 20 20 │
+ 21 21 │ ; + + +``` + +``` +unsorted.jsx:20:13 lint/nursery/useSortedClasses FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! These CSS classes should be sorted. + + 18 │
+ 19 │
+ > 20 │
+ │ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + 21 │ ; + 22 │ + + i Unsafe fix: Sort the classes. + + 18 18 │
+ 19 19 │
+ 20 │ - → + 20 │ + → + 21 21 │ ; + 22 22 │ + + +``` + +``` +unsorted.jsx:35:13 lint/nursery/useSortedClasses FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! These CSS classes should be sorted. + + 33 │ // nested values + 34 │ /* SHOULD emit diagnostics (class attribute supported by default) */ + > 35 │
; + │ ^^^^^^^^^^^^^^^^^^ + 36 │
; + 37 │
; + 35 │ + ; + 36 36 │
; + 37 37 │
; + > 36 │
; + │ ^^^^^^^^^^^^^^^^^^ + 37 │
; + 36 │ - ; + 36 │ + ; + 37 37 │
41 │ "px-2 foo p-4 bar", + │ ^^^^^^^^^^^^^^^^^^ + 42 │ // TODO: property should be sorted + 43 │ { "px-2 foo p-4 bar": "px-2 foo p-4 bar", custom: ["px-2 foo p-4 bar"] }, + + i Unsafe fix: Sort the classes. + + 39 39 │ // TODO: property should be sorted + 40 40 │ "px-2 foo p-4 bar": [ + 41 │ - → → → "px-2·foo·p-4·bar", + 41 │ + → → → "foo·bar·p-4·px-2", + 42 42 │ // TODO: property should be sorted + 43 43 │ { "px-2 foo p-4 bar": "px-2 foo p-4 bar", custom: ["px-2 foo p-4 bar"] }, + + +``` + +``` +unsorted.jsx:43:26 lint/nursery/useSortedClasses FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! These CSS classes should be sorted. + + 41 │ "px-2 foo p-4 bar", + 42 │ // TODO: property should be sorted + > 43 │ { "px-2 foo p-4 bar": "px-2 foo p-4 bar", custom: ["px-2 foo p-4 bar"] }, + │ ^^^^^^^^^^^^^^^^^^ + 44 │ ], + 45 │ }} + + i Unsafe fix: Sort the classes. + + 41 41 │ "px-2 foo p-4 bar", + 42 42 │ // TODO: property should be sorted + 43 │ - → → → {·"px-2·foo·p-4·bar":·"px-2·foo·p-4·bar",·custom:·["px-2·foo·p-4·bar"]·}, + 43 │ + → → → {·"px-2·foo·p-4·bar":·"foo·bar·p-4·px-2",·custom:·["px-2·foo·p-4·bar"]·}, + 44 44 │ ], + 45 45 │ }} + + +``` + +``` +unsorted.jsx:43:55 lint/nursery/useSortedClasses FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + ! These CSS classes should be sorted. + + 41 │ "px-2 foo p-4 bar", + 42 │ // TODO: property should be sorted + > 43 │ { "px-2 foo p-4 bar": "px-2 foo p-4 bar", custom: ["px-2 foo p-4 bar"] }, + │ ^^^^^^^^^^^^^^^^^^ + 44 │ ], + 45 │ }} + + i Unsafe fix: Sort the classes. + + 41 41 │ "px-2 foo p-4 bar", + 42 42 │ // TODO: property should be sorted + 43 │ - → → → {·"px-2·foo·p-4·bar":·"px-2·foo·p-4·bar",·custom:·["px-2·foo·p-4·bar"]·}, + 43 │ + → → → {·"px-2·foo·p-4·bar":·"px-2·foo·p-4·bar",·custom:·["foo·bar·p-4·px-2"]·}, + 44 44 │ ], + 45 45 │ }} + + +``` + + diff --git a/crates/biome_service/src/configuration/linter/rules.rs b/crates/biome_service/src/configuration/linter/rules.rs index 624fc8184261..631c99e77fc1 100644 --- a/crates/biome_service/src/configuration/linter/rules.rs +++ b/crates/biome_service/src/configuration/linter/rules.rs @@ -2323,6 +2323,9 @@ pub struct Nursery { #[doc = "Enforce using function types instead of object type with call signatures."] #[serde(skip_serializing_if = "Option::is_none")] pub use_shorthand_function_type: Option, + #[doc = "Enforce the sorting of CSS utility classes."] + #[serde(skip_serializing_if = "Option::is_none")] + pub use_sorted_classes: Option, } impl DeserializableValidator for Nursery { fn validate( @@ -2340,7 +2343,7 @@ impl DeserializableValidator for Nursery { } impl Nursery { const GROUP_NAME: &'static str = "nursery"; - pub(crate) const GROUP_RULES: [&'static str; 26] = [ + pub(crate) const GROUP_RULES: [&'static str; 27] = [ "noDuplicateJsonKeys", "noEmptyBlockStatements", "noEmptyTypeParameters", @@ -2367,6 +2370,7 @@ impl Nursery { "useNodejsImportProtocol", "useNumberNamespace", "useShorthandFunctionType", + "useSortedClasses", ]; const RECOMMENDED_RULES: [&'static str; 12] = [ "noDuplicateJsonKeys", @@ -2396,7 +2400,7 @@ impl Nursery { RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[22]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[24]), ]; - const ALL_RULES_AS_FILTERS: [RuleFilter<'static>; 26] = [ + const ALL_RULES_AS_FILTERS: [RuleFilter<'static>; 27] = [ RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[0]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[1]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[2]), @@ -2423,6 +2427,7 @@ impl Nursery { RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[23]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[24]), RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[25]), + RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[26]), ]; #[doc = r" Retrieves the recommended rules"] pub(crate) fn is_recommended(&self) -> bool { @@ -2569,6 +2574,11 @@ impl Nursery { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[25])); } } + if let Some(rule) = self.use_sorted_classes.as_ref() { + if rule.is_enabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[26])); + } + } index_set } pub(crate) fn get_disabled_rules(&self) -> IndexSet { @@ -2703,6 +2713,11 @@ impl Nursery { index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[25])); } } + if let Some(rule) = self.use_sorted_classes.as_ref() { + if rule.is_disabled() { + index_set.insert(RuleFilter::Rule(Self::GROUP_NAME, Self::GROUP_RULES[26])); + } + } index_set } #[doc = r" Checks if, given a rule name, matches one of the rules contained in this category"] @@ -2716,7 +2731,7 @@ impl Nursery { pub(crate) fn recommended_rules_as_filters() -> [RuleFilter<'static>; 12] { Self::RECOMMENDED_RULES_AS_FILTERS } - pub(crate) fn all_rules_as_filters() -> [RuleFilter<'static>; 26] { + pub(crate) fn all_rules_as_filters() -> [RuleFilter<'static>; 27] { Self::ALL_RULES_AS_FILTERS } #[doc = r" Select preset rules"] @@ -2765,6 +2780,7 @@ impl Nursery { "useNodejsImportProtocol" => self.use_nodejs_import_protocol.as_ref(), "useNumberNamespace" => self.use_number_namespace.as_ref(), "useShorthandFunctionType" => self.use_shorthand_function_type.as_ref(), + "useSortedClasses" => self.use_sorted_classes.as_ref(), _ => None, } } diff --git a/crates/biome_service/tests/invalid/hooks_missing_name.json.snap b/crates/biome_service/tests/invalid/hooks_missing_name.json.snap index 2f8718c1251d..2493bbaaee7e 100644 --- a/crates/biome_service/tests/invalid/hooks_missing_name.json.snap +++ b/crates/biome_service/tests/invalid/hooks_missing_name.json.snap @@ -43,6 +43,7 @@ hooks_missing_name.json:6:5 deserialize ━━━━━━━━━━━━━ - useNodejsImportProtocol - useNumberNamespace - useShorthandFunctionType + - useSortedClasses diff --git a/packages/@biomejs/backend-jsonrpc/src/workspace.ts b/packages/@biomejs/backend-jsonrpc/src/workspace.ts index 45f3ff3d1233..5e898f91f82f 100644 --- a/packages/@biomejs/backend-jsonrpc/src/workspace.ts +++ b/packages/@biomejs/backend-jsonrpc/src/workspace.ts @@ -962,6 +962,10 @@ export interface Nursery { * Enforce using function types instead of object type with call signatures. */ useShorthandFunctionType?: RuleConfiguration; + /** + * Enforce the sorting of CSS utility classes. + */ + useSortedClasses?: RuleConfiguration; } /** * A list of rules that belong to this group @@ -1400,7 +1404,8 @@ export type PossibleOptions = | DeprecatedHooksOptions | NamingConventionOptions | RestrictedGlobalsOptions - | ValidAriaRoleOptions; + | ValidAriaRoleOptions + | UtilityClassSortingOptions; /** * Options for the rule `noExcessiveCognitiveComplexity`. */ @@ -1465,6 +1470,16 @@ export interface ValidAriaRoleOptions { allowInvalidRoles: string[]; ignoreNonDom: boolean; } +export interface UtilityClassSortingOptions { + /** + * Additional attributes that will be sorted. + */ + attributes?: string[]; + /** + * Names of the functions or tagged templates that will be sorted. + */ + functions?: string[]; +} export type ConsistentArrayType = "shorthand" | "generic"; export type FilenameCases = FilenameCase[]; export interface Hooks { @@ -1700,6 +1715,7 @@ export type Category = | "lint/nursery/useNodejsImportProtocol" | "lint/nursery/useNumberNamespace" | "lint/nursery/useShorthandFunctionType" + | "lint/nursery/useSortedClasses" | "lint/performance/noAccumulatingSpread" | "lint/performance/noDelete" | "lint/security/noDangerouslySetInnerHtml" diff --git a/packages/@biomejs/biome/configuration_schema.json b/packages/@biomejs/biome/configuration_schema.json index db808fd3876d..f250f5c1dd29 100644 --- a/packages/@biomejs/biome/configuration_schema.json +++ b/packages/@biomejs/biome/configuration_schema.json @@ -1467,6 +1467,13 @@ { "$ref": "#/definitions/RuleConfiguration" }, { "type": "null" } ] + }, + "useSortedClasses": { + "description": "Enforce the sorting of CSS utility classes.", + "anyOf": [ + { "$ref": "#/definitions/RuleConfiguration" }, + { "type": "null" } + ] } } }, @@ -1680,6 +1687,10 @@ { "description": "Options for `useValidAriaRole` rule", "allOf": [{ "$ref": "#/definitions/ValidAriaRoleOptions" }] + }, + { + "description": "Options for `useSortedClasses` rule", + "allOf": [{ "$ref": "#/definitions/UtilityClassSortingOptions" }] } ] }, @@ -2388,6 +2399,22 @@ } ] }, + "UtilityClassSortingOptions": { + "type": "object", + "properties": { + "attributes": { + "description": "Additional attributes that will be sorted.", + "type": ["array", "null"], + "items": { "type": "string" } + }, + "functions": { + "description": "Names of the functions or tagged templates that will be sorted.", + "type": ["array", "null"], + "items": { "type": "string" } + } + }, + "additionalProperties": false + }, "ValidAriaRoleOptions": { "type": "object", "required": ["allowInvalidRoles", "ignoreNonDom"], diff --git a/tmptest.js b/tmptest.js new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/website/src/components/generated/NumberOfRules.astro b/website/src/components/generated/NumberOfRules.astro index 87dc1678b0ec..365b543aa4b5 100644 --- a/website/src/components/generated/NumberOfRules.astro +++ b/website/src/components/generated/NumberOfRules.astro @@ -1,2 +1,2 @@ -

Biome's linter has a total of 196 rules

\ No newline at end of file +

Biome's linter has a total of 197 rules

\ No newline at end of file diff --git a/website/src/content/docs/internals/changelog.mdx b/website/src/content/docs/internals/changelog.mdx index f92292ff8c30..4881b8521a9f 100644 --- a/website/src/content/docs/internals/changelog.mdx +++ b/website/src/content/docs/internals/changelog.mdx @@ -56,6 +56,14 @@ Read our [guidelines for writing a good changelog entry](https://github.com/biom it.only("test", () => {}); ``` +- Add rule [noSortedClasses](https://biomejs.dev/linter/rules/use-sorted-classes), to sort CSS utility classes: + + ```diff + -

+ +
+ ``` + Contributed by @DaniGuardiola + ### Parser ## 1.5.3 (2024-01-22) @@ -136,7 +144,7 @@ Read our [guidelines for writing a good changelog entry](https://github.com/biom Contributed by @magic-akari -- Correctly parse type arguments in expression([#1184](https://github.com/biomejs/biome/issues/1184)). +- Correctly parse type arguments in expression([#1184](https://github.com/biomejs/biome/issues/1184)). The following code is now correctly parsed in typescript: diff --git a/website/src/content/docs/linter/rules/index.mdx b/website/src/content/docs/linter/rules/index.mdx index 14c2ac281c6f..5201ead766e7 100644 --- a/website/src/content/docs/linter/rules/index.mdx +++ b/website/src/content/docs/linter/rules/index.mdx @@ -258,3 +258,4 @@ Rules that belong to this group are not subject to semantic versionnode: protocol for Node.js builtin modules. | ⚠️ | | [useNumberNamespace](/linter/rules/use-number-namespace) | Use the Number properties instead of global ones. | ⚠️ | | [useShorthandFunctionType](/linter/rules/use-shorthand-function-type) | Enforce using function types instead of object type with call signatures. | 🔧 | +| [useSortedClasses](/linter/rules/use-sorted-classes) | Enforce the sorting of CSS utility classes. | ⚠️ | diff --git a/website/src/content/docs/linter/rules/use-sorted-classes.md b/website/src/content/docs/linter/rules/use-sorted-classes.md new file mode 100644 index 000000000000..612f7f3ca484 --- /dev/null +++ b/website/src/content/docs/linter/rules/use-sorted-classes.md @@ -0,0 +1,142 @@ +--- +title: useSortedClasses (not released) +--- + +**Diagnostic Category: `lint/nursery/useSortedClasses`** + +:::danger +This rule hasn't been released yet. +::: + +:::caution +This rule is part of the [nursery](/linter/rules/#nursery) group. +::: + +Enforce the sorting of CSS utility classes. + +This rule implements the same sorting algorithm as [Tailwind CSS](https://tailwindcss.com/blog/automatic-class-sorting-with-prettier#how-classes-are-sorted), but supports any utility class framework including [UnoCSS](https://unocss.dev/). + +It is analogous to [`prettier-plugin-tailwindcss`](https://github.com/tailwindlabs/prettier-plugin-tailwindcss). + +:::caution + +## Important notes + +This rule is a work in progress, and is only partially implemented. Progress is being tracked in the following GitHub issue: https://github.com/biomejs/biome/issues/1274 + +Currently, utility class sorting is **not part of the formatter**, and is implemented as a linter rule instead, with an automatic fix. The fix is, at this stage, classified as unsafe. This means that **it won't be applied automatically** as part of IDE actions such as "fix on save". + +We appreciate any feedback on this rule, and encourage you to try it out and report any issues you find. + +**Please read this entire documentation page before reporting an issue.** + +Notably, keep in mind that the following features are not supported yet: + +- Variant sorting. +- Custom utilitites and variants (such as ones introduced by Tailwind CSS plugins). Only the default Tailwind CSS configuration is supported. +- Options such as `prefix` and `separator`. +- Tagged template literals. +- Object properties (e.g. in `clsx` calls). + +Please don't report issues about these features. +::: + +## Examples + +### Invalid + +```jsx +
; +``` + +
nursery/useSortedClasses.js:1:12 lint/nursery/useSortedClasses  FIXABLE  ━━━━━━━━━━━━━━━━━━━━━━━━━━━
+
+   These CSS classes should be sorted.
+  
+  > 1 │ <div class="px-2 foo p-4 bar" />;
+              ^^^^^^^^^^^^^^^^^^
+    2 │ 
+  
+   Unsafe fix: Sort the classes.
+  
+    1  - <div·class="px-2·foo·p-4·bar"·/>;
+      1+ <div·class="foo·bar·p-4·px-2"·/>;
+    2 2  
+  
+
+ +## Options + +### Code-related + +```json +{ + "options": { + "attributes": ["classList"], + "functions": ["clsx", "cva", "tw"] + } +} +``` + +#### attributes + +Classes in the `class` and `className` JSX attributes are always sorted. Use this option to add more attributes that should be sorted. + +#### functions + +If specified, strings in the indicated functions will be sorted. This is useful when working with libraries like [`clsx`](https://github.com/lukeed/clsx) or [`cva`](https://cva.style/). + +```jsx +clsx("px-2 foo p-4 bar", { + "block mx-4": condition, +}); +``` + +Tagged template literals are also supported, for example: + +```jsx +tw`px-2`; +tw.div`px-2`; +``` + +:::caution +Tagged template literal support has not been implemented yet. +::: + +### Sort-related + +:::caution +At the moment, this rule does not support customizing the sort options. Instead, the default Tailwind CSS configuration is hard-coded. +::: + +## Differences with [Prettier](https://github.com/tailwindlabs/prettier-plugin-tailwindcss) + +The main key difference is that Tailwind CSS and its Prettier plugin read and execute the `tailwind.config.js` JavaScript file, which Biome can't do. Instead, Biome implements a simpler version of the configuration. The trade-offs are explained below. + +### Values are not known + +The rule has no knowledge of values such as colors, font sizes, or spacing values, which are normally defined in a configuration file like `tailwind.config.js`. Instead, the rule matches utilities that support values in a simpler way: if they start with a known utility prefix, such as `px-` or `text-`, they're considered valid. + +This has two implications: + +- False positives: classes can be wrongly recognized as utilities even though their values are incorrect. For example, if there's a `px-` utility defined in the configuration, it will match all of the following classes: `px-2`, `px-1337`, `px-[not-actually-valid]`, `px-literally-anything`. +- No distinction between different utilities that share the same prefix: for example, `text-red-500` and `text-lg` are both interpreted as the same type of utility by this rule, even though the former refers to a color and the latter to a font size. This results in all utilities that share the same prefix being sorted together, regardless of their actual values. + +### Custom additions must be specified + +The built-in Tailwind CSS preset (enabled by default) contains the set of utilities and variants that are available with the default configuration. More utilities and variants can be added through Tailwind CSS plugins. In Biome, these need to be manually specified in the Biome configuration file in order to "extend" the preset. + +### Presets can't be modified + +In Tailwind CSS, core plugins (which provide the default utilities and variants) can be disabled. In Biome, however, there is no way to disable parts of a preset: it's all or nothing. A work-around is to, instead of using a preset, manually specify all utilities and variants in the Biome configuration file. + +### Whitespace is collapsed + +The Tailwind CSS Prettier plugin preserves all original whitespace. This rule, however, collapses all whitespace (including newlines) into single spaces. + +This is a deliberate decision. We're unsure about this behavior, and would appreciate feedback on it. If this is a problem for you, please share a detailed explanation of your use case in [the GitHub issue](https://github.com/biomejs/biome/issues/1274). + +## Related links + +- [Disable a rule](/linter/#disable-a-lint-rule) +- [Rule options](/linter/#rule-options)