Skip to main content

sql_dialect_fmt_lint/
lib.rs

1//! Lint rules for SQL, independent of any editor protocol.
2//!
3//! [`lint`] (and its options/dialect-aware variants) checks source text against the SDF rule set
4//! and returns [`LintDiagnostic`]s located at byte ranges, so the CLI can render `path:line:col`
5//! findings and the language server can convert them to LSP diagnostics without this crate ever
6//! depending on LSP types.
7//!
8//! ## Rules
9//!
10//! | code | rule |
11//! | --- | --- |
12//! | `SDF001` | `SELECT *` in a select list |
13//! | `SDF002` | large literal `IN (...)` list |
14//! | `SDF003` | unsupported embedded `LANGUAGE ... AS $$...$$` |
15//! | `SDF004` | `DELETE` without `WHERE` |
16//! | `SDF005` | `UPDATE` without `WHERE` |
17//! | `SDF006` | comma join in `FROM` (implicit cross join) |
18//! | `SDF007` | `ORDER BY` ordinal (e.g. `ORDER BY 1`) |
19//!
20//! ## Suppression
21//!
22//! A finding is suppressed by a line comment on the previous line:
23//!
24//! ```sql
25//! -- sql-dialect-fmt: disable-next-line SDF001
26//! SELECT * FROM t;
27//! ```
28//!
29//! Omitting the code (or writing `all`/`lint`) suppresses every rule for the next line.
30
31use sql_dialect_fmt_parser::{parse_with_dialect, SyntaxKind, SyntaxNode};
32use sql_dialect_fmt_syntax::keyword_kind;
33use sql_dialect_fmt_text::LineIndex;
34
35pub use sql_dialect_fmt_parser::Dialect;
36
37/// Lint knobs. Every rule is on by default; each can be disabled independently.
38#[derive(Clone, Copy, Debug, PartialEq, Eq)]
39pub struct LintOptions {
40    /// Warn on top-level `SELECT *` in a select list (`SDF001`).
41    pub select_wildcard: bool,
42    /// Warn on large literal `IN (...)` lists (`SDF002`).
43    pub large_in_list: bool,
44    /// Warn when `LANGUAGE <name> AS $$...$$` uses an embedded language the formatter cannot
45    /// format (`SDF003`).
46    pub unsupported_embedded_language: bool,
47    /// Warn on `DELETE` statements without a `WHERE` clause (`SDF004`).
48    pub delete_without_where: bool,
49    /// Warn on `UPDATE` statements without a `WHERE` clause (`SDF005`).
50    pub update_without_where: bool,
51    /// Warn on comma joins in `FROM` — implicit cross joins (`SDF006`).
52    pub comma_join: bool,
53    /// Warn on ordinal `ORDER BY` items such as `ORDER BY 1` (`SDF007`).
54    pub order_by_ordinal: bool,
55    /// Item count above which a literal `IN (...)` list is considered large.
56    pub large_in_list_threshold: usize,
57}
58
59impl Default for LintOptions {
60    fn default() -> Self {
61        Self {
62            select_wildcard: true,
63            large_in_list: true,
64            unsupported_embedded_language: true,
65            delete_without_where: true,
66            update_without_where: true,
67            comma_join: true,
68            order_by_ordinal: true,
69            large_in_list_threshold: 100,
70        }
71    }
72}
73
74/// The stable identity of a lint rule; rendered as `SDFxxx` via [`LintCode::as_str`].
75#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
76pub enum LintCode {
77    SelectWildcard,
78    LargeInList,
79    UnsupportedEmbeddedLanguage,
80    DeleteWithoutWhere,
81    UpdateWithoutWhere,
82    CommaJoin,
83    OrderByOrdinal,
84}
85
86impl LintCode {
87    /// The `SDFxxx` code string editors and suppression comments use.
88    pub fn as_str(self) -> &'static str {
89        match self {
90            LintCode::SelectWildcard => "SDF001",
91            LintCode::LargeInList => "SDF002",
92            LintCode::UnsupportedEmbeddedLanguage => "SDF003",
93            LintCode::DeleteWithoutWhere => "SDF004",
94            LintCode::UpdateWithoutWhere => "SDF005",
95            LintCode::CommaJoin => "SDF006",
96            LintCode::OrderByOrdinal => "SDF007",
97        }
98    }
99
100    /// Parse an `SDFxxx` code string back into a [`LintCode`].
101    pub fn from_code(value: &str) -> Option<Self> {
102        match value {
103            "SDF001" => Some(Self::SelectWildcard),
104            "SDF002" => Some(Self::LargeInList),
105            "SDF003" => Some(Self::UnsupportedEmbeddedLanguage),
106            "SDF004" => Some(Self::DeleteWithoutWhere),
107            "SDF005" => Some(Self::UpdateWithoutWhere),
108            "SDF006" => Some(Self::CommaJoin),
109            "SDF007" => Some(Self::OrderByOrdinal),
110            _ => None,
111        }
112    }
113}
114
115/// How severe a finding is. Every current rule emits [`LintSeverity::Warning`].
116#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
117pub enum LintSeverity {
118    Warning,
119    Error,
120}
121
122/// One lint finding, located at a byte range into the linted source text.
123#[derive(Clone, Debug, PartialEq, Eq)]
124pub struct LintDiagnostic {
125    /// Which rule fired.
126    pub code: LintCode,
127    /// Severity of the finding.
128    pub severity: LintSeverity,
129    /// Human-readable description of the problem.
130    pub message: String,
131    /// Byte range of the offending source span.
132    pub range: std::ops::Range<usize>,
133}
134
135/// Lint `text` as Snowflake SQL with default [`LintOptions`].
136pub fn lint(text: &str) -> Vec<LintDiagnostic> {
137    lint_with_options(text, LintOptions::default())
138}
139
140/// Lint `text` as Snowflake SQL with explicit options.
141pub fn lint_with_options(text: &str, options: LintOptions) -> Vec<LintDiagnostic> {
142    lint_with_dialect(text, Dialect::Snowflake, options)
143}
144
145/// Lint `text` as `dialect`, honoring `options` and suppression comments. Findings are sorted by
146/// source position.
147pub fn lint_with_dialect(
148    text: &str,
149    dialect: Dialect,
150    options: LintOptions,
151) -> Vec<LintDiagnostic> {
152    let context = LintContext {
153        tokens: lex_tokens(text, dialect),
154        root: parse_with_dialect(text, dialect).syntax(),
155    };
156
157    let mut diagnostics = Vec::new();
158    run_rule::<SelectWildcardRule>(&context, &options, &mut diagnostics);
159    run_rule::<LargeInListRule>(&context, &options, &mut diagnostics);
160    run_rule::<UnsupportedEmbeddedLanguageRule>(&context, &options, &mut diagnostics);
161    run_rule::<DeleteWithoutWhereRule>(&context, &options, &mut diagnostics);
162    run_rule::<UpdateWithoutWhereRule>(&context, &options, &mut diagnostics);
163    run_rule::<CommaJoinRule>(&context, &options, &mut diagnostics);
164    run_rule::<OrderByOrdinalRule>(&context, &options, &mut diagnostics);
165
166    let index = LineIndex::new(text);
167    diagnostics.retain(|diagnostic| !is_suppressed(text, &index, diagnostic));
168    diagnostics.sort_by_key(|diagnostic| (diagnostic.range.start, diagnostic.range.end));
169    diagnostics
170}
171
172/// Everything a rule can look at: the classified token stream and the lossless CST.
173struct LintContext<'a> {
174    tokens: Vec<LintToken<'a>>,
175    root: SyntaxNode,
176}
177
178fn run_rule<R: LintRule>(
179    context: &LintContext<'_>,
180    options: &LintOptions,
181    out: &mut Vec<LintDiagnostic>,
182) {
183    if R::enabled(options) {
184        R::check(context, options, out);
185    }
186}
187
188trait LintRule {
189    const CODE: LintCode;
190
191    fn enabled(options: &LintOptions) -> bool;
192
193    fn check(context: &LintContext<'_>, options: &LintOptions, out: &mut Vec<LintDiagnostic>);
194
195    fn warning(range: std::ops::Range<usize>, message: &str) -> LintDiagnostic {
196        LintDiagnostic {
197            code: Self::CODE,
198            severity: LintSeverity::Warning,
199            message: message.to_string(),
200            range,
201        }
202    }
203}
204
205/// The coarse token classification the token-stream rules key off. Deliberately smaller than a
206/// highlighter's palette: lint rules only care about keywords, plain words, dollar-quoted bodies,
207/// and trivia.
208#[derive(Clone, Copy, Debug, PartialEq, Eq)]
209enum TokenClass {
210    /// Whitespace or a comment.
211    Trivia,
212    /// A reserved, contextual, or soft keyword.
213    Keyword,
214    /// An unquoted identifier that is not a keyword (includes type names).
215    Word,
216    /// A dollar-quoted string body (`$$...$$`).
217    DollarString,
218    /// Anything else: punctuation, operators, literals, quoted identifiers, errors.
219    Other,
220}
221
222#[derive(Clone, Debug)]
223struct LintToken<'a> {
224    class: TokenClass,
225    text: &'a str,
226    range: std::ops::Range<usize>,
227}
228
229fn lex_tokens(text: &str, dialect: Dialect) -> Vec<LintToken<'_>> {
230    let lexed = sql_dialect_fmt_lexer::tokenize_for_dialect(text, dialect);
231    lexed
232        .tokens
233        .into_iter()
234        .scan(0usize, |offset, token| {
235            let start = *offset;
236            *offset += token.text.len();
237            Some(LintToken {
238                class: classify(token.kind, token.text),
239                text: token.text,
240                range: start..*offset,
241            })
242        })
243        .collect()
244}
245
246fn classify(kind: SyntaxKind, text: &str) -> TokenClass {
247    if kind.is_trivia() {
248        return TokenClass::Trivia;
249    }
250    if kind.is_keyword()
251        || kind == SyntaxKind::CONTEXTUAL_KEYWORD
252        || (kind == SyntaxKind::IDENT && keyword_kind(text).is_some())
253    {
254        return TokenClass::Keyword;
255    }
256    match kind {
257        SyntaxKind::IDENT => TokenClass::Word,
258        SyntaxKind::DOLLAR_STRING => TokenClass::DollarString,
259        _ => TokenClass::Other,
260    }
261}
262
263fn is_significant(token: &LintToken<'_>) -> bool {
264    token.class != TokenClass::Trivia
265}
266
267fn is_keyword(token: &LintToken<'_>, expected: &str) -> bool {
268    token.class == TokenClass::Keyword && token.text.eq_ignore_ascii_case(expected)
269}
270
271// ---------------------------------------------------------------------------
272// SDF001: SELECT *
273// ---------------------------------------------------------------------------
274
275struct SelectWildcardRule;
276
277impl LintRule for SelectWildcardRule {
278    const CODE: LintCode = LintCode::SelectWildcard;
279
280    fn enabled(options: &LintOptions) -> bool {
281        options.select_wildcard
282    }
283
284    fn check(context: &LintContext<'_>, _options: &LintOptions, out: &mut Vec<LintDiagnostic>) {
285        let mut in_select_list = false;
286        let mut paren_depth = 0usize;
287        let mut previous_significant: Option<&str> = None;
288
289        for token in context.tokens.iter().filter(|token| is_significant(token)) {
290            if is_keyword(token, "select") {
291                in_select_list = true;
292                paren_depth = 0;
293                previous_significant = Some(token.text);
294                continue;
295            }
296
297            if in_select_list {
298                match token.text {
299                    "(" => paren_depth += 1,
300                    ")" => paren_depth = paren_depth.saturating_sub(1),
301                    ";" if paren_depth == 0 => in_select_list = false,
302                    _ => {
303                        if paren_depth == 0 && is_keyword(token, "from") {
304                            in_select_list = false;
305                        } else if paren_depth == 0
306                            && token.text == "*"
307                            && previous_significant.is_some_and(is_wildcard_prefix)
308                        {
309                            out.push(Self::warning(
310                                token.range.clone(),
311                                "avoid SELECT * in shared SQL; list columns explicitly",
312                            ));
313                        }
314                    }
315                }
316            }
317
318            previous_significant = Some(token.text);
319        }
320    }
321}
322
323fn is_wildcard_prefix(text: &str) -> bool {
324    matches!(text, "," | ".")
325        || text.eq_ignore_ascii_case("select")
326        || text.eq_ignore_ascii_case("distinct")
327        || text.eq_ignore_ascii_case("all")
328}
329
330// ---------------------------------------------------------------------------
331// SDF002: large IN list
332// ---------------------------------------------------------------------------
333
334struct LargeInListRule;
335
336impl LintRule for LargeInListRule {
337    const CODE: LintCode = LintCode::LargeInList;
338
339    fn enabled(options: &LintOptions) -> bool {
340        options.large_in_list
341    }
342
343    fn check(context: &LintContext<'_>, options: &LintOptions, out: &mut Vec<LintDiagnostic>) {
344        #[derive(Debug)]
345        struct InList {
346            start: usize,
347            depth: usize,
348            commas: usize,
349            saw_top_level_item: bool,
350            possible_subquery: bool,
351        }
352
353        let mut pending_in: Option<usize> = None;
354        let mut list: Option<InList> = None;
355
356        for token in context.tokens.iter().filter(|token| is_significant(token)) {
357            let mut close_list_at: Option<usize> = None;
358            if let Some(active) = list.as_mut() {
359                match token.text {
360                    "(" => active.depth += 1,
361                    ")" => {
362                        active.depth = active.depth.saturating_sub(1);
363                        if active.depth == 0 {
364                            close_list_at = Some(token.range.end);
365                        }
366                    }
367                    "," if active.depth == 1 => active.commas += 1,
368                    _ if active.depth == 1 && !active.saw_top_level_item => {
369                        active.saw_top_level_item = true;
370                        if is_keyword(token, "select") || is_keyword(token, "with") {
371                            active.possible_subquery = true;
372                        }
373                    }
374                    _ => {}
375                }
376
377                if let Some(end) = close_list_at {
378                    if let Some(active) = list.take() {
379                        let item_count = if active.saw_top_level_item {
380                            active.commas + 1
381                        } else {
382                            0
383                        };
384                        if !active.possible_subquery && item_count > options.large_in_list_threshold
385                        {
386                            out.push(Self::warning(
387                                active.start..end,
388                                "large IN list; prefer a temp table, CTE, or semi-join when practical",
389                            ));
390                        }
391                    }
392                }
393                continue;
394            }
395
396            if let Some(start) = pending_in.take() {
397                if token.text == "(" {
398                    list = Some(InList {
399                        start,
400                        depth: 1,
401                        commas: 0,
402                        saw_top_level_item: false,
403                        possible_subquery: false,
404                    });
405                    continue;
406                }
407            }
408
409            if is_keyword(token, "in") {
410                pending_in = Some(token.range.start);
411            }
412        }
413    }
414}
415
416// ---------------------------------------------------------------------------
417// SDF003: unsupported embedded language
418// ---------------------------------------------------------------------------
419
420struct UnsupportedEmbeddedLanguageRule;
421
422impl LintRule for UnsupportedEmbeddedLanguageRule {
423    const CODE: LintCode = LintCode::UnsupportedEmbeddedLanguage;
424
425    fn enabled(options: &LintOptions) -> bool {
426        options.unsupported_embedded_language
427    }
428
429    fn check(context: &LintContext<'_>, _options: &LintOptions, out: &mut Vec<LintDiagnostic>) {
430        let mut expect_language_name = false;
431        let mut language_name: Option<(&str, std::ops::Range<usize>)> = None;
432        let mut saw_as_after_language = false;
433
434        for token in &context.tokens {
435            if token.class == TokenClass::Trivia {
436                continue;
437            }
438            if token.text == ";" {
439                expect_language_name = false;
440                language_name = None;
441                saw_as_after_language = false;
442                continue;
443            }
444            match token.class {
445                TokenClass::DollarString => {
446                    if saw_as_after_language {
447                        if let Some((word, range)) = language_name.take() {
448                            if !is_supported_embedded_language(word) {
449                                out.push(Self::warning(
450                                    range,
451                                    &format!(
452                                        "unsupported embedded language {word}; expected SQL, JAVASCRIPT, PYTHON, JAVA, or SCALA"
453                                    ),
454                                ));
455                            }
456                        }
457                    }
458                    expect_language_name = false;
459                    saw_as_after_language = false;
460                }
461                TokenClass::Keyword if token.text.eq_ignore_ascii_case("language") => {
462                    expect_language_name = true;
463                    language_name = None;
464                    saw_as_after_language = false;
465                }
466                TokenClass::Keyword | TokenClass::Word if expect_language_name => {
467                    language_name = Some((token.text, token.range.clone()));
468                    expect_language_name = false;
469                }
470                TokenClass::Keyword
471                    if language_name.is_some() && token.text.eq_ignore_ascii_case("as") =>
472                {
473                    saw_as_after_language = true;
474                }
475                _ => {
476                    expect_language_name = false;
477                }
478            }
479        }
480    }
481}
482
483fn is_supported_embedded_language(word: &str) -> bool {
484    ["SQL", "JAVASCRIPT", "PYTHON", "JAVA", "SCALA"]
485        .iter()
486        .any(|candidate| candidate.eq_ignore_ascii_case(word))
487}
488
489// ---------------------------------------------------------------------------
490// SDF004 / SDF005: DELETE / UPDATE without WHERE
491// ---------------------------------------------------------------------------
492
493struct DeleteWithoutWhereRule;
494
495impl LintRule for DeleteWithoutWhereRule {
496    const CODE: LintCode = LintCode::DeleteWithoutWhere;
497
498    fn enabled(options: &LintOptions) -> bool {
499        options.delete_without_where
500    }
501
502    fn check(context: &LintContext<'_>, _options: &LintOptions, out: &mut Vec<LintDiagnostic>) {
503        check_statement_without_where(
504            &context.root,
505            SyntaxKind::DELETE_STMT,
506            "DELETE without WHERE affects every row; add a WHERE clause or use TRUNCATE",
507            Self::warning,
508            out,
509        );
510    }
511}
512
513struct UpdateWithoutWhereRule;
514
515impl LintRule for UpdateWithoutWhereRule {
516    const CODE: LintCode = LintCode::UpdateWithoutWhere;
517
518    fn enabled(options: &LintOptions) -> bool {
519        options.update_without_where
520    }
521
522    fn check(context: &LintContext<'_>, _options: &LintOptions, out: &mut Vec<LintDiagnostic>) {
523        check_statement_without_where(
524            &context.root,
525            SyntaxKind::UPDATE_STMT,
526            "UPDATE without WHERE affects every row; add a WHERE clause",
527            Self::warning,
528            out,
529        );
530    }
531}
532
533/// Shared walk for SDF004/SDF005: flag `kind` statements with no direct `WHERE_CLAUSE` child,
534/// anchored at the statement's leading keyword. `DELETE`/`UPDATE` actions inside `MERGE ... WHEN`
535/// arms are separate node kinds (`MERGE_WHEN`), so they never trip this.
536fn check_statement_without_where(
537    root: &SyntaxNode,
538    kind: SyntaxKind,
539    message: &str,
540    warning: impl Fn(std::ops::Range<usize>, &str) -> LintDiagnostic,
541    out: &mut Vec<LintDiagnostic>,
542) {
543    for statement in root.descendants().filter(|node| node.kind() == kind) {
544        let has_where = statement
545            .children()
546            .any(|child| child.kind() == SyntaxKind::WHERE_CLAUSE);
547        if has_where {
548            continue;
549        }
550        if let Some(range) = first_significant_token_range(&statement) {
551            out.push(warning(range, message));
552        }
553    }
554}
555
556// ---------------------------------------------------------------------------
557// SDF006: comma join in FROM
558// ---------------------------------------------------------------------------
559
560struct CommaJoinRule;
561
562impl LintRule for CommaJoinRule {
563    const CODE: LintCode = LintCode::CommaJoin;
564
565    fn enabled(options: &LintOptions) -> bool {
566        options.comma_join
567    }
568
569    fn check(context: &LintContext<'_>, _options: &LintOptions, out: &mut Vec<LintDiagnostic>) {
570        for from_clause in context
571            .root
572            .descendants()
573            .filter(|node| node.kind() == SyntaxKind::FROM_CLAUSE)
574        {
575            let elements: Vec<_> = from_clause.children_with_tokens().collect();
576            for (position, element) in elements.iter().enumerate() {
577                let Some(comma) = element
578                    .as_token()
579                    .filter(|token| token.kind() == SyntaxKind::COMMA)
580                else {
581                    continue;
582                };
583                // `FROM t, LATERAL ...` and `FROM t, TABLE(...)` are the documented lateral /
584                // table-function idioms (e.g. LATERAL FLATTEN), not accidental cross joins.
585                if joined_table_ref_is_lateral_or_table_function(&elements[position + 1..]) {
586                    continue;
587                }
588                let start: usize = comma.text_range().start().into();
589                let end: usize = comma.text_range().end().into();
590                out.push(Self::warning(
591                    start..end,
592                    "comma join is an implicit cross join; use an explicit JOIN ... ON instead",
593                ));
594            }
595        }
596    }
597}
598
599/// Whether the first node following a `FROM`-clause comma is a `TABLE_REF` that begins with
600/// `LATERAL` or `TABLE(` — Snowflake's lateral/table-function idioms rather than a cross join.
601fn joined_table_ref_is_lateral_or_table_function(
602    rest: &[sql_dialect_fmt_syntax::SyntaxElement],
603) -> bool {
604    let Some(node) = rest.iter().find_map(|element| element.as_node()) else {
605        return false;
606    };
607    if node.kind() != SyntaxKind::TABLE_REF {
608        return false;
609    }
610    node.descendants_with_tokens()
611        .filter_map(|child| child.into_token())
612        .find(|token| !token.kind().is_trivia())
613        .is_some_and(|token| matches!(token.kind(), SyntaxKind::LATERAL_KW | SyntaxKind::TABLE_KW))
614}
615
616// ---------------------------------------------------------------------------
617// SDF007: ORDER BY ordinal
618// ---------------------------------------------------------------------------
619
620struct OrderByOrdinalRule;
621
622impl LintRule for OrderByOrdinalRule {
623    const CODE: LintCode = LintCode::OrderByOrdinal;
624
625    fn enabled(options: &LintOptions) -> bool {
626        options.order_by_ordinal
627    }
628
629    fn check(context: &LintContext<'_>, _options: &LintOptions, out: &mut Vec<LintDiagnostic>) {
630        for order_by in context
631            .root
632            .descendants()
633            .filter(|node| node.kind() == SyntaxKind::ORDER_BY_CLAUSE)
634        {
635            // Inside `OVER (...)` and `WITHIN GROUP (...)` an integer is a constant expression,
636            // not a positional column reference, so only query-level ORDER BY is checked.
637            if order_by.parent().is_some_and(|parent| {
638                matches!(
639                    parent.kind(),
640                    SyntaxKind::WINDOW_SPEC | SyntaxKind::WITHIN_GROUP
641                )
642            }) {
643                continue;
644            }
645            for item in order_by
646                .children()
647                .filter(|node| node.kind() == SyntaxKind::ORDER_BY_ITEM)
648            {
649                let Some(range) = ordinal_literal_range(&item) else {
650                    continue;
651                };
652                out.push(Self::warning(
653                    range,
654                    "ORDER BY ordinal is fragile; order by the column name or expression instead",
655                ));
656            }
657        }
658    }
659}
660
661/// If the ORDER BY item's sort key is a bare integer literal, the byte range of that integer.
662fn ordinal_literal_range(item: &SyntaxNode) -> Option<std::ops::Range<usize>> {
663    let expr = item.children().next()?;
664    if expr.kind() != SyntaxKind::LITERAL {
665        return None;
666    }
667    let token = expr
668        .children_with_tokens()
669        .filter_map(|element| element.into_token())
670        .find(|token| !token.kind().is_trivia())?;
671    if token.kind() != SyntaxKind::INT_NUMBER {
672        return None;
673    }
674    let start: usize = token.text_range().start().into();
675    let end: usize = token.text_range().end().into();
676    Some(start..end)
677}
678
679fn first_significant_token_range(node: &SyntaxNode) -> Option<std::ops::Range<usize>> {
680    node.descendants_with_tokens()
681        .filter_map(|element| element.into_token())
682        .find(|token| !token.kind().is_trivia())
683        .map(|token| {
684            let start: usize = token.text_range().start().into();
685            let end: usize = token.text_range().end().into();
686            start..end
687        })
688}
689
690// ---------------------------------------------------------------------------
691// Suppression comments
692// ---------------------------------------------------------------------------
693
694fn is_suppressed(text: &str, index: &LineIndex<'_>, diagnostic: &LintDiagnostic) -> bool {
695    let line = index.utf8_position(diagnostic.range.start).line as usize;
696    if line == 0 {
697        return false;
698    }
699    let Some(previous_line) = text.lines().nth(line - 1) else {
700        return false;
701    };
702    line_suppresses_code(previous_line, diagnostic.code)
703}
704
705fn line_suppresses_code(line: &str, code: LintCode) -> bool {
706    let trimmed = line.trim_start();
707    let Some(comment) = trimmed.strip_prefix("--") else {
708        return false;
709    };
710    let Some((_, rest)) = comment.split_once("sql-dialect-fmt: disable-next-line") else {
711        return false;
712    };
713    let rest = rest.trim();
714    if rest.is_empty() {
715        return true;
716    }
717    rest.split(|ch: char| ch.is_whitespace() || ch == ',' || ch == ';')
718        .filter(|part| !part.is_empty())
719        .any(|part| {
720            part.eq_ignore_ascii_case(code.as_str())
721                || part.eq_ignore_ascii_case("all")
722                || part.eq_ignore_ascii_case("lint")
723        })
724}
725
726#[cfg(test)]
727mod tests {
728    use super::*;
729
730    fn codes(text: &str) -> Vec<&'static str> {
731        lint(text)
732            .into_iter()
733            .map(|diagnostic| diagnostic.code.as_str())
734            .collect()
735    }
736
737    fn find(text: &str, code: LintCode) -> LintDiagnostic {
738        lint(text)
739            .into_iter()
740            .find(|diagnostic| diagnostic.code == code)
741            .unwrap_or_else(|| panic!("expected a {} finding in {text:?}", code.as_str()))
742    }
743
744    #[test]
745    fn select_wildcard_is_flagged_at_the_star() {
746        let text = "SELECT * FROM t;";
747        let diagnostic = find(text, LintCode::SelectWildcard);
748        assert_eq!(diagnostic.severity, LintSeverity::Warning);
749        assert_eq!(
750            diagnostic.range,
751            text.find('*').unwrap()..text.find('*').unwrap() + 1
752        );
753    }
754
755    #[test]
756    fn select_wildcard_ignores_function_stars() {
757        assert!(codes("SELECT count(*) FROM t;").is_empty());
758    }
759
760    #[test]
761    fn large_in_list_respects_threshold_option() {
762        let text = "SELECT id FROM t WHERE id IN (1, 2, 3);";
763        assert!(codes(text).is_empty());
764        let options = LintOptions {
765            large_in_list_threshold: 2,
766            ..LintOptions::default()
767        };
768        let diagnostics = lint_with_options(text, options);
769        assert_eq!(diagnostics.len(), 1);
770        assert_eq!(diagnostics[0].code, LintCode::LargeInList);
771        assert_eq!(diagnostics[0].range.start, text.find("IN").unwrap());
772    }
773
774    #[test]
775    fn in_subquery_is_not_a_large_in_list() {
776        let options = LintOptions {
777            large_in_list_threshold: 0,
778            ..LintOptions::default()
779        };
780        assert!(lint_with_options(
781            "SELECT id FROM t WHERE id IN (SELECT id FROM src);",
782            options
783        )
784        .is_empty());
785    }
786
787    #[test]
788    fn unsupported_embedded_language_is_flagged() {
789        let text = "CREATE FUNCTION f() RETURNS STRING LANGUAGE RUBY AS $$x$$;";
790        let diagnostic = find(text, LintCode::UnsupportedEmbeddedLanguage);
791        assert!(diagnostic.message.contains("RUBY"));
792        let start = text.find("RUBY").unwrap();
793        assert_eq!(diagnostic.range, start..start + 4);
794    }
795
796    #[test]
797    fn embedded_language_does_not_fire_for_plain_columns_or_dynamic_sql() {
798        assert!(codes("SELECT language FROM t;").is_empty());
799        assert!(codes("EXECUTE IMMEDIATE $$ SELECT 1 $$;").is_empty());
800    }
801
802    #[test]
803    fn delete_without_where_is_flagged_at_the_delete_keyword() {
804        let text = "DELETE FROM t;";
805        let diagnostic = find(text, LintCode::DeleteWithoutWhere);
806        assert_eq!(diagnostic.severity, LintSeverity::Warning);
807        assert_eq!(diagnostic.range, 0..6);
808    }
809
810    #[test]
811    fn delete_with_where_is_clean() {
812        assert!(codes("DELETE FROM t WHERE id = 1;").is_empty());
813        assert!(codes("DELETE FROM t USING s WHERE t.id = s.id;").is_empty());
814    }
815
816    #[test]
817    fn merge_matched_delete_is_not_a_bare_delete() {
818        let text = "MERGE INTO t USING s ON t.id = s.id WHEN MATCHED THEN DELETE;";
819        assert!(codes(text).is_empty());
820    }
821
822    #[test]
823    fn delete_inside_a_scripting_block_is_flagged() {
824        let text = "BEGIN\n    DELETE FROM t;\nEND;";
825        let diagnostic = find(text, LintCode::DeleteWithoutWhere);
826        assert_eq!(diagnostic.range.start, text.find("DELETE").unwrap());
827    }
828
829    #[test]
830    fn update_without_where_is_flagged() {
831        let text = "SELECT 1;\nUPDATE t SET a = 1;";
832        let diagnostic = find(text, LintCode::UpdateWithoutWhere);
833        assert_eq!(diagnostic.range.start, text.find("UPDATE").unwrap());
834    }
835
836    #[test]
837    fn update_with_where_is_clean() {
838        assert!(codes("UPDATE t SET a = 1 WHERE b = 2;").is_empty());
839    }
840
841    #[test]
842    fn merge_matched_update_is_not_a_bare_update() {
843        let text = "MERGE INTO t USING s ON t.id = s.id WHEN MATCHED THEN UPDATE SET t.a = s.a;";
844        assert!(codes(text).is_empty());
845    }
846
847    #[test]
848    fn comma_join_is_flagged_at_the_comma() {
849        let text = "SELECT a FROM t1, t2 WHERE t1.id = t2.id;";
850        let diagnostic = find(text, LintCode::CommaJoin);
851        let comma = text.find(',').unwrap();
852        assert_eq!(diagnostic.range, comma..comma + 1);
853    }
854
855    #[test]
856    fn explicit_joins_and_argument_commas_are_clean() {
857        assert!(codes("SELECT a FROM t1 JOIN t2 ON t1.id = t2.id;").is_empty());
858        assert!(codes("SELECT a FROM t1 CROSS JOIN t2;").is_empty());
859        assert!(codes("SELECT d FROM TABLE(f(x, y));").is_empty());
860        assert!(codes("SELECT greatest(a, b) FROM t;").is_empty());
861    }
862
863    #[test]
864    fn lateral_and_table_function_commas_are_clean() {
865        assert!(codes("SELECT a FROM t, LATERAL FLATTEN(input => t.v) f;").is_empty());
866        assert!(codes("SELECT b FROM t1, TABLE(GENERATOR(ROWCOUNT => 10));").is_empty());
867    }
868
869    #[test]
870    fn multiple_comma_joins_produce_one_finding_each() {
871        let diagnostics = lint("SELECT a FROM t1, t2, t3;");
872        let commas = diagnostics
873            .iter()
874            .filter(|diagnostic| diagnostic.code == LintCode::CommaJoin)
875            .count();
876        assert_eq!(commas, 2);
877    }
878
879    #[test]
880    fn order_by_ordinal_is_flagged_per_item() {
881        let text = "SELECT a, b FROM t ORDER BY 1 DESC, b ASC;";
882        let diagnostics = lint(text);
883        let ordinals: Vec<_> = diagnostics
884            .iter()
885            .filter(|diagnostic| diagnostic.code == LintCode::OrderByOrdinal)
886            .collect();
887        assert_eq!(ordinals.len(), 1);
888        let one = text.find("BY 1").unwrap() + 3;
889        assert_eq!(ordinals[0].range, one..one + 1);
890    }
891
892    #[test]
893    fn order_by_expressions_and_limits_are_clean() {
894        assert!(codes("SELECT a FROM t ORDER BY a;").is_empty());
895        assert!(codes("SELECT a FROM t ORDER BY a + 1;").is_empty());
896        assert!(codes("SELECT a FROM t ORDER BY a LIMIT 1;").is_empty());
897    }
898
899    #[test]
900    fn window_and_within_group_order_by_are_not_ordinals() {
901        assert!(codes("SELECT row_number() OVER (ORDER BY 1) FROM t;").is_empty());
902        assert!(codes("SELECT listagg(a) WITHIN GROUP (ORDER BY 1) FROM t;").is_empty());
903    }
904
905    #[test]
906    fn each_new_rule_is_gated_by_its_option() {
907        for (text, disabled) in [
908            (
909                "DELETE FROM t;",
910                LintOptions {
911                    delete_without_where: false,
912                    ..LintOptions::default()
913                },
914            ),
915            (
916                "UPDATE t SET a = 1;",
917                LintOptions {
918                    update_without_where: false,
919                    ..LintOptions::default()
920                },
921            ),
922            (
923                "SELECT a FROM t1, t2;",
924                LintOptions {
925                    comma_join: false,
926                    ..LintOptions::default()
927                },
928            ),
929            (
930                "SELECT a FROM t ORDER BY 1;",
931                LintOptions {
932                    order_by_ordinal: false,
933                    ..LintOptions::default()
934                },
935            ),
936        ] {
937            assert!(!lint(text).is_empty(), "{text} should lint by default");
938            assert!(
939                lint_with_options(text, disabled).is_empty(),
940                "{text} should be clean when its rule is disabled"
941            );
942        }
943    }
944
945    #[test]
946    fn databricks_dialect_is_honored() {
947        let diagnostics = lint_with_dialect(
948            "DELETE FROM `my schema`.`t`;",
949            Dialect::Databricks,
950            LintOptions::default(),
951        );
952        assert_eq!(diagnostics.len(), 1);
953        assert_eq!(diagnostics[0].code, LintCode::DeleteWithoutWhere);
954    }
955
956    #[test]
957    fn findings_are_sorted_by_position() {
958        let text = "SELECT * FROM t1, t2 ORDER BY 1;";
959        let diagnostics = lint(text);
960        let starts: Vec<_> = diagnostics
961            .iter()
962            .map(|diagnostic| diagnostic.range.start)
963            .collect();
964        let mut sorted = starts.clone();
965        sorted.sort_unstable();
966        assert_eq!(starts, sorted);
967        assert_eq!(diagnostics.len(), 3);
968    }
969
970    #[test]
971    fn disable_next_line_suppresses_specific_lint_code() {
972        let text = "-- sql-dialect-fmt: disable-next-line SDF001\nSELECT * FROM t;";
973        assert!(lint(text).is_empty());
974    }
975
976    #[test]
977    fn disable_next_line_suppresses_all_lint_codes_when_code_is_omitted() {
978        let text = "-- sql-dialect-fmt: disable-next-line\nSELECT * FROM t;";
979        assert!(lint(text).is_empty());
980    }
981
982    #[test]
983    fn disable_next_line_does_not_suppress_other_lines() {
984        let text =
985            "-- sql-dialect-fmt: disable-next-line SDF001\nSELECT a FROM t;\nSELECT * FROM t;";
986        assert_eq!(lint(text).len(), 1);
987    }
988
989    #[test]
990    fn disable_next_line_suppresses_new_rules_too() {
991        for (text, code) in [
992            (
993                "-- sql-dialect-fmt: disable-next-line SDF004\nDELETE FROM t;",
994                "SDF004",
995            ),
996            (
997                "-- sql-dialect-fmt: disable-next-line SDF005\nUPDATE t SET a = 1;",
998                "SDF005",
999            ),
1000            (
1001                "-- sql-dialect-fmt: disable-next-line SDF006\nSELECT a FROM t1, t2;",
1002                "SDF006",
1003            ),
1004            (
1005                "-- sql-dialect-fmt: disable-next-line SDF007\nSELECT a FROM t ORDER BY 1;",
1006                "SDF007",
1007            ),
1008        ] {
1009            assert!(lint(text).is_empty(), "{code} should be suppressed");
1010        }
1011    }
1012
1013    #[test]
1014    fn codes_round_trip() {
1015        for code in [
1016            LintCode::SelectWildcard,
1017            LintCode::LargeInList,
1018            LintCode::UnsupportedEmbeddedLanguage,
1019            LintCode::DeleteWithoutWhere,
1020            LintCode::UpdateWithoutWhere,
1021            LintCode::CommaJoin,
1022            LintCode::OrderByOrdinal,
1023        ] {
1024            assert_eq!(LintCode::from_code(code.as_str()), Some(code));
1025        }
1026        assert_eq!(LintCode::from_code("SDF999"), None);
1027    }
1028}