Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions crates/pgls_pretty_print/src/nodes/a_expr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,8 @@ fn emit_aexpr_distinct(e: &mut EventEmitter, n: &AExpr) {
e.token(TokenKind::DISTINCT_KW);
e.space();
e.token(TokenKind::FROM_KW);
e.space();
// Like a comparison operator, the right-hand side wraps when the expression doesn't fit.
e.line(LineType::SoftOrSpace);

if let Some(ref rexpr) = n.rexpr {
emit_operand_with_parens(e, rexpr, parent_info, OperandSide::Right);
Expand All @@ -250,7 +251,8 @@ fn emit_aexpr_not_distinct(e: &mut EventEmitter, n: &AExpr) {
e.token(TokenKind::DISTINCT_KW);
e.space();
e.token(TokenKind::FROM_KW);
e.space();
// Like a comparison operator, the right-hand side wraps when the expression doesn't fit.
e.line(LineType::SoftOrSpace);

if let Some(ref rexpr) = n.rexpr {
emit_operand_with_parens(e, rexpr, parent_info, OperandSide::Right);
Expand Down Expand Up @@ -341,7 +343,8 @@ fn emit_aexpr_like(e: &mut EventEmitter, n: &AExpr) {
}

e.token(TokenKind::LIKE_KW);
e.space();
// Like a comparison operator, the pattern wraps when the expression doesn't fit.
e.line(LineType::SoftOrSpace);

if let Some(ref rexpr) = n.rexpr {
super::emit_node(rexpr, e);
Expand All @@ -365,7 +368,8 @@ fn emit_aexpr_ilike(e: &mut EventEmitter, n: &AExpr) {
}

e.token(TokenKind::ILIKE_KW);
e.space();
// Like a comparison operator, the pattern wraps when the expression doesn't fit.
e.line(LineType::SoftOrSpace);

if let Some(ref rexpr) = n.rexpr {
super::emit_node(rexpr, e);
Expand All @@ -391,7 +395,8 @@ fn emit_aexpr_similar(e: &mut EventEmitter, n: &AExpr) {
e.token(TokenKind::SIMILAR_KW);
e.space();
e.token(TokenKind::TO_KW);
e.space();
// Like a comparison operator, the pattern wraps when the expression doesn't fit.
e.line(LineType::SoftOrSpace);

// PostgreSQL stores SIMILAR TO as a call to similar_to_escape(pattern) or similar_to_escape(pattern, escape)
// We need to emit the original pattern (and ESCAPE clause if present)
Expand Down
17 changes: 16 additions & 1 deletion crates/pgls_pretty_print/src/nodes/func_call.rs
Original file line number Diff line number Diff line change
Expand Up @@ -337,16 +337,21 @@ fn emit_sql_syntax_call(e: &mut EventEmitter, n: &FuncCall) -> bool {
("extract", [field, source]) => {
e.token(TokenKind::EXTRACT_KW);
super::open_call_arguments(e);
// The arguments stay together on the indented line when they fit there.
e.group_start(GroupKind::List);
emit_extract_field(e, field);
e.space();
e.token(TokenKind::FROM_KW);
e.space();
e.line(LineType::SoftOrSpace);
super::emit_node(source, e);
e.group_end();
super::close_call_arguments(e);
}
("overlay", [string, replacement, start, rest @ ..]) if rest.len() <= 1 => {
e.token(TokenKind::OVERLAY_KW);
super::open_call_arguments(e);
// The arguments stay together on the indented line when they fit there.
e.group_start(GroupKind::List);
super::emit_node(string, e);
e.line(LineType::SoftOrSpace);
e.token(TokenKind::PLACING_KW);
Expand All @@ -362,23 +367,29 @@ fn emit_sql_syntax_call(e: &mut EventEmitter, n: &FuncCall) -> bool {
e.space();
super::emit_node(length, e);
}
e.group_end();
super::close_call_arguments(e);
}
// Stored as position(string, substring), written POSITION(substring IN string). Both
// operands are `b_expr`, which an operator expression is not.
("position", [string, substring]) => {
e.token(TokenKind::POSITION_KW);
super::open_call_arguments(e);
// The arguments stay together on the indented line when they fit there.
e.group_start(GroupKind::List);
emit_parenthesized_unless_simple(e, substring);
e.line(LineType::SoftOrSpace);
e.token(TokenKind::IN_KW);
e.space();
emit_parenthesized_unless_simple(e, string);
e.group_end();
super::close_call_arguments(e);
}
("substring", [string, start, rest @ ..]) if rest.len() <= 1 => {
e.token(TokenKind::SUBSTRING_KW);
super::open_call_arguments(e);
// The arguments stay together on the indented line when they fit there.
e.group_start(GroupKind::List);
super::emit_node(string, e);
e.line(LineType::SoftOrSpace);
e.token(TokenKind::FROM_KW);
Expand All @@ -390,6 +401,7 @@ fn emit_sql_syntax_call(e: &mut EventEmitter, n: &FuncCall) -> bool {
e.space();
super::emit_node(length, e);
}
e.group_end();
super::close_call_arguments(e);
}
// Stored as btrim(string[, characters]), written TRIM(BOTH characters FROM string).
Expand Down Expand Up @@ -491,11 +503,14 @@ fn emit_sql_syntax_call(e: &mut EventEmitter, n: &FuncCall) -> bool {
("xmlexists", [xpath, document]) => {
e.token(TokenKind::XMLEXISTS_KW);
super::open_call_arguments(e);
// The arguments stay together on the indented line when they fit there.
e.group_start(GroupKind::List);
super::emit_node(xpath, e);
e.line(LineType::SoftOrSpace);
e.token(TokenKind::PASSING_KW);
e.space();
emit_parenthesized_unless_simple(e, document);
e.group_end();
super::close_call_arguments(e);
}
("system_user", []) => e.token(TokenKind::SYSTEM_USER_KW),
Expand Down
27 changes: 15 additions & 12 deletions crates/pgls_pretty_print/src/renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,8 @@ impl<W: Write> Renderer<W> {

/// The group on one line, if it can be collapsed and fits on the current line followed by
/// `trailing_width` columns that cannot move to the next line. Only the first line of a text
/// spanning several lines, a function body for instance, sits on the current one.
/// spanning several lines, a function body for instance, sits on the current one, and what
/// follows the text has to fit on its last line.
///
/// Measuring stops as soon as the line is full, so a large group that cannot fit is not
/// rendered in full for every level it is nested in.
Expand All @@ -298,13 +299,15 @@ impl<W: Write> Renderer<W> {
group_events: &[LayoutEvent],
trailing_width: usize,
) -> Option<String> {
let available = self
let mut available = self
.config
.max_line_length
.saturating_sub(self.effective_line_length());
let mut buffer = String::new();
// Set once the text reaches a line break, after which the width no longer matters.
let mut first_line_fits = false;
// Where the line being measured starts in `buffer`. After a text spanning several lines,
// that is its last line, which starts at the left margin; the lines in between are the
// text's own.
let mut line_start = 0;

for event in group_events {
match event {
Expand Down Expand Up @@ -335,15 +338,15 @@ impl<W: Write> Renderer<W> {
| LayoutEvent::AlignEnd => {}
}

if !first_line_fits {
match buffer.split_once('\n') {
Some((first_line, _)) if first_line.len() <= available => {
first_line_fits = true;
}
Some(_) => return None,
None if buffer.len() + trailing_width > available => return None,
None => {}
if let Some(last_break) = buffer[line_start..].rfind('\n') {
if line_start == 0 && buffer.find('\n').is_some_and(|first| first > available) {
return None;
}
line_start += last_break + 1;
available = self.config.max_line_length;
}
if buffer.len() - line_start + trailing_width > available {
return None;
}
}

Expand Down
74 changes: 72 additions & 2 deletions crates/pgls_pretty_print/tests/common/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
// Every test crate includes this module and uses only some of it.
#![allow(dead_code)]

use pgls_query::protobuf::Token;

/// Checks the whitespace the formatter wrote between tokens: no space before a comma or `)`, none
Expand Down Expand Up @@ -85,7 +88,6 @@ pub fn whitespace_problems(output: &str) -> Vec<String> {
problems
}

#[allow(dead_code)] // Not every test crate asserts directly.
pub fn assert_clean_whitespace(output: &str) {
let problems = whitespace_problems(output);
assert!(
Expand All @@ -99,7 +101,6 @@ pub fn assert_clean_whitespace(output: &str) {
/// spaces, a space before a tab when indenting with tabs. Alignment after the tabs is written as
/// spaces, so spaces after tabs are fine. Lines inside a multi-line token, such as a function body,
/// keep the indentation of the source.
#[allow(dead_code)] // Not every test crate checks indentation.
pub fn mixed_indentation(output: &str, indent_with_tabs: bool) -> Vec<String> {
let token_ranges = pgls_query::scan(output)
.map(|scan| {
Expand All @@ -125,3 +126,72 @@ pub fn mixed_indentation(output: &str, indent_with_tabs: bool) -> Vec<String> {
}
problems
}

/// Columns a line may need besides its indentation and its longest token before the formatter is
/// expected to break it: a keyword or punctuation around an unbreakable token, `passing '...'` or
/// `as "..."` for instance.
const WIDTH_OVERHEAD: usize = 12;

/// Lines longer than `max_line_length`, counting a tab as `tab_width` columns.
///
/// A line is excused when its indentation and its longest token leave no room to break it: a long
/// string literal, quoted identifier or function body, or code nested so deeply that little room is
/// left. String literals count like any other token, so a call that merely has a literal among its
/// arguments still has to break. A `--` comment ending the line is written as in the source and is
/// not counted.
pub fn width_problems(output: &str, max_line_length: usize, tab_width: usize) -> Vec<String> {
let tokens: Vec<_> = pgls_query::scan(output)
.map(|scan| {
scan.tokens
.iter()
.map(|token| {
(
token.start as usize..token.end as usize,
Token::try_from(token.token) == Ok(Token::SqlComment),
)
})
.collect()
})
.unwrap_or_default();
let columns = |text: &str| -> usize {
text.chars()
.map(|c| if c == '\t' { tab_width } else { 1 })
.sum()
};

let mut problems = Vec::new();
let mut line_start = 0;
for (index, line) in output.split('\n').enumerate() {
let line_end = line_start + line.len();
let code_end = tokens
.iter()
.find(|(range, line_comment)| {
*line_comment && range.start >= line_start && range.start < line_end
})
.map_or(line_end, |(range, _)| range.start);
let code = output[line_start..code_end].trim_end();
let width = columns(code);
if width > max_line_length {
let indent = columns(&code[..code.len() - code.trim_start().len()]);
let longest_token = tokens
.iter()
.map(|(range, _)| {
let start = range.start.max(line_start);
let end = range.end.min(code_end);
output
.get(start..end)
.map_or(0, |piece| piece.chars().count())
})
.max()
.unwrap_or(0);
if indent + longest_token + WIDTH_OVERHEAD <= max_line_length {
problems.push(format!(
"line {} has {width} columns, more than {max_line_length}: `{line}`",
index + 1
));
}
}
line_start = line_end + 1;
}
problems
}
9 changes: 9 additions & 0 deletions crates/pgls_pretty_print/tests/option_matrix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,15 @@ fn check(sql: &str, config: &FormatConfig) -> Result<(), String> {
return Ok(());
};
let mut problems = common::whitespace_problems(&output);
// At width 40 some statements can't fit. A `::` cast doesn't break either, so in a compact
// `select '...'::type` or `('...'::jsonb)['a']['b']` nothing can move to the next line.
if config.line_width >= 80 && matches!(config.cast_style, CastStyle::Cast) {
problems.extend(common::width_problems(
&output,
config.line_width,
config.indent_size,
));
}
problems.extend(common::mixed_indentation(
&output,
matches!(config.indent_style, IndentStyle::Tabs),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,15 @@ set min_parallel_table_scan_size = 0;

set max_parallel_workers_per_gather = 4;

select jsonb_pretty(explain_filter_to_json('explain (analyze, verbose, buffers, format json)
select * from tenk1 order by tenthous') #- '{0,Plan,Plans,0,Plans,0,Workers}' #- '{0,Plan,Plans,0,Workers}' #- '{0,Plan,Plans,0,Sort Method}' #- '{0,Plan,Plans,0,Sort Space Type}');
select
jsonb_pretty(
explain_filter_to_json('explain (analyze, verbose, buffers, format json)
select * from tenk1 order by tenthous') #-
'{0,Plan,Plans,0,Plans,0,Workers}' #-
'{0,Plan,Plans,0,Workers}' #-
'{0,Plan,Plans,0,Sort Method}' #-
'{0,Plan,Plans,0,Sort Space Type}'
);

rollback;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,11 @@ set max_parallel_workers_per_gather = 4;
select
jsonb_pretty(
explain_filter_to_json('explain (analyze, verbose, buffers, format json)
select * from tenk1 order by tenthous') #- '{0,Plan,Plans,0,Plans,0,Workers}' #- '{0,Plan,Plans,0,Workers}' #- '{0,Plan,Plans,0,Sort Method}' #- '{0,Plan,Plans,0,Sort Space Type}'
select * from tenk1 order by tenthous') #-
'{0,Plan,Plans,0,Plans,0,Workers}' #-
'{0,Plan,Plans,0,Workers}' #-
'{0,Plan,Plans,0,Sort Method}' #-
'{0,Plan,Plans,0,Sort Space Type}'
);

rollback;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,8 @@ select
y,
jsonb_path_query('[true, false, null]', '$[*] ? (@ == true && ($x == true && $y == true) ||
@ == false && !($x == true && $y == true) ||
@ == null && ($x == true && $y == true) is unknown)', jsonb_build_object('x', x, 'y', y)) as "x && y"
@ == null && ($x == true && $y == true) is unknown)', jsonb_build_object('x', x, 'y', y))
as "x && y"
from
(values (cast('true' as jsonb)), ('false'), ('"null"')) as x (x),
(values (cast('true' as jsonb)), ('false'), ('"null"')) as y (y);
Expand All @@ -333,7 +334,8 @@ select
y,
jsonb_path_query('[true, false, null]', '$[*] ? (@ == true && ($x == true || $y == true) ||
@ == false && !($x == true || $y == true) ||
@ == null && ($x == true || $y == true) is unknown)', jsonb_build_object('x', x, 'y', y)) as "x || y"
@ == null && ($x == true || $y == true) is unknown)', jsonb_build_object('x', x, 'y', y))
as "x || y"
from
(values (cast('true' as jsonb)), ('false'), ('"null"')) as x (x),
(values (cast('true' as jsonb)), ('false'), ('"null"')) as y (y);
Expand Down
Loading
Loading