From 01564236427ea46d096ef6eb4d6ecd8c66939a65 Mon Sep 17 00:00:00 2001 From: Steve Dignam Date: Tue, 18 Aug 2026 15:50:11 -0400 Subject: [PATCH] ide: add code actions for builtins -> pg functions --- crates/squawk_ide/src/code_actions/mod.rs | 45 ++ .../code_actions/rewrite_as_regular_string.rs | 6 +- .../rewrite_at_local_as_timezone.rs | 84 +++ .../rewrite_at_time_zone_as_timezone.rs | 97 +++ .../rewrite_collation_for_as_function_call.rs | 81 ++ .../rewrite_extract_as_function_call.rs | 132 ++++ .../code_actions/rewrite_in_as_expression.rs | 98 +++ .../rewrite_is_normalized_as_function_call.rs | 120 +++ .../rewrite_normalize_as_function_call.rs | 146 ++++ .../rewrite_overlaps_as_function_call.rs | 142 ++++ .../rewrite_overlay_as_function_call.rs | 106 +++ .../rewrite_pattern_matching_as_operators.rs | 199 +++++ .../rewrite_position_as_function_call.rs | 85 +++ .../rewrite_substring_as_function_call.rs | 169 +++++ .../rewrite_system_user_as_function_call.rs | 87 +++ .../rewrite_trim_as_function_call.rs | 185 +++++ .../rewrite_xmlexists_as_function_call.rs | 96 +++ .../src/generated/syntax_kind.rs | 10 +- crates/squawk_parser/src/grammar.rs | 81 +- .../tests/data/ok/select_funcs.sql | 21 + .../snapshots/tests__create_table_ok.snap | 2 +- .../tests/snapshots/tests__misc_ok.snap | 10 +- .../snapshots/tests__select_funcs_ok.snap | 690 +++++++++++++++--- .../squawk_syntax/src/ast/generated/nodes.rs | 457 +++++++++++- crates/squawk_syntax/src/ast/node_ext.rs | 57 ++ crates/squawk_syntax/src/postgresql.ungram | 57 +- crates/squawk_syntax/src/quote.rs | 9 + 27 files changed, 3084 insertions(+), 188 deletions(-) create mode 100644 crates/squawk_ide/src/code_actions/rewrite_at_local_as_timezone.rs create mode 100644 crates/squawk_ide/src/code_actions/rewrite_at_time_zone_as_timezone.rs create mode 100644 crates/squawk_ide/src/code_actions/rewrite_collation_for_as_function_call.rs create mode 100644 crates/squawk_ide/src/code_actions/rewrite_extract_as_function_call.rs create mode 100644 crates/squawk_ide/src/code_actions/rewrite_in_as_expression.rs create mode 100644 crates/squawk_ide/src/code_actions/rewrite_is_normalized_as_function_call.rs create mode 100644 crates/squawk_ide/src/code_actions/rewrite_normalize_as_function_call.rs create mode 100644 crates/squawk_ide/src/code_actions/rewrite_overlaps_as_function_call.rs create mode 100644 crates/squawk_ide/src/code_actions/rewrite_overlay_as_function_call.rs create mode 100644 crates/squawk_ide/src/code_actions/rewrite_pattern_matching_as_operators.rs create mode 100644 crates/squawk_ide/src/code_actions/rewrite_position_as_function_call.rs create mode 100644 crates/squawk_ide/src/code_actions/rewrite_substring_as_function_call.rs create mode 100644 crates/squawk_ide/src/code_actions/rewrite_system_user_as_function_call.rs create mode 100644 crates/squawk_ide/src/code_actions/rewrite_trim_as_function_call.rs create mode 100644 crates/squawk_ide/src/code_actions/rewrite_xmlexists_as_function_call.rs diff --git a/crates/squawk_ide/src/code_actions/mod.rs b/crates/squawk_ide/src/code_actions/mod.rs index d321ba204..158202915 100644 --- a/crates/squawk_ide/src/code_actions/mod.rs +++ b/crates/squawk_ide/src/code_actions/mod.rs @@ -12,22 +12,37 @@ mod remove_else_clause; mod remove_redundant_alias; mod rewrite_as_dollar_quoted_string; mod rewrite_as_regular_string; +mod rewrite_at_local_as_timezone; +mod rewrite_at_time_zone_as_timezone; mod rewrite_between_as_binary_expression; mod rewrite_cast_to_double_colon; +mod rewrite_collation_for_as_function_call; mod rewrite_create_table_as_as_select_into; mod rewrite_double_colon_to_cast; +mod rewrite_extract_as_function_call; mod rewrite_from; +mod rewrite_in_as_expression; mod rewrite_integer_radix; +mod rewrite_is_normalized_as_function_call; mod rewrite_leading_from; +mod rewrite_normalize_as_function_call; mod rewrite_not_equals_operator; +mod rewrite_overlaps_as_function_call; +mod rewrite_overlay_as_function_call; +mod rewrite_pattern_matching_as_operators; +mod rewrite_position_as_function_call; mod rewrite_rows_from_as_unnest; mod rewrite_select_as_table; mod rewrite_select_as_values; mod rewrite_select_into_as_create_table_as; +mod rewrite_substring_as_function_call; +mod rewrite_system_user_as_function_call; mod rewrite_table_as_select; mod rewrite_timestamp_type; +mod rewrite_trim_as_function_call; mod rewrite_unnest_as_rows_from; mod rewrite_values_as_select; +mod rewrite_xmlexists_as_function_call; mod unnest; mod unquote_identifier; @@ -42,22 +57,37 @@ use remove_else_clause::remove_else_clause; use remove_redundant_alias::remove_redundant_alias; use rewrite_as_dollar_quoted_string::rewrite_as_dollar_quoted_string; use rewrite_as_regular_string::rewrite_as_regular_string; +use rewrite_at_local_as_timezone::rewrite_at_local_as_timezone; +use rewrite_at_time_zone_as_timezone::rewrite_at_time_zone_as_timezone; use rewrite_between_as_binary_expression::rewrite_between_as_binary_expression; use rewrite_cast_to_double_colon::rewrite_cast_to_double_colon; +use rewrite_collation_for_as_function_call::rewrite_collation_for_as_function_call; use rewrite_create_table_as_as_select_into::rewrite_create_table_as_as_select_into; use rewrite_double_colon_to_cast::rewrite_double_colon_to_cast; +use rewrite_extract_as_function_call::rewrite_extract_as_function_call; use rewrite_from::rewrite_from; +use rewrite_in_as_expression::rewrite_in_as_expression; use rewrite_integer_radix::rewrite_integer_radix; +use rewrite_is_normalized_as_function_call::rewrite_is_normalized_as_function_call; use rewrite_leading_from::rewrite_leading_from; +use rewrite_normalize_as_function_call::rewrite_normalize_as_function_call; use rewrite_not_equals_operator::rewrite_not_equals_operator; +use rewrite_overlaps_as_function_call::rewrite_overlaps_as_function_call; +use rewrite_overlay_as_function_call::rewrite_overlay_as_function_call; +use rewrite_pattern_matching_as_operators::rewrite_pattern_matching_as_operators; +use rewrite_position_as_function_call::rewrite_position_as_function_call; use rewrite_rows_from_as_unnest::rewrite_rows_from_as_unnest; use rewrite_select_as_table::rewrite_select_as_table; use rewrite_select_as_values::rewrite_select_as_values; use rewrite_select_into_as_create_table_as::rewrite_select_into_as_create_table_as; +use rewrite_substring_as_function_call::rewrite_substring_as_function_call; +use rewrite_system_user_as_function_call::rewrite_system_user_as_function_call; use rewrite_table_as_select::rewrite_table_as_select; use rewrite_timestamp_type::rewrite_timestamp_type; +use rewrite_trim_as_function_call::rewrite_trim_as_function_call; use rewrite_unnest_as_rows_from::rewrite_unnest_as_rows_from; use rewrite_values_as_select::rewrite_values_as_select; +use rewrite_xmlexists_as_function_call::rewrite_xmlexists_as_function_call; use unquote_identifier::unquote_identifier; #[derive(Debug, Clone, PartialEq, Eq)] @@ -100,5 +130,20 @@ pub fn code_actions(db: &dyn Db, position: InFile) -> Option Option { return None; } - // quotes are escaped by using two of them in Postgres - let escaped = content.replace('\'', "''"); - Some(format!("'{escaped}'")) + Some(quote_string_literal(content)) } fn split_dollar_quoted(text: &str) -> Option<(String, &str)> { diff --git a/crates/squawk_ide/src/code_actions/rewrite_at_local_as_timezone.rs b/crates/squawk_ide/src/code_actions/rewrite_at_local_as_timezone.rs new file mode 100644 index 000000000..e808e9729 --- /dev/null +++ b/crates/squawk_ide/src/code_actions/rewrite_at_local_as_timezone.rs @@ -0,0 +1,84 @@ +use rowan::TextSize; +use salsa::Database as Db; +use squawk_linter::Edit; +use squawk_syntax::ast::{self, AstNode}; + +use crate::{file::InFile, offsets::token_from_offset}; + +use super::{ActionKind, CodeAction}; + +pub(super) fn rewrite_at_local_as_timezone( + db: &dyn Db, + position: InFile, + actions: &mut Vec, +) -> Option<()> { + let token = token_from_offset(db, position)?; + let postfix_expr = token + .parent_ancestors() + .filter_map(ast::PostfixExpr::cast) + .find(|expr| matches!(expr.op(), Some(ast::PostfixOp::AtLocal(_))))?; + + let expr = postfix_expr.expr()?; + let replacement = format!("timezone({})", expr.syntax().text()); + + actions.push(CodeAction { + title: "Rewrite `AT LOCAL` as `timezone`".to_owned(), + edits: vec![Edit::replace( + postfix_expr.syntax().text_range(), + replacement, + )], + kind: ActionKind::RefactorRewrite, + }); + + Some(()) +} + +#[cfg(test)] +mod test { + use insta::assert_snapshot; + + use crate::code_actions::test_utils::{apply_code_action, code_action_not_applicable}; + + use super::rewrite_at_local_as_timezone; + + #[test] + fn rewrites_at_local_as_timezone() { + assert_snapshot!( + apply_code_action( + rewrite_at_local_as_timezone, + "select TIMESTAMPTZ '2026-08-17 09:30+00' AT $0LOCAL;", + ), + @"select timezone(TIMESTAMPTZ '2026-08-17 09:30+00');" + ); + } + + #[test] + fn rewrites_with_cursor_on_operand() { + assert_snapshot!( + apply_code_action( + rewrite_at_local_as_timezone, + "select (created_$0at + interval '1 hour') AT LOCAL from events;", + ), + @"select timezone((created_at + interval '1 hour')) from events;" + ); + } + + #[test] + fn rewrites_with_cursor_at_end_of_expression() { + assert_snapshot!( + apply_code_action( + rewrite_at_local_as_timezone, + "select created_at AT LOCAL$0;", + ), + @"select timezone(created_at);" + ); + } + + #[test] + fn not_applicable_to_at_time_zone() { + assert!(code_action_not_applicable( + rewrite_at_local_as_timezone, + "select created_at AT TIME $0ZONE 'UTC';" + )); + } +} diff --git a/crates/squawk_ide/src/code_actions/rewrite_at_time_zone_as_timezone.rs b/crates/squawk_ide/src/code_actions/rewrite_at_time_zone_as_timezone.rs new file mode 100644 index 000000000..ce1e58dd4 --- /dev/null +++ b/crates/squawk_ide/src/code_actions/rewrite_at_time_zone_as_timezone.rs @@ -0,0 +1,97 @@ +use rowan::TextSize; +use salsa::Database as Db; +use squawk_linter::Edit; +use squawk_syntax::ast::{self, AstNode}; + +use crate::{file::InFile, offsets::token_from_offset}; + +use super::{ActionKind, CodeAction}; + +pub(super) fn rewrite_at_time_zone_as_timezone( + db: &dyn Db, + position: InFile, + actions: &mut Vec, +) -> Option<()> { + let token = token_from_offset(db, position)?; + let bin_expr = token + .parent_ancestors() + .filter_map(ast::BinExpr::cast) + .find(|expr| matches!(expr.op(), Some(ast::BinOp::AtTimeZone(_))))?; + + let value = bin_expr.lhs()?; + let zone = bin_expr.rhs()?; + let replacement = format!( + "timezone({}, {})", + zone.syntax().text(), + value.syntax().text() + ); + + actions.push(CodeAction { + title: "Rewrite as timezone function `timezone()`".to_owned(), + edits: vec![Edit::replace(bin_expr.syntax().text_range(), replacement)], + kind: ActionKind::RefactorRewrite, + }); + + Some(()) +} + +#[cfg(test)] +mod test { + use insta::assert_snapshot; + + use crate::code_actions::test_utils::{apply_code_action, code_action_not_applicable}; + + use super::rewrite_at_time_zone_as_timezone; + + #[test] + fn rewrites_at_time_zone_as_timezone() { + assert_snapshot!( + apply_code_action( + rewrite_at_time_zone_as_timezone, + "select TIMESTAMP '2026-08-17 09:30' AT TIME $0ZONE 'America/New_York';", + ), + @"select timezone('America/New_York', TIMESTAMP '2026-08-17 09:30');" + ); + } + + #[test] + fn applies_from_the_value_and_preserves_surrounding_expression() { + assert_snapshot!( + apply_code_action( + rewrite_at_time_zone_as_timezone, + "select 1 + created$0_at AT TIME ZONE zone_name;", + ), + @"select 1 + timezone(zone_name, created_at);" + ); + } + + #[test] + fn rewrites_expression_zone() { + assert_snapshot!( + apply_code_action( + rewrite_at_time_zone_as_timezone, + "select ts AT TIME ZONE coale$0sce(zone_name, 'UTC');", + ), + @"select timezone(coalesce(zone_name, 'UTC'), ts);" + ); + } + + #[test] + fn rewrites_nearest_nested_at_time_zone() { + assert_snapshot!( + apply_code_action( + rewrite_at_time_zone_as_timezone, + "select (ts AT TIME ZONE z$01) AT TIME ZONE z2;", + ), + @"select (timezone(z1, ts)) AT TIME ZONE z2;" + ); + } + + #[test] + fn not_applicable_outside_at_time_zone() { + assert!(code_action_not_applicable( + rewrite_at_time_zone_as_timezone, + "select created_at +$0 interval '1 hour';" + )); + } +} diff --git a/crates/squawk_ide/src/code_actions/rewrite_collation_for_as_function_call.rs b/crates/squawk_ide/src/code_actions/rewrite_collation_for_as_function_call.rs new file mode 100644 index 000000000..c52e108eb --- /dev/null +++ b/crates/squawk_ide/src/code_actions/rewrite_collation_for_as_function_call.rs @@ -0,0 +1,81 @@ +use rowan::TextSize; +use salsa::Database as Db; +use squawk_linter::Edit; +use squawk_syntax::ast::{self, AstNode}; + +use crate::{file::InFile, offsets::token_from_offset}; + +use super::{ActionKind, CodeAction}; + +pub(super) fn rewrite_collation_for_as_function_call( + db: &dyn Db, + position: InFile, + actions: &mut Vec, +) -> Option<()> { + let token = token_from_offset(db, position)?; + let collation_for = token + .parent_ancestors() + .find_map(ast::CollationForFn::cast)?; + let expr = collation_for.expr()?; + + actions.push(CodeAction { + title: "Rewrite as `pg_collation_for` function call".to_owned(), + edits: vec![Edit::replace( + collation_for.syntax().text_range(), + format!("pg_collation_for({})", expr.syntax().text()), + )], + kind: ActionKind::RefactorRewrite, + }); + + Some(()) +} + +#[cfg(test)] +mod test { + use insta::assert_snapshot; + + use crate::code_actions::test_utils::{apply_code_action, code_action_not_applicable}; + + use super::rewrite_collation_for_as_function_call; + + #[test] + fn rewrites_collation_for_as_function_call() { + assert_snapshot!( + apply_code_action( + rewrite_collation_for_as_function_call, + "select COLLATION $0FOR ('x'::text);", + ), + @"select pg_collation_for('x'::text);" + ); + } + + #[test] + fn rewrites_with_cursor_in_expression() { + assert_snapshot!( + apply_code_action( + rewrite_collation_for_as_function_call, + "select COLLATION FOR (lower('x'$0));", + ), + @"select pg_collation_for(lower('x'));" + ); + } + + #[test] + fn rewrites_innermost_collation_for() { + assert_snapshot!( + apply_code_action( + rewrite_collation_for_as_function_call, + "select COLLATION FOR (COLLATION F$0OR (x));", + ), + @"select COLLATION FOR (pg_collation_for(x));" + ); + } + + #[test] + fn not_applicable_outside_collation_for() { + assert!(code_action_not_applicable( + rewrite_collation_for_as_function_call, + "select collati$0on from t;" + )); + } +} diff --git a/crates/squawk_ide/src/code_actions/rewrite_extract_as_function_call.rs b/crates/squawk_ide/src/code_actions/rewrite_extract_as_function_call.rs new file mode 100644 index 000000000..e9a41ef4d --- /dev/null +++ b/crates/squawk_ide/src/code_actions/rewrite_extract_as_function_call.rs @@ -0,0 +1,132 @@ +use rowan::TextSize; +use salsa::Database as Db; +use squawk_linter::Edit; +use squawk_syntax::{ + ast::{self, AstNode, LitKind}, + quote::quote_string_literal, +}; + +use crate::{file::InFile, offsets::token_from_offset}; + +use super::{ActionKind, CodeAction}; + +pub(super) fn rewrite_extract_as_function_call( + db: &dyn Db, + position: InFile, + actions: &mut Vec, +) -> Option<()> { + let token = token_from_offset(db, position)?; + let extract = token.parent_ancestors().find_map(ast::ExtractFn::cast)?; + let field = extract.extract_field()?; + let expr = extract.expr()?; + + let field = extract_field_argument(&field)?; + let replacement = format!("pg_catalog.extract({field}, {})", expr.syntax().text()); + + actions.push(CodeAction { + title: "Rewrite as function call `pg_catalog.extract()`".to_owned(), + edits: vec![Edit::replace(extract.syntax().text_range(), replacement)], + kind: ActionKind::RefactorRewrite, + }); + + Some(()) +} + +fn extract_field_argument(field: &ast::ExtractField) -> Option { + match field { + ast::ExtractField::ExtractFieldLiteral(field) + if matches!( + field.literal()?.kind()?, + LitKind::String(_) + | LitKind::EscString(_) + | LitKind::NationalString(_) + | LitKind::UnicodeEscString(_) + | LitKind::DollarQuotedString(_) + ) => + { + Some(field.syntax().text().to_string()) + } + ast::ExtractField::ExtractFieldName(name) => Some(quote_string_literal(&name.text())), + ast::ExtractField::ExtractFieldLiteral(_) => None, + } +} + +#[cfg(test)] +mod test { + use insta::assert_snapshot; + + use crate::code_actions::test_utils::{apply_code_action, code_action_not_applicable}; + + use super::rewrite_extract_as_function_call; + + #[test] + fn rewrites_extract_as_function_call() { + assert_snapshot!( + apply_code_action( + rewrite_extract_as_function_call, + "select EXTR$0ACT(YEAR FROM DATE '2026-08-17');", + ), + @"select pg_catalog.extract('year', DATE '2026-08-17');" + ); + } + + #[test] + fn applies_with_cursor_in_source_expression() { + assert_snapshot!( + apply_code_action( + rewrite_extract_as_function_call, + "select extract(epoch from current_time$0stamp);", + ), + @"select pg_catalog.extract('epoch', current_timestamp);" + ); + } + + #[test] + fn preserves_string_field_and_aggregate_clause() { + assert_snapshot!( + apply_code_action( + rewrite_extract_as_function_call, + "select extract('EPOCH' from ts$0) filter (where ok);", + ), + @"select pg_catalog.extract('EPOCH', ts) filter (where ok);" + ); + } + + #[test] + fn converts_quoted_field_to_string() { + assert_snapshot!( + apply_code_action( + rewrite_extract_as_function_call, + r#"select extract("time""zone" from ts$0);"#, + ), + @"select pg_catalog.extract('time\"zone', ts);" + ); + } + + #[test] + fn not_applicable_outside_extract() { + assert!(code_action_not_applicable( + rewrite_extract_as_function_call, + "select date_part('year', ts$0);" + )); + } + + #[test] + fn converts_unicode_identifier_with_custom_escape() { + assert_snapshot!( + apply_code_action( + rewrite_extract_as_function_call, + r#"select extract(U&"@0079ear" UESCAPE '@' from ts$0);"#, + ), + @"select pg_catalog.extract('year', ts);" + ); + } + + #[test] + fn does_not_reinterpret_bit_string_field_as_identifier() { + assert!(code_action_not_applicable( + rewrite_extract_as_function_call, + "select extract(B'0101' from ts$0);" + )); + } +} diff --git a/crates/squawk_ide/src/code_actions/rewrite_in_as_expression.rs b/crates/squawk_ide/src/code_actions/rewrite_in_as_expression.rs new file mode 100644 index 000000000..483712d60 --- /dev/null +++ b/crates/squawk_ide/src/code_actions/rewrite_in_as_expression.rs @@ -0,0 +1,98 @@ +use rowan::TextSize; +use salsa::Database as Db; +use squawk_linter::Edit; +use squawk_syntax::ast::{self, AstNode, BinOp}; + +use crate::{file::InFile, offsets::token_from_offset}; + +use super::{ActionKind, CodeAction}; + +pub(super) fn rewrite_in_as_expression( + db: &dyn Db, + position: InFile, + actions: &mut Vec, +) -> Option<()> { + let token = token_from_offset(db, position)?; + let (in_expr, quantifier, comparison) = token.parent_ancestors().find_map(|node| { + let expr = ast::BinExpr::cast(node)?; + match expr.op()? { + BinOp::In(_) => Some((expr, "ANY", "=")), + BinOp::NotIn(_) => Some((expr, "ALL", "!=")), + _ => None, + } + })?; + + let lhs = in_expr.lhs()?; + let ast::Expr::TupleExpr(tuple) = in_expr.rhs()? else { + return None; + }; + let items = tuple + .exprs() + .map(|expr| expr.syntax().text().to_string()) + .collect::>() + .join(", "); + let replacement = format!( + "{} {comparison} {quantifier} (ARRAY[{items}])", + lhs.syntax().text() + ); + + actions.push(CodeAction { + title: "Rewrite `IN` as an expression".to_owned(), + edits: vec![Edit::replace(in_expr.syntax().text_range(), replacement)], + kind: ActionKind::RefactorRewrite, + }); + + Some(()) +} + +#[cfg(test)] +mod test { + use insta::assert_snapshot; + + use crate::code_actions::test_utils::{apply_code_action, code_action_not_applicable}; + + use super::rewrite_in_as_expression; + + #[test] + fn rewrites_in_tuple_as_any_array() { + assert_snapshot!( + apply_code_action(rewrite_in_as_expression, "select x $0IN (1, y, 'three');"), + @"select x = ANY (ARRAY[1, y, 'three']);" + ); + } + + #[test] + fn rewrites_not_in_tuple_as_all_array() { + assert_snapshot!( + apply_code_action(rewrite_in_as_expression, "select x NOT $0IN (1, y, 3);"), + @"select x != ALL (ARRAY[1, y, 3]);" + ); + } + + #[test] + fn rewrites_with_cursor_in_rhs() { + assert_snapshot!( + apply_code_action( + rewrite_in_as_expression, + "select x IN (-1, 'two'::$0text, (3 + 4));" + ), + @"select x = ANY (ARRAY[-1, 'two'::text, (3 + 4)]);" + ); + } + + #[test] + fn does_not_rewrite_single_item_in() { + assert!(code_action_not_applicable( + rewrite_in_as_expression, + "select x $0IN (1);" + )); + } + + #[test] + fn does_not_rewrite_in_subquery() { + assert!(code_action_not_applicable( + rewrite_in_as_expression, + "select x $0IN (select y from t);" + )); + } +} diff --git a/crates/squawk_ide/src/code_actions/rewrite_is_normalized_as_function_call.rs b/crates/squawk_ide/src/code_actions/rewrite_is_normalized_as_function_call.rs new file mode 100644 index 000000000..8d27dd6b0 --- /dev/null +++ b/crates/squawk_ide/src/code_actions/rewrite_is_normalized_as_function_call.rs @@ -0,0 +1,120 @@ +use rowan::TextSize; +use salsa::Database as Db; +use squawk_linter::Edit; +use squawk_syntax::{ + ast::{self, AstNode, PostfixOp}, + quote::quote_string_literal, +}; + +use crate::{file::InFile, offsets::token_from_offset}; + +use super::{ActionKind, CodeAction}; + +pub(super) fn rewrite_is_normalized_as_function_call( + db: &dyn Db, + position: InFile, + actions: &mut Vec, +) -> Option<()> { + let token = token_from_offset(db, position)?; + let postfix_expr = token.parent_ancestors().find_map(ast::PostfixExpr::cast)?; + + let (normal_form, negated) = match postfix_expr.op()? { + PostfixOp::IsNormalized(op) => (op.unicode_normal_form(), false), + PostfixOp::IsNotNormalized(op) => (op.unicode_normal_form(), true), + _ => return None, + }; + + let expr = postfix_expr.expr()?; + let expr_text = expr.syntax().text(); + let normal_form = normal_form.map(|form| form.syntax().text().to_string().to_ascii_uppercase()); + + let arguments = match normal_form { + Some(form) => format!("{expr_text}, {}", quote_string_literal(&form)), + None => expr_text.to_string(), + }; + let negation = if negated { "not " } else { "" }; + let replacement = format!("{negation}is_normalized({arguments})"); + + actions.push(CodeAction { + title: "Rewrite as `is_normalized` function call".to_owned(), + edits: vec![Edit::replace( + postfix_expr.syntax().text_range(), + replacement, + )], + kind: ActionKind::RefactorRewrite, + }); + + Some(()) +} + +#[cfg(test)] +mod test { + use insta::assert_snapshot; + + use crate::code_actions::test_utils::{apply_code_action, code_action_not_applicable}; + + use super::rewrite_is_normalized_as_function_call; + + #[test] + fn rewrites_is_normalized_as_function_call() { + assert_snapshot!( + apply_code_action( + rewrite_is_normalized_as_function_call, + r"select U&'\0061\0301' IS NFC $0NORMALIZED;", + ), + @r"select is_normalized(U&'\0061\0301', 'NFC');" + ); + } + + #[test] + fn rewrites_default_normal_form() { + assert_snapshot!( + apply_code_action( + rewrite_is_normalized_as_function_call, + "select ('a' || 'b') $0IS NORMALIZED;", + ), + @"select is_normalized(('a' || 'b'));" + ); + } + + #[test] + fn rewrites_and_canonicalizes_optional_normal_form() { + assert_snapshot!( + apply_code_action( + rewrite_is_normalized_as_function_call, + "select value IS nfkd NOR$0MALIZED from t;", + ), + @"select is_normalized(value, 'NFKD') from t;" + ); + } + + #[test] + fn rewrites_is_not_normalized() { + assert_snapshot!( + apply_code_action( + rewrite_is_normalized_as_function_call, + "select value IS $0NOT NFD NORMALIZED from t;", + ), + @"select not is_normalized(value, 'NFD') from t;" + ); + } + + #[test] + fn applies_when_cursor_is_on_the_value() { + assert_snapshot!( + apply_code_action( + rewrite_is_normalized_as_function_call, + "select val$0ue IS NFC NORMALIZED from t;", + ), + @"select is_normalized(value, 'NFC') from t;" + ); + } + + #[test] + fn is_not_applicable_outside_is_normalized_expression() { + assert!(code_action_not_applicable( + rewrite_is_normalized_as_function_call, + "select value$0 from t;" + )); + } +} diff --git a/crates/squawk_ide/src/code_actions/rewrite_normalize_as_function_call.rs b/crates/squawk_ide/src/code_actions/rewrite_normalize_as_function_call.rs new file mode 100644 index 000000000..0f9c67447 --- /dev/null +++ b/crates/squawk_ide/src/code_actions/rewrite_normalize_as_function_call.rs @@ -0,0 +1,146 @@ +use rowan::TextSize; +use salsa::Database as Db; +use squawk_linter::Edit; +use squawk_syntax::{ + SyntaxKind, + ast::{self, AstNode}, + quote::quote_string_literal, +}; + +use crate::{file::InFile, offsets::token_from_offset}; + +use super::{ActionKind, CodeAction}; + +pub(super) fn rewrite_normalize_as_function_call( + db: &dyn Db, + position: InFile, + actions: &mut Vec, +) -> Option<()> { + let token = token_from_offset(db, position)?; + let (name, normal_form) = token.parent_ancestors().find_map(|node| { + let call = ast::CallExpr::cast(node)?; + let ast::Expr::NameRef(name) = call.expr()? else { + return None; + }; + if name.syntax().first_token()?.kind() != SyntaxKind::NORMALIZE_KW { + return None; + } + + let args = call.arg_list()?.args().collect::>(); + let normal_form = match args.as_slice() { + [arg] => { + arg.expr()?; + None + } + [arg, form] => { + arg.expr()?; + Some(normal_form(&form.expr()?)?) + } + _ => return None, + }; + + Some((name, normal_form)) + })?; + + let mut edits = vec![Edit::replace( + name.syntax().text_range(), + "pg_catalog.normalize", + )]; + if let Some((range, form)) = normal_form { + edits.push(Edit::replace(range, quote_string_literal(&form))); + } + + actions.push(CodeAction { + title: "Rewrite as function call `pg_catalog.normalize()`".to_owned(), + edits, + kind: ActionKind::RefactorRewrite, + }); + + Some(()) +} + +fn normal_form(expr: &ast::Expr) -> Option<(rowan::TextRange, String)> { + let ast::Expr::NameRef(name) = expr else { + return None; + }; + let token = name.syntax().first_token()?; + if !matches!( + token.kind(), + SyntaxKind::NFC_KW | SyntaxKind::NFD_KW | SyntaxKind::NFKC_KW | SyntaxKind::NFKD_KW + ) { + return None; + } + Some(( + expr.syntax().text_range(), + token.text().to_ascii_uppercase(), + )) +} + +#[cfg(test)] +mod test { + use insta::assert_snapshot; + + use crate::code_actions::test_utils::{apply_code_action, code_action_not_applicable}; + + use super::rewrite_normalize_as_function_call; + + #[test] + fn rewrites_normalize_as_function_call() { + assert_snapshot!( + apply_code_action( + rewrite_normalize_as_function_call, + r"select NORM$0ALIZE(U&'\0061\0301', NFC);", + ), + @r"select pg_catalog.normalize(U&'\0061\0301', 'NFC');" + ); + } + + #[test] + fn rewrites_default_normal_form_with_cursor_in_argument() { + assert_snapshot!( + apply_code_action( + rewrite_normalize_as_function_call, + "select normalize(val$0ue);", + ), + @"select pg_catalog.normalize(value);" + ); + } + + #[test] + fn canonicalizes_normal_form_and_preserves_call_clauses() { + assert_snapshot!( + apply_code_action( + rewrite_normalize_as_function_call, + "select normalize(value, nf$0kd) FILTER (WHERE ok);", + ), + @"select pg_catalog.normalize(value, 'NFKD') FILTER (WHERE ok);" + ); + } + + #[test] + fn rewrites_innermost_normalize_call() { + assert_snapshot!( + apply_code_action( + rewrite_normalize_as_function_call, + "select normalize(normal$0ize(value, nfd), nfkc);", + ), + @"select normalize(pg_catalog.normalize(value, 'NFD'), nfkc);" + ); + } + + #[test] + fn not_applicable_to_already_qualified_call() { + assert!(code_action_not_applicable( + rewrite_normalize_as_function_call, + "select pg_catalog.norm$0alize(value, 'NFC');" + )); + } + + #[test] + fn not_applicable_to_non_normal_form_argument() { + assert!(code_action_not_applicable( + rewrite_normalize_as_function_call, + "select normalize(value, fo$0rm);" + )); + } +} diff --git a/crates/squawk_ide/src/code_actions/rewrite_overlaps_as_function_call.rs b/crates/squawk_ide/src/code_actions/rewrite_overlaps_as_function_call.rs new file mode 100644 index 000000000..baaf8ffc3 --- /dev/null +++ b/crates/squawk_ide/src/code_actions/rewrite_overlaps_as_function_call.rs @@ -0,0 +1,142 @@ +use rowan::{TextRange, TextSize}; +use salsa::Database as Db; +use squawk_linter::Edit; +use squawk_syntax::{ + SyntaxKind, SyntaxNode, + ast::{self, AstNode}, +}; + +use crate::{file::InFile, offsets::token_from_offset}; + +use super::{ActionKind, CodeAction}; + +pub(super) fn rewrite_overlaps_as_function_call( + db: &dyn Db, + position: InFile, + actions: &mut Vec, +) -> Option<()> { + let token = token_from_offset(db, position)?; + let bin_expr = token + .parent_ancestors() + .filter_map(ast::BinExpr::cast) + .find(|expr| matches!(expr.op(), Some(ast::BinOp::Overlaps(_))))?; + + let lhs = pair_tuple_ranges(bin_expr.lhs()?)?; + let rhs = pair_tuple_ranges(bin_expr.rhs()?)?; + + actions.push(CodeAction { + title: "Rewrite as `overlaps` function call".to_owned(), + edits: vec![ + Edit::replace( + TextRange::new(lhs.outer.start(), lhs.l_paren.start()), + "overlaps", + ), + Edit::replace(TextRange::new(lhs.r_paren.start(), rhs.l_paren.end()), ", "), + ], + kind: ActionKind::RefactorRewrite, + }); + + Some(()) +} + +struct TupleRanges { + outer: TextRange, + l_paren: TextRange, + r_paren: TextRange, +} + +fn pair_tuple_ranges(expr: ast::Expr) -> Option { + let ast::Expr::TupleExpr(tuple) = expr else { + return None; + }; + + if tuple.exprs().count() != 2 { + return None; + } + + tuple_ranges(tuple.syntax()) +} + +fn tuple_ranges(tuple: &SyntaxNode) -> Option { + let l_paren = tuple + .children_with_tokens() + .find(|element| element.kind() == SyntaxKind::L_PAREN)?; + let r_paren = tuple + .children_with_tokens() + .find(|element| element.kind() == SyntaxKind::R_PAREN)?; + + Some(TupleRanges { + outer: tuple.text_range(), + l_paren: l_paren.text_range(), + r_paren: r_paren.text_range(), + }) +} + +#[cfg(test)] +mod test { + use insta::assert_snapshot; + + use crate::code_actions::test_utils::{apply_code_action, code_action_not_applicable}; + + use super::rewrite_overlaps_as_function_call; + + #[test] + fn rewrites_overlaps_as_function_call() { + assert_snapshot!( + apply_code_action( + rewrite_overlaps_as_function_call, + "select (TIMESTAMP '2026-01-01', TIMESTAMP '2026-06-01') OVER$0LAPS (TIMESTAMP '2026-03-01', TIMESTAMP '2026-09-01');", + ), + @"select overlaps(TIMESTAMP '2026-01-01', TIMESTAMP '2026-06-01', TIMESTAMP '2026-03-01', TIMESTAMP '2026-09-01');" + ); + } + + #[test] + fn rewrites_with_cursor_in_an_operand() { + assert_snapshot!( + apply_code_action( + rewrite_overlaps_as_function_call, + "select (started_$0at, ended_at) overlaps (other_start, other_end);", + ), + @"select overlaps(started_at, ended_at, other_start, other_end);" + ); + } + + #[test] + fn rewrites_explicit_row_syntax() { + assert_snapshot!( + apply_code_action( + rewrite_overlaps_as_function_call, + "select ROW(started_at, duration) overlaps R$0OW(other_start, other_duration);", + ), + @"select overlaps(started_at, duration, other_start, other_duration);" + ); + } + + #[test] + fn rewrites_innermost_overlaps_expression() { + assert_snapshot!( + apply_code_action( + rewrite_overlaps_as_function_call, + "select ((a, b) over$0laps (c, d), e) overlaps (f, g);", + ), + @"select (overlaps(a, b, c, d), e) overlaps (f, g);" + ); + } + + #[test] + fn not_applicable_to_non_pair_tuples() { + assert!(code_action_not_applicable( + rewrite_overlaps_as_function_call, + "select (a, b, c) over$0laps (d, e);" + )); + } + + #[test] + fn not_applicable_outside_overlaps() { + assert!(code_action_not_applicable( + rewrite_overlaps_as_function_call, + "select overlaps(a, b, c,$0 d);" + )); + } +} diff --git a/crates/squawk_ide/src/code_actions/rewrite_overlay_as_function_call.rs b/crates/squawk_ide/src/code_actions/rewrite_overlay_as_function_call.rs new file mode 100644 index 000000000..94cb4cfad --- /dev/null +++ b/crates/squawk_ide/src/code_actions/rewrite_overlay_as_function_call.rs @@ -0,0 +1,106 @@ +use rowan::TextSize; +use salsa::Database as Db; +use squawk_linter::Edit; +use squawk_syntax::ast::{self, AstNode, OverlayArgs}; + +use crate::{file::InFile, offsets::token_from_offset}; + +use super::{ActionKind, CodeAction}; + +pub(super) fn rewrite_overlay_as_function_call( + db: &dyn Db, + position: InFile, + actions: &mut Vec, +) -> Option<()> { + let token = token_from_offset(db, position)?; + let overlay = token.parent_ancestors().find_map(ast::OverlayFn::cast)?; + let args = match overlay.overlay_args()? { + OverlayArgs::OverlayExprs(_) => return None, + OverlayArgs::OverlayPlacing(args) => args, + }; + + let string = args.string()?; + let placing = args.placing()?; + let from = args.from()?; + let mut arguments = format!( + "{}, {}, {}", + string.syntax().text(), + placing.syntax().text(), + from.syntax().text() + ); + if let Some(for_) = args.for_() { + arguments.push_str(&format!(", {}", for_.syntax().text())); + } + + actions.push(CodeAction { + title: "Rewrite as `overlay` function call".to_owned(), + edits: vec![Edit::replace( + overlay.syntax().text_range(), + format!("overlay({arguments})"), + )], + kind: ActionKind::RefactorRewrite, + }); + + Some(()) +} + +#[cfg(test)] +mod test { + use insta::assert_snapshot; + + use crate::code_actions::test_utils::{apply_code_action, code_action_not_applicable}; + + use super::rewrite_overlay_as_function_call; + + #[test] + fn rewrites_overlay_as_function_call() { + assert_snapshot!( + apply_code_action( + rewrite_overlay_as_function_call, + "select OVER$0LAY('hello world' PLACING 'Y' FROM 1 FOR 5);", + ), + @"select overlay('hello world', 'Y', 1, 5);" + ); + } + + #[test] + fn rewrites_without_optional_for_argument() { + assert_snapshot!( + apply_code_action( + rewrite_overlay_as_function_call, + "select overlay(value PLACING replacement FROM pos$0);", + ), + @"select overlay(value, replacement, pos);" + ); + } + + #[test] + fn applies_with_cursor_in_source_expression() { + assert_snapshot!( + apply_code_action( + rewrite_overlay_as_function_call, + "select overlay(lower(val$0ue) placing 'x' from 2 for length(value));", + ), + @"select overlay(lower(value), 'x', 2, length(value));" + ); + } + + #[test] + fn rewrites_innermost_overlay_and_preserves_aggregate_clause() { + assert_snapshot!( + apply_code_action( + rewrite_overlay_as_function_call, + "select overlay(overlay(a placing b fr$0om 1) placing c from 2) filter (where ok);", + ), + @"select overlay(overlay(a, b, 1) placing c from 2) filter (where ok);" + ); + } + + #[test] + fn not_applicable_to_comma_separated_call() { + assert!(code_action_not_applicable( + rewrite_overlay_as_function_call, + "select overlay(a, b, 1$0, 2);" + )); + } +} diff --git a/crates/squawk_ide/src/code_actions/rewrite_pattern_matching_as_operators.rs b/crates/squawk_ide/src/code_actions/rewrite_pattern_matching_as_operators.rs new file mode 100644 index 000000000..6a5a2e2c0 --- /dev/null +++ b/crates/squawk_ide/src/code_actions/rewrite_pattern_matching_as_operators.rs @@ -0,0 +1,199 @@ +use rowan::TextSize; +use salsa::Database as Db; +use squawk_linter::Edit; +use squawk_syntax::ast::{self, AstNode, BinOp}; + +use crate::{file::InFile, offsets::token_from_offset}; + +use super::{ActionKind, CodeAction}; + +pub(super) fn rewrite_pattern_matching_as_operators( + db: &dyn Db, + position: InFile, + actions: &mut Vec, +) -> Option<()> { + let token = token_from_offset(db, position)?; + + let (expr, operator, operator_range, title, needs_similar_to_escape) = token + .parent_ancestors() + .filter_map(ast::BinExpr::cast) + .find_map(|expr| { + let (operator, operator_range, title, needs_similar_to_escape) = match expr.op()? { + BinOp::Like(token) => ("~~", token.text_range(), "Rewrite `LIKE` as `~~`", false), + BinOp::NotLike(op) => ( + "!~~", + op.syntax().text_range(), + "Rewrite `NOT LIKE` as `!~~`", + false, + ), + BinOp::Ilike(token) => { + ("~~*", token.text_range(), "Rewrite `ILIKE` as `~~*`", false) + } + BinOp::NotIlike(op) => ( + "!~~*", + op.syntax().text_range(), + "Rewrite `NOT ILIKE` as `!~~*`", + false, + ), + BinOp::SimilarTo(op) => ( + "~", + op.syntax().text_range(), + "Rewrite `SIMILAR TO` as `~`", + true, + ), + BinOp::NotSimilarTo(op) => ( + "!~", + op.syntax().text_range(), + "Rewrite `NOT SIMILAR TO` as `!~`", + true, + ), + _ => return None, + }; + Some(( + expr, + operator, + operator_range, + title, + needs_similar_to_escape, + )) + })?; + + let rhs = expr.rhs()?; + let explicit_escape = ast::BinExpr::cast(rhs.syntax().clone()).and_then(|escape_expr| { + if !matches!(escape_expr.op(), Some(BinOp::Escape(_))) { + return None; + } + Some((escape_expr.lhs()?, escape_expr.rhs()?)) + }); + + let mut edits = vec![Edit::replace(operator_range, operator.to_owned())]; + if needs_similar_to_escape || explicit_escape.is_some() { + let function = if needs_similar_to_escape { + "similar_to_escape" + } else { + "like_escape" + }; + let arguments = match explicit_escape { + Some((pattern, escape)) => { + format!("{}, {}", pattern.syntax().text(), escape.syntax().text()) + } + None => rhs.syntax().text().to_string(), + }; + edits.push(Edit::replace( + rhs.syntax().text_range(), + format!("pg_catalog.{function}({arguments})"), + )); + } + + actions.push(CodeAction { + title: title.to_owned(), + edits, + kind: ActionKind::RefactorRewrite, + }); + + Some(()) +} + +#[cfg(test)] +mod test { + use insta::assert_snapshot; + + use crate::code_actions::test_utils::{apply_code_action, code_action_not_applicable}; + + use super::rewrite_pattern_matching_as_operators; + + #[test] + fn rewrites_like_as_operator() { + assert_snapshot!( + apply_code_action(rewrite_pattern_matching_as_operators, "select s $0LIKE p;"), + @"select s ~~ p;" + ); + } + + #[test] + fn rewrites_not_like_as_operator() { + assert_snapshot!( + apply_code_action(rewrite_pattern_matching_as_operators, "select s NOT $0LIKE p;"), + @"select s !~~ p;" + ); + } + + #[test] + fn rewrites_ilike_as_operator() { + assert_snapshot!( + apply_code_action(rewrite_pattern_matching_as_operators, "select s $0ILIKE p;"), + @"select s ~~* p;" + ); + } + + #[test] + fn rewrites_not_ilike_as_operator() { + assert_snapshot!( + apply_code_action(rewrite_pattern_matching_as_operators, "select s NOT $0ILIKE p;"), + @"select s !~~* p;" + ); + } + + #[test] + fn rewrites_similar_to_as_operator() { + assert_snapshot!( + apply_code_action( + rewrite_pattern_matching_as_operators, + "select s $0SIMILAR TO p;", + ), + @"select s ~ pg_catalog.similar_to_escape(p);" + ); + } + + #[test] + fn rewrites_not_similar_to_as_operator() { + assert_snapshot!( + apply_code_action( + rewrite_pattern_matching_as_operators, + "select s NOT $0SIMILAR TO p;", + ), + @"select s !~ pg_catalog.similar_to_escape(p);" + ); + } + + #[test] + fn rewrites_similar_to_with_escape() { + assert_snapshot!( + apply_code_action( + rewrite_pattern_matching_as_operators, + "select s SIMILAR TO p ESC$0APE e;", + ), + @"select s ~ pg_catalog.similar_to_escape(p, e);" + ); + } + + #[test] + fn rewrites_like_with_escape() { + assert_snapshot!( + apply_code_action( + rewrite_pattern_matching_as_operators, + "select s LIK$0E p ESCAPE e;", + ), + @"select s ~~ pg_catalog.like_escape(p, e);" + ); + } + + #[test] + fn applies_when_cursor_is_on_pattern() { + assert_snapshot!( + apply_code_action( + rewrite_pattern_matching_as_operators, + "select s NOT ILIKE lower($0pattern);", + ), + @"select s !~~* lower(pattern);" + ); + } + + #[test] + fn not_applicable_to_other_binary_expressions() { + assert!(code_action_not_applicable( + rewrite_pattern_matching_as_operators, + "select left +$0 right;" + )); + } +} diff --git a/crates/squawk_ide/src/code_actions/rewrite_position_as_function_call.rs b/crates/squawk_ide/src/code_actions/rewrite_position_as_function_call.rs new file mode 100644 index 000000000..8293c6dd5 --- /dev/null +++ b/crates/squawk_ide/src/code_actions/rewrite_position_as_function_call.rs @@ -0,0 +1,85 @@ +use rowan::TextSize; +use salsa::Database as Db; +use squawk_linter::Edit; +use squawk_syntax::ast::{self, AstNode}; + +use crate::{file::InFile, offsets::token_from_offset}; + +use super::{ActionKind, CodeAction}; + +pub(super) fn rewrite_position_as_function_call( + db: &dyn Db, + position: InFile, + actions: &mut Vec, +) -> Option<()> { + let token = token_from_offset(db, position)?; + let position_fn = token.parent_ancestors().find_map(ast::PositionFn::cast)?; + + let needle = position_fn.pos()?; + let haystack = position_fn.string()?; + + actions.push(CodeAction { + title: "Rewrite as function call `pg_catalog.position()`".to_owned(), + edits: vec![Edit::replace( + position_fn.syntax().text_range(), + format!( + "pg_catalog.position({}, {})", + haystack.syntax().text(), + needle.syntax().text() + ), + )], + kind: ActionKind::RefactorRewrite, + }); + + Some(()) +} + +#[cfg(test)] +mod test { + use insta::assert_snapshot; + + use crate::code_actions::test_utils::{apply_code_action, code_action_not_applicable}; + + use super::rewrite_position_as_function_call; + + #[test] + fn rewrites_position_as_function_call() { + assert_snapshot!( + apply_code_action( + rewrite_position_as_function_call, + "select POSI$0TION('world' IN 'hello world');", + ), + @"select pg_catalog.position('hello world', 'world');" + ); + } + + #[test] + fn applies_with_cursor_in_an_operand() { + assert_snapshot!( + apply_code_action( + rewrite_position_as_function_call, + "select position(needle in coalesce(hay$0stack, ''));", + ), + @"select pg_catalog.position(coalesce(haystack, ''), needle);" + ); + } + + #[test] + fn rewrites_innermost_position_call() { + assert_snapshot!( + apply_code_action( + rewrite_position_as_function_call, + "select position(position(a i$0n b)::text in c);", + ), + @"select position(pg_catalog.position(b, a)::text in c);" + ); + } + + #[test] + fn not_applicable_outside_position_call() { + assert!(code_action_not_applicable( + rewrite_position_as_function_call, + "select strpos(haystack, nee$0dle);" + )); + } +} diff --git a/crates/squawk_ide/src/code_actions/rewrite_substring_as_function_call.rs b/crates/squawk_ide/src/code_actions/rewrite_substring_as_function_call.rs new file mode 100644 index 000000000..5da615161 --- /dev/null +++ b/crates/squawk_ide/src/code_actions/rewrite_substring_as_function_call.rs @@ -0,0 +1,169 @@ +use rowan::TextSize; +use salsa::Database as Db; +use squawk_linter::Edit; +use squawk_syntax::ast::{self, AstNode, SubstringArgs}; + +use crate::{file::InFile, offsets::token_from_offset}; + +use super::{ActionKind, CodeAction}; + +pub(super) fn rewrite_substring_as_function_call( + db: &dyn Db, + position: InFile, + actions: &mut Vec, +) -> Option<()> { + let token = token_from_offset(db, position)?; + let substring = token.parent_ancestors().find_map(ast::SubstringFn::cast)?; + + let arguments = match substring.substring_args()? { + SubstringArgs::SubstringFromFor(args) => { + let string = args.string()?; + let start = args.start()?; + match args.count() { + Some(count) => format!( + "{}, {}, {}", + string.syntax().text(), + start.syntax().text(), + count.syntax().text() + ), + None => format!("{}, {}", string.syntax().text(), start.syntax().text()), + } + } + SubstringArgs::SubstringForFrom(args) => { + let string = args.string()?; + let count = args.count()?; + let start = args + .start() + .map(|start| start.syntax().text().to_string()) + .unwrap_or_else(|| "1".to_owned()); + format!( + "{}, {start}, {}", + string.syntax().text(), + count.syntax().text() + ) + } + SubstringArgs::SubstringSimilarEscape(args) => format!( + "{}, {}, {}", + args.string()?.syntax().text(), + args.pattern()?.syntax().text(), + args.escape()?.syntax().text() + ), + SubstringArgs::SubstringExprs(_) => return None, + }; + + actions.push(CodeAction { + title: "Rewrite as `substring` function call".to_owned(), + edits: vec![Edit::replace( + substring.syntax().text_range(), + format!("substring({arguments})"), + )], + kind: ActionKind::RefactorRewrite, + }); + + Some(()) +} + +#[cfg(test)] +mod test { + use insta::assert_snapshot; + + use crate::code_actions::test_utils::{apply_code_action, code_action_not_applicable}; + + use super::rewrite_substring_as_function_call; + + #[test] + fn rewrites_substring_from_for_as_function_call() { + assert_snapshot!( + apply_code_action( + rewrite_substring_as_function_call, + "select SUBS$0TRING('hello world' FROM 3 FOR 5);", + ), + @"select substring('hello world', 3, 5);" + ); + } + + #[test] + fn rewrites_for_before_from_in_function_argument_order() { + assert_snapshot!( + apply_code_action( + rewrite_substring_as_function_call, + "select substring(value FOR leng$0th FROM start);", + ), + @"select substring(value, start, length);" + ); + } + + #[test] + fn rewrites_optional_from_only_form() { + assert_snapshot!( + apply_code_action( + rewrite_substring_as_function_call, + "select substring(lower(val$0ue) FROM start);", + ), + @"select substring(lower(value), start);" + ); + } + + #[test] + fn rewrites_optional_for_only_form() { + assert_snapshot!( + apply_code_action( + rewrite_substring_as_function_call, + "select substring(value FOR leng$0th);", + ), + @"select substring(value, 1, length);" + ); + } + + #[test] + fn rewrites_similar_escape_form() { + assert_snapshot!( + apply_code_action( + rewrite_substring_as_function_call, + r##"select SUBS$0TRING('hello world' SIMILAR '%#"o w#"%' ESCAPE '#');"##, + ), + @r##"select substring('hello world', '%#"o w#"%', '#');"## + ); + } + + #[test] + fn rewrites_similar_escape_expression_arguments() { + assert_snapshot!( + apply_code_action( + rewrite_substring_as_function_call, + "select substring(lower(value) similar ('%' || patt$0ern) escape coalesce(escape_char, '#'));", + ), + @"select substring(lower(value), ('%' || pattern), coalesce(escape_char, '#'));" + ); + } + + #[test] + fn rewrites_innermost_from_for_call_and_preserves_aggregate_clause() { + assert_snapshot!( + apply_code_action( + rewrite_substring_as_function_call, + "select substring(substring(value FR$0OM 2) FROM 3 FOR 4) FILTER (WHERE ok);", + ), + @"select substring(substring(value, 2) FROM 3 FOR 4) FILTER (WHERE ok);" + ); + } + + #[test] + fn rewrites_innermost_similar_escape_call_and_preserves_aggregate_clause() { + assert_snapshot!( + apply_code_action( + rewrite_substring_as_function_call, + "select substring(substring(value similar patt$0ern escape esc) similar outer_pattern escape outer_esc) filter (where ok);", + ), + @"select substring(substring(value, pattern, esc) similar outer_pattern escape outer_esc) filter (where ok);" + ); + } + + #[test] + fn not_applicable_to_comma_separated_call() { + assert!(code_action_not_applicable( + rewrite_substring_as_function_call, + "select substring(value, 2$0, 3);" + )); + } +} diff --git a/crates/squawk_ide/src/code_actions/rewrite_system_user_as_function_call.rs b/crates/squawk_ide/src/code_actions/rewrite_system_user_as_function_call.rs new file mode 100644 index 000000000..3b47f84e5 --- /dev/null +++ b/crates/squawk_ide/src/code_actions/rewrite_system_user_as_function_call.rs @@ -0,0 +1,87 @@ +use rowan::TextSize; +use salsa::Database as Db; +use squawk_linter::Edit; +use squawk_syntax::{ + SyntaxKind, + ast::{self, AstNode}, +}; + +use crate::{file::InFile, offsets::token_from_offset}; + +use super::{ActionKind, CodeAction}; + +pub(super) fn rewrite_system_user_as_function_call( + db: &dyn Db, + position: InFile, + actions: &mut Vec, +) -> Option<()> { + let token = token_from_offset(db, position)?; + if token.kind() != SyntaxKind::SYSTEM_USER_KW { + return None; + } + + let name = token.parent().and_then(ast::NameRef::cast)?; + let parent = name.syntax().parent()?; + + if ast::CallExpr::can_cast(parent.kind()) || ast::FieldExpr::can_cast(parent.kind()) { + return None; + } + + actions.push(CodeAction { + title: "Rewrite as function call `pg_catalog.system_user()`".to_owned(), + edits: vec![Edit::replace( + name.syntax().text_range(), + "pg_catalog.system_user()", + )], + kind: ActionKind::RefactorRewrite, + }); + + Some(()) +} + +#[cfg(test)] +mod test { + use insta::assert_snapshot; + + use crate::code_actions::test_utils::{apply_code_action, code_action_not_applicable}; + + use super::rewrite_system_user_as_function_call; + + #[test] + fn rewrites_system_user_as_function_call() { + assert_snapshot!( + apply_code_action( + rewrite_system_user_as_function_call, + "select SYSTEM_$0USER;", + ), + @"select pg_catalog.system_user();" + ); + } + + #[test] + fn rewrites_at_expression_boundary() { + assert_snapshot!( + apply_code_action( + rewrite_system_user_as_function_call, + "select (SYSTEM_USER$0)::text;", + ), + @"select (pg_catalog.system_user())::text;" + ); + } + + #[test] + fn not_applicable_to_function_call() { + assert!(code_action_not_applicable( + rewrite_system_user_as_function_call, + "select system_$0user();" + )); + } + + #[test] + fn not_applicable_to_qualified_reference() { + assert!(code_action_not_applicable( + rewrite_system_user_as_function_call, + "select account.system_$0user from accounts;" + )); + } +} diff --git a/crates/squawk_ide/src/code_actions/rewrite_trim_as_function_call.rs b/crates/squawk_ide/src/code_actions/rewrite_trim_as_function_call.rs new file mode 100644 index 000000000..ed4617e94 --- /dev/null +++ b/crates/squawk_ide/src/code_actions/rewrite_trim_as_function_call.rs @@ -0,0 +1,185 @@ +use rowan::TextSize; +use salsa::Database as Db; +use squawk_linter::Edit; +use squawk_syntax::ast::{self, AstNode, TrimArgs, TrimSide}; + +use crate::{file::InFile, offsets::token_from_offset}; + +use super::{ActionKind, CodeAction}; + +pub(super) fn rewrite_trim_as_function_call( + db: &dyn Db, + position: InFile, + actions: &mut Vec, +) -> Option<()> { + let token = token_from_offset(db, position)?; + let trim = token.parent_ancestors().find_map(ast::TrimFn::cast)?; + + let (function, is_trailing) = match trim.trim_side()? { + TrimSide::TrimBoth(_) => ("btrim", false), + TrimSide::TrimLeading(_) => ("ltrim", false), + TrimSide::TrimTrailing(_) => ("rtrim", true), + }; + + let expressions = match trim.trim_args()? { + TrimArgs::TrimExprFrom(args) => { + let mut expressions = args.exprs().collect::>(); + if expressions.len() < 2 || (!is_trailing && expressions.len() != 2) { + return None; + } + expressions.rotate_left(1); + expressions + } + TrimArgs::TrimFrom(args) => args.exprs().collect(), + TrimArgs::TrimExprs(args) if is_trailing => args.exprs().collect(), + TrimArgs::TrimExprs(_) => return None, + }; + if expressions.is_empty() { + return None; + } + + let arguments = expressions + .iter() + .map(|expr| expr.syntax().text().to_string()) + .collect::>() + .join(", "); + + actions.push(CodeAction { + title: format!("Rewrite as `{function}` function call"), + edits: vec![Edit::replace( + trim.syntax().text_range(), + format!("{function}({arguments})"), + )], + kind: ActionKind::RefactorRewrite, + }); + + Some(()) +} + +#[cfg(test)] +mod test { + use insta::assert_snapshot; + + use crate::code_actions::test_utils::{apply_code_action, code_action_not_applicable}; + + use super::rewrite_trim_as_function_call; + + #[test] + fn rewrites_trim_both_as_btrim() { + assert_snapshot!( + apply_code_action( + rewrite_trim_as_function_call, + "select TRIM($0BOTH 'x' FROM 'xxhixx');", + ), + @"select btrim('xxhixx', 'x');" + ); + } + + #[test] + fn rewrites_trim_both_without_characters() { + assert_snapshot!( + apply_code_action( + rewrite_trim_as_function_call, + "select trim(BOTH FROM lower(val$0ue));", + ), + @"select btrim(lower(value));" + ); + } + + #[test] + fn rewrites_trim_leading_as_ltrim() { + assert_snapshot!( + apply_code_action( + rewrite_trim_as_function_call, + "select TRIM($0LEADING 'x' FROM 'xxhixx');", + ), + @"select ltrim('xxhixx', 'x');" + ); + } + + #[test] + fn rewrites_trim_leading_without_characters() { + assert_snapshot!( + apply_code_action( + rewrite_trim_as_function_call, + "select trim(leading from val$0ue);", + ), + @"select ltrim(value);" + ); + } + + #[test] + fn rewrites_from_first_function_style_form() { + assert_snapshot!( + apply_code_action( + rewrite_trim_as_function_call, + "select trim(leading from val$0ue, characters);", + ), + @"select ltrim(value, characters);" + ); + } + + #[test] + fn rewrites_trim_trailing_as_rtrim() { + assert_snapshot!( + apply_code_action( + rewrite_trim_as_function_call, + "select TRIM($0TRAILING 'x' FROM 'xxhixx');", + ), + @"select rtrim('xxhixx', 'x');" + ); + } + + #[test] + fn rewrites_trim_trailing_without_characters() { + assert_snapshot!( + apply_code_action( + rewrite_trim_as_function_call, + "select trim(trailing from lower(val$0ue));", + ), + @"select rtrim(lower(value));" + ); + } + + #[test] + fn rewrites_comma_separated_trailing_variant() { + assert_snapshot!( + apply_code_action( + rewrite_trim_as_function_call, + "select trim(trailing val$0ue, characters);", + ), + @"select rtrim(value, characters);" + ); + } + + #[test] + fn rewrites_innermost_call_and_preserves_aggregate_clause() { + assert_snapshot!( + apply_code_action( + rewrite_trim_as_function_call, + "select trim(both 'x' from trim(leading fr$0om value)) filter (where ok);", + ), + @"select trim(both 'x' from ltrim(value)) filter (where ok);" + ); + } + + #[test] + fn not_applicable_without_trim_side() { + assert!(code_action_not_applicable( + rewrite_trim_as_function_call, + "select trim(val$0ue);" + )); + } + + #[test] + fn not_applicable_to_nonstandard_both_or_leading_forms() { + assert!(code_action_not_applicable( + rewrite_trim_as_function_call, + "select trim(both value$0, 'x');" + )); + assert!(code_action_not_applicable( + rewrite_trim_as_function_call, + "select trim(leading val$0ue);" + )); + } +} diff --git a/crates/squawk_ide/src/code_actions/rewrite_xmlexists_as_function_call.rs b/crates/squawk_ide/src/code_actions/rewrite_xmlexists_as_function_call.rs new file mode 100644 index 000000000..921aea877 --- /dev/null +++ b/crates/squawk_ide/src/code_actions/rewrite_xmlexists_as_function_call.rs @@ -0,0 +1,96 @@ +use rowan::TextSize; +use salsa::Database as Db; +use squawk_linter::Edit; +use squawk_syntax::ast::{self, AstNode}; + +use crate::{file::InFile, offsets::token_from_offset}; + +use super::{ActionKind, CodeAction}; + +pub(super) fn rewrite_xmlexists_as_function_call( + db: &dyn Db, + position: InFile, + actions: &mut Vec, +) -> Option<()> { + let token = token_from_offset(db, position)?; + let xml_exists = token.parent_ancestors().find_map(ast::XmlExistsFn::cast)?; + let passing = xml_exists.xml_row_passing_clause()?; + let xpath = passing.row()?; + let xml = passing.xml_passing_doc()?.expr()?; + + actions.push(CodeAction { + title: "Rewrite as function call `pg_catalog.xmlexists()`".to_owned(), + edits: vec![Edit::replace( + xml_exists.syntax().text_range(), + format!( + "pg_catalog.xmlexists({}, {})", + xpath.syntax().text(), + xml.syntax().text() + ), + )], + kind: ActionKind::RefactorRewrite, + }); + + Some(()) +} + +#[cfg(test)] +mod test { + use insta::assert_snapshot; + + use crate::code_actions::test_utils::{apply_code_action, code_action_not_applicable}; + + use super::rewrite_xmlexists_as_function_call; + + #[test] + fn rewrites_xmlexists_as_function_call() { + assert_snapshot!( + apply_code_action( + rewrite_xmlexists_as_function_call, + "select XML$0EXISTS('//x' PASSING XMLPARSE(DOCUMENT ''));", + ), + @"select pg_catalog.xmlexists('//x', XMLPARSE(DOCUMENT ''));" + ); + } + + #[test] + fn applies_with_cursor_in_passing_expression() { + assert_snapshot!( + apply_code_action( + rewrite_xmlexists_as_function_call, + "select xmlexists(xpath passing xmlparse(document xm$0l_source));", + ), + @"select pg_catalog.xmlexists(xpath, xmlparse(document xml_source));" + ); + } + + #[test] + fn omits_optional_passing_mechanisms() { + assert_snapshot!( + apply_code_action( + rewrite_xmlexists_as_function_call, + "select xmlexists(path passing by va$0lue document by ref);", + ), + @"select pg_catalog.xmlexists(path, document);" + ); + } + + #[test] + fn rewrites_innermost_call_and_preserves_aggregate_clause() { + assert_snapshot!( + apply_code_action( + rewrite_xmlexists_as_function_call, + "select xmlexists(path passing xmlexists(inner_path pass$0ing document)::xml) FILTER (WHERE ok);", + ), + @"select xmlexists(path passing pg_catalog.xmlexists(inner_path, document)::xml) FILTER (WHERE ok);" + ); + } + + #[test] + fn not_applicable_outside_xmlexists() { + assert!(code_action_not_applicable( + rewrite_xmlexists_as_function_call, + "select xmlparse(document xm$0l_source);" + )); + } +} diff --git a/crates/squawk_parser/src/generated/syntax_kind.rs b/crates/squawk_parser/src/generated/syntax_kind.rs index 67a199e4e..4a3952e99 100644 --- a/crates/squawk_parser/src/generated/syntax_kind.rs +++ b/crates/squawk_parser/src/generated/syntax_kind.rs @@ -1009,7 +1009,8 @@ pub enum SyntaxKind { EXTENSION_SCHEMA, EXTENSION_VERSION, EXTENSION_VERSION_CLAUSE, - EXTRACT_FIELD, + EXTRACT_FIELD_LITERAL, + EXTRACT_FIELD_NAME, EXTRACT_FN, FAT_ARROW, FDW_OPTION_LIST, @@ -1734,7 +1735,11 @@ pub enum SyntaxKind { SUBSCRIPTION, SUBSCRIPTION_REF, SUBSCRIPTION_RENAME_TO, + SUBSTRING_EXPRS, SUBSTRING_FN, + SUBSTRING_FOR_FROM, + SUBSTRING_FROM_FOR, + SUBSTRING_SIMILAR_ESCAPE, SUPPORT_FUNC_OPTION, SYMMETRIC, TABLE, @@ -1795,7 +1800,10 @@ pub enum SyntaxKind { TRIGGER_REF, TRIGGER_RENAME_TO, TRIM_BOTH, + TRIM_EXPRS, + TRIM_EXPR_FROM, TRIM_FN, + TRIM_FROM, TRIM_LEADING, TRIM_TRAILING, TRUNCATE, diff --git a/crates/squawk_parser/src/grammar.rs b/crates/squawk_parser/src/grammar.rs index 29de16a86..4d27ee14f 100644 --- a/crates/squawk_parser/src/grammar.rs +++ b/crates/squawk_parser/src/grammar.rs @@ -285,16 +285,19 @@ fn when_clause(p: &mut Parser<'_>) -> CompletedMarker { m.complete(p, WHEN_CLAUSE) } -const EXTRACT_ARG_FIRST_: TokenSet = - TokenSet::new(&[YEAR_KW, MONTH_KW, DAY_KW, HOUR_KW, MINUTE_KW, SECOND_KW]).union(STRING_FIRST); - -// IDENT | YEAR_P | MONTH_P | DAY_P | HOUR_P | MINUTE_P | SECOND_P | Sconst -const EXTRACT_ARG_FIRST: TokenSet = IDENTS.union(EXTRACT_ARG_FIRST_); +const EXTRACT_ARG_FIRST: TokenSet = TokenSet::new(&[ + IDENT, YEAR_KW, MONTH_KW, DAY_KW, HOUR_KW, MINUTE_KW, SECOND_KW, +]) +.union(STRING_FIRST); fn extract_arg(p: &mut Parser<'_>) { - if p.at_ts(EXTRACT_ARG_FIRST) { + if p.at_ts(STRING_FIRST) { let m = p.start(); - p.bump_any(); - m.complete(p, EXTRACT_FIELD); + literal(p); + m.complete(p, EXTRACT_FIELD_LITERAL); + } else if p.at_ts(EXTRACT_ARG_FIRST) { + let m = p.start(); + pg_name(p); + m.complete(p, EXTRACT_FIELD_NAME); } else { p.error(format!( "expected ident, year, month, day, hour, minute, second, or string, got {:?}", @@ -384,31 +387,40 @@ fn opt_trim_side(p: &mut Parser<'_>) { m.complete(p, kind); } -fn trim_fn(p: &mut Parser<'_>) -> CompletedMarker { - assert!(p.at(TRIM_KW)); +fn trim_args(p: &mut Parser<'_>) -> CompletedMarker { let m = p.start(); - p.expect(TRIM_KW); - p.expect(L_PAREN); - opt_trim_side(p); // | FROM expr_list // | a_expr FROM expr_list // | expr_list - if p.eat(FROM_KW) { + let kind = if p.eat(FROM_KW) { if !opt_expr_list(p) { p.error("expected expression") } + TRIM_FROM } else { if expr(p).is_none() { p.error("expected expression"); } if p.eat(FROM_KW) { opt_expr_list(p); + TRIM_EXPR_FROM } else { if p.eat(COMMA) { opt_expr_list(p); } + TRIM_EXPRS } }; + m.complete(p, kind) +} + +fn trim_fn(p: &mut Parser<'_>) -> CompletedMarker { + assert!(p.at(TRIM_KW)); + let m = p.start(); + p.expect(TRIM_KW); + p.expect(L_PAREN); + opt_trim_side(p); + trim_args(p); p.expect(R_PAREN); let m = m.complete(p, TRIM_FN).precede(p); opt_agg_clauses(p); @@ -424,13 +436,10 @@ fn trim_fn(p: &mut Parser<'_>) -> CompletedMarker { // | a_expr SIMILAR a_expr ESCAPE a_expr // // SUBSTRING '(' func_arg_list_opt ')' -fn substring_fn(p: &mut Parser<'_>) -> CompletedMarker { - assert!(p.at(SUBSTRING_KW)); +fn substring_args(p: &mut Parser<'_>) -> CompletedMarker { let m = p.start(); - p.expect(SUBSTRING_KW); - p.expect(L_PAREN); expr(p); - match p.current() { + let kind = match p.current() { // FOR a_expr FROM a_expr // FOR a_expr FOR_KW => { @@ -440,6 +449,7 @@ fn substring_fn(p: &mut Parser<'_>) -> CompletedMarker { if p.eat(FROM_KW) { expr(p); } + SUBSTRING_FOR_FROM } // FROM a_expr // FROM a_expr FOR a_expr @@ -450,17 +460,39 @@ fn substring_fn(p: &mut Parser<'_>) -> CompletedMarker { if p.eat(FOR_KW) { expr(p); } + SUBSTRING_FROM_FOR } // SIMILAR a_expr ESCAPE a_expr SIMILAR_KW => { p.bump(SIMILAR_KW); + expr_bp( + p, + 1, + &Restrictions { + escape_disabled: true, + ..Restrictions::default() + }, + ); + p.expect(ESCAPE_KW); expr(p); + SUBSTRING_SIMILAR_ESCAPE } - _ if p.eat(COMMA) => { - opt_expr_list(p); + _ => { + if p.eat(COMMA) { + opt_expr_list(p); + } + SUBSTRING_EXPRS } - _ => (), - } + }; + m.complete(p, kind) +} + +fn substring_fn(p: &mut Parser<'_>) -> CompletedMarker { + assert!(p.at(SUBSTRING_KW)); + let m = p.start(); + p.expect(SUBSTRING_KW); + p.expect(L_PAREN); + substring_args(p); p.expect(R_PAREN); let m = m.complete(p, SUBSTRING_FN).precede(p); opt_agg_clauses(p); @@ -2785,7 +2817,7 @@ fn current_op(p: &Parser<'_>, r: &Restrictions) -> (u8, SyntaxKind, Associativit // overlaps OVERLAPS_KW => (7, OVERLAPS_KW, Left), // escape - ESCAPE_KW => (7, ESCAPE_KW, Left), + ESCAPE_KW if !r.escape_disabled => (7, ESCAPE_KW, Left), // like LIKE_KW => (6, LIKE_KW, Left), // ilike @@ -2868,6 +2900,7 @@ const OVERLAPPING_TOKENS: TokenSet = TokenSet::new(&[OR_KW, AND_KW, IS_KW, COLLA #[derive(Default)] struct Restrictions { order_by_allowed: bool, + escape_disabled: bool, in_disabled: bool, is_disabled: bool, not_disabled: bool, diff --git a/crates/squawk_parser/tests/data/ok/select_funcs.sql b/crates/squawk_parser/tests/data/ok/select_funcs.sql index 6f60424c0..6b02dc132 100644 --- a/crates/squawk_parser/tests/data/ok/select_funcs.sql +++ b/crates/squawk_parser/tests/data/ok/select_funcs.sql @@ -185,7 +185,28 @@ select extract(day from a); select extract(hour from a); select extract(minute from a); select extract(second from a); +select extract('century' from a); +select extract('day' from a); +select extract('decade' from a); +select extract('dow' from a); +select extract('doy' from a); +select extract('epoch' from a); +select extract('hour' from a); +select extract('isodow' from a); +select extract('isoyear' from a); +select extract('julian' from a); +select extract('microseconds' from a); +select extract('millennium' from a); +select extract('milliseconds' from a); select extract('minute' from a); +select extract('month' from a); +select extract('quarter' from a); +select extract('second' from a); +select extract('timezone' from a); +select extract('timezone_hour' from a); +select extract('timezone_minute' from a); +select extract('week' from a); +select extract('year' from a); select extract(epoch from timestamptz '2013-07-01 12:00:00'); select extract(century from timestamp '2000-12-16 12:21:13'); select extract(isodow from timestamp '2001-02-18 20:38:40'); diff --git a/crates/squawk_parser/tests/snapshots/tests__create_table_ok.snap b/crates/squawk_parser/tests/snapshots/tests__create_table_ok.snap index 79eef80a0..4f9d52c44 100644 --- a/crates/squawk_parser/tests/snapshots/tests__create_table_ok.snap +++ b/crates/squawk_parser/tests/snapshots/tests__create_table_ok.snap @@ -513,7 +513,7 @@ SOURCE_FILE EXTRACT_FN EXTRACT_KW "extract" L_PAREN "(" - EXTRACT_FIELD + EXTRACT_FIELD_NAME MONTH_KW "month" WHITESPACE " " FROM_KW "from" diff --git a/crates/squawk_parser/tests/snapshots/tests__misc_ok.snap b/crates/squawk_parser/tests/snapshots/tests__misc_ok.snap index 7b77552ac..52775478b 100644 --- a/crates/squawk_parser/tests/snapshots/tests__misc_ok.snap +++ b/crates/squawk_parser/tests/snapshots/tests__misc_ok.snap @@ -1050,8 +1050,9 @@ SOURCE_FILE EXTRACT_KW "EXTRACT" L_PAREN "(" WHITESPACE " " - EXTRACT_FIELD - STRING "'hour'" + EXTRACT_FIELD_LITERAL + LITERAL + STRING "'hour'" WHITESPACE " " FROM_KW "FROM" WHITESPACE " " @@ -1110,8 +1111,9 @@ SOURCE_FILE EXTRACT_KW "EXTRACT" L_PAREN "(" WHITESPACE " " - EXTRACT_FIELD - STRING "'hour'" + EXTRACT_FIELD_LITERAL + LITERAL + STRING "'hour'" WHITESPACE " " FROM_KW "FROM" WHITESPACE " " diff --git a/crates/squawk_parser/tests/snapshots/tests__select_funcs_ok.snap b/crates/squawk_parser/tests/snapshots/tests__select_funcs_ok.snap index 5345cf051..6d5e4307a 100644 --- a/crates/squawk_parser/tests/snapshots/tests__select_funcs_ok.snap +++ b/crates/squawk_parser/tests/snapshots/tests__select_funcs_ok.snap @@ -546,13 +546,14 @@ SOURCE_FILE TRIM_BOTH BOTH_KW "both" WHITESPACE " " - NAME_REF - IDENT "a" - WHITESPACE " " - FROM_KW "from" - WHITESPACE " " - NAME_REF - IDENT "a" + TRIM_EXPR_FROM + NAME_REF + IDENT "a" + WHITESPACE " " + FROM_KW "from" + WHITESPACE " " + NAME_REF + IDENT "a" R_PAREN ")" SEMICOLON ";" WHITESPACE "\n" @@ -569,10 +570,11 @@ SOURCE_FILE TRIM_BOTH BOTH_KW "both" WHITESPACE " " - FROM_KW "from" - WHITESPACE " " - NAME_REF - IDENT "a" + TRIM_FROM + FROM_KW "from" + WHITESPACE " " + NAME_REF + IDENT "a" R_PAREN ")" SEMICOLON ";" WHITESPACE "\n" @@ -589,8 +591,9 @@ SOURCE_FILE TRIM_BOTH BOTH_KW "both" WHITESPACE " " - NAME_REF - IDENT "a" + TRIM_EXPRS + NAME_REF + IDENT "a" R_PAREN ")" SEMICOLON ";" WHITESPACE "\n" @@ -607,8 +610,9 @@ SOURCE_FILE TRIM_LEADING LEADING_KW "leading" WHITESPACE " " - NAME_REF - IDENT "a" + TRIM_EXPRS + NAME_REF + IDENT "a" R_PAREN ")" SEMICOLON ";" WHITESPACE "\n" @@ -625,8 +629,9 @@ SOURCE_FILE TRIM_TRAILING TRAILING_KW "trailing" WHITESPACE " " - NAME_REF - IDENT "a" + TRIM_EXPRS + NAME_REF + IDENT "a" R_PAREN ")" SEMICOLON ";" WHITESPACE "\n" @@ -640,8 +645,9 @@ SOURCE_FILE TRIM_FN TRIM_KW "trim" L_PAREN "(" - NAME_REF - IDENT "a" + TRIM_EXPRS + NAME_REF + IDENT "a" R_PAREN ")" SEMICOLON ";" WHITESPACE "\n\n" @@ -657,18 +663,19 @@ SOURCE_FILE SUBSTRING_FN SUBSTRING_KW "substring" L_PAREN "(" - NAME_REF - IDENT "a" - WHITESPACE " " - FROM_KW "from" - WHITESPACE " " - NAME_REF - IDENT "b" - WHITESPACE " " - FOR_KW "for" - WHITESPACE " " - NAME_REF - IDENT "c" + SUBSTRING_FROM_FOR + NAME_REF + IDENT "a" + WHITESPACE " " + FROM_KW "from" + WHITESPACE " " + NAME_REF + IDENT "b" + WHITESPACE " " + FOR_KW "for" + WHITESPACE " " + NAME_REF + IDENT "c" R_PAREN ")" SEMICOLON ";" WHITESPACE "\n" @@ -682,18 +689,19 @@ SOURCE_FILE SUBSTRING_FN SUBSTRING_KW "substring" L_PAREN "(" - NAME_REF - IDENT "a" - WHITESPACE " " - FOR_KW "for" - WHITESPACE " " - NAME_REF - IDENT "b" - WHITESPACE " " - FROM_KW "from" - WHITESPACE " " - NAME_REF - IDENT "c" + SUBSTRING_FOR_FROM + NAME_REF + IDENT "a" + WHITESPACE " " + FOR_KW "for" + WHITESPACE " " + NAME_REF + IDENT "b" + WHITESPACE " " + FROM_KW "from" + WHITESPACE " " + NAME_REF + IDENT "c" R_PAREN ")" SEMICOLON ";" WHITESPACE "\n" @@ -707,13 +715,14 @@ SOURCE_FILE SUBSTRING_FN SUBSTRING_KW "substring" L_PAREN "(" - NAME_REF - IDENT "a" - WHITESPACE " " - FROM_KW "from" - WHITESPACE " " - NAME_REF - IDENT "b" + SUBSTRING_FROM_FOR + NAME_REF + IDENT "a" + WHITESPACE " " + FROM_KW "from" + WHITESPACE " " + NAME_REF + IDENT "b" R_PAREN ")" SEMICOLON ";" WHITESPACE "\n" @@ -727,13 +736,14 @@ SOURCE_FILE SUBSTRING_FN SUBSTRING_KW "substring" L_PAREN "(" - NAME_REF - IDENT "a" - WHITESPACE " " - FOR_KW "for" - WHITESPACE " " - NAME_REF - IDENT "b" + SUBSTRING_FOR_FROM + NAME_REF + IDENT "a" + WHITESPACE " " + FOR_KW "for" + WHITESPACE " " + NAME_REF + IDENT "b" R_PAREN ")" SEMICOLON ";" WHITESPACE "\n" @@ -747,12 +757,12 @@ SOURCE_FILE SUBSTRING_FN SUBSTRING_KW "substring" L_PAREN "(" - NAME_REF - IDENT "a" - WHITESPACE " " - SIMILAR_KW "similar" - WHITESPACE " " - BIN_EXPR + SUBSTRING_SIMILAR_ESCAPE + NAME_REF + IDENT "a" + WHITESPACE " " + SIMILAR_KW "similar" + WHITESPACE " " NAME_REF IDENT "b" WHITESPACE " " @@ -773,36 +783,37 @@ SOURCE_FILE SUBSTRING_FN SUBSTRING_KW "substring" L_PAREN "(" - BIN_EXPR - NAME_REF - IDENT "a" - WHITESPACE " " - CUSTOM_OP - PIPE "|" - PIPE "|" - WHITESPACE " " - LITERAL - STRING "''" - COMMA "," - WHITESPACE " " - BIN_EXPR - NAME_REF - IDENT "b" - WHITESPACE " " - STAR "*" - WHITESPACE " " - LITERAL - INT_NUMBER "2" - COMMA "," - WHITESPACE " " - BIN_EXPR - NAME_REF - IDENT "c" + SUBSTRING_EXPRS + BIN_EXPR + NAME_REF + IDENT "a" + WHITESPACE " " + CUSTOM_OP + PIPE "|" + PIPE "|" + WHITESPACE " " + LITERAL + STRING "''" + COMMA "," WHITESPACE " " - MINUS "-" + BIN_EXPR + NAME_REF + IDENT "b" + WHITESPACE " " + STAR "*" + WHITESPACE " " + LITERAL + INT_NUMBER "2" + COMMA "," WHITESPACE " " - LITERAL - INT_NUMBER "1" + BIN_EXPR + NAME_REF + IDENT "c" + WHITESPACE " " + MINUS "-" + WHITESPACE " " + LITERAL + INT_NUMBER "1" R_PAREN ")" SEMICOLON ";" WHITESPACE "\n" @@ -816,16 +827,17 @@ SOURCE_FILE SUBSTRING_FN SUBSTRING_KW "substring" L_PAREN "(" - LITERAL - STRING "'hello world'" - COMMA "," - WHITESPACE " " - LITERAL - INT_NUMBER "1" - COMMA "," - WHITESPACE " " - LITERAL - INT_NUMBER "5" + SUBSTRING_EXPRS + LITERAL + STRING "'hello world'" + COMMA "," + WHITESPACE " " + LITERAL + INT_NUMBER "1" + COMMA "," + WHITESPACE " " + LITERAL + INT_NUMBER "5" R_PAREN ")" SEMICOLON ";" WHITESPACE "\n\n" @@ -2980,7 +2992,7 @@ SOURCE_FILE EXTRACT_FN EXTRACT_KW "extract" L_PAREN "(" - EXTRACT_FIELD + EXTRACT_FIELD_NAME IDENT "foo" WHITESPACE " " FROM_KW "from" @@ -3008,7 +3020,7 @@ SOURCE_FILE EXTRACT_FN EXTRACT_KW "extract" L_PAREN "(" - EXTRACT_FIELD + EXTRACT_FIELD_NAME YEAR_KW "year" WHITESPACE " " FROM_KW "from" @@ -3028,7 +3040,7 @@ SOURCE_FILE EXTRACT_FN EXTRACT_KW "extract" L_PAREN "(" - EXTRACT_FIELD + EXTRACT_FIELD_NAME MONTH_KW "month" WHITESPACE " " FROM_KW "from" @@ -3048,7 +3060,7 @@ SOURCE_FILE EXTRACT_FN EXTRACT_KW "extract" L_PAREN "(" - EXTRACT_FIELD + EXTRACT_FIELD_NAME DAY_KW "day" WHITESPACE " " FROM_KW "from" @@ -3068,7 +3080,7 @@ SOURCE_FILE EXTRACT_FN EXTRACT_KW "extract" L_PAREN "(" - EXTRACT_FIELD + EXTRACT_FIELD_NAME HOUR_KW "hour" WHITESPACE " " FROM_KW "from" @@ -3088,7 +3100,7 @@ SOURCE_FILE EXTRACT_FN EXTRACT_KW "extract" L_PAREN "(" - EXTRACT_FIELD + EXTRACT_FIELD_NAME MINUTE_KW "minute" WHITESPACE " " FROM_KW "from" @@ -3108,7 +3120,7 @@ SOURCE_FILE EXTRACT_FN EXTRACT_KW "extract" L_PAREN "(" - EXTRACT_FIELD + EXTRACT_FIELD_NAME SECOND_KW "second" WHITESPACE " " FROM_KW "from" @@ -3128,8 +3140,450 @@ SOURCE_FILE EXTRACT_FN EXTRACT_KW "extract" L_PAREN "(" - EXTRACT_FIELD - STRING "'minute'" + EXTRACT_FIELD_LITERAL + LITERAL + STRING "'century'" + WHITESPACE " " + FROM_KW "from" + WHITESPACE " " + NAME_REF + IDENT "a" + R_PAREN ")" + SEMICOLON ";" + WHITESPACE "\n" + SELECT + SELECT_CLAUSE + SELECT_KW "select" + WHITESPACE " " + TARGET_LIST + TARGET + CALL_EXPR + EXTRACT_FN + EXTRACT_KW "extract" + L_PAREN "(" + EXTRACT_FIELD_LITERAL + LITERAL + STRING "'day'" + WHITESPACE " " + FROM_KW "from" + WHITESPACE " " + NAME_REF + IDENT "a" + R_PAREN ")" + SEMICOLON ";" + WHITESPACE "\n" + SELECT + SELECT_CLAUSE + SELECT_KW "select" + WHITESPACE " " + TARGET_LIST + TARGET + CALL_EXPR + EXTRACT_FN + EXTRACT_KW "extract" + L_PAREN "(" + EXTRACT_FIELD_LITERAL + LITERAL + STRING "'decade'" + WHITESPACE " " + FROM_KW "from" + WHITESPACE " " + NAME_REF + IDENT "a" + R_PAREN ")" + SEMICOLON ";" + WHITESPACE "\n" + SELECT + SELECT_CLAUSE + SELECT_KW "select" + WHITESPACE " " + TARGET_LIST + TARGET + CALL_EXPR + EXTRACT_FN + EXTRACT_KW "extract" + L_PAREN "(" + EXTRACT_FIELD_LITERAL + LITERAL + STRING "'dow'" + WHITESPACE " " + FROM_KW "from" + WHITESPACE " " + NAME_REF + IDENT "a" + R_PAREN ")" + SEMICOLON ";" + WHITESPACE "\n" + SELECT + SELECT_CLAUSE + SELECT_KW "select" + WHITESPACE " " + TARGET_LIST + TARGET + CALL_EXPR + EXTRACT_FN + EXTRACT_KW "extract" + L_PAREN "(" + EXTRACT_FIELD_LITERAL + LITERAL + STRING "'doy'" + WHITESPACE " " + FROM_KW "from" + WHITESPACE " " + NAME_REF + IDENT "a" + R_PAREN ")" + SEMICOLON ";" + WHITESPACE "\n" + SELECT + SELECT_CLAUSE + SELECT_KW "select" + WHITESPACE " " + TARGET_LIST + TARGET + CALL_EXPR + EXTRACT_FN + EXTRACT_KW "extract" + L_PAREN "(" + EXTRACT_FIELD_LITERAL + LITERAL + STRING "'epoch'" + WHITESPACE " " + FROM_KW "from" + WHITESPACE " " + NAME_REF + IDENT "a" + R_PAREN ")" + SEMICOLON ";" + WHITESPACE "\n" + SELECT + SELECT_CLAUSE + SELECT_KW "select" + WHITESPACE " " + TARGET_LIST + TARGET + CALL_EXPR + EXTRACT_FN + EXTRACT_KW "extract" + L_PAREN "(" + EXTRACT_FIELD_LITERAL + LITERAL + STRING "'hour'" + WHITESPACE " " + FROM_KW "from" + WHITESPACE " " + NAME_REF + IDENT "a" + R_PAREN ")" + SEMICOLON ";" + WHITESPACE "\n" + SELECT + SELECT_CLAUSE + SELECT_KW "select" + WHITESPACE " " + TARGET_LIST + TARGET + CALL_EXPR + EXTRACT_FN + EXTRACT_KW "extract" + L_PAREN "(" + EXTRACT_FIELD_LITERAL + LITERAL + STRING "'isodow'" + WHITESPACE " " + FROM_KW "from" + WHITESPACE " " + NAME_REF + IDENT "a" + R_PAREN ")" + SEMICOLON ";" + WHITESPACE "\n" + SELECT + SELECT_CLAUSE + SELECT_KW "select" + WHITESPACE " " + TARGET_LIST + TARGET + CALL_EXPR + EXTRACT_FN + EXTRACT_KW "extract" + L_PAREN "(" + EXTRACT_FIELD_LITERAL + LITERAL + STRING "'isoyear'" + WHITESPACE " " + FROM_KW "from" + WHITESPACE " " + NAME_REF + IDENT "a" + R_PAREN ")" + SEMICOLON ";" + WHITESPACE "\n" + SELECT + SELECT_CLAUSE + SELECT_KW "select" + WHITESPACE " " + TARGET_LIST + TARGET + CALL_EXPR + EXTRACT_FN + EXTRACT_KW "extract" + L_PAREN "(" + EXTRACT_FIELD_LITERAL + LITERAL + STRING "'julian'" + WHITESPACE " " + FROM_KW "from" + WHITESPACE " " + NAME_REF + IDENT "a" + R_PAREN ")" + SEMICOLON ";" + WHITESPACE "\n" + SELECT + SELECT_CLAUSE + SELECT_KW "select" + WHITESPACE " " + TARGET_LIST + TARGET + CALL_EXPR + EXTRACT_FN + EXTRACT_KW "extract" + L_PAREN "(" + EXTRACT_FIELD_LITERAL + LITERAL + STRING "'microseconds'" + WHITESPACE " " + FROM_KW "from" + WHITESPACE " " + NAME_REF + IDENT "a" + R_PAREN ")" + SEMICOLON ";" + WHITESPACE "\n" + SELECT + SELECT_CLAUSE + SELECT_KW "select" + WHITESPACE " " + TARGET_LIST + TARGET + CALL_EXPR + EXTRACT_FN + EXTRACT_KW "extract" + L_PAREN "(" + EXTRACT_FIELD_LITERAL + LITERAL + STRING "'millennium'" + WHITESPACE " " + FROM_KW "from" + WHITESPACE " " + NAME_REF + IDENT "a" + R_PAREN ")" + SEMICOLON ";" + WHITESPACE "\n" + SELECT + SELECT_CLAUSE + SELECT_KW "select" + WHITESPACE " " + TARGET_LIST + TARGET + CALL_EXPR + EXTRACT_FN + EXTRACT_KW "extract" + L_PAREN "(" + EXTRACT_FIELD_LITERAL + LITERAL + STRING "'milliseconds'" + WHITESPACE " " + FROM_KW "from" + WHITESPACE " " + NAME_REF + IDENT "a" + R_PAREN ")" + SEMICOLON ";" + WHITESPACE "\n" + SELECT + SELECT_CLAUSE + SELECT_KW "select" + WHITESPACE " " + TARGET_LIST + TARGET + CALL_EXPR + EXTRACT_FN + EXTRACT_KW "extract" + L_PAREN "(" + EXTRACT_FIELD_LITERAL + LITERAL + STRING "'minute'" + WHITESPACE " " + FROM_KW "from" + WHITESPACE " " + NAME_REF + IDENT "a" + R_PAREN ")" + SEMICOLON ";" + WHITESPACE "\n" + SELECT + SELECT_CLAUSE + SELECT_KW "select" + WHITESPACE " " + TARGET_LIST + TARGET + CALL_EXPR + EXTRACT_FN + EXTRACT_KW "extract" + L_PAREN "(" + EXTRACT_FIELD_LITERAL + LITERAL + STRING "'month'" + WHITESPACE " " + FROM_KW "from" + WHITESPACE " " + NAME_REF + IDENT "a" + R_PAREN ")" + SEMICOLON ";" + WHITESPACE "\n" + SELECT + SELECT_CLAUSE + SELECT_KW "select" + WHITESPACE " " + TARGET_LIST + TARGET + CALL_EXPR + EXTRACT_FN + EXTRACT_KW "extract" + L_PAREN "(" + EXTRACT_FIELD_LITERAL + LITERAL + STRING "'quarter'" + WHITESPACE " " + FROM_KW "from" + WHITESPACE " " + NAME_REF + IDENT "a" + R_PAREN ")" + SEMICOLON ";" + WHITESPACE "\n" + SELECT + SELECT_CLAUSE + SELECT_KW "select" + WHITESPACE " " + TARGET_LIST + TARGET + CALL_EXPR + EXTRACT_FN + EXTRACT_KW "extract" + L_PAREN "(" + EXTRACT_FIELD_LITERAL + LITERAL + STRING "'second'" + WHITESPACE " " + FROM_KW "from" + WHITESPACE " " + NAME_REF + IDENT "a" + R_PAREN ")" + SEMICOLON ";" + WHITESPACE "\n" + SELECT + SELECT_CLAUSE + SELECT_KW "select" + WHITESPACE " " + TARGET_LIST + TARGET + CALL_EXPR + EXTRACT_FN + EXTRACT_KW "extract" + L_PAREN "(" + EXTRACT_FIELD_LITERAL + LITERAL + STRING "'timezone'" + WHITESPACE " " + FROM_KW "from" + WHITESPACE " " + NAME_REF + IDENT "a" + R_PAREN ")" + SEMICOLON ";" + WHITESPACE "\n" + SELECT + SELECT_CLAUSE + SELECT_KW "select" + WHITESPACE " " + TARGET_LIST + TARGET + CALL_EXPR + EXTRACT_FN + EXTRACT_KW "extract" + L_PAREN "(" + EXTRACT_FIELD_LITERAL + LITERAL + STRING "'timezone_hour'" + WHITESPACE " " + FROM_KW "from" + WHITESPACE " " + NAME_REF + IDENT "a" + R_PAREN ")" + SEMICOLON ";" + WHITESPACE "\n" + SELECT + SELECT_CLAUSE + SELECT_KW "select" + WHITESPACE " " + TARGET_LIST + TARGET + CALL_EXPR + EXTRACT_FN + EXTRACT_KW "extract" + L_PAREN "(" + EXTRACT_FIELD_LITERAL + LITERAL + STRING "'timezone_minute'" + WHITESPACE " " + FROM_KW "from" + WHITESPACE " " + NAME_REF + IDENT "a" + R_PAREN ")" + SEMICOLON ";" + WHITESPACE "\n" + SELECT + SELECT_CLAUSE + SELECT_KW "select" + WHITESPACE " " + TARGET_LIST + TARGET + CALL_EXPR + EXTRACT_FN + EXTRACT_KW "extract" + L_PAREN "(" + EXTRACT_FIELD_LITERAL + LITERAL + STRING "'week'" + WHITESPACE " " + FROM_KW "from" + WHITESPACE " " + NAME_REF + IDENT "a" + R_PAREN ")" + SEMICOLON ";" + WHITESPACE "\n" + SELECT + SELECT_CLAUSE + SELECT_KW "select" + WHITESPACE " " + TARGET_LIST + TARGET + CALL_EXPR + EXTRACT_FN + EXTRACT_KW "extract" + L_PAREN "(" + EXTRACT_FIELD_LITERAL + LITERAL + STRING "'year'" WHITESPACE " " FROM_KW "from" WHITESPACE " " @@ -3148,7 +3602,7 @@ SOURCE_FILE EXTRACT_FN EXTRACT_KW "extract" L_PAREN "(" - EXTRACT_FIELD + EXTRACT_FIELD_NAME IDENT "epoch" WHITESPACE " " FROM_KW "from" @@ -3174,7 +3628,7 @@ SOURCE_FILE EXTRACT_FN EXTRACT_KW "extract" L_PAREN "(" - EXTRACT_FIELD + EXTRACT_FIELD_NAME IDENT "century" WHITESPACE " " FROM_KW "from" @@ -3198,7 +3652,7 @@ SOURCE_FILE EXTRACT_FN EXTRACT_KW "extract" L_PAREN "(" - EXTRACT_FIELD + EXTRACT_FIELD_NAME IDENT "isodow" WHITESPACE " " FROM_KW "from" @@ -3222,7 +3676,7 @@ SOURCE_FILE EXTRACT_FN EXTRACT_KW "extract" L_PAREN "(" - EXTRACT_FIELD + EXTRACT_FIELD_NAME IDENT "isoyear" WHITESPACE " " FROM_KW "from" @@ -3248,7 +3702,7 @@ SOURCE_FILE EXTRACT_FN EXTRACT_KW "extract" L_PAREN "(" - EXTRACT_FIELD + EXTRACT_FIELD_NAME IDENT "julian" WHITESPACE " " FROM_KW "from" @@ -3274,7 +3728,7 @@ SOURCE_FILE EXTRACT_FN EXTRACT_KW "extract" L_PAREN "(" - EXTRACT_FIELD + EXTRACT_FIELD_NAME IDENT "microseconds" WHITESPACE " " FROM_KW "from" @@ -3298,7 +3752,7 @@ SOURCE_FILE EXTRACT_FN EXTRACT_KW "extract" L_PAREN "(" - EXTRACT_FIELD + EXTRACT_FIELD_NAME IDENT "millennium" WHITESPACE " " FROM_KW "from" @@ -3322,7 +3776,7 @@ SOURCE_FILE EXTRACT_FN EXTRACT_KW "extract" L_PAREN "(" - EXTRACT_FIELD + EXTRACT_FIELD_NAME IDENT "quarter" WHITESPACE " " FROM_KW "from" diff --git a/crates/squawk_syntax/src/ast/generated/nodes.rs b/crates/squawk_syntax/src/ast/generated/nodes.rs index 958afa2c4..655cdb088 100644 --- a/crates/squawk_syntax/src/ast/generated/nodes.rs +++ b/crates/squawk_syntax/src/ast/generated/nodes.rs @@ -10979,14 +10979,21 @@ impl ExtensionVersionClause { } #[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct ExtractField { +pub struct ExtractFieldLiteral { pub(crate) syntax: SyntaxNode, } -impl ExtractField { +impl ExtractFieldLiteral { #[inline] - pub fn string_token(&self) -> Option { - support::token(&self.syntax, SyntaxKind::STRING) + pub fn literal(&self) -> Option { + support::child(&self.syntax) } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ExtractFieldName { + pub(crate) syntax: SyntaxNode, +} +impl ExtractFieldName { #[inline] pub fn day_token(&self) -> Option { support::token(&self.syntax, SyntaxKind::DAY_KW) @@ -24560,20 +24567,27 @@ impl SubscriptionRenameTo { } } +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct SubstringExprs { + pub(crate) syntax: SyntaxNode, +} +impl SubstringExprs { + #[inline] + pub fn exprs(&self) -> AstChildren { + support::children(&self.syntax) + } +} + #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct SubstringFn { pub(crate) syntax: SyntaxNode, } impl SubstringFn { #[inline] - pub fn expr(&self) -> Option { + pub fn substring_args(&self) -> Option { support::child(&self.syntax) } #[inline] - pub fn exprs(&self) -> AstChildren { - support::children(&self.syntax) - } - #[inline] pub fn l_paren_token(&self) -> Option { support::token(&self.syntax, SyntaxKind::L_PAREN) } @@ -24582,6 +24596,21 @@ impl SubstringFn { support::token(&self.syntax, SyntaxKind::R_PAREN) } #[inline] + pub fn substring_token(&self) -> Option { + support::token(&self.syntax, SyntaxKind::SUBSTRING_KW) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct SubstringForFrom { + pub(crate) syntax: SyntaxNode, +} +impl SubstringForFrom { + #[inline] + pub fn expr(&self) -> Option { + support::child(&self.syntax) + } + #[inline] pub fn for_token(&self) -> Option { support::token(&self.syntax, SyntaxKind::FOR_KW) } @@ -24589,13 +24618,43 @@ impl SubstringFn { pub fn from_token(&self) -> Option { support::token(&self.syntax, SyntaxKind::FROM_KW) } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct SubstringFromFor { + pub(crate) syntax: SyntaxNode, +} +impl SubstringFromFor { #[inline] - pub fn similar_token(&self) -> Option { - support::token(&self.syntax, SyntaxKind::SIMILAR_KW) + pub fn expr(&self) -> Option { + support::child(&self.syntax) } #[inline] - pub fn substring_token(&self) -> Option { - support::token(&self.syntax, SyntaxKind::SUBSTRING_KW) + pub fn for_token(&self) -> Option { + support::token(&self.syntax, SyntaxKind::FOR_KW) + } + #[inline] + pub fn from_token(&self) -> Option { + support::token(&self.syntax, SyntaxKind::FROM_KW) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct SubstringSimilarEscape { + pub(crate) syntax: SyntaxNode, +} +impl SubstringSimilarEscape { + #[inline] + pub fn expr(&self) -> Option { + support::child(&self.syntax) + } + #[inline] + pub fn escape_token(&self) -> Option { + support::token(&self.syntax, SyntaxKind::ESCAPE_KW) + } + #[inline] + pub fn similar_token(&self) -> Option { + support::token(&self.syntax, SyntaxKind::SIMILAR_KW) } } @@ -25544,10 +25603,10 @@ impl TrimBoth { } #[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct TrimFn { +pub struct TrimExprFrom { pub(crate) syntax: SyntaxNode, } -impl TrimFn { +impl TrimExprFrom { #[inline] pub fn expr(&self) -> Option { support::child(&self.syntax) @@ -25557,6 +25616,32 @@ impl TrimFn { support::children(&self.syntax) } #[inline] + pub fn from_token(&self) -> Option { + support::token(&self.syntax, SyntaxKind::FROM_KW) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct TrimExprs { + pub(crate) syntax: SyntaxNode, +} +impl TrimExprs { + #[inline] + pub fn exprs(&self) -> AstChildren { + support::children(&self.syntax) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct TrimFn { + pub(crate) syntax: SyntaxNode, +} +impl TrimFn { + #[inline] + pub fn trim_args(&self) -> Option { + support::child(&self.syntax) + } + #[inline] pub fn trim_side(&self) -> Option { support::child(&self.syntax) } @@ -25569,15 +25654,26 @@ impl TrimFn { support::token(&self.syntax, SyntaxKind::R_PAREN) } #[inline] - pub fn from_token(&self) -> Option { - support::token(&self.syntax, SyntaxKind::FROM_KW) - } - #[inline] pub fn trim_token(&self) -> Option { support::token(&self.syntax, SyntaxKind::TRIM_KW) } } +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct TrimFrom { + pub(crate) syntax: SyntaxNode, +} +impl TrimFrom { + #[inline] + pub fn exprs(&self) -> AstChildren { + support::children(&self.syntax) + } + #[inline] + pub fn from_token(&self) -> Option { + support::token(&self.syntax, SyntaxKind::FROM_KW) + } +} + #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct TrimLeading { pub(crate) syntax: SyntaxNode, @@ -28493,6 +28589,12 @@ pub enum ExtensionMemberObject { ObjectView(ObjectView), } +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum ExtractField { + ExtractFieldLiteral(ExtractFieldLiteral), + ExtractFieldName(ExtractFieldName), +} + #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum FdwOption { OptionAlterOptionList(OptionAlterOptionList), @@ -29398,6 +29500,14 @@ pub enum SubscriptionSource { ServerClause(ServerClause), } +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum SubstringArgs { + SubstringExprs(SubstringExprs), + SubstringForFrom(SubstringForFrom), + SubstringFromFor(SubstringFromFor), + SubstringSimilarEscape(SubstringSimilarEscape), +} + #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum TableArg { Column(Column), @@ -29486,6 +29596,13 @@ pub enum TriggerTarget { User(User), } +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum TrimArgs { + TrimExprFrom(TrimExprFrom), + TrimExprs(TrimExprs), + TrimFrom(TrimFrom), +} + #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum TrimSide { TrimBoth(TrimBoth), @@ -37575,10 +37692,28 @@ impl AstNode for ExtensionVersionClause { &self.syntax } } -impl AstNode for ExtractField { +impl AstNode for ExtractFieldLiteral { + #[inline] + fn can_cast(kind: SyntaxKind) -> bool { + kind == SyntaxKind::EXTRACT_FIELD_LITERAL + } + #[inline] + fn cast(syntax: SyntaxNode) -> Option { + if Self::can_cast(syntax.kind()) { + Some(Self { syntax }) + } else { + None + } + } + #[inline] + fn syntax(&self) -> &SyntaxNode { + &self.syntax + } +} +impl AstNode for ExtractFieldName { #[inline] fn can_cast(kind: SyntaxKind) -> bool { - kind == SyntaxKind::EXTRACT_FIELD + kind == SyntaxKind::EXTRACT_FIELD_NAME } #[inline] fn cast(syntax: SyntaxNode) -> Option { @@ -50625,6 +50760,24 @@ impl AstNode for SubscriptionRenameTo { &self.syntax } } +impl AstNode for SubstringExprs { + #[inline] + fn can_cast(kind: SyntaxKind) -> bool { + kind == SyntaxKind::SUBSTRING_EXPRS + } + #[inline] + fn cast(syntax: SyntaxNode) -> Option { + if Self::can_cast(syntax.kind()) { + Some(Self { syntax }) + } else { + None + } + } + #[inline] + fn syntax(&self) -> &SyntaxNode { + &self.syntax + } +} impl AstNode for SubstringFn { #[inline] fn can_cast(kind: SyntaxKind) -> bool { @@ -50643,6 +50796,60 @@ impl AstNode for SubstringFn { &self.syntax } } +impl AstNode for SubstringForFrom { + #[inline] + fn can_cast(kind: SyntaxKind) -> bool { + kind == SyntaxKind::SUBSTRING_FOR_FROM + } + #[inline] + fn cast(syntax: SyntaxNode) -> Option { + if Self::can_cast(syntax.kind()) { + Some(Self { syntax }) + } else { + None + } + } + #[inline] + fn syntax(&self) -> &SyntaxNode { + &self.syntax + } +} +impl AstNode for SubstringFromFor { + #[inline] + fn can_cast(kind: SyntaxKind) -> bool { + kind == SyntaxKind::SUBSTRING_FROM_FOR + } + #[inline] + fn cast(syntax: SyntaxNode) -> Option { + if Self::can_cast(syntax.kind()) { + Some(Self { syntax }) + } else { + None + } + } + #[inline] + fn syntax(&self) -> &SyntaxNode { + &self.syntax + } +} +impl AstNode for SubstringSimilarEscape { + #[inline] + fn can_cast(kind: SyntaxKind) -> bool { + kind == SyntaxKind::SUBSTRING_SIMILAR_ESCAPE + } + #[inline] + fn cast(syntax: SyntaxNode) -> Option { + if Self::can_cast(syntax.kind()) { + Some(Self { syntax }) + } else { + None + } + } + #[inline] + fn syntax(&self) -> &SyntaxNode { + &self.syntax + } +} impl AstNode for SupportFuncOption { #[inline] fn can_cast(kind: SyntaxKind) -> bool { @@ -51723,6 +51930,42 @@ impl AstNode for TrimBoth { &self.syntax } } +impl AstNode for TrimExprFrom { + #[inline] + fn can_cast(kind: SyntaxKind) -> bool { + kind == SyntaxKind::TRIM_EXPR_FROM + } + #[inline] + fn cast(syntax: SyntaxNode) -> Option { + if Self::can_cast(syntax.kind()) { + Some(Self { syntax }) + } else { + None + } + } + #[inline] + fn syntax(&self) -> &SyntaxNode { + &self.syntax + } +} +impl AstNode for TrimExprs { + #[inline] + fn can_cast(kind: SyntaxKind) -> bool { + kind == SyntaxKind::TRIM_EXPRS + } + #[inline] + fn cast(syntax: SyntaxNode) -> Option { + if Self::can_cast(syntax.kind()) { + Some(Self { syntax }) + } else { + None + } + } + #[inline] + fn syntax(&self) -> &SyntaxNode { + &self.syntax + } +} impl AstNode for TrimFn { #[inline] fn can_cast(kind: SyntaxKind) -> bool { @@ -51741,6 +51984,24 @@ impl AstNode for TrimFn { &self.syntax } } +impl AstNode for TrimFrom { + #[inline] + fn can_cast(kind: SyntaxKind) -> bool { + kind == SyntaxKind::TRIM_FROM + } + #[inline] + fn cast(syntax: SyntaxNode) -> Option { + if Self::can_cast(syntax.kind()) { + Some(Self { syntax }) + } else { + None + } + } + #[inline] + fn syntax(&self) -> &SyntaxNode { + &self.syntax + } +} impl AstNode for TrimLeading { #[inline] fn can_cast(kind: SyntaxKind) -> bool { @@ -61433,6 +61694,49 @@ impl From for ExtensionMemberObject { ExtensionMemberObject::ObjectView(node) } } +impl AstNode for ExtractField { + #[inline] + fn can_cast(kind: SyntaxKind) -> bool { + matches!( + kind, + SyntaxKind::EXTRACT_FIELD_LITERAL | SyntaxKind::EXTRACT_FIELD_NAME + ) + } + #[inline] + fn cast(syntax: SyntaxNode) -> Option { + let res = match syntax.kind() { + SyntaxKind::EXTRACT_FIELD_LITERAL => { + ExtractField::ExtractFieldLiteral(ExtractFieldLiteral { syntax }) + } + SyntaxKind::EXTRACT_FIELD_NAME => { + ExtractField::ExtractFieldName(ExtractFieldName { syntax }) + } + _ => { + return None; + } + }; + Some(res) + } + #[inline] + fn syntax(&self) -> &SyntaxNode { + match self { + ExtractField::ExtractFieldLiteral(it) => &it.syntax, + ExtractField::ExtractFieldName(it) => &it.syntax, + } + } +} +impl From for ExtractField { + #[inline] + fn from(node: ExtractFieldLiteral) -> ExtractField { + ExtractField::ExtractFieldLiteral(node) + } +} +impl From for ExtractField { + #[inline] + fn from(node: ExtractFieldName) -> ExtractField { + ExtractField::ExtractFieldName(node) + } +} impl AstNode for FdwOption { #[inline] fn can_cast(kind: SyntaxKind) -> bool { @@ -68660,6 +68964,70 @@ impl From for SubscriptionSource { SubscriptionSource::ServerClause(node) } } +impl AstNode for SubstringArgs { + #[inline] + fn can_cast(kind: SyntaxKind) -> bool { + matches!( + kind, + SyntaxKind::SUBSTRING_EXPRS + | SyntaxKind::SUBSTRING_FOR_FROM + | SyntaxKind::SUBSTRING_FROM_FOR + | SyntaxKind::SUBSTRING_SIMILAR_ESCAPE + ) + } + #[inline] + fn cast(syntax: SyntaxNode) -> Option { + let res = match syntax.kind() { + SyntaxKind::SUBSTRING_EXPRS => SubstringArgs::SubstringExprs(SubstringExprs { syntax }), + SyntaxKind::SUBSTRING_FOR_FROM => { + SubstringArgs::SubstringForFrom(SubstringForFrom { syntax }) + } + SyntaxKind::SUBSTRING_FROM_FOR => { + SubstringArgs::SubstringFromFor(SubstringFromFor { syntax }) + } + SyntaxKind::SUBSTRING_SIMILAR_ESCAPE => { + SubstringArgs::SubstringSimilarEscape(SubstringSimilarEscape { syntax }) + } + _ => { + return None; + } + }; + Some(res) + } + #[inline] + fn syntax(&self) -> &SyntaxNode { + match self { + SubstringArgs::SubstringExprs(it) => &it.syntax, + SubstringArgs::SubstringForFrom(it) => &it.syntax, + SubstringArgs::SubstringFromFor(it) => &it.syntax, + SubstringArgs::SubstringSimilarEscape(it) => &it.syntax, + } + } +} +impl From for SubstringArgs { + #[inline] + fn from(node: SubstringExprs) -> SubstringArgs { + SubstringArgs::SubstringExprs(node) + } +} +impl From for SubstringArgs { + #[inline] + fn from(node: SubstringForFrom) -> SubstringArgs { + SubstringArgs::SubstringForFrom(node) + } +} +impl From for SubstringArgs { + #[inline] + fn from(node: SubstringFromFor) -> SubstringArgs { + SubstringArgs::SubstringFromFor(node) + } +} +impl From for SubstringArgs { + #[inline] + fn from(node: SubstringSimilarEscape) -> SubstringArgs { + SubstringArgs::SubstringSimilarEscape(node) + } +} impl AstNode for TableArg { #[inline] fn can_cast(kind: SyntaxKind) -> bool { @@ -69325,6 +69693,53 @@ impl From for TriggerTarget { TriggerTarget::User(node) } } +impl AstNode for TrimArgs { + #[inline] + fn can_cast(kind: SyntaxKind) -> bool { + matches!( + kind, + SyntaxKind::TRIM_EXPR_FROM | SyntaxKind::TRIM_EXPRS | SyntaxKind::TRIM_FROM + ) + } + #[inline] + fn cast(syntax: SyntaxNode) -> Option { + let res = match syntax.kind() { + SyntaxKind::TRIM_EXPR_FROM => TrimArgs::TrimExprFrom(TrimExprFrom { syntax }), + SyntaxKind::TRIM_EXPRS => TrimArgs::TrimExprs(TrimExprs { syntax }), + SyntaxKind::TRIM_FROM => TrimArgs::TrimFrom(TrimFrom { syntax }), + _ => { + return None; + } + }; + Some(res) + } + #[inline] + fn syntax(&self) -> &SyntaxNode { + match self { + TrimArgs::TrimExprFrom(it) => &it.syntax, + TrimArgs::TrimExprs(it) => &it.syntax, + TrimArgs::TrimFrom(it) => &it.syntax, + } + } +} +impl From for TrimArgs { + #[inline] + fn from(node: TrimExprFrom) -> TrimArgs { + TrimArgs::TrimExprFrom(node) + } +} +impl From for TrimArgs { + #[inline] + fn from(node: TrimExprs) -> TrimArgs { + TrimArgs::TrimExprs(node) + } +} +impl From for TrimArgs { + #[inline] + fn from(node: TrimFrom) -> TrimArgs { + TrimArgs::TrimFrom(node) + } +} impl AstNode for TrimSide { #[inline] fn can_cast(kind: SyntaxKind) -> bool { diff --git a/crates/squawk_syntax/src/ast/node_ext.rs b/crates/squawk_syntax/src/ast/node_ext.rs index fc4804f23..8f0bf0cfe 100644 --- a/crates/squawk_syntax/src/ast/node_ext.rs +++ b/crates/squawk_syntax/src/ast/node_ext.rs @@ -833,6 +833,12 @@ impl ast::CreateConversion { } } +impl ast::ExtractFieldName { + pub fn text(&self) -> String { + normalize_name_node(self.syntax()) + } +} + impl ast::PositionFn { #[inline] pub fn pos(&self) -> Option { @@ -845,6 +851,57 @@ impl ast::PositionFn { } } +impl ast::SubstringForFrom { + #[inline] + pub fn string(&self) -> Option { + support::children(self.syntax()).next() + } + + #[inline] + pub fn count(&self) -> Option { + support::children(self.syntax()).nth(1) + } + + #[inline] + pub fn start(&self) -> Option { + support::children(self.syntax()).nth(2) + } +} + +impl ast::SubstringFromFor { + #[inline] + pub fn string(&self) -> Option { + support::children(self.syntax()).next() + } + + #[inline] + pub fn start(&self) -> Option { + support::children(self.syntax()).nth(1) + } + + #[inline] + pub fn count(&self) -> Option { + support::children(self.syntax()).nth(2) + } +} + +impl ast::SubstringSimilarEscape { + #[inline] + pub fn string(&self) -> Option { + support::children(self.syntax()).next() + } + + #[inline] + pub fn pattern(&self) -> Option { + support::children(self.syntax()).nth(1) + } + + #[inline] + pub fn escape(&self) -> Option { + support::children(self.syntax()).nth(2) + } +} + impl ast::OverlayPlacing { #[inline] pub fn string(&self) -> Option { diff --git a/crates/squawk_syntax/src/postgresql.ungram b/crates/squawk_syntax/src/postgresql.ungram index cdf99b24a..f1fbc0b68 100644 --- a/crates/squawk_syntax/src/postgresql.ungram +++ b/crates/squawk_syntax/src/postgresql.ungram @@ -150,7 +150,14 @@ ExtractFn = ')' ExtractField = - '#ident' | 'year' | 'month' | 'day' | 'hour' | 'minute' | 'second' | '@string' + ExtractFieldName +| ExtractFieldLiteral + +ExtractFieldName = + '#ident' | 'year' | 'month' | 'day' | 'hour' | 'minute' | 'second' + +ExtractFieldLiteral = + Literal JsonExistsFn = 'json_exists' '(' @@ -315,12 +322,25 @@ JsonValueFn = ')' SubstringFn = - 'substring' '(' - Expr 'for' Expr ('from' Expr)? - | Expr 'from' Expr ('for' Expr)? - | Expr 'similar' Expr - | (Expr (',' Expr)*) - ')' + 'substring' '(' SubstringArgs ')' + +SubstringArgs = + SubstringForFrom +| SubstringFromFor +| SubstringSimilarEscape +| SubstringExprs + +SubstringForFrom = + Expr 'for' Expr ('from' Expr)? + +SubstringFromFor = + Expr 'from' Expr ('for' Expr)? + +SubstringSimilarEscape = + Expr 'similar' Expr 'escape' Expr + +SubstringExprs = + Expr (',' Expr)* PositionFn = 'position' '(' pos:Expr 'in' string:Expr ')' @@ -343,14 +363,21 @@ OverlayExpr = | NamedArg TrimFn = - 'trim' '(' - TrimSide? - ( - 'from' (Expr (',' Expr)*) - | (Expr 'from' (Expr (',' Expr)*)) - | (Expr (',' Expr)*) - ) - ')' + 'trim' '(' TrimSide? TrimArgs ')' + +TrimArgs = + TrimFrom +| TrimExprFrom +| TrimExprs + +TrimFrom = + 'from' (Expr (',' Expr)*) + +TrimExprFrom = + Expr 'from' (Expr (',' Expr)*) + +TrimExprs = + Expr (',' Expr)* TrimSide = TrimBoth diff --git a/crates/squawk_syntax/src/quote.rs b/crates/squawk_syntax/src/quote.rs index fd0e27a62..93dad3225 100644 --- a/crates/squawk_syntax/src/quote.rs +++ b/crates/squawk_syntax/src/quote.rs @@ -1,6 +1,10 @@ use crate::SyntaxNode; use crate::generated::keywords::RESERVED_KEYWORDS; +pub fn quote_string_literal(text: &str) -> String { + format!("'{}'", text.replace('\'', "''")) +} + pub fn quote_column_alias(text: &str) -> String { if needs_quoting(text) { format!(r#""{}""#, text.replace('"', r#""""#)) @@ -105,6 +109,11 @@ mod tests { use super::*; + #[test] + fn quote_string_literal_escapes_apostrophes() { + assert_snapshot!(quote_string_literal("it's"), @"'it''s'"); + } + #[test] fn quote_column_alias_handles_embedded_quotes() { assert_snapshot!(quote_column_alias(r#"foo"bar"#), @r#""foo""bar""#);