From 5e15d2b6a54e8d4754e3a8dbf12f957b3bc7186a Mon Sep 17 00:00:00 2001 From: edjubert Date: Mon, 14 Sep 2026 11:06:29 +0200 Subject: [PATCH 1/7] test(pretty-print): allow per-fixture format configuration --- .../data/single/format_config_header.sql | 6 ++ .../tests__format_config_header_80.snap | 5 ++ crates/pgls_pretty_print/tests/tests.rs | 86 +++++++++++++++---- 3 files changed, 82 insertions(+), 15 deletions(-) create mode 100644 crates/pgls_pretty_print/tests/data/single/format_config_header.sql create mode 100644 crates/pgls_pretty_print/tests/snapshots/single/tests__format_config_header_80.snap 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__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..9baab6c7e 100644 --- a/crates/pgls_pretty_print/tests/tests.rs +++ b/crates/pgls_pretty_print/tests/tests.rs @@ -6,7 +6,7 @@ use pgls_pretty_print::{ 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 +37,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. +fn parse_fixture(content: &str) -> (RenderConfig, Option, String) { + const HEADER: &str = "-- pgls-format:"; + + let mut config = RenderConfig { + max_line_length: 100, + indent_size: 2, + indent_style: IndentStyle::Spaces, + ..Default::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.max_line_length = 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, + (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,11 +105,15 @@ 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:#?}"); @@ -68,9 +124,7 @@ fn test_single(fixture: Fixture<&str>) { let mut output = String::new(); let config = RenderConfig { max_line_length, - indent_size: 2, - indent_style: IndentStyle::Spaces, - ..Default::default() + ..fixture_config.clone() }; let mut renderer = Renderer::new(&mut output, config); renderer.render(emitter.events).expect("Failed to render"); @@ -119,19 +173,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 { @@ -153,9 +211,7 @@ fn test_multi(fixture: Fixture<&str>) { let mut output = String::new(); let config = RenderConfig { max_line_length, - indent_size: 2, - indent_style: IndentStyle::Spaces, - ..Default::default() + ..fixture_config.clone() }; let mut renderer = Renderer::new(&mut output, config); renderer.render(emitter.events).expect("Failed to render"); From a513ed25d4c3a80451fa38a61686b4f2f6414379 Mon Sep 17 00:00:00 2001 From: edjubert Date: Mon, 14 Sep 2026 11:07:49 +0200 Subject: [PATCH 2/7] refactor(pretty-print): thread the format config into the emitter --- crates/pgls_pretty_print/src/emitter.rs | 21 +++++++++++++++--- crates/pgls_pretty_print/src/lib.rs | 2 +- crates/pgls_pretty_print/src/renderer.rs | 10 ++++----- crates/pgls_pretty_print/tests/tests.rs | 27 +++++++++++++----------- 4 files changed, 39 insertions(+), 21 deletions(-) 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..818b672e1 100644 --- a/crates/pgls_pretty_print/src/lib.rs +++ b/crates/pgls_pretty_print/src/lib.rs @@ -104,7 +104,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 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/tests.rs b/crates/pgls_pretty_print/tests/tests.rs index 9baab6c7e..8fd18c94d 100644 --- a/crates/pgls_pretty_print/tests/tests.rs +++ b/crates/pgls_pretty_print/tests/tests.rs @@ -3,6 +3,7 @@ use dir_test::{Fixture, dir_test}; use insta::{assert_snapshot, with_settings}; use pgls_pretty_print::{ + FormatConfig, emitter::EventEmitter, nodes::emit_node_enum, normalize::normalize_ast, @@ -40,15 +41,13 @@ enum StringState { /// 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. -fn parse_fixture(content: &str) -> (RenderConfig, Option, String) { +/// +/// 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 = RenderConfig { - max_line_length: 100, - indent_size: 2, - indent_style: IndentStyle::Spaces, - ..Default::default() - }; + let mut config = FormatConfig::default(); let mut explicit_width = None; let Some(rest) = content.strip_prefix(HEADER) else { @@ -68,7 +67,7 @@ fn parse_fixture(content: &str) -> (RenderConfig, Option, String) { match (key.trim(), value.trim()) { ("lineWidth", value) => { let width = value.parse().expect("lineWidth must be a number"); - config.max_line_length = width; + config.line_width = width; explicit_width = Some(width); } ("indentSize", value) => { @@ -118,13 +117,15 @@ fn test_single(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, - ..fixture_config.clone() + ..RenderConfig::from(fixture_config.clone()) }; let mut renderer = Renderer::new(&mut output, config); renderer.render(emitter.events).expect("Failed to render"); @@ -205,13 +206,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, - ..fixture_config.clone() + ..RenderConfig::from(fixture_config.clone()) }; let mut renderer = Renderer::new(&mut output, config); renderer.render(emitter.events).expect("Failed to render"); From d5569ed4feda58f92eaa4ab9e952a8832ae8641d Mon Sep 17 00:00:00 2001 From: edjubert Date: Mon, 14 Sep 2026 11:45:49 +0200 Subject: [PATCH 3/7] feat(format): add the clauseBodyStyle option --- crates/pgls_configuration/src/format.rs | 36 +++++++++++++++++++ crates/pgls_pretty_print/src/lib.rs | 13 +++++++ crates/pgls_workspace/src/settings.rs | 7 +++- crates/pgls_workspace/src/workspace/server.rs | 1 + docs/features/formatting.md | 1 + docs/schema.json | 19 ++++++++++ 6 files changed, 76 insertions(+), 1 deletion(-) diff --git a/crates/pgls_configuration/src/format.rs b/crates/pgls_configuration/src/format.rs index 7855571cd..5559ffbf0 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 { } } +/// Where the body of a clause starts. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, Merge, PartialEq, Serialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[serde(rename_all = "lowercase")] +pub enum ClauseBodyStyle { + #[default] + Break, + Compact, +} + +impl FromStr for ClauseBodyStyle { + type Err = &'static str; + + fn from_str(s: &str) -> Result { + match s { + "break" => Ok(Self::Break), + "compact" => Ok(Self::Compact), + _ => Err("Value not supported for ClauseBodyStyle. Use 'break' or 'compact'."), + } + } +} + +impl From for pgls_pretty_print::ClauseBodyStyle { + fn from(style: ClauseBodyStyle) -> Self { + match style { + ClauseBodyStyle::Break => Self::Break, + ClauseBodyStyle::Compact => Self::Compact, + } + } +} + /// 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, + /// Where the body of a clause starts: "break" for a new line, "compact" to keep the first + /// element on the keyword line. Default: "break". + #[partial(bpaf(long("clause-body-style")))] + pub clause_body_style: ClauseBodyStyle, /// 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(), + clause_body_style: ClauseBodyStyle::default(), skip_fn_bodies: false, ignore: Default::default(), include: Default::default(), diff --git a/crates/pgls_pretty_print/src/lib.rs b/crates/pgls_pretty_print/src/lib.rs index 818b672e1..df318b916 100644 --- a/crates/pgls_pretty_print/src/lib.rs +++ b/crates/pgls_pretty_print/src/lib.rs @@ -31,6 +31,16 @@ pub enum FormatError { BetaUnsupported { message: String }, } +/// Where the body of a clause starts. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub enum ClauseBodyStyle { + /// The body starts on the line after the keyword. + #[default] + Break, + /// The first element of the body stays on the keyword line: `FROM plan`. + Compact, +} + /// Configuration for the SQL formatter. #[derive(Debug, Clone)] pub struct FormatConfig { @@ -46,6 +56,8 @@ pub struct FormatConfig { pub constant_case: KeywordCase, /// Casing for data types (text, varchar, int). Default: Lower. pub type_case: KeywordCase, + /// Where the body of a clause starts. Default: Break. + pub clause_body_style: ClauseBodyStyle, } impl Default for FormatConfig { @@ -57,6 +69,7 @@ impl Default for FormatConfig { keyword_case: KeywordCase::default(), constant_case: KeywordCase::default(), type_case: KeywordCase::default(), + clause_body_style: ClauseBodyStyle::default(), } } } diff --git a/crates/pgls_workspace/src/settings.rs b/crates/pgls_workspace/src/settings.rs index 70d864dc0..8eb24c501 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::{ClauseBodyStyle, FormatConfiguration, IndentStyle, KeywordCase}, 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, + clause_body_style: conf.clause_body_style, 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, + /// Where the body of a clause starts: break or compact. Default: break. + pub clause_body_style: ClauseBodyStyle, + /// 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(), + clause_body_style: ClauseBodyStyle::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..c428bd64d 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(), + clause_body_style: settings.formatter.clause_body_style.into(), }; let mut diagnostics = Vec::new(); diff --git a/docs/features/formatting.md b/docs/features/formatting.md index e7700a0d6..2a7a21a14 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"` | +| `clauseBodyStyle` | `"break"` | Where a clause body starts: `"break"` for a new line, `"compact"` to keep the first element on the keyword line | ### Example Output diff --git a/docs/schema.json b/docs/schema.json index d21ba9ee1..8e8392b19 100644 --- a/docs/schema.json +++ b/docs/schema.json @@ -288,6 +288,14 @@ }, "additionalProperties": false }, + "ClauseBodyStyle": { + "description": "Where the body of a clause starts.", + "type": "string", + "enum": [ + "break", + "compact" + ] + }, "Cluster": { "description": "A list of rules that belong to this group", "type": "object", @@ -454,6 +462,17 @@ "description": "The configuration for SQL formatting.", "type": "object", "properties": { + "clauseBodyStyle": { + "description": "Where the body of a clause starts: \"break\" for a new line, \"compact\" to keep the first element on the keyword line. Default: \"break\".", + "anyOf": [ + { + "$ref": "#/definitions/ClauseBodyStyle" + }, + { + "type": "null" + } + ] + }, "constantCase": { "description": "Constant casing (NULL, TRUE, FALSE): \"upper\" or \"lower\". Default: \"lower\".", "anyOf": [ From a9d3259d254aab6cd697bd080271ceb7db4be14c Mon Sep 17 00:00:00 2001 From: edjubert Date: Mon, 14 Sep 2026 11:46:29 +0200 Subject: [PATCH 4/7] feat(pretty-print): keep a condition on its keyword line when compact --- crates/pgls_pretty_print/src/nodes/mod.rs | 9 ++++++++- .../tests/data/single/compact_clause_condition.sql | 5 +++++ .../single/tests__compact_clause_condition_80.snap | 11 +++++++++++ crates/pgls_pretty_print/tests/tests.rs | 6 +++++- 4 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 crates/pgls_pretty_print/tests/data/single/compact_clause_condition.sql create mode 100644 crates/pgls_pretty_print/tests/snapshots/single/tests__compact_clause_condition_80.snap diff --git a/crates/pgls_pretty_print/src/nodes/mod.rs b/crates/pgls_pretty_print/src/nodes/mod.rs index 9c5e3c39d..01eb2bbd9 100644 --- a/crates/pgls_pretty_print/src/nodes/mod.rs +++ b/crates/pgls_pretty_print/src/nodes/mod.rs @@ -534,9 +534,16 @@ pub fn emit_node(node: &Node, e: &mut EventEmitter) { } pub(super) fn emit_clause_condition(e: &mut EventEmitter, clause: &Node) { + use crate::ClauseBodyStyle; use crate::emitter::LineType; - e.line(LineType::SoftOrSpace); + // Compact keeps the condition on the keyword line. The indent stays in both cases so that the + // condition's own continuation lines, the second AND of a chain for instance, sit under it. + match e.config().clause_body_style { + ClauseBodyStyle::Compact => e.space(), + ClauseBodyStyle::Break => e.line(LineType::SoftOrSpace), + } + e.indent_start(); emit_node(clause, e); e.indent_end(); diff --git a/crates/pgls_pretty_print/tests/data/single/compact_clause_condition.sql b/crates/pgls_pretty_print/tests/data/single/compact_clause_condition.sql new file mode 100644 index 000000000..4ac0a4f22 --- /dev/null +++ b/crates/pgls_pretty_print/tests/data/single/compact_clause_condition.sql @@ -0,0 +1,5 @@ +-- pgls-format: clauseBodyStyle=compact, indentStyle=tabs, indentSize=4, lineWidth=80 +SELECT staging.buildings.id +FROM staging.buildings +WHERE staging.buildings.construction_year > 1950 + AND staging.buildings.address_fk IS NOT NULL; diff --git a/crates/pgls_pretty_print/tests/snapshots/single/tests__compact_clause_condition_80.snap b/crates/pgls_pretty_print/tests/snapshots/single/tests__compact_clause_condition_80.snap new file mode 100644 index 000000000..4053f641d --- /dev/null +++ b/crates/pgls_pretty_print/tests/snapshots/single/tests__compact_clause_condition_80.snap @@ -0,0 +1,11 @@ +--- +source: crates/pgls_pretty_print/tests/tests.rs +input_file: crates/pgls_pretty_print/tests/data/single/compact_clause_condition.sql +--- +select + staging.buildings.id +from + staging.buildings +where staging.buildings.construction_year > + 1950 and + staging.buildings.address_fk is not null; diff --git a/crates/pgls_pretty_print/tests/tests.rs b/crates/pgls_pretty_print/tests/tests.rs index 8fd18c94d..c5e7758f8 100644 --- a/crates/pgls_pretty_print/tests/tests.rs +++ b/crates/pgls_pretty_print/tests/tests.rs @@ -3,7 +3,7 @@ use dir_test::{Fixture, dir_test}; use insta::{assert_snapshot, with_settings}; use pgls_pretty_print::{ - FormatConfig, + ClauseBodyStyle, FormatConfig, emitter::EventEmitter, nodes::emit_node_enum, normalize::normalize_ast, @@ -81,6 +81,10 @@ fn parse_fixture(content: &str) -> (FormatConfig, Option, String) { ("constantCase", "lower") => config.constant_case = KeywordCase::Lower, ("typeCase", "upper") => config.type_case = KeywordCase::Upper, ("typeCase", "lower") => config.type_case = KeywordCase::Lower, + ("clauseBodyStyle", "break") => config.clause_body_style = ClauseBodyStyle::Break, + ("clauseBodyStyle", "compact") => { + config.clause_body_style = ClauseBodyStyle::Compact; + } (key, value) => panic!("unknown pgls-format entry: {key}={value}"), } } From 14968bb9be685f9d3d85ea138d933097d0d13497 Mon Sep 17 00:00:00 2001 From: edjubert Date: Mon, 14 Sep 2026 11:56:55 +0200 Subject: [PATCH 5/7] feat(pretty-print): keep FROM and WINDOW bodies on their keyword line when compact --- crates/pgls_pretty_print/src/nodes/select_stmt.rs | 15 ++++++++++++--- .../tests/data/single/compact_from_clause.sql | 7 +++++++ .../tests__compact_clause_condition_80.snap | 3 +-- .../single/tests__compact_from_clause_80.snap | 14 ++++++++++++++ 4 files changed, 34 insertions(+), 5 deletions(-) create mode 100644 crates/pgls_pretty_print/tests/data/single/compact_from_clause.sql create mode 100644 crates/pgls_pretty_print/tests/snapshots/single/tests__compact_from_clause_80.snap diff --git a/crates/pgls_pretty_print/src/nodes/select_stmt.rs b/crates/pgls_pretty_print/src/nodes/select_stmt.rs index 96a7ff829..32506e567 100644 --- a/crates/pgls_pretty_print/src/nodes/select_stmt.rs +++ b/crates/pgls_pretty_print/src/nodes/select_stmt.rs @@ -3,8 +3,8 @@ use pgls_query::{ protobuf::{LimitOption, SelectStmt, SetOperation}, }; -use crate::TokenKind; use crate::emitter::{EventEmitter, GroupKind, LineType}; +use crate::{ClauseBodyStyle, TokenKind}; use super::{ node_list::emit_comma_separated_list, string::emit_keyword, window_def::emit_window_definition, @@ -204,7 +204,13 @@ fn emit_select_stmt_impl(e: &mut EventEmitter, n: &SelectStmt, with_semicolon: b if !n.from_clause.is_empty() { e.line(LineType::SoftOrSpace); e.token(TokenKind::FROM_KW); - e.line(LineType::SoftOrSpace); + + // Compact keeps the first relation on the FROM line; the joins that follow still + // break onto their own indented lines. + match e.config().clause_body_style { + ClauseBodyStyle::Compact => e.space(), + ClauseBodyStyle::Break => e.line(LineType::SoftOrSpace), + } e.indent_start(); @@ -247,7 +253,10 @@ fn emit_select_stmt_impl(e: &mut EventEmitter, n: &SelectStmt, with_semicolon: b if !n.window_clause.is_empty() { e.line(LineType::SoftOrSpace); e.token(TokenKind::WINDOW_KW); - e.line(LineType::SoftOrSpace); + match e.config().clause_body_style { + ClauseBodyStyle::Compact => e.space(), + ClauseBodyStyle::Break => e.line(LineType::SoftOrSpace), + } e.indent_start(); for (idx, window) in n.window_clause.iter().enumerate() { if idx > 0 { diff --git a/crates/pgls_pretty_print/tests/data/single/compact_from_clause.sql b/crates/pgls_pretty_print/tests/data/single/compact_from_clause.sql new file mode 100644 index 000000000..6c306a4fc --- /dev/null +++ b/crates/pgls_pretty_print/tests/data/single/compact_from_clause.sql @@ -0,0 +1,7 @@ +-- pgls-format: clauseBodyStyle=compact, indentStyle=tabs, indentSize=4, lineWidth=80 +SELECT + staging.buildings.id, + staging.addresses.city +FROM staging.buildings + LEFT JOIN staging.addresses ON staging.addresses.id = staging.buildings.address_fk +WHERE staging.buildings.construction_year > 1950; diff --git a/crates/pgls_pretty_print/tests/snapshots/single/tests__compact_clause_condition_80.snap b/crates/pgls_pretty_print/tests/snapshots/single/tests__compact_clause_condition_80.snap index 4053f641d..3d9dbb9df 100644 --- a/crates/pgls_pretty_print/tests/snapshots/single/tests__compact_clause_condition_80.snap +++ b/crates/pgls_pretty_print/tests/snapshots/single/tests__compact_clause_condition_80.snap @@ -4,8 +4,7 @@ input_file: crates/pgls_pretty_print/tests/data/single/compact_clause_condition. --- select staging.buildings.id -from - staging.buildings +from staging.buildings where staging.buildings.construction_year > 1950 and staging.buildings.address_fk is not null; diff --git a/crates/pgls_pretty_print/tests/snapshots/single/tests__compact_from_clause_80.snap b/crates/pgls_pretty_print/tests/snapshots/single/tests__compact_from_clause_80.snap new file mode 100644 index 000000000..3f6b853f6 --- /dev/null +++ b/crates/pgls_pretty_print/tests/snapshots/single/tests__compact_from_clause_80.snap @@ -0,0 +1,14 @@ +--- +source: crates/pgls_pretty_print/tests/tests.rs +input_file: crates/pgls_pretty_print/tests/data/single/compact_from_clause.sql +--- +select + staging.buildings.id, + staging.addresses.city +from staging.buildings + left outer join + staging.addresses + on staging.addresses.id = + staging.buildings.address_fk +where staging.buildings.construction_year > + 1950; From 79fb4f1d24c0922b6c4d794804a60e333564dbee Mon Sep 17 00:00:00 2001 From: edjubert Date: Mon, 14 Sep 2026 11:59:03 +0200 Subject: [PATCH 6/7] feat(format): add the isolateSemicolon option --- crates/pgls_configuration/src/format.rs | 5 +++++ crates/pgls_pretty_print/src/lib.rs | 6 ++++++ crates/pgls_pretty_print/src/renderer.rs | 3 +++ crates/pgls_workspace/src/settings.rs | 6 ++++++ crates/pgls_workspace/src/workspace/server.rs | 1 + docs/features/formatting.md | 1 + docs/schema.json | 7 +++++++ 7 files changed, 29 insertions(+) diff --git a/crates/pgls_configuration/src/format.rs b/crates/pgls_configuration/src/format.rs index 5559ffbf0..c4258b78d 100644 --- a/crates/pgls_configuration/src/format.rs +++ b/crates/pgls_configuration/src/format.rs @@ -132,6 +132,10 @@ pub struct FormatConfiguration { /// element on the keyword line. Default: "break". #[partial(bpaf(long("clause-body-style")))] pub clause_body_style: ClauseBodyStyle, + /// If `true`, the terminating semicolon goes on its own line when the statement spans several + /// lines. Default: `false`. + #[partial(bpaf(long("isolate-semicolon")))] + pub isolate_semicolon: bool, /// If `true`, skip formatting of SQL function bodies (keep them verbatim). Default: `false`. #[partial(bpaf(long("skip-fn-bodies")))] pub skip_fn_bodies: bool, @@ -154,6 +158,7 @@ impl Default for FormatConfiguration { constant_case: KeywordCase::default(), type_case: KeywordCase::default(), clause_body_style: ClauseBodyStyle::default(), + isolate_semicolon: false, skip_fn_bodies: false, ignore: Default::default(), include: Default::default(), diff --git a/crates/pgls_pretty_print/src/lib.rs b/crates/pgls_pretty_print/src/lib.rs index df318b916..116af8f04 100644 --- a/crates/pgls_pretty_print/src/lib.rs +++ b/crates/pgls_pretty_print/src/lib.rs @@ -58,6 +58,9 @@ pub struct FormatConfig { pub type_case: KeywordCase, /// Where the body of a clause starts. Default: Break. pub clause_body_style: ClauseBodyStyle, + /// Put the terminating semicolon on its own line when the statement spans several lines. + /// Default: false. + pub isolate_semicolon: bool, } impl Default for FormatConfig { @@ -70,6 +73,7 @@ impl Default for FormatConfig { constant_case: KeywordCase::default(), type_case: KeywordCase::default(), clause_body_style: ClauseBodyStyle::default(), + isolate_semicolon: false, } } } @@ -83,6 +87,7 @@ impl From for RenderConfig { keyword_case: config.keyword_case, constant_case: config.constant_case, type_case: config.type_case, + isolate_semicolon: config.isolate_semicolon, } } } @@ -128,6 +133,7 @@ pub fn format_statement( keyword_case: config.keyword_case.clone(), constant_case: config.constant_case.clone(), type_case: config.type_case.clone(), + isolate_semicolon: config.isolate_semicolon, }; let mut output = String::new(); diff --git a/crates/pgls_pretty_print/src/renderer.rs b/crates/pgls_pretty_print/src/renderer.rs index 83e708cb6..bd27c43d3 100644 --- a/crates/pgls_pretty_print/src/renderer.rs +++ b/crates/pgls_pretty_print/src/renderer.rs @@ -25,6 +25,8 @@ pub struct RenderConfig { pub constant_case: KeywordCase, /// Casing for data types (text, varchar, int, etc.) pub type_case: KeywordCase, + /// Put the terminating semicolon on its own line when the statement spans several lines. + pub isolate_semicolon: bool, } impl Default for RenderConfig { @@ -36,6 +38,7 @@ impl Default for RenderConfig { keyword_case: KeywordCase::default(), constant_case: KeywordCase::default(), type_case: KeywordCase::default(), + isolate_semicolon: false, } } } diff --git a/crates/pgls_workspace/src/settings.rs b/crates/pgls_workspace/src/settings.rs index 8eb24c501..d7f49b543 100644 --- a/crates/pgls_workspace/src/settings.rs +++ b/crates/pgls_workspace/src/settings.rs @@ -377,6 +377,7 @@ fn to_formatter_settings( constant_case: conf.constant_case, type_case: conf.type_case, clause_body_style: conf.clause_body_style, + isolate_semicolon: conf.isolate_semicolon, 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))?, @@ -582,6 +583,10 @@ pub struct FormatterSettings { /// Where the body of a clause starts: break or compact. Default: break. pub clause_body_style: ClauseBodyStyle, + /// If true, the terminating semicolon goes on its own line when the statement spans several + /// lines. Default: false. + pub isolate_semicolon: bool, + /// If true, skip formatting of SQL function bodies (keep them verbatim). Default: false. pub skip_fn_bodies: bool, @@ -603,6 +608,7 @@ impl Default for FormatterSettings { constant_case: KeywordCase::default(), type_case: KeywordCase::default(), clause_body_style: ClauseBodyStyle::default(), + isolate_semicolon: false, 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 c428bd64d..eec7010f4 100644 --- a/crates/pgls_workspace/src/workspace/server.rs +++ b/crates/pgls_workspace/src/workspace/server.rs @@ -938,6 +938,7 @@ impl Workspace for WorkspaceServer { constant_case: settings.formatter.constant_case.into(), type_case: settings.formatter.type_case.into(), clause_body_style: settings.formatter.clause_body_style.into(), + isolate_semicolon: settings.formatter.isolate_semicolon, }; let mut diagnostics = Vec::new(); diff --git a/docs/features/formatting.md b/docs/features/formatting.md index 2a7a21a14..6b1e462fd 100644 --- a/docs/features/formatting.md +++ b/docs/features/formatting.md @@ -38,6 +38,7 @@ Configure formatting behavior in your `postgres-language-server.jsonc`: | `constantCase` | `"lower"` | Casing for constants (NULL, TRUE, FALSE): `"upper"` or `"lower"` | | `typeCase` | `"lower"` | Casing for data types (text, int, varchar): `"upper"` or `"lower"` | | `clauseBodyStyle` | `"break"` | Where a clause body starts: `"break"` for a new line, `"compact"` to keep the first element on the keyword line | +| `isolateSemicolon` | `false` | Put the terminating semicolon on its own line when the statement spans several lines | ### Example Output diff --git a/docs/schema.json b/docs/schema.json index 8e8392b19..18726991e 100644 --- a/docs/schema.json +++ b/docs/schema.json @@ -533,6 +533,13 @@ } ] }, + "isolateSemicolon": { + "description": "If `true`, the terminating semicolon goes on its own line when the statement spans several lines. Default: `false`.", + "type": [ + "boolean", + "null" + ] + }, "keywordCase": { "description": "Keyword casing: \"upper\" or \"lower\". Default: \"lower\".", "anyOf": [ From 4372bf263f49a664ab7a896e2735b8eafdce24f3 Mon Sep 17 00:00:00 2001 From: edjubert Date: Mon, 14 Sep 2026 12:01:23 +0200 Subject: [PATCH 7/7] feat(pretty-print): isolate the statement semicolon when asked --- crates/pgls_pretty_print/src/renderer.rs | 47 ++++++++++++++++++- .../tests/data/single/isolated_semicolon.sql | 6 +++ .../single/tests__isolated_semicolon_80.snap | 11 +++++ crates/pgls_pretty_print/tests/tests.rs | 5 ++ 4 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 crates/pgls_pretty_print/tests/data/single/isolated_semicolon.sql create mode 100644 crates/pgls_pretty_print/tests/snapshots/single/tests__isolated_semicolon_80.snap diff --git a/crates/pgls_pretty_print/src/renderer.rs b/crates/pgls_pretty_print/src/renderer.rs index bd27c43d3..8d8d789b9 100644 --- a/crates/pgls_pretty_print/src/renderer.rs +++ b/crates/pgls_pretty_print/src/renderer.rs @@ -1,3 +1,4 @@ +use crate::TokenKind; use crate::emitter::{LayoutEvent, LineType}; use std::fmt::Write; @@ -143,6 +144,14 @@ impl Renderer { while i < events.len() { match &events[i] { LayoutEvent::Token(token) => { + // This path is only taken when the enclosing group broke, so the statement + // already spans several lines and the terminator can stand alone. A statement + // that fits is rendered by try_single_line, which never reaches this code, and + // therefore keeps its semicolon attached. + if self.config.isolate_semicolon && matches!(token, TokenKind::SEMICOLON) { + self.write_line_break()?; + } + let text = token.render(&self.config); self.write_text(&text)?; i += 1; @@ -317,7 +326,7 @@ impl Renderer { mod tests { use super::*; use crate::codegen::token_kind::TokenKind; - use crate::emitter::EventEmitter; + use crate::emitter::{EventEmitter, GroupKind}; fn render_events(events: Vec, config: RenderConfig) -> String { let mut output = String::new(); @@ -438,4 +447,40 @@ 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_broken_statement_gets_its_semicolon_on_its_own_line() { + let mut emitter = EventEmitter::new(crate::FormatConfig::default()); + emitter.group_start(GroupKind::SelectStmt); + emitter.token(TokenKind::SELECT_KW); + emitter.line(crate::emitter::LineType::Hard); + emitter.token(TokenKind::INT_NUMBER(1)); + emitter.token(TokenKind::SEMICOLON); + emitter.group_end(); + + let config = RenderConfig { + isolate_semicolon: true, + ..Default::default() + }; + + let output = render_events(emitter.events, config); + assert_eq!(output, "select\n1\n;"); + } + + #[test] + fn a_single_line_statement_keeps_its_semicolon_attached() { + let mut emitter = EventEmitter::new(crate::FormatConfig::default()); + emitter.token(TokenKind::SELECT_KW); + emitter.space(); + emitter.token(TokenKind::INT_NUMBER(1)); + emitter.token(TokenKind::SEMICOLON); + + let config = RenderConfig { + isolate_semicolon: true, + ..Default::default() + }; + + let output = render_events(emitter.events, config); + assert_eq!(output, "select 1;"); + } } diff --git a/crates/pgls_pretty_print/tests/data/single/isolated_semicolon.sql b/crates/pgls_pretty_print/tests/data/single/isolated_semicolon.sql new file mode 100644 index 000000000..b8fe94ac4 --- /dev/null +++ b/crates/pgls_pretty_print/tests/data/single/isolated_semicolon.sql @@ -0,0 +1,6 @@ +-- pgls-format: isolateSemicolon=true, indentStyle=tabs, indentSize=4, lineWidth=80 +SELECT + staging.buildings.id, + staging.buildings.construction_year, + staging.buildings.address_fk +FROM staging.buildings; diff --git a/crates/pgls_pretty_print/tests/snapshots/single/tests__isolated_semicolon_80.snap b/crates/pgls_pretty_print/tests/snapshots/single/tests__isolated_semicolon_80.snap new file mode 100644 index 000000000..a35062687 --- /dev/null +++ b/crates/pgls_pretty_print/tests/snapshots/single/tests__isolated_semicolon_80.snap @@ -0,0 +1,11 @@ +--- +source: crates/pgls_pretty_print/tests/tests.rs +input_file: crates/pgls_pretty_print/tests/data/single/isolated_semicolon.sql +--- +select + staging.buildings.id, + staging.buildings.construction_year, + staging.buildings.address_fk +from + staging.buildings +; diff --git a/crates/pgls_pretty_print/tests/tests.rs b/crates/pgls_pretty_print/tests/tests.rs index c5e7758f8..3ecb69903 100644 --- a/crates/pgls_pretty_print/tests/tests.rs +++ b/crates/pgls_pretty_print/tests/tests.rs @@ -85,6 +85,11 @@ fn parse_fixture(content: &str) -> (FormatConfig, Option, String) { ("clauseBodyStyle", "compact") => { config.clause_body_style = ClauseBodyStyle::Compact; } + ("isolateSemicolon", value) => { + config.isolate_semicolon = value + .parse() + .expect("isolateSemicolon must be true or false"); + } (key, value) => panic!("unknown pgls-format entry: {key}={value}"), } }