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 self.check_local_rc_new(
279 line,
280 line_num,
281 current_fn.as_deref(),
282 fn_has_closure,
283 &fns_with_closures,
284 &mut report,
285 );
286
287 self.check_type_alias_new(
289 line,
290 line_num,
291 current_fn.as_deref(),
292 fn_has_closure,
293 &mut report,
294 );
295
296 self.check_rc_function_call(
298 line,
299 line_num,
300 current_fn.as_deref(),
301 fn_has_closure,
302 &mut report,
303 );
304
305 if self.line_creates_closure(line) {
307 self.check_closure_captures(line, line_num, source, &mut report);
309 }
310
311 if fn_has_closure && current_fn.is_some() {
313 self.check_missing_self_clone(line, line_num, &mut report);
314 }
315 }
316
317 Ok(report)
318 }
319
320 fn check_local_rc_new(
322 &mut self,
323 line: &str,
324 line_num: usize,
325 current_fn: Option<&str>,
326 fn_has_closure: bool,
327 fns_with_closures: &HashSet<String>,
328 report: &mut StateSyncReport,
329 ) {
330 let Some(var_name) = self.detect_local_rc_new(line) else {
331 return;
332 };
333 let fn_name = current_fn.unwrap_or("<unknown>").to_string();
334 self.local_rcs
335 .entry(fn_name.clone())
336 .or_default()
337 .push((var_name.clone(), line_num));
338
339 let fn_has_any_closure = fn_has_closure
341 || fns_with_closures.contains(&fn_name)
342 || self.function_likely_creates_closure(&fn_name);
343 if fn_has_any_closure {
344 report.errors.push(LintError {
345 rule: "WASM-SS-001".to_string(),
346 message: format!(
347 "Local `{var_name}` creates new Rc - if captured by closure, \
348 it will be disconnected from self"
349 ),
350 file: self.current_file.clone(),
351 line: line_num,
352 column: line.find(&var_name).unwrap_or(0) + 1,
353 severity: LintSeverity::Error,
354 suggestion: Some(format!(
355 "Use `let {var_name}_clone = self.{var_name}.clone()` instead"
356 )),
357 });
358 }
359 }
360
361 fn check_type_alias_new(
363 &self,
364 line: &str,
365 line_num: usize,
366 current_fn: Option<&str>,
367 fn_has_closure: bool,
368 report: &mut StateSyncReport,
369 ) {
370 let Some((alias_name, var_name)) = self.detect_type_alias_new(line) else {
371 return;
372 };
373 if !fn_has_closure
374 && !self.function_likely_creates_closure(current_fn.unwrap_or("<unknown>"))
375 {
376 return;
377 }
378 report.errors.push(LintError {
379 rule: "WASM-SS-006".to_string(),
380 message: format!(
381 "Type alias `{alias_name}::new()` creates local Rc - \
382 may cause state desync if captured in closure"
383 ),
384 file: self.current_file.clone(),
385 line: line_num,
386 column: line.find(&var_name).unwrap_or(0) + 1,
387 severity: LintSeverity::Warning,
388 suggestion: Some(format!(
389 "Use `self.{var_name}.clone()` instead of `{alias_name}::new()`"
390 )),
391 });
392 }
393
394 fn check_rc_function_call(
396 &self,
397 line: &str,
398 line_num: usize,
399 current_fn: Option<&str>,
400 fn_has_closure: bool,
401 report: &mut StateSyncReport,
402 ) {
403 let Some((fn_name_called, var_name)) = self.detect_rc_function_call(line) else {
404 return;
405 };
406 if !fn_has_closure
407 && !self.function_likely_creates_closure(current_fn.unwrap_or("<unknown>"))
408 {
409 return;
410 }
411 report.errors.push(LintError {
412 rule: "WASM-SS-007".to_string(),
413 message: format!(
414 "Function `{fn_name_called}()` returns Rc - \
415 local assignment may cause state desync in closure"
416 ),
417 file: self.current_file.clone(),
418 line: line_num,
419 column: line.find(&var_name).unwrap_or(0) + 1,
420 severity: LintSeverity::Warning,
421 suggestion: Some("Clone from self instead of calling helper function".to_string()),
422 });
423 }
424
425 fn collect_type_info(&mut self, source: &str, report: &mut StateSyncReport) {
427 for (line_num, line) in source.lines().enumerate() {
428 let line_num = line_num + 1;
429 let trimmed = line.trim();
430
431 if trimmed.starts_with("type ") && trimmed.contains("Rc<") {
433 if let Some(alias_name) = self.extract_type_alias_name(trimmed) {
434 self.rc_type_aliases.insert(alias_name.clone());
435 report.errors.push(LintError {
436 rule: "WASM-SS-006".to_string(),
437 message: format!(
438 "Type alias `{alias_name}` wraps Rc - usage with ::new() may cause state desync"
439 ),
440 file: self.current_file.clone(),
441 line: line_num,
442 column: 1,
443 severity: LintSeverity::Info,
444 suggestion: Some("Consider using self.field.clone() pattern instead".to_string()),
445 });
446 }
447 }
448
449 if trimmed.contains("fn ") && trimmed.contains("-> Rc<") {
451 if let Some(fn_name) = self.detect_function_start(trimmed) {
452 self.rc_returning_functions.insert(fn_name.clone());
453 report.errors.push(LintError {
454 rule: "WASM-SS-007".to_string(),
455 message: format!(
456 "Function `{fn_name}` returns Rc - callers may create disconnected state"
457 ),
458 file: self.current_file.clone(),
459 line: line_num,
460 column: 1,
461 severity: LintSeverity::Info,
462 suggestion: Some("Document that callers should use self.field.clone() instead".to_string()),
463 });
464 }
465 }
466 }
467 }
468
469 fn extract_type_alias_name(&self, line: &str) -> Option<String> {
471 let trimmed = line.trim();
473 if !trimmed.starts_with("type ") {
474 return None;
475 }
476 let after_type = &trimmed[5..];
477 let name_end = after_type
478 .find(|c: char| !c.is_alphanumeric() && c != '_')
479 .unwrap_or(after_type.len());
480 let name = &after_type[..name_end];
481 if !name.is_empty() {
482 Some(name.to_string())
483 } else {
484 None
485 }
486 }
487
488 fn detect_type_alias_new(&self, line: &str) -> Option<(String, String)> {
490 let trimmed = line.trim();
491
492 for alias in &self.rc_type_aliases {
494 let pattern = format!("{alias}::new(");
495 if trimmed.contains(&pattern) {
496 if let Some(after_let) = trimmed.strip_prefix("let ") {
498 let after_mut = after_let.strip_prefix("mut ").unwrap_or(after_let);
499 let name_end = after_mut
500 .find(|c: char| !c.is_alphanumeric() && c != '_')
501 .unwrap_or(after_mut.len());
502 let var_name = &after_mut[..name_end];
503 if !var_name.is_empty() {
504 return Some((alias.clone(), var_name.to_string()));
505 }
506 }
507 }
508 }
509 None
510 }
511
512 fn detect_rc_function_call(&self, line: &str) -> Option<(String, String)> {
514 let trimmed = line.trim();
515
516 for fn_name in &self.rc_returning_functions {
518 let patterns = [
520 format!("Self::{fn_name}("),
521 format!("self.{fn_name}("),
522 format!("{fn_name}("), ];
524
525 for pattern in &patterns {
526 if !trimmed.contains(pattern) {
527 continue;
528 }
529 if let Some(var_name) = Self::let_binding_name(trimmed) {
531 return Some((fn_name.clone(), var_name));
532 }
533 }
534 }
535 None
536 }
537
538 fn let_binding_name(trimmed: &str) -> Option<String> {
540 let after_let = trimmed.strip_prefix("let ")?;
541 let after_mut = after_let.strip_prefix("mut ").unwrap_or(after_let);
542 let name_end = after_mut
543 .find(|c: char| !c.is_alphanumeric() && c != '_')
544 .unwrap_or(after_mut.len());
545 let var_name = &after_mut[..name_end];
546 if var_name.is_empty() {
547 None
548 } else {
549 Some(var_name.to_string())
550 }
551 }
552
553 fn detect_function_start(&self, line: &str) -> Option<String> {
555 let trimmed = line.trim();
556
557 if trimmed.contains("fn ")
559 && (trimmed.starts_with("fn ")
560 || trimmed.starts_with("pub fn ")
561 || trimmed.starts_with("pub(crate) fn ")
562 || trimmed.starts_with("async fn ")
563 || trimmed.starts_with("pub async fn "))
564 {
565 if let Some(fn_pos) = trimmed.find("fn ") {
567 let after_fn = &trimmed[fn_pos + 3..];
568 let name_end = after_fn
569 .find(|c: char| !c.is_alphanumeric() && c != '_')
570 .unwrap_or(after_fn.len());
571 let name = &after_fn[..name_end];
572 if !name.is_empty() {
573 return Some(name.to_string());
574 }
575 }
576 }
577 None
578 }
579
580 fn line_creates_closure(&self, line: &str) -> bool {
582 let trimmed = line.trim();
583 for pattern in &self.closure_creators {
584 if trimmed.contains(pattern.as_str()) {
585 return true;
586 }
587 }
588 false
589 }
590
591 fn find_functions_with_closures(&self, source: &str) -> HashSet<String> {
593 let mut result = HashSet::new();
594 let mut current_fn: Option<String> = None;
595 let mut brace_depth = 0;
596 let mut fn_start_depth = 0;
597
598 for line in source.lines() {
599 brace_depth += line.matches('{').count();
600 brace_depth = brace_depth.saturating_sub(line.matches('}').count());
601
602 if let Some(fn_name) = self.detect_function_start(line) {
603 current_fn = Some(fn_name);
604 fn_start_depth = brace_depth;
605 }
606
607 if current_fn.is_some() && brace_depth < fn_start_depth {
608 current_fn = None;
609 }
610
611 if self.line_creates_closure(line) {
612 if let Some(ref fn_name) = current_fn {
613 result.insert(fn_name.clone());
614 }
615 }
616 }
617
618 result
619 }
620
621 fn detect_local_rc_new(&self, line: &str) -> Option<String> {
623 let trimmed = line.trim();
624
625 if let Some(after_let) = trimmed.strip_prefix("let ") {
628 if trimmed.contains("Rc::new(") {
629 let after_mut = after_let.strip_prefix("mut ").unwrap_or(after_let);
631
632 let name_end = after_mut
633 .find(|c: char| !c.is_alphanumeric() && c != '_')
634 .unwrap_or(after_mut.len());
635 let name = &after_mut[..name_end];
636
637 if !line.contains(".clone()") && !name.is_empty() {
640 return Some(name.to_string());
641 }
642 }
643 }
644 None
645 }
646
647 fn function_likely_creates_closure(&self, fn_name: &str) -> bool {
649 let closure_fn_names = [
651 "spawn",
652 "start",
653 "on_message",
654 "on_click",
655 "on_event",
656 "set_callback",
657 "register",
658 "subscribe",
659 "listen",
660 ];
661 closure_fn_names.iter().any(|&n| fn_name.contains(n))
662 }
663
664 fn check_closure_captures(
666 &self,
667 _line: &str,
668 line_num: usize,
669 source: &str,
670 report: &mut StateSyncReport,
671 ) {
672 let lines: Vec<&str> = source.lines().collect();
674 let start = line_num.saturating_sub(10);
675 let end = (line_num + 10).min(lines.len());
676
677 let context = &lines[start..end];
678
679 for line in context {
681 if line.contains("let ") && line.contains("Rc::new(") && !line.contains(".clone()") {
682 continue;
684 }
685
686 if line.contains("self.state") && line.contains("state_ptr") {
688 report.errors.push(LintError {
690 rule: "WASM-SS-002".to_string(),
691 message: "Potential state desync: both `self.state` and local \
692 `state_ptr` reference exist"
693 .to_string(),
694 file: self.current_file.clone(),
695 line: line_num,
696 column: 1,
697 severity: LintSeverity::Warning,
698 suggestion: Some(
699 "Ensure closure uses `self.state_ptr.clone()`, not a local Rc".to_string(),
700 ),
701 });
702 }
703 }
704 }
705
706 fn check_missing_self_clone(&self, line: &str, line_num: usize, report: &mut StateSyncReport) {
708 if self.line_creates_closure(line)
710 && line.contains("state_ptr")
711 && !line.contains("state_ptr_clone")
712 {
713 report.errors.push(LintError {
714 rule: "WASM-SS-005".to_string(),
715 message: "Closure may capture local state - ensure \
716 `self.state_ptr.clone()` is used"
717 .to_string(),
718 file: self.current_file.clone(),
719 line: line_num,
720 column: 1,
721 severity: LintSeverity::Warning,
722 suggestion: Some(
723 "Add `let state_ptr_clone = self.state_ptr.clone();` before closure"
724 .to_string(),
725 ),
726 });
727 return;
728 }
729
730 if line.contains("state_ptr.borrow") && !line.contains("self.") && !line.contains("_clone")
733 {
734 report.errors.push(LintError {
735 rule: "WASM-SS-005".to_string(),
736 message: "Using `state_ptr` directly - may be disconnected from self".to_string(),
737 file: self.current_file.clone(),
738 line: line_num,
739 column: 1,
740 severity: LintSeverity::Warning,
741 suggestion: Some(
742 "Use `let state_ptr_clone = self.state_ptr.clone();` before closure"
743 .to_string(),
744 ),
745 });
746 }
747 }
748
749 pub fn lint_directory(&mut self, dir: &Path) -> LintResult {
751 fn visit_dir(linter: &mut StateSyncLinter, dir: &Path, report: &mut StateSyncReport) {
752 if let Ok(entries) = std::fs::read_dir(dir) {
753 for entry in entries.flatten() {
754 let path = entry.path();
755 if path.is_dir() {
756 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
758 if !name.starts_with('.') && name != "target" {
759 visit_dir(linter, &path, report);
760 }
761 } else if path.extension().map(|e| e == "rs").unwrap_or(false) {
762 if let Ok(file_report) = linter.lint_file(&path) {
763 report.merge(file_report);
764 }
765 }
766 }
767 }
768 }
769
770 let mut report = StateSyncReport::default();
771 visit_dir(self, dir, &mut report);
772 Ok(report)
773 }
774}
775
776#[cfg(test)]
777mod tests {
778 use super::*;
779
780 #[test]
781 fn test_detect_local_rc_new() {
782 let linter = StateSyncLinter::new();
783
784 assert!(linter
786 .detect_local_rc_new("let state_ptr = Rc::new(RefCell::new(State::Init));")
787 .is_some());
788 assert!(linter
789 .detect_local_rc_new(" let foo = Rc::new(42);")
790 .is_some());
791
792 assert!(linter
794 .detect_local_rc_new("let state_ptr_clone = self.state_ptr.clone();")
795 .is_none());
796 }
797
798 #[test]
799 fn test_detect_function_start() {
800 let linter = StateSyncLinter::new();
801
802 assert_eq!(
803 linter.detect_function_start("fn foo() {"),
804 Some("foo".to_string())
805 );
806 assert_eq!(
807 linter.detect_function_start("pub fn spawn(&mut self) {"),
808 Some("spawn".to_string())
809 );
810 assert_eq!(
811 linter.detect_function_start("pub async fn start() {"),
812 Some("start".to_string())
813 );
814 assert_eq!(linter.detect_function_start("// fn not_a_function"), None);
815 }
816
817 #[test]
818 fn test_line_creates_closure() {
819 let linter = StateSyncLinter::new();
820
821 assert!(linter.line_creates_closure("let f = move || { do_stuff(); };"));
822 assert!(linter.line_creates_closure("let cb = Closure::wrap(Box::new(move |e| {}));"));
823 assert!(!linter.line_creates_closure("fn regular_function() {}"));
824 }
825
826 #[test]
827 fn test_lint_buggy_code() {
828 let mut linter = StateSyncLinter::new();
829
830 let buggy_code = r#"
831impl WorkerManager {
832 pub fn spawn(&mut self) {
833 // BUG: Creates local Rc, not from self
834 let state_ptr = Rc::new(RefCell::new(ManagerState::Spawning));
835
836 let on_message = Closure::wrap(Box::new(move |event| {
837 *state_ptr.borrow_mut() = ManagerState::Ready;
838 }));
839 }
840}
841"#;
842
843 let report = linter.lint_source(buggy_code).expect("lint failed");
844
845 assert!(!report.errors.is_empty(), "Expected lint errors");
847 assert!(
848 report.errors.iter().any(|e| e.rule == "WASM-SS-001"),
849 "Expected WASM-SS-001 error"
850 );
851 }
852
853 #[test]
854 fn test_lint_correct_code() {
855 let mut linter = StateSyncLinter::new();
856
857 let correct_code = r#"
858impl WorkerManager {
859 pub fn spawn(&mut self) {
860 // CORRECT: Clone from self
861 let state_ptr_clone = self.state_ptr.clone();
862
863 let on_message = Closure::wrap(Box::new(move |event| {
864 *state_ptr_clone.borrow_mut() = ManagerState::Ready;
865 }));
866 }
867}
868"#;
869
870 let report = linter.lint_source(correct_code).expect("lint failed");
871
872 let ss001_errors: Vec<_> = report
874 .errors
875 .iter()
876 .filter(|e| e.rule == "WASM-SS-001")
877 .collect();
878 assert!(
879 ss001_errors.is_empty(),
880 "Should not report WASM-SS-001 for correct pattern"
881 );
882 }
883
884 #[test]
885 fn test_severity_display() {
886 assert_eq!(LintSeverity::Error.to_string(), "error");
887 assert_eq!(LintSeverity::Warning.to_string(), "warning");
888 assert_eq!(LintSeverity::Info.to_string(), "info");
889 }
890
891 #[test]
892 fn test_lint_error_display() {
893 let err = LintError {
894 rule: "WASM-SS-001".to_string(),
895 message: "Local Rc captured".to_string(),
896 file: "src/lib.rs".to_string(),
897 line: 42,
898 column: 13,
899 severity: LintSeverity::Error,
900 suggestion: Some("Use self.state_ptr.clone()".to_string()),
901 };
902
903 let display = err.to_string();
904 assert!(display.contains("WASM-SS-001"));
905 assert!(display.contains("Local Rc captured"));
906 assert!(display.contains("src/lib.rs:42:13"));
907 assert!(display.contains("self.state_ptr.clone()"));
908 }
909
910 #[test]
911 fn test_report_counts() {
912 let mut report = StateSyncReport::default();
913
914 report.errors.push(LintError {
915 rule: "WASM-SS-001".to_string(),
916 message: "test".to_string(),
917 file: "test.rs".to_string(),
918 line: 1,
919 column: 1,
920 severity: LintSeverity::Error,
921 suggestion: None,
922 });
923
924 report.errors.push(LintError {
925 rule: "WASM-SS-002".to_string(),
926 message: "test".to_string(),
927 file: "test.rs".to_string(),
928 line: 2,
929 column: 1,
930 severity: LintSeverity::Warning,
931 suggestion: None,
932 });
933
934 assert_eq!(report.error_count(), 1);
935 assert_eq!(report.warning_count(), 1);
936 assert!(report.has_errors());
937 }
938
939 #[test]
942 fn test_lint_error_display_without_suggestion() {
943 let err = LintError {
944 rule: "WASM-SS-002".to_string(),
945 message: "Potential desync".to_string(),
946 file: "src/worker.rs".to_string(),
947 line: 10,
948 column: 5,
949 severity: LintSeverity::Warning,
950 suggestion: None,
951 };
952
953 let display = err.to_string();
954 assert!(display.contains("WASM-SS-002"));
955 assert!(display.contains("Potential desync"));
956 assert!(display.contains("src/worker.rs:10:5"));
957 assert!(!display.contains("help:"));
959 }
960
961 #[test]
962 fn test_report_merge() {
963 let mut report1 = StateSyncReport {
964 errors: vec![LintError {
965 rule: "WASM-SS-001".to_string(),
966 message: "error1".to_string(),
967 file: "file1.rs".to_string(),
968 line: 1,
969 column: 1,
970 severity: LintSeverity::Error,
971 suggestion: None,
972 }],
973 files_analyzed: 1,
974 lines_analyzed: 100,
975 };
976
977 let report2 = StateSyncReport {
978 errors: vec![LintError {
979 rule: "WASM-SS-002".to_string(),
980 message: "error2".to_string(),
981 file: "file2.rs".to_string(),
982 line: 2,
983 column: 1,
984 severity: LintSeverity::Warning,
985 suggestion: None,
986 }],
987 files_analyzed: 2,
988 lines_analyzed: 200,
989 };
990
991 report1.merge(report2);
992
993 assert_eq!(report1.errors.len(), 2);
994 assert_eq!(report1.files_analyzed, 3);
995 assert_eq!(report1.lines_analyzed, 300);
996 }
997
998 #[test]
999 fn test_report_no_errors() {
1000 let report = StateSyncReport::default();
1001 assert!(!report.has_errors());
1002 assert_eq!(report.error_count(), 0);
1003 assert_eq!(report.warning_count(), 0);
1004 }
1005
1006 #[test]
1007 fn test_report_only_warnings_no_errors() {
1008 let mut report = StateSyncReport::default();
1009 report.errors.push(LintError {
1010 rule: "WASM-SS-002".to_string(),
1011 message: "warning".to_string(),
1012 file: "test.rs".to_string(),
1013 line: 1,
1014 column: 1,
1015 severity: LintSeverity::Warning,
1016 suggestion: None,
1017 });
1018 report.errors.push(LintError {
1019 rule: "WASM-SS-006".to_string(),
1020 message: "warning2".to_string(),
1021 file: "test.rs".to_string(),
1022 line: 2,
1023 column: 1,
1024 severity: LintSeverity::Warning,
1025 suggestion: None,
1026 });
1027
1028 assert!(!report.has_errors());
1029 assert_eq!(report.error_count(), 0);
1030 assert_eq!(report.warning_count(), 2);
1031 }
1032
1033 #[test]
1034 fn test_extract_type_alias_name() {
1035 let linter = StateSyncLinter::new();
1036
1037 assert_eq!(
1039 linter.extract_type_alias_name("type StatePtr = Rc<RefCell<State>>;"),
1040 Some("StatePtr".to_string())
1041 );
1042
1043 assert_eq!(
1045 linter.extract_type_alias_name("type My_State_Ptr = Rc<RefCell<State>>;"),
1046 Some("My_State_Ptr".to_string())
1047 );
1048
1049 assert_eq!(linter.extract_type_alias_name("let x = 5;"), None);
1051
1052 assert_eq!(linter.extract_type_alias_name("type "), None);
1054
1055 assert_eq!(
1057 linter.extract_type_alias_name("type Handler<T> = Rc<RefCell<T>>;"),
1058 Some("Handler".to_string())
1059 );
1060 }
1061
1062 #[test]
1063 fn test_detect_type_alias_new_pattern() {
1064 let mut linter = StateSyncLinter::new();
1065 linter.rc_type_aliases.insert("StatePtr".to_string());
1066
1067 let result = linter.detect_type_alias_new("let state = StatePtr::new(Default::default());");
1069 assert!(result.is_some());
1070 let (alias, var) = result.unwrap();
1071 assert_eq!(alias, "StatePtr");
1072 assert_eq!(var, "state");
1073
1074 let result =
1076 linter.detect_type_alias_new("let mut state = StatePtr::new(Default::default());");
1077 assert!(result.is_some());
1078 let (alias, var) = result.unwrap();
1079 assert_eq!(alias, "StatePtr");
1080 assert_eq!(var, "state");
1081
1082 let result = linter.detect_type_alias_new("let x = Rc::new(5);");
1084 assert!(result.is_none());
1085
1086 let result = linter.detect_type_alias_new("StatePtr::new(Default::default());");
1088 assert!(result.is_none());
1089 }
1090
1091 #[test]
1092 fn test_detect_rc_function_call() {
1093 let mut linter = StateSyncLinter::new();
1094 linter
1095 .rc_returning_functions
1096 .insert("make_state".to_string());
1097
1098 let result = linter.detect_rc_function_call("let state = Self::make_state();");
1100 assert!(result.is_some());
1101 let (fn_name, var) = result.unwrap();
1102 assert_eq!(fn_name, "make_state");
1103 assert_eq!(var, "state");
1104
1105 let result = linter.detect_rc_function_call("let state = self.make_state();");
1107 assert!(result.is_some());
1108
1109 let result = linter.detect_rc_function_call("let state = make_state();");
1111 assert!(result.is_some());
1112
1113 let result = linter.detect_rc_function_call("let mut state = Self::make_state();");
1115 assert!(result.is_some());
1116
1117 let result = linter.detect_rc_function_call("let x = other_func();");
1119 assert!(result.is_none());
1120
1121 let result = linter.detect_rc_function_call("Self::make_state();");
1123 assert!(result.is_none());
1124 }
1125
1126 #[test]
1127 fn test_function_likely_creates_closure() {
1128 let linter = StateSyncLinter::new();
1129
1130 assert!(linter.function_likely_creates_closure("spawn"));
1132 assert!(linter.function_likely_creates_closure("start"));
1133 assert!(linter.function_likely_creates_closure("on_message"));
1134 assert!(linter.function_likely_creates_closure("on_click"));
1135 assert!(linter.function_likely_creates_closure("on_event"));
1136 assert!(linter.function_likely_creates_closure("set_callback"));
1137 assert!(linter.function_likely_creates_closure("register"));
1138 assert!(linter.function_likely_creates_closure("subscribe"));
1139 assert!(linter.function_likely_creates_closure("listen"));
1140
1141 assert!(linter.function_likely_creates_closure("spawn_worker"));
1143 assert!(linter.function_likely_creates_closure("do_spawn"));
1144
1145 assert!(!linter.function_likely_creates_closure("calculate"));
1147 assert!(!linter.function_likely_creates_closure("get_value"));
1148 assert!(!linter.function_likely_creates_closure("process"));
1149 }
1150
1151 #[test]
1152 fn test_detect_function_start_pub_crate() {
1153 let linter = StateSyncLinter::new();
1154
1155 assert_eq!(
1157 linter.detect_function_start("pub(crate) fn internal_func() {"),
1158 Some("internal_func".to_string())
1159 );
1160
1161 assert_eq!(
1163 linter.detect_function_start(" fn helper() {"),
1164 Some("helper".to_string())
1165 );
1166
1167 assert_eq!(
1169 linter.detect_function_start("async fn async_work() {"),
1170 Some("async_work".to_string())
1171 );
1172
1173 assert_eq!(linter.detect_function_start("impl Foo {"), None);
1175
1176 assert_eq!(linter.detect_function_start("let f = || {};"), None);
1178 }
1179
1180 #[test]
1181 fn test_detect_local_rc_new_edge_cases() {
1182 let linter = StateSyncLinter::new();
1183
1184 assert_eq!(
1186 linter.detect_local_rc_new("let mut counter = Rc::new(0);"),
1187 Some("counter".to_string())
1188 );
1189
1190 assert!(linter
1192 .detect_local_rc_new("counter = Rc::new(0);")
1193 .is_none());
1194
1195 assert!(linter
1197 .detect_local_rc_new("let ptr = self.state.clone();")
1198 .is_none());
1199
1200 assert!(linter
1202 .detect_local_rc_new(" let x = Rc::new(RefCell::new(vec![]));")
1203 .is_some());
1204 }
1205
1206 #[test]
1207 fn test_lint_type_alias_detection() {
1208 let mut linter = StateSyncLinter::new();
1209
1210 let code_with_type_alias = r#"
1211type StatePtr = Rc<RefCell<State>>;
1212
1213impl Worker {
1214 pub fn spawn(&mut self) {
1215 let state = StatePtr::new(State::default());
1216 let closure = move || {
1217 state.borrow_mut().update();
1218 };
1219 }
1220}
1221"#;
1222
1223 let report = linter
1224 .lint_source(code_with_type_alias)
1225 .expect("lint failed");
1226
1227 assert!(
1229 report.errors.iter().any(|e| e.rule == "WASM-SS-006"),
1230 "Expected WASM-SS-006 for type alias"
1231 );
1232 }
1233
1234 #[test]
1235 fn test_lint_rc_returning_function() {
1236 let mut linter = StateSyncLinter::new();
1237
1238 let code_with_rc_fn = r#"
1239fn make_state() -> Rc<RefCell<State>> {
1240 Rc::new(RefCell::new(State::default()))
1241}
1242
1243impl Worker {
1244 pub fn spawn(&mut self) {
1245 let state = make_state();
1246 let closure = move || {
1247 state.borrow_mut().update();
1248 };
1249 }
1250}
1251"#;
1252
1253 let report = linter.lint_source(code_with_rc_fn).expect("lint failed");
1254
1255 assert!(
1257 report.errors.iter().any(|e| e.rule == "WASM-SS-007"),
1258 "Expected WASM-SS-007 for Rc-returning function"
1259 );
1260 }
1261
1262 #[test]
1263 fn test_lint_wasm_ss_005_missing_clone() {
1264 let mut linter = StateSyncLinter::new();
1265
1266 let code_with_missing_clone = r#"
1267impl Worker {
1268 pub fn process(&mut self) {
1269 let closure = move || {
1270 // Uses state_ptr directly without clone from self
1271 state_ptr.borrow_mut().process();
1272 };
1273 }
1274}
1275"#;
1276
1277 let report = linter
1278 .lint_source(code_with_missing_clone)
1279 .expect("lint failed");
1280
1281 assert!(
1283 report.errors.iter().any(|e| e.rule == "WASM-SS-005"),
1284 "Expected WASM-SS-005 for missing self clone"
1285 );
1286 }
1287
1288 #[test]
1289 fn test_lint_wasm_ss_002_desync_pattern() {
1290 let mut linter = StateSyncLinter::new();
1291
1292 let code_with_desync = r#"
1293impl Worker {
1294 pub fn spawn(&mut self) {
1295 // Both self.state and state_ptr exist - potential desync
1296 let state_ptr = Rc::new(RefCell::new(self.state.clone()));
1297 let closure = move || {
1298 state_ptr.borrow_mut().update();
1299 };
1300 }
1301}
1302"#;
1303
1304 let report = linter.lint_source(code_with_desync).expect("lint failed");
1305
1306 assert!(
1308 !report.errors.is_empty(),
1309 "Expected lint errors for desync pattern"
1310 );
1311 }
1312
1313 #[test]
1314 fn test_lint_empty_source() {
1315 let mut linter = StateSyncLinter::new();
1316 let report = linter.lint_source("").expect("lint failed");
1317 assert!(report.errors.is_empty());
1318 assert_eq!(report.files_analyzed, 1);
1319 assert_eq!(report.lines_analyzed, 0);
1320 }
1321
1322 #[test]
1323 fn test_lint_source_with_no_functions() {
1324 let mut linter = StateSyncLinter::new();
1325
1326 let code = r#"
1327// Just constants and types
1328const MAX: usize = 100;
1329type MyType = Vec<u32>;
1330"#;
1331
1332 let report = linter.lint_source(code).expect("lint failed");
1333 assert!(
1335 !report.errors.iter().any(|e| e.rule == "WASM-SS-001"),
1336 "Should not report WASM-SS-001 for code without functions"
1337 );
1338 }
1339
1340 #[test]
1341 fn test_lint_function_without_closure() {
1342 let mut linter = StateSyncLinter::new();
1343
1344 let code = r#"
1345impl Calculator {
1346 pub fn add(&self, a: i32, b: i32) -> i32 {
1347 let result = Rc::new(a + b);
1348 *result
1349 }
1350}
1351"#;
1352
1353 let report = linter.lint_source(code).expect("lint failed");
1354 assert!(
1356 !report.errors.iter().any(|e| e.rule == "WASM-SS-001"),
1357 "Should not report WASM-SS-001 for function without closure"
1358 );
1359 }
1360
1361 #[test]
1362 fn test_lint_closure_with_move_pipe() {
1363 let linter = StateSyncLinter::new();
1364
1365 assert!(linter.line_creates_closure("let f = move |x| x + 1;"));
1367 assert!(linter.line_creates_closure("let f = move || println!(\"hi\");"));
1369 assert!(linter.line_creates_closure("let cb = Closure::once(Box::new(|| {}));"));
1371 }
1372
1373 #[test]
1374 fn test_lint_brace_depth_tracking() {
1375 let mut linter = StateSyncLinter::new();
1376
1377 let code = r#"
1379impl Outer {
1380 pub fn outer_fn(&mut self) {
1381 {
1382 let inner_scope = Rc::new(RefCell::new(0));
1383 }
1384 // After inner scope closes, we're back in outer_fn
1385 let closure = move || {};
1386 }
1387}
1388"#;
1389
1390 let report = linter.lint_source(code).expect("lint failed");
1391 assert!(report.lines_analyzed > 0);
1393 }
1394
1395 #[test]
1396 fn test_lint_multiple_functions() {
1397 let mut linter = StateSyncLinter::new();
1398
1399 let code = r#"
1400impl Multi {
1401 pub fn first(&mut self) {
1402 let state = Rc::new(RefCell::new(0));
1403 let closure = move || {};
1404 }
1405
1406 pub fn second(&mut self) {
1407 let state_clone = self.state.clone();
1408 let closure = move || {};
1409 }
1410}
1411"#;
1412
1413 let report = linter.lint_source(code).expect("lint failed");
1414 let ss001_count = report
1416 .errors
1417 .iter()
1418 .filter(|e| e.rule == "WASM-SS-001")
1419 .count();
1420 assert!(ss001_count >= 1, "Expected at least one WASM-SS-001 error");
1421 }
1422
1423 #[test]
1424 fn test_lint_directory_with_tempdir() {
1425 use std::io::Write;
1426
1427 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
1428 let rs_file_path = temp_dir.path().join("test.rs");
1429
1430 let code = r#"
1431impl Test {
1432 pub fn spawn(&mut self) {
1433 let state = Rc::new(RefCell::new(0));
1434 let closure = move || {};
1435 }
1436}
1437"#;
1438
1439 std::fs::File::create(&rs_file_path)
1440 .expect("Failed to create file")
1441 .write_all(code.as_bytes())
1442 .expect("Failed to write file");
1443
1444 let mut linter = StateSyncLinter::new();
1445 let report = linter
1446 .lint_directory(temp_dir.path())
1447 .expect("lint_directory failed");
1448
1449 assert_eq!(report.files_analyzed, 1);
1450 assert!(report.lines_analyzed > 0);
1451 }
1452
1453 #[test]
1454 fn test_lint_directory_skips_hidden_and_target() {
1455 use std::io::Write;
1456
1457 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
1458
1459 let hidden_dir = temp_dir.path().join(".hidden");
1461 std::fs::create_dir(&hidden_dir).expect("Failed to create .hidden dir");
1462 let hidden_file = hidden_dir.join("hidden.rs");
1463 std::fs::File::create(&hidden_file)
1464 .expect("Failed to create hidden file")
1465 .write_all(b"fn hidden() {}")
1466 .expect("Failed to write");
1467
1468 let target_dir = temp_dir.path().join("target");
1470 std::fs::create_dir(&target_dir).expect("Failed to create target dir");
1471 let target_file = target_dir.join("generated.rs");
1472 std::fs::File::create(&target_file)
1473 .expect("Failed to create target file")
1474 .write_all(b"fn generated() {}")
1475 .expect("Failed to write");
1476
1477 let regular_file = temp_dir.path().join("src.rs");
1479 std::fs::File::create(®ular_file)
1480 .expect("Failed to create regular file")
1481 .write_all(b"fn regular() {}")
1482 .expect("Failed to write");
1483
1484 let mut linter = StateSyncLinter::new();
1485 let report = linter
1486 .lint_directory(temp_dir.path())
1487 .expect("lint_directory failed");
1488
1489 assert_eq!(report.files_analyzed, 1);
1491 }
1492
1493 #[test]
1494 fn test_lint_file_not_found() {
1495 let mut linter = StateSyncLinter::new();
1496 let result = linter.lint_file(std::path::Path::new("/nonexistent/path/file.rs"));
1497 assert!(result.is_err());
1498 assert!(result
1499 .unwrap_err()
1500 .contains("Failed to read /nonexistent/path/file.rs"));
1501 }
1502
1503 #[test]
1504 fn test_lint_file_success() {
1505 use std::io::Write;
1506
1507 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
1508 let rs_file = temp_dir.path().join("test.rs");
1509
1510 let code = "fn test() { let x = 1; }";
1511 std::fs::File::create(&rs_file)
1512 .expect("Failed to create file")
1513 .write_all(code.as_bytes())
1514 .expect("Failed to write");
1515
1516 let mut linter = StateSyncLinter::new();
1517 let report = linter.lint_file(&rs_file).expect("lint_file failed");
1518
1519 assert_eq!(report.files_analyzed, 1);
1520 assert_eq!(report.lines_analyzed, 1);
1521 }
1522
1523 #[test]
1524 fn test_collect_type_info_function_returning_rc() {
1525 let mut linter = StateSyncLinter::new();
1526 let mut report = StateSyncReport::default();
1527
1528 let code = r#"
1529fn create_state() -> Rc<RefCell<State>> {
1530 Rc::new(RefCell::new(State::default()))
1531}
1532"#;
1533
1534 linter.collect_type_info(code, &mut report);
1535
1536 assert!(linter.rc_returning_functions.contains("create_state"));
1537 assert!(report.errors.iter().any(|e| e.rule == "WASM-SS-007"));
1538 }
1539
1540 #[test]
1541 fn test_collect_type_info_type_alias() {
1542 let mut linter = StateSyncLinter::new();
1543 let mut report = StateSyncReport::default();
1544
1545 let code = r#"
1546type SharedState = Rc<RefCell<State>>;
1547"#;
1548
1549 linter.collect_type_info(code, &mut report);
1550
1551 assert!(linter.rc_type_aliases.contains("SharedState"));
1552 assert!(report.errors.iter().any(|e| e.rule == "WASM-SS-006"));
1553 }
1554
1555 #[test]
1556 fn test_lint_source_text_based_directly() {
1557 let mut linter = StateSyncLinter::new();
1558 linter.current_file = "test.rs".to_string();
1559
1560 let code = r#"
1561impl Worker {
1562 pub fn on_event(&mut self) {
1563 let state = Rc::new(RefCell::new(0));
1564 let cb = Closure::wrap(Box::new(move || {}));
1565 }
1566}
1567"#;
1568
1569 let report = linter
1570 .lint_source_text_based(code)
1571 .expect("lint_source_text_based failed");
1572
1573 assert!(report.files_analyzed == 1);
1574 assert!(report.lines_analyzed > 0);
1575 }
1576
1577 #[test]
1578 fn test_severity_equality() {
1579 assert_eq!(LintSeverity::Error, LintSeverity::Error);
1580 assert_eq!(LintSeverity::Warning, LintSeverity::Warning);
1581 assert_eq!(LintSeverity::Info, LintSeverity::Info);
1582 assert_ne!(LintSeverity::Error, LintSeverity::Warning);
1583 assert_ne!(LintSeverity::Warning, LintSeverity::Info);
1584 }
1585
1586 #[test]
1587 fn test_lint_error_clone() {
1588 let err = LintError {
1589 rule: "TEST-001".to_string(),
1590 message: "test message".to_string(),
1591 file: "test.rs".to_string(),
1592 line: 1,
1593 column: 1,
1594 severity: LintSeverity::Error,
1595 suggestion: Some("fix it".to_string()),
1596 };
1597
1598 let cloned = err.clone();
1599 assert_eq!(err.rule, cloned.rule);
1600 assert_eq!(err.message, cloned.message);
1601 assert_eq!(err.file, cloned.file);
1602 assert_eq!(err.line, cloned.line);
1603 assert_eq!(err.column, cloned.column);
1604 assert_eq!(err.severity, cloned.severity);
1605 assert_eq!(err.suggestion, cloned.suggestion);
1606 }
1607
1608 #[test]
1609 fn test_linter_default() {
1610 let linter = StateSyncLinter::default();
1611 assert!(linter.closure_creators.contains("Closure::wrap"));
1613 assert!(linter.closure_creators.contains("move ||"));
1614 }
1615}