Skip to content
Merged
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
1 change: 1 addition & 0 deletions Cargo.lock

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

8 changes: 4 additions & 4 deletions crates/squawk_linter/src/ignore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,10 @@ fn comment_body(token: &SyntaxToken) -> Option<(&str, TextRange)> {
}

// TODO: maybe in a future version we can rename this to squawk-ignore-line
const IGNORE_LINE_TEXT: &str = "squawk-ignore";
const IGNORE_FILE_TEXT: &str = "squawk-ignore-file";
pub const IGNORE_LINE_TEXT: &str = "squawk-ignore";
pub const IGNORE_FILE_TEXT: &str = "squawk-ignore-file";

fn ignore_rule_names(token: &SyntaxToken) -> Option<(&str, TextRange, IgnoreKind)> {
pub fn ignore_rule_info(token: &SyntaxToken) -> Option<(&str, TextRange, IgnoreKind)> {
if let Some((comment_body, range)) = comment_body(token) {
let without_start = comment_body.trim_start();
let trim_start_size = comment_body.len() - without_start.len();
Expand Down Expand Up @@ -74,7 +74,7 @@ pub(crate) fn find_ignores(ctx: &mut Linter, file: &SyntaxNode) {
rowan::WalkEvent::Enter(NodeOrToken::Token(token))
if token.kind() == SyntaxKind::COMMENT =>
{
if let Some((rule_names, range, kind)) = ignore_rule_names(&token) {
if let Some((rule_names, range, kind)) = ignore_rule_info(&token) {
let mut set = HashSet::new();
let mut offset = 0usize;

Expand Down
4 changes: 2 additions & 2 deletions crates/squawk_linter/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use squawk_syntax::{Parse, SourceFile};

pub use version::Version;

mod ignore;
pub mod ignore;
mod ignore_index;
mod version;
mod visitors;
Expand Down Expand Up @@ -250,7 +250,7 @@ pub struct Edit {
pub text: Option<String>,
}
impl Edit {
fn insert<T: Into<String>>(text: T, at: TextSize) -> Self {
pub fn insert<T: Into<String>>(text: T, at: TextSize) -> Self {
Self {
text_range: TextRange::new(at, at),
text: Some(text.into()),
Expand Down
1 change: 1 addition & 0 deletions crates/squawk_server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ squawk_lexer.workspace = true
squawk_linter.workspace = true
squawk_syntax.workspace = true
line-index.workspace = true
insta.workspace = true

[lints]
workspace = true
19 changes: 19 additions & 0 deletions crates/squawk_server/src/diagnostic.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
use serde::{Deserialize, Serialize};

pub(crate) const DIAGNOSTIC_NAME: &str = "squawk";

// Based on Ruff's setup for LSP diagnostic edits
// see: https://github.com/astral-sh/ruff/blob/1a368b0bf97c3d0246390679166bbd2d589acf39/crates/ruff_server/src/lint.rs#L31
/// This is serialized on the diagnostic `data` field.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub(crate) struct AssociatedDiagnosticData {
/// The message describing what the fix does, if it exists, or the diagnostic name otherwise.
pub(crate) title: String,
/// Edits to fix the diagnostic. If this is empty, a fix
/// does not exist.
pub(crate) edits: Vec<lsp_types::TextEdit>,
/// Edit to ignore the rule the line
pub(crate) ignore_line_edit: Option<lsp_types::TextEdit>,
/// Edit to ignore the rule for the file
pub(crate) ignore_file_edit: Option<lsp_types::TextEdit>,
}
190 changes: 190 additions & 0 deletions crates/squawk_server/src/ignore.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
use line_index::{LineIndex, TextSize};
use squawk_linter::{
Edit, Rule, Violation,
ignore::{IGNORE_FILE_TEXT, IGNORE_LINE_TEXT},
};
use squawk_syntax::{Parse, SourceFile, SyntaxKind, SyntaxToken, ast::AstNode};

const UNSUPPORTED_RULES: &[Rule] = &[Rule::UnusedIgnore];

pub(crate) fn ignore_line_edit(
violation: &Violation,
line_index: &LineIndex,
parse: &Parse<SourceFile>,
) -> Option<Edit> {
if UNSUPPORTED_RULES.contains(&violation.code) {
return None;
}
let tree = parse.tree();
let rule_name = violation.code.to_string();

let violation_line = line_index.line_col(violation.text_range.start());
let previous_line = violation_line.line.checked_sub(1)?;
let previous_line_offset = line_index.line(previous_line)?.start();
let previous_line_token = tree
.syntax()
.token_at_offset(previous_line_offset)
.right_biased()?;

match previous_line_token.kind() {
SyntaxKind::COMMENT if is_ignore_comment(&previous_line_token) => {
let (_str, range, _ignore_kind) =
squawk_linter::ignore::ignore_rule_info(&previous_line_token)?;
Some(Edit::insert(format!(" {rule_name},"), range.start()))
}
_ => Some(Edit::insert(
format!("-- {IGNORE_LINE_TEXT} {rule_name}\n"),
violation.text_range.start(),
)),
}
}

pub(crate) fn ignore_file_edit(
violation: &Violation,
_line_index: &LineIndex,
_parse: &Parse<SourceFile>,
) -> Option<Edit> {
if UNSUPPORTED_RULES.contains(&violation.code) {
return None;
}
let rule_name = violation.code.to_string();
Some(Edit::insert(
format!("-- {IGNORE_FILE_TEXT} {rule_name}\n"),
TextSize::new(0),
))
}

fn is_ignore_comment(token: &SyntaxToken) -> bool {
assert_eq!(token.kind(), SyntaxKind::COMMENT);
squawk_linter::ignore::ignore_rule_info(&token).is_some()
}

#[cfg(test)]
mod test {
use crate::{diagnostic::AssociatedDiagnosticData, lint::lint};

#[test]
fn ignore_line() {
let sql = "
create table a (
a int
);

-- an existing comment that shouldn't get in the way of us adding a new ignore
create table b (
b int
);

-- squawk-ignore prefer-text-field
create table c (
b int
);
";
let ignore_line_edits = lint(sql)
.into_iter()
.flat_map(|x| {
let data = x.data?;
let associated_data: AssociatedDiagnosticData =
serde_json::from_value(data).unwrap();
associated_data.ignore_line_edit
})
.collect::<Vec<_>>();
insta::assert_snapshot!(apply_text_edits(sql, ignore_line_edits), @r"
-- squawk-ignore prefer-robust-stmts
create table a (
a int
);

-- an existing comment that shouldn't get in the way of us adding a new ignore
-- squawk-ignore prefer-robust-stmts
create table b (
b int
);

-- squawk-ignore prefer-robust-stmts, prefer-text-field
create table c (
b int
);
");
}

#[test]
fn ignore_file() {
let sql = "
-- some existing comment
create table a (
a int
);

create table b (
b int
);

create table c (
b int
);
";
let ignore_line_edits = lint(sql)
.into_iter()
.flat_map(|x| {
let data = x.data?;
let associated_data: AssociatedDiagnosticData =
serde_json::from_value(data).unwrap();
associated_data.ignore_file_edit
})
.collect::<Vec<_>>();
insta::assert_snapshot!(apply_text_edits(sql, ignore_line_edits), @r"
-- squawk-ignore-file prefer-robust-stmts
-- squawk-ignore-file prefer-robust-stmts
-- squawk-ignore-file prefer-robust-stmts

-- some existing comment
create table a (
a int
);

create table b (
b int
);

create table c (
b int
);
");
}

fn apply_text_edits(sql: &str, mut edits: Vec<lsp_types::TextEdit>) -> String {
use line_index::{LineCol, LineIndex};

// Sort edits by position (reverse order to apply from end to start)
edits.sort_by(|a, b| {
b.range
.start
.line
.cmp(&a.range.start.line)
.then_with(|| b.range.start.character.cmp(&a.range.start.character))
});

let line_index = LineIndex::new(sql);
let mut result = sql.to_string();

for edit in edits {
// Convert LSP positions to byte offsets
let start_offset = line_index.offset(LineCol {
line: edit.range.start.line,
col: edit.range.start.character,
});
let end_offset = line_index.offset(LineCol {
line: edit.range.end.line,
col: edit.range.end.character,
});

let start_byte: usize = start_offset.unwrap_or_default().into();
let end_byte: usize = end_offset.unwrap_or_default().into();

result.replace_range(start_byte..end_byte, &edit.new_text);
}

result
}
}
Loading
Loading