diff --git a/apps/oxfmt/test/api/sort_tailwindcss.test.ts b/apps/oxfmt/test/api/sort_tailwindcss.test.ts index 28043fadd7..515a1da1b4 100644 --- a/apps/oxfmt/test/api/sort_tailwindcss.test.ts +++ b/apps/oxfmt/test/api/sort_tailwindcss.test.ts @@ -697,7 +697,8 @@ const f = clsx({ kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk const result = await format("test.ts", input, options); // The line breaks are collapsed, so the template is not hugged as multiline, - // and the collapsed key and call arguments are short. + // and the collapsed key and Tailwind call arguments are short. + // `foo()` is not a Tailwind function, so its argument is kept and measured as is. expect(result.code).toMatchInlineSnapshot(` "const a = clsx( \`flex items-center justify-between p-4 \${active ? "bg-white" : "bg-black"} text-black hover:bg-gray-100\`, @@ -714,8 +715,9 @@ const f = clsx({ kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk const eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee = clsx(\`flex p-4\`); const f = clsx({ - kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk: - foo(\`flex p-4\`), + kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk: foo( + \`p-4 flex\`, + ), }); " `); @@ -1005,6 +1007,37 @@ const f = clsx({ kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk expect(result.errors).toStrictEqual([]); }); + it("should NOT sort template literals inside nested non-Tailwind call expressions", async () => { + const input = `const a = cn(x.includes(\`a b\`) && "p-4 flex"); +const b = cn(x.includes(\`\${"a b"} c\`)); +const C =
; +const D =
; +`; + const options = { experimentalTailwindcss: { functions: ["cn"] } }; + + const result = await format("test.tsx", input, options); + + // Like strings, the templates (and strings inside them) are kept as is + expect(result.code).toMatchInlineSnapshot(` + "const a = cn(x.includes(\`a b\`) && "flex p-4"); + const b = cn(x.includes(\`\${"a b"} c\`)); + const C =
; + const D = ( +
+ ); + " + `); + expect(result.errors).toStrictEqual([]); + + // Formatting the output again should not change it. + const second = await format("test.tsx", result.code, options); + expect(second.code).toBe(result.code); + }); + it("should preserve escape sequences in nested call expressions", async () => { // Various escape sequences should be preserved in nested calls const input = `const A =
{ /// structures (e.g., `{ a: { "b-c": 1 } }` where only the inner object needs quoted keys). quote_needed_stack: Vec, - /// Stack tracking whether we're inside a Tailwind class context. - /// When non-empty, StringLiterals should be sorted as Tailwind classes. - tailwind_context_stack: Vec, + /// Stack tracking whether we're inside a Tailwind class context, read by [`Self::tailwind_context`]. + /// - `None` hides the context below it, inside a nested non-Tailwind call + tailwind_context_stack: Vec>, /// Set only for an embedded program (see [`crate::format_to_ir`]): /// its sole statement prints without its semicolon. @@ -268,7 +261,13 @@ impl<'ast> JsFormatContext<'ast> { /// Push a Tailwind context entry onto the stack. /// Call this when entering a JSXAttribute or CallExpression with Tailwind class context. pub fn push_tailwind_context(&mut self, entry: TailwindContextEntry) { - self.tailwind_context_stack.push(entry); + self.tailwind_context_stack.push(Some(entry)); + } + + /// Hide the current Tailwind context until the matching [`Self::pop_tailwind_context`]. + /// Call this when entering a nested non-Tailwind CallExpression. + pub fn hide_tailwind_context(&mut self) { + self.tailwind_context_stack.push(None); } /// Pop a Tailwind context entry from the stack. @@ -278,13 +277,9 @@ impl<'ast> JsFormatContext<'ast> { } /// Get the current Tailwind context, if any. - /// Returns `Some` if we're inside a Tailwind class context (JSXAttribute or CallExpression). + /// Returns `Some` if we're inside a Tailwind class context (JSXAttribute or CallExpression), + /// and not inside a nested non-Tailwind call (see [`Self::hide_tailwind_context`]). pub fn tailwind_context(&self) -> Option<&TailwindContextEntry> { - self.tailwind_context_stack.last() - } - - /// Get a mutable reference to the current Tailwind context, if any. - pub fn tailwind_context_mut(&mut self) -> Option<&mut TailwindContextEntry> { - self.tailwind_context_stack.last_mut() + self.tailwind_context_stack.last().and_then(Option::as_ref) } } diff --git a/crates/oxc_formatter/src/print/call_like_expression/mod.rs b/crates/oxc_formatter/src/print/call_like_expression/mod.rs index defe59616d..2732512679 100644 --- a/crates/oxc_formatter/src/print/call_like_expression/mod.rs +++ b/crates/oxc_formatter/src/print/call_like_expression/mod.rs @@ -26,18 +26,14 @@ impl<'a> FormatWrite<'a> for AstNode<'_, 'a, CallExpression<'a>> { // Check if this is a Tailwind function call (e.g., clsx, cn, tw) let tailwind_ctx_to_push = tailwind_context_for_callee(&self.callee, f); - // For nested non-Tailwind calls inside a Tailwind context, disable sorting - // to prevent sorting strings inside the nested call's arguments. + // For nested non-Tailwind calls inside a Tailwind context, hide the context + // to prevent sorting strings and templates inside the nested call's arguments. // (e.g., `classNames("a", x.includes("\n") ? "b" : "c")` - don't sort "\n") - let was_disabled = if tailwind_ctx_to_push.is_none() - && let Some(ctx) = f.context_mut().tailwind_context_mut() - { - let was = ctx.disabled; - ctx.disabled = true; - Some(was) - } else { - None - }; + let hides_tailwind_ctx = + tailwind_ctx_to_push.is_none() && f.context().tailwind_context().is_some(); + if hides_tailwind_ctx { + f.context_mut().hide_tailwind_context(); + } // NOTE: Decided before this call pushes its Tailwind context, and `MemberChain` pushes none, // so a Tailwind call with a multiline template takes the branch below, which sorts it @@ -77,11 +73,8 @@ impl<'a> FormatWrite<'a> for AstNode<'_, 'a, CallExpression<'a>> { } } - // Restore the previous disabled state - if let Some(was) = was_disabled - && let Some(ctx) = f.context_mut().tailwind_context_mut() - { - ctx.disabled = was; + if hides_tailwind_ctx { + f.context_mut().pop_tailwind_context(); } } } diff --git a/crates/oxc_formatter/src/print/mod.rs b/crates/oxc_formatter/src/print/mod.rs index 5f9ea08f80..054148850f 100644 --- a/crates/oxc_formatter/src/print/mod.rs +++ b/crates/oxc_formatter/src/print/mod.rs @@ -89,7 +89,7 @@ use crate::{ write_trailing_comments_before, }, string::{FormatLiteralStringToken, StringLiteralParentKind}, - tailwindcss::{tailwind_context_for_string_literal, write_tailwind_string_literal}, + tailwindcss::{tailwind_context_for_class_list, write_tailwind_string_literal}, typecast::is_cast_target, }, write, @@ -1246,7 +1246,7 @@ impl<'a> FormatWrite<'a> for AstNode<'_, 'a, StringLiteral<'a>> { fn write(&self, f: &mut JsFormatter<'_, 'a>) { // Check if we're in a Tailwind context via stack (O(1) lookup) // This handles nested string literals inside JSXAttribute/CallExpression values - if let Some(ctx) = tailwind_context_for_string_literal(self, f) { + if let Some(ctx) = tailwind_context_for_class_list(self, f) { // We're inside a Tailwind context - sort this string literal as Tailwind classes write_tailwind_string_literal(self, ctx, f); } else { diff --git a/crates/oxc_formatter/src/print/template/mod.rs b/crates/oxc_formatter/src/print/template/mod.rs index 9529e9bd9c..13d032f634 100644 --- a/crates/oxc_formatter/src/print/template/mod.rs +++ b/crates/oxc_formatter/src/print/template/mod.rs @@ -23,7 +23,10 @@ use crate::{ call_expression::is_test_each_pattern, expression::is_member_expression_without_chain_wrappers, format_node_without_trailing_comments::FormatNodeWithoutTrailingComments, - tailwindcss::{tailwind_context_for_callee, write_tailwind_template_element}, + tailwindcss::{ + tailwind_context_for_callee, tailwind_context_for_class_list, + write_tailwind_template_element, + }, }, write, }; @@ -110,16 +113,9 @@ impl<'a> FormatWrite<'a> for AstNode<'_, 'a, TaggedTemplateExpression<'a>> { impl<'a> FormatWrite<'a> for AstNode<'_, 'a, TemplateElement<'a>> { fn write(&self, f: &mut JsFormatter<'_, 'a>) { - let source = f.source_text().text_for(self); - // Check if we're in a Tailwind context via the stack // (handles JSXAttribute, CallExpression, TaggedTemplateExpression, and nested contexts) - let tailwind_ctx = f.context().tailwind_context().copied().filter(|_| { - // No whitespace means only one class, so no need to sort - source.as_bytes().iter().any(|&b| b.is_ascii_whitespace()) - }); - - if let Some(ctx) = tailwind_ctx { + if let Some(ctx) = tailwind_context_for_class_list(self, f) { write_tailwind_template_element(self, ctx, f); } else { write!(f, text(self.value.raw.as_str())); diff --git a/crates/oxc_formatter/src/utils/assignment_like.rs b/crates/oxc_formatter/src/utils/assignment_like.rs index 149087621f..edd2f2e43a 100644 --- a/crates/oxc_formatter/src/utils/assignment_like.rs +++ b/crates/oxc_formatter/src/utils/assignment_like.rs @@ -21,10 +21,7 @@ use crate::{ format_node_without_trailing_comments::FormatNodeWithoutTrailingComments, member_chain::is_member_call_chain, object::{format_property_key, write_member_name}, - tailwindcss::{ - collapsed_class_list_width, tailwind_collapses_template_line_breaks, - tailwind_context_for_callee, - }, + tailwindcss::{collapsed_class_list_width, tailwind_context_for_callee}, typecast::is_cast_target, }, write, @@ -1251,6 +1248,11 @@ fn is_short_argument( callee: &Expression, f: &JsFormatter, ) -> bool { + // Decided by `callee` alone: a Tailwind one pushes its context, a non-Tailwind one hides an outer one + // (except for a call printed as a `MemberChain`, which pushes none) + let collapses_tailwind_whitespace = + || tailwind_context_for_callee(callee, f).is_some_and(|ctx| !ctx.preserve_whitespace); + match argument { Expression::Identifier(identifier) => identifier.name.width() <= threshold as usize, Expression::UnaryExpression(unary_expression) => { @@ -1265,9 +1267,7 @@ fn is_short_argument( ); let text = formatter.clean_text(f); - let width = if tailwind_context_for_callee(callee, f) - .is_some_and(|ctx| !ctx.preserve_whitespace) - { + let width = if collapses_tailwind_whitespace() { // Opening and closing quotes, and the classes 2 + collapsed_class_list_width(&text[1..text.len() - 1]) } else { @@ -1281,7 +1281,7 @@ fn is_short_argument( // Prettier: https://github.com/prettier/prettier/blob/a043ac0d733c4d53f980aa73807a63fc914f23bd/src/language-js/print/assignment.js#L402-L405 literal.quasis.len() == 1 && { let raw = literal.quasis[0].value.raw; - if tailwind_collapses_template_line_breaks(Some(callee), f) { + if collapses_tailwind_whitespace() { collapsed_class_list_width(&raw) <= threshold as usize } else { raw.width() <= threshold as usize && !raw.contains('\n') diff --git a/crates/oxc_formatter/src/utils/object.rs b/crates/oxc_formatter/src/utils/object.rs index c0165cf95b..f1569780fd 100644 --- a/crates/oxc_formatter/src/utils/object.rs +++ b/crates/oxc_formatter/src/utils/object.rs @@ -9,7 +9,7 @@ use crate::{ formatter::{JsFormatter, JsFormatterExt as _}, utils::{ string::{FormatLiteralStringToken, StringLiteralParentKind}, - tailwindcss::{tailwind_context_for_string_literal, write_tailwind_string_literal}, + tailwindcss::{tailwind_context_for_class_list, write_tailwind_string_literal}, }, write, }; @@ -26,7 +26,7 @@ pub fn format_property_key<'a>( // Check if we're in a Tailwind context and the key is a string literal with multiple classes if let AstNodeKind::StringLiteral(string) = &key.as_kind() { - if let Some(ctx) = tailwind_context_for_string_literal(string, f) { + if let Some(ctx) = tailwind_context_for_class_list(string, f) { // Reuse the existing Tailwind string literal writer write_tailwind_string_literal(string, ctx, f); return; @@ -57,7 +57,7 @@ pub fn write_member_name<'a>( f: &mut JsFormatter<'_, 'a>, ) -> usize { if let AstNodeKind::StringLiteral(string) = &key.as_kind() { - if let Some(ctx) = tailwind_context_for_string_literal(string, f) { + if let Some(ctx) = tailwind_context_for_class_list(string, f) { // Reuse the existing Tailwind string literal writer string.format_leading_comments(f); let width = write_tailwind_string_literal(string, ctx, f); diff --git a/crates/oxc_formatter/src/utils/tailwindcss.rs b/crates/oxc_formatter/src/utils/tailwindcss.rs index 3985fd9808..f8d88aa823 100644 --- a/crates/oxc_formatter/src/utils/tailwindcss.rs +++ b/crates/oxc_formatter/src/utils/tailwindcss.rs @@ -26,25 +26,19 @@ use super::string::{FormatLiteralStringToken, StringLiteralParentKind}; // Detection Functions // ============================================================================ -/// Returns the Tailwind context if we should sort classes in the given string literal. +/// Returns the Tailwind context if we should sort classes in the given string literal or template element. /// /// Returns `Some(ctx)` when: -/// - We're inside a Tailwind function call context -/// - The context is not disabled (e.g., not inside a nested non-Tailwind call) -/// - The string contains whitespace (indicating multiple classes to sort) -pub fn tailwind_context_for_string_literal<'a>( - string: &AstNode<'_, 'a, StringLiteral<'a>>, - f: &JsFormatter<'_, 'a>, +/// - We're inside a Tailwind class context (see [`JsFormatContext::tailwind_context`]) +/// - The source contains whitespace (no whitespace means only one class, so no need to sort) +pub fn tailwind_context_for_class_list( + node: &impl GetSpan, + f: &JsFormatter<'_, '_>, ) -> Option { - f.context().tailwind_context().copied().filter(|ctx| { - let text = f.source_text().text_for(string); - - if ctx.disabled { - return false; - } - - text.as_bytes().iter().any(|&b| b.is_ascii_whitespace()) - }) + f.context() + .tailwind_context() + .copied() + .filter(|_| f.source_text().text_for(node).bytes().any(|b| b.is_ascii_whitespace())) } /// Checks if a JSX attribute is a Tailwind class attribute. @@ -84,17 +78,16 @@ pub fn tailwind_context_for_jsx_attribute( .map(|opts| TailwindContextEntry::new(opts.preserve_whitespace)) } -/// Whether a template literal printed at this point has its whitespace, including line breaks, collapsed. -/// - `callee` is the tag of a tagged template, or the callee of the call it's an argument of -/// - Reads what `TemplateElement` reads: the current Tailwind context, or the one a Tailwind `callee` pushes +/// Whether a template literal printed at this point (under `tag` when tagged) has its line breaks collapsed. +/// - Reads what `TemplateElement` reads: the current Tailwind context, or the one a Tailwind `tag` pushes pub fn tailwind_collapses_template_line_breaks( - callee: Option<&Expression<'_>>, + tag: Option<&Expression<'_>>, f: &JsFormatter<'_, '_>, ) -> bool { f.context() .tailwind_context() .copied() - .or_else(|| callee.and_then(|callee| tailwind_context_for_callee(callee, f))) + .or_else(|| tag.and_then(|tag| tailwind_context_for_callee(tag, f))) .is_some_and(|ctx| !ctx.preserve_whitespace) }