Skip to content

Commit 81c07f9

Browse files
authored
server: string rewrites (#725)
`'foo'` -> `$$foo$$` and `$$foo$$` -> `'foo'`
1 parent 8c116dd commit 81c07f9

File tree

3 files changed

+209
-2
lines changed

3 files changed

+209
-2
lines changed

crates/squawk_ide/src/code_actions.rs

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,128 @@ use squawk_syntax::{
55
ast::{self, AstNode},
66
};
77

8+
#[derive(Debug, Clone)]
9+
pub enum ActionKind {
10+
QuickFix,
11+
RefactorRewrite,
12+
}
13+
814
#[derive(Debug, Clone)]
915
pub struct CodeAction {
1016
pub title: String,
1117
pub edits: Vec<Edit>,
18+
pub kind: ActionKind,
1219
}
1320

1421
pub fn code_actions(file: ast::SourceFile, offset: TextSize) -> Option<Vec<CodeAction>> {
1522
let mut actions = vec![];
23+
rewrite_as_regular_string(&mut actions, &file, offset);
24+
rewrite_as_dollar_quoted_string(&mut actions, &file, offset);
1625
remove_else_clause(&mut actions, &file, offset);
1726
Some(actions)
1827
}
1928

29+
fn rewrite_as_regular_string(
30+
actions: &mut Vec<CodeAction>,
31+
file: &ast::SourceFile,
32+
offset: TextSize,
33+
) -> Option<()> {
34+
let dollar_string = file
35+
.syntax()
36+
.token_at_offset(offset)
37+
.find(|token| token.kind() == SyntaxKind::DOLLAR_QUOTED_STRING)?;
38+
39+
let replacement = dollar_quoted_to_string(dollar_string.text())?;
40+
actions.push(CodeAction {
41+
title: "Rewrite as regular string".to_owned(),
42+
edits: vec![Edit::replace(dollar_string.text_range(), replacement)],
43+
kind: ActionKind::RefactorRewrite,
44+
});
45+
46+
Some(())
47+
}
48+
49+
fn rewrite_as_dollar_quoted_string(
50+
actions: &mut Vec<CodeAction>,
51+
file: &ast::SourceFile,
52+
offset: TextSize,
53+
) -> Option<()> {
54+
let string = file
55+
.syntax()
56+
.token_at_offset(offset)
57+
.find(|token| token.kind() == SyntaxKind::STRING)?;
58+
59+
let replacement = string_to_dollar_quoted(string.text())?;
60+
actions.push(CodeAction {
61+
title: "Rewrite as dollar-quoted string".to_owned(),
62+
edits: vec![Edit::replace(string.text_range(), replacement)],
63+
kind: ActionKind::RefactorRewrite,
64+
});
65+
66+
Some(())
67+
}
68+
69+
fn string_to_dollar_quoted(text: &str) -> Option<String> {
70+
let normalized = normalize_single_quoted_string(text)?;
71+
let delimiter = dollar_delimiter(&normalized)?;
72+
let boundary = format!("${}$", delimiter);
73+
Some(format!("{boundary}{normalized}{boundary}"))
74+
}
75+
76+
fn dollar_quoted_to_string(text: &str) -> Option<String> {
77+
debug_assert!(text.starts_with('$'));
78+
let (delimiter, content) = split_dollar_quoted(text)?;
79+
let boundary = format!("${}$", delimiter);
80+
81+
if !text.starts_with(&boundary) || !text.ends_with(&boundary) {
82+
return None;
83+
}
84+
85+
// quotes are escaped by using two of them in Postgres
86+
let escaped = content.replace('\'', "''");
87+
Some(format!("'{}'", escaped))
88+
}
89+
90+
fn split_dollar_quoted(text: &str) -> Option<(String, &str)> {
91+
debug_assert!(text.starts_with('$'));
92+
let second_dollar = text[1..].find('$')?;
93+
// the `foo` in `select $foo$bar$foo$`
94+
let delimiter = &text[1..=second_dollar];
95+
let boundary = format!("${}$", delimiter);
96+
97+
if !text.ends_with(&boundary) {
98+
return None;
99+
}
100+
101+
let start = boundary.len();
102+
let end = text.len().checked_sub(boundary.len())?;
103+
let content = text.get(start..end)?;
104+
Some((delimiter.to_owned(), content))
105+
}
106+
107+
fn normalize_single_quoted_string(text: &str) -> Option<String> {
108+
let body = text.strip_prefix('\'')?.strip_suffix('\'')?;
109+
return Some(body.replace("''", "'"));
110+
}
111+
112+
fn dollar_delimiter(content: &str) -> Option<String> {
113+
// We can't safely transform a trailing `$` i.e., `select 'foo $'` with an
114+
// empty delim, because we'll `select $$foo $$$` which isn't valid.
115+
if !content.contains("$$") && !content.ends_with('$') {
116+
return Some("".to_owned());
117+
}
118+
119+
let mut delim = "q".to_owned();
120+
// don't want to just loop forever
121+
for idx in 0..10 {
122+
if !content.contains(&format!("${}$", delim)) {
123+
return Some(delim);
124+
}
125+
delim.push_str(&idx.to_string());
126+
}
127+
None
128+
}
129+
20130
fn remove_else_clause(
21131
actions: &mut Vec<CodeAction>,
22132
file: &ast::SourceFile,
@@ -40,6 +150,7 @@ fn remove_else_clause(
40150
actions.push(CodeAction {
41151
title: "Remove `else` clause".to_owned(),
42152
edits,
153+
kind: ActionKind::RefactorRewrite,
43154
});
44155
Some(())
45156
}
@@ -151,4 +262,91 @@ mod test {
151262
"select case x when true then 1 else 2 end$0;"
152263
));
153264
}
265+
266+
#[test]
267+
fn rewrite_string() {
268+
assert_snapshot!(apply_code_action(
269+
rewrite_as_dollar_quoted_string,
270+
"select 'fo$0o';"),
271+
@"select $$foo$$;"
272+
);
273+
}
274+
275+
#[test]
276+
fn rewrite_string_with_single_quote() {
277+
assert_snapshot!(apply_code_action(
278+
rewrite_as_dollar_quoted_string,
279+
"select 'it''s$0 nice';"),
280+
@"select $$it's nice$$;"
281+
);
282+
}
283+
284+
#[test]
285+
fn rewrite_string_with_dollar_signs() {
286+
assert_snapshot!(apply_code_action(
287+
rewrite_as_dollar_quoted_string,
288+
"select 'foo $$ ba$0r';"),
289+
@"select $q$foo $$ bar$q$;"
290+
);
291+
}
292+
293+
#[test]
294+
fn rewrite_string_when_trailing_dollar() {
295+
assert_snapshot!(apply_code_action(
296+
rewrite_as_dollar_quoted_string,
297+
"select 'foo $'$0;"),
298+
@"select $q$foo $$q$;"
299+
);
300+
}
301+
302+
#[test]
303+
fn rewrite_string_not_applicable() {
304+
assert!(code_action_not_applicable(
305+
rewrite_as_dollar_quoted_string,
306+
"select 1 + $0 2;"
307+
));
308+
}
309+
310+
#[test]
311+
fn rewrite_prefix_string_not_applicable() {
312+
assert!(code_action_not_applicable(
313+
rewrite_as_dollar_quoted_string,
314+
"select b'foo$0';"
315+
));
316+
}
317+
318+
#[test]
319+
fn rewrite_dollar_string() {
320+
assert_snapshot!(apply_code_action(
321+
rewrite_as_regular_string,
322+
"select $$fo$0o$$;"),
323+
@"select 'foo';"
324+
);
325+
}
326+
327+
#[test]
328+
fn rewrite_dollar_string_with_tag() {
329+
assert_snapshot!(apply_code_action(
330+
rewrite_as_regular_string,
331+
"select $tag$fo$0o$tag$;"),
332+
@"select 'foo';"
333+
);
334+
}
335+
336+
#[test]
337+
fn rewrite_dollar_string_with_quote() {
338+
assert_snapshot!(apply_code_action(
339+
rewrite_as_regular_string,
340+
"select $$it'$0s fine$$;"),
341+
@"select 'it''s fine';"
342+
);
343+
}
344+
345+
#[test]
346+
fn rewrite_dollar_string_not_applicable() {
347+
assert!(code_action_not_applicable(
348+
rewrite_as_regular_string,
349+
"select 'foo$0';"
350+
));
351+
}
154352
}

crates/squawk_server/src/lib.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,10 @@ pub fn run() -> Result<()> {
4545
TextDocumentSyncKind::INCREMENTAL,
4646
)),
4747
code_action_provider: Some(CodeActionProviderCapability::Options(CodeActionOptions {
48-
code_action_kinds: Some(vec![CodeActionKind::QUICKFIX]),
48+
code_action_kinds: Some(vec![
49+
CodeActionKind::QUICKFIX,
50+
CodeActionKind::REFACTOR_REWRITE,
51+
]),
4952
work_done_progress_options: WorkDoneProgressOptions {
5053
work_done_progress: None,
5154
},

crates/squawk_server/src/lsp_utils.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use std::{collections::HashMap, ops::Range};
33
use line_index::{LineIndex, TextRange, TextSize};
44
use log::warn;
55
use lsp_types::{CodeAction, CodeActionKind, Url, WorkspaceEdit};
6+
use squawk_ide::code_actions::ActionKind;
67

78
fn text_range(index: &LineIndex, range: lsp_types::Range) -> Option<TextRange> {
89
let start = offset(index, range.start)?;
@@ -42,9 +43,14 @@ pub(crate) fn code_action(
4243
uri: Url,
4344
action: squawk_ide::code_actions::CodeAction,
4445
) -> lsp_types::CodeAction {
46+
let kind = match action.kind {
47+
ActionKind::QuickFix => CodeActionKind::QUICKFIX,
48+
ActionKind::RefactorRewrite => CodeActionKind::REFACTOR_REWRITE,
49+
};
50+
4551
CodeAction {
4652
title: action.title,
47-
kind: Some(CodeActionKind::QUICKFIX),
53+
kind: Some(kind),
4854
diagnostics: None,
4955
edit: Some(WorkspaceEdit {
5056
changes: Some({

0 commit comments

Comments
 (0)