use std::path::Path;
use sqry_core::graph::{
GraphBuilder, GraphResult, Language,
unified::{GraphBuildHelper, StagingGraph},
};
use tree_sitter::{Node, Tree};
#[derive(Debug, Default)]
pub struct CssGraphBuilder;
impl GraphBuilder for CssGraphBuilder {
fn language(&self) -> Language {
Language::Css
}
fn build_graph(
&self,
tree: &Tree,
content: &[u8],
file: &Path,
staging: &mut StagingGraph,
) -> GraphResult<()> {
let mut helper = GraphBuildHelper::new(staging, file, Language::Css);
let module_id = helper.add_module("css::module", None);
let root = tree.root_node();
extract_css_dsl_nodes(&root, content, &mut helper, module_id)?;
extract_css_resources(&root, content, &mut helper)?;
Ok(())
}
}
fn extract_css_resources(
node: &Node,
content: &[u8],
helper: &mut GraphBuildHelper,
) -> GraphResult<()> {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
match child.kind() {
"import_statement" => {
extract_import_statement(&child, content, helper);
}
"at_rule" => {
extract_at_rule(&child, content, helper)?;
}
"call_expression" => {
if let Ok(text) = child.utf8_text(content)
&& text.trim_start().to_lowercase().starts_with("url")
{
extract_url_call(&child, content, helper);
}
}
"declaration" => {
extract_css_variable(&child, content, helper);
}
_ => {}
}
extract_css_resources(&child, content, helper)?;
}
Ok(())
}
#[derive(Debug, Default)]
struct ImportInfo {
path: Option<String>,
layer_name: Option<String>,
has_supports: bool,
}
fn extract_import_statement(node: &Node, content: &[u8], helper: &mut GraphBuildHelper) {
let mut info = ImportInfo::default();
let mut cursor = node.walk();
let children: Vec<_> = node.children(&mut cursor).collect();
for (i, child) in children.iter().enumerate() {
match child.kind() {
"string_value" => {
if info.path.is_none()
&& let Ok(text) = child.utf8_text(content)
{
let path = extract_string_content(text);
if !path.is_empty() && !path.starts_with("data:") {
info.path = Some(path);
}
}
}
"call_expression" => {
if info.path.is_none()
&& let Some(path) = extract_url_path(child, content)
&& !path.is_empty()
&& !path.starts_with("data:")
{
info.path = Some(path);
}
}
"keyword_query" => {
if let Ok(text) = child.utf8_text(content) {
let keyword = text.to_lowercase();
if keyword == "layer" {
info.layer_name = Some(extract_layer_name(&children, i, content));
} else if keyword == "supports" {
info.has_supports = true;
}
}
}
_ => {}
}
}
if let Some(path) = info.path {
let module_id = helper
.get_node("css::module")
.unwrap_or_else(|| helper.add_module("css::module", None));
let import_id = helper.add_import(&path, None);
if let Some(layer_name) = info.layer_name {
let prefixed_alias = if layer_name.is_empty() {
"@layer:".to_string()
} else {
format!("@layer:{layer_name}")
};
helper.add_import_edge_full(module_id, import_id, Some(&prefixed_alias), false);
} else {
helper.add_import_edge(module_id, import_id);
}
}
}
fn extract_layer_name(children: &[Node], layer_keyword_idx: usize, content: &[u8]) -> String {
for child in children.iter().skip(layer_keyword_idx + 1) {
if child.kind() == "ERROR"
&& let Ok(text) = child.utf8_text(content)
{
let text = text.trim();
if text.starts_with('(') && text.ends_with(')') {
let inner = text[1..text.len() - 1].trim();
return inner.to_string();
}
}
}
String::new()
}
fn extract_url_path(node: &Node, content: &[u8]) -> Option<String> {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() == "arguments" {
let mut arg_cursor = child.walk();
for arg in child.children(&mut arg_cursor) {
if (arg.kind() == "string_value" || arg.kind() == "plain_value")
&& let Ok(text) = arg.utf8_text(content)
{
return Some(extract_string_content(text));
}
}
}
}
None
}
fn extract_string_content(text: &str) -> String {
text.trim_matches(|c| c == '"' || c == '\'').to_string()
}
fn extract_at_rule(node: &Node, content: &[u8], helper: &mut GraphBuildHelper) -> GraphResult<()> {
let mut cursor = node.walk();
let children: Vec<_> = node.children(&mut cursor).collect();
let is_layer = children.iter().any(|child| {
child.kind() == "at_keyword"
&& child
.utf8_text(content)
.is_ok_and(|t| t.to_lowercase() == "@layer")
});
if !is_layer {
return Ok(());
}
let layer_names: Vec<String> = children
.iter()
.filter(|child| child.kind() == "keyword_query")
.filter_map(|child| child.utf8_text(content).ok())
.map(std::string::ToString::to_string)
.collect();
let module_id = helper
.get_node("css::module")
.unwrap_or_else(|| helper.add_module("css::module", None));
for layer_name in &layer_names {
let layer_qualified_name = format!("css::layer::{layer_name}");
let layer_id = helper.add_module(&layer_qualified_name, None);
helper.add_contains_edge(module_id, layer_id);
}
for child in &children {
if child.kind() == "block" {
extract_css_resources(child, content, helper)?;
}
}
Ok(())
}
fn extract_url_call(node: &Node, content: &[u8], helper: &mut GraphBuildHelper) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() == "arguments" {
let mut arg_cursor = child.walk();
for arg in child.children(&mut arg_cursor) {
if (arg.kind() == "string_value" || arg.kind() == "plain_value")
&& let Ok(text) = arg.utf8_text(content)
{
let path = text.trim_matches(|c| c == '"' || c == '\'');
if !path.starts_with("data:") && !path.is_empty() {
let _asset_id = helper.add_variable(path, None);
}
}
}
}
}
}
fn extract_css_variable(node: &Node, content: &[u8], helper: &mut GraphBuildHelper) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() == "property_name"
&& let Ok(text) = child.utf8_text(content)
&& text.starts_with("--")
{
let _var_id = helper.add_variable(text, None);
}
}
}
use sqry_core::graph::node::{Position, Span};
fn extract_css_dsl_nodes(
node: &Node,
content: &[u8],
helper: &mut GraphBuildHelper,
module_id: sqry_core::graph::unified::NodeId,
) -> GraphResult<()> {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
match child.kind() {
"rule_set" => {
extract_css_rule(&child, content, helper, module_id)?;
}
"at_rule" => {
let mut at_cursor = child.walk();
for at_child in child.children(&mut at_cursor) {
if at_child.kind() == "block" {
extract_css_dsl_nodes(&at_child, content, helper, module_id)?;
}
}
}
_ => {}
}
extract_css_dsl_nodes(&child, content, helper, module_id)?;
}
Ok(())
}
#[allow(clippy::unnecessary_wraps)]
fn extract_css_rule(
node: &Node,
content: &[u8],
helper: &mut GraphBuildHelper,
module_id: sqry_core::graph::unified::NodeId,
) -> GraphResult<()> {
let selectors = extract_selectors_from_rule(node, content);
if selectors.is_empty() {
return Ok(());
}
let span = span_from_node(node);
let primary_selector = &selectors[0];
let rule_name = format!(
"css::rule::{}@{}:{}",
primary_selector, span.start.line, span.start.column
);
let rule_id = helper.add_module(&rule_name, Some(span));
helper.add_contains_edge(module_id, rule_id);
for selector in selectors {
let selector_name = format!("css::selector::{selector}");
let selector_id = helper.add_variable(&selector_name, Some(span));
helper.add_contains_edge(rule_id, selector_id);
}
Ok(())
}
fn extract_selectors_from_rule(node: &Node, content: &[u8]) -> Vec<String> {
let mut selectors = Vec::new();
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() == "selectors" {
selectors.extend(extract_individual_selectors(&child, content));
}
}
selectors
}
fn extract_individual_selectors(node: &Node, content: &[u8]) -> Vec<String> {
let mut selectors = Vec::new();
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
match child.kind() {
"class_selector"
| "id_selector"
| "tag_name"
| "universal_selector"
| "attribute_selector"
| "pseudo_class_selector"
| "pseudo_element_selector" => {
if let Ok(text) = child.utf8_text(content) {
selectors.push(text.trim().to_string());
}
}
"descendant_selector"
| "child_selector"
| "sibling_selector"
| "adjacent_sibling_selector" => {
selectors.extend(extract_individual_selectors(&child, content));
}
_ => {
selectors.extend(extract_individual_selectors(&child, content));
}
}
}
selectors
}
fn span_from_node(node: &Node) -> Span {
let start = node.start_position();
let end = node.end_position();
Span {
start: Position {
line: start.row,
column: start.column,
},
end: Position {
line: end.row,
column: end.column,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use sqry_core::graph::unified::build::test_helpers::*;
use std::path::PathBuf;
use tree_sitter::Parser;
fn parse_css(source: &str) -> Tree {
let mut parser = Parser::new();
parser
.set_language(&tree_sitter_css::LANGUAGE.into())
.expect("failed to set language");
parser.parse(source, None).expect("failed to parse")
}
#[test]
fn test_extracts_stylesheet_module() {
let source = r"
.button {
color: red;
}
";
let tree = parse_css(source);
let mut staging = StagingGraph::new();
let builder = CssGraphBuilder;
let file = PathBuf::from("styles.css");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
assert!(staging.node_count() >= 1, "Should have at least one node");
}
#[test]
fn test_extracts_css_custom_properties() {
let source = r"
:root {
--primary-color: #007bff;
--secondary-color: #6c757d;
--font-size: 16px;
}
";
let tree = parse_css(source);
let mut staging = StagingGraph::new();
let builder = CssGraphBuilder;
let file = PathBuf::from("variables.css");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
assert!(staging.node_count() >= 1);
}
#[test]
fn test_extracts_import_edges() {
let source = r#"
@import "reset.css";
@import url("./components/button.css");
.button {
color: blue;
}
"#;
let tree = parse_css(source);
let mut staging = StagingGraph::new();
let builder = CssGraphBuilder;
let file = PathBuf::from("main.css");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
let imports = collect_import_edges(&staging);
assert!(!imports.is_empty(), "Should have import edges");
}
#[test]
fn test_extracts_url_asset_edges() {
let source = r#"
.hero {
background-image: url("/images/hero-bg.jpg");
}
.icon {
background: url("./assets/icon.svg") no-repeat;
}
"#;
let tree = parse_css(source);
let mut staging = StagingGraph::new();
let builder = CssGraphBuilder;
let file = PathBuf::from("src/styles.css");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
assert!(
staging.node_count() >= 1,
"Should have at least one node for url() assets"
);
}
#[test]
fn test_skips_comments() {
let source = r"
/* This is a comment with --fake-variable: value; */
:root {
--real-variable: blue;
}
";
let tree = parse_css(source);
let mut staging = StagingGraph::new();
let builder = CssGraphBuilder;
let file = PathBuf::from("test.css");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
assert_has_node(&staging, "--real-variable");
}
#[test]
fn test_import_creates_target_module_node() {
let source = r#"@import "reset.css";"#;
let tree = parse_css(source);
let mut staging = StagingGraph::new();
let builder = CssGraphBuilder;
let file = PathBuf::from("src/styles/main.css");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
assert_has_node(&staging, "reset");
let imports = collect_import_edges(&staging);
assert!(!imports.is_empty(), "Should have import edges");
}
#[test]
fn test_import_resolves_relative_paths() {
let source = r#"@import "./components/button.css";"#;
let tree = parse_css(source);
let mut staging = StagingGraph::new();
let builder = CssGraphBuilder;
let file = PathBuf::from("src/styles/main.css");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
assert_has_node(&staging, "button");
let imports = collect_import_edges(&staging);
assert_eq!(imports.len(), 1, "Should have exactly one import edge");
}
#[test]
fn test_different_files_same_relative_import_get_different_targets() {
let builder = CssGraphBuilder;
let source1 = r#"@import "./utils.css";"#;
let tree1 = parse_css(source1);
let mut staging1 = StagingGraph::new();
let file1 = PathBuf::from("src/foo/main.css");
builder
.build_graph(&tree1, source1.as_bytes(), &file1, &mut staging1)
.unwrap();
let source2 = r#"@import "./utils.css";"#;
let tree2 = parse_css(source2);
let mut staging2 = StagingGraph::new();
let file2 = PathBuf::from("src/bar/main.css");
builder
.build_graph(&tree2, source2.as_bytes(), &file2, &mut staging2)
.unwrap();
assert_has_node(&staging1, "utils");
assert_has_node(&staging2, "utils");
let imports1 = collect_import_edges(&staging1);
let imports2 = collect_import_edges(&staging2);
assert_eq!(imports1.len(), 1);
assert_eq!(imports2.len(), 1);
}
#[test]
fn test_url_skips_data_uris() {
let source = r#"
.icon {
background-image: url("data:image/svg+xml;base64,PHN2Zz4...");
}
.real-image {
background-image: url("./images/icon.png");
}
"#;
let tree = parse_css(source);
let mut staging = StagingGraph::new();
let builder = CssGraphBuilder;
let file = PathBuf::from("styles.css");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
assert_has_node(&staging, "images/icon");
assert!(staging.node_count() >= 2);
}
#[test]
fn test_url_remote_urls_use_http_language() {
let source = r#"
.external {
background-image: url("https://example.com/image.png");
}
"#;
let tree = parse_css(source);
let mut staging = StagingGraph::new();
let builder = CssGraphBuilder;
let file = PathBuf::from("styles.css");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
assert_has_node(&staging, "example.com");
assert!(staging.node_count() >= 2);
}
#[test]
fn test_import_remote_urls_use_http_language() {
let source = r#"@import "https://cdn.example.com/normalize.css";"#;
let tree = parse_css(source);
let mut staging = StagingGraph::new();
let builder = CssGraphBuilder;
let file = PathBuf::from("styles.css");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
assert_has_node(&staging, "cdn.example.com");
let imports = collect_import_edges(&staging);
assert_eq!(imports.len(), 1);
}
#[test]
fn test_url_resolves_relative_paths() {
let source = r#"
.bg {
background: url("../images/bg.png");
}
"#;
let tree = parse_css(source);
let mut staging = StagingGraph::new();
let builder = CssGraphBuilder;
let file = PathBuf::from("src/css/main.css");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
assert_has_node(&staging, "images/bg");
assert!(staging.node_count() >= 2);
}
#[test]
fn test_target_nodes_have_correct_kind() {
let source = r#"
@import "./components/button.css";
.bg {
background: url("./images/hero.png");
}
"#;
let tree = parse_css(source);
let mut staging = StagingGraph::new();
let builder = CssGraphBuilder;
let file = PathBuf::from("main.css");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
assert_has_node(&staging, "button");
assert_has_node(&staging, "hero");
let imports = collect_import_edges(&staging);
assert_eq!(imports.len(), 1);
assert!(staging.node_count() >= 3);
}
#[test]
fn test_url_uppercase_function_name() {
let source = r#"
.icon1 {
background-image: URL("./images/icon1.png");
}
.icon2 {
background-image: Url("./images/icon2.png");
}
.icon3 {
background-image: url("./images/icon3.png");
}
"#;
let tree = parse_css(source);
let mut staging = StagingGraph::new();
let builder = CssGraphBuilder;
let file = PathBuf::from("styles.css");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
assert_has_node(&staging, "icon1");
assert_has_node(&staging, "icon2");
assert_has_node(&staging, "icon3");
assert!(staging.node_count() >= 4);
}
#[test]
fn test_import_uppercase_url() {
let source = r#"
@import URL("./reset.css");
@import Url("./theme.css");
"#;
let tree = parse_css(source);
let mut staging = StagingGraph::new();
let builder = CssGraphBuilder;
let file = PathBuf::from("styles.css");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
assert_has_node(&staging, "reset");
assert_has_node(&staging, "theme");
let imports = collect_import_edges(&staging);
assert_eq!(imports.len(), 2, "Should have 2 import edges");
}
#[test]
fn test_protocol_relative_url_in_url_function() {
let source = r#"
.cdn-asset {
background-image: url("//cdn.example.com/images/bg.png");
}
"#;
let tree = parse_css(source);
let mut staging = StagingGraph::new();
let builder = CssGraphBuilder;
let file = PathBuf::from("styles.css");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
assert_has_node(&staging, "cdn.example.com");
assert!(staging.node_count() >= 2);
}
#[test]
fn test_protocol_relative_url_in_import() {
let source = r#"@import "//cdn.example.com/styles/normalize.css";"#;
let tree = parse_css(source);
let mut staging = StagingGraph::new();
let builder = CssGraphBuilder;
let file = PathBuf::from("styles.css");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
assert_has_node(&staging, "cdn.example.com");
let imports = collect_import_edges(&staging);
assert_eq!(imports.len(), 1);
}
#[test]
fn test_uppercase_http_scheme_in_import() {
let source = r#"@import "HTTP://example.com/styles.css";"#;
let tree = parse_css(source);
let mut staging = StagingGraph::new();
let builder = CssGraphBuilder;
let file = PathBuf::from("styles.css");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
assert_has_node(&staging, "example.com");
let imports = collect_import_edges(&staging);
assert_eq!(imports.len(), 1);
}
#[test]
fn test_uppercase_https_scheme_in_url() {
let source = r#".bg { background: url("HTTPS://example.com/image.png"); }"#;
let tree = parse_css(source);
let mut staging = StagingGraph::new();
let builder = CssGraphBuilder;
let file = PathBuf::from("styles.css");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
assert_has_node(&staging, "example.com");
assert!(staging.node_count() >= 2);
}
#[test]
fn test_mixed_case_scheme_in_import() {
let source = r#"@import "Http://cdn.example.com/normalize.css";"#;
let tree = parse_css(source);
let mut staging = StagingGraph::new();
let builder = CssGraphBuilder;
let file = PathBuf::from("styles.css");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
assert_has_node(&staging, "cdn.example.com");
let imports = collect_import_edges(&staging);
assert_eq!(imports.len(), 1);
}
use sqry_core::graph::unified::build::staging::StagingOp;
use sqry_core::graph::unified::edge::EdgeKind;
fn build_string_lookup(staging: &StagingGraph) -> std::collections::HashMap<u32, String> {
staging
.operations()
.iter()
.filter_map(|op| {
if let StagingOp::InternString { local_id, value } = op {
Some((local_id.index(), value.clone()))
} else {
None
}
})
.collect()
}
fn resolve_string(
strings: &std::collections::HashMap<u32, String>,
id: sqry_core::graph::unified::StringId,
) -> String {
strings
.get(&id.index())
.cloned()
.unwrap_or_else(|| format!("<unresolved:{}>", id.index()))
}
#[test]
fn test_import_with_named_layer() {
let source = r#"@import "theme.css" layer(base);"#;
let tree = parse_css(source);
let mut staging = StagingGraph::new();
let builder = CssGraphBuilder;
let file = PathBuf::from("styles.css");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
let ops = staging.operations();
let import_edge = ops.iter().find(|op| {
matches!(
op,
StagingOp::AddEdge {
kind: EdgeKind::Imports { .. },
..
}
)
});
assert!(
import_edge.is_some(),
"Expected Imports edge for @import layer(name)"
);
if let StagingOp::AddEdge {
kind: EdgeKind::Imports { alias, is_wildcard },
..
} = import_edge.unwrap()
{
assert!(alias.is_some(), "Layer name should be stored as alias");
let strings = build_string_lookup(&staging);
let alias_str = resolve_string(&strings, *alias.as_ref().unwrap());
assert!(
alias_str.starts_with("@layer:"),
"Layer alias should have @layer: prefix, got: {:?}",
alias_str
);
assert!(
alias_str.contains("base"),
"Layer alias should contain the layer name 'base', got: {:?}",
alias_str
);
assert!(!*is_wildcard, "Layer import should not be wildcard");
}
}
#[test]
fn test_import_with_anonymous_layer() {
let source = r#"@import "file.css" layer();"#;
let tree = parse_css(source);
let mut staging = StagingGraph::new();
let builder = CssGraphBuilder;
let file = PathBuf::from("styles.css");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
let ops = staging.operations();
let import_edge = ops.iter().find(|op| {
matches!(
op,
StagingOp::AddEdge {
kind: EdgeKind::Imports { .. },
..
}
)
});
assert!(
import_edge.is_some(),
"Expected Imports edge for @import layer()"
);
if let StagingOp::AddEdge {
kind: EdgeKind::Imports { alias, .. },
..
} = import_edge.unwrap()
{
assert!(
alias.is_some(),
"Anonymous layer should have @layer: prefix alias"
);
let strings = build_string_lookup(&staging);
let alias_str = resolve_string(&strings, *alias.as_ref().unwrap());
assert_eq!(
alias_str, "@layer:",
"Anonymous layer alias should be exactly '@layer:', got: {:?}",
alias_str
);
}
}
#[test]
fn test_import_with_nested_layer_name() {
let source = r#"@import url("file.css") layer(theme.dark);"#;
let tree = parse_css(source);
let mut staging = StagingGraph::new();
let builder = CssGraphBuilder;
let file = PathBuf::from("styles.css");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
let ops = staging.operations();
let has_import_node = ops.iter().any(|op| matches!(op, StagingOp::AddNode { .. }));
assert!(
has_import_node,
"Should create nodes for import with nested layer name"
);
}
#[test]
fn test_import_with_supports_condition() {
let source = r#"@import "file.css" supports(display: grid);"#;
let tree = parse_css(source);
let mut staging = StagingGraph::new();
let builder = CssGraphBuilder;
let file = PathBuf::from("styles.css");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
let ops = staging.operations();
let has_import = ops.iter().any(|op| {
matches!(
op,
StagingOp::AddEdge {
kind: EdgeKind::Imports { .. },
..
}
)
});
assert!(has_import, "Expected Imports edge for @import supports()");
}
#[test]
fn test_layer_ordering_declaration() {
let source = r#"@layer base, utils, components;"#;
let tree = parse_css(source);
let mut staging = StagingGraph::new();
let builder = CssGraphBuilder;
let file = PathBuf::from("styles.css");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
let ops = staging.operations();
let module_nodes: Vec<_> = ops
.iter()
.filter(|op| matches!(op, StagingOp::AddNode { .. }))
.collect();
assert!(
module_nodes.len() >= 4,
"Expected at least 4 module nodes (css::module + 3 layers), got {}",
module_nodes.len()
);
let contains_edges: Vec<_> = ops
.iter()
.filter(|op| {
matches!(
op,
StagingOp::AddEdge {
kind: EdgeKind::Contains,
..
}
)
})
.collect();
assert_eq!(
contains_edges.len(),
3,
"Expected 3 Contains edges for layer ordering"
);
}
#[test]
fn test_layer_block_definition() {
let source = r#"@layer name { .foo { color: red; } }"#;
let tree = parse_css(source);
let mut staging = StagingGraph::new();
let builder = CssGraphBuilder;
let file = PathBuf::from("styles.css");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
let ops = staging.operations();
let module_nodes: Vec<_> = ops
.iter()
.filter(|op| matches!(op, StagingOp::AddNode { .. }))
.collect();
assert!(
module_nodes.len() >= 2,
"Expected at least 2 module nodes, got {}",
module_nodes.len()
);
}
#[test]
fn test_basic_import_without_layer() {
let source = r#"@import "reset.css";"#;
let tree = parse_css(source);
let mut staging = StagingGraph::new();
let builder = CssGraphBuilder;
let file = PathBuf::from("styles.css");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
let ops = staging.operations();
let import_edge = ops.iter().find(|op| {
matches!(
op,
StagingOp::AddEdge {
kind: EdgeKind::Imports { .. },
..
}
)
});
assert!(
import_edge.is_some(),
"Expected Imports edge for basic @import"
);
if let StagingOp::AddEdge {
kind: EdgeKind::Imports { alias, is_wildcard },
..
} = import_edge.unwrap()
{
assert!(alias.is_none(), "Basic import should not have alias");
assert!(!*is_wildcard, "Basic import should not be wildcard");
}
}
#[test]
fn test_import_with_url_and_layer() {
let source = r#"@import url("theme.css") layer(base);"#;
let tree = parse_css(source);
let mut staging = StagingGraph::new();
let builder = CssGraphBuilder;
let file = PathBuf::from("styles.css");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
let ops = staging.operations();
let has_nodes = ops.iter().any(|op| matches!(op, StagingOp::AddNode { .. }));
assert!(
has_nodes,
"Should create nodes even with url() + layer() syntax"
);
}
#[test]
fn test_multiple_layer_declarations() {
let source = r#"
@layer reset, base;
@layer components, utils;
"#;
let tree = parse_css(source);
let mut staging = StagingGraph::new();
let builder = CssGraphBuilder;
let file = PathBuf::from("styles.css");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
let ops = staging.operations();
let contains_edges: Vec<_> = ops
.iter()
.filter(|op| {
matches!(
op,
StagingOp::AddEdge {
kind: EdgeKind::Contains,
..
}
)
})
.collect();
assert_eq!(
contains_edges.len(),
4,
"Expected 4 Contains edges for multiple layer declarations"
);
}
#[test]
fn test_layer_with_css_inside() {
let source = r#"
@layer base {
:root {
--primary-color: blue;
}
}
"#;
let tree = parse_css(source);
let mut staging = StagingGraph::new();
let builder = CssGraphBuilder;
let file = PathBuf::from("styles.css");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
let ops = staging.operations();
let node_count = ops
.iter()
.filter(|op| matches!(op, StagingOp::AddNode { .. }))
.count();
assert!(
node_count >= 3,
"Expected at least 3 nodes (module, layer, variable), got {}",
node_count
);
}
#[test]
fn test_mixed_imports_and_layers() {
let source = r#"
@layer reset, base, components;
@import "reset.css" layer(reset);
@import "base.css" layer(base);
@layer components {
.button { color: blue; }
}
"#;
let tree = parse_css(source);
let mut staging = StagingGraph::new();
let builder = CssGraphBuilder;
let file = PathBuf::from("styles.css");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
let ops = staging.operations();
let import_edges: Vec<_> = ops
.iter()
.filter(|op| {
matches!(
op,
StagingOp::AddEdge {
kind: EdgeKind::Imports { .. },
..
}
)
})
.collect();
assert_eq!(import_edges.len(), 2, "Expected 2 Imports edges");
let strings = build_string_lookup(&staging);
for edge in import_edges {
if let StagingOp::AddEdge {
kind: EdgeKind::Imports { alias, .. },
..
} = edge
{
assert!(alias.is_some(), "Import should have layer alias");
let alias_str = resolve_string(&strings, *alias.as_ref().unwrap());
assert!(
alias_str.starts_with("@layer:"),
"Layer alias should have @layer: prefix, got: {:?}",
alias_str
);
}
}
}
#[test]
fn test_import_creates_proper_edge_structure() {
let source = r#"@import "theme.css" layer(base);"#;
let tree = parse_css(source);
let mut staging = StagingGraph::new();
let builder = CssGraphBuilder;
let file = PathBuf::from("styles.css");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
let ops = staging.operations();
let nodes: Vec<_> = ops
.iter()
.filter(|op| matches!(op, StagingOp::AddNode { .. }))
.collect();
let edges: Vec<_> = ops
.iter()
.filter(|op| matches!(op, StagingOp::AddEdge { .. }))
.collect();
assert!(
nodes.len() >= 2,
"Expected at least 2 nodes (module and import)"
);
assert!(!edges.is_empty(), "Expected at least 1 edge");
}
#[test]
fn test_single_layer_declaration() {
let source = r#"@layer base;"#;
let tree = parse_css(source);
let mut staging = StagingGraph::new();
let builder = CssGraphBuilder;
let file = PathBuf::from("styles.css");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
let ops = staging.operations();
let contains_edges: Vec<_> = ops
.iter()
.filter(|op| {
matches!(
op,
StagingOp::AddEdge {
kind: EdgeKind::Contains,
..
}
)
})
.collect();
assert_eq!(
contains_edges.len(),
1,
"Expected 1 Contains edge for single layer declaration"
);
}
}