From 2ad5e30422691a32a328e13e6a6d93c0ae2fc65c Mon Sep 17 00:00:00 2001 From: Steve Dignam Date: Mon, 5 May 2025 23:23:12 -0400 Subject: [PATCH] v2: swap linters --- Cargo.lock | 16 +- Cargo.toml | 1 + README.md | 9 +- crates/cli/Cargo.toml | 20 +- crates/cli/src/config.rs | 45 +- crates/cli/src/debug.rs | 98 ++ crates/cli/src/file.rs | 22 + crates/cli/src/file_finding.rs | 36 +- crates/cli/src/github.rs | 314 +++++++ crates/cli/src/main.rs | 169 ++-- crates/cli/src/reporter.rs | 882 +++++------------- ...st_config__load_assume_in_transaction.snap | 2 +- ...k__config__test_config__load_cfg_full.snap | 2 +- ...fig__test_config__load_excluded_paths.snap | 2 +- ...fig__test_config__load_excluded_rules.snap | 2 +- ..._test_config__load_fail_on_violations.snap | 2 +- ..._config__test_config__load_pg_version.snap | 2 +- ...t__generating_comment_multiple_files.snap} | 13 +- ...nt__generating_comment_no_violations.snap} | 2 +- ...t__generating_no_violations_no_files.snap} | 2 +- ...eck_files__check_files_invalid_syntax.snap | 4 +- ...t_reporter__display_no_violations_tty.snap | 3 +- ...test_reporter__display_violations_tty.snap | 74 +- ...reporter__highlight_column_for_issues.snap | 8 - ...reporter__test_reporter__span_offsets.snap | 122 ++- crates/cli/src/subcommand.rs | 233 ----- crates/github/src/lib.rs | 11 + ...new => parser__test__alter_table_err.snap} | 1 - docs/docs/cli.md | 13 +- 29 files changed, 966 insertions(+), 1144 deletions(-) create mode 100644 crates/cli/src/debug.rs create mode 100644 crates/cli/src/file.rs create mode 100644 crates/cli/src/github.rs rename crates/cli/src/snapshots/{squawk__reporter__test_github_comment__generating_comment_multiple_files.snap => squawk__github__test_github_comment__generating_comment_multiple_files.snap} (61%) rename crates/cli/src/snapshots/{squawk__reporter__test_github_comment__generating_comment_no_violations.snap => squawk__github__test_github_comment__generating_comment_no_violations.snap} (95%) rename crates/cli/src/snapshots/{squawk__reporter__test_github_comment__generating_no_violations_no_files.snap => squawk__github__test_github_comment__generating_no_violations_no_files.snap} (89%) delete mode 100644 crates/cli/src/snapshots/squawk__reporter__test_reporter__highlight_column_for_issues.snap delete mode 100644 crates/cli/src/subcommand.rs rename crates/squawk_parser/src/snapshots/{parser__test__alter_table_err.snap.new => parser__test__alter_table_err.snap} (99%) diff --git a/Cargo.lock b/Cargo.lock index 62d550d7..6bb8339a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2477,13 +2477,13 @@ dependencies = [ [[package]] name = "serde_repr" -version = "0.1.8" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2ad84e47328a31223de7fed7a4f5087f2d6ddfe586cf3ca25b7a165bc0a5aed" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.101", ] [[package]] @@ -2596,18 +2596,23 @@ checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" name = "squawk" version = "1.6.1" dependencies = [ + "annotate-snippets", + "anyhow", "atty", "base64 0.12.3", "console 0.11.3", + "enum-iterator", "glob", "insta", + "line-index", "log", "serde", "serde_json", "simplelog", "squawk-github", - "squawk-linter", - "squawk-parser", + "squawk_lexer", + "squawk_linter", + "squawk_syntax", "structopt", "tempfile", "toml", @@ -2647,7 +2652,6 @@ dependencies = [ "serde_repr", ] - [[package]] name = "squawk_lexer" version = "0.0.0" diff --git a/Cargo.toml b/Cargo.toml index f49efdc4..e6299ea7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,6 +63,7 @@ needless_return = "allow" doc_markdown = "deny" manual_let_else = "deny" explicit_iter_loop = "deny" +too_many_arguments = "allow" [profile.dev] debug = 0 diff --git a/README.md b/README.md index ae469a36..36b2ed6b 100644 --- a/README.md +++ b/README.md @@ -74,9 +74,6 @@ FLAGS: -h, --help Prints help information - --list-rules - List all available rules - -V, --version Prints version information @@ -88,8 +85,8 @@ OPTIONS: -c, --config Path to the squawk config file (.squawk.toml) - --dump-ast - Output AST in JSON [possible values: Raw, Parsed, Debug] + --debug + Output debug info [possible values: Lex, Parse] --exclude-path ... Paths to exclude @@ -102,8 +99,6 @@ OPTIONS: Exclude specific warnings For example: --exclude=require-concurrent-index-creation,ban-drop-database - --explain - Provide documentation on the given rule --pg-version Specify postgres version diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 0c0c4371..e82829d6 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -1,9 +1,11 @@ [package] name = "squawk" version = "1.6.1" -authors = ["Steve Dignam "] -edition = "2018" -license = "GPL-3.0" +default-run = "squawk" + +authors.workspace = true +edition.workspace = true +license.workspace = true description = "Linter for Postgresql focused on database migrations." repository = "https://github.com/sbdchd/squawk" documentation = "https://github.com/sbdchd/squawk/blob/master/README.md" @@ -23,11 +25,19 @@ atty.workspace = true base64.workspace = true simplelog.workspace = true log.workspace = true -squawk-parser.workspace = true -squawk-linter.workspace = true +enum-iterator.workspace = true +squawk_syntax.workspace = true +squawk_linter.workspace = true +squawk_lexer.workspace = true squawk-github.workspace = true toml.workspace = true glob.workspace = true +anyhow.workspace = true +annotate-snippets.workspace = true +line-index.workspace = true [dev-dependencies] insta.workspace = true + +[lints] +workspace = true diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index 1fcbbe12..56901cf7 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -1,41 +1,11 @@ +use anyhow::{Context, Result}; use log::info; use serde::Deserialize; -use squawk_linter::{versions::Version, violations::RuleViolationKind}; -use std::{env, io, path::Path, path::PathBuf}; +use squawk_linter::{Rule, Version}; +use std::{env, path::Path, path::PathBuf}; const FILE_NAME: &str = ".squawk.toml"; -#[derive(Debug)] -pub enum ConfigError { - LookupError(io::Error), - ReadError(io::Error), - ParseError(toml::de::Error), -} - -impl std::convert::From for ConfigError { - fn from(e: io::Error) -> Self { - Self::ReadError(e) - } -} - -impl std::convert::From for ConfigError { - fn from(e: toml::de::Error) -> Self { - Self::ParseError(e) - } -} - -impl std::fmt::Display for ConfigError { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - match *self { - Self::LookupError(ref err) => { - write!(f, "Error when finding configuration file: {err}") - } - Self::ReadError(ref err) => write!(f, "Failed to read configuration file: {err}"), - Self::ParseError(ref err) => write!(f, "Failed to parse configuration file: {err}"), - } - } -} - #[derive(Debug, Default, Deserialize)] pub struct UploadToGitHubConfig { #[serde(default)] @@ -47,7 +17,7 @@ pub struct Config { #[serde(default)] pub excluded_paths: Vec, #[serde(default)] - pub excluded_rules: Vec, + pub excluded_rules: Vec, #[serde(default)] pub pg_version: Option, #[serde(default)] @@ -57,7 +27,7 @@ pub struct Config { } impl Config { - pub fn parse(custom_path: Option) -> Result, ConfigError> { + pub fn parse(custom_path: Option) -> Result> { let path = if let Some(path) = custom_path { Some(path) } else { @@ -90,8 +60,9 @@ fn recurse_directory(directory: &Path, file_name: &str) -> Result Result, ConfigError> { - recurse_directory(&env::current_dir()?, FILE_NAME).map_err(ConfigError::LookupError) +fn find_by_traversing_back() -> Result> { + recurse_directory(&env::current_dir()?, FILE_NAME) + .context("Error when finding configuration file") } #[cfg(test)] diff --git a/crates/cli/src/debug.rs b/crates/cli/src/debug.rs new file mode 100644 index 00000000..5c34442a --- /dev/null +++ b/crates/cli/src/debug.rs @@ -0,0 +1,98 @@ +use std::{io, path::PathBuf}; + +use annotate_snippets::{Level, Message, Renderer, Snippet}; +use anyhow::Result; +use squawk_syntax::syntax_error::SyntaxError; + +use crate::{ + file::{sql_from_path, sql_from_stdin}, + DebugOption, +}; + +pub(crate) fn debug( + f: &mut W, + paths: &[PathBuf], + read_stdin: bool, + dump_ast: &DebugOption, + verbose: bool, +) -> Result<()> { + let process_dump_ast = |sql: &str, filename: &str, f: &mut W| -> Result<()> { + match dump_ast { + DebugOption::Lex => { + let tokens = squawk_lexer::tokenize(sql); + let mut start = 0; + for token in tokens { + if verbose { + let content = &sql[start as usize..(start + token.len) as usize]; + start += token.len; + writeln!(f, "{:?} @ {:?}", content, token.kind)?; + } else { + writeln!(f, "{:?}", token)?; + } + } + } + DebugOption::Parse => { + let parse = squawk_syntax::SourceFile::parse(sql); + if verbose { + writeln!(f, "{}\n---", parse.syntax_node())?; + } + writeln!(f, "{:#?}", parse.syntax_node())?; + let errors = parse.errors(); + if !errors.is_empty() { + let mut snap = "---".to_string(); + for syntax_error in &errors { + let range = syntax_error.range(); + let text = syntax_error.message(); + // split into there own lines so that we can just grep + // for error without hitting this part + snap += "\n"; + snap += "ERROR"; + if range.start() == range.end() { + snap += &format!("@{:?} {:?}", range.start(), text); + } else { + snap += &format!("@{:?}:{:?} {:?}", range.start(), range.end(), text); + } + } + writeln!(f, "{}", snap)?; + let renderer = Renderer::styled(); + render_syntax_errors(&errors, filename, sql, |message| { + writeln!(f, "{}", renderer.render(message))?; + Ok(()) + })?; + } + } + } + Ok(()) + }; + if read_stdin { + let sql = sql_from_stdin()?; + process_dump_ast(&sql, "stdin", f)?; + return Ok(()); + } + + for path in paths { + let sql = sql_from_path(path)?; + process_dump_ast(&sql, &path.to_string_lossy(), f)?; + } + Ok(()) +} + +fn render_syntax_errors( + errors: &[SyntaxError], + filename: &str, + sql: &str, + mut render: impl FnMut(Message<'_>) -> Result<()>, +) -> Result<()> { + for err in errors { + let text = err.message(); + let span = err.range().into(); + let message = Level::Warning.title(text).id("syntax-error").snippet( + Snippet::source(sql) + .origin(filename) + .fold(true) + .annotation(Level::Error.span(span)), + ); + render(message)?; + } + Ok(()) +} diff --git a/crates/cli/src/file.rs b/crates/cli/src/file.rs new file mode 100644 index 00000000..4ff80609 --- /dev/null +++ b/crates/cli/src/file.rs @@ -0,0 +1,22 @@ +use std::{ + fs::File, + io::{self, Read}, + path::PathBuf, +}; + +use anyhow::Result; + +pub(crate) fn sql_from_stdin() -> Result { + let mut buffer = String::new(); + let stdin = io::stdin(); + let mut handle = stdin.lock(); + handle.read_to_string(&mut buffer)?; + Ok(buffer) +} + +pub(crate) fn sql_from_path(path: &PathBuf) -> Result { + let mut file = File::open(path)?; + let mut contents = String::new(); + file.read_to_string(&mut contents)?; + Ok(contents) +} diff --git a/crates/cli/src/file_finding.rs b/crates/cli/src/file_finding.rs index 63e0d292..d75db718 100644 --- a/crates/cli/src/file_finding.rs +++ b/crates/cli/src/file_finding.rs @@ -1,42 +1,10 @@ +use anyhow::Result; use std::path::PathBuf; use log::info; -#[derive(Debug)] -pub enum FindFilesError { - PatternError(glob::PatternError), - GlobError(glob::GlobError), -} - -impl std::fmt::Display for FindFilesError { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - match *self { - Self::PatternError(ref err) => { - write!(f, "Failed to build pattern: {err}") - } - Self::GlobError(ref err) => { - write!(f, "Failed to read file: {err}") - } - } - } -} - -impl std::convert::From for FindFilesError { - fn from(e: glob::PatternError) -> Self { - Self::PatternError(e) - } -} -impl std::convert::From for FindFilesError { - fn from(e: glob::GlobError) -> Self { - Self::GlobError(e) - } -} - /// Given a list of patterns or paths, along with exclusion patterns, find matching files. -pub fn find_paths( - path_patterns: &[String], - exclude_patterns: &[String], -) -> Result, FindFilesError> { +pub fn find_paths(path_patterns: &[String], exclude_patterns: &[String]) -> Result> { let mut matched_paths = vec![]; let exclude_paths: Vec<_> = exclude_patterns .iter() diff --git a/crates/cli/src/github.rs b/crates/cli/src/github.rs new file mode 100644 index 00000000..8683bfd2 --- /dev/null +++ b/crates/cli/src/github.rs @@ -0,0 +1,314 @@ +use crate::config::Config; +use crate::reporter::{fmt_tty_violation, CheckReport}; +use crate::Command; +use crate::{file_finding::find_paths, reporter::check_files}; +use anyhow::{anyhow, bail, Result}; +use console::strip_ansi_codes; +use log::info; +use squawk_github::{actions, app, comment_on_pr, GitHubApi}; +use squawk_linter::{Rule, Version}; + +const VERSION: &str = env!("CARGO_PKG_VERSION"); + +fn get_github_private_key( + github_private_key: Option, + github_private_key_base64: Option, +) -> Result { + if let Some(private_key) = github_private_key { + Ok(private_key) + } else { + let Some(key) = github_private_key_base64 else { + bail!("Missing GitHub private key"); + }; + let bytes = base64::decode(key).map_err(|err| { + anyhow!( + "Failed to decode GitHub private key from base64 encoding: {}", + err + ) + })?; + Ok(String::from_utf8(bytes) + .map_err(|err| anyhow!("Could not decode GitHub private key to string: {}", err))?) + } +} + +fn create_gh_app( + github_install_id: Option, + github_app_id: Option, + github_token: Option, + github_private_key: Option, + github_private_key_base64: Option, +) -> Result> { + if let Some(github_install_id) = github_install_id { + if let Some(github_app_id) = github_app_id { + info!("using github app client"); + let gh_private_key = + get_github_private_key(github_private_key, github_private_key_base64)?; + let app = app::GitHub::new(&gh_private_key, github_app_id, github_install_id)?; + return Ok(Box::new(app)); + } + } + + if let Some(github_token) = github_token { + info!("using github actions client"); + return Ok(Box::new(actions::GitHub::new(&github_token))); + }; + bail!( + "Missing GitHub credentials: + + For a GitHub token: + --github-token is required + + For a GitHub App: + --github-app-id is required + --github-install-id is required + --github-private-key or --github-private-key-base64 is required + " + ) +} + +pub fn check_and_comment_on_pr( + cmd: Command, + cfg: &Config, + is_stdin: bool, + stdin_path: Option, + exclude: &[Rule], + exclude_paths: &[String], + pg_version: Option, + assume_in_transaction: bool, +) -> Result<()> { + let Command::UploadToGithub { + paths, + fail_on_violations, + github_private_key, + github_token, + github_app_id, + github_install_id, + github_repo_owner, + github_repo_name, + github_pr_number, + github_private_key_base64, + } = cmd; + + let fail_on_violations = + if let Some(fail_on_violations_cfg) = cfg.upload_to_github.fail_on_violations { + fail_on_violations_cfg + } else { + fail_on_violations + }; + + let github_app = create_gh_app( + github_install_id, + github_app_id, + github_token, + github_private_key, + github_private_key_base64, + )?; + + let found_paths = find_paths(&paths, exclude_paths)?; + + info!("checking files"); + let file_results = check_files( + &found_paths, + is_stdin, + stdin_path, + exclude, + pg_version, + assume_in_transaction, + )?; + + // We should only leave a comment when there are files checked. + if paths.is_empty() { + info!("no files checked, exiting"); + return Ok(()); + } + info!("generating github comment body"); + let comment_body = get_comment_body(&file_results, VERSION); + + comment_on_pr( + github_app.as_ref(), + &github_repo_owner, + &github_repo_name, + github_pr_number, + &comment_body, + )?; + + let violations: usize = file_results.iter().map(|f| f.violations.len()).sum(); + + if fail_on_violations && violations > 0 { + let files = file_results.len(); + bail!("Found {violations} violation(s) across {files} file(s)"); + } + + Ok(()) +} + +fn get_comment_body(files: &[CheckReport], version: &str) -> String { + let violations_count: usize = files.iter().map(|x| x.violations.len()).sum(); + + let violations_emoji = get_violations_emoji(violations_count); + + format!( + r" +# Squawk Report + +### **{violations_emoji} {violation_count}** violations across **{file_count}** file(s) + +--- +{sql_file_content} + +[📚 More info on rules](https://github.com/sbdchd/squawk#rules) + +⚡️ Powered by [`Squawk`](https://github.com/sbdchd/squawk) ({version}), a linter for PostgreSQL, focused on migrations +", + violations_emoji = violations_emoji, + violation_count = violations_count, + file_count = files.len(), + sql_file_content = files + .iter() + .filter_map(|x| get_sql_file_content(x).ok()) + .collect::>() + .join("\n"), + version = version + ) + .trim_matches('\n') + .into() +} + +const fn get_violations_emoji(count: usize) -> &'static str { + if count > 0 { + "🚒" + } else { + "✅" + } +} + +fn get_sql_file_content(violation: &CheckReport) -> Result { + let sql = &violation.sql; + let mut buff = Vec::new(); + let violation_count = violation.violations.len(); + for v in &violation.violations { + fmt_tty_violation(&mut buff, v, &violation.filename, sql)?; + } + let violations_text_raw = &String::from_utf8_lossy(&buff); + let violations_text = strip_ansi_codes(violations_text_raw); + + let violation_content = if violation_count > 0 { + format!( + r" +``` +{} +```", + violations_text.trim_matches('\n') + ) + } else { + "No violations found.".to_string() + }; + + let violations_emoji = get_violations_emoji(violation_count); + + Ok(format!( + r" +

{filename}

+ +```sql +{sql} +``` + +

{violations_emoji} Rule Violations ({violation_count})

+ +{violation_content} + +--- + ", + violations_emoji = violations_emoji, + filename = violation.filename, + sql = sql, + violation_count = violation_count, + violation_content = violation_content + )) +} + +#[cfg(test)] +mod test_github_comment { + use crate::{ + github::get_comment_body, + reporter::{CheckReport, ReportViolation, ViolationLevel}, + }; + + use insta::assert_snapshot; + use line_index::{TextRange, TextSize}; + + /// Most cases, hopefully, will be a single migration for a given PR, but + /// let's check the case of multiple migrations + #[test] + fn generating_comment_multiple_files() { + let violations = vec![CheckReport { + filename: "alpha.sql".into(), + sql: r" +SELECT 1; + " + .into(), + violations: vec![ReportViolation { + file: "alpha.sql".into(), + line: 1, + column: 0, + level: ViolationLevel::Warning, + rule_name: "adding-not-nullable-field".to_string(), + range: TextRange::new(TextSize::new(0), TextSize::new(0)), + message: "Adding a NOT NULL field requires exclusive locks and table rewrites." + .to_string(), + help: Some("Make the field nullable.".to_string()), + }], + }]; + + let body = get_comment_body(&violations, "0.2.3"); + + assert_snapshot!(body); + } + + /// Even when we don't have violations we still want to output the SQL for + /// easy human reading. + #[test] + fn generating_comment_no_violations() { + let violations = vec![ + CheckReport { + filename: "alpha.sql".into(), + sql: r#" +BEGIN; +-- +-- Create model Bar +-- +CREATE TABLE "core_bar" ( + "id" serial NOT NULL PRIMARY KEY, + "alpha" varchar(100) NOT NULL +); + "# + .into(), + violations: vec![], + }, + CheckReport { + filename: "bravo.sql".into(), + sql: r#" +ALTER TABLE "core_recipe" ADD COLUMN "foo" integer DEFAULT 10; + "# + .into(), + violations: vec![], + }, + ]; + + let body = get_comment_body(&violations, "0.2.3"); + + assert_snapshot!(body); + } + + /// Ideally the logic won't leave a comment when there are no migrations but + /// better safe than sorry + #[test] + fn generating_no_violations_no_files() { + let violations = vec![]; + + let body = get_comment_body(&violations, "0.2.3"); + + assert_snapshot!(body); + } +} diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index b6f2e9cd..4c9e2e86 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -1,37 +1,75 @@ -#![allow(clippy::match_wildcard_for_single_variants)] -#[allow(clippy::non_ascii_literal)] -#[allow(clippy::cast_sign_loss)] -#[allow(clippy::enum_variant_names)] -#[allow(clippy::module_name_repetitions)] mod config; +mod debug; +mod file; mod file_finding; +mod github; mod reporter; -mod subcommand; +use anyhow::{Context, Result}; +use debug::debug; +use reporter::check_and_dump_files; +use squawk_linter::{Rule, Version}; +use structopt::clap::arg_enum; use crate::file_finding::find_paths; -use crate::reporter::{ - check_files, dump_ast_for_paths, explain_rule, list_rules, print_violations, DumpAstOption, - Reporter, -}; -use crate::subcommand::{check_and_comment_on_pr, Command}; use atty::Stream; use config::Config; use log::info; use simplelog::CombinedLogger; -use squawk_linter::versions::Version; -use squawk_linter::violations::RuleViolationKind; use std::io; use std::path::PathBuf; -use std::process; +use std::process::{self, ExitCode}; use structopt::StructOpt; -fn exit(res: Result, msg: &str) -> ! { - match res { - Ok(_) => process::exit(0), - Err(err) => { - eprintln!("{msg}: {err}"); - process::exit(1) - } +#[derive(StructOpt, Debug)] +pub enum Command { + /// Comment on a PR with Squawk's results. + UploadToGithub { + /// Paths to search + paths: Vec, + /// Exits with an error if violations are found + #[structopt(long)] + fail_on_violations: bool, + #[structopt(long, env = "SQUAWK_GITHUB_PRIVATE_KEY")] + github_private_key: Option, + #[structopt(long, env = "SQUAWK_GITHUB_PRIVATE_KEY_BASE64")] + github_private_key_base64: Option, + #[structopt(long, env = "SQUAWK_GITHUB_TOKEN")] + github_token: Option, + /// GitHub App Id. + #[structopt(long, env = "SQUAWK_GITHUB_APP_ID")] + github_app_id: Option, + /// GitHub Install Id. The installation that squawk is acting on. + #[structopt(long, env = "SQUAWK_GITHUB_INSTALL_ID")] + github_install_id: Option, + /// GitHub Repo Owner + /// github.com/sbdchd/squawk, sbdchd is the owner + #[structopt(long, env = "SQUAWK_GITHUB_REPO_OWNER")] + github_repo_owner: String, + /// GitHub Repo Name + /// github.com/sbdchd/squawk, squawk is the name + #[structopt(long, env = "SQUAWK_GITHUB_REPO_NAME")] + github_repo_name: String, + /// GitHub Pull Request Number + /// github.com/sbdchd/squawk/pull/10, 10 is the PR number + #[structopt(long, env = "SQUAWK_GITHUB_PR_NUMBER")] + github_pr_number: i64, + }, +} + +arg_enum! { + #[derive(Debug, StructOpt)] + pub enum DebugOption { + Lex, + Parse + } +} + +arg_enum! { + #[derive(Debug, StructOpt)] + pub enum Reporter { + Tty, + Gcc, + Json, } } @@ -62,22 +100,16 @@ struct Opt { use_delimiter = true, global = true )] - excluded_rules: Option>, + excluded_rules: Option>, /// Specify postgres version /// /// For example: /// --pg-version=13.0 #[structopt(long, global = true)] pg_version: Option, - /// List all available rules - #[structopt(long)] - list_rules: bool, - /// Provide documentation on the given rule - #[structopt(long, value_name = "rule")] - explain: Option, - /// Output AST in JSON - #[structopt(long,value_name ="ast-format", possible_values = &DumpAstOption::variants(), case_insensitive = true)] - dump_ast: Option, + /// Output debug format + #[structopt(long,value_name ="format", possible_values = &DebugOption::variants(), case_insensitive = true)] + debug: Option, /// Style of error reporting #[structopt(long, possible_values = &Reporter::variants(), case_insensitive = true)] reporter: Option, @@ -107,14 +139,14 @@ struct Opt { } #[allow(clippy::too_many_lines)] -fn main() { +fn main() -> Result { let opts = Opt::from_args(); if opts.verbose { CombinedLogger::init(vec![simplelog::TermLogger::new( simplelog::LevelFilter::Info, simplelog::Config::default(), - simplelog::TerminalMode::Mixed, + simplelog::TerminalMode::Stderr, simplelog::ColorChoice::Auto, )]) .expect("problem creating logger"); @@ -177,67 +209,38 @@ fn main() { process::exit(1); } if let Some(subcommand) = opts.cmd { - exit( - check_and_comment_on_pr( - subcommand, - &conf, - is_stdin, - opts.stdin_filepath, - &excluded_rules, - &excluded_paths, - pg_version, - assume_in_transaction, - ), - "Upload to GitHub failed", - ); + github::check_and_comment_on_pr( + subcommand, + &conf, + is_stdin, + opts.stdin_filepath, + &excluded_rules, + &excluded_paths, + pg_version, + assume_in_transaction, + ) + .context("Upload to GitHub failed")?; } else if !found_paths.is_empty() || is_stdin { let read_stdin = found_paths.is_empty() && is_stdin; - if let Some(dump_ast_kind) = opts.dump_ast { - exit( - dump_ast_for_paths(&mut handle, &found_paths, read_stdin, &dump_ast_kind), - "Failed to dump AST", - ); + if let Some(kind) = opts.debug { + debug(&mut handle, &found_paths, read_stdin, &kind, opts.verbose)?; } else { - match check_files( + let reporter = opts.reporter.unwrap_or(Reporter::Tty); + let exit_code = check_and_dump_files( + &mut handle, &found_paths, read_stdin, opts.stdin_filepath, &excluded_rules, pg_version, assume_in_transaction, - ) { - Ok(file_reports) => { - let reporter = opts.reporter.unwrap_or(Reporter::Tty); - let total_violations = file_reports - .iter() - .map(|f| f.violations.len()) - .sum::(); - match print_violations(&mut handle, file_reports, &reporter) { - Ok(()) => { - let exit_code = i32::from(total_violations > 0); - process::exit(exit_code); - } - Err(e) => { - eprintln!("Problem reporting violations: {e}"); - process::exit(1); - } - } - } - Err(e) => { - eprintln!("Problem linting SQL files: {e}"); - process::exit(1) - } - } + &reporter, + )?; + return Ok(exit_code); } - } else if opts.list_rules { - exit(list_rules(&mut handle), "Could not list rules"); - } else if let Some(rule_name) = opts.explain { - exit( - explain_rule(&mut handle, &rule_name), - "Could not explain rules", - ); } else { - clap_app.print_long_help().expect("problem printing help"); + clap_app.print_long_help()?; println!(); } + Ok(ExitCode::SUCCESS) } diff --git a/crates/cli/src/reporter.rs b/crates/cli/src/reporter.rs index ff829e15..549d27e4 100644 --- a/crates/cli/src/reporter.rs +++ b/crates/cli/src/reporter.rs @@ -1,222 +1,231 @@ -use console::strip_ansi_codes; +use annotate_snippets::Level; +use annotate_snippets::Renderer; +use annotate_snippets::Snippet; +use anyhow::Result; use console::style; +use line_index::LineIndex; +use line_index::TextRange; use log::info; use serde::Serialize; -use squawk_linter::errors::CheckSqlError; -use squawk_linter::versions::Version; -use squawk_linter::violations::{RuleViolation, RuleViolationKind, Span, ViolationMessage}; -use squawk_linter::{check_sql, SquawkRule, RULES}; -use squawk_parser::error::PgQueryError; -use squawk_parser::parse::{parse_sql_query, parse_sql_query_json}; -use std::convert::TryFrom; -use std::fs::File; +use squawk_linter::Linter; +use squawk_linter::Rule; +use squawk_linter::Version; +use squawk_syntax::SourceFile; use std::io; -use std::io::prelude::*; use std::path::PathBuf; -use structopt::clap::arg_enum; -use structopt::StructOpt; +use std::process::ExitCode; -fn get_sql_from_path(path: &PathBuf) -> Result { - let mut file = File::open(path)?; - let mut contents = String::new(); - file.read_to_string(&mut contents).map(|_| contents) -} - -arg_enum! { - #[derive(Debug, StructOpt)] - pub enum DumpAstOption { - Raw, - Parsed, - Debug - } -} - -#[derive(Debug)] -pub enum DumpAstError { - PgQuery(PgQueryError), - Io(std::io::Error), - Json(serde_json::error::Error), -} - -impl std::fmt::Display for DumpAstError { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - match *self { - Self::PgQuery(ref err) => err.fmt(f), - Self::Io(ref err) => err.fmt(f), - Self::Json(ref err) => err.fmt(f), - } - } -} - -impl std::convert::From for DumpAstError { - fn from(e: PgQueryError) -> Self { - Self::PgQuery(e) - } -} - -impl std::convert::From for DumpAstError { - fn from(e: std::io::Error) -> Self { - Self::Io(e) - } -} +use crate::{ + file::{sql_from_path, sql_from_stdin}, + Reporter, +}; -impl std::convert::From for DumpAstError { - fn from(e: serde_json::error::Error) -> Self { - Self::Json(e) +fn check_sql( + sql: &str, + path: &str, + excluded_rules: &[Rule], + pg_version: Option, + assume_in_transaction: bool, +) -> CheckReport { + let mut linter = Linter::without_rules(excluded_rules); + if let Some(pg_version) = pg_version { + linter.settings.pg_version = pg_version; + } + linter.settings.assume_in_transaction = assume_in_transaction; + let parse = SourceFile::parse(sql); + let parse_errors = parse.errors(); + let errors = linter.lint(parse, sql); + let line_index = LineIndex::new(sql); + + let mut violations = Vec::with_capacity(parse_errors.len() + errors.len()); + + for e in parse_errors { + let range_start = e.range().start(); + let line_col = line_index.line_col(range_start); + violations.push(ReportViolation { + file: path.to_string(), + line: line_col.line as usize, + column: line_col.col as usize, + level: ViolationLevel::Error, + help: None, + range: e.range(), + message: e.message().to_string(), + rule_name: "syntax-error".to_string(), + }) + } + for e in errors { + let range_start = e.text_range.start(); + let line_col = line_index.line_col(range_start); + violations.push(ReportViolation { + file: path.to_string(), + line: line_col.line as usize, + column: line_col.col as usize, + range: e.text_range, + help: e.help, + level: ViolationLevel::Warning, + message: e.message, + rule_name: e.code.to_string(), + }) + } + + CheckReport { + filename: path.into(), + sql: sql.into(), + violations, } } -pub fn dump_ast_for_paths( +fn render_lint_error( f: &mut W, - paths: &[PathBuf], - read_stdin: bool, - dump_ast: &DumpAstOption, -) -> Result<(), DumpAstError> { - let mut process_dump_ast = |sql: &str| -> Result<(), DumpAstError> { - match dump_ast { - DumpAstOption::Raw => { - let json_ast = parse_sql_query_json(sql)?; - let json_str = serde_json::to_string(&json_ast)?; - writeln!(f, "{json_str}")?; - } - DumpAstOption::Parsed => { - let ast = parse_sql_query(sql)?; - let ast_str = serde_json::to_string(&ast)?; - writeln!(f, "{ast_str}")?; - } - DumpAstOption::Debug => { - let ast = parse_sql_query(sql)?; - writeln!(f, "{ast:#?}")?; - } - } - Ok(()) - }; - if read_stdin { - let sql = get_sql_from_stdin()?; - process_dump_ast(&sql)?; - return Ok(()); - } - - for path in paths { - let sql = get_sql_from_path(path)?; - process_dump_ast(&sql)?; - } - Ok(()) -} - -#[derive(Debug)] -pub enum CheckFilesError { - CheckSql(CheckSqlError), - IoError(std::io::Error), -} + err: &ReportViolation, + filename: &str, + sql: &str, +) -> Result<()> { + let renderer = Renderer::styled(); + let error_name = &err.rule_name; -impl std::fmt::Display for CheckFilesError { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - match *self { - Self::CheckSql(ref err) => err.fmt(f), - Self::IoError(ref err) => err.fmt(f), - } - } -} -impl std::convert::From for CheckFilesError { - fn from(e: std::io::Error) -> Self { - Self::IoError(e) - } -} + let title = &err.message; -impl std::convert::From for CheckFilesError { - fn from(e: CheckSqlError) -> Self { - Self::CheckSql(e) + let mut message = Level::Warning.title(title).id(error_name).snippet( + Snippet::source(sql) + .origin(filename) + .fold(true) + .annotation(Level::Error.span(err.range.into())), + ); + if let Some(help) = &err.help { + message = message.footer(Level::Help.title(help)); } -} -fn process_violations( - sql: &str, - path: &str, - excluded_rules: &[RuleViolationKind], - pg_version: Option, - assume_in_transaction: bool, -) -> ViolationContent { - match check_sql(sql, excluded_rules, pg_version, assume_in_transaction) { - Ok(violations) => pretty_violations(violations, sql, path), - Err(err) => ViolationContent { - filename: path.into(), - sql: sql.into(), - violations: vec![ReportViolation { - column: 0, - file: path.into(), - level: ViolationLevel::Error, - line: 0, - messages: vec![ - ViolationMessage::Note(err.to_string()), - ViolationMessage::Help( - "Modify your Postgres statement to use valid syntax.".into(), - ), - ], - rule_name: RuleViolationKind::InvalidStatement, - sql: sql.into(), - }], - }, - } + writeln!(f, "{}", renderer.render(message))?; + Ok(()) } pub fn check_files( path_patterns: &[PathBuf], read_stdin: bool, stdin_path: Option, - excluded_rules: &[RuleViolationKind], + excluded_rules: &[Rule], pg_version: Option, assume_in_transaction: bool, -) -> Result, CheckFilesError> { - let mut output_violations = vec![]; - +) -> Result> { + let mut violations = vec![]; if read_stdin { info!("reading content from stdin"); - let sql = get_sql_from_stdin()?; + let sql = sql_from_stdin()?; // ignore stdin if it's empty. if sql.trim().is_empty() { info!("ignoring empty stdin"); } else { let path = stdin_path.unwrap_or_else(|| "stdin".into()); - output_violations.push(process_violations( + let content = check_sql( &sql, &path, excluded_rules, pg_version, assume_in_transaction, - )); + ); + violations.push(content); } } for path in path_patterns { info!("checking file path: {}", path.display()); - let sql = get_sql_from_path(path)?; - output_violations.push(process_violations( + let sql = sql_from_path(path)?; + let content = check_sql( &sql, path.to_str().unwrap(), excluded_rules, pg_version, assume_in_transaction, - )); + ); + violations.push(content); } - Ok(output_violations) + + Ok(violations) +} + +pub fn check_and_dump_files( + f: &mut W, + path_patterns: &[PathBuf], + read_stdin: bool, + stdin_path: Option, + excluded_rules: &[Rule], + pg_version: Option, + assume_in_transaction: bool, + reporter: &Reporter, +) -> Result { + let violations = check_files( + path_patterns, + read_stdin, + stdin_path, + excluded_rules, + pg_version, + assume_in_transaction, + )?; + + let ok = violations.iter().map(|x| x.violations.len()).sum::() == 0; + + print_violations(f, violations, reporter)?; + + Ok(if ok { + ExitCode::SUCCESS + } else { + ExitCode::FAILURE + }) +} + +struct Summary { + total_violations: usize, + files_checked: usize, + files_with_violations: usize, } -fn get_sql_from_stdin() -> Result { - let mut buffer = String::new(); - let stdin = io::stdin(); - let mut handle = stdin.lock(); - handle.read_to_string(&mut buffer)?; - Ok(buffer) +impl Summary { + fn from(violations: &[CheckReport]) -> Summary { + let total_violations: usize = violations.iter().map(|x| x.violations.len()).sum(); + let files_checked = violations.len(); + let files_with_violations = violations + .iter() + .filter(|x| !x.violations.is_empty()) + .count(); + Summary { + total_violations, + files_checked, + files_with_violations, + } + } } -arg_enum! { - #[derive(Debug, StructOpt)] - pub enum Reporter { - Tty, - Gcc, - Json, +fn print_summary(f: &mut W, summary: &Summary) -> Result<()> { + if summary.total_violations == 0 { + writeln!( + f, + "\nFound 0 issues in {files_checked} {files} 🎉", + files_checked = summary.files_checked, + files = if summary.files_checked == 1 { + "file" + } else { + "files" + } + )?; + } else { + writeln!( + f, + "\nFind detailed examples and solutions for each rule at {}", + style("https://squawkhq.com/docs/rules").underlined() + )?; + writeln!( + f, + "Found {total_violations} issue{plural} in {files_with_violations} file{files_plural} (checked {files_checked} {files_checked_plural})", + total_violations = summary.total_violations, + plural = if summary.total_violations == 1 { "" } else { "s" }, + files_with_violations = summary.files_with_violations, + files_plural = if summary.files_with_violations == 1 { "" } else { "s" }, + files_checked = summary.files_checked, + files_checked_plural = if summary.files_checked == 1 { "source file" } else { "source files" } + )?; } + Ok(()) } #[derive(Debug, Serialize)] @@ -235,36 +244,23 @@ impl std::fmt::Display for ViolationLevel { } } +// TODO: don't use this for json dumps #[derive(Debug, Serialize)] pub struct ReportViolation { pub file: String, pub line: usize, pub column: usize, - pub level: ViolationLevel, - pub messages: Vec, - pub rule_name: RuleViolationKind, - // don't output in JSON format #[serde(skip_serializing)] - pub sql: String, + pub range: TextRange, + pub level: ViolationLevel, + pub message: String, + pub help: Option, + pub rule_name: String, } -fn fmt_gcc( - f: &mut W, - files: &[ViolationContent], -) -> std::result::Result<(), std::io::Error> { - for file in files { - for violation in &file.violations { - let message = violation - .messages - .iter() - .map(|v| { - match v { - ViolationMessage::Note(s) | ViolationMessage::Help(s) => s, - } - .to_string() - }) - .collect::>() - .join(" "); +fn fmt_gcc(f: &mut W, reports: &[CheckReport]) -> Result<()> { + for report in reports { + for violation in &report.violations { writeln!( f, "{}:{}:{}: {}: {} {}", @@ -273,7 +269,7 @@ fn fmt_gcc( violation.column, violation.level, violation.rule_name, - message + violation.message, )?; } } @@ -283,382 +279,67 @@ fn fmt_gcc( pub fn fmt_tty_violation( f: &mut W, violation: &ReportViolation, -) -> Result<(), std::io::Error> { - let violation_level = match violation.level { - ViolationLevel::Warning => style(format!("{}", violation.level)).yellow(), - ViolationLevel::Error => style(format!("{}", violation.level)).red(), - }; - writeln!( - f, - "{}:{}:{}: {}: {}", - violation.file, violation.line, violation.column, violation_level, violation.rule_name - )?; - - writeln!(f)?; - for (i, line) in violation.sql.lines().enumerate() { - // TODO(sbdchd): handle the transition from 2 digits to 3 - writeln!(f, " {: >2} | {}", violation.line + i, line)?; - } - writeln!(f)?; - for msg in &violation.messages { - match msg { - ViolationMessage::Note(note) => { - writeln!(f, " {}: {}", style("note").bold(), note)?; - } - ViolationMessage::Help(help) => { - writeln!(f, " {}: {}", style("help").bold(), help)?; - } - } - } - writeln!(f) + filename: &str, + sql: &str, +) -> Result<()> { + render_lint_error(f, violation, filename, sql)?; + Ok(()) } -pub fn fmt_tty(f: &mut W, files: &[ViolationContent]) -> Result<(), std::io::Error> { - for file in files { - for violation in &file.violations { - fmt_tty_violation(f, violation)?; +pub fn fmt_tty(f: &mut W, reports: &[CheckReport]) -> Result<()> { + let summary = Summary::from(reports); + for report in reports { + for violation in &report.violations { + fmt_tty_violation(f, violation, &report.filename, &report.sql)?; } } - let total_violations = files.iter().map(|f| f.violations.len()).sum::(); - let files_checked = files.len(); - let files_with_violations = files.iter().filter(|f| !f.violations.is_empty()).count(); - if total_violations == 0 { - writeln!( - f, - "Found 0 issues in {files_checked} {files} 🎉", - files_checked = files_checked, - files = if files_checked == 1 { "file" } else { "files" } - )?; - } else { - writeln!( - f, - "find detailed examples and solutions for each rule at {}", - style("https://squawkhq.com/docs/rules").underlined() - )?; - writeln!( - f, - "Found {total_violations} issue{plural} in {files_with_violations} file{files_plural} (checked {files_checked} {files_checked_plural})", - total_violations = total_violations, - plural = if total_violations == 1 { "" } else { "s" }, - files_with_violations = files_with_violations, - files_plural = if files_with_violations == 1 { "" } else { "s" }, - files_checked = files_checked, - files_checked_plural = if files_checked == 1 { "source file" } else { "source files" } - )?; - } + print_summary(f, &summary)?; Ok(()) } -fn fmt_json( - f: &mut W, - files: Vec, -) -> std::result::Result<(), std::io::Error> { - let violations = files +fn fmt_json(f: &mut W, reports: Vec) -> Result<()> { + let violations = reports .into_iter() .flat_map(|x| x.violations) .collect::>(); let json_str = serde_json::to_string(&violations)?; - writeln!(f, "{json_str}") + writeln!(f, "{json_str}")?; + Ok(()) } #[derive(Debug)] -pub struct ViolationContent { +pub struct CheckReport { pub filename: String, pub sql: String, pub violations: Vec, } -pub fn pretty_violations( - violations: Vec, - sql: &str, - filename: &str, -) -> ViolationContent { - ViolationContent { - filename: filename.into(), - sql: sql.into(), - violations: violations - .into_iter() - .map(|violation| { - // NOTE: the span information from postgres includes the preceeding - // whitespace for nodes. - let Span { start, len } = violation.span; - - #[allow(clippy::cast_sign_loss)] - let start = start as usize; - - let mut lineno = 0; - - for (idx, char) in sql.chars().enumerate() { - if char == '\n' { - lineno += 1; - } - - if idx == start { - break; - } - } - - lineno += 1; - - let content = if let Some(len) = len { - #[allow(clippy::cast_sign_loss)] - &sql[start..=start + len as usize] - } else { - // Use current line - let tail = sql[start..].find('\n').unwrap_or(sql.len() - start); - - &sql.chars().skip(start).take(tail + 1).collect::() - }; - - // TODO(sbdchd): could remove the leading whitespace and comments to - // get cleaner reports - - let col = content.find(|c: char| c != '\n').unwrap_or(0); - - // slice off the beginning new lines - let problem_sql = &content[col..]; - - ReportViolation { - file: filename.into(), - line: lineno, - column: col, - level: ViolationLevel::Warning, - messages: violation.messages, - rule_name: violation.kind, - sql: problem_sql.into(), - } - }) - .collect(), - } -} - pub fn print_violations( writer: &mut W, - file_reports: Vec, + reports: Vec, reporter: &Reporter, -) -> Result<(), std::io::Error> { +) -> Result<()> { match reporter { - Reporter::Gcc => fmt_gcc(writer, &file_reports), - Reporter::Json => fmt_json(writer, file_reports), - Reporter::Tty => fmt_tty(writer, &file_reports), - } -} - -pub fn list_rules(writer: &mut W) -> Result<(), std::io::Error> { - for r in RULES.iter() { - output_rule_info(writer, r)?; - } - Ok(()) -} - -fn output_rule_info(writer: &mut W, rule: &SquawkRule) -> Result<(), std::io::Error> { - writeln!(writer, "{}", rule.name)?; - for msg in &rule.messages { - let msg_content = match msg { - ViolationMessage::Note(s) => format!("note: {s}"), - ViolationMessage::Help(s) => format!("help: {s}"), - }; - writeln!(writer, " {msg_content}")?; - } - Ok(()) -} - -pub fn explain_rule(writer: &mut W, name: &str) -> Result<(), std::io::Error> { - if let Ok(name) = RuleViolationKind::try_from(name) { - if let Some(r) = RULES.iter().find(|r| r.name == name) { - output_rule_info(writer, r)?; - } - } - Ok(()) -} - -const fn get_violations_emoji(count: usize) -> &'static str { - if count > 0 { - "🚒" - } else { - "✅" - } -} - -fn get_sql_file_content(violation: &ViolationContent) -> Result { - let sql = &violation.sql; - let mut buff = Vec::new(); - let violation_count = violation.violations.len(); - for v in &violation.violations { - fmt_tty_violation(&mut buff, v)?; - } - let violations_text_raw = &String::from_utf8_lossy(&buff); - let violations_text = strip_ansi_codes(violations_text_raw); - - let violation_content = if violation_count > 0 { - format!( - r" -``` -{} -```", - violations_text.trim_matches('\n') - ) - } else { - "No violations found.".to_string() - }; - - let violations_emoji = get_violations_emoji(violation_count); - - Ok(format!( - r" -

{filename}

- -```sql -{sql} -``` - -

{violations_emoji} Rule Violations ({violation_count})

- -{violation_content} - ---- - ", - violations_emoji = violations_emoji, - filename = violation.filename, - sql = sql, - violation_count = violation_count, - violation_content = violation_content - )) -} - -pub fn get_comment_body(files: &[ViolationContent], version: &str) -> String { - let violations_count: usize = files.iter().map(|x| x.violations.len()).sum(); - - let violations_emoji = get_violations_emoji(violations_count); - - format!( - r" -# Squawk Report - -### **{violations_emoji} {violation_count}** violations across **{file_count}** file(s) - ---- -{sql_file_content} - -[📚 More info on rules](https://github.com/sbdchd/squawk#rules) - -⚡️ Powered by [`Squawk`](https://github.com/sbdchd/squawk) ({version}), a linter for PostgreSQL, focused on migrations -", - violations_emoji = violations_emoji, - violation_count = violations_count, - file_count = files.len(), - sql_file_content = files - .iter() - .filter_map(|x| get_sql_file_content(x).ok()) - .collect::>() - .join("\n"), - version = version - ) - .trim_matches('\n') - .into() -} - -#[cfg(test)] -mod test_github_comment { - use super::*; - - use insta::assert_snapshot; - - /// Most cases, hopefully, will be a single migration for a given PR, but - /// let's check the case of multiple migrations - #[test] - fn generating_comment_multiple_files() { - let violations = vec![ViolationContent { - filename: "alpha.sql".into(), - sql: r" -SELECT 1; - " - .into(), - violations: vec![ReportViolation { - file: "alpha.sql".into(), - line: 1, - column: 0, - level: ViolationLevel::Warning, - messages: vec![ - ViolationMessage::Note( - "Adding a NOT NULL field requires exclusive locks and table rewrites." - .into(), - ), - ViolationMessage::Help("Make the field nullable.".into()), - ], - rule_name: RuleViolationKind::AddingNotNullableField, - sql: "ALTER TABLE \"core_recipe\" ADD COLUMN \"foo\" integer NOT NULL;".into(), - }], - }]; - - let body = get_comment_body(&violations, "0.2.3"); - - assert_snapshot!(body); - } - - /// Even when we don't have violations we still want to output the SQL for - /// easy human reading. - #[test] - fn generating_comment_no_violations() { - let violations = vec![ - ViolationContent { - filename: "alpha.sql".into(), - sql: r#" -BEGIN; --- --- Create model Bar --- -CREATE TABLE "core_bar" ( - "id" serial NOT NULL PRIMARY KEY, - "alpha" varchar(100) NOT NULL -); - "# - .into(), - violations: vec![], - }, - ViolationContent { - filename: "bravo.sql".into(), - sql: r#" -ALTER TABLE "core_recipe" ADD COLUMN "foo" integer DEFAULT 10; - "# - .into(), - violations: vec![], - }, - ]; - - let body = get_comment_body(&violations, "0.2.3"); - - assert_snapshot!(body); - } - - /// Ideally the logic won't leave a comment when there are no migrations but - /// better safe than sorry - #[test] - fn generating_no_violations_no_files() { - let violations = vec![]; - - let body = get_comment_body(&violations, "0.2.3"); - - assert_snapshot!(body); + Reporter::Gcc => fmt_gcc(writer, &reports), + Reporter::Json => fmt_json(writer, reports), + Reporter::Tty => fmt_tty(writer, &reports), } } #[cfg(test)] mod test_check_files { + use super::check_sql; + use crate::reporter::fmt_json; use insta::assert_snapshot; use serde_json::Value; - use crate::reporter::fmt_json; - - use super::process_violations; - #[test] fn check_files_invalid_syntax() { let sql = r" select \; "; let mut buff = Vec::new(); - let res = process_violations(sql, "test.sql", &[], None, false); + let res = check_sql(sql, "test.sql", &[], None, false); fmt_json(&mut buff, vec![res]).unwrap(); let val: Value = serde_json::from_slice(&buff).unwrap(); @@ -668,21 +349,11 @@ select \; #[cfg(test)] mod test_reporter { - use crate::reporter::{pretty_violations, print_violations, Reporter}; - + use super::check_sql; + use crate::reporter::{print_violations, Reporter}; use console::strip_ansi_codes; use insta::{assert_debug_snapshot, assert_snapshot}; - use squawk_linter::{ - check_sql_with_rule, - violations::{RuleViolation, RuleViolationKind}, - }; - use squawk_parser::ast::Span; - - fn lint_sql(sql: &str) -> Vec { - check_sql_with_rule(sql, &RuleViolationKind::AddingRequiredField, None, false).unwrap() - } - #[test] fn display_violations_gcc() { let sql = r#" @@ -690,7 +361,6 @@ mod test_reporter { ALTER TABLE "core_foo" ADD COLUMN "bar" integer NOT NULL; SELECT 1; "#; - let violations = lint_sql(sql); let filename = "main.sql"; @@ -698,15 +368,21 @@ SELECT 1; let res = print_violations( &mut buff, - vec![pretty_violations(violations, sql, filename)], + vec![check_sql(sql, filename, &[], None, false)], &Reporter::Gcc, ); assert!(res.is_ok()); - assert_snapshot!(String::from_utf8_lossy(&buff), @r" - main.sql:1:0: warning: adding-required-field Adding a NOT NULL field without a DEFAULT will fail for a populated table. Make the field nullable or add a non-VOLATILE DEFAULT (Postgres 11+). - main.sql:3:1: warning: adding-required-field Adding a NOT NULL field without a DEFAULT will fail for a populated table. Make the field nullable or add a non-VOLATILE DEFAULT (Postgres 11+). - "); + assert_snapshot!(String::from_utf8_lossy(&buff), @r###" + main.sql:1:29: warning: adding-required-field Adding a new column that is `NOT NULL` and has no default value to an existing table effectively makes it required. + main.sql:1:29: warning: prefer-robust-stmts Missing `IF NOT EXISTS`, the migration can't be rerun if it fails part way through. + main.sql:1:46: warning: prefer-big-int Using 32-bit integer fields can result in hitting the max `int` limit. + main.sql:1:46: warning: prefer-bigint-over-int Using 32-bit integer fields can result in hitting the max `int` limit. + main.sql:2:23: warning: adding-required-field Adding a new column that is `NOT NULL` and has no default value to an existing table effectively makes it required. + main.sql:2:23: warning: prefer-robust-stmts Missing `IF NOT EXISTS`, the migration can't be rerun if it fails part way through. + main.sql:2:40: warning: prefer-big-int Using 32-bit integer fields can result in hitting the max `int` limit. + main.sql:2:40: warning: prefer-bigint-over-int Using 32-bit integer fields can result in hitting the max `int` limit. + "###); } #[test] @@ -716,13 +392,12 @@ SELECT 1; ALTER TABLE "core_foo" ADD COLUMN "bar" integer NOT NULL; SELECT 1; "#; - let violations = lint_sql(sql); let filename = "main.sql"; let mut buff = Vec::new(); let res = print_violations( &mut buff, - vec![pretty_violations(violations, sql, filename)], + vec![check_sql(sql, filename, &[], None, false)], &Reporter::Tty, ); @@ -733,10 +408,11 @@ SELECT 1; #[test] fn display_no_violations_tty() { let mut buff = Vec::new(); + let sql = "select 1;"; let res = print_violations( &mut buff, - vec![pretty_violations(vec![], "", "main.sql")], + vec![check_sql(sql, "main.sql", &[], None, false)], &Reporter::Tty, ); @@ -752,20 +428,17 @@ SELECT 1; ALTER TABLE "core_foo" ADD COLUMN "bar" integer NOT NULL; SELECT 1; "#; - let violations = lint_sql(sql); let filename = "main.sql"; let mut buff = Vec::new(); let res = print_violations( &mut buff, - vec![pretty_violations(violations, sql, filename)], + vec![check_sql(sql, filename, &[], None, false)], &Reporter::Json, ); assert!(res.is_ok()); - assert_snapshot!(String::from_utf8_lossy(&buff), @r#" - [{"file":"main.sql","line":1,"column":0,"level":"Warning","messages":[{"Note":"Adding a NOT NULL field without a DEFAULT will fail for a populated table."},{"Help":"Make the field nullable or add a non-VOLATILE DEFAULT (Postgres 11+)."}],"rule_name":"adding-required-field"},{"file":"main.sql","line":3,"column":1,"level":"Warning","messages":[{"Note":"Adding a NOT NULL field without a DEFAULT will fail for a populated table."},{"Help":"Make the field nullable or add a non-VOLATILE DEFAULT (Postgres 11+)."}],"rule_name":"adding-required-field"}] - "#); + assert_snapshot!(String::from_utf8_lossy(&buff), @r###"[{"file":"main.sql","line":1,"column":29,"level":"Warning","message":"Adding a new column that is `NOT NULL` and has no default value to an existing table effectively makes it required.","help":"Make the field nullable or add a non-VOLATILE DEFAULT","rule_name":"adding-required-field"},{"file":"main.sql","line":1,"column":29,"level":"Warning","message":"Missing `IF NOT EXISTS`, the migration can't be rerun if it fails part way through.","help":null,"rule_name":"prefer-robust-stmts"},{"file":"main.sql","line":1,"column":46,"level":"Warning","message":"Using 32-bit integer fields can result in hitting the max `int` limit.","help":"Use 64-bit integer values instead to prevent hitting this limit.","rule_name":"prefer-big-int"},{"file":"main.sql","line":1,"column":46,"level":"Warning","message":"Using 32-bit integer fields can result in hitting the max `int` limit.","help":"Use 64-bit integer values instead to prevent hitting this limit.","rule_name":"prefer-bigint-over-int"},{"file":"main.sql","line":2,"column":23,"level":"Warning","message":"Adding a new column that is `NOT NULL` and has no default value to an existing table effectively makes it required.","help":"Make the field nullable or add a non-VOLATILE DEFAULT","rule_name":"adding-required-field"},{"file":"main.sql","line":2,"column":23,"level":"Warning","message":"Missing `IF NOT EXISTS`, the migration can't be rerun if it fails part way through.","help":null,"rule_name":"prefer-robust-stmts"},{"file":"main.sql","line":2,"column":40,"level":"Warning","message":"Using 32-bit integer fields can result in hitting the max `int` limit.","help":"Use 64-bit integer values instead to prevent hitting this limit.","rule_name":"prefer-big-int"},{"file":"main.sql","line":2,"column":40,"level":"Warning","message":"Using 32-bit integer fields can result in hitting the max `int` limit.","help":"Use 64-bit integer values instead to prevent hitting this limit.","rule_name":"prefer-bigint-over-int"}]"###); } #[test] @@ -776,102 +449,7 @@ SELECT 1; ALTER TABLE "core_foo" ADD COLUMN "bar" integer NOT NULL; SELECT 1; "#; - let violations = lint_sql(sql); - - let filename = "main.sql"; - assert_debug_snapshot!(pretty_violations(violations, sql, filename)); - } - - /// `pretty_violations` was slicing the SQL improperly, trimming off the first - /// letter. - #[test] - fn trimming_sql_newlines() { - let sql = r#"ALTER TABLE "core_recipe" ADD COLUMN "foo" integer NOT NULL;"#; - let violations = lint_sql(sql); - - assert_debug_snapshot!(violations, @r#" - [ - RuleViolation { - kind: AddingRequiredField, - span: Span { - start: 0, - len: Some( - 59, - ), - }, - messages: [ - Note( - "Adding a NOT NULL field without a DEFAULT will fail for a populated table.", - ), - Help( - "Make the field nullable or add a non-VOLATILE DEFAULT (Postgres 11+).", - ), - ], - }, - ] - "#); - let filename = "main.sql"; - assert_debug_snapshot!(pretty_violations(violations, sql, filename), @r#" - ViolationContent { - filename: "main.sql", - sql: "ALTER TABLE \"core_recipe\" ADD COLUMN \"foo\" integer NOT NULL;", - violations: [ - ReportViolation { - file: "main.sql", - line: 1, - column: 0, - level: Warning, - messages: [ - Note( - "Adding a NOT NULL field without a DEFAULT will fail for a populated table.", - ), - Help( - "Make the field nullable or add a non-VOLATILE DEFAULT (Postgres 11+).", - ), - ], - rule_name: AddingRequiredField, - sql: "ALTER TABLE \"core_recipe\" ADD COLUMN \"foo\" integer NOT NULL;", - }, - ], - } - "#); - } - - #[test] - fn regression_slicing_issue_425() { - // Squawk was crashing with an slicing issue. - let sql = "ALTER TABLE test ADD COLUMN IF NOT EXISTS test INTEGER;"; - let violation = RuleViolation::new( - RuleViolationKind::PreferBigInt, - Span { - start: 42, - len: None, - }, - None, - ); - pretty_violations(vec![violation], sql, "main.sql"); - } - #[test] - fn highlight_column_for_issues() { - // Display only the columns with issues for large DDLs. - fn lint_sql(sql: &str) -> Vec { - check_sql_with_rule(sql, &RuleViolationKind::PreferTextField, None, false).unwrap() - } - // Squawk was crashing with an slicing issue. - let sql = "create table test_table ( - col1 varchar(255), - col2 varchar(255), - col3 varchar(255) - --- other columns -);"; - let violations = lint_sql(sql); - let res = pretty_violations(violations, sql, "main.sql"); - let columns = res - .violations - .iter() - .map(|v| v.sql.clone()) - .collect::(); - assert_snapshot!(columns); + assert_debug_snapshot!(check_sql(sql, filename, &[], None, false)); } } diff --git a/crates/cli/src/snapshots/squawk__config__test_config__load_assume_in_transaction.snap b/crates/cli/src/snapshots/squawk__config__test_config__load_assume_in_transaction.snap index 404ab3bd..c5a9b625 100644 --- a/crates/cli/src/snapshots/squawk__config__test_config__load_assume_in_transaction.snap +++ b/crates/cli/src/snapshots/squawk__config__test_config__load_assume_in_transaction.snap @@ -1,5 +1,5 @@ --- -source: cli/src/config.rs +source: crates/cli/src/config.rs expression: "Config::parse(Some(squawk_toml.path().to_path_buf()))" --- Ok( diff --git a/crates/cli/src/snapshots/squawk__config__test_config__load_cfg_full.snap b/crates/cli/src/snapshots/squawk__config__test_config__load_cfg_full.snap index 76bc343a..26c13a29 100644 --- a/crates/cli/src/snapshots/squawk__config__test_config__load_cfg_full.snap +++ b/crates/cli/src/snapshots/squawk__config__test_config__load_cfg_full.snap @@ -1,5 +1,5 @@ --- -source: cli/src/config.rs +source: crates/cli/src/config.rs expression: "Config::parse(Some(squawk_toml.path().to_path_buf()))" --- Ok( diff --git a/crates/cli/src/snapshots/squawk__config__test_config__load_excluded_paths.snap b/crates/cli/src/snapshots/squawk__config__test_config__load_excluded_paths.snap index 8cc7525a..e1b381f8 100644 --- a/crates/cli/src/snapshots/squawk__config__test_config__load_excluded_paths.snap +++ b/crates/cli/src/snapshots/squawk__config__test_config__load_excluded_paths.snap @@ -1,5 +1,5 @@ --- -source: cli/src/config.rs +source: crates/cli/src/config.rs expression: "Config::parse(Some(squawk_toml.path().to_path_buf()))" --- Ok( diff --git a/crates/cli/src/snapshots/squawk__config__test_config__load_excluded_rules.snap b/crates/cli/src/snapshots/squawk__config__test_config__load_excluded_rules.snap index b5111052..af87ef10 100644 --- a/crates/cli/src/snapshots/squawk__config__test_config__load_excluded_rules.snap +++ b/crates/cli/src/snapshots/squawk__config__test_config__load_excluded_rules.snap @@ -1,5 +1,5 @@ --- -source: cli/src/config.rs +source: crates/cli/src/config.rs expression: "Config::parse(Some(squawk_toml.path().to_path_buf()))" --- Ok( diff --git a/crates/cli/src/snapshots/squawk__config__test_config__load_fail_on_violations.snap b/crates/cli/src/snapshots/squawk__config__test_config__load_fail_on_violations.snap index 4efed376..c220dd50 100644 --- a/crates/cli/src/snapshots/squawk__config__test_config__load_fail_on_violations.snap +++ b/crates/cli/src/snapshots/squawk__config__test_config__load_fail_on_violations.snap @@ -1,5 +1,5 @@ --- -source: cli/src/config.rs +source: crates/cli/src/config.rs expression: "Config::parse(Some(squawk_toml.path().to_path_buf()))" --- Ok( diff --git a/crates/cli/src/snapshots/squawk__config__test_config__load_pg_version.snap b/crates/cli/src/snapshots/squawk__config__test_config__load_pg_version.snap index 7d49a7c8..457fbcd3 100644 --- a/crates/cli/src/snapshots/squawk__config__test_config__load_pg_version.snap +++ b/crates/cli/src/snapshots/squawk__config__test_config__load_pg_version.snap @@ -1,5 +1,5 @@ --- -source: cli/src/config.rs +source: crates/cli/src/config.rs expression: "Config::parse(Some(squawk_toml.path().to_path_buf()))" --- Ok( diff --git a/crates/cli/src/snapshots/squawk__reporter__test_github_comment__generating_comment_multiple_files.snap b/crates/cli/src/snapshots/squawk__github__test_github_comment__generating_comment_multiple_files.snap similarity index 61% rename from crates/cli/src/snapshots/squawk__reporter__test_github_comment__generating_comment_multiple_files.snap rename to crates/cli/src/snapshots/squawk__github__test_github_comment__generating_comment_multiple_files.snap index f90336e1..cdbe8d0e 100644 --- a/crates/cli/src/snapshots/squawk__reporter__test_github_comment__generating_comment_multiple_files.snap +++ b/crates/cli/src/snapshots/squawk__github__test_github_comment__generating_comment_multiple_files.snap @@ -1,5 +1,5 @@ --- -source: cli/src/reporter.rs +source: crates/cli/src/github.rs expression: body --- # Squawk Report @@ -20,12 +20,11 @@ SELECT 1; ``` -alpha.sql:1:0: warning: adding-not-nullable-field - - 1 | ALTER TABLE "core_recipe" ADD COLUMN "foo" integer NOT NULL; - - note: Adding a NOT NULL field requires exclusive locks and table rewrites. - help: Make the field nullable. +warning[adding-not-nullable-field]: Adding a NOT NULL field requires exclusive locks and table rewrites. +--> alpha.sql:1:1 + | + | + = help: Make the field nullable. ``` --- diff --git a/crates/cli/src/snapshots/squawk__reporter__test_github_comment__generating_comment_no_violations.snap b/crates/cli/src/snapshots/squawk__github__test_github_comment__generating_comment_no_violations.snap similarity index 95% rename from crates/cli/src/snapshots/squawk__reporter__test_github_comment__generating_comment_no_violations.snap rename to crates/cli/src/snapshots/squawk__github__test_github_comment__generating_comment_no_violations.snap index 56c4017d..adcd97e2 100644 --- a/crates/cli/src/snapshots/squawk__reporter__test_github_comment__generating_comment_no_violations.snap +++ b/crates/cli/src/snapshots/squawk__github__test_github_comment__generating_comment_no_violations.snap @@ -1,5 +1,5 @@ --- -source: cli/src/reporter.rs +source: crates/cli/src/github.rs expression: body --- # Squawk Report diff --git a/crates/cli/src/snapshots/squawk__reporter__test_github_comment__generating_no_violations_no_files.snap b/crates/cli/src/snapshots/squawk__github__test_github_comment__generating_no_violations_no_files.snap similarity index 89% rename from crates/cli/src/snapshots/squawk__reporter__test_github_comment__generating_no_violations_no_files.snap rename to crates/cli/src/snapshots/squawk__github__test_github_comment__generating_no_violations_no_files.snap index 93a0728f..cafbb237 100644 --- a/crates/cli/src/snapshots/squawk__reporter__test_github_comment__generating_no_violations_no_files.snap +++ b/crates/cli/src/snapshots/squawk__github__test_github_comment__generating_no_violations_no_files.snap @@ -1,5 +1,5 @@ --- -source: cli/src/reporter.rs +source: crates/cli/src/github.rs expression: body --- # Squawk Report diff --git a/crates/cli/src/snapshots/squawk__reporter__test_check_files__check_files_invalid_syntax.snap b/crates/cli/src/snapshots/squawk__reporter__test_check_files__check_files_invalid_syntax.snap index 07262d89..3fd6484a 100644 --- a/crates/cli/src/snapshots/squawk__reporter__test_check_files__check_files_invalid_syntax.snap +++ b/crates/cli/src/snapshots/squawk__reporter__test_check_files__check_files_invalid_syntax.snap @@ -1,5 +1,5 @@ --- -source: cli/src/reporter.rs +source: crates/cli/src/reporter.rs expression: val --- -[{"column":0,"file":"test.sql","level":"Error","line":0,"messages":[{"Note":"Postgres failed to parse query: syntax error at or near \"\\\""},{"Help":"Modify your Postgres statement to use valid syntax."}],"rule_name":"invalid-statement"}] +[{"column":6,"file":"test.sql","help":null,"level":"Error","line":1,"message":"expected SEMICOLON","rule_name":"syntax-error"},{"column":7,"file":"test.sql","help":null,"level":"Error","line":1,"message":"expected command, found ERROR","rule_name":"syntax-error"}] diff --git a/crates/cli/src/snapshots/squawk__reporter__test_reporter__display_no_violations_tty.snap b/crates/cli/src/snapshots/squawk__reporter__test_reporter__display_no_violations_tty.snap index 8e01e07b..e10f8546 100644 --- a/crates/cli/src/snapshots/squawk__reporter__test_reporter__display_no_violations_tty.snap +++ b/crates/cli/src/snapshots/squawk__reporter__test_reporter__display_no_violations_tty.snap @@ -1,6 +1,5 @@ --- -source: cli/src/reporter.rs +source: crates/cli/src/reporter.rs expression: "strip_ansi_codes(&String::from_utf8_lossy(&buff))" --- Found 0 issues in 1 file 🎉 - diff --git a/crates/cli/src/snapshots/squawk__reporter__test_reporter__display_violations_tty.snap b/crates/cli/src/snapshots/squawk__reporter__test_reporter__display_violations_tty.snap index b3decbb5..64615c17 100644 --- a/crates/cli/src/snapshots/squawk__reporter__test_reporter__display_violations_tty.snap +++ b/crates/cli/src/snapshots/squawk__reporter__test_reporter__display_violations_tty.snap @@ -1,21 +1,61 @@ --- -source: cli/src/reporter.rs +source: crates/cli/src/reporter.rs expression: "strip_ansi_codes(&String::from_utf8_lossy(&buff))" --- -main.sql:1:0: warning: adding-required-field +warning[adding-required-field]: Adding a new column that is `NOT NULL` and has no default value to an existing table effectively makes it required. + --> main.sql:2:30 + | +2 | ALTER TABLE "core_recipe" ADD COLUMN "foo" integer NOT NULL; + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | + = help: Make the field nullable or add a non-VOLATILE DEFAULT +warning[prefer-robust-stmts]: Missing `IF NOT EXISTS`, the migration can't be rerun if it fails part way through. + --> main.sql:2:30 + | +2 | ALTER TABLE "core_recipe" ADD COLUMN "foo" integer NOT NULL; + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | +warning[prefer-big-int]: Using 32-bit integer fields can result in hitting the max `int` limit. + --> main.sql:2:47 + | +2 | ALTER TABLE "core_recipe" ADD COLUMN "foo" integer NOT NULL; + | ^^^^^^^ + | + = help: Use 64-bit integer values instead to prevent hitting this limit. +warning[prefer-bigint-over-int]: Using 32-bit integer fields can result in hitting the max `int` limit. + --> main.sql:2:47 + | +2 | ALTER TABLE "core_recipe" ADD COLUMN "foo" integer NOT NULL; + | ^^^^^^^ + | + = help: Use 64-bit integer values instead to prevent hitting this limit. +warning[adding-required-field]: Adding a new column that is `NOT NULL` and has no default value to an existing table effectively makes it required. + --> main.sql:3:24 + | +3 | ALTER TABLE "core_foo" ADD COLUMN "bar" integer NOT NULL; + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | + = help: Make the field nullable or add a non-VOLATILE DEFAULT +warning[prefer-robust-stmts]: Missing `IF NOT EXISTS`, the migration can't be rerun if it fails part way through. + --> main.sql:3:24 + | +3 | ALTER TABLE "core_foo" ADD COLUMN "bar" integer NOT NULL; + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | +warning[prefer-big-int]: Using 32-bit integer fields can result in hitting the max `int` limit. + --> main.sql:3:41 + | +3 | ALTER TABLE "core_foo" ADD COLUMN "bar" integer NOT NULL; + | ^^^^^^^ + | + = help: Use 64-bit integer values instead to prevent hitting this limit. +warning[prefer-bigint-over-int]: Using 32-bit integer fields can result in hitting the max `int` limit. + --> main.sql:3:41 + | +3 | ALTER TABLE "core_foo" ADD COLUMN "bar" integer NOT NULL; + | ^^^^^^^ + | + = help: Use 64-bit integer values instead to prevent hitting this limit. - 1 | - 2 | ALTER TABLE "core_recipe" ADD COLUMN "foo" integer NOT NULL; - - note: Adding a NOT NULL field without a DEFAULT will fail for a populated table. - help: Make the field nullable or add a non-VOLATILE DEFAULT (Postgres 11+). - -main.sql:3:1: warning: adding-required-field - - 3 | ALTER TABLE "core_foo" ADD COLUMN "bar" integer NOT NULL; - - note: Adding a NOT NULL field without a DEFAULT will fail for a populated table. - help: Make the field nullable or add a non-VOLATILE DEFAULT (Postgres 11+). - -find detailed examples and solutions for each rule at https://squawkhq.com/docs/rules -Found 2 issues in 1 file (checked 1 source file) +Find detailed examples and solutions for each rule at https://squawkhq.com/docs/rules +Found 8 issues in 1 file (checked 1 source file) diff --git a/crates/cli/src/snapshots/squawk__reporter__test_reporter__highlight_column_for_issues.snap b/crates/cli/src/snapshots/squawk__reporter__test_reporter__highlight_column_for_issues.snap deleted file mode 100644 index e5158419..00000000 --- a/crates/cli/src/snapshots/squawk__reporter__test_reporter__highlight_column_for_issues.snap +++ /dev/null @@ -1,8 +0,0 @@ ---- -source: cli/src/reporter.rs -expression: columns ---- -col1 varchar(255), -col2 varchar(255), -col3 varchar(255) - diff --git a/crates/cli/src/snapshots/squawk__reporter__test_reporter__span_offsets.snap b/crates/cli/src/snapshots/squawk__reporter__test_reporter__span_offsets.snap index e821d116..037107f8 100644 --- a/crates/cli/src/snapshots/squawk__reporter__test_reporter__span_offsets.snap +++ b/crates/cli/src/snapshots/squawk__reporter__test_reporter__span_offsets.snap @@ -1,42 +1,102 @@ --- -source: cli/src/reporter.rs -expression: "pretty_violations(violations, sql, filename)" +source: crates/cli/src/reporter.rs +expression: "check_sql(sql, filename, &[], None, false)" --- -ViolationContent { +CheckReport { filename: "main.sql", sql: "\n\n ALTER TABLE \"core_recipe\" ADD COLUMN \"foo\" integer NOT NULL;\nALTER TABLE \"core_foo\" ADD COLUMN \"bar\" integer NOT NULL;\nSELECT 1;\n", violations: [ ReportViolation { file: "main.sql", line: 2, - column: 2, - level: Warning, - messages: [ - Note( - "Adding a NOT NULL field without a DEFAULT will fail for a populated table.", - ), - Help( - "Make the field nullable or add a non-VOLATILE DEFAULT (Postgres 11+).", - ), - ], - rule_name: AddingRequiredField, - sql: " ALTER TABLE \"core_recipe\" ADD COLUMN \"foo\" integer NOT NULL;", - }, - ReportViolation { - file: "main.sql", - line: 4, - column: 1, - level: Warning, - messages: [ - Note( - "Adding a NOT NULL field without a DEFAULT will fail for a populated table.", - ), - Help( - "Make the field nullable or add a non-VOLATILE DEFAULT (Postgres 11+).", - ), - ], - rule_name: AddingRequiredField, - sql: "ALTER TABLE \"core_foo\" ADD COLUMN \"bar\" integer NOT NULL;", + column: 29, + range: 31..64, + level: Warning, + message: "Adding a new column that is `NOT NULL` and has no default value to an existing table effectively makes it required.", + help: Some( + "Make the field nullable or add a non-VOLATILE DEFAULT", + ), + rule_name: "adding-required-field", + }, + ReportViolation { + file: "main.sql", + line: 2, + column: 29, + range: 31..64, + level: Warning, + message: "Missing `IF NOT EXISTS`, the migration can't be rerun if it fails part way through.", + help: None, + rule_name: "prefer-robust-stmts", + }, + ReportViolation { + file: "main.sql", + line: 2, + column: 46, + range: 48..55, + level: Warning, + message: "Using 32-bit integer fields can result in hitting the max `int` limit.", + help: Some( + "Use 64-bit integer values instead to prevent hitting this limit.", + ), + rule_name: "prefer-big-int", + }, + ReportViolation { + file: "main.sql", + line: 2, + column: 46, + range: 48..55, + level: Warning, + message: "Using 32-bit integer fields can result in hitting the max `int` limit.", + help: Some( + "Use 64-bit integer values instead to prevent hitting this limit.", + ), + rule_name: "prefer-bigint-over-int", + }, + ReportViolation { + file: "main.sql", + line: 3, + column: 23, + range: 89..122, + level: Warning, + message: "Adding a new column that is `NOT NULL` and has no default value to an existing table effectively makes it required.", + help: Some( + "Make the field nullable or add a non-VOLATILE DEFAULT", + ), + rule_name: "adding-required-field", + }, + ReportViolation { + file: "main.sql", + line: 3, + column: 23, + range: 89..122, + level: Warning, + message: "Missing `IF NOT EXISTS`, the migration can't be rerun if it fails part way through.", + help: None, + rule_name: "prefer-robust-stmts", + }, + ReportViolation { + file: "main.sql", + line: 3, + column: 40, + range: 106..113, + level: Warning, + message: "Using 32-bit integer fields can result in hitting the max `int` limit.", + help: Some( + "Use 64-bit integer values instead to prevent hitting this limit.", + ), + rule_name: "prefer-big-int", + }, + ReportViolation { + file: "main.sql", + line: 3, + column: 40, + range: 106..113, + level: Warning, + message: "Using 32-bit integer fields can result in hitting the max `int` limit.", + help: Some( + "Use 64-bit integer values instead to prevent hitting this limit.", + ), + rule_name: "prefer-bigint-over-int", }, ], } diff --git a/crates/cli/src/subcommand.rs b/crates/cli/src/subcommand.rs deleted file mode 100644 index 3bdacda3..00000000 --- a/crates/cli/src/subcommand.rs +++ /dev/null @@ -1,233 +0,0 @@ -#![allow(clippy::too_many_arguments)] -use crate::config::Config; -use crate::{ - file_finding::{find_paths, FindFilesError}, - reporter::{check_files, get_comment_body, CheckFilesError}, -}; -use log::info; -use squawk_github::{actions, app, comment_on_pr, GitHubApi, GithubError}; -use squawk_linter::{versions::Version, violations::RuleViolationKind}; -use structopt::StructOpt; - -const VERSION: &str = env!("CARGO_PKG_VERSION"); - -#[derive(Debug)] -pub enum SquawkError { - CheckFilesError(CheckFilesError), - FindFilesError(FindFilesError), - GithubError(GithubError), - GithubPrivateKeyBase64DecodeError(base64::DecodeError), - GithubPrivateKeyDecodeError(std::string::FromUtf8Error), - GithubPrivateKeyMissing, - GitHubCredentialsMissing, - RulesViolatedError { violations: usize, files: usize }, -} - -impl std::fmt::Display for SquawkError { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - match *self { - Self::CheckFilesError(ref err) => { - write!(f, "Failed to dump AST: {err}") - } - Self::FindFilesError(ref err) => { - write!(f, "Failed to find files: {err}") - } - Self::GithubError(ref err) => err.fmt(f), - Self::GithubPrivateKeyBase64DecodeError(ref err) => write!( - f, - "Failed to decode GitHub private key from base64 encoding: {err}" - ), - Self::GithubPrivateKeyDecodeError(ref err) => { - write!(f, "Could not decode GitHub private key to string: {err}") - } - Self::GithubPrivateKeyMissing => write!(f, "Missing GitHub private key"), - Self::GitHubCredentialsMissing => write!( - f, - "Missing GitHub credentials: - -For a GitHub token: ---github-token is required - -For a GitHub App: ---github-app-id is required ---github-install-id is required ---github-private-key or --github-private-key-base64 is required -" - ), - Self::RulesViolatedError { violations, files } => { - write!(f, "Found {violations} violation(s) across {files} file(s)") - } - } - } -} - -impl std::convert::From for SquawkError { - fn from(e: GithubError) -> Self { - Self::GithubError(e) - } -} - -impl std::convert::From for SquawkError { - fn from(e: CheckFilesError) -> Self { - Self::CheckFilesError(e) - } -} -impl std::convert::From for SquawkError { - fn from(e: FindFilesError) -> Self { - Self::FindFilesError(e) - } -} - -#[derive(StructOpt, Debug)] -pub enum Command { - /// Comment on a PR with Squawk's results. - UploadToGithub { - /// Paths to search - paths: Vec, - /// Exits with an error if violations are found - #[structopt(long)] - fail_on_violations: bool, - #[structopt(long, env = "SQUAWK_GITHUB_PRIVATE_KEY")] - github_private_key: Option, - #[structopt(long, env = "SQUAWK_GITHUB_PRIVATE_KEY_BASE64")] - github_private_key_base64: Option, - #[structopt(long, env = "SQUAWK_GITHUB_TOKEN")] - github_token: Option, - /// GitHub App Id. - #[structopt(long, env = "SQUAWK_GITHUB_APP_ID")] - github_app_id: Option, - /// GitHub Install Id. The installation that squawk is acting on. - #[structopt(long, env = "SQUAWK_GITHUB_INSTALL_ID")] - github_install_id: Option, - /// GitHub Repo Owner - /// github.com/sbdchd/squawk, sbdchd is the owner - #[structopt(long, env = "SQUAWK_GITHUB_REPO_OWNER")] - github_repo_owner: String, - /// GitHub Repo Name - /// github.com/sbdchd/squawk, squawk is the name - #[structopt(long, env = "SQUAWK_GITHUB_REPO_NAME")] - github_repo_name: String, - /// GitHub Pull Request Number - /// github.com/sbdchd/squawk/pull/10, 10 is the PR number - #[structopt(long, env = "SQUAWK_GITHUB_PR_NUMBER")] - github_pr_number: i64, - }, -} - -fn get_github_private_key( - github_private_key: Option, - github_private_key_base64: Option, -) -> Result { - if let Some(private_key) = github_private_key { - Ok(private_key) - } else { - let key = github_private_key_base64.ok_or(SquawkError::GithubPrivateKeyMissing)?; - let bytes = base64::decode(key).map_err(SquawkError::GithubPrivateKeyBase64DecodeError)?; - Ok(String::from_utf8(bytes).map_err(SquawkError::GithubPrivateKeyDecodeError)?) - } -} - -fn create_gh_app( - github_install_id: Option, - github_app_id: Option, - github_token: Option, - github_private_key: Option, - github_private_key_base64: Option, -) -> Result, SquawkError> { - if let Some(github_install_id) = github_install_id { - if let Some(github_app_id) = github_app_id { - info!("using github app client"); - let gh_private_key = - get_github_private_key(github_private_key, github_private_key_base64)?; - return Ok(Box::new(app::GitHub::new( - &gh_private_key, - github_app_id, - github_install_id, - )?)); - } - } - - if let Some(github_token) = github_token { - info!("using github actions client"); - return Ok(Box::new(actions::GitHub::new(&github_token))); - }; - Err(SquawkError::GitHubCredentialsMissing) -} - -pub fn check_and_comment_on_pr( - cmd: Command, - cfg: &Config, - is_stdin: bool, - stdin_path: Option, - exclude: &[RuleViolationKind], - exclude_paths: &[String], - pg_version: Option, - assume_in_transaction: bool, -) -> Result<(), SquawkError> { - let Command::UploadToGithub { - paths, - fail_on_violations, - github_private_key, - github_token, - github_app_id, - github_install_id, - github_repo_owner, - github_repo_name, - github_pr_number, - github_private_key_base64, - } = cmd; - - let fail_on_violations = - if let Some(fail_on_violations_cfg) = cfg.upload_to_github.fail_on_violations { - fail_on_violations_cfg - } else { - fail_on_violations - }; - - let github_app = create_gh_app( - github_install_id, - github_app_id, - github_token, - github_private_key, - github_private_key_base64, - )?; - - let found_paths = find_paths(&paths, exclude_paths)?; - - info!("checking files"); - let file_results = check_files( - &found_paths, - is_stdin, - stdin_path, - exclude, - pg_version, - assume_in_transaction, - )?; - - // We should only leave a comment when there are files checked. - if paths.is_empty() { - info!("no files checked, exiting"); - return Ok(()); - } - info!("generating github comment body"); - let comment_body = get_comment_body(&file_results, VERSION); - - comment_on_pr( - github_app.as_ref(), - &github_repo_owner, - &github_repo_name, - github_pr_number, - &comment_body, - )?; - - let violations: usize = file_results.iter().map(|f| f.violations.len()).sum(); - - if fail_on_violations && violations > 0 { - return Err(SquawkError::RulesViolatedError { - violations, - files: file_results.len(), - }); - } - - Ok(()) -} diff --git a/crates/github/src/lib.rs b/crates/github/src/lib.rs index f74a28d0..d2cfec8b 100644 --- a/crates/github/src/lib.rs +++ b/crates/github/src/lib.rs @@ -3,6 +3,8 @@ #![allow(clippy::single_match_else)] pub mod actions; pub mod app; +use std::error::Error; + use log::info; use serde::{Deserialize, Serialize}; @@ -12,6 +14,15 @@ pub enum GithubError { HttpError(reqwest::Error), } +impl Error for GithubError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + GithubError::JsonWebTokenCreation(err) => Some(err), + GithubError::HttpError(err) => Some(err), + } + } +} + #[derive(Debug, Deserialize, Serialize)] pub struct Comment { pub id: i64, diff --git a/crates/squawk_parser/src/snapshots/parser__test__alter_table_err.snap.new b/crates/squawk_parser/src/snapshots/parser__test__alter_table_err.snap similarity index 99% rename from crates/squawk_parser/src/snapshots/parser__test__alter_table_err.snap.new rename to crates/squawk_parser/src/snapshots/parser__test__alter_table_err.snap index aeedc020..f8a94c59 100644 --- a/crates/squawk_parser/src/snapshots/parser__test__alter_table_err.snap.new +++ b/crates/squawk_parser/src/snapshots/parser__test__alter_table_err.snap @@ -1,6 +1,5 @@ --- source: crates/squawk_parser/src/test.rs -assertion_line: 88 input_file: crates/squawk_parser/test_data/err/alter_table.sql --- SOURCE_FILE diff --git a/docs/docs/cli.md b/docs/docs/cli.md index 57ea72b0..d80e00fb 100644 --- a/docs/docs/cli.md +++ b/docs/docs/cli.md @@ -41,7 +41,6 @@ squawk --config=~/.squawk.toml example.sql The `--exclude`, `--exclude-path`, and `--pg-version` flags will always be prioritized over the configuration file. - ## example `.squawk.toml` configurations ### excluding rules @@ -86,11 +85,8 @@ excluded_paths = [ fail_on_violations = true ``` - - See the [Squawk website](https://squawkhq.com/docs/rules) for documentation on each rule with examples and reasoning. - ## `squawk --help` ``` @@ -108,9 +104,6 @@ FLAGS: -h, --help Prints help information - --list-rules - List all available rules - -V, --version Prints version information @@ -122,15 +115,13 @@ OPTIONS: -c, --config Path to the squawk config file (.squawk.toml) - --dump-ast - Output AST in JSON [possible values: Raw, Parsed, Debug] + --debug + Output debug format [possible values: Lex, Parsed] -e, --exclude ... Exclude specific warnings For example: --exclude=require-concurrent-index-creation,ban-drop-database - --explain - Provide documentation on the given rule --pg-version Specify postgres version