From c0c0ee5f32bc84e714cd5840122e8971c9f92198 Mon Sep 17 00:00:00 2001 From: Steve Dignam Date: Fri, 21 Aug 2026 23:01:11 -0400 Subject: [PATCH] fmt: format types --- crates/squawk_fmt/src/fmt.rs | 433 ++++++++++++++---- .../squawk_fmt/tests/after/create_table.snap | 24 + .../tests/after/select_comments.snap | 16 + crates/squawk_fmt/tests/after/types.snap | 137 ++++++ .../squawk_fmt/tests/before/create_table.sql | 12 + .../tests/before/select_comments.sql | 11 + crates/squawk_fmt/tests/before/types.sql | 99 ++++ crates/squawk_parser/src/grammar.rs | 42 +- .../squawk_parser/tests/data/err/interval.sql | 2 + .../tests/snapshots/tests__interval_err.snap | 70 +++ .../snapshots/tests__select_casts_ok.snap | 18 +- .../squawk_syntax/src/ast/generated/nodes.rs | 16 +- crates/squawk_syntax/src/postgresql.ungram | 5 +- 13 files changed, 766 insertions(+), 119 deletions(-) create mode 100644 crates/squawk_fmt/tests/after/types.snap create mode 100644 crates/squawk_fmt/tests/before/types.sql create mode 100644 crates/squawk_parser/tests/data/err/interval.sql create mode 100644 crates/squawk_parser/tests/snapshots/tests__interval_err.snap diff --git a/crates/squawk_fmt/src/fmt.rs b/crates/squawk_fmt/src/fmt.rs index add4ce1a..cf85da7e 100644 --- a/crates/squawk_fmt/src/fmt.rs +++ b/crates/squawk_fmt/src/fmt.rs @@ -4,7 +4,7 @@ use rowan::Direction; use squawk_line_index::{LineEnding, UniversalNewlines, find_newline}; use squawk_syntax::ast::{self, AstNode, LitKind, normalize_name_node}; use squawk_syntax::quote::{quote_bare_column_alias, quote_column_alias, quote_ident}; -use squawk_syntax::{SyntaxKind, SyntaxNode, SyntaxToken}; +use squawk_syntax::{SyntaxElement, SyntaxKind, SyntaxNode, SyntaxToken}; use tiny_pretty::Doc; use tiny_pretty::{LineBreak, PrintOptions, print}; @@ -142,14 +142,23 @@ fn is_unicode_escape(text: &str) -> bool { .is_some_and(|text| text.starts_with("&\"")) } -fn build_table_arg<'a>(create_table: ast::TableArg) -> Doc<'a> { - match create_table { - ast::TableArg::Column(column) => build_name(column.name().unwrap().syntax()) - .append(Doc::space()) - .append(Doc::text(column.ty().unwrap().syntax().to_string())), - ast::TableArg::LikeClause(like_clause) => build_like_clause(&like_clause), +fn build_table_arg<'a>(arg: ast::TableArg) -> Doc<'a> { + let doc = leading_comments(arg.syntax()); + let doc = doc.append(match &arg { + ast::TableArg::Column(column) => { + let mut doc = build_name(column.name().unwrap().syntax()); + if let Some(ty) = column.ty() { + doc = doc + .append(Doc::space()) + .append(leading_comments(ty.syntax())) + .append(build_type(ty)); + } + doc + } + ast::TableArg::LikeClause(like_clause) => build_like_clause(like_clause), ast::TableArg::TableConstraint(_table_constraint) => todo!(), - } + }); + doc.append(trailing_comments(arg.syntax())) } fn build_like_clause<'a>(like_clause: &ast::LikeClause) -> Doc<'a> { @@ -262,23 +271,11 @@ fn build_semicolon<'a>(semi: Option) -> Doc<'a> { return Doc::nil(); }; let mut doc = Doc::nil(); - let mut comments: Vec = vec![]; - for next in semi.siblings_with_tokens(Direction::Prev).skip(1) { - match next { - rowan::NodeOrToken::Node(_) => break, - rowan::NodeOrToken::Token(token) => { - if token.kind() == SyntaxKind::COMMENT { - comments.push(token); - } else if token.kind() == SyntaxKind::WHITESPACE { - continue; - } else { - break; - } - } - } - } - for comment in comments.iter().rev() { + for comment in comment_tokens_before(semi) { doc = doc.append(Doc::text(comment.text().to_string())); + if is_line_comment(&comment) { + doc = doc.append(Doc::hard_line()); + } } doc.append(Doc::text(";")) } @@ -344,30 +341,52 @@ fn build_expr<'a>(expr: ast::Expr) -> Doc<'a> { // ast::Expr::CaseExpr(case_expr) => todo!(), ast::Expr::CastExpr(cast_expr) => { let mut doc = Doc::nil(); - if cast_expr.colon_colon().is_some() { + if let Some(colon_colon) = cast_expr.colon_colon() { + let ty = cast_expr.ty().unwrap(); doc = doc .append(build_expr(cast_expr.expr().unwrap())) + .append(comments_before(colon_colon.syntax().clone())) .append(Doc::text("::")) - .append(build_type(cast_expr.ty().unwrap())) - } else if cast_expr.as_token().is_some() { + .append(leading_comments(ty.syntax())) + .append(build_type(ty)) + } else if let Some(as_token) = cast_expr.as_token() { if cast_expr.cast_token().is_some() { doc = doc.append(Doc::text("cast")) } else if cast_expr.treat_token().is_some() { doc = doc.append(Doc::text("treat")) } + let expr = cast_expr.expr().unwrap(); + let ty = cast_expr.ty().unwrap(); + if let Some(l_paren) = cast_expr.l_paren_token() { + doc = doc.append(comments_before(l_paren)); + } doc = doc .append(Doc::text("(")) - .append(build_expr(cast_expr.expr().unwrap())) + .append(leading_comments(expr.syntax())) + .append(build_expr(expr)) .append(Doc::space()) + .append(leading_comments_token(&as_token)) .append(Doc::text("as")) .append(Doc::space()) - .append(build_type(cast_expr.ty().unwrap())) - .append(Doc::text(")")) + .append(leading_comments(ty.syntax())) + .append(build_type(ty)); + if let Some(r_paren) = cast_expr.r_paren_token() { + doc = doc.append(comments_before(r_paren)); + } + doc = doc.append(Doc::text(")")) } else { + let literal = cast_expr.literal().unwrap(); doc = doc .append(build_type(cast_expr.ty().unwrap())) .append(Doc::space()) - .append(build_literal(cast_expr.literal().unwrap())) + .append(leading_comments(literal.syntax())) + .append(build_literal(literal)); + if let Some(qualifier) = cast_expr.interval_qualifier() { + doc = doc + .append(Doc::space()) + .append(leading_comments(qualifier.syntax())) + .append(build_interval_qualifier(&qualifier)) + } } doc } @@ -533,24 +552,30 @@ fn build_unicode_normal_form<'a>(form: ast::UnicodeNormalForm) -> Doc<'a> { fn build_keyword_node<'a>(node: &SyntaxNode) -> Doc<'a> { let mut docs: Vec> = vec![]; + let mut after_line_comment = false; for el in node.children_with_tokens() { - match el { - rowan::NodeOrToken::Token(token) => match token.kind() { - SyntaxKind::WHITESPACE => continue, - SyntaxKind::COMMENT => { - if !docs.is_empty() { - docs.push(Doc::space()); - } - docs.push(Doc::text(token.text().to_string())); + let Some(token) = el.into_token() else { + continue; + }; + match token.kind() { + SyntaxKind::WHITESPACE => continue, + SyntaxKind::COMMENT => { + if !docs.is_empty() && !after_line_comment { + docs.push(Doc::space()); } - _ => { - if !docs.is_empty() { - docs.push(Doc::space()); - } - docs.push(Doc::text(token.text().to_ascii_lowercase())); + docs.push(Doc::text(token.text().to_string())); + after_line_comment = is_line_comment(&token); + if after_line_comment { + docs.push(Doc::hard_line()); + } + } + _ => { + if !docs.is_empty() && !after_line_comment { + docs.push(Doc::space()); } - }, - rowan::NodeOrToken::Node(_) => (), + after_line_comment = false; + docs.push(Doc::text(token.text().to_ascii_lowercase())); + } } } Doc::list(docs) @@ -653,62 +678,296 @@ fn format_string_token(t: &SyntaxToken) -> String { } fn build_type<'a>(ty: ast::Type) -> Doc<'a> { - Doc::text(ty.syntax().to_string()) -} - -fn leading_comments_token<'a>(node: &SyntaxToken) -> Doc<'a> { - let mut doc = Doc::nil(); - for next in node.siblings_with_tokens(Direction::Prev).skip(1) { - match next { - rowan::NodeOrToken::Node(_node) => { - break; + match ty { + ast::Type::ArrayType(array_type) => { + let mut doc = match array_type.ty() { + Some(inner) => build_type(inner), + None => Doc::nil(), + }; + if let Some(array_token) = array_type.array_token() { + doc = doc + .append(Doc::space()) + .append(leading_comments_token(&array_token)) + .append(Doc::text("array")); } - rowan::NodeOrToken::Token(token) => { - if token.kind() == SyntaxKind::COMMENT { + for bound in array_type.array_bounds() { + doc = doc + .append(comments_before(bound.syntax().clone())) + .append(build_array_bound(&bound)); + } + doc + } + ast::Type::BitType(bit_type) => { + build_keyword_node(bit_type.syntax()).append(build_type_args(bit_type.arg_list())) + } + ast::Type::BitVaryingType(bit_varying_type) => { + build_keyword_node(bit_varying_type.syntax()) + .append(build_type_args(bit_varying_type.arg_list())) + } + ast::Type::CharacterType(character_type) => build_keyword_node(character_type.syntax()) + .append(build_type_args(character_type.arg_list())), + ast::Type::VarcharType(varchar_type) => build_keyword_node(varchar_type.syntax()) + .append(build_type_args(varchar_type.arg_list())), + ast::Type::DoubleType(double_type) => build_keyword_node(double_type.syntax()), + ast::Type::ExprType(expr_type) => match expr_type.expr() { + Some(expr) => build_expr(expr), + None => Doc::nil(), + }, + ast::Type::IntervalType(interval_type) => { + let mut doc = build_setof(interval_type.setof_token()); + if let Some(interval_token) = interval_type.interval_token() { + doc = doc + .append(leading_comments_token(&interval_token)) + .append(Doc::text("interval")); + } + doc = doc.append(build_type_precision( + interval_type.l_paren_token(), + interval_type.literal(), + interval_type.r_paren_token(), + )); + if let Some(qualifier) = interval_type.interval_qualifier() { + doc = doc + .append(Doc::space()) + .append(leading_comments(qualifier.syntax())) + .append(build_interval_qualifier(&qualifier)); + } + doc + } + ast::Type::PathType(path_type) => { + let mut doc = build_setof(path_type.setof_token()); + if let Some(path) = path_type.path_ref() { + doc = doc + .append(leading_comments(path.syntax())) + .append(build_path_ref(&path)); + } + let arg_list = path_type.arg_list(); + if let Some(arg_list) = &arg_list { + doc = doc.append(comments_before(arg_list.syntax().clone())); + } + doc.append(build_type_args(arg_list)) + } + ast::Type::PercentType(percent_type) => { + let mut doc = build_setof(percent_type.setof_token()); + if let Some(path) = percent_type.path_ref() { + doc = doc + .append(leading_comments(path.syntax())) + .append(build_path_ref(&path)); + } + if let Some(clause) = percent_type.percent_type_clause() { + doc = doc.append(comments_before(clause.syntax().clone())); + if clause.percent_token().is_some() { + doc = doc.append(Doc::text("%")); + } + if let Some(type_token) = clause.type_token() { doc = doc - .append(Doc::text(token.text().to_string())) - .append(Doc::space()); - } else if token.kind() == SyntaxKind::WHITESPACE { - continue; - } else { - break; + .append(comments_before(type_token)) + .append(Doc::text("type")); } } + doc + } + ast::Type::TimeType(time_type) => { + let mut doc = build_setof(time_type.setof_token()); + if let Some(time_token) = time_type.time_token() { + doc = doc + .append(leading_comments_token(&time_token)) + .append(Doc::text("time")); + } + doc.append(build_type_precision( + time_type.l_paren_token(), + time_type.literal(), + time_type.r_paren_token(), + )) + .append(build_timezone(time_type.timezone())) + } + ast::Type::TimestampType(timestamp_type) => { + let mut doc = build_setof(timestamp_type.setof_token()); + if let Some(timestamp_token) = timestamp_type.timestamp_token() { + doc = doc + .append(leading_comments_token(×tamp_token)) + .append(Doc::text("timestamp")); + } + doc.append(build_type_precision( + timestamp_type.l_paren_token(), + timestamp_type.literal(), + timestamp_type.r_paren_token(), + )) + .append(build_timezone(timestamp_type.timezone())) + } + } +} + +fn build_setof<'a>(setof: Option) -> Doc<'a> { + match setof { + Some(_) => Doc::text("setof").append(Doc::space()), + None => Doc::nil(), + } +} + +fn build_array_bound<'a>(bound: &ast::ArrayBound) -> Doc<'a> { + let mut doc = Doc::text("["); + if let Some(expr) = bound.expr() { + doc = doc + .append(leading_comments(expr.syntax())) + .append(build_expr(expr)); + } + if let Some(r_brack) = bound.r_brack_token() { + doc = doc.append(comments_before(r_brack)); + } + doc.append(Doc::text("]")) +} + +fn build_type_args<'a>(arg_list: Option) -> Doc<'a> { + let Some(arg_list) = arg_list else { + return Doc::nil(); + }; + let args: Vec> = arg_list + .args() + .map(|arg| { + let mut doc = leading_comments(arg.syntax()); + if let Some(expr) = arg.expr() { + doc = doc.append(build_expr(expr)); + } + doc.append(trailing_comments(arg.syntax())) + }) + .collect(); + let mut doc = Doc::text("("); + if args.is_empty() { + if let Some(r_paren) = arg_list.r_paren_token() { + doc = doc.append(comments_before(r_paren)); + } + } else { + doc = doc.append(Doc::list( + Itertools::intersperse(args.into_iter(), Doc::text(",").append(Doc::space())).collect(), + )); + } + doc.append(Doc::text(")")) +} + +fn build_type_precision<'a>( + l_paren: Option, + literal: Option, + r_paren: Option, +) -> Doc<'a> { + let Some(l_paren) = l_paren else { + return Doc::nil(); + }; + let mut doc = comments_before(l_paren).append(Doc::text("(")); + if let Some(literal) = literal { + doc = doc + .append(leading_comments(literal.syntax())) + .append(build_literal(literal)); + } + if let Some(r_paren) = r_paren { + doc = doc.append(comments_before(r_paren)); + } + doc.append(Doc::text(")")) +} + +fn build_timezone<'a>(timezone: Option) -> Doc<'a> { + let Some(timezone) = timezone else { + return Doc::nil(); + }; + let doc = Doc::space().append(leading_comments(timezone.syntax())); + match timezone { + ast::Timezone::WithTimezone(with_timezone) => { + doc.append(build_keyword_node(with_timezone.syntax())) + } + ast::Timezone::WithoutTimezone(without_timezone) => { + doc.append(build_keyword_node(without_timezone.syntax())) + } + } +} + +fn build_interval_qualifier<'a>(qualifier: &ast::IntervalQualifier) -> Doc<'a> { + match qualifier { + ast::IntervalQualifier::IntervalSecond(second) => { + let mut doc = Doc::nil(); + if let Some(unit) = second + .day_token() + .or_else(|| second.hour_token()) + .or_else(|| second.minute_token()) + { + doc = doc + .append(Doc::text(unit.text().to_ascii_lowercase())) + .append(Doc::space()); + } + if let Some(to_token) = second.to_token() { + doc = doc + .append(leading_comments_token(&to_token)) + .append(Doc::text("to")) + .append(Doc::space()); + } + if let Some(second_token) = second.second_token() { + doc = doc + .append(leading_comments_token(&second_token)) + .append(Doc::text("second")); + } + doc.append(build_type_precision( + second.l_paren_token(), + second.literal(), + second.r_paren_token(), + )) + } + ast::IntervalQualifier::IntervalDay(day) => build_keyword_node(day.syntax()), + ast::IntervalQualifier::IntervalHour(hour) => build_keyword_node(hour.syntax()), + ast::IntervalQualifier::IntervalMinute(minute) => build_keyword_node(minute.syntax()), + ast::IntervalQualifier::IntervalMonth(month) => build_keyword_node(month.syntax()), + ast::IntervalQualifier::IntervalYear(year) => build_keyword_node(year.syntax()), + } +} + +fn comments_before<'a>(el: impl Into) -> Doc<'a> { + let mut doc = Doc::nil(); + for token in comment_tokens_before(el) { + doc = doc + .append(Doc::space()) + .append(Doc::text(token.text().to_string())); + if is_line_comment(&token) { + doc = doc.append(Doc::hard_line()); } } doc } +fn comment_tokens_before(el: impl Into) -> Vec { + let mut tokens: Vec = vec![]; + let mut curr = el.into().prev_sibling_or_token(); + while let Some(rowan::NodeOrToken::Token(token)) = curr { + match token.kind() { + SyntaxKind::COMMENT => tokens.push(token.clone()), + SyntaxKind::WHITESPACE => (), + _ => break, + } + curr = token.prev_sibling_or_token(); + } + tokens.reverse(); + tokens +} + +fn leading_comments_token<'a>(token: &SyntaxToken) -> Doc<'a> { + build_leading_comments(&comment_tokens_before(token.clone())) +} + fn is_line_comment(token: &SyntaxToken) -> bool { token.text().starts_with("--") } fn leading_comments<'a>(node: &SyntaxNode) -> Doc<'a> { - let mut docs: Vec> = vec![]; - for next in node.siblings_with_tokens(Direction::Prev).skip(1) { - match next { - rowan::NodeOrToken::Node(_node) => { - break; - } - rowan::NodeOrToken::Token(token) => { - if token.kind() == SyntaxKind::COMMENT { - docs.push(Doc::text(token.text().to_string()).append( - if is_line_comment(&token) { - Doc::hard_line() - } else { - Doc::space() - }, - )); - } else if token.kind() == SyntaxKind::WHITESPACE { - continue; - } else { - break; - } - } - } + build_leading_comments(&comment_tokens_before(node.clone())) +} + +fn build_leading_comments<'a>(tokens: &[SyntaxToken]) -> Doc<'a> { + let mut doc = Doc::nil(); + for token in tokens { + doc = doc.append(Doc::text(token.text().to_string())); + doc = doc.append(if is_line_comment(token) { + Doc::hard_line() + } else { + Doc::space() + }); } - docs.reverse(); - Doc::list(docs) + doc } fn trailing_comments<'a>(node: &SyntaxNode) -> Doc<'a> { diff --git a/crates/squawk_fmt/tests/after/create_table.snap b/crates/squawk_fmt/tests/after/create_table.snap index 404a8e15..69f9e429 100644 --- a/crates/squawk_fmt/tests/after/create_table.snap +++ b/crates/squawk_fmt/tests/after/create_table.snap @@ -44,3 +44,27 @@ create table t(U&"c!006fl" uescape /* c */ '!' int); create table foo /*a*/ /*b*/.bar(id int); create table foo -- a line comment .bar(id int); + +-- comments between table args +create table t( + a int /*x*/, + b int +); +create table t( + /*a*/ a int, + /*b*/ b int /*c*/ +); +create table t( + a int -- one + , + b int +); +create table t( + a int, + b int -- two + +); + +-- line comment before the semicolon +create table t(a int)-- one +; diff --git a/crates/squawk_fmt/tests/after/select_comments.snap b/crates/squawk_fmt/tests/after/select_comments.snap index 9610518f..b4a5e66c 100644 --- a/crates/squawk_fmt/tests/after/select_comments.snap +++ b/crates/squawk_fmt/tests/after/select_comments.snap @@ -21,3 +21,19 @@ select 1 -- a line comment , 2; + +-- line comments before the semicolon +select + 1-- a +; +select + 1-- b +-- c +; +select + 1, + 2-- d +; +select + 1/*e*/-- f +; diff --git a/crates/squawk_fmt/tests/after/types.snap b/crates/squawk_fmt/tests/after/types.snap new file mode 100644 index 00000000..bb7da10c --- /dev/null +++ b/crates/squawk_fmt/tests/after/types.snap @@ -0,0 +1,137 @@ +--- +source: crates/squawk_fmt/tests/tests.rs +input_file: crates/squawk_fmt/tests/before/types.sql +--- +-- keywords in types are lowercased +create table t( + a int, + b numeric(10, 2), + c pg_catalog.varchar(10), + d "MyType" +); + +-- character types +create table t( + a varchar(10), + b character varying, + c national char varying(2), + d nchar(3), + e character(4), + f national character +); + +-- bit & double types +create table t( + a bit, + b bit(4), + c bit varying, + d bit varying(3), + e double precision +); + +-- date & time types +create table t( + a time, + b time(3) with time zone, + c timestamp without time zone, + d timestamp(6) with time zone +); + +-- interval types +create table t( + a interval, + b interval(6), + c interval year, + d interval year to month, + e interval day to hour, + f interval hour to minute, + g interval second(2), + h interval day to second(3) +); + +-- array types +create table t( + a int[], + b text array, + c text array[4], + d int[][3], + e int[10][10] +); + +-- types in casts +select + 1::int8, + cast(1 as int8), + treat(2 as bigint), + pg_catalog.varchar(10) 'foo'; +select + '1'::interval day to second(3), + 'a'::character varying(2), + now()::timestamp(3) with time zone; +select 1::setof int; + +-- comments inside types +create table t( + a national /*a*/ char /*b*/ varying /*c*/(2), + b int /*d*/[], + c interval /*e*/ day to /*f*/ second /*g*/(3), + d numeric /*h*/(/*i*/ 10 /*j*/, /*k*/ 2 /*l*/), + e time /*m*/(/*n*/ 3 /*o*/) /*p*/ with /*q*/ time /*r*/ zone, + f int[ /*s*/][/*t*/ 3 /*u*/], + g timestamp /*v*/ without /*w*/ time /*x*/ zone, + h timestamp with /*y*/ time /*z*/ zone, + i double /*aa*/ precision, + j character /*bb*/ varying, + k national /*cc*/ character, + l bit /*dd*/ varying /*ee*/(/*ff*/ 3 /*gg*/), + m interval year /*hh*/ to /*ii*/ month +); +select 1::setof /*a*/ int, 2::pg_catalog /*b*/./*c*/ int4; + +-- line comments inside types +create table t( + a int -- one + [], + b numeric -- two + (10), + c text -- three + array[2], + d time -- four + (3) with time zone, + e interval day to -- five + second(3) +); + +-- interval literals keep their qualifier +select + interval '1' day to second(3), + interval '2' year to month, + interval(4) '3'; + +-- comments around casts +select 1 /*a*/::/*b*/ int8; +select cast /*c*/(/*d*/ 1 /*e*/ as /*f*/ int8 /*g*/); +select treat /*h*/(2 as /*i*/ bigint); +select pg_catalog.varchar(10) /*j*/ 'foo'; +select interval '4' /*k*/ year to month; + +-- line comments before a type's trailing keywords +create table t( + a double -- one + precision, + b bit -- two + varying(3), + c national -- three + character varying(2), + d varchar -- four + (10), + e timestamp without -- five + time zone, + f time(3) with time -- six + zone, + g interval year -- seven + to month +); +select + 1::double -- eight + precision; diff --git a/crates/squawk_fmt/tests/before/create_table.sql b/crates/squawk_fmt/tests/before/create_table.sql index 4e60dd88..52bcaff2 100644 --- a/crates/squawk_fmt/tests/before/create_table.sql +++ b/crates/squawk_fmt/tests/before/create_table.sql @@ -27,3 +27,15 @@ create table t (U&"c!006fl" uescape /* c */ '!' int); create table foo/*a*//*b*/.bar (id int); create table foo -- a line comment . bar (id int); + +-- comments between table args +create table t (a int /*x*/, b int); +create table t (/*a*/ a int, /*b*/ b int /*c*/); +create table t (a int -- one +, b int); +create table t (a int, b int -- two +); + +-- line comment before the semicolon +create table t (a int) -- one +; diff --git a/crates/squawk_fmt/tests/before/select_comments.sql b/crates/squawk_fmt/tests/before/select_comments.sql index b0cb1bb3..09ba6c47 100644 --- a/crates/squawk_fmt/tests/before/select_comments.sql +++ b/crates/squawk_fmt/tests/before/select_comments.sql @@ -15,3 +15,14 @@ select 1 /*a*/ /*b*/ group by 1; select 1 -- a line comment , 2; + +-- line comments before the semicolon +select 1 -- a +; +select 1 -- b +-- c +; +select 1, 2 -- d +; +select 1 /*e*/ -- f +; diff --git a/crates/squawk_fmt/tests/before/types.sql b/crates/squawk_fmt/tests/before/types.sql new file mode 100644 index 00000000..2abe5ecb --- /dev/null +++ b/crates/squawk_fmt/tests/before/types.sql @@ -0,0 +1,99 @@ +-- keywords in types are lowercased +create table t (a INT, b NUMERIC(10, 2), c PG_CATALOG.VARCHAR(10), d "MyType"); + +-- character types +create table t ( + a VARCHAR(10), + b CHARACTER VARYING, + c NATIONAL CHAR VARYING (2), + d NCHAR(3), + e CHARACTER(4), + f NATIONAL CHARACTER +); + +-- bit & double types +create table t (a BIT, b BIT(4), c BIT VARYING, d BIT VARYING(3), e DOUBLE PRECISION); + +-- date & time types +create table t ( + a TIME, + b TIME(3) WITH TIME ZONE, + c TIMESTAMP WITHOUT TIME ZONE, + d TIMESTAMP(6) WITH TIME ZONE +); + +-- interval types +create table t ( + a INTERVAL, + b INTERVAL(6), + c INTERVAL YEAR, + d INTERVAL YEAR TO MONTH, + e INTERVAL DAY TO HOUR, + f INTERVAL HOUR TO MINUTE, + g INTERVAL SECOND(2), + h INTERVAL DAY TO SECOND(3) +); + +-- array types +create table t (a INT[], b TEXT ARRAY, c TEXT ARRAY[4], d INT[][3], e INT[10][10]); + +-- types in casts +select 1::int8, cast(1 as INT8), treat(2 as BIGINT), pg_catalog.varchar(10) 'foo'; +select '1'::INTERVAL DAY TO SECOND(3), 'a'::CHARACTER VARYING(2), now()::TIMESTAMP(3) WITH TIME ZONE; +select 1::SETOF INT; + +-- comments inside types +create table t ( + a NATIONAL /*a*/ CHAR /*b*/ VARYING /*c*/ (2), + b INT /*d*/ [], + c INTERVAL /*e*/ DAY TO /*f*/ SECOND /*g*/ (3), + d NUMERIC /*h*/ ( /*i*/ 10 /*j*/ , /*k*/ 2 /*l*/ ), + e TIME /*m*/ ( /*n*/ 3 /*o*/ ) /*p*/ WITH /*q*/ TIME /*r*/ ZONE, + f INT [ /*s*/ ] [ /*t*/ 3 /*u*/ ], + g TIMESTAMP /*v*/ WITHOUT /*w*/ TIME /*x*/ ZONE, + h TIMESTAMP WITH /*y*/ TIME /*z*/ ZONE, + i DOUBLE /*aa*/ PRECISION, + j CHARACTER /*bb*/ VARYING, + k NATIONAL /*cc*/ CHARACTER, + l BIT /*dd*/ VARYING /*ee*/ ( /*ff*/ 3 /*gg*/ ), + m INTERVAL YEAR /*hh*/ TO /*ii*/ MONTH +); +select 1::SETOF /*a*/ INT, 2::pg_catalog /*b*/ . /*c*/ int4; + +-- line comments inside types +create table t (a INT -- one +[], b NUMERIC -- two +(10), c TEXT -- three +ARRAY[2], d TIME -- four +(3) WITH TIME ZONE, e INTERVAL DAY TO -- five +SECOND(3)); + +-- interval literals keep their qualifier +select interval '1' day to second(3), interval '2' year to month, interval(4) '3'; + +-- comments around casts +select 1 /*a*/ :: /*b*/ INT8; +select cast /*c*/ ( /*d*/ 1 /*e*/ as /*f*/ INT8 /*g*/ ); +select treat /*h*/ ( 2 as /*i*/ BIGINT ); +select pg_catalog.varchar(10) /*j*/ 'foo'; +select interval '4' /*k*/ year to month; + +-- line comments before a type's trailing keywords +create table t ( + a DOUBLE -- one + PRECISION, + b BIT -- two + VARYING(3), + c NATIONAL -- three + CHARACTER VARYING(2), + d VARCHAR -- four + (10), + e TIMESTAMP WITHOUT -- five + TIME ZONE, + f TIME(3) WITH TIME -- six + ZONE, + g INTERVAL YEAR -- seven + TO MONTH +); +select 1::DOUBLE -- eight +PRECISION; diff --git a/crates/squawk_parser/src/grammar.rs b/crates/squawk_parser/src/grammar.rs index 00cc720e..f73ab672 100644 --- a/crates/squawk_parser/src/grammar.rs +++ b/crates/squawk_parser/src/grammar.rs @@ -2299,7 +2299,8 @@ fn opt_type_name_with(p: &mut Parser<'_>, type_args_enabled: bool) -> Option { p.bump(INTERVAL_KW); - opt_interval_trailing(p); + let has_precision = opt_interval_precision(p); + opt_interval_trailing(p, has_precision); INTERVAL_TYPE } DOUBLE_KW if p.nth_at(1, PRECISION_KW) => { @@ -2419,6 +2420,20 @@ fn opt_all_or_distinct(p: &mut Parser<'_>) { m.complete(p, kind); } +const INTERVAL_QUALIFIER_FIRST: TokenSet = + TokenSet::new(&[DAY_KW, HOUR_KW, MINUTE_KW, MONTH_KW, SECOND_KW, YEAR_KW]); + +fn opt_interval_precision(p: &mut Parser<'_>) -> bool { + if !p.eat(L_PAREN) { + return false; + } + if opt_numeric_literal(p).is_none() { + p.error("expected an integer"); + } + p.expect(R_PAREN); + true +} + fn interval_second(p: &mut Parser<'_>) { p.expect(SECOND_KW); if p.eat(L_PAREN) { @@ -2429,7 +2444,10 @@ fn interval_second(p: &mut Parser<'_>) { } } -fn opt_interval_trailing(p: &mut Parser<'_>) { +fn opt_interval_trailing(p: &mut Parser<'_>, has_precision: bool) { + if has_precision && p.at_ts(INTERVAL_QUALIFIER_FIRST) { + p.error("unexpected interval qualifier"); + } let m = p.start(); let kind = match (p.current(), p.nth(1)) { (DAY_KW, TO_KW) => { @@ -2500,14 +2518,6 @@ fn opt_interval_trailing(p: &mut Parser<'_>) { interval_second(p); INTERVAL_SECOND } - (L_PAREN, _) => { - p.bump(L_PAREN); - if opt_numeric_literal(p).is_none() { - p.error("expected number") - } - p.expect(R_PAREN); - INTERVAL_SECOND - } _ => { m.abandon(p); return; @@ -2521,6 +2531,7 @@ fn name_ref_(p: &mut Parser<'_>) -> Option { return None; } let m = p.start(); + let mut has_interval_precision = false; let kind = match p.current() { TIMESTAMP_KW | TIME_KW => { let kind = if p.eat(TIMESTAMP_KW) { @@ -2558,7 +2569,8 @@ fn name_ref_(p: &mut Parser<'_>) -> Option { CHARACTER_KW | CHAR_KW | NCHAR_KW | VARCHAR_KW => char_type(p), INTERVAL_KW => { p.bump(INTERVAL_KW); - opt_interval_trailing(p); + has_interval_precision = opt_interval_precision(p); + opt_interval_trailing(p, has_interval_precision); INTERVAL_TYPE } _ => { @@ -2593,7 +2605,7 @@ fn name_ref_(p: &mut Parser<'_>) -> Option { string_literal(p); if kind == INTERVAL_TYPE { - opt_interval_trailing(p); + opt_interval_trailing(p, has_interval_precision); } Some(cm.precede(p).complete(p, CAST_EXPR)) } else { @@ -12220,7 +12232,7 @@ fn operator_class_option(p: &mut Parser<'_>) { OPERATOR_KW => { p.bump(OPERATOR_KW); if opt_numeric_literal(p).is_none() { - p.error("expected number"); + p.error("expected an integer"); } operator(p); if p.eat(L_PAREN) { @@ -12248,7 +12260,7 @@ fn operator_class_option(p: &mut Parser<'_>) { FUNCTION_KW => { p.bump(FUNCTION_KW); if opt_numeric_literal(p).is_none() { - p.error("expected number"); + p.error("expected an integer"); } opt_param_list(p, ParamKind::TypeOnly); function_sig(p); @@ -12279,7 +12291,7 @@ fn operator_drop_class_option(p: &mut Parser<'_>) { let m = p.start(); p.bump_any(); if opt_numeric_literal(p).is_none() { - p.error("expected number"); + p.error("expected an integer"); } opt_param_list(p, ParamKind::TypeOnly); m.complete(p, kind); diff --git a/crates/squawk_parser/tests/data/err/interval.sql b/crates/squawk_parser/tests/data/err/interval.sql new file mode 100644 index 00000000..43bb69a2 --- /dev/null +++ b/crates/squawk_parser/tests/data/err/interval.sql @@ -0,0 +1,2 @@ +select 1::interval(6) day to second(3); +select interval(6) '1 day' day to second; diff --git a/crates/squawk_parser/tests/snapshots/tests__interval_err.snap b/crates/squawk_parser/tests/snapshots/tests__interval_err.snap new file mode 100644 index 00000000..19411f30 --- /dev/null +++ b/crates/squawk_parser/tests/snapshots/tests__interval_err.snap @@ -0,0 +1,70 @@ +--- +source: crates/squawk_parser/tests/tests.rs +input_file: crates/squawk_parser/tests/data/err/interval.sql +--- +SOURCE_FILE + SELECT + SELECT_CLAUSE + SELECT_KW "select" + WHITESPACE " " + TARGET_LIST + TARGET + CAST_EXPR + LITERAL + INT_NUMBER "1" + COLON_COLON + COLON ":" + COLON ":" + INTERVAL_TYPE + INTERVAL_KW "interval" + L_PAREN "(" + LITERAL + INT_NUMBER "6" + R_PAREN ")" + WHITESPACE " " + INTERVAL_SECOND + DAY_KW "day" + WHITESPACE " " + TO_KW "to" + WHITESPACE " " + SECOND_KW "second" + L_PAREN "(" + LITERAL + INT_NUMBER "3" + R_PAREN ")" + SEMICOLON ";" + WHITESPACE "\n" + SELECT + SELECT_CLAUSE + SELECT_KW "select" + WHITESPACE " " + TARGET_LIST + TARGET + CAST_EXPR + INTERVAL_TYPE + INTERVAL_KW "interval" + L_PAREN "(" + LITERAL + INT_NUMBER "6" + R_PAREN ")" + WHITESPACE " " + LITERAL + STRING "'1 day'" + WHITESPACE " " + INTERVAL_SECOND + DAY_KW "day" + WHITESPACE " " + TO_KW "to" + WHITESPACE " " + SECOND_KW "second" + SEMICOLON ";" + WHITESPACE "\n" +--- +error[syntax-error]: unexpected interval qualifier + ╭▸ +1 │ select 1::interval(6) day to second(3); + ╰╴ ━ +error[syntax-error]: unexpected interval qualifier + ╭▸ +2 │ select interval(6) '1 day' day to second; + ╰╴ ━ diff --git a/crates/squawk_parser/tests/snapshots/tests__select_casts_ok.snap b/crates/squawk_parser/tests/snapshots/tests__select_casts_ok.snap index ef196cdd..9318b753 100644 --- a/crates/squawk_parser/tests/snapshots/tests__select_casts_ok.snap +++ b/crates/squawk_parser/tests/snapshots/tests__select_casts_ok.snap @@ -1199,11 +1199,10 @@ SOURCE_FILE COLON ":" INTERVAL_TYPE INTERVAL_KW "interval" - INTERVAL_SECOND - L_PAREN "(" - LITERAL - INT_NUMBER "0" - R_PAREN ")" + L_PAREN "(" + LITERAL + INT_NUMBER "0" + R_PAREN ")" SEMICOLON ";" WHITESPACE "\n\n" SELECT @@ -1561,11 +1560,10 @@ SOURCE_FILE COLON ":" INTERVAL_TYPE INTERVAL_KW "interval" - INTERVAL_SECOND - L_PAREN "(" - LITERAL - INT_NUMBER "10" - R_PAREN ")" + L_PAREN "(" + LITERAL + INT_NUMBER "10" + R_PAREN ")" SEMICOLON ";" WHITESPACE "\n\n" COMMENT "-- JsonType" diff --git a/crates/squawk_syntax/src/ast/generated/nodes.rs b/crates/squawk_syntax/src/ast/generated/nodes.rs index 1140ff74..1a399f52 100644 --- a/crates/squawk_syntax/src/ast/generated/nodes.rs +++ b/crates/squawk_syntax/src/ast/generated/nodes.rs @@ -2559,10 +2559,6 @@ pub struct ArrayType { pub(crate) syntax: SyntaxNode, } impl ArrayType { - #[inline] - pub fn array_bound(&self) -> Option { - support::child(&self.syntax) - } #[inline] pub fn array_bounds(&self) -> AstChildren { support::children(&self.syntax) @@ -13094,6 +13090,18 @@ impl IntervalType { support::child(&self.syntax) } #[inline] + pub fn literal(&self) -> Option { + support::child(&self.syntax) + } + #[inline] + pub fn l_paren_token(&self) -> Option { + support::token(&self.syntax, SyntaxKind::L_PAREN) + } + #[inline] + pub fn r_paren_token(&self) -> Option { + support::token(&self.syntax, SyntaxKind::R_PAREN) + } + #[inline] pub fn interval_token(&self) -> Option { support::token(&self.syntax, SyntaxKind::INTERVAL_KW) } diff --git a/crates/squawk_syntax/src/postgresql.ungram b/crates/squawk_syntax/src/postgresql.ungram index 8b9e414f..0304582a 100644 --- a/crates/squawk_syntax/src/postgresql.ungram +++ b/crates/squawk_syntax/src/postgresql.ungram @@ -817,7 +817,7 @@ TimestampType = IntervalType = 'setof'? 'interval' - IntervalQualifier? + ('(' Literal ')' | IntervalQualifier)? IntervalQualifier = IntervalYear @@ -846,8 +846,7 @@ IntervalMinute = | ('day' | 'hour') 'to' 'minute' IntervalSecond = - '(' Literal ')' -| (('day' | 'hour' | 'minute') 'to')? 'second' ('(' Literal ')')? + (('day' | 'hour' | 'minute') 'to')? 'second' ('(' Literal ')')? Type = ArrayType