diff --git a/crates/pgls_workspace/src/workspace/server.rs b/crates/pgls_workspace/src/workspace/server.rs index 7a04b53c8..76bb44c26 100644 --- a/crates/pgls_workspace/src/workspace/server.rs +++ b/crates/pgls_workspace/src/workspace/server.rs @@ -19,7 +19,7 @@ use cache_budget::CacheBudget; use connection_manager::ConnectionManager; #[cfg(feature = "db")] use document::TypecheckDiagnosticsMapper; -use document::{DefaultMapper, Document, ExecuteStatementMapper, FormatStatementMapper}; +use document::{DefaultMapper, Document, ExecuteStatementMapper}; #[cfg(feature = "db")] use futures::{StreamExt, TryStreamExt, stream}; #[cfg(feature = "db")] @@ -69,7 +69,7 @@ use crate::{ CheckDatabaseConnectionParams, CheckDatabaseConnectionResult, PullDiagnosticsResult, PullFileDiagnosticsParams, }, - format::{PullFileFormattingParams, PullFormattingResult, StatementFormatResult}, + format::{PullFileFormattingParams, PullFormattingResult}, inlay_hints::{InlayHint, PullInlayHintsParams, PullInlayHintsResult}, on_hover::{OnHoverParams, OnHoverResult}, }, @@ -95,6 +95,7 @@ mod connection_key; #[cfg(feature = "db")] mod connection_manager; pub(crate) mod document; +mod formatting; mod migration; mod pg_query; #[cfg(feature = "db")] @@ -1595,193 +1596,14 @@ impl Workspace for WorkspaceServer { .get(¶ms.path) .ok_or(WorkspaceError::not_found())?; - let config = pgls_pretty_print::FormatConfig { - line_width: settings.formatter.line_width as usize, - indent_size: settings.formatter.indent_size as usize, - indent_style: settings.formatter.indent_style.into(), - keyword_case: settings.formatter.keyword_case.into(), - constant_case: settings.formatter.constant_case.into(), - type_case: settings.formatter.type_case.into(), - comma_style: settings.formatter.comma_style.into(), - logical_operator_placement: settings.formatter.logical_operator_placement.into(), - layout: settings.formatter.layout.into(), - values_row_layout: settings.formatter.values_row_layout.into(), - cast_style: settings.formatter.cast_style.into(), - clause_body_style: settings.formatter.clause_body_style.into(), - isolate_semicolon: settings.formatter.isolate_semicolon, - function_argument_groups: settings - .formatter - .function_argument_groups - .iter() - .map(|(name, size)| (name.to_lowercase(), usize::from(size.get()))) - .collect(), - }; - - let mut diagnostics = Vec::new(); - let mut statements = Vec::new(); - let mut formatted_output = String::new(); - let path_str = params.path.as_path().display().to_string(); - - let format_candidates: Vec<_> = doc.iter(FormatStatementMapper).collect(); - let mut formatted_sql_fn_bodies = HashMap::new(); - - // Only format SQL function bodies if skip_fn_bodies is false - if !settings.formatter.skip_fn_bodies { - for (id, _stmt_range, text, ast_result) in &format_candidates { - if !id.is_child() { - continue; - } - - let Some(parent_id) = id.parent() else { - continue; - }; - - let Ok(ast) = ast_result else { - continue; - }; - - let Ok(result) = pgls_pretty_print::format_statement(ast, text, &config) else { - continue; - }; - - let Some(formatted) = restore_named_parameters(text, &result.formatted, ast) else { - continue; - }; - - formatted_sql_fn_bodies.insert(parent_id, formatted); - } - } - - // Reconstruct the file by replacing each statement's source span with its - // formatted output while copying the gaps between statements (whitespace and - // standalone comments) verbatim. libpg_query drops comments from the AST, so - // anything that lives outside a statement's range would otherwise be lost. - let source = doc.get_document_content(); - let mut last_end = 0usize; - - // Formatting a range leaves everything outside its statements as written. - let collapses_blank_lines = params.range.is_none(); - - for (id, stmt_range, text, ast_result) in format_candidates { - if id.is_child() { - continue; - } - - let stmt_start = usize::from(stmt_range.start()); - let stmt_end = usize::from(stmt_range.end()); - - // Preserve the standalone comments that precede this statement, with at most one - // blank line between statements and none before the first. - let gap = &source[last_end..stmt_start]; - if collapses_blank_lines { - let gap = collapse_blank_lines(gap); - formatted_output.push_str(if last_end == 0 { - gap.trim_start() - } else { - &gap - }); - } else { - formatted_output.push_str(gap); - } - last_end = stmt_end; - - // Leave statements outside the requested range untouched. - if let Some(filter_range) = params.range - && stmt_range.intersect(filter_range).is_none() - { - formatted_output.push_str(&text); - continue; - } - - match ast_result { - Ok(ast) => { - let mut ast = ast; - if let Some(formatted_sql_fn_body) = formatted_sql_fn_bodies.get(&id) { - // Copies the tree only for the functions whose body is replaced. - sql_function::set_formatted_sql_fn_body( - Arc::make_mut(&mut ast), - formatted_sql_fn_body, - ); - } - - match pgls_pretty_print::format_statement(&ast, &text, &config) { - Ok(result) => { - match restore_named_parameters(&text, &result.formatted, &ast) { - Some(formatted) => { - if text != formatted { - statements.push(StatementFormatResult { - original: text.clone(), - formatted: formatted.clone(), - range: stmt_range, - }); - } - formatted_output.push_str(&formatted); - } - None => { - diagnostics.push(SDiagnostic::new( - pgls_diagnostics::Error::from( - WorkspaceError::format_error( - "Named parameters could not be restored after \ - formatting, the statement was left untouched" - .to_string(), - ), - ) - .with_file_path(&path_str) - .with_file_span(stmt_range), - )); - - formatted_output.push_str(&text); - } - } - } - Err(err) => { - diagnostics.push(SDiagnostic::new( - pgls_diagnostics::Error::from(WorkspaceError::format_error( - err.to_string(), - )) - .with_file_path(&path_str) - .with_file_span(stmt_range), - )); - - formatted_output.push_str(&text); - } - } - } - Err(syntax_err) => { - diagnostics.push(SDiagnostic::new( - pgls_diagnostics::Error::from(syntax_err).with_file_path(&path_str), - )); - - formatted_output.push_str(&text); - } - } - } - - // Preserve any trailing text or comments after the last statement, without blank lines - // at the end of the file but with its final line break. - let rest = &source[last_end..]; - if collapses_blank_lines { - let rest = collapse_blank_lines(rest); - let rest = if last_end == 0 { - rest.trim() - } else { - rest.trim_end() - }; - formatted_output.push_str(rest); - if source.ends_with('\n') && !formatted_output.is_empty() { - formatted_output.push('\n'); - } - } else { - formatted_output.push_str(rest); - } - + let result = formatting::format_document( + doc, + &settings.formatter, + params.range, + ¶ms.path.as_path().display().to_string(), + ); self.charge_document_caches(&documents, ¶ms.path); - Ok(PullFormattingResult { - original: doc.get_document_content().to_string(), - formatted: formatted_output, - statements, - diagnostics, - }) + Ok(result) } #[cfg(feature = "db")] @@ -2049,93 +1871,6 @@ fn is_dir(path: &Path) -> bool { path.is_dir() || (path.is_symlink() && fs::read_link(path).is_ok_and(|path| path.is_dir())) } -/// Keeps at most one blank line in `text`, which only holds whitespace and comments. -fn collapse_blank_lines(text: &str) -> String { - let mut result = String::with_capacity(text.len()); - // Line breaks since the last text that is not whitespace. Two make a blank line. - let mut line_breaks = 0; - for line in text.split_inclusive('\n') { - let is_blank = line.trim().is_empty(); - let ends_line = line.ends_with('\n'); - if is_blank && ends_line { - line_breaks += 1; - if line_breaks <= 2 { - result.push('\n'); - } - continue; - } - - result.push_str(line); - if !is_blank { - line_breaks = usize::from(ends_line); - } - } - result -} - -/// Put psql named parameters back where the formatter printed their placeholders. -/// -/// The parser only ever sees placeholders, so the printed statement carries `$1` or a bare -/// identifier instead of `:'agen_code'` or `:raw_data`. Substitution happens on the token stream -/// so that a `$1` inside a string literal is left alone, and the result is proven by converting it -/// back and comparing the normalized ASTs: a statement that does not survive that round trip is -/// reported as unrestorable and kept as it was written. -fn restore_named_parameters( - original: &str, - formatted: &str, - original_ast: &pgls_query::NodeEnum, -) -> Option { - use pgls_lexer::{SyntaxKind, lex}; - - let conversion = pgls_lexer::convert_to_positional_params_with_metadata(original); - - if conversion.identifier_replacements.is_empty() && conversion.value_replacements.is_empty() { - return Some(formatted.to_string()); - } - - if !conversion.restorable { - return None; - } - - let lexed = lex(formatted); - let mut restored = String::with_capacity(formatted.len()); - - for (idx, kind) in lexed.tokens().enumerate() { - if kind == SyntaxKind::EOF { - break; - } - - let text = lexed.text(idx); - - let replacement = match kind { - SyntaxKind::IDENT => conversion.identifier_replacements.get(text), - SyntaxKind::POSITIONAL_PARAM => text - .trim_start_matches('$') - .parse::() - .ok() - .and_then(|index| conversion.value_replacements.get(&index)), - _ => None, - }; - - match replacement { - Some(source_text) => restored.push_str(source_text), - None => restored.push_str(text), - } - } - - let reparsed = pgls_query::parse(&pgls_lexer::convert_to_positional_params(&restored)).ok()?; - let mut restored_ast = reparsed.into_root()?; - let mut expected_ast = original_ast.clone(); - pgls_pretty_print::normalize_ast(&mut restored_ast); - pgls_pretty_print::normalize_ast(&mut expected_ast); - - if restored_ast == expected_ast { - Some(restored) - } else { - None - } -} - #[cfg(all(test, feature = "db"))] #[path = "server.tests.rs"] mod tests; diff --git a/crates/pgls_workspace/src/workspace/server/analyser.rs b/crates/pgls_workspace/src/workspace/server/analyser.rs index 7f88c3a2c..c2860617c 100644 --- a/crates/pgls_workspace/src/workspace/server/analyser.rs +++ b/crates/pgls_workspace/src/workspace/server/analyser.rs @@ -5,9 +5,9 @@ use rustc_hash::FxHashSet; use crate::settings::Settings; pub(crate) struct AnalyserVisitorBuilder<'a> { - lint: Option>, + lint: Option>, #[cfg(feature = "db")] - splinter: Option>, + splinter: Option>, settings: &'a Settings, } @@ -35,7 +35,7 @@ impl<'a> AnalyserVisitorBuilder<'a> { only: &'a [RuleSelector], skip: &'a [RuleSelector], ) -> Self { - self.lint = Some(LintVisitor::new(only, skip, self.settings)); + self.lint = Some(RuleSelection::new(only, skip)); self } @@ -46,7 +46,7 @@ impl<'a> AnalyserVisitorBuilder<'a> { only: &'a [RuleSelector], skip: &'a [RuleSelector], ) -> Self { - self.splinter = Some(SplinterVisitor::new(only, skip, self.settings)); + self.splinter = Some(RuleSelection::new(only, skip)); self } @@ -56,14 +56,38 @@ impl<'a> AnalyserVisitorBuilder<'a> { let mut enabled_rules = vec![]; if let Some(mut lint) = self.lint { pgls_analyser::visit_registry(&mut lint); - let (linter_enabled_rules, linter_disabled_rules) = lint.finish(); + let (mut linter_enabled_rules, linter_disabled_rules) = lint.finish( + self.settings + .as_linter_rules() + .map(|rules| (rules.as_enabled_rules(), rules.as_disabled_rules())), + ); + + // `linter.enabled` switches the lint rules, and `typecheck.enabled` the typecheck + // rules. + let linter_enabled = self.settings.linter.enabled; + let typecheck_enabled = self.settings.typecheck.enabled; + linter_enabled_rules.retain(|filter| { + let group = match filter { + RuleFilter::Group(group) | RuleFilter::Rule(group, _) => *group, + }; + if group == pgls_analyser::TYPECHECK_GROUP { + typecheck_enabled + } else { + linter_enabled + } + }); + enabled_rules.extend(linter_enabled_rules); disabled_rules.extend(linter_disabled_rules); } #[cfg(feature = "db")] if let Some(mut splinter) = self.splinter { pgls_splinter::registry::visit_registry(&mut splinter); - let (splinter_enabled_rules, splinter_disabled_rules) = splinter.finish(); + let (splinter_enabled_rules, splinter_disabled_rules) = splinter.finish( + self.settings + .as_splinter_rules() + .map(|rules| (rules.as_enabled_rules(), rules.as_disabled_rules())), + ); enabled_rules.extend(splinter_enabled_rules); disabled_rules.extend(splinter_disabled_rules); } @@ -72,93 +96,51 @@ impl<'a> AnalyserVisitorBuilder<'a> { } } -/// Type meant to register all the lint rules +/// The rules of a registry that `only` and `skip` select, visiting the registry of the linter +/// or of splinter. #[derive(Debug)] -struct LintVisitor<'a> { - pub(crate) enabled_rules: FxHashSet>, - pub(crate) disabled_rules: FxHashSet>, +struct RuleSelection<'a> { + enabled_rules: FxHashSet>, + disabled_rules: FxHashSet>, only: &'a [RuleSelector], skip: &'a [RuleSelector], - settings: &'a Settings, } -impl<'a> LintVisitor<'a> { - pub(crate) fn new( - only: &'a [RuleSelector], - skip: &'a [RuleSelector], - settings: &'a Settings, - ) -> Self { +impl<'a> RuleSelection<'a> { + fn new(only: &'a [RuleSelector], skip: &'a [RuleSelector]) -> Self { Self { enabled_rules: Default::default(), disabled_rules: Default::default(), only, skip, - settings, } } + /// The enabled and the disabled rules. Without `only`, they include the rules that the + /// configuration enables and disables, `configured`. fn finish( mut self, + configured: Option<( + FxHashSet>, + FxHashSet>, + )>, ) -> ( FxHashSet>, FxHashSet>, ) { - let has_only_filter = !self.only.is_empty(); - - if !has_only_filter { - let enabled_rules = self - .settings - .as_linter_rules() - .map(|rules| rules.as_enabled_rules()) - .unwrap_or_default(); - + if self.only.is_empty() { + let (enabled_rules, disabled_rules) = configured.unwrap_or_default(); self.enabled_rules.extend(enabled_rules); - - let disabled_rules = self - .settings - .as_linter_rules() - .map(|rules| rules.as_disabled_rules()) - .unwrap_or_default(); self.disabled_rules.extend(disabled_rules); } - // `linter.enabled` switches the lint rules, and `typecheck.enabled` the typecheck rules. - let linter_enabled = self.settings.linter.enabled; - let typecheck_enabled = self.settings.typecheck.enabled; - self.enabled_rules.retain(|filter| { - let group = match filter { - RuleFilter::Group(group) | RuleFilter::Rule(group, _) => *group, - }; - if group == pgls_analyser::TYPECHECK_GROUP { - typecheck_enabled - } else { - linter_enabled - } - }); - (self.enabled_rules, self.disabled_rules) } - - fn push_rule(&mut self) - where - R: RuleMeta + 'static, - { - // Do not report unused suppression comment diagnostics if a single rule is run. - for filter in self.only.iter().flat_map(|selector| selector.filters()) { - if filter.match_rule::() { - self.enabled_rules.insert(filter); - } - } - for filter in self.skip.iter().flat_map(|selector| selector.filters()) { - if filter.match_rule::() { - self.disabled_rules.insert(filter); - } - } - } } -impl RegistryVisitor for LintVisitor<'_> { +impl RegistryVisitor for RuleSelection<'_> { fn record_category(&mut self) { + // The splinter rules are of the lint kind too. if C::CATEGORY == RuleCategory::Lint { C::record_groups(self) } @@ -188,69 +170,7 @@ impl RegistryVisitor for LintVisitor<'_> { where R: RuleMeta + 'static, { - self.push_rule::() - } -} - -/// Type meant to register all the splinter (database lint) rules -#[cfg(feature = "db")] -#[derive(Debug)] -struct SplinterVisitor<'a> { - pub(crate) enabled_rules: FxHashSet>, - pub(crate) disabled_rules: FxHashSet>, - only: &'a [RuleSelector], - skip: &'a [RuleSelector], - settings: &'a Settings, -} - -#[cfg(feature = "db")] -impl<'a> SplinterVisitor<'a> { - pub(crate) fn new( - only: &'a [RuleSelector], - skip: &'a [RuleSelector], - settings: &'a Settings, - ) -> Self { - Self { - enabled_rules: Default::default(), - disabled_rules: Default::default(), - only, - skip, - settings, - } - } - - fn finish( - mut self, - ) -> ( - FxHashSet>, - FxHashSet>, - ) { - let has_only_filter = !self.only.is_empty(); - - if !has_only_filter { - let enabled_rules = self - .settings - .as_splinter_rules() - .map(|rules| rules.as_enabled_rules()) - .unwrap_or_default(); - - self.enabled_rules.extend(enabled_rules); - - let disabled_rules = self - .settings - .as_splinter_rules() - .map(|rules| rules.as_disabled_rules()) - .unwrap_or_default(); - self.disabled_rules.extend(disabled_rules); - } - - (self.enabled_rules, self.disabled_rules) - } - - fn push_rule(&mut self) - where - R: RuleMeta + 'static, - { + // Do not report unused suppression comment diagnostics if a single rule is run. for filter in self.only.iter().flat_map(|selector| selector.filters()) { if filter.match_rule::() { self.enabled_rules.insert(filter); @@ -264,42 +184,6 @@ impl<'a> SplinterVisitor<'a> { } } -#[cfg(feature = "db")] -impl RegistryVisitor for SplinterVisitor<'_> { - fn record_category(&mut self) { - // Splinter uses Lint as its kind in declare_category! macro - // We always record because we're visiting the splinter registry specifically - C::record_groups(self) - } - - fn record_group(&mut self) { - if self - .only - .iter() - .flat_map(|selector| selector.filters()) - .any(|filter| filter.match_group::()) - { - G::record_rules(self) - } - - if self - .skip - .iter() - .flat_map(|selector| selector.filters()) - .any(|filter| filter.match_group::()) - { - G::record_rules(self) - } - } - - fn record_rule(&mut self) - where - R: RuleMeta + 'static, - { - self.push_rule::() - } -} - #[cfg(test)] mod tests { use pgls_analyse::RuleFilter; diff --git a/crates/pgls_workspace/src/workspace/server/formatting.rs b/crates/pgls_workspace/src/workspace/server/formatting.rs new file mode 100644 index 000000000..481284e17 --- /dev/null +++ b/crates/pgls_workspace/src/workspace/server/formatting.rs @@ -0,0 +1,298 @@ +//! Formatting of a document. Each statement is printed on its own, and the text between the +//! statements is kept, since the parser drops comments. + +use std::{collections::HashMap, sync::Arc}; + +use pgls_diagnostics::{DiagnosticExt, serde::Diagnostic as SDiagnostic}; +use pgls_pretty_print::FormatConfig; +use pgls_text_size::TextRange; + +use super::{ + document::{Document, FormatStatementMapper}, + sql_function, +}; +use crate::{ + WorkspaceError, + features::format::{PullFormattingResult, StatementFormatResult}, + settings::FormatterSettings, +}; + +fn format_config(settings: &FormatterSettings) -> FormatConfig { + FormatConfig { + line_width: settings.line_width as usize, + indent_size: settings.indent_size as usize, + indent_style: settings.indent_style.into(), + keyword_case: settings.keyword_case.into(), + constant_case: settings.constant_case.into(), + type_case: settings.type_case.into(), + comma_style: settings.comma_style.into(), + logical_operator_placement: settings.logical_operator_placement.into(), + layout: settings.layout.into(), + values_row_layout: settings.values_row_layout.into(), + cast_style: settings.cast_style.into(), + clause_body_style: settings.clause_body_style.into(), + isolate_semicolon: settings.isolate_semicolon, + function_argument_groups: settings + .function_argument_groups + .iter() + .map(|(name, size)| (name.to_lowercase(), usize::from(size.get()))) + .collect(), + } +} + +/// Formats the statements of `doc`, or only those that intersect `range`. `path` is the path of +/// the document in diagnostics. +pub(super) fn format_document( + doc: &Document, + settings: &FormatterSettings, + range: Option, + path: &str, +) -> PullFormattingResult { + let mut diagnostics = Vec::new(); + let mut statements = Vec::new(); + let mut formatted_output = String::new(); + + let config = format_config(settings); + let format_candidates: Vec<_> = doc.iter(FormatStatementMapper).collect(); + let mut formatted_sql_fn_bodies = HashMap::new(); + + // Only format SQL function bodies if skip_fn_bodies is false + if !settings.skip_fn_bodies { + for (id, _stmt_range, text, ast_result) in &format_candidates { + if !id.is_child() { + continue; + } + + let Some(parent_id) = id.parent() else { + continue; + }; + + let Ok(ast) = ast_result else { + continue; + }; + + let Ok(result) = pgls_pretty_print::format_statement(ast, text, &config) else { + continue; + }; + + let Some(formatted) = restore_named_parameters(text, &result.formatted, ast) else { + continue; + }; + + formatted_sql_fn_bodies.insert(parent_id, formatted); + } + } + + // Reconstruct the file by replacing each statement's source span with its + // formatted output while copying the gaps between statements (whitespace and + // standalone comments) verbatim. libpg_query drops comments from the AST, so + // anything that lives outside a statement's range would otherwise be lost. + let source = doc.get_document_content(); + let mut last_end = 0usize; + + // Formatting a range leaves everything outside its statements as written. + let collapses_blank_lines = range.is_none(); + + for (id, stmt_range, text, ast_result) in format_candidates { + if id.is_child() { + continue; + } + + let stmt_start = usize::from(stmt_range.start()); + let stmt_end = usize::from(stmt_range.end()); + + // Preserve the standalone comments that precede this statement, with at most one + // blank line between statements and none before the first. + let gap = &source[last_end..stmt_start]; + if collapses_blank_lines { + let gap = collapse_blank_lines(gap); + formatted_output.push_str(if last_end == 0 { + gap.trim_start() + } else { + &gap + }); + } else { + formatted_output.push_str(gap); + } + last_end = stmt_end; + + // Leave statements outside the requested range untouched. + if let Some(filter_range) = range + && stmt_range.intersect(filter_range).is_none() + { + formatted_output.push_str(&text); + continue; + } + + match ast_result { + Ok(ast) => { + let mut ast = ast; + if let Some(formatted_sql_fn_body) = formatted_sql_fn_bodies.get(&id) { + // Copies the tree only for the functions whose body is replaced. + sql_function::set_formatted_sql_fn_body( + Arc::make_mut(&mut ast), + formatted_sql_fn_body, + ); + } + + match pgls_pretty_print::format_statement(&ast, &text, &config) { + Ok(result) => match restore_named_parameters(&text, &result.formatted, &ast) { + Some(formatted) => { + if text != formatted { + statements.push(StatementFormatResult { + original: text.clone(), + formatted: formatted.clone(), + range: stmt_range, + }); + } + formatted_output.push_str(&formatted); + } + None => { + diagnostics.push(SDiagnostic::new( + pgls_diagnostics::Error::from(WorkspaceError::format_error( + "Named parameters could not be restored after \ + formatting, the statement was left untouched" + .to_string(), + )) + .with_file_path(path) + .with_file_span(stmt_range), + )); + + formatted_output.push_str(&text); + } + }, + Err(err) => { + diagnostics.push(SDiagnostic::new( + pgls_diagnostics::Error::from(WorkspaceError::format_error( + err.to_string(), + )) + .with_file_path(path) + .with_file_span(stmt_range), + )); + + formatted_output.push_str(&text); + } + } + } + Err(syntax_err) => { + diagnostics.push(SDiagnostic::new( + pgls_diagnostics::Error::from(syntax_err).with_file_path(path), + )); + + formatted_output.push_str(&text); + } + } + } + + // Preserve any trailing text or comments after the last statement, without blank lines + // at the end of the file but with its final line break. + let rest = &source[last_end..]; + if collapses_blank_lines { + let rest = collapse_blank_lines(rest); + let rest = if last_end == 0 { + rest.trim() + } else { + rest.trim_end() + }; + formatted_output.push_str(rest); + if source.ends_with('\n') && !formatted_output.is_empty() { + formatted_output.push('\n'); + } + } else { + formatted_output.push_str(rest); + } + + PullFormattingResult { + original: doc.get_document_content().to_string(), + formatted: formatted_output, + statements, + diagnostics, + } +} + +/// Keeps at most one blank line in `text`, which only holds whitespace and comments. +fn collapse_blank_lines(text: &str) -> String { + let mut result = String::with_capacity(text.len()); + // Line breaks since the last text that is not whitespace. Two make a blank line. + let mut line_breaks = 0; + for line in text.split_inclusive('\n') { + let is_blank = line.trim().is_empty(); + let ends_line = line.ends_with('\n'); + if is_blank && ends_line { + line_breaks += 1; + if line_breaks <= 2 { + result.push('\n'); + } + continue; + } + + result.push_str(line); + if !is_blank { + line_breaks = usize::from(ends_line); + } + } + result +} + +/// Put psql named parameters back where the formatter printed their placeholders. +/// +/// The parser only ever sees placeholders, so the printed statement carries `$1` or a bare +/// identifier instead of `:'agen_code'` or `:raw_data`. Substitution happens on the token stream +/// so that a `$1` inside a string literal is left alone, and the result is proven by converting it +/// back and comparing the normalized ASTs: a statement that does not survive that round trip is +/// reported as unrestorable and kept as it was written. +fn restore_named_parameters( + original: &str, + formatted: &str, + original_ast: &pgls_query::NodeEnum, +) -> Option { + use pgls_lexer::{SyntaxKind, lex}; + + let conversion = pgls_lexer::convert_to_positional_params_with_metadata(original); + + if conversion.identifier_replacements.is_empty() && conversion.value_replacements.is_empty() { + return Some(formatted.to_string()); + } + + if !conversion.restorable { + return None; + } + + let lexed = lex(formatted); + let mut restored = String::with_capacity(formatted.len()); + + for (idx, kind) in lexed.tokens().enumerate() { + if kind == SyntaxKind::EOF { + break; + } + + let text = lexed.text(idx); + + let replacement = match kind { + SyntaxKind::IDENT => conversion.identifier_replacements.get(text), + SyntaxKind::POSITIONAL_PARAM => text + .trim_start_matches('$') + .parse::() + .ok() + .and_then(|index| conversion.value_replacements.get(&index)), + _ => None, + }; + + match replacement { + Some(source_text) => restored.push_str(source_text), + None => restored.push_str(text), + } + } + + let reparsed = pgls_query::parse(&pgls_lexer::convert_to_positional_params(&restored)).ok()?; + let mut restored_ast = reparsed.into_root()?; + let mut expected_ast = original_ast.clone(); + pgls_pretty_print::normalize_ast(&mut restored_ast); + pgls_pretty_print::normalize_ast(&mut expected_ast); + + if restored_ast == expected_ast { + Some(restored) + } else { + None + } +}