diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index db391eaf1c35..2a13a9eb1c27 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -5,13 +5,13 @@ on: branches: - main paths: # Only run when changes are made to rust code or root Cargo - - 'crates/**' - - 'fuzz/**' - - 'xtask/**' - - 'Cargo.toml' - - 'Cargo.lock' - - 'rust-toolchain.toml' - - 'rustfmt.toml' + - "crates/**" + - "fuzz/**" + - "xtask/**" + - "Cargo.toml" + - "Cargo.lock" + - "rust-toolchain.toml" + - "rustfmt.toml" # Cancel jobs when the PR is updated concurrency: @@ -184,5 +184,6 @@ jobs: run: | if [[ `git status --porcelain` ]]; then git status + git diff exit 1 fi diff --git a/Cargo.lock b/Cargo.lock index 40693894f25f..8ab20d883726 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -322,6 +322,16 @@ dependencies = [ "tracing", ] +[[package]] +name = "biome_css_semantic" +version = "0.0.0" +dependencies = [ + "biome_css_parser", + "biome_css_syntax", + "biome_rowan", + "rustc-hash 1.1.0", +] + [[package]] name = "biome_css_syntax" version = "0.5.7" diff --git a/Cargo.toml b/Cargo.toml index 5e5aeb302068..6284e42a0b52 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -97,6 +97,7 @@ biome_css_analyze = { version = "0.5.7", path = "./crates/biome_css_a biome_css_factory = { version = "0.5.7", path = "./crates/biome_css_factory" } biome_css_formatter = { version = "0.5.7", path = "./crates/biome_css_formatter" } biome_css_parser = { version = "0.5.7", path = "./crates/biome_css_parser" } +biome_css_semantic = { version = "0.0.0", path = "./crates/biome_css_semantic" } biome_css_syntax = { version = "0.5.7", path = "./crates/biome_css_syntax" } biome_deserialize = { version = "0.6.0", path = "./crates/biome_deserialize" } biome_deserialize_macros = { version = "0.6.0", path = "./crates/biome_deserialize_macros" } diff --git a/crates/biome_css_semantic/Cargo.toml b/crates/biome_css_semantic/Cargo.toml new file mode 100644 index 000000000000..0ad329e17e3a --- /dev/null +++ b/crates/biome_css_semantic/Cargo.toml @@ -0,0 +1,23 @@ + +[package] +authors.workspace = true +categories.workspace = true +description = "Biome's semantic model for CSS" +edition.workspace = true +homepage.workspace = true +keywords.workspace = true +license.workspace = true +name = "biome_css_semantic" +repository.workspace = true +version = "0.0.0" + +[dependencies] +biome_css_syntax = { workspace = true } +biome_rowan = { workspace = true } +rustc-hash = { workspace = true } + +[dev-dependencies] +biome_css_parser = { path = "../biome_css_parser" } + +[lints] +workspace = true diff --git a/crates/biome_css_semantic/src/events.rs b/crates/biome_css_semantic/src/events.rs new file mode 100644 index 000000000000..e0b9ff48973a --- /dev/null +++ b/crates/biome_css_semantic/src/events.rs @@ -0,0 +1,105 @@ +use std::collections::VecDeque; + +use biome_css_syntax::{AnyCssSelector, CssRelativeSelector, CssSyntaxKind::*}; +use biome_rowan::{AstNode, TextRange}; + +use crate::semantic_model::model::Specificity; + +#[derive(Debug)] +pub enum SemanticEvent { + RuleStart(TextRange), + RuleEnd, + SelectorDeclaration { + name: String, + range: TextRange, + specificity: Specificity, + }, + PropertyDeclaration { + property: String, + value: String, + property_range: TextRange, + value_range: TextRange, + }, +} + +#[derive(Default, Debug)] +pub struct SemanticEventExtractor { + stash: VecDeque, + current_rule_stack: Vec, +} + +impl SemanticEventExtractor { + pub fn enter(&mut self, node: &biome_css_syntax::CssSyntaxNode) { + match node.kind() { + kind if kind == CSS_QUALIFIED_RULE || kind == CSS_NESTED_QUALIFIED_RULE => { + let range = node.text_range(); + self.stash.push_back(SemanticEvent::RuleStart(range)); + self.current_rule_stack.push(range); + } + CSS_SELECTOR_LIST => { + node.children() + .filter_map(AnyCssSelector::cast) + .for_each(|s| self.process_selector(s)); + } + CSS_RELATIVE_SELECTOR_LIST => { + node.children() + .filter_map(CssRelativeSelector::cast) + .filter_map(|s| s.selector().ok()) + .for_each(|s| self.process_selector(s)); + } + CSS_DECLARATION => { + if let Some(property_name) = node.first_child().and_then(|p| p.first_child()) { + if let Some(value) = property_name.next_sibling() { + self.stash.push_back(SemanticEvent::PropertyDeclaration { + property: property_name.text_trimmed().to_string(), + value: value.text_trimmed().to_string(), + property_range: property_name.text_range(), + value_range: value.text_range(), + }); + } + } + } + _ => {} + } + } + + fn process_selector(&mut self, selector: AnyCssSelector) { + match selector { + AnyCssSelector::CssComplexSelector(s) => { + if let Ok(l) = s.left() { + self.add_selector_event(l.text(), l.range()); + } + if let Ok(r) = s.right() { + self.add_selector_event(r.text(), r.range()); + } + } + AnyCssSelector::CssCompoundSelector(selector) => { + self.add_selector_event(selector.text().to_string(), selector.range()); + } + _ => {} + } + } + + fn add_selector_event(&mut self, name: String, range: TextRange) { + self.stash.push_back(SemanticEvent::SelectorDeclaration { + name, + range, + specificity: Specificity(0, 0, 0), // TODO: Implement this + }); + } + + pub fn leave(&mut self, node: &biome_css_syntax::CssSyntaxNode) { + if matches!( + node.kind(), + biome_css_syntax::CssSyntaxKind::CSS_QUALIFIED_RULE + | biome_css_syntax::CssSyntaxKind::CSS_NESTED_QUALIFIED_RULE + ) { + self.current_rule_stack.pop(); + self.stash.push_back(SemanticEvent::RuleEnd); + } + } + + pub fn pop(&mut self) -> Option { + self.stash.pop_front() + } +} diff --git a/crates/biome_css_semantic/src/lib.rs b/crates/biome_css_semantic/src/lib.rs new file mode 100644 index 000000000000..0734f9922a92 --- /dev/null +++ b/crates/biome_css_semantic/src/lib.rs @@ -0,0 +1,5 @@ +mod events; +mod semantic_model; + +pub use events::*; +pub use semantic_model::*; diff --git a/crates/biome_css_semantic/src/semantic_model/builder.rs b/crates/biome_css_semantic/src/semantic_model/builder.rs new file mode 100644 index 000000000000..296afe99923e --- /dev/null +++ b/crates/biome_css_semantic/src/semantic_model/builder.rs @@ -0,0 +1,96 @@ +use biome_css_syntax::{CssRoot, CssSyntaxKind, CssSyntaxNode}; +use biome_rowan::TextRange; +use rustc_hash::FxHashMap; + +use super::model::{Declaration, Rule, Selector, SemanticModel, SemanticModelData}; +use crate::events::SemanticEvent; + +pub struct SemanticModelBuilder { + root: CssRoot, + node_by_range: FxHashMap, + rules: Vec, + current_rule_stack: Vec, +} + +impl SemanticModelBuilder { + pub fn new(root: CssRoot) -> Self { + Self { + root, + node_by_range: FxHashMap::default(), + rules: Vec::new(), + current_rule_stack: Vec::new(), + } + } + + pub fn build(self) -> SemanticModel { + let data = SemanticModelData { + root: self.root, + node_by_range: self.node_by_range, + rules: self.rules, + }; + SemanticModel::new(data) + } + + #[inline] + pub fn push_node(&mut self, node: &CssSyntaxNode) { + use CssSyntaxKind::*; + if matches!( + node.kind(), + CSS_SELECTOR_LIST | CSS_DECLARATION | CSS_DECLARATION_OR_RULE_LIST | CSS_QUALIFIED_RULE + ) { + self.node_by_range.insert(node.text_range(), node.clone()); + } + } + + #[inline] + pub fn push_event(&mut self, event: SemanticEvent) { + match event { + SemanticEvent::RuleStart(range) => { + let new_rule = Rule { + selectors: Vec::new(), + declarations: Vec::new(), + children: Vec::new(), + range, + }; + self.current_rule_stack.push(new_rule); + } + SemanticEvent::RuleEnd => { + if let Some(completed_rule) = self.current_rule_stack.pop() { + if let Some(parent_rule) = self.current_rule_stack.last_mut() { + parent_rule.children.push(completed_rule); + } else { + self.rules.push(completed_rule); + } + } + } + SemanticEvent::SelectorDeclaration { + name, + range, + specificity, + } => { + if let Some(current_rule) = self.current_rule_stack.last_mut() { + current_rule.selectors.push(Selector { + name, + range, + specificity, + }); + } + } + SemanticEvent::PropertyDeclaration { + property, + value, + property_range, + value_range, + } => { + if let Some(current_rule) = self.current_rule_stack.last_mut() { + current_rule.declarations.push(Declaration { + property, + value, + property_range, + value_range, + }); + } + } + } + } +} diff --git a/crates/biome_css_semantic/src/semantic_model/mod.rs b/crates/biome_css_semantic/src/semantic_model/mod.rs new file mode 100644 index 000000000000..c1fbcc5c111f --- /dev/null +++ b/crates/biome_css_semantic/src/semantic_model/mod.rs @@ -0,0 +1,88 @@ +mod builder; +pub(crate) mod model; + +use biome_css_syntax::CssRoot; +use biome_rowan::AstNode; +use builder::SemanticModelBuilder; +use model::SemanticModel; + +use crate::events::SemanticEventExtractor; + +pub fn semantic_model(root: &CssRoot) -> SemanticModel { + let mut extractor = SemanticEventExtractor::default(); + let mut builder = SemanticModelBuilder::new(root.clone()); + + let root = root.syntax(); + for node in root.preorder() { + match node { + biome_css_syntax::WalkEvent::Enter(node) => { + builder.push_node(&node); + extractor.enter(&node); + } + biome_css_syntax::WalkEvent::Leave(node) => extractor.leave(&node), + } + } + + while let Some(e) = extractor.pop() { + builder.push_event(e); + } + + builder.build() +} + +#[cfg(test)] +mod tests { + use biome_css_parser::parse_css; + use biome_css_parser::CssParserOptions; + + #[test] + fn test_simple_ruleset() { + let parse = parse_css( + r#"p { + font-family: verdana; + font-size: 20px; +}"#, + CssParserOptions::default(), + ); + + let root = parse.tree(); + let model = super::semantic_model(&root); + let rule = model.rules().first().unwrap(); + + assert_eq!(rule.selectors.len(), 1); + assert_eq!(rule.declarations.len(), 2); + } + #[test] + fn test_nested_selector() { + let parse = parse_css( + r#".parent { + color: blue; + + .child { + color: red; + } +}"#, + CssParserOptions::default(), + ); + + let root = parse.tree(); + let model = super::semantic_model(&root); + let rule = model.rules().first().unwrap(); + + assert_eq!(rule.selectors.len(), 1); + assert_eq!(rule.declarations.len(), 1); + assert_eq!(rule.children.len(), 1); + } + + #[test] + fn debug() { + let parse = parse_css( + r#"[a="b"i], [ a="b"i], [ a ="b"i], [ a = "b"i], [ a = "b" i], [ a = "b" i ] {}"#, + CssParserOptions::default(), + ); + + let root = parse.tree(); + let model = super::semantic_model(&root); + dbg!(&model.rules()); + } +} diff --git a/crates/biome_css_semantic/src/semantic_model/model.rs b/crates/biome_css_semantic/src/semantic_model/model.rs new file mode 100644 index 000000000000..cad02c9f5e64 --- /dev/null +++ b/crates/biome_css_semantic/src/semantic_model/model.rs @@ -0,0 +1,94 @@ +use std::rc::Rc; + +use biome_css_syntax::{CssRoot, CssSyntaxNode}; +use biome_rowan::TextRange; +use rustc_hash::FxHashMap; + +/// The façade for all semantic information of a CSS document. +/// +/// This struct provides access to the root, rules, and individual nodes of the CSS document. +/// It holds a reference-counted pointer to the internal `SemanticModelData`. +#[derive(Clone, Debug)] +pub struct SemanticModel { + pub(crate) data: Rc, +} + +impl SemanticModel { + pub(crate) fn new(data: SemanticModelData) -> Self { + Self { + data: Rc::new(data), + } + } + + pub fn root(&self) -> &CssRoot { + &self.data.root + } + + /// Retrieves a node by its text range. + pub fn node_by_range(&self, range: TextRange) -> Option<&CssSyntaxNode> { + self.data.node_by_range.get(&range) + } + + /// Returns a slice of all rules in the CSS document. + pub fn rules(&self) -> &[Rule] { + &self.data.rules + } +} + +/// Contains the internal data of a `SemanticModel`. +/// +/// This struct holds the root of the CSS document, a mapping of nodes by their range, +/// and a list of all rules in the document. +#[derive(Debug)] +pub(crate) struct SemanticModelData { + pub(crate) root: CssRoot, + // Map to each by its range + pub(crate) node_by_range: FxHashMap, + // List of all the rules + pub(crate) rules: Vec, +} + +/// Represents a CSS rule, including its selectors, declarations, and nested rules. +#[derive(Debug)] +pub struct Rule { + /// The selectors associated with this rule. + pub selectors: Vec, + /// The declarations within this rule. + pub declarations: Vec, + /// Any nested rules within this rule. + pub children: Vec, + /// The text range of this rule in the source document. + pub range: TextRange, +} + +/// Represents a CSS declaration (property-value pair). +#[derive(Debug, Clone)] +pub struct Declaration { + /// The property name. + pub property: String, + /// The property value. + pub value: String, + /// The text range of the property in the source document. + pub property_range: TextRange, + /// The text range of the value in the source document. + pub value_range: TextRange, +} + +/// Represents a CSS selector. +#[derive(Debug, Clone)] +pub struct Selector { + /// The name of the selector. + pub name: String, + /// The text range of the selector in the source document. + pub range: TextRange, + /// The specificity of the selector. + pub specificity: Specificity, +} + +/// Represents the specificity of a CSS selector. +/// +/// This specificity is represented as a tuple of three `u32` values, +/// corresponding to (ID selectors, class selectors, type selectors). +/// More details https://developer.mozilla.org/en-US/docs/Web/CSS/Specificity +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Default)] +pub struct Specificity(pub u32, pub u32, pub u32); diff --git a/knope.toml b/knope.toml index b95f730b33b9..641743ff4b08 100644 --- a/knope.toml +++ b/knope.toml @@ -208,6 +208,10 @@ versioned_files = ["crates/biome_graphql_analyze/Cargo.toml"] changelog = "crates/biome_graphql_semantic/CHANGELOG.md" versioned_files = ["crates/biome_graphql_semantic/Cargo.toml"] +[packages.biome_css_semantic] +changelog = "crates/biome_css_semantic/CHANGELOG.md" +versioned_files = ["crates/biome_css_semantic/Cargo.toml"] + ## End of crates. DO NOT CHANGE! # Workflow to create a changeset