Skip to main content

sqry_lang_css/
lib.rs

1// Nested conditionals kept for readability when traversing CSS AST
2
3// Nested conditionals kept for readability when traversing CSS AST
4
5//! CSS language plugin
6//!
7//! Extracts stylesheet rules, at-rules, and custom properties while tracking
8//! resource relations (`@import`, `url()`) to support semantic cross-language
9//! search between CSS, HTML, and assets.
10
11pub mod relations;
12
13pub use relations::CssGraphBuilder;
14
15use sqry_core::ast::{Scope, ScopeId, link_nested_scopes};
16use sqry_core::plugin::{
17    LanguageMetadata, LanguagePlugin,
18    error::{ParseError, ScopeError},
19};
20use std::path::Path;
21use tree_sitter::{Language, Node, Parser, Tree};
22
23const LANGUAGE_ID: &str = "css";
24const LANGUAGE_NAME: &str = "CSS";
25const TREE_SITTER_VERSION: &str = "0.23";
26
27/// CSS language plugin implementation
28pub struct CssPlugin {
29    graph_builder: CssGraphBuilder,
30}
31
32impl CssPlugin {
33    /// Creates a new CSS plugin instance.
34    #[must_use]
35    pub fn new() -> Self {
36        Self {
37            graph_builder: CssGraphBuilder,
38        }
39    }
40}
41
42impl Default for CssPlugin {
43    fn default() -> Self {
44        Self::new()
45    }
46}
47
48impl LanguagePlugin for CssPlugin {
49    fn metadata(&self) -> LanguageMetadata {
50        LanguageMetadata {
51            id: LANGUAGE_ID,
52            name: LANGUAGE_NAME,
53            version: env!("CARGO_PKG_VERSION"),
54            author: "Verivus Pty Ltd",
55            description: "CSS language support for sqry",
56            tree_sitter_version: TREE_SITTER_VERSION,
57        }
58    }
59
60    fn extensions(&self) -> &'static [&'static str] {
61        &["css", "scss", "sass", "less"]
62    }
63
64    fn language(&self) -> Language {
65        tree_sitter_css::LANGUAGE.into()
66    }
67
68    fn parse_ast(&self, content: &[u8]) -> Result<Tree, ParseError> {
69        let mut parser = Parser::new();
70        parser
71            .set_language(&self.language())
72            .map_err(|err| ParseError::LanguageSetFailed(err.to_string()))?;
73
74        parser
75            .parse(content, None)
76            .ok_or(ParseError::TreeSitterFailed)
77    }
78
79    fn extract_scopes(
80        &self,
81        tree: &Tree,
82        content: &[u8],
83        file_path: &Path,
84    ) -> Result<Vec<Scope>, ScopeError> {
85        Ok(Self::extract_css_scopes(tree, content, file_path))
86    }
87
88    fn graph_builder(&self) -> Option<&dyn sqry_core::graph::GraphBuilder> {
89        Some(&self.graph_builder)
90    }
91}
92
93impl CssPlugin {
94    /// Extract scopes from CSS - @media, @supports, @keyframes, and rule sets
95    fn extract_css_scopes(tree: &Tree, content: &[u8], file_path: &Path) -> Vec<Scope> {
96        let mut scopes = Vec::new();
97        Self::collect_scopes(tree.root_node(), content, file_path, &mut scopes);
98
99        // Sort by position (required for link_nested_scopes)
100        scopes.sort_by_key(|s| (s.start_line, s.start_column));
101
102        // Build parent-child relationships
103        link_nested_scopes(&mut scopes);
104
105        scopes
106    }
107
108    fn collect_scopes(node: Node<'_>, content: &[u8], file_path: &Path, scopes: &mut Vec<Scope>) {
109        let scope_info = match node.kind() {
110            "media_statement" => Some(Self::extract_media_scope(node, content)),
111            "supports_statement" => Some(Self::extract_supports_scope(node, content)),
112            "keyframes_statement" => Some(Self::extract_keyframes_scope(node, content)),
113            "rule_set" => Self::extract_ruleset_scope(node, content),
114            // Newer CSS at-rules
115            "at_rule" => Self::extract_at_rule_scope(node, content),
116            _ => None,
117        };
118
119        if let Some((scope_type, name)) = scope_info {
120            let start = node.start_position();
121            let end = node.end_position();
122            scopes.push(Scope {
123                id: ScopeId::new(0), // Will be reassigned by link_nested_scopes
124                scope_type,
125                name,
126                file_path: file_path.to_path_buf(),
127                start_line: start.row + 1,
128                start_column: start.column,
129                end_line: end.row + 1,
130                end_column: end.column,
131                parent_id: None, // Will be set by link_nested_scopes
132            });
133        }
134
135        // Recurse into children
136        let mut cursor = node.walk();
137        for child in node.children(&mut cursor) {
138            Self::collect_scopes(child, content, file_path, scopes);
139        }
140    }
141
142    fn extract_media_scope(node: Node<'_>, content: &[u8]) -> (String, String) {
143        // Try to get the media query condition
144        let mut cursor = node.walk();
145        for child in node.children(&mut cursor) {
146            if (child.kind() == "query_list" || child.kind() == "feature_query")
147                && let Ok(text) = child.utf8_text(content)
148            {
149                return ("media".to_string(), format!("@media {}", text.trim()));
150            }
151        }
152        ("media".to_string(), "@media".to_string())
153    }
154
155    fn extract_supports_scope(node: Node<'_>, content: &[u8]) -> (String, String) {
156        let mut cursor = node.walk();
157        for child in node.children(&mut cursor) {
158            if (child.kind() == "feature_query" || child.kind() == "parenthesized_query")
159                && let Ok(text) = child.utf8_text(content)
160            {
161                return ("supports".to_string(), format!("@supports {}", text.trim()));
162            }
163        }
164        ("supports".to_string(), "@supports".to_string())
165    }
166
167    fn extract_keyframes_scope(node: Node<'_>, content: &[u8]) -> (String, String) {
168        let mut cursor = node.walk();
169        for child in node.children(&mut cursor) {
170            if child.kind() == "keyframes_name"
171                && let Ok(text) = child.utf8_text(content)
172            {
173                return (
174                    "keyframes".to_string(),
175                    format!("@keyframes {}", text.trim()),
176                );
177            }
178        }
179        ("keyframes".to_string(), "@keyframes".to_string())
180    }
181
182    fn extract_ruleset_scope(node: Node<'_>, content: &[u8]) -> Option<(String, String)> {
183        // Get the selector(s) as the scope name
184        let mut cursor = node.walk();
185        for child in node.children(&mut cursor) {
186            if child.kind() == "selectors"
187                && let Ok(text) = child.utf8_text(content)
188            {
189                let selector = text.trim();
190                // Truncate long selectors
191                let display_name = if selector.len() > 50 {
192                    format!("{}...", &selector[..47])
193                } else {
194                    selector.to_string()
195                };
196                return Some(("rule_set".to_string(), display_name));
197            }
198        }
199        None
200    }
201
202    /// Extract scope from generic at-rules (@container, @layer, @font-face, @page, etc.)
203    fn extract_at_rule_scope(node: Node<'_>, content: &[u8]) -> Option<(String, String)> {
204        // First child should be at_keyword (e.g., "@container", "@layer")
205        let mut cursor = node.walk();
206        let mut at_keyword = None;
207        let mut name_parts = Vec::new();
208
209        for child in node.children(&mut cursor) {
210            match child.kind() {
211                "at_keyword" => {
212                    if let Ok(text) = child.utf8_text(content) {
213                        at_keyword = Some(text.trim().to_lowercase());
214                    }
215                }
216                // Capture identifier/name after keyword
217                "keyword_query" | "plain_value" | "identifier" => {
218                    if let Ok(text) = child.utf8_text(content) {
219                        name_parts.push(text.trim().to_string());
220                    }
221                }
222                _ => {}
223            }
224        }
225
226        let keyword = at_keyword?;
227        match keyword.as_str() {
228            "@container" => {
229                let name = if name_parts.is_empty() {
230                    "@container".to_string()
231                } else {
232                    format!("@container {}", name_parts.join(" "))
233                };
234                Some(("container".to_string(), name))
235            }
236            "@layer" => {
237                let name = if name_parts.is_empty() {
238                    "@layer".to_string()
239                } else {
240                    format!("@layer {}", name_parts.join(" "))
241                };
242                Some(("layer".to_string(), name))
243            }
244            "@font-face" => Some(("font_face".to_string(), "@font-face".to_string())),
245            "@page" => {
246                let name = if name_parts.is_empty() {
247                    "@page".to_string()
248                } else {
249                    format!("@page {}", name_parts.join(" "))
250                };
251                Some(("page".to_string(), name))
252            }
253            // Skip other generic at-rules that aren't scope-like
254            _ => None,
255        }
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262    use sqry_core::graph::unified::NodeId;
263    use sqry_core::graph::unified::build::staging::{StagingGraph, StagingOp};
264    use sqry_core::graph::unified::edge::EdgeKind;
265    use sqry_core::graph::unified::node::NodeKind;
266    use sqry_core::graph::unified::storage::NodeEntry;
267    use std::collections::HashMap;
268    use std::fs;
269    use std::path::{Path, PathBuf};
270
271    fn load_fixture(name: &str) -> (Vec<u8>, PathBuf) {
272        let path = PathBuf::from("tests/fixtures").join(name);
273        let content = fs::read(&path).expect("failed to read fixture");
274        (content, path)
275    }
276
277    fn build_string_lookup(staging: &StagingGraph) -> HashMap<u32, String> {
278        let mut lookup = HashMap::new();
279        for op in staging.operations() {
280            if let StagingOp::InternString { local_id, value } = op {
281                lookup.insert(local_id.index(), value.clone());
282            }
283        }
284        lookup
285    }
286
287    fn resolved_node_name(entry: &NodeEntry, strings: &HashMap<u32, String>) -> Option<String> {
288        entry
289            .qualified_name
290            .and_then(|qualified_name_id| strings.get(&qualified_name_id.index()).cloned())
291            .or_else(|| strings.get(&entry.name.index()).cloned())
292    }
293
294    fn find_node_entry<'a>(
295        staging: &'a StagingGraph,
296        name: &str,
297        kind: NodeKind,
298    ) -> Option<&'a NodeEntry> {
299        let strings = build_string_lookup(staging);
300        for op in staging.operations() {
301            if let StagingOp::AddNode { entry, .. } = op
302                && entry.kind == kind
303                && resolved_node_name(entry, &strings).is_some_and(|node_name| node_name == name)
304            {
305                return Some(entry);
306            }
307        }
308        None
309    }
310
311    fn find_node_id(staging: &StagingGraph, name: &str, kind: NodeKind) -> Option<NodeId> {
312        let strings = build_string_lookup(staging);
313        for op in staging.operations() {
314            if let StagingOp::AddNode { entry, expected_id } = op
315                && entry.kind == kind
316                && resolved_node_name(entry, &strings).is_some_and(|node_name| node_name == name)
317            {
318                return *expected_id;
319            }
320        }
321        None
322    }
323
324    fn build_graph(content: &[u8], path: &Path) -> StagingGraph {
325        let plugin = CssPlugin::default();
326        let tree = plugin.parse_ast(content).expect("parse css");
327        let builder = plugin.graph_builder().expect("graph builder");
328        let mut staging = StagingGraph::new();
329        builder
330            .build_graph(&tree, content, path, &mut staging)
331            .expect("build graph");
332        staging
333    }
334
335    #[test]
336    fn extracts_custom_properties_and_assets() {
337        let (content, path) = load_fixture("basic.css");
338        let staging = build_graph(&content, &path);
339
340        assert!(
341            find_node_entry(&staging, "--primary", NodeKind::Variable).is_some(),
342            "custom property node not found"
343        );
344        assert!(
345            find_node_entry(&staging, "/assets/bg.png", NodeKind::Variable).is_some(),
346            "asset url node not found"
347        );
348    }
349
350    #[test]
351    fn extracts_import_edges() {
352        let (content, path) = load_fixture("basic.css");
353        let staging = build_graph(&content, &path);
354
355        let module_id =
356            find_node_id(&staging, "css::module", NodeKind::Module).expect("module node not found");
357        let import_id =
358            find_node_id(&staging, "./reset.css", NodeKind::Import).expect("import node not found");
359
360        let mut has_edge = false;
361        for op in staging.operations() {
362            if let StagingOp::AddEdge {
363                source,
364                target,
365                kind,
366                ..
367            } = op
368                && matches!(kind, EdgeKind::Imports { .. })
369                && *source == module_id
370                && *target == import_id
371            {
372                has_edge = true;
373                break;
374            }
375        }
376        assert!(has_edge, "import edge not found for ./reset.css");
377    }
378
379    // ========================================================================
380    // Scope Extraction Tests
381    // ========================================================================
382
383    #[test]
384    fn test_extract_scopes_basic_media() {
385        let plugin = CssPlugin::default();
386        let source = b"@media (max-width: 768px) { .mobile { display: block; } }";
387        let tree = plugin.parse_ast(source).unwrap();
388        let scopes = plugin
389            .extract_scopes(&tree, source, Path::new("test.css"))
390            .unwrap();
391
392        assert!(!scopes.is_empty(), "Should extract at least media scope");
393        let media_scope = scopes.iter().find(|s| s.scope_type == "media");
394        assert!(media_scope.is_some(), "Should have media scope");
395        assert!(
396            media_scope.unwrap().name.contains("@media"),
397            "Media scope name should contain @media"
398        );
399    }
400
401    #[test]
402    fn test_extract_scopes_keyframes() {
403        let plugin = CssPlugin::default();
404        let source = b"@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }";
405        let tree = plugin.parse_ast(source).unwrap();
406        let scopes = plugin
407            .extract_scopes(&tree, source, Path::new("test.css"))
408            .unwrap();
409
410        let keyframes_scope = scopes.iter().find(|s| s.scope_type == "keyframes");
411        assert!(keyframes_scope.is_some(), "Should have keyframes scope");
412        assert!(
413            keyframes_scope.unwrap().name.contains("fadeIn"),
414            "Keyframes scope should contain animation name"
415        );
416    }
417
418    #[test]
419    fn test_extract_scopes_supports() {
420        let plugin = CssPlugin::default();
421        let source = b"@supports (display: grid) { .grid { display: grid; } }";
422        let tree = plugin.parse_ast(source).unwrap();
423        let scopes = plugin
424            .extract_scopes(&tree, source, Path::new("test.css"))
425            .unwrap();
426
427        let supports_scope = scopes.iter().find(|s| s.scope_type == "supports");
428        assert!(supports_scope.is_some(), "Should have supports scope");
429    }
430
431    #[test]
432    fn test_extract_scopes_rule_set() {
433        let plugin = CssPlugin::default();
434        let source = b".my-class { color: red; } #my-id { color: blue; }";
435        let tree = plugin.parse_ast(source).unwrap();
436        let scopes = plugin
437            .extract_scopes(&tree, source, Path::new("test.css"))
438            .unwrap();
439
440        let rule_sets: Vec<_> = scopes
441            .iter()
442            .filter(|s| s.scope_type == "rule_set")
443            .collect();
444        assert_eq!(rule_sets.len(), 2, "Should have 2 rule_set scopes");
445        assert!(
446            rule_sets.iter().any(|s| s.name == ".my-class"),
447            "Should have .my-class selector"
448        );
449        assert!(
450            rule_sets.iter().any(|s| s.name == "#my-id"),
451            "Should have #my-id selector"
452        );
453    }
454
455    #[test]
456    fn test_extract_scopes_container_query() {
457        let plugin = CssPlugin::default();
458        let source = b"@container sidebar (min-width: 400px) { .card { display: flex; } }";
459        let tree = plugin.parse_ast(source).unwrap();
460        let scopes = plugin
461            .extract_scopes(&tree, source, Path::new("test.css"))
462            .unwrap();
463
464        // Container queries are mandatory - tree-sitter-css 0.23+ required
465        let container_scope = scopes.iter().find(|s| s.scope_type == "container");
466        assert!(
467            container_scope.is_some(),
468            "Container scope must be extracted (requires tree-sitter-css 0.23+)"
469        );
470        assert!(
471            container_scope.unwrap().name.contains("@container"),
472            "Container scope name should contain @container"
473        );
474    }
475
476    #[test]
477    fn test_extract_scopes_layer() {
478        let plugin = CssPlugin::default();
479        let source = b"@layer utilities { .flex { display: flex; } }";
480        let tree = plugin.parse_ast(source).unwrap();
481        let scopes = plugin
482            .extract_scopes(&tree, source, Path::new("test.css"))
483            .unwrap();
484
485        // Cascade layers are mandatory - tree-sitter-css 0.23+ required
486        let layer_scope = scopes.iter().find(|s| s.scope_type == "layer");
487        assert!(
488            layer_scope.is_some(),
489            "Layer scope must be extracted (requires tree-sitter-css 0.23+)"
490        );
491        assert!(
492            layer_scope.unwrap().name.contains("@layer"),
493            "Layer scope name should contain @layer"
494        );
495    }
496
497    #[test]
498    fn test_extract_scopes_font_face() {
499        let plugin = CssPlugin::default();
500        let source = b"@font-face { font-family: 'MyFont'; src: url('myfont.woff2'); }";
501        let tree = plugin.parse_ast(source).unwrap();
502        let scopes = plugin
503            .extract_scopes(&tree, source, Path::new("test.css"))
504            .unwrap();
505
506        // Font-face rules are mandatory
507        let font_face_scope = scopes.iter().find(|s| s.scope_type == "font_face");
508        assert!(
509            font_face_scope.is_some(),
510            "Font-face scope must be extracted"
511        );
512        assert_eq!(
513            font_face_scope.unwrap().name,
514            "@font-face",
515            "Font-face scope name must be @font-face"
516        );
517    }
518
519    #[test]
520    fn test_extract_scopes_page() {
521        let plugin = CssPlugin::default();
522        let source = b"@page :first { margin: 2cm; }";
523        let tree = plugin.parse_ast(source).unwrap();
524        let scopes = plugin
525            .extract_scopes(&tree, source, Path::new("test.css"))
526            .unwrap();
527
528        // Page rules are mandatory
529        let page_scope = scopes.iter().find(|s| s.scope_type == "page");
530        assert!(page_scope.is_some(), "Page scope must be extracted");
531        assert!(
532            page_scope.unwrap().name.contains("@page"),
533            "Page scope name should contain @page"
534        );
535    }
536
537    #[test]
538    fn test_extract_scopes_nested_media() {
539        let plugin = CssPlugin::default();
540        let source = br"
541@media screen {
542    .container { width: 100%; }
543    @media (min-width: 768px) {
544        .container { width: 750px; }
545    }
546}
547";
548        let tree = plugin.parse_ast(source).unwrap();
549        let scopes = plugin
550            .extract_scopes(&tree, source, Path::new("test.css"))
551            .unwrap();
552
553        let media_scopes: Vec<_> = scopes.iter().filter(|s| s.scope_type == "media").collect();
554        assert!(media_scopes.len() >= 2, "Should have nested media scopes");
555
556        // Check parent-child relationship
557        let inner_media = media_scopes.iter().find(|s| s.name.contains("min-width"));
558        if let Some(inner) = inner_media {
559            assert!(
560                inner.parent_id.is_some(),
561                "Inner media should have parent_id"
562            );
563        }
564    }
565
566    #[test]
567    fn test_extract_scopes_boundaries() {
568        let plugin = CssPlugin::default();
569        let source = br"
570.my-selector {
571    color: red;
572    font-size: 14px;
573}
574";
575        let tree = plugin.parse_ast(source).unwrap();
576        let scopes = plugin
577            .extract_scopes(&tree, source, Path::new("test.css"))
578            .unwrap();
579
580        assert!(!scopes.is_empty(), "Should extract at least one scope");
581        let scope = &scopes[0];
582
583        // Verify boundaries are valid
584        assert!(scope.start_line >= 1, "start_line should be >= 1");
585        assert!(
586            scope.end_line >= scope.start_line,
587            "end_line should be >= start_line"
588        );
589    }
590
591    #[test]
592    fn test_extract_scopes_long_selector_truncation() {
593        let plugin = CssPlugin::default();
594        let source =
595            b".very-long-selector-name-that-exceeds-fifty-characters-limit { color: red; }";
596        let tree = plugin.parse_ast(source).unwrap();
597        let scopes = plugin
598            .extract_scopes(&tree, source, Path::new("test.css"))
599            .unwrap();
600
601        let rule_set = scopes.iter().find(|s| s.scope_type == "rule_set");
602        assert!(rule_set.is_some());
603        // Long selectors should be truncated
604        assert!(
605            rule_set.unwrap().name.len() <= 53,
606            "Selector should be truncated"
607        );
608    }
609
610    #[test]
611    fn test_extract_scopes_empty_file() {
612        let plugin = CssPlugin::default();
613        let source = b"";
614        let tree = plugin.parse_ast(source).unwrap();
615        let scopes = plugin
616            .extract_scopes(&tree, source, Path::new("test.css"))
617            .unwrap();
618
619        assert!(scopes.is_empty(), "Empty file should have no scopes");
620    }
621
622    #[test]
623    fn test_extract_scopes_malformed() {
624        let plugin = CssPlugin::default();
625        // Malformed CSS with missing closing brace
626        let source = b".broken { color: red;";
627        let tree = plugin.parse_ast(source).unwrap();
628        let result = plugin.extract_scopes(&tree, source, Path::new("test.css"));
629
630        // Should not panic, may return empty or partial results
631        assert!(result.is_ok(), "Should handle malformed CSS gracefully");
632    }
633}
634// Nested conditionals retained for readability in parser traversal