1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
//! SEM003: Unreachable code after exit/return/exec
//!
//! **Rule**: Detect statements that follow unconditional `exit`, `return`, or `exec`
//! at the same block level, which can never be reached.
//!
//! **Why this matters**:
//! Dead code after exit/return/exec is never executed, indicating:
//! - Forgotten cleanup of debugging code
//! - Logic errors in control flow
//! - Misleading code that suggests behavior that never happens
//!
//! ## Examples
//!
//! ❌ **BAD** (unreachable code):
//! ```bash
//! echo "starting"
//! exit 0
//! echo "this never runs" # SEM003
//! ```
//!
//! ✅ **GOOD** (exit is last statement):
//! ```bash
//! echo "starting"
//! exit 0
//! ```
//!
//! ✅ **OK** (exit inside conditional — code after if IS reachable):
//! ```bash
//! if [ "$1" = "stop" ]; then
//! exit 0
//! fi
//! echo "continuing" # reachable when condition is false
//! ```
use crate::linter::{Diagnostic, LintResult, Severity, Span};
/// Commands that unconditionally terminate execution in the current scope.
const EXIT_COMMANDS: &[&str] = &["exit", "return", "exec"];
/// Check for unreachable code after exit/return/exec statements.
pub fn check(source: &str) -> LintResult {
let mut result = LintResult::new();
// Track nesting depth: we only flag dead code at the same nesting level
// as the exit statement. Code inside if/while/for/case blocks is at a
// deeper level and doesn't count.
let mut depth: i32 = 0;
let mut exit_at_depth_zero: Option<(usize, &str)> = None; // (line_num, command)
for (line_num, line) in source.lines().enumerate() {
let trimmed = line.trim();
// Skip empty lines and comments
if trimmed.is_empty() || trimmed.starts_with('#') {
// If we've seen an exit and this is just whitespace/comment, skip
continue;
}
// Track block depth changes
let depth_change = compute_depth_change(trimmed);
depth += depth_change;
// Only analyze top-level statements (depth 0)
if depth != 0 {
// Reset exit tracking when we enter a block
exit_at_depth_zero = None;
continue;
}
// Check if this line is an exit command at depth 0
if is_exit_command(trimmed) {
if exit_at_depth_zero.is_none() {
let cmd = extract_exit_command(trimmed);
exit_at_depth_zero = Some((line_num + 1, cmd));
}
continue;
}
// If we've seen an exit at depth 0 and this is a non-empty statement, flag it
if let Some((exit_line, exit_cmd)) = exit_at_depth_zero {
// This line is unreachable
let msg = format!("Unreachable code after '{exit_cmd}' on line {exit_line}");
result.add(Diagnostic::new(
"SEM003",
Severity::Warning,
&msg,
Span::new(line_num + 1, 1, line_num + 1, trimmed.len()),
));
// Only flag the first unreachable line to avoid noise
exit_at_depth_zero = None;
}
}
result
}
/// Check if a trimmed line is an unconditional exit/return/exec command.
fn is_exit_command(trimmed: &str) -> bool {
// Match: exit, exit N, return, return N, exec CMD
// Don't match: exit_handler(), return_value=, execution_log, etc.
for cmd in EXIT_COMMANDS {
if trimmed == *cmd
|| trimmed.starts_with(&format!("{cmd} "))
|| trimmed.starts_with(&format!("{cmd}\t"))
// Handle exit with semicolons: "exit 0;"
|| trimmed.starts_with(&format!("{cmd};"))
{
// Make sure it's not a variable assignment or function name
// e.g., "exit_code=1" should NOT match
return true;
}
}
false
}
/// Extract the exit command name from a line.
fn extract_exit_command(trimmed: &str) -> &str {
for cmd in EXIT_COMMANDS {
if trimmed.starts_with(cmd) {
return cmd;
}
}
"exit"
}
/// Compute the net depth change for a line.
///
/// Positive = entering a block, negative = leaving a block.
/// This is approximate but handles common patterns:
/// - `if/then/while/for/case` → +1
/// - `fi/done/esac` → -1
/// - `else/elif` → 0 (same level)
fn compute_depth_change(trimmed: &str) -> i32 {
let mut change = 0i32;
// "then" / "do" on own line — already counted via if/while/for
if trimmed == "then" || trimmed == "do" {
return 0;
}
// Block openers
let is_opener = trimmed.starts_with("if ") || trimmed == "if"
|| trimmed.starts_with("while ")
|| trimmed.starts_with("until ")
|| trimmed.starts_with("for ")
|| trimmed.starts_with("case ");
if is_opener {
change += 1;
}
// Block closers
let closers = ["fi", "done", "esac"];
for closer in closers {
if trimmed == closer
|| trimmed.starts_with(&format!("{closer};"))
|| trimmed.starts_with(&format!("{closer} "))
{
change -= 1;
}
}
// Handle "if ...; then ... fi" all on one line (net 0)
if (trimmed.contains("; then") && trimmed.contains("; fi"))
|| (trimmed.contains("; do") && trimmed.contains("; done"))
{
return 0;
}
change
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pmat213_exit_then_code() {
let src = "#!/bin/bash\nexit 0\necho unreachable";
let result = check(src);
assert!(
result.diagnostics.iter().any(|d| d.code == "SEM003"),
"Should detect unreachable code after exit"
);
}
#[test]
fn test_pmat213_return_then_code() {
let src = "#!/bin/bash\nreturn 1\necho unreachable";
let result = check(src);
assert!(result.diagnostics.iter().any(|d| d.code == "SEM003"));
}
#[test]
fn test_pmat213_exec_then_code() {
let src = "#!/bin/bash\nexec /bin/sh\necho unreachable";
let result = check(src);
assert!(result.diagnostics.iter().any(|d| d.code == "SEM003"));
}
#[test]
fn test_pmat213_exit_last_line_no_warning() {
let src = "#!/bin/bash\necho hello\nexit 0";
let result = check(src);
assert!(
result.diagnostics.is_empty(),
"Exit as last statement should not trigger SEM003"
);
}
#[test]
fn test_pmat213_exit_in_if_not_dead() {
let src = "#!/bin/bash\nif [ -f /tmp/x ]; then\n exit 1\nfi\necho reachable";
let result = check(src);
assert!(
!result.diagnostics.iter().any(|d| d.code == "SEM003"),
"Code after if-block with exit should NOT be flagged as dead"
);
}
#[test]
fn test_pmat213_no_false_positive_on_exit_variable() {
let src = "#!/bin/bash\nexit_code=1\necho $exit_code";
let result = check(src);
assert!(
!result.diagnostics.iter().any(|d| d.code == "SEM003"),
"exit_code= should not trigger SEM003"
);
}
#[test]
fn test_pmat213_comments_after_exit_not_flagged() {
let src = "#!/bin/bash\nexit 0\n# This is a comment\n";
let result = check(src);
assert!(result.diagnostics.is_empty());
}
#[test]
fn test_pmat213_is_exit_command() {
assert!(is_exit_command("exit"));
assert!(is_exit_command("exit 0"));
assert!(is_exit_command("exit 1"));
assert!(is_exit_command("return"));
assert!(is_exit_command("return 0"));
assert!(is_exit_command("exec /bin/sh"));
assert!(!is_exit_command("exit_code=1"));
assert!(!is_exit_command("return_value"));
assert!(!is_exit_command("execution_log"));
}
#[test]
fn test_pmat213_depth_tracking() {
assert_eq!(compute_depth_change("if [ -f x ]; then"), 1);
assert_eq!(compute_depth_change("fi"), -1);
assert_eq!(compute_depth_change("while true; do"), 1);
assert_eq!(compute_depth_change("done"), -1);
assert_eq!(compute_depth_change("echo hello"), 0);
}
}