Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/tall-jokes-send.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@biomejs/biome": patch
---

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.

Invalid `v-bind` usages include:
```vue
<Foo v-bind /> <!-- Missing argument -->
<Foo v-bind:foo /> <!-- Missing value -->
<Foo v-bind:foo.bar="baz" /> <!-- Invalid modifier -->
```
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 22 additions & 1 deletion crates/biome_configuration/src/analyzer/linter/rules.rs

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions crates/biome_diagnostics_categories/src/categories.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ define_categories! {
"lint/nursery/noUselessUndefined": "https://biomejs.dev/linter/rules/no-useless-undefined",
"lint/nursery/noVueDataObjectDeclaration": "https://biomejs.dev/linter/rules/no-vue-data-object-declaration",
"lint/nursery/noVueDuplicateKeys": "https://biomejs.dev/linter/rules/no-vue-duplicate-keys",
"lint/nursery/useVueValidVBind": "https://biomejs.dev/linter/rules/use-vue-valid-v-bind",
"lint/nursery/noVueReservedKeys": "https://biomejs.dev/linter/rules/no-vue-reserved-keys",
"lint/nursery/noVueReservedProps": "https://biomejs.dev/linter/rules/no-vue-reserved-props",
"lint/nursery/useAnchorHref": "https://biomejs.dev/linter/rules/use-anchor-href",
Expand Down
1 change: 1 addition & 0 deletions crates/biome_html_analyze/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ biome_diagnostics = { workspace = true }
biome_html_factory = { workspace = true }
biome_html_syntax = { workspace = true }
biome_rowan = { workspace = true }
biome_rule_options = { workspace = true }
biome_string_case = { workspace = true }
biome_suppression = { workspace = true }
schemars = { workspace = true, optional = true }
Expand Down
3 changes: 2 additions & 1 deletion crates/biome_html_analyze/src/lint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@
//! Generated file, do not edit by hand, see `xtask/codegen`

pub mod a11y;
::biome_analyze::declare_category! { pub Lint { kind : Lint , groups : [self :: a11y :: A11y ,] } }
pub mod nursery;
::biome_analyze::declare_category! { pub Lint { kind : Lint , groups : [self :: a11y :: A11y , self :: nursery :: Nursery ,] } }
7 changes: 7 additions & 0 deletions crates/biome_html_analyze/src/lint/nursery.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
//! Generated file, do not edit by hand, see `xtask/codegen`

//! Generated file, do not edit by hand, see `xtask/codegen`

use biome_analyze::declare_lint_group;
pub mod use_vue_valid_v_bind;
declare_lint_group! { pub Nursery { name : "nursery" , rules : [self :: use_vue_valid_v_bind :: UseVueValidVBind ,] } }
150 changes: 150 additions & 0 deletions crates/biome_html_analyze/src/lint/nursery/use_vue_valid_v_bind.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
use biome_analyze::{
Ast, Rule, RuleDiagnostic, RuleDomain, RuleSource, context::RuleContext, declare_lint_rule,
};
use biome_console::markup;
use biome_html_syntax::{AnyVueDirective, VueModifierList};
use biome_rowan::{AstNode, TextRange};
use biome_rule_options::use_vue_valid_v_bind::UseVueValidVBindOptions;

declare_lint_rule! {
/// Forbids `v-bind` directives with missing arguments or invalid modifiers.
///
/// This rule reports v-bind directives in the following cases:
/// - The directive does not have an argument. E.g. `<div v-bind></div>`
/// - The directive does not have a value. E.g. `<div v-bind:aaa></div>`
/// - The directive has invalid modifiers. E.g. `<div v-bind:aaa.bbb="ccc"></div>`
///
/// ## Examples
///
/// ### Invalid
///
/// ```vue,expect_diagnostic
/// <Foo v-bind />
/// ```
///
/// ```vue,expect_diagnostic
/// <div v-bind></div>
/// ```
///
/// ### Valid
///
/// ```vue
/// <Foo v-bind:foo="foo" />
/// ```
///
pub UseVueValidVBind {
version: "next",
name: "useVueValidVBind",
language: "html",
recommended: true,
domains: &[RuleDomain::Vue],
sources: &[RuleSource::EslintVueJs("valid-v-bind").same()],
}
}

const VALID_MODIFIERS: &[&str] = &["prop", "camel", "sync", "attr"];

pub enum ViolationKind {
MissingValue,
MissingArgument,
InvalidModifier(TextRange),
}

impl Rule for UseVueValidVBind {
type Query = Ast<AnyVueDirective>;
type State = ViolationKind;
type Signals = Option<Self::State>;
type Options = UseVueValidVBindOptions;

fn run(ctx: &RuleContext<Self>) -> Option<Self::State> {
let node = ctx.query();
match node {
AnyVueDirective::VueDirective(vue_directive) => {
if vue_directive.name_token().ok()?.text_trimmed() != "v-bind" {
return None;
}

if vue_directive.initializer().is_none() {
return Some(ViolationKind::MissingValue);
}

if vue_directive.arg().is_none() {
return Some(ViolationKind::MissingArgument);
}

if let Some(invalid_range) = find_invalid_modifiers(&vue_directive.modifiers()) {
return Some(ViolationKind::InvalidModifier(invalid_range));
}

None
}
AnyVueDirective::VueVBindShorthandDirective(dir) => {
// missing argument would be caught by the parser

if dir.initializer().is_none() {
return Some(ViolationKind::MissingValue);
}

if let Some(invalid_range) = find_invalid_modifiers(&dir.modifiers()) {
return Some(ViolationKind::InvalidModifier(invalid_range));
}

None
}
_ => None,
}
}

fn diagnostic(ctx: &RuleContext<Self>, state: &Self::State) -> Option<RuleDiagnostic> {
Some(
match state {
ViolationKind::MissingValue => RuleDiagnostic::new(
rule_category!(),
ctx.query().range(),
markup! {
"This v-bind directive is missing a value."
},
)
.note(markup! {
"v-bind directives require a value."
}).note(markup! {
"Add a value to the directive, e.g. "<Emphasis>"v-bind:foo=\"bar\""</Emphasis>"."
}),
ViolationKind::MissingArgument => RuleDiagnostic::new(
rule_category!(),
ctx.query().range(),
markup! {
"This v-bind directive is missing an argument."
},
)
.note(markup! {
"v-bind directives require an argument to specify which attribute to bind to."
}).note(markup! {
"For example, use " <Emphasis>"v-bind:foo"</Emphasis> " to bind to the " <Emphasis>"foo"</Emphasis> " attribute."
}),
ViolationKind::InvalidModifier(invalid_range) =>
RuleDiagnostic::new(
rule_category!(),
invalid_range,
markup! {
"This v-bind directive has an invalid modifier."
},
)
.note(markup! {
"Only the following modifiers are allowed on v-bind directives: "<Emphasis>"prop"</Emphasis>", "<Emphasis>"camel"</Emphasis>", "<Emphasis>"sync"</Emphasis>", and "<Emphasis>"attr"</Emphasis>"."
}).note(markup! {
"Remove or correct the invalid modifier."
}),
}
)
}
}

fn find_invalid_modifiers(modifiers: &VueModifierList) -> Option<TextRange> {
for modifier in modifiers {
if !VALID_MODIFIERS.contains(&modifier.modifier_token().ok()?.text()) {
return Some(modifier.range());
}
}
None
}
6 changes: 3 additions & 3 deletions crates/biome_html_analyze/tests/spec_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ use camino::Utf8Path;
use std::ops::Deref;
use std::{fs::read_to_string, slice};

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

fn run_test(input: &'static str, _: &str, _: &str, _: &str) {
register_leak_checker();
Expand Down Expand Up @@ -93,7 +93,7 @@ pub(crate) fn analyze_and_snap(
input_file: &Utf8Path,
check_action_type: CheckActionType,
) {
let parsed = parse_html(input_code, HtmlParseOptions::default());
let parsed = parse_html(input_code, (&source_type).into());
let root = parsed.tree();

let mut diagnostics = Vec::new();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<!-- should generate diagnostics -->

<template>
<!-- Missing argument: long-form without an argument -->
<div v-bind></div>
<div v-bind />
<Foo v-bind />

<!-- Missing value -->
<Foo v-bind:foo />
<Foo :foo />

<!-- Missing argument with modifier -->
<div v-bind.prop></div>

<!-- Invalid single modifier on long-form -->
<div v-bind:foo.invalid="bar"></div>

<!-- Invalid modifier on shorthand -->
<span :bar.badModifier="baz"></span>

<!-- Mixed valid and invalid modifiers: 'prop' is valid, 'wrong' is not -->
<p :baz.prop.wrong="value"></p>

<!-- Dynamic argument is present but modifier is invalid -->
<p v-bind:[dynamic].notAValidModifier="value"></p>

<!-- Multiple invalid modifiers -->
<button :disabled.once="true"></button>

<!-- Component binding with unknown modifier -->
<MyComponent v-bind:propName.weird="someValue"></MyComponent>
</template>
Loading
Loading