Files
PgTidy/pkg/config/config.go
T
Hein 04711cf7b2
CI / Test (push) Successful in 28s
CI / Build (push) Successful in 25s
fix(cmd): handle errors and improve output formatting
* update error handling in various commands to use blank identifier
* enhance output formatting for better readability
* add golangci-lint to Makefile for linting checks
2026-07-01 12:53:25 +02:00

383 lines
12 KiB
Go

// Package config defines PgTidy's formatter (and, later, linter) configuration.
//
// Defaults encode the project house style. A .pgtidy.yaml file discovered by
// walking up from the target directory overrides individual fields.
package config
import (
"fmt"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
// Case controls keyword/identifier casing.
type Case string
const (
CaseUpper Case = "upper"
CaseLower Case = "lower"
CasePreserve Case = "preserve"
)
// CommaStyle controls where separators sit in multi-line lists.
type CommaStyle string
const (
CommaLeading CommaStyle = "leading"
CommaTrailing CommaStyle = "trailing"
)
// WrapMode controls whether a construct wraps to multiple lines.
type WrapMode string
const (
WrapAlways WrapMode = "always"
WrapWhenLong WrapMode = "when_long"
WrapNever WrapMode = "never"
)
// Placement controls where a token or block is placed relative to surrounding content.
type Placement string
const (
PlacementSameLine Placement = "same_line"
PlacementNewLine Placement = "new_line"
)
// Style is the formatter configuration.
type Style struct {
// --- Core ---
Indent string
Newline string
// --- Casing ---
KeywordCase Case
IdentCase Case
TypeCase Case
AliasCase Case // token immediately following AS in SELECT/FROM
BuiltinCase Case // built-in function names (COALESCE, MAX, NOW, …)
CustomTypeCase Case // user-defined / domain types not in the built-in set
// --- Query layout ---
Commas CommaStyle
AlignColumns bool // pad SELECT list so values align
AlignLineComments bool // align trailing -- comments in a block
SelectAlignAs bool // pad between expression and AS in SELECT list
SetAlignEqual bool // align = in UPDATE SET list
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
WhereAndOrIndent bool // AND/OR indented one level under WHERE
// --- Subqueries ---
SubqueryOpening Placement // opening ( placement: same_line|new_line
SubqueryContent Placement // content indentation: same_line|new_line
SubqueryClosing Placement // closing ) placement: same_line|new_line
SubquerySpaceBeforeParen bool // space before ( in subqueries
// --- INSERT ---
InsertCollapseValues bool // fold multiple VALUES rows onto fewer lines
// --- Routines ---
AlignParamTypes bool // pad param names so type column aligns
RoutineAsWrap bool // newline before AS $$
// --- PL/pgSQL body ---
PlpgsqlMaxBlankLines int // max consecutive blank lines in body
PlpgsqlDeclareAlignType bool // align type column in DECLARE block
PlpgsqlDeclareAlignEq bool // align := / = in DECLARE block
PlpgsqlIfThenNewline bool // THEN on its own line
PlpgsqlLoopCollapse bool // collapse empty loop bodies to one line
// --- Expressions ---
BinaryOpAlign bool // align =, <>, || etc. vertically in WHERE/expr lists
SpaceAfterCommaInCalls bool // space after , in function calls: func(a, b)
CaseWhenWrap bool // each WHEN … THEN on its own line
CaseEnd Placement // END placement: same_line|new_line
CaseCollapse bool // collapse short CASE to one line
RecordSpaceBeforeParen bool // space before ( in ROW(…) / record constructors
}
// Default returns the house-style configuration.
func Default() Style {
return Style{
Indent: " ",
Newline: "\n",
KeywordCase: CaseUpper,
IdentCase: CaseLower,
TypeCase: CaseLower,
AliasCase: CaseLower,
BuiltinCase: CaseLower,
CustomTypeCase: CaseLower,
Commas: CommaLeading,
AlignColumns: false,
AlignLineComments: false,
SelectAlignAs: false,
SetAlignEqual: false,
IndentJoin: false,
JoinIndentSize: 1,
WhereWrap: WrapAlways,
WhereAndOrIndent: true,
SubqueryOpening: PlacementSameLine,
SubqueryContent: PlacementNewLine,
SubqueryClosing: PlacementNewLine,
SubquerySpaceBeforeParen: false,
InsertCollapseValues: true,
AlignParamTypes: true,
RoutineAsWrap: true,
PlpgsqlMaxBlankLines: 1,
PlpgsqlDeclareAlignType: false,
PlpgsqlDeclareAlignEq: false,
PlpgsqlIfThenNewline: true,
PlpgsqlLoopCollapse: true,
BinaryOpAlign: false,
SpaceAfterCommaInCalls: false,
CaseWhenWrap: false,
CaseEnd: PlacementNewLine,
CaseCollapse: false,
RecordSpaceBeforeParen: false,
}
}
// yamlFile is the on-disk representation of .pgtidy.yaml.
// All fields are pointers so we can distinguish "not set" from "set to zero value".
type yamlFile struct {
Indent *string `yaml:"indent"`
Newline *string `yaml:"newline"`
KeywordCase *string `yaml:"keyword_case"`
IdentCase *string `yaml:"ident_case"`
TypeCase *string `yaml:"type_case"`
AliasCase *string `yaml:"alias_case"`
BuiltinCase *string `yaml:"builtin_case"`
CustomTypeCase *string `yaml:"custom_type_case"`
Commas *string `yaml:"commas"`
AlignColumns *bool `yaml:"align_columns"`
AlignLineComments *bool `yaml:"align_line_comments"`
SelectAlignAs *bool `yaml:"select_align_as"`
SetAlignEqual *bool `yaml:"set_align_equal"`
IndentJoin *bool `yaml:"indent_join"`
JoinIndentSize *int `yaml:"join_indent_size"`
WhereWrap *string `yaml:"where_wrap"`
WhereAndOrIndent *bool `yaml:"where_and_or_indent"`
SubqueryOpening *string `yaml:"subquery_opening"`
SubqueryContent *string `yaml:"subquery_content"`
SubqueryClosing *string `yaml:"subquery_closing"`
SubquerySpaceBeforeParen *bool `yaml:"subquery_space_before_paren"`
InsertCollapseValues *bool `yaml:"insert_collapse_values"`
AlignParamTypes *bool `yaml:"align_param_types"`
RoutineAsWrap *bool `yaml:"routine_as_wrap"`
PlpgsqlMaxBlankLines *int `yaml:"plpgsql_max_blank_lines"`
PlpgsqlDeclareAlignType *bool `yaml:"plpgsql_declare_align_type"`
PlpgsqlDeclareAlignEq *bool `yaml:"plpgsql_declare_align_eq"`
PlpgsqlIfThenNewline *bool `yaml:"plpgsql_if_then_newline"`
PlpgsqlLoopCollapse *bool `yaml:"plpgsql_loop_collapse"`
BinaryOpAlign *bool `yaml:"binary_op_align"`
SpaceAfterCommaInCalls *bool `yaml:"space_after_comma_in_calls"`
CaseWhenWrap *bool `yaml:"case_when_wrap"`
CaseEnd *string `yaml:"case_end"`
CaseCollapse *bool `yaml:"case_collapse"`
RecordSpaceBeforeParen *bool `yaml:"record_space_before_paren"`
}
// Load discovers and parses the nearest .pgtidy.yaml by walking up from
// startDir. Fields present in the file override the house-style defaults;
// missing fields keep the default value. Returns Default() when no config
// file is found.
func Load(startDir string) (Style, error) {
st := Default()
path, err := findConfig(startDir)
if err != nil || path == "" {
return st, err
}
data, err := os.ReadFile(path)
if err != nil {
return st, fmt.Errorf("pgtidy: read %s: %w", path, err)
}
var yf yamlFile
if err := yaml.Unmarshal(data, &yf); err != nil {
return st, fmt.Errorf("pgtidy: parse %s: %w", path, err)
}
if yf.Indent != nil {
st.Indent = *yf.Indent
}
if yf.Newline != nil {
st.Newline = *yf.Newline
}
if err := loadCase(yf.KeywordCase, &st.KeywordCase, path, "keyword_case"); err != nil {
return st, err
}
if err := loadCase(yf.IdentCase, &st.IdentCase, path, "ident_case"); err != nil {
return st, err
}
if err := loadCase(yf.TypeCase, &st.TypeCase, path, "type_case"); err != nil {
return st, err
}
if err := loadCase(yf.AliasCase, &st.AliasCase, path, "alias_case"); err != nil {
return st, err
}
if err := loadCase(yf.BuiltinCase, &st.BuiltinCase, path, "builtin_case"); err != nil {
return st, err
}
if err := loadCase(yf.CustomTypeCase, &st.CustomTypeCase, path, "custom_type_case"); err != nil {
return st, err
}
if yf.Commas != nil {
cs := CommaStyle(*yf.Commas)
if cs != CommaLeading && cs != CommaTrailing {
return st, fmt.Errorf("pgtidy: %s: commas: must be \"leading\" or \"trailing\"", path)
}
st.Commas = cs
}
loadBool(yf.AlignColumns, &st.AlignColumns)
loadBool(yf.AlignLineComments, &st.AlignLineComments)
loadBool(yf.SelectAlignAs, &st.SelectAlignAs)
loadBool(yf.SetAlignEqual, &st.SetAlignEqual)
loadBool(yf.IndentJoin, &st.IndentJoin)
if yf.JoinIndentSize != nil {
st.JoinIndentSize = *yf.JoinIndentSize
}
if yf.WhereWrap != nil {
wm := WrapMode(*yf.WhereWrap)
if err := validWrap(wm); err != nil {
return st, fmt.Errorf("pgtidy: %s: where_wrap: %w", path, err)
}
st.WhereWrap = wm
}
loadBool(yf.WhereAndOrIndent, &st.WhereAndOrIndent)
if yf.SubqueryOpening != nil {
pl := Placement(*yf.SubqueryOpening)
if err := validPlacement(pl); err != nil {
return st, fmt.Errorf("pgtidy: %s: subquery_opening: %w", path, err)
}
st.SubqueryOpening = pl
}
if yf.SubqueryContent != nil {
pl := Placement(*yf.SubqueryContent)
if err := validPlacement(pl); err != nil {
return st, fmt.Errorf("pgtidy: %s: subquery_content: %w", path, err)
}
st.SubqueryContent = pl
}
if yf.SubqueryClosing != nil {
pl := Placement(*yf.SubqueryClosing)
if err := validPlacement(pl); err != nil {
return st, fmt.Errorf("pgtidy: %s: subquery_closing: %w", path, err)
}
st.SubqueryClosing = pl
}
loadBool(yf.SubquerySpaceBeforeParen, &st.SubquerySpaceBeforeParen)
loadBool(yf.InsertCollapseValues, &st.InsertCollapseValues)
loadBool(yf.AlignParamTypes, &st.AlignParamTypes)
loadBool(yf.RoutineAsWrap, &st.RoutineAsWrap)
if yf.PlpgsqlMaxBlankLines != nil {
st.PlpgsqlMaxBlankLines = *yf.PlpgsqlMaxBlankLines
}
loadBool(yf.PlpgsqlDeclareAlignType, &st.PlpgsqlDeclareAlignType)
loadBool(yf.PlpgsqlDeclareAlignEq, &st.PlpgsqlDeclareAlignEq)
loadBool(yf.PlpgsqlIfThenNewline, &st.PlpgsqlIfThenNewline)
loadBool(yf.PlpgsqlLoopCollapse, &st.PlpgsqlLoopCollapse)
loadBool(yf.BinaryOpAlign, &st.BinaryOpAlign)
loadBool(yf.SpaceAfterCommaInCalls, &st.SpaceAfterCommaInCalls)
loadBool(yf.CaseWhenWrap, &st.CaseWhenWrap)
if yf.CaseEnd != nil {
pl := Placement(*yf.CaseEnd)
if err := validPlacement(pl); err != nil {
return st, fmt.Errorf("pgtidy: %s: case_end: %w", path, err)
}
st.CaseEnd = pl
}
loadBool(yf.CaseCollapse, &st.CaseCollapse)
loadBool(yf.RecordSpaceBeforeParen, &st.RecordSpaceBeforeParen)
return st, nil
}
func loadCase(src *string, dst *Case, path, key string) error {
if src == nil {
return nil
}
c := Case(*src)
if err := validCase(c); err != nil {
return fmt.Errorf("pgtidy: %s: %s: %w", path, key, err)
}
*dst = c
return nil
}
func loadBool(src *bool, dst *bool) {
if src != nil {
*dst = *src
}
}
// findConfig walks parent directories from startDir looking for .pgtidy.yaml.
// Returns ("", nil) when no file is found before reaching the filesystem root.
func findConfig(startDir string) (string, error) {
dir, err := filepath.Abs(startDir)
if err != nil {
return "", err
}
for {
candidate := filepath.Join(dir, ".pgtidy.yaml")
if _, err := os.Stat(candidate); err == nil {
return candidate, nil
}
parent := filepath.Dir(dir)
if parent == dir {
return "", nil
}
dir = parent
}
}
func validCase(c Case) error {
switch c {
case CaseUpper, CaseLower, CasePreserve:
return nil
}
return fmt.Errorf("must be \"upper\", \"lower\", or \"preserve\"")
}
func validWrap(w WrapMode) error {
switch w {
case WrapAlways, WrapWhenLong, WrapNever:
return nil
}
return fmt.Errorf("must be \"always\", \"when_long\", or \"never\"")
}
func validPlacement(p Placement) error {
switch p {
case PlacementSameLine, PlacementNewLine:
return nil
}
return fmt.Errorf("must be \"same_line\" or \"new_line\"")
}