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
//! SC1003: Want to escape a single quote? Use `'don'\''t'` or `'don'"'"'t'`
//!
//! Detects broken single-quote patterns like `'don't'` where a word character
//! appears on both sides of what looks like a misplaced single quote.
use crate::linter::{Diagnostic, LintResult, Severity, Span};
/// Check for broken single-quote escaping patterns
pub fn check(source: &str) -> LintResult {
let mut result = LintResult::new();
for (line_num, line) in source.lines().enumerate() {
let line_num = line_num + 1;
let trimmed = line.trim_start();
if trimmed.starts_with('#') {
continue;
}
check_line(line, line_num, &mut result);
}
result
}
fn check_line(line: &str, line_num: usize, result: &mut LintResult) {
let bytes = line.as_bytes();
let len = bytes.len();
// Track single-quote regions: find sequences of quotes
// We look for the pattern: '<word_chars>'<word_chars>'
// which suggests someone wrote 'don't' instead of 'don'\''t'
let mut i = 0;
while i < len {
if bytes[i] == b'\'' {
// Found opening quote - scan for the pattern
if let Some(issue_col) = find_broken_quote_pattern(bytes, i, len) {
let diagnostic = Diagnostic::new(
"SC1003",
Severity::Warning,
"Want to escape a single quote? Use '\\'' or '\"'\"'",
Span::new(line_num, issue_col + 1, line_num, issue_col + 2),
);
result.add(diagnostic);
// Skip past this match to avoid duplicate reports
i = issue_col + 1;
continue;
}
}
i += 1;
}
}
/// Look for pattern: 'word_chars'word_chars' starting at position `start`.
/// Returns the column of the middle (problematic) quote if found.
fn find_broken_quote_pattern(bytes: &[u8], start: usize, len: usize) -> Option<usize> {
// bytes[start] == b'\''
// Look for: '<alphanum_chars>'<alphanum_chars>'
let mut i = start + 1;
// Scan word chars after opening quote
let word_start = i;
while i < len && is_word_char(bytes[i]) {
i += 1;
}
if i == word_start || i >= len {
return None;
}
// Must hit a single quote (the middle one)
if bytes[i] != b'\'' {
return None;
}
let middle_quote = i;
// Character before the middle quote must be a word char (already guaranteed)
// Character after the middle quote must be a word char
i += 1;
if i >= len || !is_word_char(bytes[i]) {
return None;
}
// Scan word chars after the middle quote
while i < len && is_word_char(bytes[i]) {
i += 1;
}
// Must end with a closing single quote
if i >= len || bytes[i] != b'\'' {
return None;
}
Some(middle_quote)
}
fn is_word_char(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_'
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sc1003_broken_single_quote() {
// 'don't' has the pattern: '<word>'<word>'
let script = "echo 'don't'";
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
assert_eq!(result.diagnostics[0].code, "SC1003");
assert_eq!(result.diagnostics[0].severity, Severity::Warning);
}
#[test]
fn test_sc1003_correct_escaping() {
let script = r"echo 'don'\''t'";
let result = check(script);
// The '\'' pattern breaks out of single quotes properly
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc1003_no_false_positive_normal_quotes() {
let script = "echo 'hello world'";
let result = check(script);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc1003_comment_line_skipped() {
let script = "# echo 'don't'";
let result = check(script);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc1003_wont_pattern() {
// 'won't' has the pattern: '<word>'<word>'
let script = "echo 'won't'";
let result = check(script);
assert_eq!(result.diagnostics.len(), 1);
}
}