squawk_ide/
code_actions.rs

1use rowan::TextSize;
2use squawk_linter::Edit;
3use squawk_syntax::{
4    SyntaxKind, SyntaxNode,
5    ast::{self, AstNode},
6};
7
8use crate::{generated::keywords::RESERVED_KEYWORDS, offsets::token_from_offset};
9
10#[derive(Debug, Clone)]
11pub enum ActionKind {
12    QuickFix,
13    RefactorRewrite,
14}
15
16#[derive(Debug, Clone)]
17pub struct CodeAction {
18    pub title: String,
19    pub edits: Vec<Edit>,
20    pub kind: ActionKind,
21}
22
23pub fn code_actions(file: ast::SourceFile, offset: TextSize) -> Option<Vec<CodeAction>> {
24    let mut actions = vec![];
25    rewrite_as_regular_string(&mut actions, &file, offset);
26    rewrite_as_dollar_quoted_string(&mut actions, &file, offset);
27    remove_else_clause(&mut actions, &file, offset);
28    rewrite_table_as_select(&mut actions, &file, offset);
29    rewrite_select_as_table(&mut actions, &file, offset);
30    quote_identifier(&mut actions, &file, offset);
31    unquote_identifier(&mut actions, &file, offset);
32    Some(actions)
33}
34
35fn rewrite_as_regular_string(
36    actions: &mut Vec<CodeAction>,
37    file: &ast::SourceFile,
38    offset: TextSize,
39) -> Option<()> {
40    let dollar_string = file
41        .syntax()
42        .token_at_offset(offset)
43        .find(|token| token.kind() == SyntaxKind::DOLLAR_QUOTED_STRING)?;
44
45    let replacement = dollar_quoted_to_string(dollar_string.text())?;
46    actions.push(CodeAction {
47        title: "Rewrite as regular string".to_owned(),
48        edits: vec![Edit::replace(dollar_string.text_range(), replacement)],
49        kind: ActionKind::RefactorRewrite,
50    });
51
52    Some(())
53}
54
55fn rewrite_as_dollar_quoted_string(
56    actions: &mut Vec<CodeAction>,
57    file: &ast::SourceFile,
58    offset: TextSize,
59) -> Option<()> {
60    let string = file
61        .syntax()
62        .token_at_offset(offset)
63        .find(|token| token.kind() == SyntaxKind::STRING)?;
64
65    let replacement = string_to_dollar_quoted(string.text())?;
66    actions.push(CodeAction {
67        title: "Rewrite as dollar-quoted string".to_owned(),
68        edits: vec![Edit::replace(string.text_range(), replacement)],
69        kind: ActionKind::RefactorRewrite,
70    });
71
72    Some(())
73}
74
75fn string_to_dollar_quoted(text: &str) -> Option<String> {
76    let normalized = normalize_single_quoted_string(text)?;
77    let delimiter = dollar_delimiter(&normalized)?;
78    let boundary = format!("${}$", delimiter);
79    Some(format!("{boundary}{normalized}{boundary}"))
80}
81
82fn dollar_quoted_to_string(text: &str) -> Option<String> {
83    debug_assert!(text.starts_with('$'));
84    let (delimiter, content) = split_dollar_quoted(text)?;
85    let boundary = format!("${}$", delimiter);
86
87    if !text.starts_with(&boundary) || !text.ends_with(&boundary) {
88        return None;
89    }
90
91    // quotes are escaped by using two of them in Postgres
92    let escaped = content.replace('\'', "''");
93    Some(format!("'{}'", escaped))
94}
95
96fn split_dollar_quoted(text: &str) -> Option<(String, &str)> {
97    debug_assert!(text.starts_with('$'));
98    let second_dollar = text[1..].find('$')?;
99    // the `foo` in `select $foo$bar$foo$`
100    let delimiter = &text[1..=second_dollar];
101    let boundary = format!("${}$", delimiter);
102
103    if !text.ends_with(&boundary) {
104        return None;
105    }
106
107    let start = boundary.len();
108    let end = text.len().checked_sub(boundary.len())?;
109    let content = text.get(start..end)?;
110    Some((delimiter.to_owned(), content))
111}
112
113fn normalize_single_quoted_string(text: &str) -> Option<String> {
114    let body = text.strip_prefix('\'')?.strip_suffix('\'')?;
115    return Some(body.replace("''", "'"));
116}
117
118fn dollar_delimiter(content: &str) -> Option<String> {
119    // We can't safely transform a trailing `$` i.e., `select 'foo $'` with an
120    // empty delim, because we'll  `select $$foo $$$` which isn't valid.
121    if !content.contains("$$") && !content.ends_with('$') {
122        return Some("".to_owned());
123    }
124
125    let mut delim = "q".to_owned();
126    // don't want to just loop forever
127    for idx in 0..10 {
128        if !content.contains(&format!("${}$", delim)) {
129            return Some(delim);
130        }
131        delim.push_str(&idx.to_string());
132    }
133    None
134}
135
136fn remove_else_clause(
137    actions: &mut Vec<CodeAction>,
138    file: &ast::SourceFile,
139    offset: TextSize,
140) -> Option<()> {
141    let else_token = file
142        .syntax()
143        .token_at_offset(offset)
144        .find(|x| x.kind() == SyntaxKind::ELSE_KW)?;
145    let parent = else_token.parent()?;
146    let else_clause = ast::ElseClause::cast(parent)?;
147
148    let mut edits = vec![];
149    edits.push(Edit::delete(else_clause.syntax().text_range()));
150    if let Some(token) = else_token.prev_token() {
151        if token.kind() == SyntaxKind::WHITESPACE {
152            edits.push(Edit::delete(token.text_range()));
153        }
154    }
155
156    actions.push(CodeAction {
157        title: "Remove `else` clause".to_owned(),
158        edits,
159        kind: ActionKind::RefactorRewrite,
160    });
161    Some(())
162}
163
164fn rewrite_table_as_select(
165    actions: &mut Vec<CodeAction>,
166    file: &ast::SourceFile,
167    offset: TextSize,
168) -> Option<()> {
169    let token = token_from_offset(file, offset)?;
170    let table = token.parent_ancestors().find_map(ast::Table::cast)?;
171
172    let relation_name = table.relation_name()?;
173    let table_name = relation_name.syntax().text();
174
175    let replacement = format!("select * from {}", table_name);
176
177    actions.push(CodeAction {
178        title: "Rewrite as `select`".to_owned(),
179        edits: vec![Edit::replace(table.syntax().text_range(), replacement)],
180        kind: ActionKind::RefactorRewrite,
181    });
182
183    Some(())
184}
185
186fn rewrite_select_as_table(
187    actions: &mut Vec<CodeAction>,
188    file: &ast::SourceFile,
189    offset: TextSize,
190) -> Option<()> {
191    let token = token_from_offset(file, offset)?;
192    let select = token.parent_ancestors().find_map(ast::Select::cast)?;
193
194    if !can_transform_select_to_table(&select) {
195        return None;
196    }
197
198    let from_clause = select.from_clause()?;
199    let from_item = from_clause.from_items().next()?;
200
201    let table_name = if let Some(name_ref) = from_item.name_ref() {
202        name_ref.syntax().text().to_string()
203    } else if let Some(field_expr) = from_item.field_expr() {
204        field_expr.syntax().text().to_string()
205    } else {
206        return None;
207    };
208
209    let replacement = format!("table {}", table_name);
210
211    actions.push(CodeAction {
212        title: "Rewrite as `table`".to_owned(),
213        edits: vec![Edit::replace(select.syntax().text_range(), replacement)],
214        kind: ActionKind::RefactorRewrite,
215    });
216
217    Some(())
218}
219
220/// Returns true if a `select` statement can be safely rewritten as a `table` statement.
221///
222/// We can only do this when there are no clauses besides the `select` and
223/// `from` clause. Additionally, we can only have a table reference in the
224/// `from` clause.
225/// The `select`'s target list must only be a `*`.
226fn can_transform_select_to_table(select: &ast::Select) -> bool {
227    if select.with_clause().is_some()
228        || select.where_clause().is_some()
229        || select.group_by_clause().is_some()
230        || select.having_clause().is_some()
231        || select.window_clause().is_some()
232        || select.order_by_clause().is_some()
233        || select.limit_clause().is_some()
234        || select.fetch_clause().is_some()
235        || select.offset_clause().is_some()
236        || select.filter_clause().is_some()
237        || select.locking_clauses().next().is_some()
238    {
239        return false;
240    }
241
242    let Some(select_clause) = select.select_clause() else {
243        return false;
244    };
245
246    if select_clause.distinct_clause().is_some() {
247        return false;
248    }
249
250    let Some(target_list) = select_clause.target_list() else {
251        return false;
252    };
253
254    let mut targets = target_list.targets();
255    let Some(target) = targets.next() else {
256        return false;
257    };
258
259    if targets.next().is_some() {
260        return false;
261    }
262
263    // only want to support: `select *`
264    if target.expr().is_some() || target.star_token().is_none() {
265        return false;
266    }
267
268    let Some(from_clause) = select.from_clause() else {
269        return false;
270    };
271
272    let mut from_items = from_clause.from_items();
273    let Some(from_item) = from_items.next() else {
274        return false;
275    };
276
277    // only can have one from item & no join exprs
278    if from_items.next().is_some() || from_clause.join_exprs().next().is_some() {
279        return false;
280    }
281
282    if from_item.alias().is_some()
283        || from_item.tablesample_clause().is_some()
284        || from_item.only_token().is_some()
285        || from_item.lateral_token().is_some()
286        || from_item.star_token().is_some()
287        || from_item.call_expr().is_some()
288        || from_item.paren_select().is_some()
289        || from_item.json_table().is_some()
290        || from_item.xml_table().is_some()
291        || from_item.cast_expr().is_some()
292    {
293        return false;
294    }
295
296    // only want table refs
297    from_item.name_ref().is_some() || from_item.field_expr().is_some()
298}
299
300fn 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
333fn 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
360fn 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
395fn is_reserved_word(text: &str) -> bool {
396    RESERVED_KEYWORDS
397        .binary_search(&text.to_lowercase().as_str())
398        .is_ok()
399}
400
401#[cfg(test)]
402mod test {
403    use super::*;
404    use crate::test_utils::fixture;
405    use insta::assert_snapshot;
406    use rowan::TextSize;
407    use squawk_syntax::ast;
408
409    fn apply_code_action(
410        f: impl Fn(&mut Vec<CodeAction>, &ast::SourceFile, TextSize) -> Option<()>,
411        sql: &str,
412    ) -> String {
413        let (mut offset, sql) = fixture(sql);
414        let parse = ast::SourceFile::parse(&sql);
415        assert_eq!(parse.errors(), vec![]);
416        let file: ast::SourceFile = parse.tree();
417
418        offset = offset.checked_sub(1.into()).unwrap_or_default();
419
420        let mut actions = vec![];
421        f(&mut actions, &file, offset);
422
423        assert!(
424            !actions.is_empty(),
425            "We should always have actions for `apply_code_action`. If you want to ensure there are no actions, use `code_action_not_applicable` instead."
426        );
427
428        let action = &actions[0];
429        let mut result = sql.clone();
430
431        let mut edits = action.edits.clone();
432        edits.sort_by_key(|e| e.text_range.start());
433        check_overlap(&edits);
434        edits.reverse();
435
436        for edit in edits {
437            let start: usize = edit.text_range.start().into();
438            let end: usize = edit.text_range.end().into();
439            let replacement = edit.text.as_deref().unwrap_or("");
440            result.replace_range(start..end, replacement);
441        }
442
443        let reparse = ast::SourceFile::parse(&result);
444        assert_eq!(
445            reparse.errors(),
446            vec![],
447            "Code actions shouldn't cause syntax errors"
448        );
449
450        result
451    }
452
453    // There's an invariant where the edits can't overlap.
454    // For example, if we have an edit that deletes the full `else clause` and
455    // another edit that deletes the `else` keyword and they overlap, then
456    // vscode doesn't surface the code action.
457    fn check_overlap(edits: &[Edit]) {
458        for (edit_i, edit_j) in edits.iter().zip(edits.iter().skip(1)) {
459            if let Some(intersection) = edit_i.text_range.intersect(edit_j.text_range) {
460                assert!(
461                    intersection.is_empty(),
462                    "Edit ranges must not overlap: {:?} and {:?} intersect at {:?}",
463                    edit_i.text_range,
464                    edit_j.text_range,
465                    intersection
466                );
467            }
468        }
469    }
470
471    fn code_action_not_applicable(
472        f: impl Fn(&mut Vec<CodeAction>, &ast::SourceFile, TextSize) -> Option<()>,
473        sql: &str,
474    ) -> bool {
475        let (offset, sql) = fixture(sql);
476        let parse = ast::SourceFile::parse(&sql);
477        assert_eq!(parse.errors(), vec![]);
478        let file: ast::SourceFile = parse.tree();
479
480        let mut actions = vec![];
481        f(&mut actions, &file, offset);
482        actions.is_empty()
483    }
484
485    #[test]
486    fn remove_else_clause_() {
487        assert_snapshot!(apply_code_action(
488            remove_else_clause,
489            "select case x when true then 1 else$0 2 end;"),
490            @"select case x when true then 1 end;"
491        );
492    }
493
494    #[test]
495    fn remove_else_clause_before_token() {
496        assert_snapshot!(apply_code_action(
497            remove_else_clause,
498            "select case x when true then 1 e$0lse 2 end;"),
499            @"select case x when true then 1 end;"
500        );
501    }
502
503    #[test]
504    fn remove_else_clause_not_applicable() {
505        assert!(code_action_not_applicable(
506            remove_else_clause,
507            "select case x when true then 1 else 2 end$0;"
508        ));
509    }
510
511    #[test]
512    fn rewrite_string() {
513        assert_snapshot!(apply_code_action(
514            rewrite_as_dollar_quoted_string,
515            "select 'fo$0o';"),
516            @"select $$foo$$;"
517        );
518    }
519
520    #[test]
521    fn rewrite_string_with_single_quote() {
522        assert_snapshot!(apply_code_action(
523            rewrite_as_dollar_quoted_string,
524            "select 'it''s$0 nice';"),
525            @"select $$it's nice$$;"
526        );
527    }
528
529    #[test]
530    fn rewrite_string_with_dollar_signs() {
531        assert_snapshot!(apply_code_action(
532            rewrite_as_dollar_quoted_string,
533            "select 'foo $$ ba$0r';"),
534            @"select $q$foo $$ bar$q$;"
535        );
536    }
537
538    #[test]
539    fn rewrite_string_when_trailing_dollar() {
540        assert_snapshot!(apply_code_action(
541            rewrite_as_dollar_quoted_string,
542            "select 'foo $'$0;"),
543            @"select $q$foo $$q$;"
544        );
545    }
546
547    #[test]
548    fn rewrite_string_not_applicable() {
549        assert!(code_action_not_applicable(
550            rewrite_as_dollar_quoted_string,
551            "select 1 + $0 2;"
552        ));
553    }
554
555    #[test]
556    fn rewrite_prefix_string_not_applicable() {
557        assert!(code_action_not_applicable(
558            rewrite_as_dollar_quoted_string,
559            "select b'foo$0';"
560        ));
561    }
562
563    #[test]
564    fn rewrite_dollar_string() {
565        assert_snapshot!(apply_code_action(
566            rewrite_as_regular_string,
567            "select $$fo$0o$$;"),
568            @"select 'foo';"
569        );
570    }
571
572    #[test]
573    fn rewrite_dollar_string_with_tag() {
574        assert_snapshot!(apply_code_action(
575            rewrite_as_regular_string,
576            "select $tag$fo$0o$tag$;"),
577            @"select 'foo';"
578        );
579    }
580
581    #[test]
582    fn rewrite_dollar_string_with_quote() {
583        assert_snapshot!(apply_code_action(
584            rewrite_as_regular_string,
585            "select $$it'$0s fine$$;"),
586            @"select 'it''s fine';"
587        );
588    }
589
590    #[test]
591    fn rewrite_dollar_string_not_applicable() {
592        assert!(code_action_not_applicable(
593            rewrite_as_regular_string,
594            "select 'foo$0';"
595        ));
596    }
597
598    #[test]
599    fn rewrite_table_as_select_simple() {
600        assert_snapshot!(apply_code_action(
601            rewrite_table_as_select,
602            "tab$0le foo;"),
603            @"select * from foo;"
604        );
605    }
606
607    #[test]
608    fn rewrite_table_as_select_qualified() {
609        assert_snapshot!(apply_code_action(
610            rewrite_table_as_select,
611            "ta$0ble schema.foo;"),
612            @"select * from schema.foo;"
613        );
614    }
615
616    #[test]
617    fn rewrite_table_as_select_after_keyword() {
618        assert_snapshot!(apply_code_action(
619            rewrite_table_as_select,
620            "table$0 bar;"),
621            @"select * from bar;"
622        );
623    }
624
625    #[test]
626    fn rewrite_table_as_select_on_table_name() {
627        assert_snapshot!(apply_code_action(
628            rewrite_table_as_select,
629            "table fo$0o;"),
630            @"select * from foo;"
631        );
632    }
633
634    #[test]
635    fn rewrite_table_as_select_not_applicable() {
636        assert!(code_action_not_applicable(
637            rewrite_table_as_select,
638            "select * from foo$0;"
639        ));
640    }
641
642    #[test]
643    fn rewrite_select_as_table_simple() {
644        assert_snapshot!(apply_code_action(
645            rewrite_select_as_table,
646            "sel$0ect * from foo;"),
647            @"table foo;"
648        );
649    }
650
651    #[test]
652    fn rewrite_select_as_table_qualified() {
653        assert_snapshot!(apply_code_action(
654            rewrite_select_as_table,
655            "select * from sch$0ema.foo;"),
656            @"table schema.foo;"
657        );
658    }
659
660    #[test]
661    fn rewrite_select_as_table_on_star() {
662        assert_snapshot!(apply_code_action(
663            rewrite_select_as_table,
664            "select $0* from bar;"),
665            @"table bar;"
666        );
667    }
668
669    #[test]
670    fn rewrite_select_as_table_on_from() {
671        assert_snapshot!(apply_code_action(
672            rewrite_select_as_table,
673            "select * fr$0om baz;"),
674            @"table baz;"
675        );
676    }
677
678    #[test]
679    fn rewrite_select_as_table_not_applicable_with_where() {
680        assert!(code_action_not_applicable(
681            rewrite_select_as_table,
682            "select * from foo$0 where x = 1;"
683        ));
684    }
685
686    #[test]
687    fn rewrite_select_as_table_not_applicable_with_order_by() {
688        assert!(code_action_not_applicable(
689            rewrite_select_as_table,
690            "select * from foo$0 order by x;"
691        ));
692    }
693
694    #[test]
695    fn rewrite_select_as_table_not_applicable_with_limit() {
696        assert!(code_action_not_applicable(
697            rewrite_select_as_table,
698            "select * from foo$0 limit 10;"
699        ));
700    }
701
702    #[test]
703    fn rewrite_select_as_table_not_applicable_with_distinct() {
704        assert!(code_action_not_applicable(
705            rewrite_select_as_table,
706            "select distinct * from foo$0;"
707        ));
708    }
709
710    #[test]
711    fn rewrite_select_as_table_not_applicable_with_columns() {
712        assert!(code_action_not_applicable(
713            rewrite_select_as_table,
714            "select id, name from foo$0;"
715        ));
716    }
717
718    #[test]
719    fn rewrite_select_as_table_not_applicable_with_join() {
720        assert!(code_action_not_applicable(
721            rewrite_select_as_table,
722            "select * from foo$0 join bar on foo.id = bar.id;"
723        ));
724    }
725
726    #[test]
727    fn rewrite_select_as_table_not_applicable_with_alias() {
728        assert!(code_action_not_applicable(
729            rewrite_select_as_table,
730            "select * from foo$0 f;"
731        ));
732    }
733
734    #[test]
735    fn rewrite_select_as_table_not_applicable_with_multiple_tables() {
736        assert!(code_action_not_applicable(
737            rewrite_select_as_table,
738            "select * from foo$0, bar;"
739        ));
740    }
741
742    #[test]
743    fn rewrite_select_as_table_not_applicable_on_table() {
744        assert!(code_action_not_applicable(
745            rewrite_select_as_table,
746            "table foo$0;"
747        ));
748    }
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    }
936}