Skip to content

Commit db1b096

Browse files
committed
feat(html/analyze): add noVueInvalidVBind
1 parent e403868 commit db1b096

File tree

21 files changed

+721
-8
lines changed

21 files changed

+721
-8
lines changed

.changeset/tall-jokes-send.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@biomejs/biome": patch
3+
---
4+
5+
Added the nursery rule [`useVueValidVBind`](https://biomejs.dev/linter/rules/use-vue-valid-v-bind/), which enforces the validity of `v-bind` directives in Vue files.
6+
7+
Invalid `v-bind` usages include:
8+
```vue
9+
<Foo v-bind /> <!-- Missing argument -->
10+
<Foo v-bind:foo /> <!-- Missing value -->
11+
<Foo v-bind:foo.bar="baz" /> <!-- Invalid modifier -->
12+
```

Cargo.lock

Lines changed: 1 addition & 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: 22 additions & 1 deletion
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
@@ -190,6 +190,7 @@ define_categories! {
190190
"lint/nursery/noUselessUndefined": "https://biomejs.dev/linter/rules/no-useless-undefined",
191191
"lint/nursery/noVueDataObjectDeclaration": "https://biomejs.dev/linter/rules/no-vue-data-object-declaration",
192192
"lint/nursery/noVueDuplicateKeys": "https://biomejs.dev/linter/rules/no-vue-duplicate-keys",
193+
"lint/nursery/useVueValidVBind": "https://biomejs.dev/linter/rules/use-vue-valid-v-bind",
193194
"lint/nursery/noVueReservedKeys": "https://biomejs.dev/linter/rules/no-vue-reserved-keys",
194195
"lint/nursery/noVueReservedProps": "https://biomejs.dev/linter/rules/no-vue-reserved-props",
195196
"lint/nursery/useAnchorHref": "https://biomejs.dev/linter/rules/use-anchor-href",

crates/biome_html_analyze/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ biome_diagnostics = { workspace = true }
1919
biome_html_factory = { workspace = true }
2020
biome_html_syntax = { workspace = true }
2121
biome_rowan = { workspace = true }
22+
biome_rule_options = { workspace = true }
2223
biome_string_case = { workspace = true }
2324
biome_suppression = { workspace = true }
2425
schemars = { workspace = true, optional = true }

crates/biome_html_analyze/src/lint.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@
33
//! Generated file, do not edit by hand, see `xtask/codegen`
44
55
pub mod a11y;
6-
::biome_analyze::declare_category! { pub Lint { kind : Lint , groups : [self :: a11y :: A11y ,] } }
6+
pub mod nursery;
7+
::biome_analyze::declare_category! { pub Lint { kind : Lint , groups : [self :: a11y :: A11y , self :: nursery :: Nursery ,] } }
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
//! Generated file, do not edit by hand, see `xtask/codegen`
2+
3+
//! Generated file, do not edit by hand, see `xtask/codegen`
4+
5+
use biome_analyze::declare_lint_group;
6+
pub mod use_vue_valid_v_bind;
7+
declare_lint_group! { pub Nursery { name : "nursery" , rules : [self :: use_vue_valid_v_bind :: UseVueValidVBind ,] } }
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
use biome_analyze::{
2+
Ast, Rule, RuleDiagnostic, RuleDomain, RuleSource, context::RuleContext, declare_lint_rule,
3+
};
4+
use biome_console::markup;
5+
use biome_html_syntax::{AnyVueDirective, VueModifierList};
6+
use biome_rowan::{AstNode, TextRange};
7+
use biome_rule_options::use_vue_valid_v_bind::UseVueValidVBindOptions;
8+
9+
declare_lint_rule! {
10+
/// Forbids `v-bind` directives with missing arguments or invalid modifiers.
11+
///
12+
/// This rule reports v-bind directives in the following cases:
13+
/// - The directive does not have an argument. E.g. `<div v-bind></div>`
14+
/// - The directive does not have a value. E.g. `<div v-bind:aaa></div>`
15+
/// - The directive has invalid modifiers. E.g. `<div v-bind:aaa.bbb="ccc"></div>`
16+
///
17+
/// ## Examples
18+
///
19+
/// ### Invalid
20+
///
21+
/// ```vue,expect_diagnostic
22+
/// <Foo v-bind />
23+
/// ```
24+
///
25+
/// ```vue,expect_diagnostic
26+
/// <div v-bind></div>
27+
/// ```
28+
///
29+
/// ### Valid
30+
///
31+
/// ```vue
32+
/// <Foo v-bind:foo="foo" />
33+
/// ```
34+
///
35+
pub UseVueValidVBind {
36+
version: "next",
37+
name: "useVueValidVBind",
38+
language: "html",
39+
recommended: true,
40+
domains: &[RuleDomain::Vue],
41+
sources: &[RuleSource::EslintVueJs("valid-v-bind").same()],
42+
}
43+
}
44+
45+
const VALID_MODIFIERS: &[&str] = &["prop", "camel", "sync", "attr"];
46+
47+
pub enum ViolationKind {
48+
MissingValue,
49+
MissingArgument,
50+
InvalidModifier(TextRange),
51+
}
52+
53+
impl Rule for UseVueValidVBind {
54+
type Query = Ast<AnyVueDirective>;
55+
type State = ViolationKind;
56+
type Signals = Option<Self::State>;
57+
type Options = UseVueValidVBindOptions;
58+
59+
fn run(ctx: &RuleContext<Self>) -> Option<Self::State> {
60+
let node = ctx.query();
61+
match node {
62+
AnyVueDirective::VueDirective(vue_directive) => {
63+
if vue_directive.name_token().ok()?.text_trimmed() != "v-bind" {
64+
return None;
65+
}
66+
67+
if vue_directive.initializer().is_none() {
68+
return Some(ViolationKind::MissingValue);
69+
}
70+
71+
if vue_directive.arg().is_none() {
72+
return Some(ViolationKind::MissingArgument);
73+
}
74+
75+
if let Some(invalid_range) = find_invalid_modifiers(&vue_directive.modifiers()) {
76+
return Some(ViolationKind::InvalidModifier(invalid_range));
77+
}
78+
79+
None
80+
}
81+
AnyVueDirective::VueVBindShorthandDirective(dir) => {
82+
// missing argument would be caught by the parser
83+
84+
if dir.initializer().is_none() {
85+
return Some(ViolationKind::MissingValue);
86+
}
87+
88+
if let Some(invalid_range) = find_invalid_modifiers(&dir.modifiers()) {
89+
return Some(ViolationKind::InvalidModifier(invalid_range));
90+
}
91+
92+
None
93+
}
94+
_ => None,
95+
}
96+
}
97+
98+
fn diagnostic(ctx: &RuleContext<Self>, state: &Self::State) -> Option<RuleDiagnostic> {
99+
Some(
100+
match state {
101+
ViolationKind::MissingValue => RuleDiagnostic::new(
102+
rule_category!(),
103+
ctx.query().range(),
104+
markup! {
105+
"This v-bind directive is missing a value."
106+
},
107+
)
108+
.note(markup! {
109+
"v-bind directives require a value."
110+
}).note(markup! {
111+
"Add a value to the directive, e.g. "<Emphasis>"v-bind:foo=\"bar\""</Emphasis>"."
112+
}),
113+
ViolationKind::MissingArgument => RuleDiagnostic::new(
114+
rule_category!(),
115+
ctx.query().range(),
116+
markup! {
117+
"This v-bind directive is missing an argument."
118+
},
119+
)
120+
.note(markup! {
121+
"v-bind directives require an argument to specify which attribute to bind to."
122+
}).note(markup! {
123+
"For example, use " <Emphasis>"v-bind:foo"</Emphasis> " to bind to the " <Emphasis>"foo"</Emphasis> " attribute."
124+
}),
125+
ViolationKind::InvalidModifier(invalid_range) =>
126+
RuleDiagnostic::new(
127+
rule_category!(),
128+
invalid_range,
129+
markup! {
130+
"This v-bind directive has an invalid modifier."
131+
},
132+
)
133+
.note(markup! {
134+
"Only the following modifiers are allowed on v-bind directives: "<Emphasis>"prop"</Emphasis>", "<Emphasis>"camel"</Emphasis>", "<Emphasis>"sync"</Emphasis>", and "<Emphasis>"attr"</Emphasis>"."
135+
}).note(markup! {
136+
"Remove or correct the invalid modifier."
137+
}),
138+
}
139+
)
140+
}
141+
}
142+
143+
fn find_invalid_modifiers(modifiers: &VueModifierList) -> Option<TextRange> {
144+
for modifier in modifiers {
145+
if !VALID_MODIFIERS.contains(&modifier.modifier_token().ok()?.text()) {
146+
return Some(modifier.range());
147+
}
148+
}
149+
None
150+
}

crates/biome_html_analyze/tests/spec_tests.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ use camino::Utf8Path;
1313
use std::ops::Deref;
1414
use std::{fs::read_to_string, slice};
1515

16-
tests_macros::gen_tests! {"tests/specs/**/*.{html,json,jsonc}", crate::run_test, "module"}
17-
tests_macros::gen_tests! {"tests/suppression/**/*.{html,json,jsonc}", crate::run_suppression_test, "module"}
16+
tests_macros::gen_tests! {"tests/specs/**/*.{html,vue,json,jsonc}", crate::run_test, "module"}
17+
tests_macros::gen_tests! {"tests/suppression/**/*.{html,vue,json,jsonc}", crate::run_suppression_test, "module"}
1818

1919
fn run_test(input: &'static str, _: &str, _: &str, _: &str) {
2020
register_leak_checker();
@@ -93,7 +93,7 @@ pub(crate) fn analyze_and_snap(
9393
input_file: &Utf8Path,
9494
check_action_type: CheckActionType,
9595
) {
96-
let parsed = parse_html(input_code, HtmlParseOptions::default());
96+
let parsed = parse_html(input_code, (&source_type).into());
9797
let root = parsed.tree();
9898

9999
let mut diagnostics = Vec::new();
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<!-- should generate diagnostics -->
2+
3+
<template>
4+
<!-- Missing argument: long-form without an argument -->
5+
<div v-bind></div>
6+
<div v-bind />
7+
<Foo v-bind />
8+
9+
<!-- Missing value -->
10+
<Foo v-bind:foo />
11+
<Foo :foo />
12+
13+
<!-- Missing argument with modifier -->
14+
<div v-bind.prop></div>
15+
16+
<!-- Invalid single modifier on long-form -->
17+
<div v-bind:foo.invalid="bar"></div>
18+
19+
<!-- Invalid modifier on shorthand -->
20+
<span :bar.badModifier="baz"></span>
21+
22+
<!-- Mixed valid and invalid modifiers: 'prop' is valid, 'wrong' is not -->
23+
<p :baz.prop.wrong="value"></p>
24+
25+
<!-- Dynamic argument is present but modifier is invalid -->
26+
<p v-bind:[dynamic].notAValidModifier="value"></p>
27+
28+
<!-- Multiple invalid modifiers -->
29+
<button :disabled.once="true"></button>
30+
31+
<!-- Component binding with unknown modifier -->
32+
<MyComponent v-bind:propName.weird="someValue"></MyComponent>
33+
</template>

0 commit comments

Comments
 (0)