Skip to content

Commit

Permalink
feat(css_semantic): build semantic model for css (biomejs#3546)
Browse files Browse the repository at this point in the history
  • Loading branch information
togami2864 committed Aug 7, 2024
1 parent 7a8a1cc commit e835ce5
Show file tree
Hide file tree
Showing 10 changed files with 434 additions and 7 deletions.
15 changes: 8 additions & 7 deletions .github/workflows/pull_request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -184,5 +184,6 @@ jobs:
run: |
if [[ `git status --porcelain` ]]; then
git status
git diff
exit 1
fi
10 changes: 10 additions & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
23 changes: 23 additions & 0 deletions crates/biome_css_semantic/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
105 changes: 105 additions & 0 deletions crates/biome_css_semantic/src/events.rs
Original file line number Diff line number Diff line change
@@ -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<SemanticEvent>,
current_rule_stack: Vec<TextRange>,
}

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<SemanticEvent> {
self.stash.pop_front()
}
}
5 changes: 5 additions & 0 deletions crates/biome_css_semantic/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
mod events;
mod semantic_model;

pub use events::*;
pub use semantic_model::*;
96 changes: 96 additions & 0 deletions crates/biome_css_semantic/src/semantic_model/builder.rs
Original file line number Diff line number Diff line change
@@ -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<TextRange, CssSyntaxNode>,
rules: Vec<Rule>,
current_rule_stack: Vec<Rule>,
}

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,
});
}
}
}
}
}
88 changes: 88 additions & 0 deletions crates/biome_css_semantic/src/semantic_model/mod.rs
Original file line number Diff line number Diff line change
@@ -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());
}
}
Loading

0 comments on commit e835ce5

Please sign in to comment.