14 Commits
Author SHA1 Message Date
Hein 4abc89700f chore(release): bump version to 0.0.6
Release / AUR package (push) Successful in 53s
Release / DataGrip Plugin (push) Successful in 4m17s
Release / Release (push) Successful in 1m57s
Release / VSCode Extension (push) Successful in 56s
Release / Test (push) Successful in 1m50s
CI / Test (push) Successful in 1m51s
CI / Build (push) Successful in 21s
Release / RPM package (push) Successful in 39s
Release / Debian packages (push) Successful in 47s
Release / Windows installer (push) Successful in 47s
2026-07-17 13:21:20 +02:00
Hein ad52f21cc2 feat(datagrip): migrate from LSP4IJ to native CLI integration
CI / Test (push) Successful in 26s
CI / Build (push) Successful in 22s
2026-07-17 13:20:49 +02:00
Hein 60602d1de7 chore(release): bump version to 0.0.5
CI / Test (push) Successful in 25s
CI / Build (push) Successful in 20s
Release / Test (push) Failing after 1m51s
Release / Release (push) Has been skipped
Release / Debian packages (push) Has been skipped
Release / RPM package (push) Has been skipped
Release / Windows installer (push) Has been skipped
Release / AUR package (push) Has been skipped
Release / VSCode Extension (push) Has been skipped
Release / DataGrip Plugin (push) Has been skipped
2026-07-17 09:40:51 +02:00
warkanum 8d19258aa0 Merge pull request 'Fix PL/pgSQL formatting indentation' (#2) from issue-1-formatting-indenting into main
CI / Test (push) Successful in 26s
CI / Build (push) Successful in 29s
Reviewed-on: #2
2026-07-16 19:38:55 +00:00
Hein 5cba6beeb1 fix: align PLpgSQL formatting clauses
CI / Build (pull_request) Successful in 1m2s
CI / Test (pull_request) Successful in 43s
2026-07-15 21:11:58 +02:00
Hein a0838e4bdc chore(release): bump version to 0.0.4
Release / Test (push) Successful in 34s
CI / Test (push) Successful in 38s
Release / Windows installer (push) Successful in 58s
CI / Build (push) Successful in 27s
Release / Release (push) Successful in 37s
Release / VSCode Extension (push) Successful in 43s
Release / Debian packages (push) Successful in 59s
Release / RPM package (push) Successful in 1m3s
Release / AUR package (push) Successful in 1m32s
Release / DataGrip Plugin (push) Successful in 3m17s
2026-07-02 14:42:16 +02:00
Hein 44fb77efd6 fix(body): handle CASE depth in body statement formatting
CI / Test (push) Successful in 28s
CI / Build (push) Successful in 25s
2026-07-02 14:40:54 +02:00
Hein 4fab2fe652 feat(ui): add version display action and status bar widget
CI / Test (push) Successful in 29s
CI / Build (push) Successful in 25s
* Implement PgTidyShowVersionAction to show pgtidy version
* Create PgTidyStatusBarWidget for real-time version display
* Update plugin.xml to register new action and widget
2026-07-02 12:58:28 +02:00
Hein 64aaae30f4 chore(release): bump version to 0.0.3
Release / Test (push) Successful in 28s
CI / Test (push) Successful in 35s
CI / Build (push) Successful in 27s
Release / Release (push) Successful in 40s
Release / Debian packages (push) Successful in 1m10s
Release / Windows installer (push) Successful in 1m6s
Release / RPM package (push) Successful in 1m8s
Release / VSCode Extension (push) Successful in 1m2s
Release / AUR package (push) Successful in 1m21s
Release / DataGrip Plugin (push) Successful in 3m16s
2026-07-01 18:04:40 +02:00
Hein c277171963 feat(pkg-nsis): add Windows installer workflow
CI / Test (push) Successful in 28s
CI / Build (push) Successful in 25s
2026-07-01 18:01:25 +02:00
Hein 04711cf7b2 fix(cmd): handle errors and improve output formatting
CI / Test (push) Successful in 28s
CI / Build (push) Successful in 25s
* update error handling in various commands to use blank identifier
* enhance output formatting for better readability
* add golangci-lint to Makefile for linting checks
2026-07-01 12:53:25 +02:00
warkanum f17e87e749 fix(body): prevent joining continuation lines to comments
CI / Test (push) Failing after 24s
CI / Build (push) Has been skipped
* Ensure comment-only lines are flushed before adding new statements
* Adjust logic to handle col-0 continuation lines correctly
2026-06-30 22:51:25 +02:00
warkanum c8030247f2 chore: more work done and planning with AI.
CI / Test (push) Failing after 25s
CI / Build (push) Has been skipped
2026-06-30 22:43:25 +02:00
warkanum a58b081cae fix(release): update package manager before RPM build
CI / Test (push) Successful in 34s
CI / Build (push) Successful in 26s
2026-06-29 16:40:29 +02:00
45 changed files with 5904 additions and 296 deletions
+47
View File
@@ -170,6 +170,7 @@ jobs:
VERSION="${{ github.event.inputs.tag || github.ref_name }}" VERSION="${{ github.event.inputs.tag || github.ref_name }}"
PKGVER="${VERSION#v}" PKGVER="${VERSION#v}"
sudo apt-get update -y
sudo apt-get install -y rpm sudo apt-get install -y rpm
CGO_ENABLED=0 go build \ CGO_ENABLED=0 go build \
@@ -207,6 +208,52 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
pkg-nsis:
name: Windows installer
needs: release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Build NSIS installer
run: |
set -euo pipefail
VERSION="${{ github.event.inputs.tag || github.ref_name }}"
PKGVER="${VERSION#v}"
sudo apt-get update -y
sudo apt-get install -y nsis
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build \
-trimpath \
-ldflags "-X main.version=${PKGVER}" \
-o pgtidy.exe \
./cmd/pgtidy
makensis -DVERSION="${PKGVER}" -DSRC_EXE="$(pwd)/pgtidy.exe" windows/installer.nsi
- name: Upload to release
run: |
set -euo pipefail
TAG="${{ github.event.inputs.tag || github.ref_name }}"
RELEASE=$(curl -s "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" \
-H "Authorization: token ${GITHUB_TOKEN}")
UPLOAD_URL=$(echo "$RELEASE" | grep -o '"upload_url":"[^"]*"' | cut -d'"' -f4 | sed 's/{[^}]*}//')
[ -z "$UPLOAD_URL" ] && { echo "upload_url not found: $RELEASE"; exit 1; }
for f in windows/pgtidy-setup-*.exe; do
echo "Uploading $(basename "$f")..."
curl -s -X POST "${UPLOAD_URL}?name=$(basename "$f")" \
-H "Authorization: token ${GITHUB_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${f}" > /dev/null
done
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
pkg-aur: pkg-aur:
name: AUR package name: AUR package
needs: release needs: release
+4
View File
@@ -0,0 +1,4 @@
version: "2"
linters:
default: standard
+4 -3
View File
@@ -36,7 +36,7 @@ pkg/pgast/ — go-pgquery wrapper: SQL → real PG AST (lint, v2)
pkg/lint/ — rule engine + rule packs (v2) pkg/lint/ — rule engine + rule packs (v2)
pkg/lsp/ — LSP server (v3) pkg/lsp/ — LSP server (v3)
editors/vscode/ — VSCode extension (v3) editors/vscode/ — VSCode extension (v3)
editors/datagrip/ — LSP4IJ integration (v4) editors/datagrip/ — native JetBrains plugin, shells out to the CLI (v4)
testdata/corpus/ — real-world .pgsql procedures used as the safety/idempotence harness testdata/corpus/ — real-world .pgsql procedures used as the safety/idempotence harness
``` ```
@@ -111,8 +111,9 @@ go fmt ./... # Format Go code
Per-platform VSIX (`win32/linux/darwin × x64/arm64`) built in CI matrix. Per-platform VSIX (`win32/linux/darwin × x64/arm64`) built in CI matrix.
### V4 — DataGrip ### V4 — DataGrip
- `editors/datagrip`: integrate via LSP4IJ (free, works across JetBrains editions incl. - `editors/datagrip`: native JetBrains plugin (no LSP4IJ dependency) that shells out to the
DataGrip). No core changes expected. `pgtidy` binary directly. Formatting and version info run `pgtidy fmt`/`version`; lint
diagnostics and quick-fixes run via a native `ExternalAnnotator` calling `pgtidy lint --json`.
## Verification ## Verification
- **Formatter:** `go test ./...` runs golden-file tests + corpus harness asserting idempotence - **Formatter:** `go test ./...` runs golden-file tests + corpus harness asserting idempotence
+2 -1
View File
@@ -20,9 +20,10 @@ vet:
fmt: fmt:
go fmt ./... go fmt ./...
## lint: vet + format check ## lint: vet + format check + golangci-lint
lint: vet lint: vet
test -z "$$(gofmt -l .)" || (echo "gofmt needed:"; gofmt -l .; exit 1) test -z "$$(gofmt -l .)" || (echo "gofmt needed:"; gofmt -l .; exit 1)
golangci-lint run ./...
## clean: remove build artifacts ## clean: remove build artifacts
clean: clean:
+8 -8
View File
@@ -11,7 +11,7 @@ import (
func cmdConfig(args []string, stdin io.Reader, stdout, stderr io.Writer) int { func cmdConfig(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
for _, a := range args { for _, a := range args {
if a == "-h" || a == "--help" { if a == "-h" || a == "--help" {
fmt.Fprintln(stdout, "pgtidy config — print effective configuration resolved from .pgtidy.yaml") _, _ = fmt.Fprintln(stdout, "pgtidy config — print effective configuration resolved from .pgtidy.yaml")
return 0 return 0
} }
} }
@@ -21,14 +21,14 @@ func cmdConfig(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
} }
st, err := config.Load(wd) st, err := config.Load(wd)
if err != nil { if err != nil {
fmt.Fprintf(stderr, "pgtidy: %v\n", err) _, _ = fmt.Fprintf(stderr, "pgtidy: %v\n", err)
return 2 return 2
} }
fmt.Fprintf(stdout, "indent: %q\n", st.Indent) _, _ = fmt.Fprintf(stdout, "indent: %q\n", st.Indent)
fmt.Fprintf(stdout, "newline: %q\n", st.Newline) _, _ = fmt.Fprintf(stdout, "newline: %q\n", st.Newline)
fmt.Fprintf(stdout, "keyword_case: %s\n", st.KeywordCase) _, _ = fmt.Fprintf(stdout, "keyword_case: %s\n", st.KeywordCase)
fmt.Fprintf(stdout, "ident_case: %s\n", st.IdentCase) _, _ = fmt.Fprintf(stdout, "ident_case: %s\n", st.IdentCase)
fmt.Fprintf(stdout, "type_case: %s\n", st.TypeCase) _, _ = fmt.Fprintf(stdout, "type_case: %s\n", st.TypeCase)
fmt.Fprintf(stdout, "commas: %s\n", st.Commas) _, _ = fmt.Fprintf(stdout, "commas: %s\n", st.Commas)
return 0 return 0
} }
+10 -10
View File
@@ -37,7 +37,7 @@ func cmdFmt(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
return 0 return 0
default: default:
if len(a) > 1 && a[0] == '-' { if len(a) > 1 && a[0] == '-' {
fmt.Fprintf(stderr, "pgtidy fmt: unknown flag %q\n", a) _, _ = fmt.Fprintf(stderr, "pgtidy fmt: unknown flag %q\n", a)
return 2 return 2
} }
files = append(files, a) files = append(files, a)
@@ -51,7 +51,7 @@ func cmdFmt(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
} }
st, err := config.Load(wd) st, err := config.Load(wd)
if err != nil { if err != nil {
fmt.Fprintf(stderr, "%v\n", err) _, _ = fmt.Fprintf(stderr, "%v\n", err)
return 2 return 2
} }
@@ -59,7 +59,7 @@ func cmdFmt(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
if len(files) == 0 { if len(files) == 0 {
src, err := io.ReadAll(stdin) src, err := io.ReadAll(stdin)
if err != nil { if err != nil {
fmt.Fprintf(stderr, "pgtidy: reading stdin: %v\n", err) _, _ = fmt.Fprintf(stderr, "pgtidy: reading stdin: %v\n", err)
return 2 return 2
} }
out := format.File(parser.Parse(string(src)), st) out := format.File(parser.Parse(string(src)), st)
@@ -69,9 +69,9 @@ func cmdFmt(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
return 1 return 1
} }
case diff: case diff:
io.WriteString(stdout, unifiedDiff(string(src), out, "stdin")) _, _ = io.WriteString(stdout, unifiedDiff(string(src), out, "stdin"))
default: default:
io.WriteString(stdout, out) _, _ = io.WriteString(stdout, out)
} }
return 0 return 0
} }
@@ -81,7 +81,7 @@ func cmdFmt(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
for _, path := range files { for _, path := range files {
src, err := os.ReadFile(path) src, err := os.ReadFile(path)
if err != nil { if err != nil {
fmt.Fprintf(stderr, "pgtidy: %v\n", err) _, _ = fmt.Fprintf(stderr, "pgtidy: %v\n", err)
exit = 2 exit = 2
continue continue
} }
@@ -94,22 +94,22 @@ func cmdFmt(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
case write: case write:
if changed { if changed {
if err := os.WriteFile(path, []byte(out), 0o644); err != nil { if err := os.WriteFile(path, []byte(out), 0o644); err != nil {
fmt.Fprintf(stderr, "pgtidy: writing %s: %v\n", path, err) _, _ = fmt.Fprintf(stderr, "pgtidy: writing %s: %v\n", path, err)
exit = 2 exit = 2
} }
} }
case list: case list:
if changed { if changed {
fmt.Fprintln(stdout, path) _, _ = fmt.Fprintln(stdout, path)
} }
case diff: case diff:
if changed { if changed {
io.WriteString(stdout, unifiedDiff(string(src), out, path)) _, _ = io.WriteString(stdout, unifiedDiff(string(src), out, path))
} }
case check: case check:
// handled after loop via anyDiff // handled after loop via anyDiff
default: default:
io.WriteString(stdout, out) _, _ = io.WriteString(stdout, out)
} }
} }
if check && anyDiff && exit == 0 { if check && anyDiff && exit == 0 {
+40 -18
View File
@@ -1,6 +1,7 @@
package main package main
import ( import (
"encoding/json"
"fmt" "fmt"
"io" "io"
"os" "os"
@@ -18,9 +19,10 @@ import (
// file:line:col: [RULEID] severity: message // file:line:col: [RULEID] severity: message
func cmdLint(args []string, stdin io.Reader, stdout, stderr io.Writer) int { func cmdLint(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
var ( var (
only []string // --only=ID,ID rule filter only []string // --only=ID,ID rule filter
fix bool fix bool
files []string jsonOutput bool
files []string
) )
for _, a := range args { for _, a := range args {
switch { switch {
@@ -29,6 +31,8 @@ func cmdLint(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
return 0 return 0
case a == "--fix": case a == "--fix":
fix = true fix = true
case a == "--json":
jsonOutput = true
case strings.HasPrefix(a, "--only="): case strings.HasPrefix(a, "--only="):
ids := strings.Split(strings.TrimPrefix(a, "--only="), ",") ids := strings.Split(strings.TrimPrefix(a, "--only="), ",")
for _, id := range ids { for _, id := range ids {
@@ -37,7 +41,7 @@ func cmdLint(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
} }
} }
case len(a) > 1 && a[0] == '-': case len(a) > 1 && a[0] == '-':
fmt.Fprintf(stderr, "pgtidy lint: unknown flag %q\n", a) _, _ = fmt.Fprintf(stderr, "pgtidy lint: unknown flag %q\n", a)
return 2 return 2
default: default:
files = append(files, a) files = append(files, a)
@@ -66,7 +70,13 @@ func cmdLint(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
return diags, nil return diags, nil
} }
printDiags := func(diags []diagnostics.Diagnostic) { var jsonDiags []diagnostics.Diagnostic
reportDiags := func(diags []diagnostics.Diagnostic) {
if jsonOutput {
jsonDiags = append(jsonDiags, diags...)
return
}
for _, d := range diags { for _, d := range diags {
loc := d.File loc := d.File
if d.Line > 0 { if d.Line > 0 {
@@ -75,7 +85,7 @@ func cmdLint(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
if loc == "" { if loc == "" {
loc = "stdin" loc = "stdin"
} }
fmt.Fprintf(stdout, "%s: [%s] %s: %s\n", loc, d.RuleID, d.Severity, d.Message) _, _ = fmt.Fprintf(stdout, "%s: [%s] %s: %s\n", loc, d.RuleID, d.Severity, d.Message)
} }
} }
@@ -84,22 +94,22 @@ func cmdLint(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
if len(files) == 0 { if len(files) == 0 {
src, err := io.ReadAll(stdin) src, err := io.ReadAll(stdin)
if err != nil { if err != nil {
fmt.Fprintf(stderr, "pgtidy: reading stdin: %v\n", err) _, _ = fmt.Fprintf(stderr, "pgtidy: reading stdin: %v\n", err)
return 2 return 2
} }
diags, err := check(string(src), "") diags, err := check(string(src), "")
if err != nil { if err != nil {
fmt.Fprintf(stderr, "pgtidy: %v\n", err) _, _ = fmt.Fprintf(stderr, "pgtidy: %v\n", err)
return 2 return 2
} }
if fix { if fix && !jsonOutput {
fixed := lint.ApplyFixes(string(src), diags) fixed := lint.ApplyFixes(string(src), diags)
if fixed != string(src) { if fixed != string(src) {
io.WriteString(stdout, fixed) _, _ = io.WriteString(stdout, fixed)
return 0 return 0
} }
} }
printDiags(diags) reportDiags(diags)
if len(diags) > 0 { if len(diags) > 0 {
found = true found = true
} }
@@ -107,36 +117,47 @@ func cmdLint(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
for _, path := range files { for _, path := range files {
src, err := os.ReadFile(path) src, err := os.ReadFile(path)
if err != nil { if err != nil {
fmt.Fprintf(stderr, "pgtidy: %v\n", err) _, _ = fmt.Fprintf(stderr, "pgtidy: %v\n", err)
return 2 return 2
} }
diags, err := check(string(src), path) diags, err := check(string(src), path)
if err != nil { if err != nil {
fmt.Fprintf(stderr, "pgtidy: %v\n", err) _, _ = fmt.Fprintf(stderr, "pgtidy: %v\n", err)
return 2 return 2
} }
if fix { if fix && !jsonOutput {
fixed := lint.ApplyFixes(string(src), diags) fixed := lint.ApplyFixes(string(src), diags)
if fixed != string(src) { if fixed != string(src) {
if err := os.WriteFile(path, []byte(fixed), 0o644); err != nil { if err := os.WriteFile(path, []byte(fixed), 0o644); err != nil {
fmt.Fprintf(stderr, "pgtidy: writing %s: %v\n", path, err) _, _ = fmt.Fprintf(stderr, "pgtidy: writing %s: %v\n", path, err)
return 2 return 2
} }
// Re-check to report any remaining unfixed diagnostics. // Re-check to report any remaining unfixed diagnostics.
diags, err = check(fixed, path) diags, err = check(fixed, path)
if err != nil { if err != nil {
fmt.Fprintf(stderr, "pgtidy: %v\n", err) _, _ = fmt.Fprintf(stderr, "pgtidy: %v\n", err)
return 2 return 2
} }
} }
} }
printDiags(diags) reportDiags(diags)
if len(diags) > 0 { if len(diags) > 0 {
found = true found = true
} }
} }
} }
if jsonOutput {
if jsonDiags == nil {
jsonDiags = []diagnostics.Diagnostic{}
}
enc := json.NewEncoder(stdout)
if err := enc.Encode(jsonDiags); err != nil {
_, _ = fmt.Fprintf(stderr, "pgtidy: encoding json: %v\n", err)
return 2
}
}
if found { if found {
return 1 return 1
} }
@@ -144,12 +165,13 @@ func cmdLint(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
} }
func lintUsage(w io.Writer) { func lintUsage(w io.Writer) {
fmt.Fprint(w, `Usage: pgtidy lint [flags] [file ...] _, _ = fmt.Fprint(w, `Usage: pgtidy lint [flags] [file ...]
Read SQL from files (or stdin) and report lint findings. Read SQL from files (or stdin) and report lint findings.
Flags: Flags:
--fix Apply autofixes for fixable rules (MIG001, MIG003) and rewrite files --fix Apply autofixes for fixable rules (MIG001, MIG003) and rewrite files
--json Emit findings as a JSON array instead of text (ignores --fix)
--only=ID,... comma-separated rule IDs to enable (default: all rules) --only=ID,... comma-separated rule IDs to enable (default: all rules)
-h, --help show this help -h, --help show this help
+2 -2
View File
@@ -12,7 +12,7 @@ import (
func cmdLsp(args []string, stdin io.Reader, stdout, stderr io.Writer) int { func cmdLsp(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
for _, a := range args { for _, a := range args {
if a == "-h" || a == "--help" { if a == "-h" || a == "--help" {
fmt.Fprintln(stdout, "pgtidy lsp — start the Language Server Protocol server (stdio transport)") _, _ = fmt.Fprintln(stdout, "pgtidy lsp — start the Language Server Protocol server (stdio transport)")
return 0 return 0
} }
} }
@@ -21,7 +21,7 @@ func cmdLsp(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
wd = "." wd = "."
} }
if err := lsp.Serve(context.Background(), stdin, stdout, wd); err != nil { if err := lsp.Serve(context.Background(), stdin, stdout, wd); err != nil {
fmt.Fprintf(stderr, "pgtidy lsp: %v\n", err) _, _ = fmt.Fprintf(stderr, "pgtidy lsp: %v\n", err)
return 1 return 1
} }
return 0 return 0
+3 -3
View File
@@ -29,20 +29,20 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
case "config": case "config":
return cmdConfig(args[1:], stdin, stdout, stderr) return cmdConfig(args[1:], stdin, stdout, stderr)
case "version", "--version", "-v": case "version", "--version", "-v":
fmt.Fprintf(stdout, "pgtidy %s\n", version) _, _ = fmt.Fprintf(stdout, "pgtidy %s\n", version)
return 0 return 0
case "help", "-h", "--help": case "help", "-h", "--help":
usage(stdout) usage(stdout)
return 0 return 0
default: default:
fmt.Fprintf(stderr, "pgtidy: unknown command %q\n", args[0]) _, _ = fmt.Fprintf(stderr, "pgtidy: unknown command %q\n", args[0])
usage(stderr) usage(stderr)
return 2 return 2
} }
} }
func usage(w io.Writer) { func usage(w io.Writer) {
fmt.Fprint(w, `pgtidy — PostgreSQL formatter and linter _, _ = fmt.Fprint(w, `pgtidy — PostgreSQL formatter and linter
Usage: Usage:
pgtidy fmt [flags] [files...] Format SQL/PL-pgSQL (stdin if no files) pgtidy fmt [flags] [files...] Format SQL/PL-pgSQL (stdin if no files)
+59 -17
View File
@@ -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
+85
View File
@@ -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
+77
View File
@@ -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.
+7 -17
View File
@@ -1,37 +1,27 @@
# PgTidy — DataGrip / JetBrains Plugin # PgTidy — DataGrip / JetBrains Plugin
Formats and lints SQL files in DataGrip (and any JetBrains IDE) via the LSP4IJ plugin. Formats SQL files in DataGrip (and any JetBrains IDE) by shelling out to the `pgtidy` binary.
## Requirements ## Requirements
- `pgtidy` binary on `PATH` — download from [releases](https://git.warky.dev/wdevs/PgTidy/releases) or build with `go install git.warky.dev/wdevs/pgtidy/cmd/pgtidy@latest` - `pgtidy` binary on `PATH` — download from [releases](https://git.warky.dev/wdevs/PgTidy/releases) or build with `go install git.warky.dev/wdevs/pgtidy/cmd/pgtidy@latest`
- [LSP4IJ](https://plugins.jetbrains.com/plugin/23257-lsp4ij) plugin installed (free, by Red Hat)
- DataGrip 2024.3+ (or any JetBrains IDE 2024.3+) - DataGrip 2024.3+ (or any JetBrains IDE 2024.3+)
## Install the plugin ## Install the plugin
### Option A — Install from disk (`.zip`)
1. Build: `./gradlew buildPlugin` (output in `build/distributions/`) 1. Build: `./gradlew buildPlugin` (output in `build/distributions/`)
2. In DataGrip: **Settings → Plugins → ⚙ → Install Plugin from Disk…** → select the `.zip` 2. In DataGrip: **Settings → Plugins → ⚙ → Install Plugin from Disk…** → select the `.zip`
3. Restart the IDE 3. Restart the IDE
### Option B — Install LSP4IJ and configure manually (no plugin build needed)
1. Install **LSP4IJ** from the marketplace (**Settings → Plugins → Marketplace → search "LSP4IJ"**)
2. Go to **Settings → Language Servers → + (Add)**
3. Fill in:
- **Name:** `PgTidy`
- **Command:** `pgtidy lsp`
4. Under **Mappings**, add file patterns: `*.sql`, `*.pgsql`
5. Click **OK** and restart the IDE
## Features ## Features
- **Formatting** — `Code → Reformat Code` (`Ctrl+Alt+L`) formats the current SQL file - **Formatting** — `Format with PgTidy` (`Ctrl+Alt+Shift+P` or right-click in the editor) formats the current SQL file via `pgtidy fmt`
- **Diagnostics** — lint findings shown as inspections (MIG001MIG003, COR001003, NAM001003) - **Version check** — `Tools → Show PgTidy Version` and the status bar widget show the installed `pgtidy` version
- **Diagnostics** — lint findings (MIG001MIG003, COR001003, NAM001003) are shown inline as editor annotations, via a native `ExternalAnnotator` that runs `pgtidy lint --json`
- **Quick fixes** — intention actions for MIG001 (add `CONCURRENTLY`) and MIG003 (add `NOT VALID`) - **Quick fixes** — intention actions for MIG001 (add `CONCURRENTLY`) and MIG003 (add `NOT VALID`)
## Troubleshooting ## Troubleshooting
**Server not starting:** Check **View → Tool Windows → LSP4IJ Consoles** for stderr output. Most common cause: `pgtidy` not found on `PATH` — update the command to the full path, e.g. `/usr/local/bin/pgtidy lsp`. **"Cannot start pgtidy" error:** the `pgtidy` binary isn't on `PATH` for the IDE process. Install it or restart the IDE after adding it to `PATH`.
**No diagnostics showing:** diagnostics only run on files with a `.sql` or `.pgsql` extension; check `pgtidy lint --json` runs cleanly on the file from a terminal with the same `PATH` as the IDE.
-1
View File
@@ -23,7 +23,6 @@ dependencies {
val platformType = providers.gradleProperty("platformType") val platformType = providers.gradleProperty("platformType")
create(platformType, platformVersion) create(platformType, platformVersion)
bundledPlugin("com.intellij.database") bundledPlugin("com.intellij.database")
plugin("com.redhat.devtools.lsp4ij:${providers.gradleProperty("lsp4ijVersion").get()}")
} }
} }
-1
View File
@@ -5,4 +5,3 @@ pluginSinceBuild=243
# DataGrip 2024.3 # DataGrip 2024.3
platformVersion=2024.3 platformVersion=2024.3
platformType=DB platformType=DB
lsp4ijVersion=0.20.1
@@ -0,0 +1,18 @@
package com.pgtidy.datagrip
data class PgTidyFix(
val offset: Int,
val end: Int,
val new: String,
val title: String,
)
data class PgTidyDiagnostic(
val ruleId: String,
val severity: String,
val message: String,
val file: String,
val line: Int,
val col: Int,
val fix: PgTidyFix?,
)
@@ -0,0 +1,50 @@
package com.pgtidy.datagrip
import com.intellij.lang.annotation.AnnotationHolder
import com.intellij.lang.annotation.ExternalAnnotator
import com.intellij.lang.annotation.HighlightSeverity
import com.intellij.openapi.editor.Document
import com.intellij.psi.PsiFile
class PgTidyExternalAnnotator : ExternalAnnotator<String, List<PgTidyDiagnostic>>() {
override fun collectInformation(file: PsiFile): String? {
val ext = file.virtualFile?.extension?.lowercase()
if (ext != "sql" && ext != "pgsql") return null
return file.viewProvider.document?.text
}
override fun doAnnotate(source: String): List<PgTidyDiagnostic> = PgTidyLintRunner.run(source)
override fun apply(file: PsiFile, diagnostics: List<PgTidyDiagnostic>, holder: AnnotationHolder) {
val document = file.viewProvider.document ?: return
for (d in diagnostics) {
if (d.line <= 0 || d.line > document.lineCount) continue
val range = lineTailRange(document, d.line, d.col) ?: continue
val severity = when (d.severity) {
"error" -> HighlightSeverity.ERROR
"warning" -> HighlightSeverity.WARNING
else -> HighlightSeverity.WEAK_WARNING
}
val builder = holder.newAnnotation(severity, "[${d.ruleId}] ${d.message}").range(range)
if (d.fix != null) {
builder.withFix(PgTidyQuickFix(d.fix))
}
builder.create()
}
}
/** Byte column [col] (1-based) to end-of-line, converted to a document TextRange. */
private fun lineTailRange(document: Document, line1Based: Int, col1Based: Int): com.intellij.openapi.util.TextRange? {
val lineIdx = line1Based - 1
val lineStart = document.getLineStartOffset(lineIdx)
val lineEnd = document.getLineEndOffset(lineIdx)
val lineText = document.getText(com.intellij.openapi.util.TextRange(lineStart, lineEnd))
val charCol = PgTidyOffsets.byteOffsetToCharIndex(lineText, col1Based - 1)
val start = lineStart + charCol
if (start >= lineEnd) return null
return com.intellij.openapi.util.TextRange(start, lineEnd)
}
}
@@ -0,0 +1,32 @@
package com.pgtidy.datagrip
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
object PgTidyLintRunner {
private val gson = Gson()
private val listType = TypeToken.getParameterized(List::class.java, PgTidyDiagnostic::class.java).type
/** Runs `pgtidy lint --json` over [source]. Returns an empty list if pgtidy is missing or output is unparseable. */
fun run(source: String): List<PgTidyDiagnostic> {
val proc = try {
ProcessBuilder("pgtidy", "lint", "--json")
.redirectErrorStream(false)
.start()
} catch (ex: Exception) {
return emptyList()
}
proc.outputStream.bufferedWriter().use { it.write(source) }
val output = proc.inputStream.bufferedReader().readText()
proc.errorStream.bufferedReader().readText()
proc.waitFor()
if (output.isBlank()) return emptyList()
return try {
gson.fromJson<List<PgTidyDiagnostic>>(output, listType) ?: emptyList()
} catch (ex: Exception) {
emptyList()
}
}
}
@@ -0,0 +1,28 @@
package com.pgtidy.datagrip
/**
* pgtidy reports positions as UTF-8 byte offsets (Go strings are byte slices);
* IntelliJ documents index text as UTF-16 chars. Converts one to the other.
*/
object PgTidyOffsets {
fun byteOffsetToCharIndex(text: String, byteOffset: Int): Int {
var bytes = 0
var i = 0
while (i < text.length) {
val cp = text.codePointAt(i)
val charCount = Character.charCount(cp)
val byteLen = utf8Length(cp)
if (bytes + byteLen > byteOffset) return i
bytes += byteLen
i += charCount
}
return text.length
}
private fun utf8Length(codePoint: Int): Int = when {
codePoint <= 0x7F -> 1
codePoint <= 0x7FF -> 2
codePoint <= 0xFFFF -> 3
else -> 4
}
}
@@ -0,0 +1,25 @@
package com.pgtidy.datagrip
import com.intellij.codeInsight.intention.IntentionAction
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.project.Project
import com.intellij.psi.PsiFile
import com.intellij.util.IncorrectOperationException
class PgTidyQuickFix(private val fix: PgTidyFix) : IntentionAction {
override fun getText(): String = fix.title
override fun getFamilyName(): String = "PgTidy"
override fun startInWriteAction(): Boolean = true
override fun isAvailable(project: Project, editor: Editor?, file: PsiFile?): Boolean = editor != null
@Throws(IncorrectOperationException::class)
override fun invoke(project: Project, editor: Editor?, file: PsiFile?) {
val document = editor?.document ?: return
val text = document.text
val start = PgTidyOffsets.byteOffsetToCharIndex(text, fix.offset)
val end = PgTidyOffsets.byteOffsetToCharIndex(text, fix.end)
document.replaceString(start, end, fix.new)
}
}
@@ -1,9 +0,0 @@
package com.pgtidy.datagrip
import com.intellij.openapi.project.Project
import com.redhat.devtools.lsp4ij.server.ProcessStreamConnectionProvider
class PgTidyServerConnection(project: Project) : ProcessStreamConnectionProvider(
listOf("pgtidy", "lsp"),
project.basePath ?: System.getProperty("user.home"),
)
@@ -1,10 +0,0 @@
package com.pgtidy.datagrip
import com.intellij.openapi.project.Project
import com.redhat.devtools.lsp4ij.LanguageServerFactory
import com.redhat.devtools.lsp4ij.server.StreamConnectionProvider
class PgTidyServerFactory : LanguageServerFactory {
override fun createConnectionProvider(project: Project): StreamConnectionProvider =
PgTidyServerConnection(project)
}
@@ -0,0 +1,42 @@
package com.pgtidy.datagrip
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.progress.ProgressIndicator
import com.intellij.openapi.progress.Task
import com.intellij.openapi.ui.Messages
class PgTidyShowVersionAction : AnAction() {
override fun actionPerformed(e: AnActionEvent) {
val project = e.project
object : Task.Backgroundable(project, "PgTidy: checking version…", false) {
override fun run(indicator: ProgressIndicator) {
val proc = try {
ProcessBuilder("pgtidy", "version")
.redirectErrorStream(false)
.start()
} catch (ex: Exception) {
ApplicationManager.getApplication().invokeLater {
Messages.showErrorDialog(project, "Cannot start pgtidy: ${ex.message}", "PgTidy")
}
return
}
val output = proc.inputStream.bufferedReader().readText()
val stderr = proc.errorStream.bufferedReader().readText()
val exit = proc.waitFor()
ApplicationManager.getApplication().invokeLater {
if (exit != 0) {
Messages.showErrorDialog(project, stderr.ifBlank { "pgtidy exited with code $exit" }, "PgTidy")
return@invokeLater
}
Messages.showInfoMessage(project, output.trim(), "PgTidy Version")
}
}
}.queue()
}
}
@@ -0,0 +1,72 @@
package com.pgtidy.datagrip
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.project.Project
import com.intellij.openapi.wm.StatusBar
import com.intellij.openapi.wm.StatusBarWidget
import com.intellij.openapi.wm.StatusBarWidgetFactory
import com.intellij.util.Consumer
import java.awt.event.MouseEvent
private const val WIDGET_ID = "com.pgtidy.StatusBarWidget"
class PgTidyStatusBarWidget(private val project: Project) : StatusBarWidget, StatusBarWidget.TextPresentation {
private var statusBar: StatusBar? = null
private var text: String = "pgtidy: …"
override fun ID(): String = WIDGET_ID
override fun install(statusBar: StatusBar) {
this.statusBar = statusBar
refresh()
}
override fun dispose() {
statusBar = null
}
override fun getPresentation(): StatusBarWidget.WidgetPresentation = this
override fun getText(): String = text
override fun getAlignment(): Float = java.awt.Component.CENTER_ALIGNMENT
override fun getTooltipText(): String = "PgTidy version — click to refresh"
override fun getClickConsumer(): Consumer<MouseEvent> = Consumer { refresh() }
fun refresh() {
ApplicationManager.getApplication().executeOnPooledThread {
text = try {
val proc = ProcessBuilder("pgtidy", "version")
.redirectErrorStream(true)
.start()
val output = proc.inputStream.bufferedReader().readText().trim()
val exit = proc.waitFor()
if (exit == 0 && output.isNotBlank()) output else "pgtidy: not found"
} catch (ex: Exception) {
"pgtidy: not found"
}
ApplicationManager.getApplication().invokeLater {
statusBar?.updateWidget(WIDGET_ID)
}
}
}
}
class PgTidyStatusBarWidgetFactory : StatusBarWidgetFactory {
override fun getId(): String = WIDGET_ID
override fun getDisplayName(): String = "PgTidy Version"
override fun isAvailable(project: Project): Boolean = true
override fun createWidget(project: Project): StatusBarWidget = PgTidyStatusBarWidget(project)
override fun disposeWidget(widget: StatusBarWidget) {
widget.dispose()
}
override fun canBeEnabledOn(statusBar: StatusBar): Boolean = true
}
@@ -5,13 +5,11 @@
<vendor url="https://git.warky.dev/wdevs/PgTidy">Warky Devs</vendor> <vendor url="https://git.warky.dev/wdevs/PgTidy">Warky Devs</vendor>
<description><![CDATA[ <description><![CDATA[
PostgreSQL formatter and linter powered by <a href="https://github.com/hein/pgtidy">pgtidy</a>.<br/> PostgreSQL formatter and linter powered by <a href="https://github.com/hein/pgtidy">pgtidy</a>.<br/>
Requires the <code>pgtidy</code> binary on PATH and the Requires the <code>pgtidy</code> binary on PATH.
<a href="https://plugins.jetbrains.com/plugin/23257-lsp4ij">LSP4IJ</a> plugin.
]]></description> ]]></description>
<depends>com.intellij.modules.platform</depends> <depends>com.intellij.modules.platform</depends>
<depends>com.intellij.database</depends> <depends>com.intellij.database</depends>
<depends>com.redhat.devtools.lsp4ij</depends>
<actions> <actions>
<action id="com.pgtidy.FormatDocument" <action id="com.pgtidy.FormatDocument"
@@ -21,17 +19,19 @@
<add-to-group group-id="EditorPopupMenu" anchor="first"/> <add-to-group group-id="EditorPopupMenu" anchor="first"/>
<keyboard-shortcut keymap="$default" first-keystroke="ctrl alt shift P"/> <keyboard-shortcut keymap="$default" first-keystroke="ctrl alt shift P"/>
</action> </action>
<action id="com.pgtidy.ShowVersion"
class="com.pgtidy.datagrip.PgTidyShowVersionAction"
text="Show PgTidy Version"
description="Show the installed pgtidy binary version">
<add-to-group group-id="ToolsMenu" anchor="last"/>
</action>
</actions> </actions>
<extensions defaultExtensionNs="com.redhat.devtools.lsp4ij"> <extensions defaultExtensionNs="com.intellij">
<server id="com.pgtidy.lsp" <statusBarWidgetFactory id="com.pgtidy.StatusBarWidget"
name="PgTidy" implementation="com.pgtidy.datagrip.PgTidyStatusBarWidgetFactory"
factoryClass="com.pgtidy.datagrip.PgTidyServerFactory"> order="last"/>
<description>PostgreSQL formatter and linter (pgtidy lsp)</description> <externalAnnotator language="" implementationClass="com.pgtidy.datagrip.PgTidyExternalAnnotator"/>
</server>
<fileNamePatternMapping patterns="*.sql;*.pgsql"
serverId="com.pgtidy.lsp"
languageId="SQL"/>
</extensions> </extensions>
</idea-plugin> </idea-plugin>
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Hein (Warky Devs) <hein@warky.dev> # Maintainer: Hein (Warky Devs) <hein@warky.dev>
pkgname=pgtidy-bin pkgname=pgtidy-bin
pkgver=0.0.2 pkgver=0.0.6
pkgrel=1 pkgrel=1
pkgdesc="PostgreSQL SQL formatter and linter" pkgdesc="PostgreSQL SQL formatter and linter"
arch=('x86_64' 'aarch64') arch=('x86_64' 'aarch64')
+1 -1
View File
@@ -1,5 +1,5 @@
Name: pgtidy Name: pgtidy
Version: 0.0.2 Version: 0.0.6
Release: 1%{?dist} Release: 1%{?dist}
Summary: PostgreSQL SQL formatter and linter Summary: PostgreSQL SQL formatter and linter
+270 -49
View File
@@ -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\"")
}
+11 -11
View File
@@ -15,27 +15,27 @@ const (
// TextFix is a byte-range replacement that can be applied to the source SQL. // TextFix is a byte-range replacement that can be applied to the source SQL.
// Replace src[Offset:End] with New. An insertion has Offset == End. // Replace src[Offset:End] with New. An insertion has Offset == End.
type TextFix struct { type TextFix struct {
Offset int // byte offset in source (inclusive) Offset int `json:"offset"` // byte offset in source (inclusive)
End int // byte offset in source (exclusive) End int `json:"end"` // byte offset in source (exclusive)
New string // replacement text New string `json:"new"` // replacement text
Title string // short description shown in editor UI Title string `json:"title"` // short description shown in editor UI
} }
// Diagnostic is a single lint finding. // Diagnostic is a single lint finding.
type Diagnostic struct { type Diagnostic struct {
// RuleID is the stable identifier for the rule that produced this finding // RuleID is the stable identifier for the rule that produced this finding
// (e.g. "MIG001"). // (e.g. "MIG001").
RuleID string RuleID string `json:"ruleId"`
// Severity is the urgency level. // Severity is the urgency level.
Severity Severity Severity Severity `json:"severity"`
// Message is a human-readable description of the finding. // Message is a human-readable description of the finding.
Message string Message string `json:"message"`
// File is the path to the source file, or "" for stdin. // File is the path to the source file, or "" for stdin.
File string File string `json:"file"`
// Line is the 1-based line number of the finding. // Line is the 1-based line number of the finding.
Line int Line int `json:"line"`
// Col is the 1-based column number of the finding. // Col is the 1-based column number of the finding.
Col int Col int `json:"col"`
// Fix is non-nil when an autofix is available for this diagnostic. // Fix is non-nil when an autofix is available for this diagnostic.
Fix *TextFix Fix *TextFix `json:"fix,omitempty"`
} }
+444 -60
View File
@@ -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,17 +363,23 @@ 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
parenDepth int parenDepth int
blockDepth int // 0=col-0 (BEGIN/END/EXCEPTION), 1=body, 2=nested… blockDepth int // 0=col-0 (BEGIN/END/EXCEPTION), 1=body, 2=nested…
caseDepth int // depth of open CASE…END expressions (WHEN…THEN is not a block opener)
inException bool inException bool
pendingBlanks int pendingBlanks int
depthInc bool // increment blockDepth after next flush depthInc bool // increment blockDepth after next flush
@@ -228,7 +389,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 +424,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,13 +434,16 @@ 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
if i == 0 || ll.indent == "" { // the condition. When true (default) it's already on its own logical line.
result.WriteString(baseIndent) stmtLines := stmt
} else { if !st.PlpgsqlIfThenNewline && fw == "if" {
result.WriteString(ll.indent) stmtLines = joinThenToCondition(stmt)
} }
result.WriteString(ll.text)
formattedLines := formatBodyStmtLines(stmtLines, baseIndent, st)
for _, line := range formattedLines {
result.WriteString(line)
result.WriteString(nl) result.WriteString(nl)
} }
@@ -301,9 +467,31 @@ 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]
// Don't join a col-0 continuation to a comment-only preceding bline:
// the comment has no structural keyword so `continue ;` at col-0 would
// disappear into the comment text and be invisible to the lexer.
if joinToPrev {
if lowerASCII(firstBodyKeyword(stmt[len(stmt)-1].text)) == "" {
joinToPrev = false
}
}
// Pre-flush pending comment-only blines before adding a new non-comment
// non-joined bline. Without this, a comment + `end if;` end up in the
// same stmt, `fw` comes from the comment (empty string), depth is never
// decremented, and the formatter diverges on the second pass.
if !joinToPrev && fw != "" && len(stmt) > 0 {
allComments := true
for _, ll := range stmt {
if lowerASCII(firstBodyKeyword(ll.text)) != "" {
allComments = false
break
}
}
if allComments {
flush()
}
}
if joinToPrev { if joinToPrev {
last := &stmt[len(stmt)-1] last := &stmt[len(stmt)-1]
@@ -312,7 +500,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,27 +514,53 @@ 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()
} }
} }
if parenDepth == 0 && tok.Kind == lexer.Ident { if parenDepth == 0 && tok.Kind == lexer.Ident {
lastD0Kw = lowerASCII(tok.Text) lastD0Kw = lowerASCII(tok.Text)
switch lastD0Kw {
case "case":
caseDepth++
case "end":
if caseDepth > 0 {
caseDepth--
}
}
} }
} }
// 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":
// ELSIF/ELSEIF headers end with THEN but must NOT increment depth // A THEN ending a CASE…WHEN branch is not a PL/pgSQL block
// (blockDepth is already at the right level for the body). // opener; only one matching END closes the whole CASE, so
// treating each WHEN…THEN as a block open would permanently
// inflate blockDepth.
if caseDepth == 0 {
fw0 := lowerASCII(firstBodyKeyword(stmt[0].text))
if fw0 != "elsif" && fw0 != "elseif" {
depthInc = true
}
flush()
}
case "loop", "begin":
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
} }
flush() flush()
case "else", "exception": case "else", "exception":
if caseDepth > 0 {
break
}
flush() flush()
} }
} }
@@ -357,6 +570,177 @@ func formatBodyStatements(text string, st config.Style) string {
return result.String() return result.String()
} }
// formatBodyStmtLines formats one flushed PL/pgSQL statement at its contextual
// base indent. Multi-line UPDATE/DELETE statements inside PL/pgSQL get their
// top-level SET/WHERE/AND/OR clauses realigned under the statement while nested
// subqueries keep their original indentation. Non-DML statements keep
// continuation indentation, except that standalone structural keywords such as
// THEN are aligned with the block opener.
func formatBodyStmtLines(lines []bline, baseIndent string, st config.Style) []string {
if len(lines) == 0 {
return nil
}
if looksLikeMultiLineBodyDML(lines) {
return reindentBodyDML(lines, baseIndent, st)
}
out := make([]string, 0, len(lines))
for i, ll := range lines {
text := ll.text
indent := baseIndent
if i > 0 && ll.indent != "" && !isStandaloneBodyKeyword(ll.text, "then", "else", "elsif", "elseif") {
indent = ll.indent
}
out = append(out, indent+text)
}
return out
}
func looksLikeMultiLineBodyDML(lines []bline) bool {
if len(lines) < 2 {
return false
}
kw := lowerASCII(firstBodyKeyword(lines[0].text))
return kw == "update" || kw == "delete"
}
func reindentBodyDML(lines []bline, baseIndent string, st config.Style) []string {
out := make([]string, 0, len(lines)+1)
afterWhere := false
parenDepth := 0
for i, ll := range lines {
text := strings.TrimRight(ll.text, " ")
lineDepth := parenDepth
kw := lowerASCII(firstBodyKeyword(text))
if afterWhere && lineDepth == 0 && kw != "and" && kw != "or" {
out = append(out, baseIndent+st.Indent+st.Indent+strings.TrimSpace(text))
afterWhere = false
updateBodyParenDepth(text, &parenDepth)
continue
}
if lineDepth == 0 && (kw == "set" || kw == "where" || kw == "values" || kw == "returning") {
if kw == "where" {
whereText := strings.TrimSpace(text)
fields := strings.Fields(whereText)
nextKw := ""
if i+1 < len(lines) {
nextKw = lowerASCII(firstBodyKeyword(lines[i+1].text))
}
if len(fields) > 1 && (nextKw == "and" || nextKw == "or") {
out = append(out, baseIndent+fields[0])
out = append(out, baseIndent+st.Indent+st.Indent+strings.TrimSpace(whereText[len(fields[0]):]))
afterWhere = false
continue
}
afterWhere = len(fields) == 1
}
out = append(out, baseIndent+strings.TrimSpace(text))
continue
}
if lineDepth == 0 && (kw == "and" || kw == "or") {
out = append(out, baseIndent+st.Indent+strings.TrimSpace(text))
afterWhere = false
updateBodyParenDepth(text, &parenDepth)
continue
}
if i == 0 {
out = append(out, baseIndent+strings.TrimSpace(text))
} else if ll.indent != "" {
out = append(out, ll.indent+strings.TrimSpace(text))
} else {
out = append(out, baseIndent+strings.TrimSpace(text))
}
afterWhere = false
updateBodyParenDepth(text, &parenDepth)
}
return out
}
func updateBodyParenDepth(s string, depth *int) {
for _, tok := range lexer.Lex(s) {
switch tok.Kind {
case lexer.LParen, lexer.LBracket:
(*depth)++
case lexer.RParen, lexer.RBracket:
if *depth > 0 {
(*depth)--
}
}
}
}
func significantBodyTokens(s string) []cst.Tok {
var toks []cst.Tok
for _, tok := range lexer.Lex(s) {
if tok.IsTrivia() || tok.Kind == lexer.EOF {
continue
}
toks = append(toks, cst.Tok{Tok: tok})
}
return toks
}
func isStandaloneBodyKeyword(s string, kws ...string) bool {
toks := significantBodyTokens(s)
if len(toks) != 1 || toks[0].Tok.Kind != lexer.Ident {
return false
}
low := lowerASCII(toks[0].Tok.Text)
for _, kw := range kws {
if low == kw {
return true
}
}
return false
}
// 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 {
+269 -20
View File
@@ -10,16 +10,13 @@ import (
// isDMLStart reports whether toks begins with a DML statement keyword. // isDMLStart reports whether toks begins with a DML statement keyword.
func isDMLStart(toks []cst.Tok) bool { func isDMLStart(toks []cst.Tok) bool {
for _, t := range toks { if len(toks) == 0 || toks[0].Tok.Kind != lexer.Ident {
if t.Tok.Kind == lexer.Ident {
switch lowerASCII(t.Tok.Text) {
case "select", "insert", "update", "delete", "with":
return true
}
return false
}
return false return false
} }
switch lowerASCII(toks[0].Tok.Text) {
case "select", "insert", "update", "delete", "with":
return true
}
return false return false
} }
@@ -179,9 +176,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 +197,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 +465,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()
} }
@@ -401,11 +507,9 @@ func dmlSplitCommas(toks []cst.Tok) [][]cst.Tok {
return items return items
} }
// dmlColList formats kwText followed by a comma-separated body. // dmlColListSelect formats a SELECT / RETURNING column list with optional
// One item: kept on the same line as the keyword. // align_columns and select_align_as settings.
// Multiple items: each on its own line with the configured comma style. func dmlColListSelect(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).
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 +530,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 +560,139 @@ 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
}
+56
View File
@@ -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
View File
@@ -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
}
+99 -3
View File
@@ -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,102 @@ 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 TestFormatIssue1PLpgSQLIndenting(t *testing.T) {
src := "CREATE FUNCTION f() RETURNS void LANGUAGE plpgsql AS $$\n" +
"DECLARE\n" +
" r_lp record;\n" +
"BEGIN\n" +
" if r_lp.total > 0\n" +
" and r_lp.totaldone >= r_lp.total\n" +
" then\n" +
" update core.process u\n" +
" set status = 'done'\n" +
" where u.rid_process = r_lp.rid_process\n" +
" and nv(u.status) <> 'done';\n" +
" elsif r_lp.total > 0\n" +
" then\n" +
" update core.process u\n" +
" set status = 'open'\n" +
" where u.rid_process = r_lp.rid_process\n" +
" and nv(u.status) <> 'open';\n" +
"\n" +
" end if;\n" +
"$$;\n"
want := "CREATE FUNCTION f(\n" +
")\n" +
"RETURNS void\n" +
"LANGUAGE plpgsql\n" +
"AS\n" +
"$$\n" +
"DECLARE\n" +
" r_lp record;\n" +
"BEGIN\n" +
" if r_lp.total > 0\n" +
" and r_lp.totaldone >= r_lp.total\n" +
" then\n" +
" update core.process u\n" +
" set status = 'done'\n" +
" where\n" +
" u.rid_process = r_lp.rid_process\n" +
" and nv(u.status) <> 'done';\n" +
" elsif r_lp.total > 0\n" +
" then\n" +
" update core.process u\n" +
" set status = 'open'\n" +
" where\n" +
" u.rid_process = r_lp.rid_process\n" +
" and nv(u.status) <> 'open';\n" +
"\n" +
" end if;\n" +
"$$;\n"
got := format(src)
if got != want {
t.Errorf("issue #1 PL/pgSQL indenting\n--- got ---\n%s\n--- want ---\n%s", got, want)
}
checkDML(t, "issue #1 PL/pgSQL indenting", got)
if !semanticallyEqual(src, got) {
t.Errorf("issue #1 PL/pgSQL indenting 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 +175,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
View File
@@ -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] }
+7 -7
View File
@@ -89,12 +89,12 @@ func (s *server) handle(raw []byte) bool {
return true return true
case "textDocument/didOpen": case "textDocument/didOpen":
var p didOpenParams var p didOpenParams
json.Unmarshal(req.Params, &p) _ = json.Unmarshal(req.Params, &p)
s.docs[p.TextDocument.URI] = p.TextDocument.Text s.docs[p.TextDocument.URI] = p.TextDocument.Text
s.pushDiagnostics(p.TextDocument.URI, p.TextDocument.Text) s.pushDiagnostics(p.TextDocument.URI, p.TextDocument.Text)
case "textDocument/didChange": case "textDocument/didChange":
var p didChangeParams var p didChangeParams
json.Unmarshal(req.Params, &p) _ = json.Unmarshal(req.Params, &p)
if len(p.ContentChanges) > 0 { if len(p.ContentChanges) > 0 {
text := p.ContentChanges[len(p.ContentChanges)-1].Text text := p.ContentChanges[len(p.ContentChanges)-1].Text
s.docs[p.TextDocument.URI] = text s.docs[p.TextDocument.URI] = text
@@ -104,7 +104,7 @@ func (s *server) handle(raw []byte) bool {
var p struct { var p struct {
TextDocument textDocID `json:"textDocument"` TextDocument textDocID `json:"textDocument"`
} }
json.Unmarshal(req.Params, &p) _ = json.Unmarshal(req.Params, &p)
delete(s.docs, p.TextDocument.URI) delete(s.docs, p.TextDocument.URI)
delete(s.fixes, p.TextDocument.URI) delete(s.fixes, p.TextDocument.URI)
s.notify("textDocument/publishDiagnostics", publishDiagnosticsParams{ s.notify("textDocument/publishDiagnostics", publishDiagnosticsParams{
@@ -113,7 +113,7 @@ func (s *server) handle(raw []byte) bool {
}) })
case "textDocument/formatting": case "textDocument/formatting":
var p formattingParams var p formattingParams
json.Unmarshal(req.Params, &p) _ = json.Unmarshal(req.Params, &p)
text, ok := s.docs[p.TextDocument.URI] text, ok := s.docs[p.TextDocument.URI]
if !ok { if !ok {
s.reply(req.ID, []textEdit{}) s.reply(req.ID, []textEdit{})
@@ -127,7 +127,7 @@ func (s *server) handle(raw []byte) bool {
s.reply(req.ID, []textEdit{fullReplace(text, formatted)}) s.reply(req.ID, []textEdit{fullReplace(text, formatted)})
case "textDocument/rangeFormatting": case "textDocument/rangeFormatting":
var p rangeFormattingParams var p rangeFormattingParams
json.Unmarshal(req.Params, &p) _ = json.Unmarshal(req.Params, &p)
text, ok := s.docs[p.TextDocument.URI] text, ok := s.docs[p.TextDocument.URI]
if !ok { if !ok {
s.reply(req.ID, []textEdit{}) s.reply(req.ID, []textEdit{})
@@ -136,7 +136,7 @@ func (s *server) handle(raw []byte) bool {
s.reply(req.ID, s.rangeFormat(text, p.Range)) s.reply(req.ID, s.rangeFormat(text, p.Range))
case "textDocument/codeAction": case "textDocument/codeAction":
var p codeActionParams var p codeActionParams
json.Unmarshal(req.Params, &p) _ = json.Unmarshal(req.Params, &p)
s.handleCodeAction(req.ID, p) s.handleCodeAction(req.ID, p)
case "$/cancelRequest": // ignore case "$/cancelRequest": // ignore
default: default:
@@ -360,7 +360,7 @@ func (s *server) send(v interface{}) {
if err != nil { if err != nil {
return return
} }
fmt.Fprintf(s.w, "Content-Length: %d\r\n\r\n", len(data)) _, _ = fmt.Fprintf(s.w, "Content-Length: %d\r\n\r\n", len(data))
s.w.Write(data) //nolint:errcheck s.w.Write(data) //nolint:errcheck
} }
+2 -2
View File
@@ -51,7 +51,7 @@ func readResp(t *testing.T, buf *bytes.Buffer) map[string]interface{} {
} }
lenStr := string(data[idx+16 : idx+eol]) lenStr := string(data[idx+16 : idx+eol])
var n int var n int
fmt.Sscanf(lenStr, "%d", &n) _, _ = fmt.Sscanf(lenStr, "%d", &n)
sep := bytes.Index(data, []byte("\r\n\r\n")) sep := bytes.Index(data, []byte("\r\n\r\n"))
if sep < 0 || len(data) < sep+4+n { if sep < 0 || len(data) < sep+4+n {
time.Sleep(5 * time.Millisecond) time.Sleep(5 * time.Millisecond)
@@ -60,7 +60,7 @@ func readResp(t *testing.T, buf *bytes.Buffer) map[string]interface{} {
body := data[sep+4 : sep+4+n] body := data[sep+4 : sep+4+n]
buf.Next(sep + 4 + n) buf.Next(sep + 4 + n)
var result map[string]interface{} var result map[string]interface{}
json.Unmarshal(body, &result) _ = json.Unmarshal(body, &result)
return result return result
} }
t.Fatal("timeout waiting for response") t.Fatal("timeout waiting for response")
+1 -1
View File
@@ -88,7 +88,7 @@ func parseCreateFunction(stmt []cst.Tok) (*cst.CreateFunction, bool) {
if i+1 < len(stmt) && stmt[i].Is("or") && stmt[i+1].Is("replace") { if i+1 < len(stmt) && stmt[i].Is("or") && stmt[i+1].Is("replace") {
i += 2 i += 2
} }
if i >= len(stmt) || !(stmt[i].Is("function") || stmt[i].Is("procedure")) { if i >= len(stmt) || (!stmt[i].Is("function") && !stmt[i].Is("procedure")) {
return nil, false return nil, false
} }
i++ i++
+1 -1
View File
@@ -41,7 +41,7 @@ func FirstTokenOffset(sql string, start int) int {
} }
case i+1 < n && sql[i] == '/' && sql[i+1] == '*': case i+1 < n && sql[i] == '/' && sql[i+1] == '*':
i += 2 i += 2
for i+1 < n && !(sql[i] == '*' && sql[i+1] == '/') { for i+1 < n && (sql[i] != '*' || sql[i+1] != '/') {
i++ i++
} }
if i+1 < n { if i+1 < n {
+6 -8
View File
@@ -1,16 +1,16 @@
--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
SECURITY DEFINER SECURITY DEFINER
AS AS
$$ $$
DECLARE DECLARE
--Error Handling-- --Error Handling--
m_funcname text = 'resolvespec_login'; m_funcname text = 'resolvespec_login';
m_errmsg text; m_errmsg text;
m_errcontext text; m_errcontext text;
@@ -18,7 +18,7 @@ DECLARE
m_errhint text; m_errhint text;
m_errstate text; m_errstate text;
m_retval integer; m_retval integer;
--Error Handling-- --Error Handling--
m_rid_user integer; m_rid_user integer;
m_rid_hub integer; m_rid_hub integer;
m_pass_hashed citext[]; m_pass_hashed citext[];
@@ -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(
+158
View File
@@ -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;
$$;
+1570
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+8
View File
@@ -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'
;
+226
View File
@@ -0,0 +1,226 @@
; PgTidy Windows installer
;
; Build with (VERSION must match the release tag, without the leading "v"):
; makensis /DVERSION=1.2.3 /DSRC_EXE=path\to\pgtidy.exe windows\installer.nsi
;
; Installs pgtidy.exe into Program Files, adds the install dir to the
; machine-wide PATH, and on startup checks the git.warky.dev Gitea API for a
; newer release than the one being installed.
!ifndef VERSION
!define VERSION "0.0.0"
!endif
!ifndef SRC_EXE
!define SRC_EXE "..\dist\pgtidy.exe"
!endif
!define PRODUCT_NAME "PgTidy"
!define PRODUCT_PUBLISHER "Warky Devs"
!define PRODUCT_HOMEPAGE "https://git.warky.dev/wdevs/pgtidy"
!define RELEASES_API_URL "https://git.warky.dev/api/v1/repos/wdevs/pgtidy/releases/latest"
!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\PgTidy"
!define ENV_KEY 'HKLM "SYSTEM\CurrentControlSet\Control\Session Manager\Environment"'
!include "MUI2.nsh"
!include "LogicLib.nsh"
Name "${PRODUCT_NAME} ${VERSION}"
OutFile "pgtidy-setup-${VERSION}.exe"
InstallDir "$PROGRAMFILES64\PgTidy"
InstallDirRegKey HKLM "${UNINST_KEY}" "InstallLocation"
RequestExecutionLevel admin
Unicode true
!define MUI_ABORTWARNING
!define MUI_ICON "..\assets\logo_128.ico"
!define MUI_UNICON "..\assets\logo_128.ico"
!insertmacro MUI_PAGE_WELCOME
!insertmacro MUI_PAGE_LICENSE "..\LICENSE"
!insertmacro MUI_PAGE_DIRECTORY
!insertmacro MUI_PAGE_INSTFILES
!insertmacro MUI_PAGE_FINISH
!insertmacro MUI_UNPAGE_CONFIRM
!insertmacro MUI_UNPAGE_INSTFILES
!insertmacro MUI_LANGUAGE "English"
; ---------------------------------------------------------------------------
; Check the Gitea releases API for a newer version than the one we are about
; to install. Best-effort only: any failure (offline, API down, no
; PowerShell) is swallowed and the installer proceeds silently.
; ---------------------------------------------------------------------------
Function .onInit
StrCpy $1 "$TEMP\pgtidy-latest-version.txt"
Delete "$1"
DetailPrint "Checking ${PRODUCT_HOMEPAGE} for a newer release..."
nsExec::ExecToLog 'powershell -NoProfile -NonInteractive -Command "try { $$r = Invoke-RestMethod -Uri ''${RELEASES_API_URL}'' -UseBasicParsing -TimeoutSec 5; $$r.tag_name | Out-File -Encoding ascii -NoNewline ''$1'' } catch { exit 0 }"'
${IfNot} ${FileExists} "$1"
Return
${EndIf}
FileOpen $2 "$1" r
FileRead $2 $3
FileClose $2
Delete "$1"
StrCpy $4 $3
; Trim a leading "v" if the tag is e.g. "v1.2.3"
StrCpy $5 $4 1
${If} $5 == "v"
StrCpy $4 $4 "" 1
${EndIf}
${If} $4 != ""
${AndIf} $4 != "${VERSION}"
MessageBox MB_YESNO|MB_ICONINFORMATION \
"A newer version of PgTidy is available: $4 (this installer is ${VERSION}).$\n$\nOpen the releases page to download it now?$\n$\nChoosing No continues installing ${VERSION}." \
IDNO +2
ExecShell "open" "${PRODUCT_HOMEPAGE}/releases/latest"
${EndIf}
FunctionEnd
; ---------------------------------------------------------------------------
; Adds $INSTDIR to the machine PATH if it isn't already present.
; ---------------------------------------------------------------------------
Function AddToPath
ReadRegStr $0 ${ENV_KEY} "Path"
Push "$0"
Push "$INSTDIR"
Call StrContains
Pop $1
${If} $1 == ""
${If} $0 == ""
StrCpy $0 "$INSTDIR"
${Else}
StrCpy $0 "$0;$INSTDIR"
${EndIf}
WriteRegExpandStr ${ENV_KEY} "Path" "$0"
SendMessage ${HWND_BROADCAST} ${WM_WININICHANGE} 0 "STR:Environment" /TIMEOUT=5000
${EndIf}
FunctionEnd
; ---------------------------------------------------------------------------
; Removes $INSTDIR from the machine PATH.
; ---------------------------------------------------------------------------
Function un.RemoveFromPath
ReadRegStr $0 ${ENV_KEY} "Path"
Push "$0;"
Push "$INSTDIR;"
Push ""
Call un.StrReplace
Pop $0
Push "$0"
Push "$INSTDIR"
Push ""
Call un.StrReplace
Pop $0
; Drop a trailing separator left behind by the replacements above.
StrCpy $1 $0 1 -1
${If} $1 == ";"
StrCpy $0 $0 -1
${EndIf}
WriteRegExpandStr ${ENV_KEY} "Path" "$0"
SendMessage ${HWND_BROADCAST} ${WM_WININICHANGE} 0 "STR:Environment" /TIMEOUT=5000
FunctionEnd
; Returns the index of needle in haystack via $R0, or "" if absent.
; Push haystack, Push needle -> Pop result
Function StrContains
Exch $R1 ; needle
Exch
Exch $R2 ; haystack
Push $R3
Push $R4
Push $R5
StrLen $R3 $R1
StrCpy $R4 0
${Do}
StrCpy $R5 $R2 $R3 $R4
${If} $R5 == $R1
StrCpy $R0 $R4
${ExitDo}
${EndIf}
${If} $R5 == ""
StrCpy $R0 ""
${ExitDo}
${EndIf}
IntOp $R4 $R4 + 1
${Loop}
Pop $R5
Pop $R4
Pop $R3
Pop $R2
Pop $R1
Push $R0
Exch
Pop $R0
FunctionEnd
; Push string, Push search, Push replace -> Pop result
Function un.StrReplace
Exch $R0 ; replace
Exch
Exch $R1 ; search
Exch 2
Exch $R2 ; string
Push $R3
Push $R4
Push $R5
Push $R6
StrLen $R3 $R1
StrCpy $R4 ""
${Do}
StrCpy $R5 $R2 $R3
${If} $R5 == $R1
StrCpy $R4 "$R4$R0"
StrCpy $R2 $R2 "" $R3
${ElseIf} $R2 == ""
${ExitDo}
${Else}
StrCpy $R6 $R2 1
StrCpy $R4 "$R4$R6"
StrCpy $R2 $R2 "" 1
${EndIf}
${Loop}
Pop $R6
Pop $R5
Pop $R4
Pop $R3
Pop $R2
Pop $R1
Pop $R0
Push $R4
FunctionEnd
Section "PgTidy" SEC_MAIN
SectionIn RO
SetOutPath "$INSTDIR"
File "${SRC_EXE}"
File "..\LICENSE"
Call AddToPath
WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${PRODUCT_NAME}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${VERSION}"
WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${PRODUCT_PUBLISHER}"
WriteRegStr HKLM "${UNINST_KEY}" "InstallLocation" "$INSTDIR"
WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$INSTDIR\uninstall.exe"
WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$INSTDIR\uninstall.exe /S"
WriteRegDWORD HKLM "${UNINST_KEY}" "NoModify" 1
WriteRegDWORD HKLM "${UNINST_KEY}" "NoRepair" 1
WriteUninstaller "$INSTDIR\uninstall.exe"
SectionEnd
Section "Uninstall"
Call un.RemoveFromPath
Delete "$INSTDIR\pgtidy.exe"
Delete "$INSTDIR\LICENSE"
Delete "$INSTDIR\uninstall.exe"
RMDir "$INSTDIR"
DeleteRegKey HKLM "${UNINST_KEY}"
SectionEnd