22 Commits
Author SHA1 Message Date
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
warkanum 995760c4da chore(release): bump version to 0.0.2
CI / Test (push) Successful in 34s
CI / Build (push) Successful in 28s
Release / Release (push) Successful in 33s
Release / AUR package (push) Successful in 1m15s
Release / Test (push) Successful in 35s
Release / RPM package (push) Failing after 31s
Release / Debian packages (push) Successful in 46s
Release / VSCode Extension (push) Successful in 41s
Release / DataGrip Plugin (push) Successful in 2m37s
2026-06-29 16:35:53 +02:00
warkanumandClaude Sonnet 4.6 53f136b18d fix(release): build RPM from pre-compiled binary with injected version
CI / Test (push) Successful in 27s
CI / Build (push) Successful in 33s
rpmbuild's %build shell cannot reliably access the Go toolchain or
module cache, risking a 'dev' version string. Build the binary on the
runner (where setup-go is active) with the correct -X main.version
ldflags, then package it via rpmbuild as a binary-install RPM.

Spec changes: Source0/1 are now the binary and LICENSE; %install copies
them directly; removed %prep/%build; -bb instead of -ba (no source RPM).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 16:17:59 +02:00
warkanumandClaude Sonnet 4.6 cd710a3e63 fix(release): build RPM directly on ubuntu runner without Docker
CI / Test (push) Successful in 27s
CI / Build (push) Successful in 25s
Docker volume mounts in Gitea CI don't expose the runner workspace
correctly, leaving /build empty inside the container. Since the pgtidy
binary is statically linked Go with no shared-lib deps, rpmbuild runs
fine on ubuntu-latest with the rpm package. Drops Docker entirely from
the RPM job.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 15:53:39 +02:00
warkanumandClaude Sonnet 4.6 b159f1bfb3 fix(release): mount host Go into RPM container instead of downloading
CI / Test (push) Successful in 33s
CI / Build (push) Successful in 28s
go.mod specifies 'go 1.26' (no patch), making the download URL
go1.26.linux-amd64.tar.gz which 404s. Use actions/setup-go on the
runner and bind-mount GOROOT into the rockylinux container read-only,
removing the curl+tar download entirely.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 15:47:58 +02:00
warkanumandClaude Sonnet 4.6 e6ebb013d2 fix(release): produce linux tarballs matching PKGBUILD source URLs
CI / Test (push) Successful in 29s
CI / Build (push) Successful in 31s
PKGBUILD expects pgtidy_${pkgver}_linux_{amd64,arm64}.tar.gz containing
the binary and LICENSE. The release job was only uploading plain binaries
(pgtidy-linux-amd64). Now creates tarballs alongside the plain binaries,
uploads both, and updates AUR SHA256 computation to use the tarballs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 15:46:53 +02:00
warkanumandClaude Sonnet 4.6 27c2b9c002 fix(release): bump Node to 22 for pnpm v11 compatibility
CI / Test (push) Successful in 29s
CI / Build (push) Successful in 35s
pnpm v11 (latest) requires Node >= 22.13; Node 20 caused
ERR_UNKNOWN_BUILTIN_MODULE: node:sqlite on install.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 15:37:54 +02:00
warkanumandClaude Sonnet 4.6 05106ec368 fix(release): publish release immediately so AUR job can curl binaries
Draft releases return 404 on asset downloads, which breaks the AUR
SHA256 computation in pkg-aur. Remove draft:true so assets are publicly
accessible when downstream jobs run.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 15:37:10 +02:00
warkanumandClaude Sonnet 4.6 749b01bfa2 fix(release): guard PREV_TAG with rev-parse before using in log range
CI / Test (push) Successful in 28s
CI / Build (push) Successful in 25s
If PREV_TAG is a malformed or non-reachable tag name, git log fails
with exit 128 under set -e. Validate with git rev-parse --verify first
and fall back to a full log if it doesn't resolve.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 15:27:06 +02:00
warkanumandClaude Sonnet 4.6 249f1694ef fix(release): fix VSCode pnpm, RPM docker mount, and AUR empty repo
CI / Test (push) Successful in 27s
CI / Build (push) Successful in 25s
- VSCode: switch from npm ci to pnpm (project uses pnpm-lock.yaml)
- RPM: replace docker create+cp with docker run --rm -v mount to avoid
  missing /build directory in rockylinux:9
- AUR: guard PKGBUILD reads with -f check so first publish to an empty
  AUR repo defaults pkgrel to 1 instead of crashing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 15:17:18 +02:00
Hein cdcce94774 fix(release): update release notes generation logic
CI / Test (push) Successful in 25s
CI / Build (push) Successful in 23s
2026-06-29 13:07:55 +02:00
33 changed files with 5752 additions and 282 deletions
+93 -42
View File
@@ -55,6 +55,14 @@ jobs:
-ldflags "-X main.version=${VERSION}" \ -ldflags "-X main.version=${VERSION}" \
-o "$NAME" ./cmd/pgtidy -o "$NAME" ./cmd/pgtidy
echo "Built $NAME ($(ls -lh "$NAME" | awk '{print $5}'))" echo "Built $NAME ($(ls -lh "$NAME" | awk '{print $5}'))"
if [ "$GOOS" = "linux" ]; then
PKGVER="${VERSION#v}"
TARNAME="pgtidy_${PKGVER}_${GOOS}_${GOARCH}.tar.gz"
cp "$NAME" pgtidy
tar czf "$TARNAME" pgtidy LICENSE
rm pgtidy
echo "Packed $TARNAME"
fi
done done
- name: Create release and upload binaries - name: Create release and upload binaries
@@ -64,12 +72,11 @@ jobs:
API="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases" API="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/releases"
PREV_TAG=$(git tag --sort=-version:refname | { grep -v "^${TAG}$" || true; } | head -1) PREV_TAG=$(git tag --sort=-version:refname | { grep -v "^${TAG}$" || true; } | head -1)
if [ -n "$PREV_TAG" ]; then if [ -n "$PREV_TAG" ] && git rev-parse --verify "${PREV_TAG}" >/dev/null 2>&1; then
RANGE="${PREV_TAG}..${TAG}" NOTES=$(git log "${PREV_TAG}..${TAG}" --pretty=format:"- %s" --no-merges)
else else
RANGE="HEAD~20..HEAD" NOTES=$(git log --pretty=format:"- %s" --no-merges)
fi fi
NOTES=$(git log "$RANGE" --pretty=format:"- %s" --no-merges)
BODY="## What's changed"$'\n'"${NOTES}" BODY="## What's changed"$'\n'"${NOTES}"
BODY_JSON=$(printf '%s' "$BODY" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))') BODY_JSON=$(printf '%s' "$BODY" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')
@@ -77,7 +84,7 @@ jobs:
RELEASE=$(curl -s -X POST "$API" \ RELEASE=$(curl -s -X POST "$API" \
-H "Authorization: token ${GITHUB_TOKEN}" \ -H "Authorization: token ${GITHUB_TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"body\":${BODY_JSON},\"draft\":true}") -d "{\"tag_name\":\"${TAG}\",\"name\":\"${TAG}\",\"body\":${BODY_JSON}}")
echo "API response: $RELEASE" echo "API response: $RELEASE"
UPLOAD_URL=$(echo "$RELEASE" | grep -o '"upload_url":"[^"]*"' | cut -d'"' -f4 | sed 's/{[^}]*}//') UPLOAD_URL=$(echo "$RELEASE" | grep -o '"upload_url":"[^"]*"' | cut -d'"' -f4 | sed 's/{[^}]*}//')
@@ -86,7 +93,7 @@ jobs:
exit 1 exit 1
fi fi
for f in pgtidy-*; do for f in pgtidy-* pgtidy_*.tar.gz; do
echo "Uploading $f..." echo "Uploading $f..."
curl -sf -X POST "${UPLOAD_URL}?name=${f}" \ curl -sf -X POST "${UPLOAD_URL}?name=${f}" \
-H "Authorization: token ${GITHUB_TOKEN}" \ -H "Authorization: token ${GITHUB_TOKEN}" \
@@ -152,47 +159,36 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: with:
fetch-depth: 0 go-version-file: go.mod
- name: Build RPM - name: Build RPM
run: | run: |
set -euo pipefail set -euo pipefail
VERSION="${{ github.event.inputs.tag || github.ref_name }}" VERSION="${{ github.event.inputs.tag || github.ref_name }}"
PKGVER="${VERSION#v}" PKGVER="${VERSION#v}"
GO_VER="$(awk '/^go / { print $2; exit }' go.mod)"
git archive --format=tar.gz --prefix=pgtidy-${PKGVER}/ HEAD \ sudo apt-get update -y
> pgtidy-${PKGVER}.tar.gz sudo apt-get install -y rpm
CGO_ENABLED=0 go build \
-trimpath \
-ldflags "-X main.version=${PKGVER}" \
-o pgtidy \
./cmd/pgtidy
sed -i "s/^Version:.*/Version: ${PKGVER}/" linux/centos/pgtidy.spec sed -i "s/^Version:.*/Version: ${PKGVER}/" linux/centos/pgtidy.spec
CID=$(docker create \ mkdir -p ~/rpmbuild/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS}
-e GO_VER="${GO_VER}" \ cp pgtidy ~/rpmbuild/SOURCES/pgtidy
-e PKGVER="${PKGVER}" \ cp LICENSE ~/rpmbuild/SOURCES/LICENSE
-w /build \ cp linux/centos/pgtidy.spec ~/rpmbuild/SPECS/
rockylinux:9 \ rpmbuild --nodeps -bb ~/rpmbuild/SPECS/pgtidy.spec
bash -lc "
set -euo pipefail
dnf install -y rpm-build git &&
curl -fsSL https://go.dev/dl/go\${GO_VER}.linux-amd64.tar.gz | tar -C /usr/local -xz &&
export PATH=\$PATH:/usr/local/go/bin &&
mkdir -p ~/rpmbuild/{BUILD,BUILDROOT,RPMS,SOURCES,SPECS,SRPMS} &&
cp pgtidy-${PKGVER}.tar.gz ~/rpmbuild/SOURCES/ &&
cp linux/centos/pgtidy.spec ~/rpmbuild/SPECS/ &&
rpmbuild --nodeps -ba ~/rpmbuild/SPECS/pgtidy.spec
")
cleanup() { docker rm -f "$CID" >/dev/null 2>&1 || true; }
trap cleanup EXIT
docker cp pgtidy-${PKGVER}.tar.gz "$CID:/build/"
docker cp linux "$CID:/build/linux"
docker start -a "$CID"
mkdir -p rpm-out mkdir -p rpm-out
docker cp "$CID:/root/rpmbuild/RPMS/." rpm-out/ find ~/rpmbuild/RPMS/ -name '*.rpm' -exec cp {} rpm-out/ \;
trap - EXIT; cleanup
- name: Upload to release - name: Upload to release
run: | run: |
@@ -212,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
@@ -259,8 +301,13 @@ jobs:
GIT_SSH_COMMAND="ssh -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$AUR_KNOWN_HOSTS -i $AUR_KEY_PATH" \ GIT_SSH_COMMAND="ssh -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$AUR_KNOWN_HOSTS -i $AUR_KEY_PATH" \
git clone ssh://aur@aur.archlinux.org/pgtidy-bin.git aur-repo git clone ssh://aur@aur.archlinux.org/pgtidy-bin.git aur-repo
CURRENT_PKGVER=$(awk -F= '/^pkgver=/ {print $2; exit}' aur-repo/PKGBUILD | tr -d "[:space:]") if [ -f aur-repo/PKGBUILD ]; then
CURRENT_PKGREL=$(awk -F= '/^pkgrel=/ {print $2; exit}' aur-repo/PKGBUILD | tr -d "[:space:]") CURRENT_PKGVER=$(awk -F= '/^pkgver=/ {print $2; exit}' aur-repo/PKGBUILD | tr -d "[:space:]")
CURRENT_PKGREL=$(awk -F= '/^pkgrel=/ {print $2; exit}' aur-repo/PKGBUILD | tr -d "[:space:]")
else
CURRENT_PKGVER=""
CURRENT_PKGREL=0
fi
if [ "$CURRENT_PKGVER" = "$PKGVER" ]; then if [ "$CURRENT_PKGVER" = "$PKGVER" ]; then
PKGREL=$((CURRENT_PKGREL + 1)) PKGREL=$((CURRENT_PKGREL + 1))
@@ -270,8 +317,8 @@ jobs:
echo "Publishing pgtidy-bin ${PKGVER}-${PKGREL} to AUR" echo "Publishing pgtidy-bin ${PKGVER}-${PKGREL} to AUR"
SHA_AMD64=$(curl -fsSL "https://git.warky.dev/wdevs/pgtidy/releases/download/v${PKGVER}/pgtidy-linux-amd64" | sha256sum | cut -d' ' -f1) SHA_AMD64=$(curl -fsSL "https://git.warky.dev/wdevs/pgtidy/releases/download/v${PKGVER}/pgtidy_${PKGVER}_linux_amd64.tar.gz" | sha256sum | cut -d' ' -f1)
SHA_ARM64=$(curl -fsSL "https://git.warky.dev/wdevs/pgtidy/releases/download/v${PKGVER}/pgtidy-linux-arm64" | sha256sum | cut -d' ' -f1) SHA_ARM64=$(curl -fsSL "https://git.warky.dev/wdevs/pgtidy/releases/download/v${PKGVER}/pgtidy_${PKGVER}_linux_arm64.tar.gz" | sha256sum | cut -d' ' -f1)
sed -e "s/^pkgver=.*/pkgver=${PKGVER}/" \ sed -e "s/^pkgver=.*/pkgver=${PKGVER}/" \
-e "s/^pkgrel=.*/pkgrel=${PKGREL}/" \ -e "s/^pkgrel=.*/pkgrel=${PKGREL}/" \
@@ -312,14 +359,18 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: '20' node-version: '22'
- uses: pnpm/action-setup@v4
with:
version: latest
- name: Install and package - name: Install and package
working-directory: editors/vscode working-directory: editors/vscode
run: | run: |
npm ci pnpm install --frozen-lockfile
npm run compile pnpm run compile
npm run package pnpm run package
- name: Upload to release - name: Upload to release
run: | run: |
+4
View File
@@ -0,0 +1,4 @@
version: "2"
linters:
default: standard
+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 {
+10 -10
View File
@@ -37,7 +37,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)
@@ -75,7 +75,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,18 +84,18 @@ 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 {
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
} }
} }
@@ -107,25 +107,25 @@ 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 {
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
} }
} }
@@ -144,7 +144,7 @@ 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.
+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.
@@ -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
}
@@ -21,8 +21,20 @@
<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.intellij">
<statusBarWidgetFactory id="com.pgtidy.StatusBarWidget"
implementation="com.pgtidy.datagrip.PgTidyStatusBarWidgetFactory"
order="last"/>
</extensions>
<extensions defaultExtensionNs="com.redhat.devtools.lsp4ij"> <extensions defaultExtensionNs="com.redhat.devtools.lsp4ij">
<server id="com.pgtidy.lsp" <server id="com.pgtidy.lsp"
name="PgTidy" name="PgTidy"
+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.1 pkgver=0.0.5
pkgrel=1 pkgrel=1
pkgdesc="PostgreSQL SQL formatter and linter" pkgdesc="PostgreSQL SQL formatter and linter"
arch=('x86_64' 'aarch64') arch=('x86_64' 'aarch64')
+9 -18
View File
@@ -1,13 +1,13 @@
Name: pgtidy Name: pgtidy
Version: 0.0.1 Version: 0.0.5
Release: 1%{?dist} Release: 1%{?dist}
Summary: PostgreSQL SQL formatter and linter Summary: PostgreSQL SQL formatter and linter
License: MIT License: MIT
URL: https://git.warky.dev/wdevs/pgtidy URL: https://git.warky.dev/wdevs/pgtidy
Source0: %{name}-%{version}.tar.gz Source0: pgtidy
Source1: LICENSE
BuildRequires: golang >= 1.26 ExclusiveArch: x86_64
%global debug_package %{nil} %global debug_package %{nil}
%define _debugsource_packages 0 %define _debugsource_packages 0
@@ -17,24 +17,15 @@ BuildRequires: golang >= 1.26
PgTidy formats and lints PostgreSQL SQL, enforcing a consistent style PgTidy formats and lints PostgreSQL SQL, enforcing a consistent style
for migrations, schemas, functions, procedures, and triggers. for migrations, schemas, functions, procedures, and triggers.
%prep
%autosetup
%build
export CGO_ENABLED=0
go build \
-trimpath \
-ldflags "-X main.version=%{version}" \
-o %{name} ./cmd/pgtidy
%install %install
install -Dm755 %{name} %{buildroot}%{_bindir}/%{name} install -Dm755 %{SOURCE0} %{buildroot}%{_bindir}/%{name}
install -Dm644 LICENSE %{buildroot}%{_licensedir}/%{name}/LICENSE mkdir -p %{buildroot}%{_datadir}/licenses/%{name}
install -m644 %{SOURCE1} %{buildroot}%{_datadir}/licenses/%{name}/LICENSE
%files %files
%license LICENSE
%{_bindir}/%{name} %{_bindir}/%{name}
%{_datadir}/licenses/%{name}/LICENSE
%changelog %changelog
* Sun Jun 29 2026 Hein (Warky Devs) <hein@warky.dev> - 0.1.0-1 * Sun Jun 29 2026 Hein (Warky Devs) <hein@warky.dev> - 0.0.1-1
- Initial package - Initial package
+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\"")
}
+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 {
+2 -4
View File
@@ -1,8 +1,8 @@
--select * from dropall('resolvespec_login'); --select * from dropall('resolvespec_login');
CREATE OR REPLACE FUNCTION resolvespec_login( CREATE OR REPLACE FUNCTION resolvespec_login(
INOUT p_data jsonb INOUT p_data jsonb
,OUT p_success boolean ,OUT p_success boolean
,OUT p_error text ,OUT p_error text
) )
LANGUAGE plpgsql LANGUAGE plpgsql
VOLATILE VOLATILE
@@ -134,8 +134,6 @@ BEGIN
where u.rid_hub = m_rid_hub; where u.rid_hub = m_rid_hub;
end if; end if;
select jsonb_build_object('token', m_session ->>'token' select jsonb_build_object('token', m_session ->>'token'
, 'session', m_session ->>'session' , 'session', m_session ->>'session'
, 'user', _jsonb_object_cat(jsonb_build_object( , 'user', _jsonb_object_cat(jsonb_build_object(
+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