|
| 1 | +use crate::frameworks::vue::vue_component::{AnyPotentialVueComponent, VueComponentQuery}; |
| 2 | +use biome_analyze::{ |
| 3 | + Rule, RuleDiagnostic, RuleDomain, RuleSource, context::RuleContext, declare_lint_rule, |
| 4 | +}; |
| 5 | +use biome_console::markup; |
| 6 | +use biome_js_syntax::{ |
| 7 | + AnyJsArrowFunctionParameters, AnyJsBindingPattern, AnyJsExpression, AnyJsFunction, |
| 8 | + AnyJsObjectMember, AnyJsObjectMemberName, JsCallExpression, JsMethodObjectMember, |
| 9 | + JsObjectMemberList, JsParameters, |
| 10 | +}; |
| 11 | +use biome_rowan::{AstNode, AstSeparatedList, TextRange}; |
| 12 | + |
| 13 | +declare_lint_rule! { |
| 14 | + /// Disallow destructuring of `props` passed to `setup` in Vue projects. |
| 15 | + /// |
| 16 | + /// In Vue's Composition API, props must be accessed as `props.propertyName` to maintain |
| 17 | + /// reactivity. Destructuring `props` directly in the `setup` function parameters will |
| 18 | + /// cause the resulting variables to lose their reactive nature. |
| 19 | + /// |
| 20 | + /// ## Examples |
| 21 | + /// |
| 22 | + /// ### Invalid |
| 23 | + /// |
| 24 | + /// ```js,expect_diagnostic |
| 25 | + /// export default { |
| 26 | + /// setup({ count }) { |
| 27 | + /// return () => h('div', count); |
| 28 | + /// } |
| 29 | + /// } |
| 30 | + /// ``` |
| 31 | + /// |
| 32 | + /// ### Valid |
| 33 | + /// |
| 34 | + /// ```js |
| 35 | + /// export default { |
| 36 | + /// setup(props) { |
| 37 | + /// return () => h('div', props.count); |
| 38 | + /// } |
| 39 | + /// } |
| 40 | + /// ``` |
| 41 | + /// |
| 42 | + pub NoVueSetupPropsReactivityLoss { |
| 43 | + version: "2.2.6", |
| 44 | + name: "noVueSetupPropsReactivityLoss", |
| 45 | + language: "js", |
| 46 | + domains: &[RuleDomain::Vue], |
| 47 | + recommended: false, |
| 48 | + sources: &[RuleSource::EslintVueJs("no-setup-props-reactivity-loss").inspired()], |
| 49 | + } |
| 50 | +} |
| 51 | + |
| 52 | +pub struct Violation(TextRange); |
| 53 | + |
| 54 | +impl Violation { |
| 55 | + fn range(&self) -> TextRange { |
| 56 | + self.0 |
| 57 | + } |
| 58 | +} |
| 59 | + |
| 60 | +enum SetupFunction { |
| 61 | + Function(AnyJsFunction), |
| 62 | + Method(JsMethodObjectMember), |
| 63 | +} |
| 64 | + |
| 65 | +impl Rule for NoVueSetupPropsReactivityLoss { |
| 66 | + type Query = VueComponentQuery; |
| 67 | + type State = Violation; |
| 68 | + type Signals = Vec<Self::State>; |
| 69 | + type Options = (); |
| 70 | + |
| 71 | + fn run(ctx: &RuleContext<Self>) -> Self::Signals { |
| 72 | + match ctx.query() { |
| 73 | + // Case: export default { setup(props) { ... } } |
| 74 | + AnyPotentialVueComponent::JsExportDefaultExpressionClause(export) => { |
| 75 | + let Some(expr) = export.expression().ok() else { |
| 76 | + return vec![]; |
| 77 | + }; |
| 78 | + let Some(obj_expr) = expr.as_js_object_expression() else { |
| 79 | + return vec![]; |
| 80 | + }; |
| 81 | + check_object_members(&obj_expr.members()) |
| 82 | + } |
| 83 | + // Case: export default defineComponent({ setup(props) { ... } }) |
| 84 | + AnyPotentialVueComponent::JsCallExpression(call_expr) => { |
| 85 | + check_call_expression_setup(call_expr) |
| 86 | + } |
| 87 | + } |
| 88 | + } |
| 89 | + |
| 90 | + fn diagnostic(_ctx: &RuleContext<Self>, state: &Self::State) -> Option<RuleDiagnostic> { |
| 91 | + Some( |
| 92 | + RuleDiagnostic::new( |
| 93 | + rule_category!(), |
| 94 | + state.range(), |
| 95 | + markup! { |
| 96 | + "Destructuring `props` in the `setup` function parameters loses reactivity." |
| 97 | + }, |
| 98 | + ) |
| 99 | + .note(markup! { |
| 100 | + "To preserve reactivity, access props as properties: `props.propertyName`." |
| 101 | + }), |
| 102 | + ) |
| 103 | + } |
| 104 | +} |
| 105 | + |
| 106 | +fn check_call_expression_setup(call_expr: &JsCallExpression) -> Vec<Violation> { |
| 107 | + if let Ok(args) = call_expr.arguments() |
| 108 | + && let Some(Ok(arg)) = args.args().iter().next() |
| 109 | + && let Some(expr) = arg.as_any_js_expression() |
| 110 | + && let Some(obj_expr) = expr.as_js_object_expression() |
| 111 | + { |
| 112 | + check_object_members(&obj_expr.members()) |
| 113 | + } else { |
| 114 | + vec![] |
| 115 | + } |
| 116 | +} |
| 117 | + |
| 118 | +fn check_object_members(members: &JsObjectMemberList) -> Vec<Violation> { |
| 119 | + members |
| 120 | + .iter() |
| 121 | + .filter_map(|m| m.ok()) |
| 122 | + .filter_map(|member| find_setup_function(&member)) |
| 123 | + .filter_map(|setup| check_setup_params(&setup)) |
| 124 | + .collect() |
| 125 | +} |
| 126 | + |
| 127 | +fn check_setup_params(setup_fn: &SetupFunction) -> Option<Violation> { |
| 128 | + let first_param = get_first_parameter(setup_fn)?; |
| 129 | + |
| 130 | + match first_param { |
| 131 | + AnyJsBindingPattern::JsObjectBindingPattern(obj) => Some(Violation(obj.range())), |
| 132 | + AnyJsBindingPattern::JsArrayBindingPattern(arr) => Some(Violation(arr.range())), |
| 133 | + AnyJsBindingPattern::AnyJsBinding(_) => None, |
| 134 | + } |
| 135 | +} |
| 136 | + |
| 137 | +fn get_first_parameter(setup_fn: &SetupFunction) -> Option<AnyJsBindingPattern> { |
| 138 | + match setup_fn { |
| 139 | + SetupFunction::Method(method) => { |
| 140 | + let params = method.parameters().ok()?; |
| 141 | + get_first_binding_from_params(¶ms) |
| 142 | + } |
| 143 | + SetupFunction::Function(func) => get_function_first_parameter(func), |
| 144 | + } |
| 145 | +} |
| 146 | + |
| 147 | +fn get_function_first_parameter(func: &AnyJsFunction) -> Option<AnyJsBindingPattern> { |
| 148 | + match func { |
| 149 | + AnyJsFunction::JsArrowFunctionExpression(arrow) => match arrow.parameters().ok()? { |
| 150 | + AnyJsArrowFunctionParameters::AnyJsBinding(binding) => { |
| 151 | + Some(AnyJsBindingPattern::AnyJsBinding(binding)) |
| 152 | + } |
| 153 | + AnyJsArrowFunctionParameters::JsParameters(params) => { |
| 154 | + get_first_binding_from_params(¶ms) |
| 155 | + } |
| 156 | + }, |
| 157 | + AnyJsFunction::JsFunctionDeclaration(decl) => { |
| 158 | + get_first_binding_from_params(&decl.parameters().ok()?) |
| 159 | + } |
| 160 | + AnyJsFunction::JsFunctionExpression(expr) => { |
| 161 | + get_first_binding_from_params(&expr.parameters().ok()?) |
| 162 | + } |
| 163 | + _ => None, |
| 164 | + } |
| 165 | +} |
| 166 | + |
| 167 | +fn find_setup_function(member: &AnyJsObjectMember) -> Option<SetupFunction> { |
| 168 | + match member { |
| 169 | + AnyJsObjectMember::JsMethodObjectMember(method) => method |
| 170 | + .name() |
| 171 | + .ok() |
| 172 | + .filter(is_named_setup) |
| 173 | + .map(|_| SetupFunction::Method(method.clone())), |
| 174 | + AnyJsObjectMember::JsPropertyObjectMember(property) => { |
| 175 | + let name = property.name().ok()?; |
| 176 | + if !is_named_setup(&name) { |
| 177 | + return None; |
| 178 | + } |
| 179 | + let value = property.value().ok()?; |
| 180 | + let func = get_function_from_expression(&value)?; |
| 181 | + Some(SetupFunction::Function(func)) |
| 182 | + } |
| 183 | + _ => None, |
| 184 | + } |
| 185 | +} |
| 186 | + |
| 187 | +fn get_function_from_expression(expr: &AnyJsExpression) -> Option<AnyJsFunction> { |
| 188 | + match expr { |
| 189 | + AnyJsExpression::JsFunctionExpression(func) => { |
| 190 | + Some(AnyJsFunction::JsFunctionExpression(func.clone())) |
| 191 | + } |
| 192 | + AnyJsExpression::JsArrowFunctionExpression(arrow) => { |
| 193 | + Some(AnyJsFunction::JsArrowFunctionExpression(arrow.clone())) |
| 194 | + } |
| 195 | + _ => None, |
| 196 | + } |
| 197 | +} |
| 198 | + |
| 199 | +fn is_named_setup(name: &AnyJsObjectMemberName) -> bool { |
| 200 | + name.name().is_some_and(|text| text.text() == "setup") |
| 201 | +} |
| 202 | + |
| 203 | +fn get_first_binding_from_params(params: &JsParameters) -> Option<AnyJsBindingPattern> { |
| 204 | + params |
| 205 | + .items() |
| 206 | + .iter() |
| 207 | + .next()? |
| 208 | + .ok()? |
| 209 | + .as_any_js_formal_parameter()? |
| 210 | + .as_js_formal_parameter()? |
| 211 | + .binding() |
| 212 | + .ok() |
| 213 | +} |
0 commit comments