From 66d8c6422f2587c68a2cc839ac07020a3ec3816f Mon Sep 17 00:00:00 2001 From: Steve Dignam Date: Wed, 10 Dec 2025 21:06:05 -0500 Subject: [PATCH 1/3] ide: quote & unquote identifiers --- crates/squawk_ide/src/code_actions.rs | 309 +++++++++++++++++++- crates/squawk_ide/src/generated/keywords.rs | 80 +++++ crates/squawk_ide/src/generated/mod.rs | 1 + crates/squawk_ide/src/goto_definition.rs | 11 +- crates/squawk_ide/src/lib.rs | 2 + crates/squawk_ide/src/offsets.rs | 16 + crates/xtask/src/codegen.rs | 22 ++ 7 files changed, 424 insertions(+), 17 deletions(-) create mode 100644 crates/squawk_ide/src/generated/keywords.rs create mode 100644 crates/squawk_ide/src/generated/mod.rs create mode 100644 crates/squawk_ide/src/offsets.rs diff --git a/crates/squawk_ide/src/code_actions.rs b/crates/squawk_ide/src/code_actions.rs index f105628c..227630a4 100644 --- a/crates/squawk_ide/src/code_actions.rs +++ b/crates/squawk_ide/src/code_actions.rs @@ -1,10 +1,12 @@ use rowan::TextSize; use squawk_linter::Edit; use squawk_syntax::{ - SyntaxKind, + SyntaxKind, SyntaxNode, ast::{self, AstNode}, }; +use crate::{generated::keywords::RESERVED_KEYWORDS, offsets::token_from_offset}; + #[derive(Debug, Clone)] pub enum ActionKind { QuickFix, @@ -25,6 +27,8 @@ pub fn code_actions(file: ast::SourceFile, offset: TextSize) -> Option Option<()> { - let node = file.syntax().token_at_offset(offset).left_biased()?; - let table = node.parent_ancestors().find_map(ast::Table::cast)?; + let token = token_from_offset(&file, offset)?; + let table = token.parent_ancestors().find_map(ast::Table::cast)?; let relation_name = table.relation_name()?; let table_name = relation_name.syntax().text(); @@ -184,8 +188,8 @@ fn rewrite_select_as_table( file: &ast::SourceFile, offset: TextSize, ) -> Option<()> { - let node = file.syntax().token_at_offset(offset).left_biased()?; - let select = node.parent_ancestors().find_map(ast::Select::cast)?; + let token = token_from_offset(&file, offset)?; + let select = token.parent_ancestors().find_map(ast::Select::cast)?; if !can_transform_select_to_table(&select) { return None; @@ -293,6 +297,108 @@ fn can_transform_select_to_table(select: &ast::Select) -> bool { from_item.name_ref().is_some() || from_item.field_expr().is_some() } +fn quote_identifier( + actions: &mut Vec, + file: &ast::SourceFile, + offset: TextSize, +) -> Option<()> { + let token = token_from_offset(&file, offset)?; + let parent = token.parent()?; + + let name_node = if let Some(name) = ast::Name::cast(parent.clone()) { + name.syntax().clone() + } else if let Some(name_ref) = ast::NameRef::cast(parent) { + name_ref.syntax().clone() + } else { + return None; + }; + + let text = name_node.text().to_string(); + + if text.starts_with('"') { + return None; + } + + let quoted = format!(r#""{}""#, text.to_lowercase()); + + actions.push(CodeAction { + title: "Quote identifier".to_owned(), + edits: vec![Edit::replace(name_node.text_range(), quoted)], + kind: ActionKind::RefactorRewrite, + }); + + Some(()) +} + +fn unquote_identifier( + actions: &mut Vec, + file: &ast::SourceFile, + offset: TextSize, +) -> Option<()> { + let token = token_from_offset(&file, offset)?; + let parent = token.parent()?; + + let name_node = if let Some(name) = ast::Name::cast(parent.clone()) { + name.syntax().clone() + } else if let Some(name_ref) = ast::NameRef::cast(parent) { + name_ref.syntax().clone() + } else { + return None; + }; + + let unquoted = unquote(&name_node)?; + + actions.push(CodeAction { + title: "Unquote identifier".to_owned(), + edits: vec![Edit::replace(name_node.text_range(), unquoted)], + kind: ActionKind::RefactorRewrite, + }); + + Some(()) +} + +fn unquote(node: &SyntaxNode) -> Option { + let text = node.text().to_string(); + + if !text.starts_with('"') || !text.ends_with('"') { + return None; + } + + let text = &text[1..text.len() - 1]; + + if is_reserved_word(text) { + return None; + } + + if text.is_empty() { + return None; + } + + let mut chars = text.chars(); + + // see: https://www.postgresql.org/docs/18/sql-syntax-lexical.html#SQL-SYNTAX-IDENTIFIERS + match chars.next() { + Some(c) if c.is_lowercase() || c == '_' => {} + _ => return None, + } + + for c in chars { + if c.is_lowercase() || c.is_ascii_digit() || c == '_' || c == '$' { + continue; + } + return None; + } + + Some(text.to_string()) +} + +fn is_reserved_word(text: &str) -> bool { + if let Ok(_) = RESERVED_KEYWORDS.binary_search(&text.to_lowercase().as_str()) { + return true; + } + false +} + #[cfg(test)] mod test { use super::*; @@ -305,11 +411,13 @@ mod test { f: impl Fn(&mut Vec, &ast::SourceFile, TextSize) -> Option<()>, sql: &str, ) -> String { - let (offset, sql) = fixture(sql); + let (mut offset, sql) = fixture(sql); let parse = ast::SourceFile::parse(&sql); assert_eq!(parse.errors(), vec![]); let file: ast::SourceFile = parse.tree(); + offset = offset.checked_sub(1.into()).unwrap_or_default(); + let mut actions = vec![]; f(&mut actions, &file, offset); @@ -388,7 +496,7 @@ mod test { fn remove_else_clause_before_token() { assert_snapshot!(apply_code_action( remove_else_clause, - "select case x when true then 1 $0else 2 end;"), + "select case x when true then 1 e$0lse 2 end;"), @"select case x when true then 1 end;" ); } @@ -639,4 +747,191 @@ mod test { "table foo$0;" )); } + + #[test] + fn quote_identifier_on_name_ref() { + assert_snapshot!(apply_code_action( + quote_identifier, + "select x$0 from t;"), + @r#"select "x" from t;"# + ); + } + + #[test] + fn quote_identifier_on_name() { + assert_snapshot!(apply_code_action( + quote_identifier, + "create table T(X$0 int);"), + @r#"create table T("x" int);"# + ); + } + + #[test] + fn quote_identifier_lowercases() { + assert_snapshot!(apply_code_action( + quote_identifier, + "create table T(COL$0 int);"), + @r#"create table T("col" int);"# + ); + } + + #[test] + fn quote_identifier_not_applicable_when_already_quoted() { + assert!(code_action_not_applicable( + quote_identifier, + r#"select "x"$0 from t;"# + )); + } + + #[test] + fn quote_identifier_not_applicable_on_select_keyword() { + assert!(code_action_not_applicable( + quote_identifier, + "sel$0ect x from t;" + )); + } + + #[test] + fn quote_identifier_on_keyword_column_name() { + assert_snapshot!(apply_code_action( + quote_identifier, + "select te$0xt from t;"), + @r#"select "text" from t;"# + ); + } + + #[test] + fn quote_identifier_example_select() { + assert_snapshot!(apply_code_action( + quote_identifier, + "select x$0 from t;"), + @r#"select "x" from t;"# + ); + } + + #[test] + fn quote_identifier_example_create_table() { + assert_snapshot!(apply_code_action( + quote_identifier, + "create table T(X$0 int);"), + @r#"create table T("x" int);"# + ); + } + + #[test] + fn unquote_identifier_simple() { + assert_snapshot!(apply_code_action( + unquote_identifier, + r#"select "x"$0 from t;"#), + @"select x from t;" + ); + } + + #[test] + fn unquote_identifier_with_underscore() { + assert_snapshot!(apply_code_action( + unquote_identifier, + r#"select "user_id"$0 from t;"#), + @"select user_id from t;" + ); + } + + #[test] + fn unquote_identifier_with_digits() { + assert_snapshot!(apply_code_action( + unquote_identifier, + r#"select "x123"$0 from t;"#), + @"select x123 from t;" + ); + } + + #[test] + fn unquote_identifier_with_dollar() { + assert_snapshot!(apply_code_action( + unquote_identifier, + r#"select "my_table$1"$0 from t;"#), + @"select my_table$1 from t;" + ); + } + + #[test] + fn unquote_identifier_starts_with_underscore() { + assert_snapshot!(apply_code_action( + unquote_identifier, + r#"select "_col"$0 from t;"#), + @"select _col from t;" + ); + } + + #[test] + fn unquote_identifier_starts_with_unicode() { + assert_snapshot!(apply_code_action( + unquote_identifier, + r#"select "é"$0 from t;"#), + @"select é from t;" + ); + } + + #[test] + fn unquote_identifier_not_applicable() { + // upper case + assert!(code_action_not_applicable( + unquote_identifier, + r#"select "X"$0 from t;"# + )); + // upper case + assert!(code_action_not_applicable( + unquote_identifier, + r#"select "Foo"$0 from t;"# + )); + // dash + assert!(code_action_not_applicable( + unquote_identifier, + r#"select "my-col"$0 from t;"# + )); + // leading digits + assert!(code_action_not_applicable( + unquote_identifier, + r#"select "123"$0 from t;"# + )); + // space + assert!(code_action_not_applicable( + unquote_identifier, + r#"select "foo bar"$0 from t;"# + )); + // quotes + assert!(code_action_not_applicable( + unquote_identifier, + r#"select "foo""bar"$0 from t;"# + )); + // already unquoted + assert!(code_action_not_applicable( + unquote_identifier, + "select x$0 from t;" + )); + // brackets + assert!(code_action_not_applicable( + unquote_identifier, + r#"select "my[col]"$0 from t;"# + )); + // curly brackets + assert!(code_action_not_applicable( + unquote_identifier, + r#"select "my{}"$0 from t;"# + )); + // reserved word + assert!(code_action_not_applicable( + unquote_identifier, + r#"select "select"$0 from t;"# + )); + } + + #[test] + fn unquote_identifier_on_name() { + assert_snapshot!(apply_code_action( + unquote_identifier, + r#"create table T("x"$0 int);"#), + @"create table T(x int);" + ); + } } diff --git a/crates/squawk_ide/src/generated/keywords.rs b/crates/squawk_ide/src/generated/keywords.rs new file mode 100644 index 00000000..1e081b33 --- /dev/null +++ b/crates/squawk_ide/src/generated/keywords.rs @@ -0,0 +1,80 @@ +pub(crate) const RESERVED_KEYWORDS: &[&str] = &[ + "all", + "analyse", + "analyze", + "and", + "any", + "array", + "as", + "asc", + "asymmetric", + "both", + "case", + "cast", + "check", + "collate", + "column", + "constraint", + "create", + "current_catalog", + "current_date", + "current_role", + "current_time", + "current_timestamp", + "current_user", + "default", + "deferrable", + "desc", + "distinct", + "do", + "else", + "end", + "except", + "false", + "fetch", + "for", + "foreign", + "from", + "grant", + "group", + "having", + "in", + "initially", + "intersect", + "into", + "lateral", + "leading", + "limit", + "localtime", + "localtimestamp", + "not", + "null", + "offset", + "on", + "only", + "or", + "order", + "placing", + "primary", + "references", + "returning", + "select", + "session_user", + "some", + "symmetric", + "system_user", + "table", + "then", + "to", + "trailing", + "true", + "union", + "unique", + "user", + "using", + "variadic", + "when", + "where", + "window", + "with", +]; diff --git a/crates/squawk_ide/src/generated/mod.rs b/crates/squawk_ide/src/generated/mod.rs new file mode 100644 index 00000000..c455eeca --- /dev/null +++ b/crates/squawk_ide/src/generated/mod.rs @@ -0,0 +1 @@ +pub(crate) mod keywords; diff --git a/crates/squawk_ide/src/goto_definition.rs b/crates/squawk_ide/src/goto_definition.rs index 0c70d338..5faf4e05 100644 --- a/crates/squawk_ide/src/goto_definition.rs +++ b/crates/squawk_ide/src/goto_definition.rs @@ -1,3 +1,4 @@ +use crate::offsets::token_from_offset; use rowan::{TextRange, TextSize}; use squawk_syntax::{ SyntaxKind, SyntaxToken, @@ -25,16 +26,6 @@ pub fn goto_definition(file: ast::SourceFile, offset: TextSize) -> Option Option { - let mut token = file.syntax().token_at_offset(offset).right_biased()?; - // want to be lenient in case someone clicks the trailing `;` of a line - // instead of an identifier - if token.kind() == SyntaxKind::SEMICOLON { - token = token.prev_token()?; - } - return Some(token); -} - #[cfg(test)] mod test { use crate::goto_definition::goto_definition; diff --git a/crates/squawk_ide/src/lib.rs b/crates/squawk_ide/src/lib.rs index 70b81988..96969388 100644 --- a/crates/squawk_ide/src/lib.rs +++ b/crates/squawk_ide/src/lib.rs @@ -1,6 +1,8 @@ pub mod code_actions; pub mod column_name; pub mod expand_selection; +mod generated; pub mod goto_definition; +mod offsets; #[cfg(test)] pub mod test_utils; diff --git a/crates/squawk_ide/src/offsets.rs b/crates/squawk_ide/src/offsets.rs new file mode 100644 index 00000000..2a342b62 --- /dev/null +++ b/crates/squawk_ide/src/offsets.rs @@ -0,0 +1,16 @@ +use rowan::TextSize; +use squawk_syntax::{ + SyntaxKind, SyntaxToken, + ast::{self, AstNode}, +}; + +pub(crate) fn token_from_offset(file: &ast::SourceFile, offset: TextSize) -> Option { + let mut token = file.syntax().token_at_offset(offset).right_biased()?; + // want to be lenient in case someone clicks the trailing `;` of a line + // instead of an identifier + // or if someone clicks the `,` in a target list, like `select a, b, c` + if token.kind() == SyntaxKind::SEMICOLON || token.kind() == SyntaxKind::COMMA { + token = token.prev_token()?; + } + return Some(token); +} diff --git a/crates/xtask/src/codegen.rs b/crates/xtask/src/codegen.rs index ce9111ba..68c36ee9 100644 --- a/crates/xtask/src/codegen.rs +++ b/crates/xtask/src/codegen.rs @@ -88,6 +88,11 @@ pub(crate) fn codegen() -> Result<()> { project_root().join("crates/squawk_parser/src/generated/syntax_kind.rs"); std::fs::write(syntax_kinds_file, syntax_kinds).context("problem writing syntax kinds")?; + let ide_reserved_keywords = project_root().join("crates/squawk_ide/src/generated/keywords.rs"); + let reserved_keywords = generate_reserved_keywords_array(&keyword_kinds.reserved_keywords)?; + std::fs::write(ide_reserved_keywords, reserved_keywords) + .context("problem writing reserved keywords")?; + Ok(()) } @@ -212,6 +217,23 @@ fn generate_kind_src( } } +fn generate_reserved_keywords_array(reserved_keywords: &Vec) -> Result { + let mut reserved_keywords = reserved_keywords + .iter() + .map(|x| x.to_lowercase()) + .collect::>(); + reserved_keywords.sort(); + + Ok(reformat( + quote! { + pub(crate) const RESERVED_KEYWORDS: &[&str] = &[ + #(#reserved_keywords),* + ]; + } + .to_string(), + )) +} + fn generate_syntax_kinds(grammar: KindsSrc) -> Result { // TODO: we should have a check to make sure each keyword is used in the grammar once the grammar is ready let conditions = grammar From 7ce6d14ce44c57b51aabb6b73dcd96e48a638290 Mon Sep 17 00:00:00 2001 From: Steve Dignam Date: Wed, 10 Dec 2025 21:08:08 -0500 Subject: [PATCH 2/3] lint --- crates/squawk_ide/src/code_actions.rs | 15 +++++++-------- crates/xtask/src/codegen.rs | 2 +- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/crates/squawk_ide/src/code_actions.rs b/crates/squawk_ide/src/code_actions.rs index 227630a4..0751b153 100644 --- a/crates/squawk_ide/src/code_actions.rs +++ b/crates/squawk_ide/src/code_actions.rs @@ -166,7 +166,7 @@ fn rewrite_table_as_select( file: &ast::SourceFile, offset: TextSize, ) -> Option<()> { - let token = token_from_offset(&file, offset)?; + let token = token_from_offset(file, offset)?; let table = token.parent_ancestors().find_map(ast::Table::cast)?; let relation_name = table.relation_name()?; @@ -188,7 +188,7 @@ fn rewrite_select_as_table( file: &ast::SourceFile, offset: TextSize, ) -> Option<()> { - let token = token_from_offset(&file, offset)?; + let token = token_from_offset(file, offset)?; let select = token.parent_ancestors().find_map(ast::Select::cast)?; if !can_transform_select_to_table(&select) { @@ -302,7 +302,7 @@ fn quote_identifier( file: &ast::SourceFile, offset: TextSize, ) -> Option<()> { - let token = token_from_offset(&file, offset)?; + let token = token_from_offset(file, offset)?; let parent = token.parent()?; let name_node = if let Some(name) = ast::Name::cast(parent.clone()) { @@ -335,7 +335,7 @@ fn unquote_identifier( file: &ast::SourceFile, offset: TextSize, ) -> Option<()> { - let token = token_from_offset(&file, offset)?; + let token = token_from_offset(file, offset)?; let parent = token.parent()?; let name_node = if let Some(name) = ast::Name::cast(parent.clone()) { @@ -393,10 +393,9 @@ fn unquote(node: &SyntaxNode) -> Option { } fn is_reserved_word(text: &str) -> bool { - if let Ok(_) = RESERVED_KEYWORDS.binary_search(&text.to_lowercase().as_str()) { - return true; - } - false + RESERVED_KEYWORDS + .binary_search(&text.to_lowercase().as_str()) + .is_ok() } #[cfg(test)] diff --git a/crates/xtask/src/codegen.rs b/crates/xtask/src/codegen.rs index 68c36ee9..d792c145 100644 --- a/crates/xtask/src/codegen.rs +++ b/crates/xtask/src/codegen.rs @@ -217,7 +217,7 @@ fn generate_kind_src( } } -fn generate_reserved_keywords_array(reserved_keywords: &Vec) -> Result { +fn generate_reserved_keywords_array(reserved_keywords: &[String]) -> Result { let mut reserved_keywords = reserved_keywords .iter() .map(|x| x.to_lowercase()) From 33c09d83c91f683885733f789df8fbba90b47043 Mon Sep 17 00:00:00 2001 From: Steve Dignam Date: Wed, 10 Dec 2025 21:09:36 -0500 Subject: [PATCH 3/3] lint --- crates/squawk_ide/src/goto_definition.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/squawk_ide/src/goto_definition.rs b/crates/squawk_ide/src/goto_definition.rs index 5faf4e05..eed9decc 100644 --- a/crates/squawk_ide/src/goto_definition.rs +++ b/crates/squawk_ide/src/goto_definition.rs @@ -1,7 +1,7 @@ use crate::offsets::token_from_offset; use rowan::{TextRange, TextSize}; use squawk_syntax::{ - SyntaxKind, SyntaxToken, + SyntaxKind, ast::{self, AstNode}, };