Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(css_semantic): build semantic model for css #3546

Merged
merged 18 commits into from
Aug 7, 2024
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
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
Loading