diff --git a/crates/pgls_configuration/src/format.rs b/crates/pgls_configuration/src/format.rs index 7855571cd..2618c38e0 100644 --- a/crates/pgls_configuration/src/format.rs +++ b/crates/pgls_configuration/src/format.rs @@ -70,6 +70,37 @@ impl From for pgls_pretty_print::renderer::KeywordCase { } } +/// How a statement is laid out across lines. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, Merge, PartialEq, Serialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "lowercase")] +pub enum Layout { + #[default] + Fit, + Expanded, +} + +impl FromStr for Layout { + type Err = &'static str; + + fn from_str(s: &str) -> Result { + match s { + "fit" => Ok(Self::Fit), + "expanded" => Ok(Self::Expanded), + _ => Err("Value not supported for Layout. Use 'fit' or 'expanded'."), + } + } +} + +impl From for pgls_pretty_print::Layout { + fn from(layout: Layout) -> Self { + match layout { + Layout::Fit => Self::Fit, + Layout::Expanded => Self::Expanded, + } + } +} + /// The configuration for SQL formatting. #[derive(Clone, Debug, Deserialize, Eq, Partial, PartialEq, Serialize)] #[partial(derive(Bpaf, Clone, Eq, PartialEq, Merge))] @@ -97,6 +128,10 @@ pub struct FormatConfiguration { /// Data type casing (text, varchar, int): "upper" or "lower". Default: "lower". #[partial(bpaf(long("type-case")))] pub type_case: KeywordCase, + /// How a statement is laid out: "fit" breaks only when a line would exceed the line width, + /// "expanded" always breaks between clauses. Default: "fit". + #[partial(bpaf(long("layout")))] + pub layout: Layout, /// If `true`, skip formatting of SQL function bodies (keep them verbatim). Default: `false`. #[partial(bpaf(long("skip-fn-bodies")))] pub skip_fn_bodies: bool, @@ -118,6 +153,7 @@ impl Default for FormatConfiguration { keyword_case: KeywordCase::default(), constant_case: KeywordCase::default(), type_case: KeywordCase::default(), + layout: Layout::default(), skip_fn_bodies: false, ignore: Default::default(), include: Default::default(), diff --git a/crates/pgls_pretty_print/src/emitter.rs b/crates/pgls_pretty_print/src/emitter.rs index 368c79b23..19c0a6db6 100644 --- a/crates/pgls_pretty_print/src/emitter.rs +++ b/crates/pgls_pretty_print/src/emitter.rs @@ -1,3 +1,4 @@ +use crate::FormatConfig; pub use crate::codegen::group_kind::GroupKind; pub use crate::codegen::token_kind::TokenKind; @@ -22,14 +23,28 @@ pub enum LayoutEvent { IndentEnd, } -#[derive(Debug, Default)] +/// Collects layout events for the renderer. +/// +/// The emitter holds the configuration because some options decide which tokens exist at all, +/// such as where a comma sits in a list, and not merely how a token is rendered. +#[derive(Debug)] pub struct EventEmitter { pub events: Vec, + config: FormatConfig, } impl EventEmitter { - pub fn new() -> Self { - Self::default() + pub fn new(config: FormatConfig) -> Self { + Self { + events: Vec::new(), + config, + } + } + + // Later option PRs inspect this while deciding which layout events to emit. + #[allow(dead_code)] + pub fn config(&self) -> &FormatConfig { + &self.config } pub fn token(&mut self, token: TokenKind) { diff --git a/crates/pgls_pretty_print/src/lib.rs b/crates/pgls_pretty_print/src/lib.rs index 008e4faa8..7208263e3 100644 --- a/crates/pgls_pretty_print/src/lib.rs +++ b/crates/pgls_pretty_print/src/lib.rs @@ -10,6 +10,17 @@ pub use crate::renderer::{IndentStyle, KeywordCase, RenderConfig}; use pgls_query::NodeEnum; use thiserror::Error; +/// How a statement is laid out across lines. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub enum Layout { + /// Break only when a line would exceed the line width. + #[default] + Fit, + /// Always break between the clauses of a statement, whatever the width. The line width then + /// only governs breaking inside a clause. + Expanded, +} + /// Error type for formatting operations. #[derive(Debug, Error)] pub enum FormatError { @@ -46,6 +57,8 @@ pub struct FormatConfig { pub constant_case: KeywordCase, /// Casing for data types (text, varchar, int). Default: Lower. pub type_case: KeywordCase, + /// How a statement is laid out across lines. Default: Fit. + pub layout: Layout, } impl Default for FormatConfig { @@ -57,6 +70,7 @@ impl Default for FormatConfig { keyword_case: KeywordCase::default(), constant_case: KeywordCase::default(), type_case: KeywordCase::default(), + layout: Layout::default(), } } } @@ -104,7 +118,7 @@ pub fn format_statement( config: &FormatConfig, ) -> Result { // Emit layout events from AST - let mut emitter = emitter::EventEmitter::new(); + let mut emitter = emitter::EventEmitter::new(config.clone()); nodes::emit_node_enum(ast, &mut emitter); // Render to string @@ -174,5 +188,6 @@ mod tests { let config = FormatConfig::default(); assert_eq!(config.line_width, 100); assert_eq!(config.indent_size, 2); + assert_eq!(config.layout, Layout::Fit); } } diff --git a/crates/pgls_pretty_print/src/nodes/insert_stmt.rs b/crates/pgls_pretty_print/src/nodes/insert_stmt.rs index 3207b3f0e..49bcf43d3 100644 --- a/crates/pgls_pretty_print/src/nodes/insert_stmt.rs +++ b/crates/pgls_pretty_print/src/nodes/insert_stmt.rs @@ -36,18 +36,26 @@ fn emit_insert_stmt_impl(e: &mut EventEmitter, n: &InsertStmt, with_semicolon: b // Emit column list if present if !n.cols.is_empty() { e.space(); - // Wrap column list in a group so it can try to fit on one line + // The column list has its own group so it can fit on one line in fit layout. In expanded + // layout the separator breaks below are hard, which makes the group open up on purpose. e.group_start(GroupKind::InsertStmt); e.token(TokenKind::L_PAREN); e.line(LineType::Soft); e.indent_start(); - emit_comma_separated_list(e, &n.cols, |node, e| { + + for (index, node) in n.cols.iter().enumerate() { + if index > 0 { + e.token(TokenKind::COMMA); + super::emit_layout_break(e); + } + if let Some(pgls_query::NodeEnum::ResTarget(res_target)) = node.node.as_ref() { emit_column_name(e, res_target); } else { super::emit_node(node, e); } - }); + } + e.indent_end(); e.line(LineType::Soft); e.token(TokenKind::R_PAREN); @@ -76,7 +84,7 @@ fn emit_insert_stmt_impl(e: &mut EventEmitter, n: &InsertStmt, with_semicolon: b // Emit VALUES or SELECT or DEFAULT VALUES if let Some(ref select_stmt) = n.select_stmt { - e.line(LineType::SoftOrSpace); + super::emit_layout_break(e); // Use no-semicolon variant since INSERT will emit its own semicolon if let Some(pgls_query::NodeEnum::SelectStmt(stmt)) = select_stmt.node.as_ref() { super::emit_select_stmt_no_semicolon(e, stmt); @@ -97,7 +105,7 @@ fn emit_insert_stmt_impl(e: &mut EventEmitter, n: &InsertStmt, with_semicolon: b } if !n.returning_list.is_empty() { - e.line(LineType::SoftOrSpace); + super::emit_layout_break(e); e.token(TokenKind::RETURNING_KW); e.space(); emit_comma_separated_list(e, &n.returning_list, super::emit_node); diff --git a/crates/pgls_pretty_print/src/nodes/join_expr.rs b/crates/pgls_pretty_print/src/nodes/join_expr.rs index 806051760..6090354cd 100644 --- a/crates/pgls_pretty_print/src/nodes/join_expr.rs +++ b/crates/pgls_pretty_print/src/nodes/join_expr.rs @@ -21,7 +21,16 @@ pub(super) fn emit_join_expr(e: &mut EventEmitter, n: &JoinExpr) { } if n.larg.is_some() { - e.line(LineType::SoftOrSpace); + if matches!(e.config().layout, crate::Layout::Expanded) { + // A hard break makes every soft line in the current group break too. Close the left + // operand's group before emitting it so the joined table and qualification can still + // fit. + e.group_end(); + super::emit_layout_break(e); + e.group_start(GroupKind::JoinExpr); + } else { + e.line(LineType::SoftOrSpace); + } } let mut first_token = true; diff --git a/crates/pgls_pretty_print/src/nodes/mod.rs b/crates/pgls_pretty_print/src/nodes/mod.rs index 9c5e3c39d..17310412d 100644 --- a/crates/pgls_pretty_print/src/nodes/mod.rs +++ b/crates/pgls_pretty_print/src/nodes/mod.rs @@ -542,6 +542,22 @@ pub(super) fn emit_clause_condition(e: &mut EventEmitter, clause: &Node) { e.indent_end(); } +/// Emits the break that separates a statement clause from the previous one. +/// +/// In expanded layout it is a `Hard` line, which `try_single_line` refuses to collapse, so the +/// break propagates to every enclosing group and the whole statement opens up. That propagation is +/// why expanded layout needs no renderer change. +#[allow(dead_code)] // Consumed by the clause emitters introduced in task 3. +pub(super) fn emit_layout_break(e: &mut EventEmitter) { + use crate::Layout; + use crate::emitter::LineType; + + match e.config().layout { + Layout::Expanded => e.line(LineType::Hard), + Layout::Fit => e.line(LineType::SoftOrSpace), + } +} + pub fn emit_node_enum(node: &NodeEnum, e: &mut EventEmitter) { match &node { NodeEnum::RawStmt(n) => emit_raw_stmt(e, n), @@ -818,3 +834,28 @@ pub fn emit_node_enum(node: &NodeEnum, e: &mut EventEmitter) { NodeEnum::Query(n) => emit_query(e, n), } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::emitter::{EventEmitter, LayoutEvent, LineType}; + use crate::{FormatConfig, Layout}; + + #[test] + fn fit_layout_emits_a_soft_or_space_break() { + let mut e = EventEmitter::new(FormatConfig::default()); + emit_layout_break(&mut e); + assert_eq!(e.events, vec![LayoutEvent::Line(LineType::SoftOrSpace)]); + } + + #[test] + fn expanded_layout_emits_a_hard_break() { + let config = FormatConfig { + layout: Layout::Expanded, + ..Default::default() + }; + let mut e = EventEmitter::new(config); + emit_layout_break(&mut e); + assert_eq!(e.events, vec![LayoutEvent::Line(LineType::Hard)]); + } +} diff --git a/crates/pgls_pretty_print/src/nodes/select_stmt.rs b/crates/pgls_pretty_print/src/nodes/select_stmt.rs index 96a7ff829..711e068c2 100644 --- a/crates/pgls_pretty_print/src/nodes/select_stmt.rs +++ b/crates/pgls_pretty_print/src/nodes/select_stmt.rs @@ -188,21 +188,27 @@ fn emit_select_stmt_impl(e: &mut EventEmitter, n: &SelectStmt, with_semicolon: b if !n.target_list.is_empty() { e.indent_start(); - e.line(LineType::SoftOrSpace); + super::emit_layout_break(e); - emit_comma_separated_list(e, &n.target_list, super::emit_node); + for (index, target) in n.target_list.iter().enumerate() { + if index > 0 { + e.token(TokenKind::COMMA); + super::emit_layout_break(e); + } + super::emit_node(target, e); + } e.indent_end(); } // Emit INTO clause if present (SELECT ... INTO table_name) if let Some(ref into_clause) = n.into_clause { - e.line(LineType::SoftOrSpace); + super::emit_layout_break(e); super::emit_into_clause(e, into_clause); } if !n.from_clause.is_empty() { - e.line(LineType::SoftOrSpace); + super::emit_layout_break(e); e.token(TokenKind::FROM_KW); e.line(LineType::SoftOrSpace); @@ -214,14 +220,14 @@ fn emit_select_stmt_impl(e: &mut EventEmitter, n: &SelectStmt, with_semicolon: b } if let Some(ref where_clause) = n.where_clause { - e.line(LineType::SoftOrSpace); + super::emit_layout_break(e); e.token(TokenKind::WHERE_KW); super::emit_clause_condition(e, where_clause); } // Emit GROUP BY clause if present if !n.group_clause.is_empty() { - e.line(LineType::SoftOrSpace); + super::emit_layout_break(e); e.token(TokenKind::GROUP_KW); e.space(); e.token(TokenKind::BY_KW); @@ -238,14 +244,14 @@ fn emit_select_stmt_impl(e: &mut EventEmitter, n: &SelectStmt, with_semicolon: b // Emit HAVING clause if present if let Some(ref having_clause) = n.having_clause { - e.line(LineType::SoftOrSpace); + super::emit_layout_break(e); e.token(TokenKind::HAVING_KW); super::emit_clause_condition(e, having_clause); } // Emit WINDOW clause if present if !n.window_clause.is_empty() { - e.line(LineType::SoftOrSpace); + super::emit_layout_break(e); e.token(TokenKind::WINDOW_KW); e.line(LineType::SoftOrSpace); e.indent_start(); @@ -266,7 +272,7 @@ fn emit_select_stmt_impl(e: &mut EventEmitter, n: &SelectStmt, with_semicolon: b // Emit ORDER BY clause if present if !n.sort_clause.is_empty() { - e.line(LineType::SoftOrSpace); + super::emit_layout_break(e); e.token(TokenKind::ORDER_KW); e.space(); e.token(TokenKind::BY_KW); @@ -279,7 +285,7 @@ fn emit_select_stmt_impl(e: &mut EventEmitter, n: &SelectStmt, with_semicolon: b match n.limit_option() { LimitOption::WithTies => { if let Some(ref limit_offset) = n.limit_offset { - e.line(LineType::SoftOrSpace); + super::emit_layout_break(e); e.token(TokenKind::OFFSET_KW); e.space(); super::emit_node(limit_offset, e); @@ -288,7 +294,7 @@ fn emit_select_stmt_impl(e: &mut EventEmitter, n: &SelectStmt, with_semicolon: b } if let Some(ref limit_count) = n.limit_count { - e.line(LineType::SoftOrSpace); + super::emit_layout_break(e); e.token(TokenKind::FETCH_KW); e.space(); e.token(TokenKind::FIRST_KW); @@ -304,14 +310,14 @@ fn emit_select_stmt_impl(e: &mut EventEmitter, n: &SelectStmt, with_semicolon: b } _ => { if let Some(ref limit_count) = n.limit_count { - e.line(LineType::SoftOrSpace); + super::emit_layout_break(e); e.token(TokenKind::LIMIT_KW); e.space(); super::emit_node(limit_count, e); } if let Some(ref limit_offset) = n.limit_offset { - e.line(LineType::SoftOrSpace); + super::emit_layout_break(e); e.token(TokenKind::OFFSET_KW); e.space(); super::emit_node(limit_offset, e); @@ -324,7 +330,7 @@ fn emit_select_stmt_impl(e: &mut EventEmitter, n: &SelectStmt, with_semicolon: b if let Some(pgls_query::NodeEnum::LockingClause(locking_clause)) = locking.node.as_ref() { - e.line(LineType::SoftOrSpace); + super::emit_layout_break(e); super::emit_locking_clause(e, locking_clause); } } diff --git a/crates/pgls_pretty_print/src/renderer.rs b/crates/pgls_pretty_print/src/renderer.rs index 36dcbbff9..83e708cb6 100644 --- a/crates/pgls_pretty_print/src/renderer.rs +++ b/crates/pgls_pretty_print/src/renderer.rs @@ -325,7 +325,7 @@ mod tests { #[test] fn test_keyword_case_upper() { - let mut emitter = EventEmitter::new(); + let mut emitter = EventEmitter::new(crate::FormatConfig::default()); emitter.token(TokenKind::SELECT_KW); emitter.space(); emitter.token(TokenKind::INT_NUMBER(1)); @@ -342,7 +342,7 @@ mod tests { #[test] fn test_keyword_case_lower() { - let mut emitter = EventEmitter::new(); + let mut emitter = EventEmitter::new(crate::FormatConfig::default()); emitter.token(TokenKind::SELECT_KW); emitter.space(); emitter.token(TokenKind::INT_NUMBER(1)); @@ -359,7 +359,7 @@ mod tests { #[test] fn test_constant_case_upper() { - let mut emitter = EventEmitter::new(); + let mut emitter = EventEmitter::new(crate::FormatConfig::default()); emitter.token(TokenKind::SELECT_KW); emitter.space(); emitter.token(TokenKind::NULL); @@ -382,7 +382,7 @@ mod tests { #[test] fn test_constant_case_lower() { - let mut emitter = EventEmitter::new(); + let mut emitter = EventEmitter::new(crate::FormatConfig::default()); emitter.token(TokenKind::SELECT_KW); emitter.space(); emitter.token(TokenKind::NULL); @@ -402,7 +402,7 @@ mod tests { #[test] fn test_mixed_case_settings() { - let mut emitter = EventEmitter::new(); + let mut emitter = EventEmitter::new(crate::FormatConfig::default()); emitter.token(TokenKind::SELECT_KW); emitter.space(); emitter.token(TokenKind::INT_NUMBER(1)); diff --git a/crates/pgls_pretty_print/tests/data/single/expanded_insert.sql b/crates/pgls_pretty_print/tests/data/single/expanded_insert.sql new file mode 100644 index 000000000..16fd864f1 --- /dev/null +++ b/crates/pgls_pretty_print/tests/data/single/expanded_insert.sql @@ -0,0 +1,2 @@ +-- pgls-format: layout=expanded, indentStyle=tabs, indentSize=4 +INSERT INTO t.x (a, b) WITH c AS (SELECT 1 AS a, 2 AS b FROM t.y) SELECT c.a, c.b FROM c; diff --git a/crates/pgls_pretty_print/tests/data/single/expanded_joins.sql b/crates/pgls_pretty_print/tests/data/single/expanded_joins.sql new file mode 100644 index 000000000..f5358ed91 --- /dev/null +++ b/crates/pgls_pretty_print/tests/data/single/expanded_joins.sql @@ -0,0 +1,2 @@ +-- pgls-format: layout=expanded, indentStyle=tabs, indentSize=4 +SELECT a FROM a LEFT JOIN b ON b.a = a.a JOIN c ON c.a = a.a; diff --git a/crates/pgls_pretty_print/tests/data/single/expanded_select.sql b/crates/pgls_pretty_print/tests/data/single/expanded_select.sql new file mode 100644 index 000000000..187083ecc --- /dev/null +++ b/crates/pgls_pretty_print/tests/data/single/expanded_select.sql @@ -0,0 +1,2 @@ +-- pgls-format: layout=expanded, indentStyle=tabs, indentSize=4 +SELECT t.a, t.b FROM s.t WHERE t.c = 1 GROUP BY t.a ORDER BY t.a; diff --git a/crates/pgls_pretty_print/tests/data/single/format_config_header.sql b/crates/pgls_pretty_print/tests/data/single/format_config_header.sql new file mode 100644 index 000000000..80810d099 --- /dev/null +++ b/crates/pgls_pretty_print/tests/data/single/format_config_header.sql @@ -0,0 +1,6 @@ +-- pgls-format: keywordCase=upper, indentStyle=tabs, indentSize=4, lineWidth=80 +SELECT + t.a, + t.b +FROM s.t +WHERE t.a > 1; diff --git a/crates/pgls_pretty_print/tests/snapshots/single/tests__expanded_insert_100.snap b/crates/pgls_pretty_print/tests/snapshots/single/tests__expanded_insert_100.snap new file mode 100644 index 000000000..e431e600e --- /dev/null +++ b/crates/pgls_pretty_print/tests/snapshots/single/tests__expanded_insert_100.snap @@ -0,0 +1,22 @@ +--- +source: crates/pgls_pretty_print/tests/tests.rs +input_file: crates/pgls_pretty_print/tests/data/single/expanded_insert.sql +--- +insert into t.x ( + a, + b +) +with +c +as ( + select + 1 as a, + 2 as b + from + t.y +) +select + c.a, + c.b +from + c; diff --git a/crates/pgls_pretty_print/tests/snapshots/single/tests__expanded_insert_80.snap b/crates/pgls_pretty_print/tests/snapshots/single/tests__expanded_insert_80.snap new file mode 100644 index 000000000..e431e600e --- /dev/null +++ b/crates/pgls_pretty_print/tests/snapshots/single/tests__expanded_insert_80.snap @@ -0,0 +1,22 @@ +--- +source: crates/pgls_pretty_print/tests/tests.rs +input_file: crates/pgls_pretty_print/tests/data/single/expanded_insert.sql +--- +insert into t.x ( + a, + b +) +with +c +as ( + select + 1 as a, + 2 as b + from + t.y +) +select + c.a, + c.b +from + c; diff --git a/crates/pgls_pretty_print/tests/snapshots/single/tests__expanded_joins_100.snap b/crates/pgls_pretty_print/tests/snapshots/single/tests__expanded_joins_100.snap new file mode 100644 index 000000000..a84567a68 --- /dev/null +++ b/crates/pgls_pretty_print/tests/snapshots/single/tests__expanded_joins_100.snap @@ -0,0 +1,10 @@ +--- +source: crates/pgls_pretty_print/tests/tests.rs +input_file: crates/pgls_pretty_print/tests/data/single/expanded_joins.sql +--- +select + a +from + a + left outer join b on b.a = a.a + inner join c on c.a = a.a; diff --git a/crates/pgls_pretty_print/tests/snapshots/single/tests__expanded_joins_80.snap b/crates/pgls_pretty_print/tests/snapshots/single/tests__expanded_joins_80.snap new file mode 100644 index 000000000..a84567a68 --- /dev/null +++ b/crates/pgls_pretty_print/tests/snapshots/single/tests__expanded_joins_80.snap @@ -0,0 +1,10 @@ +--- +source: crates/pgls_pretty_print/tests/tests.rs +input_file: crates/pgls_pretty_print/tests/data/single/expanded_joins.sql +--- +select + a +from + a + left outer join b on b.a = a.a + inner join c on c.a = a.a; diff --git a/crates/pgls_pretty_print/tests/snapshots/single/tests__expanded_select_100.snap b/crates/pgls_pretty_print/tests/snapshots/single/tests__expanded_select_100.snap new file mode 100644 index 000000000..766438833 --- /dev/null +++ b/crates/pgls_pretty_print/tests/snapshots/single/tests__expanded_select_100.snap @@ -0,0 +1,13 @@ +--- +source: crates/pgls_pretty_print/tests/tests.rs +input_file: crates/pgls_pretty_print/tests/data/single/expanded_select.sql +--- +select + t.a, + t.b +from + s.t +where + t.c = 1 +group by t.a +order by t.a; diff --git a/crates/pgls_pretty_print/tests/snapshots/single/tests__expanded_select_80.snap b/crates/pgls_pretty_print/tests/snapshots/single/tests__expanded_select_80.snap new file mode 100644 index 000000000..766438833 --- /dev/null +++ b/crates/pgls_pretty_print/tests/snapshots/single/tests__expanded_select_80.snap @@ -0,0 +1,13 @@ +--- +source: crates/pgls_pretty_print/tests/tests.rs +input_file: crates/pgls_pretty_print/tests/data/single/expanded_select.sql +--- +select + t.a, + t.b +from + s.t +where + t.c = 1 +group by t.a +order by t.a; diff --git a/crates/pgls_pretty_print/tests/snapshots/single/tests__format_config_header_80.snap b/crates/pgls_pretty_print/tests/snapshots/single/tests__format_config_header_80.snap new file mode 100644 index 000000000..6c5d4d49e --- /dev/null +++ b/crates/pgls_pretty_print/tests/snapshots/single/tests__format_config_header_80.snap @@ -0,0 +1,5 @@ +--- +source: crates/pgls_pretty_print/tests/tests.rs +input_file: crates/pgls_pretty_print/tests/data/single/format_config_header.sql +--- +SELECT t.a, t.b FROM s.t WHERE t.a > 1; diff --git a/crates/pgls_pretty_print/tests/tests.rs b/crates/pgls_pretty_print/tests/tests.rs index 0eb68ed4d..9e57a52b8 100644 --- a/crates/pgls_pretty_print/tests/tests.rs +++ b/crates/pgls_pretty_print/tests/tests.rs @@ -3,10 +3,11 @@ use dir_test::{Fixture, dir_test}; use insta::{assert_snapshot, with_settings}; use pgls_pretty_print::{ + FormatConfig, Layout, emitter::EventEmitter, nodes::emit_node_enum, normalize::normalize_ast, - renderer::{IndentStyle, RenderConfig, Renderer}, + renderer::{IndentStyle, KeywordCase, RenderConfig, Renderer}, }; /// Line widths to test - each test file is run at both widths @@ -37,12 +38,64 @@ enum StringState { Dollar(Vec), } +/// A fixture may open with `-- pgls-format: key=value, key=value` to declare the configuration it +/// must be rendered with. Keeping it in the fixture rather than in the harness is what lets an +/// option that is off by default own its own test data. +/// +/// It returns a `FormatConfig`, not a `RenderConfig`: some options decide which tokens exist and +/// are therefore read by the emitter, so the fixture has to reach both sides of the pipeline. +fn parse_fixture(content: &str) -> (FormatConfig, Option, String) { + const HEADER: &str = "-- pgls-format:"; + + let mut config = FormatConfig::default(); + let mut explicit_width = None; + + let Some(rest) = content.strip_prefix(HEADER) else { + return (config, None, content.to_string()); + }; + + let (header, sql) = match rest.split_once('\n') { + Some((header, sql)) => (header, sql), + None => (rest, ""), + }; + + for entry in header.split(',') { + let Some((key, value)) = entry.split_once('=') else { + panic!("malformed pgls-format entry: {entry}"); + }; + + match (key.trim(), value.trim()) { + ("lineWidth", value) => { + let width = value.parse().expect("lineWidth must be a number"); + config.line_width = width; + explicit_width = Some(width); + } + ("indentSize", value) => { + config.indent_size = value.parse().expect("indentSize must be a number"); + } + ("indentStyle", "tabs") => config.indent_style = IndentStyle::Tabs, + ("indentStyle", "spaces") => config.indent_style = IndentStyle::Spaces, + ("keywordCase", "upper") => config.keyword_case = KeywordCase::Upper, + ("keywordCase", "lower") => config.keyword_case = KeywordCase::Lower, + ("constantCase", "upper") => config.constant_case = KeywordCase::Upper, + ("constantCase", "lower") => config.constant_case = KeywordCase::Lower, + ("typeCase", "upper") => config.type_case = KeywordCase::Upper, + ("typeCase", "lower") => config.type_case = KeywordCase::Lower, + ("layout", "expanded") => config.layout = Layout::Expanded, + ("layout", "fit") => config.layout = Layout::Fit, + (key, value) => panic!("unknown pgls-format entry: {key}={value}"), + } + } + + (config, explicit_width, sql.to_string()) +} + #[dir_test( dir: "$CARGO_MANIFEST_DIR/tests/data/single/", glob: "*.sql", )] fn test_single(fixture: Fixture<&str>) { - let content = fixture.content(); + let (fixture_config, explicit_width, content) = parse_fixture(fixture.content()); println!("Original content:\n{content}"); @@ -53,24 +106,28 @@ fn test_single(fixture: Fixture<&str>) { .and_then(|x| x.strip_suffix(".sql")) .unwrap(); - // Run test at each configured line width - for &max_line_length in &LINE_WIDTHS { + let widths: Vec = match explicit_width { + Some(width) => vec![width], + None => LINE_WIDTHS.to_vec(), + }; + + for max_line_length in widths { let test_name = format!("{base_test_name}_{max_line_length}"); - let parsed = pgls_query::parse(content).expect("Failed to parse SQL"); + let parsed = pgls_query::parse(&content).expect("Failed to parse SQL"); let mut ast = parsed.into_root().expect("No root node found"); println!("Parsed AST: {ast:#?}"); - let mut emitter = EventEmitter::new(); + // The emitter gets the fixture config, not the default one: an option that decides which + // tokens exist is read here, before the renderer ever sees the events. + let mut emitter = EventEmitter::new(fixture_config.clone()); emit_node_enum(&ast, &mut emitter); let mut output = String::new(); let config = RenderConfig { max_line_length, - indent_size: 2, - indent_style: IndentStyle::Spaces, - ..Default::default() + ..RenderConfig::from(fixture_config.clone()) }; let mut renderer = Renderer::new(&mut output, config); renderer.render(emitter.events).expect("Failed to render"); @@ -119,19 +176,23 @@ fn test_multi(fixture: Fixture<&str>) { } } - let content = fixture.content(); + let (fixture_config, explicit_width, content) = parse_fixture(fixture.content()); let input_file = absolute_fixture_path; let base_test_name = absolute_fixture_path .file_name() .and_then(|x| x.strip_suffix(".sql")) .unwrap(); - // Run test at each configured line width - for &max_line_length in &LINE_WIDTHS { + let widths: Vec = match explicit_width { + Some(width) => vec![width], + None => LINE_WIDTHS.to_vec(), + }; + + for max_line_length in widths { let test_name = format!("{base_test_name}_{max_line_length}"); // Split the content into statements - let split_result = pgls_statement_splitter::split(content); + let split_result = pgls_statement_splitter::split(&content); let mut formatted_statements = Vec::new(); for range in &split_result.ranges { @@ -147,15 +208,15 @@ fn test_multi(fixture: Fixture<&str>) { println!("Parsed AST: {ast:#?}"); - let mut emitter = EventEmitter::new(); + // The emitter gets the fixture config, not the default one: an option that decides + // which tokens exist is read here, before the renderer ever sees the events. + let mut emitter = EventEmitter::new(fixture_config.clone()); emit_node_enum(&ast, &mut emitter); let mut output = String::new(); let config = RenderConfig { max_line_length, - indent_size: 2, - indent_style: IndentStyle::Spaces, - ..Default::default() + ..RenderConfig::from(fixture_config.clone()) }; let mut renderer = Renderer::new(&mut output, config); renderer.render(emitter.events).expect("Failed to render"); diff --git a/crates/pgls_workspace/src/settings.rs b/crates/pgls_workspace/src/settings.rs index 70d864dc0..e725178b2 100644 --- a/crates/pgls_workspace/src/settings.rs +++ b/crates/pgls_workspace/src/settings.rs @@ -18,7 +18,7 @@ use pgls_configuration::{ database::PartialDatabaseConfiguration, diagnostics::InvalidIgnorePattern, files::FilesConfiguration, - format::{FormatConfiguration, IndentStyle, KeywordCase}, + format::{FormatConfiguration, IndentStyle, KeywordCase, Layout}, migrations::{MigrationsConfiguration, PartialMigrationsConfiguration}, pglinter::PglinterConfiguration, plpgsql_check::PlPgSqlCheckConfiguration, @@ -376,6 +376,7 @@ fn to_formatter_settings( keyword_case: conf.keyword_case, constant_case: conf.constant_case, type_case: conf.type_case, + layout: conf.layout, skip_fn_bodies: conf.skip_fn_bodies, ignored_files: to_matcher(working_directory.clone(), Some(&conf.ignore))?, included_files: to_matcher(working_directory.clone(), Some(&conf.include))?, @@ -578,6 +579,9 @@ pub struct FormatterSettings { /// Data type casing (text, varchar, int): upper or lower. Default: lower. pub type_case: KeywordCase, + /// How a statement is laid out across lines: fit or expanded. Default: fit. + pub layout: Layout, + /// If true, skip formatting of SQL function bodies (keep them verbatim). Default: false. pub skip_fn_bodies: bool, @@ -598,6 +602,7 @@ impl Default for FormatterSettings { keyword_case: KeywordCase::default(), constant_case: KeywordCase::default(), type_case: KeywordCase::default(), + layout: Layout::default(), skip_fn_bodies: false, ignored_files: Matcher::empty(), included_files: Matcher::empty(), diff --git a/crates/pgls_workspace/src/workspace/server.rs b/crates/pgls_workspace/src/workspace/server.rs index ec20cf15c..bfd72c693 100644 --- a/crates/pgls_workspace/src/workspace/server.rs +++ b/crates/pgls_workspace/src/workspace/server.rs @@ -937,6 +937,7 @@ impl Workspace for WorkspaceServer { keyword_case: settings.formatter.keyword_case.into(), constant_case: settings.formatter.constant_case.into(), type_case: settings.formatter.type_case.into(), + layout: settings.formatter.layout.into(), }; let mut diagnostics = Vec::new(); diff --git a/docs/features/formatting.md b/docs/features/formatting.md index e7700a0d6..84c4b4864 100644 --- a/docs/features/formatting.md +++ b/docs/features/formatting.md @@ -37,6 +37,7 @@ Configure formatting behavior in your `postgres-language-server.jsonc`: | `keywordCase` | `"lower"` | Casing for SQL keywords: `"upper"` or `"lower"` | | `constantCase` | `"lower"` | Casing for constants (NULL, TRUE, FALSE): `"upper"` or `"lower"` | | `typeCase` | `"lower"` | Casing for data types (text, int, varchar): `"upper"` or `"lower"` | +| `layout` | `"fit"` | How a statement is laid out: `"fit"` breaks only past the line width, `"expanded"` always breaks between clauses | ### Example Output diff --git a/docs/schema.json b/docs/schema.json index d21ba9ee1..d31ba7e4e 100644 --- a/docs/schema.json +++ b/docs/schema.json @@ -525,6 +525,17 @@ } ] }, + "layout": { + "description": "How a statement is laid out: \"fit\" breaks only when a line would exceed the line width, \"expanded\" always breaks between clauses. Default: \"fit\".", + "anyOf": [ + { + "$ref": "#/definitions/Layout" + }, + { + "type": "null" + } + ] + }, "lineWidth": { "description": "Maximum line width before breaking. Default: 100.", "type": [ @@ -593,6 +604,14 @@ } ] }, + "Layout": { + "description": "How a statement is laid out across lines.", + "type": "string", + "enum": [ + "fit", + "expanded" + ] + }, "LinterConfiguration": { "type": "object", "properties": {