1use std::collections::{HashMap, HashSet};
29use std::path::Path;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum LintSeverity {
34 Error,
36 Warning,
38 Info,
40}
41
42impl std::fmt::Display for LintSeverity {
43 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44 match self {
45 Self::Error => write!(f, "error"),
46 Self::Warning => write!(f, "warning"),
47 Self::Info => write!(f, "info"),
48 }
49 }
50}
51
52#[derive(Debug, Clone)]
54pub struct LintError {
55 pub rule: String,
57 pub message: String,
59 pub file: String,
61 pub line: usize,
63 pub column: usize,
65 pub severity: LintSeverity,
67 pub suggestion: Option<String>,
69}
70
71impl std::fmt::Display for LintError {
72 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73 write!(
74 f,
75 "{}[{}]: {} ({}:{}:{})",
76 self.severity, self.rule, self.message, self.file, self.line, self.column
77 )?;
78 if let Some(suggestion) = &self.suggestion {
79 write!(f, "\n = help: {suggestion}")?;
80 }
81 Ok(())
82 }
83}
84
85pub type LintResult = Result<StateSyncReport, String>;
87
88#[derive(Debug, Default)]
90pub struct StateSyncReport {
91 pub errors: Vec<LintError>,
93 pub files_analyzed: usize,
95 pub lines_analyzed: usize,
97}
98
99impl StateSyncReport {
100 #[must_use]
102 pub fn has_errors(&self) -> bool {
103 self.errors
104 .iter()
105 .any(|e| e.severity == LintSeverity::Error)
106 }
107
108 #[must_use]
110 pub fn error_count(&self) -> usize {
111 self.errors
112 .iter()
113 .filter(|e| e.severity == LintSeverity::Error)
114 .count()
115 }
116
117 #[must_use]
119 pub fn warning_count(&self) -> usize {
120 self.errors
121 .iter()
122 .filter(|e| e.severity == LintSeverity::Warning)
123 .count()
124 }
125
126 pub fn merge(&mut self, other: Self) {
128 self.errors.extend(other.errors);
129 self.files_analyzed += other.files_analyzed;
130 self.lines_analyzed += other.lines_analyzed;
131 }
132}
133
134#[derive(Debug)]
148pub struct StateSyncLinter {
149 local_rcs: HashMap<String, Vec<(String, usize)>>,
151 closure_captures: HashSet<String>,
153 current_file: String,
155 closure_creators: HashSet<String>,
157 rc_type_aliases: HashSet<String>,
159 rc_returning_functions: HashSet<String>,
161}
162
163impl Default for StateSyncLinter {
164 fn default() -> Self {
165 Self::new()
166 }
167}
168
169impl StateSyncLinter {
170 #[must_use]
172 pub fn new() -> Self {
173 let mut closure_creators = HashSet::new();
174 closure_creators.insert("Closure::wrap".to_string());
176 closure_creators.insert("Closure::once".to_string());
177 closure_creators.insert("move ||".to_string());
178 closure_creators.insert("move |".to_string());
179
180 Self {
181 local_rcs: HashMap::new(),
182 closure_captures: HashSet::new(),
183 current_file: String::new(),
184 closure_creators,
185 rc_type_aliases: HashSet::new(),
186 rc_returning_functions: HashSet::new(),
187 }
188 }
189
190 pub fn lint_file(&mut self, path: &Path) -> LintResult {
192 let content = std::fs::read_to_string(path)
193 .map_err(|e| format!("Failed to read {}: {e}", path.display()))?;
194
195 self.current_file = path.display().to_string();
196 self.lint_source(&content)
197 }
198
199 pub fn lint_source(&mut self, source: &str) -> LintResult {
205 if let Ok(ast_report) = super::ast_visitor::lint_source_ast(source, &self.current_file) {
207 let mut report = ast_report;
209 if let Ok(text_report) = self.lint_source_text_based(source) {
210 for error in text_report.errors {
212 if !report.errors.iter().any(|e| {
213 e.rule == error.rule && e.line == error.line && e.file == error.file
214 }) {
215 report.errors.push(error);
216 }
217 }
218 }
219 return Ok(report);
220 }
221
222 self.lint_source_text_based(source)
224 }
225
226 fn lint_source_text_based(&mut self, source: &str) -> LintResult {
228 let mut report = StateSyncReport {
229 files_analyzed: 1,
230 lines_analyzed: source.lines().count(),
231 ..Default::default()
232 };
233
234 self.local_rcs.clear();
236 self.closure_captures.clear();
237 self.rc_type_aliases.clear();
238 self.rc_returning_functions.clear();
239
240 self.collect_type_info(source, &mut report);
242
243 let fns_with_closures = self.find_functions_with_closures(source);
245
246 let mut current_fn: Option<String> = None;
248 let mut fn_has_closure = false;
249 let mut brace_depth = 0;
250 let mut fn_start_depth = 0;
251
252 for (line_num, line) in source.lines().enumerate() {
253 let line_num = line_num + 1; brace_depth += line.matches('{').count();
257 brace_depth = brace_depth.saturating_sub(line.matches('}').count());
258
259 if let Some(fn_name) = self.detect_function_start(line) {
261 current_fn = Some(fn_name);
262 fn_start_depth = brace_depth;
263 fn_has_closure = false;
264 self.local_rcs.clear();
265 }
266
267 if current_fn.is_some() && brace_depth < fn_start_depth {
269 current_fn = None;
270 }
271
272 if self.line_creates_closure(line) {
274 fn_has_closure = true;
275 }
276
277 if let Some(var_name) = self.detect_local_rc_new(line) {
279 let fn_name = current_fn
280 .clone()
281 .unwrap_or_else(|| "<unknown>".to_string());
282 self.local_rcs
283 .entry(fn_name.clone())
284 .or_default()
285 .push((var_name.clone(), line_num));
286
287 let fn_has_any_closure = fn_has_closure
289 || fns_with_closures.contains(&fn_name)
290 || self.function_likely_creates_closure(&fn_name);
291 if fn_has_any_closure {
292 report.errors.push(LintError {
293 rule: "WASM-SS-001".to_string(),
294 message: format!(
295 "Local `{var_name}` creates new Rc - if captured by closure, \
296 it will be disconnected from self"
297 ),
298 file: self.current_file.clone(),
299 line: line_num,
300 column: line.find(&var_name).unwrap_or(0) + 1,
301 severity: LintSeverity::Error,
302 suggestion: Some(format!(
303 "Use `let {var_name}_clone = self.{var_name}.clone()` instead"
304 )),
305 });
306 }
307 }
308
309 if let Some((alias_name, var_name)) = self.detect_type_alias_new(line) {
311 if fn_has_closure
312 || self.function_likely_creates_closure(
313 current_fn.as_deref().unwrap_or("<unknown>"),
314 )
315 {
316 report.errors.push(LintError {
317 rule: "WASM-SS-006".to_string(),
318 message: format!(
319 "Type alias `{alias_name}::new()` creates local Rc - \
320 may cause state desync if captured in closure"
321 ),
322 file: self.current_file.clone(),
323 line: line_num,
324 column: line.find(&var_name).unwrap_or(0) + 1,
325 severity: LintSeverity::Warning,
326 suggestion: Some(format!(
327 "Use `self.{var_name}.clone()` instead of `{alias_name}::new()`"
328 )),
329 });
330 }
331 }
332
333 if let Some((fn_name_called, var_name)) = self.detect_rc_function_call(line) {
335 if fn_has_closure
336 || self.function_likely_creates_closure(
337 current_fn.as_deref().unwrap_or("<unknown>"),
338 )
339 {
340 report.errors.push(LintError {
341 rule: "WASM-SS-007".to_string(),
342 message: format!(
343 "Function `{fn_name_called}()` returns Rc - \
344 local assignment may cause state desync in closure"
345 ),
346 file: self.current_file.clone(),
347 line: line_num,
348 column: line.find(&var_name).unwrap_or(0) + 1,
349 severity: LintSeverity::Warning,
350 suggestion: Some(
351 "Clone from self instead of calling helper function".to_string(),
352 ),
353 });
354 }
355 }
356
357 if self.line_creates_closure(line) {
359 self.check_closure_captures(line, line_num, source, &mut report);
361 }
362
363 if fn_has_closure && current_fn.is_some() {
365 self.check_missing_self_clone(line, line_num, &mut report);
366 }
367 }
368
369 Ok(report)
370 }
371
372 fn collect_type_info(&mut self, source: &str, report: &mut StateSyncReport) {
374 for (line_num, line) in source.lines().enumerate() {
375 let line_num = line_num + 1;
376 let trimmed = line.trim();
377
378 if trimmed.starts_with("type ") && trimmed.contains("Rc<") {
380 if let Some(alias_name) = self.extract_type_alias_name(trimmed) {
381 self.rc_type_aliases.insert(alias_name.clone());
382 report.errors.push(LintError {
383 rule: "WASM-SS-006".to_string(),
384 message: format!(
385 "Type alias `{alias_name}` wraps Rc - usage with ::new() may cause state desync"
386 ),
387 file: self.current_file.clone(),
388 line: line_num,
389 column: 1,
390 severity: LintSeverity::Info,
391 suggestion: Some("Consider using self.field.clone() pattern instead".to_string()),
392 });
393 }
394 }
395
396 if trimmed.contains("fn ") && trimmed.contains("-> Rc<") {
398 if let Some(fn_name) = self.detect_function_start(trimmed) {
399 self.rc_returning_functions.insert(fn_name.clone());
400 report.errors.push(LintError {
401 rule: "WASM-SS-007".to_string(),
402 message: format!(
403 "Function `{fn_name}` returns Rc - callers may create disconnected state"
404 ),
405 file: self.current_file.clone(),
406 line: line_num,
407 column: 1,
408 severity: LintSeverity::Info,
409 suggestion: Some("Document that callers should use self.field.clone() instead".to_string()),
410 });
411 }
412 }
413 }
414 }
415
416 fn extract_type_alias_name(&self, line: &str) -> Option<String> {
418 let trimmed = line.trim();
420 if !trimmed.starts_with("type ") {
421 return None;
422 }
423 let after_type = &trimmed[5..];
424 let name_end = after_type
425 .find(|c: char| !c.is_alphanumeric() && c != '_')
426 .unwrap_or(after_type.len());
427 let name = &after_type[..name_end];
428 if !name.is_empty() {
429 Some(name.to_string())
430 } else {
431 None
432 }
433 }
434
435 fn detect_type_alias_new(&self, line: &str) -> Option<(String, String)> {
437 let trimmed = line.trim();
438
439 for alias in &self.rc_type_aliases {
441 let pattern = format!("{alias}::new(");
442 if trimmed.contains(&pattern) {
443 if let Some(after_let) = trimmed.strip_prefix("let ") {
445 let after_mut = after_let.strip_prefix("mut ").unwrap_or(after_let);
446 let name_end = after_mut
447 .find(|c: char| !c.is_alphanumeric() && c != '_')
448 .unwrap_or(after_mut.len());
449 let var_name = &after_mut[..name_end];
450 if !var_name.is_empty() {
451 return Some((alias.clone(), var_name.to_string()));
452 }
453 }
454 }
455 }
456 None
457 }
458
459 fn detect_rc_function_call(&self, line: &str) -> Option<(String, String)> {
461 let trimmed = line.trim();
462
463 for fn_name in &self.rc_returning_functions {
465 let patterns = [
467 format!("Self::{fn_name}("),
468 format!("self.{fn_name}("),
469 format!("{fn_name}("), ];
471
472 for pattern in &patterns {
473 if trimmed.contains(pattern) {
474 if let Some(after_let) = trimmed.strip_prefix("let ") {
476 let after_mut = after_let.strip_prefix("mut ").unwrap_or(after_let);
477 let name_end = after_mut
478 .find(|c: char| !c.is_alphanumeric() && c != '_')
479 .unwrap_or(after_mut.len());
480 let var_name = &after_mut[..name_end];
481 if !var_name.is_empty() {
482 return Some((fn_name.clone(), var_name.to_string()));
483 }
484 }
485 }
486 }
487 }
488 None
489 }
490
491 fn detect_function_start(&self, line: &str) -> Option<String> {
493 let trimmed = line.trim();
494
495 if trimmed.contains("fn ")
497 && (trimmed.starts_with("fn ")
498 || trimmed.starts_with("pub fn ")
499 || trimmed.starts_with("pub(crate) fn ")
500 || trimmed.starts_with("async fn ")
501 || trimmed.starts_with("pub async fn "))
502 {
503 if let Some(fn_pos) = trimmed.find("fn ") {
505 let after_fn = &trimmed[fn_pos + 3..];
506 let name_end = after_fn
507 .find(|c: char| !c.is_alphanumeric() && c != '_')
508 .unwrap_or(after_fn.len());
509 let name = &after_fn[..name_end];
510 if !name.is_empty() {
511 return Some(name.to_string());
512 }
513 }
514 }
515 None
516 }
517
518 fn line_creates_closure(&self, line: &str) -> bool {
520 let trimmed = line.trim();
521 for pattern in &self.closure_creators {
522 if trimmed.contains(pattern.as_str()) {
523 return true;
524 }
525 }
526 false
527 }
528
529 fn find_functions_with_closures(&self, source: &str) -> HashSet<String> {
531 let mut result = HashSet::new();
532 let mut current_fn: Option<String> = None;
533 let mut brace_depth = 0;
534 let mut fn_start_depth = 0;
535
536 for line in source.lines() {
537 brace_depth += line.matches('{').count();
538 brace_depth = brace_depth.saturating_sub(line.matches('}').count());
539
540 if let Some(fn_name) = self.detect_function_start(line) {
541 current_fn = Some(fn_name);
542 fn_start_depth = brace_depth;
543 }
544
545 if current_fn.is_some() && brace_depth < fn_start_depth {
546 current_fn = None;
547 }
548
549 if self.line_creates_closure(line) {
550 if let Some(ref fn_name) = current_fn {
551 result.insert(fn_name.clone());
552 }
553 }
554 }
555
556 result
557 }
558
559 fn detect_local_rc_new(&self, line: &str) -> Option<String> {
561 let trimmed = line.trim();
562
563 if let Some(after_let) = trimmed.strip_prefix("let ") {
566 if trimmed.contains("Rc::new(") {
567 let after_mut = after_let.strip_prefix("mut ").unwrap_or(after_let);
569
570 let name_end = after_mut
571 .find(|c: char| !c.is_alphanumeric() && c != '_')
572 .unwrap_or(after_mut.len());
573 let name = &after_mut[..name_end];
574
575 if !line.contains(".clone()") && !name.is_empty() {
578 return Some(name.to_string());
579 }
580 }
581 }
582 None
583 }
584
585 fn function_likely_creates_closure(&self, fn_name: &str) -> bool {
587 let closure_fn_names = [
589 "spawn",
590 "start",
591 "on_message",
592 "on_click",
593 "on_event",
594 "set_callback",
595 "register",
596 "subscribe",
597 "listen",
598 ];
599 closure_fn_names.iter().any(|&n| fn_name.contains(n))
600 }
601
602 fn check_closure_captures(
604 &self,
605 _line: &str,
606 line_num: usize,
607 source: &str,
608 report: &mut StateSyncReport,
609 ) {
610 let lines: Vec<&str> = source.lines().collect();
612 let start = line_num.saturating_sub(10);
613 let end = (line_num + 10).min(lines.len());
614
615 let context = &lines[start..end];
616
617 for line in context {
619 if line.contains("let ") && line.contains("Rc::new(") && !line.contains(".clone()") {
620 continue;
622 }
623
624 if line.contains("self.state") && line.contains("state_ptr") {
626 report.errors.push(LintError {
628 rule: "WASM-SS-002".to_string(),
629 message: "Potential state desync: both `self.state` and local \
630 `state_ptr` reference exist"
631 .to_string(),
632 file: self.current_file.clone(),
633 line: line_num,
634 column: 1,
635 severity: LintSeverity::Warning,
636 suggestion: Some(
637 "Ensure closure uses `self.state_ptr.clone()`, not a local Rc".to_string(),
638 ),
639 });
640 }
641 }
642 }
643
644 fn check_missing_self_clone(&self, line: &str, line_num: usize, report: &mut StateSyncReport) {
646 if self.line_creates_closure(line)
648 && line.contains("state_ptr")
649 && !line.contains("state_ptr_clone")
650 {
651 report.errors.push(LintError {
652 rule: "WASM-SS-005".to_string(),
653 message: "Closure may capture local state - ensure \
654 `self.state_ptr.clone()` is used"
655 .to_string(),
656 file: self.current_file.clone(),
657 line: line_num,
658 column: 1,
659 severity: LintSeverity::Warning,
660 suggestion: Some(
661 "Add `let state_ptr_clone = self.state_ptr.clone();` before closure"
662 .to_string(),
663 ),
664 });
665 return;
666 }
667
668 if line.contains("state_ptr.borrow") && !line.contains("self.") && !line.contains("_clone")
671 {
672 report.errors.push(LintError {
673 rule: "WASM-SS-005".to_string(),
674 message: "Using `state_ptr` directly - may be disconnected from self".to_string(),
675 file: self.current_file.clone(),
676 line: line_num,
677 column: 1,
678 severity: LintSeverity::Warning,
679 suggestion: Some(
680 "Use `let state_ptr_clone = self.state_ptr.clone();` before closure"
681 .to_string(),
682 ),
683 });
684 }
685 }
686
687 pub fn lint_directory(&mut self, dir: &Path) -> LintResult {
689 fn visit_dir(linter: &mut StateSyncLinter, dir: &Path, report: &mut StateSyncReport) {
690 if let Ok(entries) = std::fs::read_dir(dir) {
691 for entry in entries.flatten() {
692 let path = entry.path();
693 if path.is_dir() {
694 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
696 if !name.starts_with('.') && name != "target" {
697 visit_dir(linter, &path, report);
698 }
699 } else if path.extension().map(|e| e == "rs").unwrap_or(false) {
700 if let Ok(file_report) = linter.lint_file(&path) {
701 report.merge(file_report);
702 }
703 }
704 }
705 }
706 }
707
708 let mut report = StateSyncReport::default();
709 visit_dir(self, dir, &mut report);
710 Ok(report)
711 }
712}
713
714#[cfg(test)]
715mod tests {
716 use super::*;
717
718 #[test]
719 fn test_detect_local_rc_new() {
720 let linter = StateSyncLinter::new();
721
722 assert!(linter
724 .detect_local_rc_new("let state_ptr = Rc::new(RefCell::new(State::Init));")
725 .is_some());
726 assert!(linter
727 .detect_local_rc_new(" let foo = Rc::new(42);")
728 .is_some());
729
730 assert!(linter
732 .detect_local_rc_new("let state_ptr_clone = self.state_ptr.clone();")
733 .is_none());
734 }
735
736 #[test]
737 fn test_detect_function_start() {
738 let linter = StateSyncLinter::new();
739
740 assert_eq!(
741 linter.detect_function_start("fn foo() {"),
742 Some("foo".to_string())
743 );
744 assert_eq!(
745 linter.detect_function_start("pub fn spawn(&mut self) {"),
746 Some("spawn".to_string())
747 );
748 assert_eq!(
749 linter.detect_function_start("pub async fn start() {"),
750 Some("start".to_string())
751 );
752 assert_eq!(linter.detect_function_start("// fn not_a_function"), None);
753 }
754
755 #[test]
756 fn test_line_creates_closure() {
757 let linter = StateSyncLinter::new();
758
759 assert!(linter.line_creates_closure("let f = move || { do_stuff(); };"));
760 assert!(linter.line_creates_closure("let cb = Closure::wrap(Box::new(move |e| {}));"));
761 assert!(!linter.line_creates_closure("fn regular_function() {}"));
762 }
763
764 #[test]
765 fn test_lint_buggy_code() {
766 let mut linter = StateSyncLinter::new();
767
768 let buggy_code = r#"
769impl WorkerManager {
770 pub fn spawn(&mut self) {
771 // BUG: Creates local Rc, not from self
772 let state_ptr = Rc::new(RefCell::new(ManagerState::Spawning));
773
774 let on_message = Closure::wrap(Box::new(move |event| {
775 *state_ptr.borrow_mut() = ManagerState::Ready;
776 }));
777 }
778}
779"#;
780
781 let report = linter.lint_source(buggy_code).expect("lint failed");
782
783 assert!(!report.errors.is_empty(), "Expected lint errors");
785 assert!(
786 report.errors.iter().any(|e| e.rule == "WASM-SS-001"),
787 "Expected WASM-SS-001 error"
788 );
789 }
790
791 #[test]
792 fn test_lint_correct_code() {
793 let mut linter = StateSyncLinter::new();
794
795 let correct_code = r#"
796impl WorkerManager {
797 pub fn spawn(&mut self) {
798 // CORRECT: Clone from self
799 let state_ptr_clone = self.state_ptr.clone();
800
801 let on_message = Closure::wrap(Box::new(move |event| {
802 *state_ptr_clone.borrow_mut() = ManagerState::Ready;
803 }));
804 }
805}
806"#;
807
808 let report = linter.lint_source(correct_code).expect("lint failed");
809
810 let ss001_errors: Vec<_> = report
812 .errors
813 .iter()
814 .filter(|e| e.rule == "WASM-SS-001")
815 .collect();
816 assert!(
817 ss001_errors.is_empty(),
818 "Should not report WASM-SS-001 for correct pattern"
819 );
820 }
821
822 #[test]
823 fn test_severity_display() {
824 assert_eq!(LintSeverity::Error.to_string(), "error");
825 assert_eq!(LintSeverity::Warning.to_string(), "warning");
826 assert_eq!(LintSeverity::Info.to_string(), "info");
827 }
828
829 #[test]
830 fn test_lint_error_display() {
831 let err = LintError {
832 rule: "WASM-SS-001".to_string(),
833 message: "Local Rc captured".to_string(),
834 file: "src/lib.rs".to_string(),
835 line: 42,
836 column: 13,
837 severity: LintSeverity::Error,
838 suggestion: Some("Use self.state_ptr.clone()".to_string()),
839 };
840
841 let display = err.to_string();
842 assert!(display.contains("WASM-SS-001"));
843 assert!(display.contains("Local Rc captured"));
844 assert!(display.contains("src/lib.rs:42:13"));
845 assert!(display.contains("self.state_ptr.clone()"));
846 }
847
848 #[test]
849 fn test_report_counts() {
850 let mut report = StateSyncReport::default();
851
852 report.errors.push(LintError {
853 rule: "WASM-SS-001".to_string(),
854 message: "test".to_string(),
855 file: "test.rs".to_string(),
856 line: 1,
857 column: 1,
858 severity: LintSeverity::Error,
859 suggestion: None,
860 });
861
862 report.errors.push(LintError {
863 rule: "WASM-SS-002".to_string(),
864 message: "test".to_string(),
865 file: "test.rs".to_string(),
866 line: 2,
867 column: 1,
868 severity: LintSeverity::Warning,
869 suggestion: None,
870 });
871
872 assert_eq!(report.error_count(), 1);
873 assert_eq!(report.warning_count(), 1);
874 assert!(report.has_errors());
875 }
876
877 #[test]
880 fn test_lint_error_display_without_suggestion() {
881 let err = LintError {
882 rule: "WASM-SS-002".to_string(),
883 message: "Potential desync".to_string(),
884 file: "src/worker.rs".to_string(),
885 line: 10,
886 column: 5,
887 severity: LintSeverity::Warning,
888 suggestion: None,
889 };
890
891 let display = err.to_string();
892 assert!(display.contains("WASM-SS-002"));
893 assert!(display.contains("Potential desync"));
894 assert!(display.contains("src/worker.rs:10:5"));
895 assert!(!display.contains("help:"));
897 }
898
899 #[test]
900 fn test_report_merge() {
901 let mut report1 = StateSyncReport {
902 errors: vec![LintError {
903 rule: "WASM-SS-001".to_string(),
904 message: "error1".to_string(),
905 file: "file1.rs".to_string(),
906 line: 1,
907 column: 1,
908 severity: LintSeverity::Error,
909 suggestion: None,
910 }],
911 files_analyzed: 1,
912 lines_analyzed: 100,
913 };
914
915 let report2 = StateSyncReport {
916 errors: vec![LintError {
917 rule: "WASM-SS-002".to_string(),
918 message: "error2".to_string(),
919 file: "file2.rs".to_string(),
920 line: 2,
921 column: 1,
922 severity: LintSeverity::Warning,
923 suggestion: None,
924 }],
925 files_analyzed: 2,
926 lines_analyzed: 200,
927 };
928
929 report1.merge(report2);
930
931 assert_eq!(report1.errors.len(), 2);
932 assert_eq!(report1.files_analyzed, 3);
933 assert_eq!(report1.lines_analyzed, 300);
934 }
935
936 #[test]
937 fn test_report_no_errors() {
938 let report = StateSyncReport::default();
939 assert!(!report.has_errors());
940 assert_eq!(report.error_count(), 0);
941 assert_eq!(report.warning_count(), 0);
942 }
943
944 #[test]
945 fn test_report_only_warnings_no_errors() {
946 let mut report = StateSyncReport::default();
947 report.errors.push(LintError {
948 rule: "WASM-SS-002".to_string(),
949 message: "warning".to_string(),
950 file: "test.rs".to_string(),
951 line: 1,
952 column: 1,
953 severity: LintSeverity::Warning,
954 suggestion: None,
955 });
956 report.errors.push(LintError {
957 rule: "WASM-SS-006".to_string(),
958 message: "warning2".to_string(),
959 file: "test.rs".to_string(),
960 line: 2,
961 column: 1,
962 severity: LintSeverity::Warning,
963 suggestion: None,
964 });
965
966 assert!(!report.has_errors());
967 assert_eq!(report.error_count(), 0);
968 assert_eq!(report.warning_count(), 2);
969 }
970
971 #[test]
972 fn test_extract_type_alias_name() {
973 let linter = StateSyncLinter::new();
974
975 assert_eq!(
977 linter.extract_type_alias_name("type StatePtr = Rc<RefCell<State>>;"),
978 Some("StatePtr".to_string())
979 );
980
981 assert_eq!(
983 linter.extract_type_alias_name("type My_State_Ptr = Rc<RefCell<State>>;"),
984 Some("My_State_Ptr".to_string())
985 );
986
987 assert_eq!(linter.extract_type_alias_name("let x = 5;"), None);
989
990 assert_eq!(linter.extract_type_alias_name("type "), None);
992
993 assert_eq!(
995 linter.extract_type_alias_name("type Handler<T> = Rc<RefCell<T>>;"),
996 Some("Handler".to_string())
997 );
998 }
999
1000 #[test]
1001 fn test_detect_type_alias_new_pattern() {
1002 let mut linter = StateSyncLinter::new();
1003 linter.rc_type_aliases.insert("StatePtr".to_string());
1004
1005 let result = linter.detect_type_alias_new("let state = StatePtr::new(Default::default());");
1007 assert!(result.is_some());
1008 let (alias, var) = result.unwrap();
1009 assert_eq!(alias, "StatePtr");
1010 assert_eq!(var, "state");
1011
1012 let result =
1014 linter.detect_type_alias_new("let mut state = StatePtr::new(Default::default());");
1015 assert!(result.is_some());
1016 let (alias, var) = result.unwrap();
1017 assert_eq!(alias, "StatePtr");
1018 assert_eq!(var, "state");
1019
1020 let result = linter.detect_type_alias_new("let x = Rc::new(5);");
1022 assert!(result.is_none());
1023
1024 let result = linter.detect_type_alias_new("StatePtr::new(Default::default());");
1026 assert!(result.is_none());
1027 }
1028
1029 #[test]
1030 fn test_detect_rc_function_call() {
1031 let mut linter = StateSyncLinter::new();
1032 linter
1033 .rc_returning_functions
1034 .insert("make_state".to_string());
1035
1036 let result = linter.detect_rc_function_call("let state = Self::make_state();");
1038 assert!(result.is_some());
1039 let (fn_name, var) = result.unwrap();
1040 assert_eq!(fn_name, "make_state");
1041 assert_eq!(var, "state");
1042
1043 let result = linter.detect_rc_function_call("let state = self.make_state();");
1045 assert!(result.is_some());
1046
1047 let result = linter.detect_rc_function_call("let state = make_state();");
1049 assert!(result.is_some());
1050
1051 let result = linter.detect_rc_function_call("let mut state = Self::make_state();");
1053 assert!(result.is_some());
1054
1055 let result = linter.detect_rc_function_call("let x = other_func();");
1057 assert!(result.is_none());
1058
1059 let result = linter.detect_rc_function_call("Self::make_state();");
1061 assert!(result.is_none());
1062 }
1063
1064 #[test]
1065 fn test_function_likely_creates_closure() {
1066 let linter = StateSyncLinter::new();
1067
1068 assert!(linter.function_likely_creates_closure("spawn"));
1070 assert!(linter.function_likely_creates_closure("start"));
1071 assert!(linter.function_likely_creates_closure("on_message"));
1072 assert!(linter.function_likely_creates_closure("on_click"));
1073 assert!(linter.function_likely_creates_closure("on_event"));
1074 assert!(linter.function_likely_creates_closure("set_callback"));
1075 assert!(linter.function_likely_creates_closure("register"));
1076 assert!(linter.function_likely_creates_closure("subscribe"));
1077 assert!(linter.function_likely_creates_closure("listen"));
1078
1079 assert!(linter.function_likely_creates_closure("spawn_worker"));
1081 assert!(linter.function_likely_creates_closure("do_spawn"));
1082
1083 assert!(!linter.function_likely_creates_closure("calculate"));
1085 assert!(!linter.function_likely_creates_closure("get_value"));
1086 assert!(!linter.function_likely_creates_closure("process"));
1087 }
1088
1089 #[test]
1090 fn test_detect_function_start_pub_crate() {
1091 let linter = StateSyncLinter::new();
1092
1093 assert_eq!(
1095 linter.detect_function_start("pub(crate) fn internal_func() {"),
1096 Some("internal_func".to_string())
1097 );
1098
1099 assert_eq!(
1101 linter.detect_function_start(" fn helper() {"),
1102 Some("helper".to_string())
1103 );
1104
1105 assert_eq!(
1107 linter.detect_function_start("async fn async_work() {"),
1108 Some("async_work".to_string())
1109 );
1110
1111 assert_eq!(linter.detect_function_start("impl Foo {"), None);
1113
1114 assert_eq!(linter.detect_function_start("let f = || {};"), None);
1116 }
1117
1118 #[test]
1119 fn test_detect_local_rc_new_edge_cases() {
1120 let linter = StateSyncLinter::new();
1121
1122 assert_eq!(
1124 linter.detect_local_rc_new("let mut counter = Rc::new(0);"),
1125 Some("counter".to_string())
1126 );
1127
1128 assert!(linter
1130 .detect_local_rc_new("counter = Rc::new(0);")
1131 .is_none());
1132
1133 assert!(linter
1135 .detect_local_rc_new("let ptr = self.state.clone();")
1136 .is_none());
1137
1138 assert!(linter
1140 .detect_local_rc_new(" let x = Rc::new(RefCell::new(vec![]));")
1141 .is_some());
1142 }
1143
1144 #[test]
1145 fn test_lint_type_alias_detection() {
1146 let mut linter = StateSyncLinter::new();
1147
1148 let code_with_type_alias = r#"
1149type StatePtr = Rc<RefCell<State>>;
1150
1151impl Worker {
1152 pub fn spawn(&mut self) {
1153 let state = StatePtr::new(State::default());
1154 let closure = move || {
1155 state.borrow_mut().update();
1156 };
1157 }
1158}
1159"#;
1160
1161 let report = linter
1162 .lint_source(code_with_type_alias)
1163 .expect("lint failed");
1164
1165 assert!(
1167 report.errors.iter().any(|e| e.rule == "WASM-SS-006"),
1168 "Expected WASM-SS-006 for type alias"
1169 );
1170 }
1171
1172 #[test]
1173 fn test_lint_rc_returning_function() {
1174 let mut linter = StateSyncLinter::new();
1175
1176 let code_with_rc_fn = r#"
1177fn make_state() -> Rc<RefCell<State>> {
1178 Rc::new(RefCell::new(State::default()))
1179}
1180
1181impl Worker {
1182 pub fn spawn(&mut self) {
1183 let state = make_state();
1184 let closure = move || {
1185 state.borrow_mut().update();
1186 };
1187 }
1188}
1189"#;
1190
1191 let report = linter.lint_source(code_with_rc_fn).expect("lint failed");
1192
1193 assert!(
1195 report.errors.iter().any(|e| e.rule == "WASM-SS-007"),
1196 "Expected WASM-SS-007 for Rc-returning function"
1197 );
1198 }
1199
1200 #[test]
1201 fn test_lint_wasm_ss_005_missing_clone() {
1202 let mut linter = StateSyncLinter::new();
1203
1204 let code_with_missing_clone = r#"
1205impl Worker {
1206 pub fn process(&mut self) {
1207 let closure = move || {
1208 // Uses state_ptr directly without clone from self
1209 state_ptr.borrow_mut().process();
1210 };
1211 }
1212}
1213"#;
1214
1215 let report = linter
1216 .lint_source(code_with_missing_clone)
1217 .expect("lint failed");
1218
1219 assert!(
1221 report.errors.iter().any(|e| e.rule == "WASM-SS-005"),
1222 "Expected WASM-SS-005 for missing self clone"
1223 );
1224 }
1225
1226 #[test]
1227 fn test_lint_wasm_ss_002_desync_pattern() {
1228 let mut linter = StateSyncLinter::new();
1229
1230 let code_with_desync = r#"
1231impl Worker {
1232 pub fn spawn(&mut self) {
1233 // Both self.state and state_ptr exist - potential desync
1234 let state_ptr = Rc::new(RefCell::new(self.state.clone()));
1235 let closure = move || {
1236 state_ptr.borrow_mut().update();
1237 };
1238 }
1239}
1240"#;
1241
1242 let report = linter.lint_source(code_with_desync).expect("lint failed");
1243
1244 assert!(
1246 !report.errors.is_empty(),
1247 "Expected lint errors for desync pattern"
1248 );
1249 }
1250
1251 #[test]
1252 fn test_lint_empty_source() {
1253 let mut linter = StateSyncLinter::new();
1254 let report = linter.lint_source("").expect("lint failed");
1255 assert!(report.errors.is_empty());
1256 assert_eq!(report.files_analyzed, 1);
1257 assert_eq!(report.lines_analyzed, 0);
1258 }
1259
1260 #[test]
1261 fn test_lint_source_with_no_functions() {
1262 let mut linter = StateSyncLinter::new();
1263
1264 let code = r#"
1265// Just constants and types
1266const MAX: usize = 100;
1267type MyType = Vec<u32>;
1268"#;
1269
1270 let report = linter.lint_source(code).expect("lint failed");
1271 assert!(
1273 !report.errors.iter().any(|e| e.rule == "WASM-SS-001"),
1274 "Should not report WASM-SS-001 for code without functions"
1275 );
1276 }
1277
1278 #[test]
1279 fn test_lint_function_without_closure() {
1280 let mut linter = StateSyncLinter::new();
1281
1282 let code = r#"
1283impl Calculator {
1284 pub fn add(&self, a: i32, b: i32) -> i32 {
1285 let result = Rc::new(a + b);
1286 *result
1287 }
1288}
1289"#;
1290
1291 let report = linter.lint_source(code).expect("lint failed");
1292 assert!(
1294 !report.errors.iter().any(|e| e.rule == "WASM-SS-001"),
1295 "Should not report WASM-SS-001 for function without closure"
1296 );
1297 }
1298
1299 #[test]
1300 fn test_lint_closure_with_move_pipe() {
1301 let linter = StateSyncLinter::new();
1302
1303 assert!(linter.line_creates_closure("let f = move |x| x + 1;"));
1305 assert!(linter.line_creates_closure("let f = move || println!(\"hi\");"));
1307 assert!(linter.line_creates_closure("let cb = Closure::once(Box::new(|| {}));"));
1309 }
1310
1311 #[test]
1312 fn test_lint_brace_depth_tracking() {
1313 let mut linter = StateSyncLinter::new();
1314
1315 let code = r#"
1317impl Outer {
1318 pub fn outer_fn(&mut self) {
1319 {
1320 let inner_scope = Rc::new(RefCell::new(0));
1321 }
1322 // After inner scope closes, we're back in outer_fn
1323 let closure = move || {};
1324 }
1325}
1326"#;
1327
1328 let report = linter.lint_source(code).expect("lint failed");
1329 assert!(report.lines_analyzed > 0);
1331 }
1332
1333 #[test]
1334 fn test_lint_multiple_functions() {
1335 let mut linter = StateSyncLinter::new();
1336
1337 let code = r#"
1338impl Multi {
1339 pub fn first(&mut self) {
1340 let state = Rc::new(RefCell::new(0));
1341 let closure = move || {};
1342 }
1343
1344 pub fn second(&mut self) {
1345 let state_clone = self.state.clone();
1346 let closure = move || {};
1347 }
1348}
1349"#;
1350
1351 let report = linter.lint_source(code).expect("lint failed");
1352 let ss001_count = report
1354 .errors
1355 .iter()
1356 .filter(|e| e.rule == "WASM-SS-001")
1357 .count();
1358 assert!(ss001_count >= 1, "Expected at least one WASM-SS-001 error");
1359 }
1360
1361 #[test]
1362 fn test_lint_directory_with_tempdir() {
1363 use std::io::Write;
1364
1365 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
1366 let rs_file_path = temp_dir.path().join("test.rs");
1367
1368 let code = r#"
1369impl Test {
1370 pub fn spawn(&mut self) {
1371 let state = Rc::new(RefCell::new(0));
1372 let closure = move || {};
1373 }
1374}
1375"#;
1376
1377 std::fs::File::create(&rs_file_path)
1378 .expect("Failed to create file")
1379 .write_all(code.as_bytes())
1380 .expect("Failed to write file");
1381
1382 let mut linter = StateSyncLinter::new();
1383 let report = linter
1384 .lint_directory(temp_dir.path())
1385 .expect("lint_directory failed");
1386
1387 assert_eq!(report.files_analyzed, 1);
1388 assert!(report.lines_analyzed > 0);
1389 }
1390
1391 #[test]
1392 fn test_lint_directory_skips_hidden_and_target() {
1393 use std::io::Write;
1394
1395 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
1396
1397 let hidden_dir = temp_dir.path().join(".hidden");
1399 std::fs::create_dir(&hidden_dir).expect("Failed to create .hidden dir");
1400 let hidden_file = hidden_dir.join("hidden.rs");
1401 std::fs::File::create(&hidden_file)
1402 .expect("Failed to create hidden file")
1403 .write_all(b"fn hidden() {}")
1404 .expect("Failed to write");
1405
1406 let target_dir = temp_dir.path().join("target");
1408 std::fs::create_dir(&target_dir).expect("Failed to create target dir");
1409 let target_file = target_dir.join("generated.rs");
1410 std::fs::File::create(&target_file)
1411 .expect("Failed to create target file")
1412 .write_all(b"fn generated() {}")
1413 .expect("Failed to write");
1414
1415 let regular_file = temp_dir.path().join("src.rs");
1417 std::fs::File::create(®ular_file)
1418 .expect("Failed to create regular file")
1419 .write_all(b"fn regular() {}")
1420 .expect("Failed to write");
1421
1422 let mut linter = StateSyncLinter::new();
1423 let report = linter
1424 .lint_directory(temp_dir.path())
1425 .expect("lint_directory failed");
1426
1427 assert_eq!(report.files_analyzed, 1);
1429 }
1430
1431 #[test]
1432 fn test_lint_file_not_found() {
1433 let mut linter = StateSyncLinter::new();
1434 let result = linter.lint_file(std::path::Path::new("/nonexistent/path/file.rs"));
1435 assert!(result.is_err());
1436 assert!(result
1437 .unwrap_err()
1438 .contains("Failed to read /nonexistent/path/file.rs"));
1439 }
1440
1441 #[test]
1442 fn test_lint_file_success() {
1443 use std::io::Write;
1444
1445 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
1446 let rs_file = temp_dir.path().join("test.rs");
1447
1448 let code = "fn test() { let x = 1; }";
1449 std::fs::File::create(&rs_file)
1450 .expect("Failed to create file")
1451 .write_all(code.as_bytes())
1452 .expect("Failed to write");
1453
1454 let mut linter = StateSyncLinter::new();
1455 let report = linter.lint_file(&rs_file).expect("lint_file failed");
1456
1457 assert_eq!(report.files_analyzed, 1);
1458 assert_eq!(report.lines_analyzed, 1);
1459 }
1460
1461 #[test]
1462 fn test_collect_type_info_function_returning_rc() {
1463 let mut linter = StateSyncLinter::new();
1464 let mut report = StateSyncReport::default();
1465
1466 let code = r#"
1467fn create_state() -> Rc<RefCell<State>> {
1468 Rc::new(RefCell::new(State::default()))
1469}
1470"#;
1471
1472 linter.collect_type_info(code, &mut report);
1473
1474 assert!(linter.rc_returning_functions.contains("create_state"));
1475 assert!(report.errors.iter().any(|e| e.rule == "WASM-SS-007"));
1476 }
1477
1478 #[test]
1479 fn test_collect_type_info_type_alias() {
1480 let mut linter = StateSyncLinter::new();
1481 let mut report = StateSyncReport::default();
1482
1483 let code = r#"
1484type SharedState = Rc<RefCell<State>>;
1485"#;
1486
1487 linter.collect_type_info(code, &mut report);
1488
1489 assert!(linter.rc_type_aliases.contains("SharedState"));
1490 assert!(report.errors.iter().any(|e| e.rule == "WASM-SS-006"));
1491 }
1492
1493 #[test]
1494 fn test_lint_source_text_based_directly() {
1495 let mut linter = StateSyncLinter::new();
1496 linter.current_file = "test.rs".to_string();
1497
1498 let code = r#"
1499impl Worker {
1500 pub fn on_event(&mut self) {
1501 let state = Rc::new(RefCell::new(0));
1502 let cb = Closure::wrap(Box::new(move || {}));
1503 }
1504}
1505"#;
1506
1507 let report = linter
1508 .lint_source_text_based(code)
1509 .expect("lint_source_text_based failed");
1510
1511 assert!(report.files_analyzed == 1);
1512 assert!(report.lines_analyzed > 0);
1513 }
1514
1515 #[test]
1516 fn test_severity_equality() {
1517 assert_eq!(LintSeverity::Error, LintSeverity::Error);
1518 assert_eq!(LintSeverity::Warning, LintSeverity::Warning);
1519 assert_eq!(LintSeverity::Info, LintSeverity::Info);
1520 assert_ne!(LintSeverity::Error, LintSeverity::Warning);
1521 assert_ne!(LintSeverity::Warning, LintSeverity::Info);
1522 }
1523
1524 #[test]
1525 fn test_lint_error_clone() {
1526 let err = LintError {
1527 rule: "TEST-001".to_string(),
1528 message: "test message".to_string(),
1529 file: "test.rs".to_string(),
1530 line: 1,
1531 column: 1,
1532 severity: LintSeverity::Error,
1533 suggestion: Some("fix it".to_string()),
1534 };
1535
1536 let cloned = err.clone();
1537 assert_eq!(err.rule, cloned.rule);
1538 assert_eq!(err.message, cloned.message);
1539 assert_eq!(err.file, cloned.file);
1540 assert_eq!(err.line, cloned.line);
1541 assert_eq!(err.column, cloned.column);
1542 assert_eq!(err.severity, cloned.severity);
1543 assert_eq!(err.suggestion, cloned.suggestion);
1544 }
1545
1546 #[test]
1547 fn test_linter_default() {
1548 let linter = StateSyncLinter::default();
1549 assert!(linter.closure_creators.contains("Closure::wrap"));
1551 assert!(linter.closure_creators.contains("move ||"));
1552 }
1553}