diff --git a/crates/pgls_pretty_print/src/codegen/mod.rs b/crates/pgls_pretty_print/src/codegen/mod.rs index 3746b4ac5..15a1718b5 100644 --- a/crates/pgls_pretty_print/src/codegen/mod.rs +++ b/crates/pgls_pretty_print/src/codegen/mod.rs @@ -1,2 +1,27 @@ pub mod group_kind; +pub mod node_location; pub mod token_kind; + +#[cfg(test)] +mod tests { + use crate::codegen::node_location::node_location; + use pgls_query::NodeEnum; + + #[test] + fn a_node_carrying_a_location_reports_it() { + let parsed = pgls_query::parse("SELECT 1 FROM s.t").expect("parse"); + let ast = parsed.into_root().expect("root"); + + let located = ast + .iter() + .filter(|node| node_location(node).is_some()) + .count(); + assert!(located > 0, "a select statement has located nodes"); + } + + #[test] + fn a_node_without_a_location_reports_none() { + let node = NodeEnum::Boolean(pgls_query::protobuf::Boolean { boolval: true }); + assert_eq!(node_location(&node.to_ref()), None); + } +} diff --git a/crates/pgls_pretty_print/src/codegen/node_location.rs b/crates/pgls_pretty_print/src/codegen/node_location.rs new file mode 100644 index 000000000..94966fbb6 --- /dev/null +++ b/crates/pgls_pretty_print/src/codegen/node_location.rs @@ -0,0 +1 @@ +pgls_pretty_print_codegen::node_location_codegen!(); diff --git a/crates/pgls_pretty_print/src/comments.rs b/crates/pgls_pretty_print/src/comments.rs new file mode 100644 index 000000000..9a5068b9a --- /dev/null +++ b/crates/pgls_pretty_print/src/comments.rs @@ -0,0 +1,339 @@ +use std::collections::HashMap; + +use pgls_query::{NodeEnum, protobuf::Token}; + +use crate::codegen::node_location::node_location; + +/// A comment found in the source of a statement. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Comment { + pub text: String, + /// True for `--` comments, which run to the end of the line and therefore force a break. + pub line_comment: bool, +} + +/// Comments of a statement, indexed by the node they surround. +#[derive(Debug, Default)] +pub struct AttachedComments { + /// Comments emitted before the node at this location. + pub leading_by_location: HashMap>, + /// Comments emitted after the node at this location. + pub trailing_by_location: HashMap>, + /// Comments that neither neighbour can hold, which today means a comment written after a + /// statement terminator. The caller must not reformat a statement that has any: emitting it + /// would drop them. + pub unattached: Vec, +} + +/// Which side of a node a comment ends up on, once the node is known. +enum Placement { + /// Emitted before the node at this location. + Leading(i32), + /// Emitted after the node at this location. + Trailing(i32), +} + +/// Attaches every comment of `sql` to a nearby AST node. +/// +/// Attachment is positional because libpg_query drops comments from the AST, and positional is +/// enough: nodes carrying an i32 location field preserve the byte offset they were parsed from. +/// A comment preceded only by whitespace on its line is leading and belongs to the next node. A +/// comment following SQL on the same line is trailing and belongs to the previous node. +pub fn attach_comments(sql: &str, ast: &NodeEnum) -> AttachedComments { + let mut attached = AttachedComments::default(); + + let comments = collect_comments(sql); + if comments.is_empty() { + return attached; + } + + let mut locations = collect_node_locations(ast); + locations.sort_unstable(); + + for source_comment in comments { + let line_start = sql[..source_comment.start] + .rfind('\n') + .map_or(0, |offset| offset + 1); + let line_prefix = &sql[line_start..source_comment.start]; + + // A line comment written after a terminator documents the next statement, not this one. + // Attaching it here would move it across a statement boundary. + if source_comment.comment.line_comment && line_prefix.trim_end().ends_with(';') { + attached.unattached.push(source_comment.comment); + continue; + } + + let leads = !source_comment.comment.line_comment + || line_prefix.trim().is_empty() + || ends_with_clause_header(line_prefix) + || ends_with_structural_separator(line_prefix); + + let next = locations + .iter() + .find(|location| **location as usize >= source_comment.end) + .copied(); + let previous = locations + .iter() + .rev() + .find(|location| **location as usize <= source_comment.start) + .copied(); + + // The preferred side first, the other one as a fallback. A comment closing a list or a + // statement has no node after it, and printing it after the node it already follows in the + // source keeps it where its author wrote it, where refusing the statement keeps nothing. + let placement = if leads { + next.map(Placement::Leading) + .or_else(|| previous.map(Placement::Trailing)) + } else { + previous + .map(Placement::Trailing) + .or_else(|| next.map(Placement::Leading)) + }; + + match placement { + Some(Placement::Leading(location)) => attached + .leading_by_location + .entry(location) + .or_default() + .push(source_comment.comment), + Some(Placement::Trailing(location)) => attached + .trailing_by_location + .entry(location) + .or_default() + .push(source_comment.comment), + None => attached.unattached.push(source_comment.comment), + } + } + + attached +} + +/// Returns whether `line_prefix` ends with a SQL clause or connective keyword. +/// +/// Such a keyword is not represented by an AST node with its own source location. A line comment +/// immediately after it must therefore be emitted before the following expression, not after the +/// previously emitted AST node. +fn ends_with_clause_header(line_prefix: &str) -> bool { + const HEADERS: &[&str] = &[ + "SELECT", + "FROM", + "WHERE", + "GROUP BY", + "HAVING", + "WINDOW", + "ORDER BY", + "LIMIT", + "OFFSET", + "FETCH", + "JOIN", + "ON", + "USING", + "AND", + "OR", + "WHEN", + "THEN", + "ELSE", + "VALUES", + "SET", + "RETURNING", + "UNION", + "INTERSECT", + "EXCEPT", + ]; + + let normalized = line_prefix.trim_end().to_ascii_uppercase(); + + HEADERS.iter().any(|header| { + let Some(prefix) = normalized.strip_suffix(header) else { + return false; + }; + + prefix.is_empty() || prefix.chars().last().is_some_and(char::is_whitespace) + }) +} + +/// Returns whether `line_prefix` ends with punctuation that separates AST nodes. +/// +/// Commas, brackets and braces are emitted by parent formatters rather than a dedicated AST node. +/// A comment after one of them must be emitted before the following node; otherwise it is +/// incorrectly attached to the last child inside the preceding expression on the next pass. +/// Parentheses are deliberately excluded: they can close a semantic expression, so a following +/// comment belongs to that expression rather than to the next node. +fn ends_with_structural_separator(line_prefix: &str) -> bool { + matches!(line_prefix.trim_end().chars().last(), Some(',' | ']' | '}')) +} + +struct SourceComment { + start: usize, + end: usize, + comment: Comment, +} + +/// Every comment of the statement, with its source range, in source order. +fn collect_comments(sql: &str) -> Vec { + let Ok(scan) = pgls_query::scan(sql) else { + return Vec::new(); + }; + + scan.tokens + .iter() + .filter_map(|token| { + let kind = Token::try_from(token.token).ok()?; + let line_comment = match kind { + Token::SqlComment => true, + Token::CComment => false, + _ => return None, + }; + + let start = usize::try_from(token.start).ok()?; + let end = usize::try_from(token.end).ok()?; + let text = sql.get(start..end)?.trim_end().to_string(); + + Some(SourceComment { + start, + end, + comment: Comment { text, line_comment }, + }) + }) + .collect() +} + +fn collect_node_locations(ast: &NodeEnum) -> Vec { + ast.iter() + .filter_map(|node| node_location(&node)) + .filter(|location| *location >= 0) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(sql: &str) -> pgls_query::NodeEnum { + pgls_query::parse(sql) + .expect("parse") + .into_root() + .expect("root") + } + + #[test] + fn a_comment_attaches_to_the_node_that_follows_it() { + let sql = "SELECT\n-- pick the magic value\n1 FROM s.t"; + let attached = attach_comments(sql, &parse(sql)); + + assert!(attached.unattached.is_empty()); + assert_eq!(attached.leading_by_location.len(), 1); + assert!(attached.trailing_by_location.is_empty()); + + let comments = attached + .leading_by_location + .values() + .next() + .expect("one entry"); + assert_eq!(comments.len(), 1); + assert_eq!(comments[0].text, "-- pick the magic value"); + assert!(comments[0].line_comment); + } + + #[test] + fn a_block_comment_is_not_a_line_comment() { + let sql = "SELECT /* inline */ 1 FROM s.t"; + let attached = attach_comments(sql, &parse(sql)); + + let comments = attached + .leading_by_location + .values() + .next() + .expect("one entry"); + assert_eq!(comments[0].text, "/* inline */"); + assert!(!comments[0].line_comment); + } + + #[test] + fn a_trailing_comment_attaches_to_the_node_that_precedes_it() { + let sql = "SELECT * FROM t WHERE a = 1 -- context\nAND b = 2"; + let attached = attach_comments(sql, &parse(sql)); + + assert!(attached.unattached.is_empty()); + assert!(attached.leading_by_location.is_empty()); + assert_eq!(attached.trailing_by_location.len(), 1); + + let comments = attached + .trailing_by_location + .values() + .next() + .expect("one entry"); + assert_eq!(comments[0].text, "-- context"); + assert!(comments[0].line_comment); + } + + #[test] + fn a_comment_after_a_clause_header_attaches_to_the_node_that_follows_it() { + let sql = "SELECT * FROM t ORDER BY -- sort by name\nname"; + let attached = attach_comments(sql, &parse(sql)); + + assert!(attached.unattached.is_empty()); + assert_eq!(attached.leading_by_location.len(), 1); + assert!(attached.trailing_by_location.is_empty()); + + let comments = attached + .leading_by_location + .values() + .next() + .expect("one entry"); + assert_eq!(comments[0].text, "-- sort by name"); + assert!(comments[0].line_comment); + } + + #[test] + fn a_comment_after_a_separator_attaches_to_the_node_that_follows_it() { + let sql = "SELECT a, -- temporarily omit b\nb FROM t"; + let attached = attach_comments(sql, &parse(sql)); + + assert!(attached.unattached.is_empty()); + assert_eq!(attached.leading_by_location.len(), 1); + assert!(attached.trailing_by_location.is_empty()); + + let comments = attached + .leading_by_location + .values() + .next() + .expect("one entry"); + assert_eq!(comments[0].text, "-- temporarily omit b"); + assert!(comments[0].line_comment); + } + + #[test] + fn a_comment_with_no_node_after_it_falls_back_to_the_previous_node() { + let sql = "SELECT 1 FROM s.t -- trailing"; + let attached = attach_comments(sql, &parse(sql)); + + assert!(attached.unattached.is_empty()); + assert_eq!(attached.trailing_by_location.len(), 1); + let comments = attached + .trailing_by_location + .values() + .next() + .expect("one entry"); + assert_eq!(comments[0].text, "-- trailing"); + } + + #[test] + fn a_comment_after_a_statement_terminator_stays_unattached() { + let sql = "SELECT 1 FROM s.t; -- trailing"; + let attached = attach_comments(sql, &parse(sql)); + + assert_eq!(attached.unattached.len(), 1); + assert_eq!(attached.unattached[0].text, "-- trailing"); + } + + #[test] + fn a_statement_without_comments_produces_an_empty_map() { + let sql = "SELECT 1 FROM s.t"; + let attached = attach_comments(sql, &parse(sql)); + + assert!(attached.leading_by_location.is_empty()); + assert!(attached.trailing_by_location.is_empty()); + assert!(attached.unattached.is_empty()); + } +} diff --git a/crates/pgls_pretty_print/src/emitter.rs b/crates/pgls_pretty_print/src/emitter.rs index 368c79b23..06fa0441c 100644 --- a/crates/pgls_pretty_print/src/emitter.rs +++ b/crates/pgls_pretty_print/src/emitter.rs @@ -1,5 +1,8 @@ +use std::collections::HashMap; + pub use crate::codegen::group_kind::GroupKind; pub use crate::codegen::token_kind::TokenKind; +use crate::Comment; #[derive(Debug, Clone, PartialEq)] pub enum LineType { @@ -16,7 +19,15 @@ pub enum LayoutEvent { Token(TokenKind), Space, Line(LineType), - GroupStart { kind: GroupKind }, + /// A comment from the source. `line_comment` is true for `--`, which runs to the end of the + /// line and therefore forbids collapsing the enclosing group. + Comment { + text: String, + line_comment: bool, + }, + GroupStart { + kind: GroupKind, + }, GroupEnd, IndentStart, IndentEnd, @@ -25,6 +36,15 @@ pub enum LayoutEvent { #[derive(Debug, Default)] pub struct EventEmitter { pub events: Vec, + /// Comments still waiting to be emitted before a node, by source location. + /// Entries are removed as they are emitted so that a comment cannot be printed twice, and so + /// that the caller can check the map is empty afterwards. + leading_comments: HashMap>, + /// Comments still waiting to be emitted after a node, by source location. + trailing_comments: HashMap>, + /// Boolean operands need standalone line comments to start their own line, otherwise a + /// comment can join the preceding operand and drift on the next formatting pass. + leading_line_comments_require_break: bool, } impl EventEmitter { @@ -32,6 +52,18 @@ impl EventEmitter { Self::default() } + pub fn with_comments( + leading_comments: HashMap>, + trailing_comments: HashMap>, + ) -> Self { + Self { + events: Vec::new(), + leading_comments, + trailing_comments, + leading_line_comments_require_break: false, + } + } + pub fn token(&mut self, token: TokenKind) { self.events.push(LayoutEvent::Token(token)); } @@ -44,6 +76,68 @@ impl EventEmitter { self.events.push(LayoutEvent::Line(line_type)); } + pub fn comment(&mut self, text: String, line_comment: bool) { + self.events + .push(LayoutEvent::Comment { text, line_comment }); + } + + /// Emits and consumes comments that precede `location`, if any. + pub fn take_leading_comments_at(&mut self, location: i32) { + let Some(comments) = self.leading_comments.remove(&location) else { + return; + }; + + for comment in comments { + let line_comment = comment.line_comment; + if line_comment && self.leading_line_comments_require_break { + self.force_current_line_break(); + } + self.comment(comment.text, line_comment); + if line_comment { + self.line(LineType::Hard); + } else { + self.space(); + } + } + } + + /// Emits and consumes comments that follow `location`, if any. + pub fn take_trailing_comments_at(&mut self, location: i32) { + let Some(comments) = self.trailing_comments.remove(&location) else { + return; + }; + + for comment in comments { + let line_comment = comment.line_comment; + self.space(); + self.comment(comment.text, line_comment); + if line_comment { + self.line(LineType::Hard); + } else { + self.space(); + } + } + } + + pub fn pending_comments(&self) -> usize { + self.leading_comments.values().map(Vec::len).sum::() + + self.trailing_comments.values().map(Vec::len).sum::() + } + + pub fn with_leading_comment_line_break(&mut self, body: impl FnOnce(&mut EventEmitter)) { + let previous = std::mem::replace(&mut self.leading_line_comments_require_break, true); + body(self); + self.leading_line_comments_require_break = previous; + } + + fn force_current_line_break(&mut self) { + match self.events.last_mut() { + None => {} + Some(LayoutEvent::Line(line_type)) => *line_type = LineType::Hard, + Some(_) => self.line(LineType::Hard), + } + } + pub fn group_start(&mut self, kind: GroupKind) { self.events.push(LayoutEvent::GroupStart { kind }); } diff --git a/crates/pgls_pretty_print/src/lib.rs b/crates/pgls_pretty_print/src/lib.rs index 008e4faa8..9673b97c1 100644 --- a/crates/pgls_pretty_print/src/lib.rs +++ b/crates/pgls_pretty_print/src/lib.rs @@ -1,10 +1,14 @@ mod codegen; +pub mod comments; pub mod emitter; pub mod nodes; pub mod normalize; pub mod renderer; +use std::collections::HashSet; + pub use crate::codegen::token_kind::TokenKind; +pub use crate::comments::{AttachedComments, Comment, attach_comments}; pub use crate::normalize::normalize_ast; pub use crate::renderer::{IndentStyle, KeywordCase, RenderConfig}; use pgls_query::NodeEnum; @@ -29,6 +33,16 @@ pub enum FormatError { Details: {message}" )] BetaUnsupported { message: String }, + + /// A comment could not be placed in the formatted output. + #[error("Formatter: {count} comment(s) could not be placed, the statement was left as written")] + UnplaceableComment { count: usize }, + + /// Reformatting a statement with comments produced a cycle instead of a fixed layout. + #[error( + "Formatter: comment layout did not stabilize after {passes} passes, the statement was left as written" + )] + NonIdempotentCommentLayout { passes: usize }, } /// Configuration for the SQL formatter. @@ -100,12 +114,76 @@ pub struct FormatResult { /// * `Ok(FormatResult)` - The formatted SQL /// * `Err(FormatError)` - If formatting fails or beta safety check fails pub fn format_statement( + ast: &NodeEnum, + sql: &str, + config: &FormatConfig, +) -> Result { + const MAX_COMMENT_FORMAT_PASSES: usize = 6; + + let attached = comments::attach_comments(sql, ast); + let has_comments = !attached.leading_by_location.is_empty() + || !attached.trailing_by_location.is_empty() + || !attached.unattached.is_empty(); + + if !has_comments { + return format_statement_once(ast, config, attached); + } + + let mut current_sql = sql.to_string(); + let mut current_ast = ast.clone(); + let mut attached = attached; + let mut seen_layouts = HashSet::from([current_sql.clone()]); + + for pass in 1..=MAX_COMMENT_FORMAT_PASSES { + let result = format_statement_once(¤t_ast, config, attached)?; + if result.formatted == current_sql { + return Ok(result); + } + + if !seen_layouts.insert(result.formatted.clone()) { + return Err(FormatError::NonIdempotentCommentLayout { passes: pass }); + } + + current_sql = result.formatted; + current_ast = pgls_query::parse(¤t_sql) + .map_err(|e| FormatError::ParseError { + message: format!("Formatted SQL failed to parse: {e}"), + })? + .into_root() + .ok_or_else(|| FormatError::ParseError { + message: "No root node in parsed output (expected single statement)".to_string(), + })?; + attached = comments::attach_comments(¤t_sql, ¤t_ast); + } + + Err(FormatError::NonIdempotentCommentLayout { + passes: MAX_COMMENT_FORMAT_PASSES, + }) +} + +/// Formats a statement exactly once, including semantic verification. +fn format_statement_once( ast: &NodeEnum, config: &FormatConfig, + attached: AttachedComments, ) -> Result { - // Emit layout events from AST - let mut emitter = emitter::EventEmitter::new(); + // A comment that no node follows cannot be placed. Refusing here preserves the original text, + // which is safer than emitting a statement that would silently drop it. + if !attached.unattached.is_empty() { + return Err(FormatError::UnplaceableComment { + count: attached.unattached.len(), + }); + } + + let mut emitter = emitter::EventEmitter::with_comments( + attached.leading_by_location, + attached.trailing_by_location, + ); nodes::emit_node_enum(ast, &mut emitter); + let pending = emitter.pending_comments(); + if pending > 0 { + return Err(FormatError::UnplaceableComment { count: pending }); + } // Render to string let render_config = RenderConfig { @@ -162,7 +240,7 @@ mod tests { let ast = parsed.into_root().unwrap(); let config = FormatConfig::default(); - let result = format_statement(&ast, &config).unwrap(); + let result = format_statement(&ast, sql, &config).unwrap(); assert!(!result.formatted.is_empty()); // Default keyword_case is Lower, so check for lowercase @@ -175,4 +253,455 @@ mod tests { assert_eq!(config.line_width, 100); assert_eq!(config.indent_size, 2); } + + #[test] + fn a_statement_with_a_comment_is_formatted() { + let sql = "SELECT\n-- pick the magic value\n1 FROM s.t"; + let ast = pgls_query::parse(sql).unwrap().into_root().unwrap(); + + let result = format_statement(&ast, sql, &FormatConfig::default()).expect("formatted"); + + assert!(result.formatted.contains("-- pick the magic value")); + assert!(result.formatted.contains("select")); + } + + #[test] + fn a_comment_after_the_statement_terminator_is_refused() { + let sql = "SELECT 1 FROM s.t; -- trailing"; + let ast = pgls_query::parse(sql).unwrap().into_root().unwrap(); + + let error = format_statement(&ast, sql, &FormatConfig::default()) + .expect_err("the comment belongs to the next statement"); + + assert!(matches!(error, FormatError::UnplaceableComment { .. })); + } + + #[test] + fn formatting_a_trailing_comment_is_idempotent() { + let sql = "SELECT * FROM t WHERE a = 1 -- keep condition context\nAND b = 2;"; + let ast = pgls_query::parse(sql).unwrap().into_root().unwrap(); + let config = FormatConfig::default(); + + let first = format_statement(&ast, sql, &config) + .expect("first pass") + .formatted; + let reparsed = pgls_query::parse(&first).unwrap().into_root().unwrap(); + let second = format_statement(&reparsed, &first, &config) + .expect("second pass") + .formatted; + + assert!(first.contains("1 -- keep condition context")); + assert_eq!(first, second); + } + + #[test] + fn formatting_a_comment_before_an_order_by_clause_is_idempotent() { + let sql = "SELECT * FROM t\n-- WHERE\n-- a is active\nORDER BY a;"; + let ast = pgls_query::parse(sql).unwrap().into_root().unwrap(); + let config = FormatConfig::default(); + + let first = format_statement(&ast, sql, &config) + .expect("first pass") + .formatted; + let reparsed = pgls_query::parse(&first).unwrap().into_root().unwrap(); + let second = format_statement(&reparsed, &first, &config) + .expect("second pass") + .formatted; + + assert!(first.contains("order by -- WHERE")); + assert_eq!(first, second); + } + + #[test] + fn formatting_a_trailing_comment_after_a_right_hand_operand_is_idempotent() { + let sql = "SELECT * FROM t WHERE type_de_variable <> '011' -- exclude VAT\n;"; + let ast = pgls_query::parse(sql).unwrap().into_root().unwrap(); + let config = FormatConfig::default(); + + let first = format_statement(&ast, sql, &config) + .expect("first pass") + .formatted; + let reparsed = pgls_query::parse(&first).unwrap().into_root().unwrap(); + let second = format_statement(&reparsed, &first, &config) + .expect("second pass") + .formatted; + + assert!(first.contains("'011' -- exclude VAT")); + assert_eq!(first, second); + } + + #[test] + fn formatting_a_comment_after_a_list_separator_is_idempotent() { + let sql = "SELECT a, -- temporarily omit b\nb FROM t;"; + let ast = pgls_query::parse(sql).unwrap().into_root().unwrap(); + let config = FormatConfig::default(); + + let first = format_statement(&ast, sql, &config) + .expect("first pass") + .formatted; + let reparsed = pgls_query::parse(&first).unwrap().into_root().unwrap(); + let second = format_statement(&reparsed, &first, &config) + .expect("second pass") + .formatted; + + assert!(first.contains("-- temporarily omit b")); + assert_eq!(first, second); + } + + #[test] + fn formatting_a_comment_before_a_conjunction_is_idempotent() { + let sql = "SELECT * FROM s.t WHERE a = 1\n-- keep b out for now\nAND b = 2;"; + let ast = pgls_query::parse(sql).unwrap().into_root().unwrap(); + let config = FormatConfig { + indent_size: 4, + indent_style: IndentStyle::Tabs, + keyword_case: KeywordCase::Upper, + ..Default::default() + }; + + let first = format_statement(&ast, sql, &config) + .expect("first pass") + .formatted; + let reparsed = pgls_query::parse(&first).unwrap().into_root().unwrap(); + let second = format_statement(&reparsed, &first, &config) + .expect("second pass") + .formatted; + + assert!(first.contains("-- keep b out for now")); + assert_eq!(first, second); + } + + #[test] + fn a_comment_after_an_update_target_relation_is_kept() { + let sql = "UPDATE s.t AS x -- remove the strays\nSET a = 1"; + let ast = pgls_query::parse(sql).unwrap().into_root().unwrap(); + + let result = format_statement(&ast, sql, &FormatConfig::default()).expect("formatted"); + + assert!(result.formatted.contains("-- remove the strays")); + } + + #[test] + fn a_comment_after_a_delete_target_relation_is_kept() { + let sql = "DELETE FROM s.t -- only the strays\nWHERE a = 1"; + let ast = pgls_query::parse(sql).unwrap().into_root().unwrap(); + + let result = format_statement(&ast, sql, &FormatConfig::default()).expect("formatted"); + + assert!(result.formatted.contains("-- only the strays")); + } + + #[test] + fn formatting_a_comment_after_a_target_relation_is_idempotent() { + let sql = "UPDATE s.t AS x -- remove the strays\nSET a = 1"; + let ast = pgls_query::parse(sql).unwrap().into_root().unwrap(); + let config = FormatConfig::default(); + + let first = format_statement(&ast, sql, &config) + .expect("first pass") + .formatted; + let reparsed = pgls_query::parse(&first).unwrap().into_root().unwrap(); + let second = format_statement(&reparsed, &first, &config) + .expect("second pass") + .formatted; + + assert_eq!(first, second); + } + + #[test] + fn a_comment_in_an_insert_column_list_is_kept() { + let sql = "INSERT INTO s.t\n(\n a\n, b -- the management type\n, c\n)\nVALUES (1, 2, 3)"; + let ast = pgls_query::parse(sql).unwrap().into_root().unwrap(); + + let result = format_statement(&ast, sql, &FormatConfig::default()).expect("formatted"); + + assert!(result.formatted.contains("-- the management type")); + } + + #[test] + fn formatting_a_comment_in_an_insert_column_list_is_idempotent() { + let sql = "INSERT INTO s.t\n(\n a\n, b -- the management type\n, c\n)\nVALUES (1, 2, 3)"; + let ast = pgls_query::parse(sql).unwrap().into_root().unwrap(); + let config = FormatConfig::default(); + + let first = format_statement(&ast, sql, &config) + .expect("first pass") + .formatted; + let reparsed = pgls_query::parse(&first).unwrap().into_root().unwrap(); + let second = format_statement(&reparsed, &first, &config) + .expect("second pass") + .formatted; + + assert_eq!(first, second); + } + + #[test] + fn a_comment_after_a_column_type_is_kept() { + let sql = "CREATE TABLE s.t (\n\ta int -- the magic column\n\t, b int\n)"; + let ast = pgls_query::parse(sql).unwrap().into_root().unwrap(); + + let result = format_statement(&ast, sql, &FormatConfig::default()).expect("formatted"); + + assert!(result.formatted.contains("-- the magic column")); + } + + #[test] + fn formatting_a_comment_after_a_column_type_is_idempotent() { + let sql = "CREATE TABLE s.t (\n\ta int -- the magic column\n\t, b int\n)"; + let ast = pgls_query::parse(sql).unwrap().into_root().unwrap(); + let config = FormatConfig::default(); + + let first = format_statement(&ast, sql, &config) + .expect("first pass") + .formatted; + let reparsed = pgls_query::parse(&first).unwrap().into_root().unwrap(); + let second = format_statement(&reparsed, &first, &config) + .expect("second pass") + .formatted; + + assert_eq!(first, second); + } + + #[test] + fn a_comment_closing_a_statement_is_kept() { + let sql = "SELECT 1 FROM s.t -- trailing"; + let ast = pgls_query::parse(sql).unwrap().into_root().unwrap(); + + let result = format_statement(&ast, sql, &FormatConfig::default()).expect("formatted"); + + assert!(result.formatted.contains("-- trailing")); + } + + #[test] + fn a_comment_closing_a_column_list_is_kept() { + let sql = "CREATE TABLE s.t (\n\tid uuid,\n\tkind text\n--\t\"createdAt\" timestamp\n)"; + let ast = pgls_query::parse(sql).unwrap().into_root().unwrap(); + + let result = format_statement(&ast, sql, &FormatConfig::default()).expect("formatted"); + + assert!(result.formatted.contains("\"createdAt\" timestamp")); + } + + #[test] + fn formatting_a_comment_closing_a_values_list_is_idempotent() { + let sql = "INSERT INTO s.t VALUES\n ('a', 'b')\n, ('c', 'd') -- the last one"; + let ast = pgls_query::parse(sql).unwrap().into_root().unwrap(); + let config = FormatConfig::default(); + + let first = format_statement(&ast, sql, &config) + .expect("first pass") + .formatted; + let reparsed = pgls_query::parse(&first).unwrap().into_root().unwrap(); + let second = format_statement(&reparsed, &first, &config) + .expect("second pass") + .formatted; + + assert!(first.contains("-- the last one")); + assert_eq!(first, second); + } + + #[test] + fn a_comment_in_an_update_set_list_is_kept() { + let sql = + "UPDATE s.t SET\n-- the reason is deducted from the WHERE below\na = 1 WHERE b = 2"; + let ast = pgls_query::parse(sql).unwrap().into_root().unwrap(); + + let result = format_statement(&ast, sql, &FormatConfig::default()).expect("formatted"); + + assert!( + result + .formatted + .contains("-- the reason is deducted from the WHERE below") + ); + } + + #[test] + fn formatting_a_comment_in_an_update_set_list_is_idempotent() { + let sql = + "UPDATE s.t SET\n-- the reason is deducted from the WHERE below\na = 1 WHERE b = 2"; + let ast = pgls_query::parse(sql).unwrap().into_root().unwrap(); + let config = FormatConfig::default(); + + let first = format_statement(&ast, sql, &config) + .expect("first pass") + .formatted; + let reparsed = pgls_query::parse(&first).unwrap().into_root().unwrap(); + let second = format_statement(&reparsed, &first, &config) + .expect("second pass") + .formatted; + + assert_eq!(first, second); + } + + #[test] + fn a_comment_before_a_window_definition_is_kept() { + let sql = "SELECT bool_or(a <> b) -- has_decimal\nOVER (PARTITION BY c) FROM s.t"; + let ast = pgls_query::parse(sql).unwrap().into_root().unwrap(); + + let result = format_statement(&ast, sql, &FormatConfig::default()).expect("formatted"); + + assert!(result.formatted.contains("-- has_decimal")); + } + + #[test] + fn formatting_a_comment_before_a_window_definition_is_idempotent() { + let sql = "SELECT bool_or(a <> b) -- has_decimal\nOVER (PARTITION BY c) FROM s.t"; + let ast = pgls_query::parse(sql).unwrap().into_root().unwrap(); + let config = FormatConfig::default(); + + let first = format_statement(&ast, sql, &config) + .expect("first pass") + .formatted; + let reparsed = pgls_query::parse(&first).unwrap().into_root().unwrap(); + let second = format_statement(&reparsed, &first, &config) + .expect("second pass") + .formatted; + + assert_eq!(first, second); + } + + #[test] + fn a_comment_before_a_with_clause_is_kept() { + let sql = + "INSERT INTO s.u\n-- how this table is fed\nWITH c AS (SELECT 1 AS a)\nSELECT a FROM c"; + let ast = pgls_query::parse(sql).unwrap().into_root().unwrap(); + + let result = format_statement(&ast, sql, &FormatConfig::default()).expect("formatted"); + + assert!(result.formatted.contains("-- how this table is fed")); + } + + #[test] + fn formatting_a_comment_before_a_with_clause_is_idempotent() { + let sql = + "INSERT INTO s.u\n-- how this table is fed\nWITH c AS (SELECT 1 AS a)\nSELECT a FROM c"; + let ast = pgls_query::parse(sql).unwrap().into_root().unwrap(); + let config = FormatConfig::default(); + + let first = format_statement(&ast, sql, &config) + .expect("first pass") + .formatted; + let reparsed = pgls_query::parse(&first).unwrap().into_root().unwrap(); + let second = format_statement(&reparsed, &first, &config) + .expect("second pass") + .formatted; + + assert_eq!(first, second); + } + + #[test] + fn formatting_comments_on_sequence_options_is_idempotent() { + let sql = "CREATE SEQUENCE numbering.missions_seq AS BIGINT START 1 -- first value\n\ + MAXVALUE 36 -- last base36 value\n\ + INCREMENT 1 NO CYCLE;"; + let ast = pgls_query::parse(sql).unwrap().into_root().unwrap(); + let config = FormatConfig::default(); + + let first = format_statement(&ast, sql, &config) + .expect("first pass") + .formatted; + let reparsed = pgls_query::parse(&first).unwrap().into_root().unwrap(); + let second = format_statement(&reparsed, &first, &config) + .expect("second pass") + .formatted; + + assert!(first.contains("-- first value")); + assert!(first.contains("-- last base36 value")); + assert_eq!(first, second); + } + + #[test] + fn formatting_comments_after_grouped_conditions_is_idempotent() { + let sql = "SELECT * FROM source WHERE ((kind = 'expense' AND code = 'CR') -- expense entries\n\ + OR (kind IN ('call', 'suspense') AND code = 'CA')) -- call entries\n\ + AND journal = '19';"; + let ast = pgls_query::parse(sql).unwrap().into_root().unwrap(); + let config = FormatConfig::default(); + + let first = format_statement(&ast, sql, &config) + .expect("first pass") + .formatted; + let reparsed = pgls_query::parse(&first).unwrap().into_root().unwrap(); + let second = format_statement(&reparsed, &first, &config) + .expect("second pass") + .formatted; + + assert!(first.contains("-- expense entries")); + assert!(first.contains("-- call entries")); + assert_eq!(first, second); + } + + #[test] + fn formatting_a_comment_between_with_and_update_is_idempotent() { + let sql = "WITH source AS (\n\ + (SELECT 1 AS id)\n\ + UNION ALL\n\ + (SELECT 2 AS id)\n\ + )\n\ + -- regenerate ids from the source number\n\ + -- use the last four digits when the source number is numeric\n\ + -- otherwise increment the highest source number\n\ + UPDATE target SET id = source.id FROM source WHERE target.id = source.id;"; + let ast = pgls_query::parse(sql).unwrap().into_root().unwrap(); + let config = FormatConfig::default(); + + let first = format_statement(&ast, sql, &config) + .expect("first pass") + .formatted; + let reparsed = pgls_query::parse(&first).unwrap().into_root().unwrap(); + let second = format_statement(&reparsed, &first, &config) + .expect("second pass") + .formatted; + + assert!(first.contains("-- regenerate ids from the source number")); + assert_eq!(first, second); + } + + #[test] + fn formatting_a_leading_comment_between_boolean_operands_is_idempotent() { + let sql = r#" +SELECT * +FROM accounting_accounts +WHERE + accounting_accounts.line_of_business = 'S' + AND staging_buildings.co_ownership_trustee_status + AND NOT starts_with(last_line.accounting_class_source, '71') + -- classes for accounting_accounts for co_owner_accounts ; not handled in this file... + AND ( + NOT starts_with(last_line.accounting_class_source, '450') + -- ... except for those re-mapped in refining due to a missing co_owner_account_fk. + OR starts_with(accounting_accounts.accounting_class, '473') + OR uaf_accounts.accounting_class_target IS NOT NULL + ) + AND NOT starts_with(accounting_accounts.accounting_class, '450') + -- classes for accounting_accounts for banks. For now bank are handled in this file + -- AND last_line.accounting_class_source::INT NOT BETWEEN 5000 AND 5999 + AND ( + ( + -- These are class for budgets. They'll be handled in the dedicated export. + coalesce(last_line.accounting_class_source, '') ~ '^[0-9]+$' + AND NOT ( + last_line.accounting_class_source >= '6000' + AND last_line.accounting_class_source <= '6799' + ) + ) + OR ( -- Non-numeric classes are also handled in this file + coalesce(last_line.accounting_class_source, '') ~ '[A-Z]' + ) + ); +"#; + let config = FormatConfig::default(); + + let ast = pgls_query::parse(sql).unwrap().into_root().unwrap(); + let first = format_statement(&ast, sql, &config) + .expect("first pass") + .formatted; + let reparsed = pgls_query::parse(&first).unwrap().into_root().unwrap(); + let second = format_statement(&reparsed, &first, &config) + .expect("second pass") + .formatted; + + assert!(first.contains("-- classes for accounting_accounts for banks")); + assert_eq!(first, second); + } } diff --git a/crates/pgls_pretty_print/src/nodes/bool_expr.rs b/crates/pgls_pretty_print/src/nodes/bool_expr.rs index ad57c8108..ddf592755 100644 --- a/crates/pgls_pretty_print/src/nodes/bool_expr.rs +++ b/crates/pgls_pretty_print/src/nodes/bool_expr.rs @@ -50,13 +50,15 @@ fn emit_not_expr(e: &mut EventEmitter, n: &BoolExpr) { } fn emit_bool_operand(e: &mut EventEmitter, node: &Node, parent_prec: u8) { - if needs_parentheses(node, parent_prec) { - e.token(TokenKind::L_PAREN); - super::emit_node(node, e); - e.token(TokenKind::R_PAREN); - } else { - super::emit_node(node, e); - } + e.with_leading_comment_line_break(|e| { + if needs_parentheses(node, parent_prec) { + e.token(TokenKind::L_PAREN); + super::emit_node(node, e); + e.token(TokenKind::R_PAREN); + } else { + super::emit_node(node, e); + } + }); } fn needs_parentheses(node: &Node, parent_prec: u8) -> bool { diff --git a/crates/pgls_pretty_print/src/nodes/create_seq_stmt.rs b/crates/pgls_pretty_print/src/nodes/create_seq_stmt.rs index 1a8a75a9b..f18187d9e 100644 --- a/crates/pgls_pretty_print/src/nodes/create_seq_stmt.rs +++ b/crates/pgls_pretty_print/src/nodes/create_seq_stmt.rs @@ -47,7 +47,9 @@ pub(super) fn emit_create_seq_stmt(e: &mut EventEmitter, n: &CreateSeqStmt) { emit_space_separated_list(e, &n.options, |opt, e| { // Use specialized sequence option emission if let Some(pgls_query::NodeEnum::DefElem(def_elem)) = opt.node.as_ref() { - super::emit_sequence_option(e, def_elem); + super::emit_with_comments_at(e, def_elem.location, |e| { + super::emit_sequence_option(e, def_elem); + }); } else { super::emit_node(opt, e); } diff --git a/crates/pgls_pretty_print/src/nodes/def_elem.rs b/crates/pgls_pretty_print/src/nodes/def_elem.rs index 87ba952cd..02899e58b 100644 --- a/crates/pgls_pretty_print/src/nodes/def_elem.rs +++ b/crates/pgls_pretty_print/src/nodes/def_elem.rs @@ -376,9 +376,13 @@ pub(super) fn emit_sequence_option(e: &mut EventEmitter, n: &DefElem) { } } "cycle" => { - if n.arg.is_some() { - // Check if the arg is a boolean/integer indicating CYCLE vs NO CYCLE - // For now, just emit CYCLE (TODO: handle NO CYCLE) + let cycles = n.arg.as_ref().and_then(|arg| match arg.node.as_ref() { + Some(NodeEnum::Boolean(boolean)) => Some(boolean.boolval), + Some(NodeEnum::Integer(integer)) => Some(integer.ival != 0), + _ => None, + }); + + if cycles.unwrap_or(false) { e.token(TokenKind::CYCLE_KW); } else { e.token(TokenKind::NO_KW); diff --git a/crates/pgls_pretty_print/src/nodes/mod.rs b/crates/pgls_pretty_print/src/nodes/mod.rs index 9c5e3c39d..93b7bfde4 100644 --- a/crates/pgls_pretty_print/src/nodes/mod.rs +++ b/crates/pgls_pretty_print/src/nodes/mod.rs @@ -528,9 +528,44 @@ use crate::emitter::{EventEmitter, GroupKind}; use pgls_query::{NodeEnum, protobuf::Node}; pub fn emit_node(node: &Node, e: &mut EventEmitter) { + let location = node + .node + .as_ref() + .and_then(|inner| crate::codegen::node_location::node_location(&inner.to_ref())); + + if let Some(location) = location { + e.take_leading_comments_at(location); + } + if let Some(ref inner) = node.node { emit_node_enum(inner, e) } + + if let Some(location) = location { + e.take_trailing_comments_at(location); + } +} + +/// Emits `body` with the comments attached to `location`, the way `emit_node` does for a child +/// reached as a `Node`. +/// +/// A parent that reaches a child through a typed emitter, `emit_range_var` for instance, never +/// goes through `emit_node`. Without this helper the comments attached to that child stay in the +/// emitter map and the whole statement is refused rather than reformatted. +pub(super) fn emit_with_comments_at( + e: &mut EventEmitter, + location: i32, + body: impl FnOnce(&mut EventEmitter), +) { + // Negative locations are dropped when the comment maps are built, so they hold nothing. + if location < 0 { + body(e); + return; + } + + e.take_leading_comments_at(location); + body(e); + e.take_trailing_comments_at(location); } pub(super) fn emit_clause_condition(e: &mut EventEmitter, clause: &Node) { @@ -818,3 +853,65 @@ pub fn emit_node_enum(node: &NodeEnum, e: &mut EventEmitter) { NodeEnum::Query(n) => emit_query(e, n), } } + +#[cfg(test)] +mod tests { + use crate::emitter::{EventEmitter, LayoutEvent}; + use crate::{Comment, TokenKind, attach_comments}; + use std::collections::HashMap; + + #[test] + fn a_comment_attached_to_a_node_is_emitted_before_it() { + let sql = "SELECT\n-- pick the magic value\n1 FROM s.t"; + let ast = pgls_query::parse(sql) + .expect("parse") + .into_root() + .expect("root"); + + let attached = attach_comments(sql, &ast); + let mut e = EventEmitter::with_comments( + attached.leading_by_location, + attached.trailing_by_location, + ); + super::emit_node_enum(&ast, &mut e); + + let comments: Vec<&LayoutEvent> = e + .events + .iter() + .filter(|event| matches!(event, LayoutEvent::Comment { .. })) + .collect(); + + assert_eq!(comments.len(), 1); + assert!(matches!( + comments[0], + LayoutEvent::Comment { text, line_comment: true } if text == "-- pick the magic value" + )); + } + + #[test] + fn a_typed_child_emitter_consumes_the_comments_of_its_location() { + let comment = Comment { + text: "-- note".to_string(), + line_comment: true, + }; + let leading = HashMap::from([(7, vec![comment])]); + let mut e = EventEmitter::with_comments(leading, HashMap::new()); + + super::emit_with_comments_at(&mut e, 7, |e| e.token(TokenKind::ONLY_KW)); + + assert_eq!(e.pending_comments(), 0); + assert!(e.events.iter().any(|event| matches!( + event, + LayoutEvent::Comment { text, .. } if text == "-- note" + ))); + } + + #[test] + fn a_negative_location_never_carries_a_comment() { + let mut e = EventEmitter::new(); + + super::emit_with_comments_at(&mut e, -1, |e| e.token(TokenKind::ONLY_KW)); + + assert_eq!(e.events, vec![LayoutEvent::Token(TokenKind::ONLY_KW)]); + } +} diff --git a/crates/pgls_pretty_print/src/nodes/range_var.rs b/crates/pgls_pretty_print/src/nodes/range_var.rs index 214096134..6140ca4de 100644 --- a/crates/pgls_pretty_print/src/nodes/range_var.rs +++ b/crates/pgls_pretty_print/src/nodes/range_var.rs @@ -15,6 +15,12 @@ pub(super) fn emit_range_var_name(e: &mut EventEmitter, n: &RangeVar) { } fn emit_range_var_impl(e: &mut EventEmitter, n: &RangeVar, allow_only: bool) { + // The parents of a RangeVar call this helper directly rather than emit_node, so this is the + // only place that can emit the comments written next to the relation name. + super::emit_with_comments_at(e, n.location, |e| emit_range_var_tokens(e, n, allow_only)); +} + +fn emit_range_var_tokens(e: &mut EventEmitter, n: &RangeVar, allow_only: bool) { e.group_start(GroupKind::RangeVar); // ONLY is only valid in DML contexts (SELECT, UPDATE, DELETE, LOCK), not DDL diff --git a/crates/pgls_pretty_print/src/nodes/res_target.rs b/crates/pgls_pretty_print/src/nodes/res_target.rs index 8732b20a7..ef70a8af2 100644 --- a/crates/pgls_pretty_print/src/nodes/res_target.rs +++ b/crates/pgls_pretty_print/src/nodes/res_target.rs @@ -28,20 +28,24 @@ pub(super) fn emit_res_target(e: &mut EventEmitter, n: &ResTarget) { } pub(super) fn emit_set_clause(e: &mut EventEmitter, n: &ResTarget) { - e.group_start(GroupKind::ResTarget); + // The SET list emits its ResTargets itself rather than through emit_node, so this is the only + // place that can emit the comments written next to an assignment. + super::emit_with_comments_at(e, n.location, |e| { + e.group_start(GroupKind::ResTarget); - if !n.name.is_empty() { - emit_column_name_with_indirection(e, n); + if !n.name.is_empty() { + emit_column_name_with_indirection(e, n); - if let Some(ref val) = n.val { - e.space(); - e.token(TokenKind::IDENT("=".to_string())); - e.space(); - emit_node(val, e); + if let Some(ref val) = n.val { + e.space(); + e.token(TokenKind::IDENT("=".to_string())); + e.space(); + emit_node(val, e); + } } - } - e.group_end(); + e.group_end(); + }); } pub(super) fn emit_set_clause_list(e: &mut EventEmitter, nodes: &[pgls_query::Node]) { @@ -184,7 +188,11 @@ pub(super) fn emit_column_name_with_indirection(e: &mut EventEmitter, n: &ResTar // Emit column name only (for INSERT column list) pub(super) fn emit_column_name(e: &mut EventEmitter, n: &ResTarget) { - e.group_start(GroupKind::ResTarget); - emit_column_name_with_indirection(e, n); - e.group_end(); + // The INSERT column list emits its ResTargets itself rather than through emit_node, so this is + // the only place that can emit the comments written next to a column name. + super::emit_with_comments_at(e, n.location, |e| { + e.group_start(GroupKind::ResTarget); + emit_column_name_with_indirection(e, n); + e.group_end(); + }); } diff --git a/crates/pgls_pretty_print/src/nodes/type_name.rs b/crates/pgls_pretty_print/src/nodes/type_name.rs index 6553cdb76..98990551d 100644 --- a/crates/pgls_pretty_print/src/nodes/type_name.rs +++ b/crates/pgls_pretty_print/src/nodes/type_name.rs @@ -17,6 +17,12 @@ const INTERVAL_FULL_RANGE: i32 = 0x7FFF; const INTERVAL_FULL_PRECISION: i32 = 0xFFFF; pub(super) fn emit_type_name(e: &mut EventEmitter, n: &TypeName) { + // Column definitions and casts call this helper directly rather than emit_node, so this is the + // only place that can emit the comments written next to a type. + super::emit_with_comments_at(e, n.location, |e| emit_type_name_tokens(e, n)); +} + +fn emit_type_name_tokens(e: &mut EventEmitter, n: &TypeName) { e.group_start(GroupKind::TypeName); if n.setof { diff --git a/crates/pgls_pretty_print/src/nodes/update_stmt.rs b/crates/pgls_pretty_print/src/nodes/update_stmt.rs index 8f0b0ebce..00bc159b5 100644 --- a/crates/pgls_pretty_print/src/nodes/update_stmt.rs +++ b/crates/pgls_pretty_print/src/nodes/update_stmt.rs @@ -17,6 +17,10 @@ fn emit_update_stmt_impl(e: &mut EventEmitter, n: &UpdateStmt, with_semicolon: b if let Some(ref with_clause) = n.with_clause { super::emit_with_clause(e, with_clause); e.line(LineType::SoftOrSpace); + + if let Some(ref range_var) = n.relation { + e.take_leading_comments_at(range_var.location); + } } e.token(TokenKind::UPDATE_KW); diff --git a/crates/pgls_pretty_print/src/nodes/window_def.rs b/crates/pgls_pretty_print/src/nodes/window_def.rs index 45e7a3191..42d494650 100644 --- a/crates/pgls_pretty_print/src/nodes/window_def.rs +++ b/crates/pgls_pretty_print/src/nodes/window_def.rs @@ -36,6 +36,12 @@ enum FrameBoundSide { // WindowDef is not a NodeEnum type, so we don't use pub(super) // It's a helper structure used within FuncCall and SelectStmt pub fn emit_window_def(e: &mut EventEmitter, n: &WindowDef) { + // The callers of a window definition reach it directly rather than through emit_node, so this + // is the only place that can emit the comments written before the OVER clause. + super::emit_with_comments_at(e, n.location, |e| emit_window_def_tokens(e, n)); +} + +fn emit_window_def_tokens(e: &mut EventEmitter, n: &WindowDef) { // Simple reference to a named window if n.refname.is_empty() && n.partition_clause.is_empty() diff --git a/crates/pgls_pretty_print/src/nodes/with_clause.rs b/crates/pgls_pretty_print/src/nodes/with_clause.rs index e721ba526..28c18f4a4 100644 --- a/crates/pgls_pretty_print/src/nodes/with_clause.rs +++ b/crates/pgls_pretty_print/src/nodes/with_clause.rs @@ -6,21 +6,25 @@ use crate::emitter::{EventEmitter, GroupKind, LineType}; use super::node_list::emit_comma_separated_list; pub(super) fn emit_with_clause(e: &mut EventEmitter, n: &WithClause) { - e.group_start(GroupKind::WithClause); + // The statements that own a WITH clause emit it directly rather than through emit_node, so this + // is the only place that can emit the comments written before the WITH keyword. + super::emit_with_comments_at(e, n.location, |e| { + e.group_start(GroupKind::WithClause); - e.token(TokenKind::WITH_KW); + e.token(TokenKind::WITH_KW); - if n.recursive { - e.space(); - e.token(TokenKind::RECURSIVE_KW); - } + if n.recursive { + e.space(); + e.token(TokenKind::RECURSIVE_KW); + } - if !n.ctes.is_empty() { - e.line(LineType::SoftOrSpace); - emit_comma_separated_list(e, &n.ctes, |node, e| { - super::emit_node(node, e); - }); - } + if !n.ctes.is_empty() { + e.line(LineType::SoftOrSpace); + emit_comma_separated_list(e, &n.ctes, |node, e| { + super::emit_node(node, e); + }); + } - e.group_end(); + e.group_end(); + }); } diff --git a/crates/pgls_pretty_print/src/renderer.rs b/crates/pgls_pretty_print/src/renderer.rs index 36dcbbff9..b9a5d57c7 100644 --- a/crates/pgls_pretty_print/src/renderer.rs +++ b/crates/pgls_pretty_print/src/renderer.rs @@ -80,6 +80,10 @@ impl Renderer { self.handle_line(line_type)?; i += 1; } + LayoutEvent::Comment { text, .. } => { + self.write_text(text)?; + i += 1; + } LayoutEvent::GroupStart { .. } => { let group_end = self.find_group_end(events, i); let group_slice = &events[i..=group_end]; @@ -152,6 +156,10 @@ impl Renderer { self.write_line_break()?; i += 1; } + LayoutEvent::Comment { text, .. } => { + self.write_text(text)?; + i += 1; + } LayoutEvent::GroupStart { .. } => { let group_end = self.find_group_end(events, i); let group_slice = &events[i..=group_end]; @@ -224,6 +232,14 @@ impl Renderer { LayoutEvent::Line(LineType::SoftOrSpace) => { buffer.push(' '); // Becomes space in single-line mode } + LayoutEvent::Comment { text, line_comment } => { + if *line_comment { + // Collapsing would push the code that follows behind the `--`. + has_hard_breaks = true; + break; + } + buffer.push_str(text); + } LayoutEvent::GroupStart { .. } | LayoutEvent::GroupEnd => { // skip group markers for single line test } @@ -435,4 +451,32 @@ mod tests { let output = render_events(emitter.events, config); assert_eq!(output, "SELECT 1 WHERE name IS NOT null AND active = true"); } + + #[test] + fn a_block_comment_is_rendered_inline() { + let mut emitter = EventEmitter::new(); + emitter.token(TokenKind::SELECT_KW); + emitter.space(); + emitter.comment("/* why */".to_string(), false); + emitter.space(); + emitter.token(TokenKind::INT_NUMBER(1)); + + let output = render_events(emitter.events, RenderConfig::default()); + assert_eq!(output, "select /* why */ 1"); + } + + #[test] + fn a_line_comment_forces_the_group_to_break() { + let mut emitter = EventEmitter::new(); + emitter.group_start(crate::emitter::GroupKind::SelectStmt); + emitter.token(TokenKind::SELECT_KW); + emitter.space(); + emitter.comment("-- why".to_string(), true); + emitter.line(crate::emitter::LineType::SoftOrSpace); + emitter.token(TokenKind::INT_NUMBER(1)); + emitter.group_end(); + + let output = render_events(emitter.events, RenderConfig::default()); + assert_eq!(output, "select -- why\n1"); + } } diff --git a/crates/pgls_pretty_print_codegen/src/lib.rs b/crates/pgls_pretty_print_codegen/src/lib.rs index 5df181b85..ff9cc6eb7 100644 --- a/crates/pgls_pretty_print_codegen/src/lib.rs +++ b/crates/pgls_pretty_print_codegen/src/lib.rs @@ -1,5 +1,6 @@ mod group_kind; mod keywords; +mod node_location; mod proto_analyser; mod token_kind; @@ -19,6 +20,12 @@ pub fn group_kind_codegen(_input: proc_macro::TokenStream) -> proc_macro::TokenS group_kind::group_kind_mod(analyser).into() } +#[proc_macro] +pub fn node_location_codegen(_input: proc_macro::TokenStream) -> proc_macro::TokenStream { + let analyser = ProtoAnalyzer::from(&proto_file_path()).unwrap(); + node_location::node_location_mod(analyser).into() +} + fn proto_file_path() -> path::PathBuf { path::PathBuf::from(env!("PG_QUERY_PROTO_PATH")) } diff --git a/crates/pgls_pretty_print_codegen/src/node_location.rs b/crates/pgls_pretty_print_codegen/src/node_location.rs new file mode 100644 index 000000000..66eaecf4b --- /dev/null +++ b/crates/pgls_pretty_print_codegen/src/node_location.rs @@ -0,0 +1,30 @@ +use quote::{format_ident, quote}; + +use crate::proto_analyser::ProtoAnalyzer; + +pub fn node_location_mod(analyser: ProtoAnalyzer) -> proc_macro2::TokenStream { + let arms = analyser + .enum_variants() + .into_iter() + .filter(|variant| variant.has_location) + .map(|variant| { + let variant_ident = format_ident!("{}", &variant.name); + quote! { + pgls_query::NodeRef::#variant_ident(n) => Some(n.location) + } + }); + + quote! { + /// Byte offset of a node in the statement it was parsed from. + /// + /// Generated from the protobuf descriptor: a node kind reports its offset when its message + /// carries a `location` field, and `None` otherwise. Comment attachment relies on it to + /// find the node a comment sits in front of. + pub fn node_location(node: &pgls_query::NodeRef<'_>) -> Option { + match node { + #(#arms),*, + _ => None, + } + } + } +} diff --git a/crates/pgls_pretty_print_codegen/src/proto_analyser.rs b/crates/pgls_pretty_print_codegen/src/proto_analyser.rs index 28abdf3e4..158fd8803 100644 --- a/crates/pgls_pretty_print_codegen/src/proto_analyser.rs +++ b/crates/pgls_pretty_print_codegen/src/proto_analyser.rs @@ -1,7 +1,7 @@ use std::path::Path; use convert_case::{Case, Casing}; -use prost_reflect::{DescriptorError, DescriptorPool}; +use prost_reflect::{DescriptorError, DescriptorPool, Kind}; pub(crate) struct ProtoAnalyzer { pool: DescriptorPool, @@ -9,6 +9,9 @@ pub(crate) struct ProtoAnalyzer { pub(crate) struct EnumVariant { pub name: String, + /// True when the message behind this variant carries a `location` field, which holds the byte + /// offset of the node in the original statement. + pub has_location: bool, } impl ProtoAnalyzer { @@ -44,7 +47,17 @@ impl ProtoAnalyzer { let field_name = field.name(); let variant_name = field_name.to_case(Case::Pascal); - variants.push(EnumVariant { name: variant_name }); + let has_location = match field.kind() { + Kind::Message(message) => message + .get_field_by_name("location") + .is_some_and(|location| matches!(location.kind(), Kind::Int32)), + _ => false, + }; + + variants.push(EnumVariant { + name: variant_name, + has_location, + }); } variants diff --git a/crates/pgls_workspace/src/workspace/server.rs b/crates/pgls_workspace/src/workspace/server.rs index ec20cf15c..3e5a6d520 100644 --- a/crates/pgls_workspace/src/workspace/server.rs +++ b/crates/pgls_workspace/src/workspace/server.rs @@ -962,14 +962,7 @@ impl Workspace for WorkspaceServer { continue; }; - // Comments are not represented in the AST, so reformatting a function - // body that contains comments would silently drop them. Leave the - // original body untouched in that case. - if statement_contains_comment(text) { - continue; - } - - let Ok(result) = pgls_pretty_print::format_statement(ast, &config) else { + let Ok(result) = pgls_pretty_print::format_statement(ast, text, &config) else { continue; }; @@ -1005,13 +998,6 @@ impl Workspace for WorkspaceServer { continue; } - // A comment inside the statement cannot survive a round-trip through the - // AST, so keep the original text rather than dropping the comment. - if statement_contains_comment(&text) { - formatted_output.push_str(&text); - continue; - } - match ast_result { Ok(ast) => { let mut ast = ast; @@ -1019,7 +1005,7 @@ impl Workspace for WorkspaceServer { sql_function::set_sql_fn_body(&mut ast, formatted_sql_fn_body); } - match pgls_pretty_print::format_statement(&ast, &config) { + match pgls_pretty_print::format_statement(&ast, &text, &config) { Ok(result) => { if text != result.formatted { statements.push(StatementFormatResult { @@ -1190,30 +1176,6 @@ fn is_dir(path: &Path) -> bool { path.is_dir() || (path.is_symlink() && fs::read_link(path).is_ok_and(|path| path.is_dir())) } -/// Returns `true` if the SQL `statement` contains a line (`--`) or block (`/* */`) -/// comment. -/// -/// libpg_query strips comments while building the AST, so the formatter (which -/// renders from the AST) cannot reproduce them. We use the scanner, which exposes -/// comments as dedicated tokens, to detect them and fall back to the original text. -/// Comments inside string literals (including dollar-quoted bodies) are part of the -/// string token and are correctly not reported here. -fn statement_contains_comment(statement: &str) -> bool { - use pgls_query::protobuf::Token; - - match pgls_query::scan(statement) { - Ok(scan) => scan.tokens.iter().any(|token| { - matches!( - Token::try_from(token.token), - Ok(Token::SqlComment | Token::CComment) - ) - }), - // If scanning fails we cannot reason about the statement; let the regular - // formatting path (which will likely fail to parse too) handle it. - Err(_) => false, - } -} - #[cfg(all(test, feature = "db"))] #[path = "server.tests.rs"] mod tests; diff --git a/crates/pgls_workspace/src/workspace/server.tests.rs b/crates/pgls_workspace/src/workspace/server.tests.rs index e4a05a1a6..3cd8324d6 100644 --- a/crates/pgls_workspace/src/workspace/server.tests.rs +++ b/crates/pgls_workspace/src/workspace/server.tests.rs @@ -825,18 +825,26 @@ async fn test_format_preserves_between_statement_comment() { #[tokio::test] async fn test_format_preserves_interior_comment() { - // A comment wedged between tokens of a statement cannot survive the AST - // round-trip, so the statement is left untouched instead of dropping it. - let content = "select amount -- the amount\nfrom customers;"; + let content = "SELECT\n-- pick the magic value\n1 AS a, 2 AS b FROM s.t;"; let formatted = format_content(content); - assert_eq!(formatted, content); + assert!(formatted.contains("-- pick the magic value")); + assert_ne!(formatted, content, "the statement was reformatted"); +} + +#[tokio::test] +async fn test_format_with_trailing_comment_is_idempotent() { + let first = format_content("SELECT * FROM t WHERE a = 1 -- condition\nAND b = 2;"); + let second = format_content(&first); + + assert!(first.contains("1 -- condition")); + assert_eq!(first, second); } #[tokio::test] async fn test_format_preserves_block_comment() { let content = "select 1 /* keep me */;"; let formatted = format_content(content); - assert_eq!(formatted, content); + assert!(formatted.contains("/* keep me */")); } #[tokio::test]