Skip to main content

squawk_syntax/
quote.rs

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