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
|
join_indent_size: 1 # Number of extra indent levels for JOINs
|
||||||
|
|
||||||
# always | when_long | never
|
# 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
|
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).
|
function call at the token level — formatted without space (known limitation).
|
||||||
- Note: SQL keywords inside PL/pgSQL function bodies remain lowercase (matching
|
- Note: SQL keywords inside PL/pgSQL function bodies remain lowercase (matching
|
||||||
the corpus golden files); casing is applied only to top-level DML.
|
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._
|
- _Still TODO: LSP range formatting._
|
||||||
|
|
||||||
## ✅ Config expansion — DataGrip settings parity
|
## ✅ Config expansion — DataGrip settings parity
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ type Style struct {
|
|||||||
IndentJoin bool // extra indentation for JOIN … ON lines
|
IndentJoin bool // extra indentation for JOIN … ON lines
|
||||||
JoinIndentSize int // extra indent levels for JOINs (default 1)
|
JoinIndentSize int // extra indent levels for JOINs (default 1)
|
||||||
WhereWrap WrapMode // always|when_long|never — each AND/OR on its own line
|
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
|
WhereAndOrIndent bool // AND/OR indented one level under WHERE
|
||||||
|
|
||||||
// --- Subqueries ---
|
// --- Subqueries ---
|
||||||
@@ -120,6 +121,7 @@ func Default() Style {
|
|||||||
SetAlignEqual: false,
|
SetAlignEqual: false,
|
||||||
IndentJoin: false,
|
IndentJoin: false,
|
||||||
JoinIndentSize: 1,
|
JoinIndentSize: 1,
|
||||||
|
LineWidth: 120,
|
||||||
WhereWrap: WrapAlways,
|
WhereWrap: WrapAlways,
|
||||||
WhereAndOrIndent: true,
|
WhereAndOrIndent: true,
|
||||||
|
|
||||||
@@ -169,6 +171,7 @@ type yamlFile struct {
|
|||||||
IndentJoin *bool `yaml:"indent_join"`
|
IndentJoin *bool `yaml:"indent_join"`
|
||||||
JoinIndentSize *int `yaml:"join_indent_size"`
|
JoinIndentSize *int `yaml:"join_indent_size"`
|
||||||
WhereWrap *string `yaml:"where_wrap"`
|
WhereWrap *string `yaml:"where_wrap"`
|
||||||
|
LineWidth *int `yaml:"line_width"`
|
||||||
WhereAndOrIndent *bool `yaml:"where_and_or_indent"`
|
WhereAndOrIndent *bool `yaml:"where_and_or_indent"`
|
||||||
|
|
||||||
SubqueryOpening *string `yaml:"subquery_opening"`
|
SubqueryOpening *string `yaml:"subquery_opening"`
|
||||||
@@ -258,6 +261,12 @@ func Load(startDir string) (Style, error) {
|
|||||||
if yf.JoinIndentSize != nil {
|
if yf.JoinIndentSize != nil {
|
||||||
st.JoinIndentSize = *yf.JoinIndentSize
|
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 {
|
if yf.WhereWrap != nil {
|
||||||
wm := WrapMode(*yf.WhereWrap)
|
wm := WrapMode(*yf.WhereWrap)
|
||||||
if err := validWrap(wm); err != nil {
|
if err := validWrap(wm); err != nil {
|
||||||
|
|||||||
@@ -244,6 +244,9 @@ func dmlWhereClause(kwText string, body []cst.Tok, st config.Style) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
nl := st.Newline
|
nl := st.Newline
|
||||||
|
if st.WhereWrap == config.WrapWhenLong {
|
||||||
|
return dmlWhereWhenLong(kwText, conditions, st)
|
||||||
|
}
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString(kwText)
|
b.WriteString(kwText)
|
||||||
for i, cond := range conditions {
|
for i, cond := range conditions {
|
||||||
@@ -261,6 +264,29 @@ func dmlWhereClause(kwText string, body []cst.Tok, st config.Style) string {
|
|||||||
return b.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
|
// dmlSplitAndOr splits toks at depth-0 AND/OR tokens, keeping the AND/OR with
|
||||||
// the following condition.
|
// the following condition.
|
||||||
func dmlSplitAndOr(toks []cst.Tok) [][]cst.Tok {
|
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)
|
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