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