1use super::{LintError, LintSeverity, StateSyncReport};
34use syn::visit::Visit;
35use syn::{ExprMethodCall, Macro};
36
37const PANIC_METHODS: &[&str] = &["unwrap", "expect"];
39
40const PANIC_MACROS: &[&str] = &["panic", "unreachable", "todo", "unimplemented"];
42
43#[derive(Debug)]
45pub struct PanicPathVisitor {
46 file: String,
48 errors: Vec<LintError>,
50 source: String,
52 in_test_module: bool,
54 in_unsafe_block: bool,
56}
57
58impl PanicPathVisitor {
59 #[must_use]
61 pub fn new(file: String, source: String) -> Self {
62 Self {
63 file,
64 errors: Vec::new(),
65 source,
66 in_test_module: false,
67 in_unsafe_block: false,
68 }
69 }
70
71 fn span_to_line(&self, span: proc_macro2::Span) -> usize {
73 span.start().line
74 }
75
76 fn span_to_column(&self, span: proc_macro2::Span) -> usize {
78 span.start().column + 1
79 }
80
81 fn get_line_content(&self, line: usize) -> String {
83 self.source
84 .lines()
85 .nth(line.saturating_sub(1))
86 .unwrap_or("")
87 .trim()
88 .to_string()
89 }
90
91 fn is_panic_method(method: &str) -> bool {
93 PANIC_METHODS.contains(&method)
94 }
95
96 fn is_panic_macro(path: &syn::Path) -> bool {
98 if let Some(ident) = path.get_ident() {
99 let name = ident.to_string();
100 return PANIC_MACROS.contains(&name.as_str());
101 }
102 if let Some(last) = path.segments.last() {
104 let name = last.ident.to_string();
105 return PANIC_MACROS.contains(&name.as_str());
106 }
107 false
108 }
109
110 fn macro_severity(name: &str) -> LintSeverity {
112 match name {
113 "unreachable" => LintSeverity::Warning, _ => LintSeverity::Error,
115 }
116 }
117
118 fn suggestion_for_method(method: &str) -> String {
120 match method {
121 "unwrap" => {
122 "Use `ok_or(err)?` or `unwrap_or_default()` instead of `unwrap()`".to_string()
123 }
124 "expect" => "Use `ok_or(err)?` or `unwrap_or_else(|| default)` instead of `expect()`"
125 .to_string(),
126 _ => format!("Avoid `{method}()` in WASM code"),
127 }
128 }
129
130 fn suggestion_for_macro(name: &str) -> String {
132 match name {
133 "panic" => {
134 "Return a Result or use `wasm_bindgen::throw_str` for controlled errors".to_string()
135 }
136 "unreachable" => {
137 "Use `unreachable!()` only when truly unreachable; prefer `debug_assert!`"
138 .to_string()
139 }
140 "todo" => "Implement the function or return `Err(\"not implemented\")`.to_string()`"
141 .to_string(),
142 "unimplemented" => {
143 "Implement the function or return an error instead of panicking".to_string()
144 }
145 _ => format!("Avoid `{name}!()` in WASM code"),
146 }
147 }
148
149 #[must_use]
151 pub fn into_report(self, lines_analyzed: usize) -> StateSyncReport {
152 StateSyncReport {
153 errors: self.errors,
154 files_analyzed: 1,
155 lines_analyzed,
156 }
157 }
158
159 #[must_use]
161 pub fn errors(&self) -> &[LintError] {
162 &self.errors
163 }
164}
165
166impl<'ast> Visit<'ast> for PanicPathVisitor {
167 fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) {
168 let is_test = node.attrs.iter().any(|attr| {
170 attr.path().is_ident("cfg")
171 && attr
172 .meta
173 .require_list()
174 .ok()
175 .and_then(|list| list.parse_args::<syn::Ident>().ok())
176 .is_some_and(|ident| ident == "test")
177 });
178
179 let was_in_test = self.in_test_module;
180 if is_test {
181 self.in_test_module = true;
182 }
183
184 syn::visit::visit_item_mod(self, node);
185
186 self.in_test_module = was_in_test;
187 }
188
189 fn visit_expr_unsafe(&mut self, node: &'ast syn::ExprUnsafe) {
190 let was_unsafe = self.in_unsafe_block;
191 self.in_unsafe_block = true;
192 syn::visit::visit_expr_unsafe(self, node);
193 self.in_unsafe_block = was_unsafe;
194 }
195
196 fn visit_expr_method_call(&mut self, node: &'ast ExprMethodCall) {
197 if self.in_test_module {
199 syn::visit::visit_expr_method_call(self, node);
200 return;
201 }
202
203 let method_name = node.method.to_string();
204
205 if Self::is_panic_method(&method_name) {
206 let line = self.span_to_line(node.method.span());
207 let column = self.span_to_column(node.method.span());
208 let line_content = self.get_line_content(line);
209
210 let has_allow = line_content.contains("#[allow(")
212 || line_content.contains("// SAFETY:")
213 || line_content.contains("// PANIC:");
214
215 if !has_allow {
216 let rule = match method_name.as_str() {
217 "unwrap" => "WASM-PANIC-001",
218 "expect" => "WASM-PANIC-002",
219 _ => "WASM-PANIC-000",
220 };
221
222 self.errors.push(LintError {
223 rule: rule.to_string(),
224 message: format!(
225 "`{method_name}()` can panic, which terminates WASM execution"
226 ),
227 file: self.file.clone(),
228 line,
229 column,
230 severity: LintSeverity::Error,
231 suggestion: Some(Self::suggestion_for_method(&method_name)),
232 });
233 }
234 }
235
236 syn::visit::visit_expr_method_call(self, node);
237 }
238
239 fn visit_macro(&mut self, node: &'ast Macro) {
240 if self.in_test_module {
242 syn::visit::visit_macro(self, node);
243 return;
244 }
245
246 if Self::is_panic_macro(&node.path) {
247 let macro_name = node
248 .path
249 .segments
250 .last()
251 .map(|s| s.ident.to_string())
252 .unwrap_or_default();
253
254 let line = self.span_to_line(
255 node.path
256 .segments
257 .first()
258 .map_or_else(|| proc_macro2::Span::call_site(), |s| s.ident.span()),
259 );
260 let column = self.span_to_column(
261 node.path
262 .segments
263 .first()
264 .map_or_else(|| proc_macro2::Span::call_site(), |s| s.ident.span()),
265 );
266
267 let rule = match macro_name.as_str() {
268 "panic" => "WASM-PANIC-003",
269 "unreachable" => "WASM-PANIC-004",
270 "todo" => "WASM-PANIC-005",
271 "unimplemented" => "WASM-PANIC-006",
272 _ => "WASM-PANIC-000",
273 };
274
275 self.errors.push(LintError {
276 rule: rule.to_string(),
277 message: format!("`{macro_name}!()` panics, which terminates WASM execution"),
278 file: self.file.clone(),
279 line,
280 column,
281 severity: Self::macro_severity(¯o_name),
282 suggestion: Some(Self::suggestion_for_macro(¯o_name)),
283 });
284 }
285
286 syn::visit::visit_macro(self, node);
287 }
288
289 fn visit_expr_index(&mut self, node: &'ast syn::ExprIndex) {
290 if self.in_test_module || self.in_unsafe_block {
292 syn::visit::visit_expr_index(self, node);
293 return;
294 }
295
296 let line = self.span_to_line(node.bracket_token.span.open());
298 let column = self.span_to_column(node.bracket_token.span.open());
299
300 self.errors.push(LintError {
301 rule: "WASM-PANIC-007".to_string(),
302 message: "Direct indexing can panic on out-of-bounds access".to_string(),
303 file: self.file.clone(),
304 line,
305 column,
306 severity: LintSeverity::Warning,
307 suggestion: Some("Use `.get(index)` with proper error handling instead".to_string()),
308 });
309
310 syn::visit::visit_expr_index(self, node);
311 }
312}
313
314pub fn lint_panic_paths(source: &str, file: &str) -> Result<StateSyncReport, String> {
326 let syntax = syn::parse_file(source).map_err(|e| format!("Parse error: {e}"))?;
327
328 let lines = source.lines().count();
329 let mut visitor = PanicPathVisitor::new(file.to_string(), source.to_string());
330
331 visitor.visit_file(&syntax);
332
333 Ok(visitor.into_report(lines))
334}
335
336#[derive(Debug, Default)]
338pub struct PanicPathSummary {
339 pub unwrap_count: usize,
341 pub expect_count: usize,
343 pub panic_count: usize,
345 pub unreachable_count: usize,
347 pub todo_count: usize,
349 pub unimplemented_count: usize,
351 pub index_count: usize,
353}
354
355impl PanicPathSummary {
356 #[must_use]
358 pub fn from_report(report: &StateSyncReport) -> Self {
359 let mut summary = Self::default();
360
361 for error in &report.errors {
362 match error.rule.as_str() {
363 "WASM-PANIC-001" => summary.unwrap_count += 1,
364 "WASM-PANIC-002" => summary.expect_count += 1,
365 "WASM-PANIC-003" => summary.panic_count += 1,
366 "WASM-PANIC-004" => summary.unreachable_count += 1,
367 "WASM-PANIC-005" => summary.todo_count += 1,
368 "WASM-PANIC-006" => summary.unimplemented_count += 1,
369 "WASM-PANIC-007" => summary.index_count += 1,
370 _ => {}
371 }
372 }
373
374 summary
375 }
376
377 #[must_use]
379 pub fn total(&self) -> usize {
380 self.unwrap_count
381 + self.expect_count
382 + self.panic_count
383 + self.unreachable_count
384 + self.todo_count
385 + self.unimplemented_count
386 + self.index_count
387 }
388
389 #[must_use]
391 pub fn error_count(&self) -> usize {
392 self.unwrap_count
393 + self.expect_count
394 + self.panic_count
395 + self.todo_count
396 + self.unimplemented_count
397 }
398}
399
400#[cfg(test)]
401mod tests {
402 use super::*;
403
404 #[test]
405 fn test_detect_unwrap() {
406 let source = r#"
407 fn example() {
408 let x = Some(5);
409 let y = x.unwrap();
410 }
411 "#;
412
413 let report = lint_panic_paths(source, "test.rs").expect("parse failed");
414 assert!(!report.errors.is_empty());
415 assert!(report.errors.iter().any(|e| e.rule == "WASM-PANIC-001"));
416 }
417
418 #[test]
419 fn test_detect_expect() {
420 let source = r#"
421 fn example() {
422 let x = Some(5);
423 let y = x.expect("should exist");
424 }
425 "#;
426
427 let report = lint_panic_paths(source, "test.rs").expect("parse failed");
428 assert!(report.errors.iter().any(|e| e.rule == "WASM-PANIC-002"));
429 }
430
431 #[test]
432 fn test_detect_panic_macro() {
433 let source = r#"
434 fn example() {
435 panic!("something went wrong");
436 }
437 "#;
438
439 let report = lint_panic_paths(source, "test.rs").expect("parse failed");
440 assert!(report.errors.iter().any(|e| e.rule == "WASM-PANIC-003"));
441 }
442
443 #[test]
444 fn test_detect_unreachable() {
445 let source = r#"
446 fn example(x: bool) {
447 if x {
448 return;
449 }
450 unreachable!();
451 }
452 "#;
453
454 let report = lint_panic_paths(source, "test.rs").expect("parse failed");
455 assert!(report.errors.iter().any(|e| e.rule == "WASM-PANIC-004"));
456 let unreachable_error = report
458 .errors
459 .iter()
460 .find(|e| e.rule == "WASM-PANIC-004")
461 .unwrap();
462 assert_eq!(unreachable_error.severity, LintSeverity::Warning);
463 }
464
465 #[test]
466 fn test_detect_todo() {
467 let source = r#"
468 fn example() {
469 todo!("implement this");
470 }
471 "#;
472
473 let report = lint_panic_paths(source, "test.rs").expect("parse failed");
474 assert!(report.errors.iter().any(|e| e.rule == "WASM-PANIC-005"));
475 }
476
477 #[test]
478 fn test_detect_unimplemented() {
479 let source = r#"
480 fn example() {
481 unimplemented!();
482 }
483 "#;
484
485 let report = lint_panic_paths(source, "test.rs").expect("parse failed");
486 assert!(report.errors.iter().any(|e| e.rule == "WASM-PANIC-006"));
487 }
488
489 #[test]
490 fn test_detect_index() {
491 let source = r#"
492 fn example() {
493 let arr = [1, 2, 3];
494 let x = arr[0];
495 }
496 "#;
497
498 let report = lint_panic_paths(source, "test.rs").expect("parse failed");
499 assert!(report.errors.iter().any(|e| e.rule == "WASM-PANIC-007"));
500 }
501
502 #[test]
503 fn test_skip_in_test_module() {
504 let source = r#"
505 #[cfg(test)]
506 mod tests {
507 fn test_example() {
508 let x = Some(5);
509 let y = x.unwrap(); // Should be skipped
510 }
511 }
512 "#;
513
514 let report = lint_panic_paths(source, "test.rs").expect("parse failed");
515 assert!(
517 report.errors.is_empty(),
518 "Test modules should be skipped: {:?}",
519 report.errors
520 );
521 }
522
523 #[test]
524 fn test_summary() {
525 let source = r#"
526 fn example() {
527 let x = Some(5);
528 x.unwrap();
529 x.unwrap();
530 x.expect("msg");
531 panic!("oops");
532 todo!();
533 }
534 "#;
535
536 let report = lint_panic_paths(source, "test.rs").expect("parse failed");
537 let summary = PanicPathSummary::from_report(&report);
538
539 assert_eq!(summary.unwrap_count, 2);
540 assert_eq!(summary.expect_count, 1);
541 assert_eq!(summary.panic_count, 1);
542 assert_eq!(summary.todo_count, 1);
543 assert_eq!(summary.total(), 5);
544 }
545
546 #[test]
547 fn test_clean_code_passes() {
548 let source = r#"
549 fn example() -> Option<i32> {
550 let x = Some(5);
551 let y = x?;
552 Some(y + 1)
553 }
554
555 fn example2() -> Result<i32, &'static str> {
556 let x: Option<i32> = Some(5);
557 let y = x.ok_or("missing")?;
558 Ok(y + 1)
559 }
560 "#;
561
562 let report = lint_panic_paths(source, "test.rs").expect("parse failed");
563 assert!(
565 report.errors.is_empty(),
566 "Clean code should pass: {:?}",
567 report.errors
568 );
569 }
570}