squawk-server 2.62.0

LSP server for Squawk
Documentation
use squawk_line_index::{LineEnding, 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>,
    line_ending: LineEnding,
) -> 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, ignore_comment_range, _ignore_kind) =
                squawk_linter::ignore::ignore_rule_info(&previous_line_token)?;
            Some(Edit::insert(
                format!(" {rule_name},"),
                ignore_comment_range.start(),
            ))
        }

        // TODO: we need to handle indenting correctly
        _ => Some(Edit::insert(
            format!("-- {IGNORE_LINE_TEXT} {rule_name}{}", line_ending.as_str()),
            line_index.line(violation_line.line)?.start(),
        )),
    }
}

pub(crate) fn ignore_file_edit(
    violation: &Violation,
    _line_index: &LineIndex,
    _parse: &Parse<SourceFile>,
    line_ending: LineEnding,
) -> 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}{}", line_ending.as_str()),
        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};
    use insta::assert_snapshot;
    use squawk_ide::db::{Database, File};

    #[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(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<_>>();
        assert_snapshot!(apply_text_edits(sql, ignore_line_edits), @r"
        -- squawk-ignore prefer-robust-stmts
        create table a (
        -- squawk-ignore prefer-bigint-over-int
          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 (
        -- squawk-ignore prefer-bigint-over-int
          b int
        );

        -- squawk-ignore prefer-robust-stmts, prefer-text-field
        create table c (
        -- squawk-ignore prefer-bigint-over-int
          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(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<_>>();
        assert_snapshot!(apply_text_edits(sql, ignore_line_edits), @r"
        -- squawk-ignore-file prefer-bigint-over-int
        -- squawk-ignore-file prefer-robust-stmts
        -- squawk-ignore-file prefer-bigint-over-int
        -- squawk-ignore-file prefer-robust-stmts
        -- squawk-ignore-file prefer-bigint-over-int
        -- 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<gen_lsp_types::TextEdit>) -> String {
        use squawk_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
    }

    fn lint_sql(sql: &str) -> Vec<gen_lsp_types::Diagnostic> {
        let db = Database::default();
        let file = File::new(&db, sql.to_owned().into());
        lint(&db, file)
    }

    fn ignore_line_edits_with_line_ending(line_ending: &str) -> String {
        use squawk_line_index::{LineIndex, find_newline};
        use squawk_linter::Linter;
        use squawk_syntax::SourceFile;

        let sql = [
            "create table a (",
            "  a int",
            ");",
            "",
            "-- squawk-ignore prefer-text-field",
            "create table b (",
            "  b text",
            ");",
            "",
        ]
        .join(line_ending);

        let parse = SourceFile::parse(&sql);
        let line_index = LineIndex::new(&sql);
        let detected = find_newline(&sql)
            .map(|(_, ending)| ending)
            .unwrap_or_default();
        let mut edits = Linter::with_default_rules()
            .lint(&parse, &sql)
            .iter()
            .filter_map(|v| super::ignore_line_edit(v, &line_index, &parse, detected))
            .collect::<Vec<_>>();

        edits.sort_by_key(|e| std::cmp::Reverse(e.text_range.start()));

        let mut result = sql.clone();
        for edit in edits {
            let start: usize = edit.text_range.start().into();
            let end: usize = edit.text_range.end().into();
            result.replace_range(start..end, &edit.text.unwrap_or_default());
        }
        result.replace('\r', "<CR>")
    }

    #[test]
    fn ignore_line_with_lf_line_endings() {
        assert_snapshot!(ignore_line_edits_with_line_ending("\n"), @"
        create table a (
        -- squawk-ignore prefer-bigint-over-int
          a int
        );

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

    #[test]
    fn ignore_line_with_crlf_line_endings() {
        assert_snapshot!(ignore_line_edits_with_line_ending("\r\n"), @"
        create table a (<CR>
        -- squawk-ignore prefer-bigint-over-int<CR>
          a int<CR>
        );<CR>
        <CR>
        -- squawk-ignore prefer-robust-stmts, prefer-text-field<CR>
        create table b (<CR>
          b text<CR>
        );<CR>
        ");
    }

    #[test]
    fn ignore_line_with_cr_line_endings() {
        assert_snapshot!(ignore_line_edits_with_line_ending("\r"), @"create table a (<CR>-- squawk-ignore prefer-bigint-over-int<CR>  a int<CR>);<CR><CR>-- squawk-ignore prefer-robust-stmts, prefer-text-field<CR>create table b (<CR>  b text<CR>);<CR>");
    }
}