Skip to main content

sqrust_core/
lib.rs

1pub mod config;
2pub use config::Config;
3
4use sqlparser::ast::Statement;
5use sqlparser::dialect::{BigQueryDialect, GenericDialect, SnowflakeDialect, DuckDbDialect, PostgreSqlDialect, MySqlDialect, AnsiDialect};
6use sqlparser::parser::Parser;
7use std::path::PathBuf;
8
9/// A single lint violation produced by a Rule.
10pub struct Diagnostic {
11    pub rule: &'static str,
12    pub message: String,
13    /// 1-indexed line number
14    pub line: usize,
15    /// 1-indexed column of the violation
16    pub col: usize,
17}
18
19/// All information a Rule needs to check one file.
20pub struct FileContext {
21    pub path: PathBuf,
22    pub source: String,
23    /// Parsed SQL statements. Empty if the file could not be parsed.
24    pub statements: Vec<Statement>,
25    /// Parse error messages, if parsing failed.
26    pub parse_errors: Vec<String>,
27}
28
29impl FileContext {
30    pub fn from_source(source: &str, path: &str) -> Self {
31        Self::from_source_with_dialect(source, path, None)
32    }
33
34    pub fn from_source_with_dialect(source: &str, path: &str, dialect: Option<&str>) -> Self {
35        let (statements, parse_errors) = match dialect {
36            Some("bigquery") => parse_with(&BigQueryDialect {}, source),
37            Some("snowflake") => parse_with(&SnowflakeDialect {}, source),
38            Some("duckdb") => parse_with(&DuckDbDialect {}, source),
39            Some("postgres") | Some("postgresql") => parse_with(&PostgreSqlDialect {}, source),
40            Some("mysql") => parse_with(&MySqlDialect {}, source),
41            Some("ansi") => parse_with(&AnsiDialect {}, source),
42            _ => parse_with(&GenericDialect {}, source),
43        };
44        FileContext {
45            path: PathBuf::from(path),
46            source: source.to_string(),
47            statements,
48            parse_errors,
49        }
50    }
51
52    /// Returns (1-indexed line number, line content) for each line.
53    pub fn lines(&self) -> impl Iterator<Item = (usize, &str)> {
54        self.source.lines().enumerate().map(|(i, line)| (i + 1, line))
55    }
56}
57
58fn parse_with<D: sqlparser::dialect::Dialect>(dialect: &D, source: &str) -> (Vec<Statement>, Vec<String>) {
59    match Parser::parse_sql(dialect, source) {
60        Ok(stmts) => (stmts, Vec::new()),
61        Err(e) => (Vec::new(), vec![e.to_string()]),
62    }
63}
64
65/// Every lint rule implements this trait.
66pub trait Rule: Send + Sync {
67    fn name(&self) -> &'static str;
68    fn check(&self, ctx: &FileContext) -> Vec<Diagnostic>;
69    /// Returns the fixed source if this rule supports auto-fix, None otherwise.
70    fn fix(&self, _ctx: &FileContext) -> Option<String> {
71        None
72    }
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78
79    #[test]
80    fn valid_sql_populates_statements() {
81        let ctx = FileContext::from_source("SELECT 1; SELECT 2;", "t.sql");
82        assert_eq!(ctx.statements.len(), 2);
83        assert!(ctx.parse_errors.is_empty());
84    }
85
86    #[test]
87    fn invalid_sql_stores_parse_error() {
88        let ctx = FileContext::from_source("SELECT FROM FROM", "t.sql");
89        assert!(ctx.statements.is_empty());
90        assert!(!ctx.parse_errors.is_empty());
91    }
92
93    #[test]
94    fn empty_sql_produces_no_statements_and_no_errors() {
95        let ctx = FileContext::from_source("", "t.sql");
96        assert!(ctx.statements.is_empty());
97        assert!(ctx.parse_errors.is_empty());
98    }
99
100    #[test]
101    fn lines_still_works_after_ast_addition() {
102        let ctx = FileContext::from_source("SELECT 1\nFROM t\n", "t.sql");
103        let lines: Vec<_> = ctx.lines().collect();
104        assert_eq!(lines.len(), 2);
105        assert_eq!(lines[0], (1, "SELECT 1"));
106        assert_eq!(lines[1], (2, "FROM t"));
107    }
108}