diff --git a/crates/flowscope-core/src/linter/mod.rs b/crates/flowscope-core/src/linter/mod.rs index 252df301..86ce7764 100644 --- a/crates/flowscope-core/src/linter/mod.rs +++ b/crates/flowscope-core/src/linter/mod.rs @@ -14,8 +14,8 @@ pub(crate) mod visit; use config::LintConfig; use document::{LintDocument, LintStatement}; use rule::{ - with_active_dialect, with_active_document_tokens, with_active_is_templated, LintContext, - LintRule, + DocumentSource, LintContext, RegisteredRule, RuleContext, RuleExecutionContext, RuleQuality, + RuleScope, StatementSource, }; use sqlparser::ast::Statement; use std::borrow::Cow; @@ -23,12 +23,11 @@ use std::borrow::Cow; use crate::{ parser::parse_sql, types::{Issue, LintConfidence, LintEngine, LintFallbackSource, Severity}, - Dialect, }; /// The SQL linter, holding a set of rules and configuration. pub struct Linter { - rules: Vec>, + rules: Vec, config: LintConfig, } @@ -36,7 +35,7 @@ impl Linter { /// Creates a new linter with the given configuration. pub fn new(config: LintConfig) -> Self { Self { - rules: rules::all_rules(&config), + rules: rules::registered_rules(&config), config, } } @@ -52,218 +51,111 @@ impl Linter { return Vec::new(); } - let is_templated = document.source_sql.is_some(); - with_active_is_templated(is_templated, || { - with_active_document_tokens(&document.raw_tokens, || { - let mut issues = Vec::new(); - - for engine in [ - LintEngine::Semantic, - LintEngine::Lexical, - LintEngine::Document, - ] { - for rule in &self.rules { - if !self.config.is_rule_enabled(rule.code()) - || rule_engine(rule.code()) != engine - || !rule_supported_in_dialect(rule.code(), document.dialect) - { - continue; - } - - let (confidence, fallback) = - lint_quality_for_rule(rule.code(), engine, document); - - if rule_uses_document_scope(rule.code()) { - let Some(synthetic_statement) = parse_sql("SELECT 1") - .ok() - .and_then(|mut statements| statements.drain(..).next()) - else { - continue; - }; - - let document_scope_sql = - document_scope_sql_for_rule(&self.config, rule.code(), document); - let ctx = LintContext { - sql: document_scope_sql.as_ref(), - statement_range: 0..document_scope_sql.len(), - statement_index: 0, - }; - - with_active_dialect(document.dialect, || { - for issue in rule.check(&synthetic_statement, &ctx) { - let mut issue = issue - .with_lint_engine(engine) - .with_lint_confidence(confidence); - - if let Some(source) = fallback { - issue = issue.with_lint_fallback_source(source); - } + let execution = RuleExecutionContext::new( + document.dialect, + &document.raw_tokens, + document.source_sql.is_some(), + ); + self.check_document_with_execution(document, execution) + } - let sqlfluff_name = rule.sqlfluff_name(); - if !sqlfluff_name.is_empty() { - issue = issue.with_sqlfluff_name(sqlfluff_name); - } + fn check_document_with_execution( + &self, + document: &LintDocument<'_>, + execution: RuleExecutionContext<'_>, + ) -> Vec { + let synthetic_statement = parse_sql("SELECT 1") + .ok() + .and_then(|mut statements| statements.drain(..).next()); + let mut issues = Vec::new(); + + for engine in [ + LintEngine::Semantic, + LintEngine::Lexical, + LintEngine::Document, + ] { + for registered in &self.rules { + let descriptor = registered.descriptor; + let rule = registered.rule.as_ref(); + if !self.config.is_rule_enabled(rule.code()) + || descriptor.engine != engine + || !descriptor.dialects.supports(document.dialect) + { + continue; + } - issues.push(issue); - } - }); + let (confidence, fallback) = lint_quality(descriptor.quality, engine, document); + match descriptor.scope { + RuleScope::Document(source) => { + let Some(synthetic_statement) = synthetic_statement.as_ref() else { continue; - } - + }; + let document_scope_sql = document_scope_sql(&self.config, source, document); + let ctx = RuleContext::with_execution( + document_scope_sql.as_ref(), + 0..document_scope_sql.len(), + 0, + execution, + ); + append_rule_issues( + &mut issues, + registered, + synthetic_statement, + &ctx, + confidence, + fallback, + ); + } + RuleScope::Statement(source) => { if document.statements.is_empty() { - if !rule_supports_statementless_fallback(rule.code()) { + if !descriptor.statementless_fallback { continue; } - - let Some(synthetic_statement) = parse_sql("SELECT 1") - .ok() - .and_then(|mut statements| statements.drain(..).next()) - else { + let Some(synthetic_statement) = synthetic_statement.as_ref() else { continue; }; - - let ctx = LintContext { - sql: document.sql, - statement_range: 0..document.sql.len(), - statement_index: 0, - }; - - with_active_dialect(document.dialect, || { - for issue in rule.check(&synthetic_statement, &ctx) { - let mut issue = issue - .with_lint_engine(engine) - .with_lint_confidence(confidence); - - if let Some(source) = fallback { - issue = issue.with_lint_fallback_source(source); - } - - let sqlfluff_name = rule.sqlfluff_name(); - if !sqlfluff_name.is_empty() { - issue = issue.with_sqlfluff_name(sqlfluff_name); - } - - issues.push(issue); - } - }); + let ctx = RuleContext::with_execution( + document.sql, + 0..document.sql.len(), + 0, + execution, + ); + append_rule_issues( + &mut issues, + registered, + synthetic_statement, + &ctx, + confidence, + fallback, + ); continue; } for statement in &document.statements { - let (ctx_sql, ctx_statement_range) = if matches!( - rule.code(), - crate::types::issue_codes::LINT_LT_002 - | crate::types::issue_codes::LINT_LT_005 - | crate::types::issue_codes::LINT_LT_004 - | crate::types::issue_codes::LINT_LT_007 - | crate::types::issue_codes::LINT_LT_012 - | crate::types::issue_codes::LINT_LT_013 - | crate::types::issue_codes::LINT_CV_009 - | crate::types::issue_codes::LINT_CV_010 - | crate::types::issue_codes::LINT_ST_004 - ) { - if matches!( - rule.code(), - crate::types::issue_codes::LINT_LT_012 - | crate::types::issue_codes::LINT_LT_013 - ) { - if let Some(source_sql) = document.source_sql { - (source_sql, 0..source_sql.len()) - } else { - (document.sql, statement.statement_range.clone()) - } - } else { - match ( - document.source_sql, - document - .source_statement_ranges - .get(statement.statement_index) - .and_then(|range| range.clone()), - ) { - (Some(source_sql), Some(source_statement_range)) => { - (source_sql, source_statement_range) - } - _ => (document.sql, statement.statement_range.clone()), - } - } - } else if rule.code() == crate::types::issue_codes::LINT_LT_001 { - // LT01 needs trailing whitespace visible so it can - // detect and fix trailing spaces/tabs on lines. - // The normal statement range trims whitespace, so - // extend it to include trailing whitespace up to - // the next newline (inclusive). - let lt01_ignore_templated = self - .config - .core_option_bool("ignore_templated_areas") - .unwrap_or(true); - match ( - document.source_sql, - document - .source_statement_ranges - .get(statement.statement_index) - .and_then(|range| range.clone()), - ) { - (Some(source_sql), Some(source_statement_range)) - if lt01_ignore_templated => - { - let range = extend_range_with_trailing_whitespace( - source_sql, - &source_statement_range, - next_source_statement_start( - &document.source_statement_ranges, - statement.statement_index, - ), - ); - (source_sql, range) - } - _ => { - let range = extend_range_with_trailing_whitespace( - document.sql, - &statement.statement_range, - next_statement_start( - &document.statements, - statement.statement_index, - ), - ); - (document.sql, range) - } - } - } else { - (document.sql, statement.statement_range.clone()) - }; - - let ctx = LintContext { - sql: ctx_sql, - statement_range: ctx_statement_range, - statement_index: statement.statement_index, - }; - - with_active_dialect(document.dialect, || { - for issue in rule.check(statement.statement, &ctx) { - let mut issue = issue - .with_lint_engine(engine) - .with_lint_confidence(confidence); - - if let Some(source) = fallback { - issue = issue.with_lint_fallback_source(source); - } - - let sqlfluff_name = rule.sqlfluff_name(); - if !sqlfluff_name.is_empty() { - issue = issue.with_sqlfluff_name(sqlfluff_name); - } - - issues.push(issue); - } - }); + let (ctx_sql, ctx_statement_range) = + statement_source_view(&self.config, source, document, statement); + let ctx = RuleContext::with_execution( + ctx_sql, + ctx_statement_range, + statement.statement_index, + execution, + ); + append_rule_issues( + &mut issues, + registered, + statement.statement, + &ctx, + confidence, + fallback, + ); } } } + } + } - let issues = suppress_noqa_issues(issues, document); - normalize_issues(issues) - }) - }) + let issues = suppress_noqa_issues(issues, document); + normalize_issues(issues) } /// Checks a single statement against all enabled lint rules. @@ -271,16 +163,107 @@ impl Linter { /// This adapter is kept for tests and rule-level helpers. Production paths /// should prefer `check_document()`. pub fn check_statement(&self, stmt: &Statement, ctx: &LintContext) -> Vec { + let ctx = RuleContext::new(ctx.sql, ctx.statement_range.clone(), ctx.statement_index); + self.check_statement_with_context(stmt, &ctx) + } + + /// Checks one statement while preserving explicit document execution metadata. + pub fn check_statement_with_context(&self, stmt: &Statement, ctx: &RuleContext) -> Vec { let document = LintDocument::new( ctx.sql, - crate::Dialect::Generic, + ctx.dialect(), vec![LintStatement { statement: stmt, statement_index: ctx.statement_index, statement_range: ctx.statement_range.clone(), }], ); - self.check_document(&document) + self.check_document_with_execution(&document, ctx.execution()) + } +} + +fn append_rule_issues( + issues: &mut Vec, + registered: &RegisteredRule, + statement: &Statement, + ctx: &RuleContext<'_>, + confidence: LintConfidence, + fallback: Option, +) { + for issue in registered.rule.check_with_context(statement, ctx) { + let mut issue = issue + .with_lint_engine(registered.descriptor.engine) + .with_lint_confidence(confidence); + + if let Some(source) = fallback { + issue = issue.with_lint_fallback_source(source); + } + + let sqlfluff_name = registered.rule.sqlfluff_name(); + if !sqlfluff_name.is_empty() { + issue = issue.with_sqlfluff_name(sqlfluff_name); + } + + issues.push(issue); + } +} + +fn statement_source_view<'a>( + config: &LintConfig, + source: StatementSource, + document: &'a LintDocument<'_>, + statement: &LintStatement<'_>, +) -> (&'a str, std::ops::Range) { + match source { + StatementSource::Rendered => (document.sql, statement.statement_range.clone()), + StatementSource::MappedSource => match ( + document.source_sql, + document + .source_statement_ranges + .get(statement.statement_index) + .and_then(|range| range.clone()), + ) { + (Some(source_sql), Some(source_statement_range)) => { + (source_sql, source_statement_range) + } + _ => (document.sql, statement.statement_range.clone()), + }, + StatementSource::WholeSource => document.source_sql.map_or_else( + || (document.sql, statement.statement_range.clone()), + |source_sql| (source_sql, 0..source_sql.len()), + ), + StatementSource::TrailingWhitespace => { + let ignore_templated = config + .core_option_bool("ignore_templated_areas") + .unwrap_or(true); + match ( + document.source_sql, + document + .source_statement_ranges + .get(statement.statement_index) + .and_then(|range| range.clone()), + ) { + (Some(source_sql), Some(source_statement_range)) if ignore_templated => { + let range = extend_range_with_trailing_whitespace( + source_sql, + &source_statement_range, + next_source_statement_start( + &document.source_statement_ranges, + statement.statement_index, + ), + ); + (source_sql, range) + } + _ => { + let range = extend_range_with_trailing_whitespace( + document.sql, + &statement.statement_range, + next_statement_start(&document.statements, statement.statement_index), + ); + (document.sql, range) + } + } + } } } @@ -382,43 +365,8 @@ const fn severity_rank(severity: Severity) -> u8 { } } -fn rule_engine(code: &str) -> LintEngine { - match code { - crate::types::issue_codes::LINT_LT_012 - | crate::types::issue_codes::LINT_LT_013 - | crate::types::issue_codes::LINT_LT_015 - | crate::types::issue_codes::LINT_ST_012 => LintEngine::Document, - c if c.starts_with("LINT_CP_") - || c.starts_with("LINT_JJ_") - || c.starts_with("LINT_LT_") - || c.starts_with("LINT_TQ_") => - { - LintEngine::Lexical - } - _ => LintEngine::Semantic, - } -} - -fn rule_supported_in_dialect(code: &str, dialect: Dialect) -> bool { - match code { - crate::types::issue_codes::LINT_AM_007 => matches!( - dialect, - Dialect::Generic - | Dialect::Ansi - | Dialect::Bigquery - | Dialect::Clickhouse - | Dialect::Databricks - | Dialect::Hive - | Dialect::Mysql - | Dialect::Redshift - | Dialect::Snowflake - ), - _ => true, - } -} - -fn lint_quality_for_rule( - code: &str, +fn lint_quality( + quality: RuleQuality, engine: LintEngine, document: &LintDocument<'_>, ) -> (LintConfidence, Option) { @@ -436,115 +384,25 @@ fn lint_quality_for_rule( ); } - if ast_rule_code(code) { + if quality == RuleQuality::Ast { return (LintConfidence::High, None); } (LintConfidence::Low, Some(LintFallbackSource::HeuristicRule)) } -fn ast_rule_code(code: &str) -> bool { - matches!( - code, - crate::types::issue_codes::LINT_AL_003 - | crate::types::issue_codes::LINT_AL_004 - | crate::types::issue_codes::LINT_AL_005 - | crate::types::issue_codes::LINT_AL_006 - | crate::types::issue_codes::LINT_AL_007 - | crate::types::issue_codes::LINT_AL_008 - | crate::types::issue_codes::LINT_AL_009 - | crate::types::issue_codes::LINT_AM_001 - | crate::types::issue_codes::LINT_AM_002 - | crate::types::issue_codes::LINT_AM_003 - | crate::types::issue_codes::LINT_AM_004 - | crate::types::issue_codes::LINT_AM_005 - | crate::types::issue_codes::LINT_AM_006 - | crate::types::issue_codes::LINT_AM_007 - | crate::types::issue_codes::LINT_AM_008 - | crate::types::issue_codes::LINT_CV_002 - | crate::types::issue_codes::LINT_CV_004 - | crate::types::issue_codes::LINT_CV_005 - | crate::types::issue_codes::LINT_CV_008 - | crate::types::issue_codes::LINT_CV_012 - | crate::types::issue_codes::LINT_RF_001 - | crate::types::issue_codes::LINT_RF_002 - | crate::types::issue_codes::LINT_RF_003 - | crate::types::issue_codes::LINT_ST_001 - | crate::types::issue_codes::LINT_ST_002 - | crate::types::issue_codes::LINT_ST_003 - | crate::types::issue_codes::LINT_ST_004 - | crate::types::issue_codes::LINT_ST_005 - | crate::types::issue_codes::LINT_ST_006 - | crate::types::issue_codes::LINT_ST_007 - | crate::types::issue_codes::LINT_ST_008 - | crate::types::issue_codes::LINT_ST_009 - | crate::types::issue_codes::LINT_ST_010 - | crate::types::issue_codes::LINT_ST_011 - ) -} - -fn rule_uses_document_scope(code: &str) -> bool { - matches!( - code, - crate::types::issue_codes::LINT_CP_001 - | crate::types::issue_codes::LINT_CP_003 - | crate::types::issue_codes::LINT_CP_004 - | crate::types::issue_codes::LINT_CP_005 - | crate::types::issue_codes::LINT_JJ_001 - ) -} - -fn rule_supports_statementless_fallback(code: &str) -> bool { - matches!( - code, - crate::types::issue_codes::LINT_LT_001 - | crate::types::issue_codes::LINT_LT_002 - | crate::types::issue_codes::LINT_LT_003 - | crate::types::issue_codes::LINT_LT_005 - | crate::types::issue_codes::LINT_LT_012 - | crate::types::issue_codes::LINT_AL_007 - | crate::types::issue_codes::LINT_AL_008 - | crate::types::issue_codes::LINT_AM_004 - | crate::types::issue_codes::LINT_CV_001 - | crate::types::issue_codes::LINT_RF_006 - | crate::types::issue_codes::LINT_ST_002 - | crate::types::issue_codes::LINT_TQ_001 - | crate::types::issue_codes::LINT_TQ_002 - | crate::types::issue_codes::LINT_CP_001 - | crate::types::issue_codes::LINT_CP_002 - | crate::types::issue_codes::LINT_CP_003 - | crate::types::issue_codes::LINT_CP_004 - | crate::types::issue_codes::LINT_CP_005 - | crate::types::issue_codes::LINT_ST_004 - ) -} - -fn document_scope_sql_for_rule<'a>( +fn document_scope_sql<'a>( config: &LintConfig, - code: &str, + source: DocumentSource, document: &LintDocument<'a>, ) -> Cow<'a, str> { - if !rule_uses_document_scope(code) { - return Cow::Borrowed(document.sql); - } - - // JJ01 checks Jinja delimiter padding in the raw source, so it must - // always see the untemplated SQL when templating has been applied. - if code == crate::types::issue_codes::LINT_JJ_001 { + if source == DocumentSource::OriginalSource { if let Some(source_sql) = document.source_sql { return Cow::Borrowed(source_sql); } return Cow::Borrowed(document.sql); } - // CP03 must apply patches against the original source text so fix spans - // remain valid when templated regions expand/contract during rendering. - if code == crate::types::issue_codes::LINT_CP_003 { - if let Some(source_sql) = document.source_sql { - return Cow::Borrowed(source_sql); - } - } - if !config .core_option_bool("ignore_templated_areas") .unwrap_or(false) @@ -634,8 +492,28 @@ fn offset_to_line(sql: &str, offset: usize) -> usize { #[cfg(test)] mod tests { - use super::{normalize_issues, strip_templated_areas}; - use crate::types::{Issue, IssueAutofixApplicability, IssuePatchEdit, Span}; + use super::{normalize_issues, strip_templated_areas, LintDocument, LintStatement, Linter}; + use crate::linter::config::LintConfig; + use crate::linter::rule::RuleContext; + use crate::parser::parse_sql_with_dialect; + use crate::types::{ + issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit, Span, + }; + + fn lint_parsed(sql: &str, dialect: Dialect) -> Vec { + let statements = parse_sql_with_dialect(sql, dialect).expect("parse"); + let lint_statements = statements + .iter() + .enumerate() + .map(|(statement_index, statement)| LintStatement { + statement, + statement_index, + statement_range: 0..sql.len(), + }) + .collect(); + let document = LintDocument::new(sql, dialect, lint_statements); + Linter::new(LintConfig::default()).check_document(&document) + } #[test] fn strip_templated_areas_preserves_lines_and_replaces_tag_content() { @@ -681,4 +559,57 @@ mod tests { let normalized = normalize_issues(vec![issue.clone(), issue]); assert_eq!(normalized.len(), 1); } + + #[test] + fn scheduler_filters_rules_using_descriptor_dialect_support() { + let sql = "SELECT a FROM t UNION SELECT b, c FROM u"; + let generic = lint_parsed(sql, Dialect::Generic); + let postgres = lint_parsed(sql, Dialect::Postgres); + + assert!(generic + .iter() + .any(|issue| issue.code == issue_codes::LINT_AM_007)); + assert!(!postgres + .iter() + .any(|issue| issue.code == issue_codes::LINT_AM_007)); + } + + #[test] + fn scheduler_honors_statementless_fallback_descriptor() { + let sql = "SELECT 1 UNION SELECT 2"; + let document = LintDocument::new(sql, Dialect::Generic, Vec::new()); + let issues = Linter::new(LintConfig::default()).check_document(&document); + + assert!(issues + .iter() + .any(|issue| issue.code == issue_codes::LINT_LT_001)); + assert!(!issues + .iter() + .any(|issue| issue.code == issue_codes::LINT_AM_002)); + } + + #[test] + fn statement_adapter_preserves_explicit_execution_context() { + let source_sql = "SELECT {{ \"greatest(a, b)\" }}, GREATEST(i, j)"; + let rendered_sql = "SELECT greatest(a, b), GREATEST(i, j)"; + let rendered = LintDocument::new(rendered_sql, Dialect::Ansi, Vec::new()); + let statements = parse_sql_with_dialect("SELECT 1", Dialect::Ansi).expect("parse"); + let config = LintConfig { + rule_configs: std::collections::BTreeMap::from([( + "core".to_string(), + serde_json::json!({"ignore_templated_areas": false}), + )]), + ..LintConfig::default() + }; + let context = RuleContext::new(source_sql, 0..source_sql.len(), 0) + .with_dialect(Dialect::Ansi) + .with_tokens(&rendered.raw_tokens) + .with_templated(true); + + let issues = Linter::new(config).check_statement_with_context(&statements[0], &context); + + assert!(issues + .iter() + .any(|issue| issue.code == issue_codes::LINT_CP_003)); + } } diff --git a/crates/flowscope-core/src/linter/rule.rs b/crates/flowscope-core/src/linter/rule.rs index 313e2cf2..9ca9b7b2 100644 --- a/crates/flowscope-core/src/linter/rule.rs +++ b/crates/flowscope-core/src/linter/rule.rs @@ -1,19 +1,82 @@ -//! Lint rule trait and context for SQL linting. +//! Lint rule trait, descriptors, and execution context for SQL linting. use super::config::sqlfluff_name_for_code; -use crate::types::{Dialect, Issue, Span}; +use crate::types::{Dialect, Issue, LintEngine, Span}; use sqlparser::ast::Statement; use sqlparser::tokenizer::TokenWithSpan; -use std::cell::{Cell, RefCell}; -use std::ops::Range; +use std::ops::{Deref, Range}; -thread_local! { - static ACTIVE_DIALECT: Cell = const { Cell::new(Dialect::Generic) }; - static ACTIVE_DOCUMENT_TOKENS: RefCell> = const { RefCell::new(Vec::new()) }; - static DOCUMENT_IS_TEMPLATED: Cell = const { Cell::new(false) }; +/// Metadata shared by every rule invocation in a lint document. +/// +/// The context is passed explicitly so concurrent and nested lint runs cannot +/// observe each other's dialect, token stream, or templating state. +#[derive(Debug, Clone, Copy)] +pub struct RuleExecutionContext<'a> { + dialect: Dialect, + document_tokens: &'a [TokenWithSpan], + is_templated: bool, +} + +impl<'a> RuleExecutionContext<'a> { + /// Creates execution metadata for a lint document. + #[must_use] + pub const fn new( + dialect: Dialect, + document_tokens: &'a [TokenWithSpan], + is_templated: bool, + ) -> Self { + Self { + dialect, + document_tokens, + is_templated, + } + } + + /// Returns the document's SQL dialect. + #[must_use] + pub const fn dialect(self) -> Dialect { + self.dialect + } + + /// Returns the token stream produced for the complete lint document. + #[must_use] + pub const fn document_tokens(self) -> &'a [TokenWithSpan] { + self.document_tokens + } + + /// Returns whether a templater processed the document before linting. + #[must_use] + pub const fn is_templated(self) -> bool { + self.is_templated + } + + #[must_use] + const fn with_dialect(mut self, dialect: Dialect) -> Self { + self.dialect = dialect; + self + } + + #[must_use] + const fn with_tokens(mut self, document_tokens: &'a [TokenWithSpan]) -> Self { + self.document_tokens = document_tokens; + self + } + + #[must_use] + const fn with_templated(mut self, is_templated: bool) -> Self { + self.is_templated = is_templated; + self + } +} + +impl Default for RuleExecutionContext<'_> { + fn default() -> Self { + Self::new(Dialect::Generic, &[], false) + } } /// Context provided to lint rules during analysis. +#[derive(Debug, Clone)] pub struct LintContext<'a> { /// The full SQL source text. pub sql: &'a str, @@ -24,6 +87,45 @@ pub struct LintContext<'a> { } impl<'a> LintContext<'a> { + /// Creates a statement context. + #[must_use] + pub fn new(sql: &'a str, statement_range: Range, statement_index: usize) -> Self { + Self { + sql, + statement_range, + statement_index, + } + } + + /// Adds explicit document execution metadata to this statement context. + #[must_use] + pub fn with_execution(self, execution: RuleExecutionContext<'a>) -> RuleContext<'a> { + RuleContext::with_execution( + self.sql, + self.statement_range, + self.statement_index, + execution, + ) + } + + /// Adds a dialect to this statement context. + #[must_use] + pub fn with_dialect(self, dialect: Dialect) -> RuleContext<'a> { + self.with_execution(RuleExecutionContext::default().with_dialect(dialect)) + } + + /// Adds a document token stream to this statement context. + #[must_use] + pub fn with_tokens(self, document_tokens: &'a [TokenWithSpan]) -> RuleContext<'a> { + self.with_execution(RuleExecutionContext::default().with_tokens(document_tokens)) + } + + /// Adds templating state to this statement context. + #[must_use] + pub fn with_templated(self, is_templated: bool) -> RuleContext<'a> { + self.with_execution(RuleExecutionContext::default().with_templated(is_templated)) + } + /// Returns the SQL text for the current statement. pub fn statement_sql(&self) -> &str { &self.sql[self.statement_range.clone()] @@ -37,82 +139,221 @@ impl<'a> LintContext<'a> { ) } - /// Returns the dialect active for the current lint pass. - pub fn dialect(&self) -> Dialect { - ACTIVE_DIALECT.with(Cell::get) + /// Returns the default dialect for direct legacy rule calls. + pub const fn dialect(&self) -> Dialect { + Dialect::Generic } - /// Invokes `f` with the active document token stream, if available. + /// Invokes `f` with the empty default token stream used by direct legacy calls. + pub fn with_document_tokens(&self, f: impl FnOnce(&[TokenWithSpan]) -> T) -> T { + f(&[]) + } + + /// Returns false because direct legacy rule calls have no templating metadata. + pub const fn is_templated(&self) -> bool { + false + } +} + +/// A statement context paired with immutable document execution metadata. +/// +/// The scheduler passes this type to registered rules. `LintContext` remains +/// available for source-compatible direct rule calls, which use default +/// execution metadata. +#[derive(Debug, Clone)] +pub struct RuleContext<'a> { + lint: LintContext<'a>, + execution: RuleExecutionContext<'a>, +} + +impl<'a> RuleContext<'a> { + /// Creates a context with generic, non-templated execution metadata. + #[must_use] + pub fn new(sql: &'a str, statement_range: Range, statement_index: usize) -> Self { + Self::with_execution( + sql, + statement_range, + statement_index, + RuleExecutionContext::default(), + ) + } + + /// Creates a statement context with explicit document execution metadata. + #[must_use] + pub fn with_execution( + sql: &'a str, + statement_range: Range, + statement_index: usize, + execution: RuleExecutionContext<'a>, + ) -> Self { + Self { + lint: LintContext::new(sql, statement_range, statement_index), + execution, + } + } + + /// Returns the source-compatible statement context. + #[must_use] + pub const fn lint_context(&self) -> &LintContext<'a> { + &self.lint + } + + /// Returns all document execution metadata. + #[must_use] + pub const fn execution(&self) -> RuleExecutionContext<'a> { + self.execution + } + + /// Overrides the dialect on a newly constructed context. + #[must_use] + pub fn with_dialect(mut self, dialect: Dialect) -> Self { + self.execution = self.execution.with_dialect(dialect); + self + } + + /// Overrides the document token stream on a newly constructed context. + #[must_use] + pub fn with_tokens(mut self, document_tokens: &'a [TokenWithSpan]) -> Self { + self.execution = self.execution.with_tokens(document_tokens); + self + } + + /// Marks a newly constructed context as templated or untemplated. + #[must_use] + pub fn with_templated(mut self, is_templated: bool) -> Self { + self.execution = self.execution.with_templated(is_templated); + self + } + + /// Creates another statement view while preserving execution metadata. + #[must_use] + pub fn statement_view( + &self, + sql: &'a str, + statement_range: Range, + statement_index: usize, + ) -> Self { + Self::with_execution(sql, statement_range, statement_index, self.execution) + } + + /// Returns the dialect active for this rule invocation. + pub const fn dialect(&self) -> Dialect { + self.execution.dialect() + } + + /// Invokes `f` with the document token stream. /// /// Tokens include location spans from the single tokenizer pass performed /// during `LintDocument` construction. pub fn with_document_tokens(&self, f: impl FnOnce(&[TokenWithSpan]) -> T) -> T { - ACTIVE_DOCUMENT_TOKENS.with(|tokens| { - let borrowed = tokens.borrow(); - f(&borrowed) - }) + f(self.execution.document_tokens()) } /// Returns true if the document was processed through a templater /// (Jinja, dbt, etc.) before linting. - pub fn is_templated(&self) -> bool { - DOCUMENT_IS_TEMPLATED.with(Cell::get) + pub const fn is_templated(&self) -> bool { + self.execution.is_templated() } } -pub(crate) fn with_active_dialect(dialect: Dialect, f: impl FnOnce() -> T) -> T { - ACTIVE_DIALECT.with(|active| { - struct DialectReset<'a> { - cell: &'a Cell, - previous: Dialect, - } +impl<'a> Deref for RuleContext<'a> { + type Target = LintContext<'a>; - impl Drop for DialectReset<'_> { - fn drop(&mut self) { - self.cell.set(self.previous); - } - } + fn deref(&self) -> &Self::Target { + &self.lint + } +} - let reset = DialectReset { - cell: active, - previous: active.replace(dialect), - }; - let result = f(); - drop(reset); - result - }) -} - -pub(crate) fn with_active_is_templated(is_templated: bool, f: impl FnOnce() -> T) -> T { - DOCUMENT_IS_TEMPLATED.with(|active| { - let previous = active.replace(is_templated); - let result = f(); - active.set(previous); - result - }) -} - -pub(crate) fn with_active_document_tokens(tokens: &[TokenWithSpan], f: impl FnOnce() -> T) -> T { - ACTIVE_DOCUMENT_TOKENS.with(|active| { - struct TokensReset<'a> { - cell: &'a RefCell>, - previous: Vec, +/// The source view supplied to a statement-scoped rule. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum StatementSource { + /// Use the rendered SQL statement range. + Rendered, + /// Prefer the corresponding original-source statement range when mapped. + MappedSource, + /// Use the complete original source when available. + WholeSource, + /// Include trailing whitespace trimmed from ordinary statement ranges. + TrailingWhitespace, +} + +/// The source view supplied to a document-scoped rule. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum DocumentSource { + /// Use original source and mask templated areas when configured. + MaskedSource, + /// Use the complete original source when available. + OriginalSource, +} + +/// How often a rule is invoked. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RuleScope { + Statement(StatementSource), + Document(DocumentSource), +} + +/// The analysis quality of a rule under normal parser/tokenizer operation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RuleQuality { + Ast, + Heuristic, +} + +/// Dialects accepted by a rule. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum DialectSupport { + All, + Only(&'static [Dialect]), +} + +impl DialectSupport { + pub fn supports(self, dialect: Dialect) -> bool { + match self { + Self::All => true, + Self::Only(dialects) => dialects.contains(&dialect), } + } +} + +/// Declarative scheduling and fallback policy for a registered rule. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct RuleDescriptor { + pub engine: LintEngine, + pub scope: RuleScope, + pub dialects: DialectSupport, + pub quality: RuleQuality, + pub statementless_fallback: bool, +} - impl Drop for TokensReset<'_> { - fn drop(&mut self) { - let _ = self.cell.replace(std::mem::take(&mut self.previous)); - } +impl RuleDescriptor { + pub const fn new( + engine: LintEngine, + scope: RuleScope, + dialects: DialectSupport, + quality: RuleQuality, + statementless_fallback: bool, + ) -> Self { + Self { + engine, + scope, + dialects, + quality, + statementless_fallback, } + } +} - let reset = TokensReset { - cell: active, - previous: active.replace(tokens.to_vec()), - }; - let result = f(); - drop(reset); - result - }) +/// A rule paired with the metadata used to schedule it. +pub(crate) struct RegisteredRule { + pub descriptor: RuleDescriptor, + pub rule: Box, +} + +impl RegisteredRule { + pub fn new(rule: Box, descriptor: RuleDescriptor) -> Self { + Self { descriptor, rule } + } } /// A single lint rule that checks a parsed SQL statement for anti-patterns. @@ -131,6 +372,130 @@ pub trait LintRule: Send + Sync { sqlfluff_name_for_code(self.code()).unwrap_or("") } - /// Check a single parsed statement and return any issues found. + /// Check a statement using generic, non-templated execution metadata. fn check(&self, stmt: &Statement, ctx: &LintContext) -> Vec; + + /// Check a statement with explicit document execution metadata. + fn check_with_context(&self, stmt: &Statement, ctx: &RuleContext) -> Vec { + self.check(stmt, ctx.lint_context()) + } +} + +/// Internal contract for built-in rules that consume explicit execution metadata. +pub(crate) trait BuiltinLintRule: Send + Sync { + fn code(&self) -> &'static str; + fn name(&self) -> &'static str; + fn description(&self) -> &'static str; + fn check_with_context(&self, stmt: &Statement, ctx: &RuleContext) -> Vec; +} + +impl LintRule for T { + fn code(&self) -> &'static str { + BuiltinLintRule::code(self) + } + + fn name(&self) -> &'static str { + BuiltinLintRule::name(self) + } + + fn description(&self) -> &'static str { + BuiltinLintRule::description(self) + } + + fn check(&self, stmt: &Statement, ctx: &LintContext) -> Vec { + BuiltinLintRule::check_with_context( + self, + stmt, + &RuleContext::new(ctx.sql, ctx.statement_range.clone(), ctx.statement_index), + ) + } + + fn check_with_context(&self, stmt: &Statement, ctx: &RuleContext) -> Vec { + BuiltinLintRule::check_with_context(self, stmt, ctx) + } +} + +#[cfg(test)] +mod tests { + use super::{LintContext, LintRule, RuleContext, RuleExecutionContext}; + use crate::{Dialect, Issue}; + use sqlparser::ast::Statement; + use sqlparser::tokenizer::{Token, TokenWithSpan}; + + struct LegacyRule; + + impl LintRule for LegacyRule { + fn code(&self) -> &'static str { + "LINT_TEST" + } + + fn name(&self) -> &'static str { + "Legacy test rule" + } + + fn description(&self) -> &'static str { + "Exercises the source-compatible public rule contract." + } + + fn check(&self, _stmt: &Statement, ctx: &LintContext) -> Vec { + assert_eq!(ctx.dialect(), Dialect::Generic); + assert!(!ctx.is_templated()); + ctx.with_document_tokens(|tokens| assert!(tokens.is_empty())); + Vec::new() + } + } + + #[test] + fn execution_contexts_are_isolated() { + let postgres_tokens = [TokenWithSpan::wrap(Token::Comma)]; + let mysql_tokens = [TokenWithSpan::wrap(Token::Period)]; + let postgres = RuleContext::with_execution( + "SELECT 1", + 0..8, + 0, + RuleExecutionContext::new(Dialect::Postgres, &postgres_tokens, true), + ); + let mysql = RuleContext::with_execution( + "SELECT 2", + 0..8, + 0, + RuleExecutionContext::new(Dialect::Mysql, &mysql_tokens, false), + ); + + assert_eq!(postgres.dialect(), Dialect::Postgres); + assert!(postgres.is_templated()); + postgres.with_document_tokens(|tokens| assert_eq!(tokens, postgres_tokens)); + + assert_eq!(mysql.dialect(), Dialect::Mysql); + assert!(!mysql.is_templated()); + mysql.with_document_tokens(|tokens| assert_eq!(tokens, mysql_tokens)); + + assert_eq!(postgres.dialect(), Dialect::Postgres); + assert!(postgres.is_templated()); + + let legacy = LintContext { + sql: "SELECT 3", + statement_range: 0..8, + statement_index: 0, + }; + assert_eq!(legacy.statement_sql(), "SELECT 3"); + assert_eq!(legacy.dialect(), Dialect::Generic); + assert!(!legacy.is_templated()); + legacy.with_document_tokens(|tokens| assert!(tokens.is_empty())); + } + + #[test] + fn legacy_rule_contract_has_one_required_non_recursive_entry_point() { + let statements = crate::parse_sql("SELECT 1").expect("parse"); + let legacy = LintContext { + sql: "SELECT 1", + statement_range: 0..8, + statement_index: 0, + }; + + assert!(LegacyRule.check(&statements[0], &legacy).is_empty()); + assert!(LegacyRule + .check_with_context(&statements[0], &RuleContext::new("SELECT 1", 0..8, 0)) + .is_empty()); + } } diff --git a/crates/flowscope-core/src/linter/rules/al_001.rs b/crates/flowscope-core/src/linter/rules/al_001.rs index c73974c6..1c6bab87 100644 --- a/crates/flowscope-core/src/linter/rules/al_001.rs +++ b/crates/flowscope-core/src/linter/rules/al_001.rs @@ -3,7 +3,7 @@ //! SQLFluff parity: configurable table aliasing style (`explicit`/`implicit`). use crate::linter::config::LintConfig; -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit, Span}; use sqlparser::ast::{ CreateView, Expr, FromTable, Ident, Merge, Query, SetExpr, Statement, TableFactor, @@ -65,7 +65,7 @@ impl Default for AliasingTableStyle { } } -impl LintRule for AliasingTableStyle { +impl BuiltinLintRule for AliasingTableStyle { fn code(&self) -> &'static str { issue_codes::LINT_AL_001 } @@ -78,7 +78,7 @@ impl LintRule for AliasingTableStyle { "Implicit/explicit aliasing of table." } - fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, statement: &Statement, ctx: &RuleContext) -> Vec { let mut issues = Vec::new(); let tokens = tokenized_for_context(ctx).or_else(|| tokenized(ctx.statement_sql(), ctx.dialect())); @@ -147,7 +147,7 @@ fn autofix_edits_for_occurrence( fn alias_occurrence_in_statement( alias: &Ident, - ctx: &LintContext, + ctx: &RuleContext, tokens: Option<&[LocatedToken]>, ) -> Option { let tokens = tokens?; @@ -530,7 +530,7 @@ fn tokenized(sql: &str, dialect: Dialect) -> Option> { Some(out) } -fn tokenized_for_context(ctx: &LintContext) -> Option> { +fn tokenized_for_context(ctx: &RuleContext) -> Option> { let statement_start = ctx.statement_range.start; ctx.with_document_tokens(|tokens| { if tokens.is_empty() { @@ -622,14 +622,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, stmt)| { - rule.check( - stmt, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(stmt, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } @@ -683,14 +676,8 @@ mod tests { .iter() .enumerate() .flat_map(|(index, stmt)| { - AliasingTableStyle::default().check( - stmt, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + AliasingTableStyle::default() + .check_with_context(stmt, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect::>(); assert_eq!(issues.len(), 2); diff --git a/crates/flowscope-core/src/linter/rules/al_002.rs b/crates/flowscope-core/src/linter/rules/al_002.rs index 813d5279..5bf97081 100644 --- a/crates/flowscope-core/src/linter/rules/al_002.rs +++ b/crates/flowscope-core/src/linter/rules/al_002.rs @@ -3,7 +3,7 @@ //! SQLFluff parity: configurable column aliasing style (`explicit`/`implicit`). use crate::linter::config::LintConfig; -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit, Span}; use sqlparser::ast::{Ident, SelectItem, Spanned, Statement}; use sqlparser::tokenizer::{Token, TokenWithSpan, Tokenizer, Whitespace}; @@ -64,7 +64,7 @@ impl Default for AliasingColumnStyle { } } -impl LintRule for AliasingColumnStyle { +impl BuiltinLintRule for AliasingColumnStyle { fn code(&self) -> &'static str { issue_codes::LINT_AL_002 } @@ -77,7 +77,7 @@ impl LintRule for AliasingColumnStyle { "Implicit/explicit aliasing of columns." } - fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, statement: &Statement, ctx: &RuleContext) -> Vec { let mut issues = Vec::new(); let tokens = tokenized_for_context(ctx).or_else(|| tokenized(ctx.statement_sql(), ctx.dialect())); @@ -152,7 +152,7 @@ fn autofix_edits_for_occurrence( fn alias_occurrence_in_statement( alias: &Ident, item: &SelectItem, - ctx: &LintContext, + ctx: &RuleContext, tokens: Option<&[LocatedToken]>, ) -> Option { let tokens = tokens?; @@ -261,7 +261,7 @@ fn tokenized(sql: &str, dialect: Dialect) -> Option> { Some(out) } -fn tokenized_for_context(ctx: &LintContext) -> Option> { +fn tokenized_for_context(ctx: &RuleContext) -> Option> { let statement_start = ctx.statement_range.start; ctx.with_document_tokens(|tokens| { if tokens.is_empty() { @@ -353,14 +353,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, stmt)| { - rule.check( - stmt, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(stmt, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } @@ -453,14 +446,8 @@ mod tests { fn allows_tsql_assignment_style_alias() { let sql = "select alias1 = col1"; let statements = parse_sql_with_dialect(sql, Dialect::Mssql).expect("parse"); - let issues = AliasingColumnStyle::default().check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = AliasingColumnStyle::default() + .check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert!(issues.is_empty()); } } diff --git a/crates/flowscope-core/src/linter/rules/al_003.rs b/crates/flowscope-core/src/linter/rules/al_003.rs index ac52f613..cce72645 100644 --- a/crates/flowscope-core/src/linter/rules/al_003.rs +++ b/crates/flowscope-core/src/linter/rules/al_003.rs @@ -5,7 +5,7 @@ //! an explicit alias for clarity and portability. use crate::linter::config::LintConfig; -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Issue}; use sqlparser::ast::*; @@ -29,7 +29,7 @@ impl Default for ImplicitAlias { } } -impl LintRule for ImplicitAlias { +impl BuiltinLintRule for ImplicitAlias { fn code(&self) -> &'static str { issue_codes::LINT_AL_003 } @@ -42,7 +42,7 @@ impl LintRule for ImplicitAlias { "Column expression without alias. Use explicit `AS` clause." } - fn check(&self, stmt: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, stmt: &Statement, ctx: &RuleContext) -> Vec { let mut issues = Vec::new(); check_statement(stmt, ctx, self.allow_scalar, &mut issues); issues @@ -51,7 +51,7 @@ impl LintRule for ImplicitAlias { fn check_statement( stmt: &Statement, - ctx: &LintContext, + ctx: &RuleContext, allow_scalar: bool, issues: &mut Vec, ) { @@ -76,7 +76,7 @@ fn check_statement( fn check_query( query: &Query, - ctx: &LintContext, + ctx: &RuleContext, allow_scalar: bool, issues: &mut Vec, has_cte_column_list: bool, @@ -95,7 +95,7 @@ fn check_query( fn check_set_expr( body: &SetExpr, - ctx: &LintContext, + ctx: &RuleContext, allow_scalar: bool, issues: &mut Vec, has_cte_column_list: bool, @@ -212,14 +212,10 @@ mod tests { fn check_sql_with_rule(sql: &str, rule: ImplicitAlias) -> Vec { let stmts = parse_sql(sql).unwrap(); - let ctx = LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }; + let ctx = RuleContext::new(sql, 0..sql.len(), 0); let mut issues = Vec::new(); for stmt in &stmts { - issues.extend(rule.check(stmt, &ctx)); + issues.extend(rule.check_with_context(stmt, &ctx)); } issues } diff --git a/crates/flowscope-core/src/linter/rules/al_004.rs b/crates/flowscope-core/src/linter/rules/al_004.rs index a356d0df..523fffa8 100644 --- a/crates/flowscope-core/src/linter/rules/al_004.rs +++ b/crates/flowscope-core/src/linter/rules/al_004.rs @@ -3,7 +3,7 @@ //! Table aliases should be unique within a query scope. use crate::linter::config::LintConfig; -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Issue}; use sqlparser::ast::{ CreateView, Expr, FunctionArg, FunctionArgExpr, FunctionArguments, Query, Select, SetExpr, @@ -65,7 +65,7 @@ impl Default for AliasingUniqueTable { } } -impl LintRule for AliasingUniqueTable { +impl BuiltinLintRule for AliasingUniqueTable { fn code(&self) -> &'static str { issue_codes::LINT_AL_004 } @@ -78,7 +78,7 @@ impl LintRule for AliasingUniqueTable { "Table aliases should be unique within each clause." } - fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, statement: &Statement, ctx: &RuleContext) -> Vec { if first_duplicate_table_alias_in_statement(statement, self.alias_case_check).is_none() { return Vec::new(); } @@ -664,14 +664,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } @@ -770,14 +763,8 @@ mod tests { serde_json::json!({"alias_case_check": "case_sensitive"}), )]), }); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert!(issues.is_empty()); } @@ -793,14 +780,8 @@ mod tests { serde_json::json!({"alias_case_check": "case_sensitive"}), )]), }); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); assert_eq!(issues[0].code, issue_codes::LINT_AL_004); } @@ -817,14 +798,8 @@ mod tests { serde_json::json!({"alias_case_check": "quoted_cs_naked_upper"}), )]), }); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); assert_eq!(issues[0].code, issue_codes::LINT_AL_004); } @@ -841,14 +816,8 @@ mod tests { serde_json::json!({"alias_case_check": "quoted_cs_naked_upper"}), )]), }); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert!(issues.is_empty()); } @@ -864,14 +833,8 @@ mod tests { serde_json::json!({"alias_case_check": "quoted_cs_naked_lower"}), )]), }); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); assert_eq!(issues[0].code, issue_codes::LINT_AL_004); } @@ -888,14 +851,8 @@ mod tests { serde_json::json!({"alias_case_check": "quoted_cs_naked_lower"}), )]), }); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert!(issues.is_empty()); } } diff --git a/crates/flowscope-core/src/linter/rules/al_005.rs b/crates/flowscope-core/src/linter/rules/al_005.rs index 92c83b56..c8dbf11c 100644 --- a/crates/flowscope-core/src/linter/rules/al_005.rs +++ b/crates/flowscope-core/src/linter/rules/al_005.rs @@ -4,7 +4,7 @@ //! anywhere in the query. This may indicate dead code or a copy-paste error. use crate::linter::config::LintConfig; -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit}; use sqlparser::ast::*; use sqlparser::tokenizer::{Token, TokenWithSpan, Tokenizer, Whitespace}; @@ -69,7 +69,7 @@ impl Default for UnusedTableAlias { } } -impl LintRule for UnusedTableAlias { +impl BuiltinLintRule for UnusedTableAlias { fn code(&self) -> &'static str { issue_codes::LINT_AL_005 } @@ -82,7 +82,7 @@ impl LintRule for UnusedTableAlias { "Tables should not be aliased if that alias is not used." } - fn check(&self, stmt: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, stmt: &Statement, ctx: &RuleContext) -> Vec { let mut issues = Vec::new(); match stmt { Statement::Query(q) => check_query(q, self.alias_case_check, ctx, &mut issues), @@ -128,7 +128,7 @@ impl LintRule for UnusedTableAlias { fn check_query( query: &Query, alias_case_check: AliasCaseCheck, - ctx: &LintContext, + ctx: &RuleContext, issues: &mut Vec, ) { if let Some(ref with) = query.with { @@ -151,7 +151,7 @@ fn check_query( fn check_set_expr( body: &SetExpr, alias_case_check: AliasCaseCheck, - ctx: &LintContext, + ctx: &RuleContext, issues: &mut Vec, ) { match body { @@ -171,7 +171,7 @@ fn check_select( select: &Select, order_by: Option<&OrderBy>, alias_case_check: AliasCaseCheck, - ctx: &LintContext, + ctx: &RuleContext, issues: &mut Vec, ) { for from_item in &select.from { @@ -225,7 +225,7 @@ fn check_select( fn check_delete( delete: &Delete, alias_case_check: AliasCaseCheck, - ctx: &LintContext, + ctx: &RuleContext, issues: &mut Vec, ) { let mut aliases: HashMap = HashMap::new(); @@ -1097,7 +1097,7 @@ fn join_constraint(op: &JoinOperator) -> Option<&Expr> { fn check_table_factor_subqueries( relation: &TableFactor, alias_case_check: AliasCaseCheck, - ctx: &LintContext, + ctx: &RuleContext, issues: &mut Vec, ) { match relation { @@ -1794,21 +1794,16 @@ fn legacy_is_ascii_ident_continue(byte: u8) -> bool { mod tests { use super::*; use crate::linter::config::LintConfig; - use crate::linter::rule::with_active_dialect; use crate::parser::{parse_sql, parse_sql_with_dialect}; use crate::types::{Dialect, IssueAutofixApplicability}; fn check_sql(sql: &str) -> Vec { let stmts = parse_sql(sql).unwrap(); let rule = UnusedTableAlias::default(); - let ctx = LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }; + let ctx = RuleContext::new(sql, 0..sql.len(), 0); let mut issues = Vec::new(); for stmt in &stmts { - issues.extend(rule.check(stmt, &ctx)); + issues.extend(rule.check_with_context(stmt, &ctx)); } issues } @@ -1827,17 +1822,11 @@ mod tests { fn check_sql_in_dialect(sql: &str, dialect: Dialect) -> Vec { let stmts = parse_sql_with_dialect(sql, dialect).unwrap(); let rule = UnusedTableAlias::default(); - let ctx = LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }; + let ctx = RuleContext::new(sql, 0..sql.len(), 0).with_dialect(dialect); let mut issues = Vec::new(); - with_active_dialect(dialect, || { - for stmt in &stmts { - issues.extend(rule.check(stmt, &ctx)); - } - }); + for stmt in &stmts { + issues.extend(rule.check_with_context(stmt, &ctx)); + } issues } @@ -2078,14 +2067,7 @@ mod tests { let rule = UnusedTableAlias::from_config(&config); let sql = "SELECT zoo.id, b.id FROM users AS \"Zoo\" JOIN books b ON zoo.id = b.user_id"; let stmts = parse_sql(sql).expect("parse"); - let issues = rule.check( - &stmts[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = rule.check_with_context(&stmts[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); assert!(issues[0].message.contains("Zoo")); } @@ -2103,14 +2085,7 @@ mod tests { let rule = UnusedTableAlias::from_config(&config); let sql = "SELECT zoo.id, b.id FROM users AS \"Zoo\" JOIN books b ON zoo.id = b.user_id"; let stmts = parse_sql(sql).expect("parse"); - let issues = rule.check( - &stmts[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = rule.check_with_context(&stmts[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert!(issues.is_empty()); } @@ -2127,14 +2102,7 @@ mod tests { let rule = UnusedTableAlias::from_config(&config); let sql = "SELECT foo.id, b.id FROM users AS \"FOO\" JOIN books b ON foo.id = b.user_id"; let stmts = parse_sql(sql).expect("parse"); - let issues = rule.check( - &stmts[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = rule.check_with_context(&stmts[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert!(issues.is_empty()); } @@ -2151,14 +2119,7 @@ mod tests { let rule = UnusedTableAlias::from_config(&config); let sql = "SELECT FOO.id, b.id FROM users AS \"foo\" JOIN books b ON FOO.id = b.user_id"; let stmts = parse_sql(sql).expect("parse"); - let issues = rule.check( - &stmts[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = rule.check_with_context(&stmts[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert!(issues.is_empty()); } diff --git a/crates/flowscope-core/src/linter/rules/al_006.rs b/crates/flowscope-core/src/linter/rules/al_006.rs index 94a13710..4fd8b50a 100644 --- a/crates/flowscope-core/src/linter/rules/al_006.rs +++ b/crates/flowscope-core/src/linter/rules/al_006.rs @@ -4,7 +4,7 @@ //! characters are discouraged. use crate::linter::config::LintConfig; -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Issue}; use sqlparser::ast::{Select, Statement, TableFactor, TableWithJoins}; @@ -40,7 +40,7 @@ impl Default for AliasingLength { } } -impl LintRule for AliasingLength { +impl BuiltinLintRule for AliasingLength { fn code(&self) -> &'static str { issue_codes::LINT_AL_006 } @@ -53,7 +53,7 @@ impl LintRule for AliasingLength { "Enforce table alias lengths in from clauses and join conditions." } - fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, statement: &Statement, ctx: &RuleContext) -> Vec { let mut violations = 0usize; visit_selects_in_statement(statement, &mut |select| { @@ -181,14 +181,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } @@ -238,15 +231,13 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( + rule.check_with_context( statement, - &LintContext { - sql: "SELECT * FROM users this_alias_name_is_longer_than_thirty_chars", - statement_range: 0 - .."SELECT * FROM users this_alias_name_is_longer_than_thirty_chars" - .len(), - statement_index: index, - }, + &RuleContext::new( + "SELECT * FROM users this_alias_name_is_longer_than_thirty_chars", + 0.."SELECT * FROM users this_alias_name_is_longer_than_thirty_chars".len(), + index, + ), ) }) .collect::>(); @@ -272,13 +263,13 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( + rule.check_with_context( statement, - &LintContext { - sql: "SELECT * FROM users eleven_chars", - statement_range: 0.."SELECT * FROM users eleven_chars".len(), - statement_index: index, - }, + &RuleContext::new( + "SELECT * FROM users eleven_chars", + 0.."SELECT * FROM users eleven_chars".len(), + index, + ), ) }) .collect::>(); @@ -303,13 +294,13 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( + rule.check_with_context( statement, - &LintContext { - sql: "SELECT * FROM users a", - statement_range: 0.."SELECT * FROM users a".len(), - statement_index: index, - }, + &RuleContext::new( + "SELECT * FROM users a", + 0.."SELECT * FROM users a".len(), + index, + ), ) }) .collect::>(); diff --git a/crates/flowscope-core/src/linter/rules/al_007.rs b/crates/flowscope-core/src/linter/rules/al_007.rs index f1ee4d92..f85b0af6 100644 --- a/crates/flowscope-core/src/linter/rules/al_007.rs +++ b/crates/flowscope-core/src/linter/rules/al_007.rs @@ -4,7 +4,7 @@ //! needed to disambiguate repeated references to the same table (self-joins). use crate::linter::config::LintConfig; -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit, Span}; use sqlparser::ast::{Ident, Select, Statement, TableFactor, TableWithJoins}; use sqlparser::keywords::Keyword; @@ -28,7 +28,7 @@ impl AliasingForbidSingleTable { } } -impl LintRule for AliasingForbidSingleTable { +impl BuiltinLintRule for AliasingForbidSingleTable { fn code(&self) -> &'static str { issue_codes::LINT_AL_007 } @@ -41,7 +41,7 @@ impl LintRule for AliasingForbidSingleTable { "Avoid table aliases in from clauses and join conditions." } - fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, statement: &Statement, ctx: &RuleContext) -> Vec { if !self.force_enable { return Vec::new(); } @@ -84,7 +84,7 @@ struct FallbackAliasCandidate { } fn fallback_single_from_alias_issue( - ctx: &LintContext, + ctx: &RuleContext, tokens: Option<&[LocatedToken]>, ) -> Option { if ctx.dialect() != Dialect::Mssql { @@ -315,7 +315,7 @@ fn collect_alias_candidates_from_table_factor( fn build_autofix_edits( alias_info: &UnnecessaryAlias, all_aliases: &[UnnecessaryAlias], - ctx: &LintContext, + ctx: &RuleContext, tokens: Option<&[LocatedToken]>, ) -> Vec { let Some(tokens) = tokens else { @@ -512,7 +512,7 @@ fn tokenized(sql: &str, dialect: Dialect) -> Option> { Some(out) } -fn tokenized_for_context(ctx: &LintContext) -> Option> { +fn tokenized_for_context(ctx: &RuleContext) -> Option> { let statement_start = ctx.statement_range.start; ctx.with_document_tokens(|tokens| { if tokens.is_empty() { @@ -599,7 +599,6 @@ fn line_col_to_offset(sql: &str, line: usize, column: usize) -> Option { #[cfg(test)] mod tests { use super::*; - use crate::linter::rule::with_active_dialect; use crate::parser::parse_sql; use crate::types::Dialect; @@ -610,14 +609,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } @@ -633,16 +625,10 @@ mod tests { )]), }; let rule = AliasingForbidSingleTable::from_config(&config); - with_active_dialect(Dialect::Mssql, || { - rule.check( - &synthetic[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ) - }) + rule.check_with_context( + &synthetic[0], + &RuleContext::new(sql, 0..sql.len(), 0).with_dialect(Dialect::Mssql), + ) } fn apply_issue_autofix(sql: &str, issue: &Issue) -> Option { @@ -671,14 +657,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } @@ -747,14 +726,8 @@ mod tests { let rule = AliasingForbidSingleTable::from_config(&config); let sql = "SELECT * FROM users u"; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert!(issues.is_empty()); } diff --git a/crates/flowscope-core/src/linter/rules/al_008.rs b/crates/flowscope-core/src/linter/rules/al_008.rs index 358a94fa..31a8592f 100644 --- a/crates/flowscope-core/src/linter/rules/al_008.rs +++ b/crates/flowscope-core/src/linter/rules/al_008.rs @@ -3,7 +3,7 @@ //! Column aliases should be unique in each SELECT projection. use crate::linter::config::LintConfig; -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Issue}; use sqlparser::ast::{ CreateView, Expr, Query, Select, SelectItem, SetExpr, Statement, TableFactor, TableWithJoins, @@ -62,7 +62,7 @@ impl Default for AliasingUniqueColumn { } } -impl LintRule for AliasingUniqueColumn { +impl BuiltinLintRule for AliasingUniqueColumn { fn code(&self) -> &'static str { issue_codes::LINT_AL_008 } @@ -75,7 +75,7 @@ impl LintRule for AliasingUniqueColumn { "Column aliases should be unique within each clause." } - fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, statement: &Statement, ctx: &RuleContext) -> Vec { let duplicate = first_duplicate_column_alias_in_statement(statement, self.alias_case_check) .or_else(|| { fallback_duplicate_column_alias_in_sql(ctx.statement_sql(), self.alias_case_check) @@ -395,14 +395,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } @@ -466,14 +459,8 @@ mod tests { serde_json::json!({"alias_case_check": "case_sensitive"}), )]), }); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert!(issues.is_empty()); } @@ -489,14 +476,8 @@ mod tests { serde_json::json!({"alias_case_check": "case_sensitive"}), )]), }); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); } @@ -512,14 +493,8 @@ mod tests { serde_json::json!({"alias_case_check": "quoted_cs_naked_upper"}), )]), }); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); } @@ -535,14 +510,8 @@ mod tests { serde_json::json!({"alias_case_check": "quoted_cs_naked_upper"}), )]), }); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert!(issues.is_empty()); } @@ -558,14 +527,8 @@ mod tests { serde_json::json!({"alias_case_check": "quoted_cs_naked_lower"}), )]), }); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); } @@ -584,14 +547,8 @@ mod tests { let sql = "select\n foo,\n b as foo,\n c as bar,\n bar,\n d foo,\n"; let synthetic = parse_sql("SELECT 1").expect("parse"); let rule = AliasingUniqueColumn::default(); - let issues = rule.check( - &synthetic[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&synthetic[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); assert_eq!(issues[0].code, issue_codes::LINT_AL_008); } @@ -608,14 +565,8 @@ mod tests { serde_json::json!({"alias_case_check": "quoted_cs_naked_lower"}), )]), }); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert!(issues.is_empty()); } } diff --git a/crates/flowscope-core/src/linter/rules/al_009.rs b/crates/flowscope-core/src/linter/rules/al_009.rs index 77b1da80..5226531b 100644 --- a/crates/flowscope-core/src/linter/rules/al_009.rs +++ b/crates/flowscope-core/src/linter/rules/al_009.rs @@ -4,7 +4,7 @@ use crate::generated::NormalizationStrategy; use crate::linter::config::LintConfig; -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Issue, IssueAutofixApplicability, IssuePatchEdit, Span}; use regex::Regex; use sqlparser::ast::{Expr, Ident, SelectItem, Statement}; @@ -64,7 +64,7 @@ impl Default for AliasingSelfAliasColumn { } } -impl LintRule for AliasingSelfAliasColumn { +impl BuiltinLintRule for AliasingSelfAliasColumn { fn code(&self) -> &'static str { issue_codes::LINT_AL_009 } @@ -77,7 +77,7 @@ impl LintRule for AliasingSelfAliasColumn { "Column aliases should not alias to itself, i.e. self-alias." } - fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, statement: &Statement, ctx: &RuleContext) -> Vec { let mut violating_aliases = Vec::new(); // Resolve Dialect mode to the concrete normalization strategy at check time @@ -160,7 +160,7 @@ struct Al009AutofixCandidate { } fn al009_autofix_candidates_for_context( - ctx: &LintContext, + ctx: &RuleContext, aliases: &[Ident], ) -> Vec { if aliases.is_empty() { @@ -202,7 +202,7 @@ fn al009_autofix_candidates_for_context( candidates } -fn statement_positioned_tokens(ctx: &LintContext) -> Vec { +fn statement_positioned_tokens(ctx: &RuleContext) -> Vec { let from_document_tokens = ctx.with_document_tokens(|tokens| { if tokens.is_empty() { return None; @@ -475,7 +475,7 @@ fn normalize_name_for_mode(name_ref: NameRef<'_>, mode: AliasCaseCheck) -> Strin } fn legacy_self_alias_candidates_for_context( - ctx: &LintContext, + ctx: &RuleContext, alias_case_check: AliasCaseCheck, dialect_strategy: Option, ) -> Vec { @@ -661,7 +661,6 @@ fn contains_assignment_alias_pattern(sql: &str) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::linter::rule::with_active_dialect; use crate::parser::{parse_sql, parse_sql_with_dialect}; use crate::types::{Dialect, IssueAutofixApplicability}; @@ -672,14 +671,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } @@ -688,18 +680,12 @@ mod tests { let statements = parse_sql_with_dialect(sql, dialect).expect("parse"); let rule = AliasingSelfAliasColumn::default(); let mut issues = Vec::new(); - with_active_dialect(dialect, || { - for (index, statement) in statements.iter().enumerate() { - issues.extend(rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - )); - } - }); + for (index, statement) in statements.iter().enumerate() { + issues.extend(rule.check_with_context( + statement, + &RuleContext::new(sql, 0..sql.len(), index).with_dialect(dialect), + )); + } issues } @@ -769,14 +755,8 @@ mod tests { serde_json::json!({"alias_case_check": "case_sensitive"}), )]), }); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert!(issues.is_empty()); } @@ -792,14 +772,8 @@ mod tests { serde_json::json!({"alias_case_check": "quoted_cs_naked_upper"}), )]), }); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); } @@ -815,14 +789,8 @@ mod tests { serde_json::json!({"alias_case_check": "quoted_cs_naked_upper"}), )]), }); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert!(issues.is_empty()); } @@ -838,14 +806,8 @@ mod tests { serde_json::json!({"alias_case_check": "quoted_cs_naked_lower"}), )]), }); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); } @@ -861,14 +823,8 @@ mod tests { serde_json::json!({"alias_case_check": "quoted_cs_naked_lower"}), )]), }); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert!(issues.is_empty()); } @@ -949,16 +905,10 @@ mod tests { let sql = "select\n this_alias_is_fine = col_a,\n col_b = col_b,\n COL_C AS COL_C,\n Col_D = Col_D,\n col_e col_e,\n COL_F COL_F,\n Col_G Col_G\nfrom foo"; let statements = parse_sql("SELECT 1").expect("synthetic parse"); let rule = AliasingSelfAliasColumn::default(); - let issues = with_active_dialect(Dialect::Mssql, || { - rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ) - }); + let issues = rule.check_with_context( + &statements[0], + &RuleContext::new(sql, 0..sql.len(), 0).with_dialect(Dialect::Mssql), + ); assert_eq!(issues.len(), 1); let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix"); assert_eq!( @@ -972,16 +922,10 @@ mod tests { let sql = "SELECT `col``col`\nFROM clients as c"; let statements = parse_sql("SELECT 1").expect("synthetic parse"); let rule = AliasingSelfAliasColumn::default(); - let issues = with_active_dialect(Dialect::Bigquery, || { - rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ) - }); + let issues = rule.check_with_context( + &statements[0], + &RuleContext::new(sql, 0..sql.len(), 0).with_dialect(Dialect::Bigquery), + ); assert_eq!(issues.len(), 1); let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix"); assert_eq!(fixed, "SELECT `col`\nFROM clients as c"); diff --git a/crates/flowscope-core/src/linter/rules/am_001.rs b/crates/flowscope-core/src/linter/rules/am_001.rs index ab9511a9..93762867 100644 --- a/crates/flowscope-core/src/linter/rules/am_001.rs +++ b/crates/flowscope-core/src/linter/rules/am_001.rs @@ -3,7 +3,7 @@ //! Using DISTINCT with GROUP BY is redundant because GROUP BY already //! collapses duplicate rows. The DISTINCT can be safely removed. -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit}; use sqlparser::ast::*; use sqlparser::keywords::Keyword; @@ -11,7 +11,7 @@ use sqlparser::tokenizer::{Token, TokenWithSpan, Tokenizer, Whitespace}; pub struct DistinctWithGroupBy; -impl LintRule for DistinctWithGroupBy { +impl BuiltinLintRule for DistinctWithGroupBy { fn code(&self) -> &'static str { issue_codes::LINT_AM_001 } @@ -24,7 +24,7 @@ impl LintRule for DistinctWithGroupBy { "Ambiguous use of 'DISTINCT' in a 'SELECT' statement with 'GROUP BY'." } - fn check(&self, stmt: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, stmt: &Statement, ctx: &RuleContext) -> Vec { let mut issues = Vec::new(); check_statement(stmt, ctx, &mut issues); @@ -45,7 +45,7 @@ impl LintRule for DistinctWithGroupBy { } } -fn check_statement(stmt: &Statement, ctx: &LintContext, issues: &mut Vec) { +fn check_statement(stmt: &Statement, ctx: &RuleContext, issues: &mut Vec) { match stmt { Statement::Query(q) => check_query(q, ctx, issues), Statement::Insert(ins) => { @@ -63,7 +63,7 @@ fn check_statement(stmt: &Statement, ctx: &LintContext, issues: &mut Vec) } } -fn check_query(query: &Query, ctx: &LintContext, issues: &mut Vec) { +fn check_query(query: &Query, ctx: &RuleContext, issues: &mut Vec) { if let Some(ref with) = query.with { for cte in &with.cte_tables { check_query(&cte.query, ctx, issues); @@ -72,7 +72,7 @@ fn check_query(query: &Query, ctx: &LintContext, issues: &mut Vec) { check_set_expr(&query.body, ctx, issues); } -fn check_set_expr(body: &SetExpr, ctx: &LintContext, issues: &mut Vec) { +fn check_set_expr(body: &SetExpr, ctx: &RuleContext, issues: &mut Vec) { match body { SetExpr::Select(select) => { let has_distinct = matches!( @@ -111,7 +111,7 @@ fn check_set_expr(body: &SetExpr, ctx: &LintContext, issues: &mut Vec) { } } -fn check_table_factor(relation: &TableFactor, ctx: &LintContext, issues: &mut Vec) { +fn check_table_factor(relation: &TableFactor, ctx: &RuleContext, issues: &mut Vec) { match relation { TableFactor::Derived { subquery, .. } => check_query(subquery, ctx, issues), TableFactor::NestedJoin { @@ -315,14 +315,10 @@ mod tests { fn check_sql(sql: &str) -> Vec { let stmts = parse_sql(sql).unwrap(); let rule = DistinctWithGroupBy; - let ctx = LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }; + let ctx = RuleContext::new(sql, 0..sql.len(), 0); let mut issues = Vec::new(); for stmt in &stmts { - issues.extend(rule.check(stmt, &ctx)); + issues.extend(rule.check_with_context(stmt, &ctx)); } issues } diff --git a/crates/flowscope-core/src/linter/rules/am_002.rs b/crates/flowscope-core/src/linter/rules/am_002.rs index 7d3332ed..c3e1af92 100644 --- a/crates/flowscope-core/src/linter/rules/am_002.rs +++ b/crates/flowscope-core/src/linter/rules/am_002.rs @@ -2,7 +2,7 @@ //! //! `UNION` should be explicit (`UNION DISTINCT` or `UNION ALL`) to avoid ambiguous implicit behavior. -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit}; use sqlparser::ast::*; use sqlparser::keywords::Keyword; @@ -10,7 +10,7 @@ use sqlparser::tokenizer::{Location, Span, Token, TokenWithSpan, Tokenizer}; pub struct BareUnion; -impl LintRule for BareUnion { +impl BuiltinLintRule for BareUnion { fn code(&self) -> &'static str { issue_codes::LINT_AM_002 } @@ -23,7 +23,7 @@ impl LintRule for BareUnion { "'UNION [DISTINCT|ALL]' is preferred over just 'UNION'." } - fn check(&self, stmt: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, stmt: &Statement, ctx: &RuleContext) -> Vec { let mut issues = Vec::new(); let mut unions = union_keyword_ranges_for_context(ctx); match stmt { @@ -47,7 +47,7 @@ impl LintRule for BareUnion { } } -fn union_keyword_ranges_for_context(ctx: &LintContext) -> UnionKeywordRanges { +fn union_keyword_ranges_for_context(ctx: &RuleContext) -> UnionKeywordRanges { let tokens = tokenized_for_context(ctx); union_keyword_ranges(ctx.statement_sql(), ctx.dialect(), tokens.as_deref()) } @@ -55,7 +55,7 @@ fn union_keyword_ranges_for_context(ctx: &LintContext) -> UnionKeywordRanges { fn check_query( query: &Query, unions: &mut UnionKeywordRanges, - ctx: &LintContext, + ctx: &RuleContext, issues: &mut Vec, ) { if let Some(ref with) = query.with { @@ -69,7 +69,7 @@ fn check_query( fn check_query_body( body: &SetExpr, unions: &mut UnionKeywordRanges, - ctx: &LintContext, + ctx: &RuleContext, issues: &mut Vec, ) { match body { @@ -183,7 +183,7 @@ fn tokenized(sql: &str, dialect: Dialect) -> Option> { tokenizer.tokenize_with_location().ok() } -fn tokenized_for_context(ctx: &LintContext) -> Option> { +fn tokenized_for_context(ctx: &RuleContext) -> Option> { let (statement_start_line, statement_start_column) = offset_to_line_col(ctx.sql, ctx.statement_range.start)?; @@ -335,21 +335,16 @@ fn relative_location( #[cfg(test)] mod tests { use super::*; - use crate::linter::rule::with_active_dialect; use crate::parser::{parse_sql, parse_sql_with_dialect}; use crate::types::IssueAutofixApplicability; fn check_sql(sql: &str) -> Vec { let stmts = parse_sql(sql).unwrap(); let rule = BareUnion; - let ctx = LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }; + let ctx = RuleContext::new(sql, 0..sql.len(), 0); let mut issues = Vec::new(); for stmt in &stmts { - issues.extend(rule.check(stmt, &ctx)); + issues.extend(rule.check_with_context(stmt, &ctx)); } issues } @@ -358,18 +353,12 @@ mod tests { let stmts = parse_sql_with_dialect(sql, dialect).unwrap(); let rule = BareUnion; let mut issues = Vec::new(); - with_active_dialect(dialect, || { - for stmt in &stmts { - issues.extend(rule.check( - stmt, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - )); - } - }); + for stmt in &stmts { + issues.extend(rule.check_with_context( + stmt, + &RuleContext::new(sql, 0..sql.len(), 0).with_dialect(dialect), + )); + } issues } diff --git a/crates/flowscope-core/src/linter/rules/am_003.rs b/crates/flowscope-core/src/linter/rules/am_003.rs index 9e8d536b..d3af2c29 100644 --- a/crates/flowscope-core/src/linter/rules/am_003.rs +++ b/crates/flowscope-core/src/linter/rules/am_003.rs @@ -2,7 +2,7 @@ //! //! SQLFluff AM03 parity: if any ORDER BY item specifies ASC/DESC, all should. -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit, Span}; use sqlparser::ast::{ CreateView, Expr, FunctionArg, FunctionArgExpr, FunctionArguments, OrderByKind, Query, Select, @@ -15,7 +15,7 @@ use super::semantic_helpers::join_on_expr; pub struct AmbiguousOrderBy; -impl LintRule for AmbiguousOrderBy { +impl BuiltinLintRule for AmbiguousOrderBy { fn code(&self) -> &'static str { issue_codes::LINT_AM_003 } @@ -28,7 +28,7 @@ impl LintRule for AmbiguousOrderBy { "Ambiguous ordering directions for columns in order by clause." } - fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, statement: &Statement, ctx: &RuleContext) -> Vec { let mut violation_count = 0usize; check_statement(statement, &mut violation_count); let clause_autofixes = am003_clause_autofixes(ctx.statement_sql(), ctx.dialect()); @@ -586,14 +586,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } diff --git a/crates/flowscope-core/src/linter/rules/am_004.rs b/crates/flowscope-core/src/linter/rules/am_004.rs index 3b2228f8..c8d3ef0f 100644 --- a/crates/flowscope-core/src/linter/rules/am_004.rs +++ b/crates/flowscope-core/src/linter/rules/am_004.rs @@ -3,7 +3,7 @@ //! Flags queries whose output width is not deterministically known, usually due //! to unresolved wildcard projections (`*` / `alias.*`). -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Issue}; use sqlparser::ast::{CreateView, Expr, SelectItem, SetExpr, Statement, Value}; use std::collections::HashMap; @@ -12,7 +12,7 @@ use super::column_count_helpers::{resolve_query_output_columns_strict, CteColumn pub struct AmbiguousColumnCount; -impl LintRule for AmbiguousColumnCount { +impl BuiltinLintRule for AmbiguousColumnCount { fn code(&self) -> &'static str { issue_codes::LINT_AM_004 } @@ -25,7 +25,7 @@ impl LintRule for AmbiguousColumnCount { "Query produces an unknown number of result columns." } - fn check(&self, stmt: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, stmt: &Statement, ctx: &RuleContext) -> Vec { let ast_unknown = statement_has_unknown_result_columns(stmt, &HashMap::new()); let fallback_unknown = !ast_unknown && statement_has_unknown_result_columns_fallback(stmt, ctx.statement_sql()); @@ -137,14 +137,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } @@ -298,14 +291,8 @@ mod tests { let sql = "with\nhubspot__contacts as (\n select * from ANALYTICS.PUBLIC_intermediate.hubspot__contacts\n),\nfinal as (\n select *\n from\n hubspot__contacts\n where not coalesce(_fivetran_deleted, false)\n)\nselect * from final\n"; let synthetic = parse_sql("SELECT 1").expect("parse"); let rule = AmbiguousColumnCount; - let issues = rule.check( - &synthetic[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&synthetic[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); assert_eq!(issues[0].code, issue_codes::LINT_AM_004); } diff --git a/crates/flowscope-core/src/linter/rules/am_005.rs b/crates/flowscope-core/src/linter/rules/am_005.rs index 71734946..aa2e9007 100644 --- a/crates/flowscope-core/src/linter/rules/am_005.rs +++ b/crates/flowscope-core/src/linter/rules/am_005.rs @@ -4,7 +4,7 @@ //! `JOIN` for clearer intent. use crate::linter::config::LintConfig; -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit, Span}; use sqlparser::ast::{JoinOperator, Select, Statement}; use sqlparser::keywords::Keyword; @@ -54,7 +54,7 @@ impl Default for AmbiguousJoinStyle { } } -impl LintRule for AmbiguousJoinStyle { +impl BuiltinLintRule for AmbiguousJoinStyle { fn code(&self) -> &'static str { issue_codes::LINT_AM_005 } @@ -67,7 +67,7 @@ impl LintRule for AmbiguousJoinStyle { "Join clauses should be fully qualified." } - fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, statement: &Statement, ctx: &RuleContext) -> Vec { let mut plain_join_count = 0usize; visit_selects_in_statement(statement, &mut |select| { @@ -124,7 +124,7 @@ struct Am005AutofixCandidate { } fn am005_autofix_candidates_for_context( - ctx: &LintContext, + ctx: &RuleContext, qualify_mode: FullyQualifyJoinTypes, ) -> Vec { let from_document_tokens = ctx.with_document_tokens(|tokens| { @@ -301,7 +301,7 @@ fn is_outer_join_side_keyword(token: &Token) -> bool { || token_word_equals(token, "FULL") } -fn count_unqualified_outer_joins(statement: &Statement, ctx: &LintContext) -> usize { +fn count_unqualified_outer_joins(statement: &Statement, ctx: &RuleContext) -> usize { count_unqualified_left_right_outer_joins(statement) + count_unqualified_full_outer_joins(statement, ctx) } @@ -335,7 +335,7 @@ fn select_unqualified_left_right_outer_join_count(select: &Select) -> usize { .sum() } -fn count_unqualified_full_outer_joins(statement: &Statement, ctx: &LintContext) -> usize { +fn count_unqualified_full_outer_joins(statement: &Statement, ctx: &RuleContext) -> usize { let full_outer_join_count = count_full_outer_joins(statement); if full_outer_join_count == 0 { return 0; @@ -369,7 +369,7 @@ fn count_explicit_full_outer_joins(sql: &str, dialect: Dialect) -> usize { count_explicit_full_outer_joins_from_tokens(&tokens) } -fn count_explicit_full_outer_joins_for_context(ctx: &LintContext) -> usize { +fn count_explicit_full_outer_joins_for_context(ctx: &RuleContext) -> usize { let from_document_tokens = ctx.with_document_tokens(|tokens| { if tokens.is_empty() { return None; @@ -503,14 +503,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } @@ -600,14 +593,8 @@ mod tests { let rule = AmbiguousJoinStyle::from_config(&config); let sql = "SELECT foo.a, bar.b FROM foo LEFT JOIN bar ON foo.id = bar.id"; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); } @@ -624,14 +611,8 @@ mod tests { let rule = AmbiguousJoinStyle::from_config(&config); let sql = "SELECT foo.a, bar.b FROM foo LEFT OUTER JOIN bar ON foo.id = bar.id"; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert!(issues.is_empty()); } @@ -648,14 +629,8 @@ mod tests { let rule = AmbiguousJoinStyle::from_config(&config); let sql = "SELECT foo.a, bar.b FROM foo RIGHT JOIN bar ON foo.id = bar.id"; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); } @@ -672,14 +647,8 @@ mod tests { let rule = AmbiguousJoinStyle::from_config(&config); let sql = "SELECT foo.a, bar.b FROM foo RIGHT OUTER JOIN bar ON foo.id = bar.id"; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert!(issues.is_empty()); } @@ -696,14 +665,8 @@ mod tests { let rule = AmbiguousJoinStyle::from_config(&config); let sql = "SELECT foo.a, bar.b FROM foo FULL JOIN bar ON foo.id = bar.id"; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); } @@ -720,14 +683,8 @@ mod tests { let rule = AmbiguousJoinStyle::from_config(&config); let sql = "SELECT foo.a, bar.b FROM foo FULL OUTER JOIN bar ON foo.id = bar.id"; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert!(issues.is_empty()); } @@ -744,14 +701,8 @@ mod tests { let rule = AmbiguousJoinStyle::from_config(&config); let sql = "SELECT * FROM a FULL JOIN b ON a.id = b.id FULL OUTER JOIN c ON b.id = c.id"; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); assert_eq!(issues[0].code, issue_codes::LINT_AM_005); } @@ -788,14 +739,8 @@ mod tests { let rule = AmbiguousJoinStyle::from_config(&config); let sql = "SELECT a FROM t FULL JOIN u ON t.id = u.id"; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); let autofix = issues[0] .autofix diff --git a/crates/flowscope-core/src/linter/rules/am_006.rs b/crates/flowscope-core/src/linter/rules/am_006.rs index bf9ff1c4..cf9cd6c1 100644 --- a/crates/flowscope-core/src/linter/rules/am_006.rs +++ b/crates/flowscope-core/src/linter/rules/am_006.rs @@ -4,7 +4,7 @@ //! styles (implicit numeric position vs explicit expressions/identifiers). use crate::linter::config::LintConfig; -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Issue}; use sqlparser::ast::{ CreateView, Expr, FunctionArg, FunctionArgExpr, FunctionArguments, GroupByExpr, @@ -62,7 +62,7 @@ impl Default for AmbiguousColumnRefs { } } -impl LintRule for AmbiguousColumnRefs { +impl BuiltinLintRule for AmbiguousColumnRefs { fn code(&self) -> &'static str { issue_codes::LINT_AM_006 } @@ -75,7 +75,7 @@ impl LintRule for AmbiguousColumnRefs { "Inconsistent column references in 'GROUP BY/ORDER BY' clauses." } - fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, statement: &Statement, ctx: &RuleContext) -> Vec { let mut issues = Vec::new(); let mut prior_style = None; check_statement( @@ -611,14 +611,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } @@ -739,14 +732,8 @@ mod tests { let rule = AmbiguousColumnRefs::from_config(&config); let sql = "SELECT foo, bar FROM fake_table GROUP BY 1, 2"; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); } @@ -763,14 +750,8 @@ mod tests { let rule = AmbiguousColumnRefs::from_config(&config); let sql = "SELECT foo, bar FROM fake_table GROUP BY foo, bar"; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); } } diff --git a/crates/flowscope-core/src/linter/rules/am_007.rs b/crates/flowscope-core/src/linter/rules/am_007.rs index 1ff3e8cc..8a7a068d 100644 --- a/crates/flowscope-core/src/linter/rules/am_007.rs +++ b/crates/flowscope-core/src/linter/rules/am_007.rs @@ -3,7 +3,7 @@ //! SQLFluff AM07 parity: set-operation branches should resolve to the same //! number of output columns when wildcard expansion is deterministically known. -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Issue}; use sqlparser::ast::{ CreateView, Query, Select, SetExpr, Statement, TableFactor, Update, UpdateTableFromKind, @@ -22,7 +22,7 @@ struct SetCountStats { fully_resolved: bool, } -impl LintRule for AmbiguousSetColumns { +impl BuiltinLintRule for AmbiguousSetColumns { fn code(&self) -> &'static str { issue_codes::LINT_AM_007 } @@ -35,7 +35,7 @@ impl LintRule for AmbiguousSetColumns { "Queries within set query produce different numbers of columns." } - fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, statement: &Statement, ctx: &RuleContext) -> Vec { let mut violation_count = 0usize; lint_statement_set_ops(statement, &HashMap::new(), &mut violation_count); @@ -192,14 +192,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } diff --git a/crates/flowscope-core/src/linter/rules/am_008.rs b/crates/flowscope-core/src/linter/rules/am_008.rs index e013c701..00735168 100644 --- a/crates/flowscope-core/src/linter/rules/am_008.rs +++ b/crates/flowscope-core/src/linter/rules/am_008.rs @@ -3,7 +3,7 @@ //! SQLFluff AM08 parity: detect implicit cross joins where JOIN-like operators //! omit ON/USING/NATURAL conditions. -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit, Span}; use sqlparser::ast::{JoinConstraint, JoinOperator, Select, Statement, TableFactor}; use sqlparser::tokenizer::{Token, TokenWithSpan, Tokenizer, Whitespace}; @@ -12,7 +12,7 @@ use super::semantic_helpers::visit_selects_in_statement; pub struct AmbiguousJoinCondition; -impl LintRule for AmbiguousJoinCondition { +impl BuiltinLintRule for AmbiguousJoinCondition { fn code(&self) -> &'static str { issue_codes::LINT_AM_008 } @@ -25,7 +25,7 @@ impl LintRule for AmbiguousJoinCondition { "Implicit cross join detected." } - fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, statement: &Statement, ctx: &RuleContext) -> Vec { let mut violations = 0usize; visit_selects_in_statement(statement, &mut |select| { @@ -164,7 +164,7 @@ struct JoinOperatorTokenSpan { /// `POSITIONAL` as a table alias and `JOIN` as a bare join. This function /// detects the pattern at the token level so the AST violation count can be /// corrected. -fn count_positional_joins_in_context(ctx: &LintContext) -> usize { +fn count_positional_joins_in_context(ctx: &RuleContext) -> usize { let sql = ctx.statement_sql(); // Quick textual check to avoid tokenization when not needed. if !sql.to_ascii_uppercase().contains("POSITIONAL") { @@ -194,7 +194,7 @@ fn count_positional_joins_in_context(ctx: &LintContext) -> usize { count } -fn am008_autofix_candidates_for_context(ctx: &LintContext) -> Vec { +fn am008_autofix_candidates_for_context(ctx: &RuleContext) -> Vec { let from_document_tokens = ctx.with_document_tokens(|tokens| { if tokens.is_empty() { return None; @@ -588,14 +588,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } diff --git a/crates/flowscope-core/src/linter/rules/am_009.rs b/crates/flowscope-core/src/linter/rules/am_009.rs index 0f7cc8a9..2ffce10e 100644 --- a/crates/flowscope-core/src/linter/rules/am_009.rs +++ b/crates/flowscope-core/src/linter/rules/am_009.rs @@ -3,7 +3,7 @@ //! SQLFluff AM09 parity: use of LIMIT/OFFSET without ORDER BY may lead to //! non-deterministic results. -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Issue}; use sqlparser::ast::{ CreateView, Expr, FunctionArg, FunctionArgExpr, FunctionArguments, LimitClause, OrderByKind, @@ -14,7 +14,7 @@ use super::semantic_helpers::join_on_expr; pub struct LimitOffsetWithoutOrderBy; -impl LintRule for LimitOffsetWithoutOrderBy { +impl BuiltinLintRule for LimitOffsetWithoutOrderBy { fn code(&self) -> &'static str { issue_codes::LINT_AM_009 } @@ -27,7 +27,7 @@ impl LintRule for LimitOffsetWithoutOrderBy { "Use of LIMIT and OFFSET without ORDER BY may lead to non-deterministic results." } - fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, statement: &Statement, ctx: &RuleContext) -> Vec { let mut violation_count = 0usize; check_statement(statement, &mut violation_count); @@ -279,14 +279,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } diff --git a/crates/flowscope-core/src/linter/rules/cp_001.rs b/crates/flowscope-core/src/linter/rules/cp_001.rs index a80c8d8b..fcf39db7 100644 --- a/crates/flowscope-core/src/linter/rules/cp_001.rs +++ b/crates/flowscope-core/src/linter/rules/cp_001.rs @@ -5,7 +5,7 @@ use std::collections::HashSet; use crate::linter::config::LintConfig; -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit}; use regex::Regex; use sqlparser::ast::Statement; @@ -46,7 +46,7 @@ impl Default for CapitalisationKeywords { } } -impl LintRule for CapitalisationKeywords { +impl BuiltinLintRule for CapitalisationKeywords { fn code(&self) -> &'static str { issue_codes::LINT_CP_001 } @@ -59,7 +59,7 @@ impl LintRule for CapitalisationKeywords { "Inconsistent capitalisation of keywords." } - fn check(&self, _statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, _statement: &Statement, ctx: &RuleContext) -> Vec { let keywords = keyword_tokens_for_context(ctx, &self.ignore_words, self.ignore_words_regex.as_ref()); let keyword_values = keywords @@ -93,7 +93,7 @@ struct KeywordCandidate { } fn keyword_tokens_for_context( - ctx: &LintContext, + ctx: &RuleContext, ignore_words: &HashSet, ignore_words_regex: Option<&Regex>, ) -> Vec { @@ -219,7 +219,7 @@ fn keyword_tokens( } fn keyword_autofix_edits( - ctx: &LintContext, + ctx: &RuleContext, keywords: &[KeywordCandidate], policy: CapitalisationPolicy, ) -> Vec { @@ -569,14 +569,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } @@ -634,14 +627,8 @@ mod tests { let rule = CapitalisationKeywords::from_config(&config); let sql = "select a from t"; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); } @@ -658,14 +645,8 @@ mod tests { let rule = CapitalisationKeywords::from_config(&config); let sql = "select a from t"; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix"); assert_eq!(fixed, "SELECT a FROM t"); @@ -684,14 +665,8 @@ mod tests { let rule = CapitalisationKeywords::from_config(&config); let sql = "SELECT a FROM t"; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); assert!( issues[0].autofix.is_none(), @@ -712,14 +687,8 @@ mod tests { let rule = CapitalisationKeywords::from_config(&config); let sql = "SELECT a from t"; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert!(issues.is_empty()); } @@ -736,14 +705,8 @@ mod tests { let rule = CapitalisationKeywords::from_config(&config); let sql = "SELECT a from t"; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert!(issues.is_empty()); } } diff --git a/crates/flowscope-core/src/linter/rules/cp_002.rs b/crates/flowscope-core/src/linter/rules/cp_002.rs index 76306ae3..f7df1d27 100644 --- a/crates/flowscope-core/src/linter/rules/cp_002.rs +++ b/crates/flowscope-core/src/linter/rules/cp_002.rs @@ -5,7 +5,7 @@ use std::collections::HashSet; use crate::linter::config::LintConfig; -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit, Span}; use regex::Regex; use sqlparser::ast::{ObjectName, Statement}; @@ -58,7 +58,7 @@ impl Default for CapitalisationIdentifiers { } } -impl LintRule for CapitalisationIdentifiers { +impl BuiltinLintRule for CapitalisationIdentifiers { fn code(&self) -> &'static str { issue_codes::LINT_CP_002 } @@ -71,7 +71,7 @@ impl LintRule for CapitalisationIdentifiers { "Inconsistent capitalisation of unquoted identifiers." } - fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, statement: &Statement, ctx: &RuleContext) -> Vec { if databricks_case_sensitive_set_property(statement, ctx.dialect()) { return Vec::new(); } @@ -986,7 +986,6 @@ fn line_col_to_offset(sql: &str, line: usize, column: usize) -> Option { mod tests { use super::*; use crate::linter::config::LintConfig; - use crate::linter::rule::with_active_dialect; use crate::parser::{parse_sql, parse_sql_with_dialect}; use crate::types::Dialect; use crate::types::IssueAutofixApplicability; @@ -1002,22 +1001,16 @@ mod tests { fn run_with_config_in_dialect(sql: &str, dialect: Dialect, config: LintConfig) -> Vec { let statements = parse_sql_with_dialect(sql, dialect).expect("parse"); let rule = CapitalisationIdentifiers::from_config(&config); - with_active_dialect(dialect, || { - statements - .iter() - .enumerate() - .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) - }) - .collect() - }) + statements + .iter() + .enumerate() + .flat_map(|(index, statement)| { + rule.check_with_context( + statement, + &RuleContext::new(sql, 0..sql.len(), index).with_dialect(dialect), + ) + }) + .collect() } fn run_statementless_with_config_in_dialect( @@ -1027,16 +1020,10 @@ mod tests { ) -> Vec { let placeholder = parse_sql("SELECT 1").expect("parse placeholder"); let rule = CapitalisationIdentifiers::from_config(&config); - with_active_dialect(dialect, || { - rule.check( - &placeholder[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ) - }) + rule.check_with_context( + &placeholder[0], + &RuleContext::new(sql, 0..sql.len(), 0).with_dialect(dialect), + ) } fn apply_issue_autofix(sql: &str, issue: &Issue) -> Option { diff --git a/crates/flowscope-core/src/linter/rules/cp_003.rs b/crates/flowscope-core/src/linter/rules/cp_003.rs index 6093b04b..a3d394af 100644 --- a/crates/flowscope-core/src/linter/rules/cp_003.rs +++ b/crates/flowscope-core/src/linter/rules/cp_003.rs @@ -6,7 +6,7 @@ use std::collections::HashSet; use crate::linter::config::LintConfig; -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit, Span}; use regex::Regex; use sqlparser::ast::Statement; @@ -53,7 +53,7 @@ impl Default for CapitalisationFunctions { } } -impl LintRule for CapitalisationFunctions { +impl BuiltinLintRule for CapitalisationFunctions { fn code(&self) -> &'static str { issue_codes::LINT_CP_003 } @@ -66,7 +66,7 @@ impl LintRule for CapitalisationFunctions { "Inconsistent capitalisation of function names." } - fn check(&self, _statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, _statement: &Statement, ctx: &RuleContext) -> Vec { let functions = function_candidates_for_context( ctx, &self.ignore_words, @@ -137,7 +137,7 @@ struct FunctionCandidate { } fn function_candidates_for_context( - ctx: &LintContext, + ctx: &RuleContext, ignore_words: &HashSet, ignore_words_regex: Option<&Regex>, ) -> Vec { @@ -211,7 +211,7 @@ fn function_candidates( } fn function_autofix_edits( - ctx: &LintContext, + ctx: &RuleContext, functions: &[FunctionCandidate], resolved_policy: CapitalisationPolicy, ) -> Vec { @@ -309,7 +309,7 @@ fn resolve_consistent_policy_from_values(values: &[String]) -> CapitalisationPol } fn rendered_function_values_for_context( - ctx: &LintContext, + ctx: &RuleContext, ignore_words: &HashSet, ignore_words_regex: Option<&Regex>, ) -> Option> { @@ -531,7 +531,6 @@ fn is_data_type_keyword(value: &str) -> bool { mod tests { use super::*; use crate::linter::config::LintConfig; - use crate::linter::rule::{with_active_document_tokens, with_active_is_templated}; use crate::parser::parse_sql; use crate::types::IssueAutofixApplicability; @@ -542,14 +541,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } @@ -636,14 +628,8 @@ mod tests { let rule = CapitalisationFunctions::from_config(&config); let sql = "SELECT COUNT(x) FROM t"; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); } @@ -660,14 +646,8 @@ mod tests { let rule = CapitalisationFunctions::from_config(&config); let sql = "SELECT count(x) FROM t"; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix"); assert_eq!(fixed, "SELECT COUNT(x) FROM t"); @@ -686,14 +666,8 @@ mod tests { let rule = CapitalisationFunctions::from_config(&config); let sql = "SELECT COUNT(x), SUM(y) FROM t"; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); // Both COUNT and SUM violate camel → 2 violations. assert_eq!(issues.len(), 2); let fixed = apply_all_autofixes(sql, &issues); @@ -713,14 +687,8 @@ mod tests { let rule = CapitalisationFunctions::from_config(&config); let sql = "SELECT current_timestamp, min(a) FROM t"; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); // Both current_timestamp and min violate pascal → 2 violations. assert_eq!(issues.len(), 2); let fixed = apply_all_autofixes(sql, &issues); @@ -740,14 +708,8 @@ mod tests { let rule = CapitalisationFunctions::from_config(&config); let sql = "SELECT Current_Timestamp, Min(a) FROM t"; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); // Both Current_Timestamp and Min violate snake → 2 violations. assert_eq!(issues.len(), 2); let fixed = apply_all_autofixes(sql, &issues); @@ -767,14 +729,8 @@ mod tests { let rule = CapitalisationFunctions::from_config(&config); let sql = "SELECT COUNT(*), count(x) FROM t"; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert!(issues.is_empty()); } @@ -797,11 +753,7 @@ mod tests { #[test] fn consistent_policy_autofix_uses_source_order_even_when_candidates_are_unsorted() { let sql = "SELECT greatest(x), GREATEST(y) FROM t"; - let ctx = LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }; + let ctx = RuleContext::new(sql, 0..sql.len(), 0); let upper_start = sql.find("GREATEST").expect("uppercase function position"); let lower_start = sql.find("greatest").expect("lowercase function position"); @@ -840,18 +792,12 @@ mod tests { )]), }); - let issues = with_active_is_templated(true, || { - with_active_document_tokens(&rendered_tokens, || { - rule.check( - &statements[0], - &LintContext { - sql: source_sql, - statement_range: 0..source_sql.len(), - statement_index: 0, - }, - ) - }) - }); + let issues = rule.check_with_context( + &statements[0], + &RuleContext::new(source_sql, 0..source_sql.len(), 0) + .with_tokens(&rendered_tokens) + .with_templated(true), + ); assert_eq!(issues.len(), 1); let autofix = issues[0] diff --git a/crates/flowscope-core/src/linter/rules/cp_004.rs b/crates/flowscope-core/src/linter/rules/cp_004.rs index 65659ce2..92280259 100644 --- a/crates/flowscope-core/src/linter/rules/cp_004.rs +++ b/crates/flowscope-core/src/linter/rules/cp_004.rs @@ -6,7 +6,7 @@ use std::collections::HashSet; use crate::linter::config::LintConfig; -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit, Span}; use regex::Regex; use sqlparser::ast::Statement; @@ -51,7 +51,7 @@ impl Default for CapitalisationLiterals { } } -impl LintRule for CapitalisationLiterals { +impl BuiltinLintRule for CapitalisationLiterals { fn code(&self) -> &'static str { issue_codes::LINT_CP_004 } @@ -64,7 +64,7 @@ impl LintRule for CapitalisationLiterals { "Inconsistent capitalisation of boolean/null literal." } - fn check(&self, _statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, _statement: &Statement, ctx: &RuleContext) -> Vec { let literals = literal_tokens_for_context(ctx, &self.ignore_words, self.ignore_words_regex.as_ref()); let literal_values = literals @@ -110,7 +110,7 @@ struct LiteralCandidate { } fn literal_tokens_for_context( - ctx: &LintContext, + ctx: &RuleContext, ignore_words: &HashSet, ignore_words_regex: Option<&Regex>, ) -> Vec { @@ -204,7 +204,7 @@ fn literal_tokens( } fn literal_autofix_edits( - ctx: &LintContext, + ctx: &RuleContext, literals: &[LiteralCandidate], policy: CapitalisationPolicy, ) -> Vec { @@ -354,14 +354,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } @@ -421,14 +414,8 @@ mod tests { let rule = CapitalisationLiterals::from_config(&config); let sql = "SELECT true FROM t"; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); } @@ -445,14 +432,8 @@ mod tests { let rule = CapitalisationLiterals::from_config(&config); let sql = "SELECT null, true FROM t"; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); // Both null and true violate upper → 2 violations. assert_eq!(issues.len(), 2); let fixed = { @@ -484,14 +465,8 @@ mod tests { let rule = CapitalisationLiterals::from_config(&config); let sql = "SELECT NULL, TRUE FROM t"; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); assert!( issues[0].autofix.is_none(), @@ -522,14 +497,8 @@ mod tests { let rule = CapitalisationLiterals::from_config(&config); let sql = "SELECT true FROM t"; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix"); assert_eq!(fixed, "SELECT TRUE FROM t"); @@ -548,14 +517,8 @@ mod tests { let rule = CapitalisationLiterals::from_config(&config); let sql = "SELECT NULL, true FROM t"; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert!(issues.is_empty()); } } diff --git a/crates/flowscope-core/src/linter/rules/cp_005.rs b/crates/flowscope-core/src/linter/rules/cp_005.rs index 3c38ac41..12892769 100644 --- a/crates/flowscope-core/src/linter/rules/cp_005.rs +++ b/crates/flowscope-core/src/linter/rules/cp_005.rs @@ -5,7 +5,7 @@ use std::collections::HashSet; use crate::linter::config::LintConfig; -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit, Span}; use regex::Regex; use sqlparser::ast::Statement; @@ -46,7 +46,7 @@ impl Default for CapitalisationTypes { } } -impl LintRule for CapitalisationTypes { +impl BuiltinLintRule for CapitalisationTypes { fn code(&self) -> &'static str { issue_codes::LINT_CP_005 } @@ -59,7 +59,7 @@ impl LintRule for CapitalisationTypes { "Inconsistent capitalisation of datatypes." } - fn check(&self, _statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, _statement: &Statement, ctx: &RuleContext) -> Vec { let types = type_tokens_for_context(ctx, &self.ignore_words, self.ignore_words_regex.as_ref()); let type_values = types @@ -105,7 +105,7 @@ struct TypeCandidate { } fn type_tokens_for_context( - ctx: &LintContext, + ctx: &RuleContext, ignore_words: &HashSet, ignore_words_regex: Option<&Regex>, ) -> Vec { @@ -237,7 +237,7 @@ fn prev_non_trivia_index(tokens: &[TokenWithSpan], index: usize) -> Option Vec { @@ -552,14 +552,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } @@ -617,14 +610,8 @@ mod tests { let rule = CapitalisationTypes::from_config(&config); let sql = "CREATE TABLE t (a int)"; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); } @@ -641,14 +628,8 @@ mod tests { let rule = CapitalisationTypes::from_config(&config); let sql = "CREATE TABLE t (a int)"; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix"); assert_eq!(fixed, "CREATE TABLE t (a INT)"); @@ -667,14 +648,8 @@ mod tests { let rule = CapitalisationTypes::from_config(&config); let sql = "CREATE TABLE t (a INT)"; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); assert!( issues[0].autofix.is_none(), @@ -695,14 +670,8 @@ mod tests { let rule = CapitalisationTypes::from_config(&config); let sql = "CREATE TABLE t (a INT, b varchar(10))"; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert!(issues.is_empty()); } diff --git a/crates/flowscope-core/src/linter/rules/cv_001.rs b/crates/flowscope-core/src/linter/rules/cv_001.rs index 0a9f8383..3d052839 100644 --- a/crates/flowscope-core/src/linter/rules/cv_001.rs +++ b/crates/flowscope-core/src/linter/rules/cv_001.rs @@ -4,7 +4,7 @@ //! `!=` not-equal operators. use crate::linter::config::LintConfig; -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::linter::visit::visit_expressions; use crate::types::{issue_codes, Issue, IssueAutofixApplicability, IssuePatchEdit}; use sqlparser::ast::{BinaryOperator, Expr, Spanned, Statement}; @@ -90,7 +90,7 @@ impl Default for ConventionNotEqual { } } -impl LintRule for ConventionNotEqual { +impl BuiltinLintRule for ConventionNotEqual { fn code(&self) -> &'static str { issue_codes::LINT_CV_001 } @@ -103,7 +103,7 @@ impl LintRule for ConventionNotEqual { "Consistent usage of '!=' or '<>' for \"not equal to\" operator." } - fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, statement: &Statement, ctx: &RuleContext) -> Vec { let tokens = tokenized_for_context(ctx).or_else(|| tokenized(ctx.statement_sql(), ctx.dialect())); let mut occurrences = statement_not_equal_occurrences_with_tokens( @@ -407,7 +407,7 @@ fn tokenized(sql: &str, dialect: crate::types::Dialect) -> Option Option> { +fn tokenized_for_context(ctx: &RuleContext) -> Option> { let statement_start = ctx.statement_range.start; let from_document = ctx.with_document_tokens(|tokens| { if tokens.is_empty() { @@ -502,14 +502,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } @@ -576,14 +569,8 @@ mod tests { let rule = ConventionNotEqual::from_config(&config); let sql = "SELECT * FROM t WHERE a <> b"; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); let angle_start = sql.find("<>").expect("angle operator"); let issue_span = issues[0].span.expect("issue span"); @@ -610,14 +597,8 @@ mod tests { let rule = ConventionNotEqual::from_config(&config); let sql = "SELECT * FROM t WHERE a <> b AND c <> d"; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); let first_start = sql.find("<>").expect("first angle operator"); @@ -654,14 +635,8 @@ mod tests { let rule = ConventionNotEqual::from_config(&config); let sql = "SELECT * FROM t WHERE a != b"; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); let bang_start = sql.find("!=").expect("bang operator"); let issue_span = issues[0].span.expect("issue span"); @@ -688,14 +663,8 @@ mod tests { let rule = ConventionNotEqual::from_config(&config); let sql = "SELECT * FROM X WHERE 1 <\n -- some comment\n> 2\n"; let statements = parse_sql("SELECT 1").expect("synthetic parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); let autofix = issues[0].autofix.as_ref().expect("autofix metadata"); assert_eq!(autofix.edits.len(), 2); @@ -719,14 +688,8 @@ mod tests { let rule = ConventionNotEqual::from_config(&config); let sql = "SELECT * FROM X WHERE 1 !\n -- some comment\n= 2\n"; let statements = parse_sql("SELECT 1").expect("synthetic parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); let autofix = issues[0].autofix.as_ref().expect("autofix metadata"); assert_eq!(autofix.edits.len(), 2); diff --git a/crates/flowscope-core/src/linter/rules/cv_002.rs b/crates/flowscope-core/src/linter/rules/cv_002.rs index def31e06..0fe76668 100644 --- a/crates/flowscope-core/src/linter/rules/cv_002.rs +++ b/crates/flowscope-core/src/linter/rules/cv_002.rs @@ -3,7 +3,7 @@ //! SQLFluff CV02 parity: detect IFNULL/NVL function usage and recommend //! COALESCE for portability and consistency. -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::linter::visit; use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit}; use sqlparser::ast::{Expr, Statement}; @@ -11,7 +11,7 @@ use sqlparser::tokenizer::{Token, TokenWithSpan, Tokenizer, Whitespace}; pub struct CoalesceConvention; -impl LintRule for CoalesceConvention { +impl BuiltinLintRule for CoalesceConvention { fn code(&self) -> &'static str { issue_codes::LINT_CV_002 } @@ -24,7 +24,7 @@ impl LintRule for CoalesceConvention { "Use 'COALESCE' instead of 'IFNULL' or 'NVL'." } - fn check(&self, stmt: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, stmt: &Statement, ctx: &RuleContext) -> Vec { let function_name_spans = tokenized_for_context(ctx).or_else(|| tokenized(ctx.statement_sql(), ctx.dialect())); let function_name_spans = function_name_spans @@ -100,7 +100,7 @@ struct LocatedToken { end: usize, } -fn tokenized_for_context(ctx: &LintContext) -> Option> { +fn tokenized_for_context(ctx: &RuleContext) -> Option> { let tokens = ctx.with_document_tokens(|tokens| { if tokens.is_empty() { return None; @@ -219,14 +219,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } diff --git a/crates/flowscope-core/src/linter/rules/cv_003.rs b/crates/flowscope-core/src/linter/rules/cv_003.rs index ef9e7d11..e7798d40 100644 --- a/crates/flowscope-core/src/linter/rules/cv_003.rs +++ b/crates/flowscope-core/src/linter/rules/cv_003.rs @@ -3,7 +3,7 @@ //! Avoid trailing comma before FROM. use crate::linter::config::LintConfig; -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::linter::rules::semantic_helpers::visit_selects_in_statement; use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit}; use sqlparser::ast::{GroupByExpr, Select, SelectItem, Spanned, Statement}; @@ -63,7 +63,7 @@ impl Default for ConventionSelectTrailingComma { } } -impl LintRule for ConventionSelectTrailingComma { +impl BuiltinLintRule for ConventionSelectTrailingComma { fn code(&self) -> &'static str { issue_codes::LINT_CV_003 } @@ -76,7 +76,7 @@ impl LintRule for ConventionSelectTrailingComma { "Trailing commas within select clause." } - fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, statement: &Statement, ctx: &RuleContext) -> Vec { let tokens = tokenized_for_context(ctx).or_else(|| tokenized(ctx.statement_sql(), ctx.dialect())); let violations = select_clause_policy_violations( @@ -361,7 +361,7 @@ fn tokenized(sql: &str, dialect: Dialect) -> Option> { Some(out) } -fn tokenized_for_context(ctx: &LintContext) -> Option> { +fn tokenized_for_context(ctx: &RuleContext) -> Option> { let statement_start = ctx.statement_range.start; let from_document = ctx.with_document_tokens(|tokens| { if tokens.is_empty() { @@ -457,14 +457,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, stmt)| { - rule.check( - stmt, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(stmt, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } diff --git a/crates/flowscope-core/src/linter/rules/cv_004.rs b/crates/flowscope-core/src/linter/rules/cv_004.rs index 7fcf5ab7..eaf8a70d 100644 --- a/crates/flowscope-core/src/linter/rules/cv_004.rs +++ b/crates/flowscope-core/src/linter/rules/cv_004.rs @@ -4,7 +4,7 @@ //! but `COUNT(*)` is the standard convention and more clearly expresses intent. use crate::linter::config::LintConfig; -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::linter::visit; use crate::types::{issue_codes, Issue, IssueAutofixApplicability, IssuePatchEdit}; use sqlparser::ast::{Spanned, *}; @@ -88,7 +88,7 @@ impl Default for CountStyle { } } -impl LintRule for CountStyle { +impl BuiltinLintRule for CountStyle { fn code(&self) -> &'static str { issue_codes::LINT_CV_004 } @@ -101,7 +101,7 @@ impl LintRule for CountStyle { "Use consistent syntax to express \"count number of rows\"." } - fn check(&self, stmt: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, stmt: &Statement, ctx: &RuleContext) -> Vec { let tokens = tokenized_for_context(ctx).or_else(|| tokenized(ctx.statement_sql(), ctx.dialect())); let wildcard_spans = tokens @@ -187,7 +187,7 @@ fn numeric_literal_matches(raw: &str, expected: u8) -> bool { .is_some_and(|value| value == expected as u64) } -fn count_numeric_argument_span(ctx: &LintContext, func: &Function) -> Option<(usize, usize)> { +fn count_numeric_argument_span(ctx: &RuleContext, func: &Function) -> Option<(usize, usize)> { let FunctionArguments::List(arg_list) = &func.args else { return None; }; @@ -381,7 +381,7 @@ fn tokenized(sql: &str, dialect: crate::types::Dialect) -> Option Option> { +fn tokenized_for_context(ctx: &RuleContext) -> Option> { let statement_start = ctx.statement_range.start; let from_document = ctx.with_document_tokens(|tokens| { if tokens.is_empty() { @@ -472,14 +472,10 @@ mod tests { fn check_sql(sql: &str) -> Vec { let stmts = parse_sql(sql).unwrap(); let rule = CountStyle::default(); - let ctx = LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }; + let ctx = RuleContext::new(sql, 0..sql.len(), 0); let mut issues = Vec::new(); for stmt in &stmts { - issues.extend(rule.check(stmt, &ctx)); + issues.extend(rule.check_with_context(stmt, &ctx)); } issues } @@ -595,14 +591,7 @@ mod tests { let rule = CountStyle::from_config(&config); let sql = "SELECT COUNT(*) FROM t"; let stmts = parse_sql(sql).unwrap(); - let issues = rule.check( - &stmts[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = rule.check_with_context(&stmts[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); let star_start = sql.find('*').expect("star argument"); @@ -622,14 +611,7 @@ mod tests { let rule = CountStyle::from_config(&config); let sql = "SELECT COUNT(1) FROM t"; let stmts = parse_sql(sql).unwrap(); - let issues = rule.check( - &stmts[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = rule.check_with_context(&stmts[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); let one_start = sql.find('1').expect("count literal"); diff --git a/crates/flowscope-core/src/linter/rules/cv_005.rs b/crates/flowscope-core/src/linter/rules/cv_005.rs index 3b83c551..15bd3676 100644 --- a/crates/flowscope-core/src/linter/rules/cv_005.rs +++ b/crates/flowscope-core/src/linter/rules/cv_005.rs @@ -3,14 +3,14 @@ //! Comparisons like `col = NULL` or `col <> NULL` are not valid null checks in SQL. //! Use `IS NULL` / `IS NOT NULL` instead. -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::linter::visit; use crate::types::{issue_codes, Issue, IssueAutofixApplicability, IssuePatchEdit}; use sqlparser::ast::{Spanned, *}; pub struct NullComparison; -impl LintRule for NullComparison { +impl BuiltinLintRule for NullComparison { fn code(&self) -> &'static str { issue_codes::LINT_CV_005 } @@ -23,7 +23,7 @@ impl LintRule for NullComparison { "Comparisons with NULL should use \"IS\" or \"IS NOT\"." } - fn check(&self, stmt: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, stmt: &Statement, ctx: &RuleContext) -> Vec { let mut issues = Vec::new(); visit::visit_expressions(stmt, &mut |expr| { let Expr::BinaryOp { left, op, right } = expr else { @@ -84,7 +84,7 @@ enum KeywordCase { } /// Detect whether the `NULL` keyword in the original SQL is uppercase or lowercase. -fn detect_null_case(ctx: &LintContext, expr: &Expr) -> KeywordCase { +fn detect_null_case(ctx: &RuleContext, expr: &Expr) -> KeywordCase { if let Some((start, end)) = expr_statement_offsets(ctx, expr) { let fragment = &ctx.statement_sql()[start..end]; // Look for the literal "null" (case-insensitive) in the expression text. @@ -120,7 +120,7 @@ fn non_null_operand<'a>(left: &'a Expr, right: &'a Expr) -> Option<&'a Expr> { } } -fn expr_statement_offsets(ctx: &LintContext, expr: &Expr) -> Option<(usize, usize)> { +fn expr_statement_offsets(ctx: &RuleContext, expr: &Expr) -> Option<(usize, usize)> { if let Some((start, end)) = expr_span_offsets(ctx.statement_sql(), expr) { return Some((start, end)); } @@ -201,14 +201,10 @@ mod tests { fn check_sql(sql: &str) -> Vec { let stmts = parse_sql(sql).unwrap(); let rule = NullComparison; - let ctx = LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }; + let ctx = RuleContext::new(sql, 0..sql.len(), 0); let mut issues = Vec::new(); for stmt in &stmts { - issues.extend(rule.check(stmt, &ctx)); + issues.extend(rule.check_with_context(stmt, &ctx)); } issues } diff --git a/crates/flowscope-core/src/linter/rules/cv_006.rs b/crates/flowscope-core/src/linter/rules/cv_006.rs index b8e37522..0c41e87d 100644 --- a/crates/flowscope-core/src/linter/rules/cv_006.rs +++ b/crates/flowscope-core/src/linter/rules/cv_006.rs @@ -3,7 +3,7 @@ //! Enforce consistent semicolon termination within a SQL document. use crate::linter::config::LintConfig; -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit, Span}; use sqlparser::ast::Statement; use sqlparser::tokenizer::{Token, TokenWithSpan, Tokenizer, Whitespace}; @@ -27,7 +27,7 @@ impl ConventionTerminator { } } -impl LintRule for ConventionTerminator { +impl BuiltinLintRule for ConventionTerminator { fn code(&self) -> &'static str { issue_codes::LINT_CV_006 } @@ -40,7 +40,7 @@ impl LintRule for ConventionTerminator { "Statements must end with a semi-colon." } - fn check(&self, _stmt: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, _stmt: &Statement, ctx: &RuleContext) -> Vec { let tokens = tokenize_with_offsets_for_context(ctx); let trailing = trailing_info(ctx, tokens.as_deref()); let has_terminal_semicolon = trailing.semicolon_offset.is_some(); @@ -99,7 +99,7 @@ impl LintRule for ConventionTerminator { impl ConventionTerminator { fn check_multiline_newline( &self, - ctx: &LintContext, + ctx: &RuleContext, trailing: &TrailingInfo, semicolon_offset: usize, ) -> Vec { @@ -178,7 +178,7 @@ impl ConventionTerminator { /// - Exactly one newline /// - Semicolon (optionally followed by spacing/comments on the same line) fn is_valid_multiline_newline_style( - ctx: &LintContext, + ctx: &RuleContext, trailing: &TrailingInfo, semicolon_offset: usize, ) -> bool { @@ -227,7 +227,7 @@ fn is_valid_multiline_newline_style( /// the semicolon. This is either the end of the statement range or the end /// of a trailing inline comment on the last line of the statement. fn find_last_content_end_before_semicolon( - ctx: &LintContext, + ctx: &RuleContext, trailing: &TrailingInfo, semicolon_offset: usize, ) -> usize { @@ -248,7 +248,7 @@ fn find_last_content_end_before_semicolon( /// ranges: (1) insert semicolon at the actual code end, (2) delete the /// misplaced semicolon. This way neither edit spans over a comment. fn build_default_mode_fix( - ctx: &LintContext, + ctx: &RuleContext, trailing: &TrailingInfo, semicolon_offset: usize, ) -> Vec { @@ -310,7 +310,7 @@ fn build_default_mode_fix( /// ranges: (1) delete the old semicolon, (2) insert `\n;` at the anchor /// point (after inline comments, before standalone comments). fn build_multiline_newline_fix( - ctx: &LintContext, + ctx: &RuleContext, trailing: &TrailingInfo, semicolon_offset: usize, ) -> Vec { @@ -447,7 +447,7 @@ fn build_multiline_newline_fix( /// Returns the byte offset immediately after the last non-comment, /// non-whitespace token that starts within the statement range. Falls back /// to `statement_range.end` when tokens are unavailable. -fn actual_code_end(ctx: &LintContext) -> usize { +fn actual_code_end(ctx: &RuleContext) -> usize { let tokens = tokenize_with_offsets_for_context(ctx); let Some(tokens) = tokens.as_deref() else { return ctx.statement_range.end; @@ -466,7 +466,7 @@ fn actual_code_end(ctx: &LintContext) -> usize { /// Inserts the semicolon at the actual code end (before any trailing /// comments) so the edit does not overlap comment protected ranges. fn build_require_final_semicolon_edits( - ctx: &LintContext, + ctx: &RuleContext, trailing: &TrailingInfo, multiline_newline: bool, ) -> Vec { @@ -511,7 +511,7 @@ fn build_require_final_semicolon_edits( /// Find the end of an inline comment on the last line of code within the /// statement range (where the parser included the comment in the range). -fn find_inline_comment_in_statement(ctx: &LintContext) -> Option { +fn find_inline_comment_in_statement(ctx: &RuleContext) -> Option { let tokens = tokenize_with_offsets_for_context(ctx)?; let code_end = tokens.iter().rfind(|t| { t.start >= ctx.statement_range.start @@ -532,7 +532,7 @@ fn find_inline_comment_in_statement(ctx: &LintContext) -> Option { } /// Detect the indentation level of the first line of the statement. -fn detect_statement_indent(ctx: &LintContext) -> String { +fn detect_statement_indent(ctx: &RuleContext) -> String { let start = ctx.statement_range.start; // Walk backwards from statement start to find the beginning of the line let line_start = ctx.sql[..start].rfind('\n').map(|pos| pos + 1).unwrap_or(0); @@ -545,7 +545,7 @@ fn detect_statement_indent(ctx: &LintContext) -> String { /// Get content after the semicolon (typically trailing comments/whitespace on the /// same line or rest of the source up to next statement). fn trailing_content_after_semicolon<'a>( - ctx: &'a LintContext<'a>, + ctx: &'a RuleContext<'a>, semicolon_offset: usize, ) -> &'a str { let after = semicolon_offset + 1; @@ -578,7 +578,7 @@ struct CommentSpan { /// Analyze the trailing tokens after statement_range.end to collect /// information about semicolons, comments, and whitespace. -fn trailing_info(ctx: &LintContext, tokens: Option<&[LocatedToken]>) -> TrailingInfo { +fn trailing_info(ctx: &RuleContext, tokens: Option<&[LocatedToken]>) -> TrailingInfo { let Some(tokens) = tokens else { return TrailingInfo { semicolon_offset: None, @@ -648,7 +648,7 @@ fn offset_to_line_number(sql: &str, offset: usize) -> usize { + 1 } -fn is_last_statement(ctx: &LintContext, tokens: Option<&[LocatedToken]>) -> bool { +fn is_last_statement(ctx: &RuleContext, tokens: Option<&[LocatedToken]>) -> bool { let Some(tokens) = tokens else { return false; }; @@ -672,7 +672,7 @@ fn is_last_statement(ctx: &LintContext, tokens: Option<&[LocatedToken]>) -> bool /// statement range (e.g., `SELECT a\nFROM foo\n-- trailing`) where `-- trailing` /// is on a separate line from the actual code. fn has_standalone_comment_at_end_of_statement( - ctx: &LintContext, + ctx: &RuleContext, tokens: Option<&[LocatedToken]>, ) -> bool { let Some(tokens) = tokens else { @@ -730,7 +730,7 @@ struct LocatedToken { end_line: usize, } -fn tokenize_with_offsets_for_context(ctx: &LintContext) -> Option> { +fn tokenize_with_offsets_for_context(ctx: &RuleContext) -> Option> { let tokens = ctx.with_document_tokens(|tokens| { if tokens.is_empty() { return None; @@ -902,7 +902,6 @@ fn line_col_to_offset(sql: &str, line: usize, column: usize) -> Option { #[cfg(test)] mod tests { use super::*; - use crate::linter::rule::with_active_dialect; use crate::parser::parse_sql; use crate::types::IssueAutofixApplicability; @@ -913,14 +912,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, stmt)| { - rule.check( - stmt, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(stmt, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } @@ -936,7 +928,7 @@ mod tests { Some(out) } - fn statement_is_multiline(ctx: &LintContext, tokens: Option<&[LocatedToken]>) -> bool { + fn statement_is_multiline(ctx: &RuleContext, tokens: Option<&[LocatedToken]>) -> bool { let Some(tokens) = tokens else { return count_line_breaks(ctx.statement_sql()) > 0; }; @@ -983,14 +975,7 @@ mod tests { let rule = ConventionTerminator::from_config(&config); let sql = "SELECT 1"; let stmts = parse_sql(sql).expect("parse"); - let issues = rule.check( - &stmts[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = rule.check_with_context(&stmts[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); assert_eq!(issues[0].code, issue_codes::LINT_CV_006); assert_eq!( @@ -1030,14 +1015,8 @@ mod tests { let rule = ConventionTerminator::from_config(&config); let sql = "SELECT\n 1;"; let stmts = parse_sql(sql).expect("parse"); - let issues = rule.check( - &stmts[0], - &LintContext { - sql, - statement_range: 0.."SELECT\n 1".len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&stmts[0], &RuleContext::new(sql, 0.."SELECT\n 1".len(), 0)); assert_eq!(issues.len(), 1); assert_eq!(issues[0].code, issue_codes::LINT_CV_006); } @@ -1046,13 +1025,9 @@ mod tests { fn default_flags_space_before_semicolon() { let sql = "SELECT a FROM foo ;"; let stmts = parse_sql(sql).expect("parse"); - let issues = ConventionTerminator::default().check( + let issues = ConventionTerminator::default().check_with_context( &stmts[0], - &LintContext { - sql, - statement_range: 0.."SELECT a FROM foo".len(), - statement_index: 0, - }, + &RuleContext::new(sql, 0.."SELECT a FROM foo".len(), 0), ); assert_eq!(issues.len(), 1); assert_eq!(issues[0].code, issue_codes::LINT_CV_006); @@ -1076,13 +1051,9 @@ mod tests { let rule = ConventionTerminator::from_config(&config); let sql = "SELECT a\nFROM foo\n\n;"; let stmts = parse_sql(sql).expect("parse"); - let issues = rule.check( + let issues = rule.check_with_context( &stmts[0], - &LintContext { - sql, - statement_range: 0.."SELECT a\nFROM foo".len(), - statement_index: 0, - }, + &RuleContext::new(sql, 0.."SELECT a\nFROM foo".len(), 0), ); assert_eq!(issues.len(), 1); assert_eq!(issues[0].code, issue_codes::LINT_CV_006); @@ -1101,13 +1072,9 @@ mod tests { let rule = ConventionTerminator::from_config(&config); let sql = "SELECT a\nFROM foo\n-- trailing\n;"; let stmts = parse_sql(sql).expect("parse"); - let issues = rule.check( + let issues = rule.check_with_context( &stmts[0], - &LintContext { - sql, - statement_range: 0.."SELECT a\nFROM foo".len(), - statement_index: 0, - }, + &RuleContext::new(sql, 0.."SELECT a\nFROM foo".len(), 0), ); assert_eq!(issues.len(), 1); assert_eq!(issues[0].code, issue_codes::LINT_CV_006); @@ -1126,13 +1093,9 @@ mod tests { let rule = ConventionTerminator::from_config(&config); let sql = "SELECT a\nFROM foo\n-- trailing\n;"; let stmts = parse_sql(sql).expect("parse"); - let issues = rule.check( + let issues = rule.check_with_context( &stmts[0], - &LintContext { - sql, - statement_range: 0.."SELECT a\nFROM foo\n-- trailing".len(), - statement_index: 0, - }, + &RuleContext::new(sql, 0.."SELECT a\nFROM foo\n-- trailing".len(), 0), ); assert_eq!(issues.len(), 1); assert_eq!(issues[0].code, issue_codes::LINT_CV_006); @@ -1151,16 +1114,10 @@ mod tests { let rule = ConventionTerminator::from_config(&config); let stmt = &parse_sql("SELECT 1").expect("parse")[0]; let sql = "SELECT 1\nGO\n"; - let issues = with_active_dialect(Dialect::Mssql, || { - rule.check( - stmt, - &LintContext { - sql, - statement_range: 0.."SELECT 1".len(), - statement_index: 0, - }, - ) - }); + let issues = rule.check_with_context( + stmt, + &RuleContext::new(sql, 0.."SELECT 1".len(), 0).with_dialect(Dialect::Mssql), + ); assert_eq!(issues.len(), 1); assert_eq!(issues[0].code, issue_codes::LINT_CV_006); } @@ -1178,16 +1135,10 @@ mod tests { let rule = ConventionTerminator::from_config(&config); let stmt = &parse_sql("SELECT 1").expect("parse")[0]; let sql = "SELECT 1\nGO\nSELECT 2;"; - let issues = with_active_dialect(Dialect::Mssql, || { - rule.check( - stmt, - &LintContext { - sql, - statement_range: 0.."SELECT 1".len(), - statement_index: 0, - }, - ) - }); + let issues = rule.check_with_context( + stmt, + &RuleContext::new(sql, 0.."SELECT 1".len(), 0).with_dialect(Dialect::Mssql), + ); assert!(issues.is_empty()); } @@ -1204,16 +1155,10 @@ mod tests { let rule = ConventionTerminator::from_config(&config); let stmt = &parse_sql("SELECT 1").expect("parse")[0]; let sql = "SELECT 1\nGO -- not a standalone separator\n"; - let issues = with_active_dialect(Dialect::Mssql, || { - rule.check( - stmt, - &LintContext { - sql, - statement_range: 0.."SELECT 1".len(), - statement_index: 0, - }, - ) - }); + let issues = rule.check_with_context( + stmt, + &RuleContext::new(sql, 0.."SELECT 1".len(), 0).with_dialect(Dialect::Mssql), + ); assert!(issues.is_empty()); } @@ -1230,13 +1175,9 @@ mod tests { let rule = ConventionTerminator::from_config(&config); let sql = "SELECT 'line1\nline2';"; let stmt = &parse_sql(sql).expect("parse")[0]; - let issues = rule.check( + let issues = rule.check_with_context( stmt, - &LintContext { - sql, - statement_range: 0.."SELECT 'line1\nline2'".len(), - statement_index: 0, - }, + &RuleContext::new(sql, 0.."SELECT 'line1\nline2'".len(), 0), ); assert!(issues.is_empty()); } @@ -1244,11 +1185,7 @@ mod tests { #[test] fn statement_is_multiline_fallback_handles_crlf_line_breaks() { let sql = "SELECT\r\n 1"; - let ctx = LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }; + let ctx = RuleContext::new(sql, 0..sql.len(), 0); assert!(statement_is_multiline(&ctx, None)); } @@ -1267,14 +1204,7 @@ mod tests { let sql = "SELECT a\nFROM foo -- inline comment\n;"; let stmts = parse_sql(sql).expect("parse"); let stmt_range = 0.."SELECT a\nFROM foo".len(); - let issues = rule.check( - &stmts[0], - &LintContext { - sql, - statement_range: stmt_range, - statement_index: 0, - }, - ); + let issues = rule.check_with_context(&stmts[0], &RuleContext::new(sql, stmt_range, 0)); assert!( issues.is_empty(), "Should not flag: inline comment before newline+semicolon is valid in multiline_newline mode" @@ -1286,13 +1216,9 @@ mod tests { // test_fail_newline_semi_colon_default let sql = "SELECT a FROM foo\n;"; let stmts = parse_sql(sql).expect("parse"); - let issues = ConventionTerminator::default().check( + let issues = ConventionTerminator::default().check_with_context( &stmts[0], - &LintContext { - sql, - statement_range: 0.."SELECT a FROM foo".len(), - statement_index: 0, - }, + &RuleContext::new(sql, 0.."SELECT a FROM foo".len(), 0), ); assert_eq!(issues.len(), 1); let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix"); @@ -1306,13 +1232,9 @@ mod tests { // The \n between comment and old ; stays (part of comment token). let sql = "SELECT a FROM foo -- inline comment\n;"; let stmts = parse_sql(sql).expect("parse"); - let issues = ConventionTerminator::default().check( + let issues = ConventionTerminator::default().check_with_context( &stmts[0], - &LintContext { - sql, - statement_range: 0.."SELECT a FROM foo".len(), - statement_index: 0, - }, + &RuleContext::new(sql, 0.."SELECT a FROM foo".len(), 0), ); assert_eq!(issues.len(), 1); let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix"); @@ -1333,13 +1255,9 @@ mod tests { let rule = ConventionTerminator::from_config(&config); let sql = "SELECT a FROM foo -- inline comment\n"; let stmts = parse_sql(sql).expect("parse"); - let issues = rule.check( + let issues = rule.check_with_context( &stmts[0], - &LintContext { - sql, - statement_range: 0.."SELECT a FROM foo".len(), - statement_index: 0, - }, + &RuleContext::new(sql, 0.."SELECT a FROM foo".len(), 0), ); assert_eq!(issues.len(), 1); let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix"); @@ -1360,13 +1278,9 @@ mod tests { let rule = ConventionTerminator::from_config(&config); let sql = "SELECT a\nFROM foo;"; let stmts = parse_sql(sql).expect("parse"); - let issues = rule.check( + let issues = rule.check_with_context( &stmts[0], - &LintContext { - sql, - statement_range: 0.."SELECT a\nFROM foo".len(), - statement_index: 0, - }, + &RuleContext::new(sql, 0.."SELECT a\nFROM foo".len(), 0), ); assert_eq!(issues.len(), 1); let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix"); @@ -1387,13 +1301,9 @@ mod tests { let rule = ConventionTerminator::from_config(&config); let sql = "SELECT a\nFROM foo\n"; let stmts = parse_sql(sql).expect("parse"); - let issues = rule.check( + let issues = rule.check_with_context( &stmts[0], - &LintContext { - sql, - statement_range: 0.."SELECT a\nFROM foo".len(), - statement_index: 0, - }, + &RuleContext::new(sql, 0.."SELECT a\nFROM foo".len(), 0), ); assert_eq!(issues.len(), 1); let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix"); @@ -1414,13 +1324,9 @@ mod tests { let rule = ConventionTerminator::from_config(&config); let sql = "SELECT a\nFROM foo -- inline comment\n"; let stmts = parse_sql(sql).expect("parse"); - let issues = rule.check( + let issues = rule.check_with_context( &stmts[0], - &LintContext { - sql, - statement_range: 0.."SELECT a\nFROM foo".len(), - statement_index: 0, - }, + &RuleContext::new(sql, 0.."SELECT a\nFROM foo".len(), 0), ); assert_eq!(issues.len(), 1); let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix"); @@ -1445,14 +1351,7 @@ mod tests { let sql = "SELECT foo\nFROM bar\n/* multiline\ncomment\n*/\n;\n"; let stmt_range = 0.."SELECT foo\nFROM bar\n/* multiline\ncomment\n*/".len(); let stmts = parse_sql(sql).expect("parse"); - let issues = rule.check( - &stmts[0], - &LintContext { - sql, - statement_range: stmt_range, - statement_index: 0, - }, - ); + let issues = rule.check_with_context(&stmts[0], &RuleContext::new(sql, stmt_range, 0)); assert_eq!(issues.len(), 1); let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix"); // Semicolon inserted before block comment. The old semicolon deletion @@ -1479,14 +1378,7 @@ mod tests { let sql = "SELECT foo\nFROM bar /* multiline\ncomment\n*/\n;\n"; let stmt_range = 0.."SELECT foo\nFROM bar /* multiline\ncomment\n*/".len(); let stmts = parse_sql(sql).expect("parse"); - let issues = rule.check( - &stmts[0], - &LintContext { - sql, - statement_range: stmt_range, - statement_index: 0, - }, - ); + let issues = rule.check_with_context(&stmts[0], &RuleContext::new(sql, stmt_range, 0)); assert_eq!(issues.len(), 1); assert_eq!(issues[0].code, issue_codes::LINT_CV_006); let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix"); @@ -1510,13 +1402,9 @@ mod tests { let rule = ConventionTerminator::from_config(&config); let sql = "SELECT foo\nFROM bar; /* multiline\ncomment\n*/\n"; let stmts = parse_sql(sql).expect("parse"); - let issues = rule.check( + let issues = rule.check_with_context( &stmts[0], - &LintContext { - sql, - statement_range: 0.."SELECT foo\nFROM bar".len(), - statement_index: 0, - }, + &RuleContext::new(sql, 0.."SELECT foo\nFROM bar".len(), 0), ); assert_eq!(issues.len(), 1); let fixed = apply_issue_autofix(sql, &issues[0]).expect("apply autofix"); diff --git a/crates/flowscope-core/src/linter/rules/cv_007.rs b/crates/flowscope-core/src/linter/rules/cv_007.rs index 39c943d3..b74e7f28 100644 --- a/crates/flowscope-core/src/linter/rules/cv_007.rs +++ b/crates/flowscope-core/src/linter/rules/cv_007.rs @@ -3,13 +3,13 @@ //! SQLFluff CV07 parity (current scope): avoid wrapping an entire statement in //! unnecessary outer brackets. -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Issue, IssueAutofixApplicability, IssuePatchEdit}; use sqlparser::ast::{SetExpr, Statement}; pub struct ConventionStatementBrackets; -impl LintRule for ConventionStatementBrackets { +impl BuiltinLintRule for ConventionStatementBrackets { fn code(&self) -> &'static str { issue_codes::LINT_CV_007 } @@ -22,7 +22,7 @@ impl LintRule for ConventionStatementBrackets { "Top-level statements should not be wrapped in brackets." } - fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, statement: &Statement, ctx: &RuleContext) -> Vec { let bracket_depth = wrapper_bracket_depth(statement); if bracket_depth > 0 { let mut issue = Issue::info( @@ -114,14 +114,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } diff --git a/crates/flowscope-core/src/linter/rules/cv_008.rs b/crates/flowscope-core/src/linter/rules/cv_008.rs index 9066fdf3..60df7c9d 100644 --- a/crates/flowscope-core/src/linter/rules/cv_008.rs +++ b/crates/flowscope-core/src/linter/rules/cv_008.rs @@ -3,13 +3,13 @@ //! RIGHT JOIN is functionally valid but harder to read and reason about in many //! codebases. Prefer LEFT JOIN for consistent join direction. -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Issue}; use sqlparser::ast::*; pub struct LeftJoinOverRightJoin; -impl LintRule for LeftJoinOverRightJoin { +impl BuiltinLintRule for LeftJoinOverRightJoin { fn code(&self) -> &'static str { issue_codes::LINT_CV_008 } @@ -22,14 +22,14 @@ impl LintRule for LeftJoinOverRightJoin { "Use 'LEFT JOIN' instead of 'RIGHT JOIN'." } - fn check(&self, stmt: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, stmt: &Statement, ctx: &RuleContext) -> Vec { let mut issues = Vec::new(); check_statement(stmt, ctx, &mut issues); issues } } -fn check_statement(stmt: &Statement, ctx: &LintContext, issues: &mut Vec) { +fn check_statement(stmt: &Statement, ctx: &RuleContext, issues: &mut Vec) { match stmt { Statement::Query(q) => check_query(q, ctx, issues), Statement::Insert(ins) => { @@ -47,7 +47,7 @@ fn check_statement(stmt: &Statement, ctx: &LintContext, issues: &mut Vec) } } -fn check_query(query: &Query, ctx: &LintContext, issues: &mut Vec) { +fn check_query(query: &Query, ctx: &RuleContext, issues: &mut Vec) { if let Some(ref with) = query.with { for cte in &with.cte_tables { check_query(&cte.query, ctx, issues); @@ -56,7 +56,7 @@ fn check_query(query: &Query, ctx: &LintContext, issues: &mut Vec) { check_set_expr(&query.body, ctx, issues); } -fn check_set_expr(body: &SetExpr, ctx: &LintContext, issues: &mut Vec) { +fn check_set_expr(body: &SetExpr, ctx: &RuleContext, issues: &mut Vec) { match body { SetExpr::Select(select) => { for from_item in &select.from { @@ -84,7 +84,7 @@ fn check_set_expr(body: &SetExpr, ctx: &LintContext, issues: &mut Vec) { } } -fn check_table_factor(relation: &TableFactor, ctx: &LintContext, issues: &mut Vec) { +fn check_table_factor(relation: &TableFactor, ctx: &RuleContext, issues: &mut Vec) { match relation { TableFactor::Derived { subquery, .. } => check_query(subquery, ctx, issues), TableFactor::NestedJoin { @@ -126,14 +126,10 @@ mod tests { fn check_sql(sql: &str) -> Vec { let stmts = parse_sql(sql).unwrap(); let rule = LeftJoinOverRightJoin; - let ctx = LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }; + let ctx = RuleContext::new(sql, 0..sql.len(), 0); let mut issues = Vec::new(); for stmt in &stmts { - issues.extend(rule.check(stmt, &ctx)); + issues.extend(rule.check_with_context(stmt, &ctx)); } issues } diff --git a/crates/flowscope-core/src/linter/rules/cv_009.rs b/crates/flowscope-core/src/linter/rules/cv_009.rs index b58dc906..cd71d063 100644 --- a/crates/flowscope-core/src/linter/rules/cv_009.rs +++ b/crates/flowscope-core/src/linter/rules/cv_009.rs @@ -5,7 +5,7 @@ use crate::extractors::extract_tables; use crate::linter::config::LintConfig; -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::linter::visit::visit_expressions; use crate::types::{issue_codes, Issue}; use regex::{Regex, RegexBuilder}; @@ -60,7 +60,7 @@ impl Default for ConventionBlockedWords { } } -impl LintRule for ConventionBlockedWords { +impl BuiltinLintRule for ConventionBlockedWords { fn code(&self) -> &'static str { issue_codes::LINT_CV_009 } @@ -73,7 +73,7 @@ impl LintRule for ConventionBlockedWords { "Block a list of configurable words from being used." } - fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, statement: &Statement, ctx: &RuleContext) -> Vec { let source_violation = if self.match_source && ctx.statement_index == 0 { let source = if self.ignore_templated_areas { mask_templated_areas(ctx.sql) @@ -288,14 +288,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } @@ -351,14 +344,8 @@ mod tests { let rule = ConventionBlockedWords::from_config(&config); let sql = "SELECT foo, wip FROM t"; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); } @@ -375,14 +362,8 @@ mod tests { let rule = ConventionBlockedWords::from_config(&config); let sql = "SELECT tmp_value FROM t"; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); } @@ -399,14 +380,8 @@ mod tests { let rule = ConventionBlockedWords::from_config(&config); let sql = "SELECT wip_item FROM t"; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); } @@ -423,14 +398,8 @@ mod tests { let rule = ConventionBlockedWords::from_config(&config); let sql = "SELECT 'TODO' AS note FROM t"; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); } @@ -457,14 +426,8 @@ mod tests { let rule = ConventionBlockedWords::from_config(&config); let sql = "SELECT * FROM {{ ref('deprecated_table') }}"; let synthetic = parse_sql("SELECT 1").expect("parse"); - let issues = rule.check( - &synthetic[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&synthetic[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); assert_eq!(issues[0].code, issue_codes::LINT_CV_009); } @@ -492,14 +455,8 @@ mod tests { let rule = ConventionBlockedWords::from_config(&config); let sql = "SELECT * FROM {{ ref('deprecated_table') }}"; let synthetic = parse_sql("SELECT 1").expect("parse"); - let issues = rule.check( - &synthetic[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&synthetic[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert!(issues.is_empty()); } } diff --git a/crates/flowscope-core/src/linter/rules/cv_010.rs b/crates/flowscope-core/src/linter/rules/cv_010.rs index f9e71ac9..dcf16b5d 100644 --- a/crates/flowscope-core/src/linter/rules/cv_010.rs +++ b/crates/flowscope-core/src/linter/rules/cv_010.rs @@ -6,7 +6,7 @@ //! normalization for autofixes. use crate::linter::config::LintConfig; -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit}; use sqlparser::ast::Statement; use std::ops::Range; @@ -74,7 +74,7 @@ impl Default for ConventionQuotedLiterals { // LintRule impl // --------------------------------------------------------------------------- -impl LintRule for ConventionQuotedLiterals { +impl BuiltinLintRule for ConventionQuotedLiterals { fn code(&self) -> &'static str { issue_codes::LINT_CV_010 } @@ -87,7 +87,7 @@ impl LintRule for ConventionQuotedLiterals { "Consistent usage of preferred quotes for quoted literals." } - fn check(&self, _statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, _statement: &Statement, ctx: &RuleContext) -> Vec { let dialect = ctx.dialect(); if !self.force_enable && !Self::is_double_quote_string_dialect(dialect) { return Vec::new(); @@ -688,28 +688,21 @@ fn has_unescaped_char(body: &str, ch: u8) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::linter::rule::with_active_dialect; use crate::parser::parse_sql; fn run_with_dialect(sql: &str, dialect: Dialect) -> Vec { let statements = parse_sql(sql).expect("parse"); let rule = ConventionQuotedLiterals::default(); - with_active_dialect(dialect, || { - statements - .iter() - .enumerate() - .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) - }) - .collect() - }) + statements + .iter() + .enumerate() + .flat_map(|(index, statement)| { + rule.check_with_context( + statement, + &RuleContext::new(sql, 0..sql.len(), index).with_dialect(dialect), + ) + }) + .collect() } fn run(sql: &str) -> Vec { @@ -719,22 +712,16 @@ mod tests { fn run_with_config(sql: &str, dialect: Dialect, config: &LintConfig) -> Vec { let statements = parse_sql(sql).expect("parse"); let rule = ConventionQuotedLiterals::from_config(config); - with_active_dialect(dialect, || { - statements - .iter() - .enumerate() - .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) - }) - .collect() - }) + statements + .iter() + .enumerate() + .flat_map(|(index, statement)| { + rule.check_with_context( + statement, + &RuleContext::new(sql, 0..sql.len(), index).with_dialect(dialect), + ) + }) + .collect() } fn apply_issue_autofix(sql: &str, issue: &Issue) -> Option { diff --git a/crates/flowscope-core/src/linter/rules/cv_011.rs b/crates/flowscope-core/src/linter/rules/cv_011.rs index 0fca1f9d..4b16a4db 100644 --- a/crates/flowscope-core/src/linter/rules/cv_011.rs +++ b/crates/flowscope-core/src/linter/rules/cv_011.rs @@ -5,7 +5,7 @@ //! preferred style. use crate::linter::config::LintConfig; -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::linter::visit::visit_expressions; use crate::types::{issue_codes, Issue, IssueAutofixApplicability, IssuePatchEdit, Span}; use sqlparser::ast::{CastKind, DataType, Expr, Spanned, Statement}; @@ -85,7 +85,7 @@ impl Default for ConventionCastingStyle { } } -impl LintRule for ConventionCastingStyle { +impl BuiltinLintRule for ConventionCastingStyle { fn code(&self) -> &'static str { issue_codes::LINT_CV_011 } @@ -98,7 +98,7 @@ impl LintRule for ConventionCastingStyle { "Enforce consistent type casting style." } - fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, statement: &Statement, ctx: &RuleContext) -> Vec { let sql = ctx.sql; let casts = collect_cast_instances(statement, sql); @@ -942,14 +942,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } @@ -961,14 +954,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } @@ -1035,14 +1021,8 @@ mod tests { let rule = ConventionCastingStyle::from_config(&config); let sql = "SELECT CAST(amount AS INT) FROM t"; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); } @@ -1059,14 +1039,8 @@ mod tests { let rule = ConventionCastingStyle::from_config(&config); let sql = "SELECT amount::INT FROM t"; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); } diff --git a/crates/flowscope-core/src/linter/rules/cv_012.rs b/crates/flowscope-core/src/linter/rules/cv_012.rs index 8c920527..437d4200 100644 --- a/crates/flowscope-core/src/linter/rules/cv_012.rs +++ b/crates/flowscope-core/src/linter/rules/cv_012.rs @@ -3,7 +3,7 @@ //! Plain `JOIN` clauses without ON/USING should use explicit join predicates, //! not implicit relationships hidden in WHERE. -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Issue, IssueAutofixApplicability, IssuePatchEdit, Span}; use sqlparser::ast::{ BinaryOperator, Expr, JoinConstraint, JoinOperator, Select, Spanned, Statement, TableFactor, @@ -16,7 +16,7 @@ use super::semantic_helpers::{table_factor_reference_name, visit_selects_in_stat pub struct ConventionJoinCondition; -impl LintRule for ConventionJoinCondition { +impl BuiltinLintRule for ConventionJoinCondition { fn code(&self) -> &'static str { issue_codes::LINT_CV_012 } @@ -29,7 +29,7 @@ impl LintRule for ConventionJoinCondition { "Use `JOIN ... ON ...` instead of `WHERE ...` for join conditions." } - fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, statement: &Statement, ctx: &RuleContext) -> Vec { let mut found_violation = false; let mut autofix_edits: Vec = Vec::new(); @@ -74,7 +74,7 @@ struct Cv12JoinFixPlan { predicates: Vec, } -fn cv012_select_autofix_result(select: &Select, ctx: &LintContext) -> Cv12SelectFixResult { +fn cv012_select_autofix_result(select: &Select, ctx: &RuleContext) -> Cv12SelectFixResult { let Some(where_expr) = &select.selection else { return Cv12SelectFixResult::default(); }; @@ -548,7 +548,7 @@ fn expr_eq(a: &Expr, b: &Expr) -> bool { } fn locate_where_keyword_abs_start( - ctx: &LintContext, + ctx: &RuleContext, select_abs_start: usize, where_expr_abs_start: usize, ) -> Option { @@ -571,7 +571,7 @@ struct PositionedToken { end: usize, } -fn positioned_statement_tokens(ctx: &LintContext) -> Option> { +fn positioned_statement_tokens(ctx: &RuleContext) -> Option> { let from_document_tokens = ctx.with_document_tokens(|tokens| { if tokens.is_empty() { return None; @@ -625,7 +625,7 @@ fn token_with_span_offsets(sql: &str, token: &TokenWithSpan) -> Option<(usize, u } fn sqlparser_span_statement_offsets( - ctx: &LintContext, + ctx: &RuleContext, span: SqlParserSpan, ) -> Option<(usize, usize)> { if let Some((start, end)) = sqlparser_span_offsets(ctx.statement_sql(), span) { @@ -641,7 +641,7 @@ fn sqlparser_span_statement_offsets( )) } -fn sqlparser_span_abs_offsets(ctx: &LintContext, span: SqlParserSpan) -> Option<(usize, usize)> { +fn sqlparser_span_abs_offsets(ctx: &RuleContext, span: SqlParserSpan) -> Option<(usize, usize)> { if let Some((start, end)) = sqlparser_span_offsets(ctx.statement_sql(), span) { return Some(( ctx.statement_range.start + start, @@ -719,14 +719,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } diff --git a/crates/flowscope-core/src/linter/rules/jj_001.rs b/crates/flowscope-core/src/linter/rules/jj_001.rs index 0e403091..1cceb7a3 100644 --- a/crates/flowscope-core/src/linter/rules/jj_001.rs +++ b/crates/flowscope-core/src/linter/rules/jj_001.rs @@ -3,14 +3,14 @@ //! SQLFluff JJ01 parity (current scope): detect inconsistent whitespace around //! Jinja delimiters. -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit}; use sqlparser::ast::Statement; use sqlparser::tokenizer::{Token, TokenWithSpan, Tokenizer}; pub struct JinjaPadding; -impl LintRule for JinjaPadding { +impl BuiltinLintRule for JinjaPadding { fn code(&self) -> &'static str { issue_codes::LINT_JJ_001 } @@ -23,7 +23,7 @@ impl LintRule for JinjaPadding { "Jinja tags should have a single whitespace on either side." } - fn check(&self, _statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, _statement: &Statement, ctx: &RuleContext) -> Vec { let Some((start, end)) = jinja_padding_violation_span(ctx) else { return Vec::new(); }; @@ -52,7 +52,7 @@ impl LintRule for JinjaPadding { } } -fn jinja_padding_violation_span(ctx: &LintContext) -> Option<(usize, usize)> { +fn jinja_padding_violation_span(ctx: &RuleContext) -> Option<(usize, usize)> { let sql = ctx.statement_sql(); // Token-based detection (works well when sqlparser can tokenize the input). @@ -131,7 +131,7 @@ fn token_spans(sql: &str, dialect: Dialect) -> Option> { Some(out) } -fn token_spans_for_context(ctx: &LintContext) -> Option> { +fn token_spans_for_context(ctx: &RuleContext) -> Option> { let offset = ctx.statement_range.start; ctx.with_document_tokens(|tokens| { if tokens.is_empty() { @@ -425,14 +425,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } @@ -515,11 +508,7 @@ mod tests { } fn detect(sql: &str) -> Option<(usize, usize)> { - jinja_padding_violation_span(&LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }) + jinja_padding_violation_span(&RuleContext::new(sql, 0..sql.len(), 0)) } #[test] diff --git a/crates/flowscope-core/src/linter/rules/lt_001.rs b/crates/flowscope-core/src/linter/rules/lt_001.rs index dc3bce92..65db44ca 100644 --- a/crates/flowscope-core/src/linter/rules/lt_001.rs +++ b/crates/flowscope-core/src/linter/rules/lt_001.rs @@ -5,7 +5,7 @@ //! whitespace, and cast operators. use crate::linter::config::LintConfig; -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit}; use sqlparser::ast::Statement; use sqlparser::keywords::Keyword; @@ -76,7 +76,7 @@ impl Default for LayoutSpacing { } } -impl LintRule for LayoutSpacing { +impl BuiltinLintRule for LayoutSpacing { fn code(&self) -> &'static str { issue_codes::LINT_LT_001 } @@ -89,7 +89,7 @@ impl LintRule for LayoutSpacing { "Inappropriate Spacing." } - fn check(&self, _statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, _statement: &Statement, ctx: &RuleContext) -> Vec { let mut violations = spacing_violations(ctx, self.ignore_templated_areas, self.alignment_options()); let has_remaining_non_whitespace = ctx.sql[ctx.statement_range.end..] @@ -104,11 +104,7 @@ impl LintRule for LayoutSpacing { && contains_template_marker(ctx.sql) && (ctx.statement_range.start > 0 || ctx.statement_range.end < ctx.sql.len()); if parser_fragment_fallback || template_fragment_fallback { - let full_ctx = LintContext { - sql: ctx.sql, - statement_range: 0..ctx.sql.len(), - statement_index: 0, - }; + let full_ctx = ctx.statement_view(ctx.sql, 0..ctx.sql.len(), 0); violations.extend(spacing_violations( &full_ctx, self.ignore_templated_areas, @@ -183,7 +179,7 @@ struct Lt01AlignmentOptions { } fn spacing_violations( - ctx: &LintContext, + ctx: &RuleContext, ignore_templated_areas: bool, alignment: Lt01AlignmentOptions, ) -> Vec { @@ -1774,7 +1770,7 @@ fn tokenized(sql: &str, dialect: Dialect) -> Option> { tokenizer.tokenize_with_location().ok() } -fn tokenized_for_context(ctx: &LintContext) -> Option> { +fn tokenized_for_context(ctx: &RuleContext) -> Option> { let (statement_start_line, statement_start_column) = offset_to_line_col(ctx.sql, ctx.statement_range.start)?; @@ -1965,7 +1961,6 @@ fn relative_location( #[cfg(test)] mod tests { use super::*; - use crate::linter::rule::with_active_dialect; use crate::parser::parse_sql; use crate::types::{Dialect, IssueAutofixApplicability}; @@ -1976,22 +1971,16 @@ mod tests { fn run_with_dialect(sql: &str, dialect: Dialect) -> Vec { let statements = parse_sql(sql).expect("parse"); let rule = LayoutSpacing::default(); - with_active_dialect(dialect, || { - statements - .iter() - .enumerate() - .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) - }) - .collect() - }) + statements + .iter() + .enumerate() + .flat_map(|(index, statement)| { + rule.check_with_context( + statement, + &RuleContext::new(sql, 0..sql.len(), index).with_dialect(dialect), + ) + }) + .collect() } fn run_statementless_with_dialect(sql: &str, dialect: Dialect) -> Vec { @@ -2000,16 +1989,10 @@ mod tests { fn run_statementless_with_rule(sql: &str, dialect: Dialect, rule: LayoutSpacing) -> Vec { let placeholder = parse_sql("SELECT 1").expect("parse placeholder"); - with_active_dialect(dialect, || { - rule.check( - &placeholder[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ) - }) + rule.check_with_context( + &placeholder[0], + &RuleContext::new(sql, 0..sql.len(), 0).with_dialect(dialect), + ) } fn apply_all_issue_autofixes(sql: &str, issues: &[Issue]) -> String { diff --git a/crates/flowscope-core/src/linter/rules/lt_002.rs b/crates/flowscope-core/src/linter/rules/lt_002.rs index 4a47a7fe..8ed9b7d6 100644 --- a/crates/flowscope-core/src/linter/rules/lt_002.rs +++ b/crates/flowscope-core/src/linter/rules/lt_002.rs @@ -17,7 +17,7 @@ //! parity design doc. use crate::linter::config::LintConfig; -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit, Span}; use sqlparser::ast::Statement; use sqlparser::keywords::Keyword; @@ -139,7 +139,7 @@ impl Default for LayoutIndent { } } -impl LintRule for LayoutIndent { +impl BuiltinLintRule for LayoutIndent { fn code(&self) -> &'static str { issue_codes::LINT_LT_002 } @@ -152,7 +152,7 @@ impl LintRule for LayoutIndent { "Incorrect Indentation." } - fn check(&self, _statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, _statement: &Statement, ctx: &RuleContext) -> Vec { let statement_sql = ctx.statement_sql(); let statement_lines: Vec<&str> = statement_sql.lines().collect(); let template_only_lines = template_only_line_flags(&statement_lines); @@ -2668,7 +2668,7 @@ fn paren_delta_simple(text: &str) -> isize { // Generic (dialect-agnostic) indentation detection and helpers // --------------------------------------------------------------------------- -fn first_line_is_indented(ctx: &LintContext) -> bool { +fn first_line_is_indented(ctx: &RuleContext) -> bool { let statement_start = ctx.statement_range.start; if statement_start == 0 { return false; @@ -2681,7 +2681,7 @@ fn first_line_is_indented(ctx: &LintContext) -> bool { !leading.is_empty() && leading.chars().all(char::is_whitespace) } -fn ignore_first_line_indent_for_fragmented_statement(ctx: &LintContext) -> bool { +fn ignore_first_line_indent_for_fragmented_statement(ctx: &RuleContext) -> bool { if ctx.statement_index == 0 || ctx.statement_range.start == 0 { return false; } @@ -2691,7 +2691,7 @@ fn ignore_first_line_indent_for_fragmented_statement(ctx: &LintContext) -> bool matches!(prev_non_ws, Some(ch) if ch != ';') } -fn first_line_is_template_fragment(ctx: &LintContext) -> bool { +fn first_line_is_template_fragment(ctx: &RuleContext) -> bool { let statement_start = ctx.statement_range.start; if statement_start == 0 { return false; @@ -2732,7 +2732,7 @@ fn first_line_is_template_fragment(ctx: &LintContext) -> bool { /// Returns autofix edits for structural indentation violations. When empty, /// no structural violation was found. fn structural_indent_edits( - ctx: &LintContext, + ctx: &RuleContext, indent_unit: usize, tab_space_size: usize, indent_style: IndentStyle, @@ -3920,7 +3920,7 @@ fn is_comment_token(token: &Token) -> bool { /// Tokenize SQL for structural analysis. Falls back to statement-level /// tokenization when document tokens are not available. -fn tokenize_for_structural_check(sql: &str, ctx: &LintContext) -> Option> { +fn tokenize_for_structural_check(sql: &str, ctx: &RuleContext) -> Option> { // Fall back to statement-level tokenization (document tokens use // 1-indexed lines which makes correlation harder; local tokenization // gives 0-indexed consistency). @@ -4092,7 +4092,7 @@ fn collapse_lt02_autofix_edits_by_start(edits: Vec) -> Vec Vec { +fn line_indent_snapshots(ctx: &RuleContext, tab_space_size: usize) -> Vec { if let Some(tokens) = tokenize_with_offsets_for_context(ctx) { let statement_start_line = offset_to_line(ctx.sql, ctx.statement_range.start); let mut first_token_by_line: BTreeMap = BTreeMap::new(); @@ -4178,7 +4178,7 @@ fn tokenize_with_locations(sql: &str, dialect: Dialect) -> Option Option> { +fn tokenize_with_offsets_for_context(ctx: &RuleContext) -> Option> { ctx.with_document_tokens(|tokens| { if tokens.is_empty() { return None; @@ -4427,7 +4427,6 @@ fn offset_to_line(sql: &str, offset: usize) -> usize { mod tests { use super::*; use crate::linter::config::LintConfig; - use crate::linter::rule::with_active_dialect; use crate::parser::parse_sql; use crate::types::{Dialect, IssueAutofixApplicability}; @@ -4436,26 +4435,26 @@ mod tests { } fn run_with_config(sql: &str, config: LintConfig) -> Vec { + run_with_config_in_dialect(sql, Dialect::Generic, config) + } + + fn run_with_config_in_dialect(sql: &str, dialect: Dialect, config: LintConfig) -> Vec { let statements = parse_sql(sql).expect("parse"); let rule = LayoutIndent::from_config(&config); statements .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( + rule.check_with_context( statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, + &RuleContext::new(sql, 0..sql.len(), index).with_dialect(dialect), ) }) .collect() } fn run_postgres(sql: &str) -> Vec { - with_active_dialect(Dialect::Postgres, || run(sql)) + run_with_config_in_dialect(sql, Dialect::Postgres, LintConfig::default()) } fn apply_issue_autofix(sql: &str, issue: &Issue) -> Option { @@ -4620,14 +4619,8 @@ mod tests { let sql = " SELECT 1"; let statements = parse_sql(sql).expect("parse"); let rule = LayoutIndent::default(); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 3..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 3..sql.len(), 0)); assert_eq!(issues.len(), 1); assert!( issues[0].autofix.is_none(), @@ -4639,11 +4632,11 @@ mod tests { fn fragmented_non_semicolon_statement_triggers_first_line_indent_guard() { let sql = "SELECT\n a"; assert!( - ignore_first_line_indent_for_fragmented_statement(&LintContext { + ignore_first_line_indent_for_fragmented_statement(&RuleContext::new( sql, - statement_range: 7..sql.len(), - statement_index: 1, - }), + 7..sql.len(), + 1 + )), "fragmented follow-on statement chunks should ignore first-line LT02" ); } @@ -4770,16 +4763,10 @@ mod tests { let first_statement = "IF (1 > 1)\n PRINT 'A';"; let placeholder = parse_sql("SELECT 1").expect("parse placeholder"); let rule = LayoutIndent::default(); - let issues = with_active_dialect(Dialect::Mssql, || { - rule.check( - &placeholder[0], - &LintContext { - sql, - statement_range: 0..first_statement.len(), - statement_index: 0, - }, - ) - }); + let issues = rule.check_with_context( + &placeholder[0], + &RuleContext::new(sql, 0..first_statement.len(), 0).with_dialect(Dialect::Mssql), + ); assert_eq!(issues.len(), 1); assert_eq!(issues[0].code, issue_codes::LINT_LT_002); } diff --git a/crates/flowscope-core/src/linter/rules/lt_003.rs b/crates/flowscope-core/src/linter/rules/lt_003.rs index 1e8d0fb6..cd1ff82b 100644 --- a/crates/flowscope-core/src/linter/rules/lt_003.rs +++ b/crates/flowscope-core/src/linter/rules/lt_003.rs @@ -3,7 +3,7 @@ //! SQLFluff LT03 parity (current scope): flag trailing operators at end of line. use crate::linter::config::LintConfig; -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit}; use sqlparser::ast::Statement; use sqlparser::keywords::Keyword; @@ -57,7 +57,7 @@ impl Default for LayoutOperators { } } -impl LintRule for LayoutOperators { +impl BuiltinLintRule for LayoutOperators { fn code(&self) -> &'static str { issue_codes::LINT_LT_003 } @@ -70,7 +70,7 @@ impl LintRule for LayoutOperators { "Operators should follow a standard for being before/after newlines." } - fn check(&self, _statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, _statement: &Statement, ctx: &RuleContext) -> Vec { let violations = operator_layout_violations(ctx, self.line_position); violations @@ -105,7 +105,7 @@ type Lt03AutofixEdit = (usize, usize, String); type Lt03Violation = (Lt03Span, Vec); fn operator_layout_violations( - ctx: &LintContext, + ctx: &RuleContext, line_position: OperatorLinePosition, ) -> Vec { let tokens = @@ -474,7 +474,7 @@ fn tokenized(sql: &str, dialect: Dialect) -> Option> { tokenizer.tokenize_with_location().ok() } -fn tokenized_for_context(ctx: &LintContext) -> Option> { +fn tokenized_for_context(ctx: &RuleContext) -> Option> { let (statement_start_line, statement_start_column) = offset_to_line_col(ctx.sql, ctx.statement_range.start)?; @@ -705,14 +705,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } @@ -920,14 +913,8 @@ mod tests { let sql = "{% macro binary_literal(expression) %}\n X'{{ expression }}'\n{% endmacro %}\n\nselect\n *\nfrom my_table\nwhere\n a =\n {{ binary_literal(\"0000\") }}\n"; let synthetic = parse_sql("SELECT 1").expect("parse"); let rule = LayoutOperators::default(); - let issues = rule.check( - &synthetic[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&synthetic[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); assert_eq!(issues[0].code, issue_codes::LINT_LT_003); } diff --git a/crates/flowscope-core/src/linter/rules/lt_004.rs b/crates/flowscope-core/src/linter/rules/lt_004.rs index 56b9ee9a..7ad761ae 100644 --- a/crates/flowscope-core/src/linter/rules/lt_004.rs +++ b/crates/flowscope-core/src/linter/rules/lt_004.rs @@ -4,7 +4,7 @@ //! patterns. use crate::linter::config::LintConfig; -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit}; use sqlparser::ast::Statement; use sqlparser::tokenizer::{Location, Span, Token, TokenWithSpan, Tokenizer, Whitespace}; @@ -57,7 +57,7 @@ impl Default for LayoutCommas { } } -impl LintRule for LayoutCommas { +impl BuiltinLintRule for LayoutCommas { fn code(&self) -> &'static str { issue_codes::LINT_LT_004 } @@ -70,7 +70,7 @@ impl LintRule for LayoutCommas { "Leading/Trailing comma enforcement." } - fn check(&self, _statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, _statement: &Statement, ctx: &RuleContext) -> Vec { let has_remaining_non_whitespace = ctx.sql[ctx.statement_range.end..] .chars() .any(|ch| !ch.is_whitespace()); @@ -80,11 +80,7 @@ impl LintRule for LayoutCommas { && has_remaining_non_whitespace; if parser_fragment_fallback { - let full_ctx = LintContext { - sql: ctx.sql, - statement_range: 0..ctx.sql.len(), - statement_index: ctx.statement_index, - }; + let full_ctx = ctx.statement_view(ctx.sql, 0..ctx.sql.len(), ctx.statement_index); let full_violations = comma_spacing_violations(&full_ctx, self.line_position); if let Some(issue) = issue_from_violations(&full_ctx, &full_violations) { return vec![issue]; @@ -102,7 +98,7 @@ type Lt04Span = (usize, usize); type Lt04AutofixEdit = (usize, usize, String); type Lt04Violation = (Lt04Span, Vec); -fn issue_from_violations(ctx: &LintContext, violations: &[Lt04Violation]) -> Option { +fn issue_from_violations(ctx: &RuleContext, violations: &[Lt04Violation]) -> Option { if violations.is_empty() { return None; } @@ -137,7 +133,7 @@ fn issue_from_violations(ctx: &LintContext, violations: &[Lt04Violation]) -> Opt } fn comma_spacing_violations( - ctx: &LintContext, + ctx: &RuleContext, line_position: CommaLinePosition, ) -> Vec { // Prefer direct tokenization of the statement slice. Document-token spans @@ -553,7 +549,7 @@ fn tokenized(sql: &str, dialect: Dialect) -> Option> { tokenizer.tokenize_with_location().ok() } -fn tokenized_for_context(ctx: &LintContext) -> Option> { +fn tokenized_for_context(ctx: &RuleContext) -> Option> { let (statement_start_line, statement_start_column) = offset_to_line_col(ctx.sql, ctx.statement_range.start)?; @@ -731,14 +727,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } @@ -906,11 +895,7 @@ mod tests { #[test] fn leading_mode_templated_column_emits_line_move_edits() { let sql = "SELECT\n c1,\n {{ \"c2\" }} AS days_since\nFROM logs"; - let ctx = LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }; + let ctx = RuleContext::new(sql, 0..sql.len(), 0); let violations = comma_spacing_violations(&ctx, CommaLinePosition::Leading); assert_eq!(violations.len(), 1); assert!( @@ -927,11 +912,7 @@ mod tests { #[test] fn trailing_mode_templated_column_emits_line_move_edits() { let sql = "SELECT\n {{ \"c1\" }}\n , c2 AS days_since\nFROM logs"; - let ctx = LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }; + let ctx = RuleContext::new(sql, 0..sql.len(), 0); let violations = comma_spacing_violations(&ctx, CommaLinePosition::Trailing); assert_eq!(violations.len(), 1); assert!( diff --git a/crates/flowscope-core/src/linter/rules/lt_005.rs b/crates/flowscope-core/src/linter/rules/lt_005.rs index ef14d193..cae0858b 100644 --- a/crates/flowscope-core/src/linter/rules/lt_005.rs +++ b/crates/flowscope-core/src/linter/rules/lt_005.rs @@ -3,7 +3,7 @@ //! SQLFluff LT05 parity (current scope): flag overflow beyond 80 columns. use crate::linter::config::LintConfig; -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit, Span}; use sqlparser::ast::Statement; use sqlparser::tokenizer::{Token, TokenWithSpan, Tokenizer, Whitespace}; @@ -66,7 +66,7 @@ impl Default for LayoutLongLines { } } -impl LintRule for LayoutLongLines { +impl BuiltinLintRule for LayoutLongLines { fn code(&self) -> &'static str { issue_codes::LINT_LT_005 } @@ -79,7 +79,7 @@ impl LintRule for LayoutLongLines { "Line is too long." } - fn check(&self, _statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, _statement: &Statement, ctx: &RuleContext) -> Vec { let Some(max_line_length) = self.max_line_length else { return Vec::new(); }; @@ -125,7 +125,7 @@ impl LintRule for LayoutLongLines { } fn long_line_overflow_spans_for_context( - ctx: &LintContext, + ctx: &RuleContext, max_len: usize, ignore_comment_lines: bool, ignore_comment_clauses: bool, @@ -1067,7 +1067,7 @@ fn tokenize_with_offsets(sql: &str, dialect: Dialect) -> Option Option> { +fn tokenize_with_offsets_for_context(ctx: &RuleContext) -> Option> { ctx.with_document_tokens(|tokens| { if tokens.is_empty() { return None; @@ -1250,14 +1250,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } @@ -1324,14 +1317,8 @@ mod tests { let rule = LayoutLongLines::from_config(&config); let sql = "SELECT this_line_is_long FROM t"; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); assert_eq!(issues[0].code, issue_codes::LINT_LT_005); } @@ -1461,14 +1448,8 @@ mod tests { let sql = "{{ config (schema='bronze', materialized='view', sort =['id','number'], dist = 'all', tags =['longlonglonglonglong']) }} \n\nselect 1\n"; let synthetic = parse_sql("SELECT 1").expect("parse"); let rule = LayoutLongLines::default(); - let issues = rule.check( - &synthetic[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&synthetic[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert!( !issues.is_empty(), "expected LT05 to flag long templated config line in statementless mode" diff --git a/crates/flowscope-core/src/linter/rules/lt_006.rs b/crates/flowscope-core/src/linter/rules/lt_006.rs index 0573c3c2..39409301 100644 --- a/crates/flowscope-core/src/linter/rules/lt_006.rs +++ b/crates/flowscope-core/src/linter/rules/lt_006.rs @@ -3,7 +3,7 @@ //! SQLFluff LT06 parity (current scope): flag function-like tokens separated //! from opening parenthesis. -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::linter::visit::visit_expressions; use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit}; use sqlparser::ast::{Expr, Statement}; @@ -13,7 +13,7 @@ use std::collections::HashSet; pub struct LayoutFunctions; -impl LintRule for LayoutFunctions { +impl BuiltinLintRule for LayoutFunctions { fn code(&self) -> &'static str { issue_codes::LINT_LT_006 } @@ -26,7 +26,7 @@ impl LintRule for LayoutFunctions { "Function name not immediately followed by parenthesis." } - fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, statement: &Statement, ctx: &RuleContext) -> Vec { let Some(issue_span) = function_spacing_issue_span(statement, ctx) else { return Vec::new(); }; @@ -58,7 +58,7 @@ struct FunctionSpacingIssueSpan { fn function_spacing_issue_span( statement: &Statement, - ctx: &LintContext, + ctx: &RuleContext, ) -> Option { let sql = ctx.statement_sql(); let tracked_function_names = tracked_function_names(statement); @@ -138,7 +138,7 @@ fn tokenized(sql: &str, dialect: Dialect) -> Option> { tokenizer.tokenize_with_location().ok() } -fn tokenized_for_context(ctx: &LintContext) -> Option> { +fn tokenized_for_context(ctx: &RuleContext) -> Option> { let (statement_start_line, statement_start_column) = offset_to_line_col(ctx.sql, ctx.statement_range.start)?; @@ -360,14 +360,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } diff --git a/crates/flowscope-core/src/linter/rules/lt_007.rs b/crates/flowscope-core/src/linter/rules/lt_007.rs index 3fb63cad..8e989bb9 100644 --- a/crates/flowscope-core/src/linter/rules/lt_007.rs +++ b/crates/flowscope-core/src/linter/rules/lt_007.rs @@ -3,7 +3,7 @@ //! SQLFluff LT07 parity (current scope): in multiline CTE bodies, the closing //! bracket should appear on its own line. -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit}; use sqlparser::ast::{CreateView, Query, Statement}; use sqlparser::keywords::Keyword; @@ -12,7 +12,7 @@ use std::ops::Range; pub struct LayoutCteBracket; -impl LintRule for LayoutCteBracket { +impl BuiltinLintRule for LayoutCteBracket { fn code(&self) -> &'static str { issue_codes::LINT_LT_007 } @@ -25,7 +25,7 @@ impl LintRule for LayoutCteBracket { "'WITH' clause closing bracket should be on a new line." } - fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, statement: &Statement, ctx: &RuleContext) -> Vec { let tokens = tokenize_with_offsets_for_context(ctx); let violation = misplaced_cte_closing_bracket_for_statement( statement, @@ -74,7 +74,7 @@ type Lt07Violation = (Lt07Span, Option); fn misplaced_cte_closing_bracket_for_statement( statement: &Statement, - ctx: &LintContext, + ctx: &RuleContext, tokens: Option<&[LocatedToken]>, ) -> Option { let query = match statement { @@ -88,7 +88,7 @@ fn misplaced_cte_closing_bracket_for_statement( fn misplaced_cte_closing_bracket_in_query( query: &Query, - ctx: &LintContext, + ctx: &RuleContext, tokens: Option<&[LocatedToken]>, ) -> Option { let with = query.with.as_ref()?; @@ -192,7 +192,7 @@ fn tokenize_with_offsets(sql: &str, dialect: Dialect) -> Option Option> { +fn tokenize_with_offsets_for_context(ctx: &RuleContext) -> Option> { let statement_start = ctx.statement_range.start; let from_document_tokens = ctx.with_document_tokens(|tokens| { if tokens.is_empty() { @@ -561,14 +561,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } diff --git a/crates/flowscope-core/src/linter/rules/lt_008.rs b/crates/flowscope-core/src/linter/rules/lt_008.rs index f06a581d..965d3a85 100644 --- a/crates/flowscope-core/src/linter/rules/lt_008.rs +++ b/crates/flowscope-core/src/linter/rules/lt_008.rs @@ -4,7 +4,7 @@ //! closing parenthesis and following query/CTE text. use crate::linter::config::LintConfig; -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit}; use sqlparser::ast::Statement; use sqlparser::tokenizer::{Token, TokenWithSpan, Tokenizer, Whitespace}; @@ -54,7 +54,7 @@ impl Default for LayoutCteNewline { } } -impl LintRule for LayoutCteNewline { +impl BuiltinLintRule for LayoutCteNewline { fn code(&self) -> &'static str { issue_codes::LINT_LT_008 } @@ -67,7 +67,7 @@ impl LintRule for LayoutCteNewline { "Blank line expected but not found after CTE closing bracket." } - fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, statement: &Statement, ctx: &RuleContext) -> Vec { lt08_violation_spans(statement, ctx, self.comma_line_position) .into_iter() .map(|((start, end), fix_span)| { @@ -105,7 +105,7 @@ type Lt08Violation = (Lt08Span, Option); fn lt08_violation_spans( statement: &Statement, - ctx: &LintContext, + ctx: &RuleContext, comma_line_position: CommaLinePosition, ) -> Vec { let Statement::Query(query) = statement else { @@ -355,7 +355,7 @@ fn tokenize_with_offsets(sql: &str, dialect: Dialect) -> Option Option> { +fn tokenize_with_offsets_for_context(ctx: &RuleContext) -> Option> { let tokens = ctx.with_document_tokens(|tokens| { if tokens.is_empty() { return None; @@ -390,7 +390,7 @@ fn token_start_offset(sql: &str, token: &TokenWithSpan) -> Option { ) } -fn token_start_offset_for_context(ctx: &LintContext, token: &TokenWithSpan) -> Option { +fn token_start_offset_for_context(ctx: &RuleContext, token: &TokenWithSpan) -> Option { if ctx.statement_range.start > 0 { if let Some(abs_start) = token_start_offset(ctx.sql, token) { if abs_start >= ctx.statement_range.start && abs_start < ctx.statement_range.end { @@ -587,14 +587,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } @@ -671,14 +664,7 @@ SELECT * FROM b"); .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect::>(); diff --git a/crates/flowscope-core/src/linter/rules/lt_009.rs b/crates/flowscope-core/src/linter/rules/lt_009.rs index 5e60ebb6..fe42fde4 100644 --- a/crates/flowscope-core/src/linter/rules/lt_009.rs +++ b/crates/flowscope-core/src/linter/rules/lt_009.rs @@ -4,7 +4,7 @@ //! and multi-target SELECT clauses, with wildcard-policy behavior. use crate::linter::config::LintConfig; -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::linter::rules::semantic_helpers::visit_selects_in_statement; use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit}; use sqlparser::ast::{SelectItem, Statement}; @@ -51,7 +51,7 @@ impl Default for LayoutSelectTargets { } } -impl LintRule for LayoutSelectTargets { +impl BuiltinLintRule for LayoutSelectTargets { fn code(&self) -> &'static str { issue_codes::LINT_LT_009 } @@ -64,7 +64,7 @@ impl LintRule for LayoutSelectTargets { "Select targets should be on a new line unless there is only one select target." } - fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, statement: &Statement, ctx: &RuleContext) -> Vec { lt09_violation_spans(statement, ctx, self.wildcard_policy) .into_iter() .map(|((start, end), fix_span)| { @@ -111,7 +111,7 @@ type Lt09Violation = (Lt09Span, Option); fn lt09_violation_spans( statement: &Statement, - ctx: &LintContext, + ctx: &RuleContext, wildcard_policy: WildcardPolicy, ) -> Vec { let sql = ctx.statement_sql(); @@ -180,7 +180,7 @@ fn tokenized(sql: &str, dialect: Dialect) -> Option> { tokenizer.tokenize_with_location().ok() } -fn tokenized_for_context(ctx: &LintContext) -> Option> { +fn tokenized_for_context(ctx: &RuleContext) -> Option> { let (statement_start_line, statement_start_column) = offset_to_line_col(ctx.sql, ctx.statement_range.start)?; @@ -982,14 +982,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } diff --git a/crates/flowscope-core/src/linter/rules/lt_010.rs b/crates/flowscope-core/src/linter/rules/lt_010.rs index ceb20bcd..fbfcb93b 100644 --- a/crates/flowscope-core/src/linter/rules/lt_010.rs +++ b/crates/flowscope-core/src/linter/rules/lt_010.rs @@ -3,7 +3,7 @@ //! SQLFluff LT10 parity (current scope): detect multiline SELECT modifiers in //! inconsistent positions. -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit}; use sqlparser::ast::Statement; use sqlparser::keywords::Keyword; @@ -17,7 +17,7 @@ type SimpleCollapseSpans = Vec<(usize, usize)>; type CommentAwareEdits = Vec<(usize, usize, String)>; type Lt010ViolationResult = (bool, SimpleCollapseSpans, CommentAwareEdits); -impl LintRule for LayoutSelectModifiers { +impl BuiltinLintRule for LayoutSelectModifiers { fn code(&self) -> &'static str { issue_codes::LINT_LT_010 } @@ -30,7 +30,7 @@ impl LintRule for LayoutSelectModifiers { "'SELECT' modifiers (e.g. 'DISTINCT') must be on the same line as 'SELECT'." } - fn check(&self, _statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, _statement: &Statement, ctx: &RuleContext) -> Vec { let (has_violation, fixable_spans, comment_aware_edits) = select_modifier_violations_and_fixable_spans(ctx); if has_violation { @@ -78,7 +78,7 @@ impl LintRule for LayoutSelectModifiers { /// Returns (has_violation, simple_collapse_spans, comment_aware_edits). /// `simple_collapse_spans` are (start, end) ranges to replace with " ". /// `comment_aware_edits` are (start, end, replacement) triples for surgical edits. -fn select_modifier_violations_and_fixable_spans(ctx: &LintContext) -> Lt010ViolationResult { +fn select_modifier_violations_and_fixable_spans(ctx: &RuleContext) -> Lt010ViolationResult { let tokens = tokenized_for_context(ctx).or_else(|| tokenized(ctx.statement_sql(), ctx.dialect())); let Some(tokens) = tokens else { @@ -190,7 +190,7 @@ fn tokenized(sql: &str, dialect: Dialect) -> Option> { tokenizer.tokenize_with_location().ok() } -fn tokenized_for_context(ctx: &LintContext) -> Option> { +fn tokenized_for_context(ctx: &RuleContext) -> Option> { let (statement_start_line, statement_start_column) = offset_to_line_col(ctx.sql, ctx.statement_range.start)?; @@ -417,14 +417,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } diff --git a/crates/flowscope-core/src/linter/rules/lt_011.rs b/crates/flowscope-core/src/linter/rules/lt_011.rs index 6b953dec..dd90d8ac 100644 --- a/crates/flowscope-core/src/linter/rules/lt_011.rs +++ b/crates/flowscope-core/src/linter/rules/lt_011.rs @@ -4,7 +4,7 @@ //! operators in multiline statements. use crate::linter::config::LintConfig; -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit}; use sqlparser::ast::Statement; use sqlparser::keywords::Keyword; @@ -54,7 +54,7 @@ impl Default for LayoutSetOperators { } } -impl LintRule for LayoutSetOperators { +impl BuiltinLintRule for LayoutSetOperators { fn code(&self) -> &'static str { issue_codes::LINT_LT_011 } @@ -67,7 +67,7 @@ impl LintRule for LayoutSetOperators { "Set operators should be surrounded by newlines." } - fn check(&self, _statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, _statement: &Statement, ctx: &RuleContext) -> Vec { let (has_violation, edit_spans) = set_operator_layout_violation_and_fixable_spans(ctx, self.line_position); if has_violation { @@ -99,7 +99,7 @@ impl LintRule for LayoutSetOperators { } fn set_operator_layout_violation_and_fixable_spans( - ctx: &LintContext, + ctx: &RuleContext, line_position: SetOperatorLinePosition, ) -> (bool, Vec<(usize, usize)>) { let tokens = @@ -208,7 +208,7 @@ fn tokenized(sql: &str, dialect: Dialect) -> Option> { tokenizer.tokenize_with_location().ok() } -fn tokenized_for_context(ctx: &LintContext) -> Option> { +fn tokenized_for_context(ctx: &RuleContext) -> Option> { let (statement_start_line, statement_start_column) = offset_to_line_col(ctx.sql, ctx.statement_range.start)?; @@ -414,14 +414,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } diff --git a/crates/flowscope-core/src/linter/rules/lt_012.rs b/crates/flowscope-core/src/linter/rules/lt_012.rs index 178f4ecf..0e1b5a20 100644 --- a/crates/flowscope-core/src/linter/rules/lt_012.rs +++ b/crates/flowscope-core/src/linter/rules/lt_012.rs @@ -3,13 +3,13 @@ //! SQLFluff LT12 parity (current scope): SQL text should end with exactly one //! trailing newline. -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Issue, IssueAutofixApplicability, IssuePatchEdit, Span}; use sqlparser::ast::Statement; pub struct LayoutEndOfFile; -impl LintRule for LayoutEndOfFile { +impl BuiltinLintRule for LayoutEndOfFile { fn code(&self) -> &'static str { issue_codes::LINT_LT_012 } @@ -22,7 +22,7 @@ impl LintRule for LayoutEndOfFile { "Files must end with a single trailing newline." } - fn check(&self, _statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, _statement: &Statement, ctx: &RuleContext) -> Vec { let content_end = ctx .sql .trim_end_matches(|ch: char| ch.is_ascii_whitespace()) @@ -88,14 +88,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } @@ -164,14 +157,8 @@ mod tests { let sql = "{{ '\\n\\n' }}"; let synthetic = parse_sql("SELECT 1").expect("parse"); let rule = LayoutEndOfFile; - let issues = rule.check( - &synthetic[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&synthetic[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); assert_eq!(issues[0].code, issue_codes::LINT_LT_012); } @@ -181,14 +168,8 @@ mod tests { let sql = "select * from {{ 'trim_whitespace_table' -}}\n"; let synthetic = parse_sql("SELECT 1").expect("parse"); let rule = LayoutEndOfFile; - let issues = rule.check( - &synthetic[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&synthetic[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert!(issues.is_empty()); } } diff --git a/crates/flowscope-core/src/linter/rules/lt_013.rs b/crates/flowscope-core/src/linter/rules/lt_013.rs index c56752be..ed43c950 100644 --- a/crates/flowscope-core/src/linter/rules/lt_013.rs +++ b/crates/flowscope-core/src/linter/rules/lt_013.rs @@ -2,13 +2,13 @@ //! //! SQLFluff LT13 parity (current scope): avoid leading blank lines. -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Issue, IssueAutofixApplicability, IssuePatchEdit, Span}; use sqlparser::ast::Statement; pub struct LayoutStartOfFile; -impl LintRule for LayoutStartOfFile { +impl BuiltinLintRule for LayoutStartOfFile { fn code(&self) -> &'static str { issue_codes::LINT_LT_013 } @@ -21,7 +21,7 @@ impl LintRule for LayoutStartOfFile { "Files must not begin with newlines or whitespace." } - fn check(&self, _statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, _statement: &Statement, ctx: &RuleContext) -> Vec { if ctx.statement_index > 0 || !has_leading_blank_lines_for_context(ctx) { Vec::new() } else { @@ -52,7 +52,7 @@ fn leading_blank_line_trim_end(sql: &str) -> Option { (first_non_ws > 0).then_some(first_non_ws) } -fn has_leading_blank_lines_for_context(ctx: &LintContext) -> bool { +fn has_leading_blank_lines_for_context(ctx: &RuleContext) -> bool { leading_blank_line_trim_end(ctx.sql).is_some() } @@ -69,14 +69,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } @@ -88,14 +81,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } diff --git a/crates/flowscope-core/src/linter/rules/lt_014.rs b/crates/flowscope-core/src/linter/rules/lt_014.rs index 24733599..b7d0fea6 100644 --- a/crates/flowscope-core/src/linter/rules/lt_014.rs +++ b/crates/flowscope-core/src/linter/rules/lt_014.rs @@ -5,7 +5,7 @@ //! Configs are per-clause-type via `layout.type..keyword_line_position`. use crate::linter::config::LintConfig; -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit}; use sqlparser::ast::Statement; use sqlparser::keywords::Keyword; @@ -150,7 +150,7 @@ impl LayoutKeywordNewline { } } -impl LintRule for LayoutKeywordNewline { +impl BuiltinLintRule for LayoutKeywordNewline { fn code(&self) -> &'static str { issue_codes::LINT_LT_014 } @@ -163,7 +163,7 @@ impl LintRule for LayoutKeywordNewline { "Keyword clauses should follow a standard for being before/after newlines." } - fn check(&self, _statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, _statement: &Statement, ctx: &RuleContext) -> Vec { let tokens = tokenized_for_context(ctx); let sql = ctx.statement_sql(); @@ -221,7 +221,7 @@ struct KeywordOccurrence { fn check_with_configs( sql: &str, - ctx: &LintContext, + ctx: &RuleContext, configs: &ClauseConfigs, tokens: Option<&[TokenWithSpan]>, ) -> Vec { @@ -325,7 +325,7 @@ fn config_for_clause( fn build_autofix_edits( sql: &str, - ctx: &LintContext, + ctx: &RuleContext, occ: &KeywordOccurrence, position: KeywordLinePosition, ) -> Vec { @@ -963,7 +963,7 @@ fn tokenized(sql: &str, dialect: Dialect) -> Option> { tokenizer.tokenize_with_location().ok() } -fn tokenized_for_context(ctx: &LintContext) -> Option> { +fn tokenized_for_context(ctx: &RuleContext) -> Option> { let (statement_start_line, statement_start_column) = offset_to_line_col(ctx.sql, ctx.statement_range.start)?; @@ -1145,14 +1145,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } diff --git a/crates/flowscope-core/src/linter/rules/lt_015.rs b/crates/flowscope-core/src/linter/rules/lt_015.rs index aaf22c86..8b8bf548 100644 --- a/crates/flowscope-core/src/linter/rules/lt_015.rs +++ b/crates/flowscope-core/src/linter/rules/lt_015.rs @@ -3,7 +3,7 @@ //! SQLFluff LT15 parity (current scope): detect excessive blank lines. use crate::linter::config::LintConfig; -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit, Span}; use sqlparser::ast::Statement; use sqlparser::tokenizer::{ @@ -50,7 +50,7 @@ impl Default for LayoutNewlines { } } -impl LintRule for LayoutNewlines { +impl BuiltinLintRule for LayoutNewlines { fn code(&self) -> &'static str { issue_codes::LINT_LT_015 } @@ -63,7 +63,7 @@ impl LintRule for LayoutNewlines { "Too many consecutive blank lines." } - fn check(&self, _statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, _statement: &Statement, ctx: &RuleContext) -> Vec { let (inside_range, statement_sql) = trimmed_statement_range_and_sql(ctx); let inside_tokens = tokenized_for_range(ctx, inside_range.clone()); let effective_batch_limit = self @@ -139,7 +139,7 @@ impl LintRule for LayoutNewlines { } } -fn trimmed_statement_range_and_sql<'a>(ctx: &'a LintContext) -> (Range, &'a str) { +fn trimmed_statement_range_and_sql<'a>(ctx: &'a RuleContext) -> (Range, &'a str) { if let Some(range) = trimmed_statement_range_from_tokens(ctx) { return (range.clone(), &ctx.sql[range]); } @@ -176,7 +176,7 @@ fn trim_ascii_whitespace_bounds(sql: &str) -> (usize, usize) { (start, end) } -fn trimmed_statement_range_from_tokens(ctx: &LintContext) -> Option> { +fn trimmed_statement_range_from_tokens(ctx: &RuleContext) -> Option> { let statement_start = ctx.statement_range.start; let statement_end = ctx.statement_range.end; @@ -479,7 +479,7 @@ fn tokenized(sql: &str, dialect: Dialect) -> Option> { tokenizer.tokenize_with_location().ok() } -fn tokenized_for_range(ctx: &LintContext, range: Range) -> Option> { +fn tokenized_for_range(ctx: &RuleContext, range: Range) -> Option> { if range.is_empty() { return Some(Vec::new()); } @@ -625,7 +625,6 @@ fn relative_location( mod tests { use super::*; use crate::linter::config::LintConfig; - use crate::linter::rule::with_active_dialect; use crate::parser::parse_sql; use crate::types::IssueAutofixApplicability; @@ -653,13 +652,9 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( + rule.check_with_context( statement, - &LintContext { - sql, - statement_range: ranges[index].clone(), - statement_index: index, - }, + &RuleContext::new(sql, ranges[index].clone(), index), ) }) .collect() @@ -687,16 +682,10 @@ mod tests { dialect: Dialect, ) -> Vec { let placeholder = parse_sql("SELECT 1").expect("parse placeholder"); - with_active_dialect(dialect, || { - rule.check( - &placeholder[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ) - }) + rule.check_with_context( + &placeholder[0], + &RuleContext::new(sql, 0..sql.len(), 0).with_dialect(dialect), + ) } fn apply_issue_autofix(sql: &str, issue: &Issue) -> Option { diff --git a/crates/flowscope-core/src/linter/rules/mod.rs b/crates/flowscope-core/src/linter/rules/mod.rs index 1efe7425..81d711ea 100644 --- a/crates/flowscope-core/src/linter/rules/mod.rs +++ b/crates/flowscope-core/src/linter/rules/mod.rs @@ -1,7 +1,11 @@ //! Lint rule implementations and registry. use super::config::LintConfig; -use super::rule::LintRule; +use super::rule::{ + DialectSupport, DocumentSource, LintRule, RegisteredRule, RuleDescriptor, RuleQuality, + RuleScope, StatementSource, +}; +use crate::types::{Dialect, LintEngine}; pub mod al_001; pub mod al_002; @@ -82,81 +86,321 @@ pub mod tq_001; pub mod tq_002; pub mod tq_003; -/// Returns all available lint rules. +const SEMANTIC_AST: RuleDescriptor = RuleDescriptor::new( + LintEngine::Semantic, + RuleScope::Statement(StatementSource::Rendered), + DialectSupport::All, + RuleQuality::Ast, + false, +); +const SEMANTIC_HEURISTIC: RuleDescriptor = RuleDescriptor::new( + LintEngine::Semantic, + RuleScope::Statement(StatementSource::Rendered), + DialectSupport::All, + RuleQuality::Heuristic, + false, +); +const LEXICAL_HEURISTIC: RuleDescriptor = RuleDescriptor::new( + LintEngine::Lexical, + RuleScope::Statement(StatementSource::Rendered), + DialectSupport::All, + RuleQuality::Heuristic, + false, +); +const DOCUMENT_HEURISTIC: RuleDescriptor = RuleDescriptor::new( + LintEngine::Document, + RuleScope::Statement(StatementSource::Rendered), + DialectSupport::All, + RuleQuality::Heuristic, + false, +); + +const AM_007_DIALECTS: &[Dialect] = &[ + Dialect::Generic, + Dialect::Ansi, + Dialect::Bigquery, + Dialect::Clickhouse, + Dialect::Databricks, + Dialect::Hive, + Dialect::Mysql, + Dialect::Redshift, + Dialect::Snowflake, +]; + +const fn fallback(mut descriptor: RuleDescriptor) -> RuleDescriptor { + descriptor.statementless_fallback = true; + descriptor +} + +const fn source(mut descriptor: RuleDescriptor, source: StatementSource) -> RuleDescriptor { + descriptor.scope = RuleScope::Statement(source); + descriptor +} + +const fn document(mut descriptor: RuleDescriptor, source: DocumentSource) -> RuleDescriptor { + descriptor.scope = RuleScope::Document(source); + descriptor +} + +const fn dialects(mut descriptor: RuleDescriptor, supported: &'static [Dialect]) -> RuleDescriptor { + descriptor.dialects = DialectSupport::Only(supported); + descriptor +} + +fn registered(rule: impl LintRule + 'static, descriptor: RuleDescriptor) -> RegisteredRule { + RegisteredRule::new(Box::new(rule), descriptor) +} + +/// Returns all available lint rules with their declarative scheduling metadata. +pub(crate) fn registered_rules(config: &LintConfig) -> Vec { + vec![ + registered(am_002::BareUnion, SEMANTIC_AST), + registered(am_009::LimitOffsetWithoutOrderBy, SEMANTIC_HEURISTIC), + registered(am_001::DistinctWithGroupBy, SEMANTIC_AST), + registered(am_004::AmbiguousColumnCount, fallback(SEMANTIC_AST)), + registered(am_003::AmbiguousOrderBy, SEMANTIC_AST), + registered( + am_005::AmbiguousJoinStyle::from_config(config), + SEMANTIC_AST, + ), + registered( + am_006::AmbiguousColumnRefs::from_config(config), + SEMANTIC_AST, + ), + registered( + am_007::AmbiguousSetColumns, + dialects(SEMANTIC_AST, AM_007_DIALECTS), + ), + registered(am_008::AmbiguousJoinCondition, SEMANTIC_AST), + registered( + al_001::AliasingTableStyle::from_config(config), + SEMANTIC_HEURISTIC, + ), + registered( + al_002::AliasingColumnStyle::from_config(config), + SEMANTIC_HEURISTIC, + ), + registered(al_003::ImplicitAlias::from_config(config), SEMANTIC_AST), + registered( + al_004::AliasingUniqueTable::from_config(config), + SEMANTIC_AST, + ), + registered(al_005::UnusedTableAlias::from_config(config), SEMANTIC_AST), + registered(al_006::AliasingLength::from_config(config), SEMANTIC_AST), + registered( + al_007::AliasingForbidSingleTable::from_config(config), + fallback(SEMANTIC_AST), + ), + registered( + al_008::AliasingUniqueColumn::from_config(config), + fallback(SEMANTIC_AST), + ), + registered( + al_009::AliasingSelfAliasColumn::from_config(config), + SEMANTIC_AST, + ), + registered( + cp_001::CapitalisationKeywords::from_config(config), + fallback(document(LEXICAL_HEURISTIC, DocumentSource::MaskedSource)), + ), + registered( + cp_002::CapitalisationIdentifiers::from_config(config), + fallback(LEXICAL_HEURISTIC), + ), + registered( + cp_003::CapitalisationFunctions::from_config(config), + fallback(document(LEXICAL_HEURISTIC, DocumentSource::OriginalSource)), + ), + registered( + cp_004::CapitalisationLiterals::from_config(config), + fallback(document(LEXICAL_HEURISTIC, DocumentSource::MaskedSource)), + ), + registered( + cp_005::CapitalisationTypes::from_config(config), + fallback(document(LEXICAL_HEURISTIC, DocumentSource::MaskedSource)), + ), + registered( + cv_001::ConventionNotEqual::from_config(config), + fallback(SEMANTIC_HEURISTIC), + ), + registered(cv_002::CoalesceConvention, SEMANTIC_AST), + registered( + cv_003::ConventionSelectTrailingComma::from_config(config), + SEMANTIC_HEURISTIC, + ), + registered(cv_004::CountStyle::from_config(config), SEMANTIC_AST), + registered(cv_005::NullComparison, SEMANTIC_AST), + registered( + cv_006::ConventionTerminator::from_config(config), + SEMANTIC_HEURISTIC, + ), + registered(cv_007::ConventionStatementBrackets, SEMANTIC_HEURISTIC), + registered(cv_008::LeftJoinOverRightJoin, SEMANTIC_AST), + registered( + cv_009::ConventionBlockedWords::from_config(config), + source(SEMANTIC_HEURISTIC, StatementSource::MappedSource), + ), + registered( + cv_010::ConventionQuotedLiterals::from_config(config), + source(SEMANTIC_HEURISTIC, StatementSource::MappedSource), + ), + registered( + cv_011::ConventionCastingStyle::from_config(config), + SEMANTIC_HEURISTIC, + ), + registered(cv_012::ConventionJoinCondition, SEMANTIC_AST), + registered( + jj_001::JinjaPadding, + document(LEXICAL_HEURISTIC, DocumentSource::OriginalSource), + ), + registered( + lt_001::LayoutSpacing::from_config(config), + fallback(source( + LEXICAL_HEURISTIC, + StatementSource::TrailingWhitespace, + )), + ), + registered( + lt_002::LayoutIndent::from_config(config), + fallback(source(LEXICAL_HEURISTIC, StatementSource::MappedSource)), + ), + registered( + lt_003::LayoutOperators::from_config(config), + fallback(LEXICAL_HEURISTIC), + ), + registered( + lt_004::LayoutCommas::from_config(config), + source(LEXICAL_HEURISTIC, StatementSource::MappedSource), + ), + registered( + lt_005::LayoutLongLines::from_config(config), + fallback(source(LEXICAL_HEURISTIC, StatementSource::MappedSource)), + ), + registered(lt_006::LayoutFunctions, LEXICAL_HEURISTIC), + registered( + lt_007::LayoutCteBracket, + source(LEXICAL_HEURISTIC, StatementSource::MappedSource), + ), + registered( + lt_008::LayoutCteNewline::from_config(config), + LEXICAL_HEURISTIC, + ), + registered( + lt_009::LayoutSelectTargets::from_config(config), + LEXICAL_HEURISTIC, + ), + registered(lt_010::LayoutSelectModifiers, LEXICAL_HEURISTIC), + registered( + lt_011::LayoutSetOperators::from_config(config), + LEXICAL_HEURISTIC, + ), + registered( + lt_012::LayoutEndOfFile, + fallback(source(DOCUMENT_HEURISTIC, StatementSource::WholeSource)), + ), + registered( + lt_013::LayoutStartOfFile, + source(DOCUMENT_HEURISTIC, StatementSource::WholeSource), + ), + registered( + lt_014::LayoutKeywordNewline::from_config(config), + LEXICAL_HEURISTIC, + ), + registered( + lt_015::LayoutNewlines::from_config(config), + DOCUMENT_HEURISTIC, + ), + registered(rf_001::ReferencesFrom::from_config(config), SEMANTIC_AST), + registered( + rf_002::ReferencesQualification::from_config(config), + SEMANTIC_AST, + ), + registered( + rf_003::ReferencesConsistent::from_config(config), + SEMANTIC_AST, + ), + registered( + rf_004::ReferencesKeywords::from_config(config), + SEMANTIC_HEURISTIC, + ), + registered( + rf_005::ReferencesSpecialChars::from_config(config), + SEMANTIC_HEURISTIC, + ), + registered( + rf_006::ReferencesQuoting::from_config(config), + fallback(SEMANTIC_HEURISTIC), + ), + registered(st_003::UnusedCte, SEMANTIC_AST), + registered(st_001::UnnecessaryElseNull, SEMANTIC_AST), + registered(st_002::StructureSimpleCase, fallback(SEMANTIC_AST)), + registered( + st_004::FlattenableNestedCase, + fallback(source(SEMANTIC_AST, StatementSource::MappedSource)), + ), + registered(st_005::StructureSubquery::from_config(config), SEMANTIC_AST), + registered(st_006::StructureColumnOrder, SEMANTIC_AST), + registered(st_007::AvoidUsingJoin, SEMANTIC_AST), + registered(st_008::StructureDistinct, SEMANTIC_AST), + registered( + st_009::StructureJoinConditionOrder::from_config(config), + SEMANTIC_AST, + ), + registered(st_010::StructureConstantExpression, SEMANTIC_AST), + registered(st_011::StructureUnusedJoin, SEMANTIC_AST), + registered(st_012::StructureConsecutiveSemicolons, DOCUMENT_HEURISTIC), + registered(tq_001::TsqlSpPrefix, fallback(LEXICAL_HEURISTIC)), + registered(tq_002::TsqlProcedureBeginEnd, fallback(LEXICAL_HEURISTIC)), + registered(tq_003::TsqlEmptyBatch, LEXICAL_HEURISTIC), + ] +} + +/// Returns all available lint rule implementations. pub fn all_rules(config: &LintConfig) -> Vec> { - let rules: Vec> = vec![ - Box::new(am_002::BareUnion), - Box::new(am_009::LimitOffsetWithoutOrderBy), - Box::new(am_001::DistinctWithGroupBy), - Box::new(am_004::AmbiguousColumnCount), - Box::new(am_003::AmbiguousOrderBy), - Box::new(am_005::AmbiguousJoinStyle::from_config(config)), - Box::new(am_006::AmbiguousColumnRefs::from_config(config)), - Box::new(am_007::AmbiguousSetColumns), - Box::new(am_008::AmbiguousJoinCondition), - Box::new(al_001::AliasingTableStyle::from_config(config)), - Box::new(al_002::AliasingColumnStyle::from_config(config)), - Box::new(al_003::ImplicitAlias::from_config(config)), - Box::new(al_004::AliasingUniqueTable::from_config(config)), - Box::new(al_005::UnusedTableAlias::from_config(config)), - Box::new(al_006::AliasingLength::from_config(config)), - Box::new(al_007::AliasingForbidSingleTable::from_config(config)), - Box::new(al_008::AliasingUniqueColumn::from_config(config)), - Box::new(al_009::AliasingSelfAliasColumn::from_config(config)), - Box::new(cp_001::CapitalisationKeywords::from_config(config)), - Box::new(cp_002::CapitalisationIdentifiers::from_config(config)), - Box::new(cp_003::CapitalisationFunctions::from_config(config)), - Box::new(cp_004::CapitalisationLiterals::from_config(config)), - Box::new(cp_005::CapitalisationTypes::from_config(config)), - Box::new(cv_001::ConventionNotEqual::from_config(config)), - Box::new(cv_002::CoalesceConvention), - Box::new(cv_003::ConventionSelectTrailingComma::from_config(config)), - Box::new(cv_004::CountStyle::from_config(config)), - Box::new(cv_005::NullComparison), - Box::new(cv_006::ConventionTerminator::from_config(config)), - Box::new(cv_007::ConventionStatementBrackets), - Box::new(cv_008::LeftJoinOverRightJoin), - Box::new(cv_009::ConventionBlockedWords::from_config(config)), - Box::new(cv_010::ConventionQuotedLiterals::from_config(config)), - Box::new(cv_011::ConventionCastingStyle::from_config(config)), - Box::new(cv_012::ConventionJoinCondition), - Box::new(jj_001::JinjaPadding), - Box::new(lt_001::LayoutSpacing::from_config(config)), - Box::new(lt_002::LayoutIndent::from_config(config)), - Box::new(lt_003::LayoutOperators::from_config(config)), - Box::new(lt_004::LayoutCommas::from_config(config)), - Box::new(lt_005::LayoutLongLines::from_config(config)), - Box::new(lt_006::LayoutFunctions), - Box::new(lt_007::LayoutCteBracket), - Box::new(lt_008::LayoutCteNewline::from_config(config)), - Box::new(lt_009::LayoutSelectTargets::from_config(config)), - Box::new(lt_010::LayoutSelectModifiers), - Box::new(lt_011::LayoutSetOperators::from_config(config)), - Box::new(lt_012::LayoutEndOfFile), - Box::new(lt_013::LayoutStartOfFile), - Box::new(lt_014::LayoutKeywordNewline::from_config(config)), - Box::new(lt_015::LayoutNewlines::from_config(config)), - Box::new(rf_001::ReferencesFrom::from_config(config)), - Box::new(rf_002::ReferencesQualification::from_config(config)), - Box::new(rf_003::ReferencesConsistent::from_config(config)), - Box::new(rf_004::ReferencesKeywords::from_config(config)), - Box::new(rf_005::ReferencesSpecialChars::from_config(config)), - Box::new(rf_006::ReferencesQuoting::from_config(config)), - Box::new(st_003::UnusedCte), - Box::new(st_001::UnnecessaryElseNull), - Box::new(st_002::StructureSimpleCase), - Box::new(st_004::FlattenableNestedCase), - Box::new(st_005::StructureSubquery::from_config(config)), - Box::new(st_006::StructureColumnOrder), - Box::new(st_007::AvoidUsingJoin), - Box::new(st_008::StructureDistinct), - Box::new(st_009::StructureJoinConditionOrder::from_config(config)), - Box::new(st_010::StructureConstantExpression), - Box::new(st_011::StructureUnusedJoin), - Box::new(st_012::StructureConsecutiveSemicolons), - Box::new(tq_001::TsqlSpPrefix), - Box::new(tq_002::TsqlProcedureBeginEnd), - Box::new(tq_003::TsqlEmptyBatch), - ]; - rules + registered_rules(config) + .into_iter() + .map(|registered| registered.rule) + .collect() +} + +#[cfg(test)] +mod registry_tests { + use std::collections::HashSet; + + use super::{registered_rules, AM_007_DIALECTS}; + use crate::linter::config::LintConfig; + use crate::linter::rule::{DialectSupport, RuleScope}; + use crate::types::{issue_codes, Dialect, LintEngine}; + + #[test] + fn every_registered_rule_has_one_complete_descriptor() { + let rules = registered_rules(&LintConfig::default()); + let mut codes = HashSet::new(); + + assert_eq!(rules.len(), 72); + for registered in rules { + assert!(codes.insert(registered.rule.code())); + assert!(!registered.rule.name().is_empty()); + assert!(!registered.rule.description().is_empty()); + if let DialectSupport::Only(dialects) = registered.descriptor.dialects { + assert!(!dialects.is_empty()); + } + if matches!(registered.descriptor.scope, RuleScope::Document(_)) { + assert_eq!(registered.descriptor.engine, LintEngine::Lexical); + } + } + } + + #[test] + fn dialect_filter_is_owned_by_the_rule_descriptor() { + let descriptor = registered_rules(&LintConfig::default()) + .into_iter() + .find(|registered| registered.rule.code() == issue_codes::LINT_AM_007) + .expect("AM07 is registered") + .descriptor; + + assert_eq!(descriptor.dialects, DialectSupport::Only(AM_007_DIALECTS)); + assert!(descriptor.dialects.supports(Dialect::Generic)); + assert!(!descriptor.dialects.supports(Dialect::Postgres)); + } } diff --git a/crates/flowscope-core/src/linter/rules/rf_001.rs b/crates/flowscope-core/src/linter/rules/rf_001.rs index d2a25f8d..d3f3572a 100644 --- a/crates/flowscope-core/src/linter/rules/rf_001.rs +++ b/crates/flowscope-core/src/linter/rules/rf_001.rs @@ -2,11 +2,10 @@ //! //! Qualified column prefixes should resolve to known FROM/JOIN sources. -use std::cell::Cell; use std::collections::HashSet; use crate::linter::config::LintConfig; -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Issue}; use sqlparser::ast::{ AlterPolicy, AlterPolicyOperation, Assignment, AssignmentTarget, ConditionalStatements, @@ -42,34 +41,7 @@ impl Default for ReferencesFrom { } } -thread_local! { - static RF01_FORCE_ENABLE_EXPLICIT: Cell = const { Cell::new(false) }; -} - -fn with_rf01_force_enable_explicit(explicit: bool, f: impl FnOnce() -> T) -> T { - RF01_FORCE_ENABLE_EXPLICIT.with(|active| { - struct Reset<'a> { - cell: &'a Cell, - previous: bool, - } - - impl Drop for Reset<'_> { - fn drop(&mut self) { - self.cell.set(self.previous); - } - } - - let reset = Reset { - cell: active, - previous: active.replace(explicit), - }; - let result = f(); - drop(reset); - result - }) -} - -impl LintRule for ReferencesFrom { +impl BuiltinLintRule for ReferencesFrom { fn code(&self) -> &'static str { issue_codes::LINT_RF_001 } @@ -82,7 +54,7 @@ impl LintRule for ReferencesFrom { "References cannot reference objects not present in 'FROM' clause." } - fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, statement: &Statement, ctx: &RuleContext) -> Vec { let effective_force_enable = if self.force_enable_configured { self.force_enable } else { @@ -95,15 +67,12 @@ impl LintRule for ReferencesFrom { return Vec::new(); } - let unresolved_count = - with_rf01_force_enable_explicit(self.force_enable_configured, || { - unresolved_references_in_statement( - statement, - &SourceRegistry::default(), - ctx.dialect(), - false, - ) - }); + let unresolved_count = unresolved_references_in_statement( + statement, + &SourceRegistry::with_force_enable_explicit(self.force_enable_configured), + ctx.dialect(), + false, + ); (0..unresolved_count) .map(|_| { @@ -121,9 +90,21 @@ impl LintRule for ReferencesFrom { struct SourceRegistry { exact: HashSet, unqualified: HashSet, + force_enable_explicit: bool, } impl SourceRegistry { + fn with_force_enable_explicit(force_enable_explicit: bool) -> Self { + Self { + force_enable_explicit, + ..Self::default() + } + } + + fn isolated(&self) -> Self { + Self::with_force_enable_explicit(self.force_enable_explicit) + } + fn register_alias(&mut self, alias: &str) { let clean = clean_identifier_component(alias); if clean.is_empty() { @@ -652,7 +633,7 @@ fn unresolved_references_in_table_factor( } else { unresolved_references_in_query( subquery, - &SourceRegistry::default(), + &scope_sources.isolated(), dialect, in_trigger, ) @@ -971,7 +952,7 @@ fn should_defer_struct_field_reference( qualifier_parts: &[String], scope_sources: &SourceRegistry, ) -> bool { - if RF01_FORCE_ENABLE_EXPLICIT.with(Cell::get) { + if scope_sources.force_enable_explicit { return false; } @@ -1091,7 +1072,6 @@ fn clean_identifier_component(raw: &str) -> String { mod tests { use super::*; use crate::linter::config::LintConfig; - use crate::linter::rule::with_active_dialect; use crate::parser::parse_sql; use crate::parser::parse_sql_with_dialect; use crate::types::Dialect; @@ -1103,14 +1083,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } @@ -1119,18 +1092,12 @@ mod tests { let statements = parse_sql_with_dialect(sql, dialect).expect("parse"); let rule = ReferencesFrom::default(); let mut issues = Vec::new(); - with_active_dialect(dialect, || { - for (index, statement) in statements.iter().enumerate() { - issues.extend(rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - )); - } - }); + for (index, statement) in statements.iter().enumerate() { + issues.extend(rule.check_with_context( + statement, + &RuleContext::new(sql, 0..sql.len(), index).with_dialect(dialect), + )); + } issues } @@ -1236,18 +1203,12 @@ mod tests { let sql = "SELECT tbl.a AS a_new, EXPLODE(tbl.b.c) AS a_b_new FROM test AS tbl"; let statements = parse_sql_with_dialect(sql, Dialect::Databricks).expect("parse"); let mut issues = Vec::new(); - with_active_dialect(Dialect::Databricks, || { - for (index, statement) in statements.iter().enumerate() { - issues.extend(rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - )); - } - }); + for (index, statement) in statements.iter().enumerate() { + issues.extend(rule.check_with_context( + statement, + &RuleContext::new(sql, 0..sql.len(), index).with_dialect(Dialect::Databricks), + )); + } assert_eq!(issues.len(), 1); } @@ -1278,14 +1239,8 @@ SELECT cte.a FROM cte ORDER BY cte.a"); let rule = ReferencesFrom::from_config(&config); let sql = "SELECT * FROM my_tbl WHERE foo.bar > 0"; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert!(issues.is_empty()); } } diff --git a/crates/flowscope-core/src/linter/rules/rf_002.rs b/crates/flowscope-core/src/linter/rules/rf_002.rs index 3a310444..b48f8492 100644 --- a/crates/flowscope-core/src/linter/rules/rf_002.rs +++ b/crates/flowscope-core/src/linter/rules/rf_002.rs @@ -7,7 +7,7 @@ use std::collections::HashSet; use regex::Regex; use crate::linter::config::LintConfig; -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::parser::parse_sql_with_dialect; use crate::types::{issue_codes, Dialect, Issue}; use sqlparser::ast::{ @@ -73,7 +73,7 @@ impl Default for ReferencesQualification { } } -impl LintRule for ReferencesQualification { +impl BuiltinLintRule for ReferencesQualification { fn code(&self) -> &'static str { issue_codes::LINT_RF_002 } @@ -86,7 +86,7 @@ impl LintRule for ReferencesQualification { "References should be qualified if select has more than one referenced table/view." } - fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, statement: &Statement, ctx: &RuleContext) -> Vec { if !self.force_enable { return Vec::new(); } @@ -1168,7 +1168,6 @@ fn is_date_part_identifier(value: &str) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::linter::rule::with_active_dialect; use crate::parser::{parse_sql, parse_sql_with_dialect}; fn run(sql: &str) -> Vec { @@ -1178,14 +1177,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } @@ -1194,18 +1186,12 @@ mod tests { let statements = parse_sql_with_dialect(sql, dialect).expect("parse"); let rule = ReferencesQualification::default(); let mut issues = Vec::new(); - with_active_dialect(dialect, || { - for (index, statement) in statements.iter().enumerate() { - issues.extend(rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - )); - } - }); + for (index, statement) in statements.iter().enumerate() { + issues.extend(rule.check_with_context( + statement, + &RuleContext::new(sql, 0..sql.len(), index).with_dialect(dialect), + )); + } issues } @@ -1221,18 +1207,12 @@ mod tests { }; let rule = ReferencesQualification::from_config(&config); let mut issues = Vec::new(); - with_active_dialect(dialect, || { - for (index, statement) in statements.iter().enumerate() { - issues.extend(rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - )); - } - }); + for (index, statement) in statements.iter().enumerate() { + issues.extend(rule.check_with_context( + statement, + &RuleContext::new(sql, 0..sql.len(), index).with_dialect(dialect), + )); + } issues } @@ -1279,14 +1259,8 @@ mod tests { let rule = ReferencesQualification::from_config(&config); let sql = "SELECT a, b FROM foo LEFT JOIN vee ON vee.a = foo.a"; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert!(issues.is_empty()); } diff --git a/crates/flowscope-core/src/linter/rules/rf_003.rs b/crates/flowscope-core/src/linter/rules/rf_003.rs index 8fcdf2ab..eeaf6fde 100644 --- a/crates/flowscope-core/src/linter/rules/rf_003.rs +++ b/crates/flowscope-core/src/linter/rules/rf_003.rs @@ -3,7 +3,7 @@ //! In single-source queries, avoid mixing qualified and unqualified references. use crate::linter::config::LintConfig; -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit, Span}; use sqlparser::ast::{ Expr, FunctionArg, FunctionArgExpr, FunctionArguments, Select, SelectItem, Spanned, Statement, @@ -78,7 +78,7 @@ impl Default for ReferencesConsistent { } } -impl LintRule for ReferencesConsistent { +impl BuiltinLintRule for ReferencesConsistent { fn code(&self) -> &'static str { issue_codes::LINT_RF_003 } @@ -91,7 +91,7 @@ impl LintRule for ReferencesConsistent { "Column references should be qualified consistently in single table statements." } - fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, statement: &Statement, ctx: &RuleContext) -> Vec { if !self.force_enable { return Vec::new(); } @@ -263,7 +263,7 @@ impl LintRule for ReferencesConsistent { } fn ancestor_source_names_for_select( - ctx: &LintContext, + ctx: &RuleContext, select: &Select, scopes: &[Rf003SelectScope], ) -> HashSet { @@ -304,7 +304,7 @@ enum Rf003ReferenceClass { fn rf003_autofix_edits_for_select( select: &Select, - ctx: &LintContext, + ctx: &RuleContext, target_style: Rf003AutofixTargetStyle, aliases: &HashSet, local_sources: &HashSet, @@ -381,7 +381,7 @@ fn preferred_qualification_prefix(select: &Select) -> Option { #[allow(clippy::too_many_arguments)] fn collect_rf003_autofix_edits_in_expr( expr: &Expr, - ctx: &LintContext, + ctx: &RuleContext, statement_sql: &str, target_style: Rf003AutofixTargetStyle, prefix: &str, @@ -751,7 +751,7 @@ fn classify_rf003_reference( } } -fn expr_statement_offsets(ctx: &LintContext, expr: &Expr) -> Option<(usize, usize)> { +fn expr_statement_offsets(ctx: &RuleContext, expr: &Expr) -> Option<(usize, usize)> { // Statement ranges may intentionally trim leading comments/whitespace. // SQLParser spans are often absolute to the full document, so prefer // document-level conversion when the statement does not start at byte 0. @@ -781,7 +781,7 @@ fn expr_statement_offsets(ctx: &LintContext, expr: &Expr) -> Option<(usize, usiz )) } -fn select_statement_offsets(ctx: &LintContext, select: &Select) -> Option<(usize, usize)> { +fn select_statement_offsets(ctx: &RuleContext, select: &Select) -> Option<(usize, usize)> { // Statement ranges may intentionally trim leading comments/whitespace. // SQLParser spans are often absolute to the full document, so prefer // document-level conversion when the statement does not start at byte 0. @@ -1765,14 +1765,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } @@ -1877,14 +1870,8 @@ mod tests { let rule = ReferencesConsistent::from_config(&config); let sql = "SELECT bar FROM my_tbl"; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); } @@ -1901,14 +1888,8 @@ mod tests { let rule = ReferencesConsistent::from_config(&config); let sql = "SELECT my_tbl.bar, baz FROM my_tbl"; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert!(issues.is_empty()); } @@ -1920,13 +1901,9 @@ mod tests { let statement_end = sql.len(); let rule = ReferencesConsistent::default(); - let issues = rule.check( + let issues = rule.check_with_context( &statements[0], - &LintContext { - sql, - statement_range: statement_start..statement_end, - statement_index: 0, - }, + &RuleContext::new(sql, statement_start..statement_end, 0), ); let fixed = apply_all_autofixes(sql, &issues); diff --git a/crates/flowscope-core/src/linter/rules/rf_004.rs b/crates/flowscope-core/src/linter/rules/rf_004.rs index 865be410..d1ee96f9 100644 --- a/crates/flowscope-core/src/linter/rules/rf_004.rs +++ b/crates/flowscope-core/src/linter/rules/rf_004.rs @@ -6,7 +6,7 @@ use std::collections::HashSet; use crate::linter::config::LintConfig; -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit}; use regex::{Regex, RegexBuilder}; use sqlparser::ast::Statement; @@ -67,7 +67,7 @@ impl Default for ReferencesKeywords { } } -impl LintRule for ReferencesKeywords { +impl BuiltinLintRule for ReferencesKeywords { fn code(&self) -> &'static str { issue_codes::LINT_RF_004 } @@ -80,7 +80,7 @@ impl LintRule for ReferencesKeywords { "Keywords should not be used as identifiers." } - fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, statement: &Statement, ctx: &RuleContext) -> Vec { if !statement_contains_keyword_identifier(statement, self) { return Vec::new(); } @@ -463,14 +463,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } diff --git a/crates/flowscope-core/src/linter/rules/rf_005.rs b/crates/flowscope-core/src/linter/rules/rf_005.rs index 5d6c9a8d..fb1dc1fe 100644 --- a/crates/flowscope-core/src/linter/rules/rf_005.rs +++ b/crates/flowscope-core/src/linter/rules/rf_005.rs @@ -6,7 +6,7 @@ use std::collections::HashSet; use crate::linter::config::LintConfig; -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Dialect, Issue}; use regex::Regex; use sqlparser::ast::Statement; @@ -68,7 +68,7 @@ impl Default for ReferencesSpecialChars { } } -impl LintRule for ReferencesSpecialChars { +impl BuiltinLintRule for ReferencesSpecialChars { fn code(&self) -> &'static str { issue_codes::LINT_RF_005 } @@ -81,7 +81,7 @@ impl LintRule for ReferencesSpecialChars { "Do not use special characters in identifiers." } - fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, statement: &Statement, ctx: &RuleContext) -> Vec { let dialect = ctx.dialect(); let has_special_chars = collect_identifier_candidates(statement) .into_iter() @@ -318,7 +318,6 @@ fn normalize_token(token: &str) -> String { #[cfg(test)] mod tests { use super::*; - use crate::linter::rule::with_active_dialect; use crate::parser::parse_sql; use crate::parser::parse_sql_with_dialect; use crate::types::Dialect; @@ -334,14 +333,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } @@ -350,18 +342,12 @@ mod tests { let statements = parse_sql_with_dialect(sql, dialect).expect("parse"); let rule = ReferencesSpecialChars::default(); let mut issues = Vec::new(); - with_active_dialect(dialect, || { - for (index, statement) in statements.iter().enumerate() { - issues.extend(rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - )); - } - }); + for (index, statement) in statements.iter().enumerate() { + issues.extend(rule.check_with_context( + statement, + &RuleContext::new(sql, 0..sql.len(), index).with_dialect(dialect), + )); + } issues } diff --git a/crates/flowscope-core/src/linter/rules/rf_006.rs b/crates/flowscope-core/src/linter/rules/rf_006.rs index de070e88..1a31dad5 100644 --- a/crates/flowscope-core/src/linter/rules/rf_006.rs +++ b/crates/flowscope-core/src/linter/rules/rf_006.rs @@ -7,7 +7,7 @@ use std::collections::HashSet; use crate::generated::NormalizationStrategy; use crate::linter::config::LintConfig; -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit}; use regex::Regex; use sqlparser::ast::Statement; @@ -92,7 +92,7 @@ impl ReferencesQuoting { } } -impl LintRule for ReferencesQuoting { +impl BuiltinLintRule for ReferencesQuoting { fn code(&self) -> &'static str { issue_codes::LINT_RF_006 } @@ -105,7 +105,7 @@ impl LintRule for ReferencesQuoting { "Unnecessary quoted identifier." } - fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, statement: &Statement, ctx: &RuleContext) -> Vec { let dialect = ctx.dialect(); let ast_has_violation = collect_identifier_candidates(statement) @@ -563,7 +563,6 @@ fn is_keyword(token: &str) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::linter::rule::with_active_dialect; use crate::parser::parse_sql; use crate::types::Dialect; use crate::types::IssueAutofixApplicability; @@ -575,14 +574,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } @@ -642,14 +634,8 @@ mod tests { let rule = ReferencesQuoting::from_config(&config); let sql = "SELECT \"good_name\" FROM \"t\""; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert!(issues.is_empty()); } @@ -666,14 +652,8 @@ mod tests { let rule = ReferencesQuoting::from_config(&config); let sql = "SELECT good_name FROM t"; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); } @@ -690,14 +670,8 @@ mod tests { let rule = ReferencesQuoting::from_config(&config); let sql = "SELECT \"select\".id FROM users AS \"select\""; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert!(issues.is_empty()); } @@ -714,14 +688,8 @@ mod tests { let rule = ReferencesQuoting::from_config(&config); let sql = "SELECT \"good_name\" FROM t"; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert!(issues.is_empty()); } @@ -738,14 +706,8 @@ mod tests { let rule = ReferencesQuoting::from_config(&config); let sql = "SELECT \"good_name\" FROM t"; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert!(issues.is_empty()); } @@ -762,14 +724,8 @@ mod tests { let rule = ReferencesQuoting::from_config(&config); let sql = "SELECT \"good_name\" FROM t"; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert!(issues.is_empty()); } @@ -791,16 +747,10 @@ mod tests { let statements = parse_sql("SELECT 1").expect("synthetic parse"); let rule = ReferencesQuoting::default(); - let issues = with_active_dialect(Dialect::Databricks, || { - rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ) - }); + let issues = rule.check_with_context( + &statements[0], + &RuleContext::new(sql, 0..sql.len(), 0).with_dialect(Dialect::Databricks), + ); assert_eq!(issues.len(), 1); let autofix = issues[0].autofix.as_ref().expect("autofix metadata"); assert_eq!(autofix.applicability, IssueAutofixApplicability::Safe); diff --git a/crates/flowscope-core/src/linter/rules/st_001.rs b/crates/flowscope-core/src/linter/rules/st_001.rs index 77b87d1a..49f6e94e 100644 --- a/crates/flowscope-core/src/linter/rules/st_001.rs +++ b/crates/flowscope-core/src/linter/rules/st_001.rs @@ -4,7 +4,7 @@ //! when no branch matches. The ELSE NULL can be removed. use crate::linter::helpers; -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::linter::visit; use crate::types::{issue_codes, Issue, IssueAutofixApplicability, IssuePatchEdit, Span}; use sqlparser::ast::*; @@ -12,7 +12,7 @@ use sqlparser::tokenizer::{Token, TokenWithSpan, Tokenizer, Whitespace}; pub struct UnnecessaryElseNull; -impl LintRule for UnnecessaryElseNull { +impl BuiltinLintRule for UnnecessaryElseNull { fn code(&self) -> &'static str { issue_codes::LINT_ST_001 } @@ -25,7 +25,7 @@ impl LintRule for UnnecessaryElseNull { "Do not specify 'else null' in a case when statement (redundant)." } - fn check(&self, stmt: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, stmt: &Statement, ctx: &RuleContext) -> Vec { let mut violation_count = 0usize; visit::visit_expressions(stmt, &mut |expr| { if let Expr::Case { @@ -82,7 +82,7 @@ struct CaseFrame { else_sig_pos: Option, } -fn st001_else_null_candidates_for_context(ctx: &LintContext) -> Vec { +fn st001_else_null_candidates_for_context(ctx: &RuleContext) -> Vec { let tokens = statement_positioned_tokens(ctx); if tokens.is_empty() { return Vec::new(); @@ -91,7 +91,7 @@ fn st001_else_null_candidates_for_context(ctx: &LintContext) -> Vec Vec { +fn statement_positioned_tokens(ctx: &RuleContext) -> Vec { let from_document_tokens = ctx.with_document_tokens(|tokens| { if tokens.is_empty() { return None; @@ -296,14 +296,10 @@ mod tests { fn check_sql(sql: &str) -> Vec { let stmts = parse_sql(sql).unwrap(); let rule = UnnecessaryElseNull; - let ctx = LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }; + let ctx = RuleContext::new(sql, 0..sql.len(), 0); let mut issues = Vec::new(); for stmt in &stmts { - issues.extend(rule.check(stmt, &ctx)); + issues.extend(rule.check_with_context(stmt, &ctx)); } issues } diff --git a/crates/flowscope-core/src/linter/rules/st_002.rs b/crates/flowscope-core/src/linter/rules/st_002.rs index 4f302ca8..03c6ccfe 100644 --- a/crates/flowscope-core/src/linter/rules/st_002.rs +++ b/crates/flowscope-core/src/linter/rules/st_002.rs @@ -13,7 +13,7 @@ //! 6. `CASE WHEN x IS NOT NULL THEN x ELSE NULL END` → `x` //! 7. `CASE WHEN x IS NOT NULL THEN x END` → `x` -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::linter::visit; use crate::types::{issue_codes, Issue, IssueAutofixApplicability, IssuePatchEdit, Span}; use regex::Regex; @@ -23,7 +23,7 @@ use std::sync::OnceLock; pub struct StructureSimpleCase; -impl LintRule for StructureSimpleCase { +impl BuiltinLintRule for StructureSimpleCase { fn code(&self) -> &'static str { issue_codes::LINT_ST_002 } @@ -36,7 +36,7 @@ impl LintRule for StructureSimpleCase { "Unnecessary 'CASE' statement." } - fn check(&self, stmt: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, stmt: &Statement, ctx: &RuleContext) -> Vec { let mut issues = Vec::new(); visit::visit_expressions(stmt, &mut |expr| { @@ -237,7 +237,7 @@ fn contains_template_tags(sql: &str) -> bool { // --------------------------------------------------------------------------- fn build_autofix( - ctx: &LintContext, + ctx: &RuleContext, expr: &Expr, rewrite: &UnnecessaryCaseKind, ) -> Option<(Span, IssueAutofixApplicability, Vec)> { @@ -295,7 +295,7 @@ fn build_autofix( /// Falls back to AST Display with keyword-case normalization when the span /// does not capture the full expression text (e.g. sqlparser omits unary /// operator keywords like `NOT` from span calculations). -fn source_text_for_expr(ctx: &LintContext, expr: &Expr) -> Option { +fn source_text_for_expr(ctx: &RuleContext, expr: &Expr) -> Option { let display_text = format!("{expr}"); let Some((start, end)) = expr_statement_offsets(ctx, expr) else { @@ -394,7 +394,7 @@ fn column_identity_expr<'a>( // Span and offset utilities // --------------------------------------------------------------------------- -fn expr_statement_offsets(ctx: &LintContext, expr: &Expr) -> Option<(usize, usize)> { +fn expr_statement_offsets(ctx: &RuleContext, expr: &Expr) -> Option<(usize, usize)> { if ctx.statement_range.start > 0 { if let Some((start, end)) = expr_span_offsets(ctx.sql, expr) { if start >= ctx.statement_range.start && end <= ctx.statement_range.end { @@ -433,7 +433,7 @@ fn expr_span_offsets(sql: &str, expr: &Expr) -> Option<(usize, usize)> { (end >= start).then_some((start, end)) } -fn span_contains_comment(ctx: &LintContext, span: Span) -> bool { +fn span_contains_comment(ctx: &RuleContext, span: Span) -> bool { let from_document_tokens = ctx.with_document_tokens(|tokens| { if tokens.is_empty() { return None; @@ -547,14 +547,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } @@ -882,14 +875,8 @@ mod tests { let sql = "select\n foo,\n case\n when\n bar is null then {{ result }}\n else bar\n end as test\nfrom baz;\n"; let synthetic = parse_sql("SELECT 1").expect("parse"); let rule = StructureSimpleCase; - let issues = rule.check( - &synthetic[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&synthetic[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); assert_eq!(issues[0].code, issue_codes::LINT_ST_002); assert!( diff --git a/crates/flowscope-core/src/linter/rules/st_003.rs b/crates/flowscope-core/src/linter/rules/st_003.rs index 6d7951aa..b9b4e824 100644 --- a/crates/flowscope-core/src/linter/rules/st_003.rs +++ b/crates/flowscope-core/src/linter/rules/st_003.rs @@ -3,14 +3,14 @@ //! A CTE (WITH clause) is defined but never referenced in the query body //! or subsequent CTEs. This is likely dead code. -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Issue}; use sqlparser::ast::*; use std::collections::HashSet; pub struct UnusedCte; -impl LintRule for UnusedCte { +impl BuiltinLintRule for UnusedCte { fn code(&self) -> &'static str { issue_codes::LINT_ST_003 } @@ -23,7 +23,7 @@ impl LintRule for UnusedCte { "Query defines a CTE (common-table expression) but does not use it." } - fn check(&self, stmt: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, stmt: &Statement, ctx: &RuleContext) -> Vec { let query = match stmt { Statement::Query(q) => q, Statement::Insert(ins) => { @@ -57,7 +57,7 @@ impl LintRule for UnusedCte { /// Checks a query for unused CTEs, including nested WITH clauses inside CTE /// bodies. -fn check_query_unused_ctes(query: &Query, ctx: &LintContext, issues: &mut Vec) { +fn check_query_unused_ctes(query: &Query, ctx: &RuleContext, issues: &mut Vec) { let with = match &query.with { Some(w) => w, None => { @@ -122,7 +122,7 @@ fn check_query_unused_ctes(query: &Query, ctx: &LintContext, issues: &mut Vec) { +fn check_set_expr_for_nested_ctes(expr: &SetExpr, ctx: &RuleContext, issues: &mut Vec) { match expr { SetExpr::Select(select) => { for item in &select.from { @@ -152,7 +152,7 @@ fn check_set_expr_for_nested_ctes(expr: &SetExpr, ctx: &LintContext, issues: &mu } /// Checks a DELETE statement for CTEs inside USING and FROM subqueries. -fn check_delete_for_nested_ctes(delete: &Delete, ctx: &LintContext, issues: &mut Vec) { +fn check_delete_for_nested_ctes(delete: &Delete, ctx: &RuleContext, issues: &mut Vec) { if let Some(using) = &delete.using { for twj in using { check_relation_for_nested_ctes(&twj.relation, ctx, issues); @@ -174,7 +174,7 @@ fn check_delete_for_nested_ctes(delete: &Delete, ctx: &LintContext, issues: &mut fn check_relation_for_nested_ctes( relation: &TableFactor, - ctx: &LintContext, + ctx: &RuleContext, issues: &mut Vec, ) { if let TableFactor::Derived { subquery, .. } = relation { @@ -182,7 +182,7 @@ fn check_relation_for_nested_ctes( } } -fn check_expr_for_nested_ctes(expr: &Expr, ctx: &LintContext, issues: &mut Vec) { +fn check_expr_for_nested_ctes(expr: &Expr, ctx: &RuleContext, issues: &mut Vec) { match expr { Expr::Subquery(q) | Expr::Exists { subquery: q, .. } => { check_query_unused_ctes(q, ctx, issues); @@ -474,11 +474,11 @@ fn collect_join_constraint_refs(join_operator: &JoinOperator, refs: &mut HashSet } } -fn find_cte_name_span(name: &Ident, ctx: &LintContext) -> Option { +fn find_cte_name_span(name: &Ident, ctx: &RuleContext) -> Option { ident_span_in_statement(name, ctx) } -fn ident_span_in_statement(name: &Ident, ctx: &LintContext) -> Option { +fn ident_span_in_statement(name: &Ident, ctx: &RuleContext) -> Option { use crate::analyzer::helpers::line_col_to_offset; let start = line_col_to_offset( @@ -511,14 +511,10 @@ mod tests { fn check_sql(sql: &str) -> Vec { let stmts = parse_sql(sql).unwrap(); let rule = UnusedCte; - let ctx = LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }; + let ctx = RuleContext::new(sql, 0..sql.len(), 0); let mut issues = Vec::new(); for stmt in &stmts { - issues.extend(rule.check(stmt, &ctx)); + issues.extend(rule.check_with_context(stmt, &ctx)); } issues } diff --git a/crates/flowscope-core/src/linter/rules/st_004.rs b/crates/flowscope-core/src/linter/rules/st_004.rs index 5886d89c..a0f98cd5 100644 --- a/crates/flowscope-core/src/linter/rules/st_004.rs +++ b/crates/flowscope-core/src/linter/rules/st_004.rs @@ -3,7 +3,7 @@ //! SQLFluff ST04 parity: flag `CASE ... ELSE CASE ... END END` patterns where //! the nested ELSE-case can be flattened into the outer CASE. -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::linter::visit; use crate::types::{issue_codes, Issue, IssueAutofixApplicability, IssuePatchEdit, Span}; use sqlparser::ast::{Expr, Spanned, Statement}; @@ -11,7 +11,7 @@ use sqlparser::tokenizer::{Token, TokenWithSpan, Tokenizer, Whitespace}; pub struct FlattenableNestedCase; -impl LintRule for FlattenableNestedCase { +impl BuiltinLintRule for FlattenableNestedCase { fn code(&self) -> &'static str { issue_codes::LINT_ST_004 } @@ -24,7 +24,7 @@ impl LintRule for FlattenableNestedCase { "Nested 'CASE' statement in 'ELSE' clause could be flattened." } - fn check(&self, stmt: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, stmt: &Statement, ctx: &RuleContext) -> Vec { let mut issues = Vec::new(); visit::visit_expressions(stmt, &mut |expr| { @@ -147,7 +147,7 @@ fn is_synthetic_select_one(stmt: &Statement) -> bool { /// The transformation removes the ELSE...CASE...END wrapper and promotes the /// inner CASE's WHEN/ELSE clauses to the outer CASE, preserving comments. fn build_flatten_autofix( - ctx: &LintContext, + ctx: &RuleContext, outer_expr: &Expr, ) -> Option<(Span, Vec)> { let Expr::Case { @@ -189,7 +189,7 @@ fn build_flatten_autofix( build_flatten_edit_from_positions(ctx, sql, &positioned, &flatten_info) } -fn build_flatten_autofix_from_sql(ctx: &LintContext) -> Option<(Span, Vec)> { +fn build_flatten_autofix_from_sql(ctx: &RuleContext) -> Option<(Span, Vec)> { let sql = ctx.statement_sql(); let masked_sql = contains_template_tags(sql).then(|| mask_templated_areas(sql)); let scan_sql = masked_sql.as_deref().unwrap_or(sql); @@ -249,7 +249,7 @@ fn mask_non_newlines(segment: &str) -> String { } fn build_flatten_edit_from_positions( - ctx: &LintContext, + ctx: &RuleContext, sql: &str, positioned: &[PositionedToken], flatten_info: &FlattenPositions, @@ -619,7 +619,7 @@ struct PositionedToken { end: usize, } -fn expr_statement_offsets(ctx: &LintContext, expr: &Expr) -> Option<(usize, usize)> { +fn expr_statement_offsets(ctx: &RuleContext, expr: &Expr) -> Option<(usize, usize)> { if let Some((start, end)) = expr_span_offsets(ctx.statement_sql(), expr) { return Some((start, end)); } @@ -743,14 +743,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } diff --git a/crates/flowscope-core/src/linter/rules/st_005.rs b/crates/flowscope-core/src/linter/rules/st_005.rs index 71d3afbb..7290a7c8 100644 --- a/crates/flowscope-core/src/linter/rules/st_005.rs +++ b/crates/flowscope-core/src/linter/rules/st_005.rs @@ -3,7 +3,7 @@ //! SQLFluff ST05 parity: avoid subqueries in FROM/JOIN clauses; prefer CTEs. use crate::linter::config::LintConfig; -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::parser::parse_sql_with_dialect; use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit}; use sqlparser::ast::{Query, Select, SetExpr, Statement, TableFactor}; @@ -63,7 +63,7 @@ impl Default for StructureSubquery { } } -impl LintRule for StructureSubquery { +impl BuiltinLintRule for StructureSubquery { fn code(&self) -> &'static str { issue_codes::LINT_ST_005 } @@ -76,7 +76,7 @@ impl LintRule for StructureSubquery { "Join/From clauses should not contain subqueries. Use CTEs instead." } - fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, statement: &Statement, ctx: &RuleContext) -> Vec { let mut violations = 0usize; visit_selects_in_statement(statement, &mut |select| { @@ -1631,7 +1631,7 @@ fn collect_source_names_from_table_factor(table_factor: &TableFactor, names: &mu #[cfg(test)] mod tests { use super::*; - use crate::linter::{config::LintConfig, rule::LintContext, Linter}; + use crate::linter::{config::LintConfig, rule::RuleContext, Linter}; use crate::parse_sql; use crate::types::IssueAutofixApplicability; @@ -1639,11 +1639,7 @@ mod tests { let statements = parse_sql(sql).expect("parse sql"); let linter = Linter::new(LintConfig::default()); let stmt = &statements[0]; - let ctx = LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }; + let ctx = RuleContext::new(sql, 0..sql.len(), 0); linter.check_statement(stmt, &ctx) } @@ -1732,14 +1728,8 @@ mod tests { serde_json::json!({"forbid_subquery_in": "join"}), )]), }); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert!(issues.is_empty()); } @@ -1755,14 +1745,8 @@ mod tests { serde_json::json!({"forbid_subquery_in": "from"}), )]), }); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); let autofix = issues[0].autofix.as_ref().expect("autofix metadata"); assert_eq!(autofix.applicability, IssueAutofixApplicability::Unsafe); @@ -1782,14 +1766,8 @@ mod tests { serde_json::json!({"forbid_subquery_in": "from"}), )]), }); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert!(issues.is_empty()); } @@ -1805,14 +1783,8 @@ mod tests { serde_json::json!({"forbid_subquery_in": "both"}), )]), }); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); } @@ -1828,14 +1800,8 @@ mod tests { serde_json::json!({"forbid_subquery_in": "both"}), )]), }); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 2); } @@ -1851,12 +1817,8 @@ mod tests { serde_json::json!({"forbid_subquery_in": forbid_in}), )]), }); - let ctx = LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }; - let issues = rule.check(&statements[0], &ctx); + let ctx = RuleContext::new(sql, 0..sql.len(), 0); + let issues = rule.check_with_context(&statements[0], &ctx); if issues.is_empty() { return None; } diff --git a/crates/flowscope-core/src/linter/rules/st_006.rs b/crates/flowscope-core/src/linter/rules/st_006.rs index a3816dc2..df771adb 100644 --- a/crates/flowscope-core/src/linter/rules/st_006.rs +++ b/crates/flowscope-core/src/linter/rules/st_006.rs @@ -8,7 +8,7 @@ //! CREATE TABLE AS, and SELECTs participating in UNION/set operations (where //! column position is semantically significant). -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit, Span}; use sqlparser::ast::{ CreateView, Expr, GroupByExpr, Query, Select, SelectItem, SetExpr, Statement, TableFactor, @@ -19,7 +19,7 @@ use std::collections::{HashMap, HashSet}; pub struct StructureColumnOrder; -impl LintRule for StructureColumnOrder { +impl BuiltinLintRule for StructureColumnOrder { fn code(&self) -> &'static str { issue_codes::LINT_ST_006 } @@ -32,7 +32,7 @@ impl LintRule for StructureColumnOrder { "Select wildcards then simple targets before calculations and aggregates." } - fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, statement: &Statement, ctx: &RuleContext) -> Vec { let mut violations_info: Vec = Vec::new(); visit_order_safe_selects(statement, &mut |select| { if let Some(info) = check_select_order(select) { @@ -526,7 +526,7 @@ struct ResolvedViolation { /// Resolve each violation to a token-stream span and, when safe, autofix edits. fn st006_resolve_violations( - ctx: &LintContext, + ctx: &RuleContext, violations: &[ViolationInfo], ) -> Vec { let candidates = st006_autofix_candidates_for_context(ctx, violations); @@ -569,7 +569,7 @@ fn st006_resolve_violations( .collect() } -fn positioned_tokens_for_context(ctx: &LintContext) -> Vec { +fn positioned_tokens_for_context(ctx: &RuleContext) -> Vec { let from_document_tokens = ctx.with_document_tokens(|tokens| { if tokens.is_empty() { return None; @@ -613,7 +613,7 @@ fn positioned_tokens_for_context(ctx: &LintContext) -> Vec { } fn st006_autofix_candidates_for_context( - ctx: &LintContext, + ctx: &RuleContext, violations: &[ViolationInfo], ) -> Vec { let tokens = positioned_tokens_for_context(ctx); @@ -663,7 +663,7 @@ fn st006_autofix_candidates_for_context( /// Resolve violations to spans without autofix, using the same segment-matching /// logic as the autofix path but without requiring the reorder to succeed. -fn st006_violation_spans(ctx: &LintContext, violations: &[ViolationInfo]) -> Vec { +fn st006_violation_spans(ctx: &RuleContext, violations: &[ViolationInfo]) -> Vec { let tokens = positioned_tokens_for_context(ctx); let segments = select_projection_segments(&tokens); @@ -1133,14 +1133,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } diff --git a/crates/flowscope-core/src/linter/rules/st_007.rs b/crates/flowscope-core/src/linter/rules/st_007.rs index 95a553fe..90bdab8f 100644 --- a/crates/flowscope-core/src/linter/rules/st_007.rs +++ b/crates/flowscope-core/src/linter/rules/st_007.rs @@ -3,14 +3,14 @@ //! USING can hide which side a column originates from and may create ambiguity //! in complex joins. Prefer explicit ON conditions. -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Issue, IssueAutofixApplicability, IssuePatchEdit, Span}; use sqlparser::ast::{Spanned, *}; use sqlparser::tokenizer::{Span as SqlParserSpan, Token, TokenWithSpan, Tokenizer, Whitespace}; pub struct AvoidUsingJoin; -impl LintRule for AvoidUsingJoin { +impl BuiltinLintRule for AvoidUsingJoin { fn code(&self) -> &'static str { issue_codes::LINT_ST_007 } @@ -23,14 +23,14 @@ impl LintRule for AvoidUsingJoin { "Prefer specifying join keys instead of using 'USING'." } - fn check(&self, stmt: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, stmt: &Statement, ctx: &RuleContext) -> Vec { let mut issues = Vec::new(); check_statement(stmt, ctx, &mut issues); issues } } -fn check_statement(stmt: &Statement, ctx: &LintContext, issues: &mut Vec) { +fn check_statement(stmt: &Statement, ctx: &RuleContext, issues: &mut Vec) { match stmt { Statement::Query(q) => check_query(q, ctx, issues), Statement::Insert(ins) => { @@ -48,7 +48,7 @@ fn check_statement(stmt: &Statement, ctx: &LintContext, issues: &mut Vec) } } -fn check_query(query: &Query, ctx: &LintContext, issues: &mut Vec) { +fn check_query(query: &Query, ctx: &RuleContext, issues: &mut Vec) { if let Some(ref with) = query.with { for cte in &with.cte_tables { check_query(&cte.query, ctx, issues); @@ -57,7 +57,7 @@ fn check_query(query: &Query, ctx: &LintContext, issues: &mut Vec) { check_set_expr(&query.body, ctx, issues); } -fn check_set_expr(body: &SetExpr, ctx: &LintContext, issues: &mut Vec) { +fn check_set_expr(body: &SetExpr, ctx: &RuleContext, issues: &mut Vec) { match body { SetExpr::Select(select) => { for from_item in &select.from { @@ -95,7 +95,7 @@ fn check_set_expr(body: &SetExpr, ctx: &LintContext, issues: &mut Vec) { } } -fn check_table_factor(relation: &TableFactor, ctx: &LintContext, issues: &mut Vec) { +fn check_table_factor(relation: &TableFactor, ctx: &RuleContext, issues: &mut Vec) { match relation { TableFactor::Derived { subquery, .. } => check_query(subquery, ctx, issues), TableFactor::NestedJoin { @@ -127,7 +127,7 @@ fn check_table_factor(relation: &TableFactor, ctx: &LintContext, issues: &mut Ve } fn using_join_issue( - ctx: &LintContext, + ctx: &RuleContext, join_operator: &JoinOperator, left_ref: Option<&str>, right_ref: Option<&str>, @@ -189,7 +189,7 @@ struct PositionedToken { } fn using_join_autofix( - ctx: &LintContext, + ctx: &RuleContext, constraint: &JoinConstraint, columns: &[ObjectName], left_ref: Option<&str>, @@ -251,7 +251,7 @@ fn using_columns_to_on_expr( } fn constraint_statement_offsets( - ctx: &LintContext, + ctx: &RuleContext, constraint: &JoinConstraint, ) -> Option<(usize, usize)> { if let Some((start, end)) = sqlparser_span_offsets(ctx.statement_sql(), constraint.span()) { @@ -269,7 +269,7 @@ fn constraint_statement_offsets( } fn locate_using_clause_span( - ctx: &LintContext, + ctx: &RuleContext, constraint_start: usize, constraint_end: usize, ) -> Option { @@ -363,7 +363,7 @@ fn table_factor_reference_name(relation: &TableFactor) -> Option { } } -fn positioned_statement_tokens(ctx: &LintContext) -> Option> { +fn positioned_statement_tokens(ctx: &RuleContext) -> Option> { let from_document_tokens = ctx.with_document_tokens(|tokens| { if tokens.is_empty() { return None; @@ -401,7 +401,7 @@ fn positioned_statement_tokens(ctx: &LintContext) -> Option Some(positioned) } -fn span_contains_comment(ctx: &LintContext, span: Span) -> bool { +fn span_contains_comment(ctx: &RuleContext, span: Span) -> bool { positioned_statement_tokens(ctx).is_some_and(|tokens| { tokens.iter().any(|token| { token.start >= span.start && token.end <= span.end && is_comment_token(&token.token) @@ -503,14 +503,10 @@ mod tests { fn check_sql(sql: &str) -> Vec { let stmts = parse_sql(sql).unwrap(); let rule = AvoidUsingJoin; - let ctx = LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }; + let ctx = RuleContext::new(sql, 0..sql.len(), 0); let mut issues = Vec::new(); for stmt in &stmts { - issues.extend(rule.check(stmt, &ctx)); + issues.extend(rule.check_with_context(stmt, &ctx)); } issues } diff --git a/crates/flowscope-core/src/linter/rules/st_008.rs b/crates/flowscope-core/src/linter/rules/st_008.rs index c5519f87..a35333ea 100644 --- a/crates/flowscope-core/src/linter/rules/st_008.rs +++ b/crates/flowscope-core/src/linter/rules/st_008.rs @@ -3,7 +3,7 @@ //! SQLFluff ST08 parity: `SELECT DISTINCT()` should be rewritten to //! `SELECT DISTINCT `. -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit, Span}; use sqlparser::ast::Statement; use sqlparser::keywords::Keyword; @@ -11,7 +11,7 @@ use sqlparser::tokenizer::{Token, TokenWithSpan, Tokenizer, Whitespace}; pub struct StructureDistinct; -impl LintRule for StructureDistinct { +impl BuiltinLintRule for StructureDistinct { fn code(&self) -> &'static str { issue_codes::LINT_ST_008 } @@ -24,7 +24,7 @@ impl LintRule for StructureDistinct { "'DISTINCT' used with parentheses." } - fn check(&self, _statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, _statement: &Statement, ctx: &RuleContext) -> Vec { let candidates = st008_autofix_candidates(ctx.statement_sql(), ctx.dialect()); candidates @@ -300,14 +300,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } diff --git a/crates/flowscope-core/src/linter/rules/st_009.rs b/crates/flowscope-core/src/linter/rules/st_009.rs index cf4da5e4..3b574360 100644 --- a/crates/flowscope-core/src/linter/rules/st_009.rs +++ b/crates/flowscope-core/src/linter/rules/st_009.rs @@ -4,7 +4,7 @@ //! and prior relation on the right side (e.g. `o.user_id = u.id`). use crate::linter::config::LintConfig; -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Issue, IssueAutofixApplicability, IssuePatchEdit, Span}; use sqlparser::ast::{BinaryOperator, Expr, Spanned, Statement, TableFactor}; @@ -69,7 +69,7 @@ impl Default for StructureJoinConditionOrder { } } -impl LintRule for StructureJoinConditionOrder { +impl BuiltinLintRule for StructureJoinConditionOrder { fn code(&self) -> &'static str { issue_codes::LINT_ST_009 } @@ -82,7 +82,7 @@ impl LintRule for StructureJoinConditionOrder { "Joins should list the table referenced earlier/later first." } - fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, statement: &Statement, ctx: &RuleContext) -> Vec { let mut issues = Vec::new(); visit_selects_in_statement(statement, &mut |select| { @@ -114,7 +114,7 @@ fn check_table_factor_joins( joins: &[sqlparser::ast::Join], seen_sources: &mut Vec, preference: PreferredFirstTableInJoinClause, - ctx: &LintContext, + ctx: &RuleContext, issues: &mut Vec, ) { let issues_before = issues.len(); @@ -221,7 +221,7 @@ fn collect_following_join_autofixes( joins: &[sqlparser::ast::Join], seen_sources: &[String], preference: PreferredFirstTableInJoinClause, - ctx: &LintContext, + ctx: &RuleContext, ) -> Vec { let mut seen = seen_sources.to_vec(); let mut edits = Vec::new(); @@ -332,7 +332,7 @@ fn has_join_pair(expr: &Expr, left_source_name: &str, right_source_name: &str) - /// Produce source-text-level edits that swap individual reversed comparison /// pairs while preserving original formatting, quoting, and keyword casing. fn join_condition_autofix_for_sources( - ctx: &LintContext, + ctx: &RuleContext, on_expr: &Expr, current_source: &str, previous_sources: &[String], @@ -529,7 +529,7 @@ fn flipped_comparison_operator(op: &BinaryOperator) -> BinaryOperator { /// pair. Each edit replaces `left op right` with `right flipped_op left` /// using the original source text for both operands. fn collect_reversed_pair_edits( - ctx: &LintContext, + ctx: &RuleContext, expr: &Expr, current_source: &str, previous_source: &str, @@ -664,7 +664,7 @@ fn flip_operator_text(gap: &str, op: &BinaryOperator) -> String { } } -fn expr_statement_offsets(ctx: &LintContext, expr: &Expr) -> Option<(usize, usize)> { +fn expr_statement_offsets(ctx: &RuleContext, expr: &Expr) -> Option<(usize, usize)> { // Statement ranges may intentionally trim leading comments/whitespace. // SQLParser span line/column coordinates are often absolute to the // original document, so prefer document-level offset conversion when the @@ -775,14 +775,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } @@ -862,14 +855,8 @@ mod tests { let rule = StructureJoinConditionOrder::from_config(&config); let sql = "select foo.a, bar.b from foo left join bar on foo.a = bar.a"; let statements = parse_sql(sql).expect("parse"); - let issues = rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ); + let issues = + rule.check_with_context(&statements[0], &RuleContext::new(sql, 0..sql.len(), 0)); assert_eq!(issues.len(), 1); assert_eq!(issues[0].code, issue_codes::LINT_ST_009); } diff --git a/crates/flowscope-core/src/linter/rules/st_010.rs b/crates/flowscope-core/src/linter/rules/st_010.rs index 31a9b584..1d6d9027 100644 --- a/crates/flowscope-core/src/linter/rules/st_010.rs +++ b/crates/flowscope-core/src/linter/rules/st_010.rs @@ -2,7 +2,7 @@ //! //! Detect redundant constant expressions in predicates. -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Issue}; use sqlparser::ast::{BinaryOperator, Expr, Merge, Statement, Update}; @@ -10,7 +10,7 @@ use super::semantic_helpers::{visit_select_expressions, visit_selects_in_stateme pub struct StructureConstantExpression; -impl LintRule for StructureConstantExpression { +impl BuiltinLintRule for StructureConstantExpression { fn code(&self) -> &'static str { issue_codes::LINT_ST_010 } @@ -23,7 +23,7 @@ impl LintRule for StructureConstantExpression { "Redundant constant expression." } - fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, statement: &Statement, ctx: &RuleContext) -> Vec { let mut violation_count = statement_constant_predicate_count(statement); visit_selects_in_statement(statement, &mut |select| { @@ -265,14 +265,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } diff --git a/crates/flowscope-core/src/linter/rules/st_011.rs b/crates/flowscope-core/src/linter/rules/st_011.rs index 64e1c16c..b1165961 100644 --- a/crates/flowscope-core/src/linter/rules/st_011.rs +++ b/crates/flowscope-core/src/linter/rules/st_011.rs @@ -4,7 +4,7 @@ use std::collections::HashSet; -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Issue}; use sqlparser::ast::{ ConnectByKind, CreateView, Expr, FunctionArg, FunctionArgExpr, JoinOperator, NamedWindowExpr, @@ -19,7 +19,7 @@ use super::semantic_helpers::{ pub struct StructureUnusedJoin; -impl LintRule for StructureUnusedJoin { +impl BuiltinLintRule for StructureUnusedJoin { fn code(&self) -> &'static str { issue_codes::LINT_ST_011 } @@ -32,7 +32,7 @@ impl LintRule for StructureUnusedJoin { "Joined table not referenced in query." } - fn check(&self, statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, statement: &Statement, ctx: &RuleContext) -> Vec { let violations = unused_join_count_for_statement(statement); (0..violations) @@ -677,14 +677,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } diff --git a/crates/flowscope-core/src/linter/rules/st_012.rs b/crates/flowscope-core/src/linter/rules/st_012.rs index 306f1604..c2dcd302 100644 --- a/crates/flowscope-core/src/linter/rules/st_012.rs +++ b/crates/flowscope-core/src/linter/rules/st_012.rs @@ -3,14 +3,14 @@ //! SQLFluff ST12 parity (current scope): detect consecutive semicolons in the //! document text. -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit, Span}; use sqlparser::ast::Statement; use sqlparser::tokenizer::{Token, TokenWithSpan, Tokenizer, Whitespace}; pub struct StructureConsecutiveSemicolons; -impl LintRule for StructureConsecutiveSemicolons { +impl BuiltinLintRule for StructureConsecutiveSemicolons { fn code(&self) -> &'static str { issue_codes::LINT_ST_012 } @@ -23,7 +23,7 @@ impl LintRule for StructureConsecutiveSemicolons { "Consecutive semicolons detected." } - fn check(&self, _statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, _statement: &Statement, ctx: &RuleContext) -> Vec { if ctx.statement_index > 0 { Vec::new() } else { @@ -130,7 +130,7 @@ fn tokenize_with_offsets(sql: &str, dialect: Dialect) -> Option Option> { +fn tokenize_with_offsets_for_context(ctx: &RuleContext) -> Option> { let tokens = ctx.with_document_tokens(|tokens| { if tokens.is_empty() { return None; @@ -211,7 +211,6 @@ fn token_with_span_offsets(sql: &str, token: &TokenWithSpan) -> Option<(usize, u #[cfg(test)] mod tests { use super::*; - use crate::linter::rule::with_active_dialect; use crate::parser::{parse_sql, parse_sql_with_dialect}; use crate::types::{Dialect, IssueAutofixApplicability}; @@ -222,14 +221,7 @@ mod tests { .iter() .enumerate() .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) + rule.check_with_context(statement, &RuleContext::new(sql, 0..sql.len(), index)) }) .collect() } @@ -239,18 +231,12 @@ mod tests { let rule = StructureConsecutiveSemicolons; let mut issues = Vec::new(); - with_active_dialect(dialect, || { - for (index, statement) in statements.iter().enumerate() { - issues.extend(rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - )); - } - }); + for (index, statement) in statements.iter().enumerate() { + issues.extend(rule.check_with_context( + statement, + &RuleContext::new(sql, 0..sql.len(), index).with_dialect(dialect), + )); + } issues } diff --git a/crates/flowscope-core/src/linter/rules/tq_001.rs b/crates/flowscope-core/src/linter/rules/tq_001.rs index 19e03cb0..282a2db5 100644 --- a/crates/flowscope-core/src/linter/rules/tq_001.rs +++ b/crates/flowscope-core/src/linter/rules/tq_001.rs @@ -3,13 +3,13 @@ //! SQLFluff TQ01 parity (current scope): avoid stored procedure names starting //! with `sp_`. -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Dialect, Issue}; use sqlparser::ast::Statement; pub struct TsqlSpPrefix; -impl LintRule for TsqlSpPrefix { +impl BuiltinLintRule for TsqlSpPrefix { fn code(&self) -> &'static str { issue_codes::LINT_TQ_001 } @@ -22,7 +22,7 @@ impl LintRule for TsqlSpPrefix { "'SP_' prefix should not be used for user-defined stored procedures in T-SQL." } - fn check(&self, _statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, _statement: &Statement, ctx: &RuleContext) -> Vec { if ctx.dialect() != Dialect::Mssql { return Vec::new(); } @@ -239,44 +239,31 @@ fn match_ascii_keyword_at(bytes: &[u8], start: usize, keyword_upper: &[u8]) -> O #[cfg(test)] mod tests { use super::*; - use crate::linter::rule::with_active_dialect; use crate::parser::parse_sql; use crate::types::Dialect; fn run(sql: &str) -> Vec { let statements = parse_sql(sql).expect("parse"); let rule = TsqlSpPrefix; - with_active_dialect(Dialect::Mssql, || { - statements - .iter() - .enumerate() - .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) - }) - .collect() - }) + statements + .iter() + .enumerate() + .flat_map(|(index, statement)| { + rule.check_with_context( + statement, + &RuleContext::new(sql, 0..sql.len(), index).with_dialect(Dialect::Mssql), + ) + }) + .collect() } fn run_statementless(sql: &str) -> Vec { let placeholder = parse_sql("SELECT 1").expect("parse"); let rule = TsqlSpPrefix; - with_active_dialect(Dialect::Mssql, || { - rule.check( - &placeholder[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ) - }) + rule.check_with_context( + &placeholder[0], + &RuleContext::new(sql, 0..sql.len(), 0).with_dialect(Dialect::Mssql), + ) } #[test] diff --git a/crates/flowscope-core/src/linter/rules/tq_002.rs b/crates/flowscope-core/src/linter/rules/tq_002.rs index bdceda2f..8f1bc953 100644 --- a/crates/flowscope-core/src/linter/rules/tq_002.rs +++ b/crates/flowscope-core/src/linter/rules/tq_002.rs @@ -3,13 +3,13 @@ //! SQLFluff TQ02 parity: procedures with multiple statements should include a //! `BEGIN`/`END` block. -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit, Span}; use sqlparser::ast::Statement; pub struct TsqlProcedureBeginEnd; -impl LintRule for TsqlProcedureBeginEnd { +impl BuiltinLintRule for TsqlProcedureBeginEnd { fn code(&self) -> &'static str { issue_codes::LINT_TQ_002 } @@ -22,7 +22,7 @@ impl LintRule for TsqlProcedureBeginEnd { "Procedure bodies with multiple statements should be wrapped in BEGIN/END." } - fn check(&self, _statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, _statement: &Statement, ctx: &RuleContext) -> Vec { if ctx.dialect() != Dialect::Mssql { return Vec::new(); } @@ -395,7 +395,6 @@ fn skip_ascii_whitespace(bytes: &[u8], mut index: usize) -> usize { #[cfg(test)] mod tests { use super::*; - use crate::linter::rule::with_active_dialect; use crate::parser::{parse_sql, parse_sql_with_dialect}; use crate::types::IssueAutofixApplicability; use crate::Dialect; @@ -403,37 +402,25 @@ mod tests { fn run(sql: &str) -> Vec { let statements = parse_sql_with_dialect(sql, Dialect::Mssql).expect("parse"); let rule = TsqlProcedureBeginEnd; - with_active_dialect(Dialect::Mssql, || { - statements - .iter() - .enumerate() - .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) - }) - .collect() - }) + statements + .iter() + .enumerate() + .flat_map(|(index, statement)| { + rule.check_with_context( + statement, + &RuleContext::new(sql, 0..sql.len(), index).with_dialect(Dialect::Mssql), + ) + }) + .collect() } fn run_statementless(sql: &str) -> Vec { let synthetic = parse_sql("SELECT 1").expect("parse synthetic statement"); let rule = TsqlProcedureBeginEnd; - with_active_dialect(Dialect::Mssql, || { - rule.check( - &synthetic[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ) - }) + rule.check_with_context( + &synthetic[0], + &RuleContext::new(sql, 0..sql.len(), 0).with_dialect(Dialect::Mssql), + ) } fn apply_issue_autofix(sql: &str, issue: &Issue) -> Option { diff --git a/crates/flowscope-core/src/linter/rules/tq_003.rs b/crates/flowscope-core/src/linter/rules/tq_003.rs index e783ecb1..e84ee8f7 100644 --- a/crates/flowscope-core/src/linter/rules/tq_003.rs +++ b/crates/flowscope-core/src/linter/rules/tq_003.rs @@ -3,7 +3,7 @@ //! SQLFluff TQ03 parity (current scope): detect empty batches between repeated //! `GO` separators. -use crate::linter::rule::{LintContext, LintRule}; +use crate::linter::rule::{BuiltinLintRule, RuleContext}; use crate::types::{issue_codes, Dialect, Issue, IssueAutofixApplicability, IssuePatchEdit}; use sqlparser::ast::Statement; use sqlparser::tokenizer::{Token, TokenWithSpan, Tokenizer, Whitespace}; @@ -11,7 +11,7 @@ use std::collections::{BTreeMap, BTreeSet}; pub struct TsqlEmptyBatch; -impl LintRule for TsqlEmptyBatch { +impl BuiltinLintRule for TsqlEmptyBatch { fn code(&self) -> &'static str { issue_codes::LINT_TQ_003 } @@ -24,7 +24,7 @@ impl LintRule for TsqlEmptyBatch { "Remove empty batches." } - fn check(&self, _statement: &Statement, ctx: &LintContext) -> Vec { + fn check_with_context(&self, _statement: &Statement, ctx: &RuleContext) -> Vec { if ctx.dialect() != Dialect::Mssql { return Vec::new(); } @@ -258,44 +258,31 @@ fn match_ascii_keyword_at(bytes: &[u8], start: usize, keyword_upper: &[u8]) -> O #[cfg(test)] mod tests { use super::*; - use crate::linter::rule::with_active_dialect; use crate::parser::parse_sql; use crate::types::IssueAutofixApplicability; fn run(sql: &str) -> Vec { let statements = parse_sql(sql).expect("parse"); let rule = TsqlEmptyBatch; - with_active_dialect(Dialect::Mssql, || { - statements - .iter() - .enumerate() - .flat_map(|(index, statement)| { - rule.check( - statement, - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: index, - }, - ) - }) - .collect() - }) + statements + .iter() + .enumerate() + .flat_map(|(index, statement)| { + rule.check_with_context( + statement, + &RuleContext::new(sql, 0..sql.len(), index).with_dialect(Dialect::Mssql), + ) + }) + .collect() } fn run_for_statement_sql(sql: &str) -> Vec { let statements = parse_sql("SELECT 1").expect("parse placeholder statement"); let rule = TsqlEmptyBatch; - with_active_dialect(Dialect::Mssql, || { - rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ) - }) + rule.check_with_context( + &statements[0], + &RuleContext::new(sql, 0..sql.len(), 0).with_dialect(Dialect::Mssql), + ) } fn apply_issue_autofix(sql: &str, issue: &Issue) -> Option { @@ -381,16 +368,10 @@ mod tests { let statements = parse_sql("SELECT 1").expect("parse placeholder statement"); let rule = TsqlEmptyBatch; let sql = "SELECT 1\nGO\nGO\n"; - let issues = with_active_dialect(Dialect::Postgres, || { - rule.check( - &statements[0], - &LintContext { - sql, - statement_range: 0..sql.len(), - statement_index: 0, - }, - ) - }); + let issues = rule.check_with_context( + &statements[0], + &RuleContext::new(sql, 0..sql.len(), 0).with_dialect(Dialect::Postgres), + ); assert!(issues.is_empty()); } }