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 span: Span::default(),
125 members: Vec::new(),
126 });
127 }
128 }
129 }
130 exports
131}
132
133pub(crate) fn parse_css_to_module(
135 file_id: FileId,
136 path: &Path,
137 source: &str,
138 content_hash: u64,
139) -> ModuleInfo {
140 let suppressions = crate::suppress::parse_suppressions_from_source(source);
141 let is_scss = path
142 .extension()
143 .and_then(|e| e.to_str())
144 .is_some_and(|ext| ext == "scss");
145
146 let stripped = strip_css_comments(source, is_scss);
148
149 let mut imports = Vec::new();
150
151 for cap in CSS_IMPORT_RE.captures_iter(&stripped) {
153 let source_path = cap
154 .get(1)
155 .or_else(|| cap.get(2))
156 .or_else(|| cap.get(3))
157 .map(|m| m.as_str().trim().to_string());
158 if let Some(src) = source_path
159 && !src.is_empty()
160 && !is_css_url_import(&src)
161 {
162 let src = normalize_css_import_path(src);
165 imports.push(ImportInfo {
166 source: src,
167 imported_name: ImportedName::SideEffect,
168 local_name: String::new(),
169 is_type_only: false,
170 span: Span::default(),
171 });
172 }
173 }
174
175 if is_scss {
177 for cap in SCSS_USE_RE.captures_iter(&stripped) {
178 if let Some(m) = cap.get(1) {
179 imports.push(ImportInfo {
180 source: normalize_css_import_path(m.as_str().to_string()),
181 imported_name: ImportedName::SideEffect,
182 local_name: String::new(),
183 is_type_only: false,
184 span: Span::default(),
185 });
186 }
187 }
188 }
189
190 let has_apply = CSS_APPLY_RE.is_match(&stripped);
193 let has_tailwind = CSS_TAILWIND_RE.is_match(&stripped);
194 if has_apply || has_tailwind {
195 imports.push(ImportInfo {
196 source: "tailwindcss".to_string(),
197 imported_name: ImportedName::SideEffect,
198 local_name: String::new(),
199 is_type_only: false,
200 span: Span::default(),
201 });
202 }
203
204 let exports = if is_css_module_file(path) {
206 extract_css_module_exports(&stripped)
207 } else {
208 Vec::new()
209 };
210
211 ModuleInfo {
212 file_id,
213 exports,
214 imports,
215 re_exports: Vec::new(),
216 dynamic_imports: Vec::new(),
217 dynamic_import_patterns: Vec::new(),
218 require_calls: Vec::new(),
219 member_accesses: Vec::new(),
220 whole_object_uses: Vec::new(),
221 has_cjs_exports: false,
222 content_hash,
223 suppressions,
224 unused_import_bindings: Vec::new(),
225 line_offsets: fallow_types::extract::compute_line_offsets(source),
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 fn export_names(source: &str) -> Vec<String> {
235 extract_css_module_exports(source)
236 .into_iter()
237 .filter_map(|e| match e.name {
238 ExportName::Named(n) => Some(n),
239 _ => None,
240 })
241 .collect()
242 }
243
244 #[test]
247 fn is_css_file_css() {
248 assert!(is_css_file(Path::new("styles.css")));
249 }
250
251 #[test]
252 fn is_css_file_scss() {
253 assert!(is_css_file(Path::new("styles.scss")));
254 }
255
256 #[test]
257 fn is_css_file_rejects_js() {
258 assert!(!is_css_file(Path::new("app.js")));
259 }
260
261 #[test]
262 fn is_css_file_rejects_ts() {
263 assert!(!is_css_file(Path::new("app.ts")));
264 }
265
266 #[test]
267 fn is_css_file_rejects_less() {
268 assert!(!is_css_file(Path::new("styles.less")));
269 }
270
271 #[test]
272 fn is_css_file_rejects_no_extension() {
273 assert!(!is_css_file(Path::new("Makefile")));
274 }
275
276 #[test]
279 fn is_css_module_file_module_css() {
280 assert!(is_css_module_file(Path::new("Component.module.css")));
281 }
282
283 #[test]
284 fn is_css_module_file_module_scss() {
285 assert!(is_css_module_file(Path::new("Component.module.scss")));
286 }
287
288 #[test]
289 fn is_css_module_file_rejects_plain_css() {
290 assert!(!is_css_module_file(Path::new("styles.css")));
291 }
292
293 #[test]
294 fn is_css_module_file_rejects_plain_scss() {
295 assert!(!is_css_module_file(Path::new("styles.scss")));
296 }
297
298 #[test]
299 fn is_css_module_file_rejects_module_js() {
300 assert!(!is_css_module_file(Path::new("utils.module.js")));
301 }
302
303 #[test]
306 fn extracts_single_class() {
307 let names = export_names(".foo { color: red; }");
308 assert_eq!(names, vec!["foo"]);
309 }
310
311 #[test]
312 fn extracts_multiple_classes() {
313 let names = export_names(".foo { } .bar { }");
314 assert_eq!(names, vec!["foo", "bar"]);
315 }
316
317 #[test]
318 fn extracts_nested_classes() {
319 let names = export_names(".foo .bar { color: red; }");
320 assert!(names.contains(&"foo".to_string()));
321 assert!(names.contains(&"bar".to_string()));
322 }
323
324 #[test]
325 fn extracts_hyphenated_class() {
326 let names = export_names(".my-class { }");
327 assert_eq!(names, vec!["my-class"]);
328 }
329
330 #[test]
331 fn extracts_camel_case_class() {
332 let names = export_names(".myClass { }");
333 assert_eq!(names, vec!["myClass"]);
334 }
335
336 #[test]
337 fn extracts_underscore_class() {
338 let names = export_names("._hidden { } .__wrapper { }");
339 assert!(names.contains(&"_hidden".to_string()));
340 assert!(names.contains(&"__wrapper".to_string()));
341 }
342
343 #[test]
346 fn pseudo_selector_hover() {
347 let names = export_names(".foo:hover { color: blue; }");
348 assert_eq!(names, vec!["foo"]);
349 }
350
351 #[test]
352 fn pseudo_selector_focus() {
353 let names = export_names(".input:focus { outline: none; }");
354 assert_eq!(names, vec!["input"]);
355 }
356
357 #[test]
358 fn pseudo_element_before() {
359 let names = export_names(".icon::before { content: ''; }");
360 assert_eq!(names, vec!["icon"]);
361 }
362
363 #[test]
364 fn combined_pseudo_selectors() {
365 let names = export_names(".btn:hover, .btn:active, .btn:focus { }");
366 assert_eq!(names, vec!["btn"]);
368 }
369
370 #[test]
373 fn classes_inside_media_query() {
374 let names = export_names(
375 "@media (max-width: 768px) { .mobile-nav { display: block; } .desktop-nav { display: none; } }",
376 );
377 assert!(names.contains(&"mobile-nav".to_string()));
378 assert!(names.contains(&"desktop-nav".to_string()));
379 }
380
381 #[test]
384 fn deduplicates_repeated_class() {
385 let names = export_names(".btn { color: red; } .btn { font-size: 14px; }");
386 assert_eq!(names.iter().filter(|n| *n == "btn").count(), 1);
387 }
388
389 #[test]
392 fn empty_source() {
393 let names = export_names("");
394 assert!(names.is_empty());
395 }
396
397 #[test]
398 fn no_classes() {
399 let names = export_names("body { margin: 0; } * { box-sizing: border-box; }");
400 assert!(names.is_empty());
401 }
402
403 #[test]
404 fn ignores_classes_in_block_comments() {
405 let stripped = strip_css_comments("/* .fake { } */ .real { }", false);
410 let names = export_names(&stripped);
411 assert!(!names.contains(&"fake".to_string()));
412 assert!(names.contains(&"real".to_string()));
413 }
414
415 #[test]
416 fn ignores_classes_in_strings() {
417 let names = export_names(r#".real { content: ".fake"; }"#);
418 assert!(names.contains(&"real".to_string()));
419 assert!(!names.contains(&"fake".to_string()));
420 }
421
422 #[test]
423 fn ignores_classes_in_url() {
424 let names = export_names(".real { background: url(./images/hero.png); }");
425 assert!(names.contains(&"real".to_string()));
426 assert!(!names.contains(&"png".to_string()));
428 }
429
430 #[test]
433 fn strip_css_block_comment() {
434 let result = strip_css_comments("/* removed */ .kept { }", false);
435 assert!(!result.contains("removed"));
436 assert!(result.contains(".kept"));
437 }
438
439 #[test]
440 fn strip_scss_line_comment() {
441 let result = strip_css_comments("// removed\n.kept { }", true);
442 assert!(!result.contains("removed"));
443 assert!(result.contains(".kept"));
444 }
445
446 #[test]
447 fn strip_scss_preserves_css_outside_comments() {
448 let source = "// line comment\n/* block comment */\n.visible { color: red; }";
449 let result = strip_css_comments(source, true);
450 assert!(result.contains(".visible"));
451 }
452
453 #[test]
456 fn url_import_http() {
457 assert!(is_css_url_import("http://example.com/style.css"));
458 }
459
460 #[test]
461 fn url_import_https() {
462 assert!(is_css_url_import("https://fonts.googleapis.com/css"));
463 }
464
465 #[test]
466 fn url_import_data() {
467 assert!(is_css_url_import("data:text/css;base64,abc"));
468 }
469
470 #[test]
471 fn url_import_local_not_skipped() {
472 assert!(!is_css_url_import("./local.css"));
473 }
474
475 #[test]
476 fn url_import_bare_specifier_not_skipped() {
477 assert!(!is_css_url_import("tailwindcss"));
478 }
479}