diff --git a/crates/pgls_configuration/src/format.rs b/crates/pgls_configuration/src/format.rs index 7855571cd..c4258b78d 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,14 @@ 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`, 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, @@ -118,6 +157,8 @@ impl Default for FormatConfiguration { keyword_case: KeywordCase::default(), 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/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..116af8f04 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,11 @@ 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, + /// Put the terminating semicolon on its own line when the statement spans several lines. + /// Default: false. + pub isolate_semicolon: bool, } impl Default for FormatConfig { @@ -57,6 +72,8 @@ impl Default for FormatConfig { keyword_case: KeywordCase::default(), constant_case: KeywordCase::default(), type_case: KeywordCase::default(), + clause_body_style: ClauseBodyStyle::default(), + isolate_semicolon: false, } } } @@ -70,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, } } } @@ -104,7 +122,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 @@ -115,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/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/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/src/renderer.rs b/crates/pgls_pretty_print/src/renderer.rs index 36dcbbff9..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; @@ -25,6 +26,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 +39,7 @@ impl Default for RenderConfig { keyword_case: KeywordCase::default(), constant_case: KeywordCase::default(), type_case: KeywordCase::default(), + isolate_semicolon: false, } } } @@ -140,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; @@ -314,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(); @@ -325,7 +337,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 +354,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 +371,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 +394,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 +414,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)); @@ -435,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/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/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/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/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__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..3d9dbb9df --- /dev/null +++ b/crates/pgls_pretty_print/tests/snapshots/single/tests__compact_clause_condition_80.snap @@ -0,0 +1,10 @@ +--- +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/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; 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/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 0eb68ed4d..3ecb69903 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::{ + ClauseBodyStyle, FormatConfig, 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,71 @@ 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, + ("clauseBodyStyle", "break") => config.clause_body_style = ClauseBodyStyle::Break, + ("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}"), + } + } + + (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 +113,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 +183,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 +215,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..d7f49b543 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,8 @@ 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, + 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))?, @@ -578,6 +580,13 @@ 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, 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, @@ -598,6 +607,8 @@ impl Default for FormatterSettings { keyword_case: KeywordCase::default(), 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 ec20cf15c..eec7010f4 100644 --- a/crates/pgls_workspace/src/workspace/server.rs +++ b/crates/pgls_workspace/src/workspace/server.rs @@ -937,6 +937,8 @@ 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(), + isolate_semicolon: settings.formatter.isolate_semicolon, }; let mut diagnostics = Vec::new(); diff --git a/docs/features/formatting.md b/docs/features/formatting.md index e7700a0d6..6b1e462fd 100644 --- a/docs/features/formatting.md +++ b/docs/features/formatting.md @@ -37,6 +37,8 @@ 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 | +| `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 d21ba9ee1..18726991e 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": [ @@ -514,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": [