diff --git a/crates/cli/src/bin/corpus_report.rs b/crates/cli/src/bin/corpus_report.rs index 8e63a52..f488b75 100644 --- a/crates/cli/src/bin/corpus_report.rs +++ b/crates/cli/src/bin/corpus_report.rs @@ -28,6 +28,9 @@ struct StmtStats { ok: usize, select_total: usize, select_ok: usize, + /// PL/pgSQL (dollar-quoted) bodies, parsed with the body grammar. + bodies_total: usize, + bodies_ok: usize, } struct FileResult { @@ -142,6 +145,28 @@ fn run_file(source: &str, show_diagnostics: bool) -> FileResult { }; } + // PL/pgSQL body coverage: parse every plpgsql dollar-quoted body. + if source.to_ascii_lowercase().contains("plpgsql") { + for token in tokens.iter().filter(|t| t.kind == SyntaxKind::DollarString) { + let Some(open) = token.text[1..].find('$').map(|i| i + 2) else { + continue; + }; + let tag = &token.text[..open]; + let Some(body) = token.text[tag.len()..].strip_suffix(tag) else { + continue; + }; + if body.trim().is_empty() { + continue; + } + stmts.bodies_total += 1; + let body_tokens = parser::lexer::lex_with(body, Dialect::Postgres, LEX_OPTIONS); + let body_parse = parser::parser::parse_plpgsql_body(&body_tokens, Dialect::Postgres); + if body_parse.diagnostics.is_empty() { + stmts.bodies_ok += 1; + } + } + } + let format_options = formatter::Options { at_params: true, ..formatter::Options::default() @@ -226,6 +251,8 @@ fn main() -> ExitCode { totals.ok += result.stmts.ok; totals.select_total += result.stmts.select_total; totals.select_ok += result.stmts.select_ok; + totals.bodies_total += result.stmts.bodies_total; + totals.bodies_ok += result.stmts.bodies_ok; if !summary_only { match result.error { None => println!("ok {}", path.display()), @@ -249,6 +276,10 @@ fn main() -> ExitCode { "select-ish {:>5}/{} parsed", totals.select_ok, totals.select_total ); + println!( + "pl bodies {:>5}/{} parsed", + totals.bodies_ok, totals.bodies_total + ); if unreadable > 0 { println!("unreadable: {unreadable}"); } diff --git a/crates/parser/src/parser.rs b/crates/parser/src/parser.rs index c6cc5ce..34c1e51 100644 --- a/crates/parser/src/parser.rs +++ b/crates/parser/src/parser.rs @@ -14,6 +14,7 @@ mod ddl; mod dml; mod expr; mod grammar; +mod plpgsql; use crate::dialect::Dialect; use crate::lexer::Token; @@ -62,6 +63,7 @@ pub fn parse(tokens: &[Token<'_>], dialect: Dialect) -> Parse { dialect, eof, depth: 0, + in_plpgsql: false, }; parser.events.push(Event::StartNode(SyntaxKind::Root)); while !parser.at_eof() { @@ -74,6 +76,46 @@ pub fn parse(tokens: &[Token<'_>], dialect: Dialect) -> Parse { } } +/// Parse a PL/pgSQL function body (the content of a dollar-quoted +/// `LANGUAGE plpgsql` string) into a CST of `Pl*` statement nodes. +/// Same guarantees as [`parse`]: always lossless, never fails. +pub fn parse_plpgsql_body(tokens: &[Token<'_>], dialect: Dialect) -> Parse { + let mut toks = Vec::new(); + let mut offset = 0; + for token in tokens { + let end = offset + token.text.len(); + if !token.kind.is_trivia() { + toks.push(Tok { + kind: token.kind, + text: token.text, + start: offset, + end, + }); + } + offset = end; + } + let eof = offset; + let mut parser = Parser { + toks, + pos: 0, + events: Vec::new(), + diagnostics: Vec::new(), + dialect, + eof, + depth: 0, + in_plpgsql: true, + }; + parser.events.push(Event::StartNode(SyntaxKind::Root)); + while !parser.at_eof() { + plpgsql::body_statement(&mut parser); + } + parser.events.push(Event::FinishNode); + Parse { + cst: build_tree(tokens, &parser.events), + diagnostics: parser.diagnostics, + } +} + /// A non-trivia token with its byte span. struct Tok<'src> { kind: SyntaxKind, @@ -103,6 +145,9 @@ pub(crate) struct Parser<'src> { dialect: Dialect, eof: usize, depth: u32, + /// Parsing a PL/pgSQL body: enables `INTO [STRICT]` targets in + /// query positions. + in_plpgsql: bool, } impl Parser<'_> { @@ -150,8 +195,12 @@ impl Parser<'_> { /// Is the current token an operator with exactly this text? pub(crate) fn at_op(&self, op: &str) -> bool { + self.nth_at_op(0, op) + } + + pub(crate) fn nth_at_op(&self, n: usize, op: &str) -> bool { self.toks - .get(self.pos) + .get(self.pos + n) .is_some_and(|t| t.kind == SyntaxKind::Operator && t.text == op) } @@ -278,7 +327,7 @@ impl Parser<'_> { // ---- statements & recovery ---- - fn statement(&mut self) { + pub(crate) fn statement(&mut self) { if self.at(SyntaxKind::Semicolon) { self.start(SyntaxKind::EmptyStmt); self.bump(); @@ -323,7 +372,7 @@ impl Parser<'_> { /// next top-level `;`, and record the diagnostic. Top-level means: /// dollar-quoted bodies are already single tokens, and in SQLite a /// `BEGIN ... END` trigger body does not end the statement. - fn error_statement(&mut self, error: StmtError) { + pub(crate) fn error_statement(&mut self, error: StmtError) { let (start, end) = match self.toks.get(error.at) { Some(t) => (t.start, t.end), None => (self.eof, self.eof), @@ -364,6 +413,10 @@ impl Parser<'_> { || self.nth_kind(1).is_none() } + pub(crate) fn in_plpgsql(&self) -> bool { + self.in_plpgsql + } + pub(crate) fn dialect(&self) -> Dialect { self.dialect } diff --git a/crates/parser/src/parser/dml.rs b/crates/parser/src/parser/dml.rs index 0b0fe6f..d4d7029 100644 --- a/crates/parser/src/parser/dml.rs +++ b/crates/parser/src/parser/dml.rs @@ -253,6 +253,9 @@ pub(crate) fn returning_clause(p: &mut Parser<'_>) -> PResult { p.start(SyntaxKind::ReturningClause); p.expect_kw("returning")?; select_list(p)?; + if p.in_plpgsql() && p.at_kw("into") { + super::grammar::pl_into(p)?; + } p.finish(); Ok(()) } diff --git a/crates/parser/src/parser/grammar.rs b/crates/parser/src/parser/grammar.rs index bae9ee1..9611e0a 100644 --- a/crates/parser/src/parser/grammar.rs +++ b/crates/parser/src/parser/grammar.rs @@ -26,6 +26,8 @@ const SELECT_ITEM_STOP: &[&str] = &[ // INSERT ... SELECT tails. "on", "do", + // PL/pgSQL FOR ... IN SELECT ... LOOP. + "loop", ]; /// Keywords that stop a bare table alias in FROM position. @@ -54,6 +56,8 @@ const TABLE_ALIAS_STOP: &[&str] = &[ "except", "with", "returning", + // PL/pgSQL FOR ... IN SELECT ... LOOP. + "loop", ]; /// A full query statement: `[WITH ...] query [trailing clauses] [;]`. @@ -219,6 +223,14 @@ fn query_primary_inner(p: &mut Parser<'_>) -> PResult { fn select_core(p: &mut Parser<'_>) -> PResult { p.start(SyntaxKind::SelectCore); p.expect_kw("select")?; + select_core_rest(p)?; + p.finish(); + Ok(()) +} + +/// Everything after the `SELECT` keyword — also reused by PL/pgSQL +/// `PERFORM`, which is SELECT syntax under another name. +pub(crate) fn select_core_rest(p: &mut Parser<'_>) -> PResult { if p.at_kw("distinct") { p.bump(); if p.at_kw("on") { @@ -229,6 +241,9 @@ fn select_core(p: &mut Parser<'_>) -> PResult { p.eat_kw("all"); } select_list(p)?; + if p.in_plpgsql() && p.at_kw("into") { + pl_into(p)?; + } if p.at_kw("from") { from_clause(p)?; } @@ -247,6 +262,20 @@ fn select_core(p: &mut Parser<'_>) -> PResult { if p.at_kw("window") { window_clause(p)?; } + Ok(()) +} + +/// PL/pgSQL `INTO [STRICT] target [, ...]`. +pub(crate) fn pl_into(p: &mut Parser<'_>) -> PResult { + p.start(SyntaxKind::PlInto); + p.expect_kw("into")?; + p.eat_kw("strict"); + loop { + qualified_name(p)?; + if !p.eat(SyntaxKind::Comma) { + break; + } + } p.finish(); Ok(()) } @@ -614,7 +643,7 @@ fn values_clause(p: &mut Parser<'_>) -> PResult { Ok(()) } -fn trailing_clauses(p: &mut Parser<'_>) -> PResult { +pub(crate) fn trailing_clauses(p: &mut Parser<'_>) -> PResult { if p.at_kw("order") { order_by_clause(p)?; } diff --git a/crates/parser/src/parser/plpgsql.rs b/crates/parser/src/parser/plpgsql.rs new file mode 100644 index 0000000..3bf0dde --- /dev/null +++ b/crates/parser/src/parser/plpgsql.rs @@ -0,0 +1,483 @@ +//! PL/pgSQL body grammar (TREE-102). +//! +//! A statement-level wrapper language: PL/pgSQL tokenizes exactly like +//! SQL, and embedded SQL statements dispatch to the existing statement +//! grammar (with `INTO [STRICT]` enabled via the parser's plpgsql flag). +//! Every body statement gets the same rollback-to-ErrorStatement +//! recovery as top-level SQL. + +use crate::syntax::SyntaxKind; + +use super::expr::{expr, type_name}; +use super::grammar::{pl_into, qualified_name, query_body, select_core_rest, trailing_clauses}; +use super::{PResult, Parser}; + +/// Keywords that end a statement list inside a block construct. +const STMT_STOP: &[&str] = &["end", "elsif", "else", "when", "exception"]; + +/// Parse one body-level statement with error recovery. +pub(crate) fn body_statement(p: &mut Parser<'_>) { + if p.at(SyntaxKind::Semicolon) { + p.start(SyntaxKind::EmptyStmt); + p.bump(); + p.finish(); + return; + } + let state = p.state(); + if let Err(error) = pl_statement(p) { + p.backtrack(state); + p.error_statement(error); + } +} + +/// A statement list that stops before block-closing keywords. +fn pl_statements(p: &mut Parser<'_>) -> PResult { + while !p.at_eof() && !p.at_any_kw(STMT_STOP) { + body_statement(p); + } + Ok(()) +} + +fn pl_statement(p: &mut Parser<'_>) -> PResult { + p.enter_depth()?; + let result = pl_statement_inner(p); + p.exit_depth(); + result +} + +fn pl_statement_inner(p: &mut Parser<'_>) -> PResult { + if p.at_kw("declare") || p.at_kw("begin") || p.at_op("<<") { + return pl_block(p); + } + if p.at_kw("if") { + return pl_if(p); + } + if p.at_kw("case") { + return pl_case(p); + } + if p.at_any_kw(&["loop", "while", "for", "foreach"]) { + return pl_loop(p); + } + if p.at_kw("exit") || p.at_kw("continue") { + return pl_exit(p); + } + if p.at_kw("return") { + return pl_return(p); + } + if p.at_kw("raise") { + return pl_raise(p); + } + if p.at_kw("perform") { + return pl_perform(p); + } + if p.at_kw("execute") { + return pl_execute(p); + } + if p.at_kw("get") { + return pl_get_diagnostics(p); + } + if p.at_kw("null") && p.nth_at(1, SyntaxKind::Semicolon) { + p.start(SyntaxKind::PlNull); + p.bump(); + p.bump(); + p.finish(); + return Ok(()); + } + // Assignment: `name[.name][[idx]] := expr;` (also plain `=`). + if p.at(SyntaxKind::Ident) || p.at(SyntaxKind::QuotedIdent) { + let state = p.state(); + if pl_assign(p).is_ok() { + return Ok(()); + } + p.backtrack(state); + } + // Everything else: the regular SQL statement grammar (SELECT, DML, + // DDL, ...), which carries its own recovery. + if p.at_eof() { + return Err(p.error("expected a statement")); + } + p.statement(); + Ok(()) +} + +/// `[<