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
3 changes: 2 additions & 1 deletion Cargo.lock

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

93 changes: 93 additions & 0 deletions crates/biome_cli/tests/cases/html.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,96 @@ fn should_not_error_when_interpolation_is_enabled() {
result,
));
}

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

let html_file = Utf8Path::new("file.html");
fs.insert(
html_file.into(),
r#"<script>function lorem() { return "ipsum" }</script>
<style>#id .class div > p { background-color: red; align: center; padding: 0; } </style>
"#
.as_bytes(),
);

fs.insert(
Utf8Path::new("biome.json").into(),
r#"{
"html": {
"formatter": {
"enabled": true,
"indentScriptAndStyle": true
}
}
}"#
.as_bytes(),
);

let (fs, result) = run_cli(
fs,
&mut console,
Args::from(["format", "--write", html_file.as_str()].as_slice()),
);

assert!(result.is_ok(), "run_cli returned {result:?}");

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

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

let html_file = Utf8Path::new("file.html");
fs.insert(
html_file.into(),
r#"<script>function lorem() { return "ipsum" }</script>
<style>#id .class div > p { background-color: red; align: center; padding: 0; } </style>
"#
.as_bytes(),
);

fs.insert(
Utf8Path::new("biome.json").into(),
r#"{
"html": {
"formatter": {
"enabled": true,
"indentScriptAndStyle": true
}
},
"javascript": {
"formatter": {
"quoteStyle": "single"
}
}
}"#
.as_bytes(),
);

let (fs, result) = run_cli(
fs,
&mut console,
Args::from(["format", "--write", html_file.as_str()].as_slice()),
);

assert!(result.is_ok(), "run_cli returned {result:?}");

assert_cli_snapshot(SnapshotPayload::new(
module_path!(),
"should_format_indent_embedded_languages_with_language_options",
fs,
console,
result,
));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
source: crates/biome_cli/tests/snap_test.rs
expression: redactor(content)
---
## `biome.json`

```json
{
"html": {
"formatter": {
"enabled": true,
"indentScriptAndStyle": true
}
}
}
```

## `file.html`

```html
<script>
function lorem() {
return "ipsum";
}
</script>
<style>
#id .class div > p {
background-color: red;
align: center;
padding: 0;
}
</style>

```

# Emitted Messages

```block
Formatted 1 file in <TIME>. Fixed 1 file.
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
source: crates/biome_cli/tests/snap_test.rs
expression: redactor(content)
---
## `biome.json`

```json
{
"html": {
"formatter": {
"enabled": true,
"indentScriptAndStyle": true
}
},
"javascript": {
"formatter": {
"quoteStyle": "single"
}
}
}
```

## `file.html`

```html
<script>
function lorem() {
return 'ipsum';
}
</script>
<style>
#id .class div > p {
background-color: red;
align: center;
padding: 0;
}
</style>

```

# Emitted Messages

```block
Formatted 1 file in <TIME>. Fixed 1 file.
```
15 changes: 13 additions & 2 deletions crates/biome_css_formatter/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ use crate::prelude::{format_bogus_node, format_suppressed_node};
pub(crate) use crate::trivia::*;
use biome_css_syntax::{
AnyCssDeclarationBlock, AnyCssRule, AnyCssRuleBlock, AnyCssValue, CssLanguage, CssSyntaxKind,
CssSyntaxNode, CssSyntaxToken,
CssSyntaxNode, CssSyntaxNodeWithOffset, CssSyntaxToken,
};
use biome_formatter::comments::Comments;
use biome_formatter::prelude::*;
Expand Down Expand Up @@ -289,6 +289,7 @@ impl FormatLanguage for CssFormatLanguage {
self,
root: &CssSyntaxNode,
source_map: Option<TransformSourceMap>,
_delegate_fmt_embedded_nodes: bool,
) -> Self::Context {
let comments = Comments::from_node(root, &CssCommentStyle, source_map.as_ref());
CssFormatContext::new(self.options, comments).with_source_map(source_map)
Expand Down Expand Up @@ -378,7 +379,17 @@ pub fn format_node(
options: CssFormatOptions,
root: &CssSyntaxNode,
) -> FormatResult<Formatted<CssFormatContext>> {
biome_formatter::format_node(root, CssFormatLanguage::new(options))
biome_formatter::format_node(root, CssFormatLanguage::new(options), false)
}

/// Formats a CSS syntax tree.
///
/// It returns the [Formatted] document that can be printed to a string.
pub fn format_node_with_offset(
options: CssFormatOptions,
root: &CssSyntaxNodeWithOffset,
) -> FormatResult<Formatted<CssFormatContext>> {
biome_formatter::format_node_with_offset(root, CssFormatLanguage::new(options), false)
}

/// Formats a single node within a file, supported by Biome.
Expand Down
1 change: 1 addition & 0 deletions crates/biome_css_syntax/src/syntax_node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ pub type CssSyntaxElement = biome_rowan::SyntaxElement<CssLanguage>;
pub type CssSyntaxNodeChildren = biome_rowan::SyntaxNodeChildren<CssLanguage>;
pub type CssSyntaxElementChildren = biome_rowan::SyntaxElementChildren<CssLanguage>;
pub type CssSyntaxList = biome_rowan::SyntaxList<CssLanguage>;
pub type CssSyntaxNodeWithOffset = biome_rowan::SyntaxNodeWithOffset<CssLanguage>;
1 change: 0 additions & 1 deletion crates/biome_formatter/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ indexmap = { workspace = true }
rustc-hash = { workspace = true }
schemars = { workspace = true, optional = true }
serde = { workspace = true, features = ["derive"], optional = true }
tracing = { workspace = true }
unicode-width = { workspace = true }

[dev-dependencies]
Expand Down
2 changes: 1 addition & 1 deletion crates/biome_formatter/src/format_element.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ impl PrintMode {
pub struct Interned(Rc<[FormatElement]>);

impl Interned {
pub(super) fn new(content: Vec<FormatElement>) -> Self {
pub fn new(content: Vec<FormatElement>) -> Self {
Self(content.into())
}
}
Expand Down
80 changes: 79 additions & 1 deletion crates/biome_formatter/src/format_element/document.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#![expect(clippy::mutable_key_type)]

use super::tag::Tag;
use crate::format_element::tag::DedentMode;
use crate::prelude::tag::GroupMode;
Expand All @@ -19,6 +20,10 @@ pub struct Document {
}

impl Document {
pub fn new(elements: Vec<FormatElement>) -> Self {
Self { elements }
}

/// Sets [`expand`](tag::Group::expand) to [`GroupMode::Propagated`] if the group contains any of:
/// * a group with [`expand`](tag::Group::expand) set to [GroupMode::Propagated] or [GroupMode::Expand].
/// * a non-soft [line break](FormatElement::Line) with mode [LineMode::Hard], [LineMode::Empty], or [LineMode::Literal].
Expand Down Expand Up @@ -132,6 +137,75 @@ impl Document {
let mut interned = FxHashMap::default();
propagate_expands(self, &mut enclosing, &mut interned);
}

pub fn into_elements(self) -> Vec<FormatElement> {
self.elements
}

pub fn as_elements(&self) -> &[FormatElement] {
&self.elements
}
}

pub trait DocumentVisitor {
/// Visit an element and optionally return a replacement
fn visit_element(&mut self, element: &FormatElement) -> Option<FormatElement>;
}

/// Applies a visitor to transform elements in the document
pub struct ElementTransformer;

impl ElementTransformer {
/// Visits a mutable [Document] and replaces its internal elements.
pub fn transform_document<V: DocumentVisitor>(document: &mut Document, visitor: &mut V) {
let elements = std::mem::take(&mut document.elements);
document.elements = Self::transform_elements(elements, visitor);
}

/// Iterates over each element of the document and map each element to a new element. The new element is
/// optionally crated using [DocumentVisitor::visit_element]. If no element is returned, the original element is kept.
///
/// Nested data structures such as [FormatElement::Interned] and [FormatElement::BestFitting] use recursion and call
/// [Self::transform_elements] again.
fn transform_elements<V: DocumentVisitor>(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add an explanation of how this algorithm works, including the role of the base_path in it? As far as I can see the ElementPath is not used for anything useful, but if it is, I'm not seeing it.

elements: Vec<FormatElement>,
visitor: &mut V,
) -> Vec<FormatElement> {
elements
.into_iter()
.map(|element| {
// Transform nested elements first
let transformed_element = match element {
FormatElement::Interned(interned) => {
let nested_elements = interned.deref().to_vec();
let transformed_nested = Self::transform_elements(nested_elements, visitor);
FormatElement::Interned(Interned::new(transformed_nested))
}
FormatElement::BestFitting(best_fitting) => {
let variants: Vec<Box<[FormatElement]>> = best_fitting
.variants()
.iter()
.map(|variant| {
Self::transform_elements(variant.to_vec(), visitor)
.into_boxed_slice()
})
.collect();
// SAFETY: Safe because the number of variants is the same after the transformation
unsafe {
FormatElement::BestFitting(BestFittingElement::from_vec_unchecked(
variants,
))
}
Comment on lines +194 to +198
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add a safety comment for from_vec_unchecked

This unsafe needs a brief invariant: e.g. variants length ≥ 2 and represent least/most expanded in order; elements remain valid post-visit.

🤖 Prompt for AI Agents
In crates/biome_formatter/src/format_element/document.rs around lines 194–198,
the unsafe call to BestFittingElement::from_vec_unchecked needs an explicit
safety comment: add a short doc-style comment immediately above the unsafe block
stating the invariants required by from_vec_unchecked (e.g. variants.len() >= 2;
the entries are ordered from least to most expanded; each FormatElement in
variants remains valid and unchanged for the lifetime expected by
BestFittingElement after visiting), and optionally add debug_asserts that check
variants.len() >= 2 and a comment that any further ordering/validity checks are
upheld by the caller/producer of variants.

}
other => other,
};
// Then apply a visitor to the element itself
visitor
.visit_element(&transformed_element)
.unwrap_or(transformed_element)
})
.collect()
}
}

impl From<Vec<FormatElement>> for Document {
Expand Down Expand Up @@ -530,6 +604,9 @@ impl Format<IrFormatContext> for &[FormatElement] {
write!(f, [text("fill(")])?;
}

StartEmbedded(_) => {
write!(f, [text("embedded(")])?;
}
StartEntry => {
// handled after the match for all start tags
}
Expand All @@ -544,7 +621,8 @@ impl Format<IrFormatContext> for &[FormatElement] {
| EndGroup
| EndLineSuffix
| EndDedent(_)
| EndVerbatim => {
| EndVerbatim
| EndEmbedded => {
write!(f, [ContentArrayEnd, text(")")])?;
}
};
Expand Down
Loading
Loading