From 3cdbfa822f00aeda28ced109002247e583291a38 Mon Sep 17 00:00:00 2001 From: Steve Dignam Date: Tue, 19 May 2026 20:21:21 -0400 Subject: [PATCH 1/2] ide: add hover for string literals --- crates/squawk_ide/src/hover.rs | 417 ++++++++++++++++++++++- crates/squawk_ide/src/infer.rs | 2 +- crates/squawk_syntax/src/ast/node_ext.rs | 24 ++ crates/squawk_syntax/src/lib.rs | 2 +- crates/squawk_syntax/src/quote.rs | 21 ++ crates/squawk_syntax/src/unescape.rs | 172 +++++++++- 6 files changed, 630 insertions(+), 8 deletions(-) diff --git a/crates/squawk_ide/src/hover.rs b/crates/squawk_ide/src/hover.rs index e4d362e9..638e8cf1 100644 --- a/crates/squawk_ide/src/hover.rs +++ b/crates/squawk_ide/src/hover.rs @@ -4,7 +4,7 @@ use crate::column_name::ColumnName; use crate::comments::preceding_comment; use crate::db::{bind, list_files, parse}; use crate::file::InFile; -use crate::infer::infer_type_from_expr; +use crate::infer::{infer_type_from_expr, infer_type_from_literal}; use crate::location::{Location, LocationKind}; use crate::name; use crate::offsets::token_from_offset; @@ -14,6 +14,13 @@ use rowan::TextSize; use salsa::Database as Db; use squawk_syntax::SyntaxNode; use squawk_syntax::SyntaxNodePtr; +use squawk_syntax::ast::LitKind; +use squawk_syntax::quote::{ + strip_dollar_quotes, strip_prefixed_quotes, strip_quotes, strip_unicode_esc_prefix, +}; +use squawk_syntax::unescape::{ + decode_esc_string, decode_plain_string, decode_unicode_esc_string, uescape_char, +}; use squawk_syntax::{ SyntaxKind, ast::{self, AstNode}, @@ -132,13 +139,170 @@ pub fn hover(db: &dyn Db, position: InFile) -> Option { return hover_name_ref(db, position); } - if let Some(name) = ast::Name::cast(parent) { + if let Some(name) = ast::Name::cast(parent.clone()) { return hover_name(db, InFile::new(file, name)); } + if let Some(literal) = ast::Literal::cast(parent.clone()) { + return hover_literal(&literal); + } + None } +fn hover_literal(literal: &ast::Literal) -> Option { + let kind = literal.kind()?; + // TODO: support all literal types + if !matches!( + kind, + LitKind::String(_) + | LitKind::BitString(_) + | LitKind::ByteString(_) + | LitKind::EscString(_) + | LitKind::UnicodeEscString(_) + | LitKind::DollarQuotedString(_) + ) { + return None; + } + + let value = literal_string_value(literal)?; + let ty = infer_type_from_literal(literal)?.to_string(); + + let comment = match kind { + LitKind::BitString(_) => format_bit_value_comment(&value, 2), + LitKind::ByteString(_) => format_bit_value_comment(&value, 16), + LitKind::String(_) + | LitKind::EscString(_) + | LitKind::UnicodeEscString(_) + | LitKind::DollarQuotedString(_) => match value.find('\n') { + Some(idx) => { + let truncated = &value[..idx]; + format!( + "value of literal (truncated up to newline): {}", + markdown_inline_code(truncated) + ) + } + None => format!("value of literal: {}", markdown_inline_code(&value)), + }, + LitKind::FloatNumber(_) => return None, + LitKind::IntNumber(_) => return None, + LitKind::Null(_) => return None, + LitKind::PositionalParam(_) => return None, + }; + + Some(Hover::new(ty, comment)) +} + +fn format_bit_value_comment(digits: &str, radix: u32) -> String { + if let Ok(n) = u128::from_str_radix(digits, radix) { + return format!( + "value of literal: {}", + markdown_inline_code(&format!("{n} (0x{n:X}|0b{n:b})")) + ); + } + + format!("value of literal: {}", markdown_inline_code(digits)) +} + +// escape backticks that exist in the text +fn markdown_inline_code(text: &str) -> String { + let mut max_run = 0; + let mut run = 0; + + for ch in text.chars() { + if ch == '`' { + run += 1; + max_run = max_run.max(run); + } else { + run = 0; + } + } + + let fence = "`".repeat(max_run + 1); + format!("{fence} {text} {fence}") +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum StringDecoding { + BitOrByte, + EscString, + UnicodeEscString, +} + +fn literal_string_value(literal: &ast::Literal) -> Option { + let escape_char = unicode_escape_char(literal); + let mut out = String::with_capacity(literal.syntax().text().len().into()); + let mut decoding: Option = None; + + for element in literal.syntax().children_with_tokens() { + let Some(token) = element.into_token() else { + continue; + }; + match token.kind() { + SyntaxKind::ESC_STRING => { + let inner = strip_prefixed_quotes(token.text(), ['e', 'E'])?; + decode_esc_string(inner, &mut out); + decoding = Some(StringDecoding::EscString); + } + SyntaxKind::UNICODE_ESC_STRING => { + let inner = strip_unicode_esc_prefix(token.text())?; + decode_unicode_esc_string(inner, escape_char, &mut out); + decoding = Some(StringDecoding::UnicodeEscString); + } + SyntaxKind::BIT_STRING => { + let inner = strip_prefixed_quotes(token.text(), ['b', 'B'])?; + out.push_str(inner); + decoding = Some(StringDecoding::BitOrByte); + } + SyntaxKind::BYTE_STRING => { + let inner = strip_prefixed_quotes(token.text(), ['x', 'X'])?; + out.push_str(inner); + decoding = Some(StringDecoding::BitOrByte); + } + SyntaxKind::DOLLAR_QUOTED_STRING => { + let inner = strip_dollar_quotes(token.text())?; + out.push_str(inner); + return Some(out); + } + SyntaxKind::STRING => { + let inner = strip_quotes(token.text())?; + match decoding { + Some(StringDecoding::EscString) => decode_esc_string(inner, &mut out), + Some(StringDecoding::UnicodeEscString) => { + decode_unicode_esc_string(inner, escape_char, &mut out) + } + Some(StringDecoding::BitOrByte) => out.push_str(inner), + None => decode_plain_string(inner, &mut out), + } + } + SyntaxKind::UESCAPE_KW => break, + _ => (), + } + } + + Some(out) +} + +fn unicode_escape_char(literal: &ast::Literal) -> char { + let mut seen_uescape = false; + for element in literal.syntax().children_with_tokens() { + let Some(token) = element.into_token() else { + continue; + }; + match token.kind() { + SyntaxKind::UESCAPE_KW => seen_uescape = true, + SyntaxKind::STRING if seen_uescape => { + if let Some(ch) = uescape_char(token.text()) { + return ch; + } + return '\\'; + } + _ => (), + } + } + '\\' +} + fn hover_name(db: &dyn Db, name: InFile) -> Option { let file = name.file_id; let name = name.value; @@ -5280,4 +5444,253 @@ alter property graph foo.ba$0r rename to baz; ╰╴ ─ hover "); } + + #[test] + fn hover_esc_string() { + assert_snapshot!(check_hover_info(r" +select e'fo$0o\nbar'; +").markdown(), @" + ```sql + text + ``` + --- + value of literal (truncated up to newline): ` foo ` + "); + } + + #[test] + fn hover_esc_string_with_tab() { + assert_snapshot!(check_hover_info(r" +select e'a\tb$0'; +").markdown(), @" + ```sql + text + ``` + --- + value of literal: ` a b ` + "); + } + + #[test] + fn hover_esc_string_hex_byte_sequence_utf8() { + assert_snapshot!(check_hover_info(r" +select e'\xC3\xA$09'; +").markdown(), @" + ```sql + text + ``` + --- + value of literal: ` é ` + "); + } + + #[test] + fn hover_unicode_esc_string() { + assert_snapshot!(check_hover_info(r" +select U&'\0061\0308b$0c'; +").markdown(), @" + ```sql + text + ``` + --- + value of literal: ` äbc ` + "); + } + + #[test] + fn hover_unicode_esc_string_with_uescape() { + assert_snapshot!(check_hover_info(r" +select U&'!0061!0062$0' uescape '!'; +").markdown(), @" + ```sql + text + ``` + --- + value of literal: ` ab ` + "); + } + + #[test] + fn hover_string_continuation() { + assert_snapshot!(check_hover_info(r" +select e'foo$0' +'\nbar'; +").markdown(), @" + ```sql + text + ``` + --- + value of literal (truncated up to newline): ` foo ` + "); + } + + #[test] + fn hover_unicode_esc_string_continuation() { + assert_snapshot!(check_hover_info(r" +select U&'\0061' +'\006$02'; +").markdown(), @" + ```sql + text + ``` + --- + value of literal: ` ab ` + "); + } + + #[test] + fn hover_plain_string_no_escape() { + assert_snapshot!(check_hover_info(r" +select 'foo$0'; +").markdown(), @" + ```sql + text + ``` + --- + value of literal: ` foo ` + "); + } + + #[test] + fn hover_plain_string_escaped_quotes() { + assert_snapshot!(check_hover_info(r" +select '''$0'; +").markdown(), @" + ```sql + text + ``` + --- + value of literal: ` ' ` + "); + } + + #[test] + fn hover_plain_string_with_doubled_quote() { + assert_snapshot!(check_hover_info(r" +select 'it''$0s'; +").markdown(), @" + ```sql + text + ``` + --- + value of literal: ` it's ` + "); + } + + #[test] + fn hover_plain_string_with_backtick() { + assert_snapshot!(check_hover_info(r" +select 'a`$0b'; +").markdown(), @" + ```sql + text + ``` + --- + value of literal: `` a`b `` + "); + } + + #[test] + fn hover_plain_string_with_leading_backtick() { + assert_snapshot!(check_hover_info(r" +select '`$0hello'; +").markdown(), @" + ```sql + text + ``` + --- + value of literal: `` `hello `` + "); + } + + #[test] + fn hover_plain_string_with_backticks() { + assert_snapshot!(check_hover_info(r" +select '`foo`$0'; +").markdown(), @" + ```sql + text + ``` + --- + value of literal: `` `foo` `` + "); + } + + #[test] + fn hover_plain_string_with_consecutive_backticks() { + assert_snapshot!(check_hover_info(r" +select 'a``$0b'; +").markdown(), @" + ```sql + text + ``` + --- + value of literal: ``` a``b ``` + "); + } + + #[test] + fn hover_dollar_quoted_string() { + assert_snapshot!(check_hover_info(r" +select $$he$0llo$$; +").markdown(), @" + ```sql + text + ``` + --- + value of literal: ` hello ` + "); + } + + #[test] + fn hover_bit_string() { + assert_snapshot!(check_hover_info(r" +select b'10$010'; +").markdown(), @" + ```sql + bit + ``` + --- + value of literal: ` 10 (0xA|0b1010) ` + "); + } + + #[test] + fn hover_byte_string() { + assert_snapshot!(check_hover_info(r" +select x'1A$03F'; +").markdown(), @" + ```sql + bit + ``` + --- + value of literal: ` 6719 (0x1A3F|0b1101000111111) ` + "); + } + + #[test] + fn hover_byte_string_short() { + assert_snapshot!(check_hover_info(r" +select x'F$0F'; +").markdown(), @" + ```sql + bit + ``` + --- + value of literal: ` 255 (0xFF|0b11111111) ` + "); + } + + #[test] + fn hover_byte_string_exceeds_u128_falls_back() { + assert_snapshot!(check_hover_info(r" +select x'e3b0c44298fc1c14$09afbf4c8996fb92427ae41e4649b934ca495991b7852b855'; +").markdown(), @" + ```sql + bit + ``` + --- + value of literal: ` e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ` + "); + } } diff --git a/crates/squawk_ide/src/infer.rs b/crates/squawk_ide/src/infer.rs index 756ffe39..8d07db57 100644 --- a/crates/squawk_ide/src/infer.rs +++ b/crates/squawk_ide/src/infer.rs @@ -64,7 +64,7 @@ pub(crate) fn infer_type_from_ty(ty: &ast::Type) -> Option { } } -fn infer_type_from_literal(literal: &ast::Literal) -> Option { +pub(crate) fn infer_type_from_literal(literal: &ast::Literal) -> Option { let token = literal.syntax().first_token()?; match token.kind() { SyntaxKind::INT_NUMBER => Some(Type::Integer), diff --git a/crates/squawk_syntax/src/ast/node_ext.rs b/crates/squawk_syntax/src/ast/node_ext.rs index be01155b..4eda92a7 100644 --- a/crates/squawk_syntax/src/ast/node_ext.rs +++ b/crates/squawk_syntax/src/ast/node_ext.rs @@ -660,6 +660,30 @@ fn name_ref() { } } +#[test] +fn unicode_quoted_name_keeps_doubled_single_quotes() { + let parse = SourceFile::parse(r#"select 1 U&"a''b""#); + assert!(parse.errors().is_empty()); + let stmt = parse.tree().stmts().next().unwrap(); + let ast::Stmt::Select(select) = stmt else { + unreachable!() + }; + let name = select + .select_clause() + .unwrap() + .target_list() + .unwrap() + .targets() + .next() + .unwrap() + .as_name() + .unwrap() + .name() + .unwrap(); + + assert_snapshot!(name.text().to_string(), @"a''b"); +} + #[test] fn index_expr() { let source_code = " diff --git a/crates/squawk_syntax/src/lib.rs b/crates/squawk_syntax/src/lib.rs index e6662bf8..b279ff48 100644 --- a/crates/squawk_syntax/src/lib.rs +++ b/crates/squawk_syntax/src/lib.rs @@ -32,7 +32,7 @@ pub mod quote; pub mod syntax_error; mod syntax_node; mod token_text; -mod unescape; +pub mod unescape; mod validation; #[cfg(test)] diff --git a/crates/squawk_syntax/src/quote.rs b/crates/squawk_syntax/src/quote.rs index 3528a018..3b364a99 100644 --- a/crates/squawk_syntax/src/quote.rs +++ b/crates/squawk_syntax/src/quote.rs @@ -76,6 +76,27 @@ pub fn is_reserved_word(text: &str) -> bool { .is_ok() } +pub fn strip_quotes(text: &str) -> Option<&str> { + text.strip_prefix('\'')?.strip_suffix('\'') +} + +pub fn strip_prefixed_quotes(text: &str, prefix: [char; 2]) -> Option<&str> { + strip_quotes(text.strip_prefix(prefix)?) +} + +pub fn strip_unicode_esc_prefix(text: &str) -> Option<&str> { + strip_quotes(text.strip_prefix(['u', 'U'])?.strip_prefix('&')?) +} + +pub fn strip_dollar_quotes(text: &str) -> Option<&str> { + let after_first = text.strip_prefix('$')?; + let tag_end = after_first.find('$')?; + let tag = &after_first[..tag_end]; + let body = &after_first[tag_end + 1..]; + let closing = format!("${tag}$"); + body.strip_suffix(&closing) +} + #[cfg(test)] mod tests { use insta::assert_snapshot; diff --git a/crates/squawk_syntax/src/unescape.rs b/crates/squawk_syntax/src/unescape.rs index a2e3b6ab..c0dd8653 100644 --- a/crates/squawk_syntax/src/unescape.rs +++ b/crates/squawk_syntax/src/unescape.rs @@ -1,7 +1,7 @@ use std::fmt; use std::ops::{Range, RangeInclusive}; -pub(crate) enum UnicodeEscapeKind { +pub enum UnicodeEscapeKind { Extended, Short, } @@ -15,7 +15,7 @@ impl UnicodeEscapeKind { } } -pub(crate) enum UnicodeEscError { +pub enum UnicodeEscError { InvalidEscape, InvalidSurrogatePair, OutOfRange, @@ -47,7 +47,7 @@ impl fmt::Display for UnicodeEscError { } } -pub(crate) fn escape_unicode_esc_str(text: &str, escape_char: char, mut callback: F) +pub fn escape_unicode_esc_str(text: &str, escape_char: char, mut callback: F) where F: FnMut(Range, Result), { @@ -164,7 +164,7 @@ const fn is_valid_uescape_char(byte: u8) -> bool { ) } -pub(crate) fn uescape_char(text: &str) -> Option { +pub fn uescape_char(text: &str) -> Option { let inner = text.strip_prefix('\'')?.strip_suffix('\'')?; let &[byte] = inner.as_bytes() else { return None; @@ -172,6 +172,143 @@ pub(crate) fn uescape_char(text: &str) -> Option { is_valid_uescape_char(byte).then(|| char::from(byte)) } +pub fn decode_plain_string(inner: &str, out: &mut String) { + let mut chars = inner.chars().peekable(); + while let Some(c) = chars.next() { + if c == '\'' && chars.peek() == Some(&'\'') { + chars.next(); + out.push('\''); + } else { + out.push(c); + } + } +} + +fn push_char_bytes(c: char, bytes: &mut Vec) { + let mut buf = [0; 4]; + let encoded = c.encode_utf8(&mut buf); + bytes.extend_from_slice(encoded.as_bytes()); +} + +pub fn decode_esc_string(inner: &str, out: &mut String) { + let mut chars = inner.chars().peekable(); + let mut bytes = vec![]; + + while let Some(c) = chars.next() { + if c == '\'' && chars.peek() == Some(&'\'') { + chars.next(); + bytes.push(b'\''); + continue; + } + if c != '\\' { + push_char_bytes(c, &mut bytes); + continue; + } + let Some(&next) = chars.peek() else { + bytes.push(b'\\'); + break; + }; + match next { + 'b' => { + chars.next(); + bytes.push(b'\x08'); + } + 'f' => { + chars.next(); + bytes.push(b'\x0C'); + } + 'n' => { + chars.next(); + bytes.push(b'\n'); + } + 'r' => { + chars.next(); + bytes.push(b'\r'); + } + 't' => { + chars.next(); + bytes.push(b'\t'); + } + '0'..='7' => { + let mut value: u32 = 0; + for _ in 0..3 { + match chars.peek() { + Some(&d) if ('0'..='7').contains(&d) => { + chars.next(); + value = value * 8 + d.to_digit(8).unwrap(); + } + _ => break, + } + } + if value != 0 { + bytes.push(value as u8); + } + } + 'x' => { + chars.next(); + let mut value: u8 = 0; + let mut got_any = false; + for _ in 0..2 { + match chars.peek() { + Some(&d) if d.is_ascii_hexdigit() => { + chars.next(); + value = value * 16 + d.to_digit(16).unwrap() as u8; + got_any = true; + } + _ => break, + } + } + if got_any { + if value != 0 { + bytes.push(value); + } + } else { + bytes.push(b'x'); + } + } + 'u' | 'U' => { + chars.next(); + let required = if next == 'u' { 4 } else { 8 }; + let mut value: u32 = 0; + let mut got_all = true; + for _ in 0..required { + match chars.peek() { + Some(&d) if d.is_ascii_hexdigit() => { + chars.next(); + value = value * 16 + d.to_digit(16).unwrap(); + } + _ => { + got_all = false; + break; + } + } + } + if got_all + && let Some(ch) = char::from_u32(value) + && ch != '\0' + { + push_char_bytes(ch, &mut bytes); + } + } + _ => { + chars.next(); + push_char_bytes(next, &mut bytes); + } + } + } + + out.push_str(&String::from_utf8_lossy(&bytes)); +} + +pub fn decode_unicode_esc_string(inner: &str, escape_char: char, out: &mut String) { + let inner = inner.replace("''", "'"); + escape_unicode_esc_str(&inner, escape_char, |_range, result| { + if let Ok(ch) = result { + out.push(ch); + } + }); +} + #[cfg(test)] mod tests { use insta::assert_snapshot; @@ -192,6 +329,18 @@ mod tests { events.join("\n") } + fn decode_escape_string(inner: &str) -> String { + let mut out = String::new(); + decode_esc_string(inner, &mut out); + out + } + + fn decode_unicode_escape_string(inner: &str, escape_char: char) -> String { + let mut out = String::new(); + decode_unicode_esc_string(inner, escape_char, &mut out); + out + } + #[test] fn ok() { assert_snapshot!(unicode_escape_events(r"hello world", '\\'), @" @@ -251,4 +400,19 @@ mod tests { 0..10 err Invalid Unicode surrogate pair "); } + + #[test] + fn decode_escape_string_hex_bytes_as_utf8() { + assert_snapshot!(decode_escape_string(r"\xC3\xA9"), @"é"); + } + + #[test] + fn decode_escape_string_skips_nul_byte() { + assert_snapshot!(decode_escape_string(r"a\000b"), @"ab"); + } + + #[test] + fn decode_unicode_string_collapses_doubled_quotes() { + assert_snapshot!(decode_unicode_escape_string("a''b", '\\'), @"a'b"); + } } From ff2955dba01cdf499ea8d1eac9424c865e5e6e5b Mon Sep 17 00:00:00 2001 From: Steve Dignam Date: Tue, 19 May 2026 23:52:18 -0400 Subject: [PATCH 2/2] update --- crates/squawk_ide/src/hover.rs | 178 +++++++++++++----------------- crates/squawk_ide/src/infer.rs | 2 + crates/squawk_ide/src/lib.rs | 1 + crates/squawk_ide/src/literals.rs | 134 ++++++++++++++++++++++ 4 files changed, 216 insertions(+), 99 deletions(-) create mode 100644 crates/squawk_ide/src/literals.rs diff --git a/crates/squawk_ide/src/hover.rs b/crates/squawk_ide/src/hover.rs index 638e8cf1..7100ac61 100644 --- a/crates/squawk_ide/src/hover.rs +++ b/crates/squawk_ide/src/hover.rs @@ -5,6 +5,9 @@ use crate::comments::preceding_comment; use crate::db::{bind, list_files, parse}; use crate::file::InFile; use crate::infer::{infer_type_from_expr, infer_type_from_literal}; +use crate::literals::binary_digits_to_hex; +use crate::literals::hex_digits_to_binary; +use crate::literals::literal_string_value; use crate::location::{Location, LocationKind}; use crate::name; use crate::offsets::token_from_offset; @@ -15,12 +18,6 @@ use salsa::Database as Db; use squawk_syntax::SyntaxNode; use squawk_syntax::SyntaxNodePtr; use squawk_syntax::ast::LitKind; -use squawk_syntax::quote::{ - strip_dollar_quotes, strip_prefixed_quotes, strip_quotes, strip_unicode_esc_prefix, -}; -use squawk_syntax::unescape::{ - decode_esc_string, decode_plain_string, decode_unicode_esc_string, uescape_char, -}; use squawk_syntax::{ SyntaxKind, ast::{self, AstNode}, @@ -143,7 +140,7 @@ pub fn hover(db: &dyn Db, position: InFile) -> Option { return hover_name(db, InFile::new(file, name)); } - if let Some(literal) = ast::Literal::cast(parent.clone()) { + if let Some(literal) = ast::Literal::cast(parent) { return hover_literal(&literal); } @@ -194,16 +191,28 @@ fn hover_literal(literal: &ast::Literal) -> Option { } fn format_bit_value_comment(digits: &str, radix: u32) -> String { - if let Ok(n) = u128::from_str_radix(digits, radix) { - return format!( - "value of literal: {}", - markdown_inline_code(&format!("{n} (0x{n:X}|0b{n:b})")) - ); + let patterns = match radix { + 2 => bit_string_patterns(digits), + 16 => byte_string_patterns(digits), + _ => None, + }; + + if let Some((hex, binary)) = patterns { + let formatted = format!("x'{hex}'|b'{binary}'"); + return format!("value of literal: {}", markdown_inline_code(&formatted)); } format!("value of literal: {}", markdown_inline_code(digits)) } +fn bit_string_patterns(digits: &str) -> Option<(String, String)> { + Some((binary_digits_to_hex(digits)?, digits.to_string())) +} + +fn byte_string_patterns(digits: &str) -> Option<(String, String)> { + Some((digits.to_string(), hex_digits_to_binary(digits)?)) +} + // escape backticks that exist in the text fn markdown_inline_code(text: &str) -> String { let mut max_run = 0; @@ -222,87 +231,6 @@ fn markdown_inline_code(text: &str) -> String { format!("{fence} {text} {fence}") } -#[derive(Clone, Copy, PartialEq, Eq)] -enum StringDecoding { - BitOrByte, - EscString, - UnicodeEscString, -} - -fn literal_string_value(literal: &ast::Literal) -> Option { - let escape_char = unicode_escape_char(literal); - let mut out = String::with_capacity(literal.syntax().text().len().into()); - let mut decoding: Option = None; - - for element in literal.syntax().children_with_tokens() { - let Some(token) = element.into_token() else { - continue; - }; - match token.kind() { - SyntaxKind::ESC_STRING => { - let inner = strip_prefixed_quotes(token.text(), ['e', 'E'])?; - decode_esc_string(inner, &mut out); - decoding = Some(StringDecoding::EscString); - } - SyntaxKind::UNICODE_ESC_STRING => { - let inner = strip_unicode_esc_prefix(token.text())?; - decode_unicode_esc_string(inner, escape_char, &mut out); - decoding = Some(StringDecoding::UnicodeEscString); - } - SyntaxKind::BIT_STRING => { - let inner = strip_prefixed_quotes(token.text(), ['b', 'B'])?; - out.push_str(inner); - decoding = Some(StringDecoding::BitOrByte); - } - SyntaxKind::BYTE_STRING => { - let inner = strip_prefixed_quotes(token.text(), ['x', 'X'])?; - out.push_str(inner); - decoding = Some(StringDecoding::BitOrByte); - } - SyntaxKind::DOLLAR_QUOTED_STRING => { - let inner = strip_dollar_quotes(token.text())?; - out.push_str(inner); - return Some(out); - } - SyntaxKind::STRING => { - let inner = strip_quotes(token.text())?; - match decoding { - Some(StringDecoding::EscString) => decode_esc_string(inner, &mut out), - Some(StringDecoding::UnicodeEscString) => { - decode_unicode_esc_string(inner, escape_char, &mut out) - } - Some(StringDecoding::BitOrByte) => out.push_str(inner), - None => decode_plain_string(inner, &mut out), - } - } - SyntaxKind::UESCAPE_KW => break, - _ => (), - } - } - - Some(out) -} - -fn unicode_escape_char(literal: &ast::Literal) -> char { - let mut seen_uescape = false; - for element in literal.syntax().children_with_tokens() { - let Some(token) = element.into_token() else { - continue; - }; - match token.kind() { - SyntaxKind::UESCAPE_KW => seen_uescape = true, - SyntaxKind::STRING if seen_uescape => { - if let Some(ch) = uescape_char(token.text()) { - return ch; - } - return '\\'; - } - _ => (), - } - } - '\\' -} - fn hover_name(db: &dyn Db, name: InFile) -> Option { let file = name.file_id; let name = name.value; @@ -5651,7 +5579,7 @@ select b'10$010'; bit ``` --- - value of literal: ` 10 (0xA|0b1010) ` + value of literal: ` x'A'|b'1010' ` "); } @@ -5664,7 +5592,33 @@ select x'1A$03F'; bit ``` --- - value of literal: ` 6719 (0x1A3F|0b1101000111111) ` + value of literal: ` x'1A3F'|b'0001101000111111' ` + "); + } + + #[test] + fn hover_byte_string_empty() { + assert_snapshot!(check_hover_info(r" +select x'$0'; +").markdown(), @" + ```sql + bit + ``` + --- + value of literal: ` x''|b'' ` + "); + } + + #[test] + fn hover_bit_string_empty() { + assert_snapshot!(check_hover_info(r" +select b'$0'; +").markdown(), @" + ```sql + bit + ``` + --- + value of literal: ` x''|b'' ` "); } @@ -5677,20 +5631,46 @@ select x'F$0F'; bit ``` --- - value of literal: ` 255 (0xFF|0b11111111) ` + value of literal: ` x'FF'|b'11111111' ` + "); + } + + #[test] + fn hover_byte_string_preserves_hex_width() { + assert_snapshot!(check_hover_info(r" +select x'0F$0F'; +").markdown(), @" + ```sql + bit + ``` + --- + value of literal: ` x'0FF'|b'000011111111' ` + "); + } + + #[test] + fn hover_bit_string_preserves_binary_width() { + assert_snapshot!(check_hover_info(r" +select b'0$00'; +").markdown(), @" + ```sql + bit + ``` + --- + value of literal: ` x'0'|b'00' ` "); } #[test] - fn hover_byte_string_exceeds_u128_falls_back() { + fn hover_byte_string_large() { assert_snapshot!(check_hover_info(r" -select x'e3b0c44298fc1c14$09afbf4c8996fb92427ae41e4649b934ca495991b7852b855'; +select x'10000000000000000000000000000000$00'; ").markdown(), @" ```sql bit ``` --- - value of literal: ` e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ` + value of literal: ` x'100000000000000000000000000000000'|b'000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000' ` "); } } diff --git a/crates/squawk_ide/src/infer.rs b/crates/squawk_ide/src/infer.rs index 8d07db57..a4c282f2 100644 --- a/crates/squawk_ide/src/infer.rs +++ b/crates/squawk_ide/src/infer.rs @@ -69,6 +69,8 @@ pub(crate) fn infer_type_from_literal(literal: &ast::Literal) -> Option { match token.kind() { SyntaxKind::INT_NUMBER => Some(Type::Integer), SyntaxKind::FLOAT_NUMBER => Some(Type::Numeric), + // TODO: this isn't necessarily text, e.g., select 1 + '1'; + // We need to look at the context of the string's usage to be sure. SyntaxKind::STRING | SyntaxKind::DOLLAR_QUOTED_STRING | SyntaxKind::ESC_STRING diff --git a/crates/squawk_ide/src/lib.rs b/crates/squawk_ide/src/lib.rs index 9cb3c8c1..94e459b8 100644 --- a/crates/squawk_ide/src/lib.rs +++ b/crates/squawk_ide/src/lib.rs @@ -17,6 +17,7 @@ pub mod goto_definition; pub mod hover; mod infer; pub mod inlay_hints; +mod literals; pub mod location; mod name; mod offsets; diff --git a/crates/squawk_ide/src/literals.rs b/crates/squawk_ide/src/literals.rs new file mode 100644 index 00000000..034a147d --- /dev/null +++ b/crates/squawk_ide/src/literals.rs @@ -0,0 +1,134 @@ +use squawk_syntax::{ + SyntaxKind, + ast::{self, AstNode}, + quote::{strip_dollar_quotes, strip_prefixed_quotes, strip_quotes, strip_unicode_esc_prefix}, + unescape::{decode_esc_string, decode_plain_string, decode_unicode_esc_string, uescape_char}, +}; + +pub(crate) fn binary_digits_to_hex(digits: &str) -> Option { + const HEX_DIGITS: &[u8; 16] = b"0123456789ABCDEF"; + + if digits.is_empty() { + return Some("".to_string()); + } + + let mut out = String::with_capacity(digits.len().div_ceil(4)); + let mut start = 0; + + while start < digits.len() { + let chunk_len = if start == 0 { + match digits.len() % 4 { + 0 => 4, + n => n, + } + } else { + 4 + }; + let end = start + chunk_len; + let value = u8::from_str_radix(&digits[start..end], 2).ok()?; + out.push(HEX_DIGITS[value as usize] as char); + start = end; + } + + Some(out) +} + +pub(crate) fn hex_digits_to_binary(digits: &str) -> Option { + const BINARY_DIGITS: [&str; 16] = [ + "0000", "0001", "0010", "0011", "0100", "0101", "0110", "0111", "1000", "1001", "1010", + "1011", "1100", "1101", "1110", "1111", + ]; + + if digits.is_empty() { + return Some("".to_string()); + } + + let mut out = String::with_capacity(digits.len() * 4); + for ch in digits.chars() { + let value = ch.to_digit(16)? as usize; + out.push_str(BINARY_DIGITS[value]); + } + + Some(out) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum StringDecoding { + BitOrByte, + EscString, + UnicodeEscString, +} + +pub(crate) fn literal_string_value(literal: &ast::Literal) -> Option { + let escape_char = unicode_escape_char(literal); + let mut out = String::with_capacity(literal.syntax().text().len().into()); + let mut decoding: Option = None; + + for element in literal.syntax().children_with_tokens() { + let Some(token) = element.into_token() else { + continue; + }; + match token.kind() { + SyntaxKind::ESC_STRING => { + let inner = strip_prefixed_quotes(token.text(), ['e', 'E'])?; + decode_esc_string(inner, &mut out); + decoding = Some(StringDecoding::EscString); + } + SyntaxKind::UNICODE_ESC_STRING => { + let inner = strip_unicode_esc_prefix(token.text())?; + decode_unicode_esc_string(inner, escape_char, &mut out); + decoding = Some(StringDecoding::UnicodeEscString); + } + SyntaxKind::BIT_STRING => { + let inner = strip_prefixed_quotes(token.text(), ['b', 'B'])?; + out.push_str(inner); + decoding = Some(StringDecoding::BitOrByte); + } + SyntaxKind::BYTE_STRING => { + let inner = strip_prefixed_quotes(token.text(), ['x', 'X'])?; + out.push_str(inner); + decoding = Some(StringDecoding::BitOrByte); + } + SyntaxKind::DOLLAR_QUOTED_STRING => { + let inner = strip_dollar_quotes(token.text())?; + out.push_str(inner); + return Some(out); + } + SyntaxKind::STRING => { + let inner = strip_quotes(token.text())?; + match decoding { + Some(StringDecoding::EscString) => decode_esc_string(inner, &mut out), + Some(StringDecoding::UnicodeEscString) => { + decode_unicode_esc_string(inner, escape_char, &mut out) + } + Some(StringDecoding::BitOrByte) => out.push_str(inner), + None => decode_plain_string(inner, &mut out), + } + } + SyntaxKind::UESCAPE_KW => break, + _ => (), + } + } + + Some(out) +} + +fn unicode_escape_char(literal: &ast::Literal) -> char { + let mut seen_uescape = false; + for element in literal.syntax().children_with_tokens() { + let Some(token) = element.into_token() else { + continue; + }; + match token.kind() { + SyntaxKind::UESCAPE_KW => seen_uescape = true, + SyntaxKind::STRING if seen_uescape => { + if let Some(ch) = uescape_char(token.text()) { + return ch; + } + return '\\'; + } + _ => (), + } + } + '\\' +}