Skip to content

Commit bdf59be

Browse files
committed
feat(html/analyze): add noVueInvalidVBind
1 parent b785a21 commit bdf59be

File tree

21 files changed

+647
-8
lines changed

21 files changed

+647
-8
lines changed

.changeset/tall-jokes-send.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
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.

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: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
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 that attribute value. E.g. <div v-bind:aaa></div>
14+
/// - The directive has invalid modifiers. E.g. <div v-bind:aaa.bbb="ccc"></div>
15+
///
16+
/// ## Examples
17+
///
18+
/// ### Invalid
19+
///
20+
/// ```vue,expect_diagnostic
21+
/// <Foo v-bind />
22+
/// ```
23+
///
24+
/// ```vue,expect_diagnostic
25+
/// <div v-bind></div>
26+
/// ```
27+
///
28+
/// ### Valid
29+
///
30+
/// ```vue
31+
/// <Foo v-bind:foo="foo" />
32+
/// ```
33+
///
34+
pub UseVueValidVBind {
35+
version: "next",
36+
name: "useVueValidVBind",
37+
language: "html",
38+
recommended: true,
39+
domains: &[RuleDomain::Vue],
40+
sources: &[RuleSource::EslintVueJs("valid-v-bind").same()],
41+
}
42+
}
43+
44+
const VALID_MODIFIERS: &[&str] = &["prop", "camel", "sync", "attr"];
45+
46+
pub enum ViolationKind {
47+
MissingArgument,
48+
InvalidModifier(TextRange),
49+
}
50+
51+
impl Rule for UseVueValidVBind {
52+
type Query = Ast<AnyVueDirective>;
53+
type State = ViolationKind;
54+
type Signals = Option<Self::State>;
55+
type Options = UseVueValidVBindOptions;
56+
57+
fn run(ctx: &RuleContext<Self>) -> Option<Self::State> {
58+
let node = ctx.query();
59+
match node {
60+
AnyVueDirective::VueDirective(vue_directive) => {
61+
if vue_directive.name_token().ok()?.text_trimmed() != "v-bind" {
62+
return None;
63+
}
64+
65+
if vue_directive.arg().is_none() {
66+
return Some(ViolationKind::MissingArgument);
67+
}
68+
69+
if let Some(invalid_range) = find_invalid_modifiers(&vue_directive.modifiers()) {
70+
return Some(ViolationKind::InvalidModifier(invalid_range));
71+
}
72+
73+
None
74+
}
75+
AnyVueDirective::VueVBindShorthandDirective(dir) => {
76+
// missing argument would be caught by the parser
77+
78+
if let Some(invalid_range) = find_invalid_modifiers(&dir.modifiers()) {
79+
return Some(ViolationKind::InvalidModifier(invalid_range));
80+
}
81+
82+
None
83+
}
84+
_ => None,
85+
}
86+
}
87+
88+
fn diagnostic(ctx: &RuleContext<Self>, state: &Self::State) -> Option<RuleDiagnostic> {
89+
Some(
90+
match state {
91+
ViolationKind::MissingArgument => RuleDiagnostic::new(
92+
rule_category!(),
93+
ctx.query().range(),
94+
markup! {
95+
"The v-bind directive is missing an argument."
96+
},
97+
)
98+
.note(markup! {
99+
"v-bind directives require an argument to specify which attribute to bind to."
100+
}).note(markup! {
101+
"For example, use " <Emphasis>"v-bind:foo"</Emphasis> " to bind to the " <Emphasis>"foo"</Emphasis> " attribute."
102+
}),
103+
ViolationKind::InvalidModifier(invalid_range) =>
104+
RuleDiagnostic::new(
105+
rule_category!(),
106+
invalid_range,
107+
markup! {
108+
"This v-bind directive has an invalid modifier."
109+
},
110+
)
111+
.note(markup! {
112+
"Only the following modifiers are allowed on v-bind directives: "<Emphasis>"prop"</Emphasis>", "<Emphasis>"camel"</Emphasis>", "<Emphasis>"sync"</Emphasis>", and "<Emphasis>"attr"</Emphasis>"."
113+
}).note(markup! {
114+
"Remove or correct the invalid modifier."
115+
}),
116+
}
117+
)
118+
}
119+
}
120+
121+
fn find_invalid_modifiers(modifiers: &VueModifierList) -> Option<TextRange> {
122+
for modifier in modifiers {
123+
if !VALID_MODIFIERS.contains(&modifier.modifier_token().ok()?.text()) {
124+
return Some(modifier.range());
125+
}
126+
}
127+
None
128+
}

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: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
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 argument with modifier -->
10+
<div v-bind.prop></div>
11+
12+
<!-- Invalid single modifier on long-form -->
13+
<div v-bind:foo.invalid="bar"></div>
14+
15+
<!-- Invalid modifier on shorthand -->
16+
<span :bar.badModifier="baz"></span>
17+
18+
<!-- Mixed valid and invalid modifiers: 'prop' is valid, 'wrong' is not -->
19+
<p :baz.prop.wrong="value"></p>
20+
21+
<!-- Dynamic argument is present but modifier is invalid -->
22+
<p v-bind:[dynamic].notAValidModifier="value"></p>
23+
24+
<!-- Multiple invalid modifiers -->
25+
<button :disabled.once="true"></button>
26+
27+
<!-- Component binding with unknown modifier -->
28+
<MyComponent v-bind:propName.weird="someValue"></MyComponent>
29+
</template>

0 commit comments

Comments
 (0)