chore: more work done and planning with AI.
This commit is contained in:
@@ -1,26 +1,68 @@
|
|||||||
# PgTidy house style — all fields shown with their default values.
|
# PgTidy house style — all fields with their default values.
|
||||||
# Place as .pgtidy.yaml in your project root (or any parent directory).
|
# Place as .pgtidy.yaml in your project root (or any parent directory).
|
||||||
# Any field you omit keeps its default.
|
# Any field you omit keeps its default.
|
||||||
|
|
||||||
# Indentation string for one level (two spaces).
|
# --- Core ---
|
||||||
indent: " "
|
|
||||||
|
|
||||||
# Line terminator written by the formatter.
|
indent: " " # One indentation level (two spaces).
|
||||||
newline: "\n"
|
newline: "\n" # Line terminator emitted by the formatter.
|
||||||
|
|
||||||
# Casing for SQL keywords (SELECT, FROM, WHERE, …).
|
# --- Casing ---
|
||||||
# upper | lower | preserve
|
# upper | lower | preserve
|
||||||
keyword_case: upper
|
|
||||||
|
|
||||||
# Casing for unquoted identifiers (column names, variable names, …).
|
keyword_case: upper # SQL keywords (SELECT, FROM, WHERE, …)
|
||||||
# upper | lower | preserve
|
ident_case: lower # Unquoted identifiers (column/variable names)
|
||||||
ident_case: lower
|
type_case: lower # Built-in type names (text, integer, boolean, …)
|
||||||
|
alias_case: lower # Token immediately following AS in SELECT / FROM
|
||||||
|
builtin_case: lower # Built-in function names (COALESCE, MAX, NOW, …)
|
||||||
|
custom_type_case: lower # User-defined / domain types not in the built-in set
|
||||||
|
|
||||||
# Casing for built-in type names (text, integer, boolean, …).
|
# --- Query layout ---
|
||||||
# upper | lower | preserve
|
|
||||||
type_case: lower
|
|
||||||
|
|
||||||
# Comma placement in multi-line parameter / column lists.
|
commas: leading # leading → ,col | trailing → col,
|
||||||
# leading → comma at the start of the continuation line (,col)
|
|
||||||
# trailing → comma at the end of the preceding line (col,)
|
align_columns: false # Pad SELECT list items so values align vertically
|
||||||
commas: leading
|
align_line_comments: false # Align trailing -- comments within a block
|
||||||
|
select_align_as: false # Pad between expression and AS keyword in SELECT list
|
||||||
|
set_align_equal: false # Align = in UPDATE SET list
|
||||||
|
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_and_or_indent: true # AND/OR indented one level under WHERE
|
||||||
|
|
||||||
|
# --- Subqueries ---
|
||||||
|
# same_line | new_line
|
||||||
|
|
||||||
|
subquery_opening: same_line # Opening ( placement
|
||||||
|
subquery_content: new_line # Content indentation inside parens
|
||||||
|
subquery_closing: new_line # Closing ) placement
|
||||||
|
subquery_space_before_paren: false # Space before ( in subqueries
|
||||||
|
|
||||||
|
# --- INSERT ---
|
||||||
|
|
||||||
|
insert_collapse_values: true # Fold multiple VALUES rows onto fewer lines
|
||||||
|
|
||||||
|
# --- Routines (functions / procedures) ---
|
||||||
|
|
||||||
|
align_param_types: true # Pad param names so type column aligns across all params
|
||||||
|
routine_as_wrap: true # Newline before AS $$ (false = keep AS on same line)
|
||||||
|
|
||||||
|
# --- PL/pgSQL body ---
|
||||||
|
|
||||||
|
plpgsql_max_blank_lines: 1 # Max consecutive blank lines in body
|
||||||
|
plpgsql_declare_align_type: false # Align type column in DECLARE block
|
||||||
|
plpgsql_declare_align_eq: false # Align := / = in DECLARE block
|
||||||
|
plpgsql_if_then_newline: true # THEN on its own line (false = same line as condition)
|
||||||
|
plpgsql_loop_collapse: true # Collapse empty loop bodies to one line
|
||||||
|
|
||||||
|
# --- Expressions ---
|
||||||
|
|
||||||
|
binary_op_align: false # Align =, <>, || etc. vertically in WHERE/expression lists
|
||||||
|
space_after_comma_in_calls: false # Space after , in function calls: func(a, b)
|
||||||
|
case_when_wrap: false # Each WHEN … THEN on its own line
|
||||||
|
case_end: new_line # END placement: same_line | new_line
|
||||||
|
case_collapse: false # Collapse short CASE expressions to one line
|
||||||
|
record_space_before_paren: false # Space before ( in ROW(…) / record constructors
|
||||||
|
|||||||
@@ -71,6 +71,89 @@ to the intended style below.
|
|||||||
`::`, `->`, `->>`, array `[...]`, or before a call's `(`.
|
`::`, `->`, `->>`, array `[...]`, or before a call's `(`.
|
||||||
- Dollar-quote tags preserved verbatim (`$$`, `$S$`, `$Z$`, …).
|
- Dollar-quote tags preserved verbatim (`$$`, `$S$`, `$Z$`, …).
|
||||||
|
|
||||||
|
## DataGrip settings mapping
|
||||||
|
|
||||||
|
`PostgresCodeStyleSettings` (DataGrip / JetBrains) is the reference for all configurable
|
||||||
|
style options. The table below maps every relevant DataGrip key to its `.pgtidy.yaml`
|
||||||
|
counterpart so a user can reproduce their DataGrip style exactly in PgTidy.
|
||||||
|
|
||||||
|
DataGrip enum conventions used below:
|
||||||
|
- **Case**: 0=preserve, 1=upper, 2=lower
|
||||||
|
- **Comma**: 1=leading (`,col`), 2=trailing (`col,`)
|
||||||
|
- **Placement**: 1=same_line, 2=new_line
|
||||||
|
- **Wrap**: 0=never, 1=when_long, 2=always
|
||||||
|
|
||||||
|
### Casing
|
||||||
|
|
||||||
|
| DataGrip key | PgTidy key | Default | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `KEYWORD_CASE` | `keyword_case` | `upper` | SELECT, FROM, WHERE, … |
|
||||||
|
| `IDENTIFIER_CASE` | `ident_case` | `lower` | unquoted column/variable names |
|
||||||
|
| `TYPE_CASE` | `type_case` | `lower` | built-in type names (text, integer, …) |
|
||||||
|
| `CUSTOM_TYPE_CASE` | `custom_type_case` | `lower` | user-defined / domain types |
|
||||||
|
| `ALIAS_CASE` | `alias_case` | `lower` | column and table aliases |
|
||||||
|
| `BUILT_IN_CASE` | `builtin_case` | `lower` | built-in functions (COALESCE, MAX, …) |
|
||||||
|
|
||||||
|
### Query layout
|
||||||
|
|
||||||
|
| DataGrip key | PgTidy key | Default | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `QUERY_EL_COMMA` | `commas` | `leading` | applies to all clause element lists |
|
||||||
|
| `QUERY_ALIGN_ELEMENTS` | `align_columns` | `false` | align SELECT list items to same column |
|
||||||
|
| `QUERY_ALIGN_LINE_COMMENTS` | `align_line_comments` | `false` | align `--` inline comments in a block |
|
||||||
|
| `SELECT_ALIGN_AS` | `select_align_as` | `false` | align `AS` keyword across SELECT list |
|
||||||
|
| `FROM_INDENT_JOIN` | `indent_join` | `false` | indent JOIN relative to FROM |
|
||||||
|
| `FROM_ONLY_JOIN_INDENT` | `join_indent_size` | `1` | extra indent levels for JOINs |
|
||||||
|
| `SET_ALIGN_EQUAL_SIGN` | `set_align_equal` | `false` | align `=` in UPDATE SET list |
|
||||||
|
| `WHERE_EL_WRAP` + `WHERE_EL_LINE` | `where_wrap` | `always` | always \| when_long \| never — each AND/OR condition on its own line |
|
||||||
|
| _(no DataGrip equivalent)_ | `where_and_or_indent` | `true` | when true, AND/OR are indented one level under WHERE, not at WHERE's column |
|
||||||
|
|
||||||
|
### Subqueries
|
||||||
|
|
||||||
|
| DataGrip key | PgTidy key | Default | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `SUBQUERY_OPENING` | `subquery_opening` | `same_line` | opening `(` placement |
|
||||||
|
| `SUBQUERY_CONTENT` | `subquery_content` | `new_line` | content indentation inside paren |
|
||||||
|
| `SUBQUERY_CLOSING` | `subquery_closing` | `new_line` | closing `)` placement |
|
||||||
|
| `SUBQUERY_PAR_SPACE_BEFORE` | `subquery_space_before_paren` | `false` | space before `(` |
|
||||||
|
|
||||||
|
### INSERT
|
||||||
|
|
||||||
|
| DataGrip key | PgTidy key | Default | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `INSERT_COLLAPSE_MULTI_ROW_VALUES` | `insert_collapse_values` | `true` | fold VALUES rows into fewer lines |
|
||||||
|
|
||||||
|
### Routine (function / procedure)
|
||||||
|
|
||||||
|
| DataGrip key | PgTidy key | Default | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `ROUTINE_ARG_COMMA` | uses `commas` | `leading` | same setting as query lists |
|
||||||
|
| `ROUTINE_ARG_ALIGN_TYPES` | `align_param_types` | `true` | align type column in param list |
|
||||||
|
| `ROUTINE_AS_WRAP` | `routine_as_wrap` | `true` | newline before `AS $$` |
|
||||||
|
|
||||||
|
### PL/pgSQL body
|
||||||
|
|
||||||
|
| DataGrip key | PgTidy key | Default | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `IMP_COMMON_KEEP_BLANK_LINES_IN_CODE` | `plpgsql_max_blank_lines` | `1` | max consecutive blank lines in body |
|
||||||
|
| `IMP_DECLARE_ALIGN_TYPE` | `plpgsql_declare_align_type` | `false` | align type column in DECLARE block |
|
||||||
|
| `IMP_DECLARE_ALIGN_EQ` | `plpgsql_declare_align_eq` | `false` | align `:=` / `=` in DECLARE block |
|
||||||
|
| `IMP_IF_THEN_WRAP_THEN` | `plpgsql_if_then_newline` | `true` | THEN on its own line |
|
||||||
|
| `IMP_LOOP_COLLAPSE` | `plpgsql_loop_collapse` | `true` | collapse empty loop bodies |
|
||||||
|
|
||||||
|
### Expressions
|
||||||
|
|
||||||
|
| DataGrip key | PgTidy key | Default | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `EXPR_BINARY_OP_ALIGN` | `binary_op_align` | `false` | align `=`, `<>`, `||`, … vertically in WHERE/expression lists; default false — must not be hardcoded |
|
||||||
|
| `EXPR_CALL_SPACE_AFTER_COMMA` | `space_after_comma_in_calls` | `false` | space after `,` in function calls |
|
||||||
|
| `EXPR_CASE_WHEN_WRAP` | `case_when_wrap` | `false` | each WHEN on its own line |
|
||||||
|
| `EXPR_CASE_END` | `case_end` | `new_line` | same_line \| new_line |
|
||||||
|
| `EXPR_CASE_COLLAPSE` | `case_collapse` | `false` | collapse short CASE to one line |
|
||||||
|
| `CORTEGE_SPACE_BEFORE_L_PAREN` | `record_space_before_paren` | `false` | space before `(` in ROW/record constructors |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Milestones
|
## Milestones
|
||||||
|
|
||||||
### V1 — Formatter + CLI (priority)
|
### V1 — Formatter + CLI (priority)
|
||||||
@@ -88,6 +171,8 @@ to the intended style below.
|
|||||||
5. **CLI** (`cmd/pgtidy fmt`): `--check`, `--write`/`-w`, stdin→stdout, `--diff`; config
|
5. **CLI** (`cmd/pgtidy fmt`): `--check`, `--write`/`-w`, stdin→stdout, `--diff`; config
|
||||||
discovery walking up to `.pgtidy.yaml`; CI-friendly exit codes.
|
discovery walking up to `.pgtidy.yaml`; CI-friendly exit codes.
|
||||||
6. **Config** (`pkg/config`): load/merge style config; defaults = house style above.
|
6. **Config** (`pkg/config`): load/merge style config; defaults = house style above.
|
||||||
|
Full field set defined in the DataGrip settings mapping section above — covers casing
|
||||||
|
(6 keys), query layout, subqueries, INSERT, routines, PL/pgSQL body, and expressions.
|
||||||
|
|
||||||
**Safety guarantees (tested):** semantic equivalence (re-lex output, compare non-trivia token
|
**Safety guarantees (tested):** semantic equivalence (re-lex output, compare non-trivia token
|
||||||
stream to input), and idempotence (`fmt(fmt(x)) == fmt(x)`). The corpus is the
|
stream to input), and idempotence (`fmt(fmt(x)) == fmt(x)`). The corpus is the
|
||||||
|
|||||||
@@ -161,6 +161,83 @@ Legend: ✅ done · 🚧 in progress · ⬜ not started
|
|||||||
- _Still TODO: Wadler Doc-IR printer for width-aware wrapping of long lines._
|
- _Still TODO: Wadler Doc-IR printer for width-aware wrapping of long lines._
|
||||||
- _Still TODO: LSP range formatting._
|
- _Still TODO: LSP range formatting._
|
||||||
|
|
||||||
|
## ✅ Config expansion — DataGrip settings parity
|
||||||
|
|
||||||
|
Reference: `PostgresCodeStyleSettings` mapping in `docs/plan.md`.
|
||||||
|
|
||||||
|
### ✅ Extended `pkg/config` fields
|
||||||
|
|
||||||
|
Added to `Style` struct, `yamlFile`, and `Load()` in `pkg/config/config.go`:
|
||||||
|
|
||||||
|
- New types: `WrapMode` (`always`|`when_long`|`never`), `Placement` (`same_line`|`new_line`)
|
||||||
|
- **Casing**: `AliasCase`, `BuiltinCase`, `CustomTypeCase` — all default `lower`
|
||||||
|
- **Query layout**: `AlignColumns`, `AlignLineComments`, `SelectAlignAs`, `SetAlignEqual`,
|
||||||
|
`IndentJoin`, `JoinIndentSize`, `WhereWrap`, `WhereAndOrIndent`
|
||||||
|
- **Subqueries**: `SubqueryOpening`, `SubqueryContent`, `SubqueryClosing`, `SubquerySpaceBeforeParen`
|
||||||
|
- **INSERT**: `InsertCollapseValues`
|
||||||
|
- **Routines**: `AlignParamTypes`, `RoutineAsWrap`
|
||||||
|
- **PL/pgSQL**: `PlpgsqlMaxBlankLines`, `PlpgsqlDeclareAlignType`, `PlpgsqlDeclareAlignEq`,
|
||||||
|
`PlpgsqlIfThenNewline`, `PlpgsqlLoopCollapse`
|
||||||
|
- **Expressions**: `BinaryOpAlign`, `SpaceAfterCommaInCalls`, `CaseWhenWrap`, `CaseEnd`,
|
||||||
|
`CaseCollapse`, `RecordSpaceBeforeParen`
|
||||||
|
- `docs/config/default.pgtidy.yaml` updated with all new keys and comments.
|
||||||
|
|
||||||
|
### ✅ Casing engine — alias and built-in classification
|
||||||
|
|
||||||
|
`pkg/format/keywords.go`: added `builtinFunctions` set (COALESCE, MAX, MIN, NOW, …).
|
||||||
|
`pkg/format/format.go`: `caseTextCtx` uses context — `prev` token and `nextIsLParen` flag
|
||||||
|
to route ident tokens through `AliasCase` (after AS) or `BuiltinCase` (before `(`).
|
||||||
|
`inline()` and `dmlInline()` pass context to `caseTextCtx`.
|
||||||
|
|
||||||
|
### ✅ Formatter — query layout settings (`pkg/format/dml.go`)
|
||||||
|
|
||||||
|
- `indent_join` + `join_indent_size`: JOIN clause indented by `JoinIndentSize × Indent`.
|
||||||
|
- `where_wrap` + `where_and_or_indent`: `dmlWhereClause` splits AND/OR conditions; `always`
|
||||||
|
puts each condition on its own line indented under WHERE; `never` keeps inline.
|
||||||
|
- `set_align_equal`: `dmlColListSet` pads LHS of SET items so `=` signs align.
|
||||||
|
- `align_columns` + `select_align_as`: `dmlColListSelect` + `alignSelectItems` pads
|
||||||
|
SELECT expressions so AS keywords and aliases align vertically.
|
||||||
|
- `space_after_comma_in_calls` applied in `dmlInline`.
|
||||||
|
- `binary_op_align` registered in config (enforcement in WHERE/expression context deferred).
|
||||||
|
|
||||||
|
### ⬜ Formatter — subquery formatting
|
||||||
|
|
||||||
|
`subquery_opening/content/closing/space_before_paren` fields are wired in config.
|
||||||
|
Enforcement in `dml.go` is not yet implemented — subqueries use current CTE formatting
|
||||||
|
as a proxy (new_line for content, inline for single-arg subexpressions).
|
||||||
|
|
||||||
|
### ⬜ Formatter — INSERT VALUES collapse
|
||||||
|
|
||||||
|
`insert_collapse_values` field is wired in config. Enforcement in `dml.go` not yet implemented.
|
||||||
|
|
||||||
|
### ✅ Formatter — routine param alignment (`pkg/format/format.go`)
|
||||||
|
|
||||||
|
- `align_param_types`: `alignParamTypes()` pads param names so type columns align; default `true`.
|
||||||
|
- `routine_as_wrap`: when `false`, AS stays on the same line as the last option clause.
|
||||||
|
- Golden file `testdata/corpus/test_a.pgsql` updated to reflect aligned params.
|
||||||
|
|
||||||
|
### ✅ Formatter — PL/pgSQL body settings (`pkg/format/body.go`)
|
||||||
|
|
||||||
|
- `plpgsql_max_blank_lines`: blank-line runs capped at the configured limit; default `1`.
|
||||||
|
- `plpgsql_declare_align_type` + `plpgsql_declare_align_eq`: two-pass declare formatter
|
||||||
|
measures name/type widths then pads for alignment; `writeDeclareAligned` helper.
|
||||||
|
- `plpgsql_if_then_newline`: when `false`, `joinThenToCondition` merges THEN onto the
|
||||||
|
preceding condition line.
|
||||||
|
- `plpgsql_loop_collapse`: `tryCollapseLoop` detects empty FOR/WHILE loop bodies and
|
||||||
|
collapses them to one line.
|
||||||
|
- CRLF normalization in trivia emission (comment text, body trivia before DECLARE).
|
||||||
|
|
||||||
|
### ⬜ Formatter — expression settings (case_when_wrap, case_end, case_collapse, record_space_before_paren)
|
||||||
|
|
||||||
|
Config fields wired. Expression-level CASE/ROW formatting not yet implemented.
|
||||||
|
|
||||||
|
### ⬜ DataGrip XML import/export (optional, V4+)
|
||||||
|
|
||||||
|
`pgtidy config import --datagrip <settings.xml>` / `pgtidy config export --datagrip`
|
||||||
|
not implemented.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Open risks
|
## Open risks
|
||||||
- `go-pgquery` tracks PG17 (not PG18) — fine for lint; irrelevant to formatter path.
|
- `go-pgquery` tracks PG17 (not PG18) — fine for lint; irrelevant to formatter path.
|
||||||
- Leading-comma + one-per-line is a first-class style option, not an afterthought.
|
- Leading-comma + one-per-line is a first-class style option, not an afterthought.
|
||||||
|
|||||||
+270
-49
@@ -16,9 +16,8 @@ import (
|
|||||||
type Case string
|
type Case string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
CaseUpper Case = "upper"
|
CaseUpper Case = "upper"
|
||||||
CaseLower Case = "lower"
|
CaseLower Case = "lower"
|
||||||
// CasePreserve leaves the token text unchanged.
|
|
||||||
CasePreserve Case = "preserve"
|
CasePreserve Case = "preserve"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,51 +25,174 @@ const (
|
|||||||
type CommaStyle string
|
type CommaStyle string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// CommaLeading puts the comma at the start of the continuation line
|
CommaLeading CommaStyle = "leading"
|
||||||
// (",col"), the house style.
|
|
||||||
CommaLeading CommaStyle = "leading"
|
|
||||||
// CommaTrailing puts the comma at the end of the preceding line ("col,").
|
|
||||||
CommaTrailing CommaStyle = "trailing"
|
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.
|
// Style is the formatter configuration.
|
||||||
type Style struct {
|
type Style struct {
|
||||||
// Indent is one indentation level (default two spaces).
|
// --- Core ---
|
||||||
Indent string
|
Indent string
|
||||||
// Newline is the line terminator emitted by the formatter.
|
|
||||||
Newline string
|
Newline string
|
||||||
// KeywordCase controls SQL keyword casing (types excluded — see TypeCase).
|
|
||||||
KeywordCase Case
|
// --- Casing ---
|
||||||
// IdentCase controls unquoted identifier casing (quoted identifiers are
|
KeywordCase Case
|
||||||
// never touched).
|
IdentCase Case
|
||||||
IdentCase Case
|
TypeCase Case
|
||||||
// TypeCase controls built-in type-name casing.
|
AliasCase Case // token immediately following AS in SELECT/FROM
|
||||||
TypeCase Case
|
BuiltinCase Case // built-in function names (COALESCE, MAX, NOW, …)
|
||||||
// Commas controls list separator placement.
|
CustomTypeCase Case // user-defined / domain types not in the built-in set
|
||||||
Commas CommaStyle
|
|
||||||
|
// --- 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.
|
// Default returns the house-style configuration.
|
||||||
func Default() Style {
|
func Default() Style {
|
||||||
return Style{
|
return Style{
|
||||||
Indent: " ",
|
Indent: " ",
|
||||||
Newline: "\n",
|
Newline: "\n",
|
||||||
KeywordCase: CaseUpper,
|
|
||||||
IdentCase: CaseLower,
|
KeywordCase: CaseUpper,
|
||||||
TypeCase: CaseLower,
|
IdentCase: CaseLower,
|
||||||
Commas: CommaLeading,
|
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.
|
// yamlFile is the on-disk representation of .pgtidy.yaml.
|
||||||
// All fields are pointers so we can distinguish "not set" from "set to zero value".
|
// All fields are pointers so we can distinguish "not set" from "set to zero value".
|
||||||
type yamlFile struct {
|
type yamlFile struct {
|
||||||
Indent *string `yaml:"indent"`
|
Indent *string `yaml:"indent"`
|
||||||
Newline *string `yaml:"newline"`
|
Newline *string `yaml:"newline"`
|
||||||
KeywordCase *string `yaml:"keyword_case"`
|
|
||||||
IdentCase *string `yaml:"ident_case"`
|
KeywordCase *string `yaml:"keyword_case"`
|
||||||
TypeCase *string `yaml:"type_case"`
|
IdentCase *string `yaml:"ident_case"`
|
||||||
Commas *string `yaml:"commas"`
|
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
|
// Load discovers and parses the nearest .pgtidy.yaml by walking up from
|
||||||
@@ -100,27 +222,26 @@ func Load(startDir string) (Style, error) {
|
|||||||
if yf.Newline != nil {
|
if yf.Newline != nil {
|
||||||
st.Newline = *yf.Newline
|
st.Newline = *yf.Newline
|
||||||
}
|
}
|
||||||
if yf.KeywordCase != nil {
|
|
||||||
c := Case(*yf.KeywordCase)
|
if err := loadCase(yf.KeywordCase, &st.KeywordCase, path, "keyword_case"); err != nil {
|
||||||
if err := validCase(c); err != nil {
|
return st, err
|
||||||
return st, fmt.Errorf("pgtidy: %s: keyword_case: %w", path, err)
|
|
||||||
}
|
|
||||||
st.KeywordCase = c
|
|
||||||
}
|
}
|
||||||
if yf.IdentCase != nil {
|
if err := loadCase(yf.IdentCase, &st.IdentCase, path, "ident_case"); err != nil {
|
||||||
c := Case(*yf.IdentCase)
|
return st, err
|
||||||
if err := validCase(c); err != nil {
|
|
||||||
return st, fmt.Errorf("pgtidy: %s: ident_case: %w", path, err)
|
|
||||||
}
|
|
||||||
st.IdentCase = c
|
|
||||||
}
|
}
|
||||||
if yf.TypeCase != nil {
|
if err := loadCase(yf.TypeCase, &st.TypeCase, path, "type_case"); err != nil {
|
||||||
c := Case(*yf.TypeCase)
|
return st, err
|
||||||
if err := validCase(c); err != nil {
|
|
||||||
return st, fmt.Errorf("pgtidy: %s: type_case: %w", path, err)
|
|
||||||
}
|
|
||||||
st.TypeCase = c
|
|
||||||
}
|
}
|
||||||
|
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 {
|
if yf.Commas != nil {
|
||||||
cs := CommaStyle(*yf.Commas)
|
cs := CommaStyle(*yf.Commas)
|
||||||
if cs != CommaLeading && cs != CommaTrailing {
|
if cs != CommaLeading && cs != CommaTrailing {
|
||||||
@@ -129,9 +250,93 @@ func Load(startDir string) (Style, error) {
|
|||||||
st.Commas = cs
|
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
|
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.
|
// findConfig walks parent directories from startDir looking for .pgtidy.yaml.
|
||||||
// Returns ("", nil) when no file is found before reaching the filesystem root.
|
// Returns ("", nil) when no file is found before reaching the filesystem root.
|
||||||
func findConfig(startDir string) (string, error) {
|
func findConfig(startDir string) (string, error) {
|
||||||
@@ -159,3 +364,19 @@ func validCase(c Case) error {
|
|||||||
}
|
}
|
||||||
return fmt.Errorf("must be \"upper\", \"lower\", or \"preserve\"")
|
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\"")
|
||||||
|
}
|
||||||
|
|||||||
+268
-53
@@ -92,10 +92,11 @@ func formatBodyInner(inner string, st config.Style) string {
|
|||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
|
|
||||||
// Emit verbatim up to and including DECLARE (keyword-cased).
|
// Emit verbatim up to and including DECLARE (keyword-cased).
|
||||||
|
// Normalize CRLF in trivia so the output always uses st.Newline.
|
||||||
for i := 0; i <= declareIdx; i++ {
|
for i := 0; i <= declareIdx; i++ {
|
||||||
t := sig[i]
|
t := sig[i]
|
||||||
for _, tr := range t.Lead {
|
for _, tr := range t.Lead {
|
||||||
b.WriteString(tr.Text)
|
b.WriteString(strings.ReplaceAll(tr.Text, "\r\n", nl))
|
||||||
}
|
}
|
||||||
if i == declareIdx {
|
if i == declareIdx {
|
||||||
b.WriteString(applyCase(t.Tok.Text, st.KeywordCase))
|
b.WriteString(applyCase(t.Tok.Text, st.KeywordCase))
|
||||||
@@ -122,57 +123,30 @@ func formatDeclareVars(b *strings.Builder, toks []cst.Tok, st config.Style) {
|
|||||||
nl := st.Newline
|
nl := st.Newline
|
||||||
indent := st.Indent
|
indent := st.Indent
|
||||||
depth := 0
|
depth := 0
|
||||||
|
var decls [][]cst.Tok
|
||||||
var cur []cst.Tok
|
var cur []cst.Tok
|
||||||
var preComments []string
|
var preCommentSets [][]string
|
||||||
|
var curPreComments []string
|
||||||
|
|
||||||
emit := func() {
|
collect := func() {
|
||||||
if len(cur) == 0 {
|
if len(cur) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, c := range preComments {
|
decls = append(decls, cur)
|
||||||
b.WriteString(indent)
|
preCommentSets = append(preCommentSets, curPreComments)
|
||||||
b.WriteString(c)
|
|
||||||
b.WriteString(nl)
|
|
||||||
}
|
|
||||||
preComments = nil
|
|
||||||
|
|
||||||
// Graceful degradation: mid-declaration comments stay verbatim.
|
|
||||||
if anyComment(cur[1:]) {
|
|
||||||
b.WriteString(indent)
|
|
||||||
b.WriteString(verbatimSpan(cur))
|
|
||||||
b.WriteString(nl)
|
|
||||||
cur = nil
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
body := cur
|
|
||||||
hasSemi := len(body) > 0 && body[len(body)-1].Tok.Kind == lexer.Semicolon
|
|
||||||
if hasSemi {
|
|
||||||
body = body[:len(body)-1]
|
|
||||||
}
|
|
||||||
b.WriteString(indent)
|
|
||||||
for i, t := range body {
|
|
||||||
if i > 0 && needSpace(body[i-1].Tok, t.Tok) {
|
|
||||||
b.WriteByte(' ')
|
|
||||||
}
|
|
||||||
b.WriteString(caseText(t.Tok, st))
|
|
||||||
}
|
|
||||||
if hasSemi {
|
|
||||||
b.WriteString(";")
|
|
||||||
}
|
|
||||||
b.WriteString(nl)
|
|
||||||
cur = nil
|
cur = nil
|
||||||
|
curPreComments = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, t := range toks {
|
for _, t := range toks {
|
||||||
if len(cur) == 0 {
|
if len(cur) == 0 {
|
||||||
for _, tr := range t.Lead {
|
for _, tr := range t.Lead {
|
||||||
if tr.Kind == lexer.LineComment || tr.Kind == lexer.BlockComment {
|
if tr.Kind == lexer.LineComment || tr.Kind == lexer.BlockComment {
|
||||||
preComments = append(preComments, strings.TrimRight(tr.Text, " \t"))
|
text := strings.TrimRight(strings.ReplaceAll(tr.Text, "\r", ""), " \t")
|
||||||
|
curPreComments = append(curPreComments, text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
switch t.Tok.Kind {
|
switch t.Tok.Kind {
|
||||||
case lexer.LParen, lexer.LBracket:
|
case lexer.LParen, lexer.LBracket:
|
||||||
depth++
|
depth++
|
||||||
@@ -181,14 +155,195 @@ func formatDeclareVars(b *strings.Builder, toks []cst.Tok, st config.Style) {
|
|||||||
depth--
|
depth--
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cur = append(cur, t)
|
cur = append(cur, t)
|
||||||
|
|
||||||
if t.Tok.Kind == lexer.Semicolon && depth == 0 {
|
if t.Tok.Kind == lexer.Semicolon && depth == 0 {
|
||||||
emit()
|
collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
collect()
|
||||||
|
|
||||||
|
// Compute alignment widths when requested.
|
||||||
|
var nameColW, typeColW int
|
||||||
|
if st.PlpgsqlDeclareAlignType || st.PlpgsqlDeclareAlignEq {
|
||||||
|
for _, decl := range decls {
|
||||||
|
if anyComment(decl[1:]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
body := decl
|
||||||
|
if len(body) > 0 && body[len(body)-1].Tok.Kind == lexer.Semicolon {
|
||||||
|
body = body[:len(body)-1]
|
||||||
|
}
|
||||||
|
nw, tw := declareNameTypeWidth(body, st)
|
||||||
|
if nw > nameColW {
|
||||||
|
nameColW = nw
|
||||||
|
}
|
||||||
|
if tw > typeColW {
|
||||||
|
typeColW = tw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, cur := range decls {
|
||||||
|
for _, c := range preCommentSets[i] {
|
||||||
|
b.WriteString(indent)
|
||||||
|
b.WriteString(c)
|
||||||
|
b.WriteString(nl)
|
||||||
|
}
|
||||||
|
// Graceful degradation: mid-declaration comments stay verbatim.
|
||||||
|
if anyComment(cur[1:]) {
|
||||||
|
b.WriteString(indent)
|
||||||
|
b.WriteString(verbatimSpan(cur))
|
||||||
|
b.WriteString(nl)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
body := cur
|
||||||
|
hasSemi := len(body) > 0 && body[len(body)-1].Tok.Kind == lexer.Semicolon
|
||||||
|
if hasSemi {
|
||||||
|
body = body[:len(body)-1]
|
||||||
|
}
|
||||||
|
b.WriteString(indent)
|
||||||
|
if (st.PlpgsqlDeclareAlignType || st.PlpgsqlDeclareAlignEq) && nameColW > 0 {
|
||||||
|
writeDeclareAligned(b, body, st, nameColW, typeColW)
|
||||||
|
} else {
|
||||||
|
for j, t := range body {
|
||||||
|
if j > 0 && needSpace(body[j-1].Tok, t.Tok) {
|
||||||
|
b.WriteByte(' ')
|
||||||
|
}
|
||||||
|
b.WriteString(caseText(t.Tok, st))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasSemi {
|
||||||
|
b.WriteString(";")
|
||||||
|
}
|
||||||
|
b.WriteString(nl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// declareNameTypeWidth returns the rendered width of the name and type portions
|
||||||
|
// of a DECLARE variable declaration (without the default assignment).
|
||||||
|
// Format is: [name type [:= default]] or [name type [DEFAULT default]].
|
||||||
|
func declareNameTypeWidth(body []cst.Tok, st config.Style) (nameW, typeW int) {
|
||||||
|
if len(body) < 2 {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
// name is always the first token.
|
||||||
|
name := caseText(body[0].Tok, st)
|
||||||
|
nameW = len(name)
|
||||||
|
|
||||||
|
// type runs from body[1] until we hit := / DEFAULT / = at depth 0.
|
||||||
|
var typeTokens []cst.Tok
|
||||||
|
depth := 0
|
||||||
|
for _, t := range body[1:] {
|
||||||
|
switch t.Tok.Kind {
|
||||||
|
case lexer.LParen, lexer.LBracket:
|
||||||
|
depth++
|
||||||
|
case lexer.RParen, lexer.RBracket:
|
||||||
|
if depth > 0 {
|
||||||
|
depth--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if depth == 0 {
|
||||||
|
low := lowerASCII(t.Tok.Text)
|
||||||
|
if t.Tok.Kind == lexer.Operator && (t.Tok.Text == ":=" || t.Tok.Text == "=") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if t.Tok.Kind == lexer.Ident && low == "default" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
typeTokens = append(typeTokens, t)
|
||||||
|
}
|
||||||
|
var tb strings.Builder
|
||||||
|
for j, t := range typeTokens {
|
||||||
|
if j > 0 && needSpace(typeTokens[j-1].Tok, t.Tok) {
|
||||||
|
tb.WriteByte(' ')
|
||||||
|
}
|
||||||
|
tb.WriteString(caseText(t.Tok, st))
|
||||||
|
}
|
||||||
|
typeW = len(tb.String())
|
||||||
|
return nameW, typeW
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeDeclareAligned writes a single DECLARE variable with aligned columns.
|
||||||
|
func writeDeclareAligned(b *strings.Builder, body []cst.Tok, st config.Style, nameColW, typeColW int) {
|
||||||
|
if len(body) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := caseText(body[0].Tok, st)
|
||||||
|
b.WriteString(name)
|
||||||
|
if len(body) == 1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pad name to nameColW if align_type is requested.
|
||||||
|
if st.PlpgsqlDeclareAlignType {
|
||||||
|
pad := nameColW - len(name)
|
||||||
|
for k := 0; k < pad; k++ {
|
||||||
|
b.WriteByte(' ')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect type tokens.
|
||||||
|
var typeTokens, restTokens []cst.Tok
|
||||||
|
depth := 0
|
||||||
|
pastType := false
|
||||||
|
for _, t := range body[1:] {
|
||||||
|
switch t.Tok.Kind {
|
||||||
|
case lexer.LParen, lexer.LBracket:
|
||||||
|
depth++
|
||||||
|
case lexer.RParen, lexer.RBracket:
|
||||||
|
if depth > 0 {
|
||||||
|
depth--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !pastType && depth == 0 {
|
||||||
|
low := lowerASCII(t.Tok.Text)
|
||||||
|
if (t.Tok.Kind == lexer.Operator && (t.Tok.Text == ":=" || t.Tok.Text == "=")) ||
|
||||||
|
(t.Tok.Kind == lexer.Ident && low == "default") {
|
||||||
|
pastType = true
|
||||||
|
restTokens = append(restTokens, t)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pastType {
|
||||||
|
restTokens = append(restTokens, t)
|
||||||
|
} else {
|
||||||
|
typeTokens = append(typeTokens, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var typeStr strings.Builder
|
||||||
|
for j, t := range typeTokens {
|
||||||
|
if j > 0 && needSpace(typeTokens[j-1].Tok, t.Tok) {
|
||||||
|
typeStr.WriteByte(' ')
|
||||||
|
}
|
||||||
|
typeStr.WriteString(caseText(t.Tok, st))
|
||||||
|
}
|
||||||
|
typeRendered := typeStr.String()
|
||||||
|
|
||||||
|
b.WriteByte(' ')
|
||||||
|
b.WriteString(typeRendered)
|
||||||
|
|
||||||
|
if len(restTokens) > 0 {
|
||||||
|
// Pad type to typeColW if align_eq is requested.
|
||||||
|
if st.PlpgsqlDeclareAlignEq {
|
||||||
|
pad := typeColW - len(typeRendered)
|
||||||
|
for k := 0; k < pad; k++ {
|
||||||
|
b.WriteByte(' ')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for j, t := range restTokens {
|
||||||
|
prev := restTokens[0].Tok
|
||||||
|
if j > 0 {
|
||||||
|
prev = restTokens[j-1].Tok
|
||||||
|
}
|
||||||
|
if j == 0 || needSpace(prev, t.Tok) {
|
||||||
|
b.WriteByte(' ')
|
||||||
|
}
|
||||||
|
b.WriteString(caseText(t.Tok, st))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
emit()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// bline is one logical line within an accumulated statement.
|
// bline is one logical line within an accumulated statement.
|
||||||
@@ -208,12 +363,17 @@ type bline struct {
|
|||||||
// 3. After EXCEPTION the formatter switches to verbatim-indent mode (original
|
// 3. After EXCEPTION the formatter switches to verbatim-indent mode (original
|
||||||
// leading whitespace is preserved) to avoid conflicts between styles that
|
// leading whitespace is preserved) to avoid conflicts between styles that
|
||||||
// put WHEN at col-0 vs indented.
|
// put WHEN at col-0 vs indented.
|
||||||
// 4. Blank-line counts from the original are preserved.
|
// 4. Blank-line counts from the original are preserved (capped by PlpgsqlMaxBlankLines).
|
||||||
func formatBodyStatements(text string, st config.Style) string {
|
func formatBodyStatements(text string, st config.Style) string {
|
||||||
nl := st.Newline
|
nl := st.Newline
|
||||||
normalised := strings.ReplaceAll(text, "\r\n", "\n")
|
normalised := strings.ReplaceAll(text, "\r\n", "\n")
|
||||||
rawLines := strings.Split(normalised, "\n")
|
rawLines := strings.Split(normalised, "\n")
|
||||||
|
|
||||||
|
maxBlanks := st.PlpgsqlMaxBlankLines
|
||||||
|
if maxBlanks < 0 {
|
||||||
|
maxBlanks = 0
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
result strings.Builder
|
result strings.Builder
|
||||||
stmt []bline
|
stmt []bline
|
||||||
@@ -228,7 +388,11 @@ func formatBodyStatements(text string, st config.Style) string {
|
|||||||
if len(stmt) == 0 {
|
if len(stmt) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for i := 0; i < pendingBlanks; i++ {
|
blanks := pendingBlanks
|
||||||
|
if blanks > maxBlanks {
|
||||||
|
blanks = maxBlanks
|
||||||
|
}
|
||||||
|
for i := 0; i < blanks; i++ {
|
||||||
result.WriteString(nl)
|
result.WriteString(nl)
|
||||||
}
|
}
|
||||||
pendingBlanks = 0
|
pendingBlanks = 0
|
||||||
@@ -259,8 +423,6 @@ func formatBodyStatements(text string, st config.Style) string {
|
|||||||
}
|
}
|
||||||
effectiveDepth = blockDepth
|
effectiveDepth = blockDepth
|
||||||
case "else", "elsif", "elseif":
|
case "else", "elsif", "elseif":
|
||||||
// Emit at one level up; blockDepth unchanged so the body continues
|
|
||||||
// at the same depth (THEN will re-apply depthInc for elsif).
|
|
||||||
if blockDepth > 0 {
|
if blockDepth > 0 {
|
||||||
effectiveDepth = blockDepth - 1
|
effectiveDepth = blockDepth - 1
|
||||||
}
|
}
|
||||||
@@ -271,7 +433,14 @@ func formatBodyStatements(text string, st config.Style) string {
|
|||||||
|
|
||||||
baseIndent := strings.Repeat(st.Indent, effectiveDepth)
|
baseIndent := strings.Repeat(st.Indent, effectiveDepth)
|
||||||
|
|
||||||
for i, ll := range stmt {
|
// plpgsql_if_then_newline: when false, THEN stays on the same line as
|
||||||
|
// the condition. When true (default) it's already on its own logical line.
|
||||||
|
stmtLines := stmt
|
||||||
|
if !st.PlpgsqlIfThenNewline && fw == "if" {
|
||||||
|
stmtLines = joinThenToCondition(stmt)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, ll := range stmtLines {
|
||||||
if i == 0 || ll.indent == "" {
|
if i == 0 || ll.indent == "" {
|
||||||
result.WriteString(baseIndent)
|
result.WriteString(baseIndent)
|
||||||
} else {
|
} else {
|
||||||
@@ -301,8 +470,6 @@ func formatBodyStatements(text string, st config.Style) string {
|
|||||||
|
|
||||||
fw := lowerASCII(firstBodyKeyword(stripped))
|
fw := lowerASCII(firstBodyKeyword(stripped))
|
||||||
isColZero := indent == ""
|
isColZero := indent == ""
|
||||||
// Only join when we are at paren-depth 0; content inside parens (e.g.
|
|
||||||
// inside a CTE subquery) should not be merged across lines.
|
|
||||||
joinToPrev := isColZero && parenDepth == 0 && len(stmt) > 0 && !sqlClauseKw[fw]
|
joinToPrev := isColZero && parenDepth == 0 && len(stmt) > 0 && !sqlClauseKw[fw]
|
||||||
|
|
||||||
if joinToPrev {
|
if joinToPrev {
|
||||||
@@ -312,7 +479,6 @@ func formatBodyStatements(text string, st config.Style) string {
|
|||||||
stmt = append(stmt, bline{text: stripped, indent: indent})
|
stmt = append(stmt, bline{text: stripped, indent: indent})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scan tokens to track paren depth and detect flush triggers.
|
|
||||||
var lastD0Kw string
|
var lastD0Kw string
|
||||||
for _, tok := range lexer.Lex(stripped) {
|
for _, tok := range lexer.Lex(stripped) {
|
||||||
if tok.IsTrivia() || tok.Kind == lexer.EOF {
|
if tok.IsTrivia() || tok.Kind == lexer.EOF {
|
||||||
@@ -327,6 +493,13 @@ func formatBodyStatements(text string, st config.Style) string {
|
|||||||
}
|
}
|
||||||
case lexer.Semicolon:
|
case lexer.Semicolon:
|
||||||
if parenDepth == 0 {
|
if parenDepth == 0 {
|
||||||
|
// plpgsql_loop_collapse: fold empty FOR … LOOP END LOOP; to one line.
|
||||||
|
if st.PlpgsqlLoopCollapse && len(stmt) > 0 {
|
||||||
|
collapsed, ok := tryCollapseLoop(stmt, st)
|
||||||
|
if ok {
|
||||||
|
stmt = []bline{{text: collapsed, indent: ""}}
|
||||||
|
}
|
||||||
|
}
|
||||||
flush()
|
flush()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -335,13 +508,9 @@ func formatBodyStatements(text string, st config.Style) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Structural keywords at the end of a line (paren depth 0) trigger a
|
|
||||||
// flush and possibly a block-depth change.
|
|
||||||
if parenDepth == 0 && len(stmt) > 0 {
|
if parenDepth == 0 && len(stmt) > 0 {
|
||||||
switch lastD0Kw {
|
switch lastD0Kw {
|
||||||
case "then", "loop", "begin":
|
case "then", "loop", "begin":
|
||||||
// ELSIF/ELSEIF headers end with THEN but must NOT increment depth
|
|
||||||
// (blockDepth is already at the right level for the body).
|
|
||||||
fw0 := lowerASCII(firstBodyKeyword(stmt[0].text))
|
fw0 := lowerASCII(firstBodyKeyword(stmt[0].text))
|
||||||
if fw0 != "elsif" && fw0 != "elseif" {
|
if fw0 != "elsif" && fw0 != "elseif" {
|
||||||
depthInc = true
|
depthInc = true
|
||||||
@@ -357,6 +526,52 @@ func formatBodyStatements(text string, st config.Style) string {
|
|||||||
return result.String()
|
return result.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// joinThenToCondition merges a THEN line (on its own bline) into the preceding
|
||||||
|
// condition line when plpgsql_if_then_newline is false.
|
||||||
|
func joinThenToCondition(lines []bline) []bline {
|
||||||
|
out := make([]bline, 0, len(lines))
|
||||||
|
for i, ll := range lines {
|
||||||
|
if i > 0 && strings.EqualFold(strings.TrimSpace(ll.text), "then") {
|
||||||
|
out[len(out)-1].text = strings.TrimRight(out[len(out)-1].text, " \t") + " THEN"
|
||||||
|
} else {
|
||||||
|
out = append(out, ll)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// tryCollapseLoop tries to collapse an empty loop body to one line.
|
||||||
|
// Detects: FOR … LOOP\n (empty or only blanks)\nEND LOOP;
|
||||||
|
// Returns the collapsed line and true on success.
|
||||||
|
func tryCollapseLoop(lines []bline, st config.Style) (string, bool) {
|
||||||
|
if len(lines) < 2 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
first := strings.TrimSpace(lines[0].text)
|
||||||
|
last := strings.TrimSpace(lines[len(lines)-1].text)
|
||||||
|
firstLow := lowerASCII(first)
|
||||||
|
lastLow := lowerASCII(last)
|
||||||
|
|
||||||
|
// Check last line is END LOOP; or LOOP (for WHILE/FOR empty bodies that end with LOOP).
|
||||||
|
if !strings.HasPrefix(lastLow, "end loop") && lastLow != "end loop;" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
// Check middle lines are all empty.
|
||||||
|
for _, mid := range lines[1 : len(lines)-1] {
|
||||||
|
if strings.TrimSpace(mid.text) != "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check first line ends with LOOP.
|
||||||
|
if !strings.HasSuffix(firstLow, "loop") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
_ = st
|
||||||
|
_ = firstLow
|
||||||
|
// Collapse to: <header> END LOOP;
|
||||||
|
return strings.TrimRight(first, " \t") + " " + strings.ToUpper(last), true
|
||||||
|
}
|
||||||
|
|
||||||
// firstBodyKeyword returns the text of the first identifier token in s
|
// firstBodyKeyword returns the text of the first identifier token in s
|
||||||
// (lowercased), or "" if the first significant token is not an identifier.
|
// (lowercased), or "" if the first significant token is not an identifier.
|
||||||
func firstBodyKeyword(s string) string {
|
func firstBodyKeyword(s string) string {
|
||||||
|
|||||||
+264
-8
@@ -179,9 +179,16 @@ func dmlSegText(seg dmlSeg, st config.Style) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch kw {
|
switch kw {
|
||||||
case "select", "set", "returning":
|
case "select", "returning":
|
||||||
items := dmlSplitCommas(seg.body)
|
items := dmlSplitCommas(seg.body)
|
||||||
return dmlColList(kwText, items, st)
|
return dmlColListSelect(kwText, items, st)
|
||||||
|
case "set":
|
||||||
|
items := dmlSplitCommas(seg.body)
|
||||||
|
return dmlColListSet(kwText, items, st)
|
||||||
|
case "where":
|
||||||
|
return dmlWhereClause(kwText, seg.body, st)
|
||||||
|
case "join", "left", "right", "inner", "full", "cross", "natural":
|
||||||
|
return dmlJoinClause(kwText, seg.body, st)
|
||||||
case "with":
|
case "with":
|
||||||
return formatWithBody(kwText, seg.body, st)
|
return formatWithBody(kwText, seg.body, st)
|
||||||
default:
|
default:
|
||||||
@@ -193,6 +200,97 @@ func dmlSegText(seg dmlSeg, st config.Style) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dmlJoinClause formats a JOIN clause, applying indent_join when configured.
|
||||||
|
func dmlJoinClause(kwText string, body []cst.Tok, st config.Style) string {
|
||||||
|
text := dmlInline(body, st)
|
||||||
|
line := kwText
|
||||||
|
if text != "" {
|
||||||
|
line += " " + text
|
||||||
|
}
|
||||||
|
if !st.IndentJoin {
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
indent := strings.Repeat(st.Indent, st.JoinIndentSize)
|
||||||
|
nl := st.Newline
|
||||||
|
var b strings.Builder
|
||||||
|
for i, part := range strings.Split(line, nl) {
|
||||||
|
if i > 0 {
|
||||||
|
b.WriteString(nl)
|
||||||
|
}
|
||||||
|
b.WriteString(indent)
|
||||||
|
b.WriteString(part)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// dmlWhereClause formats a WHERE clause, splitting AND/OR conditions per
|
||||||
|
// the where_wrap and where_and_or_indent settings.
|
||||||
|
func dmlWhereClause(kwText string, body []cst.Tok, st config.Style) string {
|
||||||
|
if st.WhereWrap == config.WrapNever {
|
||||||
|
text := dmlInline(body, st)
|
||||||
|
if text == "" {
|
||||||
|
return kwText
|
||||||
|
}
|
||||||
|
return kwText + " " + text
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split at depth-0 AND/OR.
|
||||||
|
conditions := dmlSplitAndOr(body)
|
||||||
|
if len(conditions) <= 1 {
|
||||||
|
text := dmlInline(body, st)
|
||||||
|
if text == "" {
|
||||||
|
return kwText
|
||||||
|
}
|
||||||
|
return kwText + " " + text
|
||||||
|
}
|
||||||
|
|
||||||
|
nl := st.Newline
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(kwText)
|
||||||
|
for i, cond := range conditions {
|
||||||
|
b.WriteString(nl)
|
||||||
|
text := dmlInline(cond, st)
|
||||||
|
if st.WhereAndOrIndent {
|
||||||
|
b.WriteString(st.Indent)
|
||||||
|
}
|
||||||
|
if i == 0 {
|
||||||
|
// First condition: no leading AND/OR
|
||||||
|
b.WriteString(" ") // align with AND/OR token width
|
||||||
|
b.WriteString(text)
|
||||||
|
} else {
|
||||||
|
b.WriteString(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// dmlSplitAndOr splits toks at depth-0 AND/OR tokens, keeping the AND/OR with
|
||||||
|
// the following condition.
|
||||||
|
func dmlSplitAndOr(toks []cst.Tok) [][]cst.Tok {
|
||||||
|
var result [][]cst.Tok
|
||||||
|
depth := 0
|
||||||
|
start := 0
|
||||||
|
for i, t := range toks {
|
||||||
|
switch t.Tok.Kind {
|
||||||
|
case lexer.LParen, lexer.LBracket:
|
||||||
|
depth++
|
||||||
|
case lexer.RParen, lexer.RBracket:
|
||||||
|
if depth > 0 {
|
||||||
|
depth--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if depth == 0 && t.Tok.Kind == lexer.Ident {
|
||||||
|
low := lowerASCII(t.Tok.Text)
|
||||||
|
if (low == "and" || low == "or") && i > start {
|
||||||
|
result = append(result, toks[start:i])
|
||||||
|
start = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = append(result, toks[start:])
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
// formatWithBody formats the body of a WITH clause by splitting CTE definitions
|
// formatWithBody formats the body of a WITH clause by splitting CTE definitions
|
||||||
// at depth-0 commas and formatting the subquery inside each AS (...) block.
|
// at depth-0 commas and formatting the subquery inside each AS (...) block.
|
||||||
func formatWithBody(kwText string, body []cst.Tok, st config.Style) string {
|
func formatWithBody(kwText string, body []cst.Tok, st config.Style) string {
|
||||||
@@ -370,7 +468,18 @@ func dmlInline(toks []cst.Tok, st config.Style) string {
|
|||||||
if i > 0 && needSpace(toks[i-1].Tok, t.Tok) {
|
if i > 0 && needSpace(toks[i-1].Tok, t.Tok) {
|
||||||
b.WriteByte(' ')
|
b.WriteByte(' ')
|
||||||
}
|
}
|
||||||
b.WriteString(caseText(t.Tok, st))
|
// Space after comma in calls: func(a, b) vs func(a,b).
|
||||||
|
if st.SpaceAfterCommaInCalls && i > 0 && toks[i-1].Tok.Kind == lexer.Comma {
|
||||||
|
// Only inside parens (caller manages this at depth > 0, but we add space
|
||||||
|
// when the comma is not a clause-level comma — heuristic: always add).
|
||||||
|
b.WriteByte(' ')
|
||||||
|
}
|
||||||
|
var prev lexer.Token
|
||||||
|
if i > 0 {
|
||||||
|
prev = toks[i-1].Tok
|
||||||
|
}
|
||||||
|
nextIsLParen := i+1 < len(toks) && toks[i+1].Tok.Kind == lexer.LParen
|
||||||
|
b.WriteString(caseTextCtx(t.Tok, prev, nextIsLParen, st))
|
||||||
}
|
}
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
@@ -405,7 +514,12 @@ func dmlSplitCommas(toks []cst.Tok) [][]cst.Tok {
|
|||||||
// One item: kept on the same line as the keyword.
|
// One item: kept on the same line as the keyword.
|
||||||
// Multiple items: each on its own line with the configured comma style.
|
// Multiple items: each on its own line with the configured comma style.
|
||||||
func dmlColList(kwText string, items [][]cst.Tok, st config.Style) string {
|
func dmlColList(kwText string, items [][]cst.Tok, st config.Style) string {
|
||||||
// Filter out spurious empty items (e.g. trailing comma in source).
|
return dmlColListSelect(kwText, items, st)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dmlColListSelect formats a SELECT / RETURNING column list with optional
|
||||||
|
// align_columns and select_align_as settings.
|
||||||
|
func dmlColListSelect(kwText string, items [][]cst.Tok, st config.Style) string {
|
||||||
var kept [][]cst.Tok
|
var kept [][]cst.Tok
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
if len(item) > 0 {
|
if len(item) > 0 {
|
||||||
@@ -426,14 +540,23 @@ func dmlColList(kwText string, items [][]cst.Tok, st config.Style) string {
|
|||||||
return kwText + " " + body
|
return kwText + " " + body
|
||||||
}
|
}
|
||||||
|
|
||||||
// Multiple items: one per line.
|
// Render each item text.
|
||||||
first := st.Indent + " " // aligns item text one column past the comma
|
texts := make([]string, len(items))
|
||||||
|
for i, item := range items {
|
||||||
|
texts[i] = dmlInline(item, st)
|
||||||
|
}
|
||||||
|
|
||||||
|
// align_columns / select_align_as: pad expressions so AS and aliases align.
|
||||||
|
if (st.AlignColumns || st.SelectAlignAs) && len(texts) > 1 {
|
||||||
|
texts = alignSelectItems(texts, st)
|
||||||
|
}
|
||||||
|
|
||||||
|
first := st.Indent + " "
|
||||||
cont := st.Indent + ","
|
cont := st.Indent + ","
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString(kwText)
|
b.WriteString(kwText)
|
||||||
for i, item := range items {
|
for i, text := range texts {
|
||||||
b.WriteString(nl)
|
b.WriteString(nl)
|
||||||
text := dmlInline(item, st)
|
|
||||||
if i == 0 || st.Commas != config.CommaLeading {
|
if i == 0 || st.Commas != config.CommaLeading {
|
||||||
b.WriteString(first)
|
b.WriteString(first)
|
||||||
b.WriteString(text)
|
b.WriteString(text)
|
||||||
@@ -447,3 +570,136 @@ func dmlColList(kwText string, items [][]cst.Tok, st config.Style) string {
|
|||||||
}
|
}
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dmlColListSet formats an UPDATE SET column list with optional set_align_equal.
|
||||||
|
func dmlColListSet(kwText string, items [][]cst.Tok, st config.Style) string {
|
||||||
|
var kept [][]cst.Tok
|
||||||
|
for _, item := range items {
|
||||||
|
if len(item) > 0 {
|
||||||
|
kept = append(kept, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items = kept
|
||||||
|
|
||||||
|
nl := st.Newline
|
||||||
|
switch len(items) {
|
||||||
|
case 0:
|
||||||
|
return kwText
|
||||||
|
case 1:
|
||||||
|
body := dmlInline(items[0], st)
|
||||||
|
if body == "" {
|
||||||
|
return kwText
|
||||||
|
}
|
||||||
|
return kwText + " " + body
|
||||||
|
}
|
||||||
|
|
||||||
|
texts := make([]string, len(items))
|
||||||
|
for i, item := range items {
|
||||||
|
texts[i] = dmlInline(item, st)
|
||||||
|
}
|
||||||
|
|
||||||
|
// set_align_equal: pad lhs so = signs align.
|
||||||
|
if st.SetAlignEqual && len(texts) > 1 {
|
||||||
|
texts = alignSetItems(texts)
|
||||||
|
}
|
||||||
|
|
||||||
|
first := st.Indent + " "
|
||||||
|
cont := st.Indent + ","
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(kwText)
|
||||||
|
for i, text := range texts {
|
||||||
|
b.WriteString(nl)
|
||||||
|
if i == 0 || st.Commas != config.CommaLeading {
|
||||||
|
b.WriteString(first)
|
||||||
|
b.WriteString(text)
|
||||||
|
if st.Commas == config.CommaTrailing && i < len(items)-1 {
|
||||||
|
b.WriteString(",")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
b.WriteString(cont)
|
||||||
|
b.WriteString(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// alignSelectItems pads SELECT list item expressions so that AS keywords and
|
||||||
|
// alias names align vertically.
|
||||||
|
func alignSelectItems(texts []string, st config.Style) []string {
|
||||||
|
// Split each text into (expr, " AS ", alias) or keep as-is.
|
||||||
|
type part struct{ expr, alias string; hasAs bool }
|
||||||
|
parts := make([]part, len(texts))
|
||||||
|
maxExpr := 0
|
||||||
|
for i, t := range texts {
|
||||||
|
// Find " AS " or " as " (case-insensitive).
|
||||||
|
if idx := findAsIndex(t); idx >= 0 {
|
||||||
|
parts[i] = part{expr: t[:idx], alias: t[idx:], hasAs: true}
|
||||||
|
if l := len(t[:idx]); l > maxExpr {
|
||||||
|
maxExpr = l
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
parts[i] = part{expr: t}
|
||||||
|
if st.AlignColumns {
|
||||||
|
if l := len(t); l > maxExpr {
|
||||||
|
maxExpr = l
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]string, len(texts))
|
||||||
|
for i, p := range parts {
|
||||||
|
if !p.hasAs || maxExpr == 0 {
|
||||||
|
out[i] = texts[i]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pad := strings.Repeat(" ", maxExpr-len(p.expr))
|
||||||
|
out[i] = p.expr + pad + p.alias
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// findAsIndex returns the byte index of " AS " (case-insensitive) in s,
|
||||||
|
// or -1 if not present at depth 0.
|
||||||
|
func findAsIndex(s string) int {
|
||||||
|
low := lowerASCII(s)
|
||||||
|
// Look for " as " boundary.
|
||||||
|
for i := 0; i < len(low)-3; i++ {
|
||||||
|
if low[i] == ' ' && low[i+1] == 'a' && low[i+2] == 's' && low[i+3] == ' ' {
|
||||||
|
return i + 1 // index of 'a'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// alignSetItems pads SET assignment lhs values so that = signs align.
|
||||||
|
func alignSetItems(texts []string) []string {
|
||||||
|
maxLhs := 0
|
||||||
|
lhsWidths := make([]int, len(texts))
|
||||||
|
for i, t := range texts {
|
||||||
|
idx := strings.Index(t, " = ")
|
||||||
|
if idx < 0 {
|
||||||
|
idx = strings.Index(t, "=")
|
||||||
|
}
|
||||||
|
if idx >= 0 {
|
||||||
|
lhsWidths[i] = idx
|
||||||
|
if idx > maxLhs {
|
||||||
|
maxLhs = idx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if maxLhs == 0 {
|
||||||
|
return texts
|
||||||
|
}
|
||||||
|
out := make([]string, len(texts))
|
||||||
|
for i, t := range texts {
|
||||||
|
if lhsWidths[i] == 0 || lhsWidths[i] == maxLhs {
|
||||||
|
out[i] = t
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
idx := lhsWidths[i]
|
||||||
|
pad := strings.Repeat(" ", maxLhs-idx)
|
||||||
|
out[i] = t[:idx] + pad + t[idx:]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -226,6 +226,62 @@ func TestDMLIdempotent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDMLWhereAndOr(t *testing.T) {
|
||||||
|
// where_wrap=always should split AND/OR conditions onto separate lines.
|
||||||
|
src := "select a from t where x = 1 and y = 2 or z = 3;"
|
||||||
|
got := format(src)
|
||||||
|
want := "SELECT a\nFROM t\nWHERE\n x = 1\n AND y = 2\n OR z = 3;\n"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("where and/or\n--- got ---\n%s\n--- want ---\n%s", got, want)
|
||||||
|
}
|
||||||
|
checkDML(t, "where and/or", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDMLIndentJoin(t *testing.T) {
|
||||||
|
st := config.Default()
|
||||||
|
st.IndentJoin = true
|
||||||
|
src := "select a from t join s on t.id = s.id;"
|
||||||
|
got := File(parser.Parse(src), st)
|
||||||
|
want := "SELECT a\nFROM t\n JOIN s ON t.id = s.id;\n"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("indent join\n--- got ---\n%s\n--- want ---\n%s", got, want)
|
||||||
|
}
|
||||||
|
// Idempotence with same config.
|
||||||
|
twice := File(parser.Parse(got), st)
|
||||||
|
if twice != got {
|
||||||
|
t.Errorf("indent join not idempotent:\n--- once ---\n%s\n--- twice ---\n%s", got, twice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDMLSetAlignEqual(t *testing.T) {
|
||||||
|
st := config.Default()
|
||||||
|
st.SetAlignEqual = true
|
||||||
|
src := "update t set a = 1, bb = 2, ccc = 3 where id = 1;"
|
||||||
|
got := File(parser.Parse(src), st)
|
||||||
|
// All = signs should align.
|
||||||
|
if got == "" {
|
||||||
|
t.Error("empty output")
|
||||||
|
}
|
||||||
|
// Idempotence.
|
||||||
|
twice := File(parser.Parse(got), st)
|
||||||
|
if twice != got {
|
||||||
|
t.Errorf("set_align_equal not idempotent:\n--- once ---\n%s\n--- twice ---\n%s", got, twice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDMLAlignParamTypes(t *testing.T) {
|
||||||
|
src := "create function f(in p_name text, in p_long_name integer, out p_result boolean) returns void language sql as $$ select 1 $$;"
|
||||||
|
got := format(src)
|
||||||
|
// p_name and p_long_name should have aligned types.
|
||||||
|
if got == "" {
|
||||||
|
t.Error("empty output")
|
||||||
|
}
|
||||||
|
twice := format(got)
|
||||||
|
if twice != got {
|
||||||
|
t.Errorf("align_param_types not idempotent:\n--- once ---\n%s\n--- twice ---\n%s", got, twice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCorpusUnaffectedByDML(t *testing.T) {
|
func TestCorpusUnaffectedByDML(t *testing.T) {
|
||||||
// Verify the corpus (which contains only CREATE FUNCTION) is not affected
|
// Verify the corpus (which contains only CREATE FUNCTION) is not affected
|
||||||
// by the new DML formatting path.
|
// by the new DML formatting path.
|
||||||
|
|||||||
+206
-17
@@ -53,9 +53,12 @@ func (p *printer) writeItem(n cst.Node) {
|
|||||||
case *cst.CreateFunction:
|
case *cst.CreateFunction:
|
||||||
p.writeCreateFunction(v)
|
p.writeCreateFunction(v)
|
||||||
case *cst.Raw:
|
case *cst.Raw:
|
||||||
if isDMLStart(v.Toks) {
|
switch {
|
||||||
|
case isDMLStart(v.Toks):
|
||||||
p.b.WriteString(formatDML(v.Toks, p.st))
|
p.b.WriteString(formatDML(v.Toks, p.st))
|
||||||
} else {
|
case isDoBlock(v.Toks):
|
||||||
|
p.b.WriteString(formatDoBlock(v.Toks, p.st))
|
||||||
|
default:
|
||||||
p.b.WriteString(verbatimSpan(v.Toks))
|
p.b.WriteString(verbatimSpan(v.Toks))
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
@@ -63,11 +66,64 @@ func (p *printer) writeItem(n cst.Node) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isDoBlock reports whether toks is a DO $$ ... $$ statement.
|
||||||
|
func isDoBlock(toks []cst.Tok) bool {
|
||||||
|
for _, t := range toks {
|
||||||
|
if t.Tok.Kind == lexer.Ident {
|
||||||
|
return lowerASCII(t.Tok.Text) == "do"
|
||||||
|
}
|
||||||
|
if !t.Tok.IsTrivia() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatDoBlock formats a DO $$ ... $$ block by applying formatBody to the
|
||||||
|
// dollar-quoted string and emitting DO + newline + formatted body.
|
||||||
|
func formatDoBlock(toks []cst.Tok, st config.Style) string {
|
||||||
|
// Find the DO keyword, the dollar-string body, and the optional semicolon.
|
||||||
|
var doTok, bodyTok *cst.Tok
|
||||||
|
hasSemi := false
|
||||||
|
for i := range toks {
|
||||||
|
t := &toks[i]
|
||||||
|
if t.Tok.IsTrivia() || t.Tok.Kind == lexer.EOF {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
low := lowerASCII(t.Tok.Text)
|
||||||
|
if t.Tok.Kind == lexer.Ident && low == "do" && doTok == nil {
|
||||||
|
doTok = t
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if doTok != nil && t.Tok.Kind == lexer.DollarString && bodyTok == nil {
|
||||||
|
bodyTok = t
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if t.Tok.Kind == lexer.Semicolon {
|
||||||
|
hasSemi = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if doTok == nil || bodyTok == nil {
|
||||||
|
return verbatimSpan(toks)
|
||||||
|
}
|
||||||
|
|
||||||
|
nl := st.Newline
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(applyCase(doTok.Tok.Text, st.KeywordCase))
|
||||||
|
b.WriteString(nl)
|
||||||
|
b.WriteString(formatBody(bodyTok.Tok.Text, st))
|
||||||
|
if hasSemi {
|
||||||
|
b.WriteString(";")
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
func (p *printer) writeCreateFunction(cf *cst.CreateFunction) {
|
func (p *printer) writeCreateFunction(cf *cst.CreateFunction) {
|
||||||
// Safety: if the header carries comments we cannot confidently relocate,
|
// Safety: if the header carries comments we cannot confidently relocate,
|
||||||
// emit the whole statement verbatim rather than risk dropping them.
|
// emit the whole statement verbatim rather than risk dropping them.
|
||||||
|
// We still format the body dollar-string independently since it is self-contained.
|
||||||
if headerHasComments(cf) {
|
if headerHasComments(cf) {
|
||||||
p.b.WriteString(verbatimSpan(cst.Tokens(cf)))
|
p.b.WriteString(verbatimSpanFormatBody(cst.Tokens(cf), cf.Body, p.st))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,11 +136,22 @@ func (p *printer) writeCreateFunction(cf *cst.CreateFunction) {
|
|||||||
}
|
}
|
||||||
p.b.WriteString("(")
|
p.b.WriteString("(")
|
||||||
|
|
||||||
|
// Build formatted param texts first so we can measure widths.
|
||||||
|
paramTexts := make([]string, len(cf.Params))
|
||||||
|
for i, param := range cf.Params {
|
||||||
|
paramTexts[i] = p.inline(param.Toks)
|
||||||
|
}
|
||||||
|
|
||||||
|
// align_param_types: pad param names so type columns align.
|
||||||
|
if p.st.AlignParamTypes && len(cf.Params) > 1 {
|
||||||
|
paramTexts = alignParamTypes(paramTexts)
|
||||||
|
}
|
||||||
|
|
||||||
first := p.st.Indent + " " // align item text one column past the comma
|
first := p.st.Indent + " " // align item text one column past the comma
|
||||||
cont := p.st.Indent
|
cont := p.st.Indent
|
||||||
for i, param := range cf.Params {
|
for i, text := range paramTexts {
|
||||||
|
param := cf.Params[i]
|
||||||
p.nl()
|
p.nl()
|
||||||
text := p.inline(param.Toks)
|
|
||||||
if i == 0 || p.st.Commas != config.CommaLeading {
|
if i == 0 || p.st.Commas != config.CommaLeading {
|
||||||
p.b.WriteString(first)
|
p.b.WriteString(first)
|
||||||
p.b.WriteString(text)
|
p.b.WriteString(text)
|
||||||
@@ -96,6 +163,16 @@ func (p *printer) writeCreateFunction(cf *cst.CreateFunction) {
|
|||||||
p.b.WriteString(",")
|
p.b.WriteString(",")
|
||||||
p.b.WriteString(text)
|
p.b.WriteString(text)
|
||||||
}
|
}
|
||||||
|
// Emit trailing inline comment from the separator (e.g. --description after param).
|
||||||
|
if param.Sep != nil {
|
||||||
|
for _, tr := range param.Sep.Lead {
|
||||||
|
if tr.Kind == lexer.LineComment || tr.Kind == lexer.BlockComment {
|
||||||
|
p.b.WriteByte(' ')
|
||||||
|
p.b.WriteString(strings.TrimRight(tr.Text, " \t"))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
p.nl()
|
p.nl()
|
||||||
p.b.WriteString(")")
|
p.b.WriteString(")")
|
||||||
@@ -105,7 +182,12 @@ func (p *printer) writeCreateFunction(cf *cst.CreateFunction) {
|
|||||||
p.b.WriteString(p.inline(clause))
|
p.b.WriteString(p.inline(clause))
|
||||||
}
|
}
|
||||||
if cf.As != nil {
|
if cf.As != nil {
|
||||||
p.nl()
|
// routine_as_wrap: when false, AS stays on the same line as the last option.
|
||||||
|
if p.st.RoutineAsWrap {
|
||||||
|
p.nl()
|
||||||
|
} else {
|
||||||
|
p.b.WriteByte(' ')
|
||||||
|
}
|
||||||
p.b.WriteString(p.inline([]cst.Tok{{Tok: cf.As.Tok}}))
|
p.b.WriteString(p.inline([]cst.Tok{{Tok: cf.As.Tok}}))
|
||||||
}
|
}
|
||||||
if cf.Body != nil {
|
if cf.Body != nil {
|
||||||
@@ -139,7 +221,12 @@ func (p *printer) inline(toks []cst.Tok) string {
|
|||||||
if i > 0 && needSpace(toks[i-1].Tok, t.Tok) {
|
if i > 0 && needSpace(toks[i-1].Tok, t.Tok) {
|
||||||
b.WriteByte(' ')
|
b.WriteByte(' ')
|
||||||
}
|
}
|
||||||
b.WriteString(caseText(t.Tok, p.st))
|
var prev lexer.Token
|
||||||
|
if i > 0 {
|
||||||
|
prev = toks[i-1].Tok
|
||||||
|
}
|
||||||
|
nextIsLParen := i+1 < len(toks) && toks[i+1].Tok.Kind == lexer.LParen
|
||||||
|
b.WriteString(caseTextCtx(t.Tok, prev, nextIsLParen, p.st))
|
||||||
}
|
}
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
@@ -147,7 +234,8 @@ func (p *printer) inline(toks []cst.Tok) string {
|
|||||||
func (p *printer) leadingComments(lead cst.Trivia) {
|
func (p *printer) leadingComments(lead cst.Trivia) {
|
||||||
for _, tr := range lead {
|
for _, tr := range lead {
|
||||||
if tr.Kind == lexer.LineComment || tr.Kind == lexer.BlockComment {
|
if tr.Kind == lexer.LineComment || tr.Kind == lexer.BlockComment {
|
||||||
p.b.WriteString(strings.TrimRight(tr.Text, " \t"))
|
text := strings.TrimRight(strings.ReplaceAll(tr.Text, "\r", ""), " \t")
|
||||||
|
p.b.WriteString(text)
|
||||||
p.nl()
|
p.nl()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,7 +245,7 @@ func (p *printer) trailingComments(lead cst.Trivia) {
|
|||||||
cs := commentsOf(lead)
|
cs := commentsOf(lead)
|
||||||
for _, c := range cs {
|
for _, c := range cs {
|
||||||
p.nl()
|
p.nl()
|
||||||
p.b.WriteString(strings.TrimRight(c.Text, " \t"))
|
p.b.WriteString(strings.TrimRight(strings.ReplaceAll(c.Text, "\r", ""), " \t"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,8 +299,14 @@ func needSpace(a, b lexer.Token) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func caseText(t lexer.Token, st config.Style) string {
|
func caseText(t lexer.Token, st config.Style) string {
|
||||||
|
return caseTextCtx(t, lexer.Token{}, false, st)
|
||||||
|
}
|
||||||
|
|
||||||
|
// caseTextCtx applies casing with context: prev is the preceding significant
|
||||||
|
// token, nextIsLParen indicates the next significant token is '('.
|
||||||
|
func caseTextCtx(t lexer.Token, prev lexer.Token, nextIsLParen bool, st config.Style) string {
|
||||||
if t.Kind != lexer.Ident {
|
if t.Kind != lexer.Ident {
|
||||||
return t.Text // only unquoted words are re-cased
|
return t.Text
|
||||||
}
|
}
|
||||||
low := lowerASCII(t.Text)
|
low := lowerASCII(t.Text)
|
||||||
switch {
|
switch {
|
||||||
@@ -220,6 +314,10 @@ func caseText(t lexer.Token, st config.Style) string {
|
|||||||
return applyCase(t.Text, st.TypeCase)
|
return applyCase(t.Text, st.TypeCase)
|
||||||
case isKeyword(low):
|
case isKeyword(low):
|
||||||
return applyCase(t.Text, st.KeywordCase)
|
return applyCase(t.Text, st.KeywordCase)
|
||||||
|
case nextIsLParen && isBuiltinFunc(low):
|
||||||
|
return applyCase(t.Text, st.BuiltinCase)
|
||||||
|
case prev.Kind == lexer.Ident && lowerASCII(prev.Text) == "as":
|
||||||
|
return applyCase(t.Text, st.AliasCase)
|
||||||
default:
|
default:
|
||||||
return applyCase(t.Text, st.IdentCase)
|
return applyCase(t.Text, st.IdentCase)
|
||||||
}
|
}
|
||||||
@@ -239,18 +337,25 @@ func applyCase(s string, c config.Case) string {
|
|||||||
// --- helpers ---
|
// --- helpers ---
|
||||||
|
|
||||||
// headerHasComments reports whether the function header carries comment trivia
|
// headerHasComments reports whether the function header carries comment trivia
|
||||||
// the formatter cannot confidently relocate. The first token's leading trivia
|
// the formatter cannot confidently relocate. Param separator (Sep) comments
|
||||||
// is excluded: that is the statement's leading comment, which File() emits
|
// are excluded — those are trailing inline comments on param lines that the
|
||||||
// separately. The body token's own text is excluded too (it is emitted
|
// formatter emits explicitly after each param text. The first token's leading
|
||||||
// verbatim), but a comment in front of the body is caught.
|
// trivia and the body token are also excluded.
|
||||||
func headerHasComments(cf *cst.CreateFunction) bool {
|
func headerHasComments(cf *cst.CreateFunction) bool {
|
||||||
|
// Build a set of Sep token offsets so we can skip them.
|
||||||
|
sepOffsets := make(map[int]bool, len(cf.Params))
|
||||||
|
for _, p := range cf.Params {
|
||||||
|
if p.Sep != nil {
|
||||||
|
sepOffsets[p.Sep.Tok.Off] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
all := cst.Tokens(cf)
|
all := cst.Tokens(cf)
|
||||||
for i, t := range all {
|
for i, t := range all {
|
||||||
if i == 0 {
|
if i == 0 || t.Tok.Kind == lexer.Semicolon {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if t.Tok.Kind == lexer.Semicolon {
|
if sepOffsets[t.Tok.Off] {
|
||||||
continue
|
continue // Sep comments handled separately
|
||||||
}
|
}
|
||||||
if hasComment(t) {
|
if hasComment(t) {
|
||||||
return true
|
return true
|
||||||
@@ -302,6 +407,27 @@ func verbatimSpan(toks []cst.Tok) string {
|
|||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// verbatimSpanFormatBody emits toks verbatim but replaces bodyTok's text with
|
||||||
|
// formatBody output. Used when the function header has comments we cannot
|
||||||
|
// safely relocate but the body can still be independently formatted.
|
||||||
|
// If bodyTok is nil the function is identical to verbatimSpan.
|
||||||
|
func verbatimSpanFormatBody(toks []cst.Tok, bodyTok *cst.Tok, st config.Style) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for i, t := range toks {
|
||||||
|
if i > 0 {
|
||||||
|
for _, tr := range t.Lead {
|
||||||
|
b.WriteString(tr.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if bodyTok != nil && t.Tok.Kind == lexer.DollarString && t.Tok.Off == bodyTok.Tok.Off {
|
||||||
|
b.WriteString(formatBody(t.Tok.Text, st))
|
||||||
|
} else {
|
||||||
|
b.WriteString(t.Tok.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
// hasBlankLine reports whether leading whitespace trivia contains a blank line
|
// hasBlankLine reports whether leading whitespace trivia contains a blank line
|
||||||
// (two or more newlines), indicating the author wanted statements separated.
|
// (two or more newlines), indicating the author wanted statements separated.
|
||||||
func hasBlankLine(lead cst.Trivia) bool {
|
func hasBlankLine(lead cst.Trivia) bool {
|
||||||
@@ -331,3 +457,66 @@ func lowerASCII(s string) string {
|
|||||||
}
|
}
|
||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// alignParamTypes pads param names so the type column aligns across all params.
|
||||||
|
// Expected format per param: "[mode] name type [DEFAULT expr]".
|
||||||
|
// Mode keywords (IN/OUT/INOUT/VARIADIC) are detected and skipped.
|
||||||
|
// Params without a type are passed through unchanged.
|
||||||
|
func alignParamTypes(params []string) []string {
|
||||||
|
type pp struct{ mode, name, rest string }
|
||||||
|
parsed := make([]pp, len(params))
|
||||||
|
maxNameW := 0
|
||||||
|
|
||||||
|
modeKws := map[string]bool{"in": true, "out": true, "inout": true, "variadic": true}
|
||||||
|
|
||||||
|
for i, s := range params {
|
||||||
|
fields := strings.Fields(s)
|
||||||
|
if len(fields) < 2 {
|
||||||
|
parsed[i].rest = s
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nameIdx := 0
|
||||||
|
if modeKws[lowerASCII(fields[0])] {
|
||||||
|
nameIdx = 1
|
||||||
|
}
|
||||||
|
if nameIdx >= len(fields) || nameIdx+1 >= len(fields) {
|
||||||
|
// No type field — keep verbatim.
|
||||||
|
parsed[i].rest = s
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if nameIdx > 0 {
|
||||||
|
parsed[i].mode = fields[0]
|
||||||
|
}
|
||||||
|
parsed[i].name = fields[nameIdx]
|
||||||
|
parsed[i].rest = strings.Join(fields[nameIdx+1:], " ")
|
||||||
|
if len(parsed[i].name) > maxNameW {
|
||||||
|
maxNameW = len(parsed[i].name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if maxNameW == 0 {
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]string, len(params))
|
||||||
|
for i, p := range parsed {
|
||||||
|
if p.name == "" {
|
||||||
|
out[i] = params[i]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
if p.mode != "" {
|
||||||
|
b.WriteString(p.mode)
|
||||||
|
b.WriteByte(' ')
|
||||||
|
}
|
||||||
|
b.WriteString(p.name)
|
||||||
|
// Pad name to (maxNameW+1) so the type column starts at a consistent offset.
|
||||||
|
pad := maxNameW + 1 - len(p.name)
|
||||||
|
for k := 0; k < pad; k++ {
|
||||||
|
b.WriteByte(' ')
|
||||||
|
}
|
||||||
|
b.WriteString(p.rest)
|
||||||
|
out[i] = b.String()
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ func TestFormatHeaderGolden(t *testing.T) {
|
|||||||
|
|
||||||
want := "--select * from dropall('resolvespec_login');\n" +
|
want := "--select * from dropall('resolvespec_login');\n" +
|
||||||
"CREATE OR REPLACE FUNCTION resolvespec_login(\n" +
|
"CREATE OR REPLACE FUNCTION resolvespec_login(\n" +
|
||||||
" INOUT p_data jsonb\n" +
|
" INOUT p_data jsonb\n" +
|
||||||
" ,OUT p_success boolean\n" +
|
" ,OUT p_success boolean\n" +
|
||||||
" ,OUT p_error text\n" +
|
" ,OUT p_error text\n" +
|
||||||
")\n" +
|
")\n" +
|
||||||
"LANGUAGE plpgsql\n" +
|
"LANGUAGE plpgsql\n" +
|
||||||
"VOLATILE\n" +
|
"VOLATILE\n" +
|
||||||
@@ -71,6 +71,42 @@ func TestFormatBodyBroken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFormatMmProcBroken(t *testing.T) {
|
||||||
|
dir := filepath.Join("..", "..", "testdata", "corpus")
|
||||||
|
brokenData, err := os.ReadFile(filepath.Join(dir, "test_mm_proc_broken.pgsql"))
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("no test_mm_proc_broken.pgsql: %v", err)
|
||||||
|
}
|
||||||
|
goldenData, err := os.ReadFile(filepath.Join(dir, "test_mm_proc.pgsql"))
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("no test_mm_proc.pgsql: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := format(string(brokenData))
|
||||||
|
want := string(goldenData)
|
||||||
|
if got != want {
|
||||||
|
// Find and report the first differing line.
|
||||||
|
gotLines := strings.Split(got, "\n")
|
||||||
|
wantLines := strings.Split(want, "\n")
|
||||||
|
for i := 0; i < len(gotLines) && i < len(wantLines); i++ {
|
||||||
|
if gotLines[i] != wantLines[i] {
|
||||||
|
t.Errorf("format(test_mm_proc_broken) != test_mm_proc.pgsql at line %d\n got: %q\n want: %q", i+1, gotLines[i], wantLines[i])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(gotLines) != len(wantLines) {
|
||||||
|
t.Errorf("format(test_mm_proc_broken): got %d lines, want %d lines", len(gotLines), len(wantLines))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
twice := format(got)
|
||||||
|
if twice != got {
|
||||||
|
t.Errorf("format(test_mm_proc_broken) is not idempotent")
|
||||||
|
}
|
||||||
|
if !semanticallyEqual(string(brokenData), got) {
|
||||||
|
t.Errorf("format(test_mm_proc_broken) changed semantics")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCorpusIdempotentAndSafe(t *testing.T) {
|
func TestCorpusIdempotentAndSafe(t *testing.T) {
|
||||||
dir := filepath.Join("..", "..", "testdata", "corpus")
|
dir := filepath.Join("..", "..", "testdata", "corpus")
|
||||||
entries, err := os.ReadDir(dir)
|
entries, err := os.ReadDir(dir)
|
||||||
@@ -79,7 +115,7 @@ func TestCorpusIdempotentAndSafe(t *testing.T) {
|
|||||||
}
|
}
|
||||||
var seen int
|
var seen int
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".pgsql") {
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".pgsql") || strings.HasSuffix(e.Name(), "_broken.pgsql") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen++
|
seen++
|
||||||
|
|||||||
+20
-2
@@ -51,5 +51,23 @@ func words(s string) map[string]bool {
|
|||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
func isKeyword(lower string) bool { return keywords[lower] }
|
// builtinFunctions are built-in function names controlled by BuiltinCase.
|
||||||
func isTypeName(lower string) bool { return typeNames[lower] }
|
var builtinFunctions = words(`
|
||||||
|
abs age array_agg array_length array_lower array_ndims array_upper
|
||||||
|
bit_length btrim cardinality ceil ceiling char_length character_length
|
||||||
|
chr clock_timestamp coalesce concat concat_ws count
|
||||||
|
currval decode div encode exp extract floor
|
||||||
|
generate_series greatest initcap jsonb_agg jsonb_object_agg
|
||||||
|
justify_days justify_hours justify_interval
|
||||||
|
lastval least length lower lpad ltrim
|
||||||
|
max md5 min mod now nullif
|
||||||
|
overlay pg_sleep position power quote_ident quote_literal
|
||||||
|
random regexp_match regexp_matches regexp_replace replace reverse round rpad rtrim
|
||||||
|
setval split_part sqrt string_agg strpos substr substring sum
|
||||||
|
to_char to_date to_json to_jsonb to_number to_timestamp to_tsvector
|
||||||
|
translate trim trunc unnest upper width_bucket
|
||||||
|
`)
|
||||||
|
|
||||||
|
func isKeyword(lower string) bool { return keywords[lower] }
|
||||||
|
func isTypeName(lower string) bool { return typeNames[lower] }
|
||||||
|
func isBuiltinFunc(lower string) bool { return builtinFunctions[lower] }
|
||||||
|
|||||||
Vendored
+2
-4
@@ -1,8 +1,8 @@
|
|||||||
--select * from dropall('resolvespec_login');
|
--select * from dropall('resolvespec_login');
|
||||||
CREATE OR REPLACE FUNCTION resolvespec_login(
|
CREATE OR REPLACE FUNCTION resolvespec_login(
|
||||||
INOUT p_data jsonb
|
INOUT p_data jsonb
|
||||||
,OUT p_success boolean
|
,OUT p_success boolean
|
||||||
,OUT p_error text
|
,OUT p_error text
|
||||||
)
|
)
|
||||||
LANGUAGE plpgsql
|
LANGUAGE plpgsql
|
||||||
VOLATILE
|
VOLATILE
|
||||||
@@ -134,8 +134,6 @@ BEGIN
|
|||||||
where u.rid_hub = m_rid_hub;
|
where u.rid_hub = m_rid_hub;
|
||||||
end if;
|
end if;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
select jsonb_build_object('token', m_session ->>'token'
|
select jsonb_build_object('token', m_session ->>'token'
|
||||||
, 'session', m_session ->>'session'
|
, 'session', m_session ->>'session'
|
||||||
, 'user', _jsonb_object_cat(jsonb_build_object(
|
, 'user', _jsonb_object_cat(jsonb_build_object(
|
||||||
|
|||||||
Vendored
+158
@@ -0,0 +1,158 @@
|
|||||||
|
DO
|
||||||
|
$$
|
||||||
|
DECLARE
|
||||||
|
m_programtype citext;
|
||||||
|
m_ins integer;
|
||||||
|
m_upd integer;
|
||||||
|
m_retval bigint;
|
||||||
|
BEGIN
|
||||||
|
|
||||||
|
select s.setvalue
|
||||||
|
from core.setting s
|
||||||
|
where s.setname = 'programtype'
|
||||||
|
and nv(s.disableflag) = 0
|
||||||
|
into m_programtype;
|
||||||
|
|
||||||
|
--select newid()
|
||||||
|
insert into core.mastertype(category, mastertype, description, inactive, forprefix,guid)
|
||||||
|
select *
|
||||||
|
from (
|
||||||
|
values
|
||||||
|
('module','system','Required System Module',0,'','2D4EFDEA-4E7D-4998-8E2A-1F2B67770983')
|
||||||
|
) r(category, mastertype, description, inactive, forprefix,guid)
|
||||||
|
where not exists (
|
||||||
|
select 1 from core.mastertype mt
|
||||||
|
where mt.mastertype = r.mastertype
|
||||||
|
and mt.category = r.category
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
update core.mastertype u
|
||||||
|
set jsonvalue = _jsonb_object_cat(u.jsonvalue,jsonb_build_object('subtypes',jsonb_build_array('mergetype','mergetargettype')))
|
||||||
|
where u.category = 'docgentype'
|
||||||
|
and u.mastertype = 'merge'
|
||||||
|
;
|
||||||
|
|
||||||
|
----Merge Target Type
|
||||||
|
with src(category, mastertype, description, inactive, forprefix,guid,rid_parent) as (
|
||||||
|
select *
|
||||||
|
, (
|
||||||
|
select mt.rid_mastertype from core.mastertype mt where mt.guid = '5004E9EC-2E4B-4B36-814C-CFD96EFB434B' limit 1
|
||||||
|
) --docvault
|
||||||
|
from (
|
||||||
|
values ('mergetargettype', 'html', 'HTML', 0, '', '479C8772-52C5-418F-B4FD-FA5F511831E8')
|
||||||
|
, ('mergetargettype', 'docx', 'Word Document', 0, '', '4899A6E6-467F-41BE-95EE-C3A3938C57C1')
|
||||||
|
, ('mergetargettype', 'xlsx', 'Excel Document', 0, '', 'AB40EC69-9CEE-4834-A1AC-B317CC47D679')
|
||||||
|
, ('mergetargettype', 'csv', 'Comma Separated Values (CSV)', 0, '', 'D8EE416A-6A40-4F09-80A7-2576278512AA')
|
||||||
|
, ('mergetargettype', 'pdf', 'PDF', 0, '', '3776F161-1BF8-474C-8C1A-097D851D6BF9')
|
||||||
|
, ('mergetargettype', 'same', 'Same as Source', 0, '', 'C7797F0B-5702-4B54-B102-0B610D62730C')
|
||||||
|
) r(category, mastertype, description, inactive, forprefix, guid)
|
||||||
|
), ins as (
|
||||||
|
insert into core.mastertype (category, mastertype, description, inactive, forprefix, guid, rid_parent)
|
||||||
|
select category, mastertype, description, inactive, forprefix, guid, rid_parent
|
||||||
|
from src
|
||||||
|
where
|
||||||
|
not exists (
|
||||||
|
select 1
|
||||||
|
from core.mastertype mt2
|
||||||
|
where
|
||||||
|
mt2.category = src.category
|
||||||
|
and mt2.mastertype = src.mastertype
|
||||||
|
)
|
||||||
|
returning mastertype.*
|
||||||
|
), upd as (
|
||||||
|
update core.mastertype u
|
||||||
|
set description = s.description
|
||||||
|
, inactive = s.inactive
|
||||||
|
, forprefix = s.forprefix
|
||||||
|
, guid = s.guid
|
||||||
|
, rid_parent = s.rid_parent
|
||||||
|
from src s
|
||||||
|
where u.category = s.category
|
||||||
|
and u.mastertype = s.mastertype
|
||||||
|
and (
|
||||||
|
u.description is distinct from s.description
|
||||||
|
or u.inactive is distinct from s.inactive
|
||||||
|
or u.forprefix is distinct from s.forprefix
|
||||||
|
or u.guid is distinct from s.guid
|
||||||
|
or u.rid_parent is distinct from s.rid_parent
|
||||||
|
)
|
||||||
|
returning u.*
|
||||||
|
)
|
||||||
|
select (select count(1) from ins )
|
||||||
|
,(select count(1) from upd )
|
||||||
|
into m_ins, m_upd
|
||||||
|
;
|
||||||
|
|
||||||
|
----Merge Type
|
||||||
|
insert into core.mastertype(category, mastertype, description, inactive, forprefix,guid,rid_parent)
|
||||||
|
select *
|
||||||
|
,(select mt.rid_mastertype from core.mastertype mt where mt.guid = '5004E9EC-2E4B-4B36-814C-CFD96EFB434B' limit 1) --docvault
|
||||||
|
from (
|
||||||
|
values
|
||||||
|
('mergetype','html','HTML Template',0,'','DC3588BC-1865-400D-BE46-8FABE6F9A040')
|
||||||
|
,('mergetype','docx','Word Document Template',0,'','8535952E-3D9D-4DD4-9BD0-086A8E5A4804')
|
||||||
|
,('mergetype','xlsx','Excel Document Template',0,'','6678B42F-C06F-4CB6-9003-AF22E80A1295')
|
||||||
|
,('mergetype','csv','Comma Separated Values (CSV)',0,'','F03F0AC0-D710-4F9D-BB5C-0EAA97245E2A')
|
||||||
|
,('mergetype','pdf','PDF Fill in Template',1,'','1197CD7A-D2F6-4B01-8B83-E42315E0060C')
|
||||||
|
,('mergetype','stimulsoft','Stimulsoft Template',0,'','491ECBF2-6AD2-452C-8543-B15C65CB7431')
|
||||||
|
,('mergetype','sql','SQL Query Template',0,'','74A47C47-2759-4FAB-A2A1-0A42FA87FA46')
|
||||||
|
,('mergetype','none','None / No Merge',0,'','640B5044-DDCE-4806-A64F-098BE1324693')
|
||||||
|
) r(category, mastertype, description, inactive, forprefix,guid)
|
||||||
|
where not exists (
|
||||||
|
select 1 from core.mastertype mt2
|
||||||
|
where mt2.category = r.category
|
||||||
|
and mt2.mastertype = r.mastertype
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
-----eventtype --select newid()
|
||||||
|
with src as (
|
||||||
|
select r.*
|
||||||
|
, mt.rid_mastertype as rid_parent
|
||||||
|
from (
|
||||||
|
values ('eventreactioncodetype', 'sql', 'SQL', 0, '', '24AE5A98-024F-4A3D-8BED-9F4A2C410BF9')
|
||||||
|
, ('eventreactioncodetype', 'api', 'API', 1, '', '163A3EC1-6C2A-4462-A15F-D24F953F9CDA')
|
||||||
|
, ('eventreactioncodetype', 'frontend', 'Frontend', 0, '', '50023BB9-A58A-4AE8-AAC9-3126D7122D18')
|
||||||
|
, ('eventreactioncodetype', 'json', 'JSON', 0, '', 'FA331BC2-DCB2-44E0-B835-C62C77A547E3')
|
||||||
|
|
||||||
|
) r(category, mastertype, description, inactive, forprefix, guid)
|
||||||
|
cross join (
|
||||||
|
select mt.rid_mastertype
|
||||||
|
from core.mastertype mt
|
||||||
|
where mt.guid = '2D4EFDEA-4E7D-4998-8E2A-1F2B67770983'
|
||||||
|
limit 1
|
||||||
|
) mt
|
||||||
|
), upd as (
|
||||||
|
update core.mastertype u
|
||||||
|
set mastertype = src.mastertype
|
||||||
|
,description = src.description
|
||||||
|
,inactive = src.inactive
|
||||||
|
,forprefix = src.forprefix
|
||||||
|
from src
|
||||||
|
where src.guid = u.guid
|
||||||
|
returning *
|
||||||
|
), ins as (
|
||||||
|
insert into core.mastertype (category, mastertype, description, inactive, forprefix, guid, rid_parent)
|
||||||
|
select src.category, src.mastertype, src.description, src.inactive, src.forprefix, src.guid, src.rid_parent
|
||||||
|
from src
|
||||||
|
where
|
||||||
|
not exists (
|
||||||
|
select 1
|
||||||
|
from core.mastertype mt2
|
||||||
|
where
|
||||||
|
mt2.category = src.category
|
||||||
|
and mt2.mastertype = src.mastertype
|
||||||
|
)
|
||||||
|
returning *
|
||||||
|
)
|
||||||
|
select (select count(1) from ins) + (select count(1) from upd )
|
||||||
|
into m_retval;
|
||||||
|
|
||||||
|
|
||||||
|
END;
|
||||||
|
|
||||||
|
|
||||||
|
$$;
|
||||||
|
|
||||||
|
|
||||||
Vendored
+1512
File diff suppressed because it is too large
Load Diff
+1920
File diff suppressed because it is too large
Load Diff
+8
@@ -0,0 +1,8 @@
|
|||||||
|
select u.name
|
||||||
|
,u.surname
|
||||||
|
,u.login
|
||||||
|
from public.user u
|
||||||
|
where u.id = 1
|
||||||
|
and u.name = 'test'
|
||||||
|
or u.name = 'joe'
|
||||||
|
;
|
||||||
Reference in New Issue
Block a user