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
//! SC1099: Missing space before `#` comment
//!
//! Detects `cmd#comment` where `#` is not preceded by a space and could be
//! misinterpreted. In shell, `#` starts a comment only when preceded by
//! whitespace or at the start of a line.
//!
//! # Examples
//!
//! Bad:
//! ```bash
//! echo hello#world
//! x=1#set x
//! ```
//!
//! Good:
//! ```bash
//! echo hello #world
//! x=1 #set x
//! echo "$#" # parameter count
//! ```
use crate::linter::{Diagnostic, LintResult, Severity, Span};
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();
if trimmed.starts_with('#') || trimmed.is_empty() {
continue;
}
let bytes = line.as_bytes();
let mut in_single_quote = false;
let mut in_double_quote = false;
let mut i = 0;
while i < bytes.len() {
let ch = bytes[i];
// Track quoting state
if ch == b'\'' && !in_double_quote {
in_single_quote = !in_single_quote;
i += 1;
continue;
}
if ch == b'"' && !in_single_quote {
in_double_quote = !in_double_quote;
i += 1;
continue;
}
// Skip escaped characters
if ch == b'\\' && i + 1 < bytes.len() {
i += 2;
continue;
}
// Only check for # outside quotes
if ch == b'#' && !in_single_quote && !in_double_quote && i > 0 {
let prev = bytes[i - 1];
// Skip $# (parameter count)
if prev == b'$' {
i += 1;
continue;
}
// Skip ${# (string length prefix)
if prev == b'{' && i >= 2 && bytes[i - 2] == b'$' {
i += 1;
continue;
}
// Skip if # is already preceded by whitespace (it's a proper comment)
if prev == b' ' || prev == b'\t' {
// This is a proper comment start, stop scanning
break;
}
// Skip #! (shebang-like patterns)
if i + 1 < bytes.len() && bytes[i + 1] == b'!' {
i += 1;
continue;
}
// This is a # not preceded by space and not in quotes
let col = i + 1;
result.add(Diagnostic::new(
"SC1099",
Severity::Warning,
"Add a space before # to make it a comment, or quote it for literal #",
Span::new(line_num, col, line_num, col + 1),
));
break; // Only flag first occurrence per line
}
i += 1;
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sc1099_no_space_before_hash() {
let result = check("echo hello#world");
assert_eq!(result.diagnostics.len(), 1);
assert_eq!(result.diagnostics[0].code, "SC1099");
assert_eq!(result.diagnostics[0].severity, Severity::Warning);
}
#[test]
fn test_sc1099_proper_comment_ok() {
let result = check("echo hello # this is a comment");
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc1099_dollar_hash_ok() {
let result = check("echo $#");
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc1099_string_length_ok() {
let result = check("echo ${#var}");
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc1099_in_single_quotes_ok() {
let result = check("echo 'hello#world'");
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc1099_in_double_quotes_ok() {
let result = check(r#"echo "hello#world""#);
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc1099_line_starting_with_hash() {
let result = check("# this is a comment");
assert_eq!(result.diagnostics.len(), 0);
}
#[test]
fn test_sc1099_assignment_with_comment() {
let result = check("x=1#comment");
assert_eq!(result.diagnostics.len(), 1);
}
}