Skip to main content

flowscope_core/linter/rules/
tq_001.rs

1//! LINT_TQ_001: TSQL `sp_` prefix.
2//!
3//! SQLFluff TQ01 parity (current scope): avoid stored procedure names starting
4//! with `sp_`.
5
6use 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        // Evaluate once per source document so parser best-effort mode (where
31        // the CREATE PROCEDURE slice may fail to parse) still gets checked.
32        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}