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: {:?}",
1093 alias_str
1094 );
1095 assert!(
1096 alias_str.contains("base"),
1097 "Layer alias should contain the layer name 'base', got: {:?}",
1098 alias_str
1099 );
1100 assert!(!*is_wildcard, "Layer import should not be wildcard");
1101 }
1102 }
1103
1104 #[test]
1105 fn test_import_with_anonymous_layer() {
1106 let source = r#"@import "file.css" layer();"#;
1108
1109 let tree = parse_css(source);
1110 let mut staging = StagingGraph::new();
1111 let builder = CssGraphBuilder;
1112 let file = PathBuf::from("styles.css");
1113
1114 builder
1115 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1116 .unwrap();
1117
1118 let ops = staging.operations();
1119
1120 let import_edge = ops.iter().find(|op| {
1122 matches!(
1123 op,
1124 StagingOp::AddEdge {
1125 kind: EdgeKind::Imports { .. },
1126 ..
1127 }
1128 )
1129 });
1130
1131 assert!(
1132 import_edge.is_some(),
1133 "Expected Imports edge for @import layer()"
1134 );
1135 if let StagingOp::AddEdge {
1136 kind: EdgeKind::Imports { alias, .. },
1137 ..
1138 } = import_edge.unwrap()
1139 {
1140 assert!(
1141 alias.is_some(),
1142 "Anonymous layer should have @layer: prefix alias"
1143 );
1144 let strings = build_string_lookup(&staging);
1146 let alias_str = resolve_string(&strings, *alias.as_ref().unwrap());
1147 assert_eq!(
1148 alias_str, "@layer:",
1149 "Anonymous layer alias should be exactly '@layer:', got: {:?}",
1150 alias_str
1151 );
1152 }
1153 }
1154
1155 #[test]
1156 fn test_import_with_nested_layer_name() {
1157 let source = r#"@import url("file.css") layer(theme.dark);"#;
1161
1162 let tree = parse_css(source);
1163 let mut staging = StagingGraph::new();
1164 let builder = CssGraphBuilder;
1165 let file = PathBuf::from("styles.css");
1166
1167 builder
1168 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1169 .unwrap();
1170
1171 let ops = staging.operations();
1172
1173 let has_import_node = ops.iter().any(|op| matches!(op, StagingOp::AddNode { .. }));
1175 assert!(
1176 has_import_node,
1177 "Should create nodes for import with nested layer name"
1178 );
1179 }
1180
1181 #[test]
1182 fn test_import_with_supports_condition() {
1183 let source = r#"@import "file.css" supports(display: grid);"#;
1185
1186 let tree = parse_css(source);
1187 let mut staging = StagingGraph::new();
1188 let builder = CssGraphBuilder;
1189 let file = PathBuf::from("styles.css");
1190
1191 builder
1192 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1193 .unwrap();
1194
1195 let ops = staging.operations();
1196
1197 let has_import = ops.iter().any(|op| {
1199 matches!(
1200 op,
1201 StagingOp::AddEdge {
1202 kind: EdgeKind::Imports { .. },
1203 ..
1204 }
1205 )
1206 });
1207 assert!(has_import, "Expected Imports edge for @import supports()");
1208 }
1209
1210 #[test]
1211 fn test_layer_ordering_declaration() {
1212 let source = r#"@layer base, utils, components;"#;
1214
1215 let tree = parse_css(source);
1216 let mut staging = StagingGraph::new();
1217 let builder = CssGraphBuilder;
1218 let file = PathBuf::from("styles.css");
1219
1220 builder
1221 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1222 .unwrap();
1223
1224 let ops = staging.operations();
1225
1226 let module_nodes: Vec<_> = ops
1228 .iter()
1229 .filter(|op| matches!(op, StagingOp::AddNode { .. }))
1230 .collect();
1231
1232 assert!(
1233 module_nodes.len() >= 4,
1234 "Expected at least 4 module nodes (css::module + 3 layers), got {}",
1235 module_nodes.len()
1236 );
1237
1238 let contains_edges: Vec<_> = ops
1240 .iter()
1241 .filter(|op| {
1242 matches!(
1243 op,
1244 StagingOp::AddEdge {
1245 kind: EdgeKind::Contains,
1246 ..
1247 }
1248 )
1249 })
1250 .collect();
1251
1252 assert_eq!(
1253 contains_edges.len(),
1254 3,
1255 "Expected 3 Contains edges for layer ordering"
1256 );
1257 }
1258
1259 #[test]
1260 fn test_layer_block_definition() {
1261 let source = r#"@layer name { .foo { color: red; } }"#;
1263
1264 let tree = parse_css(source);
1265 let mut staging = StagingGraph::new();
1266 let builder = CssGraphBuilder;
1267 let file = PathBuf::from("styles.css");
1268
1269 builder
1270 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1271 .unwrap();
1272
1273 let ops = staging.operations();
1274
1275 let module_nodes: Vec<_> = ops
1277 .iter()
1278 .filter(|op| matches!(op, StagingOp::AddNode { .. }))
1279 .collect();
1280
1281 assert!(
1282 module_nodes.len() >= 2,
1283 "Expected at least 2 module nodes, got {}",
1284 module_nodes.len()
1285 );
1286 }
1287
1288 #[test]
1289 fn test_basic_import_without_layer() {
1290 let source = r#"@import "reset.css";"#;
1292
1293 let tree = parse_css(source);
1294 let mut staging = StagingGraph::new();
1295 let builder = CssGraphBuilder;
1296 let file = PathBuf::from("styles.css");
1297
1298 builder
1299 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1300 .unwrap();
1301
1302 let ops = staging.operations();
1303
1304 let import_edge = ops.iter().find(|op| {
1306 matches!(
1307 op,
1308 StagingOp::AddEdge {
1309 kind: EdgeKind::Imports { .. },
1310 ..
1311 }
1312 )
1313 });
1314
1315 assert!(
1316 import_edge.is_some(),
1317 "Expected Imports edge for basic @import"
1318 );
1319 if let StagingOp::AddEdge {
1320 kind: EdgeKind::Imports { alias, is_wildcard },
1321 ..
1322 } = import_edge.unwrap()
1323 {
1324 assert!(alias.is_none(), "Basic import should not have alias");
1325 assert!(!*is_wildcard, "Basic import should not be wildcard");
1326 }
1327 }
1328
1329 #[test]
1330 fn test_import_with_url_and_layer() {
1331 let source = r#"@import url("theme.css") layer(base);"#;
1333
1334 let tree = parse_css(source);
1335 let mut staging = StagingGraph::new();
1336 let builder = CssGraphBuilder;
1337 let file = PathBuf::from("styles.css");
1338
1339 builder
1340 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1341 .unwrap();
1342
1343 let ops = staging.operations();
1344
1345 let has_nodes = ops.iter().any(|op| matches!(op, StagingOp::AddNode { .. }));
1348 assert!(
1349 has_nodes,
1350 "Should create nodes even with url() + layer() syntax"
1351 );
1352 }
1353
1354 #[test]
1355 fn test_multiple_layer_declarations() {
1356 let source = r#"
1358@layer reset, base;
1359@layer components, utils;
1360"#;
1361
1362 let tree = parse_css(source);
1363 let mut staging = StagingGraph::new();
1364 let builder = CssGraphBuilder;
1365 let file = PathBuf::from("styles.css");
1366
1367 builder
1368 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1369 .unwrap();
1370
1371 let ops = staging.operations();
1372
1373 let contains_edges: Vec<_> = ops
1375 .iter()
1376 .filter(|op| {
1377 matches!(
1378 op,
1379 StagingOp::AddEdge {
1380 kind: EdgeKind::Contains,
1381 ..
1382 }
1383 )
1384 })
1385 .collect();
1386
1387 assert_eq!(
1388 contains_edges.len(),
1389 4,
1390 "Expected 4 Contains edges for multiple layer declarations"
1391 );
1392 }
1393
1394 #[test]
1395 fn test_layer_with_css_inside() {
1396 let source = r#"
1398@layer base {
1399 :root {
1400 --primary-color: blue;
1401 }
1402}
1403"#;
1404
1405 let tree = parse_css(source);
1406 let mut staging = StagingGraph::new();
1407 let builder = CssGraphBuilder;
1408 let file = PathBuf::from("styles.css");
1409
1410 builder
1411 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1412 .unwrap();
1413
1414 let ops = staging.operations();
1415
1416 let node_count = ops
1418 .iter()
1419 .filter(|op| matches!(op, StagingOp::AddNode { .. }))
1420 .count();
1421 assert!(
1422 node_count >= 3,
1423 "Expected at least 3 nodes (module, layer, variable), got {}",
1424 node_count
1425 );
1426 }
1427
1428 #[test]
1429 fn test_mixed_imports_and_layers() {
1430 let source = r#"
1432@layer reset, base, components;
1433@import "reset.css" layer(reset);
1434@import "base.css" layer(base);
1435
1436@layer components {
1437 .button { color: blue; }
1438}
1439"#;
1440
1441 let tree = parse_css(source);
1442 let mut staging = StagingGraph::new();
1443 let builder = CssGraphBuilder;
1444 let file = PathBuf::from("styles.css");
1445
1446 builder
1447 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1448 .unwrap();
1449
1450 let ops = staging.operations();
1451
1452 let import_edges: Vec<_> = ops
1454 .iter()
1455 .filter(|op| {
1456 matches!(
1457 op,
1458 StagingOp::AddEdge {
1459 kind: EdgeKind::Imports { .. },
1460 ..
1461 }
1462 )
1463 })
1464 .collect();
1465 assert_eq!(import_edges.len(), 2, "Expected 2 Imports edges");
1466
1467 let strings = build_string_lookup(&staging);
1469 for edge in import_edges {
1470 if let StagingOp::AddEdge {
1471 kind: EdgeKind::Imports { alias, .. },
1472 ..
1473 } = edge
1474 {
1475 assert!(alias.is_some(), "Import should have layer alias");
1476 let alias_str = resolve_string(&strings, *alias.as_ref().unwrap());
1477 assert!(
1478 alias_str.starts_with("@layer:"),
1479 "Layer alias should have @layer: prefix, got: {:?}",
1480 alias_str
1481 );
1482 }
1483 }
1484 }
1485
1486 #[test]
1487 fn test_import_creates_proper_edge_structure() {
1488 let source = r#"@import "theme.css" layer(base);"#;
1490
1491 let tree = parse_css(source);
1492 let mut staging = StagingGraph::new();
1493 let builder = CssGraphBuilder;
1494 let file = PathBuf::from("styles.css");
1495
1496 builder
1497 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1498 .unwrap();
1499
1500 let ops = staging.operations();
1501
1502 let nodes: Vec<_> = ops
1504 .iter()
1505 .filter(|op| matches!(op, StagingOp::AddNode { .. }))
1506 .collect();
1507 let edges: Vec<_> = ops
1508 .iter()
1509 .filter(|op| matches!(op, StagingOp::AddEdge { .. }))
1510 .collect();
1511
1512 assert!(
1513 nodes.len() >= 2,
1514 "Expected at least 2 nodes (module and import)"
1515 );
1516 assert!(!edges.is_empty(), "Expected at least 1 edge");
1517 }
1518
1519 #[test]
1520 fn test_single_layer_declaration() {
1521 let source = r#"@layer base;"#;
1523
1524 let tree = parse_css(source);
1525 let mut staging = StagingGraph::new();
1526 let builder = CssGraphBuilder;
1527 let file = PathBuf::from("styles.css");
1528
1529 builder
1530 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1531 .unwrap();
1532
1533 let ops = staging.operations();
1534
1535 let contains_edges: Vec<_> = ops
1537 .iter()
1538 .filter(|op| {
1539 matches!(
1540 op,
1541 StagingOp::AddEdge {
1542 kind: EdgeKind::Contains,
1543 ..
1544 }
1545 )
1546 })
1547 .collect();
1548
1549 assert_eq!(
1550 contains_edges.len(),
1551 1,
1552 "Expected 1 Contains edge for single layer declaration"
1553 );
1554 }
1555}