squawk_ide/
code_actions.rs1use rowan::TextSize;
2use squawk_linter::Edit;
3use squawk_syntax::{
4 SyntaxKind,
5 ast::{self, AstNode},
6};
7
8#[derive(Debug, Clone)]
9pub struct CodeAction {
10 pub title: String,
11 pub edits: Vec<Edit>,
12}
13
14pub fn code_actions(file: ast::SourceFile, offset: TextSize) -> Option<Vec<CodeAction>> {
15 let mut actions = vec![];
16 remove_else_clause(&mut actions, &file, offset);
17 Some(actions)
18}
19
20fn remove_else_clause(
21 actions: &mut Vec<CodeAction>,
22 file: &ast::SourceFile,
23 offset: TextSize,
24) -> Option<()> {
25 let else_token = file
26 .syntax()
27 .token_at_offset(offset)
28 .find(|x| x.kind() == SyntaxKind::ELSE_KW)?;
29 let parent = else_token.parent()?;
30 let else_clause = ast::ElseClause::cast(parent)?;
31
32 let mut edits = vec![];
33 edits.push(Edit::delete(else_clause.syntax().text_range()));
34 if let Some(token) = else_token.prev_token() {
35 if token.kind() == SyntaxKind::WHITESPACE {
36 edits.push(Edit::delete(token.text_range()));
37 }
38 }
39
40 actions.push(CodeAction {
41 title: "Remove `else` clause".to_owned(),
42 edits,
43 });
44 Some(())
45}
46
47#[cfg(test)]
48mod test {
49 use super::*;
50 use crate::test_utils::fixture;
51 use insta::assert_snapshot;
52 use rowan::TextSize;
53 use squawk_syntax::ast;
54
55 fn apply_code_action(
56 f: impl Fn(&mut Vec<CodeAction>, &ast::SourceFile, TextSize) -> Option<()>,
57 sql: &str,
58 ) -> String {
59 let (offset, sql) = fixture(sql);
60 let parse = ast::SourceFile::parse(&sql);
61 assert_eq!(parse.errors(), vec![]);
62 let file: ast::SourceFile = parse.tree();
63
64 let mut actions = vec![];
65 f(&mut actions, &file, offset);
66
67 assert!(
68 !actions.is_empty(),
69 "We should always have actions for `apply_code_action`. If you want to ensure there are no actions, use `code_action_not_applicable` instead."
70 );
71
72 let action = &actions[0];
73 let mut result = sql.clone();
74
75 let mut edits = action.edits.clone();
76 edits.sort_by_key(|e| e.text_range.start());
77 check_overlap(&edits);
78 edits.reverse();
79
80 for edit in edits {
81 let start: usize = edit.text_range.start().into();
82 let end: usize = edit.text_range.end().into();
83 let replacement = edit.text.as_deref().unwrap_or("");
84 result.replace_range(start..end, replacement);
85 }
86
87 let reparse = ast::SourceFile::parse(&result);
88 assert_eq!(
89 reparse.errors(),
90 vec![],
91 "Code actions shouldn't cause syntax errors"
92 );
93
94 result
95 }
96
97 fn check_overlap(edits: &[Edit]) {
102 for (edit_i, edit_j) in edits.iter().zip(edits.iter().skip(1)) {
103 if let Some(intersection) = edit_i.text_range.intersect(edit_j.text_range) {
104 assert!(
105 intersection.is_empty(),
106 "Edit ranges must not overlap: {:?} and {:?} intersect at {:?}",
107 edit_i.text_range,
108 edit_j.text_range,
109 intersection
110 );
111 }
112 }
113 }
114
115 fn code_action_not_applicable(
116 f: impl Fn(&mut Vec<CodeAction>, &ast::SourceFile, TextSize) -> Option<()>,
117 sql: &str,
118 ) -> bool {
119 let (offset, sql) = fixture(sql);
120 let parse = ast::SourceFile::parse(&sql);
121 assert_eq!(parse.errors(), vec![]);
122 let file: ast::SourceFile = parse.tree();
123
124 let mut actions = vec![];
125 f(&mut actions, &file, offset);
126 actions.is_empty()
127 }
128
129 #[test]
130 fn remove_else_clause_() {
131 assert_snapshot!(apply_code_action(
132 remove_else_clause,
133 "select case x when true then 1 else$0 2 end;"),
134 @"select case x when true then 1 end;"
135 );
136 }
137
138 #[test]
139 fn remove_else_clause_before_token() {
140 assert_snapshot!(apply_code_action(
141 remove_else_clause,
142 "select case x when true then 1 $0else 2 end;"),
143 @"select case x when true then 1 end;"
144 );
145 }
146
147 #[test]
148 fn remove_else_clause_not_applicable() {
149 assert!(code_action_not_applicable(
150 remove_else_clause,
151 "select case x when true then 1 else 2 end$0;"
152 ));
153 }
154}