Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ members = [
"crates/uroborosql-fmt",
"crates/uroborosql-fmt-cli",
"crates/uroborosql-fmt-napi",
"crates/uroborosql-fmt-wasm"
"crates/uroborosql-fmt-wasm",
"crates/uroborosql-lint",
"crates/uroborosql-lint-cli"
]
resolver = "2"

Expand All @@ -16,6 +18,7 @@ repository = "https://github.com/future-architect/uroborosql-fmt"
[workspace.dependencies]
# Internal crates
uroborosql-fmt = { path = "./crates/uroborosql-fmt" }
postgresql-cst-parser = { git = "https://github.com/future-architect/postgresql-cst-parser", branch = "feat/new-apis-for-linter" }

[profile.release]
lto = true
2 changes: 1 addition & 1 deletion crates/uroborosql-fmt/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ serde_json = "1.0.91"
thiserror = "1.0.38"

# git config --global core.longpaths true を管理者権限で実行しないとけない
postgresql-cst-parser = { git = "https://github.com/future-architect/postgresql-cst-parser" }
postgresql-cst-parser = { workspace = true }

[dev-dependencies]
console = "0.15.10"
Expand Down
10 changes: 10 additions & 0 deletions crates/uroborosql-lint-cli/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[package]
name = "uroborosql-lint-cli"
version = "0.1.0"
authors.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true

[dependencies]
uroborosql-lint = { path = "../uroborosql-lint" }
63 changes: 63 additions & 0 deletions crates/uroborosql-lint-cli/src/main.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
use std::{env, fs, path::PathBuf, process};

use uroborosql_lint::{Diagnostic, LintError, Linter};

fn main() {
if let Err(err) = run() {
eprintln!("{err}");
process::exit(1);
}
}

fn run() -> Result<(), String> {
let mut args = env::args_os().skip(1);
let Some(path_os) = args.next() else {
return Err(format!(
"Usage: {} <SQL_FILE>...",
env::args()
.next()
.unwrap_or_else(|| "uroborosql-lint-cli".to_string())
));
};

if args.next().is_some() {
return Err("Only a single SQL file can be specified".into());
}

let linter = Linter::new();
let mut exit_with_error = false;

let path = PathBuf::from(path_os);
let display = path.display().to_string();

let sql =
fs::read_to_string(&path).map_err(|err| format!("Failed to read {}: {}", display, err))?;

match linter.run(&sql) {
Ok(diagnostics) => {
for diagnostic in diagnostics {
print_diagnostic(&display, &diagnostic);
}
}
Err(LintError::ParseError(message)) => {
eprintln!("{}: failed to parse SQL: {}", display, message);
exit_with_error = true;
}
}

if exit_with_error {
Err("Linting finished with errors".into())
} else {
Ok(())
}
}

fn print_diagnostic(file: &str, diagnostic: &Diagnostic) {
let line = diagnostic.span.start.line + 1;
let column = diagnostic.span.start.column + 1;

println!(
"{}:{}:{}: {}: {}",
file, line, column, diagnostic.rule_id, diagnostic.message
);
}
10 changes: 10 additions & 0 deletions crates/uroborosql-lint/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[package]
name = "uroborosql-lint"
version = "0.1.0"
authors.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true

[dependencies]
postgresql-cst-parser = { workspace = true }
22 changes: 22 additions & 0 deletions crates/uroborosql-lint/src/context.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
use crate::diagnostic::Diagnostic;

/// Mutable linting context shared across rules.
pub struct LintContext {
diagnostics: Vec<Diagnostic>,
}

impl LintContext {
pub fn new(_source: &str) -> Self {
Self {
diagnostics: Vec::new(),
}
}

pub fn report(&mut self, diagnostic: Diagnostic) {
self.diagnostics.push(diagnostic);
}

pub fn into_diagnostics(self) -> Vec<Diagnostic> {
self.diagnostics
}
}
62 changes: 62 additions & 0 deletions crates/uroborosql-lint/src/diagnostic.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
use postgresql_cst_parser::tree_sitter::Range;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Error,
Warning,
Info,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Position {
pub line: usize,
pub column: usize,
pub byte: usize,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SqlSpan {
pub start: Position,
pub end: Position,
}

impl SqlSpan {
pub fn from_range(range: &Range) -> Self {
SqlSpan {
start: Position {
line: range.start_position.row,
column: range.start_position.column,
byte: range.start_byte,
},
end: Position {
line: range.end_position.row,
column: range.end_position.column,
byte: range.end_byte,
},
}
}
}

#[derive(Debug, Clone)]
pub struct Diagnostic {
pub rule_id: &'static str,
pub message: String,
pub severity: Severity,
pub span: SqlSpan,
}

impl Diagnostic {
pub fn new(
rule_id: &'static str,
severity: Severity,
message: impl Into<String>,
range: &Range,
) -> Self {
Self {
rule_id,
severity,
message: message.into(),
span: SqlSpan::from_range(range),
}
}
}
9 changes: 9 additions & 0 deletions crates/uroborosql-lint/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
mod context;
mod diagnostic;
mod linter;
mod rule;
mod rules;
mod tree;

pub use diagnostic::{Diagnostic, Severity, SqlSpan};
pub use linter::{LintError, LintOptions, Linter};
138 changes: 138 additions & 0 deletions crates/uroborosql-lint/src/linter.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
use crate::{
context::LintContext,
diagnostic::{Diagnostic, Severity},
rule::Rule,
rules::{
MissingTwoWaySample, NoDistinct, NoNotIn, NoUnionDistinct, NoWildcardProjection,
TooLargeInList,
},
tree::collect_preorder,
};
use postgresql_cst_parser::tree_sitter;
use std::collections::HashMap;

#[derive(Debug)]
pub enum LintError {
ParseError(String),
}

#[derive(Debug, Clone, Default)]
pub struct LintOptions {
severity_overrides: HashMap<String, Severity>,
}

impl LintOptions {
pub fn new() -> Self {
Self::default()
}

pub fn severity_for(&self, rule_id: &str) -> Option<Severity> {
self.severity_overrides.get(rule_id).copied()
}

pub fn set_severity_override(&mut self, rule_id: impl Into<String>, severity: Severity) {
self.severity_overrides.insert(rule_id.into(), severity);
}

pub fn with_severity_override(
mut self,
rule_id: impl Into<String>,
severity: Severity,
) -> Self {
self.set_severity_override(rule_id, severity);
self
}
}

pub struct Linter {
rules: Vec<Box<dyn Rule>>,
options: LintOptions,
}

impl Default for Linter {
fn default() -> Self {
Self::new()
}
}

impl Linter {
pub fn new() -> Self {
Self::with_options(LintOptions::default())
}

pub fn with_options(options: LintOptions) -> Self {
Self::with_rules_and_options(default_rules(), options)
}

pub fn with_rules(rules: Vec<Box<dyn Rule>>) -> Self {
Self::with_rules_and_options(rules, LintOptions::default())
}

pub fn with_rules_and_options(rules: Vec<Box<dyn Rule>>, options: LintOptions) -> Self {
Self { rules, options }
}

pub fn run(&self, sql: &str) -> Result<Vec<Diagnostic>, LintError> {
let tree = tree_sitter::parse_2way(sql)
.map_err(|err| LintError::ParseError(format!("{err:?}")))?;
let root = tree.root_node();
let nodes = collect_preorder(root.clone());
let mut ctx = LintContext::new(sql);

for rule in &self.rules {
let severity = self
.options
.severity_for(rule.name())
.unwrap_or_else(|| rule.default_severity());

rule.run_once(&root, &mut ctx, severity);

let targets = rule.target_kinds();
if targets.is_empty() {
for node in &nodes {
rule.run_on_node(node, &mut ctx, severity);
}
} else {
for node in &nodes {
if targets.iter().any(|kind| node.kind() == *kind) {
rule.run_on_node(node, &mut ctx, severity);
}
}
}
}

Ok(ctx.into_diagnostics())
}
}

fn default_rules() -> Vec<Box<dyn Rule>> {
vec![
Box::new(NoDistinct),
Box::new(NoNotIn),
Box::new(NoUnionDistinct),
Box::new(NoWildcardProjection),
Box::new(MissingTwoWaySample),
Box::new(TooLargeInList),
]
}

#[cfg(test)]
pub mod tests {
use super::*;
use crate::diagnostic::Severity;

pub fn run_with_rules(sql: &str, rules: Vec<Box<dyn Rule>>) -> Vec<Diagnostic> {
Linter::with_rules(rules).run(sql).expect("lint ok")
}

#[test]
fn applies_severity_override() {
let options = LintOptions::default().with_severity_override("no-distinct", Severity::Error);
let linter = Linter::with_options(options);
let sql = "SELECT DISTINCT id FROM users;";
let diagnostics = linter.run(sql).expect("lint ok");

assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].severity, Severity::Error);
}
}
13 changes: 13 additions & 0 deletions crates/uroborosql-lint/src/rule.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
use crate::{context::LintContext, diagnostic::Severity};
use postgresql_cst_parser::{syntax_kind::SyntaxKind, tree_sitter::Node};

pub trait Rule: Send + Sync {
fn name(&self) -> &'static str;
fn default_severity(&self) -> Severity;
fn target_kinds(&self) -> &'static [SyntaxKind] {
&[]
}
fn run_once<'tree>(&self, _root: &Node<'tree>, _ctx: &mut LintContext, _severity: Severity) {}
fn run_on_node<'tree>(&self, _node: &Node<'tree>, _ctx: &mut LintContext, _severity: Severity) {
}
}
Loading
Loading