Skip to content

Commit 50e93bd

Browse files
fix(embeds): improve detection of bindings in Astro files (#9473)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent 071d9b9 commit 50e93bd

File tree

7 files changed

+283
-6
lines changed

7 files changed

+283
-6
lines changed

.changeset/sharp-adults-jog.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+
Improved the detection of variables inside Astro files. Now the rule `noUnusedVariables` and others will trigger fewer false positives.

crates/biome_cli/tests/cases/handle_astro_files.rs

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -807,6 +807,8 @@ import { Component } from "./component.svelte";
807807
let hello = "Hello World";
808808
let array = [];
809809
let props = [];
810+
let dynamicId = "main";
811+
let dynamicClass = "container";
810812
---
811813
812814
<html>
@@ -815,6 +817,7 @@ let props = [];
815817
{ array.map(item => (<span>{item}</span>)) }
816818
<Component />
817819
<input {...props}>
820+
<div id={dynamicId} class={dynamicClass}></div>
818821
</html>
819822
"#
820823
.as_bytes(),
@@ -1179,3 +1182,122 @@ fn return_in_template_expression_should_error() {
11791182
result,
11801183
));
11811184
}
1185+
1186+
#[test]
1187+
fn no_unused_variables_in_astro_attribute_expressions() {
1188+
let fs = MemoryFileSystem::default();
1189+
let mut console = BufferConsole::default();
1190+
1191+
fs.insert(
1192+
"biome.json".into(),
1193+
r#"{ "html": { "linter": {"enabled": true}, "experimentalFullSupportEnabled": true } }"#
1194+
.as_bytes(),
1195+
);
1196+
1197+
let file = Utf8Path::new("file.astro");
1198+
fs.insert(
1199+
file.into(),
1200+
// Variables used in plain HTML attribute expressions, body text expressions,
1201+
// and Astro directives together — verifies all extraction paths work for
1202+
// cross-snippet binding tracking.
1203+
r#"---
1204+
const handler = () => {};
1205+
const dynamicId = "foo";
1206+
const dynamicClass = "bar";
1207+
const label = "Click me";
1208+
const pageTitle = "Home";
1209+
const sectionId = "main";
1210+
const dataTheme = "dark";
1211+
const items = ["a", "b", "c"];
1212+
const showList = true;
1213+
---
1214+
1215+
<html>
1216+
<head><title>{pageTitle}</title></head>
1217+
<body>
1218+
<button onclick={handler} id={dynamicId} class={dynamicClass} aria-label={label}>Click</button>
1219+
<section id={sectionId} data-theme={dataTheme}>
1220+
<div set:html={items.map(i => `<li>${i}</li>`).join("")} />
1221+
<span set:text={showList ? label : "hidden"} />
1222+
</section>
1223+
</body>
1224+
</html>
1225+
"#
1226+
.as_bytes(),
1227+
);
1228+
1229+
let (fs, result) = run_cli(
1230+
fs,
1231+
&mut console,
1232+
Args::from(["lint", "--only=noUnusedVariables", file.as_str()].as_slice()),
1233+
);
1234+
1235+
assert!(result.is_ok(), "run_cli returned {result:?}");
1236+
1237+
assert_cli_snapshot(SnapshotPayload::new(
1238+
module_path!(),
1239+
"no_unused_variables_in_astro_attribute_expressions",
1240+
fs,
1241+
console,
1242+
result,
1243+
));
1244+
}
1245+
1246+
#[test]
1247+
fn no_unused_variables_in_astro_directive_expressions() {
1248+
let fs = MemoryFileSystem::default();
1249+
let mut console = BufferConsole::default();
1250+
1251+
fs.insert(
1252+
"biome.json".into(),
1253+
r#"{ "html": { "linter": {"enabled": true}, "experimentalFullSupportEnabled": true } }"#
1254+
.as_bytes(),
1255+
);
1256+
1257+
let file = Utf8Path::new("file.astro");
1258+
fs.insert(
1259+
file.into(),
1260+
// Covers every Astro directive type that accepts an expression value:
1261+
// - class:list={expr} (AstroClassDirective)
1262+
// - set:text={expr} (AstroSetDirective)
1263+
// - set:html={expr} (AstroSetDirective)
1264+
// - define:vars={expr} (AstroDefineDirective)
1265+
// - client:visible={expr} (AstroClientDirective with options)
1266+
// - client:idle={expr} (AstroClientDirective with options)
1267+
r#"---
1268+
const classes = ["a", "b"];
1269+
const greeting = "hello";
1270+
const rawHtml = "<strong>bold</strong>";
1271+
const textColor = "red";
1272+
const visibilityOpts = { rootMargin: "200px" };
1273+
const idleOpts = { timeout: 500 };
1274+
---
1275+
1276+
<div class:list={classes}>Content</div>
1277+
<p set:text={greeting} />
1278+
<article set:html={rawHtml} />
1279+
<style define:vars={{ textColor }}>
1280+
h1 { color: var(--textColor); }
1281+
</style>
1282+
<Component client:visible={visibilityOpts} />
1283+
<Component client:idle={idleOpts} />
1284+
"#
1285+
.as_bytes(),
1286+
);
1287+
1288+
let (fs, result) = run_cli(
1289+
fs,
1290+
&mut console,
1291+
Args::from(["lint", "--only=noUnusedVariables", file.as_str()].as_slice()),
1292+
);
1293+
1294+
assert!(result.is_ok(), "run_cli returned {result:?}");
1295+
1296+
assert_cli_snapshot(SnapshotPayload::new(
1297+
module_path!(),
1298+
"no_unused_variables_in_astro_directive_expressions",
1299+
fs,
1300+
console,
1301+
result,
1302+
));
1303+
}

crates/biome_cli/tests/snapshots/main_cases_handle_astro_files/embedded_bindings_are_tracked_correctly.snap

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import { Component } from "./component.svelte";
2121
let hello = "Hello World";
2222
let array = [];
2323
let props = [];
24+
let dynamicId = "main";
25+
let dynamicClass = "container";
2426
---
2527
2628
<html>
@@ -29,6 +31,7 @@ let props = [];
2931
{ array.map(item => (<span>{item}</span>)) }
3032
<Component />
3133
<input {...props}>
34+
<div id={dynamicId} class={dynamicClass}></div>
3235
</html>
3336
3437
```
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
source: crates/biome_cli/tests/snap_test.rs
3+
expression: redactor(content)
4+
---
5+
## `biome.json`
6+
7+
```json
8+
{
9+
"html": {
10+
"linter": { "enabled": true },
11+
"experimentalFullSupportEnabled": true
12+
}
13+
}
14+
```
15+
16+
## `file.astro`
17+
18+
```astro
19+
---
20+
const handler = () => {};
21+
const dynamicId = "foo";
22+
const dynamicClass = "bar";
23+
const label = "Click me";
24+
const pageTitle = "Home";
25+
const sectionId = "main";
26+
const dataTheme = "dark";
27+
const items = ["a", "b", "c"];
28+
const showList = true;
29+
---
30+
31+
<html>
32+
<head><title>{pageTitle}</title></head>
33+
<body>
34+
<button onclick={handler} id={dynamicId} class={dynamicClass} aria-label={label}>Click</button>
35+
<section id={sectionId} data-theme={dataTheme}>
36+
<div set:html={items.map(i => `<li>${i}</li>`).join("")} />
37+
<span set:text={showList ? label : "hidden"} />
38+
</section>
39+
</body>
40+
</html>
41+
42+
```
43+
44+
# Emitted Messages
45+
46+
```block
47+
Checked 1 file in <TIME>. No fixes applied.
48+
```
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
source: crates/biome_cli/tests/snap_test.rs
3+
expression: redactor(content)
4+
---
5+
## `biome.json`
6+
7+
```json
8+
{
9+
"html": {
10+
"linter": { "enabled": true },
11+
"experimentalFullSupportEnabled": true
12+
}
13+
}
14+
```
15+
16+
## `file.astro`
17+
18+
```astro
19+
---
20+
const classes = ["a", "b"];
21+
const greeting = "hello";
22+
const rawHtml = "<strong>bold</strong>";
23+
const textColor = "red";
24+
const visibilityOpts = { rootMargin: "200px" };
25+
const idleOpts = { timeout: 500 };
26+
---
27+
28+
<div class:list={classes}>Content</div>
29+
<p set:text={greeting} />
30+
<article set:html={rawHtml} />
31+
<style define:vars={{ textColor }}>
32+
h1 { color: var(--textColor); }
33+
</style>
34+
<Component client:visible={visibilityOpts} />
35+
<Component client:idle={idleOpts} />
36+
37+
```
38+
39+
# Emitted Messages
40+
41+
```block
42+
Checked 1 file in <TIME>. No fixes applied.
43+
```

crates/biome_html_syntax/src/directive_ext.rs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,18 @@
1-
use crate::{AnySvelteDirective, HtmlAttributeInitializerClause};
1+
use crate::{AnyAstroDirective, AnySvelteDirective, HtmlAttributeInitializerClause};
2+
3+
impl AnyAstroDirective {
4+
/// Returns the initializer from an Astro directive's value, if available.
5+
pub fn initializer(&self) -> Option<HtmlAttributeInitializerClause> {
6+
match self {
7+
Self::AstroClassDirective(dir) => dir.value().ok()?.initializer(),
8+
Self::AstroClientDirective(dir) => dir.value().ok()?.initializer(),
9+
Self::AstroDefineDirective(dir) => dir.value().ok()?.initializer(),
10+
Self::AstroIsDirective(dir) => dir.value().ok()?.initializer(),
11+
Self::AstroServerDirective(dir) => dir.value().ok()?.initializer(),
12+
Self::AstroSetDirective(dir) => dir.value().ok()?.initializer(),
13+
}
14+
}
15+
}
216

317
impl AnySvelteDirective {
418
/// Returns the initializer from a Svelte directive's value, if available.

crates/biome_service/src/file_handlers/html.rs

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,12 @@ use biome_html_formatter::{
4646
use biome_html_parser::{HtmlParserOptions, parse_html_with_cache};
4747
use biome_html_syntax::element_ext::AnyEmbeddedContent;
4848
use biome_html_syntax::{
49-
AnySvelteDirective, AstroEmbeddedContent, HtmlAttributeInitializerClause,
50-
HtmlDoubleTextExpression, HtmlElement, HtmlFileSource, HtmlLanguage, HtmlRoot,
51-
HtmlSingleTextExpression, HtmlSyntaxNode, HtmlTextExpression, HtmlTextExpressions, HtmlVariant,
52-
SvelteAwaitBlock, SvelteEachBlock, SvelteIfBlock, SvelteKeyBlock, VueDirective,
53-
VueVBindShorthandDirective, VueVOnShorthandDirective, VueVSlotShorthandDirective,
49+
AnyAstroDirective, AnySvelteDirective, AstroEmbeddedContent, HtmlAttribute,
50+
HtmlAttributeInitializerClause, HtmlDoubleTextExpression, HtmlElement, HtmlFileSource,
51+
HtmlLanguage, HtmlRoot, HtmlSingleTextExpression, HtmlSyntaxNode, HtmlTextExpression,
52+
HtmlTextExpressions, HtmlVariant, SvelteAwaitBlock, SvelteEachBlock, SvelteIfBlock,
53+
SvelteKeyBlock, VueDirective, VueVBindShorthandDirective, VueVOnShorthandDirective,
54+
VueVSlotShorthandDirective,
5455
};
5556
use biome_js_parser::parse_js_with_offset_and_cache;
5657
use biome_js_syntax::{EmbeddingKind, JsFileSource, JsLanguage};
@@ -527,6 +528,36 @@ fn parse_embedded_nodes(
527528
{
528529
nodes.push(parsed.node);
529530
}
531+
532+
// Astro directives: class:list={...}, define:vars={...}, etc.
533+
if let Some(directive) = AnyAstroDirective::cast_ref(&element)
534+
&& let Some(initializer) = directive.initializer()
535+
&& let Some(candidate) = build_attribute_expression_candidate(&initializer)
536+
&& let Some(embed_match) = EmbedDetectorsRegistry::detect_match(
537+
HostLanguage::Html,
538+
&candidate,
539+
&doc_file_source,
540+
)
541+
&& let Some(parsed) =
542+
parse_matched_embed(&candidate, &embed_match, &mut ctx, None)
543+
{
544+
nodes.push(parsed.node);
545+
}
546+
547+
// Plain HTML attributes with expression values: class={expr}, id={expr}, etc.
548+
if let Some(attr) = HtmlAttribute::cast_ref(&element)
549+
&& let Some(initializer) = attr.initializer()
550+
&& let Some(candidate) = build_attribute_expression_candidate(&initializer)
551+
&& let Some(embed_match) = EmbedDetectorsRegistry::detect_match(
552+
HostLanguage::Html,
553+
&candidate,
554+
&doc_file_source,
555+
)
556+
&& let Some(parsed) =
557+
parse_matched_embed(&candidate, &embed_match, &mut ctx, None)
558+
{
559+
nodes.push(parsed.node);
560+
}
530561
}
531562
}
532563
HtmlVariant::Vue => {
@@ -992,6 +1023,17 @@ fn build_vue_directive_candidate(
9921023
/// The JS content is the literal token inside the expression node.
9931024
fn build_svelte_directive_candidate(
9941025
initializer: &HtmlAttributeInitializerClause,
1026+
) -> Option<EmbedCandidate> {
1027+
build_attribute_expression_candidate(initializer)
1028+
}
1029+
1030+
/// Build an `EmbedCandidate::Directive` from an initializer clause containing
1031+
/// a curly brace text expression (`attr={expr}`).
1032+
///
1033+
/// Used by both Astro and Svelte attribute expression extraction.
1034+
/// Returns `None` if the initializer does not contain a text expression.
1035+
fn build_attribute_expression_candidate(
1036+
initializer: &HtmlAttributeInitializerClause,
9951037
) -> Option<EmbedCandidate> {
9961038
let value_node = initializer.value().ok()?;
9971039
let text_expression = value_node.as_html_attribute_single_text_expression()?;

0 commit comments

Comments
 (0)