1use std::path::Path;
34
35use sqry_core::graph::{
36 GraphBuilder, GraphResult, Language,
37 unified::{GraphBuildHelper, StagingGraph},
38};
39use tree_sitter::{Node, Tree};
40
41#[derive(Debug, Default)]
43pub struct CssGraphBuilder;
44
45impl GraphBuilder for CssGraphBuilder {
46 fn language(&self) -> Language {
47 Language::Css
48 }
49
50 fn build_graph(
51 &self,
52 tree: &Tree,
53 content: &[u8],
54 file: &Path,
55 staging: &mut StagingGraph,
56 ) -> GraphResult<()> {
57 let mut helper = GraphBuildHelper::new(staging, file, Language::Css);
58
59 let module_id = helper.add_module("css::module", None);
61
62 let root = tree.root_node();
64 extract_css_dsl_nodes(&root, content, &mut helper, module_id)?;
65
66 extract_css_resources(&root, content, &mut helper)?;
68
69 Ok(())
70 }
71}
72
73fn extract_css_resources(
79 node: &Node,
80 content: &[u8],
81 helper: &mut GraphBuildHelper,
82) -> GraphResult<()> {
83 let mut cursor = node.walk();
84
85 for child in node.children(&mut cursor) {
86 match child.kind() {
87 "import_statement" => {
88 extract_import_statement(&child, content, helper);
89 }
90 "at_rule" => {
91 extract_at_rule(&child, content, helper)?;
93 }
94 "call_expression" => {
95 if let Ok(text) = child.utf8_text(content)
97 && text.trim_start().to_lowercase().starts_with("url")
98 {
99 extract_url_call(&child, content, helper);
100 }
101 }
102 "declaration" => {
103 extract_css_variable(&child, content, helper);
105 }
106 _ => {}
107 }
108
109 extract_css_resources(&child, content, helper)?;
111 }
112
113 Ok(())
114}
115
116#[derive(Debug, Default)]
118struct ImportInfo {
119 path: Option<String>,
121 layer_name: Option<String>,
123 has_supports: bool,
125}
126
127fn extract_import_statement(node: &Node, content: &[u8], helper: &mut GraphBuildHelper) {
136 let mut info = ImportInfo::default();
137
138 let mut cursor = node.walk();
140 let children: Vec<_> = node.children(&mut cursor).collect();
141
142 for (i, child) in children.iter().enumerate() {
143 match child.kind() {
144 "string_value" => {
145 if info.path.is_none()
147 && let Ok(text) = child.utf8_text(content)
148 {
149 let path = extract_string_content(text);
150 if !path.is_empty() && !path.starts_with("data:") {
151 info.path = Some(path);
152 }
153 }
154 }
155 "call_expression" => {
156 if info.path.is_none()
158 && let Some(path) = extract_url_path(child, content)
159 && !path.is_empty()
160 && !path.starts_with("data:")
161 {
162 info.path = Some(path);
163 }
164 }
165 "keyword_query" => {
166 if let Ok(text) = child.utf8_text(content) {
168 let keyword = text.to_lowercase();
169 if keyword == "layer" {
170 info.layer_name = Some(extract_layer_name(&children, i, content));
172 } else if keyword == "supports" {
173 info.has_supports = true;
174 }
175 }
176 }
177 _ => {}
178 }
179 }
180
181 if let Some(path) = info.path {
183 let module_id = helper
184 .get_node("css::module")
185 .unwrap_or_else(|| helper.add_module("css::module", None));
186 let import_id = helper.add_import(&path, None);
187
188 if let Some(layer_name) = info.layer_name {
194 let prefixed_alias = if layer_name.is_empty() {
195 "@layer:".to_string()
196 } else {
197 format!("@layer:{layer_name}")
198 };
199 helper.add_import_edge_full(module_id, import_id, Some(&prefixed_alias), false);
200 } else {
201 helper.add_import_edge(module_id, import_id);
202 }
203 }
204}
205
206fn extract_layer_name(children: &[Node], layer_keyword_idx: usize, content: &[u8]) -> String {
211 for child in children.iter().skip(layer_keyword_idx + 1) {
213 if child.kind() == "ERROR"
214 && let Ok(text) = child.utf8_text(content)
215 {
216 let text = text.trim();
218 if text.starts_with('(') && text.ends_with(')') {
219 let inner = text[1..text.len() - 1].trim();
220 return inner.to_string();
221 }
222 }
223 }
224
225 String::new()
227}
228
229fn extract_url_path(node: &Node, content: &[u8]) -> Option<String> {
231 let mut cursor = node.walk();
232 for child in node.children(&mut cursor) {
233 if child.kind() == "arguments" {
234 let mut arg_cursor = child.walk();
235 for arg in child.children(&mut arg_cursor) {
236 if (arg.kind() == "string_value" || arg.kind() == "plain_value")
237 && let Ok(text) = arg.utf8_text(content)
238 {
239 return Some(extract_string_content(text));
240 }
241 }
242 }
243 }
244 None
245}
246
247fn extract_string_content(text: &str) -> String {
249 text.trim_matches(|c| c == '"' || c == '\'').to_string()
250}
251
252fn extract_at_rule(node: &Node, content: &[u8], helper: &mut GraphBuildHelper) -> GraphResult<()> {
258 let mut cursor = node.walk();
259 let children: Vec<_> = node.children(&mut cursor).collect();
260
261 let is_layer = children.iter().any(|child| {
263 child.kind() == "at_keyword"
264 && child
265 .utf8_text(content)
266 .is_ok_and(|t| t.to_lowercase() == "@layer")
267 });
268
269 if !is_layer {
270 return Ok(());
271 }
272
273 let layer_names: Vec<String> = children
275 .iter()
276 .filter(|child| child.kind() == "keyword_query")
277 .filter_map(|child| child.utf8_text(content).ok())
278 .map(std::string::ToString::to_string)
279 .collect();
280
281 let module_id = helper
284 .get_node("css::module")
285 .unwrap_or_else(|| helper.add_module("css::module", None));
286
287 for layer_name in &layer_names {
288 let layer_qualified_name = format!("css::layer::{layer_name}");
290 let layer_id = helper.add_module(&layer_qualified_name, None);
291
292 helper.add_contains_edge(module_id, layer_id);
294 }
295
296 for child in &children {
298 if child.kind() == "block" {
299 extract_css_resources(child, content, helper)?;
300 }
301 }
302
303 Ok(())
304}
305
306fn extract_url_call(node: &Node, content: &[u8], helper: &mut GraphBuildHelper) {
308 let mut cursor = node.walk();
310 for child in node.children(&mut cursor) {
311 if child.kind() == "arguments" {
312 let mut arg_cursor = child.walk();
313 for arg in child.children(&mut arg_cursor) {
314 if (arg.kind() == "string_value" || arg.kind() == "plain_value")
315 && let Ok(text) = arg.utf8_text(content)
316 {
317 let path = text.trim_matches(|c| c == '"' || c == '\'');
318 if !path.starts_with("data:") && !path.is_empty() {
319 let _asset_id = helper.add_variable(path, None);
320 }
321 }
322 }
323 }
324 }
325}
326
327fn extract_css_variable(node: &Node, content: &[u8], helper: &mut GraphBuildHelper) {
329 let mut cursor = node.walk();
331 for child in node.children(&mut cursor) {
332 if child.kind() == "property_name"
333 && let Ok(text) = child.utf8_text(content)
334 && text.starts_with("--")
335 {
336 let _var_id = helper.add_variable(text, None);
337 }
338 }
339}
340
341use sqry_core::graph::node::{Position, Span};
346
347fn extract_css_dsl_nodes(
354 node: &Node,
355 content: &[u8],
356 helper: &mut GraphBuildHelper,
357 module_id: sqry_core::graph::unified::NodeId,
358) -> GraphResult<()> {
359 let mut cursor = node.walk();
360
361 for child in node.children(&mut cursor) {
362 match child.kind() {
363 "rule_set" => {
364 extract_css_rule(&child, content, helper, module_id)?;
365 }
366 "at_rule" => {
367 let mut at_cursor = child.walk();
369 for at_child in child.children(&mut at_cursor) {
370 if at_child.kind() == "block" {
371 extract_css_dsl_nodes(&at_child, content, helper, module_id)?;
372 }
373 }
374 }
375 _ => {}
376 }
377
378 extract_css_dsl_nodes(&child, content, helper, module_id)?;
380 }
381
382 Ok(())
383}
384
385#[allow(clippy::unnecessary_wraps)]
387fn extract_css_rule(
388 node: &Node,
389 content: &[u8],
390 helper: &mut GraphBuildHelper,
391 module_id: sqry_core::graph::unified::NodeId,
392) -> GraphResult<()> {
393 let selectors = extract_selectors_from_rule(node, content);
395
396 if selectors.is_empty() {
397 return Ok(());
398 }
399
400 let span = span_from_node(node);
402 let primary_selector = &selectors[0];
403 let rule_name = format!(
404 "css::rule::{}@{}:{}",
405 primary_selector, span.start.line, span.start.column
406 );
407 let rule_id = helper.add_module(&rule_name, Some(span));
408
409 helper.add_contains_edge(module_id, rule_id);
411
412 for selector in selectors {
414 let selector_name = format!("css::selector::{selector}");
415 let selector_id = helper.add_variable(&selector_name, Some(span));
416
417 helper.add_contains_edge(rule_id, selector_id);
419 }
420
421 Ok(())
422}
423
424fn extract_selectors_from_rule(node: &Node, content: &[u8]) -> Vec<String> {
426 let mut selectors = Vec::new();
427 let mut cursor = node.walk();
428
429 for child in node.children(&mut cursor) {
430 if child.kind() == "selectors" {
431 selectors.extend(extract_individual_selectors(&child, content));
432 }
433 }
434
435 selectors
436}
437
438fn extract_individual_selectors(node: &Node, content: &[u8]) -> Vec<String> {
440 let mut selectors = Vec::new();
441 let mut cursor = node.walk();
442
443 for child in node.children(&mut cursor) {
444 match child.kind() {
445 "class_selector"
446 | "id_selector"
447 | "tag_name"
448 | "universal_selector"
449 | "attribute_selector"
450 | "pseudo_class_selector"
451 | "pseudo_element_selector" => {
452 if let Ok(text) = child.utf8_text(content) {
453 selectors.push(text.trim().to_string());
454 }
455 }
456 "descendant_selector"
457 | "child_selector"
458 | "sibling_selector"
459 | "adjacent_sibling_selector" => {
460 selectors.extend(extract_individual_selectors(&child, content));
463 }
464 _ => {
465 selectors.extend(extract_individual_selectors(&child, content));
467 }
468 }
469 }
470
471 selectors
472}
473
474fn span_from_node(node: &Node) -> Span {
476 let start = node.start_position();
477 let end = node.end_position();
478 Span {
479 start: Position {
480 line: start.row,
481 column: start.column,
482 },
483 end: Position {
484 line: end.row,
485 column: end.column,
486 },
487 }
488}
489
490#[cfg(test)]
491mod tests {
492 use super::*;
493 use sqry_core::graph::unified::build::test_helpers::*;
494 use std::path::PathBuf;
495 use tree_sitter::Parser;
496
497 fn parse_css(source: &str) -> Tree {
498 let mut parser = Parser::new();
499 parser
500 .set_language(&tree_sitter_css::LANGUAGE.into())
501 .expect("failed to set language");
502 parser.parse(source, None).expect("failed to parse")
503 }
504
505 #[test]
506 fn test_extracts_stylesheet_module() {
507 let source = r"
508.button {
509 color: red;
510}
511";
512
513 let tree = parse_css(source);
514 let mut staging = StagingGraph::new();
515 let builder = CssGraphBuilder;
516 let file = PathBuf::from("styles.css");
517
518 builder
519 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
520 .unwrap();
521
522 assert!(staging.node_count() >= 1, "Should have at least one node");
523 }
524
525 #[test]
526 fn test_extracts_css_custom_properties() {
527 let source = r"
528:root {
529 --primary-color: #007bff;
530 --secondary-color: #6c757d;
531 --font-size: 16px;
532}
533";
534
535 let tree = parse_css(source);
536 let mut staging = StagingGraph::new();
537 let builder = CssGraphBuilder;
538 let file = PathBuf::from("variables.css");
539
540 builder
541 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
542 .unwrap();
543
544 assert!(staging.node_count() >= 1);
545 }
546
547 #[test]
548 fn test_extracts_import_edges() {
549 let source = r#"
550@import "reset.css";
551@import url("./components/button.css");
552
553.button {
554 color: blue;
555}
556"#;
557
558 let tree = parse_css(source);
559 let mut staging = StagingGraph::new();
560 let builder = CssGraphBuilder;
561 let file = PathBuf::from("main.css");
562
563 builder
564 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
565 .unwrap();
566
567 let imports = collect_import_edges(&staging);
568 assert!(!imports.is_empty(), "Should have import edges");
569 }
570
571 #[test]
572 fn test_extracts_url_asset_edges() {
573 let source = r#"
574.hero {
575 background-image: url("/images/hero-bg.jpg");
576}
577
578.icon {
579 background: url("./assets/icon.svg") no-repeat;
580}
581"#;
582
583 let tree = parse_css(source);
584 let mut staging = StagingGraph::new();
585 let builder = CssGraphBuilder;
586 let file = PathBuf::from("src/styles.css");
588
589 builder
590 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
591 .unwrap();
592
593 assert!(
594 staging.node_count() >= 1,
595 "Should have at least one node for url() assets"
596 );
597 }
598
599 #[test]
600 fn test_skips_comments() {
601 let source = r"
602/* This is a comment with --fake-variable: value; */
603:root {
604 --real-variable: blue;
605}
606";
607
608 let tree = parse_css(source);
609 let mut staging = StagingGraph::new();
610 let builder = CssGraphBuilder;
611 let file = PathBuf::from("test.css");
612
613 builder
614 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
615 .unwrap();
616
617 assert_has_node(&staging, "--real-variable");
619 }
620
621 #[test]
626 fn test_import_creates_target_module_node() {
627 let source = r#"@import "reset.css";"#;
629
630 let tree = parse_css(source);
631 let mut staging = StagingGraph::new();
632 let builder = CssGraphBuilder;
633 let file = PathBuf::from("src/styles/main.css");
634
635 builder
636 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
637 .unwrap();
638
639 assert_has_node(&staging, "reset");
641
642 let imports = collect_import_edges(&staging);
644 assert!(!imports.is_empty(), "Should have import edges");
645 }
646
647 #[test]
648 fn test_import_resolves_relative_paths() {
649 let source = r#"@import "./components/button.css";"#;
651
652 let tree = parse_css(source);
653 let mut staging = StagingGraph::new();
654 let builder = CssGraphBuilder;
655 let file = PathBuf::from("src/styles/main.css");
656
657 builder
658 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
659 .unwrap();
660
661 assert_has_node(&staging, "button");
663
664 let imports = collect_import_edges(&staging);
666 assert_eq!(imports.len(), 1, "Should have exactly one import edge");
667 }
668
669 #[test]
670 fn test_different_files_same_relative_import_get_different_targets() {
671 let builder = CssGraphBuilder;
674
675 let source1 = r#"@import "./utils.css";"#;
677 let tree1 = parse_css(source1);
678 let mut staging1 = StagingGraph::new();
679 let file1 = PathBuf::from("src/foo/main.css");
680 builder
681 .build_graph(&tree1, source1.as_bytes(), &file1, &mut staging1)
682 .unwrap();
683
684 let source2 = r#"@import "./utils.css";"#;
686 let tree2 = parse_css(source2);
687 let mut staging2 = StagingGraph::new();
688 let file2 = PathBuf::from("src/bar/main.css");
689 builder
690 .build_graph(&tree2, source2.as_bytes(), &file2, &mut staging2)
691 .unwrap();
692
693 assert_has_node(&staging1, "utils");
695 assert_has_node(&staging2, "utils");
696
697 let imports1 = collect_import_edges(&staging1);
699 let imports2 = collect_import_edges(&staging2);
700 assert_eq!(imports1.len(), 1);
701 assert_eq!(imports2.len(), 1);
702 }
703
704 #[test]
705 fn test_url_skips_data_uris() {
706 let source = r#"
708.icon {
709 background-image: url("data:image/svg+xml;base64,PHN2Zz4...");
710}
711.real-image {
712 background-image: url("./images/icon.png");
713}
714"#;
715
716 let tree = parse_css(source);
717 let mut staging = StagingGraph::new();
718 let builder = CssGraphBuilder;
719 let file = PathBuf::from("styles.css");
720
721 builder
722 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
723 .unwrap();
724
725 assert_has_node(&staging, "images/icon");
727
728 assert!(staging.node_count() >= 2);
733 }
734
735 #[test]
736 fn test_url_remote_urls_use_http_language() {
737 let source = r#"
739.external {
740 background-image: url("https://example.com/image.png");
741}
742"#;
743
744 let tree = parse_css(source);
745 let mut staging = StagingGraph::new();
746 let builder = CssGraphBuilder;
747 let file = PathBuf::from("styles.css");
748
749 builder
750 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
751 .unwrap();
752
753 assert_has_node(&staging, "example.com");
755
756 assert!(staging.node_count() >= 2);
759 }
760
761 #[test]
762 fn test_import_remote_urls_use_http_language() {
763 let source = r#"@import "https://cdn.example.com/normalize.css";"#;
765
766 let tree = parse_css(source);
767 let mut staging = StagingGraph::new();
768 let builder = CssGraphBuilder;
769 let file = PathBuf::from("styles.css");
770
771 builder
772 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
773 .unwrap();
774
775 assert_has_node(&staging, "cdn.example.com");
777
778 let imports = collect_import_edges(&staging);
780 assert_eq!(imports.len(), 1);
781 }
782
783 #[test]
784 fn test_url_resolves_relative_paths() {
785 let source = r#"
787.bg {
788 background: url("../images/bg.png");
789}
790"#;
791
792 let tree = parse_css(source);
793 let mut staging = StagingGraph::new();
794 let builder = CssGraphBuilder;
795 let file = PathBuf::from("src/css/main.css");
796
797 builder
798 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
799 .unwrap();
800
801 assert_has_node(&staging, "images/bg");
803
804 assert!(staging.node_count() >= 2);
806 }
807
808 #[test]
809 fn test_target_nodes_have_correct_kind() {
810 let source = r#"
812@import "./components/button.css";
813.bg {
814 background: url("./images/hero.png");
815}
816"#;
817
818 let tree = parse_css(source);
819 let mut staging = StagingGraph::new();
820 let builder = CssGraphBuilder;
821 let file = PathBuf::from("main.css");
822
823 builder
824 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
825 .unwrap();
826
827 assert_has_node(&staging, "button");
829
830 assert_has_node(&staging, "hero");
832
833 let imports = collect_import_edges(&staging);
835 assert_eq!(imports.len(), 1);
836
837 assert!(staging.node_count() >= 3);
839 }
840
841 #[test]
846 fn test_url_uppercase_function_name() {
847 let source = r#"
849.icon1 {
850 background-image: URL("./images/icon1.png");
851}
852.icon2 {
853 background-image: Url("./images/icon2.png");
854}
855.icon3 {
856 background-image: url("./images/icon3.png");
857}
858"#;
859
860 let tree = parse_css(source);
861 let mut staging = StagingGraph::new();
862 let builder = CssGraphBuilder;
863 let file = PathBuf::from("styles.css");
864
865 builder
866 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
867 .unwrap();
868
869 assert_has_node(&staging, "icon1");
871 assert_has_node(&staging, "icon2");
872 assert_has_node(&staging, "icon3");
873
874 assert!(staging.node_count() >= 4);
876 }
877
878 #[test]
879 fn test_import_uppercase_url() {
880 let source = r#"
882@import URL("./reset.css");
883@import Url("./theme.css");
884"#;
885
886 let tree = parse_css(source);
887 let mut staging = StagingGraph::new();
888 let builder = CssGraphBuilder;
889 let file = PathBuf::from("styles.css");
890
891 builder
892 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
893 .unwrap();
894
895 assert_has_node(&staging, "reset");
897 assert_has_node(&staging, "theme");
898
899 let imports = collect_import_edges(&staging);
901 assert_eq!(imports.len(), 2, "Should have 2 import edges");
902 }
903
904 #[test]
905 fn test_protocol_relative_url_in_url_function() {
906 let source = r#"
908.cdn-asset {
909 background-image: url("//cdn.example.com/images/bg.png");
910}
911"#;
912
913 let tree = parse_css(source);
914 let mut staging = StagingGraph::new();
915 let builder = CssGraphBuilder;
916 let file = PathBuf::from("styles.css");
917
918 builder
919 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
920 .unwrap();
921
922 assert_has_node(&staging, "cdn.example.com");
924
925 assert!(staging.node_count() >= 2);
927 }
928
929 #[test]
930 fn test_protocol_relative_url_in_import() {
931 let source = r#"@import "//cdn.example.com/styles/normalize.css";"#;
933
934 let tree = parse_css(source);
935 let mut staging = StagingGraph::new();
936 let builder = CssGraphBuilder;
937 let file = PathBuf::from("styles.css");
938
939 builder
940 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
941 .unwrap();
942
943 assert_has_node(&staging, "cdn.example.com");
945
946 let imports = collect_import_edges(&staging);
948 assert_eq!(imports.len(), 1);
949 }
950
951 #[test]
952 fn test_uppercase_http_scheme_in_import() {
953 let source = r#"@import "HTTP://example.com/styles.css";"#;
955
956 let tree = parse_css(source);
957 let mut staging = StagingGraph::new();
958 let builder = CssGraphBuilder;
959 let file = PathBuf::from("styles.css");
960
961 builder
962 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
963 .unwrap();
964
965 assert_has_node(&staging, "example.com");
967
968 let imports = collect_import_edges(&staging);
970 assert_eq!(imports.len(), 1);
971 }
972
973 #[test]
974 fn test_uppercase_https_scheme_in_url() {
975 let source = r#".bg { background: url("HTTPS://example.com/image.png"); }"#;
977
978 let tree = parse_css(source);
979 let mut staging = StagingGraph::new();
980 let builder = CssGraphBuilder;
981 let file = PathBuf::from("styles.css");
982
983 builder
984 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
985 .unwrap();
986
987 assert_has_node(&staging, "example.com");
989
990 assert!(staging.node_count() >= 2);
992 }
993
994 #[test]
995 fn test_mixed_case_scheme_in_import() {
996 let source = r#"@import "Http://cdn.example.com/normalize.css";"#;
998
999 let tree = parse_css(source);
1000 let mut staging = StagingGraph::new();
1001 let builder = CssGraphBuilder;
1002 let file = PathBuf::from("styles.css");
1003
1004 builder
1005 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1006 .unwrap();
1007
1008 assert_has_node(&staging, "cdn.example.com");
1010
1011 let imports = collect_import_edges(&staging);
1013 assert_eq!(imports.len(), 1);
1014 }
1015
1016 use sqry_core::graph::unified::build::staging::StagingOp;
1021 use sqry_core::graph::unified::edge::EdgeKind;
1022
1023 fn build_string_lookup(staging: &StagingGraph) -> std::collections::HashMap<u32, String> {
1025 staging
1026 .operations()
1027 .iter()
1028 .filter_map(|op| {
1029 if let StagingOp::InternString { local_id, value } = op {
1030 Some((local_id.index(), value.clone()))
1031 } else {
1032 None
1033 }
1034 })
1035 .collect()
1036 }
1037
1038 fn resolve_string(
1040 strings: &std::collections::HashMap<u32, String>,
1041 id: sqry_core::graph::unified::StringId,
1042 ) -> String {
1043 strings
1044 .get(&id.index())
1045 .cloned()
1046 .unwrap_or_else(|| format!("<unresolved:{}>", id.index()))
1047 }
1048
1049 #[test]
1050 fn test_import_with_named_layer() {
1051 let source = r#"@import "theme.css" layer(base);"#;
1054
1055 let tree = parse_css(source);
1056 let mut staging = StagingGraph::new();
1057 let builder = CssGraphBuilder;
1058 let file = PathBuf::from("styles.css");
1059
1060 builder
1061 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1062 .unwrap();
1063
1064 let ops = staging.operations();
1065
1066 let import_edge = ops.iter().find(|op| {
1068 matches!(
1069 op,
1070 StagingOp::AddEdge {
1071 kind: EdgeKind::Imports { .. },
1072 ..
1073 }
1074 )
1075 });
1076
1077 assert!(
1078 import_edge.is_some(),
1079 "Expected Imports edge for @import layer(name)"
1080 );
1081 if let StagingOp::AddEdge {
1082 kind: EdgeKind::Imports { alias, is_wildcard },
1083 ..
1084 } = import_edge.unwrap()
1085 {
1086 assert!(alias.is_some(), "Layer name should be stored as alias");
1087 let strings = build_string_lookup(&staging);
1089 let alias_str = resolve_string(&strings, *alias.as_ref().unwrap());
1090 assert!(
1091 alias_str.starts_with("@layer:"),
1092 "Layer alias should have @layer: prefix, got: {alias_str:?}"
1093 );
1094 assert!(
1095 alias_str.contains("base"),
1096 "Layer alias should contain the layer name 'base', got: {alias_str:?}"
1097 );
1098 assert!(!*is_wildcard, "Layer import should not be wildcard");
1099 }
1100 }
1101
1102 #[test]
1103 fn test_import_with_anonymous_layer() {
1104 let source = r#"@import "file.css" layer();"#;
1106
1107 let tree = parse_css(source);
1108 let mut staging = StagingGraph::new();
1109 let builder = CssGraphBuilder;
1110 let file = PathBuf::from("styles.css");
1111
1112 builder
1113 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1114 .unwrap();
1115
1116 let ops = staging.operations();
1117
1118 let import_edge = ops.iter().find(|op| {
1120 matches!(
1121 op,
1122 StagingOp::AddEdge {
1123 kind: EdgeKind::Imports { .. },
1124 ..
1125 }
1126 )
1127 });
1128
1129 assert!(
1130 import_edge.is_some(),
1131 "Expected Imports edge for @import layer()"
1132 );
1133 if let StagingOp::AddEdge {
1134 kind: EdgeKind::Imports { alias, .. },
1135 ..
1136 } = import_edge.unwrap()
1137 {
1138 assert!(
1139 alias.is_some(),
1140 "Anonymous layer should have @layer: prefix alias"
1141 );
1142 let strings = build_string_lookup(&staging);
1144 let alias_str = resolve_string(&strings, *alias.as_ref().unwrap());
1145 assert_eq!(
1146 alias_str, "@layer:",
1147 "Anonymous layer alias should be exactly '@layer:', got: {alias_str:?}"
1148 );
1149 }
1150 }
1151
1152 #[test]
1153 fn test_import_with_nested_layer_name() {
1154 let source = r#"@import url("file.css") layer(theme.dark);"#;
1158
1159 let tree = parse_css(source);
1160 let mut staging = StagingGraph::new();
1161 let builder = CssGraphBuilder;
1162 let file = PathBuf::from("styles.css");
1163
1164 builder
1165 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1166 .unwrap();
1167
1168 let ops = staging.operations();
1169
1170 let has_import_node = ops.iter().any(|op| matches!(op, StagingOp::AddNode { .. }));
1172 assert!(
1173 has_import_node,
1174 "Should create nodes for import with nested layer name"
1175 );
1176 }
1177
1178 #[test]
1179 fn test_import_with_supports_condition() {
1180 let source = r#"@import "file.css" supports(display: grid);"#;
1182
1183 let tree = parse_css(source);
1184 let mut staging = StagingGraph::new();
1185 let builder = CssGraphBuilder;
1186 let file = PathBuf::from("styles.css");
1187
1188 builder
1189 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1190 .unwrap();
1191
1192 let ops = staging.operations();
1193
1194 let has_import = ops.iter().any(|op| {
1196 matches!(
1197 op,
1198 StagingOp::AddEdge {
1199 kind: EdgeKind::Imports { .. },
1200 ..
1201 }
1202 )
1203 });
1204 assert!(has_import, "Expected Imports edge for @import supports()");
1205 }
1206
1207 #[test]
1208 fn test_layer_ordering_declaration() {
1209 let source = r"@layer base, utils, components;";
1211
1212 let tree = parse_css(source);
1213 let mut staging = StagingGraph::new();
1214 let builder = CssGraphBuilder;
1215 let file = PathBuf::from("styles.css");
1216
1217 builder
1218 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1219 .unwrap();
1220
1221 let ops = staging.operations();
1222
1223 let module_nodes: Vec<_> = ops
1225 .iter()
1226 .filter(|op| matches!(op, StagingOp::AddNode { .. }))
1227 .collect();
1228
1229 assert!(
1230 module_nodes.len() >= 4,
1231 "Expected at least 4 module nodes (css::module + 3 layers), got {}",
1232 module_nodes.len()
1233 );
1234
1235 let contains_edges: Vec<_> = ops
1237 .iter()
1238 .filter(|op| {
1239 matches!(
1240 op,
1241 StagingOp::AddEdge {
1242 kind: EdgeKind::Contains,
1243 ..
1244 }
1245 )
1246 })
1247 .collect();
1248
1249 assert_eq!(
1250 contains_edges.len(),
1251 3,
1252 "Expected 3 Contains edges for layer ordering"
1253 );
1254 }
1255
1256 #[test]
1257 fn test_layer_block_definition() {
1258 let source = r"@layer name { .foo { color: red; } }";
1260
1261 let tree = parse_css(source);
1262 let mut staging = StagingGraph::new();
1263 let builder = CssGraphBuilder;
1264 let file = PathBuf::from("styles.css");
1265
1266 builder
1267 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1268 .unwrap();
1269
1270 let ops = staging.operations();
1271
1272 let module_nodes: Vec<_> = ops
1274 .iter()
1275 .filter(|op| matches!(op, StagingOp::AddNode { .. }))
1276 .collect();
1277
1278 assert!(
1279 module_nodes.len() >= 2,
1280 "Expected at least 2 module nodes, got {}",
1281 module_nodes.len()
1282 );
1283 }
1284
1285 #[test]
1286 fn test_basic_import_without_layer() {
1287 let source = r#"@import "reset.css";"#;
1289
1290 let tree = parse_css(source);
1291 let mut staging = StagingGraph::new();
1292 let builder = CssGraphBuilder;
1293 let file = PathBuf::from("styles.css");
1294
1295 builder
1296 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1297 .unwrap();
1298
1299 let ops = staging.operations();
1300
1301 let import_edge = ops.iter().find(|op| {
1303 matches!(
1304 op,
1305 StagingOp::AddEdge {
1306 kind: EdgeKind::Imports { .. },
1307 ..
1308 }
1309 )
1310 });
1311
1312 assert!(
1313 import_edge.is_some(),
1314 "Expected Imports edge for basic @import"
1315 );
1316 if let StagingOp::AddEdge {
1317 kind: EdgeKind::Imports { alias, is_wildcard },
1318 ..
1319 } = import_edge.unwrap()
1320 {
1321 assert!(alias.is_none(), "Basic import should not have alias");
1322 assert!(!*is_wildcard, "Basic import should not be wildcard");
1323 }
1324 }
1325
1326 #[test]
1327 fn test_import_with_url_and_layer() {
1328 let source = r#"@import url("theme.css") layer(base);"#;
1330
1331 let tree = parse_css(source);
1332 let mut staging = StagingGraph::new();
1333 let builder = CssGraphBuilder;
1334 let file = PathBuf::from("styles.css");
1335
1336 builder
1337 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1338 .unwrap();
1339
1340 let ops = staging.operations();
1341
1342 let has_nodes = ops.iter().any(|op| matches!(op, StagingOp::AddNode { .. }));
1345 assert!(
1346 has_nodes,
1347 "Should create nodes even with url() + layer() syntax"
1348 );
1349 }
1350
1351 #[test]
1352 fn test_multiple_layer_declarations() {
1353 let source = r"
1355@layer reset, base;
1356@layer components, utils;
1357";
1358
1359 let tree = parse_css(source);
1360 let mut staging = StagingGraph::new();
1361 let builder = CssGraphBuilder;
1362 let file = PathBuf::from("styles.css");
1363
1364 builder
1365 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1366 .unwrap();
1367
1368 let ops = staging.operations();
1369
1370 let contains_edges: Vec<_> = ops
1372 .iter()
1373 .filter(|op| {
1374 matches!(
1375 op,
1376 StagingOp::AddEdge {
1377 kind: EdgeKind::Contains,
1378 ..
1379 }
1380 )
1381 })
1382 .collect();
1383
1384 assert_eq!(
1385 contains_edges.len(),
1386 4,
1387 "Expected 4 Contains edges for multiple layer declarations"
1388 );
1389 }
1390
1391 #[test]
1392 fn test_layer_with_css_inside() {
1393 let source = r"
1395@layer base {
1396 :root {
1397 --primary-color: blue;
1398 }
1399}
1400";
1401
1402 let tree = parse_css(source);
1403 let mut staging = StagingGraph::new();
1404 let builder = CssGraphBuilder;
1405 let file = PathBuf::from("styles.css");
1406
1407 builder
1408 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1409 .unwrap();
1410
1411 let ops = staging.operations();
1412
1413 let node_count = ops
1415 .iter()
1416 .filter(|op| matches!(op, StagingOp::AddNode { .. }))
1417 .count();
1418 assert!(
1419 node_count >= 3,
1420 "Expected at least 3 nodes (module, layer, variable), got {node_count}"
1421 );
1422 }
1423
1424 #[test]
1425 fn test_mixed_imports_and_layers() {
1426 let source = r#"
1428@layer reset, base, components;
1429@import "reset.css" layer(reset);
1430@import "base.css" layer(base);
1431
1432@layer components {
1433 .button { color: blue; }
1434}
1435"#;
1436
1437 let tree = parse_css(source);
1438 let mut staging = StagingGraph::new();
1439 let builder = CssGraphBuilder;
1440 let file = PathBuf::from("styles.css");
1441
1442 builder
1443 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1444 .unwrap();
1445
1446 let ops = staging.operations();
1447
1448 let import_edges: Vec<_> = ops
1450 .iter()
1451 .filter(|op| {
1452 matches!(
1453 op,
1454 StagingOp::AddEdge {
1455 kind: EdgeKind::Imports { .. },
1456 ..
1457 }
1458 )
1459 })
1460 .collect();
1461 assert_eq!(import_edges.len(), 2, "Expected 2 Imports edges");
1462
1463 let strings = build_string_lookup(&staging);
1465 for edge in import_edges {
1466 if let StagingOp::AddEdge {
1467 kind: EdgeKind::Imports { alias, .. },
1468 ..
1469 } = edge
1470 {
1471 assert!(alias.is_some(), "Import should have layer alias");
1472 let alias_str = resolve_string(&strings, *alias.as_ref().unwrap());
1473 assert!(
1474 alias_str.starts_with("@layer:"),
1475 "Layer alias should have @layer: prefix, got: {alias_str:?}"
1476 );
1477 }
1478 }
1479 }
1480
1481 #[test]
1482 fn test_import_creates_proper_edge_structure() {
1483 let source = r#"@import "theme.css" layer(base);"#;
1485
1486 let tree = parse_css(source);
1487 let mut staging = StagingGraph::new();
1488 let builder = CssGraphBuilder;
1489 let file = PathBuf::from("styles.css");
1490
1491 builder
1492 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1493 .unwrap();
1494
1495 let ops = staging.operations();
1496
1497 let nodes: Vec<_> = ops
1499 .iter()
1500 .filter(|op| matches!(op, StagingOp::AddNode { .. }))
1501 .collect();
1502 let edges: Vec<_> = ops
1503 .iter()
1504 .filter(|op| matches!(op, StagingOp::AddEdge { .. }))
1505 .collect();
1506
1507 assert!(
1508 nodes.len() >= 2,
1509 "Expected at least 2 nodes (module and import)"
1510 );
1511 assert!(!edges.is_empty(), "Expected at least 1 edge");
1512 }
1513
1514 #[test]
1515 fn test_single_layer_declaration() {
1516 let source = r"@layer base;";
1518
1519 let tree = parse_css(source);
1520 let mut staging = StagingGraph::new();
1521 let builder = CssGraphBuilder;
1522 let file = PathBuf::from("styles.css");
1523
1524 builder
1525 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1526 .unwrap();
1527
1528 let ops = staging.operations();
1529
1530 let contains_edges: Vec<_> = ops
1532 .iter()
1533 .filter(|op| {
1534 matches!(
1535 op,
1536 StagingOp::AddEdge {
1537 kind: EdgeKind::Contains,
1538 ..
1539 }
1540 )
1541 })
1542 .collect();
1543
1544 assert_eq!(
1545 contains_edges.len(),
1546 1,
1547 "Expected 1 Contains edge for single layer declaration"
1548 );
1549 }
1550}