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
5 changes: 5 additions & 0 deletions .changeset/warm-plugins-offset.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@biomejs/biome": patch
---

Fixed plugin diagnostics showing incorrect line numbers in Vue, Astro, and Svelte files. Plugin diagnostics now correctly account for the template/frontmatter offset, pointing to the right location in the `<script>` block.
4 changes: 2 additions & 2 deletions crates/biome_analyze/src/analyzer_plugin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use std::{fmt::Debug, sync::Arc};
use biome_rowan::{AnySyntaxNode, Language, RawSyntaxKind, SyntaxKind, SyntaxNode, WalkEvent};

use crate::matcher::SignalRuleKey;
use crate::{DiagnosticSignal, RuleCategory, RuleDiagnostic, SignalEntry, Visitor, VisitorContext};
use crate::{PluginSignal, RuleCategory, RuleDiagnostic, SignalEntry, Visitor, VisitorContext};

/// Slice of analyzer plugins that can be cheaply cloned.
pub type AnalyzerPluginSlice<'a> = &'a [Arc<Box<dyn AnalyzerPlugin>>];
Expand Down Expand Up @@ -111,7 +111,7 @@ where

SignalEntry {
text_range: diagnostic.span().unwrap_or_default(),
signal: Box::new(DiagnosticSignal::new(move || diagnostic.clone())),
signal: Box::new(PluginSignal::<L>::new(diagnostic)),
rule: SignalRuleKey::Plugin(name.into()),
category: RuleCategory::Lint,
instances: Default::default(),
Expand Down
2 changes: 1 addition & 1 deletion crates/biome_analyze/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ pub use crate::services::{
ExtendedConfigurationProvider, FromServices, ServiceBag, ServicesDiagnostic,
};
pub use crate::signals::{
AnalyzerAction, AnalyzerSignal, AnalyzerTransformation, DiagnosticSignal,
AnalyzerAction, AnalyzerSignal, AnalyzerTransformation, DiagnosticSignal, PluginSignal,
};
use crate::suppressions::Suppressions;
pub use crate::syntax::{Ast, SyntaxVisitor};
Expand Down
40 changes: 38 additions & 2 deletions crates/biome_analyze/src/signals.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ use crate::categories::{
SUPPRESSION_INLINE_ACTION_CATEGORY, SUPPRESSION_TOP_LEVEL_ACTION_CATEGORY,
};
use crate::{
AnalyzerDiagnostic, AnalyzerOptions, OtherActionCategory, Queryable, RuleGroup, ServiceBag,
SuppressionAction,
AnalyzerDiagnostic, AnalyzerOptions, OtherActionCategory, Queryable, RuleDiagnostic, RuleGroup,
ServiceBag, SuppressionAction,
categories::ActionCategory,
context::RuleContext,
registry::{RuleLanguage, RuleRoot},
Expand Down Expand Up @@ -101,6 +101,42 @@ where
}
}

/// Implementation of [AnalyzerSignal] for plugin diagnostics that preserves
/// the [RuleDiagnostic] as [DiagnosticKind::Rule](crate::diagnostics::DiagnosticKind::Rule),
/// ensuring diagnostic offset adjustments are correctly applied for embedded
/// languages (Vue, Svelte, Astro).
///
/// Unlike [DiagnosticSignal] which converts through [Error] into
/// [DiagnosticKind::Raw](crate::diagnostics::DiagnosticKind::Raw), this type
/// directly converts via `AnalyzerDiagnostic::from(RuleDiagnostic)`.
pub struct PluginSignal<L> {
diagnostic: RuleDiagnostic,
_phantom: PhantomData<L>,
}

impl<L: Language> PluginSignal<L> {
pub fn new(diagnostic: RuleDiagnostic) -> Self {
Self {
diagnostic,
_phantom: PhantomData,
}
}
}

impl<L: Language> AnalyzerSignal<L> for PluginSignal<L> {
fn diagnostic(&self) -> Option<AnalyzerDiagnostic> {
Some(AnalyzerDiagnostic::from(self.diagnostic.clone()))
}

fn actions(&self) -> AnalyzerActionIter<L> {
AnalyzerActionIter::new(vec![])
}

fn transformations(&self) -> AnalyzerTransformationIter<L> {
AnalyzerTransformationIter::new(vec![])
}
}

/// Code Action object returned by the analyzer, generated from a [crate::RuleAction]
/// with additional information about the rule injected by the analyzer
///
Expand Down
70 changes: 70 additions & 0 deletions crates/biome_cli/tests/commands/check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3130,6 +3130,76 @@ fn check_plugin_suppressions() {
));
}

#[test]
fn check_plugin_diagnostic_offset_in_vue_file() {
let fs = MemoryFileSystem::default();
let mut console = BufferConsole::default();

fs.insert(
Utf8PathBuf::from("biome.json"),
br#"{
"overrides": [
{
"includes": ["**/*.vue"],
"plugins": ["noFoo.grit"]
}
]
}
"#,
);

fs.insert(
Utf8PathBuf::from("noFoo.grit"),
br#"language js;

JsIdentifierBinding() as $name where {
$name <: r"^foo$",
register_diagnostic(
span = $name,
message = "Avoid using 'foo' as a variable name.",
severity = "error"
)
}
"#,
);

// The template is intentionally multi-line so that if the diagnostic offset
// is not applied, the reported span would point into the template instead of
// the script section.
let file_path = "file.vue";
fs.insert(
file_path.into(),
br#"<template>
<p>line 1</p>
<p>line 2</p>
<p>line 3</p>
<p>line 4</p>
<p>line 5</p>
<p>line 6</p>
<p>line 7</p>
<p>line 8</p>
<p>line 9</p>
<p>line 10</p>
</template>

<script setup lang="ts">
const foo = 'bad'
</script>
"#,
);

let (fs, result) =
run_cli_with_server_workspace(fs, &mut console, Args::from(["lint", file_path].as_slice()));

assert_cli_snapshot(SnapshotPayload::new(
module_path!(),
"check_plugin_diagnostic_offset_in_vue_file",
fs,
console,
result,
));
}

#[test]
fn doesnt_check_file_when_assist_is_disabled() {
let fs = MemoryFileSystem::default();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
---
source: crates/biome_cli/tests/snap_test.rs
assertion_line: 432
expression: redactor(content)
---
## `biome.json`

```json
{
"overrides": [
{
"includes": ["**/*.vue"],
"plugins": ["noFoo.grit"]
}
]
}
```

## `file.vue`

```vue
<template>
<p>line 1</p>
<p>line 2</p>
<p>line 3</p>
<p>line 4</p>
<p>line 5</p>
<p>line 6</p>
<p>line 7</p>
<p>line 8</p>
<p>line 9</p>
<p>line 10</p>
</template>

<script setup lang="ts">
const foo = 'bad'
</script>

```

## `noFoo.grit`

```grit
language js;

JsIdentifierBinding() as $name where {
$name <: r"^foo$",
register_diagnostic(
span = $name,
message = "Avoid using 'foo' as a variable name.",
severity = "error"
)
}

```

# Termination Message

```block
lint ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

× Some errors were emitted while running checks.



```

# Emitted Messages

```block
file.vue:15:7 lint/correctness/noUnusedVariables FIXABLE ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

! This variable foo is unused.

14 │ <script setup lang="ts">
> 15 │ const foo = 'bad'
│ ^^^
16 │ </script>
17 │

i Unused variables are often the result of typos, incomplete refactors, or other sources of bugs.

i Unsafe fix: If this is intentional, prepend foo with an underscore.

1 │ - const·foo·=·'bad'
1 │ + const·_foo·=·'bad'
2 2 │


```

```block
file.vue:15:7 plugin ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

× Avoid using 'foo' as a variable name.

14 │ <script setup lang="ts">
> 15 │ const foo = 'bad'
│ ^^^
16 │ </script>
17 │


```

```block
Checked 1 file in <TIME>. No fixes applied.
Found 1 error.
Found 1 warning.
```
98 changes: 98 additions & 0 deletions crates/biome_lsp/src/server.tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4216,6 +4216,104 @@ async fn should_not_return_error_on_code_actions_for_grit_files() -> Result<()>
Ok(())
}

#[tokio::test]
async fn pull_plugin_diagnostics_for_vue_files() -> Result<()> {
let fs = MemoryFileSystem::default();

let config = r#"{
"plugins": ["./noFoo.grit"]
}"#;

let plugin = br#"language js;

JsIdentifierBinding() as $name where {
$name <: r"^foo$",
register_diagnostic(
span = $name,
message = "Avoid using 'foo' as a variable name.",
severity = "error"
)
}
"#;

fs.insert(to_utf8_file_path_buf(uri!("biome.json")), config);
fs.insert(to_utf8_file_path_buf(uri!("noFoo.grit")), plugin.as_slice());

let factory = ServerFactory::new_with_fs(Arc::new(fs));
let (service, client) = factory.create().into_inner();
let (stream, sink) = client.split();
let mut server = Server::new(service);

let (sender, mut receiver) = channel(CHANNEL_BUFFER_SIZE);
let reader = tokio::spawn(client_handler(stream, sink, sender));

server.initialize().await?;
server.initialized().await?;

server.load_configuration().await?;

// The template is intentionally multi-line so that if the diagnostic offset
// is not applied, the reported span would point into the template instead of
// the script section.
let vue_file = r#"<template>
<p>line 1</p>
<p>line 2</p>
<p>line 3</p>
<p>line 4</p>
<p>line 5</p>
<p>line 6</p>
<p>line 7</p>
<p>line 8</p>
<p>line 9</p>
<p>line 10</p>
</template>

<script setup lang="ts">
const foo = 'bad'
</script>
"#;

server
.open_named_document(vue_file, uri!("file.vue"), "vue")
.await?;

let notification = wait_for_notification(&mut receiver, |n| n.is_publish_diagnostics()).await;

// The plugin diagnostic for `foo` should point to line 14 (0-indexed),
// character 6-9 in the full Vue file, not within the extracted script block.
match &notification {
Some(ServerNotification::PublishDiagnostics(params)) => {
assert_eq!(params.uri, uri!("file.vue"));
let plugin_diag = params
.diagnostics
.iter()
.find(|d| d.message.contains("Avoid using 'foo'"))
.expect("expected a plugin diagnostic for 'foo'");
assert_eq!(
plugin_diag.range,
Range {
start: Position {
line: 14,
character: 6,
},
end: Position {
line: 14,
character: 9,
},
},
"plugin diagnostic should point to 'foo' on line 14 of the full Vue file"
);
}
other => panic!("expected PublishDiagnostics, got {other:?}"),
}

server.close_document().await?;
server.shutdown().await?;
reader.abort();

Ok(())
}

// #endregion

// #region TEST UTILS
Expand Down
Loading
Loading