Skip to main content

sql_dialect_fmt_lsp/
lint.rs

1use lsp_types::{Diagnostic, DiagnosticSeverity, NumberOrString, Range};
2use sql_dialect_fmt_highlight::{HighlightKind, HighlightToken};
3use sql_dialect_fmt_text::LineIndex;
4
5use crate::{lsp_position, PositionEncoding};
6
7/// LSP-only lint knobs layered on top of parser diagnostics.
8#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub struct LintOptions {
10    /// Warn on top-level `SELECT *` in a select list.
11    pub select_wildcard: bool,
12    /// Warn on large literal `IN (...)` lists.
13    pub large_in_list: bool,
14    /// Warn when `LANGUAGE <name> AS $$...$$` uses an embedded language the formatter cannot format.
15    pub unsupported_embedded_language: bool,
16    /// Item count above which a literal `IN (...)` list is considered large.
17    pub large_in_list_threshold: usize,
18}
19
20impl Default for LintOptions {
21    fn default() -> Self {
22        Self {
23            select_wildcard: true,
24            large_in_list: true,
25            unsupported_embedded_language: true,
26            large_in_list_threshold: 100,
27        }
28    }
29}
30
31#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
32pub enum LintCode {
33    SelectWildcard,
34    LargeInList,
35    UnsupportedEmbeddedLanguage,
36}
37
38impl LintCode {
39    pub fn as_str(self) -> &'static str {
40        match self {
41            LintCode::SelectWildcard => "SDF001",
42            LintCode::LargeInList => "SDF002",
43            LintCode::UnsupportedEmbeddedLanguage => "SDF003",
44        }
45    }
46
47    fn from_str(value: &str) -> Option<Self> {
48        match value {
49            "SDF001" => Some(Self::SelectWildcard),
50            "SDF002" => Some(Self::LargeInList),
51            "SDF003" => Some(Self::UnsupportedEmbeddedLanguage),
52            _ => None,
53        }
54    }
55}
56
57pub fn diagnostic_lint_code(diagnostic: &Diagnostic) -> Option<LintCode> {
58    match diagnostic.code.as_ref()? {
59        NumberOrString::String(value) => LintCode::from_str(value),
60        NumberOrString::Number(_) => None,
61    }
62}
63
64pub(crate) fn diagnostics_with_encoding(
65    text: &str,
66    tokens: &[HighlightToken<'_>],
67    index: &LineIndex<'_>,
68    options: LintOptions,
69    encoding: PositionEncoding,
70) -> Vec<Diagnostic> {
71    let mut diagnostics = Vec::new();
72    if SelectWildcardRule::enabled(options) {
73        diagnostics.extend(SelectWildcardRule::diagnostics(
74            tokens, index, options, encoding,
75        ));
76    }
77    if LargeInListRule::enabled(options) {
78        diagnostics.extend(LargeInListRule::diagnostics(
79            tokens, index, options, encoding,
80        ));
81    }
82    if UnsupportedEmbeddedLanguageRule::enabled(options) {
83        diagnostics.extend(UnsupportedEmbeddedLanguageRule::diagnostics(
84            tokens, index, options, encoding,
85        ));
86    }
87    diagnostics
88        .into_iter()
89        .filter(|diagnostic| !is_suppressed(text, diagnostic))
90        .collect()
91}
92
93trait LintRule {
94    const CODE: LintCode;
95
96    fn enabled(options: LintOptions) -> bool;
97
98    fn diagnostics(
99        tokens: &[HighlightToken<'_>],
100        index: &LineIndex<'_>,
101        options: LintOptions,
102        encoding: PositionEncoding,
103    ) -> Vec<Diagnostic>;
104
105    fn warning(
106        index: &LineIndex<'_>,
107        range: std::ops::Range<usize>,
108        message: &str,
109        encoding: PositionEncoding,
110    ) -> Diagnostic {
111        Diagnostic {
112            range: Range::new(
113                lsp_position(index, range.start, encoding),
114                lsp_position(index, range.end, encoding),
115            ),
116            severity: Some(DiagnosticSeverity::WARNING),
117            code: Some(NumberOrString::String(Self::CODE.as_str().to_string())),
118            source: Some("sql-dialect-fmt".to_string()),
119            message: message.to_string(),
120            ..Default::default()
121        }
122    }
123}
124
125struct SelectWildcardRule;
126
127impl LintRule for SelectWildcardRule {
128    const CODE: LintCode = LintCode::SelectWildcard;
129
130    fn enabled(options: LintOptions) -> bool {
131        options.select_wildcard
132    }
133
134    fn diagnostics(
135        tokens: &[HighlightToken<'_>],
136        index: &LineIndex<'_>,
137        _options: LintOptions,
138        encoding: PositionEncoding,
139    ) -> Vec<Diagnostic> {
140        let mut diagnostics = Vec::new();
141        let mut in_select_list = false;
142        let mut paren_depth = 0usize;
143        let mut previous_significant: Option<&str> = None;
144
145        for token in tokens.iter().filter(|token| is_significant(token.kind)) {
146            if token.kind == HighlightKind::Keyword && token.text.eq_ignore_ascii_case("select") {
147                in_select_list = true;
148                paren_depth = 0;
149                previous_significant = Some(token.text);
150                continue;
151            }
152
153            if in_select_list {
154                match token.text {
155                    "(" => paren_depth += 1,
156                    ")" => paren_depth = paren_depth.saturating_sub(1),
157                    ";" if paren_depth == 0 => in_select_list = false,
158                    _ => {
159                        if paren_depth == 0
160                            && token.kind == HighlightKind::Keyword
161                            && token.text.eq_ignore_ascii_case("from")
162                        {
163                            in_select_list = false;
164                        } else if paren_depth == 0
165                            && token.text == "*"
166                            && previous_significant.is_some_and(is_wildcard_prefix)
167                        {
168                            diagnostics.push(Self::warning(
169                                index,
170                                token.range.clone(),
171                                "avoid SELECT * in shared SQL; list columns explicitly",
172                                encoding,
173                            ));
174                        }
175                    }
176                }
177            }
178
179            previous_significant = Some(token.text);
180        }
181
182        diagnostics
183    }
184}
185
186struct LargeInListRule;
187
188impl LintRule for LargeInListRule {
189    const CODE: LintCode = LintCode::LargeInList;
190
191    fn enabled(options: LintOptions) -> bool {
192        options.large_in_list
193    }
194
195    fn diagnostics(
196        tokens: &[HighlightToken<'_>],
197        index: &LineIndex<'_>,
198        options: LintOptions,
199        encoding: PositionEncoding,
200    ) -> Vec<Diagnostic> {
201        #[derive(Debug)]
202        struct InList {
203            start: usize,
204            depth: usize,
205            commas: usize,
206            saw_top_level_item: bool,
207            possible_subquery: bool,
208        }
209
210        let mut diagnostics = Vec::new();
211        let mut pending_in: Option<usize> = None;
212        let mut list: Option<InList> = None;
213
214        for token in tokens.iter().filter(|token| is_significant(token.kind)) {
215            let mut close_list_at: Option<usize> = None;
216            if let Some(active) = list.as_mut() {
217                match token.text {
218                    "(" => active.depth += 1,
219                    ")" => {
220                        active.depth = active.depth.saturating_sub(1);
221                        if active.depth == 0 {
222                            close_list_at = Some(token.range.end);
223                        }
224                    }
225                    "," if active.depth == 1 => active.commas += 1,
226                    _ if active.depth == 1 && !active.saw_top_level_item => {
227                        active.saw_top_level_item = true;
228                        if token.kind == HighlightKind::Keyword
229                            && (token.text.eq_ignore_ascii_case("select")
230                                || token.text.eq_ignore_ascii_case("with"))
231                        {
232                            active.possible_subquery = true;
233                        }
234                    }
235                    _ => {}
236                }
237
238                if let Some(end) = close_list_at {
239                    if let Some(active) = list.take() {
240                        let item_count = if active.saw_top_level_item {
241                            active.commas + 1
242                        } else {
243                            0
244                        };
245                        if !active.possible_subquery && item_count > options.large_in_list_threshold
246                        {
247                            diagnostics.push(Self::warning(
248                                index,
249                                active.start..end,
250                                "large IN list; prefer a temp table, CTE, or semi-join when practical",
251                                encoding,
252                            ));
253                        }
254                    }
255                }
256                continue;
257            }
258
259            if let Some(start) = pending_in.take() {
260                if token.text == "(" {
261                    list = Some(InList {
262                        start,
263                        depth: 1,
264                        commas: 0,
265                        saw_top_level_item: false,
266                        possible_subquery: false,
267                    });
268                    continue;
269                }
270            }
271
272            if token.kind == HighlightKind::Keyword && token.text.eq_ignore_ascii_case("in") {
273                pending_in = Some(token.range.start);
274            }
275        }
276
277        diagnostics
278    }
279}
280
281struct UnsupportedEmbeddedLanguageRule;
282
283impl LintRule for UnsupportedEmbeddedLanguageRule {
284    const CODE: LintCode = LintCode::UnsupportedEmbeddedLanguage;
285
286    fn enabled(options: LintOptions) -> bool {
287        options.unsupported_embedded_language
288    }
289
290    fn diagnostics(
291        tokens: &[HighlightToken<'_>],
292        index: &LineIndex<'_>,
293        _options: LintOptions,
294        encoding: PositionEncoding,
295    ) -> Vec<Diagnostic> {
296        let mut diagnostics = Vec::new();
297        let mut expect_language_name = false;
298        let mut language_name: Option<(&str, std::ops::Range<usize>)> = None;
299        let mut saw_as_after_language = false;
300
301        for token in tokens {
302            match token.kind {
303                HighlightKind::Whitespace | HighlightKind::Comment => {}
304                HighlightKind::Punctuation if token.text == ";" => {
305                    expect_language_name = false;
306                    language_name = None;
307                    saw_as_after_language = false;
308                }
309                HighlightKind::DollarString => {
310                    if saw_as_after_language {
311                        if let Some((word, range)) = language_name.take() {
312                            if !is_supported_embedded_language(word) {
313                                diagnostics.push(Self::warning(
314                                    index,
315                                    range,
316                                    &format!(
317                                        "unsupported embedded language {word}; expected SQL, JAVASCRIPT, PYTHON, JAVA, or SCALA"
318                                    ),
319                                    encoding,
320                                ));
321                            }
322                        }
323                    }
324                    expect_language_name = false;
325                    saw_as_after_language = false;
326                }
327                HighlightKind::Keyword if token.text.eq_ignore_ascii_case("language") => {
328                    expect_language_name = true;
329                    language_name = None;
330                    saw_as_after_language = false;
331                }
332                HighlightKind::Keyword | HighlightKind::Identifier | HighlightKind::Type
333                    if expect_language_name =>
334                {
335                    language_name = Some((token.text, token.range.clone()));
336                    expect_language_name = false;
337                }
338                HighlightKind::Keyword
339                    if language_name.is_some() && token.text.eq_ignore_ascii_case("as") =>
340                {
341                    saw_as_after_language = true;
342                }
343                _ => {
344                    expect_language_name = false;
345                }
346            }
347        }
348
349        diagnostics
350    }
351}
352
353fn is_suppressed(text: &str, diagnostic: &Diagnostic) -> bool {
354    let Some(code) = diagnostic_lint_code(diagnostic) else {
355        return false;
356    };
357    let line = diagnostic.range.start.line as usize;
358    if line == 0 {
359        return false;
360    }
361    let Some(previous_line) = text.lines().nth(line - 1) else {
362        return false;
363    };
364    line_suppresses_code(previous_line, code)
365}
366
367fn line_suppresses_code(line: &str, code: LintCode) -> bool {
368    let trimmed = line.trim_start();
369    let Some(comment) = trimmed.strip_prefix("--") else {
370        return false;
371    };
372    let Some((_, rest)) = comment.split_once("sql-dialect-fmt: disable-next-line") else {
373        return false;
374    };
375    let rest = rest.trim();
376    if rest.is_empty() {
377        return true;
378    }
379    rest.split(|ch: char| ch.is_whitespace() || ch == ',' || ch == ';')
380        .filter(|part| !part.is_empty())
381        .any(|part| {
382            part.eq_ignore_ascii_case(code.as_str())
383                || part.eq_ignore_ascii_case("all")
384                || part.eq_ignore_ascii_case("lint")
385        })
386}
387
388fn is_wildcard_prefix(text: &str) -> bool {
389    matches!(text, "," | ".")
390        || text.eq_ignore_ascii_case("select")
391        || text.eq_ignore_ascii_case("distinct")
392        || text.eq_ignore_ascii_case("all")
393}
394
395fn is_significant(kind: HighlightKind) -> bool {
396    !matches!(kind, HighlightKind::Whitespace | HighlightKind::Comment)
397}
398
399fn is_supported_embedded_language(word: &str) -> bool {
400    ["SQL", "JAVASCRIPT", "PYTHON", "JAVA", "SCALA"]
401        .iter()
402        .any(|candidate| candidate.eq_ignore_ascii_case(word))
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408    use sql_dialect_fmt_highlight::highlight;
409
410    fn lint_diagnostics(text: &str, options: LintOptions) -> Vec<Diagnostic> {
411        let index = LineIndex::new(text);
412        let highlighted = highlight(text);
413        diagnostics_with_encoding(
414            text,
415            &highlighted.tokens,
416            &index,
417            options,
418            PositionEncoding::Utf16,
419        )
420    }
421
422    #[test]
423    fn disable_next_line_suppresses_specific_lint_code() {
424        let text = "-- sql-dialect-fmt: disable-next-line SDF001\nSELECT * FROM t;";
425        assert!(lint_diagnostics(text, LintOptions::default()).is_empty());
426    }
427
428    #[test]
429    fn disable_next_line_suppresses_all_lint_codes_when_code_is_omitted() {
430        let text = "-- sql-dialect-fmt: disable-next-line\nSELECT * FROM t;";
431        assert!(lint_diagnostics(text, LintOptions::default()).is_empty());
432    }
433
434    #[test]
435    fn disable_next_line_does_not_suppress_other_lines() {
436        let text =
437            "-- sql-dialect-fmt: disable-next-line SDF001\nSELECT a FROM t;\nSELECT * FROM t;";
438        assert_eq!(lint_diagnostics(text, LintOptions::default()).len(), 1);
439    }
440}