// 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\"") }