1use std::path::Path;
7use std::sync::LazyLock;
8
9use oxc_span::Span;
10
11use crate::{ExportInfo, ExportName, ImportInfo, ImportedName, ModuleInfo};
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_APPLY_RE: LazyLock<regex::Regex> =
30 LazyLock::new(|| regex::Regex::new(r"@apply\s+[^;}\n]+").expect("valid regex"));
31
32static CSS_TAILWIND_RE: LazyLock<regex::Regex> =
35 LazyLock::new(|| regex::Regex::new(r"@tailwind\s+\w+").expect("valid regex"));
36
37static CSS_COMMENT_RE: LazyLock<regex::Regex> =
39 LazyLock::new(|| regex::Regex::new(r"(?s)/\*.*?\*/").expect("valid regex"));
40
41static SCSS_LINE_COMMENT_RE: LazyLock<regex::Regex> =
43 LazyLock::new(|| regex::Regex::new(r"//[^\n]*").expect("valid regex"));
44
45static CSS_CLASS_RE: LazyLock<regex::Regex> =
48 LazyLock::new(|| regex::Regex::new(r"\.([a-zA-Z_][a-zA-Z0-9_-]*)").expect("valid regex"));
49
50static CSS_NON_SELECTOR_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
53 regex::Regex::new(r#"(?s)"[^"]*"|'[^']*'|url\([^)]*\)"#).expect("valid regex")
54});
55
56pub(crate) fn is_css_file(path: &Path) -> bool {
57 path.extension()
58 .and_then(|e| e.to_str())
59 .is_some_and(|ext| ext == "css" || ext == "scss")
60}
61
62fn is_css_module_file(path: &Path) -> bool {
63 is_css_file(path)
64 && path
65 .file_stem()
66 .and_then(|s| s.to_str())
67 .is_some_and(|stem| stem.ends_with(".module"))
68}
69
70fn is_css_url_import(source: &str) -> bool {
72 source.starts_with("http://") || source.starts_with("https://") || source.starts_with("data:")
73}
74
75fn normalize_css_import_path(path: String) -> String {
81 if path.starts_with('.') || path.starts_with('/') || path.contains("://") {
82 return path;
83 }
84 let ext = std::path::Path::new(&path)
86 .extension()
87 .and_then(|e| e.to_str());
88 match ext {
89 Some(e)
90 if e.eq_ignore_ascii_case("css")
91 || e.eq_ignore_ascii_case("scss")
92 || e.eq_ignore_ascii_case("sass")
93 || e.eq_ignore_ascii_case("less") =>
94 {
95 format!("./{path}")
96 }
97 _ => path,
98 }
99}
100
101fn strip_css_comments(source: &str, is_scss: bool) -> String {
103 let stripped = CSS_COMMENT_RE.replace_all(source, "");
104 if is_scss {
105 SCSS_LINE_COMMENT_RE.replace_all(&stripped, "").into_owned()
106 } else {
107 stripped.into_owned()
108 }
109}
110
111pub fn extract_css_module_exports(source: &str) -> Vec<ExportInfo> {
113 let cleaned = CSS_NON_SELECTOR_RE.replace_all(source, "");
114 let mut seen = rustc_hash::FxHashSet::default();
115 let mut exports = Vec::new();
116 for cap in CSS_CLASS_RE.captures_iter(&cleaned) {
117 if let Some(m) = cap.get(1) {
118 let class_name = m.as_str().to_string();
119 if seen.insert(class_name.clone()) {
120 exports.push(ExportInfo {
121 name: ExportName::Named(class_name),
122 local_name: None,
123 is_type_only: false,
124 is_public: false,
125 span: Span::default(),
126 members: Vec::new(),
127 });
128 }
129 }
130 }
131 exports
132}
133
134pub(crate) fn parse_css_to_module(
136 file_id: FileId,
137 path: &Path,
138 source: &str,
139 content_hash: u64,
140) -> ModuleInfo {
141 let suppressions = crate::suppress::parse_suppressions_from_source(source);
142 let is_scss = path
143 .extension()
144 .and_then(|e| e.to_str())
145 .is_some_and(|ext| ext == "scss");
146
147 let stripped = strip_css_comments(source, is_scss);
149
150 let mut imports = Vec::new();
151
152 for cap in CSS_IMPORT_RE.captures_iter(&stripped) {
154 let source_path = cap
155 .get(1)
156 .or_else(|| cap.get(2))
157 .or_else(|| cap.get(3))
158 .map(|m| m.as_str().trim().to_string());
159 if let Some(src) = source_path
160 && !src.is_empty()
161 && !is_css_url_import(&src)
162 {
163 let src = normalize_css_import_path(src);
166 imports.push(ImportInfo {
167 source: src,
168 imported_name: ImportedName::SideEffect,
169 local_name: String::new(),
170 is_type_only: false,
171 span: Span::default(),
172 });
173 }
174 }
175
176 if is_scss {
178 for cap in SCSS_USE_RE.captures_iter(&stripped) {
179 if let Some(m) = cap.get(1) {
180 imports.push(ImportInfo {
181 source: normalize_css_import_path(m.as_str().to_string()),
182 imported_name: ImportedName::SideEffect,
183 local_name: String::new(),
184 is_type_only: false,
185 span: Span::default(),
186 });
187 }
188 }
189 }
190
191 let has_apply = CSS_APPLY_RE.is_match(&stripped);
194 let has_tailwind = CSS_TAILWIND_RE.is_match(&stripped);
195 if has_apply || has_tailwind {
196 imports.push(ImportInfo {
197 source: "tailwindcss".to_string(),
198 imported_name: ImportedName::SideEffect,
199 local_name: String::new(),
200 is_type_only: false,
201 span: Span::default(),
202 });
203 }
204
205 let exports = if is_css_module_file(path) {
207 extract_css_module_exports(&stripped)
208 } else {
209 Vec::new()
210 };
211
212 ModuleInfo {
213 file_id,
214 exports,
215 imports,
216 re_exports: Vec::new(),
217 dynamic_imports: Vec::new(),
218 dynamic_import_patterns: Vec::new(),
219 require_calls: Vec::new(),
220 member_accesses: Vec::new(),
221 whole_object_uses: Vec::new(),
222 has_cjs_exports: false,
223 content_hash,
224 suppressions,
225 unused_import_bindings: Vec::new(),
226 line_offsets: fallow_types::extract::compute_line_offsets(source),
227 }
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233
234 fn export_names(source: &str) -> Vec<String> {
236 extract_css_module_exports(source)
237 .into_iter()
238 .filter_map(|e| match e.name {
239 ExportName::Named(n) => Some(n),
240 _ => None,
241 })
242 .collect()
243 }
244
245 #[test]
248 fn is_css_file_css() {
249 assert!(is_css_file(Path::new("styles.css")));
250 }
251
252 #[test]
253 fn is_css_file_scss() {
254 assert!(is_css_file(Path::new("styles.scss")));
255 }
256
257 #[test]
258 fn is_css_file_rejects_js() {
259 assert!(!is_css_file(Path::new("app.js")));
260 }
261
262 #[test]
263 fn is_css_file_rejects_ts() {
264 assert!(!is_css_file(Path::new("app.ts")));
265 }
266
267 #[test]
268 fn is_css_file_rejects_less() {
269 assert!(!is_css_file(Path::new("styles.less")));
270 }
271
272 #[test]
273 fn is_css_file_rejects_no_extension() {
274 assert!(!is_css_file(Path::new("Makefile")));
275 }
276
277 #[test]
280 fn is_css_module_file_module_css() {
281 assert!(is_css_module_file(Path::new("Component.module.css")));
282 }
283
284 #[test]
285 fn is_css_module_file_module_scss() {
286 assert!(is_css_module_file(Path::new("Component.module.scss")));
287 }
288
289 #[test]
290 fn is_css_module_file_rejects_plain_css() {
291 assert!(!is_css_module_file(Path::new("styles.css")));
292 }
293
294 #[test]
295 fn is_css_module_file_rejects_plain_scss() {
296 assert!(!is_css_module_file(Path::new("styles.scss")));
297 }
298
299 #[test]
300 fn is_css_module_file_rejects_module_js() {
301 assert!(!is_css_module_file(Path::new("utils.module.js")));
302 }
303
304 #[test]
307 fn extracts_single_class() {
308 let names = export_names(".foo { color: red; }");
309 assert_eq!(names, vec!["foo"]);
310 }
311
312 #[test]
313 fn extracts_multiple_classes() {
314 let names = export_names(".foo { } .bar { }");
315 assert_eq!(names, vec!["foo", "bar"]);
316 }
317
318 #[test]
319 fn extracts_nested_classes() {
320 let names = export_names(".foo .bar { color: red; }");
321 assert!(names.contains(&"foo".to_string()));
322 assert!(names.contains(&"bar".to_string()));
323 }
324
325 #[test]
326 fn extracts_hyphenated_class() {
327 let names = export_names(".my-class { }");
328 assert_eq!(names, vec!["my-class"]);
329 }
330
331 #[test]
332 fn extracts_camel_case_class() {
333 let names = export_names(".myClass { }");
334 assert_eq!(names, vec!["myClass"]);
335 }
336
337 #[test]
338 fn extracts_underscore_class() {
339 let names = export_names("._hidden { } .__wrapper { }");
340 assert!(names.contains(&"_hidden".to_string()));
341 assert!(names.contains(&"__wrapper".to_string()));
342 }
343
344 #[test]
347 fn pseudo_selector_hover() {
348 let names = export_names(".foo:hover { color: blue; }");
349 assert_eq!(names, vec!["foo"]);
350 }
351
352 #[test]
353 fn pseudo_selector_focus() {
354 let names = export_names(".input:focus { outline: none; }");
355 assert_eq!(names, vec!["input"]);
356 }
357
358 #[test]
359 fn pseudo_element_before() {
360 let names = export_names(".icon::before { content: ''; }");
361 assert_eq!(names, vec!["icon"]);
362 }
363
364 #[test]
365 fn combined_pseudo_selectors() {
366 let names = export_names(".btn:hover, .btn:active, .btn:focus { }");
367 assert_eq!(names, vec!["btn"]);
369 }
370
371 #[test]
374 fn classes_inside_media_query() {
375 let names = export_names(
376 "@media (max-width: 768px) { .mobile-nav { display: block; } .desktop-nav { display: none; } }",
377 );
378 assert!(names.contains(&"mobile-nav".to_string()));
379 assert!(names.contains(&"desktop-nav".to_string()));
380 }
381
382 #[test]
385 fn deduplicates_repeated_class() {
386 let names = export_names(".btn { color: red; } .btn { font-size: 14px; }");
387 assert_eq!(names.iter().filter(|n| *n == "btn").count(), 1);
388 }
389
390 #[test]
393 fn empty_source() {
394 let names = export_names("");
395 assert!(names.is_empty());
396 }
397
398 #[test]
399 fn no_classes() {
400 let names = export_names("body { margin: 0; } * { box-sizing: border-box; }");
401 assert!(names.is_empty());
402 }
403
404 #[test]
405 fn ignores_classes_in_block_comments() {
406 let stripped = strip_css_comments("/* .fake { } */ .real { }", false);
411 let names = export_names(&stripped);
412 assert!(!names.contains(&"fake".to_string()));
413 assert!(names.contains(&"real".to_string()));
414 }
415
416 #[test]
417 fn ignores_classes_in_strings() {
418 let names = export_names(r#".real { content: ".fake"; }"#);
419 assert!(names.contains(&"real".to_string()));
420 assert!(!names.contains(&"fake".to_string()));
421 }
422
423 #[test]
424 fn ignores_classes_in_url() {
425 let names = export_names(".real { background: url(./images/hero.png); }");
426 assert!(names.contains(&"real".to_string()));
427 assert!(!names.contains(&"png".to_string()));
429 }
430
431 #[test]
434 fn strip_css_block_comment() {
435 let result = strip_css_comments("/* removed */ .kept { }", false);
436 assert!(!result.contains("removed"));
437 assert!(result.contains(".kept"));
438 }
439
440 #[test]
441 fn strip_scss_line_comment() {
442 let result = strip_css_comments("// removed\n.kept { }", true);
443 assert!(!result.contains("removed"));
444 assert!(result.contains(".kept"));
445 }
446
447 #[test]
448 fn strip_scss_preserves_css_outside_comments() {
449 let source = "// line comment\n/* block comment */\n.visible { color: red; }";
450 let result = strip_css_comments(source, true);
451 assert!(result.contains(".visible"));
452 }
453
454 #[test]
457 fn url_import_http() {
458 assert!(is_css_url_import("http://example.com/style.css"));
459 }
460
461 #[test]
462 fn url_import_https() {
463 assert!(is_css_url_import("https://fonts.googleapis.com/css"));
464 }
465
466 #[test]
467 fn url_import_data() {
468 assert!(is_css_url_import("data:text/css;base64,abc"));
469 }
470
471 #[test]
472 fn url_import_local_not_skipped() {
473 assert!(!is_css_url_import("./local.css"));
474 }
475
476 #[test]
477 fn url_import_bare_specifier_not_skipped() {
478 assert!(!is_css_url_import("tailwindcss"));
479 }
480}