Skip to content

Commit f0e760b

Browse files
committed
feat(linter): add vue/define-props-destructuring rule (#14272)
related #11440 https://eslint.vuejs.org/rules/define-props-destructuring.html
1 parent bdf9010 commit f0e760b

File tree

4 files changed

+393
-0
lines changed

4 files changed

+393
-0
lines changed

crates/oxc_linter/src/generated/rule_runner_impls.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2902,6 +2902,11 @@ impl RuleRunner for crate::rules::vue::define_props_declaration::DefinePropsDecl
29022902
Some(&AstTypesBitset::from_types(&[AstType::CallExpression]));
29032903
}
29042904

2905+
impl RuleRunner for crate::rules::vue::define_props_destructuring::DefinePropsDestructuring {
2906+
const NODE_TYPES: Option<&AstTypesBitset> =
2907+
Some(&AstTypesBitset::from_types(&[AstType::CallExpression]));
2908+
}
2909+
29052910
impl RuleRunner for crate::rules::vue::max_props::MaxProps {
29062911
const NODE_TYPES: Option<&AstTypesBitset> = None;
29072912
}

crates/oxc_linter/src/rules.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -642,6 +642,7 @@ pub(crate) mod node {
642642
pub(crate) mod vue {
643643
pub mod define_emits_declaration;
644644
pub mod define_props_declaration;
645+
pub mod define_props_destructuring;
645646
pub mod max_props;
646647
pub mod no_multiple_slot_args;
647648
pub mod no_required_prop_with_default;
@@ -1241,6 +1242,7 @@ oxc_macros::declare_all_lint_rules! {
12411242
vitest::prefer_to_be_object,
12421243
vitest::prefer_to_be_truthy,
12431244
vitest::require_local_test_context_for_concurrent_snapshots,
1245+
vue::define_props_destructuring,
12441246
vue::define_emits_declaration,
12451247
vue::define_props_declaration,
12461248
vue::max_props,
Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
1+
use oxc_ast::{AstKind, ast::BindingPatternKind};
2+
use oxc_diagnostics::OxcDiagnostic;
3+
use oxc_macros::declare_oxc_lint;
4+
use oxc_span::Span;
5+
use schemars::JsonSchema;
6+
use serde::{Deserialize, Serialize};
7+
8+
use crate::{
9+
AstNode,
10+
context::{ContextHost, LintContext},
11+
frameworks::FrameworkOptions,
12+
rule::Rule,
13+
};
14+
15+
fn prefer_destructuring_diagnostic(span: Span) -> OxcDiagnostic {
16+
OxcDiagnostic::warn("Prefer destructuring from `defineProps` directly.").with_label(span)
17+
}
18+
19+
fn avoid_destructuring_diagnostic(span: Span) -> OxcDiagnostic {
20+
OxcDiagnostic::warn("Avoid destructuring from `defineProps`.").with_label(span)
21+
}
22+
23+
fn avoid_with_defaults_diagnostic(span: Span) -> OxcDiagnostic {
24+
OxcDiagnostic::warn("Avoid using `withDefaults` with destructuring.").with_label(span)
25+
}
26+
27+
#[derive(Debug, Default, Clone, Eq, PartialEq, Serialize, Deserialize, JsonSchema)]
28+
#[schemars(untagged, rename_all = "camelCase")]
29+
enum Destructure {
30+
#[default]
31+
Always,
32+
Never,
33+
}
34+
35+
#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)]
36+
pub struct DefinePropsDestructuring {
37+
destructure: Destructure,
38+
}
39+
40+
declare_oxc_lint!(
41+
/// ### What it does
42+
///
43+
/// This rule enforces a consistent style for handling Vue 3 Composition API props,
44+
/// allowing you to choose between requiring destructuring or prohibiting it.
45+
///
46+
/// ### Why is this bad?
47+
///
48+
/// By default, the rule requires you to use destructuring syntax when using `defineProps`
49+
/// instead of storing props in a variable and warns against combining `withDefaults` with destructuring.
50+
///
51+
/// ### Examples
52+
///
53+
/// Examples of **incorrect** code for this rule:
54+
/// ```vue
55+
/// <script setup lang="ts">
56+
/// const props = defineProps(['foo']);
57+
/// const propsWithDefaults = withDefaults(defineProps(['foo']), { foo: 'default' });
58+
/// const { baz } = withDefaults(defineProps(['baz']), { baz: 'default' });
59+
/// const props = defineProps<{ foo?: string }>()
60+
/// const propsWithDefaults = withDefaults(defineProps<{ foo?: string }>(), { foo: 'default' })
61+
/// </script>
62+
/// ```
63+
///
64+
/// Examples of **correct** code for this rule:
65+
/// ```vue
66+
/// <script setup lang="ts">
67+
/// const { foo } = defineProps(['foo'])
68+
/// const { bar = 'default' } = defineProps(['bar'])
69+
/// const { foo } = defineProps<{ foo?: string }>()
70+
/// const { bar = 'default' } = defineProps<{ bar?: string }>()
71+
/// </script>
72+
/// ```
73+
///
74+
/// ### Options
75+
/// ```json
76+
/// {
77+
/// "vue/define-props-destructuring": ["error", {
78+
/// "destructure": "always" | "never"
79+
/// }]
80+
/// }
81+
/// ```
82+
/// `destructure` - Sets the destructuring preference for props
83+
/// - `"always"` (default) - Requires destructuring when using `defineProps` and warns against using `withDefaults` with destructuring
84+
/// - `"never"` - Requires using a variable to store props and prohibits destructuring
85+
DefinePropsDestructuring,
86+
vue,
87+
style,
88+
config = DefinePropsDestructuring,
89+
);
90+
91+
impl Rule for DefinePropsDestructuring {
92+
fn from_configuration(value: serde_json::Value) -> Self {
93+
let val = value
94+
.get(0)
95+
.and_then(|v| v.as_object())
96+
.and_then(|obj| obj.get("destructure").and_then(|v| v.as_str()));
97+
Self {
98+
destructure: match val {
99+
Some("never") => Destructure::Never,
100+
_ => Destructure::Always,
101+
},
102+
}
103+
}
104+
105+
fn run<'a>(&self, node: &AstNode<'a>, ctx: &LintContext<'a>) {
106+
let AstKind::CallExpression(call_expr) = node.kind() else { return };
107+
108+
// only check call Expression which is `defineProps`
109+
if call_expr
110+
.callee
111+
.get_identifier_reference()
112+
.is_none_or(|reference| reference.name != "defineProps")
113+
{
114+
return;
115+
}
116+
117+
if call_expr.arguments.is_empty() && call_expr.type_arguments.is_none() {
118+
return;
119+
}
120+
121+
let parent = &ctx.nodes().parent_node(node.id());
122+
let with_defaults_span = get_parent_with_defaults_call_expression_span(parent, ctx);
123+
let has_destructuring = is_parent_destructuring_variable(parent, ctx);
124+
125+
if self.destructure == Destructure::Never {
126+
if has_destructuring {
127+
ctx.diagnostic(avoid_destructuring_diagnostic(call_expr.span));
128+
}
129+
} else if !has_destructuring {
130+
ctx.diagnostic(prefer_destructuring_diagnostic(call_expr.span));
131+
} else if let Some(span) = with_defaults_span {
132+
ctx.diagnostic(avoid_with_defaults_diagnostic(span));
133+
}
134+
}
135+
136+
fn should_run(&self, ctx: &ContextHost<'_>) -> bool {
137+
ctx.frameworks_options() == FrameworkOptions::VueSetup
138+
}
139+
}
140+
141+
fn get_parent_with_defaults_call_expression_span(
142+
parent: &AstNode<'_>,
143+
ctx: &LintContext<'_>,
144+
) -> Option<Span> {
145+
let AstKind::Argument(_) = parent.kind() else { return None };
146+
let parent = &ctx.nodes().parent_kind(parent.id());
147+
let AstKind::CallExpression(call_expr) = parent else { return None };
148+
149+
call_expr.callee.get_identifier_reference().and_then(|reference| {
150+
if reference.name == "withDefaults" { Some(reference.span) } else { None }
151+
})
152+
}
153+
154+
fn is_parent_destructuring_variable(parent: &AstNode<'_>, ctx: &LintContext<'_>) -> bool {
155+
let Some(declarator) = (match parent.kind() {
156+
AstKind::VariableDeclarator(var_decl) => Some(var_decl),
157+
_ => ctx.nodes().ancestor_kinds(parent.id()).find_map(|kind| {
158+
if let AstKind::VariableDeclarator(var_decl) = kind { Some(var_decl) } else { None }
159+
}),
160+
}) else {
161+
return false;
162+
};
163+
164+
matches!(declarator.id.kind, BindingPatternKind::ObjectPattern(_))
165+
}
166+
167+
#[test]
168+
fn test() {
169+
use crate::tester::Tester;
170+
use std::path::PathBuf;
171+
172+
let pass = vec![
173+
(
174+
"
175+
<script setup>
176+
const props = defineProps()
177+
</script>
178+
",
179+
None,
180+
None,
181+
Some(PathBuf::from("test.vue")),
182+
),
183+
(
184+
"
185+
<script setup>
186+
const { foo = 'default' } = defineProps(['foo'])
187+
</script>
188+
",
189+
None,
190+
None,
191+
Some(PathBuf::from("test.vue")),
192+
),
193+
(
194+
r#"
195+
<script setup lang="ts">
196+
const { foo = 'default' } = defineProps<{ foo?: string }>()
197+
</script>
198+
"#,
199+
None,
200+
None,
201+
Some(PathBuf::from("test.vue")),
202+
), // { "parserOptions": { "parser": require.resolve("@typescript-eslint/parser") } },
203+
(
204+
"
205+
<script setup>
206+
const props = defineProps(['foo'])
207+
</script>
208+
",
209+
Some(serde_json::json!([{ "destructure": "never" }])),
210+
None,
211+
Some(PathBuf::from("test.vue")),
212+
),
213+
(
214+
"
215+
<script setup>
216+
const props = withDefaults(defineProps(['foo']), { foo: 'default' })
217+
</script>
218+
",
219+
Some(serde_json::json!([{ "destructure": "never" }])),
220+
None,
221+
Some(PathBuf::from("test.vue")),
222+
),
223+
(
224+
r#"
225+
<script setup lang="ts">
226+
const props = defineProps<{ foo?: string }>()
227+
</script>
228+
"#,
229+
Some(serde_json::json!([{ "destructure": "never" }])),
230+
None,
231+
Some(PathBuf::from("test.vue")),
232+
), // { "parserOptions": { "parser": require.resolve("@typescript-eslint/parser") } }
233+
];
234+
235+
let fail = vec![
236+
(
237+
"
238+
<script setup>
239+
const props = defineProps(['foo'])
240+
</script>
241+
",
242+
None,
243+
None,
244+
Some(PathBuf::from("test.vue")),
245+
),
246+
(
247+
"
248+
<script setup>
249+
const props = withDefaults(defineProps(['foo']), { foo: 'default' })
250+
</script>
251+
",
252+
None,
253+
None,
254+
Some(PathBuf::from("test.vue")),
255+
),
256+
(
257+
"
258+
<script setup>
259+
const { foo } = withDefaults(defineProps(['foo']), { foo: 'default' })
260+
</script>
261+
",
262+
None,
263+
None,
264+
Some(PathBuf::from("test.vue")),
265+
),
266+
(
267+
r#"
268+
<script setup lang="ts">
269+
const props = withDefaults(defineProps<{ foo?: string }>(), { foo: 'default' })
270+
</script>
271+
"#,
272+
None,
273+
None,
274+
Some(PathBuf::from("test.vue")),
275+
), // { "parserOptions": { "parser": require.resolve("@typescript-eslint/parser") } },
276+
(
277+
r#"
278+
<script setup lang="ts">
279+
const { foo } = withDefaults(defineProps<{ foo?: string }>(), { foo: 'default' })
280+
</script>
281+
"#,
282+
None,
283+
None,
284+
Some(PathBuf::from("test.vue")),
285+
), // { "parserOptions": { "parser": require.resolve("@typescript-eslint/parser") } },
286+
(
287+
"
288+
<script setup>
289+
const { foo } = defineProps(['foo'])
290+
</script>
291+
",
292+
Some(serde_json::json!([{ "destructure": "never" }])),
293+
None,
294+
Some(PathBuf::from("test.vue")),
295+
),
296+
(
297+
"
298+
<script setup>
299+
const { foo } = withDefaults(defineProps(['foo']), { foo: 'default' })
300+
</script>
301+
",
302+
Some(serde_json::json!([{ "destructure": "never" }])),
303+
None,
304+
Some(PathBuf::from("test.vue")),
305+
),
306+
(
307+
r#"
308+
<script setup lang="ts">
309+
const { foo } = defineProps<{ foo?: string }>()
310+
</script>
311+
"#,
312+
Some(serde_json::json!([{ "destructure": "never" }])),
313+
None,
314+
Some(PathBuf::from("test.vue")),
315+
), // { "parserOptions": { "parser": require.resolve("@typescript-eslint/parser") } }
316+
];
317+
318+
Tester::new(DefinePropsDestructuring::NAME, DefinePropsDestructuring::PLUGIN, pass, fail)
319+
.test_and_snapshot();
320+
}

0 commit comments

Comments
 (0)