Skip to main content

sqrust_rules/lint/
create_schema_statement.rs

1use sqrust_core::{Diagnostic, FileContext, Rule};
2use sqlparser::ast::Statement;
3
4pub struct CreateSchemaStatement;
5
6impl Rule for CreateSchemaStatement {
7    fn name(&self) -> &'static str {
8        "Lint/CreateSchemaStatement"
9    }
10
11    fn check(&self, ctx: &FileContext) -> Vec<Diagnostic> {
12        // Skip files that failed to parse — AST may be incomplete.
13        if !ctx.parse_errors.is_empty() {
14            return Vec::new();
15        }
16
17        let source = &ctx.source;
18        let source_upper = source.to_uppercase();
19        let mut diags = Vec::new();
20        let mut occurrence: usize = 0;
21
22        for stmt in &ctx.statements {
23            if let Statement::CreateSchema { .. } = stmt {
24                let (line, col) =
25                    find_nth_keyword(source, &source_upper, "CREATE", occurrence);
26                occurrence += 1;
27                diags.push(Diagnostic {
28                    rule: self.name(),
29                    message: "CREATE SCHEMA is managed by dbt project configuration \
30                               — avoid manual schema DDL in SQL files"
31                        .to_string(),
32                    line,
33                    col,
34                });
35            }
36        }
37
38        diags
39    }
40}
41
42/// Finds the 1-indexed (line, col) of the `nth` (0-indexed) word-boundary
43/// occurrence of `keyword` (already uppercased) in `source_upper`.
44/// Falls back to (1, 1) if not found.
45fn find_nth_keyword(
46    source: &str,
47    source_upper: &str,
48    keyword: &str,
49    nth: usize,
50) -> (usize, usize) {
51    let kw_len = keyword.len();
52    let bytes = source_upper.as_bytes();
53    let text_len = bytes.len();
54
55    let mut count = 0usize;
56    let mut search_from = 0usize;
57
58    while search_from < text_len {
59        let Some(rel) = source_upper[search_from..].find(keyword) else {
60            break;
61        };
62        let abs = search_from + rel;
63
64        let before_ok = abs == 0
65            || {
66                let b = bytes[abs - 1];
67                !b.is_ascii_alphanumeric() && b != b'_'
68            };
69        let after = abs + kw_len;
70        let after_ok = after >= text_len
71            || {
72                let b = bytes[after];
73                !b.is_ascii_alphanumeric() && b != b'_'
74            };
75
76        if before_ok && after_ok {
77            if count == nth {
78                return offset_to_line_col(source, abs);
79            }
80            count += 1;
81        }
82        search_from = abs + 1;
83    }
84
85    (1, 1)
86}
87
88/// Converts a byte offset in `source` to a 1-indexed (line, col) pair.
89fn offset_to_line_col(source: &str, offset: usize) -> (usize, usize) {
90    let before = &source[..offset];
91    let line = before.chars().filter(|&c| c == '\n').count() + 1;
92    let col = before.rfind('\n').map(|p| offset - p - 1).unwrap_or(offset) + 1;
93    (line, col)
94}