Skip to main content

sqrust_rules/layout/
max_identifier_length.rs

1use sqrust_core::{Diagnostic, FileContext, Rule};
2
3pub struct MaxIdentifierLength {
4    pub max_length: usize,
5}
6
7impl Default for MaxIdentifierLength {
8    fn default() -> Self {
9        MaxIdentifierLength { max_length: 30 }
10    }
11}
12
13impl Rule for MaxIdentifierLength {
14    fn name(&self) -> &'static str {
15        "Layout/MaxIdentifierLength"
16    }
17
18    fn check(&self, ctx: &FileContext) -> Vec<Diagnostic> {
19        if !ctx.parse_errors.is_empty() {
20            return Vec::new();
21        }
22
23        let max = self.max_length;
24        let mut diags = Vec::new();
25
26        // Scan the source text for identifiers using a simple tokenizer.
27        // We skip string literals and only look at unquoted and quoted identifiers.
28        let src = &ctx.source;
29        let bytes = src.as_bytes();
30        let len = bytes.len();
31        let mut i = 0;
32
33        while i < len {
34            let ch = bytes[i];
35
36            // Skip string literals (single-quoted).
37            if ch == b'\'' {
38                i += 1;
39                while i < len && bytes[i] != b'\'' {
40                    if bytes[i] == b'\\' {
41                        i += 1;
42                    }
43                    i += 1;
44                }
45                i += 1; // closing quote
46                continue;
47            }
48
49            // Skip line comments.
50            if i + 1 < len && ch == b'-' && bytes[i + 1] == b'-' {
51                while i < len && bytes[i] != b'\n' {
52                    i += 1;
53                }
54                continue;
55            }
56
57            // Quoted identifiers ("..." or `...`).
58            if ch == b'"' || ch == b'`' {
59                let close = ch;
60                let start = i;
61                i += 1;
62                let content_start = i;
63                while i < len && bytes[i] != close {
64                    i += 1;
65                }
66                let content = &src[content_start..i];
67                i += 1; // closing quote
68                if content.len() > max {
69                    let (line, col) = offset_to_line_col(src, start);
70                    diags.push(Diagnostic {
71                        rule: "Layout/MaxIdentifierLength",
72                        message: format!(
73                            "Identifier '{}' is {} characters long; maximum is {}",
74                            content,
75                            content.len(),
76                            max
77                        ),
78                        line,
79                        col,
80                    });
81                }
82                continue;
83            }
84
85            // Unquoted identifiers: start with letter or underscore.
86            if ch.is_ascii_alphabetic() || ch == b'_' {
87                let start = i;
88                while i < len && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
89                    i += 1;
90                }
91                let ident = &src[start..i];
92                // Skip SQL keywords.
93                if !is_keyword(ident) && ident.len() > max {
94                    let (line, col) = offset_to_line_col(src, start);
95                    diags.push(Diagnostic {
96                        rule: "Layout/MaxIdentifierLength",
97                        message: format!(
98                            "Identifier '{}' is {} characters long; maximum is {}",
99                            ident,
100                            ident.len(),
101                            max
102                        ),
103                        line,
104                        col,
105                    });
106                }
107                continue;
108            }
109
110            i += 1;
111        }
112
113        diags
114    }
115}
116
117fn is_keyword(s: &str) -> bool {
118    const KEYWORDS: &[&str] = &[
119        "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "FULL",
120        "OUTER", "ON", "AND", "OR", "NOT", "IN", "IS", "NULL", "AS", "BY",
121        "GROUP", "ORDER", "HAVING", "LIMIT", "OFFSET", "UNION", "ALL",
122        "DISTINCT", "INSERT", "INTO", "UPDATE", "SET", "DELETE", "CREATE",
123        "TABLE", "ALTER", "DROP", "WITH", "CASE", "WHEN", "THEN", "ELSE",
124        "END", "EXISTS", "BETWEEN", "LIKE", "ASC", "DESC", "PRIMARY", "KEY",
125        "FOREIGN", "REFERENCES", "INDEX", "VIEW", "CROSS", "NATURAL",
126        "USING", "VALUES", "DEFAULT", "CONSTRAINT", "UNIQUE", "CHECK",
127        "RETURNS", "RETURN", "BEGIN", "COMMIT", "ROLLBACK", "TRANSACTION",
128        "TRUE", "FALSE", "EXCEPT", "INTERSECT", "RECURSIVE", "LATERAL",
129        "REPLACE", "IF", "COUNT", "SUM", "AVG", "MIN", "MAX", "ABS", "YEAR",
130        "MONTH", "DAY", "UPPER", "LOWER", "LENGTH", "TRIM", "COALESCE",
131        "NULLIF", "CAST", "CONVERT", "SUBSTRING", "CONCAT", "REPLACE",
132        "OVER", "PARTITION", "ROWS", "RANGE", "UNBOUNDED", "PRECEDING",
133        "FOLLOWING", "CURRENT", "ROW",
134    ];
135    KEYWORDS
136        .iter()
137        .any(|k| k.eq_ignore_ascii_case(s))
138}
139
140fn offset_to_line_col(source: &str, offset: usize) -> (usize, usize) {
141    let before = &source[..offset];
142    let line = before.chars().filter(|&c| c == '\n').count() + 1;
143    let col = before.rfind('\n').map(|p| offset - p - 1).unwrap_or(offset) + 1;
144    (line, col)
145}