diff --git a/docs/config/default.pgtidy.yaml b/docs/config/default.pgtidy.yaml index 1bdd92a..9d9895e 100644 --- a/docs/config/default.pgtidy.yaml +++ b/docs/config/default.pgtidy.yaml @@ -29,7 +29,8 @@ indent_join: false # Extra indentation for JOIN … ON lines 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 +where_wrap: always # Each AND/OR condition on its own line (when_long: only if the clause exceeds line_width) +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/todo.md b/docs/todo.md index 59d1ffb..ac7b7ad 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. -- _Still TODO: Wadler Doc-IR printer for width-aware wrapping of long lines._ +- ✅ 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, CASE, joins, subqueries) from string assembly 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 0d20c7a..7c162f0 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -69,6 +69,7 @@ type Style struct { IndentJoin bool // extra indentation for JOIN … ON lines 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) WhereAndOrIndent bool // AND/OR indented one level under WHERE // --- Subqueries --- @@ -120,6 +121,7 @@ func Default() Style { SetAlignEqual: false, IndentJoin: false, JoinIndentSize: 1, + LineWidth: 120, WhereWrap: WrapAlways, WhereAndOrIndent: true, @@ -169,6 +171,7 @@ type yamlFile struct { IndentJoin *bool `yaml:"indent_join"` JoinIndentSize *int `yaml:"join_indent_size"` WhereWrap *string `yaml:"where_wrap"` + LineWidth *int `yaml:"line_width"` WhereAndOrIndent *bool `yaml:"where_and_or_indent"` SubqueryOpening *string `yaml:"subquery_opening"` @@ -258,6 +261,12 @@ func Load(startDir string) (Style, error) { if yf.JoinIndentSize != nil { st.JoinIndentSize = *yf.JoinIndentSize } + if yf.LineWidth != nil { + if *yf.LineWidth < 0 { + return st, fmt.Errorf("pgtidy: %s: line_width: must be >= 0", path) + } + st.LineWidth = *yf.LineWidth + } 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 ae6d9e5..d0e3225 100644 --- a/pkg/format/dml.go +++ b/pkg/format/dml.go @@ -244,6 +244,9 @@ func dmlWhereClause(kwText string, body []cst.Tok, st config.Style) string { } nl := st.Newline + if st.WhereWrap == config.WrapWhenLong { + return dmlWhereWhenLong(kwText, conditions, st) + } var b strings.Builder b.WriteString(kwText) for i, cond := range conditions { @@ -261,6 +264,29 @@ func dmlWhereClause(kwText string, body []cst.Tok, st config.Style) string { return b.String() } +// dmlWhereWhenLong is the where_wrap: when_long layout: the whole clause stays +// on one line when it fits in line_width, otherwise it breaks exactly as +// where_wrap: always would. +func dmlWhereWhenLong(kwText string, conditions [][]cst.Tok, st config.Style) string { + prefix := "" + if st.WhereAndOrIndent { + prefix = st.Indent + } + parts := []Doc{Text(kwText)} + for i, cond := range conditions { + text := dmlInline(cond, st) + pfx := prefix + if i == 0 { + pfx += " " // align with AND/OR token width + } + // Continuation lines of a multi-line condition (subquery) keep the + // same prefix writeListItem would give them. + text = strings.ReplaceAll(text, st.Newline, st.Newline+pfx) + parts = append(parts, IfBreak(Concat(Text(st.Newline), Text(pfx)), Text(" ")), Text(text)) + } + return Render(Group(Concat(parts...)), st.LineWidth, st.Indent, st.Newline) +} + // dmlSplitAndOr splits toks at depth-0 AND/OR tokens, keeping the AND/OR with // the following condition. func dmlSplitAndOr(toks []cst.Tok) [][]cst.Tok { diff --git a/pkg/format/dml_test.go b/pkg/format/dml_test.go index b0afbe7..38482b3 100644 --- a/pkg/format/dml_test.go +++ b/pkg/format/dml_test.go @@ -611,3 +611,24 @@ func TestDMLRecordSpaceBeforeParen(t *testing.T) { t.Errorf("row() space_before_paren not idempotent:\n--- once ---\n%s\n--- twice ---\n%s", gotSpaced, twice) } } + +func TestWhereWhenLong(t *testing.T) { + src := "select a from t where a = 1 and b = 2" + st := config.Default() + st.WhereWrap = config.WrapWhenLong + st.LineWidth = 80 + short := File(parser.Parse(src), st) + if !strings.Contains(short, "WHERE a = 1 AND b = 2") { + t.Errorf("short clause should stay on one line:\n%s", short) + } + st.LineWidth = 12 + long := File(parser.Parse(src), st) + if strings.Contains(long, "WHERE a = 1") || !strings.Contains(long, "AND b = 2") { + t.Errorf("long clause should break per condition:\n%s", long) + } + st.WhereWrap = config.WrapAlways + want := File(parser.Parse(src), st) + if long != want { + t.Errorf("broken when_long should equal always:\n%s\n---\n%s", long, want) + } +} diff --git a/pkg/format/doc.go b/pkg/format/doc.go new file mode 100644 index 0000000..a26e656 --- /dev/null +++ b/pkg/format/doc.go @@ -0,0 +1,158 @@ +package format + +import "strings" + +// Doc is a Wadler/Prettier-style layout document. Build one from Text, Line, +// SoftLine, Group, Indent, IfBreak and Concat, then Render it at a line width: +// a Group is printed flat (Lines become spaces, SoftLines vanish) when it fits +// in the remaining width, otherwise broken (Lines become newlines). +// +// Rendering is relative to column 0; callers that splice the result mid-line +// re-indent continuation lines themselves, so width checks ignore that offset. +type Doc interface{ isDoc() } + +type ( + docText string + docLine struct{ soft bool } + docConcat []Doc + docIndent struct{ d Doc } + docGroup struct{ d Doc } + docBreak struct{ broken, flat Doc } +) + +func (docText) isDoc() {} +func (docLine) isDoc() {} +func (docConcat) isDoc() {} +func (docIndent) isDoc() {} +func (docGroup) isDoc() {} +func (docBreak) isDoc() {} + +// Text is literal text. If it contains a newline the enclosing group can never +// be printed flat. +func Text(s string) Doc { return docText(s) } + +// Line is a space when flat and a newline (plus indentation) when broken. +func Line() Doc { return docLine{} } + +// SoftLine is nothing when flat and a newline (plus indentation) when broken. +func SoftLine() Doc { return docLine{soft: true} } + +// Concat joins docs in order. +func Concat(ds ...Doc) Doc { return docConcat(ds) } + +// Indent indents every line break inside d by one indent unit. +func Indent(d Doc) Doc { return docIndent{d} } + +// Group lays d out flat if it fits on the current line, otherwise broken. +func Group(d Doc) Doc { return docGroup{d} } + +// IfBreak renders broken when the enclosing group is broken, flat otherwise. +func IfBreak(broken, flat Doc) Doc { return docBreak{broken, flat} } + +type docCmd struct { + indent string + flat bool + d Doc +} + +// Render lays out d. width <= 0 means unlimited (every group stays flat). +func Render(d Doc, width int, unit, nl string) string { + var b strings.Builder + col := 0 + stack := []docCmd{{"", false, d}} + for len(stack) > 0 { + c := stack[len(stack)-1] + stack = stack[:len(stack)-1] + switch v := c.d.(type) { + case docText: + b.WriteString(string(v)) + if i := strings.LastIndexByte(string(v), '\n'); i >= 0 { + col = len(v) - i - 1 + } else { + col += len(v) + } + case docConcat: + for i := len(v) - 1; i >= 0; i-- { + stack = append(stack, docCmd{c.indent, c.flat, v[i]}) + } + case docIndent: + stack = append(stack, docCmd{c.indent + unit, c.flat, v.d}) + case docBreak: + if c.flat { + stack = append(stack, docCmd{c.indent, c.flat, v.flat}) + } else { + stack = append(stack, docCmd{c.indent, c.flat, v.broken}) + } + case docLine: + if c.flat { + if !v.soft { + b.WriteByte(' ') + col++ + } + break + } + b.WriteString(nl) + b.WriteString(c.indent) + col = len(c.indent) + case docGroup: + flat := c.flat || width <= 0 || fitsFlat(v.d, width-col, c.indent, unit, stack) + stack = append(stack, docCmd{c.indent, flat, v.d}) + } + } + return b.String() +} + +// fitsFlat reports whether d, printed flat, plus whatever follows it up to the +// next possible line break, fits in rem columns. +func fitsFlat(d Doc, rem int, indent, unit string, rest []docCmd) bool { + if rem < 0 { + return false + } + type item struct { + d Doc + flat bool + } + work := []item{{d, true}} + ri := len(rest) - 1 + for rem >= 0 { + if len(work) == 0 { + if ri < 0 { + return true + } + work = append(work, item{rest[ri].d, rest[ri].flat}) + ri-- + continue + } + it := work[len(work)-1] + work = work[:len(work)-1] + switch v := it.d.(type) { + case docText: + if strings.ContainsRune(string(v), '\n') { + return false + } + rem -= len(v) + case docConcat: + for i := len(v) - 1; i >= 0; i-- { + work = append(work, item{v[i], it.flat}) + } + case docIndent: + work = append(work, item{v.d, it.flat}) + case docGroup: + work = append(work, item{v.d, it.flat}) + case docBreak: + if it.flat { + work = append(work, item{v.flat, it.flat}) + } else { + work = append(work, item{v.broken, it.flat}) + } + case docLine: + if !it.flat { + return true // a real line break ends the measured run + } + if !v.soft { + rem-- + } + } + } + return false +} diff --git a/pkg/format/doc_test.go b/pkg/format/doc_test.go new file mode 100644 index 0000000..b7eb9a5 --- /dev/null +++ b/pkg/format/doc_test.go @@ -0,0 +1,40 @@ +package format + +import "testing" + +func TestDocGroupFlatWhenFits(t *testing.T) { + d := Group(Concat(Text("WHERE"), Indent(Concat(Line(), Text("a = 1"), Line(), Text("AND b = 2"))))) + if got, want := Render(d, 80, " ", "\n"), "WHERE a = 1 AND b = 2"; got != want { + t.Errorf("flat: got %q want %q", got, want) + } + if got, want := Render(d, 10, " ", "\n"), "WHERE\n a = 1\n AND b = 2"; got != want { + t.Errorf("broken: got %q want %q", got, want) + } + if got, want := Render(d, 0, " ", "\n"), "WHERE a = 1 AND b = 2"; got != want { + t.Errorf("unlimited: got %q want %q", got, want) + } +} + +func TestDocNewlineInTextForcesBreak(t *testing.T) { + d := Group(Concat(Text("x"), Line(), Text("(\n y)"))) + if got, want := Render(d, 80, " ", "\n"), "x\n(\n y)"; got != want { + t.Errorf("got %q want %q", got, want) + } +} + +func TestDocIfBreakAndSoftLine(t *testing.T) { + d := Group(Concat(Text("f("), Indent(Concat(SoftLine(), Text("a, b"))), SoftLine(), IfBreak(Text(" -- long"), Text("")))) + if got, want := Render(d, 80, " ", "\n"), "f(a, b"; got != want { + t.Errorf("flat: got %q want %q", got, want) + } + if got, want := Render(d, 3, " ", "\n"), "f(\n a, b\n -- long"; got != want { + t.Errorf("broken: got %q want %q", got, want) + } +} + +func TestDocTrailingTextCountsTowardFit(t *testing.T) { + d := Concat(Group(Concat(Text("aaa"), Line(), Text("bbb"))), Text(";;;;")) + if got, want := Render(d, 10, " ", "\n"), "aaa\nbbb;;;;"; got != want { + t.Errorf("got %q want %q", got, want) + } +}