Skip to content

Commit cadad2c

Browse files
authored
feat(lint): implement noVueDuplicateKeys rule (biomejs#7542)
1 parent 47907e7 commit cadad2c

File tree

45 files changed

+1276
-30
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+1276
-30
lines changed

.changeset/rude-horses-worry.md

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
---
2+
"@biomejs/biome": patch
3+
---
4+
5+
Added the rule [`noVueDuplicateKeys`](https://biomejs.dev/linter/rules/no-vue-duplicate-keys/), which prevents duplicate keys in Vue component definitions.
6+
7+
This rule prevents the use of duplicate keys across different Vue component options such as `props`, `data`, `computed`, `methods`, and `setup`. Even if keys don't conflict in the script tag, they may cause issues in the template since Vue allows direct access to these keys.
8+
9+
##### Invalid examples
10+
11+
```vue
12+
<script>
13+
export default {
14+
props: ['foo'],
15+
data() {
16+
return {
17+
foo: 'bar'
18+
};
19+
}
20+
};
21+
</script>
22+
```
23+
24+
```vue
25+
<script>
26+
export default {
27+
data() {
28+
return {
29+
message: 'hello'
30+
};
31+
},
32+
methods: {
33+
message() {
34+
console.log('duplicate key');
35+
}
36+
}
37+
};
38+
</script>
39+
```
40+
41+
```vue
42+
<script>
43+
export default {
44+
computed: {
45+
count() {
46+
return this.value * 2;
47+
}
48+
},
49+
methods: {
50+
count() {
51+
this.value++;
52+
}
53+
}
54+
};
55+
</script>
56+
```
57+
58+
##### Valid examples
59+
60+
```vue
61+
<script>
62+
export default {
63+
props: ['foo'],
64+
data() {
65+
return {
66+
bar: 'baz'
67+
};
68+
},
69+
methods: {
70+
handleClick() {
71+
console.log('unique key');
72+
}
73+
}
74+
};
75+
</script>
76+
```
77+
78+
```vue
79+
<script>
80+
export default {
81+
computed: {
82+
displayMessage() {
83+
return this.message.toUpperCase();
84+
}
85+
},
86+
methods: {
87+
clearMessage() {
88+
this.message = '';
89+
}
90+
}
91+
};
92+
</script>
93+
```

crates/biome_cli/src/execute/migrate/eslint_any_rule_to_biome.rs

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/biome_configuration/src/analyzer/linter/rules.rs

Lines changed: 48 additions & 27 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/biome_diagnostics_categories/src/categories.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ define_categories! {
184184
"lint/nursery/noUselessCatchBinding": "https://biomejs.dev/linter/rules/no-useless-catch-binding",
185185
"lint/nursery/noUselessUndefined": "https://biomejs.dev/linter/rules/no-useless-undefined",
186186
"lint/nursery/noVueDataObjectDeclaration": "https://biomejs.dev/linter/rules/no-vue-data-object-declaration",
187+
"lint/nursery/noVueDuplicateKeys": "https://biomejs.dev/linter/rules/no-vue-duplicate-keys",
187188
"lint/nursery/noVueReservedKeys": "https://biomejs.dev/linter/rules/no-vue-reserved-keys",
188189
"lint/nursery/noVueReservedProps": "https://biomejs.dev/linter/rules/no-vue-reserved-props",
189190
"lint/nursery/useAnchorHref": "https://biomejs.dev/linter/rules/use-anchor-href",

crates/biome_js_analyze/src/frameworks/vue/vue_component.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -710,15 +710,15 @@ declare_node_union! {
710710
impl VueDeclarationName for AnyVueSetupDeclaration {
711711
fn declaration_name(&self) -> Option<TokenText> {
712712
match self {
713-
Self::JsIdentifierBinding(ident) => Some(ident.name_token().ok()?.token_text()),
713+
Self::JsIdentifierBinding(ident) => Some(ident.name_token().ok()?.token_text_trimmed()),
714714
Self::JsFunctionDeclaration(function) => Some(
715715
function
716716
.id()
717717
.ok()?
718718
.as_js_identifier_binding()?
719719
.name_token()
720720
.ok()?
721-
.token_text(),
721+
.token_text_trimmed(),
722722
),
723723
Self::JsPropertyObjectMember(property) => property.name().ok()?.name(),
724724
}

crates/biome_js_analyze/src/lint/nursery.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ pub mod no_unused_expressions;
2020
pub mod no_useless_catch_binding;
2121
pub mod no_useless_undefined;
2222
pub mod no_vue_data_object_declaration;
23+
pub mod no_vue_duplicate_keys;
2324
pub mod no_vue_reserved_keys;
2425
pub mod no_vue_reserved_props;
2526
pub mod use_anchor_href;
@@ -33,4 +34,4 @@ pub mod use_qwik_classlist;
3334
pub mod use_react_function_components;
3435
pub mod use_sorted_classes;
3536
pub mod use_vue_multi_word_component_names;
36-
declare_lint_group! { pub Nursery { name : "nursery" , rules : [self :: no_deprecated_imports :: NoDeprecatedImports , self :: no_floating_promises :: NoFloatingPromises , self :: no_import_cycles :: NoImportCycles , self :: no_jsx_literals :: NoJsxLiterals , self :: no_misused_promises :: NoMisusedPromises , self :: no_next_async_client_component :: NoNextAsyncClientComponent , self :: no_non_null_asserted_optional_chain :: NoNonNullAssertedOptionalChain , self :: no_qwik_use_visible_task :: NoQwikUseVisibleTask , self :: no_react_forward_ref :: NoReactForwardRef , self :: no_secrets :: NoSecrets , self :: no_shadow :: NoShadow , self :: no_unnecessary_conditions :: NoUnnecessaryConditions , self :: no_unresolved_imports :: NoUnresolvedImports , self :: no_unused_expressions :: NoUnusedExpressions , self :: no_useless_catch_binding :: NoUselessCatchBinding , self :: no_useless_undefined :: NoUselessUndefined , self :: no_vue_data_object_declaration :: NoVueDataObjectDeclaration , self :: no_vue_reserved_keys :: NoVueReservedKeys , self :: no_vue_reserved_props :: NoVueReservedProps , self :: use_anchor_href :: UseAnchorHref , self :: use_consistent_arrow_return :: UseConsistentArrowReturn , self :: use_consistent_type_definitions :: UseConsistentTypeDefinitions , self :: use_exhaustive_switch_cases :: UseExhaustiveSwitchCases , self :: use_explicit_type :: UseExplicitType , self :: use_image_size :: UseImageSize , self :: use_max_params :: UseMaxParams , self :: use_qwik_classlist :: UseQwikClasslist , self :: use_react_function_components :: UseReactFunctionComponents , self :: use_sorted_classes :: UseSortedClasses , self :: use_vue_multi_word_component_names :: UseVueMultiWordComponentNames ,] } }
37+
declare_lint_group! { pub Nursery { name : "nursery" , rules : [self :: no_deprecated_imports :: NoDeprecatedImports , self :: no_floating_promises :: NoFloatingPromises , self :: no_import_cycles :: NoImportCycles , self :: no_jsx_literals :: NoJsxLiterals , self :: no_misused_promises :: NoMisusedPromises , self :: no_next_async_client_component :: NoNextAsyncClientComponent , self :: no_non_null_asserted_optional_chain :: NoNonNullAssertedOptionalChain , self :: no_qwik_use_visible_task :: NoQwikUseVisibleTask , self :: no_react_forward_ref :: NoReactForwardRef , self :: no_secrets :: NoSecrets , self :: no_shadow :: NoShadow , self :: no_unnecessary_conditions :: NoUnnecessaryConditions , self :: no_unresolved_imports :: NoUnresolvedImports , self :: no_unused_expressions :: NoUnusedExpressions , self :: no_useless_catch_binding :: NoUselessCatchBinding , self :: no_useless_undefined :: NoUselessUndefined , self :: no_vue_data_object_declaration :: NoVueDataObjectDeclaration , self :: no_vue_duplicate_keys :: NoVueDuplicateKeys , self :: no_vue_reserved_keys :: NoVueReservedKeys , self :: no_vue_reserved_props :: NoVueReservedProps , self :: use_anchor_href :: UseAnchorHref , self :: use_consistent_arrow_return :: UseConsistentArrowReturn , self :: use_consistent_type_definitions :: UseConsistentTypeDefinitions , self :: use_exhaustive_switch_cases :: UseExhaustiveSwitchCases , self :: use_explicit_type :: UseExplicitType , self :: use_image_size :: UseImageSize , self :: use_max_params :: UseMaxParams , self :: use_qwik_classlist :: UseQwikClasslist , self :: use_react_function_components :: UseReactFunctionComponents , self :: use_sorted_classes :: UseSortedClasses , self :: use_vue_multi_word_component_names :: UseVueMultiWordComponentNames ,] } }
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
use crate::frameworks::vue::vue_component::{
2+
VueComponent, VueComponentDeclarations, VueComponentQuery, VueDeclaration,
3+
VueDeclarationCollectionFilter, VueDeclarationName,
4+
};
5+
use biome_analyze::{
6+
Rule, RuleDiagnostic, RuleDomain, RuleSource, context::RuleContext, declare_lint_rule,
7+
};
8+
use biome_console::markup;
9+
use biome_diagnostics::Severity;
10+
use biome_rule_options::no_vue_duplicate_keys::NoVueDuplicateKeysOptions;
11+
use enumflags2::BitFlag;
12+
use rustc_hash::FxHashMap;
13+
14+
declare_lint_rule! {
15+
/// Disallow duplicate keys in Vue component data, methods, computed properties, and other options.
16+
///
17+
/// This rule prevents the use of duplicate keys across different Vue component options
18+
/// such as `props`, `data`, `computed`, `methods`, and `setup`. Even if keys don't conflict
19+
/// in the script tag, they may cause issues in the template since Vue allows direct
20+
/// access to these keys.
21+
///
22+
/// ## Examples
23+
///
24+
/// ### Invalid
25+
///
26+
/// ```vue,expect_diagnostic
27+
/// <script>
28+
/// export default {
29+
/// props: ['foo'],
30+
/// data() {
31+
/// return {
32+
/// foo: 'bar'
33+
/// };
34+
/// }
35+
/// };
36+
/// </script>
37+
/// ```
38+
///
39+
/// ```vue,expect_diagnostic
40+
/// <script>
41+
/// export default {
42+
/// data() {
43+
/// return {
44+
/// message: 'hello'
45+
/// };
46+
/// },
47+
/// methods: {
48+
/// message() {
49+
/// console.log('duplicate key');
50+
/// }
51+
/// }
52+
/// };
53+
/// </script>
54+
/// ```
55+
///
56+
/// ```vue,expect_diagnostic
57+
/// <script>
58+
/// export default {
59+
/// computed: {
60+
/// count() {
61+
/// return this.value * 2;
62+
/// }
63+
/// },
64+
/// methods: {
65+
/// count() {
66+
/// this.value++;
67+
/// }
68+
/// }
69+
/// };
70+
/// </script>
71+
/// ```
72+
///
73+
/// ### Valid
74+
///
75+
/// ```vue
76+
/// <script>
77+
/// export default {
78+
/// props: ['foo'],
79+
/// data() {
80+
/// return {
81+
/// bar: 'baz'
82+
/// };
83+
/// },
84+
/// methods: {
85+
/// handleClick() {
86+
/// console.log('unique key');
87+
/// }
88+
/// }
89+
/// };
90+
/// </script>
91+
/// ```
92+
///
93+
/// ```vue
94+
/// <script>
95+
/// export default {
96+
/// computed: {
97+
/// displayMessage() {
98+
/// return this.message.toUpperCase();
99+
/// }
100+
/// },
101+
/// methods: {
102+
/// clearMessage() {
103+
/// this.message = '';
104+
/// }
105+
/// }
106+
/// };
107+
/// </script>
108+
/// ```
109+
///
110+
pub NoVueDuplicateKeys {
111+
version: "next",
112+
name: "noVueDuplicateKeys",
113+
language: "js",
114+
recommended: true,
115+
severity: Severity::Error,
116+
domains: &[RuleDomain::Vue],
117+
sources: &[RuleSource::EslintVueJs("no-dupe-keys").same()],
118+
}
119+
}
120+
121+
impl Rule for NoVueDuplicateKeys {
122+
type Query = VueComponentQuery;
123+
type State = RuleState;
124+
type Signals = Box<[Self::State]>;
125+
type Options = NoVueDuplicateKeysOptions;
126+
127+
fn run(ctx: &RuleContext<Self>) -> Self::Signals {
128+
let Some(component) = VueComponent::from_potential_component(
129+
ctx.query(),
130+
ctx.model(),
131+
ctx.source_type(),
132+
ctx.file_path(),
133+
) else {
134+
return Box::new([]);
135+
};
136+
137+
let mut key_declarations: FxHashMap<String, Vec<VueDeclaration>> = FxHashMap::default();
138+
139+
// Collect all declarations across all Vue component sections
140+
for declaration in component.declarations(VueDeclarationCollectionFilter::all()) {
141+
if let Some(name) = declaration.declaration_name() {
142+
let key = name.text().to_string();
143+
key_declarations.entry(key).or_default().push(declaration);
144+
}
145+
}
146+
147+
// Find duplicates
148+
key_declarations
149+
.into_iter()
150+
.filter_map(|(key, declarations)| {
151+
if declarations.len() > 1 {
152+
Some(RuleState { key, declarations })
153+
} else {
154+
None
155+
}
156+
})
157+
.collect::<Box<[_]>>()
158+
}
159+
160+
fn diagnostic(_ctx: &RuleContext<Self>, state: &Self::State) -> Option<RuleDiagnostic> {
161+
let mut declarations_iterator = state.declarations.iter();
162+
let first_declaration = declarations_iterator.next()?;
163+
let mut diagnostic = RuleDiagnostic::new(
164+
rule_category!(),
165+
first_declaration.declaration_name_range()?,
166+
markup! {
167+
"Duplicate key "<Emphasis>{&state.key}</Emphasis>" found in Vue component."
168+
},
169+
);
170+
171+
// Add related information for other occurrences
172+
for declaration in declarations_iterator {
173+
if let Some(range) = declaration.declaration_name_range() {
174+
diagnostic = diagnostic.detail(
175+
range,
176+
markup! {
177+
"Key "<Emphasis>{&state.key}</Emphasis>" is also defined here."
178+
},
179+
);
180+
}
181+
}
182+
183+
diagnostic = diagnostic.note(markup! {
184+
"Keys defined in different Vue component options (props, data, methods, computed) can conflict when accessed in the template. Rename the key to avoid conflicts."
185+
});
186+
187+
Some(diagnostic)
188+
}
189+
}
190+
191+
pub struct RuleState {
192+
key: String,
193+
declarations: Vec<VueDeclaration>,
194+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<script>
2+
export default {
3+
asyncData() {
4+
return {
5+
foo: 'async'
6+
}
7+
},
8+
data() {
9+
return {
10+
foo: 'data'
11+
}
12+
}
13+
}
14+
</script>

0 commit comments

Comments
 (0)