From c51e8e2da4fa3acb8344809b92c3fa89adf67701 Mon Sep 17 00:00:00 2001 From: Hein Date: Tue, 6 Oct 2026 14:51:24 +0200 Subject: [PATCH] feat(format): select_wrap and join_wrap with when_long via Doc-IR --- docs/config/default.pgtidy.yaml | 2 ++ docs/plan.md | 3 +++ docs/todo.md | 2 +- pkg/config/config.go | 20 ++++++++++++++ pkg/format/dml.go | 38 +++++++++++++++++++++++++- pkg/format/dml_test.go | 47 +++++++++++++++++++++++++++++++++ 6 files changed, 110 insertions(+), 2 deletions(-) diff --git a/docs/config/default.pgtidy.yaml b/docs/config/default.pgtidy.yaml index 242c923..c7a87f8 100644 --- a/docs/config/default.pgtidy.yaml +++ b/docs/config/default.pgtidy.yaml @@ -30,6 +30,8 @@ join_indent_size: 1 # Number of extra indent levels for JOINs # always | when_long | never where_wrap: always # Each AND/OR condition on its own line (when_long: only if the clause exceeds line_width) +select_wrap: always # always | when_long | never — one SELECT/RETURNING column per line +join_wrap: never # always | when_long | never — ON (and AND/OR) on their own lines under the JOIN line_width: 120 # Target width for when_long wrapping; 0 = unlimited where_and_or_indent: true # AND/OR indented one level under WHERE diff --git a/docs/plan.md b/docs/plan.md index 487d7a7..ea1b407 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -109,6 +109,9 @@ DataGrip enum conventions used below: | `FROM_ONLY_JOIN_INDENT` | `join_indent_size` | `1` | extra indent levels for JOINs | | `SET_ALIGN_EQUAL_SIGN` | `set_align_equal` | `false` | align `=` in UPDATE SET list | | `WHERE_EL_WRAP` + `WHERE_EL_LINE` | `where_wrap` | `always` | always \| when_long \| never — each AND/OR condition on its own line | +| _(new)_ | `select_wrap` | `always` | always \| when_long \| never — one SELECT/RETURNING column per line | +| _(new)_ | `join_wrap` | `never` | always \| when_long \| never — `ON` and AND/OR conditions on their own lines under the JOIN | +| _(new)_ | `line_width` | `120` | target width for every `when_long` decision and `case_collapse`; 0 = unlimited | | _(no DataGrip equivalent)_ | `where_and_or_indent` | `true` | when true, AND/OR are indented one level under WHERE, not at WHERE's column | ### Subqueries diff --git a/docs/todo.md b/docs/todo.md index 3792cc6..de22a4d 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -167,7 +167,7 @@ Legend: ✅ done · 🚧 in progress · ⬜ not started function call at the token level — formatted without space (known limitation). - Note: SQL keywords inside PL/pgSQL function bodies remain lowercase (matching the corpus golden files); casing is applied only to top-level DML. -- ✅ Doc-IR core (`pkg/format/doc.go`: Text/Line/SoftLine/Group/Indent/IfBreak + `Render`) and `line_width` (default 120, 0 = unlimited). First consumer: `where_wrap: when_long` (`dmlWhereWhenLong`). _Still TODO: move the remaining DML builders (select lists, joins, subqueries) from string assembly onto Doc._ +- ✅ Doc-IR core (`pkg/format/doc.go`: Text/Line/SoftLine/Group/Indent/IfBreak + `Render`) and `line_width` (default 120, 0 = unlimited). First consumer: `where_wrap: when_long` (`dmlWhereWhenLong`). `select_wrap` (default `always`) and `join_wrap` (default `never`) also take `always|when_long|never`; `when_long` keeps the SELECT list / `JOIN … ON` on one line when it fits `line_width`, otherwise uses the existing one-column-per-line / ON-per-line layout. Items containing a subquery or wrapped CASE always use the broken layout. _Still TODO: move the remaining DML builders (subqueries, CTEs, VALUES) onto Doc._ - _Still TODO: LSP range formatting._ ## ✅ Config expansion — DataGrip settings parity diff --git a/pkg/config/config.go b/pkg/config/config.go index 7c162f0..40c9d2e 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -70,6 +70,8 @@ type Style struct { JoinIndentSize int // extra indent levels for JOINs (default 1) WhereWrap WrapMode // always|when_long|never — each AND/OR on its own line LineWidth int // target width for when_long wrapping (0 = unlimited) + SelectWrap WrapMode // always|when_long|never — one SELECT/RETURNING column per line + JoinWrap WrapMode // always|when_long|never — break JOIN … ON onto its own lines WhereAndOrIndent bool // AND/OR indented one level under WHERE // --- Subqueries --- @@ -122,6 +124,8 @@ func Default() Style { IndentJoin: false, JoinIndentSize: 1, LineWidth: 120, + SelectWrap: WrapAlways, + JoinWrap: WrapNever, WhereWrap: WrapAlways, WhereAndOrIndent: true, @@ -172,6 +176,8 @@ type yamlFile struct { JoinIndentSize *int `yaml:"join_indent_size"` WhereWrap *string `yaml:"where_wrap"` LineWidth *int `yaml:"line_width"` + SelectWrap *string `yaml:"select_wrap"` + JoinWrap *string `yaml:"join_wrap"` WhereAndOrIndent *bool `yaml:"where_and_or_indent"` SubqueryOpening *string `yaml:"subquery_opening"` @@ -267,6 +273,20 @@ func Load(startDir string) (Style, error) { } st.LineWidth = *yf.LineWidth } + if yf.SelectWrap != nil { + wm := WrapMode(*yf.SelectWrap) + if err := validWrap(wm); err != nil { + return st, fmt.Errorf("pgtidy: %s: select_wrap: %w", path, err) + } + st.SelectWrap = wm + } + if yf.JoinWrap != nil { + wm := WrapMode(*yf.JoinWrap) + if err := validWrap(wm); err != nil { + return st, fmt.Errorf("pgtidy: %s: join_wrap: %w", path, err) + } + st.JoinWrap = wm + } if yf.WhereWrap != nil { wm := WrapMode(*yf.WhereWrap) if err := validWrap(wm); err != nil { diff --git a/pkg/format/dml.go b/pkg/format/dml.go index bfaeaa9..1b064a0 100644 --- a/pkg/format/dml.go +++ b/pkg/format/dml.go @@ -206,6 +206,27 @@ func dmlJoinClause(kwText string, body []cst.Tok, st config.Style) string { if text != "" { line += " " + text } + if onIdx := dmlKeywordIdx(body, 0, "on"); onIdx >= 0 && st.JoinWrap != config.WrapNever { + nl := st.Newline + var b strings.Builder + b.WriteString(kwText) + if head := dmlInline(body[:onIdx], st); head != "" { + b.WriteString(" " + head) + } + for i, cond := range dmlSplitAndOr(body[onIdx+1:]) { + b.WriteString(nl + st.Indent) + if i == 0 { + b.WriteString(caseText(body[onIdx].Tok, st) + " ") + } + b.WriteString(strings.ReplaceAll(dmlInline(cond, st), nl, nl+st.Indent)) + } + broken := b.String() + if st.JoinWrap == config.WrapAlways || strings.Contains(line, nl) { + line = broken + } else { + line = Render(Group(IfBreak(Text(broken), Text(line))), st.LineWidth, st.Indent, nl) + } + } if !st.IndentJoin { return line } @@ -720,16 +741,31 @@ func dmlColListSelect(kwText string, items [][]cst.Tok, st config.Style) string } texts := make([]string, len(items)) + multiline := false for i, item := range items { texts[i] = dmlInline(item, st) + if strings.Contains(texts[i], st.Newline) { + multiline = true + } } + flat := kwText + " " + strings.Join(texts, ", ") // align_columns / select_align_as: pad expressions so AS and aliases align. if (st.AlignColumns || st.SelectAlignAs) && len(texts) > 1 { texts = alignSelectItems(texts, st) } - return dmlCommaList(kwText, texts, st) + broken := dmlCommaList(kwText, texts, st) + if multiline { + return broken // an embedded subquery / wrapped CASE can't sit on one line + } + switch st.SelectWrap { + case config.WrapNever: + return flat + case config.WrapWhenLong: + return Render(Group(IfBreak(Text(broken), Text(flat))), st.LineWidth, st.Indent, st.Newline) + } + return broken } // dmlColListSet formats an UPDATE SET column list with optional set_align_equal. diff --git a/pkg/format/dml_test.go b/pkg/format/dml_test.go index 8cf1127..5d890cf 100644 --- a/pkg/format/dml_test.go +++ b/pkg/format/dml_test.go @@ -647,3 +647,50 @@ func TestDMLCaseCollapseUsesLineWidth(t *testing.T) { t.Errorf("exceeds 30, should wrap:\n%s", got) } } + +func TestSelectWrap(t *testing.T) { + src := "select a, b, c from t" + st := config.Default() + if got := File(parser.Parse(src), st); !strings.Contains(got, "\n ,b") { + t.Errorf("default select_wrap=always should list one per line:\n%s", got) + } + st.SelectWrap = config.WrapNever + if got := File(parser.Parse(src), st); !strings.Contains(got, "SELECT a, b, c") { + t.Errorf("never:\n%s", got) + } + st.SelectWrap = config.WrapWhenLong + st.LineWidth = 120 + if got := File(parser.Parse(src), st); !strings.Contains(got, "SELECT a, b, c") { + t.Errorf("when_long, short:\n%s", got) + } + st.LineWidth = 8 + if got := File(parser.Parse(src), st); !strings.Contains(got, "\n ,b") { + t.Errorf("when_long, long:\n%s", got) + } +} + +func TestJoinWrap(t *testing.T) { + src := "select a from t join u on t.id = u.id and t.x = u.x" + st := config.Default() + base := File(parser.Parse(src), st) + if !strings.Contains(base, "JOIN u ON t.id = u.id AND t.x = u.x") { + t.Fatalf("default join_wrap=never should stay inline:\n%s", base) + } + st.JoinWrap = config.WrapAlways + got := File(parser.Parse(src), st) + if !strings.Contains(got, "JOIN u\n ON t.id = u.id\n AND t.x = u.x") { + t.Errorf("always:\n%s", got) + } + if twice := File(parser.Parse(got), st); twice != got { + t.Errorf("not idempotent:\n%s\n---\n%s", got, twice) + } + st.JoinWrap = config.WrapWhenLong + st.LineWidth = 120 + if File(parser.Parse(src), st) != base { + t.Errorf("when_long short should equal inline") + } + st.LineWidth = 20 + if File(parser.Parse(src), st) != got { + t.Errorf("when_long long should equal always") + } +}