feat(format): Doc-IR core, line_width (120) and where_wrap when_long
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user