flowscope_core/linter/rules/
tq_001.rs1use crate::linter::rule::{LintContext, LintRule};
7use crate::types::{issue_codes, Dialect, Issue};
8use sqlparser::ast::Statement;
9
10pub struct TsqlSpPrefix;
11
12impl LintRule for TsqlSpPrefix {
13 fn code(&self) -> &'static str {
14 issue_codes::LINT_TQ_001
15 }
16
17 fn name(&self) -> &'static str {
18 "TSQL sp_ prefix"
19 }
20
21 fn description(&self) -> &'static str {
22 "'SP_' prefix should not be used for user-defined stored procedures in T-SQL."
23 }
24
25 fn check(&self, _statement: &Statement, ctx: &LintContext) -> Vec<Issue> {
26 if ctx.dialect() != Dialect::Mssql {
27 return Vec::new();
28 }
29
30 if ctx.statement_index != 0 {
33 return Vec::new();
34 }
35
36 let has_violation = procedure_name_has_sp_prefix_in_sql(ctx.sql);
37
38 if has_violation {
39 vec![Issue::warning(
40 issue_codes::LINT_TQ_001,
41 "Avoid stored procedure names with sp_ prefix.",
42 )
43 .with_statement(ctx.statement_index)]
44 } else {
45 Vec::new()
46 }
47 }
48}
49
50fn procedure_name_has_sp_prefix_in_sql(sql: &str) -> bool {
51 let bytes = sql.as_bytes();
52 let Some(header_end) = procedure_header_end(bytes) else {
53 return false;
54 };
55 let Some(name) = parse_procedure_name(bytes, header_end) else {
56 return false;
57 };
58 name.to_ascii_lowercase().starts_with("sp_")
59}
60
61fn procedure_header_end(bytes: &[u8]) -> Option<usize> {
62 let mut index = skip_ascii_whitespace_and_comments(bytes, 0);
63
64 if let Some(create_end) = match_ascii_keyword_at(bytes, index, b"CREATE") {
65 index = skip_ascii_whitespace_and_comments(bytes, create_end);
66 if let Some(or_end) = match_ascii_keyword_at(bytes, index, b"OR") {
67 index = skip_ascii_whitespace_and_comments(bytes, or_end);
68 let alter_end = match_ascii_keyword_at(bytes, index, b"ALTER")?;
69 index = skip_ascii_whitespace_and_comments(bytes, alter_end);
70 }
71 let proc_end = match_procedure_keyword(bytes, index)?;
72 return Some(skip_ascii_whitespace_and_comments(bytes, proc_end));
73 }
74
75 if let Some(alter_end) = match_ascii_keyword_at(bytes, index, b"ALTER") {
76 index = skip_ascii_whitespace_and_comments(bytes, alter_end);
77 let proc_end = match_procedure_keyword(bytes, index)?;
78 return Some(skip_ascii_whitespace_and_comments(bytes, proc_end));
79 }
80
81 None
82}
83
84fn match_procedure_keyword(bytes: &[u8], start: usize) -> Option<usize> {
85 match_ascii_keyword_at(bytes, start, b"PROCEDURE")
86 .or_else(|| match_ascii_keyword_at(bytes, start, b"PROC"))
87}
88
89fn parse_procedure_name(bytes: &[u8], start: usize) -> Option<String> {
90 let mut index = skip_ascii_whitespace_and_comments(bytes, start);
91 let (mut next, mut last_part) = parse_identifier_part(bytes, index)?;
92
93 loop {
94 index = skip_ascii_whitespace_and_comments(bytes, next);
95 if index >= bytes.len() || bytes[index] != b'.' {
96 return Some(last_part);
97 }
98
99 index = skip_ascii_whitespace_and_comments(bytes, index + 1);
100 let (part_end, part_value) = parse_identifier_part(bytes, index)?;
101 next = part_end;
102 last_part = part_value;
103 }
104}
105
106fn parse_identifier_part(bytes: &[u8], start: usize) -> Option<(usize, String)> {
107 if start >= bytes.len() {
108 return None;
109 }
110
111 if bytes[start] == b'[' {
112 return parse_bracket_identifier(bytes, start);
113 }
114
115 if bytes[start] == b'"' {
116 return parse_double_quoted_identifier(bytes, start);
117 }
118
119 if !is_ascii_ident_start(bytes[start]) {
120 return None;
121 }
122
123 let mut end = start + 1;
124 while end < bytes.len() && is_ascii_ident_continue(bytes[end]) {
125 end += 1;
126 }
127
128 Some((
129 end,
130 String::from_utf8_lossy(&bytes[start..end]).into_owned(),
131 ))
132}
133
134fn parse_bracket_identifier(bytes: &[u8], start: usize) -> Option<(usize, String)> {
135 let mut index = start + 1;
136 let mut out = String::new();
137 while index < bytes.len() {
138 if bytes[index] == b']' {
139 if index + 1 < bytes.len() && bytes[index + 1] == b']' {
140 out.push(']');
141 index += 2;
142 } else {
143 return Some((index + 1, out));
144 }
145 } else {
146 out.push(bytes[index] as char);
147 index += 1;
148 }
149 }
150 None
151}
152
153fn parse_double_quoted_identifier(bytes: &[u8], start: usize) -> Option<(usize, String)> {
154 let mut index = start + 1;
155 let mut out = String::new();
156 while index < bytes.len() {
157 if bytes[index] == b'"' {
158 if index + 1 < bytes.len() && bytes[index + 1] == b'"' {
159 out.push('"');
160 index += 2;
161 } else {
162 return Some((index + 1, out));
163 }
164 } else {
165 out.push(bytes[index] as char);
166 index += 1;
167 }
168 }
169 None
170}
171
172fn skip_ascii_whitespace_and_comments(bytes: &[u8], mut index: usize) -> usize {
173 loop {
174 while index < bytes.len() && is_ascii_whitespace_byte(bytes[index]) {
175 index += 1;
176 }
177
178 if index + 1 < bytes.len() && bytes[index] == b'-' && bytes[index + 1] == b'-' {
179 index += 2;
180 while index < bytes.len() && !matches!(bytes[index], b'\n' | b'\r') {
181 index += 1;
182 }
183 continue;
184 }
185
186 if index + 1 < bytes.len() && bytes[index] == b'/' && bytes[index + 1] == b'*' {
187 index += 2;
188 while index + 1 < bytes.len() {
189 if bytes[index] == b'*' && bytes[index + 1] == b'/' {
190 index += 2;
191 break;
192 }
193 index += 1;
194 }
195 continue;
196 }
197
198 return index;
199 }
200}
201
202fn is_ascii_whitespace_byte(byte: u8) -> bool {
203 matches!(byte, b' ' | b'\n' | b'\r' | b'\t' | 0x0b | 0x0c)
204}
205
206fn is_ascii_ident_start(byte: u8) -> bool {
207 byte.is_ascii_alphabetic() || byte == b'_'
208}
209
210fn is_ascii_ident_continue(byte: u8) -> bool {
211 byte.is_ascii_alphanumeric() || byte == b'_'
212}
213
214fn is_word_boundary_for_keyword(bytes: &[u8], index: usize) -> bool {
215 index == 0 || index >= bytes.len() || !is_ascii_ident_continue(bytes[index])
216}
217
218fn match_ascii_keyword_at(bytes: &[u8], start: usize, keyword_upper: &[u8]) -> Option<usize> {
219 let end = start.checked_add(keyword_upper.len())?;
220 if end > bytes.len() {
221 return None;
222 }
223 if !is_word_boundary_for_keyword(bytes, start.saturating_sub(1))
224 || !is_word_boundary_for_keyword(bytes, end)
225 {
226 return None;
227 }
228 let matches = bytes[start..end]
229 .iter()
230 .zip(keyword_upper.iter())
231 .all(|(actual, expected)| actual.to_ascii_uppercase() == *expected);
232 if matches {
233 Some(end)
234 } else {
235 None
236 }
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242 use crate::linter::rule::with_active_dialect;
243 use crate::parser::parse_sql;
244 use crate::types::Dialect;
245
246 fn run(sql: &str) -> Vec<Issue> {
247 let statements = parse_sql(sql).expect("parse");
248 let rule = TsqlSpPrefix;
249 with_active_dialect(Dialect::Mssql, || {
250 statements
251 .iter()
252 .enumerate()
253 .flat_map(|(index, statement)| {
254 rule.check(
255 statement,
256 &LintContext {
257 sql,
258 statement_range: 0..sql.len(),
259 statement_index: index,
260 },
261 )
262 })
263 .collect()
264 })
265 }
266
267 fn run_statementless(sql: &str) -> Vec<Issue> {
268 let placeholder = parse_sql("SELECT 1").expect("parse");
269 let rule = TsqlSpPrefix;
270 with_active_dialect(Dialect::Mssql, || {
271 rule.check(
272 &placeholder[0],
273 &LintContext {
274 sql,
275 statement_range: 0..sql.len(),
276 statement_index: 0,
277 },
278 )
279 })
280 }
281
282 #[test]
283 fn flags_sp_prefixed_procedure_name() {
284 let issues = run("CREATE PROCEDURE dbo.sp_legacy AS SELECT 1;");
285 assert_eq!(issues.len(), 1);
286 assert_eq!(issues[0].code, issue_codes::LINT_TQ_001);
287 }
288
289 #[test]
290 fn does_not_flag_non_sp_prefixed_procedure_name() {
291 let issues = run("CREATE PROCEDURE proc_legacy AS SELECT 1;");
292 assert!(issues.is_empty());
293 }
294
295 #[test]
296 fn flags_sp_prefixed_bracket_quoted_name_in_statementless_mode() {
297 let issues = run_statementless("CREATE PROCEDURE dbo.[sp_legacy]\nAS\nSELECT 1");
298 assert_eq!(issues.len(), 1);
299 }
300
301 #[test]
302 fn flags_sp_prefixed_double_quoted_name_in_statementless_mode() {
303 let issues = run_statementless("CREATE PROCEDURE dbo.\"sp_legacy\"\nAS\nSELECT 1");
304 assert_eq!(issues.len(), 1);
305 }
306
307 #[test]
308 fn does_not_flag_non_sp_prefixed_quoted_names_in_statementless_mode() {
309 let bracket = run_statementless("CREATE PROCEDURE dbo.[usp_legacy]\nAS\nSELECT 1");
310 assert!(bracket.is_empty());
311 let quoted = run_statementless("CREATE PROCEDURE dbo.\"usp_legacy\"\nAS\nSELECT 1");
312 assert!(quoted.is_empty());
313 }
314
315 #[test]
316 fn does_not_flag_sp_prefix_text_inside_string_literal() {
317 let issues =
318 run_statementless("SELECT 'CREATE PROCEDURE sp_legacy AS SELECT 1' AS sql_snippet");
319 assert!(issues.is_empty());
320 }
321}