Unwrap Abuse Linter¶
AI Agent Context (click to expand)
Purpose: Complete guide to using the unwrap abuse linter for detecting .unwrap() and .expect() calls in Rust code that may panic at runtime
Scope: Configuration, usage, refactoring patterns, and best practices for unwrap/expect abuse detection
Overview: Comprehensive documentation for the unwrap abuse linter that detects .unwrap() and .expect() calls in Rust code. Covers how the linter works using tree-sitter AST analysis, configuration options (including test-aware filtering), CLI and library usage, Rust code examples with safer alternatives, common refactoring patterns using the ? operator, unwrap_or(), unwrap_or_default(), match/if let, and anyhow::Context, and integration with CI/CD pipelines. Helps teams write more robust Rust code by encouraging explicit error handling instead of panicking.
Dependencies: tree-sitter-rust (AST parsing), src.analyzers.rust_base (base analyzer)
Exports: Usage documentation, configuration examples, refactoring patterns
Related: cli-reference.md for CLI commands, configuration.md for config format, how-to-ignore-violations.md for ignore patterns
Implementation: Tree-sitter AST-based detection with test-aware filtering and configurable allow/ignore rules
This follows the AI-Optimized Documentation Standard.
Try It Now¶
Example output:
src/main.rs:15 - .unwrap() call may panic at runtime: let config = File::open("config.toml").unwrap();
Suggestion: Use the ? operator, .unwrap_or(), .unwrap_or_default(), or match/if-let for safe error handling.
Fix it: Replace .unwrap() calls with the ? operator, unwrap_or(), unwrap_or_default(), or match/if let expressions for safe error handling.
Overview¶
The unwrap abuse linter detects .unwrap() and .expect() calls in Rust code that may panic at runtime. It analyzes Rust source files using tree-sitter AST parsing to identify method calls that bypass proper error handling.
What is Unwrap Abuse?¶
Unwrap abuse is the practice of calling .unwrap() or .expect() on Result<T, E> or Option<T> values without handling the error or None case. These calls panic if the value is Err or None, crashing the program:
// Bad - panics if file doesn't exist
fn load_config() -> Config {
let file = File::open("config.toml").unwrap();
let contents = std::io::read_to_string(file).unwrap();
toml::from_str(&contents).unwrap()
}
// Good - propagates errors to caller
fn load_config() -> Result<Config, Box<dyn Error>> {
let file = File::open("config.toml")?;
let contents = std::io::read_to_string(file)?;
let config = toml::from_str(&contents)?;
Ok(config)
}
Why Eliminate Unwrap Abuse?¶
Unwrap abuse is problematic because:
- Runtime panics: .unwrap() crashes the program on unexpected input
- Poor error messages: Bare .unwrap() provides no context about why it failed
- Fragile code: Works during development but fails in production with edge cases
- Hides error paths: Skips error handling rather than addressing failure scenarios
- Infectious pattern: AI-generated code frequently uses .unwrap() as a shortcut
Benefits¶
- Robustness: Programs handle errors gracefully instead of crashing
- Better diagnostics: Error messages propagate context about what went wrong
- Production safety: Code handles edge cases that only appear under real workloads
- Idiomatic Rust: Follows Rust community best practices for error handling
- Team consistency: Enforces shared code quality standards for error handling
How It Works¶
Tree-Sitter AST Detection¶
The linter uses tree-sitter AST parsing to analyze Rust code structure:
-
Parse source code into AST using
tree-sitter-rust -
Find method calls by traversing the AST for
call_expressionnodes: - Inspects
field_expressionchildren forfield_identifiernodes -
Matches method names
"unwrap"or"expect" -
Determine test context for each call:
- Checks if the call is inside a
#[test]function - Checks if the call is inside a
#[cfg(test)]module -
Uses
is_inside_test()from the base Rust analyzer -
Apply configuration filters:
- Skip calls in test code (if
allow_in_testsis enabled) - Skip
.expect()calls (ifallow_expectis enabled) -
Skip files matching ignore patterns
-
Report violations with suggestions for safer alternatives
Rule IDs¶
| Rule ID | Trigger | Description |
|---|---|---|
unwrap-abuse.unwrap-call |
.unwrap() |
Bare unwrap call without error context |
unwrap-abuse.expect-call |
.expect() |
Expect call with message, still panics |
Test-Aware Detection¶
The linter understands Rust test conventions and skips calls inside test code by default:
// Skipped - inside #[test] function
#[test]
fn test_parsing() {
let result = parse("input").unwrap(); // OK in tests
assert_eq!(result, expected);
}
// Skipped - inside #[cfg(test)] module
#[cfg(test)]
mod tests {
fn helper() {
let val = compute().unwrap(); // OK in test modules
}
}
// Flagged - production code
fn process_request(input: &str) -> Response {
let data = parse(input).unwrap(); // VIOLATION
build_response(data)
}
Configuration¶
Basic Configuration¶
Create .thailint.yaml:
unwrap-abuse:
enabled: true
allow_in_tests: true # Allow in #[test] and #[cfg(test)]
allow_expect: true # Allow .expect() (provides panic context)
ignore:
- "examples/"
- "benches/"
- "tests/"
Configuration Options¶
| Option | Type | Default | Description |
|---|---|---|---|
enabled |
boolean | true |
Enable/disable unwrap abuse linter |
allow_in_tests |
boolean | true |
Allow .unwrap()/.expect() in #[test] functions and #[cfg(test)] modules |
allow_expect |
boolean | true |
Allow .expect() calls (community-recommended alternative to bare .unwrap()) |
ignore |
array | ["examples/", "benches/", "tests/"] |
File path patterns to ignore |
Recommended Configurations¶
Strict (flag everything):
Standard (default - allow tests and expect):
unwrap-abuse:
enabled: true
allow_in_tests: true
allow_expect: true
ignore:
- "examples/"
- "benches/"
- "tests/"
Lenient (only flag bare unwrap in production code):
unwrap-abuse:
enabled: true
allow_in_tests: true
allow_expect: true
ignore:
- "examples/"
- "benches/"
- "tests/"
- "build.rs"
Rationale for Defaults¶
allow_in_tests: true: Test code uses.unwrap()idiomatically because panicking is the desired behavior for test failures. Flagging test code generates excessive noise.allow_expect: true: The Rust community recommends.expect("descriptive message")as a deliberate alternative to bare.unwrap(). It provides panic context and signals intentional design rather than lazy error handling.ignore: ["examples/", "benches/", "tests/"]: Example and benchmark code prioritizes clarity over robustness. Test directories are ignored at the file-path level in addition to the AST-levelallow_in_testscheck.
Ignoring Violations¶
See How to Ignore Violations for complete ignore guide.
Quick examples:
// Line-level ignore
let port = env::var("PORT").unwrap(); // thailint: ignore[unwrap-abuse] - Set by orchestrator, guaranteed present
// File-level ignore (at top of file)
// thailint: ignore-file[unwrap-abuse]
Usage¶
CLI Mode¶
Basic Usage¶
# Check specific directory
thailint unwrap-abuse src/
# Check specific file
thailint unwrap-abuse src/main.rs
# Recursive check
thailint unwrap-abuse --recursive src/
With Configuration¶
# Use config file
thailint unwrap-abuse --config .thailint.yaml src/
# Auto-discover config (.thailint.yaml or .thailint.json)
thailint unwrap-abuse src/
Output Formats¶
# Human-readable text (default)
thailint unwrap-abuse src/
# JSON output for CI/CD
thailint unwrap-abuse --format json src/
# SARIF output for GitHub Code Scanning
thailint unwrap-abuse --format sarif src/ > report.sarif
# JSON with exit code check
thailint unwrap-abuse --format json src/ > report.json
echo "Exit code: $?"
Additional Options¶
# Verbose output (debug logging)
thailint unwrap-abuse --verbose src/
# Parallel execution
thailint unwrap-abuse --parallel src/
Library Mode¶
High-Level API¶
from src import Linter
# Initialize with config file
linter = Linter(config_file='.thailint.yaml')
# Lint directory with unwrap-abuse rule
violations = linter.lint('src/', rules=['unwrap-abuse'])
# Process violations
if violations:
for v in violations:
print(f"{v.file_path}:{v.line} - {v.message}")
Direct Rule API¶
from src.linters.unwrap_abuse import UnwrapAbuseRule, UnwrapAbuseConfig
# Create rule with default config
rule = UnwrapAbuseRule()
# Create rule with custom config
config = UnwrapAbuseConfig(
allow_in_tests=True,
allow_expect=False, # Flag .expect() calls too
ignore=["examples/"],
)
rule = UnwrapAbuseRule(config=config)
Docker Mode¶
# Run with default config
docker run --rm -v $(pwd):/workspace \
washad/thailint:latest unwrap-abuse /workspace/src/
# With custom config file
docker run --rm \
-v $(pwd):/workspace \
-v $(pwd)/.thailint.yaml:/config/.thailint.yaml:ro \
washad/thailint:latest unwrap-abuse \
--config /config/.thailint.yaml /workspace/src/
Violation Examples¶
Example 1: File I/O with Unwrap¶
Code with violations:
use std::fs::File;
use std::io::Read;
fn load_settings() -> String {
let mut file = File::open("settings.json").unwrap(); // VIOLATION
let mut contents = String::new();
file.read_to_string(&mut contents).unwrap(); // VIOLATION
contents
}
Violation messages:
src/config.rs:5 - .unwrap() call may panic at runtime: let mut file = File::open("settings.json").unwrap();
Suggestion: Use the ? operator, .unwrap_or(), .unwrap_or_default(), or match/if-let for safe error handling.
src/config.rs:7 - .unwrap() call may panic at runtime: file.read_to_string(&mut contents).unwrap();
Suggestion: Use the ? operator, .unwrap_or(), .unwrap_or_default(), or match/if-let for safe error handling.
Refactored code:
use std::fs::File;
use std::io::{self, Read};
fn load_settings() -> io::Result<String> {
let mut file = File::open("settings.json")?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
Ok(contents)
}
Example 2: Option Handling with Unwrap¶
Code with violations:
fn get_first_word(text: &str) -> &str {
text.split_whitespace().next().unwrap() // VIOLATION - panics on empty string
}
fn find_user(users: &[User], id: u64) -> User {
users.iter().find(|u| u.id == id).unwrap().clone() // VIOLATION - panics if not found
}
Violation messages:
src/parser.rs:2 - .unwrap() call may panic at runtime: text.split_whitespace().next().unwrap()
Suggestion: Use the ? operator, .unwrap_or(), .unwrap_or_default(), or match/if-let for safe error handling.
src/parser.rs:6 - .unwrap() call may panic at runtime: users.iter().find(|u| u.id == id).unwrap().clone()
Suggestion: Use the ? operator, .unwrap_or(), .unwrap_or_default(), or match/if-let for safe error handling.
Refactored code:
fn get_first_word(text: &str) -> Option<&str> {
text.split_whitespace().next()
}
fn find_user(users: &[User], id: u64) -> Option<&User> {
users.iter().find(|u| u.id == id)
}
Example 3: Expect Calls (Flagged When allow_expect: false)¶
Code with violations (when allow_expect: false):
fn connect_database() -> Connection {
let url = env::var("DATABASE_URL")
.expect("DATABASE_URL must be set"); // VIOLATION
Connection::establish(&url)
.expect("Failed to connect to database"); // VIOLATION
}
Violation messages:
src/db.rs:3 - .expect() call may panic at runtime: .expect("DATABASE_URL must be set");
Suggestion: Use the ? operator with a descriptive error via .context() or .with_context(), or use match/if-let for explicit error handling.
src/db.rs:6 - .expect() call may panic at runtime: .expect("Failed to connect to database");
Suggestion: Use the ? operator with a descriptive error via .context() or .with_context(), or use match/if-let for explicit error handling.
Refactored code:
use anyhow::{Context, Result};
fn connect_database() -> Result<Connection> {
let url = env::var("DATABASE_URL")
.context("DATABASE_URL must be set")?;
let conn = Connection::establish(&url)
.context("Failed to connect to database")?;
Ok(conn)
}
Example 4: Acceptable Contexts (No Violations)¶
// Test code - OK (allow_in_tests: true)
#[test]
fn test_parsing() {
let result = parse("valid input").unwrap();
assert_eq!(result.value, 42);
}
// Test module - OK (allow_in_tests: true)
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_connection() {
let conn = connect("localhost").unwrap();
assert!(conn.is_alive());
}
}
// .expect() with message - OK (allow_expect: true, default)
fn init() {
let logger = Logger::init().expect("Logger must initialize");
}
Refactoring Patterns¶
Pattern 1: The ? Operator (Error Propagation)¶
The most idiomatic Rust pattern for handling Result and Option values.
Before:
fn read_config(path: &str) -> Config {
let file = File::open(path).unwrap();
let contents = std::io::read_to_string(file).unwrap();
serde_json::from_str(&contents).unwrap()
}
After:
fn read_config(path: &str) -> Result<Config, Box<dyn Error>> {
let file = File::open(path)?;
let contents = std::io::read_to_string(file)?;
let config = serde_json::from_str(&contents)?;
Ok(config)
}
When to use: Whenever the calling function can return a Result or Option. This is the preferred pattern for most Rust code.
Pattern 2: unwrap_or() (Default Value)¶
Provides a fallback value when the operation fails or returns None.
Before:
fn get_port() -> u16 {
env::var("PORT").unwrap().parse().unwrap()
}
fn get_username(user: &Option<User>) -> String {
user.as_ref().unwrap().name.clone()
}
After:
fn get_port() -> u16 {
env::var("PORT")
.unwrap_or_else(|_| "8080".to_string())
.parse()
.unwrap_or(8080)
}
fn get_username(user: &Option<User>) -> String {
user.as_ref()
.map(|u| u.name.clone())
.unwrap_or_else(|| "anonymous".to_string())
}
When to use: When a sensible default value exists and the error case is not exceptional.
Pattern 3: unwrap_or_default() (Type Default)¶
Uses the type's Default implementation as the fallback.
Before:
fn get_items(data: &HashMap<String, Vec<String>>, key: &str) -> Vec<String> {
data.get(key).unwrap().clone()
}
fn parse_count(input: &str) -> i32 {
input.parse().unwrap()
}
After:
fn get_items(data: &HashMap<String, Vec<String>>, key: &str) -> Vec<String> {
data.get(key).cloned().unwrap_or_default()
}
fn parse_count(input: &str) -> i32 {
input.parse().unwrap_or_default() // Returns 0 for i32
}
When to use: When the type implements Default and the default value (e.g., 0, "", []) is appropriate for the error case.
Pattern 4: match / if let (Explicit Handling)¶
Handles each case explicitly with custom logic.
Before:
fn process_message(queue: &mut VecDeque<Message>) {
let msg = queue.pop_front().unwrap();
handle(msg);
}
fn get_value(map: &HashMap<String, String>, key: &str) -> String {
map.get(key).unwrap().clone()
}
After:
fn process_message(queue: &mut VecDeque<Message>) {
if let Some(msg) = queue.pop_front() {
handle(msg);
} else {
log::warn!("Queue is empty, skipping processing");
}
}
fn get_value(map: &HashMap<String, String>, key: &str) -> String {
match map.get(key) {
Some(value) => value.clone(),
None => {
log::warn!("Key '{}' not found, using empty string", key);
String::new()
}
}
}
When to use: When different error cases need different handling, or when logging/metrics are needed for the failure path.
Pattern 5: anyhow::Context / .context() (Rich Errors)¶
Adds descriptive context to errors using the anyhow crate, replacing .expect() with non-panicking alternatives.
Before:
fn load_database() -> Database {
let url = env::var("DATABASE_URL")
.expect("DATABASE_URL must be set");
let pool = Pool::connect(&url)
.expect("Failed to establish database connection");
Database::new(pool)
}
After:
use anyhow::{Context, Result};
fn load_database() -> Result<Database> {
let url = env::var("DATABASE_URL")
.context("DATABASE_URL environment variable must be set")?;
let pool = Pool::connect(&url)
.with_context(|| format!("Failed to connect to database at {}", url))?;
Ok(Database::new(pool))
}
When to use: When replacing .expect() calls and rich error context is needed. The anyhow crate provides .context() for static messages and .with_context() for dynamic messages constructed with closures.
Pattern 6: Combining Patterns¶
Real-world code often combines multiple patterns.
Before:
fn process_request(req: &Request) -> Response {
let auth = req.headers().get("Authorization").unwrap();
let token = auth.to_str().unwrap();
let user = validate_token(token).unwrap();
let data = fetch_data(&user).unwrap();
let result = transform(data).unwrap();
Response::ok(result)
}
After:
use anyhow::{Context, Result};
fn process_request(req: &Request) -> Result<Response> {
let auth = req.headers()
.get("Authorization")
.context("Missing Authorization header")?; // Pattern 4 -> Pattern 5
let token = auth.to_str()
.context("Authorization header contains invalid UTF-8")?; // Pattern 5
let user = validate_token(token)
.context("Token validation failed")?; // Pattern 1 + 5
let data = fetch_data(&user)
.with_context(|| format!("Failed to fetch data for user {}", user.id))?; // Pattern 5
let result = transform(data)
.unwrap_or_default(); // Pattern 3
Ok(Response::ok(result))
}
Language Support¶
Rust Support¶
Fully Supported
Method calls detected:
- .unwrap() on Result<T, E> and Option<T>
- .expect("message") on Result<T, E> and Option<T>
Detection mechanism:
- Tree-sitter AST parsing via tree-sitter-rust
- Matches call_expression -> field_expression -> field_identifier nodes
- Identifies method names "unwrap" and "expect"
Test-aware contexts (skipped by default):
- #[test] annotated functions
- #[cfg(test)] modules
- Files in tests/ directory (via ignore config)
Ignored by default:
- examples/ directory
- benches/ directory
- tests/ directory
Other Languages¶
The unwrap abuse linter is Rust-specific. For error handling patterns in other languages, see: - Python: Exception handling (try/except) -- covered by other linters - TypeScript: Promise handling and null checks -- covered by other linters
CI/CD Integration¶
GitHub Actions¶
name: Lint
on: [push, pull_request]
jobs:
unwrap-abuse-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install thailint
run: pip install thailint
- name: Check for unwrap abuse
run: |
thailint unwrap-abuse src/
With SARIF Upload (GitHub Code Scanning)¶
name: Lint
on: [push, pull_request]
jobs:
unwrap-abuse-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install thailint
run: pip install thailint
- name: Check for unwrap abuse
run: |
thailint unwrap-abuse --format sarif src/ > unwrap-abuse.sarif
continue-on-error: true
- name: Upload SARIF results
uses: github/codeql-action/upload-sarif@v2
with:
sarif_file: unwrap-abuse.sarif
Pre-commit Hook¶
# .pre-commit-config.yaml
repos:
- repo: local
hooks:
- id: unwrap-abuse-check
name: Check for unwrap abuse in Rust
entry: thailint unwrap-abuse
language: python
types: [rust]
pass_filenames: true
Makefile Integration¶
lint-unwrap-abuse:
@echo "=== Checking for unwrap abuse ==="
@poetry run thailint unwrap-abuse src/ || exit 1
lint-all: lint-unwrap-abuse
@echo "All checks passed"
Performance¶
The unwrap abuse linter is designed for speed:
| Operation | Performance | Target |
|---|---|---|
| Single file parse (tree-sitter) | ~10-30ms | <100ms |
| Single file analysis | ~5-15ms | <50ms |
| 100 files | ~500ms | <2s |
| 1000 files | ~2-3s | <10s |
Optimizations: - Tree-sitter parsing provides fast, incremental AST construction - Recursive AST traversal stops early for non-matching node types - Test context detection uses efficient parent-node traversal - File-level ignore patterns skip entire files before parsing
Troubleshooting¶
Common Issues¶
Issue: .expect() calls are not flagged
# Problem - allow_expect is true (default)
unwrap-abuse:
allow_expect: true
# Solution - set allow_expect to false
unwrap-abuse:
allow_expect: false
The .expect() method is allowed by default because it provides panic context and is the Rust community recommended alternative to bare .unwrap(). Set allow_expect: false to flag .expect() calls as well.
Issue: Test code is flagged
# Problem - allow_in_tests is false
unwrap-abuse:
allow_in_tests: false
# Solution - allow unwrap in test code (default)
unwrap-abuse:
allow_in_tests: true
Issue: Example or benchmark files are flagged
# Problem - ignore list does not include the directory
unwrap-abuse:
ignore: []
# Solution - add directories to ignore list
unwrap-abuse:
ignore:
- "examples/"
- "benches/"
- "tests/"
Issue: No violations reported for Rust files
# Check that tree-sitter-rust is installed
python -c "import tree_sitter_rust; print('OK')"
# Check that the file is detected as Rust
thailint unwrap-abuse --verbose src/main.rs
Tree-sitter-rust must be installed for AST-based detection. Without it, the analyzer returns no results.
Issue: False positives in generated code
# Add generated directories to ignore list
unwrap-abuse:
ignore:
- "examples/"
- "benches/"
- "tests/"
- "generated/"
- "target/"
Issue: Violations in third-party vendored code
# Add vendored directories to ignore list
unwrap-abuse:
ignore:
- "examples/"
- "benches/"
- "tests/"
- "vendor/"
- "third_party/"
Best Practices¶
1. Prefer the ? Operator Over .unwrap()¶
The ? operator is the most idiomatic Rust pattern for error propagation:
// Bad - panics on error
fn read_file(path: &str) -> String {
std::fs::read_to_string(path).unwrap()
}
// Good - propagates error to caller
fn read_file(path: &str) -> io::Result<String> {
std::fs::read_to_string(path)
}
2. Use .expect() Only for Programming Errors¶
Reserve .expect() for cases where failure represents a bug, not a runtime condition:
// Acceptable - programmer error if regex is invalid
let re = Regex::new(r"^\d{4}-\d{2}-\d{2}$")
.expect("Date regex pattern is valid");
// Bad - runtime condition, not a programming error
let file = File::open(user_provided_path)
.expect("File should exist");
3. Add Context to Errors with anyhow¶
Use anyhow::Context to provide descriptive error messages:
use anyhow::{Context, Result};
// Bad - no context on failure
fn load_config() -> Result<Config> {
let text = std::fs::read_to_string("config.toml")?;
let config: Config = toml::from_str(&text)?;
Ok(config)
}
// Good - context explains what failed and why it matters
fn load_config() -> Result<Config> {
let text = std::fs::read_to_string("config.toml")
.context("Failed to read config.toml from working directory")?;
let config: Config = toml::from_str(&text)
.context("Failed to parse config.toml as valid TOML")?;
Ok(config)
}
4. Use unwrap_or_default() for Collection Operations¶
When working with collections, unwrap_or_default() provides clean fallbacks:
// Bad
let items: Vec<String> = map.get("key").unwrap().clone();
// Good
let items: Vec<String> = map.get("key").cloned().unwrap_or_default();
5. Handle Option with if let or map¶
Use combinators and pattern matching for Option values:
// Bad
let name = user.name.as_ref().unwrap();
// Good - if let
if let Some(name) = &user.name {
println!("Hello, {name}");
}
// Good - map/unwrap_or
let greeting = user.name
.as_ref()
.map(|n| format!("Hello, {n}"))
.unwrap_or_else(|| "Hello, stranger".to_string());
6. Allow .unwrap() in Tests¶
Test code benefits from .unwrap() because panicking is the desired behavior for unexpected failures:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_valid_parse() {
// .unwrap() is appropriate here - test should fail loudly
let result = parse("valid input").unwrap();
assert_eq!(result.value, 42);
}
}
Keep allow_in_tests: true (the default) to avoid noisy test violations.
7. Document Intentional .unwrap() Usage¶
When .unwrap() is genuinely safe, document why:
// The regex is a compile-time constant; this cannot fail at runtime.
let date_pattern = Regex::new(r"^\d{4}-\d{2}-\d{2}$").unwrap();
// The channel receiver is guaranteed to exist while sender is alive.
let msg = rx.recv().unwrap(); // thailint: ignore[unwrap-abuse] - Channel invariant
Related Documentation¶
- How to Ignore Violations - Complete ignore guide
- Configuration Reference - Config file format
- CLI Reference - Command-line usage
- API Reference - Library API documentation
- ReadTheDocs - Online documentation