Skip to content

fix(pretty_print): align expanded CASE from where it starts - #894

Merged
psteinroe merged 3 commits into
mainfrom
fix/pretty-print-case-alignment
Oct 26, 2026
Merged

psteinroe merged 3 commits into
mainfrom
fix/pretty-print-case-alignment

Conversation

@psteinroe

@psteinroe psteinroe commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #893.

Follow-up to #884. Expanded CASE expressions lost their alignment when CASE started left of the current indent, and a broken WHEN condition continued at the WHEN column.

Alignment from where CASE starts

The renderer stored an alignment as an offset from the indent of the level it started at, and clamped a negative offset to zero. When the text on a line starts left of that indent, the offset is negative. That happens for select case once the select list has opened its indent, or on case in a join, and WHEN and END then drifted right:

-- layout: expanded, clauseBodyStyle: compact, indentSize: 8
-- before
select case
                when a
                        then 1
                else 2
        end
from t;
-- after
select case
               when a
                       then 1
               else 2
       end
from t;

An alignment now keeps the column CASE starts at together with the indent level at that point. A continuation line starts at that column plus one indent per level opened since. When the offset is not negative this gives the same columns as before, so the existing expanded CASE output is unchanged.

With indentStyle: "tabs" an aligned line is written as one tab per indent level and spaces for the rest, as before. When the column is left of the indent, it uses only as many tabs as fit before the column: \t when instead of \t\t when.

WHEN conditions are indented

A WHEN condition that breaks continued at the column of WHEN, so and read as a sibling of when. In expanded layout every AND/OR breaks, so this affected every multi-operand condition:

-- before
case
  when aaaa = 1
  and bbbb = 2
    then 'x'
-- after
case
  when aaaa = 1
    and bbbb = 2
    then 'x'

The condition is now one indent level in from WHEN, the same way a WHERE condition is. Fit layout does the same once the condition breaks. Expanded layout still breaks at every AND/OR in a WHEN condition. I left that alone because it is what expanded layout does everywhere else, and the reporter of #884 uses it that way.

Docs

docs/features/formatting.md now describes the expanded CASE layout and what tabs do to alignment: the columns only line up when the editor's tab width equals indentSize. A test checks that the example in the docs is what the formatter prints.

Tests and snapshot changes

New tests in case_layout.rs: CASE after on with indentSize: 4, select case with indentSize: 8 in spaces and tabs, the exact tab and space output after a leading comma, and WHEN condition continuation in expanded and fit layout. Two tests in comment_layout.rs now expect the AND of a WHEN condition, and the comment before it, one tab further in. The only snapshot that changes is expanded_case_expr_200, where the AND lines of two WHEN conditions move one level in.

Not covered

A CASE nested in the THEN of another CASE still moves right by about 9 columns per level in expanded layout. Twenty levels reach 184 columns at width 100. Three or four levels stay within the width, so I didn't change the layout for this.

Follow-ups: The nested CASE drift in expanded layout is fixed in #935.

@psteinroe
psteinroe changed the base branch from fix/pretty-print-values-layout to main October 26, 2026 07:20
@psteinroe
psteinroe merged commit 792ccb1 into main Oct 26, 2026
11 checks passed
@psteinroe
psteinroe deleted the fix/pretty-print-case-alignment branch October 26, 2026 07:53
psteinroe added a commit that referenced this pull request Oct 26, 2026
Stacked on #894.

Follow-up to #864 for the comparison case, plus a nested-subquery case from #857.

**`= (subquery)` stays together**

#864 kept `exists (`, `in (`, `= any (` and `> all (` on one line, but a scalar subquery compared with an operator still broke between the operator and `(`. With the config reported in #857 (`layout: expanded`, `clauseBodyStyle: compact`, `logicalOperatorPlacement: leading`):

```sql
-- before
using (
  org_id =
  (
    select org_id
    ...
-- after
using (
  org_id = (
    select org_id
    from members
    where user_id = auth.uid()
  )
);
```

The default config did the same, `t.owner_id =` followed by `(select …)` on its own line. A binary operator now puts a plain space before a right-hand `(subquery)` or `array(subquery)`, and the subquery breaks inside its parentheses as it does after `in (`. Other right-hand operands still break after the operator when they don't fit.

**Subqueries with clauses expand inside a select-list-only subquery**

Since #857, a subquery made of a select list alone, such as `(select auth.uid())`, uses fit layout in expanded mode, because there are no clauses to break between. That fit layout applied to everything inside it, so a subquery with FROM/WHERE nested in it stayed on one line in expanded mode:

```sql
-- layout: expanded, clauseBodyStyle: compact
select * from t where a = (select coalesce((select max(x) from y where y.id = t.id), 0));
```

The emitter now remembers the configured layout, and a nested subquery with clauses switches back to it, so the inner `select max(x)` / `from y` / `where ...` expands again.

**Snapshot changes**

All reviewed; one category: `x =\n(` / `x <>\n(` / `x <\n(` / `<>\narray(` → `x = (`, `x <> (`, `x < (`, `<> array(`. When the subquery is short enough to fit on the next line, it now sits indented inside the parentheses instead (`relnamespace = (\n  select oid from pg_namespace where ...\n)`). Affected: `create_cast`, `create_index`, `create_view`, `namespace`, `opr_sanity`, `rangefuncs`, `reloptions`, `tablespace`, `type_sanity`, `with`.

New fixtures `scalar_subquery.sql` and `scalar_subquery_expanded.sql` (the #857 reporter's config) cover a policy, `=` with a multi-clause subquery, `= array(subquery)`, the nested-coalesce case and a short `= (select 1)`. New `subquery_layout.rs` tests cover both findings.

**Not covered**

With `clauseBodyStyle: compact`, a WHERE condition keeps its indent for its own continuation lines, so a subquery that opens on the `where` line is indented two levels and closes one level in:

```sql
where a = (
    select ...
  );
```

That is the same for `where exists (` today and is a separate change to the compact layout.

**Follow-ups:** The compact WHERE indentation is fixed in #938.
psteinroe added a commit that referenced this pull request Oct 26, 2026
Stacked on #912.

Fixes formatter options and `format` check output that were hard to use, found in the release review.

**Option errors list the valid values**

```sh
$ postgres-language-server format --clause-body-style=foo a.sql
# before
Error: couldn't parse `foo`: Value not supported for clause body style
# after
Error: couldn't parse `foo`: expected 'break' or 'compact'

$ postgres-language-server format --values-row-layout=foo a.sql
# before
Error: couldn't parse `foo`: Value not supported for Layout. Use 'fit' or 'expanded'.
# after
Error: couldn't parse `foo`: expected 'fit' or 'expanded'
```

All formatter enums now say what they expect, without naming the Rust type. `Layout` is shared by `--layout` and `--values-row-layout`, and the user never sees that name.

**A zero line width or indent size is rejected**

`--line-width=0` and `--indent-size=0`, and `"lineWidth": 0` / `"indentSize": 0` in the config file, were accepted. `lineWidth` and `indentSize` are now `NonZeroU16` / `NonZeroU8`, as `files.maxSize` already is. The CLI says `couldn't parse '0': number would be zero for non-zero type`, the config file `invalid value: integer '0', expected a nonzero u16`. The JSON schema now has `minimum: 1` for both.

**Check output names the line**

Without `--write`, `format` printed one diagnostic per statement that would change. When a statement's diff fits on one line, it is printed without line numbers, and the header only names the file. In a large file there was no way to find the statement. The message now carries the line:

```
a.sql format ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

  × Statement at line 4 could be formatted using `--write`

  - SELECT··x·FROM·y·WHERE·z=1;
  + select·x·from·y·where·z·=·1;
```

I put the line in the message rather than giving the diagnostic a span. With a span, the printer adds a code frame of the statement above the diff. When only the blank lines between statements change, the whole-file diff now says "File could be formatted" instead of "Statement".

**Help shows the values**

Every format flag was shown as `=ARG` in `--help` and in the CLI reference. They now show `NUMBER`, `<spaces|tabs>`, `<fit|expanded>` and so on, like the other options.

**Docs**

The `layout` option was described as "always breaks between clauses". Expanded layout also puts every select list item and every AND/OR operand on its own line and breaks before every CASE branch. A select-list-only subquery and a CHECK condition follow the line width instead. The option's description (and so the schema, bindings and CLI reference) and `docs/features/formatting.md` now say this, and the docs have an example. Together with the CASE and tabs docs in #894, this covers the docs gaps from the review.

**Tests**

- CLI tests for the two option errors and for rejecting zero, with the two check-output snapshots updated for the new messages.
- A configuration test for rejecting zero in the config file.
- Schema, TypeScript bindings and CLI reference regenerated with `just gen-lint`.

**Risks**

A config file with `lineWidth` or `indentSize` set to 0 used to load and now fails to load with the error above. With either set to 0 the formatter output was unusable anyway: every group broke, or nothing was indented.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant