Skip to content

Commit 9f68fe9

Browse files
wojpadlowojpadloclaude
authored
Snowflake: decode octal/hex/unicode string-literal escapes (#17)
* Snowflake: decode octal/hex/unicode string-literal escapes Decode \ooo, \xhh and \uhhhh as Unicode code points, keep \0 as NUL, collapse unrecognized letters (\a, \Z) to the bare letter, and raise a tokenizer error on malformed \x / \u — gated behind a new supports_snowflake_string_literal_escapes dialect capability so other backslash-escaping dialects keep MySQL/BigQuery semantics. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Satisfy rust-1.97 clippy on main (useless_borrows_in_formatting, needless_return_with_question_mark) Pre-existing lints in ast/parser/tests/examples surfaced by the stable toolchain advancing to rust-1.97 (main last passed CI on the older stable). Mechanical fixes only; no behaviour change. Unrelated to the escape-parity commit but required to get this PR's lint job green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: wojpadlo <wojciech.padlo@localstack.cloud> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c96cee9 commit 9f68fe9

10 files changed

Lines changed: 234 additions & 20 deletions

File tree

‎examples/cli.rs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,9 @@ $ cargo run --example cli - [--dialectname]
7171
.expect("failed to read from stdin");
7272
String::from_utf8(buf).expect("stdin content wasn't valid utf8")
7373
} else {
74-
println!("Parsing from file '{}' using {:?}", &filename, dialect);
74+
println!("Parsing from file '{}' using {:?}", filename, dialect);
7575
fs::read_to_string(&filename)
76-
.unwrap_or_else(|_| panic!("Unable to read the file {}", &filename))
76+
.unwrap_or_else(|_| panic!("Unable to read the file {}", filename))
7777
};
7878
let without_bom = if contents.chars().next().unwrap() as u64 != 0xfeff {
7979
contents.as_str()

‎src/ast/ddl.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4735,7 +4735,7 @@ impl fmt::Display for AlterTable {
47354735
if self.only {
47364736
write!(f, "ONLY ")?;
47374737
}
4738-
write!(f, "{} ", &self.name)?;
4738+
write!(f, "{} ", self.name)?;
47394739
if let Some(cluster) = &self.on_cluster {
47404740
write!(f, "ON CLUSTER {cluster} ")?;
47414741
}

‎src/ast/mod.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13462,7 +13462,7 @@ impl fmt::Display for AlterUser {
1346213462
let has_props = !self.set_props.options.is_empty();
1346313463
if has_props {
1346413464
write!(f, " SET")?;
13465-
write!(f, " {}", &self.set_props)?;
13465+
write!(f, " {}", self.set_props)?;
1346613466
}
1346713467
if !self.unset_props.is_empty() {
1346813468
write!(f, " UNSET {}", display_comma_separated(&self.unset_props))?;

‎src/ast/query.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3530,7 +3530,7 @@ pub struct LockClause {
35303530

35313531
impl fmt::Display for LockClause {
35323532
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
3533-
write!(f, "FOR {}", &self.lock_type)?;
3533+
write!(f, "FOR {}", self.lock_type)?;
35343534
if let Some(ref of) = self.of {
35353535
write!(f, " OF {of}")?;
35363536
}

‎src/dialect/mod.rs‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,18 @@ pub trait Dialect: Debug + Any {
283283
false
284284
}
285285

286+
/// Determine whether the dialect decodes Snowflake's extended backslash
287+
/// escape set inside single-quoted string literals: octal `\ooo`, hex
288+
/// `\xhh` and unicode `\uhhhh` (each yielding a Unicode code point), with
289+
/// unrecognized letters such as `\a` / `\Z` collapsing to the bare letter
290+
/// and malformed `\x` / `\u` raising a tokenizer error. This differs from
291+
/// the MySQL/BigQuery-style mapping used by
292+
/// [`Self::supports_string_literal_backslash_escape`], so a dialect that
293+
/// enables this must also enable that one.
294+
fn supports_snowflake_string_literal_escapes(&self) -> bool {
295+
false
296+
}
297+
286298
/// Determine whether the dialect strips the backslash when escaping LIKE wildcards (%, _).
287299
///
288300
/// [MySQL] has a special case when escaping single quoted strings which leaves these unescaped
@@ -2016,6 +2028,10 @@ mod tests {
20162028
self.0.supports_string_literal_backslash_escape()
20172029
}
20182030

2031+
fn supports_snowflake_string_literal_escapes(&self) -> bool {
2032+
self.0.supports_snowflake_string_literal_escapes()
2033+
}
2034+
20192035
fn supports_filter_during_aggregation(&self) -> bool {
20202036
self.0.supports_filter_during_aggregation()
20212037
}

‎src/dialect/snowflake.rs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,11 @@ impl Dialect for SnowflakeDialect {
172172
true
173173
}
174174

175+
// See https://docs.snowflake.com/en/sql-reference/data-types-text#escape-sequences-in-single-quoted-string-constants
176+
fn supports_snowflake_string_literal_escapes(&self) -> bool {
177+
true
178+
}
179+
175180
fn supports_within_after_array_aggregation(&self) -> bool {
176181
true
177182
}

‎src/parser/mod.rs‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5010,7 +5010,7 @@ impl<'a> Parser<'a> {
50105010
if self.parse_keyword(expected) {
50115011
Ok(self.get_current_token().clone())
50125012
} else {
5013-
self.expected_ref(format!("{:?}", &expected).as_str(), self.peek_token_ref())
5013+
self.expected_ref(format!("{:?}", expected).as_str(), self.peek_token_ref())
50145014
}
50155015
}
50165016

@@ -5023,7 +5023,7 @@ impl<'a> Parser<'a> {
50235023
if self.parse_keyword(expected) {
50245024
Ok(())
50255025
} else {
5026-
self.expected_ref(format!("{:?}", &expected).as_str(), self.peek_token_ref())
5026+
self.expected_ref(format!("{:?}", expected).as_str(), self.peek_token_ref())
50275027
}
50285028
}
50295029

@@ -13974,7 +13974,7 @@ impl<'a> Parser<'a> {
1397413974
}
1397513975
Token::EOF => break,
1397613976
token => {
13977-
return Err(ParserError::ParserError(format!(
13977+
Err(ParserError::ParserError(format!(
1397813978
"Unexpected token in identifier: {token}"
1397913979
)))?;
1398013980
}
@@ -17298,7 +17298,7 @@ impl<'a> Parser<'a> {
1729817298
where_clause = Some(self.parse_expr()?);
1729917299
} else {
1730017300
let tok = self.peek_token_ref();
17301-
return parser_err!(
17301+
parser_err!(
1730217302
format!(
1730317303
"Expected one of DIMENSIONS, METRICS, FACTS or WHERE, got {}",
1730417304
tok.token

‎src/tokenizer.rs‎

Lines changed: 194 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2287,17 +2287,21 @@ impl<'a> Tokenizer<'a> {
22872287

22882288
num_consecutive_quotes = 0;
22892289

2290-
if let Some(next) = chars.peek() {
2290+
if let Some(&next) = chars.peek() {
22912291
if !self.unescape
22922292
|| (self.dialect.ignores_wildcard_escapes()
2293-
&& (*next == '%' || *next == '_'))
2293+
&& (next == '%' || next == '_'))
22942294
{
22952295
// In no-escape mode, the given query has to be saved completely
22962296
// including backslashes. Similarly, with ignore_like_wildcard_escapes,
22972297
// the backslash is not stripped.
22982298
s.push(ch);
2299-
s.push(*next);
2299+
s.push(next);
23002300
chars.next(); // consume next
2301+
} else if self.dialect.supports_snowflake_string_literal_escapes() {
2302+
let escape_loc = chars.location();
2303+
chars.next(); // consume the escape indicator
2304+
s.push(self.unescape_snowflake_escape(next, chars, escape_loc)?);
23012305
} else {
23022306
let n = match next {
23032307
'0' => '\0',
@@ -2308,7 +2312,7 @@ impl<'a> Tokenizer<'a> {
23082312
'r' => '\r',
23092313
't' => '\t',
23102314
'Z' => '\u{1a}',
2311-
_ => *next,
2315+
_ => next,
23122316
};
23132317
s.push(n);
23142318
chars.next(); // consume next
@@ -2331,6 +2335,174 @@ impl<'a> Tokenizer<'a> {
23312335
self.tokenizer_error(error_loc, "Unterminated string literal")
23322336
}
23332337

2338+
/// Decode a single Snowflake backslash escape (the backslash and the
2339+
/// `indicator` char have already been consumed). Octal `\ooo`, hex `\xhh`
2340+
/// and unicode `\uhhhh` each yield a Unicode code point; recognized control
2341+
/// letters map to their control character; every other letter collapses to
2342+
/// itself. Malformed `\x` / `\u` raise a tokenizer error, matching real
2343+
/// Snowflake.
2344+
fn unescape_snowflake_escape(
2345+
&self,
2346+
indicator: char,
2347+
chars: &mut State,
2348+
loc: Location,
2349+
) -> Result<char, TokenizerError> {
2350+
match indicator {
2351+
'x' => {
2352+
let mut digits = String::new();
2353+
while digits.len() < 2 {
2354+
match chars.peek() {
2355+
Some(c) if c.is_ascii_hexdigit() => {
2356+
if let Some(c) = chars.next() {
2357+
digits.push(c);
2358+
}
2359+
}
2360+
_ => break,
2361+
}
2362+
}
2363+
if digits.len() < 2 {
2364+
return self.tokenizer_error(
2365+
loc,
2366+
format!(
2367+
"Invalid hex escape sequence '\\x{digits}'; should be exactly 2 digits."
2368+
),
2369+
);
2370+
}
2371+
self.code_point(&digits, 16, loc)
2372+
}
2373+
'u' => self.unescape_unicode(chars, loc),
2374+
// Octal `\ooo`: up to three octal digits, but a third digit is only
2375+
// consumed when the running value stays within a single byte
2376+
// (<= 0xFF); otherwise it is left as a literal character.
2377+
'0'..='7' => {
2378+
let mut value = indicator.to_digit(8).unwrap_or(0);
2379+
// Second octal digit always fits in a byte (max 0o77 = 63).
2380+
if let Some(d) = chars.peek().and_then(|c| c.to_digit(8)) {
2381+
value = value * 8 + d;
2382+
chars.next();
2383+
// Third digit only when the byte does not overflow.
2384+
if let Some(d) = chars.peek().and_then(|c| c.to_digit(8)) {
2385+
if value * 8 + d <= 0xFF {
2386+
value = value * 8 + d;
2387+
chars.next();
2388+
}
2389+
}
2390+
}
2391+
char::from_u32(value).map_or_else(
2392+
|| self.tokenizer_error(loc, "Invalid octal escape sequence."),
2393+
Ok,
2394+
)
2395+
}
2396+
'b' => Ok('\u{8}'),
2397+
'f' => Ok('\u{c}'),
2398+
'n' => Ok('\n'),
2399+
'r' => Ok('\r'),
2400+
't' => Ok('\t'),
2401+
other => Ok(other),
2402+
}
2403+
}
2404+
2405+
fn code_point(&self, digits: &str, radix: u32, loc: Location) -> Result<char, TokenizerError> {
2406+
u32::from_str_radix(digits, radix)
2407+
.ok()
2408+
.and_then(char::from_u32)
2409+
.map_or_else(|| self.tokenizer_error(loc, "Invalid escape sequence."), Ok)
2410+
}
2411+
2412+
/// Decode a `\uhhhh` escape (the `\u` has already been consumed). A high
2413+
/// surrogate must be followed by a `\uhhhh` low surrogate and combines with
2414+
/// it into an astral code point; an unpaired high or low surrogate is an
2415+
/// error, matching real Snowflake.
2416+
fn unescape_unicode(&self, chars: &mut State, loc: Location) -> Result<char, TokenizerError> {
2417+
let value = self.read_hex4(chars, loc)?;
2418+
if (0xD800..=0xDBFF).contains(&value) {
2419+
return match self.read_low_surrogate(chars) {
2420+
Some(low) => {
2421+
let cp = 0x1_0000 + ((value - 0xD800) << 10) + (low - 0xDC00);
2422+
char::from_u32(cp)
2423+
.map_or_else(|| self.tokenizer_error(loc, "Invalid escape sequence."), Ok)
2424+
}
2425+
None => self.tokenizer_error(
2426+
loc,
2427+
format!(
2428+
"Invalid Unicode string literal; high surrogate '\\u{value:04X}' \
2429+
must be followed by a low surrogate ('\\uDC00'-'\\uDFFF')."
2430+
),
2431+
),
2432+
};
2433+
}
2434+
if (0xDC00..=0xDFFF).contains(&value) {
2435+
return self.tokenizer_error(
2436+
loc,
2437+
format!(
2438+
"Invalid Unicode string literal; low surrogate '\\u{value:04X}' \
2439+
must be preceded by a high surrogate ('\\uD800'-'\\uDBFF')."
2440+
),
2441+
);
2442+
}
2443+
char::from_u32(value)
2444+
.map_or_else(|| self.tokenizer_error(loc, "Invalid escape sequence."), Ok)
2445+
}
2446+
2447+
/// Read exactly four hex digits as a `u32`, erroring like Snowflake when
2448+
/// fewer than four are present.
2449+
fn read_hex4(&self, chars: &mut State, loc: Location) -> Result<u32, TokenizerError> {
2450+
let mut digits = String::new();
2451+
while digits.len() < 4 {
2452+
match chars.peek() {
2453+
Some(c) if c.is_ascii_hexdigit() => {
2454+
if let Some(c) = chars.next() {
2455+
digits.push(c);
2456+
}
2457+
}
2458+
_ => break,
2459+
}
2460+
}
2461+
if digits.len() < 4 {
2462+
return self.tokenizer_error(
2463+
loc,
2464+
format!(
2465+
"Invalid unicode escape sequence '\\u{digits}'; should be exactly 4 digits."
2466+
),
2467+
);
2468+
}
2469+
u32::from_str_radix(&digits, 16).map_or_else(
2470+
|_| self.tokenizer_error(loc, "Invalid unicode escape sequence."),
2471+
Ok,
2472+
)
2473+
}
2474+
2475+
/// Consume a trailing `\uhhhh` low surrogate, returning its value when
2476+
/// present and in range. Consumes greedily on the error path — the caller
2477+
/// aborts the whole token, so partial consumption is irrelevant.
2478+
fn read_low_surrogate(&self, chars: &mut State) -> Option<u32> {
2479+
if chars.peek() != Some(&'\\') {
2480+
return None;
2481+
}
2482+
chars.next();
2483+
if chars.peek() != Some(&'u') {
2484+
return None;
2485+
}
2486+
chars.next();
2487+
let mut digits = String::new();
2488+
while digits.len() < 4 {
2489+
match chars.peek() {
2490+
Some(c) if c.is_ascii_hexdigit() => {
2491+
if let Some(c) = chars.next() {
2492+
digits.push(c);
2493+
}
2494+
}
2495+
_ => break,
2496+
}
2497+
}
2498+
if digits.len() < 4 {
2499+
return None;
2500+
}
2501+
u32::from_str_radix(&digits, 16)
2502+
.ok()
2503+
.filter(|low| (0xDC00..=0xDFFF).contains(low))
2504+
}
2505+
23342506
fn tokenize_multiline_comment(
23352507
&self,
23362508
chars: &mut State,
@@ -3878,10 +4050,13 @@ mod tests {
38784050
(r#"'%a\'%b'"#, r#"%a\'%b"#, r#"%a'%b"#),
38794051
(r#"'a\'\'b\'c\'d'"#, r#"a\'\'b\'c\'d"#, r#"a''b'c'd"#),
38804052
(r#"'\\'"#, r#"\\"#, r#"\"#),
4053+
// Snowflake keeps `\0` as NUL, maps the control-letter escapes, and
4054+
// collapses unrecognized letters (`\a` -> a, `\Z` -> Z) to the bare
4055+
// letter rather than a control character.
38814056
(
38824057
r#"'\0\a\b\f\n\r\t\Z'"#,
38834058
r#"\0\a\b\f\n\r\t\Z"#,
3884-
"\0\u{7}\u{8}\u{c}\n\r\t\u{1a}",
4059+
"\0a\u{8}\u{c}\n\r\tZ",
38854060
),
38864061
(r#"'\"'"#, r#"\""#, "\""),
38874062
(r#"'\\a\\b\'c'"#, r#"\\a\\b\'c"#, r#"\a\b'c"#),
@@ -3890,6 +4065,20 @@ mod tests {
38904065
(r#"'\q'"#, r#"\q"#, r#"q"#),
38914066
(r#"'\%\_'"#, r#"\%\_"#, r#"%_"#),
38924067
(r#"'\\%\\_'"#, r#"\\%\\_"#, r#"\%\_"#),
4068+
// Hex `\xhh` and unicode `\uhhhh` yield a code point; octal `\ooo`
4069+
// takes a third digit only when the byte does not overflow.
4070+
(r#"'\x41'"#, r#"\x41"#, "A"),
4071+
(r#"'\u0041'"#, r#"\u0041"#, "A"),
4072+
(r#"'\u00e9'"#, r#"\u00e9"#, "\u{e9}"),
4073+
(r#"'\ud83d\ude00'"#, r#"\ud83d\ude00"#, "\u{1f600}"),
4074+
(r#"'\xff'"#, r#"\xff"#, "\u{ff}"),
4075+
(r#"'A'"#, r#"A"#, "A"),
4076+
(r#"'é'"#, r#"é"#, "\u{e9}"),
4077+
(r#"'ꯍ'"#, r#"ꯍ"#, "\u{abcd}"),
4078+
(r#"'\101'"#, r#"\101"#, "A"),
4079+
(r#"'\1012'"#, r#"\1012"#, "A2"),
4080+
(r#"'\777'"#, r#"\777"#, "\u{3f}7"),
4081+
(r#"'\400'"#, r#"\400"#, "\u{20}0"),
38934082
] {
38944083
let tokens = Tokenizer::new(&dialect, sql)
38954084
.with_unescape(false)

0 commit comments

Comments
 (0)