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 if self.should_scan_file(&path) {
286 files.push(path);
287 }
288 }
289 }
290
291 Ok(())
292 }
293
294 fn should_scan_file(&self, path: &Path) -> bool {
296 if let Some(extension) = path.extension() {
298 if let Some(ext_str) = extension.to_str() {
299 if !self.config.extensions.contains(&ext_str.to_string()) {
300 return false;
301 }
302 } else {
303 return false;
304 }
305 } else {
306 return false;
307 }
308
309 if let Some(file_name) = path.file_name() {
311 if let Some(name_str) = file_name.to_str() {
312 for pattern in &self.config.exclude_patterns {
313 if self.matches_pattern(name_str, pattern) {
314 return false;
315 }
316 }
317 }
318 }
319
320 true
321 }
322
323 fn should_exclude_directory(&self, path: &Path) -> bool {
325 if let Some(dir_name) = path.file_name() {
326 if let Some(name_str) = dir_name.to_str() {
327 for exclude_dir in &self.config.exclude_dirs {
328 if let Some(exclude_name) = exclude_dir.file_name() {
329 if let Some(exclude_str) = exclude_name.to_str() {
330 if name_str == exclude_str {
331 return true;
332 }
333 }
334 }
335 }
336 }
337 }
338 false
339 }
340
341 fn matches_pattern(&self, text: &str, pattern: &str) -> bool {
343 if pattern.contains('*') {
344 let parts: Vec<&str> = pattern.split('*').collect();
345 if parts.len() == 2 {
346 let prefix = parts[0];
347 let suffix = parts[1];
348 text.starts_with(prefix) && text.ends_with(suffix)
349 } else {
350 false
351 }
352 } else {
353 text == pattern
354 }
355 }
356}
357
358impl Default for ClassScanner {
359 fn default() -> Self {
360 Self::new()
361 }
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367 use std::fs;
368
369 #[test]
370 fn test_scanner_creation() {
371 let scanner = ClassScanner::new();
372 assert_eq!(scanner.get_config().extensions, vec!["rs"]);
373 }
374
375 #[test]
376 fn test_custom_config() {
377 let config = ScanConfig {
378 extensions: vec!["rs".to_string(), "toml".to_string()],
379 include_dirs: vec![],
380 exclude_dirs: vec![],
381 exclude_patterns: vec![],
382 max_file_size: Some(1024),
383 follow_symlinks: true,
384 };
385
386 let scanner = ClassScanner::with_config(config);
387 assert_eq!(scanner.get_config().extensions.len(), 2);
388 assert_eq!(scanner.get_config().max_file_size, Some(1024));
389 }
390
391 #[test]
392 fn test_scan_single_file() {
393 let mut scanner = ClassScanner::new();
394 let temp_file = std::env::temp_dir().join("test_scan.rs");
395
396 let content = r#"
397 use tailwind_rs_core::ClassBuilder;
398
399 fn test() -> String {
400 ClassBuilder::new()
401 .class("px-4")
402 .class("py-2")
403 .class("bg-blue-500")
404 .build_string()
405 }
406 "#;
407
408 fs::write(&temp_file, content).unwrap();
409
410 let results = scanner.scan_files(&[temp_file.clone()]).unwrap();
411
412 assert_eq!(results.stats.files_scanned, 1);
417 assert_eq!(results.stats.files_skipped, 0);
418
419 fs::remove_file(&temp_file).unwrap();
421 }
422
423 #[test]
424 fn test_scan_directory() {
425 let mut scanner = ClassScanner::new();
426 let temp_dir = std::env::temp_dir().join("test_scan_dir");
427
428 fs::create_dir_all(&temp_dir).unwrap();
430
431 let file1 = temp_dir.join("file1.rs");
432 let file2 = temp_dir.join("file2.rs");
433 let ignored = temp_dir.join("ignored_test.rs");
434
435 fs::write(&file1, r#"ClassBuilder::new().class("p-4").build_string()"#).unwrap();
436 fs::write(&file2, r#"ClassBuilder::new().class("m-2").build_string()"#).unwrap();
437 fs::write(
438 &ignored,
439 r#"ClassBuilder::new().class("ignored").build_string()"#,
440 )
441 .unwrap();
442
443 let results = scanner.scan_directory(&temp_dir).unwrap();
444
445 assert!(!results.classes.contains("ignored")); assert_eq!(results.stats.files_scanned, 2);
451 fs::remove_dir_all(&temp_dir).unwrap();
456 }
457
458 #[test]
459 fn test_clear() {
460 let mut scanner = ClassScanner::new();
461 let temp_file = std::env::temp_dir().join("test_clear.rs");
462
463 let content = r#"ClassBuilder::new().class("test-class").build_string()"#;
464 fs::write(&temp_file, content).unwrap();
465
466 scanner.scan_files(&[temp_file.clone()]).unwrap();
467 scanner.clear();
471 assert!(scanner.parser.get_classes().is_empty());
472
473 fs::remove_file(&temp_file).unwrap();
475 }
476
477 #[test]
478 fn test_pattern_matching() {
479 let scanner = ClassScanner::new();
480
481 assert!(scanner.matches_pattern("my_test.rs", "*_test.rs"));
482 assert!(scanner.matches_pattern("my_tests.rs", "*_tests.rs"));
483 assert!(!scanner.matches_pattern("normal_file.rs", "*_test.rs"));
484 assert!(scanner.matches_pattern("exact.rs", "exact.rs"));
485 }
486}