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 complexity: Vec::new(),
228 }
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234
235 fn export_names(source: &str) -> Vec<String> {
237 extract_css_module_exports(source)
238 .into_iter()
239 .filter_map(|e| match e.name {
240 ExportName::Named(n) => Some(n),
241 _ => None,
242 })
243 .collect()
244 }
245
246 #[test]
249 fn is_css_file_css() {
250 assert!(is_css_file(Path::new("styles.css")));
251 }
252
253 #[test]
254 fn is_css_file_scss() {
255 assert!(is_css_file(Path::new("styles.scss")));
256 }
257
258 #[test]
259 fn is_css_file_rejects_js() {
260 assert!(!is_css_file(Path::new("app.js")));
261 }
262
263 #[test]
264 fn is_css_file_rejects_ts() {
265 assert!(!is_css_file(Path::new("app.ts")));
266 }
267
268 #[test]
269 fn is_css_file_rejects_less() {
270 assert!(!is_css_file(Path::new("styles.less")));
271 }
272
273 #[test]
274 fn is_css_file_rejects_no_extension() {
275 assert!(!is_css_file(Path::new("Makefile")));
276 }
277
278 #[test]
281 fn is_css_module_file_module_css() {
282 assert!(is_css_module_file(Path::new("Component.module.css")));
283 }
284
285 #[test]
286 fn is_css_module_file_module_scss() {
287 assert!(is_css_module_file(Path::new("Component.module.scss")));
288 }
289
290 #[test]
291 fn is_css_module_file_rejects_plain_css() {
292 assert!(!is_css_module_file(Path::new("styles.css")));
293 }
294
295 #[test]
296 fn is_css_module_file_rejects_plain_scss() {
297 assert!(!is_css_module_file(Path::new("styles.scss")));
298 }
299
300 #[test]
301 fn is_css_module_file_rejects_module_js() {
302 assert!(!is_css_module_file(Path::new("utils.module.js")));
303 }
304
305 #[test]
308 fn extracts_single_class() {
309 let names = export_names(".foo { color: red; }");
310 assert_eq!(names, vec!["foo"]);
311 }
312
313 #[test]
314 fn extracts_multiple_classes() {
315 let names = export_names(".foo { } .bar { }");
316 assert_eq!(names, vec!["foo", "bar"]);
317 }
318
319 #[test]
320 fn extracts_nested_classes() {
321 let names = export_names(".foo .bar { color: red; }");
322 assert!(names.contains(&"foo".to_string()));
323 assert!(names.contains(&"bar".to_string()));
324 }
325
326 #[test]
327 fn extracts_hyphenated_class() {
328 let names = export_names(".my-class { }");
329 assert_eq!(names, vec!["my-class"]);
330 }
331
332 #[test]
333 fn extracts_camel_case_class() {
334 let names = export_names(".myClass { }");
335 assert_eq!(names, vec!["myClass"]);
336 }
337
338 #[test]
339 fn extracts_underscore_class() {
340 let names = export_names("._hidden { } .__wrapper { }");
341 assert!(names.contains(&"_hidden".to_string()));
342 assert!(names.contains(&"__wrapper".to_string()));
343 }
344
345 #[test]
348 fn pseudo_selector_hover() {
349 let names = export_names(".foo:hover { color: blue; }");
350 assert_eq!(names, vec!["foo"]);
351 }
352
353 #[test]
354 fn pseudo_selector_focus() {
355 let names = export_names(".input:focus { outline: none; }");
356 assert_eq!(names, vec!["input"]);
357 }
358
359 #[test]
360 fn pseudo_element_before() {
361 let names = export_names(".icon::before { content: ''; }");
362 assert_eq!(names, vec!["icon"]);
363 }
364
365 #[test]
366 fn combined_pseudo_selectors() {
367 let names = export_names(".btn:hover, .btn:active, .btn:focus { }");
368 assert_eq!(names, vec!["btn"]);
370 }
371
372 #[test]
375 fn classes_inside_media_query() {
376 let names = export_names(
377 "@media (max-width: 768px) { .mobile-nav { display: block; } .desktop-nav { display: none; } }",
378 );
379 assert!(names.contains(&"mobile-nav".to_string()));
380 assert!(names.contains(&"desktop-nav".to_string()));
381 }
382
383 #[test]
386 fn deduplicates_repeated_class() {
387 let names = export_names(".btn { color: red; } .btn { font-size: 14px; }");
388 assert_eq!(names.iter().filter(|n| *n == "btn").count(), 1);
389 }
390
391 #[test]
394 fn empty_source() {
395 let names = export_names("");
396 assert!(names.is_empty());
397 }
398
399 #[test]
400 fn no_classes() {
401 let names = export_names("body { margin: 0; } * { box-sizing: border-box; }");
402 assert!(names.is_empty());
403 }
404
405 #[test]
406 fn ignores_classes_in_block_comments() {
407 let stripped = strip_css_comments("/* .fake { } */ .real { }", false);
412 let names = export_names(&stripped);
413 assert!(!names.contains(&"fake".to_string()));
414 assert!(names.contains(&"real".to_string()));
415 }
416
417 #[test]
418 fn ignores_classes_in_strings() {
419 let names = export_names(r#".real { content: ".fake"; }"#);
420 assert!(names.contains(&"real".to_string()));
421 assert!(!names.contains(&"fake".to_string()));
422 }
423
424 #[test]
425 fn ignores_classes_in_url() {
426 let names = export_names(".real { background: url(./images/hero.png); }");
427 assert!(names.contains(&"real".to_string()));
428 assert!(!names.contains(&"png".to_string()));
430 }
431
432 #[test]
435 fn strip_css_block_comment() {
436 let result = strip_css_comments("/* removed */ .kept { }", false);
437 assert!(!result.contains("removed"));
438 assert!(result.contains(".kept"));
439 }
440
441 #[test]
442 fn strip_scss_line_comment() {
443 let result = strip_css_comments("// removed\n.kept { }", true);
444 assert!(!result.contains("removed"));
445 assert!(result.contains(".kept"));
446 }
447
448 #[test]
449 fn strip_scss_preserves_css_outside_comments() {
450 let source = "// line comment\n/* block comment */\n.visible { color: red; }";
451 let result = strip_css_comments(source, true);
452 assert!(result.contains(".visible"));
453 }
454
455 #[test]
458 fn url_import_http() {
459 assert!(is_css_url_import("http://example.com/style.css"));
460 }
461
462 #[test]
463 fn url_import_https() {
464 assert!(is_css_url_import("https://fonts.googleapis.com/css"));
465 }
466
467 #[test]
468 fn url_import_data() {
469 assert!(is_css_url_import("data:text/css;base64,abc"));
470 }
471
472 #[test]
473 fn url_import_local_not_skipped() {
474 assert!(!is_css_url_import("./local.css"));
475 }
476
477 #[test]
478 fn url_import_bare_specifier_not_skipped() {
479 assert!(!is_css_url_import("tailwindcss"));
480 }
481}