1pub 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
27pub struct CssPlugin {
29 graph_builder: CssGraphBuilder,
30}
31
32impl CssPlugin {
33 #[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 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 scopes.sort_by_key(|s| (s.start_line, s.start_column));
101
102 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 "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), 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, });
133 }
134
135 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 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 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 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 fn extract_at_rule_scope(node: Node<'_>, content: &[u8]) -> Option<(String, String)> {
204 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 "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 _ => 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 #[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 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 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 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 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 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 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 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 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 assert!(result.is_ok(), "Should handle malformed CSS gracefully");
632 }
633}
634