Skip to main content

squawk_syntax/
quote.rs

1use crate::SyntaxNode;
2use crate::generated::keywords::{AS_LABEL_KEYWORDS, RESERVED_KEYWORDS, TYPE_FUNC_NAME_KEYWORDS};
3
4pub fn quote_string_literal(text: &str) -> String {
5    format!("'{}'", text.replace('\'', "''"))
6}
7
8fn quote(text: &str) -> String {
9    format!(r#""{}""#, text.replace('"', r#""""#))
10}
11
12pub fn quote_column_alias(text: &str) -> String {
13    if needs_quoting(text) {
14        quote(text)
15    } else {
16        text.to_string()
17    }
18}
19
20pub fn quote_bare_column_alias(text: &str) -> String {
21    if needs_quoting(text) || is_as_label_word(text) {
22        quote(text)
23    } else {
24        text.to_string()
25    }
26}
27
28pub fn quote_ident(text: &str) -> String {
29    if needs_quoting(text) || is_reserved_word(text) || is_type_func_name_word(text) {
30        quote(text)
31    } else {
32        text.to_string()
33    }
34}
35
36pub fn unquote_ident(node: &SyntaxNode) -> Option<String> {
37    let text = node.text().to_string();
38
39    if !text.starts_with('"') || !text.ends_with('"') {
40        return None;
41    }
42
43    let text = &text[1..text.len() - 1];
44
45    if is_reserved_word(text) || is_type_func_name_word(text) {
46        return None;
47    }
48
49    if text.is_empty() {
50        return None;
51    }
52
53    let mut chars = text.chars();
54
55    // see: https://www.postgresql.org/docs/18/sql-syntax-lexical.html#SQL-SYNTAX-IDENTIFIERS
56    match chars.next() {
57        Some(c) if c.is_lowercase() || c == '_' => {}
58        _ => return None,
59    }
60
61    for c in chars {
62        if c.is_lowercase() || c.is_ascii_digit() || c == '_' || c == '$' {
63            continue;
64        }
65        return None;
66    }
67
68    Some(text.to_string())
69}
70
71pub fn needs_quoting(text: &str) -> bool {
72    if text.is_empty() {
73        return true;
74    }
75
76    // Column labels in AS clauses allow all keywords, so we don't need to check
77    // for reserved words. See PostgreSQL grammar:
78    // ColLabel: IDENT | unreserved_keyword | col_name_keyword | type_func_name_keyword | reserved_keyword
79
80    let mut chars = text.chars();
81
82    match chars.next() {
83        Some(c) if c.is_lowercase() || c == '_' => {}
84        _ => return true,
85    }
86
87    for c in chars {
88        if c.is_lowercase() || c.is_ascii_digit() || c == '_' || c == '$' {
89            continue;
90        }
91        return true;
92    }
93
94    false
95}
96
97pub fn is_reserved_word(text: &str) -> bool {
98    RESERVED_KEYWORDS
99        .binary_search(&text.to_ascii_lowercase().as_str())
100        .is_ok()
101}
102
103fn is_type_func_name_word(text: &str) -> bool {
104    TYPE_FUNC_NAME_KEYWORDS
105        .binary_search(&text.to_ascii_lowercase().as_str())
106        .is_ok()
107}
108
109fn is_as_label_word(text: &str) -> bool {
110    AS_LABEL_KEYWORDS
111        .binary_search(&text.to_ascii_lowercase().as_str())
112        .is_ok()
113}
114
115pub fn strip_quotes(text: &str) -> Option<&str> {
116    text.strip_prefix('\'')?.strip_suffix('\'')
117}
118
119pub fn strip_prefixed_quotes(text: &str, prefix: [char; 2]) -> Option<&str> {
120    strip_quotes(text.strip_prefix(prefix)?)
121}
122
123pub fn strip_unicode_esc_prefix(text: &str) -> Option<&str> {
124    strip_quotes(text.strip_prefix(['u', 'U'])?.strip_prefix('&')?)
125}
126
127pub fn dollar_quote_tag(text: &str) -> Option<&str> {
128    text.strip_prefix('$')?.split_once('$').map(|(tag, _)| tag)
129}
130
131pub fn strip_dollar_quotes(text: &str) -> Option<&str> {
132    let tag = dollar_quote_tag(text)?;
133    let body = &text[tag.len() + 2..];
134    let closing = format!("${tag}$");
135    body.strip_suffix(&closing)
136}
137
138#[cfg(test)]
139mod tests {
140    use insta::assert_snapshot;
141
142    use super::*;
143
144    #[test]
145    fn quote_string_literal_escapes_apostrophes() {
146        assert_snapshot!(quote_string_literal("it's"), @"'it''s'");
147    }
148
149    #[test]
150    fn quote_column_alias_handles_embedded_quotes() {
151        assert_snapshot!(quote_column_alias(r#"foo"bar"#), @r#""foo""bar""#);
152    }
153
154    #[test]
155    fn quote_column_alias_doesnt_quote_reserved_words() {
156        // Keywords are allowed as column labels in AS clauses
157        assert_snapshot!(quote_column_alias("case"), @"case");
158        assert_snapshot!(quote_column_alias("array"), @"array");
159    }
160
161    #[test]
162    fn quote_column_alias_doesnt_quote_simple_identifiers() {
163        assert_snapshot!(quote_column_alias("col_name"), @"col_name");
164    }
165
166    #[test]
167    fn quote_column_alias_handles_special_column_name() {
168        assert_snapshot!(quote_column_alias("?column?"), @r#""?column?""#);
169    }
170
171    #[test]
172    fn quote_bare_column_alias_quotes_keywords_that_need_an_as() {
173        assert_snapshot!(quote_bare_column_alias("filter"), @r#""filter""#);
174        assert_snapshot!(quote_bare_column_alias("day"), @r#""day""#);
175        // also reserved
176        assert_snapshot!(quote_bare_column_alias("array"), @r#""array""#);
177    }
178
179    #[test]
180    fn quote_bare_column_alias_doesnt_quote_bare_label_keywords() {
181        assert_snapshot!(quote_bare_column_alias("between"), @"between");
182        assert_snapshot!(quote_bare_column_alias("all"), @"all");
183        assert_snapshot!(quote_bare_column_alias("left"), @"left");
184        assert_snapshot!(quote_bare_column_alias("col_name"), @"col_name");
185    }
186
187    #[test]
188    fn quote_ident_doesnt_quote_simple_identifiers() {
189        assert_snapshot!(quote_ident("col_name"), @"col_name");
190        assert_snapshot!(quote_ident("users"), @"users");
191        assert_snapshot!(quote_ident("t2$"), @"t2$");
192    }
193
194    #[test]
195    fn quote_ident_doesnt_quote_column_or_table_keywords() {
196        // unreserved
197        assert_snapshot!(quote_ident("data"), @"data");
198        assert_snapshot!(quote_ident("value"), @"value");
199        // col name
200        assert_snapshot!(quote_ident("int"), @"int");
201    }
202
203    #[test]
204    fn quote_ident_quotes_reserved_words() {
205        assert_snapshot!(quote_ident("select"), @r#""select""#);
206        assert_snapshot!(quote_ident("array"), @r#""array""#);
207    }
208
209    #[test]
210    fn quote_ident_quotes_type_func_name_words() {
211        assert_snapshot!(quote_ident("left"), @r#""left""#);
212        assert_snapshot!(quote_ident("join"), @r#""join""#);
213    }
214
215    #[test]
216    fn quote_ident_quotes_names_that_dont_fold_to_themselves() {
217        assert_snapshot!(quote_ident("Mixed"), @r#""Mixed""#);
218        assert_snapshot!(quote_ident("has space"), @r#""has space""#);
219        assert_snapshot!(quote_ident(""), @r#""""#);
220        assert_snapshot!(quote_ident(r#"foo"bar"#), @r#""foo""bar""#);
221    }
222}