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
//! MAKE017: Missing .ONESHELL
//!
//! **Rule**: Detect Makefiles without .ONESHELL directive for multi-line recipes
//!
//! **Why this matters**:
//! By default, Make executes each line of a recipe in a separate shell. This means
//! variables set in one line won't be visible in the next line, and `cd` commands
//! don't persist. .ONESHELL tells Make to execute the entire recipe in a single
//! shell, making multi-line recipes behave as expected.
//!
//! **Auto-fix**: Add .ONESHELL: at top of Makefile
//!
//! ## Examples
//!
//! ❌ **BAD** (without .ONESHELL - each line in separate shell):
//! ```makefile
//! test:
//! \tcd test_dir
//! \t./run_tests.sh
//! ```
//! (./run_tests.sh runs in original directory, not test_dir)
//!
//! ✅ **GOOD** (with .ONESHELL - all lines in same shell):
//! ```makefile
//! .ONESHELL:
//!
//! test:
//! \tcd test_dir
//! \t./run_tests.sh
//! ```
//! (./run_tests.sh runs in test_dir as expected)
use crate::linter::{Diagnostic, Fix, LintResult, Severity, Span};
/// Check for missing .ONESHELL directive
pub fn check(source: &str) -> LintResult {
let mut result = LintResult::new();
// Empty Makefile doesn't need .ONESHELL
if source.trim().is_empty() {
return result;
}
// Check if .ONESHELL is present (case-sensitive)
if has_oneshell(source) {
return result;
}
// Check if there are any multi-line recipes
if !has_multiline_recipes(source) {
return result;
}
// Missing .ONESHELL with multi-line recipes - create diagnostic
let span = Span::new(1, 1, 1, 1); // Point to start of file
// Create fix by adding .ONESHELL: at top
let fix_replacement = format!(".ONESHELL:\n\n{}", source);
let diag = Diagnostic::new(
"MAKE017",
Severity::Warning,
"Missing .ONESHELL - multi-line recipes execute in separate shells (consider adding .ONESHELL: for consistent behavior)",
span,
)
.with_fix(Fix::new(&fix_replacement));
result.add(diag);
result
}
/// Check if Makefile has .ONESHELL directive (case-sensitive)
fn has_oneshell(source: &str) -> bool {
for line in source.lines() {
let trimmed = line.trim();
if trimmed.starts_with(".ONESHELL") {
return true;
}
}
false
}
/// Check if Makefile has any multi-line recipes (targets with 2+ recipe lines)
fn has_multiline_recipes(source: &str) -> bool {
let mut in_recipe = false;
let mut recipe_line_count = 0;
for line in source.lines() {
if line.starts_with('\t') {
// Recipe line
if in_recipe {
recipe_line_count += 1;
if recipe_line_count >= 2 {
return true; // Found multi-line recipe
}
} else {
in_recipe = true;
recipe_line_count = 1;
}
} else if !line.trim().is_empty() {
// Non-recipe, non-empty line - reset
in_recipe = false;
recipe_line_count = 0;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
// RED PHASE: Write failing tests first
#[test]
fn test_MAKE017_detects_missing_oneshell() {
let makefile = "test:\n\tcd test_dir\n\t./run_tests.sh";
let result = check(makefile);
assert_eq!(result.diagnostics.len(), 1);
let diag = &result.diagnostics[0];
assert_eq!(diag.code, "MAKE017");
assert_eq!(diag.severity, Severity::Warning);
assert!(diag.message.to_lowercase().contains("oneshell"));
}
#[test]
fn test_MAKE017_detects_multiline_recipe_without_oneshell() {
let makefile = "build:\n\tVERSION=1.0\n\techo $VERSION";
let result = check(makefile);
// Multi-line recipe without .ONESHELL
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_MAKE017_provides_fix() {
let makefile = "test:\n\tcd test_dir\n\t./run_tests.sh";
let result = check(makefile);
assert!(result.diagnostics[0].fix.is_some());
let fix = result.diagnostics[0].fix.as_ref().unwrap();
// Fix should add .ONESHELL: at top
assert!(fix.replacement.contains(".ONESHELL:"));
}
#[test]
fn test_MAKE017_no_warning_with_oneshell() {
let makefile = ".ONESHELL:\n\ntest:\n\tcd test_dir\n\t./run_tests.sh";
let result = check(makefile);
// .ONESHELL present - OK
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_MAKE017_no_warning_for_single_line_recipes() {
let makefile = "test:\n\t./run_tests.sh";
let result = check(makefile);
// Single-line recipe doesn't need .ONESHELL
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_MAKE017_detects_in_complex_makefile() {
let makefile =
"CC = gcc\n\nbuild:\n\tVERSION=1.0\n\techo $VERSION\n\ntest:\n\tcd test\n\t./run.sh";
let result = check(makefile);
// Has multi-line recipes, no .ONESHELL
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_MAKE017_case_sensitive() {
let makefile = ".oneshell:\n\ntest:\n\tcd test_dir\n\t./run_tests.sh";
let result = check(makefile);
// .oneshell (lowercase) is NOT valid - must be .ONESHELL
assert_eq!(result.diagnostics.len(), 1);
}
#[test]
fn test_MAKE017_empty_makefile() {
let makefile = "";
let result = check(makefile);
// Empty Makefile doesn't need .ONESHELL
assert_eq!(result.diagnostics.len(), 0);
}
}