Skip to main content

sqry_lang_css/relations/
graph_builder.rs

1//! `GraphBuilder` for CSS stylesheets.
2//!
3//! Extracts stylesheet-level relationships:
4//! - Module node for the stylesheet file
5//! - Variable nodes for CSS custom properties (--var-name)
6//! - Import edges for @import statements (with path resolution and @layer support)
7//! - Import edges for `url()` asset references (with path normalization)
8//!
9//! # Path Resolution
10//!
11//! Import paths are resolved to canonical forms to ensure cross-file edges
12//! converge on the correct target:
13//! - Relative paths (`./reset.css`, `../lib/theme.css`) → resolved to canonical paths
14//! - Absolute paths (`/styles/main.css`) → normalized
15//! - Remote URLs (`https://...`) → preserved as-is with special handling
16//!
17//! # `url()` Handling
18//!
19//! Asset references via `url()` are processed as follows:
20//! - `data:` URIs are skipped (embedded data, not external dependencies)
21//! - Remote URLs (`http://`, `https://`) are marked with `Language::Http`
22//! - Relative/absolute paths are resolved relative to the stylesheet
23//!
24//! # CSS Cascade Layers (@layer)
25//!
26//! This module supports CSS Cascade Layers (CSS Cascading and Inheritance Level 5):
27//! - `@import "file.css" layer(name)` - import with named layer, stored in Imports.alias
28//! - `@import "file.css" layer()` - import with anonymous layer (alias = "")
29//! - `@import "file.css" supports(condition)` - conditional import (future extension)
30//! - `@layer base, utils, components` - layer ordering declaration
31//! - `@layer name { ... }` - layer block definition
32
33use std::path::Path;
34
35use sqry_core::graph::{
36    GraphBuilder, GraphResult, Language,
37    unified::{GraphBuildHelper, StagingGraph},
38};
39use tree_sitter::{Node, Tree};
40
41/// `GraphBuilder` for CSS stylesheets
42#[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        // Create module node for the CSS file itself
60        let module_id = helper.add_module("css::module", None);
61
62        // Extract DSL nodes (CSS rules and selectors)
63        let root = tree.root_node();
64        extract_css_dsl_nodes(&root, content, &mut helper, module_id)?;
65
66        // Walk AST to find @import and url() references
67        extract_css_resources(&root, content, &mut helper)?;
68
69        Ok(())
70    }
71}
72
73// ============================================================================
74// Helper Functions
75// ============================================================================
76
77/// Extract @import, `url()`, @layer, and CSS resources from CSS AST
78fn 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                // Handle @layer declarations
92                extract_at_rule(&child, content, helper)?;
93            }
94            "call_expression" => {
95                // Check if this is a url() call
96                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                // CSS custom properties (--var-name)
104                extract_css_variable(&child, content, helper);
105            }
106            _ => {}
107        }
108
109        // Recurse into child nodes
110        extract_css_resources(&child, content, helper)?;
111    }
112
113    Ok(())
114}
115
116/// Information extracted from an @import statement
117#[derive(Debug, Default)]
118struct ImportInfo {
119    /// The import path (from `string_value` or `url()` call)
120    path: Option<String>,
121    /// Layer name if @import has layer(name), empty string if `layer()` (anonymous)
122    layer_name: Option<String>,
123    /// True if @import has `supports()` condition
124    has_supports: bool,
125}
126
127/// Extract @import statement with `layer()` and `supports()` modifiers
128///
129/// Handles:
130/// - `@import "file.css"` - basic import
131/// - `@import "file.css" layer(name)` - import with named layer
132/// - `@import "file.css" layer()` - import with anonymous layer
133/// - `@import "file.css" supports(condition)` - conditional import
134/// - `@import url("file.css") layer(name)` - `url()` syntax with layer
135fn extract_import_statement(node: &Node, content: &[u8], helper: &mut GraphBuildHelper) {
136    let mut info = ImportInfo::default();
137
138    // Collect import path and modifiers from child nodes
139    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                // Direct string import path: @import "file.css"
146                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                // url() function: @import url("file.css")
157                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                // Check for "layer" or "supports" keywords
167                if let Ok(text) = child.utf8_text(content) {
168                    let keyword = text.to_lowercase();
169                    if keyword == "layer" {
170                        // Look for layer name in the next ERROR node (contains parenthesized content)
171                        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    // Create import node with layer information if present
182    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        // CSS @import with layer() uses a prefixed alias convention to avoid
189        // conflating layer names with ES-style import aliases.
190        // Convention: "@layer:<name>" or "@layer:" for anonymous layers.
191        // This preserves the alias field's semantics (import renaming) while
192        // encoding layer metadata in a recognizable, prefixed format.
193        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
206/// Extract the layer name from ERROR nodes following a "layer" keyword
207///
208/// The tree-sitter-css parser marks `layer(name)` as ERROR nodes since it's
209/// a newer CSS feature. We extract the layer name by parsing the ERROR content.
210fn extract_layer_name(children: &[Node], layer_keyword_idx: usize, content: &[u8]) -> String {
211    // Look for ERROR node or parenthesized content after the "layer" keyword
212    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            // Extract content between parentheses: "(name)" -> "name"
217            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    // If no ERROR node found, return empty string (anonymous layer)
226    String::new()
227}
228
229/// Extract the path from a `url()` call expression
230fn 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
247/// Extract the content from a string value, removing quotes
248fn extract_string_content(text: &str) -> String {
249    text.trim_matches(|c| c == '"' || c == '\'').to_string()
250}
251
252/// Extract @layer at-rule declarations
253///
254/// Handles:
255/// - `@layer base, utils, components;` - layer ordering declaration
256/// - `@layer name { ... }` - layer block definition
257fn 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    // Check if this is an @layer rule
262    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    // Collect layer names from keyword_query nodes
274    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    // Create module nodes for each layer in the ordering
282    // This represents the layer dependency structure
283    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        // Create a module node for each declared layer
289        let layer_qualified_name = format!("css::layer::{layer_name}");
290        let layer_id = helper.add_module(&layer_qualified_name, None);
291
292        // Create a Contains edge from the stylesheet module to the layer
293        helper.add_contains_edge(module_id, layer_id);
294    }
295
296    // If there's a block, recurse into it
297    for child in &children {
298        if child.kind() == "block" {
299            extract_css_resources(child, content, helper)?;
300        }
301    }
302
303    Ok(())
304}
305
306/// Extract `url()` function call
307fn extract_url_call(node: &Node, content: &[u8], helper: &mut GraphBuildHelper) {
308    // Find the arguments node
309    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
327/// Extract CSS custom property (--variable-name)
328fn extract_css_variable(node: &Node, content: &[u8], helper: &mut GraphBuildHelper) {
329    // Look for property_name that starts with --
330    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
341// ============================================================================
342// DSL Node Extraction (Rules and Selectors)
343// ============================================================================
344
345use sqry_core::graph::node::{Position, Span};
346
347/// Extract CSS DSL nodes (rules and selectors) from the AST.
348///
349/// Creates:
350/// - Rule nodes (`NodeKind::Module`) for each CSS rule
351/// - Selector nodes (`NodeKind::Variable`) for each selector in a rule
352/// - Contains edges: module → rule, rule → selector
353fn 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                // Recurse into @layer blocks to find nested rules
368                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        // Recurse into child nodes
379        extract_css_dsl_nodes(&child, content, helper, module_id)?;
380    }
381
382    Ok(())
383}
384
385/// Extract a single CSS rule and its selectors.
386#[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    // Extract all selectors from this rule
394    let selectors = extract_selectors_from_rule(node, content);
395
396    if selectors.is_empty() {
397        return Ok(());
398    }
399
400    // Create a rule node with position-based uniqueness
401    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    // Add Contains edge from module to rule
410    helper.add_contains_edge(module_id, rule_id);
411
412    // Create selector nodes and link them to the rule
413    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        // Add Contains edge from rule to selector
418        helper.add_contains_edge(rule_id, selector_id);
419    }
420
421    Ok(())
422}
423
424/// Extract all selectors from a `rule_set` node.
425fn 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
438/// Extract individual selectors from a selectors container node.
439fn 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                // For combinator selectors, recurse to extract individual selectors
461                // Example: ".container > .item" should extract both .container and .item
462                selectors.extend(extract_individual_selectors(&child, content));
463            }
464            _ => {
465                // Recurse into other containers
466                selectors.extend(extract_individual_selectors(&child, content));
467            }
468        }
469    }
470
471    selectors
472}
473
474/// Create a Span from a tree-sitter Node.
475fn 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        // Use a path with parent dir so relative paths can resolve
587        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        // Verify only --real-variable is extracted, not --fake-variable from comment
618        assert_has_node(&staging, "--real-variable");
619    }
620
621    // =========================================================================
622    // New tests for review findings (HIGH #1, #2 and MEDIUM #3)
623    // =========================================================================
624
625    #[test]
626    fn test_import_creates_target_module_node() {
627        // HIGH #1: Verify target module nodes are created for @import
628        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        // Verify target module node was created for reset.css
640        assert_has_node(&staging, "reset");
641
642        // Verify an import edge was created
643        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        // HIGH #1: Verify relative paths are resolved to canonical forms
650        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        // Verify target node was created for button.css
662        assert_has_node(&staging, "button");
663
664        // Verify an import edge was created
665        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        // HIGH #1: Critical fix - same relative import from different files
672        // should resolve to different canonical targets
673        let builder = CssGraphBuilder;
674
675        // File 1: src/foo/main.css importing ./utils.css
676        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        // File 2: src/bar/main.css importing ./utils.css
685        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        // Verify both staging graphs created utils nodes (path resolution happens in GraphBuilder)
694        assert_has_node(&staging1, "utils");
695        assert_has_node(&staging2, "utils");
696
697        // Verify import edges were created
698        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        // HIGH #2: data: URIs should be skipped (not external dependencies)
707        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        // Verify only the real image node is created (data: URI skipped)
726        assert_has_node(&staging, "images/icon");
727
728        // The staging graph should have nodes for:
729        // 1. css::module
730        // 2. ./images/icon.png variable
731        // Data URI should be skipped, so we shouldn't see a "data:" node
732        assert!(staging.node_count() >= 2);
733    }
734
735    #[test]
736    fn test_url_remote_urls_use_http_language() {
737        // HIGH #2: Remote URLs should use Language::Http
738        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        // Verify node was created for the remote URL
754        assert_has_node(&staging, "example.com");
755
756        // Note: Language::Http would be set in the node, but test helpers don't expose
757        // language field verification. The key behavior is that the node is created.
758        assert!(staging.node_count() >= 2);
759    }
760
761    #[test]
762    fn test_import_remote_urls_use_http_language() {
763        // HIGH #1 extension: Remote @import should also use Language::Http
764        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        // Verify node was created for remote import
776        assert_has_node(&staging, "cdn.example.com");
777
778        // Verify import edge was created
779        let imports = collect_import_edges(&staging);
780        assert_eq!(imports.len(), 1);
781    }
782
783    #[test]
784    fn test_url_resolves_relative_paths() {
785        // HIGH #2: url() relative paths should be resolved
786        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        // Verify node was created for the image path
802        assert_has_node(&staging, "images/bg");
803
804        // Verify nodes were created (module + variable for the asset)
805        assert!(staging.node_count() >= 2);
806    }
807
808    #[test]
809    fn test_target_nodes_have_correct_kind() {
810        // MEDIUM #3: Verify target nodes are created with correct NodeKind
811        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        // Verify import target node (button.css) was created
828        assert_has_node(&staging, "button");
829
830        // Verify url() asset node (hero.png) was created
831        assert_has_node(&staging, "hero");
832
833        // Verify import edge was created
834        let imports = collect_import_edges(&staging);
835        assert_eq!(imports.len(), 1);
836
837        // Verify we have multiple nodes (module + import + asset)
838        assert!(staging.node_count() >= 3);
839    }
840
841    // =========================================================================
842    // Regression tests for review findings (iter 2)
843    // =========================================================================
844
845    #[test]
846    fn test_url_uppercase_function_name() {
847        // HIGH: Case-insensitive URL() matching - CSS is case-insensitive
848        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        // Verify all three image nodes were created (case-insensitive url matching)
870        assert_has_node(&staging, "icon1");
871        assert_has_node(&staging, "icon2");
872        assert_has_node(&staging, "icon3");
873
874        // Should have at least 4 nodes (module + 3 images)
875        assert!(staging.node_count() >= 4);
876    }
877
878    #[test]
879    fn test_import_uppercase_url() {
880        // HIGH: Case-insensitive URL() in @import statements
881        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        // Verify both import target nodes were created
896        assert_has_node(&staging, "reset");
897        assert_has_node(&staging, "theme");
898
899        // Verify import edges were created
900        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        // MEDIUM: Protocol-relative URLs (//cdn.example.com/...) should be treated as remote
907        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        // Verify node was created for protocol-relative URL
923        assert_has_node(&staging, "cdn.example.com");
924
925        // Should have at least 2 nodes (module + remote asset)
926        assert!(staging.node_count() >= 2);
927    }
928
929    #[test]
930    fn test_protocol_relative_url_in_import() {
931        // MEDIUM: Protocol-relative URLs in @import should also be treated as remote
932        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        // Verify node was created for protocol-relative import
944        assert_has_node(&staging, "cdn.example.com");
945
946        // Verify import edge was created
947        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        // MEDIUM: Uppercase schemes like HTTP:// are valid per RFC 3986
954        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        // Verify node was created for uppercase HTTP scheme
966        assert_has_node(&staging, "example.com");
967
968        // Verify import edge was created
969        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        // MEDIUM: Uppercase HTTPS:// in url() should be recognized as remote
976        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        // Verify node was created for uppercase HTTPS scheme
988        assert_has_node(&staging, "example.com");
989
990        // Should have at least 2 nodes (module + remote asset)
991        assert!(staging.node_count() >= 2);
992    }
993
994    #[test]
995    fn test_mixed_case_scheme_in_import() {
996        // Mixed case schemes like Http:// are also valid per RFC 3986
997        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        // Verify node was created for mixed case Http scheme
1009        assert_has_node(&staging, "cdn.example.com");
1010
1011        // Verify import edge was created
1012        let imports = collect_import_edges(&staging);
1013        assert_eq!(imports.len(), 1);
1014    }
1015
1016    // =========================================================================
1017    // CSS Cascade Layers (@layer) Tests - Wave 4 Implementation
1018    // =========================================================================
1019
1020    use sqry_core::graph::unified::build::staging::StagingOp;
1021    use sqry_core::graph::unified::edge::EdgeKind;
1022
1023    /// Build a string lookup map from `InternString` operations
1024    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    /// Resolve a `StringId` to its value using the string lookup map
1039    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        // @import with layer(name) should store layer name with @layer: prefix in alias
1052        // Convention: "@layer:<name>" distinguishes layer metadata from ES-style aliases
1053        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        // Find Imports edge
1067        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            // Verify the @layer: prefix convention
1088            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        // @import with layer() (anonymous) should store "@layer:" prefix only
1105        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        // Find Imports edge
1119        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            // Verify the @layer: prefix convention for anonymous layer
1143            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        // @import with layer(theme.dark) should store the nested layer name
1155        // Note: tree-sitter-css parses "theme.dark" differently due to the dot
1156        // being interpreted as a class selector, so this test verifies we handle it
1157        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        // Should have at least created an import node
1171        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        // @import with supports(condition) is detected
1181        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        // Should have created import nodes and edges
1195        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        // @layer base, utils, components; should create module nodes for each layer
1210        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        // Count module nodes (should be 4: css::module + 3 layers)
1224        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        // Count Contains edges (should be 3: one for each layer)
1236        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        // @layer name { .foo { color: red; } } should create layer module
1259        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        // Should have css::module and css::layer::name
1273        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        // Basic @import without layer should not have alias
1288        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        // Find Imports edge
1302        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        // @import url("file.css") layer(base) should work with url() syntax
1329        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        // Note: tree-sitter-css may mark this as ERROR due to newer syntax
1343        // but we should still attempt to extract what we can
1344        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        // Multiple @layer declarations in same file
1354        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        // Count Contains edges (should be 4: reset, base, components, utils)
1371        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        // @layer with CSS rules inside should extract CSS custom properties
1394        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        // Should have module nodes and variable node for --primary-color
1414        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        // Complex CSS with both @import with layer and @layer declarations
1427        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        // Count Imports edges (should be 2: reset.css and base.css)
1449        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        // All Imports should have layer alias with @layer: prefix
1464        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        // Verify the edge structure: module -> import node
1484        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        // Should have: css::module node, import node, and Imports edge between them
1498        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        // Single layer: @layer base;
1517        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        // Should have 1 Contains edge for the single layer
1531        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}