diff --git a/Cargo.lock b/Cargo.lock index 7c3eebcc8e84..30ebc220711a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -206,6 +206,7 @@ dependencies = [ "biome_formatter", "biome_graphql_analyze", "biome_graphql_syntax", + "biome_html_syntax", "biome_js_analyze", "biome_js_formatter", "biome_js_syntax", @@ -662,6 +663,8 @@ dependencies = [ "biome_rowan", "biome_service", "biome_suppression", + "countme", + "tests_macros", ] [[package]] @@ -1031,6 +1034,7 @@ dependencies = [ "biome_graphql_syntax", "biome_grit_patterns", "biome_html_formatter", + "biome_html_parser", "biome_html_syntax", "biome_js_analyze", "biome_js_factory", diff --git a/crates/biome_configuration/Cargo.toml b/crates/biome_configuration/Cargo.toml index b4ae45f6ac84..ee5241a0bb84 100644 --- a/crates/biome_configuration/Cargo.toml +++ b/crates/biome_configuration/Cargo.toml @@ -24,6 +24,7 @@ biome_flags = { workspace = true } biome_formatter = { workspace = true, features = ["serde"] } biome_graphql_analyze = { workspace = true } biome_graphql_syntax = { workspace = true } +biome_html_syntax = { workspace = true } biome_js_analyze = { workspace = true } biome_js_formatter = { workspace = true, features = ["serde"] } biome_js_syntax = { workspace = true, features = ["schema"] } @@ -50,6 +51,7 @@ schema = [ "biome_json_syntax/schema", "biome_css_syntax/schema", "biome_graphql_syntax/schema", + "biome_html_syntax/schema", ] [dev-dependencies] @@ -57,3 +59,7 @@ insta = { workspace = true } [lints] workspace = true + +[package.metadata.cargo-udeps.ignore] +# currently technically not used, but needed in order to compile because of the `schema` feature +normal = ["biome_html_syntax"] diff --git a/crates/biome_html_formatter/Cargo.toml b/crates/biome_html_formatter/Cargo.toml index bde30b2536af..5288131cddf9 100644 --- a/crates/biome_html_formatter/Cargo.toml +++ b/crates/biome_html_formatter/Cargo.toml @@ -22,6 +22,8 @@ biome_fs = { workspace = true } biome_html_parser = { workspace = true } biome_parser = { workspace = true } biome_service = { workspace = true } +countme = { workspace = true, features = ["enable"] } +tests_macros = { workspace = true } [lints] workspace = true diff --git a/crates/biome_html_formatter/tests/prettier_tests.rs b/crates/biome_html_formatter/tests/prettier_tests.rs new file mode 100644 index 000000000000..75b8cd3c13a5 --- /dev/null +++ b/crates/biome_html_formatter/tests/prettier_tests.rs @@ -0,0 +1,33 @@ +use std::{env, path::Path}; + +use biome_formatter::{IndentStyle, IndentWidth}; +use biome_formatter_test::test_prettier_snapshot::{PrettierSnapshot, PrettierTestFile}; +use biome_html_formatter::{context::HtmlFormatOptions, HtmlFormatLanguage}; +use biome_html_syntax::HtmlFileSource; + +mod language; + +tests_macros::gen_tests! {"tests/specs/prettier/**/*.html", crate::test_snapshot, "script"} + +#[allow(dead_code)] +fn test_snapshot(input: &'static str, _: &str, _: &str, _: &str) { + countme::enable(true); + + let root_path = Path::new(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/specs/prettier/" + )); + + let test_file = PrettierTestFile::new(input, root_path); + let source_type = HtmlFileSource::html(); + + let options = HtmlFormatOptions::new() + .with_indent_style(IndentStyle::Space) + .with_indent_width(IndentWidth::default()); + + let language = language::HtmlTestFormatLanguage::new(source_type); + + let snapshot = PrettierSnapshot::new(test_file, language, HtmlFormatLanguage::new(options)); + + snapshot.test() +} diff --git a/crates/biome_html_formatter/tests/spec_test.rs b/crates/biome_html_formatter/tests/spec_test.rs new file mode 100644 index 000000000000..fea0ee2e56b9 --- /dev/null +++ b/crates/biome_html_formatter/tests/spec_test.rs @@ -0,0 +1,47 @@ +use biome_formatter_test::spec::{SpecSnapshot, SpecTestFile}; +use biome_html_formatter::{context::HtmlFormatOptions, HtmlFormatLanguage}; +use biome_html_syntax::HtmlFileSource; +use std::path::Path; + +mod language { + include!("language.rs"); +} + +/// [insta.rs](https://insta.rs/docs) snapshot testing +/// +/// For better development workflow, run +/// `cargo watch -i '*.new' -x 'test -p biome_js_formatter formatter'` +/// +/// To review and commit the snapshots, `cargo install cargo-insta`, and run +/// `cargo insta review` or `cargo insta accept` +/// +/// The input and the expected output are stored as dedicated files in the `tests/specs` directory where +/// the input file name is `{spec_name}.json` and the output file name is `{spec_name}.json.snap`. +/// +/// Specs can be grouped in directories by specifying the directory name in the spec name. Examples: +/// +/// # Examples +/// +/// * `json/null` -> input: `tests/specs/json/null.json`, expected output: `tests/specs/json/null.json.snap` +/// * `null` -> input: `tests/specs/null.json`, expected output: `tests/specs/null.json.snap` +pub fn run(spec_input_file: &str, _expected_file: &str, test_directory: &str, _file_type: &str) { + let root_path = Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/specs/")); + + let Some(test_file) = SpecTestFile::try_from_file(spec_input_file, root_path, None) else { + return; + }; + + let source_type: HtmlFileSource = test_file.input_file().as_path().try_into().unwrap(); + + let options = HtmlFormatOptions::new(); + let language = language::HtmlTestFormatLanguage::new(source_type); + + let snapshot = SpecSnapshot::new( + test_file, + test_directory, + language, + HtmlFormatLanguage::new(options), + ); + + snapshot.test() +} diff --git a/crates/biome_html_formatter/tests/spec_tests.rs b/crates/biome_html_formatter/tests/spec_tests.rs new file mode 100644 index 000000000000..2b1d5ed90d3d --- /dev/null +++ b/crates/biome_html_formatter/tests/spec_tests.rs @@ -0,0 +1,9 @@ +mod quick_test; +mod spec_test; + +mod formatter { + + mod html { + tests_macros::gen_tests! {"tests/specs/**/*.html", crate::spec_test::run, "unknown"} + } +} diff --git a/crates/biome_html_formatter/tests/specs/example.html b/crates/biome_html_formatter/tests/specs/example.html new file mode 100644 index 000000000000..64aa040891f4 --- /dev/null +++ b/crates/biome_html_formatter/tests/specs/example.html @@ -0,0 +1 @@ + diff --git a/crates/biome_html_formatter/tests/specs/example.html.snap b/crates/biome_html_formatter/tests/specs/example.html.snap new file mode 100644 index 000000000000..80615268696d --- /dev/null +++ b/crates/biome_html_formatter/tests/specs/example.html.snap @@ -0,0 +1,29 @@ +--- +source: crates/biome_formatter_test/src/snapshot_builder.rs +info: example.html +--- +# Input + +```html + + +``` + + +============================= + +# Outputs + +## Output 1 + +----- +Indent style: Tab +Indent width: 2 +Line ending: LF +Line width: 80 +Attribute Position: Auto +----- + +```html + +``` diff --git a/crates/biome_html_formatter/tests/specs/prettier/prepare_tests.js b/crates/biome_html_formatter/tests/specs/prettier/prepare_tests.js new file mode 100644 index 000000000000..5898bb1954df --- /dev/null +++ b/crates/biome_html_formatter/tests/specs/prettier/prepare_tests.js @@ -0,0 +1,12 @@ +const {extractPrettierTests} = require("../../../../biome_formatter_test/src/prettier/prepare_tests"); + +async function main() { + await extractPrettierTests("html", { + parser: "html", + }); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/crates/biome_html_syntax/src/file_source.rs b/crates/biome_html_syntax/src/file_source.rs index e3009c9379c4..706ff71cd48e 100644 --- a/crates/biome_html_syntax/src/file_source.rs +++ b/crates/biome_html_syntax/src/file_source.rs @@ -1,13 +1,19 @@ use biome_rowan::FileSourceError; use std::{ffi::OsStr, path::Path}; -#[derive(Debug, Default, Clone)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive( + Debug, Clone, Default, Copy, Eq, PartialEq, Hash, serde::Serialize, serde::Deserialize, +)] pub struct HtmlFileSource { #[allow(unused)] variant: HtmlVariant, } -#[derive(Debug, Default, Clone)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive( + Debug, Clone, Default, Copy, Eq, PartialEq, Hash, serde::Serialize, serde::Deserialize, +)] enum HtmlVariant { #[default] Standard, diff --git a/crates/biome_service/Cargo.toml b/crates/biome_service/Cargo.toml index 4508d79e6817..fb3bb96f8035 100644 --- a/crates/biome_service/Cargo.toml +++ b/crates/biome_service/Cargo.toml @@ -33,6 +33,7 @@ biome_graphql_parser = { workspace = true } biome_graphql_syntax = { workspace = true } biome_grit_patterns = { workspace = true } biome_html_formatter = { workspace = true } +biome_html_parser = { workspace = true } biome_html_syntax = { workspace = true } biome_js_analyze = { workspace = true } biome_js_factory = { workspace = true, optional = true } diff --git a/crates/biome_service/src/file_handlers/html.rs b/crates/biome_service/src/file_handlers/html.rs index d670a306c5d6..9d39e9714f15 100644 --- a/crates/biome_service/src/file_handlers/html.rs +++ b/crates/biome_service/src/file_handlers/html.rs @@ -1,7 +1,20 @@ -use biome_html_formatter::HtmlFormatOptions; +use biome_formatter::Printed; +use biome_fs::BiomePath; +use biome_html_formatter::{format_node, HtmlFormatOptions}; +use biome_html_parser::parse_html_with_cache; use biome_html_syntax::HtmlLanguage; +use biome_parser::AnyParse; +use biome_rowan::NodeCache; -use crate::settings::ServiceLanguage; +use crate::{ + settings::{ServiceLanguage, Settings, WorkspaceSettingsHandle}, + WorkspaceError, +}; + +use super::{ + AnalyzerCapabilities, Capabilities, DebugCapabilities, DocumentFileSource, ExtensionHandler, + FormatterCapabilities, ParseResult, ParserCapabilities, SearchCapabilities, +}; impl ServiceLanguage for HtmlLanguage { type FormatterSettings = (); @@ -39,3 +52,67 @@ impl ServiceLanguage for HtmlLanguage { todo!() } } + +#[derive(Debug, Default, PartialEq, Eq)] +pub(crate) struct HtmlFileHandler; + +impl ExtensionHandler for HtmlFileHandler { + fn capabilities(&self) -> Capabilities { + Capabilities { + parser: ParserCapabilities { parse: Some(parse) }, + debug: DebugCapabilities { + debug_syntax_tree: None, + debug_control_flow: None, + debug_formatter_ir: None, + }, + analyzer: AnalyzerCapabilities { + lint: None, + code_actions: None, + rename: None, + fix_all: None, + organize_imports: None, + }, + formatter: FormatterCapabilities { + format: Some(format), + format_range: None, + format_on_type: None, + }, + search: SearchCapabilities { search: None }, + } + } +} + +fn parse( + _biome_path: &BiomePath, + file_source: DocumentFileSource, + text: &str, + _settings: Option<&Settings>, + cache: &mut NodeCache, +) -> ParseResult { + let parse = parse_html_with_cache(text, cache); + + ParseResult { + any_parse: parse.into(), + language: Some(file_source), + } +} + +#[tracing::instrument(level = "debug", skip(parse, settings))] +fn format( + biome_path: &BiomePath, + document_file_source: &DocumentFileSource, + parse: AnyParse, + settings: WorkspaceSettingsHandle, +) -> Result { + let options = settings.format_options::(biome_path, document_file_source); + + tracing::debug!("Format with the following options: \n{}", options); + + let tree = parse.syntax(); + let formatted = format_node(options, &tree)?; + + match formatted.print() { + Ok(printed) => Ok(printed), + Err(error) => Err(WorkspaceError::FormatError(error.into())), + } +} diff --git a/crates/biome_service/src/file_handlers/mod.rs b/crates/biome_service/src/file_handlers/mod.rs index da1f1adbbf95..375d27654376 100644 --- a/crates/biome_service/src/file_handlers/mod.rs +++ b/crates/biome_service/src/file_handlers/mod.rs @@ -28,6 +28,7 @@ use biome_formatter::Printed; use biome_fs::BiomePath; use biome_graphql_syntax::{GraphqlFileSource, GraphqlLanguage}; use biome_grit_patterns::{GritQuery, GritQueryResult, GritTargetFile}; +use biome_html_syntax::HtmlFileSource; use biome_js_parser::{parse, JsParserOptions}; use biome_js_syntax::{ EmbeddingKind, JsFileSource, JsLanguage, Language, LanguageVariant, TextRange, TextSize, @@ -37,6 +38,7 @@ use biome_parser::AnyParse; use biome_project::PackageJson; use biome_rowan::{FileSourceError, NodeCache}; use biome_string_case::StrExtension; +use html::HtmlFileHandler; pub use javascript::JsFormatterSettings; use std::borrow::Cow; use std::ffi::OsStr; @@ -61,6 +63,7 @@ pub enum DocumentFileSource { Json(JsonFileSource), Css(CssFileSource), Graphql(GraphqlFileSource), + Html(HtmlFileSource), #[default] Unknown, } @@ -89,6 +92,12 @@ impl From for DocumentFileSource { } } +impl From for DocumentFileSource { + fn from(value: HtmlFileSource) -> Self { + Self::Html(value) + } +} + impl From<&Path> for DocumentFileSource { fn from(path: &Path) -> Self { Self::from_path(path) @@ -132,6 +141,9 @@ impl DocumentFileSource { if let Ok(file_source) = GraphqlFileSource::try_from_extension(extension) { return Ok(file_source.into()); } + if let Ok(file_source) = HtmlFileSource::try_from_extension(extension) { + return Ok(file_source.into()); + } Err(FileSourceError::UnknownExtension) } @@ -154,6 +166,9 @@ impl DocumentFileSource { if let Ok(file_source) = GraphqlFileSource::try_from_language_id(language_id) { return Ok(file_source.into()); } + if let Ok(file_source) = HtmlFileSource::try_from_language_id(language_id) { + return Ok(file_source.into()); + } Err(FileSourceError::UnknownLanguageId) } @@ -293,6 +308,7 @@ impl DocumentFileSource { }, DocumentFileSource::Json(_) | DocumentFileSource::Css(_) => true, DocumentFileSource::Graphql(_) => true, + DocumentFileSource::Html(_) => true, DocumentFileSource::Unknown => false, } } @@ -324,6 +340,7 @@ impl biome_console::fmt::Display for DocumentFileSource { } DocumentFileSource::Css(_) => fmt.write_markup(markup! { "CSS" }), DocumentFileSource::Graphql(_) => fmt.write_markup(markup! { "GraphQL" }), + DocumentFileSource::Html(_) => fmt.write_markup(markup! { "HTML" }), DocumentFileSource::Unknown => fmt.write_markup(markup! { "Unknown" }), } } @@ -501,6 +518,7 @@ pub(crate) struct Features { svelte: SvelteFileHandler, unknown: UnknownFileHandler, graphql: GraphqlFileHandler, + html: HtmlFileHandler, } impl Features { @@ -513,6 +531,7 @@ impl Features { vue: VueFileHandler {}, svelte: SvelteFileHandler {}, graphql: GraphqlFileHandler {}, + html: HtmlFileHandler {}, unknown: UnknownFileHandler::default(), } } @@ -533,6 +552,7 @@ impl Features { DocumentFileSource::Json(_) => self.json.capabilities(), DocumentFileSource::Css(_) => self.css.capabilities(), DocumentFileSource::Graphql(_) => self.graphql.capabilities(), + DocumentFileSource::Html(_) => self.html.capabilities(), DocumentFileSource::Unknown => self.unknown.capabilities(), } } diff --git a/packages/@biomejs/backend-jsonrpc/src/workspace.ts b/packages/@biomejs/backend-jsonrpc/src/workspace.ts index 7a5f8934758e..9e76901fb63c 100644 --- a/packages/@biomejs/backend-jsonrpc/src/workspace.ts +++ b/packages/@biomejs/backend-jsonrpc/src/workspace.ts @@ -2524,7 +2524,8 @@ export type DocumentFileSource = | { Js: JsFileSource } | { Json: JsonFileSource } | { Css: CssFileSource } - | { Graphql: GraphqlFileSource }; + | { Graphql: GraphqlFileSource } + | { Html: HtmlFileSource }; export interface JsFileSource { /** * Used to mark if the source is being used for an Astro, Svelte or Vue file @@ -2545,6 +2546,9 @@ export interface CssFileSource { export interface GraphqlFileSource { variant: GraphqlVariant; } +export interface HtmlFileSource { + variant: HtmlVariant; +} export type EmbeddingKind = "Astro" | "Vue" | "Svelte" | "None"; export type Language = | "JavaScript" @@ -2570,6 +2574,7 @@ export type CssVariant = "Standard"; * The style of GraphQL contained in the file. */ export type GraphqlVariant = "Standard"; +export type HtmlVariant = "Standard" | "Astro"; export interface ChangeFileParams { content: string; path: BiomePath; diff --git a/xtask/rules_check/src/lib.rs b/xtask/rules_check/src/lib.rs index 0828accb4bb6..f285d9deb2af 100644 --- a/xtask/rules_check/src/lib.rs +++ b/xtask/rules_check/src/lib.rs @@ -448,6 +448,7 @@ fn assert_lint( }); } } + DocumentFileSource::Html(..) => todo!("HTML analysis is not yet supported"), // Unknown code blocks should be ignored by tests DocumentFileSource::Unknown => {} }