1use crate::ast_parser::AstParser;
7use crate::error::{Result, TailwindError};
8use std::collections::{HashMap, HashSet};
9use std::fs;
10use std::path::{Path, PathBuf};
11
12#[derive(Debug, Clone)]
14pub struct ScanConfig {
15 pub extensions: Vec<String>,
17 pub include_dirs: Vec<PathBuf>,
19 pub exclude_dirs: Vec<PathBuf>,
21 pub exclude_patterns: Vec<String>,
23 pub max_file_size: Option<usize>,
25 pub follow_symlinks: bool,
27}
28
29impl Default for ScanConfig {
30 fn default() -> Self {
31 Self {
32 extensions: vec!["rs".to_string()],
33 include_dirs: vec![],
34 exclude_dirs: vec!["target".to_string().into(), ".git".to_string().into()],
35 exclude_patterns: vec!["*_test.rs".to_string(), "*_tests.rs".to_string()],
36 max_file_size: Some(10 * 1024 * 1024), follow_symlinks: false,
38 }
39 }
40}
41
42#[derive(Debug, Clone)]
44pub struct ScanResults {
45 pub classes: HashSet<String>,
47 pub responsive_classes: HashMap<String, HashSet<String>>,
49 pub conditional_classes: HashMap<String, HashSet<String>>,
51 pub classes_by_file: HashMap<PathBuf, HashSet<String>>,
53 pub stats: ScanStats,
55}
56
57#[derive(Debug, Clone)]
59pub struct ScanStats {
60 pub files_scanned: usize,
62 pub files_skipped: usize,
64 pub total_classes: usize,
66 pub unique_classes: usize,
68 pub duration_ms: u64,
70 pub total_file_size: u64,
72}
73
74#[derive(Debug, Clone)]
76pub struct ClassScanner {
77 config: ScanConfig,
78 parser: AstParser,
79}
80
81impl ClassScanner {
82 pub fn new() -> Self {
84 Self {
85 config: ScanConfig::default(),
86 parser: AstParser::new(),
87 }
88 }
89
90 pub fn with_config(config: ScanConfig) -> Self {
92 Self {
93 config,
94 parser: AstParser::new(),
95 }
96 }
97
98 pub fn scan_directory(&mut self, path: &Path) -> Result<ScanResults> {
100 let start_time = std::time::Instant::now();
101 let mut stats = ScanStats {
102 files_scanned: 0,
103 files_skipped: 0,
104 total_classes: 0,
105 unique_classes: 0,
106 duration_ms: 0,
107 total_file_size: 0,
108 };
109
110 let mut classes_by_file = HashMap::new();
111
112 let files = self.find_files_to_scan(path)?;
114
115 for file_path in files {
116 if let Some(max_size) = self.config.max_file_size {
118 if let Ok(metadata) = fs::metadata(&file_path) {
119 if metadata.len() > max_size as u64 {
120 stats.files_skipped += 1;
121 continue;
122 }
123 stats.total_file_size += metadata.len();
124 }
125 }
126
127 match self.parser.parse_file(&file_path) {
129 Ok(()) => {
130 stats.files_scanned += 1;
131
132 let file_classes: HashSet<String> = self.parser.get_classes().clone();
134 if !file_classes.is_empty() {
135 classes_by_file.insert(file_path, file_classes);
136 }
137 }
138 Err(e) => {
139 eprintln!("Warning: Failed to parse file {:?}: {}", file_path, e);
140 stats.files_skipped += 1;
141 }
142 }
143 }
144
145 let classes = self.parser.get_classes().clone();
147 let responsive_classes = self.parser.get_all_responsive_classes().clone();
148 let conditional_classes = self.parser.get_all_conditional_classes().clone();
149
150 stats.total_classes = classes.len();
151 stats.unique_classes = classes.len();
152 stats.duration_ms = start_time.elapsed().as_millis() as u64;
153
154 Ok(ScanResults {
155 classes,
156 responsive_classes,
157 conditional_classes,
158 classes_by_file,
159 stats,
160 })
161 }
162
163 pub fn scan_files(&mut self, files: &[PathBuf]) -> Result<ScanResults> {
165 let start_time = std::time::Instant::now();
166 let mut stats = ScanStats {
167 files_scanned: 0,
168 files_skipped: 0,
169 total_classes: 0,
170 unique_classes: 0,
171 duration_ms: 0,
172 total_file_size: 0,
173 };
174
175 let mut classes_by_file = HashMap::new();
176
177 for file_path in files {
178 if !self.should_scan_file(file_path) {
180 stats.files_skipped += 1;
181 continue;
182 }
183
184 if let Some(max_size) = self.config.max_file_size {
186 if let Ok(metadata) = fs::metadata(file_path) {
187 if metadata.len() > max_size as u64 {
188 stats.files_skipped += 1;
189 continue;
190 }
191 stats.total_file_size += metadata.len();
192 }
193 }
194
195 match self.parser.parse_file(file_path) {
197 Ok(()) => {
198 stats.files_scanned += 1;
199
200 let file_classes: HashSet<String> = self.parser.get_classes().clone();
202 if !file_classes.is_empty() {
203 classes_by_file.insert(file_path.clone(), file_classes);
204 }
205 }
206 Err(e) => {
207 eprintln!("Warning: Failed to parse file {:?}: {}", file_path, e);
208 stats.files_skipped += 1;
209 }
210 }
211 }
212
213 let classes = self.parser.get_classes().clone();
215 let responsive_classes = self.parser.get_all_responsive_classes().clone();
216 let conditional_classes = self.parser.get_all_conditional_classes().clone();
217
218 stats.total_classes = classes.len();
219 stats.unique_classes = classes.len();
220 stats.duration_ms = start_time.elapsed().as_millis() as u64;
221
222 Ok(ScanResults {
223 classes,
224 responsive_classes,
225 conditional_classes,
226 classes_by_file,
227 stats,
228 })
229 }
230
231 pub fn get_config(&self) -> &ScanConfig {
233 &self.config
234 }
235
236 pub fn set_config(&mut self, config: ScanConfig) {
238 self.config = config;
239 }
240
241 pub fn clear(&mut self) {
243 self.parser.clear();
244 }
245
246 fn find_files_to_scan(&self, path: &Path) -> Result<Vec<PathBuf>> {
248 let mut files = Vec::new();
249
250 if path.is_file() {
251 if self.should_scan_file(path) {
252 files.push(path.to_path_buf());
253 }
254 } else if path.is_dir() {
255 self.scan_directory_recursive(path, &mut files)?;
256 } else {
257 return Err(TailwindError::build(format!(
258 "Path {:?} is neither a file nor a directory",
259 path
260 )));
261 }
262
263 Ok(files)
264 }
265
266 fn scan_directory_recursive(&self, dir: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
268 let entries = fs::read_dir(dir).map_err(|e| {
269 TailwindError::build(format!("Failed to read directory {:?}: {}", dir, e))
270 })?;
271
272 for entry in entries {
273 let entry = entry.map_err(|e| {
274 TailwindError::build(format!("Failed to read directory entry: {}", e))
275 })?;
276 let path = entry.path();
277
278 if path.is_dir() {
280 if self.should_exclude_directory(&path) {
281 continue;
282 }
283 self.scan_directory_recursive(&path, files)?;
284 } else if path.is_file()
285 && self.should_scan_file(&path) {
286 files.push(path);
287 }
288 }
289
290 Ok(())
291 }
292
293 fn should_scan_file(&self, path: &Path) -> bool {
295 if let Some(extension) = path.extension() {
297 if let Some(ext_str) = extension.to_str() {
298 if !self.config.extensions.contains(&ext_str.to_string()) {
299 return false;
300 }
301 } else {
302 return false;
303 }
304 } else {
305 return false;
306 }
307
308 if let Some(file_name) = path.file_name() {
310 if let Some(name_str) = file_name.to_str() {
311 for pattern in &self.config.exclude_patterns {
312 if self.matches_pattern(name_str, pattern) {
313 return false;
314 }
315 }
316 }
317 }
318
319 true
320 }
321
322 fn should_exclude_directory(&self, path: &Path) -> bool {
324 if let Some(dir_name) = path.file_name() {
325 if let Some(name_str) = dir_name.to_str() {
326 for exclude_dir in &self.config.exclude_dirs {
327 if let Some(exclude_name) = exclude_dir.file_name() {
328 if let Some(exclude_str) = exclude_name.to_str() {
329 if name_str == exclude_str {
330 return true;
331 }
332 }
333 }
334 }
335 }
336 }
337 false
338 }
339
340 fn matches_pattern(&self, text: &str, pattern: &str) -> bool {
342 if pattern.contains('*') {
343 let parts: Vec<&str> = pattern.split('*').collect();
344 if parts.len() == 2 {
345 let prefix = parts[0];
346 let suffix = parts[1];
347 text.starts_with(prefix) && text.ends_with(suffix)
348 } else {
349 false
350 }
351 } else {
352 text == pattern
353 }
354 }
355}
356
357impl Default for ClassScanner {
358 fn default() -> Self {
359 Self::new()
360 }
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366 use std::fs;
367
368 #[test]
369 fn test_scanner_creation() {
370 let scanner = ClassScanner::new();
371 assert_eq!(scanner.get_config().extensions, vec!["rs"]);
372 }
373
374 #[test]
375 fn test_custom_config() {
376 let config = ScanConfig {
377 extensions: vec!["rs".to_string(), "toml".to_string()],
378 include_dirs: vec![],
379 exclude_dirs: vec![],
380 exclude_patterns: vec![],
381 max_file_size: Some(1024),
382 follow_symlinks: true,
383 };
384
385 let scanner = ClassScanner::with_config(config);
386 assert_eq!(scanner.get_config().extensions.len(), 2);
387 assert_eq!(scanner.get_config().max_file_size, Some(1024));
388 }
389
390 #[test]
391 fn test_scan_single_file() {
392 let mut scanner = ClassScanner::new();
393 let temp_file = std::env::temp_dir().join("test_scan.rs");
394
395 let content = r#"
396 use tailwind_rs_core::ClassBuilder;
397
398 fn test() -> String {
399 ClassBuilder::new()
400 .class("px-4")
401 .class("py-2")
402 .class("bg-blue-500")
403 .build_string()
404 }
405 "#;
406
407 fs::write(&temp_file, content).unwrap();
408
409 let results = scanner.scan_files(&[temp_file.clone()]).unwrap();
410
411 assert_eq!(results.stats.files_scanned, 1);
416 assert_eq!(results.stats.files_skipped, 0);
417
418 fs::remove_file(&temp_file).unwrap();
420 }
421
422 #[test]
423 fn test_scan_directory() {
424 let mut scanner = ClassScanner::new();
425 let temp_dir = std::env::temp_dir().join("test_scan_dir");
426
427 fs::create_dir_all(&temp_dir).unwrap();
429
430 let file1 = temp_dir.join("file1.rs");
431 let file2 = temp_dir.join("file2.rs");
432 let ignored = temp_dir.join("ignored_test.rs");
433
434 fs::write(&file1, r#"ClassBuilder::new().class("p-4").build_string()"#).unwrap();
435 fs::write(&file2, r#"ClassBuilder::new().class("m-2").build_string()"#).unwrap();
436 fs::write(
437 &ignored,
438 r#"ClassBuilder::new().class("ignored").build_string()"#,
439 )
440 .unwrap();
441
442 let results = scanner.scan_directory(&temp_dir).unwrap();
443
444 assert!(!results.classes.contains("ignored")); assert_eq!(results.stats.files_scanned, 2);
450 fs::remove_dir_all(&temp_dir).unwrap();
455 }
456
457 #[test]
458 fn test_clear() {
459 let mut scanner = ClassScanner::new();
460 let temp_file = std::env::temp_dir().join("test_clear.rs");
461
462 let content = r#"ClassBuilder::new().class("test-class").build_string()"#;
463 fs::write(&temp_file, content).unwrap();
464
465 scanner.scan_files(&[temp_file.clone()]).unwrap();
466 scanner.clear();
470 assert!(scanner.parser.get_classes().is_empty());
471
472 fs::remove_file(&temp_file).unwrap();
474 }
475
476 #[test]
477 fn test_pattern_matching() {
478 let scanner = ClassScanner::new();
479
480 assert!(scanner.matches_pattern("my_test.rs", "*_test.rs"));
481 assert!(scanner.matches_pattern("my_tests.rs", "*_tests.rs"));
482 assert!(!scanner.matches_pattern("normal_file.rs", "*_test.rs"));
483 assert!(scanner.matches_pattern("exact.rs", "exact.rs"));
484 }
485}