11use rowan:: TextSize ;
22use squawk_linter:: Edit ;
33use 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 ) ]
911pub 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) ]
297402mod 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