feat(format): Doc-IR core, line_width (120) and where_wrap when_long

This commit is contained in:
Hein
2026-10-06 14:44:45 +02:00
parent 4ff729eeeb
commit 553609e988
7 changed files with 257 additions and 2 deletions
+2 -1
View File
@@ -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
+1 -1
View File
@@ -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
+9
View File
@@ -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 {
+26
View File
@@ -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 {
+21
View File
@@ -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)
}
}
+158
View File
@@ -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
}
+40
View File
@@ -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)
}
}