1#![allow(clippy::must_use_candidate)]
16#![allow(clippy::missing_panics_doc)]
17#![allow(clippy::missing_errors_doc)]
18#![allow(clippy::module_name_repetitions)]
19#![allow(clippy::missing_const_for_fn)]
20#![allow(clippy::uninlined_format_args)]
21#![allow(clippy::cast_possible_truncation)]
22#![allow(clippy::struct_excessive_bools)]
23#![allow(clippy::manual_let_else)]
24#![allow(clippy::unused_self)]
25#![allow(clippy::format_push_string)]
26
27use serde::{Deserialize, Serialize};
28use std::path::{Path, PathBuf};
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "lowercase")]
33pub enum LintSeverity {
34 Error,
36 Warning,
38 Info,
40}
41
42impl LintSeverity {
43 #[must_use]
45 pub const fn as_str(&self) -> &'static str {
46 match self {
47 Self::Error => "ERROR",
48 Self::Warning => "WARN",
49 Self::Info => "INFO",
50 }
51 }
52
53 #[must_use]
55 pub const fn symbol(&self) -> &'static str {
56 match self {
57 Self::Error => "✗",
58 Self::Warning => "⚠",
59 Self::Info => "ℹ",
60 }
61 }
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct LintResult {
67 pub file: PathBuf,
69 pub line: Option<u32>,
71 pub column: Option<u32>,
73 pub severity: LintSeverity,
75 pub code: String,
77 pub message: String,
79 pub suggestion: Option<String>,
81}
82
83impl LintResult {
84 pub fn error(
86 file: impl Into<PathBuf>,
87 code: impl Into<String>,
88 message: impl Into<String>,
89 ) -> Self {
90 Self {
91 file: file.into(),
92 line: None,
93 column: None,
94 severity: LintSeverity::Error,
95 code: code.into(),
96 message: message.into(),
97 suggestion: None,
98 }
99 }
100
101 pub fn warning(
103 file: impl Into<PathBuf>,
104 code: impl Into<String>,
105 message: impl Into<String>,
106 ) -> Self {
107 Self {
108 file: file.into(),
109 line: None,
110 column: None,
111 severity: LintSeverity::Warning,
112 code: code.into(),
113 message: message.into(),
114 suggestion: None,
115 }
116 }
117
118 pub fn info(
120 file: impl Into<PathBuf>,
121 code: impl Into<String>,
122 message: impl Into<String>,
123 ) -> Self {
124 Self {
125 file: file.into(),
126 line: None,
127 column: None,
128 severity: LintSeverity::Info,
129 code: code.into(),
130 message: message.into(),
131 suggestion: None,
132 }
133 }
134
135 #[must_use]
137 pub fn at_line(mut self, line: u32) -> Self {
138 self.line = Some(line);
139 self
140 }
141
142 #[must_use]
144 pub fn at_column(mut self, column: u32) -> Self {
145 self.column = Some(column);
146 self
147 }
148
149 #[must_use]
151 pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
152 self.suggestion = Some(suggestion.into());
153 self
154 }
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct LintReport {
160 pub root: PathBuf,
162 pub results: Vec<LintResult>,
164 pub errors: usize,
166 pub warnings: usize,
168 pub infos: usize,
170 pub files_checked: usize,
172}
173
174impl LintReport {
175 pub fn new(root: impl Into<PathBuf>) -> Self {
177 Self {
178 root: root.into(),
179 results: Vec::new(),
180 errors: 0,
181 warnings: 0,
182 infos: 0,
183 files_checked: 0,
184 }
185 }
186
187 pub fn add(&mut self, result: LintResult) {
189 match result.severity {
190 LintSeverity::Error => self.errors += 1,
191 LintSeverity::Warning => self.warnings += 1,
192 LintSeverity::Info => self.infos += 1,
193 }
194 self.results.push(result);
195 }
196
197 #[must_use]
199 pub fn has_errors(&self) -> bool {
200 self.errors > 0
201 }
202
203 #[must_use]
205 pub fn passed(&self) -> bool {
206 !self.has_errors()
207 }
208}
209
210#[derive(Debug)]
212pub struct ContentLinter {
213 root: PathBuf,
215 pub lint_html: bool,
217 pub lint_css: bool,
219 pub lint_js: bool,
221 pub lint_wasm: bool,
223 pub lint_json: bool,
225}
226
227impl ContentLinter {
228 pub fn new(root: impl Into<PathBuf>) -> Self {
230 Self {
231 root: root.into(),
232 lint_html: true,
233 lint_css: true,
234 lint_js: true,
235 lint_wasm: true,
236 lint_json: true,
237 }
238 }
239
240 pub fn lint(&self) -> LintReport {
242 let mut report = LintReport::new(&self.root);
243 self.lint_directory(&self.root, &mut report);
244 report
245 }
246
247 pub fn lint_file(&self, path: &Path) -> Vec<LintResult> {
249 let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("");
250
251 match extension {
252 "html" | "htm" if self.lint_html => self.lint_html_file(path),
253 "css" if self.lint_css => self.lint_css_file(path),
254 "js" | "mjs" if self.lint_js => self.lint_js_file(path),
255 "wasm" if self.lint_wasm => self.lint_wasm_file(path),
256 "json" if self.lint_json => self.lint_json_file(path),
257 _ => Vec::new(),
258 }
259 }
260
261 fn lint_directory(&self, dir: &Path, report: &mut LintReport) {
262 let entries = match std::fs::read_dir(dir) {
263 Ok(e) => e,
264 Err(_) => return,
265 };
266
267 for entry in entries.flatten() {
268 let path = entry.path();
269
270 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
272 if name.starts_with('.') || name == "node_modules" || name == "target" {
273 continue;
274 }
275
276 if path.is_dir() {
277 self.lint_directory(&path, report);
278 } else {
279 let results = self.lint_file(&path);
280 if !results.is_empty() {
281 report.files_checked += 1;
282 for result in results {
283 report.add(result);
284 }
285 } else if self.is_lintable(&path) {
286 report.files_checked += 1;
287 }
288 }
289 }
290 }
291
292 fn is_lintable(&self, path: &Path) -> bool {
293 let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("");
294 matches!(
295 extension,
296 "html" | "htm" | "css" | "js" | "mjs" | "wasm" | "json"
297 )
298 }
299
300 fn lint_html_file(&self, path: &Path) -> Vec<LintResult> {
301 let mut results = Vec::new();
302
303 let content = match std::fs::read_to_string(path) {
304 Ok(c) => c,
305 Err(e) => {
306 results.push(LintResult::error(
307 path,
308 "HTML000",
309 format!("Cannot read file: {e}"),
310 ));
311 return results;
312 }
313 };
314
315 if !content
317 .trim_start()
318 .to_lowercase()
319 .starts_with("<!doctype html")
320 {
321 results.push(
322 LintResult::warning(path, "HTML001", "Missing <!DOCTYPE html> declaration")
323 .at_line(1)
324 .with_suggestion("Add <!DOCTYPE html> at the start of the file"),
325 );
326 }
327
328 let content_lower = content.to_lowercase();
330 if !content_lower.contains("<html") {
331 results.push(LintResult::error(path, "HTML002", "Missing <html> element"));
332 }
333 if !content_lower.contains("<head") {
334 results.push(LintResult::warning(
335 path,
336 "HTML003",
337 "Missing <head> element",
338 ));
339 }
340 if !content_lower.contains("<body") {
341 results.push(LintResult::warning(
342 path,
343 "HTML004",
344 "Missing <body> element",
345 ));
346 }
347
348 let open_divs = content_lower.matches("<div").count();
350 let close_divs = content_lower.matches("</div>").count();
351 if open_divs != close_divs {
352 results.push(LintResult::warning(
353 path,
354 "HTML005",
355 format!(
356 "Mismatched <div> tags: {} open, {} close",
357 open_divs, close_divs
358 ),
359 ));
360 }
361
362 for (line_num, line) in content.lines().enumerate() {
364 let line_lower = line.to_lowercase();
365 if line_lower.contains("<img") && !line_lower.contains("alt=") {
366 results.push(
367 LintResult::warning(path, "HTML006", "<img> tag missing alt attribute")
368 .at_line((line_num + 1) as u32)
369 .with_suggestion("Add alt attribute for accessibility"),
370 );
371 }
372 }
373
374 results
375 }
376
377 fn lint_css_file(&self, path: &Path) -> Vec<LintResult> {
378 let mut results = Vec::new();
379
380 let content = match std::fs::read_to_string(path) {
381 Ok(c) => c,
382 Err(e) => {
383 results.push(LintResult::error(
384 path,
385 "CSS000",
386 format!("Cannot read file: {e}"),
387 ));
388 return results;
389 }
390 };
391
392 let open_braces = content.matches('{').count();
394 let close_braces = content.matches('}').count();
395 if open_braces != close_braces {
396 results.push(LintResult::error(
397 path,
398 "CSS001",
399 format!(
400 "Mismatched braces: {} open, {} close",
401 open_braces, close_braces
402 ),
403 ));
404 }
405
406 for (line_num, line) in content.lines().enumerate() {
408 let trimmed = line.trim();
409
410 if trimmed.starts_with("-webkit-") && !trimmed.starts_with("-webkit-") {
412 let prop = trimmed.split(':').next().unwrap_or("");
413 let standard = prop.trim_start_matches("-webkit-");
414 results.push(
415 LintResult::info(path, "CSS002", format!("Vendor prefix {} used", prop))
416 .at_line((line_num + 1) as u32)
417 .with_suggestion(format!("Also include standard property: {}", standard)),
418 );
419 }
420
421 if trimmed == "{}" {
423 results.push(
424 LintResult::warning(path, "CSS003", "Empty CSS rule")
425 .at_line((line_num + 1) as u32),
426 );
427 }
428 }
429
430 results
431 }
432
433 fn lint_js_file(&self, path: &Path) -> Vec<LintResult> {
434 let mut results = Vec::new();
435
436 let content = match std::fs::read_to_string(path) {
437 Ok(c) => c,
438 Err(e) => {
439 results.push(LintResult::error(
440 path,
441 "JS000",
442 format!("Cannot read file: {e}"),
443 ));
444 return results;
445 }
446 };
447
448 let open_braces = content.matches('{').count();
450 let close_braces = content.matches('}').count();
451 if open_braces != close_braces {
452 results.push(LintResult::error(
453 path,
454 "JS001",
455 format!(
456 "Mismatched braces: {} open, {} close",
457 open_braces, close_braces
458 ),
459 ));
460 }
461
462 let open_parens = content.matches('(').count();
463 let close_parens = content.matches(')').count();
464 if open_parens != close_parens {
465 results.push(LintResult::error(
466 path,
467 "JS002",
468 format!(
469 "Mismatched parentheses: {} open, {} close",
470 open_parens, close_parens
471 ),
472 ));
473 }
474
475 for (line_num, line) in content.lines().enumerate() {
477 if line.contains("console.log") {
479 results.push(
480 LintResult::info(path, "JS003", "console.log found")
481 .at_line((line_num + 1) as u32)
482 .with_suggestion("Remove console.log before production"),
483 );
484 }
485
486 if line.trim().starts_with("debugger") {
488 results.push(
489 LintResult::warning(path, "JS004", "debugger statement found")
490 .at_line((line_num + 1) as u32)
491 .with_suggestion("Remove debugger statements before production"),
492 );
493 }
494 }
495
496 results
497 }
498
499 fn lint_wasm_file(&self, path: &Path) -> Vec<LintResult> {
500 let mut results = Vec::new();
501
502 let content = match std::fs::read(path) {
503 Ok(c) => c,
504 Err(e) => {
505 results.push(LintResult::error(
506 path,
507 "WASM000",
508 format!("Cannot read file: {e}"),
509 ));
510 return results;
511 }
512 };
513
514 if content.len() < 8 {
516 results.push(LintResult::error(
517 path,
518 "WASM001",
519 "File too small to be valid WASM",
520 ));
521 return results;
522 }
523
524 if content[0..4] != [0x00, 0x61, 0x73, 0x6D] {
526 results.push(
527 LintResult::error(path, "WASM002", "Invalid WASM magic number")
528 .with_suggestion("File does not appear to be a valid WebAssembly module"),
529 );
530 }
531
532 if content[4..8] != [0x01, 0x00, 0x00, 0x00] {
534 let version = u32::from_le_bytes([content[4], content[5], content[6], content[7]]);
535 results.push(LintResult::warning(
536 path,
537 "WASM003",
538 format!("Unexpected WASM version: {}", version),
539 ));
540 }
541
542 results
543 }
544
545 fn lint_json_file(&self, path: &Path) -> Vec<LintResult> {
546 let mut results = Vec::new();
547
548 let content = match std::fs::read_to_string(path) {
549 Ok(c) => c,
550 Err(e) => {
551 results.push(LintResult::error(
552 path,
553 "JSON000",
554 format!("Cannot read file: {e}"),
555 ));
556 return results;
557 }
558 };
559
560 if let Err(e) = serde_json::from_str::<serde_json::Value>(&content) {
562 let line = e.line();
563 let column = e.column();
564 results.push(
565 LintResult::error(path, "JSON001", format!("Invalid JSON: {}", e))
566 .at_line(line as u32)
567 .at_column(column as u32),
568 );
569 }
570
571 results
572 }
573}
574
575pub fn render_lint_report(report: &LintReport) -> String {
577 let mut output = String::new();
578
579 output.push_str(&format!("LINT REPORT: {}\n", report.root.display()));
580 output.push_str("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n");
581
582 if report.results.is_empty() {
583 output.push_str("✓ All files passed linting\n");
584 } else {
585 let mut by_file: std::collections::HashMap<&Path, Vec<&LintResult>> =
587 std::collections::HashMap::new();
588 for result in &report.results {
589 by_file.entry(&result.file).or_default().push(result);
590 }
591
592 for (file, results) in by_file {
593 let relative = file.strip_prefix(&report.root).unwrap_or(file);
594 output.push_str(&format!("{}:\n", relative.display()));
595
596 for result in results {
597 let location = match (result.line, result.column) {
598 (Some(l), Some(c)) => format!("Line {}:{}", l, c),
599 (Some(l), None) => format!("Line {}", l),
600 _ => String::new(),
601 };
602
603 output.push_str(&format!(
604 " {} {} [{}] {}\n",
605 result.severity.symbol(),
606 location,
607 result.code,
608 result.message
609 ));
610
611 if let Some(ref suggestion) = result.suggestion {
612 output.push_str(&format!(" Suggestion: {}\n", suggestion));
613 }
614 }
615 output.push('\n');
616 }
617 }
618
619 output.push_str("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
620 output.push_str(&format!(
621 "Summary: {} errors, {} warnings, {} files checked\n",
622 report.errors, report.warnings, report.files_checked
623 ));
624
625 output
626}
627
628pub fn render_lint_json(report: &LintReport) -> Result<String, serde_json::Error> {
630 serde_json::to_string_pretty(report)
631}
632
633#[cfg(test)]
634#[allow(clippy::unwrap_used, clippy::expect_used)]
635mod tests {
636 use super::*;
637 use tempfile::TempDir;
638
639 #[test]
640 fn test_lint_severity_as_str() {
641 assert_eq!(LintSeverity::Error.as_str(), "ERROR");
642 assert_eq!(LintSeverity::Warning.as_str(), "WARN");
643 assert_eq!(LintSeverity::Info.as_str(), "INFO");
644 }
645
646 #[test]
647 fn test_lint_severity_symbol() {
648 assert_eq!(LintSeverity::Error.symbol(), "✗");
649 assert_eq!(LintSeverity::Warning.symbol(), "⚠");
650 assert_eq!(LintSeverity::Info.symbol(), "ℹ");
651 }
652
653 #[test]
654 fn test_lint_result_builder() {
655 let result = LintResult::error("test.html", "HTML001", "Test error")
656 .at_line(10)
657 .at_column(5)
658 .with_suggestion("Fix it");
659
660 assert_eq!(result.file, PathBuf::from("test.html"));
661 assert_eq!(result.code, "HTML001");
662 assert_eq!(result.line, Some(10));
663 assert_eq!(result.column, Some(5));
664 assert_eq!(result.suggestion, Some("Fix it".to_string()));
665 }
666
667 #[test]
668 fn test_lint_report_add() {
669 let mut report = LintReport::new("./");
670 report.add(LintResult::error("a.html", "E001", "error"));
671 report.add(LintResult::warning("b.css", "W001", "warning"));
672 report.add(LintResult::info("c.js", "I001", "info"));
673
674 assert_eq!(report.errors, 1);
675 assert_eq!(report.warnings, 1);
676 assert_eq!(report.infos, 1);
677 assert!(report.has_errors());
678 assert!(!report.passed());
679 }
680
681 #[test]
682 fn test_lint_report_no_errors() {
683 let mut report = LintReport::new("./");
684 report.add(LintResult::warning("a.css", "W001", "warning"));
685
686 assert!(!report.has_errors());
687 assert!(report.passed());
688 }
689
690 #[test]
691 fn test_lint_html_missing_doctype() {
692 let temp = TempDir::new().unwrap();
693 let html_path = temp.path().join("test.html");
694 std::fs::write(&html_path, "<html><head></head><body></body></html>").unwrap();
695
696 let linter = ContentLinter::new(temp.path());
697 let results = linter.lint_file(&html_path);
698
699 assert!(results.iter().any(|r| r.code == "HTML001"));
700 }
701
702 #[test]
703 fn test_lint_html_valid() {
704 let temp = TempDir::new().unwrap();
705 let html_path = temp.path().join("test.html");
706 std::fs::write(
707 &html_path,
708 "<!DOCTYPE html><html><head></head><body></body></html>",
709 )
710 .unwrap();
711
712 let linter = ContentLinter::new(temp.path());
713 let results = linter.lint_file(&html_path);
714
715 assert!(results.iter().all(|r| r.severity != LintSeverity::Error));
716 }
717
718 #[test]
719 fn test_lint_html_missing_alt() {
720 let temp = TempDir::new().unwrap();
721 let html_path = temp.path().join("test.html");
722 std::fs::write(
723 &html_path,
724 "<!DOCTYPE html><html><head></head><body><img src=\"test.png\"></body></html>",
725 )
726 .unwrap();
727
728 let linter = ContentLinter::new(temp.path());
729 let results = linter.lint_file(&html_path);
730
731 assert!(results.iter().any(|r| r.code == "HTML006"));
732 }
733
734 #[test]
735 fn test_lint_css_mismatched_braces() {
736 let temp = TempDir::new().unwrap();
737 let css_path = temp.path().join("test.css");
738 std::fs::write(&css_path, "body { color: red;").unwrap();
739
740 let linter = ContentLinter::new(temp.path());
741 let results = linter.lint_file(&css_path);
742
743 assert!(results.iter().any(|r| r.code == "CSS001"));
744 }
745
746 #[test]
747 fn test_lint_js_debugger() {
748 let temp = TempDir::new().unwrap();
749 let js_path = temp.path().join("test.js");
750 std::fs::write(&js_path, "function test() {\n debugger;\n}").unwrap();
751
752 let linter = ContentLinter::new(temp.path());
753 let results = linter.lint_file(&js_path);
754
755 assert!(results.iter().any(|r| r.code == "JS004"));
756 }
757
758 #[test]
759 fn test_lint_json_invalid() {
760 let temp = TempDir::new().unwrap();
761 let json_path = temp.path().join("test.json");
762 std::fs::write(&json_path, "{invalid json}").unwrap();
763
764 let linter = ContentLinter::new(temp.path());
765 let results = linter.lint_file(&json_path);
766
767 assert!(results.iter().any(|r| r.code == "JSON001"));
768 }
769
770 #[test]
771 fn test_lint_json_valid() {
772 let temp = TempDir::new().unwrap();
773 let json_path = temp.path().join("test.json");
774 std::fs::write(&json_path, r#"{"key": "value"}"#).unwrap();
775
776 let linter = ContentLinter::new(temp.path());
777 let results = linter.lint_file(&json_path);
778
779 assert!(results.is_empty());
780 }
781
782 #[test]
783 fn test_lint_wasm_invalid_magic() {
784 let temp = TempDir::new().unwrap();
785 let wasm_path = temp.path().join("test.wasm");
786 std::fs::write(&wasm_path, b"not wasm data here").unwrap();
787
788 let linter = ContentLinter::new(temp.path());
789 let results = linter.lint_file(&wasm_path);
790
791 assert!(results.iter().any(|r| r.code == "WASM002"));
792 }
793
794 #[test]
795 fn test_lint_wasm_valid() {
796 let temp = TempDir::new().unwrap();
797 let wasm_path = temp.path().join("test.wasm");
798 std::fs::write(&wasm_path, [0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00]).unwrap();
800
801 let linter = ContentLinter::new(temp.path());
802 let results = linter.lint_file(&wasm_path);
803
804 assert!(results.is_empty());
805 }
806
807 #[test]
808 fn test_render_lint_report() {
809 let mut report = LintReport::new("./test");
810 report.files_checked = 3;
811 report.add(LintResult::error("test.html", "HTML001", "Missing DOCTYPE"));
812
813 let output = render_lint_report(&report);
814
815 assert!(output.contains("LINT REPORT"));
816 assert!(output.contains("HTML001"));
817 assert!(output.contains("1 errors"));
818 }
819
820 #[test]
821 fn test_render_lint_json() {
822 let report = LintReport::new("./test");
823 let json = render_lint_json(&report).unwrap();
824
825 assert!(json.contains("\"root\""));
826 assert!(json.contains("\"results\""));
827 }
828
829 #[test]
830 fn test_lint_directory_full() {
831 let temp = TempDir::new().unwrap();
832
833 std::fs::write(
835 temp.path().join("index.html"),
836 "<!DOCTYPE html><html><head></head><body></body></html>",
837 )
838 .unwrap();
839 std::fs::write(temp.path().join("style.css"), "body { color: red; }").unwrap();
840 std::fs::write(temp.path().join("app.js"), "function test() {}").unwrap();
841 std::fs::write(temp.path().join("data.json"), r#"{"key": "value"}"#).unwrap();
842
843 let linter = ContentLinter::new(temp.path());
844 let report = linter.lint();
845
846 assert!(report.files_checked >= 4);
848 assert!(report.passed());
850 }
851
852 #[test]
853 fn test_lint_directory_with_errors() {
854 let temp = TempDir::new().unwrap();
855
856 std::fs::write(temp.path().join("bad.html"), "<html>no doctype</html>").unwrap();
858 std::fs::write(temp.path().join("bad.css"), "body { color: red").unwrap(); std::fs::write(temp.path().join("bad.json"), "{invalid}").unwrap();
860
861 let linter = ContentLinter::new(temp.path());
862 let report = linter.lint();
863
864 assert!(report.errors > 0);
865 assert!(!report.passed());
866 }
867
868 #[test]
869 fn test_lint_directory_nested() {
870 let temp = TempDir::new().unwrap();
871
872 let subdir = temp.path().join("subdir");
874 std::fs::create_dir(&subdir).unwrap();
875 std::fs::write(
876 subdir.join("nested.html"),
877 "<!DOCTYPE html><html><head></head><body></body></html>",
878 )
879 .unwrap();
880
881 let linter = ContentLinter::new(temp.path());
882 let report = linter.lint();
883
884 assert!(report.files_checked >= 1);
886 }
887
888 #[test]
889 fn test_lint_directory_skips_hidden() {
890 let temp = TempDir::new().unwrap();
891
892 let hidden = temp.path().join(".hidden");
894 std::fs::create_dir(&hidden).unwrap();
895 std::fs::write(hidden.join("test.html"), "<html>bad</html>").unwrap();
896
897 std::fs::write(
899 temp.path().join("visible.html"),
900 "<!DOCTYPE html><html><head></head><body></body></html>",
901 )
902 .unwrap();
903
904 let linter = ContentLinter::new(temp.path());
905 let report = linter.lint();
906
907 assert_eq!(report.files_checked, 1);
909 }
910
911 #[test]
912 fn test_lint_directory_skips_node_modules() {
913 let temp = TempDir::new().unwrap();
914
915 let node_modules = temp.path().join("node_modules");
917 std::fs::create_dir(&node_modules).unwrap();
918 std::fs::write(node_modules.join("lib.js"), "console.log('test');").unwrap();
919
920 let linter = ContentLinter::new(temp.path());
921 let report = linter.lint();
922
923 assert_eq!(report.files_checked, 0);
925 }
926
927 #[test]
928 fn test_lint_file_unknown_extension() {
929 let temp = TempDir::new().unwrap();
930 let txt_path = temp.path().join("test.txt");
931 std::fs::write(&txt_path, "Just some text").unwrap();
932
933 let linter = ContentLinter::new(temp.path());
934 let results = linter.lint_file(&txt_path);
935
936 assert!(results.is_empty());
938 }
939
940 #[test]
941 fn test_lint_file_mjs_extension() {
942 let temp = TempDir::new().unwrap();
943 let mjs_path = temp.path().join("test.mjs");
944 std::fs::write(&mjs_path, "export function test() {}").unwrap();
945
946 let linter = ContentLinter::new(temp.path());
947 let results = linter.lint_file(&mjs_path);
948
949 assert!(results.is_empty()); }
952
953 #[test]
954 fn test_lint_disabled_html() {
955 let temp = TempDir::new().unwrap();
956 let html_path = temp.path().join("test.html");
957 std::fs::write(&html_path, "<html>no doctype</html>").unwrap();
958
959 let mut linter = ContentLinter::new(temp.path());
960 linter.lint_html = false;
961
962 let results = linter.lint_file(&html_path);
963 assert!(results.is_empty()); }
965
966 #[test]
967 fn test_lint_disabled_css() {
968 let temp = TempDir::new().unwrap();
969 let css_path = temp.path().join("test.css");
970 std::fs::write(&css_path, "body { color: red").unwrap(); let mut linter = ContentLinter::new(temp.path());
973 linter.lint_css = false;
974
975 let results = linter.lint_file(&css_path);
976 assert!(results.is_empty()); }
978
979 #[test]
980 fn test_lint_disabled_js() {
981 let temp = TempDir::new().unwrap();
982 let js_path = temp.path().join("test.js");
983 std::fs::write(&js_path, "console.log('debug');").unwrap();
984
985 let mut linter = ContentLinter::new(temp.path());
986 linter.lint_js = false;
987
988 let results = linter.lint_file(&js_path);
989 assert!(results.is_empty()); }
991
992 #[test]
993 fn test_lint_disabled_wasm() {
994 let temp = TempDir::new().unwrap();
995 let wasm_path = temp.path().join("test.wasm");
996 std::fs::write(&wasm_path, b"not valid wasm").unwrap();
997
998 let mut linter = ContentLinter::new(temp.path());
999 linter.lint_wasm = false;
1000
1001 let results = linter.lint_file(&wasm_path);
1002 assert!(results.is_empty()); }
1004
1005 #[test]
1006 fn test_lint_disabled_json() {
1007 let temp = TempDir::new().unwrap();
1008 let json_path = temp.path().join("test.json");
1009 std::fs::write(&json_path, "{invalid}").unwrap();
1010
1011 let mut linter = ContentLinter::new(temp.path());
1012 linter.lint_json = false;
1013
1014 let results = linter.lint_file(&json_path);
1015 assert!(results.is_empty()); }
1017
1018 #[test]
1019 fn test_lint_wasm_too_small() {
1020 let temp = TempDir::new().unwrap();
1021 let wasm_path = temp.path().join("tiny.wasm");
1022 std::fs::write(&wasm_path, b"tiny").unwrap();
1023
1024 let linter = ContentLinter::new(temp.path());
1025 let results = linter.lint_file(&wasm_path);
1026
1027 assert!(results.iter().any(|r| r.code == "WASM001"));
1028 }
1029
1030 #[test]
1031 fn test_lint_wasm_wrong_version() {
1032 let temp = TempDir::new().unwrap();
1033 let wasm_path = temp.path().join("oldversion.wasm");
1034 std::fs::write(&wasm_path, [0x00, 0x61, 0x73, 0x6D, 0x02, 0x00, 0x00, 0x00]).unwrap();
1036
1037 let linter = ContentLinter::new(temp.path());
1038 let results = linter.lint_file(&wasm_path);
1039
1040 assert!(results.iter().any(|r| r.code == "WASM003"));
1041 }
1042
1043 #[test]
1044 fn test_lint_html_missing_html_tag() {
1045 let temp = TempDir::new().unwrap();
1046 let html_path = temp.path().join("test.html");
1047 std::fs::write(&html_path, "<!DOCTYPE html><head></head><body></body>").unwrap();
1048
1049 let linter = ContentLinter::new(temp.path());
1050 let results = linter.lint_file(&html_path);
1051
1052 assert!(results.iter().any(|r| r.code == "HTML002"));
1053 }
1054
1055 #[test]
1056 fn test_lint_html_missing_head() {
1057 let temp = TempDir::new().unwrap();
1058 let html_path = temp.path().join("test.html");
1059 std::fs::write(&html_path, "<!DOCTYPE html><html><body></body></html>").unwrap();
1060
1061 let linter = ContentLinter::new(temp.path());
1062 let results = linter.lint_file(&html_path);
1063
1064 assert!(results.iter().any(|r| r.code == "HTML003"));
1065 }
1066
1067 #[test]
1068 fn test_lint_html_missing_body() {
1069 let temp = TempDir::new().unwrap();
1070 let html_path = temp.path().join("test.html");
1071 std::fs::write(&html_path, "<!DOCTYPE html><html><head></head></html>").unwrap();
1072
1073 let linter = ContentLinter::new(temp.path());
1074 let results = linter.lint_file(&html_path);
1075
1076 assert!(results.iter().any(|r| r.code == "HTML004"));
1077 }
1078
1079 #[test]
1080 fn test_lint_html_mismatched_divs() {
1081 let temp = TempDir::new().unwrap();
1082 let html_path = temp.path().join("test.html");
1083 std::fs::write(
1084 &html_path,
1085 "<!DOCTYPE html><html><head></head><body><div><div></div></body></html>",
1086 )
1087 .unwrap();
1088
1089 let linter = ContentLinter::new(temp.path());
1090 let results = linter.lint_file(&html_path);
1091
1092 assert!(results.iter().any(|r| r.code == "HTML005"));
1093 }
1094
1095 #[test]
1096 fn test_lint_css_empty_rule() {
1097 let temp = TempDir::new().unwrap();
1098 let css_path = temp.path().join("test.css");
1099 std::fs::write(&css_path, "body\n{}\n.empty\n{}").unwrap();
1101
1102 let linter = ContentLinter::new(temp.path());
1103 let results = linter.lint_file(&css_path);
1104
1105 assert!(results.iter().any(|r| r.code == "CSS003"));
1106 }
1107
1108 #[test]
1109 fn test_lint_js_console_log() {
1110 let temp = TempDir::new().unwrap();
1111 let js_path = temp.path().join("test.js");
1112 std::fs::write(&js_path, "console.log('debugging');").unwrap();
1113
1114 let linter = ContentLinter::new(temp.path());
1115 let results = linter.lint_file(&js_path);
1116
1117 assert!(results.iter().any(|r| r.code == "JS003"));
1118 }
1119
1120 #[test]
1121 fn test_lint_js_mismatched_braces() {
1122 let temp = TempDir::new().unwrap();
1123 let js_path = temp.path().join("test.js");
1124 std::fs::write(&js_path, "function test() { return 1;").unwrap();
1125
1126 let linter = ContentLinter::new(temp.path());
1127 let results = linter.lint_file(&js_path);
1128
1129 assert!(results.iter().any(|r| r.code == "JS001"));
1130 }
1131
1132 #[test]
1133 fn test_lint_js_mismatched_parens() {
1134 let temp = TempDir::new().unwrap();
1135 let js_path = temp.path().join("test.js");
1136 std::fs::write(&js_path, "function test( { return 1; }").unwrap();
1137
1138 let linter = ContentLinter::new(temp.path());
1139 let results = linter.lint_file(&js_path);
1140
1141 assert!(results.iter().any(|r| r.code == "JS002"));
1142 }
1143
1144 #[test]
1145 fn test_is_lintable() {
1146 let temp = TempDir::new().unwrap();
1147 let linter = ContentLinter::new(temp.path());
1148
1149 assert!(linter.is_lintable(Path::new("test.html")));
1150 assert!(linter.is_lintable(Path::new("test.htm")));
1151 assert!(linter.is_lintable(Path::new("test.css")));
1152 assert!(linter.is_lintable(Path::new("test.js")));
1153 assert!(linter.is_lintable(Path::new("test.mjs")));
1154 assert!(linter.is_lintable(Path::new("test.wasm")));
1155 assert!(linter.is_lintable(Path::new("test.json")));
1156 assert!(!linter.is_lintable(Path::new("test.txt")));
1157 assert!(!linter.is_lintable(Path::new("test.rs")));
1158 }
1159
1160 #[test]
1161 fn test_render_report_with_location() {
1162 let mut report = LintReport::new("./test");
1163 report.add(
1164 LintResult::error("test.html", "HTML001", "Test")
1165 .at_line(5)
1166 .at_column(10),
1167 );
1168
1169 let output = render_lint_report(&report);
1170 assert!(output.contains("Line 5:10"));
1171 }
1172
1173 #[test]
1174 fn test_render_report_with_line_only() {
1175 let mut report = LintReport::new("./test");
1176 report.add(LintResult::warning("test.css", "CSS001", "Test").at_line(3));
1177
1178 let output = render_lint_report(&report);
1179 assert!(output.contains("Line 3"));
1180 }
1181
1182 #[test]
1183 fn test_render_report_empty() {
1184 let report = LintReport::new("./test");
1185 let output = render_lint_report(&report);
1186
1187 assert!(output.contains("All files passed linting"));
1188 }
1189
1190 #[test]
1191 fn test_render_report_with_suggestion() {
1192 let mut report = LintReport::new("./test");
1193 report.add(
1194 LintResult::info("test.js", "JS001", "Found issue")
1195 .with_suggestion("Try fixing it this way"),
1196 );
1197
1198 let output = render_lint_report(&report);
1199 assert!(output.contains("Suggestion: Try fixing it this way"));
1200 }
1201
1202 #[test]
1203 fn test_lint_file_read_error() {
1204 let linter = ContentLinter::new("/tmp");
1205
1206 let results = linter.lint_file(Path::new("/nonexistent/test.html"));
1208 assert!(results.iter().any(|r| r.code == "HTML000"));
1209
1210 let results = linter.lint_file(Path::new("/nonexistent/test.css"));
1211 assert!(results.iter().any(|r| r.code == "CSS000"));
1212
1213 let results = linter.lint_file(Path::new("/nonexistent/test.js"));
1214 assert!(results.iter().any(|r| r.code == "JS000"));
1215
1216 let results = linter.lint_file(Path::new("/nonexistent/test.wasm"));
1217 assert!(results.iter().any(|r| r.code == "WASM000"));
1218
1219 let results = linter.lint_file(Path::new("/nonexistent/test.json"));
1220 assert!(results.iter().any(|r| r.code == "JSON000"));
1221 }
1222}