Skip to content

Commit 4d7c33f

Browse files
authored
ide: quote & unquote identifiers (#733)
1 parent 9fe514d commit 4d7c33f

File tree

7 files changed

+424
-18
lines changed

7 files changed

+424
-18
lines changed

crates/squawk_ide/src/code_actions.rs

Lines changed: 301 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
use rowan::TextSize;
22
use squawk_linter::Edit;
33
use squawk_syntax::{
4-
SyntaxKind,
4+
SyntaxKind, SyntaxNode,
55
ast::{self, AstNode},
66
};
77

8+
use crate::{generated::keywords::RESERVED_KEYWORDS, offsets::token_from_offset};
9+
810
#[derive(Debug, Clone)]
911
pub enum ActionKind {
1012
QuickFix,
@@ -25,6 +27,8 @@ pub fn code_actions(file: ast::SourceFile, offset: TextSize) -> Option<Vec<CodeA
2527
remove_else_clause(&mut actions, &file, offset);
2628
rewrite_table_as_select(&mut actions, &file, offset);
2729
rewrite_select_as_table(&mut actions, &file, offset);
30+
quote_identifier(&mut actions, &file, offset);
31+
unquote_identifier(&mut actions, &file, offset);
2832
Some(actions)
2933
}
3034

@@ -162,8 +166,8 @@ fn rewrite_table_as_select(
162166
file: &ast::SourceFile,
163167
offset: TextSize,
164168
) -> Option<()> {
165-
let node = file.syntax().token_at_offset(offset).left_biased()?;
166-
let table = node.parent_ancestors().find_map(ast::Table::cast)?;
169+
let token = token_from_offset(file, offset)?;
170+
let table = token.parent_ancestors().find_map(ast::Table::cast)?;
167171

168172
let relation_name = table.relation_name()?;
169173
let table_name = relation_name.syntax().text();
@@ -184,8 +188,8 @@ fn rewrite_select_as_table(
184188
file: &ast::SourceFile,
185189
offset: TextSize,
186190
) -> Option<()> {
187-
let node = file.syntax().token_at_offset(offset).left_biased()?;
188-
let select = node.parent_ancestors().find_map(ast::Select::cast)?;
191+
let token = token_from_offset(file, offset)?;
192+
let select = token.parent_ancestors().find_map(ast::Select::cast)?;
189193

190194
if !can_transform_select_to_table(&select) {
191195
return None;
@@ -293,6 +297,107 @@ fn can_transform_select_to_table(select: &ast::Select) -> bool {
293297
from_item.name_ref().is_some() || from_item.field_expr().is_some()
294298
}
295299

300+
fn quote_identifier(
301+
actions: &mut Vec<CodeAction>,
302+
file: &ast::SourceFile,
303+
offset: TextSize,
304+
) -> Option<()> {
305+
let token = token_from_offset(file, offset)?;
306+
let parent = token.parent()?;
307+
308+
let name_node = if let Some(name) = ast::Name::cast(parent.clone()) {
309+
name.syntax().clone()
310+
} else if let Some(name_ref) = ast::NameRef::cast(parent) {
311+
name_ref.syntax().clone()
312+
} else {
313+
return None;
314+
};
315+
316+
let text = name_node.text().to_string();
317+
318+
if text.starts_with('"') {
319+
return None;
320+
}
321+
322+
let quoted = format!(r#""{}""#, text.to_lowercase());
323+
324+
actions.push(CodeAction {
325+
title: "Quote identifier".to_owned(),
326+
edits: vec![Edit::replace(name_node.text_range(), quoted)],
327+
kind: ActionKind::RefactorRewrite,
328+
});
329+
330+
Some(())
331+
}
332+
333+
fn unquote_identifier(
334+
actions: &mut Vec<CodeAction>,
335+
file: &ast::SourceFile,
336+
offset: TextSize,
337+
) -> Option<()> {
338+
let token = token_from_offset(file, offset)?;
339+
let parent = token.parent()?;
340+
341+
let name_node = if let Some(name) = ast::Name::cast(parent.clone()) {
342+
name.syntax().clone()
343+
} else if let Some(name_ref) = ast::NameRef::cast(parent) {
344+
name_ref.syntax().clone()
345+
} else {
346+
return None;
347+
};
348+
349+
let unquoted = unquote(&name_node)?;
350+
351+
actions.push(CodeAction {
352+
title: "Unquote identifier".to_owned(),
353+
edits: vec![Edit::replace(name_node.text_range(), unquoted)],
354+
kind: ActionKind::RefactorRewrite,
355+
});
356+
357+
Some(())
358+
}
359+
360+
fn unquote(node: &SyntaxNode) -> Option<String> {
361+
let text = node.text().to_string();
362+
363+
if !text.starts_with('"') || !text.ends_with('"') {
364+
return None;
365+
}
366+
367+
let text = &text[1..text.len() - 1];
368+
369+
if is_reserved_word(text) {
370+
return None;
371+
}
372+
373+
if text.is_empty() {
374+
return None;
375+
}
376+
377+
let mut chars = text.chars();
378+
379+
// see: https://www.postgresql.org/docs/18/sql-syntax-lexical.html#SQL-SYNTAX-IDENTIFIERS
380+
match chars.next() {
381+
Some(c) if c.is_lowercase() || c == '_' => {}
382+
_ => return None,
383+
}
384+
385+
for c in chars {
386+
if c.is_lowercase() || c.is_ascii_digit() || c == '_' || c == '$' {
387+
continue;
388+
}
389+
return None;
390+
}
391+
392+
Some(text.to_string())
393+
}
394+
395+
fn is_reserved_word(text: &str) -> bool {
396+
RESERVED_KEYWORDS
397+
.binary_search(&text.to_lowercase().as_str())
398+
.is_ok()
399+
}
400+
296401
#[cfg(test)]
297402
mod test {
298403
use super::*;
@@ -305,11 +410,13 @@ mod test {
305410
f: impl Fn(&mut Vec<CodeAction>, &ast::SourceFile, TextSize) -> Option<()>,
306411
sql: &str,
307412
) -> String {
308-
let (offset, sql) = fixture(sql);
413+
let (mut offset, sql) = fixture(sql);
309414
let parse = ast::SourceFile::parse(&sql);
310415
assert_eq!(parse.errors(), vec![]);
311416
let file: ast::SourceFile = parse.tree();
312417

418+
offset = offset.checked_sub(1.into()).unwrap_or_default();
419+
313420
let mut actions = vec![];
314421
f(&mut actions, &file, offset);
315422

@@ -388,7 +495,7 @@ mod test {
388495
fn remove_else_clause_before_token() {
389496
assert_snapshot!(apply_code_action(
390497
remove_else_clause,
391-
"select case x when true then 1 $0else 2 end;"),
498+
"select case x when true then 1 e$0lse 2 end;"),
392499
@"select case x when true then 1 end;"
393500
);
394501
}
@@ -639,4 +746,191 @@ mod test {
639746
"table foo$0;"
640747
));
641748
}
749+
750+
#[test]
751+
fn quote_identifier_on_name_ref() {
752+
assert_snapshot!(apply_code_action(
753+
quote_identifier,
754+
"select x$0 from t;"),
755+
@r#"select "x" from t;"#
756+
);
757+
}
758+
759+
#[test]
760+
fn quote_identifier_on_name() {
761+
assert_snapshot!(apply_code_action(
762+
quote_identifier,
763+
"create table T(X$0 int);"),
764+
@r#"create table T("x" int);"#
765+
);
766+
}
767+
768+
#[test]
769+
fn quote_identifier_lowercases() {
770+
assert_snapshot!(apply_code_action(
771+
quote_identifier,
772+
"create table T(COL$0 int);"),
773+
@r#"create table T("col" int);"#
774+
);
775+
}
776+
777+
#[test]
778+
fn quote_identifier_not_applicable_when_already_quoted() {
779+
assert!(code_action_not_applicable(
780+
quote_identifier,
781+
r#"select "x"$0 from t;"#
782+
));
783+
}
784+
785+
#[test]
786+
fn quote_identifier_not_applicable_on_select_keyword() {
787+
assert!(code_action_not_applicable(
788+
quote_identifier,
789+
"sel$0ect x from t;"
790+
));
791+
}
792+
793+
#[test]
794+
fn quote_identifier_on_keyword_column_name() {
795+
assert_snapshot!(apply_code_action(
796+
quote_identifier,
797+
"select te$0xt from t;"),
798+
@r#"select "text" from t;"#
799+
);
800+
}
801+
802+
#[test]
803+
fn quote_identifier_example_select() {
804+
assert_snapshot!(apply_code_action(
805+
quote_identifier,
806+
"select x$0 from t;"),
807+
@r#"select "x" from t;"#
808+
);
809+
}
810+
811+
#[test]
812+
fn quote_identifier_example_create_table() {
813+
assert_snapshot!(apply_code_action(
814+
quote_identifier,
815+
"create table T(X$0 int);"),
816+
@r#"create table T("x" int);"#
817+
);
818+
}
819+
820+
#[test]
821+
fn unquote_identifier_simple() {
822+
assert_snapshot!(apply_code_action(
823+
unquote_identifier,
824+
r#"select "x"$0 from t;"#),
825+
@"select x from t;"
826+
);
827+
}
828+
829+
#[test]
830+
fn unquote_identifier_with_underscore() {
831+
assert_snapshot!(apply_code_action(
832+
unquote_identifier,
833+
r#"select "user_id"$0 from t;"#),
834+
@"select user_id from t;"
835+
);
836+
}
837+
838+
#[test]
839+
fn unquote_identifier_with_digits() {
840+
assert_snapshot!(apply_code_action(
841+
unquote_identifier,
842+
r#"select "x123"$0 from t;"#),
843+
@"select x123 from t;"
844+
);
845+
}
846+
847+
#[test]
848+
fn unquote_identifier_with_dollar() {
849+
assert_snapshot!(apply_code_action(
850+
unquote_identifier,
851+
r#"select "my_table$1"$0 from t;"#),
852+
@"select my_table$1 from t;"
853+
);
854+
}
855+
856+
#[test]
857+
fn unquote_identifier_starts_with_underscore() {
858+
assert_snapshot!(apply_code_action(
859+
unquote_identifier,
860+
r#"select "_col"$0 from t;"#),
861+
@"select _col from t;"
862+
);
863+
}
864+
865+
#[test]
866+
fn unquote_identifier_starts_with_unicode() {
867+
assert_snapshot!(apply_code_action(
868+
unquote_identifier,
869+
r#"select "é"$0 from t;"#),
870+
@"select é from t;"
871+
);
872+
}
873+
874+
#[test]
875+
fn unquote_identifier_not_applicable() {
876+
// upper case
877+
assert!(code_action_not_applicable(
878+
unquote_identifier,
879+
r#"select "X"$0 from t;"#
880+
));
881+
// upper case
882+
assert!(code_action_not_applicable(
883+
unquote_identifier,
884+
r#"select "Foo"$0 from t;"#
885+
));
886+
// dash
887+
assert!(code_action_not_applicable(
888+
unquote_identifier,
889+
r#"select "my-col"$0 from t;"#
890+
));
891+
// leading digits
892+
assert!(code_action_not_applicable(
893+
unquote_identifier,
894+
r#"select "123"$0 from t;"#
895+
));
896+
// space
897+
assert!(code_action_not_applicable(
898+
unquote_identifier,
899+
r#"select "foo bar"$0 from t;"#
900+
));
901+
// quotes
902+
assert!(code_action_not_applicable(
903+
unquote_identifier,
904+
r#"select "foo""bar"$0 from t;"#
905+
));
906+
// already unquoted
907+
assert!(code_action_not_applicable(
908+
unquote_identifier,
909+
"select x$0 from t;"
910+
));
911+
// brackets
912+
assert!(code_action_not_applicable(
913+
unquote_identifier,
914+
r#"select "my[col]"$0 from t;"#
915+
));
916+
// curly brackets
917+
assert!(code_action_not_applicable(
918+
unquote_identifier,
919+
r#"select "my{}"$0 from t;"#
920+
));
921+
// reserved word
922+
assert!(code_action_not_applicable(
923+
unquote_identifier,
924+
r#"select "select"$0 from t;"#
925+
));
926+
}
927+
928+
#[test]
929+
fn unquote_identifier_on_name() {
930+
assert_snapshot!(apply_code_action(
931+
unquote_identifier,
932+
r#"create table T("x"$0 int);"#),
933+
@"create table T(x int);"
934+
);
935+
}
642936
}

0 commit comments

Comments
 (0)