|
| 1 | +use biome_analyze::{ |
| 2 | + FixKind, Rule, RuleDiagnostic, RuleDomain, RuleSource, context::RuleContext, |
| 3 | + declare_lint_rule, |
| 4 | +}; |
| 5 | +use biome_console::markup; |
| 6 | +use biome_js_semantic::ReferencesExtensions; |
| 7 | +use biome_js_syntax::{ |
| 8 | + AnyJsBinding, AnyJsBindingPattern, AnyJsCallArgument, AnyJsExpression, JsCallExpression, |
| 9 | + JsFileSource, JsImport, JsVariableDeclarator, |
| 10 | +}; |
| 11 | +use biome_rowan::{AstNode, AstSeparatedList, BatchMutationExt, TokenText}; |
| 12 | +use biome_rule_options::no_svelte_unnecessary_state_wrap::NoSvelteUnnecessaryStateWrapOptions; |
| 13 | + |
| 14 | +use crate::JsRuleAction; |
| 15 | +use crate::services::semantic::Semantic; |
| 16 | + |
| 17 | +/// Classes from `svelte/reactivity` that are already reactive without `$state`. |
| 18 | +const REACTIVE_CLASSES: &[&str] = &[ |
| 19 | + "SvelteSet", |
| 20 | + "SvelteMap", |
| 21 | + "SvelteURL", |
| 22 | + "SvelteURLSearchParams", |
| 23 | + "SvelteDate", |
| 24 | + "MediaQuery", |
| 25 | +]; |
| 26 | + |
| 27 | +const SVELTE_REACTIVITY_MODULE: &str = "svelte/reactivity"; |
| 28 | + |
| 29 | +declare_lint_rule! { |
| 30 | + /// Disallow unnecessary `$state` wrapping of reactive classes. |
| 31 | + /// |
| 32 | + /// Several classes exported from `svelte/reactivity` — such as `SvelteMap`, `SvelteSet`, and |
| 33 | + /// `SvelteDate` — are already deeply reactive without the `$state` rune. Wrapping them in |
| 34 | + /// `$state(...)` is redundant and may mislead readers into thinking the reactivity comes from |
| 35 | + /// the rune rather than the class itself. |
| 36 | + /// |
| 37 | + /// Use the `additionalReactiveClasses` option to extend this list with custom reactive classes |
| 38 | + /// from your own codebase. |
| 39 | + /// |
| 40 | + /// Use `allowReassign: true` if you need to reassign the variable itself after declaration, |
| 41 | + /// which requires `$state` to track the reference change. |
| 42 | + /// |
| 43 | + /// ## Examples |
| 44 | + /// |
| 45 | + /// ### Invalid |
| 46 | + /// |
| 47 | + /// ```svelte,expect_diagnostic |
| 48 | + /// <script> |
| 49 | + /// import { SvelteMap } from "svelte/reactivity"; |
| 50 | + /// const map = $state(new SvelteMap()); |
| 51 | + /// </script> |
| 52 | + /// ``` |
| 53 | + /// |
| 54 | + /// ### Valid |
| 55 | + /// |
| 56 | + /// ```svelte |
| 57 | + /// <script> |
| 58 | + /// import { SvelteMap } from "svelte/reactivity"; |
| 59 | + /// const map = new SvelteMap(); |
| 60 | + /// </script> |
| 61 | + /// ``` |
| 62 | + /// |
| 63 | + pub NoSvelteUnnecessaryStateWrap { |
| 64 | + version: "next", |
| 65 | + name: "noSvelteUnnecessaryStateWrap", |
| 66 | + language: "js", |
| 67 | + domains: &[RuleDomain::Svelte], |
| 68 | + sources: &[RuleSource::EslintSvelte("no-unnecessary-state-wrap").same()], |
| 69 | + recommended: false, |
| 70 | + fix_kind: FixKind::Unsafe, |
| 71 | + } |
| 72 | +} |
| 73 | + |
| 74 | +pub struct RuleState { |
| 75 | + /// Name of the reactive class being unnecessarily wrapped. |
| 76 | + class_name: TokenText, |
| 77 | + /// The inner expression (the `new X()` / `X()` argument) to replace the call with. |
| 78 | + inner_expr: AnyJsExpression, |
| 79 | +} |
| 80 | + |
| 81 | +impl Rule for NoSvelteUnnecessaryStateWrap { |
| 82 | + type Query = Semantic<JsCallExpression>; |
| 83 | + type State = RuleState; |
| 84 | + type Signals = Option<Self::State>; |
| 85 | + type Options = NoSvelteUnnecessaryStateWrapOptions; |
| 86 | + |
| 87 | + fn run(ctx: &RuleContext<Self>) -> Self::Signals { |
| 88 | + let call = ctx.query(); |
| 89 | + let model = ctx.model(); |
| 90 | + |
| 91 | + if !ctx |
| 92 | + .source_type::<JsFileSource>() |
| 93 | + .as_embedding_kind() |
| 94 | + .is_svelte() |
| 95 | + { |
| 96 | + return None; |
| 97 | + } |
| 98 | + |
| 99 | + // Callee must be the `$state` rune. |
| 100 | + let AnyJsExpression::JsIdentifierExpression(callee_ident) = call.callee().ok()? else { |
| 101 | + return None; |
| 102 | + }; |
| 103 | + if callee_ident |
| 104 | + .name() |
| 105 | + .ok()? |
| 106 | + .value_token() |
| 107 | + .ok()? |
| 108 | + .text_trimmed() |
| 109 | + != "$state" |
| 110 | + { |
| 111 | + return None; |
| 112 | + } |
| 113 | + |
| 114 | + // Must be called with exactly one argument. |
| 115 | + let args = call.arguments().ok()?.args(); |
| 116 | + if args.len() != 1 { |
| 117 | + return None; |
| 118 | + } |
| 119 | + let AnyJsCallArgument::AnyJsExpression(arg_expr) = args.first()?.ok()? else { |
| 120 | + return None; |
| 121 | + }; |
| 122 | + |
| 123 | + // Argument must be `new X(...)` or `X(...)`. |
| 124 | + let class_callee = match &arg_expr { |
| 125 | + AnyJsExpression::JsNewExpression(new_expr) => new_expr.callee().ok()?, |
| 126 | + AnyJsExpression::JsCallExpression(inner_call) => inner_call.callee().ok()?, |
| 127 | + _ => return None, |
| 128 | + }; |
| 129 | + let AnyJsExpression::JsIdentifierExpression(class_ident) = class_callee else { |
| 130 | + return None; |
| 131 | + }; |
| 132 | + let class_ref = class_ident.name().ok()?; |
| 133 | + let class_name_text = class_ref.value_token().ok()?.token_text_trimmed(); |
| 134 | + |
| 135 | + let options = ctx.options(); |
| 136 | + |
| 137 | + let is_builtin = REACTIVE_CLASSES.contains(&class_name_text.text()); |
| 138 | + let is_additional = options |
| 139 | + .additional_reactive_classes |
| 140 | + .as_deref() |
| 141 | + .unwrap_or(&[]) |
| 142 | + .iter() |
| 143 | + .any(|c| c.as_ref() == class_name_text.text()); |
| 144 | + |
| 145 | + if !is_builtin && !is_additional { |
| 146 | + return None; |
| 147 | + } |
| 148 | + |
| 149 | + // For built-in classes, verify the class is imported from `svelte/reactivity`. |
| 150 | + if is_builtin { |
| 151 | + let binding = model.binding(&class_ref)?; |
| 152 | + let imported_from = binding |
| 153 | + .syntax() |
| 154 | + .ancestors() |
| 155 | + .find_map(JsImport::cast) |
| 156 | + .and_then(|import| import.source_text().ok()); |
| 157 | + match imported_from { |
| 158 | + Some(source) if source.text() == SVELTE_REACTIVITY_MODULE => {} |
| 159 | + _ => return None, |
| 160 | + } |
| 161 | + } |
| 162 | + |
| 163 | + // If `allowReassign` is enabled, skip variables that are reassigned after declaration. |
| 164 | + if options.allow_reassign.unwrap_or(false) { |
| 165 | + if let Some(declarator) = |
| 166 | + call.syntax().ancestors().find_map(JsVariableDeclarator::cast) |
| 167 | + { |
| 168 | + if let Ok(AnyJsBindingPattern::AnyJsBinding(AnyJsBinding::JsIdentifierBinding( |
| 169 | + id_binding, |
| 170 | + ))) = declarator.id() |
| 171 | + { |
| 172 | + if id_binding.all_writes(model).next().is_some() { |
| 173 | + return None; |
| 174 | + } |
| 175 | + } |
| 176 | + } |
| 177 | + } |
| 178 | + |
| 179 | + Some(RuleState { |
| 180 | + class_name: class_name_text, |
| 181 | + inner_expr: arg_expr, |
| 182 | + }) |
| 183 | + } |
| 184 | + |
| 185 | + fn diagnostic(ctx: &RuleContext<Self>, state: &Self::State) -> Option<RuleDiagnostic> { |
| 186 | + let call = ctx.query(); |
| 187 | + Some( |
| 188 | + RuleDiagnostic::new( |
| 189 | + rule_category!(), |
| 190 | + call.range(), |
| 191 | + markup! { |
| 192 | + <Emphasis>{state.class_name.text()}</Emphasis>" is already reactive, wrapping it in "<Emphasis>"$state()"</Emphasis>" is unnecessary." |
| 193 | + }, |
| 194 | + ) |
| 195 | + .note(markup! { |
| 196 | + "Classes from "<Emphasis>"svelte/reactivity"</Emphasis>" track their own mutations without needing a "<Emphasis>"$state"</Emphasis>" wrapper." |
| 197 | + }) |
| 198 | + .note(markup! { |
| 199 | + "Remove the "<Emphasis>"$state()"</Emphasis>" wrapper. If you need to reassign the variable itself, enable the "<Emphasis>"allowReassign"</Emphasis>" option." |
| 200 | + }), |
| 201 | + ) |
| 202 | + } |
| 203 | + |
| 204 | + fn action(ctx: &RuleContext<Self>, state: &Self::State) -> Option<JsRuleAction> { |
| 205 | + let call = ctx.query(); |
| 206 | + let mut mutation = ctx.root().begin(); |
| 207 | + mutation.replace_node( |
| 208 | + AnyJsExpression::JsCallExpression(call.clone()), |
| 209 | + state.inner_expr.clone(), |
| 210 | + ); |
| 211 | + Some(JsRuleAction::new( |
| 212 | + ctx.metadata().action_category(ctx.category(), ctx.group()), |
| 213 | + ctx.metadata().applicability(), |
| 214 | + markup! { "Remove the unnecessary "<Emphasis>"$state()"</Emphasis>" wrapper." } |
| 215 | + .to_owned(), |
| 216 | + mutation, |
| 217 | + )) |
| 218 | + } |
| 219 | +} |
0 commit comments