Skip to main content

flowscope_core/linter/
rule.rs

1//! Lint rule trait and context for SQL linting.
2
3use super::config::sqlfluff_name_for_code;
4use crate::types::{Dialect, Issue, Span};
5use sqlparser::ast::Statement;
6use sqlparser::tokenizer::TokenWithSpan;
7use std::cell::{Cell, RefCell};
8use std::ops::Range;
9
10thread_local! {
11    static ACTIVE_DIALECT: Cell<Dialect> = const { Cell::new(Dialect::Generic) };
12    static ACTIVE_DOCUMENT_TOKENS: RefCell<Vec<TokenWithSpan>> = const { RefCell::new(Vec::new()) };
13    static DOCUMENT_IS_TEMPLATED: Cell<bool> = const { Cell::new(false) };
14}
15
16/// Context provided to lint rules during analysis.
17pub struct LintContext<'a> {
18    /// The full SQL source text.
19    pub sql: &'a str,
20    /// Byte range of the current statement within the SQL source.
21    pub statement_range: Range<usize>,
22    /// Zero-based index of the current statement.
23    pub statement_index: usize,
24}
25
26impl<'a> LintContext<'a> {
27    /// Returns the SQL text for the current statement.
28    pub fn statement_sql(&self) -> &str {
29        &self.sql[self.statement_range.clone()]
30    }
31
32    /// Converts a byte offset relative to the statement into an absolute `Span`.
33    pub fn span_from_statement_offset(&self, start: usize, end: usize) -> Span {
34        Span::new(
35            self.statement_range.start + start,
36            self.statement_range.start + end,
37        )
38    }
39
40    /// Returns the dialect active for the current lint pass.
41    pub fn dialect(&self) -> Dialect {
42        ACTIVE_DIALECT.with(Cell::get)
43    }
44
45    /// Invokes `f` with the active document token stream, if available.
46    ///
47    /// Tokens include location spans from the single tokenizer pass performed
48    /// during `LintDocument` construction.
49    pub fn with_document_tokens<T>(&self, f: impl FnOnce(&[TokenWithSpan]) -> T) -> T {
50        ACTIVE_DOCUMENT_TOKENS.with(|tokens| {
51            let borrowed = tokens.borrow();
52            f(&borrowed)
53        })
54    }
55
56    /// Returns true if the document was processed through a templater
57    /// (Jinja, dbt, etc.) before linting.
58    pub fn is_templated(&self) -> bool {
59        DOCUMENT_IS_TEMPLATED.with(Cell::get)
60    }
61}
62
63pub(crate) fn with_active_dialect<T>(dialect: Dialect, f: impl FnOnce() -> T) -> T {
64    ACTIVE_DIALECT.with(|active| {
65        struct DialectReset<'a> {
66            cell: &'a Cell<Dialect>,
67            previous: Dialect,
68        }
69
70        impl Drop for DialectReset<'_> {
71            fn drop(&mut self) {
72                self.cell.set(self.previous);
73            }
74        }
75
76        let reset = DialectReset {
77            cell: active,
78            previous: active.replace(dialect),
79        };
80        let result = f();
81        drop(reset);
82        result
83    })
84}
85
86pub(crate) fn with_active_is_templated<T>(is_templated: bool, f: impl FnOnce() -> T) -> T {
87    DOCUMENT_IS_TEMPLATED.with(|active| {
88        let previous = active.replace(is_templated);
89        let result = f();
90        active.set(previous);
91        result
92    })
93}
94
95pub(crate) fn with_active_document_tokens<T>(tokens: &[TokenWithSpan], f: impl FnOnce() -> T) -> T {
96    ACTIVE_DOCUMENT_TOKENS.with(|active| {
97        struct TokensReset<'a> {
98            cell: &'a RefCell<Vec<TokenWithSpan>>,
99            previous: Vec<TokenWithSpan>,
100        }
101
102        impl Drop for TokensReset<'_> {
103            fn drop(&mut self) {
104                let _ = self.cell.replace(std::mem::take(&mut self.previous));
105            }
106        }
107
108        let reset = TokensReset {
109            cell: active,
110            previous: active.replace(tokens.to_vec()),
111        };
112        let result = f();
113        drop(reset);
114        result
115    })
116}
117
118/// A single lint rule that checks a parsed SQL statement for anti-patterns.
119pub trait LintRule: Send + Sync {
120    /// Machine-readable rule code (e.g., "LINT_AM_008").
121    fn code(&self) -> &'static str;
122
123    /// Short human-readable name (e.g., "Bare UNION").
124    fn name(&self) -> &'static str;
125
126    /// Longer description of what this rule checks.
127    fn description(&self) -> &'static str;
128
129    /// SQLFluff dotted identifier (e.g., `aliasing.table`).
130    fn sqlfluff_name(&self) -> &'static str {
131        sqlfluff_name_for_code(self.code()).unwrap_or("")
132    }
133
134    /// Check a single parsed statement and return any issues found.
135    fn check(&self, stmt: &Statement, ctx: &LintContext) -> Vec<Issue>;
136}