diff --git a/crates/squawk_ide/src/binder.rs b/crates/squawk_ide/src/binder.rs index 1fa22e1e..649d57cb 100644 --- a/crates/squawk_ide/src/binder.rs +++ b/crates/squawk_ide/src/binder.rs @@ -6,7 +6,6 @@ use rustc_hash::FxHashMap; use smallvec::SmallVec; use squawk_syntax::{SyntaxNodePtr, ast, ast::AstNode}; -use crate::literals::literal_string_value; use crate::name::{AsName, schema_and_func_name}; use crate::scope::Scope; use crate::symbols::{Name, Schema, Symbol, SymbolKind}; @@ -1775,7 +1774,7 @@ fn bind_prepare_transaction(b: &mut Binder, prepare: ast::PrepareTransaction) { let Some(literal) = prepare.literal() else { return; }; - let Some(transaction_id) = literal_string_value(&literal) else { + let Some(transaction_id) = literal.string_value() else { return; }; @@ -1787,7 +1786,7 @@ fn bind_prepared_transaction_ref(b: &mut Binder, literal: Option) let Some(literal) = literal else { return; }; - let Some(transaction_id) = literal_string_value(&literal) else { + let Some(transaction_id) = literal.string_value() else { return; }; @@ -2063,7 +2062,7 @@ fn bind_select_set_config(b: &mut Binder, select: &ast::Select, position: TextSi let Some(ast::Expr::Literal(setting_name_literal)) = args.next().and_then(|a| a.expr()) else { return; }; - let Some(setting_name) = literal_string_value(&setting_name_literal) else { + let Some(setting_name) = setting_name_literal.string_value() else { return; }; if !setting_name.eq_ignore_ascii_case("search_path") { @@ -2073,7 +2072,7 @@ fn bind_select_set_config(b: &mut Binder, select: &ast::Select, position: TextSi let Some(ast::Expr::Literal(new_value_literal)) = args.next().and_then(|a| a.expr()) else { return; }; - let Some(new_value) = literal_string_value(&new_value_literal) else { + let Some(new_value) = new_value_literal.string_value() else { return; }; diff --git a/crates/squawk_ide/src/hover.rs b/crates/squawk_ide/src/hover.rs index c071485d..a3583ac2 100644 --- a/crates/squawk_ide/src/hover.rs +++ b/crates/squawk_ide/src/hover.rs @@ -6,7 +6,6 @@ use crate::file::InFile; use crate::infer::{infer_type_from_expr, infer_type_from_literal}; use crate::literals::binary_digits_to_hex; use crate::literals::hex_digits_to_binary; -use crate::literals::literal_string_value; use crate::location::{Location, LocationKind}; use crate::name; use crate::offsets::token_from_offset; @@ -275,7 +274,7 @@ fn hover_literal(literal: &ast::Literal) -> Option { return None; } - let value = literal_string_value(literal)?; + let value = literal.string_value()?; let ty = infer_type_from_literal(literal)?.to_string(); let comment = match kind { diff --git a/crates/squawk_ide/src/literals.rs b/crates/squawk_ide/src/literals.rs index c48dcec8..b433defb 100644 --- a/crates/squawk_ide/src/literals.rs +++ b/crates/squawk_ide/src/literals.rs @@ -1,10 +1,3 @@ -use squawk_syntax::{ - SyntaxKind, - ast::{self, AstNode}, - quote::{strip_dollar_quotes, strip_prefixed_quotes, strip_quotes, strip_unicode_esc_prefix}, - unescape::{decode_esc_string, decode_plain_string, decode_unicode_esc_string, uescape_char}, -}; - #[derive(Clone, Copy, PartialEq, Eq)] pub(crate) enum IntegerRadix { Binary, @@ -86,88 +79,3 @@ pub(crate) fn hex_digits_to_binary(digits: &str) -> Option { Some(out) } - -#[derive(Clone, Copy, PartialEq, Eq)] -enum StringDecoding { - BitOrByte, - EscString, - UnicodeEscString, -} - -pub(crate) fn literal_string_value(literal: &ast::Literal) -> Option { - let escape_char = unicode_escape_char(literal); - let mut out = String::with_capacity(literal.syntax().text().len().into()); - let mut decoding: Option = None; - - for element in literal.syntax().children_with_tokens() { - let Some(token) = element.into_token() else { - continue; - }; - match token.kind() { - SyntaxKind::ESC_STRING => { - let inner = strip_prefixed_quotes(token.text(), ['e', 'E'])?; - decode_esc_string(inner, &mut out); - decoding = Some(StringDecoding::EscString); - } - SyntaxKind::UNICODE_ESC_STRING => { - let inner = strip_unicode_esc_prefix(token.text())?; - decode_unicode_esc_string(inner, escape_char, &mut out); - decoding = Some(StringDecoding::UnicodeEscString); - } - SyntaxKind::BIT_STRING => { - let inner = strip_prefixed_quotes(token.text(), ['b', 'B'])?; - out.push_str(inner); - decoding = Some(StringDecoding::BitOrByte); - } - SyntaxKind::BYTE_STRING => { - let inner = strip_prefixed_quotes(token.text(), ['x', 'X'])?; - out.push_str(inner); - decoding = Some(StringDecoding::BitOrByte); - } - SyntaxKind::DOLLAR_QUOTED_STRING => { - let inner = strip_dollar_quotes(token.text())?; - out.push_str(inner); - return Some(out); - } - SyntaxKind::NATIONAL_STRING => { - let inner = strip_prefixed_quotes(token.text(), ['n', 'N'])?; - decode_plain_string(inner, &mut out); - } - SyntaxKind::STRING => { - let inner = strip_quotes(token.text())?; - match decoding { - Some(StringDecoding::EscString) => decode_esc_string(inner, &mut out), - Some(StringDecoding::UnicodeEscString) => { - decode_unicode_esc_string(inner, escape_char, &mut out) - } - Some(StringDecoding::BitOrByte) => out.push_str(inner), - None => decode_plain_string(inner, &mut out), - } - } - SyntaxKind::UESCAPE_KW => break, - _ => (), - } - } - - Some(out) -} - -fn unicode_escape_char(literal: &ast::Literal) -> char { - let mut seen_uescape = false; - for element in literal.syntax().children_with_tokens() { - let Some(token) = element.into_token() else { - continue; - }; - match token.kind() { - SyntaxKind::UESCAPE_KW => seen_uescape = true, - SyntaxKind::STRING if seen_uescape => { - if let Some(ch) = uescape_char(token.text()) { - return ch; - } - return '\\'; - } - _ => (), - } - } - '\\' -} diff --git a/crates/squawk_lexer/src/lib.rs b/crates/squawk_lexer/src/lib.rs index b8805c91..23b4d546 100644 --- a/crates/squawk_lexer/src/lib.rs +++ b/crates/squawk_lexer/src/lib.rs @@ -106,6 +106,11 @@ impl Cursor<'_> { TokenKind::Literal { kind: literal_kind } } '.' => match self.first() { + // https://github.com/postgres/postgres/blob/db0c96cc18aec417101e37e59fcc53d4bf647915/src/backend/parser/scan.l#L353 + '.' => { + self.bump(); + TokenKind::DotDot + } '0'..='9' => { let literal_kind = self.number('.'); TokenKind::Literal { kind: literal_kind } @@ -302,7 +307,9 @@ impl Cursor<'_> { }; match self.first() { - '.' => { + // `1..10` lexes as 1, .., 10 rather than `1.` followed by `.10` + // https://github.com/postgres/postgres/blob/db0c96cc18aec417101e37e59fcc53d4bf647915/src/backend/parser/scan.l#L410 + '.' if self.second() != '.' => { self.bump(); self.eat_fractional() } @@ -794,6 +801,63 @@ $foo$hello$world$bar$ "#) } + #[test] + fn dot_dot_is_its_own_token() { + assert_debug_snapshot!(lex("1..10 1 .. 10 1.5..2 1...2 .. a[1..2]"), @r#" + [ + "1" @ Literal { kind: Int { base: Decimal, empty_int: false, trailing_junk_start: 1 } }, + ".." @ DotDot, + "10" @ Literal { kind: Int { base: Decimal, empty_int: false, trailing_junk_start: 2 } }, + " " @ Whitespace, + "1" @ Literal { kind: Int { base: Decimal, empty_int: false, trailing_junk_start: 1 } }, + " " @ Whitespace, + ".." @ DotDot, + " " @ Whitespace, + "10" @ Literal { kind: Int { base: Decimal, empty_int: false, trailing_junk_start: 2 } }, + " " @ Whitespace, + "1.5" @ Literal { kind: Numeric { empty_exponent_start: None, trailing_junk_start: 3 } }, + ".." @ DotDot, + "2" @ Literal { kind: Int { base: Decimal, empty_int: false, trailing_junk_start: 1 } }, + " " @ Whitespace, + "1" @ Literal { kind: Int { base: Decimal, empty_int: false, trailing_junk_start: 1 } }, + ".." @ DotDot, + ".2" @ Literal { kind: Numeric { empty_exponent_start: None, trailing_junk_start: 2 } }, + " " @ Whitespace, + ".." @ DotDot, + " " @ Whitespace, + "a" @ Ident, + "[" @ OpenBracket, + "1" @ Literal { kind: Int { base: Decimal, empty_int: false, trailing_junk_start: 1 } }, + ".." @ DotDot, + "2" @ Literal { kind: Int { base: Decimal, empty_int: false, trailing_junk_start: 1 } }, + "]" @ CloseBracket, + ] + "#); + } + + #[test] + fn dot_dot_does_not_disturb_neighboring_numerics() { + assert_debug_snapshot!(lex("1. .10 0 . .5 a.b 1.5"), @r#" + [ + "1." @ Literal { kind: Numeric { empty_exponent_start: None, trailing_junk_start: 2 } }, + " " @ Whitespace, + ".10" @ Literal { kind: Numeric { empty_exponent_start: None, trailing_junk_start: 3 } }, + " " @ Whitespace, + "0" @ Literal { kind: Int { base: Decimal, empty_int: false, trailing_junk_start: 1 } }, + " " @ Whitespace, + "." @ Dot, + " " @ Whitespace, + ".5" @ Literal { kind: Numeric { empty_exponent_start: None, trailing_junk_start: 2 } }, + " " @ Whitespace, + "a" @ Ident, + "." @ Dot, + "b" @ Ident, + " " @ Whitespace, + "1.5" @ Literal { kind: Numeric { empty_exponent_start: None, trailing_junk_start: 3 } }, + ] + "#); + } + #[test] fn numeric_exponent_underscore_after_sign() { assert_debug_snapshot!(lex("1e+_2 1e-_2 1.0e+_2 .1e+_2"), @r#" diff --git a/crates/squawk_lexer/src/token.rs b/crates/squawk_lexer/src/token.rs index c058f136..87bbea6a 100644 --- a/crates/squawk_lexer/src/token.rs +++ b/crates/squawk_lexer/src/token.rs @@ -35,6 +35,8 @@ pub enum TokenKind { Colon, /// `.` Dot, + /// `..` + DotDot, /// `=` Eq, /// `>` diff --git a/crates/squawk_parser/src/generated/syntax_kind.rs b/crates/squawk_parser/src/generated/syntax_kind.rs index f4a179f2..dd613d90 100644 --- a/crates/squawk_parser/src/generated/syntax_kind.rs +++ b/crates/squawk_parser/src/generated/syntax_kind.rs @@ -20,9 +20,11 @@ pub enum SyntaxKind { L_CURLY, R_CURLY, L_ANGLE, + LESS_LESS, LTEQ, NEQB, R_ANGLE, + GREATER_GREATER, GTEQ, AT, POUND, @@ -37,6 +39,7 @@ pub enum SyntaxKind { PERCENT, UNDERSCORE, DOT, + DOT_DOT, COLON, COLON_EQ, EQ, @@ -556,6 +559,55 @@ pub enum SyntaxKind { YEAR_KW, YES_KW, ZONE_KW, + ALIAS_KW, + ASSERT_KW, + COLUMN_NAME_KW, + CONSTANT_KW, + CONSTRAINT_NAME_KW, + DATATYPE_KW, + DEBUG_KW, + DETAIL_KW, + DIAGNOSTICS_KW, + DUMP_KW, + ELSEIF_KW, + ELSIF_KW, + ERRCODE_KW, + EXCEPTION_KW, + EXIT_KW, + FOREACH_KW, + GET_KW, + HINT_KW, + INFO_KW, + LOG_KW, + LOOP_KW, + MESSAGE_KW, + MESSAGE_TEXT_KW, + NOTICE_KW, + OPEN_KW, + PERFORM_KW, + PG_CONTEXT_KW, + PG_DATATYPE_NAME_KW, + PG_EXCEPTION_CONTEXT_KW, + PG_EXCEPTION_DETAIL_KW, + PG_EXCEPTION_HINT_KW, + PG_ROUTINE_OID_KW, + PRINT_STRICT_PARAMS_KW, + QUERY_KW, + RAISE_KW, + RETURNED_SQLSTATE_KW, + REVERSE_KW, + ROW_COUNT_KW, + ROWTYPE_KW, + SCHEMA_NAME_KW, + SLICE_KW, + SQLSTATE_KW, + STACKED_KW, + TABLE_NAME_KW, + USE_COLUMN_KW, + USE_VARIABLE_KW, + VARIABLE_CONFLICT_KW, + WARNING_KW, + WHILE_KW, BIT_STRING, BYTE_STRING, DOLLAR_QUOTED_STRING, @@ -1451,6 +1503,8 @@ pub enum SyntaxKind { PERCENT_TYPE, PERCENT_TYPE_CLAUSE, PERIOD_COLUMN, + PLPGSQL, + PLPGSQL_NULL_STMT, POLICY, POLICY_COMMAND, POLICY_COMMAND_ALL, @@ -2964,4 +3018,109 @@ impl SyntaxKind { }; Some(kw) } + #[doc = r"PL/pgSQL keywords that aren't SQL keywords. These stay `IDENT` in the token stream so SQL is unaffected; the PL/pgSQL grammar matches on the contextual kind instead."] + pub(crate) fn from_contextual_keyword(ident: &str) -> Option { + let kw = if ident.eq_ignore_ascii_case("alias") { + SyntaxKind::ALIAS_KW + } else if ident.eq_ignore_ascii_case("assert") { + SyntaxKind::ASSERT_KW + } else if ident.eq_ignore_ascii_case("column_name") { + SyntaxKind::COLUMN_NAME_KW + } else if ident.eq_ignore_ascii_case("constant") { + SyntaxKind::CONSTANT_KW + } else if ident.eq_ignore_ascii_case("constraint_name") { + SyntaxKind::CONSTRAINT_NAME_KW + } else if ident.eq_ignore_ascii_case("datatype") { + SyntaxKind::DATATYPE_KW + } else if ident.eq_ignore_ascii_case("debug") { + SyntaxKind::DEBUG_KW + } else if ident.eq_ignore_ascii_case("detail") { + SyntaxKind::DETAIL_KW + } else if ident.eq_ignore_ascii_case("diagnostics") { + SyntaxKind::DIAGNOSTICS_KW + } else if ident.eq_ignore_ascii_case("dump") { + SyntaxKind::DUMP_KW + } else if ident.eq_ignore_ascii_case("elseif") { + SyntaxKind::ELSEIF_KW + } else if ident.eq_ignore_ascii_case("elsif") { + SyntaxKind::ELSIF_KW + } else if ident.eq_ignore_ascii_case("errcode") { + SyntaxKind::ERRCODE_KW + } else if ident.eq_ignore_ascii_case("exception") { + SyntaxKind::EXCEPTION_KW + } else if ident.eq_ignore_ascii_case("exit") { + SyntaxKind::EXIT_KW + } else if ident.eq_ignore_ascii_case("foreach") { + SyntaxKind::FOREACH_KW + } else if ident.eq_ignore_ascii_case("get") { + SyntaxKind::GET_KW + } else if ident.eq_ignore_ascii_case("hint") { + SyntaxKind::HINT_KW + } else if ident.eq_ignore_ascii_case("info") { + SyntaxKind::INFO_KW + } else if ident.eq_ignore_ascii_case("log") { + SyntaxKind::LOG_KW + } else if ident.eq_ignore_ascii_case("loop") { + SyntaxKind::LOOP_KW + } else if ident.eq_ignore_ascii_case("message") { + SyntaxKind::MESSAGE_KW + } else if ident.eq_ignore_ascii_case("message_text") { + SyntaxKind::MESSAGE_TEXT_KW + } else if ident.eq_ignore_ascii_case("notice") { + SyntaxKind::NOTICE_KW + } else if ident.eq_ignore_ascii_case("open") { + SyntaxKind::OPEN_KW + } else if ident.eq_ignore_ascii_case("perform") { + SyntaxKind::PERFORM_KW + } else if ident.eq_ignore_ascii_case("pg_context") { + SyntaxKind::PG_CONTEXT_KW + } else if ident.eq_ignore_ascii_case("pg_datatype_name") { + SyntaxKind::PG_DATATYPE_NAME_KW + } else if ident.eq_ignore_ascii_case("pg_exception_context") { + SyntaxKind::PG_EXCEPTION_CONTEXT_KW + } else if ident.eq_ignore_ascii_case("pg_exception_detail") { + SyntaxKind::PG_EXCEPTION_DETAIL_KW + } else if ident.eq_ignore_ascii_case("pg_exception_hint") { + SyntaxKind::PG_EXCEPTION_HINT_KW + } else if ident.eq_ignore_ascii_case("pg_routine_oid") { + SyntaxKind::PG_ROUTINE_OID_KW + } else if ident.eq_ignore_ascii_case("print_strict_params") { + SyntaxKind::PRINT_STRICT_PARAMS_KW + } else if ident.eq_ignore_ascii_case("query") { + SyntaxKind::QUERY_KW + } else if ident.eq_ignore_ascii_case("raise") { + SyntaxKind::RAISE_KW + } else if ident.eq_ignore_ascii_case("returned_sqlstate") { + SyntaxKind::RETURNED_SQLSTATE_KW + } else if ident.eq_ignore_ascii_case("reverse") { + SyntaxKind::REVERSE_KW + } else if ident.eq_ignore_ascii_case("row_count") { + SyntaxKind::ROW_COUNT_KW + } else if ident.eq_ignore_ascii_case("rowtype") { + SyntaxKind::ROWTYPE_KW + } else if ident.eq_ignore_ascii_case("schema_name") { + SyntaxKind::SCHEMA_NAME_KW + } else if ident.eq_ignore_ascii_case("slice") { + SyntaxKind::SLICE_KW + } else if ident.eq_ignore_ascii_case("sqlstate") { + SyntaxKind::SQLSTATE_KW + } else if ident.eq_ignore_ascii_case("stacked") { + SyntaxKind::STACKED_KW + } else if ident.eq_ignore_ascii_case("table_name") { + SyntaxKind::TABLE_NAME_KW + } else if ident.eq_ignore_ascii_case("use_column") { + SyntaxKind::USE_COLUMN_KW + } else if ident.eq_ignore_ascii_case("use_variable") { + SyntaxKind::USE_VARIABLE_KW + } else if ident.eq_ignore_ascii_case("variable_conflict") { + SyntaxKind::VARIABLE_CONFLICT_KW + } else if ident.eq_ignore_ascii_case("warning") { + SyntaxKind::WARNING_KW + } else if ident.eq_ignore_ascii_case("while") { + SyntaxKind::WHILE_KW + } else { + return None; + }; + Some(kw) + } } diff --git a/crates/squawk_parser/src/input.rs b/crates/squawk_parser/src/input.rs index 2cdc30fb..f9cfca9f 100644 --- a/crates/squawk_parser/src/input.rs +++ b/crates/squawk_parser/src/input.rs @@ -42,7 +42,6 @@ type bits = u64; pub struct Input { kind: Vec, joint: Vec, - // TODO: I think we can remove this contextual_kind: Vec, } @@ -52,10 +51,10 @@ impl Input { pub(crate) fn push(&mut self, kind: SyntaxKind) { self.push_impl(kind, SyntaxKind::EOF) } - // #[inline] - // pub(crate) fn push_ident(&mut self, contextual_kind: SyntaxKind) { - // self.push_impl(SyntaxKind::IDENT, contextual_kind) - // } + #[inline] + pub(crate) fn push_ident(&mut self, contextual_kind: SyntaxKind) { + self.push_impl(SyntaxKind::IDENT, contextual_kind) + } /// Sets jointness for the last token we've pushed. /// /// This is a separate API rather than an argument to the `push` to make it @@ -94,14 +93,14 @@ impl Input { pub(crate) fn kind(&self, idx: usize) -> SyntaxKind { self.kind.get(idx).copied().unwrap_or(SyntaxKind::EOF) } - // TODO: we may want to use this in the parser since we have a lot of - // "keywords" that are actually contextual. - // pub(crate) fn contextual_kind(&self, idx: usize) -> SyntaxKind { - // self.contextual_kind - // .get(idx) - // .copied() - // .unwrap_or(SyntaxKind::EOF) - // } + /// For an `IDENT`, the PL/pgSQL keyword it spells, or `IDENT` if it spells + /// none. `EOF` for every other token. + pub(crate) fn contextual_kind(&self, idx: usize) -> SyntaxKind { + self.contextual_kind + .get(idx) + .copied() + .unwrap_or(SyntaxKind::EOF) + } pub(crate) fn is_joint(&self, n: usize) -> bool { let (idx, b_idx) = self.bit_index(n); self.joint[idx] & 1 << b_idx != 0 diff --git a/crates/squawk_parser/src/lexed_str.rs b/crates/squawk_parser/src/lexed_str.rs index 9fa84f8a..d0b9563d 100644 --- a/crates/squawk_parser/src/lexed_str.rs +++ b/crates/squawk_parser/src/lexed_str.rs @@ -68,6 +68,10 @@ impl<'a> LexedStr<'a> { self.kind[i] } + pub(crate) fn text(&self, i: usize) -> &str { + self.range_text(i..i + 1) + } + pub(crate) fn range_text(&self, r: ops::Range) -> &str { assert!(r.start < r.end && r.end <= self.len()); let lo = self.start[r.start] as usize; @@ -206,6 +210,7 @@ impl<'a> Converter<'a> { squawk_lexer::TokenKind::Semi => SyntaxKind::SEMICOLON, squawk_lexer::TokenKind::Comma => SyntaxKind::COMMA, squawk_lexer::TokenKind::Dot => SyntaxKind::DOT, + squawk_lexer::TokenKind::DotDot => SyntaxKind::DOT_DOT, squawk_lexer::TokenKind::OpenParen => SyntaxKind::L_PAREN, squawk_lexer::TokenKind::CloseParen => SyntaxKind::R_PAREN, squawk_lexer::TokenKind::OpenBracket => SyntaxKind::L_BRACK, diff --git a/crates/squawk_parser/src/lib.rs b/crates/squawk_parser/src/lib.rs index 547122da..ea767f9f 100644 --- a/crates/squawk_parser/src/lib.rs +++ b/crates/squawk_parser/src/lib.rs @@ -35,6 +35,7 @@ mod grammar; mod input; mod lexed_str; mod output; +mod plpgsql_grammar; mod shortcuts; mod syntax_kind; mod token_set; @@ -151,14 +152,25 @@ impl CompletedMarker { } } -pub fn parse(input: &Input) -> Output { - let mut p = Parser::new(input); - // 2. lex tokens to event vec via parser aka actually run the parser code, - // it calls the methods on the parser to create a vector of events - grammar::entry_point(&mut p); - let events = p.finish(); - // 3. forward parents - event::process(events) +#[derive(Clone, Copy, Debug)] +pub enum EntryPoint { + SourceFile, + Plpgsql, +} + +impl EntryPoint { + pub fn parse(&self, input: &Input) -> Output { + let mut p = Parser::new(input); + // 2. lex tokens to event vec via parser aka actually run the parser code, + // it calls the methods on the parser to create a vector of events + match self { + Self::SourceFile => grammar::entry_point(&mut p), + Self::Plpgsql => plpgsql_grammar::plpgsql_entry_point(&mut p), + } + let events = p.finish(); + // 3. forward parents + event::process(events) + } } pub(crate) struct Parser<'t> { @@ -219,6 +231,8 @@ impl<'t> Parser<'t> { | SyntaxKind::NEQB | SyntaxKind::LTEQ | SyntaxKind::FAT_ARROW + | SyntaxKind::LESS_LESS + | SyntaxKind::GREATER_GREATER | SyntaxKind::GTEQ => 2, SyntaxKind::SIMILAR_TO => { let m = self.start(); @@ -570,6 +584,24 @@ impl<'t> Parser<'t> { self.do_bump(kind, 1); } + /// Advances the parser by one token, remapping its kind. + /// This is useful to create contextual keywords from + /// identifiers. + #[expect(dead_code, reason = "used by the PL/pgSQL grammar")] + pub(crate) fn bump_remap(&mut self, kind: SyntaxKind) { + if self.nth(0) == SyntaxKind::EOF { + // FIXME: panic!? + return; + } + self.do_bump(kind, 1); + } + + /// Checks if the nth token is contextual keyword `kw`. + #[expect(dead_code, reason = "used by the PL/pgSQL grammar")] + pub(crate) fn nth_at_contextual_kw(&self, n: usize, kw: SyntaxKind) -> bool { + self.inp.contextual_kind(self.pos + n) == kw + } + /// Consume the next token if it is `kind` or emit an error /// otherwise. pub(crate) fn expect(&mut self, kind: SyntaxKind) -> bool { @@ -883,6 +915,20 @@ impl<'t> Parser<'t> { SyntaxKind::EQ, TrivaBetween::NotAllowed, ), + // << used for PL/pgSQL + SyntaxKind::LESS_LESS => self.at_composite2( + n, + SyntaxKind::L_ANGLE, + SyntaxKind::L_ANGLE, + TrivaBetween::NotAllowed, + ), + // >> + SyntaxKind::GREATER_GREATER => self.at_composite2( + n, + SyntaxKind::R_ANGLE, + SyntaxKind::R_ANGLE, + TrivaBetween::NotAllowed, + ), SyntaxKind::CUSTOM_OP => { // TODO: is this right? if self.at_ts(OPERATOR_FIRST) { diff --git a/crates/squawk_parser/src/plpgsql_grammar.rs b/crates/squawk_parser/src/plpgsql_grammar.rs new file mode 100644 index 00000000..b9749ca4 --- /dev/null +++ b/crates/squawk_parser/src/plpgsql_grammar.rs @@ -0,0 +1,17 @@ +use crate::{Parser, syntax_kind::SyntaxKind::*}; + +pub(crate) fn plpgsql_entry_point(p: &mut Parser) { + let m = p.start(); + while !p.at(EOF) { + if p.at(NULL_KW) && p.nth_at(1, SEMICOLON) { + let m = p.start(); + p.bump(NULL_KW); + p.bump(SEMICOLON); + m.complete(p, PLPGSQL_NULL_STMT); + } else { + let kind = p.current(); + p.err_and_bump(&format!("expected a statement, found {kind:?}")); + } + } + m.complete(p, PLPGSQL); +} diff --git a/crates/squawk_parser/src/shortcuts.rs b/crates/squawk_parser/src/shortcuts.rs index 28efd2d9..1f8f693d 100644 --- a/crates/squawk_parser/src/shortcuts.rs +++ b/crates/squawk_parser/src/shortcuts.rs @@ -115,18 +115,17 @@ impl LexedStr<'_> { was_joint = false; // skip over any triva since the parser shouldn't have to deal // with it - } - // else if kind == SyntaxKind::IDENT { - // let token_text = self.text(i); - // let contextual_kw = - // SyntaxKind::from_contextual_keyword(token_text).unwrap_or(SyntaxKind::IDENT); - // res.push_ident(contextual_kw); - // } - else { + } else { if was_joint { res.was_joint(); } - res.push(kind); + if kind == SyntaxKind::IDENT { + let contextual_kind = SyntaxKind::from_contextual_keyword(self.text(i)) + .unwrap_or(SyntaxKind::IDENT); + res.push_ident(contextual_kind); + } else { + res.push(kind); + } was_joint = true; } } @@ -169,3 +168,52 @@ impl LexedStr<'_> { builder.pos == builder.lexed.len() } } + +#[cfg(test)] +mod tests { + use super::LexedStr; + use crate::SyntaxKind; + + fn kinds(text: &str) -> Vec<(SyntaxKind, SyntaxKind)> { + let lexed = LexedStr::new(text); + let input = lexed.to_input(); + (0..text.split_whitespace().count()) + .map(|i| (input.kind(i), input.contextual_kind(i))) + .collect() + } + + #[test] + fn plpgsql_keywords_stay_idents_with_a_contextual_kind() { + assert_eq!( + kinds("message raise elsif"), + vec![ + (SyntaxKind::IDENT, SyntaxKind::MESSAGE_KW), + (SyntaxKind::IDENT, SyntaxKind::RAISE_KW), + (SyntaxKind::IDENT, SyntaxKind::ELSIF_KW), + ] + ); + } + + #[test] + fn sql_keywords_are_not_contextual() { + assert_eq!( + kinds("select begin declare"), + vec![ + (SyntaxKind::SELECT_KW, SyntaxKind::EOF), + (SyntaxKind::BEGIN_KW, SyntaxKind::EOF), + (SyntaxKind::DECLARE_KW, SyntaxKind::EOF), + ] + ); + } + + #[test] + fn plain_idents_have_no_contextual_kind() { + assert_eq!( + kinds("foo bar"), + vec![ + (SyntaxKind::IDENT, SyntaxKind::IDENT), + (SyntaxKind::IDENT, SyntaxKind::IDENT), + ] + ); + } +} diff --git a/crates/squawk_parser/tests/data/err/select_literal.sql b/crates/squawk_parser/tests/data/err/select_literal.sql index 3e5eeeb1..fb9639ea 100644 --- a/crates/squawk_parser/tests/data/err/select_literal.sql +++ b/crates/squawk_parser/tests/data/err/select_literal.sql @@ -61,3 +61,10 @@ select ''::timestamp(1.5); select ''::time(-1); select timestamp(foo) ''; select time() ''; + +-- `..` lexes as a single token for PLPGSQL usage, but error in SQL +select 1..10; +select 1 .. 10; +select 1.5..2; +select 1...2; +select a[1..2]; diff --git a/crates/squawk_parser/tests/data/ok/plpgsql_keywords_as_idents.sql b/crates/squawk_parser/tests/data/ok/plpgsql_keywords_as_idents.sql new file mode 100644 index 00000000..6596f92d --- /dev/null +++ b/crates/squawk_parser/tests/data/ok/plpgsql_keywords_as_idents.sql @@ -0,0 +1,32 @@ +-- PL/pgSQL keywords live in their own namespace and stay plain identifiers in +-- SQL. See postgres's pl_reserved_kwlist.h / pl_unreserved_kwlist.h. +create table t ( + alias text, + assert text, + constant text, + datatype text, + detail text, + diagnostics text, + elsif text, + exception text, + hint text, + info text, + log text, + loop text, + message text, + notice text, + perform text, + query text, + raise text, + reverse text, + rowtype text, + slice text, + sqlstate text, + stacked text, + warning text, + while text +); + +select message, detail, hint from t where query = 'x'; + +create index on t (slice, reverse); diff --git a/crates/squawk_parser/tests/data/plpgsql/err/unrecognized_stmt.sql b/crates/squawk_parser/tests/data/plpgsql/err/unrecognized_stmt.sql new file mode 100644 index 00000000..117ecaad --- /dev/null +++ b/crates/squawk_parser/tests/data/plpgsql/err/unrecognized_stmt.sql @@ -0,0 +1 @@ +begin null; end diff --git a/crates/squawk_parser/tests/data/plpgsql/ok/null_stmt.sql b/crates/squawk_parser/tests/data/plpgsql/ok/null_stmt.sql new file mode 100644 index 00000000..ff464d15 --- /dev/null +++ b/crates/squawk_parser/tests/data/plpgsql/ok/null_stmt.sql @@ -0,0 +1 @@ +null; diff --git a/crates/squawk_parser/tests/snapshots/tests__plpgsql_keywords_as_idents_ok.snap b/crates/squawk_parser/tests/snapshots/tests__plpgsql_keywords_as_idents_ok.snap new file mode 100644 index 00000000..4345c9a4 --- /dev/null +++ b/crates/squawk_parser/tests/snapshots/tests__plpgsql_keywords_as_idents_ok.snap @@ -0,0 +1,331 @@ +--- +source: crates/squawk_parser/tests/tests.rs +input_file: crates/squawk_parser/tests/data/ok/plpgsql_keywords_as_idents.sql +--- +SOURCE_FILE + COMMENT "-- PL/pgSQL keywords live in their own namespace and stay plain identifiers in" + WHITESPACE "\n" + COMMENT "-- SQL. See postgres's pl_reserved_kwlist.h / pl_unreserved_kwlist.h." + WHITESPACE "\n" + CREATE_TABLE + CREATE_KW "create" + WHITESPACE " " + TABLE_KW "table" + WHITESPACE " " + TABLE_NAME + PATH + PATH_SEGMENT + IDENT "t" + WHITESPACE " " + TABLE_ARG_LIST + L_PAREN "(" + WHITESPACE "\n " + COLUMN + COLUMN_NAME + IDENT "alias" + WHITESPACE " " + PATH_TYPE + PATH_REF + PATH_SEGMENT_REF + TEXT_KW "text" + COMMA "," + WHITESPACE "\n " + COLUMN + COLUMN_NAME + IDENT "assert" + WHITESPACE " " + PATH_TYPE + PATH_REF + PATH_SEGMENT_REF + TEXT_KW "text" + COMMA "," + WHITESPACE "\n " + COLUMN + COLUMN_NAME + IDENT "constant" + WHITESPACE " " + PATH_TYPE + PATH_REF + PATH_SEGMENT_REF + TEXT_KW "text" + COMMA "," + WHITESPACE "\n " + COLUMN + COLUMN_NAME + IDENT "datatype" + WHITESPACE " " + PATH_TYPE + PATH_REF + PATH_SEGMENT_REF + TEXT_KW "text" + COMMA "," + WHITESPACE "\n " + COLUMN + COLUMN_NAME + IDENT "detail" + WHITESPACE " " + PATH_TYPE + PATH_REF + PATH_SEGMENT_REF + TEXT_KW "text" + COMMA "," + WHITESPACE "\n " + COLUMN + COLUMN_NAME + IDENT "diagnostics" + WHITESPACE " " + PATH_TYPE + PATH_REF + PATH_SEGMENT_REF + TEXT_KW "text" + COMMA "," + WHITESPACE "\n " + COLUMN + COLUMN_NAME + IDENT "elsif" + WHITESPACE " " + PATH_TYPE + PATH_REF + PATH_SEGMENT_REF + TEXT_KW "text" + COMMA "," + WHITESPACE "\n " + COLUMN + COLUMN_NAME + IDENT "exception" + WHITESPACE " " + PATH_TYPE + PATH_REF + PATH_SEGMENT_REF + TEXT_KW "text" + COMMA "," + WHITESPACE "\n " + COLUMN + COLUMN_NAME + IDENT "hint" + WHITESPACE " " + PATH_TYPE + PATH_REF + PATH_SEGMENT_REF + TEXT_KW "text" + COMMA "," + WHITESPACE "\n " + COLUMN + COLUMN_NAME + IDENT "info" + WHITESPACE " " + PATH_TYPE + PATH_REF + PATH_SEGMENT_REF + TEXT_KW "text" + COMMA "," + WHITESPACE "\n " + COLUMN + COLUMN_NAME + IDENT "log" + WHITESPACE " " + PATH_TYPE + PATH_REF + PATH_SEGMENT_REF + TEXT_KW "text" + COMMA "," + WHITESPACE "\n " + COLUMN + COLUMN_NAME + IDENT "loop" + WHITESPACE " " + PATH_TYPE + PATH_REF + PATH_SEGMENT_REF + TEXT_KW "text" + COMMA "," + WHITESPACE "\n " + COLUMN + COLUMN_NAME + IDENT "message" + WHITESPACE " " + PATH_TYPE + PATH_REF + PATH_SEGMENT_REF + TEXT_KW "text" + COMMA "," + WHITESPACE "\n " + COLUMN + COLUMN_NAME + IDENT "notice" + WHITESPACE " " + PATH_TYPE + PATH_REF + PATH_SEGMENT_REF + TEXT_KW "text" + COMMA "," + WHITESPACE "\n " + COLUMN + COLUMN_NAME + IDENT "perform" + WHITESPACE " " + PATH_TYPE + PATH_REF + PATH_SEGMENT_REF + TEXT_KW "text" + COMMA "," + WHITESPACE "\n " + COLUMN + COLUMN_NAME + IDENT "query" + WHITESPACE " " + PATH_TYPE + PATH_REF + PATH_SEGMENT_REF + TEXT_KW "text" + COMMA "," + WHITESPACE "\n " + COLUMN + COLUMN_NAME + IDENT "raise" + WHITESPACE " " + PATH_TYPE + PATH_REF + PATH_SEGMENT_REF + TEXT_KW "text" + COMMA "," + WHITESPACE "\n " + COLUMN + COLUMN_NAME + IDENT "reverse" + WHITESPACE " " + PATH_TYPE + PATH_REF + PATH_SEGMENT_REF + TEXT_KW "text" + COMMA "," + WHITESPACE "\n " + COLUMN + COLUMN_NAME + IDENT "rowtype" + WHITESPACE " " + PATH_TYPE + PATH_REF + PATH_SEGMENT_REF + TEXT_KW "text" + COMMA "," + WHITESPACE "\n " + COLUMN + COLUMN_NAME + IDENT "slice" + WHITESPACE " " + PATH_TYPE + PATH_REF + PATH_SEGMENT_REF + TEXT_KW "text" + COMMA "," + WHITESPACE "\n " + COLUMN + COLUMN_NAME + IDENT "sqlstate" + WHITESPACE " " + PATH_TYPE + PATH_REF + PATH_SEGMENT_REF + TEXT_KW "text" + COMMA "," + WHITESPACE "\n " + COLUMN + COLUMN_NAME + IDENT "stacked" + WHITESPACE " " + PATH_TYPE + PATH_REF + PATH_SEGMENT_REF + TEXT_KW "text" + COMMA "," + WHITESPACE "\n " + COLUMN + COLUMN_NAME + IDENT "warning" + WHITESPACE " " + PATH_TYPE + PATH_REF + PATH_SEGMENT_REF + TEXT_KW "text" + COMMA "," + WHITESPACE "\n " + COLUMN + COLUMN_NAME + IDENT "while" + WHITESPACE " " + PATH_TYPE + PATH_REF + PATH_SEGMENT_REF + TEXT_KW "text" + WHITESPACE "\n" + R_PAREN ")" + SEMICOLON ";" + WHITESPACE "\n\n" + SELECT + SELECT_CLAUSE + SELECT_KW "select" + WHITESPACE " " + TARGET_LIST + TARGET + NAME_REF + IDENT "message" + COMMA "," + WHITESPACE " " + TARGET + NAME_REF + IDENT "detail" + COMMA "," + WHITESPACE " " + TARGET + NAME_REF + IDENT "hint" + WHITESPACE " " + FROM_CLAUSE + FROM_KW "from" + WHITESPACE " " + RELATION_FROM_ITEM + RELATION_NAME_REF + PATH_REF + PATH_SEGMENT_REF + IDENT "t" + WHITESPACE " " + WHERE_CLAUSE + WHERE_KW "where" + WHITESPACE " " + BIN_EXPR + NAME_REF + IDENT "query" + WHITESPACE " " + EQ "=" + WHITESPACE " " + LITERAL + STRING "'x'" + SEMICOLON ";" + WHITESPACE "\n\n" + CREATE_INDEX + CREATE_KW "create" + WHITESPACE " " + INDEX_KW "index" + WHITESPACE " " + ON_KW "on" + WHITESPACE " " + TABLE_RELATION_NAME + TABLE_NAME_REF + PATH_REF + PATH_SEGMENT_REF + IDENT "t" + WHITESPACE " " + PARTITION_ITEM_LIST + L_PAREN "(" + PARTITION_ITEM + NAME_REF + IDENT "slice" + COMMA "," + WHITESPACE " " + PARTITION_ITEM + NAME_REF + IDENT "reverse" + R_PAREN ")" + SEMICOLON ";" + WHITESPACE "\n" diff --git a/crates/squawk_parser/tests/snapshots/tests__plpgsql_null_stmt_ok.snap b/crates/squawk_parser/tests/snapshots/tests__plpgsql_null_stmt_ok.snap new file mode 100644 index 00000000..a9bf4088 --- /dev/null +++ b/crates/squawk_parser/tests/snapshots/tests__plpgsql_null_stmt_ok.snap @@ -0,0 +1,9 @@ +--- +source: crates/squawk_parser/tests/tests.rs +input_file: crates/squawk_parser/tests/data/plpgsql/ok/null_stmt.sql +--- +PLPGSQL + PLPGSQL_NULL_STMT + NULL_KW "null" + SEMICOLON ";" + WHITESPACE "\n" diff --git a/crates/squawk_parser/tests/snapshots/tests__plpgsql_unrecognized_stmt_err.snap b/crates/squawk_parser/tests/snapshots/tests__plpgsql_unrecognized_stmt_err.snap new file mode 100644 index 00000000..a9562d8d --- /dev/null +++ b/crates/squawk_parser/tests/snapshots/tests__plpgsql_unrecognized_stmt_err.snap @@ -0,0 +1,24 @@ +--- +source: crates/squawk_parser/tests/tests.rs +input_file: crates/squawk_parser/tests/data/plpgsql/err/unrecognized_stmt.sql +--- +PLPGSQL + ERROR + BEGIN_KW "begin" + WHITESPACE " " + PLPGSQL_NULL_STMT + NULL_KW "null" + SEMICOLON ";" + WHITESPACE " " + ERROR + END_KW "end" + WHITESPACE "\n" +--- +error[syntax-error]: expected a statement, found BEGIN_KW + ╭▸ +1 │ begin null; end + ╰╴━ +error[syntax-error]: expected a statement, found END_KW + ╭▸ +1 │ begin null; end + ╰╴ ━ diff --git a/crates/squawk_parser/tests/snapshots/tests__select_literal_err.snap b/crates/squawk_parser/tests/snapshots/tests__select_literal_err.snap index 1fe113a0..a28c3ec4 100644 --- a/crates/squawk_parser/tests/snapshots/tests__select_literal_err.snap +++ b/crates/squawk_parser/tests/snapshots/tests__select_literal_err.snap @@ -721,6 +721,91 @@ SOURCE_FILE LITERAL STRING "''" SEMICOLON ";" + WHITESPACE "\n\n" + COMMENT "-- `..` lexes as a single token for PLPGSQL usage, but error in SQL" + WHITESPACE "\n" + SELECT + SELECT_CLAUSE + SELECT_KW "select" + WHITESPACE " " + TARGET_LIST + TARGET + LITERAL + INT_NUMBER "1" + ERROR + DOT_DOT ".." + ERROR + INT_NUMBER "10" + EMPTY_STMT + SEMICOLON ";" + WHITESPACE "\n" + SELECT + SELECT_CLAUSE + SELECT_KW "select" + WHITESPACE " " + TARGET_LIST + TARGET + LITERAL + INT_NUMBER "1" + WHITESPACE " " + ERROR + DOT_DOT ".." + WHITESPACE " " + ERROR + INT_NUMBER "10" + EMPTY_STMT + SEMICOLON ";" + WHITESPACE "\n" + SELECT + SELECT_CLAUSE + SELECT_KW "select" + WHITESPACE " " + TARGET_LIST + TARGET + LITERAL + NUMERIC_NUMBER "1.5" + ERROR + DOT_DOT ".." + ERROR + INT_NUMBER "2" + EMPTY_STMT + SEMICOLON ";" + WHITESPACE "\n" + SELECT + SELECT_CLAUSE + SELECT_KW "select" + WHITESPACE " " + TARGET_LIST + TARGET + LITERAL + INT_NUMBER "1" + ERROR + DOT_DOT ".." + ERROR + NUMERIC_NUMBER ".2" + EMPTY_STMT + SEMICOLON ";" + WHITESPACE "\n" + SELECT + SELECT_CLAUSE + SELECT_KW "select" + WHITESPACE " " + TARGET_LIST + TARGET + INDEX_EXPR + NAME_REF + IDENT "a" + L_BRACK "[" + LITERAL + INT_NUMBER "1" + ERROR + DOT_DOT ".." + ERROR + INT_NUMBER "2" + ERROR + R_BRACK "]" + EMPTY_STMT + SEMICOLON ";" WHITESPACE "\n" --- error[syntax-error]: trailing junk after numeric literal @@ -1003,3 +1088,51 @@ error[syntax-error]: expected an expression, found R_PAREN ╭▸ 63 │ select time() ''; ╰╴ ━ +error[syntax-error]: expected command, found DOT_DOT + ╭▸ +66 │ select 1..10; + ╰╴ ━ +error[syntax-error]: expected command, found INT_NUMBER + ╭▸ +66 │ select 1..10; + ╰╴ ━ +error[syntax-error]: expected command, found DOT_DOT + ╭▸ +67 │ select 1 .. 10; + ╰╴ ━ +error[syntax-error]: expected command, found INT_NUMBER + ╭▸ +67 │ select 1 .. 10; + ╰╴ ━ +error[syntax-error]: expected command, found DOT_DOT + ╭▸ +68 │ select 1.5..2; + ╰╴ ━ +error[syntax-error]: expected command, found INT_NUMBER + ╭▸ +68 │ select 1.5..2; + ╰╴ ━ +error[syntax-error]: expected command, found DOT_DOT + ╭▸ +69 │ select 1...2; + ╰╴ ━ +error[syntax-error]: expected command, found NUMERIC_NUMBER + ╭▸ +69 │ select 1...2; + ╰╴ ━ +error[syntax-error]: expected R_BRACK + ╭▸ +70 │ select a[1..2]; + ╰╴ ━ +error[syntax-error]: expected command, found DOT_DOT + ╭▸ +70 │ select a[1..2]; + ╰╴ ━ +error[syntax-error]: expected command, found INT_NUMBER + ╭▸ +70 │ select a[1..2]; + ╰╴ ━ +error[syntax-error]: expected command, found R_BRACK + ╭▸ +70 │ select a[1..2]; + ╰╴ ━ diff --git a/crates/squawk_parser/tests/tests.rs b/crates/squawk_parser/tests/tests.rs index 4d880b0c..1bcea277 100644 --- a/crates/squawk_parser/tests/tests.rs +++ b/crates/squawk_parser/tests/tests.rs @@ -3,7 +3,7 @@ use annotate_snippets::{AnnotationKind, Level, Renderer, Snippet, renderer::Deco use camino::Utf8Path; use dir_test::{Fixture, dir_test}; use insta::{assert_snapshot, with_settings}; -use squawk_parser::{LexedStr, parse}; +use squawk_parser::{EntryPoint, LexedStr}; use std::fmt::Write; #[dir_test( @@ -64,6 +64,62 @@ fn parser_err(fixture: Fixture<&str>) { ); } +#[dir_test( + dir: "$CARGO_MANIFEST_DIR/tests/data/plpgsql/ok", + glob: "*.sql", +)] +fn plpgsql_ok(fixture: Fixture<&str>) { + let content = fixture.content(); + let absolute_fixture_path = Utf8Path::new(fixture.path()); + let input_file = absolute_fixture_path; + let test_name = absolute_fixture_path + .file_name() + .and_then(|x| x.strip_suffix(".sql")) + .unwrap(); + + let (parsed, errors) = parse(content, EntryPoint::Plpgsql); + + with_settings!({ + omit_expression => true, + input_file => input_file, + }, { + assert_snapshot!(format!("plpgsql_{test_name}_ok"), parsed); + }); + + assert!( + errors.is_none(), + "tests defined in `plpgsql/ok` can't have parser errors." + ); +} + +#[dir_test( + dir: "$CARGO_MANIFEST_DIR/tests/data/plpgsql/err", + glob: "*.sql", +)] +fn plpgsql_err(fixture: Fixture<&str>) { + let content = fixture.content(); + let absolute_fixture_path = Utf8Path::new(fixture.path()); + let input_file = absolute_fixture_path; + let test_name = absolute_fixture_path + .file_name() + .and_then(|x| x.strip_suffix(".sql")) + .unwrap(); + + let (parsed, errors) = parse(content, EntryPoint::Plpgsql); + + with_settings!({ + omit_expression => true, + input_file => input_file, + }, { + assert_snapshot!(format!("plpgsql_{test_name}_err"), parsed); + }); + + assert!( + errors.is_some(), + "tests defined in `plpgsql/err` must have parser errors." + ); +} + #[dir_test( dir: "$CARGO_MANIFEST_DIR/../../postgres/regression_suite", glob: "*.sql", @@ -136,9 +192,14 @@ fn bom_after_start_is_an_ident_char() { #[must_use] fn parse_text(text: &str) -> (String, Option) { + parse(text, EntryPoint::SourceFile) +} + +#[must_use] +fn parse(text: &str, entry_point: EntryPoint) -> (String, Option) { let lexed = LexedStr::new(text); let input = lexed.to_input(); - let output = parse(&input); + let output = entry_point.parse(&input); let mut buf = String::new(); let mut errors: Vec<(std::ops::Range, String)> = Vec::new(); diff --git a/crates/squawk_syntax/src/ast/generated/nodes.rs b/crates/squawk_syntax/src/ast/generated/nodes.rs index 7ac40736..c57660f9 100644 --- a/crates/squawk_syntax/src/ast/generated/nodes.rs +++ b/crates/squawk_syntax/src/ast/generated/nodes.rs @@ -19393,6 +19393,32 @@ impl PeriodColumn { } } +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Plpgsql { + pub(crate) syntax: SyntaxNode, +} +impl Plpgsql { + #[inline] + pub fn stmts(&self) -> AstChildren { + support::children(&self.syntax) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct PlpgsqlNullStmt { + pub(crate) syntax: SyntaxNode, +} +impl PlpgsqlNullStmt { + #[inline] + pub fn semicolon_token(&self) -> Option { + support::token(&self.syntax, SyntaxKind::SEMICOLON) + } + #[inline] + pub fn null_token(&self) -> Option { + support::token(&self.syntax, SyntaxKind::NULL_KW) + } +} + #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct Policy { pub(crate) syntax: SyntaxNode, @@ -46010,6 +46036,42 @@ impl AstNode for PeriodColumn { &self.syntax } } +impl AstNode for Plpgsql { + #[inline] + fn can_cast(kind: SyntaxKind) -> bool { + kind == SyntaxKind::PLPGSQL + } + #[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 PlpgsqlNullStmt { + #[inline] + fn can_cast(kind: SyntaxKind) -> bool { + kind == SyntaxKind::PLPGSQL_NULL_STMT + } + #[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 Policy { #[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 fb99d492..93b2ffab 100644 --- a/crates/squawk_syntax/src/ast/node_ext.rs +++ b/crates/squawk_syntax/src/ast/node_ext.rs @@ -29,7 +29,7 @@ use std::borrow::Cow; use either::Either; #[cfg(test)] use insta::assert_snapshot; -use rowan::{GreenNodeData, GreenTokenData, NodeOrToken}; +use rowan::{GreenNodeData, GreenTokenData, NodeOrToken, TextSize}; use squawk_line_index::{LineEnding, find_newline}; #[cfg(test)] @@ -38,7 +38,15 @@ use rowan::Direction; use crate::ast; use crate::ast::AstNode; -use crate::unescape::{escape_unicode_esc_str, uescape_char}; +use crate::decoded_text::DecodedText; +use crate::quote::{ + dollar_quote_tag, strip_dollar_quotes, strip_prefixed_quotes, strip_quotes, + strip_unicode_esc_prefix, +}; +use crate::unescape::{ + decode_esc_string, decode_plain_string, decode_unicode_esc_string, escape_unicode_esc_str, + uescape_char, +}; use crate::{SyntaxKind, SyntaxNode, SyntaxToken, TokenText}; use super::support; @@ -193,6 +201,109 @@ impl ast::Literal { } } +#[derive(Clone, Copy, PartialEq, Eq)] +enum StringDecoding { + BitOrByte, + EscString, + UnicodeEscString, +} + +impl ast::Literal { + pub fn string_value(&self) -> Option { + Some(self.decoded_value()?.into_text()) + } + + pub fn decoded_value(&self) -> Option { + let escape_char = self.unicode_escape_char(); + let mut out = DecodedText::new(self.syntax().text_range().start()); + let mut decoding: Option = None; + + for element in self.syntax().children_with_tokens() { + let Some(token) = element.into_token() else { + continue; + }; + let start = token.text_range().start(); + match token.kind() { + SyntaxKind::ESC_STRING => { + let inner = strip_prefixed_quotes(token.text(), ['e', 'E'])?; + let inner_start = start + TextSize::new(2); + decode_esc_string(inner, inner_start, &mut out); + out.mark_end(inner_start + TextSize::of(inner)); + decoding = Some(StringDecoding::EscString); + } + SyntaxKind::UNICODE_ESC_STRING => { + let inner = strip_unicode_esc_prefix(token.text())?; + let inner_start = start + TextSize::new(3); + decode_unicode_esc_string(inner, inner_start, escape_char, &mut out); + out.mark_end(inner_start + TextSize::of(inner)); + decoding = Some(StringDecoding::UnicodeEscString); + } + SyntaxKind::BIT_STRING => { + let inner = strip_prefixed_quotes(token.text(), ['b', 'B'])?; + out.push_str(inner, start + TextSize::new(2)); + decoding = Some(StringDecoding::BitOrByte); + } + SyntaxKind::BYTE_STRING => { + let inner = strip_prefixed_quotes(token.text(), ['x', 'X'])?; + out.push_str(inner, start + TextSize::new(2)); + decoding = Some(StringDecoding::BitOrByte); + } + SyntaxKind::DOLLAR_QUOTED_STRING => { + let tag = dollar_quote_tag(token.text())?; + let inner = strip_dollar_quotes(token.text())?; + out.push_str(inner, start + TextSize::new(tag.len() as u32 + 2)); + return Some(out); + } + SyntaxKind::NATIONAL_STRING => { + let inner = strip_prefixed_quotes(token.text(), ['n', 'N'])?; + let inner_start = start + TextSize::new(2); + decode_plain_string(inner, inner_start, &mut out); + out.mark_end(inner_start + TextSize::of(inner)); + } + SyntaxKind::STRING => { + let inner = strip_quotes(token.text())?; + let inner_start = start + TextSize::new(1); + match decoding { + Some(StringDecoding::EscString) => { + decode_esc_string(inner, inner_start, &mut out); + } + Some(StringDecoding::UnicodeEscString) => { + decode_unicode_esc_string(inner, inner_start, escape_char, &mut out); + } + Some(StringDecoding::BitOrByte) => out.push_str(inner, inner_start), + None => decode_plain_string(inner, inner_start, &mut out), + } + out.mark_end(inner_start + TextSize::of(inner)); + } + SyntaxKind::UESCAPE_KW => break, + _ => (), + } + } + + Some(out) + } + + fn unicode_escape_char(&self) -> char { + let mut seen_uescape = false; + for element in self.syntax().children_with_tokens() { + let Some(token) = element.into_token() else { + continue; + }; + match token.kind() { + SyntaxKind::UESCAPE_KW => seen_uescape = true, + SyntaxKind::STRING if seen_uescape => { + if let Some(ch) = uescape_char(token.text()) { + return ch; + } + return '\\'; + } + _ => (), + } + } + '\\' + } +} + impl ast::Constraint { #[inline] pub fn constraint_name(&self) -> Option { @@ -1949,3 +2060,143 @@ fn vacuum_full_dollar_quoted_off_is_not_full() { fn vacuum_full_0_is_not_full() { assert!(!extract_vacuum("VACUUM (FULL 0) foo;").is_full()); } + +#[cfg(test)] +fn decode_literal(sql: &str) -> String { + let parse = SourceFile::parse(sql); + assert!(parse.errors().is_empty(), "{:?}", parse.errors()); + let literal = parse + .tree() + .syntax() + .descendants() + .find_map(ast::Literal::cast) + .unwrap(); + let decoded = literal.decoded_value().unwrap(); + let text = decoded.text(); + + let offsets = text + .char_indices() + .map(|(i, _)| i) + .chain(std::iter::once(text.len())); + + let mut out = format!("{text:?}\n"); + for offset in offsets { + let pos = usize::from(decoded.source_pos(TextSize::new(offset as u32))); + let at = sql[pos..].chars().next(); + out.push_str(&format!(" {offset} -> {pos} {at:?}\n")); + } + out +} + +#[test] +fn decoded_value_maps_doubled_quotes() { + assert_snapshot!(decode_literal("select 'a''b';"), @r#" + "a'b" + 0 -> 8 Some('a') + 1 -> 9 Some('\'') + 2 -> 11 Some('b') + 3 -> 12 Some('\'') + "#); +} + +#[test] +fn decoded_value_maps_escape_string() { + assert_snapshot!(decode_literal(r"select E'a\nb';"), @r#" + "a\nb" + 0 -> 9 Some('a') + 1 -> 10 Some('\\') + 2 -> 12 Some('b') + 3 -> 13 Some('\'') + "#); +} + +#[test] +fn decoded_value_maps_multibyte_escape() { + assert_snapshot!(decode_literal(r"select E'a\xc3\xa9b';"), @r#" + "aéb" + 0 -> 9 Some('a') + 1 -> 10 Some('\\') + 3 -> 18 Some('b') + 4 -> 19 Some('\'') + "#); +} + +#[test] +fn decoded_value_maps_unicode_escape() { + assert_snapshot!(decode_literal(r"select U&'a\0062c';"), @r#" + "abc" + 0 -> 10 Some('a') + 1 -> 11 Some('\\') + 2 -> 16 Some('c') + 3 -> 17 Some('\'') + "#); +} + +#[test] +fn decoded_value_maps_doubled_quote_before_unicode_escape() { + assert_snapshot!(decode_literal(r"select U&'a''b\0063';"), @r#" + "a'bc" + 0 -> 10 Some('a') + 1 -> 11 Some('\'') + 2 -> 13 Some('b') + 3 -> 14 Some('\\') + 4 -> 19 Some('\'') + "#); +} + +#[test] +fn decoded_value_maps_escape_that_produces_nothing() { + assert_snapshot!(decode_literal(r"select E'ab\udb99';"), @r#" + "ab" + 0 -> 9 Some('a') + 1 -> 10 Some('b') + 2 -> 17 Some('\'') + "#); +} + +#[test] +fn decoded_value_maps_end_past_a_trailing_escape() { + assert_snapshot!(decode_literal(r"select E'null;\n';"), @r#" + "null;\n" + 0 -> 9 Some('n') + 1 -> 10 Some('u') + 2 -> 11 Some('l') + 3 -> 12 Some('l') + 4 -> 13 Some(';') + 5 -> 14 Some('\\') + 6 -> 16 Some('\'') + "#); +} + +#[test] +fn decoded_value_maps_end_past_a_trailing_doubled_quote() { + assert_snapshot!(decode_literal("select 'ab''';"), @r#" + "ab'" + 0 -> 8 Some('a') + 1 -> 9 Some('b') + 2 -> 10 Some('\'') + 3 -> 12 Some('\'') + "#); +} + +#[test] +fn decoded_value_maps_string_continuation() { + assert_snapshot!(decode_literal("select 'ab'\n'cd';"), @r#" + "abcd" + 0 -> 8 Some('a') + 1 -> 9 Some('b') + 2 -> 13 Some('c') + 3 -> 14 Some('d') + 4 -> 15 Some('\'') + "#); +} + +#[test] +fn decoded_value_maps_dollar_quoted() { + assert_snapshot!(decode_literal("select $tag$ab$tag$;"), @r#" + "ab" + 0 -> 12 Some('a') + 1 -> 13 Some('b') + 2 -> 14 Some('$') + "#); +} diff --git a/crates/squawk_syntax/src/decoded_text.rs b/crates/squawk_syntax/src/decoded_text.rs new file mode 100644 index 00000000..7fced467 --- /dev/null +++ b/crates/squawk_syntax/src/decoded_text.rs @@ -0,0 +1,68 @@ +use rowan::{TextRange, TextSize}; + +#[derive(Debug)] +pub struct DecodedText { + text: String, + marks: Vec, +} + +#[derive(Debug)] +struct Mark { + decoded: u32, + pos: u32, +} + +impl DecodedText { + pub fn new(pos: TextSize) -> Self { + Self { + text: String::new(), + marks: vec![Mark { + decoded: 0, + pos: pos.into(), + }], + } + } + + pub fn text(&self) -> &str { + &self.text + } + + pub fn into_text(self) -> String { + self.text + } + + pub fn push_str(&mut self, text: &str, pos: TextSize) { + self.sync(pos); + self.text.push_str(text); + } + + pub fn push_char(&mut self, c: char, pos: TextSize) { + self.sync(pos); + self.text.push(c); + } + + pub fn mark_end(&mut self, pos: TextSize) { + self.sync(pos); + } + + fn sync(&mut self, pos: TextSize) { + let decoded = self.text.len() as u32; + let pos = pos.into(); + match self.marks.last_mut() { + Some(mark) if mark.decoded == decoded => mark.pos = pos, + Some(mark) if mark.pos + (decoded - mark.decoded) == pos => (), + _ => self.marks.push(Mark { decoded, pos }), + } + } + + pub fn source_pos(&self, offset: TextSize) -> TextSize { + let offset = u32::from(offset); + let idx = self.marks.partition_point(|mark| mark.decoded <= offset); + let mark = &self.marks[idx.saturating_sub(1)]; + TextSize::new(mark.pos + offset.saturating_sub(mark.decoded)) + } + + pub fn source_range(&self, range: TextRange) -> TextRange { + TextRange::new(self.source_pos(range.start()), self.source_pos(range.end())) + } +} diff --git a/crates/squawk_syntax/src/lib.rs b/crates/squawk_syntax/src/lib.rs index 571589c7..b26685da 100644 --- a/crates/squawk_syntax/src/lib.rs +++ b/crates/squawk_syntax/src/lib.rs @@ -26,8 +26,10 @@ pub mod ast; pub mod column_name; +pub mod decoded_text; mod generated; mod parsing; +pub mod plpgsql; mod ptr; pub mod quote; pub mod syntax_error; diff --git a/crates/squawk_syntax/src/parsing.rs b/crates/squawk_syntax/src/parsing.rs index c3edd051..5d901e93 100644 --- a/crates/squawk_syntax/src/parsing.rs +++ b/crates/squawk_syntax/src/parsing.rs @@ -31,7 +31,15 @@ use crate::{syntax_error::SyntaxError, syntax_node::SyntaxTreeBuilder}; pub(crate) fn parse_text(text: &str) -> (GreenNode, Vec) { let lexed = squawk_parser::LexedStr::new(text); let parser_input = lexed.to_input(); - let parser_output = squawk_parser::parse(&parser_input); + let parser_output = squawk_parser::EntryPoint::SourceFile.parse(&parser_input); + let (node, errors, _eof) = build_tree(lexed, parser_output); + (node, errors) +} + +pub(crate) fn parse_plpgsql_text(text: &str) -> (GreenNode, Vec) { + let lexed = squawk_parser::LexedStr::new(text); + let parser_input = lexed.to_input(); + let parser_output = squawk_parser::EntryPoint::Plpgsql.parse(&parser_input); let (node, errors, _eof) = build_tree(lexed, parser_output); (node, errors) } diff --git a/crates/squawk_syntax/src/plpgsql.rs b/crates/squawk_syntax/src/plpgsql.rs new file mode 100644 index 00000000..36e8e1c3 --- /dev/null +++ b/crates/squawk_syntax/src/plpgsql.rs @@ -0,0 +1,273 @@ +use rowan::{GreenNode, TextRange}; + +use crate::{ + SyntaxNode, ast, ast::AstNode, decoded_text::DecodedText, parsing, syntax_error::SyntaxError, +}; + +pub struct Plpgsql { + green: GreenNode, + errors: Vec, + decoded: DecodedText, +} + +impl Plpgsql { + pub(crate) fn parse(decoded: DecodedText) -> Self { + let (green, errors) = parsing::parse_plpgsql_text(decoded.text()); + let errors = errors + .into_iter() + .map(|error| { + let range = decoded.source_range(error.range()); + error.with_range(range) + }) + .collect(); + + Self { + green, + errors, + decoded, + } + } + + pub fn syntax(&self) -> SyntaxNode { + SyntaxNode::new_root(self.green.clone()) + } + + pub fn tree(&self) -> ast::Plpgsql { + ast::Plpgsql::cast(self.syntax()).expect("root is always a Plpgsql") + } + + pub fn text(&self) -> &str { + self.decoded.text() + } + + pub fn source_range(&self, range: TextRange) -> TextRange { + self.decoded.source_range(range) + } + + pub fn errors(&self) -> &[SyntaxError] { + &self.errors + } +} + +fn is_plpgsql(language_ref: Option, literal: Option) -> bool { + let name = match (language_ref, literal) { + (Some(language_ref), _) => language_ref.syntax().text().to_string(), + (_, Some(literal)) => literal.string_value().unwrap_or_default(), + _ => return false, + }; + name.eq_ignore_ascii_case("plpgsql") +} + +fn from_options(options: ast::FuncOptionList) -> Option { + let mut plpgsql = false; + let mut body = None; + + for option in options.options() { + match option { + ast::FuncOption::LanguageFuncOption(option) => { + plpgsql = is_plpgsql(option.language_ref(), option.literal()); + } + ast::FuncOption::AsFuncOption(option) => { + if let Some(ast::AsFuncTarget::AsDefinition(definition)) = option.as_func_target() { + body = definition.literal(); + } + } + _ => (), + } + } + + plpgsql.then_some(())?; + Some(Plpgsql::parse(body?.decoded_value()?)) +} + +impl ast::CreateFunction { + pub fn plpgsql(&self) -> Option { + from_options(self.option_list()?) + } +} + +impl ast::CreateProcedure { + pub fn plpgsql(&self) -> Option { + from_options(self.option_list()?) + } +} + +impl ast::Do { + pub fn plpgsql(&self) -> Option { + if let Some(language) = self.do_language() + && !is_plpgsql(language.language_ref(), language.literal()) + { + return None; + } + Some(Plpgsql::parse(self.body()?.decoded_value()?)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::SourceFile; + use insta::assert_snapshot; + use rowan::{TextRange, TextSize}; + + fn find(sql: &str) -> Option { + let parse = SourceFile::parse(sql); + assert!(parse.errors().is_empty(), "{:?}", parse.errors()); + + parse.tree().syntax().descendants().find_map(|node| { + ast::CreateFunction::cast(node.clone()) + .and_then(|it| it.plpgsql()) + .or_else(|| ast::CreateProcedure::cast(node.clone()).and_then(|it| it.plpgsql())) + .or_else(|| ast::Do::cast(node.clone()).and_then(|it| it.plpgsql())) + }) + } + + fn body(sql: &str) -> String { + let body = find(sql).expect("no plpgsql body"); + + let range = body.source_range(TextRange::up_to(TextSize::of(body.text()))); + let start = usize::from(range.start()); + let end = usize::from(range.end()); + + let mut out = format!("{:#?}", body.syntax()); + out.push_str(&format!("---\nsource {range:?} {:?}\n", &sql[start..end])); + for error in body.errors() { + out.push_str(&format!("error {:?} {}\n", error.range(), error.message())); + } + out + } + + #[test] + fn language_after_as() { + assert_snapshot!( + body("create function f() returns int as $$ null; $$ language plpgsql;"), + @r#" + PLPGSQL@0..7 + WHITESPACE@0..1 " " + PLPGSQL_NULL_STMT@1..6 + NULL_KW@1..5 "null" + SEMICOLON@5..6 ";" + WHITESPACE@6..7 " " + --- + source 37..44 " null; " + "# + ); + } + + #[test] + fn language_before_as() { + assert_snapshot!( + body("create function f() returns int language plpgsql as $$ null; $$;"), + @r#" + PLPGSQL@0..7 + WHITESPACE@0..1 " " + PLPGSQL_NULL_STMT@1..6 + NULL_KW@1..5 "null" + SEMICOLON@5..6 ";" + WHITESPACE@6..7 " " + --- + source 54..61 " null; " + "# + ); + } + + #[test] + fn other_language_is_not_a_body() { + assert!(find("create function f() returns int as $$ select 1 $$ language sql;").is_none()); + } + + #[test] + fn procedure() { + assert_snapshot!( + body("create procedure p() as $$ null; $$ language plpgsql;"), + @r#" + PLPGSQL@0..7 + WHITESPACE@0..1 " " + PLPGSQL_NULL_STMT@1..6 + NULL_KW@1..5 "null" + SEMICOLON@5..6 ";" + WHITESPACE@6..7 " " + --- + source 26..33 " null; " + "# + ); + } + + #[test] + fn do_defaults_to_plpgsql() { + assert_snapshot!(body("do $$ null; $$;"), @r#" + PLPGSQL@0..7 + WHITESPACE@0..1 " " + PLPGSQL_NULL_STMT@1..6 + NULL_KW@1..5 "null" + SEMICOLON@5..6 ";" + WHITESPACE@6..7 " " + --- + source 5..12 " null; " + "# + ); + } + + #[test] + fn do_with_other_language_is_not_a_body() { + assert!(find("do language sql $$ select 1 $$;").is_none()); + } + + #[test] + fn escaped_body_maps_back_through_the_escapes() { + assert_snapshot!( + body(r"create function f() returns int as E'null;\n' language plpgsql;"), + @r#" + PLPGSQL@0..6 + PLPGSQL_NULL_STMT@0..5 + NULL_KW@0..4 "null" + SEMICOLON@4..5 ";" + WHITESPACE@5..6 "\n" + --- + source 37..44 "null;\\n" + "# + ); + } + + #[test] + fn unparsed_tokens_are_errors() { + assert_snapshot!(body("do $$ begin null; end $$;"), @r#" + PLPGSQL@0..17 + WHITESPACE@0..1 " " + ERROR@1..6 + BEGIN_KW@1..6 "begin" + WHITESPACE@6..7 " " + PLPGSQL_NULL_STMT@7..12 + NULL_KW@7..11 "null" + SEMICOLON@11..12 ";" + WHITESPACE@12..13 " " + ERROR@13..16 + END_KW@13..16 "end" + WHITESPACE@16..17 " " + --- + source 5..22 " begin null; end " + error 6..6 expected a statement, found BEGIN_KW + error 18..18 expected a statement, found END_KW + "#); + } + + #[test] + fn errors_in_an_escaped_body_map_into_the_file() { + assert_snapshot!(body(r"do E'begin\n null;\n end';"), @r#" + PLPGSQL@0..17 + ERROR@0..5 + BEGIN_KW@0..5 "begin" + WHITESPACE@5..7 "\n " + PLPGSQL_NULL_STMT@7..12 + NULL_KW@7..11 "null" + SEMICOLON@11..12 ";" + WHITESPACE@12..14 "\n " + ERROR@14..17 + END_KW@14..17 "end" + --- + source 5..24 "begin\\n null;\\n end" + error 5..5 expected a statement, found BEGIN_KW + error 21..21 expected a statement, found END_KW + "#); + } +} diff --git a/crates/squawk_syntax/src/postgresql.ungram b/crates/squawk_syntax/src/postgresql.ungram index ed989b98..c11012a4 100644 --- a/crates/squawk_syntax/src/postgresql.ungram +++ b/crates/squawk_syntax/src/postgresql.ungram @@ -2036,6 +2036,12 @@ RoutineBody = ReturnStmt | AtomicBody +Plpgsql = + stmts:(PlpgsqlNullStmt*) + +PlpgsqlNullStmt = + 'null' ';' + FuncOptionList = options:(FuncOption*) diff --git a/crates/squawk_syntax/src/test.rs b/crates/squawk_syntax/src/test.rs index 3d600ce6..9bb1e8f3 100644 --- a/crates/squawk_syntax/src/test.rs +++ b/crates/squawk_syntax/src/test.rs @@ -1,10 +1,15 @@ // based on https://github.com/rust-lang/rust-analyzer/blob/d8887c0758bbd2d5f752d5bd405d4491e90e7ed6/crates/parser/src/tests.rs use annotate_snippets::{AnnotationKind, Level, Renderer, Snippet, renderer::DecorStyle}; -use camino::Utf8Path; +use camino::{Utf8Path, Utf8PathBuf}; use dir_test::{Fixture, dir_test}; use insta::{assert_snapshot, with_settings}; -use crate::{SourceFile, syntax_error::SyntaxError}; +use crate::{ + Parse, SourceFile, SyntaxKind, SyntaxNode, + ast::{self, AstNode}, + plpgsql::Plpgsql, + syntax_error::SyntaxError, +}; fn render_errors(sql: &str, errors: &[SyntaxError]) -> String { let mut rendered = String::new(); @@ -97,6 +102,100 @@ fn regression_suite_validation(fixture: Fixture<&str>) { ); } +fn plpgsql_bodies(parse: &Parse) -> Vec { + parse + .tree() + .syntax() + .descendants() + .filter_map(|node| { + ast::CreateFunction::cast(node.clone()) + .and_then(|it| it.plpgsql()) + .or_else(|| ast::CreateProcedure::cast(node.clone()).and_then(|it| it.plpgsql())) + .or_else(|| ast::Do::cast(node).and_then(|it| it.plpgsql())) + }) + .collect() +} + +fn token_counts(node: &SyntaxNode) -> (usize, usize) { + let mut total = 0; + let mut unparsed = 0; + for token in node + .descendants_with_tokens() + .filter_map(|element| element.into_token()) + .filter(|token| !token.kind().is_trivia()) + { + total += 1; + if token + .parent_ancestors() + .any(|ancestor| ancestor.kind() == SyntaxKind::ERROR) + { + unparsed += 1; + } + } + (total, unparsed) +} + +#[test] +fn plpgsql_suite_score() { + let dir = Utf8Path::new(env!("CARGO_MANIFEST_DIR")).join("../../postgres/plpgsql"); + let mut files = std::fs::read_dir(&dir) + .unwrap() + .map(|entry| Utf8PathBuf::try_from(entry.unwrap().path()).unwrap()) + .filter(|path| path.extension() == Some("sql")) + .collect::>(); + files.sort(); + + let row = |label: &str, counts: [usize; 5]| { + let [sql, bodies, tokens, unparsed, err] = counts; + format!("{label:<25}{sql:>6}{bodies:>8}{tokens:>8}{unparsed:>10}{err:>6}\n") + }; + + let mut table = format!( + "{:<25}{:>6}{:>8}{:>8}{:>10}{:>6}\n", + "file", "sql", "bodies", "tokens", "unparsed", "err" + ); + let mut totals = [0; 5]; + + for path in &files { + let content = std::fs::read_to_string(path).unwrap(); + let parse = SourceFile::parse(&content); + let bodies = plpgsql_bodies(&parse); + + let mut counts = [parse.errors().len(), bodies.len(), 0, 0, 0]; + for body in &bodies { + let (tokens, unparsed) = token_counts(&body.syntax()); + counts[2] += tokens; + counts[3] += unparsed; + counts[4] += body.errors().len(); + } + + table.push_str(&row(path.file_name().unwrap(), counts)); + for (total, count) in totals.iter_mut().zip(counts) { + *total += count; + } + } + + table.push_str(&row("total", totals)); + + assert_snapshot!(table, @" + file sql bodies tokens unparsed err + plpgsql_array.sql 0 26 949 949 949 + plpgsql_cache.sql 0 2 60 60 60 + plpgsql_call.sql 0 45 1698 1696 1696 + plpgsql_control.sql 0 27 1424 1422 1422 + plpgsql_copy.sql 79 4 28 28 28 + plpgsql_domain.sql 0 23 307 307 307 + plpgsql_misc.sql 6 16 261 261 261 + plpgsql_record.sql 27 65 2008 2000 2000 + plpgsql_simple.sql 3 9 217 217 217 + plpgsql_transaction.sql 0 37 1203 1199 1199 + plpgsql_trap.sql 0 7 354 354 354 + plpgsql_trigger.sql 0 1 55 55 55 + plpgsql_varprops.sql 0 33 736 706 706 + total 115 295 9300 9254 9254 + "); +} + #[dir_test( dir: "$CARGO_MANIFEST_DIR/test_data", glob: "**/*.sql", diff --git a/crates/squawk_syntax/src/unescape.rs b/crates/squawk_syntax/src/unescape.rs index c0dd8653..36525528 100644 --- a/crates/squawk_syntax/src/unescape.rs +++ b/crates/squawk_syntax/src/unescape.rs @@ -1,6 +1,10 @@ use std::fmt; use std::ops::{Range, RangeInclusive}; +use rowan::TextSize; + +use crate::decoded_text::DecodedText; + pub enum UnicodeEscapeKind { Extended, Short, @@ -172,68 +176,115 @@ pub fn uescape_char(text: &str) -> Option { is_valid_uescape_char(byte).then(|| char::from(byte)) } -pub fn decode_plain_string(inner: &str, out: &mut String) { - let mut chars = inner.chars().peekable(); - while let Some(c) = chars.next() { - if c == '\'' && chars.peek() == Some(&'\'') { +pub fn decode_plain_string(inner: &str, start_pos: TextSize, out: &mut DecodedText) { + let mut chars = inner.char_indices().peekable(); + while let Some((i, c)) = chars.next() { + let pos = start_pos + TextSize::new(i as u32); + if c == '\'' && chars.peek().is_some_and(|&(_, next)| next == '\'') { chars.next(); - out.push('\''); - } else { - out.push(c); } + out.push_char(c, pos); } } -fn push_char_bytes(c: char, bytes: &mut Vec) { - let mut buf = [0; 4]; - let encoded = c.encode_utf8(&mut buf); - bytes.extend_from_slice(encoded.as_bytes()); +struct EscBuffer { + bytes: Vec, + pos: TextSize, +} + +impl EscBuffer { + fn new(pos: TextSize) -> Self { + Self { bytes: vec![], pos } + } + + fn push(&mut self, byte: u8, pos: TextSize) { + if self.bytes.is_empty() { + self.pos = pos; + } + self.bytes.push(byte); + } + + fn push_char(&mut self, c: char, pos: TextSize) { + if self.bytes.is_empty() { + self.pos = pos; + } + let mut buf = [0; 4]; + self.bytes + .extend_from_slice(c.encode_utf8(&mut buf).as_bytes()); + } + + fn drain(&mut self, out: &mut DecodedText) { + if self.bytes.is_empty() { + return; + } + match std::str::from_utf8(&self.bytes) { + Ok(text) => out.push_str(text, self.pos), + Err(err) if err.error_len().is_some() => { + out.push_str(&String::from_utf8_lossy(&self.bytes), self.pos); + } + Err(_) => return, + } + self.bytes.clear(); + } + + fn flush(&mut self, out: &mut DecodedText) { + if self.bytes.is_empty() { + return; + } + out.push_str(&String::from_utf8_lossy(&self.bytes), self.pos); + self.bytes.clear(); + } } -pub fn decode_esc_string(inner: &str, out: &mut String) { - let mut chars = inner.chars().peekable(); - let mut bytes = vec![]; +pub fn decode_esc_string(inner: &str, start_pos: TextSize, out: &mut DecodedText) { + let mut chars = inner.char_indices().peekable(); + let mut esc = EscBuffer::new(start_pos); + + while let Some((i, c)) = chars.next() { + let pos = start_pos + TextSize::new(i as u32); - while let Some(c) = chars.next() { - if c == '\'' && chars.peek() == Some(&'\'') { + if c == '\'' && chars.peek().is_some_and(|&(_, next)| next == '\'') { chars.next(); - bytes.push(b'\''); + esc.flush(out); + out.push_char('\'', pos); continue; } if c != '\\' { - push_char_bytes(c, &mut bytes); + esc.flush(out); + out.push_char(c, pos); continue; } - let Some(&next) = chars.peek() else { - bytes.push(b'\\'); + let Some(&(_, next)) = chars.peek() else { + esc.flush(out); + out.push_char('\\', pos); break; }; match next { 'b' => { chars.next(); - bytes.push(b'\x08'); + esc.push(b'\x08', pos); } 'f' => { chars.next(); - bytes.push(b'\x0C'); + esc.push(b'\x0C', pos); } 'n' => { chars.next(); - bytes.push(b'\n'); + esc.push(b'\n', pos); } 'r' => { chars.next(); - bytes.push(b'\r'); + esc.push(b'\r', pos); } 't' => { chars.next(); - bytes.push(b'\t'); + esc.push(b'\t', pos); } '0'..='7' => { let mut value: u32 = 0; for _ in 0..3 { match chars.peek() { - Some(&d) if ('0'..='7').contains(&d) => { + Some(&(_, d)) if ('0'..='7').contains(&d) => { chars.next(); value = value * 8 + d.to_digit(8).unwrap(); } @@ -241,7 +292,7 @@ pub fn decode_esc_string(inner: &str, out: &mut String) { } } if value != 0 { - bytes.push(value as u8); + esc.push(value as u8, pos); } } 'x' => { @@ -250,7 +301,7 @@ pub fn decode_esc_string(inner: &str, out: &mut String) { let mut got_any = false; for _ in 0..2 { match chars.peek() { - Some(&d) if d.is_ascii_hexdigit() => { + Some(&(_, d)) if d.is_ascii_hexdigit() => { chars.next(); value = value * 16 + d.to_digit(16).unwrap() as u8; got_any = true; @@ -260,10 +311,10 @@ pub fn decode_esc_string(inner: &str, out: &mut String) { } if got_any { if value != 0 { - bytes.push(value); + esc.push(value, pos); } } else { - bytes.push(b'x'); + esc.push(b'x', pos); } } 'u' | 'U' => { @@ -273,7 +324,7 @@ pub fn decode_esc_string(inner: &str, out: &mut String) { let mut got_all = true; for _ in 0..required { match chars.peek() { - Some(&d) if d.is_ascii_hexdigit() => { + Some(&(_, d)) if d.is_ascii_hexdigit() => { chars.next(); value = value * 16 + d.to_digit(16).unwrap(); } @@ -287,24 +338,32 @@ pub fn decode_esc_string(inner: &str, out: &mut String) { && let Some(ch) = char::from_u32(value) && ch != '\0' { - push_char_bytes(ch, &mut bytes); + esc.push_char(ch, pos); } } _ => { chars.next(); - push_char_bytes(next, &mut bytes); + esc.push_char(next, pos); } } + esc.drain(out); } - out.push_str(&String::from_utf8_lossy(&bytes)); + esc.flush(out); } -pub fn decode_unicode_esc_string(inner: &str, escape_char: char, out: &mut String) { - let inner = inner.replace("''", "'"); - escape_unicode_esc_str(&inner, escape_char, |_range, result| { +pub fn decode_unicode_esc_string( + inner: &str, + start_pos: TextSize, + escape_char: char, + out: &mut DecodedText, +) { + let mut dequoted = DecodedText::new(start_pos); + decode_plain_string(inner, start_pos, &mut dequoted); + + escape_unicode_esc_str(dequoted.text(), escape_char, |range, result| { if let Ok(ch) = result { - out.push(ch); + out.push_char(ch, dequoted.source_pos(TextSize::new(range.start as u32))); } }); } @@ -330,15 +389,15 @@ mod tests { } fn decode_escape_string(inner: &str) -> String { - let mut out = String::new(); - decode_esc_string(inner, &mut out); - out + let mut out = DecodedText::new(TextSize::new(0)); + decode_esc_string(inner, TextSize::new(0), &mut out); + out.into_text() } fn decode_unicode_escape_string(inner: &str, escape_char: char) -> String { - let mut out = String::new(); - decode_unicode_esc_string(inner, escape_char, &mut out); - out + let mut out = DecodedText::new(TextSize::new(0)); + decode_unicode_esc_string(inner, TextSize::new(0), escape_char, &mut out); + out.into_text() } #[test] @@ -411,6 +470,18 @@ mod tests { assert_snapshot!(decode_escape_string(r"a\000b"), @"ab"); } + #[test] + fn escape_string_incomplete_byte_escape() { + assert_snapshot!(decode_escape_string(r"\xc3a"), @"�a"); + assert_snapshot!(decode_escape_string(r"\xc3"), @"�"); + } + + #[test] + fn escape_string_trailing_backslash() { + assert_snapshot!(decode_escape_string(r"a\"), @r"a\"); + assert_snapshot!(decode_escape_string(r"\xC3\"), @r"�\"); + } + #[test] fn decode_unicode_string_collapses_doubled_quotes() { assert_snapshot!(decode_unicode_escape_string("a''b", '\\'), @"a'b"); diff --git a/crates/xtask/src/codegen.rs b/crates/xtask/src/codegen.rs index 07a7084f..aeb7f655 100644 --- a/crates/xtask/src/codegen.rs +++ b/crates/xtask/src/codegen.rs @@ -32,7 +32,7 @@ use ungrammar::{Grammar, Rule}; use xshell::{Shell, cmd}; use crate::{ - keywords::{KeywordKinds, keyword_kinds}, + keywords::{KeywordKinds, contextual_keywords, keyword_kinds}, path::project_root, }; @@ -92,7 +92,12 @@ pub(crate) fn codegen() -> Result<()> { let keyword_arrays = generate_keyword_arrays(&keyword_kinds)?; std::fs::write(syntax_keywords, keyword_arrays).context("problem writing keyword arrays")?; - let kinds = generate_kind_src(&ast_src.nodes, &grammar, keyword_kinds.all_keywords); + let kinds = generate_kind_src( + &ast_src.nodes, + &grammar, + keyword_kinds.all_keywords, + contextual_keywords()?, + ); let syntax_kinds = generate_syntax_kinds(kinds)?; let syntax_kinds_file = @@ -106,6 +111,7 @@ pub(crate) fn codegen() -> Result<()> { pub(crate) struct KindsSrc { pub(crate) punct: &'static [(&'static str, &'static str)], pub(crate) keywords: &'static [&'static str], + pub(crate) contextual_keywords: &'static [&'static str], pub(crate) literals: &'static [&'static str], pub(crate) tokens: &'static [&'static str], pub(crate) nodes: &'static [&'static str], @@ -130,9 +136,11 @@ const PUNCT: &[(&str, &str)] = &[ ("{", "L_CURLY"), ("}", "R_CURLY"), ("<", "L_ANGLE"), + ("<<", "LESS_LESS"), ("<=", "LTEQ"), ("<>", "NEQB"), (">", "R_ANGLE"), + (">>", "GREATER_GREATER"), (">=", "GTEQ"), ("@", "AT"), ("#", "POUND"), @@ -147,6 +155,7 @@ const PUNCT: &[(&str, &str)] = &[ ("%", "PERCENT"), ("_", "UNDERSCORE"), (".", "DOT"), + ("..", "DOT_DOT"), (":", "COLON"), (":=", "COLON_EQ"), ("=", "EQ"), @@ -161,6 +170,7 @@ fn generate_kind_src( nodes: &[AstNodeSrc], grammar: &ungrammar::Grammar, pg_keywords: Vec, + pl_keywords: Vec, ) -> KindsSrc { let mut keywords: Vec<&_> = Vec::new(); let mut tokens: Vec<&_> = TOKENS.to_vec(); @@ -198,7 +208,9 @@ fn generate_kind_src( .zip(used_puncts) .filter(|(_, used)| !used) .for_each(|((punct, _), _)| { - if *punct != "_" { + // `..`, `<<` and `>>` are unused in SQL, but used for PL/pgSQL + // https://github.com/postgres/postgres/blob/db0c96cc18aec417101e37e59fcc53d4bf647915/src/backend/parser/gram.y#L692 + if *punct != "_" && *punct != ".." && *punct != "<<" && *punct != ">>" { panic!("Punctuation {punct:?} is not used in grammar"); } }); @@ -206,6 +218,14 @@ fn generate_kind_src( keywords.sort(); keywords.dedup(); + let mut contextual_keywords: Vec<&_> = pl_keywords + .into_iter() + .map(|s| &*s.leak()) + .collect::>(); + contextual_keywords.sort(); + contextual_keywords.dedup(); + let contextual_keywords = Vec::leak(contextual_keywords); + // we leak things here for simplicity, that way we don't have to deal with lifetimes // The execution is a one shot job so thats fine let nodes = nodes @@ -230,6 +250,7 @@ fn generate_kind_src( punct: PUNCT, nodes, keywords, + contextual_keywords, literals, tokens, } @@ -280,10 +301,8 @@ fn generate_keyword_arrays(keyword_kinds: &KeywordKinds) -> Result { Ok(format!("{PRELUDE}{}", output.trim_start())) } -fn generate_syntax_kinds(grammar: KindsSrc) -> Result { - // TODO: we should have a check to make sure each keyword is used in the grammar once the grammar is ready - let conditions = grammar - .keywords +fn keyword_lookup_conditions(keywords: &[&str]) -> Vec { + keywords .iter() .enumerate() .map(|(i, keyword)| { @@ -302,7 +321,13 @@ fn generate_syntax_kinds(grammar: KindsSrc) -> Result { } } }) - .collect::>(); + .collect() +} + +fn generate_syntax_kinds(grammar: KindsSrc) -> Result { + // TODO: we should have a check to make sure each keyword is used in the grammar once the grammar is ready + let conditions = keyword_lookup_conditions(grammar.keywords); + let contextual_conditions = keyword_lookup_conditions(grammar.contextual_keywords); let punctuation = grammar .punct @@ -310,7 +335,12 @@ fn generate_syntax_kinds(grammar: KindsSrc) -> Result { .map(|(_token, name)| format_ident!("{}", name)) .collect::>(); - let all_keywords_values = grammar.keywords.to_vec(); + let all_keywords_values = grammar + .keywords + .iter() + .chain(grammar.contextual_keywords) + .copied() + .collect::>(); let all_keywords = all_keywords_values .iter() .map(|&name| match name { @@ -369,6 +399,14 @@ fn generate_syntax_kinds(grammar: KindsSrc) -> Result { }; Some(kw) } + + #[doc = r"PL/pgSQL keywords that aren't SQL keywords. These stay `IDENT` in the token stream so SQL is unaffected; the PL/pgSQL grammar matches on the contextual kind instead."] + pub(crate) fn from_contextual_keyword(ident: &str) -> Option { + let kw = #(#contextual_conditions)* else { + return None; + }; + Some(kw) + } } } .to_string(), @@ -505,7 +543,9 @@ fn token_to_name(tk: &str) -> Option<&'static str> { "']'" => "r_brack", "]" => "r_brack", "<" => "l_angle", + "<<" => "less_less", ">" => "r_angle", + ">>" => "greater_greater", ">=" => "gteq", "<=" => "lteq", "<>" => "neqb", diff --git a/crates/xtask/src/keywords.rs b/crates/xtask/src/keywords.rs index 92555687..2c22caf6 100644 --- a/crates/xtask/src/keywords.rs +++ b/crates/xtask/src/keywords.rs @@ -4,6 +4,10 @@ use enum_iterator::{Sequence, all}; use rustc_hash::{FxHashMap, FxHashSet}; use squawk_line_index::UniversalNewlines; +const KWLIST: &str = "postgres/kwlist.h"; +const PL_RESERVED_KWLIST: &str = "postgres/pl_reserved_kwlist.h"; +const PL_UNRESERVED_KWLIST: &str = "postgres/pl_unreserved_kwlist.h"; + struct KeywordMeta { pub(crate) category: KeywordCategory, pub(crate) label: KeywordLabel, @@ -76,7 +80,7 @@ fn keyword_allowed(cat: KeywordCategory, kw_type: KWType) -> bool { } fn parse_header() -> Result> { - let kwlist_file = project_root().join("postgres/kwlist.h"); + let kwlist_file = project_root().join(KWLIST); let data = std::fs::read_to_string(kwlist_file).context("Failed to read kwlist.h")?; let mut keywords = FxHashMap::default(); @@ -228,3 +232,41 @@ pub(crate) fn keyword_kinds() -> Result { type_keywords, }) } + +fn parse_pl_header(path: &str) -> Result> { + let file = project_root().join(path); + let data = std::fs::read_to_string(file).with_context(|| format!("Failed to read {path}"))?; + + let mut keywords = vec![]; + for line in data.universal_newlines() { + if !line.starts_with("PG_KEYWORD") { + continue; + } + let row = line + .split(&['(', ')']) + .nth(1) + .with_context(|| format!("Invalid {path} structure"))?; + let name = row + .split(',') + .next() + .with_context(|| format!("Invalid {path} row"))?; + keywords.push(name.trim().replace('"', "")); + } + + Ok(keywords) +} + +// PL/pgSQL keywords that aren't SQL keywords +pub(crate) fn contextual_keywords() -> Result> { + let sql_keywords = parse_header()?; + + let mut keywords = parse_pl_header(PL_RESERVED_KWLIST)? + .into_iter() + .chain(parse_pl_header(PL_UNRESERVED_KWLIST)?) + .filter(|keyword| !sql_keywords.contains_key(keyword)) + .collect::>(); + keywords.sort(); + keywords.dedup(); + + Ok(keywords) +} diff --git a/crates/xtask/src/sync_pg.rs b/crates/xtask/src/sync_pg.rs index 7c46a845..00f08e88 100644 --- a/crates/xtask/src/sync_pg.rs +++ b/crates/xtask/src/sync_pg.rs @@ -9,6 +9,8 @@ use xshell::{Shell, cmd}; const SQL_REGRESSION_SUITE_DIR: &str = "postgres/regression_suite"; const PLPGSQL_REGRESSION_SUITE_DIR: &str = "postgres/plpgsql"; const KWLIST_PATH: &str = "postgres/kwlist.h"; +const PL_RESERVED_KWLIST_PATH: &str = "postgres/pl_reserved_kwlist.h"; +const PL_UNRESERVED_KWLIST_PATH: &str = "postgres/pl_unreserved_kwlist.h"; const START_END_MARKERS: &[(&str, &str)] = &[ ( @@ -181,7 +183,27 @@ pub(crate) fn sync_pg() -> Result<()> { let clone_dir = clone_postgres()?; let (sha, date) = git_head_info(&clone_dir)?; - sync_kwlist(&clone_dir, &sha, &date)?; + sync_kwlist( + &clone_dir, + &sha, + &date, + "src/include/parser/kwlist.h", + KWLIST_PATH, + )?; + sync_kwlist( + &clone_dir, + &sha, + &date, + "src/pl/plpgsql/src/pl_reserved_kwlist.h", + PL_RESERVED_KWLIST_PATH, + )?; + sync_kwlist( + &clone_dir, + &sha, + &date, + "src/pl/plpgsql/src/pl_unreserved_kwlist.h", + PL_UNRESERVED_KWLIST_PATH, + )?; sync_regression_suite(&clone_dir)?; sync_plpgsql_suite(&clone_dir)?; @@ -215,7 +237,7 @@ fn clone_postgres() -> Result { sh.change_dir(&clone_dir); cmd!( sh, - "git sparse-checkout set --no-cone /src/test/regress/sql /src/pl/plpgsql/src/sql /src/include/parser/kwlist.h" + "git sparse-checkout set --no-cone /src/test/regress/sql /src/pl/plpgsql/src/sql /src/include/parser/kwlist.h /src/pl/plpgsql/src/pl_reserved_kwlist.h /src/pl/plpgsql/src/pl_unreserved_kwlist.h" ) .run()?; @@ -230,16 +252,22 @@ fn git_head_info(clone_dir: &Utf8Path) -> Result<(String, String)> { Ok((sha, date)) } -fn sync_kwlist(clone_dir: &Utf8Path, sha: &str, date: &str) -> Result<()> { - println!("Syncing kwlist.h..."); - let source = clone_dir.join("src/include/parser/kwlist.h"); +fn sync_kwlist( + clone_dir: &Utf8Path, + sha: &str, + date: &str, + source_path: &str, + dest_path: &str, +) -> Result<()> { + println!("Syncing {source_path}..."); + let source = clone_dir.join(source_path); let file_content = std::fs::read_to_string(&source)?; let preamble = format!( r"// synced from: // commit: {sha} // committed at: {date} -// file: https://github.com/postgres/postgres/blob/{sha}/src/include/parser/kwlist.h +// file: https://github.com/postgres/postgres/blob/{sha}/{source_path} // // update via: // cargo xtask sync-pg @@ -247,7 +275,7 @@ fn sync_kwlist(clone_dir: &Utf8Path, sha: &str, date: &str) -> Result<()> { " ); - let kwlist_file = project_root().join(KWLIST_PATH); + let kwlist_file = project_root().join(dest_path); let mut file = File::create(kwlist_file)?; file.write_all((preamble + &file_content).as_bytes())?; Ok(()) diff --git a/postgres/pl_reserved_kwlist.h b/postgres/pl_reserved_kwlist.h new file mode 100644 index 00000000..d7b8d04c --- /dev/null +++ b/postgres/pl_reserved_kwlist.h @@ -0,0 +1,58 @@ +// synced from: +// commit: dde9a87d4d02a67a5bd459045f03a6f467a00a80 +// committed at: 2026-07-18T08:00:26+05:30 +// file: https://github.com/postgres/postgres/blob/dde9a87d4d02a67a5bd459045f03a6f467a00a80/src/pl/plpgsql/src/pl_reserved_kwlist.h +// +// update via: +// cargo xtask sync-pg + +/*------------------------------------------------------------------------- + * + * pl_reserved_kwlist.h + * + * The keyword lists are kept in their own source files for use by + * automatic tools. The exact representation of a keyword is determined + * by the PG_KEYWORD macro, which is not defined in this file; it can + * be defined by the caller for special purposes. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/pl/plpgsql/src/pl_reserved_kwlist.h + * + *------------------------------------------------------------------------- + */ + +/* There is deliberately not an #ifndef PL_RESERVED_KWLIST_H here. */ + +/* + * List of (keyword-name, keyword-token-value) pairs. + * + * Be careful not to put the same word into pl_unreserved_kwlist.h. + * + * Note: gen_keywordlist.pl requires the entries to appear in ASCII order. + */ + +/* name, value */ +PG_KEYWORD("all", K_ALL) +PG_KEYWORD("begin", K_BEGIN) +PG_KEYWORD("by", K_BY) +PG_KEYWORD("case", K_CASE) +PG_KEYWORD("declare", K_DECLARE) +PG_KEYWORD("else", K_ELSE) +PG_KEYWORD("end", K_END) +PG_KEYWORD("for", K_FOR) +PG_KEYWORD("foreach", K_FOREACH) +PG_KEYWORD("from", K_FROM) +PG_KEYWORD("if", K_IF) +PG_KEYWORD("in", K_IN) +PG_KEYWORD("into", K_INTO) +PG_KEYWORD("loop", K_LOOP) +PG_KEYWORD("not", K_NOT) +PG_KEYWORD("null", K_NULL) +PG_KEYWORD("or", K_OR) +PG_KEYWORD("then", K_THEN) +PG_KEYWORD("to", K_TO) +PG_KEYWORD("using", K_USING) +PG_KEYWORD("when", K_WHEN) +PG_KEYWORD("while", K_WHILE) diff --git a/postgres/pl_unreserved_kwlist.h b/postgres/pl_unreserved_kwlist.h new file mode 100644 index 00000000..cd81fc52 --- /dev/null +++ b/postgres/pl_unreserved_kwlist.h @@ -0,0 +1,122 @@ +// synced from: +// commit: dde9a87d4d02a67a5bd459045f03a6f467a00a80 +// committed at: 2026-07-18T08:00:26+05:30 +// file: https://github.com/postgres/postgres/blob/dde9a87d4d02a67a5bd459045f03a6f467a00a80/src/pl/plpgsql/src/pl_unreserved_kwlist.h +// +// update via: +// cargo xtask sync-pg + +/*------------------------------------------------------------------------- + * + * pl_unreserved_kwlist.h + * + * The keyword lists are kept in their own source files for use by + * automatic tools. The exact representation of a keyword is determined + * by the PG_KEYWORD macro, which is not defined in this file; it can + * be defined by the caller for special purposes. + * + * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group + * Portions Copyright (c) 1994, Regents of the University of California + * + * src/pl/plpgsql/src/pl_unreserved_kwlist.h + * + *------------------------------------------------------------------------- + */ + +/* There is deliberately not an #ifndef PL_UNRESERVED_KWLIST_H here. */ + +/* + * List of (keyword-name, keyword-token-value) pairs. + * + * Be careful not to put the same word into pl_reserved_kwlist.h. Also be + * sure that pl_gram.y's unreserved_keyword production agrees with this list. + * + * Note: gen_keywordlist.pl requires the entries to appear in ASCII order. + */ + +/* name, value */ +PG_KEYWORD("absolute", K_ABSOLUTE) +PG_KEYWORD("alias", K_ALIAS) +PG_KEYWORD("and", K_AND) +PG_KEYWORD("array", K_ARRAY) +PG_KEYWORD("assert", K_ASSERT) +PG_KEYWORD("backward", K_BACKWARD) +PG_KEYWORD("call", K_CALL) +PG_KEYWORD("chain", K_CHAIN) +PG_KEYWORD("close", K_CLOSE) +PG_KEYWORD("collate", K_COLLATE) +PG_KEYWORD("column", K_COLUMN) +PG_KEYWORD("column_name", K_COLUMN_NAME) +PG_KEYWORD("commit", K_COMMIT) +PG_KEYWORD("constant", K_CONSTANT) +PG_KEYWORD("constraint", K_CONSTRAINT) +PG_KEYWORD("constraint_name", K_CONSTRAINT_NAME) +PG_KEYWORD("continue", K_CONTINUE) +PG_KEYWORD("current", K_CURRENT) +PG_KEYWORD("cursor", K_CURSOR) +PG_KEYWORD("datatype", K_DATATYPE) +PG_KEYWORD("debug", K_DEBUG) +PG_KEYWORD("default", K_DEFAULT) +PG_KEYWORD("detail", K_DETAIL) +PG_KEYWORD("diagnostics", K_DIAGNOSTICS) +PG_KEYWORD("do", K_DO) +PG_KEYWORD("dump", K_DUMP) +PG_KEYWORD("elseif", K_ELSIF) +PG_KEYWORD("elsif", K_ELSIF) +PG_KEYWORD("errcode", K_ERRCODE) +PG_KEYWORD("error", K_ERROR) +PG_KEYWORD("exception", K_EXCEPTION) +PG_KEYWORD("execute", K_EXECUTE) +PG_KEYWORD("exit", K_EXIT) +PG_KEYWORD("fetch", K_FETCH) +PG_KEYWORD("first", K_FIRST) +PG_KEYWORD("forward", K_FORWARD) +PG_KEYWORD("get", K_GET) +PG_KEYWORD("hint", K_HINT) +PG_KEYWORD("import", K_IMPORT) +PG_KEYWORD("info", K_INFO) +PG_KEYWORD("insert", K_INSERT) +PG_KEYWORD("is", K_IS) +PG_KEYWORD("last", K_LAST) +PG_KEYWORD("log", K_LOG) +PG_KEYWORD("merge", K_MERGE) +PG_KEYWORD("message", K_MESSAGE) +PG_KEYWORD("message_text", K_MESSAGE_TEXT) +PG_KEYWORD("move", K_MOVE) +PG_KEYWORD("next", K_NEXT) +PG_KEYWORD("no", K_NO) +PG_KEYWORD("notice", K_NOTICE) +PG_KEYWORD("open", K_OPEN) +PG_KEYWORD("option", K_OPTION) +PG_KEYWORD("perform", K_PERFORM) +PG_KEYWORD("pg_context", K_PG_CONTEXT) +PG_KEYWORD("pg_datatype_name", K_PG_DATATYPE_NAME) +PG_KEYWORD("pg_exception_context", K_PG_EXCEPTION_CONTEXT) +PG_KEYWORD("pg_exception_detail", K_PG_EXCEPTION_DETAIL) +PG_KEYWORD("pg_exception_hint", K_PG_EXCEPTION_HINT) +PG_KEYWORD("pg_routine_oid", K_PG_ROUTINE_OID) +PG_KEYWORD("print_strict_params", K_PRINT_STRICT_PARAMS) +PG_KEYWORD("prior", K_PRIOR) +PG_KEYWORD("query", K_QUERY) +PG_KEYWORD("raise", K_RAISE) +PG_KEYWORD("relative", K_RELATIVE) +PG_KEYWORD("return", K_RETURN) +PG_KEYWORD("returned_sqlstate", K_RETURNED_SQLSTATE) +PG_KEYWORD("reverse", K_REVERSE) +PG_KEYWORD("rollback", K_ROLLBACK) +PG_KEYWORD("row_count", K_ROW_COUNT) +PG_KEYWORD("rowtype", K_ROWTYPE) +PG_KEYWORD("schema", K_SCHEMA) +PG_KEYWORD("schema_name", K_SCHEMA_NAME) +PG_KEYWORD("scroll", K_SCROLL) +PG_KEYWORD("slice", K_SLICE) +PG_KEYWORD("sqlstate", K_SQLSTATE) +PG_KEYWORD("stacked", K_STACKED) +PG_KEYWORD("strict", K_STRICT) +PG_KEYWORD("table", K_TABLE) +PG_KEYWORD("table_name", K_TABLE_NAME) +PG_KEYWORD("type", K_TYPE) +PG_KEYWORD("use_column", K_USE_COLUMN) +PG_KEYWORD("use_variable", K_USE_VARIABLE) +PG_KEYWORD("variable_conflict", K_VARIABLE_CONFLICT) +PG_KEYWORD("warning", K_WARNING)