use std::borrow::Cow;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock};
use crate::Formatter;
use crate::core::config::FluffConfig;
use crate::core::linter::common::{BatchRenderedResult, ParsedString, RenderedFile};
use crate::core::linter::linted_file::LintedFile;
use crate::core::linter::linting_result::LintingResult;
use crate::core::rules::noqa::IgnoreMask;
use crate::core::rules::{ErasedRule, Exception, LintPhase, RulePack};
use crate::rules::get_ruleset;
use crate::templaters::{ProcessingMode, Templater, TemplaterKind};
use hashbrown::{HashMap, HashSet};
use itertools::Itertools;
use rayon::iter::{IntoParallelRefIterator as _, ParallelIterator as _};
use smol_str::{SmolStr, ToSmolStr};
use sqruff_lib_core::dialects::Dialect;
use sqruff_lib_core::dialects::syntax::{SyntaxKind, SyntaxSet};
use sqruff_lib_core::errors::{
SQLBaseError, SQLFluffUserError, SQLLexError, SQLLintError, SQLParseError, SQLTemplaterError,
};
use sqruff_lib_core::helpers;
use sqruff_lib_core::linter::compute_anchor_edit_info;
use sqruff_lib_core::parser::Parser;
use sqruff_lib_core::parser::segments::{ErasedSegment, Tables};
use sqruff_lib_core::templaters::TemplatedFile;
use walkdir::WalkDir;
pub struct Linter {
config: FluffConfig,
formatter: Option<Arc<dyn Formatter>>,
templater: &'static dyn Templater,
rules: OnceLock<Vec<ErasedRule>>,
include_parse_errors: bool,
}
impl Linter {
pub fn new(
config: FluffConfig,
formatter: Option<Arc<dyn Formatter>>,
templater: Option<&'static dyn Templater>,
include_parse_errors: bool,
) -> Result<Linter, String> {
let templater: &'static dyn Templater = match templater {
Some(templater) => templater,
None => Linter::get_templater(&config)?,
};
Ok(Linter {
config,
formatter,
templater,
rules: OnceLock::new(),
include_parse_errors,
})
}
pub fn get_templater(config: &FluffConfig) -> Result<&'static dyn Templater, String> {
config.templater_kind().map(TemplaterKind::templater)
}
pub fn lint_string_wrapped(
&mut self,
sql: &str,
fix: bool,
) -> Result<LintedFile, SQLFluffUserError> {
let filename = "<string input>".to_owned();
self.lint_string(sql, Some(filename), fix)
}
pub fn parse_string(
&self,
tables: &Tables,
sql: &str,
filename: Option<String>,
) -> Result<ParsedString, SQLFluffUserError> {
let f_name = filename.unwrap_or_else(|| "<string>".to_string());
self.config.process_raw_file_for_config(sql);
let rendered = self.render_string(sql, f_name.clone(), &self.config)?;
Ok(self.parse_rendered(tables, rendered))
}
pub fn lint_string(
&self,
sql: &str,
filename: Option<String>,
fix: bool,
) -> Result<LintedFile, SQLFluffUserError> {
let tables = Tables::default();
let parsed = self.parse_string(&tables, sql, filename)?;
self.lint_parsed(&tables, parsed, fix)
}
pub fn lint_paths(
&mut self,
mut paths: Vec<PathBuf>,
fix: bool,
ignorer: &(dyn Fn(&Path) -> bool + Send + Sync),
) -> Result<LintingResult, SQLFluffUserError> {
if paths.is_empty() {
paths.push(std::env::current_dir().unwrap());
}
let mut expanded_paths = Vec::new();
for path in paths {
if path.is_file() {
expanded_paths.push(path.to_string_lossy().to_string());
} else {
expanded_paths.extend(self.paths_from_path(
path,
None,
None,
None,
None,
Some(ignorer),
));
};
}
let paths: Vec<String> = expanded_paths
.into_iter()
.filter(|path| {
let should_ignore = ignorer(Path::new(path));
if should_ignore {
log::debug!(
"Filtering out ignored file '{}' from final processing list",
path
);
}
!should_ignore
})
.collect_vec();
let mut files = Vec::with_capacity(paths.len());
match self.templater.processing_mode() {
ProcessingMode::Parallel => {
let results: Vec<_> = paths
.par_iter()
.map(|path| {
let rendered = self.render_file(path.clone());
self.lint_rendered(rendered, fix)
})
.collect();
for result in results {
files.push(result?);
}
}
ProcessingMode::Batch => {
let batch_results = self.render_files_batch(&paths);
for result in batch_results {
match result {
BatchRenderedResult::Rendered(rendered) => {
files.push(self.lint_rendered(rendered, fix)?);
}
BatchRenderedResult::Skipped { filename, reason } => {
if let Some(formatter) = &self.formatter {
formatter.dispatch_file_skip(&filename, &reason);
}
}
}
}
}
ProcessingMode::Sequential => {
for path in &paths {
let rendered = self.render_file(path.clone());
files.push(self.lint_rendered(rendered, fix)?);
}
}
}
Ok(LintingResult::new(files))
}
pub fn get_rulepack(&self) -> Result<RulePack, SQLFluffUserError> {
let rs = get_ruleset();
rs.get_rulepack(&self.config)
}
pub fn render_file(&self, fname: String) -> RenderedFile {
let in_str = std::fs::read_to_string(&fname).unwrap();
match self.render_string(&in_str, fname.clone(), &self.config) {
Ok(rendered) => rendered,
Err(err) => {
log::error!("Failed to template file {}: {:?}", fname, err);
let source_str = Self::normalise_newlines(&in_str).to_string();
RenderedFile {
templated_file: TemplatedFile::new(
source_str.clone(),
fname.clone(),
None,
None,
None,
)
.expect("Creating raw TemplatedFile should not fail"),
templater_violations: vec![SQLTemplaterError::new(format!(
"Failed to template file {fname}: {err}"
))],
filename: fname,
source_str,
}
}
}
}
pub fn render_files_batch(&self, fnames: &[String]) -> Vec<BatchRenderedResult> {
if fnames.is_empty() {
return Vec::new();
}
if let Some(_error) = self.config.verify_dialect_specified() {
return fnames
.iter()
.map(|fname| {
let source_str = std::fs::read_to_string(fname).unwrap_or_default();
BatchRenderedResult::Rendered(RenderedFile {
templated_file: TemplatedFile::new(
source_str.clone(),
fname.clone(),
None,
None,
None,
)
.expect("Creating raw TemplatedFile should not fail"),
templater_violations: vec![],
filename: fname.clone(),
source_str,
})
})
.collect();
}
let files: Vec<(String, String)> = fnames
.iter()
.map(|fname| {
let content = std::fs::read_to_string(fname).unwrap_or_default();
let normalized = Self::normalise_newlines(&content).to_string();
(normalized, fname.clone())
})
.collect();
let file_refs: Vec<(&str, &str)> = files
.iter()
.map(|(content, fname)| (content.as_str(), fname.as_str()))
.collect();
let results = self
.templater
.process(&file_refs, &self.config, &self.formatter);
results
.into_iter()
.zip(files.iter())
.map(|(result, (source_str, fname))| match result {
Ok(templated_file) => BatchRenderedResult::Rendered(RenderedFile {
templated_file,
templater_violations: vec![],
filename: fname.clone(),
source_str: source_str.clone(),
}),
Err(err) => {
let err_str = err.to_string();
if let Some(reason) = err_str.strip_prefix("SKIP:") {
return BatchRenderedResult::Skipped {
filename: fname.clone(),
reason: reason.to_string(),
};
}
log::error!("Failed to template file {}: {:?}", fname, err);
BatchRenderedResult::Rendered(RenderedFile {
templated_file: TemplatedFile::new(
source_str.clone(),
fname.clone(),
None,
None,
None,
)
.expect("Creating raw TemplatedFile should not fail"),
templater_violations: vec![SQLTemplaterError::new(format!(
"Failed to template file {fname}: {err}"
))],
filename: fname.clone(),
source_str: source_str.clone(),
})
}
})
.collect()
}
pub fn lint_rendered(
&self,
rendered: RenderedFile,
fix: bool,
) -> Result<LintedFile, SQLFluffUserError> {
let tables = Tables::default();
let parsed = self.parse_rendered(&tables, rendered);
self.lint_parsed(&tables, parsed, fix)
}
pub fn lint_parsed(
&self,
tables: &Tables,
parsed_string: ParsedString,
fix: bool,
) -> Result<LintedFile, SQLFluffUserError> {
let mut violations = parsed_string.violations;
let (patches, ignore_mask, initial_linting_errors) = match parsed_string.tree {
Some(erased_segment) => {
let (tree, ignore_mask, initial_linting_errors) = self.lint_fix_parsed(
tables,
erased_segment,
&parsed_string.templated_file,
fix,
)?;
let patches = tree.iter_patches(&parsed_string.templated_file);
(patches, ignore_mask, initial_linting_errors)
}
None => (Vec::new(), None, Vec::new()),
};
violations.extend(initial_linting_errors.into_iter().map_into());
if let Some(ignore_mask) = &ignore_mask {
violations.retain(|violation| !ignore_mask.is_masked(violation, None));
}
let linted_file = LintedFile::new(
parsed_string.filename,
patches,
parsed_string.templated_file,
violations,
ignore_mask,
);
if let Some(formatter) = &self.formatter {
formatter.dispatch_file_violations(&linted_file);
}
Ok(linted_file)
}
pub fn lint_fix_parsed(
&self,
tables: &Tables,
mut tree: ErasedSegment,
templated_file: &TemplatedFile,
fix: bool,
) -> Result<(ErasedSegment, Option<IgnoreMask>, Vec<SQLLintError>), SQLFluffUserError> {
let mut initial_violations = Vec::new();
let phases: &[_] = if fix {
&[LintPhase::Main, LintPhase::Post]
} else {
&[LintPhase::Main]
};
let mut previous_versions: HashSet<(SmolStr, bool)> =
[(tree.raw().to_smolstr(), false)].into_iter().collect();
let loop_limit = if fix { 10 } else { 1 };
let (ignore_mask, violations): (Option<IgnoreMask>, Vec<SQLBaseError>) = {
let disable_noqa = self
.config
.get("disable_noqa", "core")
.as_bool()
.unwrap_or(false);
if disable_noqa {
(None, Vec::new())
} else {
let (ignore_mask, errors) = IgnoreMask::from_tree(&tree);
(Some(ignore_mask), errors)
}
};
initial_violations.extend(violations.into_iter().map_into());
let ignore_templated_areas = self
.config
.get("ignore_templated_areas", "core")
.as_bool()
.unwrap_or(true);
let mut anchor_info = HashMap::default();
for phase in phases {
let loop_limit = if *phase == LintPhase::Main {
loop_limit
} else {
2
};
let rules = self.rules()?;
let filtered_rules;
let mut rules_this_phase: &[ErasedRule] = if phases.len() > 1 {
filtered_rules = rules
.iter()
.filter(|rule| rule.lint_phase() == *phase)
.cloned()
.collect_vec();
&filtered_rules
} else {
rules
};
for loop_ in 0..loop_limit {
let is_first_linter_pass = *phase == phases[0] && loop_ == 0;
let mut changed = false;
if is_first_linter_pass {
rules_this_phase = self.rules()?;
}
for rule in rules_this_phase {
anchor_info.clear();
if fix && !is_first_linter_pass && !rule.is_fix_compatible() {
continue;
}
let result = crate::core::rules::crawl(
rule,
tables,
&self.config.dialect,
templated_file,
tree.clone(),
&self.config,
&mut |mut result| {
let suppress_templated_violation = ignore_templated_areas
&& !rule.targets_templated()
&& result.anchor_in_templated_section();
if ignore_mask.as_ref().is_none_or(|ignore_mask| {
!ignore_mask.is_masked(&result, rule.into())
}) {
if !suppress_templated_violation
|| (fix && !result.fixes.is_empty())
{
compute_anchor_edit_info(
&mut anchor_info,
std::mem::take(&mut result.fixes),
);
}
if is_first_linter_pass && !suppress_templated_violation {
initial_violations.extend(result.to_linting_error(rule));
}
}
},
);
if let Err(Exception) = result {
if is_first_linter_pass {
initial_violations.push(
SQLLintError::new(
"Unexpected exception. Could you open an issue at https://github.com/quarylabs/sqruff",
tree.clone(),
false,
),
);
}
continue;
}
if fix && !anchor_info.is_empty() {
let (new_tree, _, _) = tree.apply_fixes(&mut anchor_info);
let has_source_fixes = !new_tree.get_all_source_fixes().is_empty();
let loop_check_tuple = (new_tree.raw().to_smolstr(), has_source_fixes);
if previous_versions.insert(loop_check_tuple) {
tree = new_tree;
changed = true;
continue;
}
}
}
if fix && !changed {
break;
}
}
}
Ok((tree, ignore_mask, initial_violations))
}
pub fn render_string(
&self,
sql: &str,
filename: String,
config: &FluffConfig,
) -> Result<RenderedFile, SQLFluffUserError> {
let sql = Self::normalise_newlines(sql);
if let Some(error) = config.verify_dialect_specified() {
return Err(error);
}
let templater_violations = vec![];
let mut results = self.templater.process(
&[(sql.as_ref(), filename.as_str())],
config,
&self.formatter,
);
match results.pop() {
Some(Ok(templated_file)) => Ok(RenderedFile {
templated_file,
templater_violations,
filename,
source_str: sql.to_string(),
}),
Some(Err(err)) => Err(SQLFluffUserError::new(format!(
"Failed to template file {filename} with error {err:?}"
))),
None => Err(SQLFluffUserError::new(format!(
"Templater returned no results for file {filename}"
))),
}
}
pub fn parse_rendered(&self, tables: &Tables, rendered: RenderedFile) -> ParsedString {
let templater_violations = rendered.templater_violations.clone();
if !templater_violations.is_empty() {
let violations: Vec<SQLBaseError> = templater_violations
.into_iter()
.map(SQLBaseError::from)
.collect();
return ParsedString {
tree: None,
violations,
templated_file: rendered.templated_file,
filename: rendered.filename,
source_str: rendered.source_str,
};
}
let mut violations = Vec::new();
let tokens = if rendered.templated_file.is_templated() {
let (t, lvs) = Self::lex_templated_file(
tables,
rendered.templated_file.clone(),
&self.config.dialect,
);
if !lvs.is_empty() {
unimplemented!("violations.extend(lvs);")
}
t
} else {
None
};
let parsed: Option<ErasedSegment>;
if let Some(token_list) = tokens {
let (p, pvs) =
Self::parse_tokens(tables, &token_list, &self.config, self.include_parse_errors);
parsed = p;
violations.extend(pvs.into_iter().map_into());
} else {
parsed = None;
};
ParsedString {
tree: parsed,
violations,
templated_file: rendered.templated_file,
filename: rendered.filename,
source_str: rendered.source_str,
}
}
fn parse_tokens(
tables: &Tables,
tokens: &[ErasedSegment],
config: &FluffConfig,
include_parse_errors: bool,
) -> (Option<ErasedSegment>, Vec<SQLParseError>) {
let parser: Parser = config.into();
let mut violations: Vec<SQLParseError> = Vec::new();
let parsed = match parser.parse(tables, tokens) {
Ok(parsed) => parsed,
Err(error) => {
violations.push(error);
None
}
};
if include_parse_errors && let Some(parsed) = &parsed {
let unparsables = parsed.recursive_crawl(
&SyntaxSet::single(SyntaxKind::Unparsable),
true,
&SyntaxSet::EMPTY,
true,
);
violations.extend(unparsables.into_iter().map(|segment| SQLParseError {
description: "Unparsable section".into(),
segment: segment.into(),
}));
};
(parsed, violations)
}
pub fn lex_templated_file(
tables: &Tables,
templated_file: TemplatedFile,
dialect: &Dialect,
) -> (Option<Vec<ErasedSegment>>, Vec<SQLLexError>) {
let mut violations: Vec<SQLLexError> = vec![];
log::debug!("LEXING RAW ({})", templated_file.name());
let lexer = dialect.lexer();
let (tokens, lex_vs) = lexer.lex(tables, templated_file);
violations.extend(lex_vs);
if tokens.is_empty() {
return (None, violations);
}
(tokens.into(), violations)
}
fn normalise_newlines(string: &str) -> Cow<'_, str> {
lazy_regex::regex!("\r\n|\r").replace_all(string, "\n")
}
fn paths_from_path(
&self,
path: PathBuf,
ignore_file_name: Option<String>,
ignore_non_existent_files: Option<bool>,
ignore_files: Option<bool>,
working_path: Option<String>,
ignorer: Option<&(dyn Fn(&Path) -> bool + Send + Sync)>,
) -> Vec<String> {
let ignore_file_name = ignore_file_name.unwrap_or_else(|| String::from(".sqlfluffignore"));
let ignore_non_existent_files = ignore_non_existent_files.unwrap_or(false);
let ignore_files = ignore_files.unwrap_or(true);
let _working_path =
working_path.unwrap_or_else(|| std::env::current_dir().unwrap().display().to_string());
let Ok(metadata) = std::fs::metadata(&path) else {
if ignore_non_existent_files {
return Vec::new();
} else {
panic!("Specified path does not exist. Check it/they exist(s): {path:?}");
}
};
let is_exact_file = metadata.is_file();
let mut path_walk = if is_exact_file {
let path = Path::new(&path);
let dirpath = path.parent().unwrap().to_str().unwrap().to_string();
let files = vec![path.file_name().unwrap().to_str().unwrap().to_string()];
vec![(dirpath, None, files)]
} else {
let walkdir = WalkDir::new(&path);
let entries: Vec<_> = if let Some(ignorer) = ignorer {
walkdir
.into_iter()
.filter_entry(|entry| {
let should_ignore = ignorer(entry.path());
if should_ignore {
let path_type = if entry.file_type().is_dir() {
"directory"
} else {
"file"
};
log::debug!(
"Skipping {} '{}' during file discovery traversal",
path_type,
entry.path().display()
);
}
!should_ignore
})
.filter_map(Result::ok)
.collect()
} else {
walkdir.into_iter().filter_map(Result::ok).collect()
};
let mut dir_files: HashMap<String, Vec<String>> = HashMap::new();
for entry in entries {
if entry.file_type().is_file() {
let dirpath = entry.path().parent().unwrap().to_str().unwrap().to_string();
let filename = entry.file_name().to_str().unwrap().to_string();
dir_files.entry(dirpath).or_default().push(filename);
}
}
dir_files
.into_iter()
.map(|(dirpath, files)| (dirpath, None, files))
.collect_vec()
};
let ignore_file_paths: Vec<String> = Vec::new();
let path_walk_ignore_file: Vec<(String, Option<()>, Vec<String>)> = ignore_file_paths
.iter()
.map(|ignore_file_path| {
let ignore_file_path = Path::new(ignore_file_path);
let dir_name = ignore_file_path
.parent()
.unwrap()
.to_str()
.unwrap()
.to_string();
let file_name = vec![
ignore_file_path
.file_name()
.unwrap()
.to_str()
.unwrap()
.to_string(),
];
(dir_name, None, file_name)
})
.collect();
path_walk.extend(path_walk_ignore_file);
let mut buffer = Vec::new();
let mut ignores = HashMap::new();
let sql_file_exts = self.config.sql_file_exts();
for (dirpath, _, filenames) in path_walk {
for fname in filenames {
let fpath = Path::new(&dirpath).join(&fname);
if ignore_files && fname == ignore_file_name {
let file = File::open(&fpath).unwrap();
let lines = BufReader::new(file).lines();
let spec = lines.map_while(Result::ok); ignores.insert(dirpath.clone(), spec.collect::<Vec<String>>());
continue;
}
for ext in sql_file_exts {
if fname.to_lowercase().ends_with(ext) {
buffer.push(fpath.clone());
}
}
}
}
let mut filtered_buffer = HashSet::new();
for fpath in buffer {
let npath = helpers::normalize(&fpath).to_str().unwrap().to_string();
filtered_buffer.insert(npath);
}
let mut files = filtered_buffer.into_iter().collect_vec();
files.sort();
files
}
pub fn config(&self) -> &FluffConfig {
&self.config
}
pub fn config_mut(&mut self) -> &mut FluffConfig {
self.rules = OnceLock::new();
&mut self.config
}
pub fn rules(&self) -> Result<&[ErasedRule], SQLFluffUserError> {
if let Some(rules) = self.rules.get() {
return Ok(rules);
}
let rulepack = self.get_rulepack()?;
let _ = self.rules.set(rulepack.rules);
Ok(self.rules.get().unwrap())
}
pub fn formatter(&self) -> Option<&Arc<dyn Formatter>> {
self.formatter.as_ref()
}
pub fn formatter_mut(&mut self) -> Option<&mut Arc<dyn Formatter>> {
self.formatter.as_mut()
}
}
#[cfg(test)]
mod tests {
use sqruff_lib_core::parser::segments::Tables;
use crate::core::config::FluffConfig;
use crate::core::linter::core::Linter;
fn postgres_all_rules_linter() -> Linter {
let config = FluffConfig::from_source(
r#"
[sqruff]
dialect = postgres
rules = all
"#,
None,
);
Linter::new(config, None, None, true).unwrap()
}
fn normalise_paths(paths: Vec<String>) -> Vec<String> {
paths
.into_iter()
.map(|path| path.replace(['/', '\\'], "."))
.collect()
}
#[test]
fn test_linter_path_from_paths_dir() {
let lntr = Linter::new(
FluffConfig::new(<_>::default(), None, None),
None,
None,
false,
)
.unwrap();
let paths =
lntr.paths_from_path("test/fixtures/lexer".into(), None, None, None, None, None);
let expected = vec![
"test.fixtures.lexer.basic.sql",
"test.fixtures.lexer.block_comment.sql",
"test.fixtures.lexer.inline_comment.sql",
];
assert_eq!(normalise_paths(paths), expected);
}
#[test]
fn test_linter_path_from_paths_default() {
let lntr = Linter::new(
FluffConfig::new(<_>::default(), None, None),
None,
None,
false,
)
.unwrap();
let paths = normalise_paths(lntr.paths_from_path(
"test/fixtures/linter".into(),
None,
None,
None,
None,
None,
));
assert!(paths.contains(&"test.fixtures.linter.passing.sql".to_string()));
assert!(paths.contains(&"test.fixtures.linter.passing_cap_extension.SQL".to_string()));
assert!(!paths.contains(&"test.fixtures.linter.discovery_file.txt".to_string()));
}
#[test]
fn test_linter_path_from_paths_exts() {
let config =
FluffConfig::new(<_>::default(), None, None).with_sql_file_exts(vec![".txt".into()]);
let lntr = Linter::new(config, None, None, false).unwrap();
let paths =
lntr.paths_from_path("test/fixtures/linter".into(), None, None, None, None, None);
let normalized_paths = normalise_paths(paths);
assert!(!normalized_paths.contains(&"test.fixtures.linter.passing.sql".into()));
assert!(
!normalized_paths.contains(&"test.fixtures.linter.passing_cap_extension.SQL".into())
);
assert!(normalized_paths.contains(&"test.fixtures.linter.discovery_file.txt".into()));
}
#[test]
fn test_linter_path_from_paths_file() {
let lntr = Linter::new(
FluffConfig::new(<_>::default(), None, None),
None,
None,
false,
)
.unwrap();
let paths = lntr.paths_from_path(
"test/fixtures/linter/indentation_errors.sql".into(),
None,
None,
None,
None,
None,
);
assert_eq!(
normalise_paths(paths),
&["test.fixtures.linter.indentation_errors.sql"]
);
}
#[test]
fn test_linter_empty_file() {
let linter = Linter::new(
FluffConfig::new(<_>::default(), None, None),
None,
None,
false,
)
.unwrap();
let tables = Tables::default();
let parsed = linter.parse_string(&tables, "", None).unwrap();
assert!(parsed.violations.is_empty());
}
#[test]
#[ignore = "The implementation of Lexer::lex_templated_file is required"]
fn test_advanced_api_methods() {
let sql = "
WITH cte AS (
SELECT * FROM tab_a
)
SELECT
cte.col_a,
tab_b.col_b
FROM cte
INNER JOIN tab_b;
"
.to_string();
let linter = Linter::new(
FluffConfig::new(<_>::default(), None, None),
None,
None,
false,
)
.unwrap();
let tables = Tables::default();
let _parsed = linter.parse_string(&tables, &sql, None).unwrap();
}
#[test]
fn test_normalise_newlines() {
let in_str = "SELECT\r\n foo\n FROM \r \n\r bar;";
let out_str = "SELECT\n foo\n FROM \n \n\n bar;";
assert_eq!(Linter::normalise_newlines(in_str), out_str);
}
#[test]
fn test_templater_error_skips_linting() {
use crate::core::linter::common::RenderedFile;
use sqruff_lib_core::errors::SQLTemplaterError;
use sqruff_lib_core::templaters::TemplatedFile;
let source =
"SELECT *\nFROM {{ ref('stg_users') }}\nWHERE created_at > '{{ var(\"start_date\") }}'";
let linter = Linter::new(
FluffConfig::new(<_>::default(), None, None),
None,
None,
false,
)
.unwrap();
let rendered = RenderedFile {
templated_file: TemplatedFile::new(
source.to_string(),
"test.sql".to_string(),
None,
None,
None,
)
.unwrap(),
templater_violations: vec![SQLTemplaterError::new(
"Failed to template file: dbt project not found".to_string(),
)],
filename: "test.sql".to_string(),
source_str: source.to_string(),
};
let result = linter.lint_rendered(rendered, false).unwrap();
let violations = result.violations();
assert_eq!(violations.len(), 1);
assert!(violations[0].desc().contains("Failed to template file"));
assert!(
!violations.iter().any(|v| v.rule_code() == "LT01"),
"Should not have LT01 false positives on template syntax"
);
}
#[test]
fn test_postgres_case_else_concat_does_not_raise_lt01_and_fixes_cleanly() {
let sql = r#"select case
when a = 1 then 'one'
when a = 2 then 'two'
else 'other' || 's'
end as b
from test;
"#;
let expected = r#"select
case
when a = 1 then 'one'
when a = 2 then 'two'
else 'other' || 's'
end as b
from test;
"#;
let mut linter = postgres_all_rules_linter();
let linted = linter.lint_string_wrapped(sql, false).unwrap();
let violations = linted.violations();
assert!(
!violations.iter().any(|v| v.rule_code() == "LT01"),
"Expected no LT01 violations, got: {:?}",
violations
.iter()
.map(|v| (v.rule_code(), v.desc().to_string()))
.collect::<Vec<_>>()
);
assert!(
violations.iter().all(|v| v.rule_code() == "LT02"),
"Expected only LT02 violations, got: {:?}",
violations
.iter()
.map(|v| (v.rule_code(), v.desc().to_string()))
.collect::<Vec<_>>()
);
let fixed = postgres_all_rules_linter()
.lint_string_wrapped(sql, true)
.unwrap()
.fix_string();
assert_eq!(fixed, expected);
}
#[test]
fn test_postgres_case_else_binary_operator_spacing_still_triggers_lt01() {
let sql = r#"select case
when a = 1 then 'one'
else 1+2
end as b
from test;
"#;
let expected = r#"select
case
when a = 1 then 'one'
else 1 + 2
end as b
from test;
"#;
let mut linter = postgres_all_rules_linter();
let linted = linter.lint_string_wrapped(sql, false).unwrap();
let violations = linted.violations();
assert!(
violations.iter().any(|v| v.rule_code() == "LT01"),
"Expected LT01 violations, got: {:?}",
violations
.iter()
.map(|v| (v.rule_code(), v.desc().to_string()))
.collect::<Vec<_>>()
);
let fixed = postgres_all_rules_linter()
.lint_string_wrapped(sql, true)
.unwrap()
.fix_string();
assert_eq!(fixed, expected);
}
}