Skip to main content

squawk_syntax/
quote.rs

1use crate::SyntaxNode;
2use crate::generated::keywords::RESERVED_KEYWORDS;
3
4pub fn quote_string_literal(text: &str) -> String {
5    format!("'{}'", text.replace('\'', "''"))
6}
7
8pub fn quote_column_alias(text: &str) -> String {
9    if needs_quoting(text) {
10        format!(r#""{}""#, text.replace('"', r#""""#))
11    } else {
12        text.to_string()
13    }
14}
15
16pub fn unquote_ident(node: &SyntaxNode) -> Option<String> {
17    let text = node.text().to_string();
18
19    if !text.starts_with('"') || !text.ends_with('"') {
20        return None;
21    }
22
23    let text = &text[1..text.len() - 1];
24
25    if is_reserved_word(text) {
26        return None;
27    }
28
29    if text.is_empty() {
30        return None;
31    }
32
33    let mut chars = text.chars();
34
35    // see: https://www.postgresql.org/docs/18/sql-syntax-lexical.html#SQL-SYNTAX-IDENTIFIERS
36    match chars.next() {
37        Some(c) if c.is_lowercase() || c == '_' => {}
38        _ => return None,
39    }
40
41    for c in chars {
42        if c.is_lowercase() || c.is_ascii_digit() || c == '_' || c == '$' {
43            continue;
44        }
45        return None;
46    }
47
48    Some(text.to_string())
49}
50
51pub fn needs_quoting(text: &str) -> bool {
52    if text.is_empty() {
53        return true;
54    }
55
56    // Column labels in AS clauses allow all keywords, so we don't need to check
57    // for reserved words. See PostgreSQL grammar:
58    // ColLabel: IDENT | unreserved_keyword | col_name_keyword | type_func_name_keyword | reserved_keyword
59
60    let mut chars = text.chars();
61
62    match chars.next() {
63        Some(c) if c.is_lowercase() || c == '_' => {}
64        _ => return true,
65    }
66
67    for c in chars {
68        if c.is_lowercase() || c.is_ascii_digit() || c == '_' || c == '$' {
69            continue;
70        }
71        return true;
72    }
73
74    false
75}
76
77pub fn is_reserved_word(text: &str) -> bool {
78    RESERVED_KEYWORDS
79        .binary_search(&text.to_ascii_lowercase().as_str())
80        .is_ok()
81}
82
83pub fn strip_quotes(text: &str) -> Option<&str> {
84    text.strip_prefix('\'')?.strip_suffix('\'')
85}
86
87pub fn strip_prefixed_quotes(text: &str, prefix: [char; 2]) -> Option<&str> {
88    strip_quotes(text.strip_prefix(prefix)?)
89}
90
91pub fn strip_unicode_esc_prefix(text: &str) -> Option<&str> {
92    strip_quotes(text.strip_prefix(['u', 'U'])?.strip_prefix('&')?)
93}
94
95pub fn dollar_quote_tag(text: &str) -> Option<&str> {
96    text.strip_prefix('$')?.split_once('$').map(|(tag, _)| tag)
97}
98
99pub fn strip_dollar_quotes(text: &str) -> Option<&str> {
100    let tag = dollar_quote_tag(text)?;
101    let body = &text[tag.len() + 2..];
102    let closing = format!("${tag}$");
103    body.strip_suffix(&closing)
104}
105
106#[cfg(test)]
107mod tests {
108    use insta::assert_snapshot;
109
110    use super::*;
111
112    #[test]
113    fn quote_string_literal_escapes_apostrophes() {
114        assert_snapshot!(quote_string_literal("it's"), @"'it''s'");
115    }
116
117    #[test]
118    fn quote_column_alias_handles_embedded_quotes() {
119        assert_snapshot!(quote_column_alias(r#"foo"bar"#), @r#""foo""bar""#);
120    }
121
122    #[test]
123    fn quote_column_alias_doesnt_quote_reserved_words() {
124        // Keywords are allowed as column labels in AS clauses
125        assert_snapshot!(quote_column_alias("case"), @"case");
126        assert_snapshot!(quote_column_alias("array"), @"array");
127    }
128
129    #[test]
130    fn quote_column_alias_doesnt_quote_simple_identifiers() {
131        assert_snapshot!(quote_column_alias("col_name"), @"col_name");
132    }
133
134    #[test]
135    fn quote_column_alias_handles_special_column_name() {
136        assert_snapshot!(quote_column_alias("?column?"), @r#""?column?""#);
137    }
138}