1use std::path::Path;
7use std::sync::LazyLock;
8
9use oxc_span::Span;
10
11use crate::{ExportInfo, ExportName, ImportInfo, ImportedName, ModuleInfo, VisibilityTag};
12use fallow_types::discover::FileId;
13
14static CSS_IMPORT_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
17 regex::Regex::new(r#"@import\s+(?:url\(\s*(?:["']([^"']+)["']|([^)]+))\s*\)|["']([^"']+)["'])"#)
18 .expect("valid regex")
19});
20
21static SCSS_USE_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
24 regex::Regex::new(r#"@(?:use|forward)\s+["']([^"']+)["']"#).expect("valid regex")
25});
26
27static CSS_PLUGIN_RE: LazyLock<regex::Regex> =
30 LazyLock::new(|| regex::Regex::new(r#"@plugin\s+["']([^"']+)["']"#).expect("valid regex"));
31
32static CSS_APPLY_RE: LazyLock<regex::Regex> =
35 LazyLock::new(|| regex::Regex::new(r"@apply\s+[^;}\n]+").expect("valid regex"));
36
37static CSS_TAILWIND_RE: LazyLock<regex::Regex> =
40 LazyLock::new(|| regex::Regex::new(r"@tailwind\s+\w+").expect("valid regex"));
41
42static CSS_COMMENT_RE: LazyLock<regex::Regex> =
44 LazyLock::new(|| regex::Regex::new(r"(?s)/\*.*?\*/").expect("valid regex"));
45
46static SCSS_LINE_COMMENT_RE: LazyLock<regex::Regex> =
48 LazyLock::new(|| regex::Regex::new(r"//[^\n]*").expect("valid regex"));
49
50static CSS_CLASS_RE: LazyLock<regex::Regex> =
53 LazyLock::new(|| regex::Regex::new(r"\.([a-zA-Z_][a-zA-Z0-9_-]*)").expect("valid regex"));
54
55static CSS_NON_SELECTOR_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
58 regex::Regex::new(r#"(?s)"[^"]*"|'[^']*'|url\([^)]*\)"#).expect("valid regex")
59});
60
61pub(crate) fn is_css_file(path: &Path) -> bool {
62 path.extension()
63 .and_then(|e| e.to_str())
64 .is_some_and(|ext| ext == "css" || ext == "scss")
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct CssImportSource {
70 pub raw: String,
72 pub normalized: String,
74 pub is_plugin: bool,
76}
77
78fn is_css_module_file(path: &Path) -> bool {
79 is_css_file(path)
80 && path
81 .file_stem()
82 .and_then(|s| s.to_str())
83 .is_some_and(|stem| stem.ends_with(".module"))
84}
85
86fn is_css_url_import(source: &str) -> bool {
88 source.starts_with("http://") || source.starts_with("https://") || source.starts_with("data:")
89}
90
91fn normalize_css_import_path(path: String, is_scss: bool) -> String {
104 if path.starts_with('.') || path.starts_with('/') || path.contains("://") {
105 return path;
106 }
107 if path.starts_with('@') && path.contains('/') {
110 return path;
111 }
112 let path_ref = std::path::Path::new(&path);
113 if !is_scss
114 && path.contains('/')
115 && path_ref
116 .extension()
117 .and_then(|e| e.to_str())
118 .is_some_and(is_style_extension)
119 {
120 return path;
121 }
122 let ext = std::path::Path::new(&path)
124 .extension()
125 .and_then(|e| e.to_str());
126 match ext {
127 Some(e) if is_style_extension(e) => format!("./{path}"),
128 _ => {
129 if is_scss && !path.contains(':') {
133 format!("./{path}")
134 } else {
135 path
136 }
137 }
138 }
139}
140
141fn is_style_extension(ext: &str) -> bool {
142 ext.eq_ignore_ascii_case("css")
143 || ext.eq_ignore_ascii_case("scss")
144 || ext.eq_ignore_ascii_case("sass")
145 || ext.eq_ignore_ascii_case("less")
146}
147
148fn strip_css_comments(source: &str, is_scss: bool) -> String {
150 let stripped = CSS_COMMENT_RE.replace_all(source, "");
151 if is_scss {
152 SCSS_LINE_COMMENT_RE.replace_all(&stripped, "").into_owned()
153 } else {
154 stripped.into_owned()
155 }
156}
157
158fn normalize_css_plugin_path(path: String) -> String {
164 path
165}
166
167#[must_use]
173pub fn extract_css_import_sources(source: &str, is_scss: bool) -> Vec<CssImportSource> {
174 let stripped = strip_css_comments(source, is_scss);
175 let mut out = Vec::new();
176
177 for cap in CSS_IMPORT_RE.captures_iter(&stripped) {
178 let raw = cap
179 .get(1)
180 .or_else(|| cap.get(2))
181 .or_else(|| cap.get(3))
182 .map(|m| m.as_str().trim().to_string());
183 if let Some(src) = raw
184 && !src.is_empty()
185 && !is_css_url_import(&src)
186 {
187 out.push(CssImportSource {
188 normalized: normalize_css_import_path(src.clone(), is_scss),
189 raw: src,
190 is_plugin: false,
191 });
192 }
193 }
194
195 if is_scss {
196 for cap in SCSS_USE_RE.captures_iter(&stripped) {
197 if let Some(m) = cap.get(1) {
198 let raw = m.as_str().to_string();
199 out.push(CssImportSource {
200 normalized: normalize_css_import_path(raw.clone(), true),
201 raw,
202 is_plugin: false,
203 });
204 }
205 }
206 }
207
208 for cap in CSS_PLUGIN_RE.captures_iter(&stripped) {
209 if let Some(m) = cap.get(1) {
210 let raw = m.as_str().trim().to_string();
211 if !raw.is_empty() && !is_css_url_import(&raw) {
212 out.push(CssImportSource {
213 normalized: normalize_css_plugin_path(raw.clone()),
214 raw,
215 is_plugin: true,
216 });
217 }
218 }
219 }
220
221 out
222}
223
224#[must_use]
231pub fn extract_css_imports(source: &str, is_scss: bool) -> Vec<String> {
232 extract_css_import_sources(source, is_scss)
233 .into_iter()
234 .map(|source| source.normalized)
235 .collect()
236}
237
238pub fn extract_css_module_exports(source: &str) -> Vec<ExportInfo> {
240 let cleaned = CSS_NON_SELECTOR_RE.replace_all(source, "");
241 let mut seen = rustc_hash::FxHashSet::default();
242 let mut exports = Vec::new();
243 for cap in CSS_CLASS_RE.captures_iter(&cleaned) {
244 if let Some(m) = cap.get(1) {
245 let class_name = m.as_str().to_string();
246 if seen.insert(class_name.clone()) {
247 exports.push(ExportInfo {
248 name: ExportName::Named(class_name),
249 local_name: None,
250 is_type_only: false,
251 visibility: VisibilityTag::None,
252 span: Span::default(),
253 members: Vec::new(),
254 super_class: None,
255 });
256 }
257 }
258 }
259 exports
260}
261
262pub(crate) fn parse_css_to_module(
264 file_id: FileId,
265 path: &Path,
266 source: &str,
267 content_hash: u64,
268) -> ModuleInfo {
269 let suppressions = crate::suppress::parse_suppressions_from_source(source);
270 let is_scss = path
271 .extension()
272 .and_then(|e| e.to_str())
273 .is_some_and(|ext| ext == "scss");
274
275 let stripped = strip_css_comments(source, is_scss);
277
278 let mut imports = Vec::new();
279
280 for cap in CSS_IMPORT_RE.captures_iter(&stripped) {
282 let source_path = cap
283 .get(1)
284 .or_else(|| cap.get(2))
285 .or_else(|| cap.get(3))
286 .map(|m| m.as_str().trim().to_string());
287 if let Some(src) = source_path
288 && !src.is_empty()
289 && !is_css_url_import(&src)
290 {
291 let src = normalize_css_import_path(src, is_scss);
294 imports.push(ImportInfo {
295 source: src,
296 imported_name: ImportedName::SideEffect,
297 local_name: String::new(),
298 is_type_only: false,
299 from_style: false,
300 span: Span::default(),
301 source_span: Span::default(),
302 });
303 }
304 }
305
306 if is_scss {
308 for cap in SCSS_USE_RE.captures_iter(&stripped) {
309 if let Some(m) = cap.get(1) {
310 imports.push(ImportInfo {
311 source: normalize_css_import_path(m.as_str().to_string(), true),
312 imported_name: ImportedName::SideEffect,
313 local_name: String::new(),
314 is_type_only: false,
315 from_style: false,
316 span: Span::default(),
317 source_span: Span::default(),
318 });
319 }
320 }
321 }
322
323 for cap in CSS_PLUGIN_RE.captures_iter(&stripped) {
326 if let Some(m) = cap.get(1) {
327 let source = m.as_str().trim().to_string();
328 if !source.is_empty() && !is_css_url_import(&source) {
329 imports.push(ImportInfo {
330 source: normalize_css_plugin_path(source),
331 imported_name: ImportedName::Default,
332 local_name: String::new(),
333 is_type_only: false,
334 from_style: false,
335 span: Span::default(),
336 source_span: Span::default(),
337 });
338 }
339 }
340 }
341
342 let has_apply = CSS_APPLY_RE.is_match(&stripped);
345 let has_tailwind = CSS_TAILWIND_RE.is_match(&stripped);
346 if has_apply || has_tailwind {
347 imports.push(ImportInfo {
348 source: "tailwindcss".to_string(),
349 imported_name: ImportedName::SideEffect,
350 local_name: String::new(),
351 is_type_only: false,
352 from_style: false,
353 span: Span::default(),
354 source_span: Span::default(),
355 });
356 }
357
358 let exports = if is_css_module_file(path) {
360 extract_css_module_exports(&stripped)
361 } else {
362 Vec::new()
363 };
364
365 ModuleInfo {
366 file_id,
367 exports,
368 imports,
369 re_exports: Vec::new(),
370 dynamic_imports: Vec::new(),
371 dynamic_import_patterns: Vec::new(),
372 require_calls: Vec::new(),
373 member_accesses: Vec::new(),
374 whole_object_uses: Vec::new(),
375 has_cjs_exports: false,
376 content_hash,
377 suppressions,
378 unused_import_bindings: Vec::new(),
379 type_referenced_import_bindings: Vec::new(),
380 value_referenced_import_bindings: Vec::new(),
381 line_offsets: fallow_types::extract::compute_line_offsets(source),
382 complexity: Vec::new(),
383 flag_uses: Vec::new(),
384 class_heritage: vec![],
385 local_type_declarations: Vec::new(),
386 public_signature_type_references: Vec::new(),
387 }
388}
389
390#[cfg(test)]
391mod tests {
392 use super::*;
393
394 fn export_names(source: &str) -> Vec<String> {
396 extract_css_module_exports(source)
397 .into_iter()
398 .filter_map(|e| match e.name {
399 ExportName::Named(n) => Some(n),
400 ExportName::Default => None,
401 })
402 .collect()
403 }
404
405 #[test]
408 fn is_css_file_css() {
409 assert!(is_css_file(Path::new("styles.css")));
410 }
411
412 #[test]
413 fn is_css_file_scss() {
414 assert!(is_css_file(Path::new("styles.scss")));
415 }
416
417 #[test]
418 fn is_css_file_rejects_js() {
419 assert!(!is_css_file(Path::new("app.js")));
420 }
421
422 #[test]
423 fn is_css_file_rejects_ts() {
424 assert!(!is_css_file(Path::new("app.ts")));
425 }
426
427 #[test]
428 fn is_css_file_rejects_less() {
429 assert!(!is_css_file(Path::new("styles.less")));
430 }
431
432 #[test]
433 fn is_css_file_rejects_no_extension() {
434 assert!(!is_css_file(Path::new("Makefile")));
435 }
436
437 #[test]
440 fn is_css_module_file_module_css() {
441 assert!(is_css_module_file(Path::new("Component.module.css")));
442 }
443
444 #[test]
445 fn is_css_module_file_module_scss() {
446 assert!(is_css_module_file(Path::new("Component.module.scss")));
447 }
448
449 #[test]
450 fn is_css_module_file_rejects_plain_css() {
451 assert!(!is_css_module_file(Path::new("styles.css")));
452 }
453
454 #[test]
455 fn is_css_module_file_rejects_plain_scss() {
456 assert!(!is_css_module_file(Path::new("styles.scss")));
457 }
458
459 #[test]
460 fn is_css_module_file_rejects_module_js() {
461 assert!(!is_css_module_file(Path::new("utils.module.js")));
462 }
463
464 #[test]
467 fn extracts_single_class() {
468 let names = export_names(".foo { color: red; }");
469 assert_eq!(names, vec!["foo"]);
470 }
471
472 #[test]
473 fn extracts_multiple_classes() {
474 let names = export_names(".foo { } .bar { }");
475 assert_eq!(names, vec!["foo", "bar"]);
476 }
477
478 #[test]
479 fn extracts_nested_classes() {
480 let names = export_names(".foo .bar { color: red; }");
481 assert!(names.contains(&"foo".to_string()));
482 assert!(names.contains(&"bar".to_string()));
483 }
484
485 #[test]
486 fn extracts_hyphenated_class() {
487 let names = export_names(".my-class { }");
488 assert_eq!(names, vec!["my-class"]);
489 }
490
491 #[test]
492 fn extracts_camel_case_class() {
493 let names = export_names(".myClass { }");
494 assert_eq!(names, vec!["myClass"]);
495 }
496
497 #[test]
498 fn extracts_underscore_class() {
499 let names = export_names("._hidden { } .__wrapper { }");
500 assert!(names.contains(&"_hidden".to_string()));
501 assert!(names.contains(&"__wrapper".to_string()));
502 }
503
504 #[test]
507 fn pseudo_selector_hover() {
508 let names = export_names(".foo:hover { color: blue; }");
509 assert_eq!(names, vec!["foo"]);
510 }
511
512 #[test]
513 fn pseudo_selector_focus() {
514 let names = export_names(".input:focus { outline: none; }");
515 assert_eq!(names, vec!["input"]);
516 }
517
518 #[test]
519 fn pseudo_element_before() {
520 let names = export_names(".icon::before { content: ''; }");
521 assert_eq!(names, vec!["icon"]);
522 }
523
524 #[test]
525 fn combined_pseudo_selectors() {
526 let names = export_names(".btn:hover, .btn:active, .btn:focus { }");
527 assert_eq!(names, vec!["btn"]);
529 }
530
531 #[test]
534 fn classes_inside_media_query() {
535 let names = export_names(
536 "@media (max-width: 768px) { .mobile-nav { display: block; } .desktop-nav { display: none; } }",
537 );
538 assert!(names.contains(&"mobile-nav".to_string()));
539 assert!(names.contains(&"desktop-nav".to_string()));
540 }
541
542 #[test]
545 fn deduplicates_repeated_class() {
546 let names = export_names(".btn { color: red; } .btn { font-size: 14px; }");
547 assert_eq!(names.iter().filter(|n| *n == "btn").count(), 1);
548 }
549
550 #[test]
553 fn empty_source() {
554 let names = export_names("");
555 assert!(names.is_empty());
556 }
557
558 #[test]
559 fn no_classes() {
560 let names = export_names("body { margin: 0; } * { box-sizing: border-box; }");
561 assert!(names.is_empty());
562 }
563
564 #[test]
565 fn ignores_classes_in_block_comments() {
566 let stripped = strip_css_comments("/* .fake { } */ .real { }", false);
571 let names = export_names(&stripped);
572 assert!(!names.contains(&"fake".to_string()));
573 assert!(names.contains(&"real".to_string()));
574 }
575
576 #[test]
577 fn ignores_classes_in_strings() {
578 let names = export_names(r#".real { content: ".fake"; }"#);
579 assert!(names.contains(&"real".to_string()));
580 assert!(!names.contains(&"fake".to_string()));
581 }
582
583 #[test]
584 fn ignores_classes_in_url() {
585 let names = export_names(".real { background: url(./images/hero.png); }");
586 assert!(names.contains(&"real".to_string()));
587 assert!(!names.contains(&"png".to_string()));
589 }
590
591 #[test]
594 fn strip_css_block_comment() {
595 let result = strip_css_comments("/* removed */ .kept { }", false);
596 assert!(!result.contains("removed"));
597 assert!(result.contains(".kept"));
598 }
599
600 #[test]
601 fn strip_scss_line_comment() {
602 let result = strip_css_comments("// removed\n.kept { }", true);
603 assert!(!result.contains("removed"));
604 assert!(result.contains(".kept"));
605 }
606
607 #[test]
608 fn strip_scss_preserves_css_outside_comments() {
609 let source = "// line comment\n/* block comment */\n.visible { color: red; }";
610 let result = strip_css_comments(source, true);
611 assert!(result.contains(".visible"));
612 }
613
614 #[test]
617 fn url_import_http() {
618 assert!(is_css_url_import("http://example.com/style.css"));
619 }
620
621 #[test]
622 fn url_import_https() {
623 assert!(is_css_url_import("https://fonts.googleapis.com/css"));
624 }
625
626 #[test]
627 fn url_import_data() {
628 assert!(is_css_url_import("data:text/css;base64,abc"));
629 }
630
631 #[test]
632 fn url_import_local_not_skipped() {
633 assert!(!is_css_url_import("./local.css"));
634 }
635
636 #[test]
637 fn url_import_bare_specifier_not_skipped() {
638 assert!(!is_css_url_import("tailwindcss"));
639 }
640
641 #[test]
644 fn normalize_relative_dot_path_unchanged() {
645 assert_eq!(
646 normalize_css_import_path("./reset.css".to_string(), false),
647 "./reset.css"
648 );
649 }
650
651 #[test]
652 fn normalize_parent_relative_path_unchanged() {
653 assert_eq!(
654 normalize_css_import_path("../shared.scss".to_string(), false),
655 "../shared.scss"
656 );
657 }
658
659 #[test]
660 fn normalize_absolute_path_unchanged() {
661 assert_eq!(
662 normalize_css_import_path("/styles/main.css".to_string(), false),
663 "/styles/main.css"
664 );
665 }
666
667 #[test]
668 fn normalize_url_unchanged() {
669 assert_eq!(
670 normalize_css_import_path("https://example.com/style.css".to_string(), false),
671 "https://example.com/style.css"
672 );
673 }
674
675 #[test]
676 fn normalize_bare_css_gets_dot_slash() {
677 assert_eq!(
678 normalize_css_import_path("app.css".to_string(), false),
679 "./app.css"
680 );
681 }
682
683 #[test]
684 fn normalize_css_package_subpath_stays_bare() {
685 assert_eq!(
686 normalize_css_import_path("tailwindcss/theme.css".to_string(), false),
687 "tailwindcss/theme.css"
688 );
689 }
690
691 #[test]
692 fn normalize_css_package_subpath_with_dotted_name_stays_bare() {
693 assert_eq!(
694 normalize_css_import_path("highlight.js/styles/github.css".to_string(), false),
695 "highlight.js/styles/github.css"
696 );
697 }
698
699 #[test]
700 fn normalize_bare_scss_gets_dot_slash() {
701 assert_eq!(
702 normalize_css_import_path("vars.scss".to_string(), false),
703 "./vars.scss"
704 );
705 }
706
707 #[test]
708 fn normalize_bare_sass_gets_dot_slash() {
709 assert_eq!(
710 normalize_css_import_path("main.sass".to_string(), false),
711 "./main.sass"
712 );
713 }
714
715 #[test]
716 fn normalize_bare_less_gets_dot_slash() {
717 assert_eq!(
718 normalize_css_import_path("theme.less".to_string(), false),
719 "./theme.less"
720 );
721 }
722
723 #[test]
724 fn normalize_bare_js_extension_stays_bare() {
725 assert_eq!(
726 normalize_css_import_path("module.js".to_string(), false),
727 "module.js"
728 );
729 }
730
731 #[test]
734 fn normalize_scss_bare_partial_gets_dot_slash() {
735 assert_eq!(
736 normalize_css_import_path("variables".to_string(), true),
737 "./variables"
738 );
739 }
740
741 #[test]
742 fn normalize_scss_bare_partial_with_subdir_gets_dot_slash() {
743 assert_eq!(
744 normalize_css_import_path("base/reset".to_string(), true),
745 "./base/reset"
746 );
747 }
748
749 #[test]
750 fn normalize_scss_builtin_stays_bare() {
751 assert_eq!(
752 normalize_css_import_path("sass:math".to_string(), true),
753 "sass:math"
754 );
755 }
756
757 #[test]
758 fn normalize_scss_relative_path_unchanged() {
759 assert_eq!(
760 normalize_css_import_path("../styles/variables".to_string(), true),
761 "../styles/variables"
762 );
763 }
764
765 #[test]
766 fn normalize_css_bare_extensionless_stays_bare() {
767 assert_eq!(
769 normalize_css_import_path("tailwindcss".to_string(), false),
770 "tailwindcss"
771 );
772 }
773
774 #[test]
777 fn normalize_scoped_package_with_css_extension_stays_bare() {
778 assert_eq!(
779 normalize_css_import_path("@fontsource/monaspace-neon/400.css".to_string(), false),
780 "@fontsource/monaspace-neon/400.css"
781 );
782 }
783
784 #[test]
785 fn normalize_scoped_package_with_scss_extension_stays_bare() {
786 assert_eq!(
787 normalize_css_import_path("@company/design-system/tokens.scss".to_string(), true),
788 "@company/design-system/tokens.scss"
789 );
790 }
791
792 #[test]
793 fn normalize_scoped_package_without_extension_stays_bare() {
794 assert_eq!(
795 normalize_css_import_path("@fallow/design-system/styles".to_string(), false),
796 "@fallow/design-system/styles"
797 );
798 }
799
800 #[test]
801 fn normalize_scoped_package_extensionless_scss_stays_bare() {
802 assert_eq!(
803 normalize_css_import_path("@company/tokens".to_string(), true),
804 "@company/tokens"
805 );
806 }
807
808 #[test]
809 fn normalize_path_alias_with_css_extension_stays_bare() {
810 assert_eq!(
815 normalize_css_import_path("@/components/Button.css".to_string(), false),
816 "@/components/Button.css"
817 );
818 }
819
820 #[test]
821 fn normalize_path_alias_extensionless_stays_bare() {
822 assert_eq!(
823 normalize_css_import_path("@/styles/variables".to_string(), false),
824 "@/styles/variables"
825 );
826 }
827
828 #[test]
831 fn strip_css_no_comments() {
832 let source = ".foo { color: red; }";
833 assert_eq!(strip_css_comments(source, false), source);
834 }
835
836 #[test]
837 fn strip_css_multiple_block_comments() {
838 let source = "/* comment-one */ .foo { } /* comment-two */ .bar { }";
839 let result = strip_css_comments(source, false);
840 assert!(!result.contains("comment-one"));
841 assert!(!result.contains("comment-two"));
842 assert!(result.contains(".foo"));
843 assert!(result.contains(".bar"));
844 }
845
846 #[test]
847 fn strip_scss_does_not_affect_non_scss() {
848 let source = "// this stays\n.foo { }";
850 let result = strip_css_comments(source, false);
851 assert!(result.contains("// this stays"));
852 }
853
854 #[test]
857 fn css_module_parses_suppressions() {
858 let info = parse_css_to_module(
859 fallow_types::discover::FileId(0),
860 Path::new("Component.module.css"),
861 "/* fallow-ignore-file */\n.btn { color: red; }",
862 0,
863 );
864 assert!(!info.suppressions.is_empty());
865 assert_eq!(info.suppressions[0].line, 0);
866 }
867
868 #[test]
871 fn extracts_class_starting_with_underscore() {
872 let names = export_names("._private { } .__dunder { }");
873 assert!(names.contains(&"_private".to_string()));
874 assert!(names.contains(&"__dunder".to_string()));
875 }
876
877 #[test]
878 fn ignores_id_selectors() {
879 let names = export_names("#myId { color: red; }");
880 assert!(!names.contains(&"myId".to_string()));
881 }
882
883 #[test]
884 fn ignores_element_selectors() {
885 let names = export_names("div { color: red; } span { }");
886 assert!(names.is_empty());
887 }
888
889 #[test]
892 fn extract_css_imports_at_import_quoted() {
893 let imports = extract_css_imports(r#"@import "./reset.css";"#, false);
894 assert_eq!(imports, vec!["./reset.css"]);
895 }
896
897 #[test]
898 fn extract_css_imports_package_subpath_stays_bare() {
899 let imports =
900 extract_css_imports(r#"@import "tailwindcss/theme.css" layer(theme);"#, false);
901 assert_eq!(imports, vec!["tailwindcss/theme.css"]);
902 }
903
904 #[test]
905 fn extract_css_imports_at_import_url() {
906 let imports = extract_css_imports(r#"@import url("./reset.css");"#, false);
907 assert_eq!(imports, vec!["./reset.css"]);
908 }
909
910 #[test]
911 fn extract_css_imports_skips_remote_urls() {
912 let imports =
913 extract_css_imports(r#"@import "https://fonts.example.com/font.css";"#, false);
914 assert!(imports.is_empty());
915 }
916
917 #[test]
918 fn extract_css_imports_scss_use_normalizes_partial() {
919 let imports = extract_css_imports(r#"@use "variables";"#, true);
920 assert_eq!(imports, vec!["./variables"]);
921 }
922
923 #[test]
924 fn extract_css_imports_scss_forward_normalizes_partial() {
925 let imports = extract_css_imports(r#"@forward "tokens";"#, true);
926 assert_eq!(imports, vec!["./tokens"]);
927 }
928
929 #[test]
930 fn extract_css_imports_skips_comments() {
931 let imports = extract_css_imports(
932 r#"/* @import "./hidden.scss"; */
933@use "real";"#,
934 true,
935 );
936 assert_eq!(imports, vec!["./real"]);
937 }
938
939 #[test]
940 fn extract_css_imports_at_plugin_keeps_package_bare() {
941 let imports = extract_css_imports(r#"@plugin "daisyui";"#, true);
942 assert_eq!(imports, vec!["daisyui"]);
943 }
944
945 #[test]
946 fn extract_css_imports_at_plugin_tracks_relative_file() {
947 let imports = extract_css_imports(r#"@plugin "./tailwind-plugin.js";"#, false);
948 assert_eq!(imports, vec!["./tailwind-plugin.js"]);
949 }
950
951 #[test]
952 fn extract_css_imports_scss_at_import_kept_relative() {
953 let imports = extract_css_imports(r"@import 'Foo';", true);
954 assert_eq!(imports, vec!["./Foo"]);
956 }
957
958 #[test]
959 fn extract_css_imports_additional_data_string_body() {
960 let body = r#"@use "./src/styles/global.scss";"#;
962 let imports = extract_css_imports(body, true);
963 assert_eq!(imports, vec!["./src/styles/global.scss"]);
964 }
965}